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

182 lines
8.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}