@@ 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.
@@ 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"
+ }
+}
@@ 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(/<!--[\s\S]*?-->/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(/<!--\s*more\s*-->/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);
+ });
+}