#!/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); }); }