From b0f22fc23c9d9438f5b2346ec02961dae1d4596c Mon Sep 17 00:00:00 2001 From: Cytrogen Date: Sat, 14 Mar 2026 19:36:11 -0400 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9Ahe?= =?UTF-8?q?xo-mastodon-syndicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Hexo 博客的 Mastodon 联合发布脚本提取为独立开源工具, 支持环境变量配置、可选 Org 文件同步、一键发布(commit + push)。 Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + LICENSE | 21 +++ README.org | 130 +++++++++++++++++ package.json | 24 ++++ publish.js | 61 ++++++++ syndicate.js | 396 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 634 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.org create mode 100644 package.json create mode 100644 publish.js create mode 100644 syndicate.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..713d5006dabfe2792c6ee1dbb4fdbdff2c44e304 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..4b968b0828925952737db87168e05f08584de64d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cytrogen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.org b/README.org new file mode 100644 index 0000000000000000000000000000000000000000..e05a5e4ccefba5849385061b49b8114fc3abc2bc --- /dev/null +++ b/README.org @@ -0,0 +1,130 @@ +#+TITLE: hexo-mastodon-syndicate +#+AUTHOR: Cytrogen +#+DESCRIPTION: 将 Hexo 博客文章联合发布到 Mastodon + +* hexo-mastodon-syndicate + +一个 Hexo 项目用的 Mastodon 联合发布脚本,实现 [[https://indieweb.org/POSSE][POSSE]](Publish on your Own Site, Syndicate Elsewhere)工作流。 + +** 工作流程 + +1. 在文章的 front matter 中添加 =syndicate: true= +2. 运行脚本,自动发送 Mastodon 帖子(包含标题、摘要和链接) +3. 脚本将 =syndicate: true= 替换为 =syndication: = +4. 提交修改后的文件并部署 + +** 安装 + +*** 方式一:npm 安装 + +#+begin_src shell +npm install --save-dev hexo-mastodon-syndicate +#+end_src + +*** 方式二:直接复制 + +将 =syndicate.js= 复制到你的 Hexo 项目中即可使用。 + +** 配置 + +*** 方式一:_config.yml + +在 Hexo 的 =_config.yml= 中添加以下配置段: + +#+begin_src yaml +diary_sources: + mastodon: + server: https://your.mastodon.instance + access_token: your_access_token_here +#+end_src + +脚本会自动读取 =_config.yml= 中的 =url= 字段作为博客地址。 + +*** 方式二:环境变量 + +设置以下环境变量即可,无需修改 =_config.yml=: + +| 环境变量 | 说明 | 必填 | +|--------------------------+--------------------------+------| +| =MASTODON_SERVER= | Mastodon 实例地址 | 是 | +| =MASTODON_ACCESS_TOKEN= | Mastodon 访问令牌 | 是 | +| =SYNDICATE_SITE_URL= | 博客站点 URL | 是 | + +环境变量也可以和 =_config.yml= 混合使用,环境变量的优先级更高。 + +*** Mastodon 访问令牌 + +前往你的 Mastodon 实例的 =设置 → 开发 → 新建应用=,创建一个具有 =write:statuses= 权限的访问令牌。 + +** 可选:Org 文件同步 + +如果你使用 Org-mode 写作,脚本可以同时更新对应的 =.org= 源文件,在其中添加 =#+SYNDICATION:= 属性。 + +设置 =SYNDICATE_ORG_DIR= 环境变量指向你的 Org 文件目录: + +#+begin_src shell +export SYNDICATE_ORG_DIR=~/Documents/Org/blog/posts +#+end_src + +未设置此变量时,Org 文件同步功能自动跳过。 + +** 可选:自定义文章目录 + +默认扫描 =source/_posts= 目录。如需更改: + +#+begin_src shell +export SYNDICATE_POSTS_DIR=source/_articles +#+end_src + +** 用法 + +在 Hexo 项目根目录运行: + +#+begin_src shell +# 通过 npx +npx hexo-mastodon-syndicate + +# 或直接运行 +node node_modules/hexo-mastodon-syndicate/syndicate.js + +# 如果是直接复制的脚本 +node syndicate.js +#+end_src + +脚本只负责联合发布和更新文件,不会自动提交或推送。 + +** 一键发布(syndicate + commit + push) + +如果你的项目通过 =git push= 自动触发部署(如 VPS 上的 Git hook、GitHub Pages、Netlify 等),可以使用 =hexo-mastodon-publish= 一键完成整个流程: + +#+begin_src shell +npx hexo-mastodon-publish +#+end_src + +它的完整流程: + +1. 检查工作区是否干净(有未提交的改动会报错退出) +2. 运行联合发布 +3. 自动 =git commit= 修改后的文件 +4. =git push= 到当前分支的上游远程 + +** 文章示例 + +#+begin_src yaml +--- +title: 我的新文章 +date: 2026-01-01 12:00:00 +syndicate: true +abbrlink: abc123 +--- +#+end_src + +运行脚本后,=syndicate: true= 会被替换为: + +#+begin_src yaml +syndication: https://your.instance/@you/123456789 +#+end_src + +** 许可证 + +[[file:LICENSE][MIT]] diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..7d84cbe0de61d3dbad721d275df15874381f394d --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "hexo-mastodon-syndicate", + "version": "1.0.0", + "description": "将 Hexo 博客文章联合发布到 Mastodon(POSSE 工作流)", + "main": "syndicate.js", + "bin": { + "hexo-mastodon-syndicate": "syndicate.js", + "hexo-mastodon-publish": "publish.js" + }, + "keywords": [ + "hexo", + "mastodon", + "syndication", + "indieweb", + "posse", + "fediverse" + ], + "author": "Cytrogen", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/cytrogen/hexo-mastodon-syndicate.git" + } +} diff --git a/publish.js b/publish.js new file mode 100644 index 0000000000000000000000000000000000000000..ed1ffa954e61df9eb348896a8dc528cf630a2024 --- /dev/null +++ b/publish.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +'use strict'; + +/** + * 一键发布脚本 + * + * 流程:检查工作区干净 → syndicate → git commit → git push + * + * 适用于 git push 会自动触发部署的项目(如 VPS 上的 Git hook、 + * GitHub Pages、Netlify 等)。 + * + * 用法:npx hexo-mastodon-publish + */ + +const { execSync } = require('child_process'); +const path = require('path'); + +const BLOG_ROOT = process.cwd(); + +function run(cmd) { + return execSync(cmd, { cwd: BLOG_ROOT, encoding: 'utf8' }).trim(); +} + +async function main() { + // 1. 检查工作区是否有未提交的改动 + const status = run('git status --porcelain'); + if (status) { + console.error('工作区有未提交的改动,请先 commit:'); + console.error(status); + process.exit(1); + } + + // 2. 运行 syndicate + const { syndicate } = require('./syndicate'); + const { syndicatedCount, modifiedFiles } = await syndicate(); + + if (syndicatedCount === 0 || modifiedFiles.length === 0) { + console.log('没有需要提交的改动。'); + return; + } + + // 3. 自动 commit + console.log('\n正在提交 syndication 改动...'); + for (const file of modifiedFiles) { + run(`git add "${file}"`); + } + + const titles = modifiedFiles.map(f => path.basename(f, '.md')).join(', '); + run(`git commit -m "更新 syndication URL: ${titles}"`); + console.log('✓ 已创建 commit'); + + // 4. Push 到当前分支的上游 + console.log('\n正在推送到远程...'); + run('git push'); + console.log('✓ 已推送'); +} + +main().catch(e => { + console.error(`错误: ${e.message}`); + process.exit(1); +}); diff --git a/syndicate.js b/syndicate.js new file mode 100644 index 0000000000000000000000000000000000000000..a9f8d981b20b39a8ba125b4aba276fddd664b4d7 --- /dev/null +++ b/syndicate.js @@ -0,0 +1,396 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Mastodon 联合发布脚本 + * + * 扫描 source/_posts/ 中 front matter 含 syndicate: true 的文章, + * 自动发送 Mastodon 帖子,写回 syndication URL。 + * + * 用法:npx hexo-mastodon-syndicate + */ + +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +// ── 路径 ────────────────────────────────────────────── + +const BLOG_ROOT = process.cwd(); +const POSTS_DIR = path.join(BLOG_ROOT, process.env.SYNDICATE_POSTS_DIR || path.join('source', '_posts')); +const CONFIG_FILE = path.join(BLOG_ROOT, '_config.yml'); +const ORG_DIR = process.env.SYNDICATE_ORG_DIR || null; + +// ── 简易 YAML 解析(仅处理本脚本需要的字段) ───────── + +function parseSimpleYaml(text) { + const result = {}; + const lines = text.split('\n'); + const stack = [{ indent: -1, obj: result }]; + + for (const line of lines) { + // 跳过注释和空行 + if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue; + + const match = line.match(/^(\s*)([\w-]+)\s*:\s*(.*)$/); + if (!match) continue; + + const indent = match[1].length; + const key = match[2]; + let value = match[3].trim(); + + // 去引号 + if ((value.startsWith("'") && value.endsWith("'")) || + (value.startsWith('"') && value.endsWith('"'))) { + value = value.slice(1, -1); + } + + // 回退到正确的父层级 + while (stack.length > 1 && stack[stack.length - 1].indent >= indent) { + stack.pop(); + } + + const parent = stack[stack.length - 1].obj; + + if (value === '' || value === undefined) { + parent[key] = {}; + stack.push({ indent, obj: parent[key] }); + } else if (value === 'true') { + parent[key] = true; + } else if (value === 'false') { + parent[key] = false; + } else { + parent[key] = value; + } + } + + return result; +} + +// ── 读取配置 ───────────────────────────────────────── + +function loadConfig() { + const envServer = process.env.MASTODON_SERVER; + const envToken = process.env.MASTODON_ACCESS_TOKEN; + const envSiteUrl = process.env.SYNDICATE_SITE_URL; + + // 环境变量齐全时,跳过 _config.yml + if (envServer && envToken) { + if (!envSiteUrl) { + throw new Error( + '使用环境变量配置时,SYNDICATE_SITE_URL 也必须设置。\n' + + '或者在 Hexo 项目根目录运行,脚本将从 _config.yml 读取 url 字段。' + ); + } + return { + siteUrl: envSiteUrl.replace(/\/$/, ''), + mastodonServer: envServer.replace(/\/$/, ''), + accessToken: envToken, + }; + } + + // 从 _config.yml 读取 + if (!fs.existsSync(CONFIG_FILE)) { + throw new Error( + '未找到 _config.yml,且环境变量 MASTODON_SERVER / MASTODON_ACCESS_TOKEN 未设置。\n\n' + + '配置方式一:在 Hexo 项目根目录运行,并在 _config.yml 中添加:\n' + + ' diary_sources:\n' + + ' mastodon:\n' + + ' server: https://your.instance\n' + + ' access_token: your_token\n\n' + + '配置方式二:设置环境变量:\n' + + ' MASTODON_SERVER=https://your.instance\n' + + ' MASTODON_ACCESS_TOKEN=your_token\n' + + ' SYNDICATE_SITE_URL=https://your.blog' + ); + } + + const raw = fs.readFileSync(CONFIG_FILE, 'utf8'); + const cfg = parseSimpleYaml(raw); + const mastodon = cfg.diary_sources && cfg.diary_sources.mastodon; + + const server = envServer || (mastodon && mastodon.server); + const token = envToken || (mastodon && mastodon.access_token); + const siteUrl = envSiteUrl || cfg.url; + + if (!server || !token) { + throw new Error( + '_config.yml 中缺少 diary_sources.mastodon 配置(server / access_token)。\n' + + '也可通过环境变量 MASTODON_SERVER / MASTODON_ACCESS_TOKEN 设置。' + ); + } + + if (!siteUrl) { + throw new Error('_config.yml 中缺少 url 字段,也未设置 SYNDICATE_SITE_URL 环境变量。'); + } + + return { + siteUrl: siteUrl.replace(/\/$/, ''), + mastodonServer: server.replace(/\/$/, ''), + accessToken: token, + }; +} + +// ── HTTP ────────────────────────────────────────────── + +function httpsPost(url, body, headers = {}) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const postData = JSON.stringify(body); + const options = { + hostname: parsed.hostname, + port: parsed.port || 443, + path: parsed.pathname + parsed.search, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'User-Agent': 'hexo-mastodon-syndicate/1.0', + ...headers, + }, + }; + const req = https.request(options, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve({ statusCode: res.statusCode, body: data }); + } else { + reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 500)}`)); + } + }); + }); + req.on('error', reject); + req.setTimeout(30000, () => { req.destroy(); reject(new Error('Timeout')); }); + req.write(postData); + req.end(); + }); +} + +// ── Front matter 解析 ───────────────────────────────── + +function parseFrontMatter(content) { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + const fm = parseSimpleYaml(match[1]); + return { frontMatter: fm, body: content.slice(match[0].length) }; +} + +// ── Markdown 语法剥离(用于生成摘要) ──────────────── + +function stripMarkdown(text) { + return text + .replace(//g, '') // HTML 注释 + .replace(/<[^>]*>/g, '') // HTML 标签 + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // 图片 + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // 链接 + .replace(/#{1,6}\s+/g, '') // 标题 + .replace(/[*_~`]+/g, '') // 粗体/斜体/删除线/代码 + .replace(/^>\s+/gm, '') // 引用 + .replace(/^[-*+]\s+/gm, '') // 无序列表 + .replace(/^\d+\.\s+/gm, '') // 有序列表 + .replace(/\n{2,}/g, '\n') // 多余空行 + .replace(/{%[\s\S]*?%}/g, '') // Hexo 标签 + .trim(); +} + +// ── 生成帖子内容 ────────────────────────────────────── + +function composeStatus(frontMatter, body, postUrl) { + const title = frontMatter.title || ''; + + let excerpt = ''; + if (frontMatter.description && typeof frontMatter.description === 'string') { + excerpt = frontMatter.description; + } else { + const moreIndex = body.search(//i); + const rawExcerpt = moreIndex > 0 ? body.slice(0, moreIndex) : body.slice(0, 600); + const stripped = stripMarkdown(rawExcerpt); + if (stripped.length > 200) { + excerpt = stripped.slice(0, 200) + '...'; + } else if (stripped.length > 0) { + excerpt = stripped; + } + } + + const parts = [title]; + if (excerpt) parts.push(excerpt); + parts.push(postUrl); + return parts.join('\n\n'); +} + +// ── 扫描文章 ────────────────────────────────────────── + +function scanPosts() { + if (!fs.existsSync(POSTS_DIR)) { + throw new Error(`文章目录不存在: ${POSTS_DIR}\n请在 Hexo 项目根目录运行,或通过 SYNDICATE_POSTS_DIR 环境变量指定。`); + } + + const files = fs.readdirSync(POSTS_DIR).filter(f => f.endsWith('.md')); + const results = []; + + for (const file of files) { + const filePath = path.join(POSTS_DIR, file); + const content = fs.readFileSync(filePath, 'utf8'); + const parsed = parseFrontMatter(content); + if (!parsed) continue; + + if (parsed.frontMatter.syndicate === true) { + results.push({ + file, + filePath, + content, + frontMatter: parsed.frontMatter, + body: parsed.body, + }); + } + } + + return results; +} + +// ── 更新 .md front matter ───────────────────────────── + +function updateMdFile(filePath, mastodonUrl) { + let content = fs.readFileSync(filePath, 'utf8'); + content = content.replace(/^syndicate:\s*true\s*$/m, `syndication: ${mastodonUrl}`); + fs.writeFileSync(filePath, content); +} + +// ── 更新 .org 上游文件(可选) ──────────────────────── + +function updateOrgFile(mdFileName, mastodonUrl) { + if (!ORG_DIR) return; + + const baseName = mdFileName.replace(/\.md$/, ''); + const orgFile = path.join(ORG_DIR, baseName + '.org'); + + if (!fs.existsSync(orgFile)) { + console.log(` ⚠ 未找到对应的 .org 文件: ${orgFile}`); + return; + } + + let content = fs.readFileSync(orgFile, 'utf8'); + + if (/^#\+SYNDICATION:/m.test(content)) { + content = content.replace(/^#\+SYNDICATION:.*$/m, `#+SYNDICATION: ${mastodonUrl}`); + } else if (/^#\+DESCRIPTION:/m.test(content)) { + content = content.replace( + /^(#\+DESCRIPTION:.*$)/m, + `$1\n#+SYNDICATION: ${mastodonUrl}` + ); + } else { + const firstBlank = content.indexOf('\n\n'); + if (firstBlank >= 0) { + content = content.slice(0, firstBlank) + `\n#+SYNDICATION: ${mastodonUrl}` + content.slice(firstBlank); + } else { + content += `\n#+SYNDICATION: ${mastodonUrl}\n`; + } + } + + fs.writeFileSync(orgFile, content); + console.log(` ✓ 已更新 .org 文件`); +} + +// ── 延时 ────────────────────────────────────────────── + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ── 核心联合发布逻辑 ──────────────────────────────── + +async function syndicate() { + const config = loadConfig(); + + const posts = scanPosts(); + if (posts.length === 0) { + console.log('没有找到 syndicate: true 的文章。'); + return { syndicatedCount: 0, modifiedFiles: [] }; + } + + console.log(`找到 ${posts.length} 篇待联合发布的文章:`); + posts.forEach(p => console.log(` - ${p.frontMatter.title} (${p.file})`)); + console.log(); + + let syndicatedCount = 0; + const modifiedFiles = []; + + for (let i = 0; i < posts.length; i++) { + const { frontMatter, body, file, filePath } = posts[i]; + + // 检查 abbrlink + if (!frontMatter.abbrlink) { + console.log(`⚠ 跳过「${frontMatter.title}」:缺少 abbrlink(请先运行 npx hexo generate)`); + continue; + } + + const postUrl = `${config.siteUrl}/posts/${frontMatter.abbrlink}.html`; + const status = composeStatus(frontMatter, body, postUrl); + + // 预览 + console.log(`── 即将发送 ──────────────────────────────`); + console.log(status); + console.log(`──────────────────────────────────────────`); + console.log(' 2 秒后发送(Ctrl+C 取消)...'); + await sleep(2000); + + // 发送到 Mastodon + try { + const res = await httpsPost( + `${config.mastodonServer}/api/v1/statuses`, + { status, visibility: 'public' }, + { Authorization: `Bearer ${config.accessToken}` } + ); + const data = JSON.parse(res.body); + const mastodonUrl = data.url; + console.log(` ✓ 已发送: ${mastodonUrl}`); + + // 更新 .md 文件 + updateMdFile(filePath, mastodonUrl); + console.log(` ✓ 已更新 .md 文件`); + modifiedFiles.push(filePath); + + // 更新 .org 文件(可选) + updateOrgFile(file, mastodonUrl); + + syndicatedCount++; + } catch (e) { + if (e.message.includes('403')) { + console.error(` ✗ 发送失败 (403 Forbidden):access_token 可能缺少 write:statuses 权限。`); + console.error(` 请前往 ${config.mastodonServer}/settings/applications 更新 token 权限。`); + } else { + console.error(` ✗ 发送失败: ${e.message}`); + } + console.log(` 跳过「${frontMatter.title}」,继续处理下一篇。`); + } + + // 多篇文章之间间隔 3 秒 + if (i < posts.length - 1) { + console.log(' 等待 3 秒...'); + await sleep(3000); + } + } + + console.log(`\n✓ 联合发布完成:${syndicatedCount} 篇文章。`); + return { syndicatedCount, modifiedFiles }; +} + +// ── 导出 ────────────────────────────────────────────── + +module.exports = { syndicate }; + +// ── 独立运行入口 ────────────────────────────────────── + +if (require.main === module) { + syndicate() + .then(({ syndicatedCount }) => { + if (syndicatedCount > 0) { + console.log('请 commit 修改后的文件并推送,以完成部署。'); + } + }) + .catch(e => { + console.error(`错误: ${e.message}`); + process.exit(1); + }); +}