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