- bridge/ 常驻桥接服务器(WebSocket 9600 + HTTP 9601,平台状态缓存) - patch/ TriliumNext 补丁(publisher 服务、API 路由、发布控制台前端) - install.sh 一键安装 + Docker 容器 CLI 处理 - 全链路使用 host.docker.internal 解决容器访问宿主机 - mcp/ AI Agent 集成说明
182 lines
8.2 KiB
TypeScript
182 lines
8.2 KiB
TypeScript
// 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, "")
|
||
.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;
|
||
}
|
||
}
|