From b5acbed0471246c4ba854e93ec652388edcfe54e Mon Sep 17 00:00:00 2001 From: jinghui Date: Sat, 17 Oct 2026 21:59:00 +0800 Subject: [PATCH] =?UTF-8?q?init:=20TriliumNext=20=C3=97=20WechatSync=20?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E5=8F=91=E5=B8=83=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bridge/ 常驻桥接服务器(WebSocket 9600 + HTTP 9601,平台状态缓存) - patch/ TriliumNext 补丁(publisher 服务、API 路由、发布控制台前端) - install.sh 一键安装 + Docker 容器 CLI 处理 - 全链路使用 host.docker.internal 解决容器访问宿主机 - mcp/ AI Agent 集成说明 --- .gitignore | 15 ++ README.md | 134 +++++++++++++ bridge/wechatsync-bridge-wrapper.sh | 17 ++ bridge/wechatsync-bridge.service | 14 ++ bridge/wechatsync-server.mjs | 264 +++++++++++++++++++++++++ install.sh | 83 ++++++++ mcp/MCP_INTEGRATION.md | 105 ++++++++++ patch/patch.sh | 62 ++++++ patch/publish.html | 297 ++++++++++++++++++++++++++++ patch/publisher_frontend.ts | 94 +++++++++ patch/wechatsync.ts | 181 +++++++++++++++++ 11 files changed, 1266 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bridge/wechatsync-bridge-wrapper.sh create mode 100644 bridge/wechatsync-bridge.service create mode 100644 bridge/wechatsync-server.mjs create mode 100644 install.sh create mode 100644 mcp/MCP_INTEGRATION.md create mode 100644 patch/patch.sh create mode 100644 patch/publish.html create mode 100644 patch/publisher_frontend.ts create mode 100644 patch/wechatsync.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0339229 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# 依赖/构建产物 +node_modules/ +dist/ +*.log + +# 密钥与本地配置(严禁提交) +.env +*.token +token.conf +wechatsync-token.conf +*.key + +# 系统临时 +.DS_Store +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..5224d52 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# TriliumNext × WechatSync 多平台发布 + +在 TriliumNext 上集成 **WechatSync 文章同步助手**,实现一篇笔记一键发布到 20+ 中国内容平台(知乎、微信公众号、CSDN、掘金、B站、头条、百家号、小红书…),并在发布控制台实时显示各平台登录状态、发布结果与草稿编辑链接。 + +## 架构 + +``` +┌──────────────┐ ①HTTP /api/publisher/publish ┌───────────────────┐ +│ 浏览器 │ ───────────────────────────────▶ │ TriliumNext 服务 │ +│ /publish 页面 │ ◀─────────────────────────────── │ (Docker/裸机) │ +└──────────────┘ 发布结果 / 平台状态 └─────────┬─────────┘ + ②HTTP │ (9601) + ▼ + ┌───────────────────────┐ + │ 桥接服务器 (本仓库) │ + │ wechatsync-server.mjs │ + │ WS 9600 / HTTP 9601 │ + └───────────┬───────────┘ + ③WS │ (9600) + ▼ + ┌───────────────────────┐ + │ Chrome + WechatSync │ + │ 扩展(各平台已登录) │ + └───────────┬───────────┘ + ④ ▼ + 知乎 / 公众号 / CSDN / … +``` + +**为什么需要桥接?** WechatSync CLI 只以 SECONDARY 模式短暂运行;Chrome 后台页约 40s 会被休眠导致 WebSocket 断连。桥接服务器常驻 PRIMARY 模式,扩展连一次即可保持在线(25s 心跳保活),并把平台状态缓存起来。 + +## 快速开始 + +### 1. 安装桥接(宿主机) + +```bash +sudo bash install.sh +``` + +安装后: +- WebSocket 监听 `:9600`(Chrome 扩展连这里) +- HTTP API 监听 `:9601`(CLI / Trilium 连这里) +- 生成 Token 存于 `/etc/wechatsync-token.conf` + +### 2. 配置 Chrome 扩展 + +1. 安装 [WechatSync 扩展](https://github.com/wechatsync/WechatSync) +2. 打开扩展设置页 +3. 服务器地址填 `ws://:9600` +4. Token 填上一步生成的值 +5. 点击连接 → 状态变为「已连接」 +6. 在各平台网页登录(扫码) + +### 3. 给 TriliumNext 打补丁 + +```bash +bash patch/patch.sh /path/to/triliumnext +``` + +补丁做三件事: +- 替换 `apps/server/src/services/publisher/wechatsync.ts`(桥接检查、重试、URL 提取) +- 替换 `apps/server/src/routes/api/publisher_frontend.ts`(新增 `platform-status` 等接口) +- 替换 `apps/client/public/publish.html`(发布控制台前端) +- 重新构建 `pnpm server:build` + +### 4. 安装 wechatsync CLI + +```bash +npm install -g @wechatsync/cli +``` + +> 若 Trilium 在 Docker 容器内,CLI 也要装进容器: +> ```bash +> docker exec triliumnext npm install -g @wechatsync/cli +> ``` + +### 5. 访问发布控制台 + +打开 `http://:/publish` + +## ⚠️ Docker 网络关键点 + +**Trilium 在容器、桥接在宿主机时**,容器内的 `localhost` 指向容器自身,访问不到宿主机服务。必须使用 `host.docker.internal`(Docker 内置 DNS → 宿主机网关 172.17.0.1)。 + +本仓库所有代码默认使用 `host.docker.internal`: +- `wechatsync.ts`:`checkBridge()` 用 `http://host.docker.internal:9601/status` +- `publisher_frontend.ts`:`platform-status` 用 `http://host.docker.internal:9601/platforms` +- CLI 调用注入 `SYNC_WS_HOST=host.docker.internal` +- `@wechatsync/cli` 的 `hostname` 改为读 `SYNC_WS_HOST` + +宿主机裸跑 Trilium 时,设置环境变量 `BRIDGE_HOST=localhost` 即可。 + +> 修改容器内 `main.cjs` 后必须**重启容器**才生效(Node 启动时只加载一次代码到内存,改文件不重载)。 + +## 目录结构 + +``` +trilium-wechatsync/ +├── install.sh # 桥接一键安装 +├── bridge/ +│ ├── wechatsync-server.mjs # 常驻桥接服务器(PRIMARY) +│ ├── wechatsync-bridge.service # systemd 服务 +│ └── wechatsync-bridge-wrapper.sh +├── patch/ +│ ├── wechatsync.ts # → apps/server/.../publisher/wechatsync.ts +│ ├── publisher_frontend.ts # → apps/server/.../routes/api/publisher_frontend.ts +│ ├── publish.html # → apps/client/public/publish.html +│ └── patch.sh # 应用补丁 + Docker 修正 +└── mcp/ + └── MCP_INTEGRATION.md # MCP Server 集成说明 +``` + +## 支持的平台 + +WechatSync 扩展内置 19 个平台 + 可扩展配置: + +知乎 · 掘金 · 微博 · 哔哩哔哩 · 百家号 · CSDN · 语雀 · 豆瓣 · 搜狐号 · 雪球 · 微信公众号 · 人人都是产品经理 · 51CTO · 慕课手记 · 开源中国 · 思否 · 博客园 · Markdown 压缩包 · 东方财富 ·(可加)头条号 · 小红书 · 什么值得买 + +## 功能 + +- ✅ 平台登录状态实时检测(绿=已登录 / 红=未登录 / 灰=未知) +- ✅ 未登录平台自动置灰不可选 +- ✅ 桥接/扩展断开时返回**缓存**状态(标注 📦 缓存),页面不再一片空白 +- ✅ 发布结果按文章分组,成功返回草稿编辑链接 +- ✅ 5 次重试等待扩展连接 +- ✅ 支持多文章批量发布到多平台 + +## 已知问题 + +- **什么值得买 (SMZDM)**:扩展侧 CSRF token 缺失,发布报错。其他平台正常。 +- **头条号 / 小红书 / 什么值得买**:不在扩展原生 `listPlatforms()` 中,可发布但登录状态显示「❓ 未知」。 + +## 协议 + +MIT diff --git a/bridge/wechatsync-bridge-wrapper.sh b/bridge/wechatsync-bridge-wrapper.sh new file mode 100644 index 0000000..1f77881 --- /dev/null +++ b/bridge/wechatsync-bridge-wrapper.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# WechatSync Bridge wrapper +# +# 从外部文件读取 Token(避免写进 systemd unit 被 systemctl 脱敏/日志泄露), +# 以 base64 存储在 /etc/wechatsync-token.conf,运行时解码。 +# +# systemd unit 可直接 ExecStart=node wechatsync-server.mjs,由服务脚本自行读取 +# /etc/wechatsync-token.conf;此 wrapper 适用于需要从别处注入 Token 的场景。 +set -e +TOKEN_FILE="${WECHATSYNC_TOKEN_FILE:-/etc/wechatsync-token.conf}" + +if [ -f "$TOKEN_FILE" ]; then + # 文件内容为 base64(email) 编码的 token + export WECHATSYNC_TOKEN="$(cat "$TOKEN_FILE" | base64 -d 2>/dev/null || cat "$TOKEN_FILE")" +fi + +exec /usr/local/bin/node /vol1/1000/HD1/APP/hermes/scripts/wechatsync-server.mjs diff --git a/bridge/wechatsync-bridge.service b/bridge/wechatsync-bridge.service new file mode 100644 index 0000000..1e99488 --- /dev/null +++ b/bridge/wechatsync-bridge.service @@ -0,0 +1,14 @@ +[Unit] +Description=WechatSync Bridge Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root +ExecStart=/usr/local/bin/node /vol1/1000/HD1/APP/hermes/scripts/wechatsync-server.mjs +Restart=always +RestartSec=5 +Environment=SYNC_WS_PORT=9600 +[Install] +WantedBy=multi-user.target diff --git a/bridge/wechatsync-server.mjs b/bridge/wechatsync-server.mjs new file mode 100644 index 0000000..25d14ec --- /dev/null +++ b/bridge/wechatsync-server.mjs @@ -0,0 +1,264 @@ +#!/usr/bin/env node +/** + * WechatSync 常驻桥接服务器 + * + * 以 PRIMARY 模式持久运行,Chrome 扩展连一次即可永久在线。 + * 后续 wechatsync sync 命令自动识别为 SECONDARY 模式,通过本服务器转发请求。 + * + * 原理:wechatsync CLI 内置 primary/secondary 双模式。 + * - PRIMARY: 启动 WebSocket 服务器 + HTTP API 服务器 + * - SECONDARY: 发现端口被占 → 通过 HTTP API 向 PRIMARY 转发 + * + * 本脚本保持 PRIMARY 永不退出,扩展只需连接一次。 + * + * 用法: + * node /vol1/1000/HD1/APP/hermes/scripts/wechatsync-server.mjs [port] + * + * 环境变量: + * SYNC_WS_PORT - WebSocket 端口(默认 9600) + * WECHATSYNC_TOKEN - 安全令牌(需与扩展配置一致) + */ + +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; +import { randomUUID } from 'crypto'; +import { readFileSync } from 'fs'; + +const PORT = parseInt(process.env.SYNC_WS_PORT || '9600', 10); +const HTTP_PORT = PORT + 1; +let tokenFromFile = ''; +try { + const raw = readFileSync('/etc/wechatsync-token.conf', 'utf-8').trim(); + tokenFromFile = Buffer.from(raw, 'base64').toString('utf-8'); +} catch(e) {} +const TOKEN = process.env.WECHATSYNC_TOKEN || tokenFromFile || ''; + +let client = null; // Chrome 扩展的 WebSocket 连接 +let pendingRequests = new Map(); +let cachedPlatforms = null; // 缓存上次平台登录状态 +let cachedAt = null; // 缓存时间戳 + +// ========== WebSocket 服务器(给 Chrome 扩展连) ========== +const wss = new WebSocketServer({ port: PORT }); + +wss.on('listening', () => { + console.error(`\n╔══════════════════════════════════════════════╗`); + console.error(`║ WechatSync 常驻服务器 ║`); + console.error(`╠══════════════════════════════════════════════╣`); + console.error(`║ WebSocket :${PORT} ← 扩展连这里`); + console.error(`║ HTTP API :${HTTP_PORT} ← sync 自动转发`); + console.error(`║ ║`); + console.error(`║ 请打开 Chrome 扩展 → 服务器地址: ║`); + console.error(`║ <你的NAS IP>:${PORT}`); + if (TOKEN) { + console.error(`║ Token: ${TOKEN.slice(0, 8)}...`); + } + console.error(`║ ║`); + console.error(`║ 连一次,永久不掉线 ✨ ║`); + console.error(`╚══════════════════════════════════════════════╝\n`); +}); + +wss.on('connection', (ws) => { + console.error(`[Server] ✅ Chrome 扩展已连接!`); + client = ws; + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + const pending = pendingRequests.get(msg.id); + if (!pending) { + // 可能是扩展主动发来的消息(非请求响应) + console.error(`[Server] Unknown response id:`, msg.id); + return; + } + clearTimeout(pending.timeout); + pendingRequests.delete(msg.id); + + if (msg.error) { + console.error(`[Server] ❌ Request ${msg.id} failed:`, msg.error.message || msg.error); + pending.reject(new Error(msg.error.message || JSON.stringify(msg.error))); + } else { + console.error(`[Server] ✅ Request ${msg.id} completed`); + // 如果是 listPlatforms 响应,缓存结果 + if (msg.id && msg.id.includes('listPlatforms')) { + cachedPlatforms = msg.result || []; + cachedAt = Date.now(); + } + pending.resolve(msg.result); + } + } catch (err) { + console.error(`[Server] Failed to parse extension message:`, err.message); + } + }); + + ws.on('close', () => { + console.error(`[Server] ⚠️ Chrome 扩展断开连接`); + client = null; + // 拒绝所有待处理的请求 + for (const [id, pending] of pendingRequests) { + clearTimeout(pending.timeout); + pending.reject(new Error('Extension disconnected')); + } + pendingRequests.clear(); + // 清除心跳 + if (ws._keepAlive) clearInterval(ws._keepAlive); + }); + + ws.on('error', (err) => { + console.error(`[Server] WebSocket error:`, err.message); + }); + + // 每 25 秒发心跳,防止扩展被 Chrome 休眠 + ws._keepAlive = setInterval(() => { + if (ws.readyState === ws.OPEN) { + ws.ping(); + } + }, 25000); +}); + +wss.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.error(`[Server] ❌ 端口 ${PORT} 已被占用!`); + console.error(`[Server] 使用其他端口: SYNC_WS_PORT=9601 node wechatsync-server.mjs`); + process.exit(1); + } + console.error(`[Server] WebSocket server error:`, err); +}); + +// ========== 请求转发 ========== +function sendRequest(method, params, token) { + return new Promise((resolve, reject) => { + if (!client) { + reject(new Error('Chrome 扩展未连接')); + return; + } + + const id = `${Date.now()}-${randomUUID().slice(0, 8)}`; + + // 30分钟超时 + const timeout = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error(`请求超时: ${method}`)); + }, 30 * 60 * 1000); + + pendingRequests.set(id, { resolve, reject, timeout }); + + client.send(JSON.stringify({ id, method, params, token }), (err) => { + if (err) { + clearTimeout(timeout); + pendingRequests.delete(id); + reject(err); + } + }); + }); +} + +// ========== HTTP API 服务器(sync 命令连这里转发) ========== +const httpServer = createServer((req, res) => { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + // 健康检查 + if (req.method === 'GET' && req.url === '/status') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + connected: client !== null, + mode: 'primary', + uptime: process.uptime(), + })); + return; + } + + // 平台列表 + 登录状态 + if (req.method === 'GET' && req.url === '/platforms') { + (async () => { + if (!client) { + if (cachedPlatforms) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ platforms: cachedPlatforms, cached: true, cachedAt })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ platforms: [], error: 'Extension not connected', cached: false })); + } + return; + } + try { + const result = await sendRequest('listPlatforms', {}, TOKEN); + const platforms = result || []; + cachedPlatforms = platforms; + cachedAt = Date.now(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ platforms, cached: false })); + } catch (error) { + // 请求失败时也尝试返回缓存 + if (cachedPlatforms) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ platforms: cachedPlatforms, cached: true, cachedAt, error: error.message })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ platforms: [], error: error.message, cached: false })); + } + } + })(); + return; + } + + // 请求转发 + if (req.method === 'POST' && req.url === '/request') { + let body = ''; + req.on('data', (chunk) => body += chunk); + req.on('end', async () => { + try { + const { method, params, token } = JSON.parse(body); + console.error(`[Server] ▶️ 收到转发请求: ${method}${params?.platforms ? ' -> ' + params.platforms : ''}`); + const result = await sendRequest(method, params, token || TOKEN); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ result })); + } catch (error) { + console.error(`[Server] ❌ 请求失败:`, error.message); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + } + }); + return; + } + + res.writeHead(404); + res.end('Not found'); +}); + +httpServer.listen(HTTP_PORT, () => { + console.error(`[Server] HTTP API ready on :${HTTP_PORT}`); +}); + +// ========== 进程管理 ========== +const shutdown = () => { + console.error(`\n[Server] Shutting down...`); + for (const [id, pending] of pendingRequests) { + clearTimeout(pending.timeout); + pending.reject(new Error('Server shutting down')); + } + pendingRequests.clear(); + wss.close(); + httpServer.close(); + process.exit(0); +}; + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); +process.stdin.resume(); + +// 定期检查 +setInterval(() => { + if (!client) { + console.error(`[Server] ⏳ 等待 Chrome 扩展连接...`); + } +}, 60000); diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..169236d --- /dev/null +++ b/install.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# install.sh — TriliumNext × WechatSync 发布桥接 一键安装 +set -e + +echo "╔══════════════════════════════════════════════╗" +echo "║ TriliumNext × WechatSync Publisher Bridge ║" +echo "╚══════════════════════════════════════════════╝" + +BRIDGE_DIR="/usr/local/share/wechatsync-bridge" +SERVICE_FILE="/etc/systemd/system/wechatsync-bridge.service" +TOKEN_FILE="/etc/wechatsync-token.conf" +WS_PORT="${SYNC_WS_PORT:-9600}" +HERE="$(cd "$(dirname "$0")" && pwd)" + +# --- 1. 安装桥接服务 --- +echo "[1/5] 安装桥接服务..." +mkdir -p "$BRIDGE_DIR" +cp "$HERE/bridge/wechatsync-server.mjs" "$BRIDGE_DIR/" +chmod 644 "$BRIDGE_DIR/wechatsync-server.mjs" +echo " ✅ 桥接已安装到 $BRIDGE_DIR" + +# --- 2. 安装 systemd 服务 --- +echo "[2/5] 安装 systemd 服务..." +[ -f "$SERVICE_FILE" ] && cp "$SERVICE_FILE" "${SERVICE_FILE}.bak" +sed "s|/vol1/1000/HD1/APP/hermes/scripts/wechatsync-server.mjs|$BRIDGE_DIR/wechatsync-server.mjs|; s|SYNC_WS_PORT=9600|SYNC_WS_PORT=$WS_PORT|" \ + "$HERE/bridge/wechatsync-bridge.service" > "$SERVICE_FILE" +systemctl daemon-reload +echo " ✅ 服务已安装" + +# --- 3. Token 设置 --- +echo "[3/5] 配置 Token..." +if [ ! -f "$TOKEN_FILE" ]; then + DEFAULT_TOKEN="$(uuidgen 2>/dev/null || node -e 'console.log(require("crypto").randomUUID())')" + printf '%s' "$DEFAULT_TOKEN" | base64 > "$TOKEN_FILE" + chmod 600 "$TOKEN_FILE" + echo " ✅ 已生成 Token: $DEFAULT_TOKEN" + echo " ⚠️ 请复制此 Token 填入 Chrome 扩展设置!" +else + echo " ✅ Token 文件已存在" +fi + +# --- 4. 启动桥接 --- +echo "[4/5] 启动桥接..." +systemctl enable wechatsync-bridge 2>/dev/null || true +systemctl restart wechatsync-bridge +sleep 2 +if systemctl is-active wechatsync-bridge >/dev/null 2>&1; then + echo " ✅ 桥接运行中 (WS:$WS_PORT, HTTP:$((WS_PORT+1)))" +else + echo " ❌ 桥接启动失败,检查: systemctl status wechatsync-bridge" + exit 1 +fi + +# --- 5. Docker 容器内 CLI --- +echo "[5/5] 检查 Docker 容器..." +if command -v docker &>/dev/null; then + CID=$(docker ps -q --filter name=triliumnext 2>/dev/null | head -1) + if [ -n "$CID" ]; then + echo " 🐳 找到 TriliumNext 容器: $CID" + docker exec "$CID" npm install -g @wechatsync/cli 2>/dev/null \ + && echo " ✅ 容器内已安装 wechatsync CLI" || echo " ⚠️ CLI 安装失败(可手动安装)" + docker exec "$CID" sed -i 's/hostname: "localhost"/hostname: process.env.SYNC_WS_HOST || "host.docker.internal"/g' \ + /usr/local/lib/node_modules/@wechatsync/cli/dist/index.js 2>/dev/null \ + && echo " ✅ CLI hostname 已修正" || true + fi +fi + +echo "" +echo "╔══════════════════════════════════════════════╗" +echo "║ ✅ 安装完成 ║" +echo "╠══════════════════════════════════════════════╣" +echo "║ WebSocket :$WS_PORT ← 扩展连这里" +echo "║ HTTP API :$((WS_PORT+1)) ← CLI/发布转发" +echo "║" +echo "║ 连接 Chrome 扩展:" +echo "║ 1. 安装 WechatSync 扩展" +echo "║ 2. 打开扩展设置" +echo "║ 3. 服务器: ws://:$WS_PORT" +echo "║ 4. Token: $(cat "$TOKEN_FILE" | base64 -d)" +echo "║ 5. 点击连接" +echo "║" +echo "║ 应用 Trilium 补丁: bash patch/patch.sh /path/to/trilium" +echo "╚══════════════════════════════════════════════╝" diff --git a/mcp/MCP_INTEGRATION.md b/mcp/MCP_INTEGRATION.md new file mode 100644 index 0000000..16759bf --- /dev/null +++ b/mcp/MCP_INTEGRATION.md @@ -0,0 +1,105 @@ +# MCP Server 集成 + +桥接服务器可被 AI Agent 通过 [MCP (Model Context Protocol)](https://modelcontextprotocol.io) 调用,实现「AI 写作 → 一键分发到多平台」。 + +## 桥接 HTTP API + +桥接在 `http://:9601` 暴露简单 HTTP 接口,任何语言都能调用: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/status` | 桥接与扩展连接状态 `{connected, mode, uptime}` | +| GET | `/platforms` | 各平台登录状态(含缓存)`{platforms[], cached, cachedAt}` | +| POST | `/request` | 转发任意请求到 Chrome 扩展 | + +### POST /request + +```json +{ + "method": "sync", // 或 listPlatforms / auth 等扩展支持的方法 + "params": { "platforms": "zhihu,csdn" }, + "token": "" +} +``` + +响应: + +```json +{ "result": { ... } } +``` + +## MCP 工具封装示例 + +用 Node 写一个最小 MCP Server,把桥接封装成工具: + +```js +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +const BRIDGE = process.env.BRIDGE_URL || "http://host.docker.internal:9601"; + +const server = new Server({ name: "wechatsync-publisher", version: "1.0.0" }, + { capabilities: { tools: {} } }); + +server.setRequestHandler("tools/list", async () => ({ + tools: [{ + name: "list_platforms", + description: "列出已登录的内容平台", + inputSchema: { type: "object", properties: {} } + }, { + name: "publish_article", + description: "把 Markdown 文章同步到指定平台", + inputSchema: { + type: "object", + properties: { + file: { type: "string", description: "本地 Markdown 文件路径" }, + platforms: { type: "string", description: "逗号分隔平台 id" } + }, + required: ["file", "platforms"] + } + }] +})); + +server.setRequestHandler("tools/call", async (req) => { + if (req.params.name === "list_platforms") { + const r = await fetch(`${BRIDGE}/platforms`); + const d = await r.json(); + return { content: [{ type: "text", text: JSON.stringify(d.platforms, null, 2) }] }; + } + if (req.params.name === "publish_article") { + const { file, platforms } = req.params.arguments; + const r = await fetch(`${BRIDGE}/request`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "sync", params: { file, platforms } }) + }); + const d = await r.json(); + return { content: [{ type: "text", text: JSON.stringify(d) }] }; + } +}); + +await server.connect(new StdioServerTransport()); +``` + +## Claude Desktop / 其他客户端配置 + +```json +{ + "mcpServers": { + "wechatsync": { + "command": "node", + "args": ["/path/to/wechatsync-mcp/index.js"], + "env": { "BRIDGE_URL": "http://host.docker.internal:9601" } + } + } +} +``` + +## 典型工作流 + +1. Agent 调用 `list_platforms` → 获取已登录平台 +2. Agent 生成/改写文章,写入 Markdown +3. Agent 调用 `publish_article` → 桥接转发给 Chrome 扩展 → 扩展在各平台创建草稿 +4. 返回草稿编辑链接给用户 + +无 Chrome 扩展连接时,桥接返回缓存的平台状态,Agent 可据此判断是否需要提示用户登录。 diff --git a/patch/patch.sh b/patch/patch.sh new file mode 100644 index 0000000..8afad2f --- /dev/null +++ b/patch/patch.sh @@ -0,0 +1,62 @@ +# TriliumNext WechatSync Publisher Patch +# 将补丁应用到 TriliumNext 源码,并处理 Docker 网络差异 +# +# 用法: bash patch.sh /path/to/triliumnext +set -e + +TRILIUM_DIR="${1:-/path/to/trilium}" +HERE="$(cd "$(dirname "$0")" && pwd)" +echo "Applying patches to $TRILIUM_DIR..." + +# --- 源码补丁 --- +copy_patch() { + local src="$1" dst="$2" label="$3" + if [ -f "$src" ]; then + cp "$src" "$dst" && echo " ✅ $label" + else + echo " ⚠️ 缺失: $src" + fi +} +copy_patch "$HERE/wechatsync.ts" "$TRILIUM_DIR/apps/server/src/services/publisher/wechatsync.ts" "wechatsync.ts" +copy_patch "$HERE/publisher_frontend.ts" "$TRILIUM_DIR/apps/server/src/routes/api/publisher_frontend.ts" "publisher_frontend.ts" +copy_patch "$HERE/publish.html" "$TRILIUM_DIR/apps/client/public/publish.html" "publish.html" + +# --- 构建 --- +echo "" +echo "Rebuilding TriliumNext server..." +cd "$TRILIUM_DIR" && pnpm server:build + +# --- Docker 运行时修正 --- +# 若 Trilium 在容器内运行,源码里的 host.docker.internal 已生效; +# 但若你直接用镜像(未从源码构建),需对已编译的 main.cjs 打补丁: +if [ -f "/.dockerenv" ] || grep -qa docker /proc/1/cgroup 2>/dev/null; then + echo "" + echo "🐳 Docker 环境:修正已编译产物桥接地址..." + MC="$TRILIUM_DIR/apps/server/dist/main.cjs" + if [ -f "$MC" ]; then + sed -i 's|http://localhost:9601|http://host.docker.internal:9601|g' "$MC" 2>/dev/null || true + sed -i 's|http://localhost:${Hca}/status|http://host.docker.internal:${Hca}/status|g' "$MC" 2>/dev/null || true + sed -i 's|SYNC_WS_PORT:"9600"|SYNC_WS_PORT:"9600",SYNC_WS_HOST:"host.docker.internal"|g' "$MC" 2>/dev/null || true + echo " ✅ main.cjs 桥接地址已修正" + fi + # 在容器内安装 wechatsync CLI 并修正其 hostname + if ! command -v wechatsync &>/dev/null; then + echo " 📦 安装 wechatsync CLI..." + npm install -g @wechatsync/cli 2>/dev/null && echo " ✅ CLI installed" || echo " ⚠️ CLI 安装失败" + fi + CLI_JS=$(find /usr/local/lib/node_modules/@wechatsync/cli/dist -name "index.js" 2>/dev/null | head -1) + if [ -n "$CLI_JS" ]; then + sed -i 's/hostname: "localhost"/hostname: process.env.SYNC_WS_HOST || "host.docker.internal"/g' "$CLI_JS" 2>/dev/null \ + && echo " ✅ CLI hostname 已修正" || true + fi + + # 把前端页面拷入已编译产物目录(镜像内路径可能不同) + for dst in "$TRILIUM_DIR/apps/server/dist/public/publish.html" /usr/src/app/dist/public/publish.html; do + [ -d "$(dirname "$dst")" ] && cp "$HERE/publish.html" "$dst" 2>/dev/null && echo " ✅ 页面已更新: $dst" || true + done +fi + +echo "" +echo "✅ 补丁完成!重启 TriliumNext 生效:" +echo " systemctl restart triliumnext # 裸机" +echo " docker restart triliumnext # Docker" diff --git a/patch/publish.html b/patch/publish.html new file mode 100644 index 0000000..1bf0115 --- /dev/null +++ b/patch/publish.html @@ -0,0 +1,297 @@ + + +📤 发布控制台 + +
+

📤 发布控制台

+ 加载中... +
+
+
+
📝 选文章
+
🌐 选平台
+
🚀 结果
+
+
+
+输入目录笔记ID(从URL复制): + + +
+
输入目录ID后点加载
+ +
+ + +
+ + diff --git a/patch/publisher_frontend.ts b/patch/publisher_frontend.ts new file mode 100644 index 0000000..d36b72c --- /dev/null +++ b/patch/publisher_frontend.ts @@ -0,0 +1,94 @@ +// TriliumNext WechatSync Publisher — API routes +// 补丁目标: apps/server/src/routes/api/publisher_frontend.ts +// +// 提供: +// GET /api/publisher/registered 已注册发布器 +// GET /api/publisher/platforms 已配置平台列表 +// GET /api/publisher/status 桥接/扩展连接状态 +// GET /api/publisher/platform-status 各平台登录状态(含缓存) +// POST /api/publisher/platforms 保存平台配置 +// GET /api/publisher/notes-with-children/:id 列出目录下笔记 +// POST /api/publisher/publish 发布文章到平台 + +import { becca, options as optionService } from "@triliumnext/core"; +import type { Request, Response } from "express"; +import auth from "../../services/auth.js"; +import { apiResultHandler } from "../route_api.js"; +import { getRegisteredPublishers, parsePlatformConfigs, extractArticleContent, publishToPlatforms } from "../../services/publisher/core.js"; +import { WechatSyncPublisher } from "../../services/publisher/wechatsync.js"; +import type { PlatformConfig } from "../../services/publisher/types.js"; + +const OPT = "publisherConfigs"; +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 function registerPublisherApi( + apiRoute: (m: string, p: string, h: (req: Request, res: Response) => any) => void, + asyncApiRoute: (m: string, p: string, h: (req: Request, res: Response) => Promise) => void, + asyncRoute: (m: string, p: string, mw: any[], h: (req: Request, res: Response) => Promise, rh: any) => void +) { + apiRoute("get", "/api/publisher/registered", () => ({ publishers: getRegisteredPublishers() })); + apiRoute("get", "/api/publisher/platforms", () => ({ platforms: parsePlatformConfigs(optionService.getOptionOrNull(OPT) || undefined) })); + + asyncApiRoute("get", "/api/publisher/status", async () => ({ + bridge: await WechatSyncPublisher.checkBridge(), + wechatsync: { installed: true } + })); + + // 平台登录状态(从桥接扩展获取,带缓存支持) + asyncApiRoute("get", "/api/publisher/platform-status", async () => { + try { + const resp = await fetch(`${BRIDGE_URL}/platforms`); + const data = await resp.json() as any; + return { + platforms: data.platforms || [], + error: data.error || null, + cached: data.cached === true, + cachedAt: data.cachedAt || null + }; + } catch (e: any) { + return { platforms: [], error: e?.message || "Bridge not reachable", cached: false }; + } + }); + + asyncRoute("post", "/api/publisher/platforms", [auth.checkApiAuth], async (req) => { + const { platforms } = req.body as { platforms: PlatformConfig[] }; + if (!Array.isArray(platforms)) return [400, { error: "platforms must be an array" }] as any; + optionService.setOption(OPT, JSON.stringify(platforms)); + return { success: true, count: platforms.length }; + }, apiResultHandler); + + apiRoute("get", "/api/publisher/notes-with-children/:noteId", (req) => { + const { noteId } = req.params; + const note = becca.getNote(noteId); + if (!note) return [404, { error: "Note not found" }] as any; + const results: { noteId: string; title: string }[] = []; + function walk(n: any) { results.push({ noteId: n.noteId, title: n.title }); for (const child of n.getChildNotes()) walk(child); } + walk(note); + return { notes: results }; + }); + + asyncRoute("post", "/api/publisher/publish", [auth.checkApiAuth], async (req) => { + const { noteId, platforms: targetPlatforms } = req.body as { noteId: string; platforms?: string[] }; + if (!noteId) return [400, { error: "noteId is required" }] as any; + const note = becca.getNote(noteId); + if (!note) return [404, { error: "Note not found" }] as any; + const html = note.getContent() || ""; + const article = extractArticleContent(noteId, note.title, html.toString()); + const configs = parsePlatformConfigs(optionService.getOptionOrNull(OPT) || undefined); + const matched = (targetPlatforms && targetPlatforms.length > 0) + ? configs.filter(c => targetPlatforms.includes(c.platform) || targetPlatforms.includes(c.id)) + : configs; + if (matched.length === 0) return [400, { error: "没有匹配的发布平台" }] as any; + const results = await publishToPlatforms(article, matched); + const mapped = results.map((r, i) => ({ ...r, id: matched[i]?.id || r.platform })); + return { + success: results.some(r => r.status === "success"), + total: results.length, + succeeded: results.filter(r => r.status === "success").length, + failed: results.filter(r => r.status === "failed").length, + results: mapped + }; + }, apiResultHandler); +} diff --git a/patch/wechatsync.ts b/patch/wechatsync.ts new file mode 100644 index 0000000..49fb16f --- /dev/null +++ b/patch/wechatsync.ts @@ -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 { + 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; + } +}