// 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 { 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(/]*src="([^"]*)"[^>]*>/gi, "![image]($1)") .replace(/]*>/gi, "## ") .replace(/<\/h2>/gi, "\n\n") .replace(/]*>/gi, "### ") .replace(/<\/h3>/gi, "\n\n") .replace(/]*>/gi, "") .replace(/<\/p>/gi, "\n\n") .replace(//gi, "**") .replace(/<\/strong>/gi, "**") .replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)") .replace(//gi, "\n") .replace(/<[^>]*>/g, "") .replace(/\n{3,}/g, "\n\n") .trim(); return header.join("\n") + body; } }