init: TriliumNext × WechatSync 多平台发布桥接

- bridge/ 常驻桥接服务器(WebSocket 9600 + HTTP 9601,平台状态缓存)
- patch/ TriliumNext 补丁(publisher 服务、API 路由、发布控制台前端)
- install.sh 一键安装 + Docker 容器 CLI 处理
- 全链路使用 host.docker.internal 解决容器访问宿主机
- mcp/ AI Agent 集成说明
This commit is contained in:
jinghui committed 2026-10-17 21:59:00 +08:00
commit b5acbed047
11 files changed
+1266

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
// TriliumNext WechatSync Publisher
// 补丁目标: apps/server/src/services/publisher/wechatsync.ts
//
// 说明:
// - checkBridge() / publish() 通过桥接 HTTP API(默认 9601)与 Chrome 扩展通信
// - 若 Trilium 运行在 Docker 容器内,桥接在宿主机上,必须用 host.docker.internal
// 而非 localhost(容器内 localhost 指向容器自身)
// - 桥接服务由 bridge/wechatsync-server.mjs 提供(PRIMARY 模式常驻)
//
// 环境变量:
// BRIDGE_HOST - 桥接主机(默认 host.docker.internal;宿主机裸跑可设 localhost)
// SYNC_WS_PORT - WebSocket 端口(默认 9600),HTTP API = 端口+1
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import os from "os";
import { getLog } from "@triliumnext/core";
import type { Publisher, ArticleContent, PlatformConfig, PublishResult } from "./types.js";
const BRIDGE_HOST = process.env.BRIDGE_HOST || "host.docker.internal";
const BRIDGE_HTTP_PORT = parseInt(process.env.SYNC_WS_PORT || "9600", 10) + 1;
const BRIDGE_URL = `http://${BRIDGE_HOST}:${BRIDGE_HTTP_PORT}`;
export class WechatSyncPublisher implements Publisher {
id: string = "wechatsync";
name: string = "WechatSync 文章同步助手";
icon: string = "bxl-wechat";
async validateConfig(config: PlatformConfig) {
const outDir = config.config.outputDir || path.join(os.homedir(), "wechatsync", "articles");
try {
fs.mkdirSync(outDir, { recursive: true });
return { valid: true, message: "目录可写: " + outDir };
} catch (e: any) {
return { valid: false, message: e.message };
}
}
/** 检查桥接服务器与 Chrome 扩展连接状态 */
static async checkBridge() {
try {
const resp = await fetch(`${BRIDGE_URL}/status`);
if (!resp.ok) {
return { online: false, extensionConnected: false, error: `HTTP ${resp.status}` };
}
const data = await resp.json();
return { online: true, extensionConnected: data.connected === true, error: undefined };
} catch (e: any) {
return { online: false, extensionConnected: false, error: e.message || "bridge unreachable" };
}
}
async publish(article: ArticleContent, config: PlatformConfig): Promise<PublishResult> {
const outDir = config.config.outputDir || path.join(os.homedir(), "wechatsync", "articles");
const log = getLog();
try {
fs.mkdirSync(outDir, { recursive: true });
const markdown = this.toMarkdown(article);
const safeTitle = article.title.replace(/[<>:"/\\|?*]/g, "_").substring(0, 80);
const filePath = path.join(outDir, safeTitle + "-" + Date.now() + ".md");
fs.writeFileSync(filePath, markdown, "utf-8");
log.info("WechatSync: 导出 → " + filePath);
const maxRetries = 5;
let draftUrl = "";
let lastErr = "";
for (let attempt = 0; attempt < maxRetries && !draftUrl; attempt++) {
const bridge = await WechatSyncPublisher.checkBridge();
if (!bridge.online) {
return {
platform: this.id, platformName: config.name, status: "failed",
errorMessage: `桥接 HTTP API 不在线(${BRIDGE_HOST}:${BRIDGE_HTTP_PORT})`,
publishedAt: new Date().toISOString()
};
}
if (!bridge.extensionConnected) {
if (attempt < 2) {
log.info(`WechatSync: 等待扩展连接... (${attempt + 1}/${maxRetries})`);
await new Promise(r => setTimeout(r, 3000));
continue;
}
return {
platform: this.id, platformName: config.name, status: "failed",
errorMessage: `Chrome 扩展未连接桥接(等了 ${attempt * 3}s 仍未连上)`,
publishedAt: new Date().toISOString()
};
}
try {
const platArg = config.config.platforms ? "--platforms " + config.config.platforms : "";
const cmd = 'wechatsync sync "' + filePath + '" ' + platArg;
const out = execSync(cmd, {
timeout: 30000,
stdio: ["pipe", "pipe", "pipe"],
env: {
...process.env,
SYNC_WS_PORT: String(BRIDGE_HTTP_PORT - 1),
SYNC_WS_HOST: BRIDGE_HOST
}
}).toString();
draftUrl = this.extractDraftUrl(out);
log.info("CLI输出: " + out.substring(0, 500));
} catch (cliErr: any) {
const combined = (cliErr.stdout || "") + "\n" + (cliErr.stderr || "");
draftUrl = this.extractDraftUrl(combined);
lastErr = combined.substring(0, 200);
log.info("CLI(含stderr): " + combined.substring(0, 500));
}
if (!draftUrl && attempt < maxRetries - 1) {
log.info(`WechatSync: 未检测到 URL,重试 (${attempt + 1}/${maxRetries})...`);
await new Promise(r => setTimeout(r, 3000));
}
}
return {
platform: this.id, platformName: config.name,
status: draftUrl ? "success" : "failed",
url: draftUrl || undefined,
errorMessage: draftUrl ? undefined : (lastErr || "未检测到返回的 URL,可能扩展未连接"),
publishedAt: new Date().toISOString()
};
} catch (e: any) {
return {
platform: this.id, platformName: config.name, status: "failed",
errorMessage: e.message, publishedAt: new Date().toISOString()
};
}
}
/** 从 CLI 输出中提取草稿编辑 URL(优先匹配各平台已知草稿页,排除图片链接) */
extractDraftUrl(output: string): string {
const urls: string[] = [];
const urlRe = /https?:\/\/[^\s"<>)\]]+/g;
let m: RegExpExecArray | null;
while ((m = urlRe.exec(output)) !== null) urls.push(m[0]);
const imageRe = /(?:img\.88531\.cn|i\.loli\.net|imgchr\.com|\.(?:png|jpg|jpeg|gif|webp|svg|ico)(?:\?|$))/i;
const draftPatterns = [
/mp\.weixin\.qq\.com\/cgi-bin\/appmsg/, // 微信公众号
/mp\.csdn\.net\/mp_blog\/creation/, // CSDN
/zhuanlan\.zhihu\.com\/p\//, // 知乎
/mp\.toutiao\.com\/profile_v4\//, // 头条号
/zhiyou\.smzdm\.com\/member\//, // 什么值得买
/juejin\.cn\/editor\/drafts\//, // 掘金
/segmentfault\.com\/\w+\/drafts/, // 思否
/weibo\.com\/ttarticle\/p\//, // 微博
/mp\.weixin\.qq\.com\/s\// // 公众号分享页
];
for (const u of urls) for (const p of draftPatterns) if (p.test(u)) return u;
// 无匹配时返回第一个非图片链接
for (const u of urls) if (!imageRe.test(u)) return u;
return "";
}
/** HTML → Markdown(带 title frontmatter) */
toMarkdown(article: ArticleContent): string {
const header = ["---", 'title: "' + article.title + '"', "---\n"];
const body = article.content
.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, "![image]($1)")
.replace(/<h2[^>]*>/gi, "## ")
.replace(/<\/h2>/gi, "\n\n")
.replace(/<h3[^>]*>/gi, "### ")
.replace(/<\/h3>/gi, "\n\n")
.replace(/<p[^>]*>/gi, "")
.replace(/<\/p>/gi, "\n\n")
.replace(/<strong>/gi, "**")
.replace(/<\/strong>/gi, "**")
.replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]*>/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
return header.join("\n") + body;
}
}