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:
commit
b5acbed047
11 files changed
+1266
No files matched your search
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# 依赖/构建产物
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 密钥与本地配置(严禁提交)
|
||||||
|
.env
|
||||||
|
*.token
|
||||||
|
token.conf
|
||||||
|
wechatsync-token.conf
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# 系统临时
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
@@ -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://<NAS_IP>: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://<your-trilium>:<port>/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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
+83
@@ -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://<NAS_IP>:$WS_PORT"
|
||||||
|
echo "║ 4. Token: $(cat "$TOKEN_FILE" | base64 -d)"
|
||||||
|
echo "║ 5. 点击连接"
|
||||||
|
echo "║"
|
||||||
|
echo "║ 应用 Trilium 补丁: bash patch/patch.sh /path/to/trilium"
|
||||||
|
echo "╚══════════════════════════════════════════════╝"
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# MCP Server 集成
|
||||||
|
|
||||||
|
桥接服务器可被 AI Agent 通过 [MCP (Model Context Protocol)](https://modelcontextprotocol.io) 调用,实现「AI 写作 → 一键分发到多平台」。
|
||||||
|
|
||||||
|
## 桥接 HTTP API
|
||||||
|
|
||||||
|
桥接在 `http://<host>: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": "<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 可据此判断是否需要提示用户登录。
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><title>📤 发布控制台</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,PingFang SC,Microsoft YaHei,sans-serif;background:#f3f4f6;color:#111;min-height:100vh}
|
||||||
|
.header{background:#fff;border-bottom:1px solid #e5e7eb;padding:16px 32px;display:flex;align-items:center;flex-wrap:wrap;gap:8px}
|
||||||
|
.header h1{font-size:20px;font-weight:700;flex:1;min-width:120px}
|
||||||
|
.bridge-badge{font-size:12px}
|
||||||
|
.container{max-width:800px;margin:24px auto;padding:0 20px 60px}
|
||||||
|
.phase{display:flex;margin-bottom:16px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb}
|
||||||
|
.phase-step{flex:1;padding:12px;text-align:center;font-size:13px;font-weight:500;color:#9ca3af;border-right:1px solid #e5e7eb}
|
||||||
|
.phase-step:last-child{border-right:none}
|
||||||
|
.phase-step.active{color:#22c55e;background:#f0fdf4;font-weight:700}
|
||||||
|
.phase-step.done{color:#6b7280;background:#f9fafb}
|
||||||
|
.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:12px;overflow:hidden}
|
||||||
|
.section-title{font-size:13px;font-weight:600;padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:8px}
|
||||||
|
.section-title .sub{font-size:11px;font-weight:400;color:#9ca3af}
|
||||||
|
.load-area{padding:12px 16px;background:#f9fafb;display:flex;gap:8px;flex-wrap:wrap;border-bottom:1px solid #e5e7eb}
|
||||||
|
.load-area input{flex:1;min-width:120px;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:13px}
|
||||||
|
.load-area button{padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
|
||||||
|
.load-area .hint{font-size:12px;color:#6b7280;width:100%}
|
||||||
|
.item{display:flex;align-items:center;padding:8px 16px;gap:10px;border-bottom:1px solid #f3f4f6;cursor:pointer;transition:background .1s}
|
||||||
|
.item:hover{background:#f9fafb}
|
||||||
|
.item input[type=checkbox]{width:15px;height:15px;cursor:pointer;flex-shrink:0}
|
||||||
|
.item .name{font-size:13px;font-weight:500;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.item .status{font-size:11px;flex-shrink:0}
|
||||||
|
.item.disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
|
||||||
|
.item.disabled input{cursor:not-allowed}
|
||||||
|
.actions{display:flex;gap:8px;padding:12px 16px}
|
||||||
|
.btn{padding:10px 20px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;background:#fff;color:#111;flex:1;text-align:center;transition:background .15s}
|
||||||
|
.btn-primary{background:#22c55e;color:#fff;border-color:#22c55e}
|
||||||
|
.btn-primary:hover{background:#16a34a}
|
||||||
|
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||||
|
.select-bar{display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid #f3f4f6;background:#fafafa;font-size:12px}
|
||||||
|
.select-bar .spacer{flex:1}
|
||||||
|
.grp{border:1px solid #e5e7eb;border-radius:8px;margin:6px 12px}
|
||||||
|
.grp-title{font-size:12px;font-weight:600;padding:6px 12px;background:#f9fafb;border-bottom:1px solid #e5e7eb}
|
||||||
|
.grp .item{padding:5px 12px 5px 24px;font-size:12px}
|
||||||
|
.empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px}
|
||||||
|
.tip{font-size:11px;color:#9ca3af;text-align:center;padding:6px 16px 10px}
|
||||||
|
</style></head><body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>📤 发布控制台</h1>
|
||||||
|
<span id="bridgeStatus" class="bridge-badge">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="phase">
|
||||||
|
<div class="phase-step active" id="ph1">📝 选文章</div>
|
||||||
|
<div class="phase-step" id="ph2">🌐 选平台</div>
|
||||||
|
<div class="phase-step" id="ph3">🚀 结果</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="secNotes">
|
||||||
|
<div class="load-area">
|
||||||
|
<span class="hint">输入目录笔记ID(从URL复制):</span>
|
||||||
|
<input type="text" id="parentInput" placeholder="如: PuLnqqL23KQo" />
|
||||||
|
<button id="loadBtn">加载</button>
|
||||||
|
</div>
|
||||||
|
<div id="notesList"><div class="empty">输入目录ID后点加载</div></div>
|
||||||
|
<div id="nextStepDiv" style="display:none;padding:12px 16px;border-top:1px solid #e5e7eb">
|
||||||
|
<button class="btn btn-primary" id="nextBtn">下一步:选择平台 →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="secPlats" style="display:none">
|
||||||
|
<div class="section-title">🌐 选择发布平台 <span class="sub" id="authSummary"></span></div>
|
||||||
|
<div id="platsList"></div>
|
||||||
|
<div class="tip">❌ 灰底 = 扩展中未登录 · 点击扩展图标扫码登录</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" id="backBtn">← 返回</button>
|
||||||
|
<button class="btn btn-primary" id="pubBtn">🚀 发布</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" id="secRes" style="display:none">
|
||||||
|
<div class="section-title">🚀 发布结果</div>
|
||||||
|
<div id="resList"></div>
|
||||||
|
<div class="actions"><button class="btn" id="doneBtn">← 完成</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var notes=[],platforms=[],T=new Map(),SN=new Set(),SP=new Set(),results=new Map(),busy=false,
|
||||||
|
API=window.location.origin,authMap=new Map(),extCached=false,extCachedAt=null;
|
||||||
|
function $(id){return document.getElementById(id)}
|
||||||
|
|
||||||
|
async function init(){
|
||||||
|
try{
|
||||||
|
// 先检查 Trilium 登录
|
||||||
|
var r1=await fetch(API+"/api/publisher/platforms?_t="+Date.now(),{credentials:"include"});
|
||||||
|
if(r1.status===401){$("bridgeStatus").innerHTML='<span style="color:#ef4444">🔒 请先登录Trilium</span>';return}
|
||||||
|
var d=await r1.json();
|
||||||
|
if(!d||!d.platforms){$("bridgeStatus").textContent="⚠️ 无平台配置";return}
|
||||||
|
|
||||||
|
platforms=d.platforms;
|
||||||
|
for(var p of platforms)SP.add(p.id);
|
||||||
|
|
||||||
|
// 获取扩展登录状态(单独 catch 不阻断主流程)
|
||||||
|
try{
|
||||||
|
var r2=await fetch(API+"/api/publisher/platform-status?_t="+Date.now(),{credentials:"include"});
|
||||||
|
var ext=await r2.json();
|
||||||
|
if(ext&&ext.platforms){
|
||||||
|
for(var p of ext.platforms) authMap.set(p.id,p);
|
||||||
|
extCached=ext.cached===true;
|
||||||
|
extCachedAt=ext.cachedAt||null;
|
||||||
|
renderBridgeStatus(ext);
|
||||||
|
}else{
|
||||||
|
$("bridgeStatus").innerHTML='<span style="color:#f59e0b">● 扩展离线</span>';
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
$("bridgeStatus").innerHTML='<span style="color:#f59e0b">● 扩展未连接</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlats();
|
||||||
|
|
||||||
|
// 绑定事件
|
||||||
|
$("loadBtn").onclick=loadChildren;
|
||||||
|
$("parentInput").onkeydown=function(e){if(e.key==="Enter")loadChildren()};
|
||||||
|
$("nextBtn").onclick=function(){goPhase(2)};
|
||||||
|
$("backBtn").onclick=function(){goPhase(1)};
|
||||||
|
$("pubBtn").onclick=handlePublish;
|
||||||
|
$("doneBtn").onclick=function(){location.reload()};
|
||||||
|
}catch(e){$("bridgeStatus").textContent="⚠️ 加载失败: "+e.message}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBridgeStatus(ext){
|
||||||
|
if(!ext||!ext.platforms){
|
||||||
|
$("bridgeStatus").innerHTML='<span style="color:#ef4444;cursor:pointer" onclick="showBridgeHelp()">● 桥接离线</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var logged=ext.platforms.filter(function(p){return p.isAuthenticated}).length;
|
||||||
|
var total=ext.platforms.length;
|
||||||
|
var cachedBadge=extCached?' <span style="font-size:11px;color:#f59e0b;cursor:help" title="扩展离线,显示的是上次缓存数据('+new Date(extCachedAt).toLocaleTimeString()+')">📦 缓存</span>':'';
|
||||||
|
$("bridgeStatus").innerHTML='<span style="color:#22c55e">●</span> 已登录 '+logged+'/'+total+' 平台'+cachedBadge;
|
||||||
|
var names=ext.platforms.filter(function(p){return p.isAuthenticated}).map(function(p){return p.name+(p.username?'('+p.username+')':'')}).join('、')||'(无)';
|
||||||
|
$("bridgeStatus").title='已登录平台:'+names+(extCached?' (缓存数据)':'');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBridgeHelp(){
|
||||||
|
if(confirm("同步助手扩展未连接。\n\n1. Chrome 打开 WechatSync 扩展\n2. 设置服务器地址为 ws://192.168.68.19:9600\n3. 输入 Token(扩展设置页显示)\n4. 扫码登录各平台\n\n确定后打开帮助页面?")){
|
||||||
|
window.open("https://github.com/wechatsync/WechatSync","_blank")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChildren(){
|
||||||
|
var id=$("parentInput").value.trim();
|
||||||
|
if(!id)return;
|
||||||
|
try{
|
||||||
|
var r=await fetch(API+"/api/publisher/notes-with-children/"+id,{credentials:"include"});
|
||||||
|
if(r.status===401){alert("Trilium 登录已过期,请刷新页面重新登录");return}
|
||||||
|
var d=await r.json();
|
||||||
|
if(d&&d.notes){
|
||||||
|
for(var n of d.notes){if(!T.has(n.noteId)){T.set(n.noteId,n.title);SN.add(n.noteId)}}
|
||||||
|
notes=[];T.forEach(function(t,id){notes.push({noteId:id,title:t})});
|
||||||
|
renderNotes();renderNextBtn();
|
||||||
|
}else{
|
||||||
|
alert("该目录没有子笔记");
|
||||||
|
}
|
||||||
|
}catch(e){alert("加载失败: "+e.message)}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNextBtn(){var el=$("nextStepDiv");el.style.display=(notes.length>0&&SN.size>0)?"block":"none"}
|
||||||
|
|
||||||
|
function renderNotes(){
|
||||||
|
var el=$("notesList");
|
||||||
|
if(!notes.length){el.innerHTML='<div class="empty">无文章,输入其他目录ID试试</div>';return}
|
||||||
|
var ac=(SN.size===notes.length);
|
||||||
|
var h='<div class="select-bar"><input type="checkbox" id="ckAllN"'+(ac?" checked":"")+'><label for="ckAllN" style="cursor:pointer">'+(ac?"取消全选":"全选")+'</label><span>'+SN.size+"/"+notes.length+"篇</span></div>";
|
||||||
|
for(var n of notes){
|
||||||
|
var ck=SN.has(n.noteId)?" checked":"";
|
||||||
|
h+='<div class="item"><input type="checkbox" class="nc" value="'+n.noteId+'"'+ck+'><span class="name">'+n.title+'</span></div>';
|
||||||
|
}
|
||||||
|
el.innerHTML=h;
|
||||||
|
$("ckAllN").onchange=function(){if(this.checked){for(var n of notes)SN.add(n.noteId)}else SN.clear();renderNotes()};
|
||||||
|
el.querySelectorAll(".item").forEach(function(e){
|
||||||
|
e.onclick=function(ev){
|
||||||
|
if(ev.target.tagName==='INPUT')return;
|
||||||
|
var c=e.querySelector(".nc");c.checked=!c.checked;
|
||||||
|
if(c.checked)SN.add(c.value);else SN.delete(c.value);renderNotes()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
renderNextBtn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlats(){
|
||||||
|
if(!platforms.length){$("platsList").innerHTML='<div class="empty">无可用平台</div>';return}
|
||||||
|
var authCount=0,totalCount=platforms.length;
|
||||||
|
for(var p of platforms){if(getAuth(p))authCount++}
|
||||||
|
$("authSummary").textContent="(已登录 "+authCount+"/"+totalCount+",仅可选已登录平台)";
|
||||||
|
|
||||||
|
// 获取过滤状态(checkbox可能尚未渲染,默认显示所有)
|
||||||
|
var faEl=document.getElementById("filterAuth");
|
||||||
|
var filterHidden=faEl&&faEl.checked;
|
||||||
|
|
||||||
|
var h='<div class="select-bar"><span>仅显示已登录 <label><input type="checkbox" id="filterAuth" checked> 隐藏未登录</label></span><span class="spacer"></span><span id="platCount">'+SP.size+"/"+totalCount+"个</span></div>";
|
||||||
|
for(var p of platforms){
|
||||||
|
var auth=getAuth(p);
|
||||||
|
var statusHtml=getStatusHtml(p);
|
||||||
|
var isDisabled=(!auth);
|
||||||
|
var hidden=(filterHidden&&isDisabled)?" style='display:none'":"";
|
||||||
|
var ck=(auth&&SP.has(p.id))?" checked":"";
|
||||||
|
if(isDisabled&&SP.has(p.id))SP.delete(p.id);
|
||||||
|
h+='<div class="item'+(isDisabled?" disabled":"")+'"'+hidden+' data-pid="'+p.id+'"><input type="checkbox" class="pc" value="'+p.id+'"'+ck+(isDisabled?" disabled":"")+'><span class="name">'+p.name+'</span><span class="status">'+statusHtml+'</span></div>';
|
||||||
|
}
|
||||||
|
var el=$("platsList");el.innerHTML=h;
|
||||||
|
updateBtn();
|
||||||
|
|
||||||
|
// 单个点击
|
||||||
|
el.querySelectorAll(".item").forEach(function(e){
|
||||||
|
e.onclick=function(ev){
|
||||||
|
if(ev.target.tagName==='INPUT')return;
|
||||||
|
var c=e.querySelector(".pc");
|
||||||
|
if(!c||c.disabled)return;
|
||||||
|
c.checked=!c.checked;
|
||||||
|
if(c.checked)SP.add(c.value);else SP.delete(c.value);renderPlats()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 过滤切换
|
||||||
|
var fa=document.getElementById("filterAuth");
|
||||||
|
if(fa)fa.onchange=function(){renderPlats()};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuth(p){
|
||||||
|
var pid=p.config&&p.config.platforms;
|
||||||
|
if(!pid)return true;
|
||||||
|
var ids=pid.split(",");
|
||||||
|
for(var i=0;i<ids.length;i++){
|
||||||
|
var ep=authMap.get(ids[i].trim());
|
||||||
|
if(ep&&ep.isAuthenticated)return true;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusHtml(p){
|
||||||
|
var pid=p.config&&p.config.platforms;
|
||||||
|
if(!pid)return '<span style="color:#22c55e">✅</span>';
|
||||||
|
var ids=pid.split(",");
|
||||||
|
for(var i=0;i<ids.length;i++){
|
||||||
|
var ep=authMap.get(ids[i].trim());
|
||||||
|
if(ep){
|
||||||
|
if(ep.isAuthenticated)return '<span style="color:#22c55e">✅ '+escHtml(ep.username||'已登录')+'</span>';
|
||||||
|
return '<span style="color:#ef4444">❌ 未登录</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '<span style="color:#9ca3af;font-style:italic">❓ 未知</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||||
|
|
||||||
|
function updateBtn(){var b=$("pubBtn");b.textContent="🚀 发布 "+SN.size+" 篇到 "+SP.size+" 个平台";b.disabled=!SN.size||!SP.size}
|
||||||
|
|
||||||
|
function goPhase(p){
|
||||||
|
$("ph1").className="phase-step"+(p>=1?(p==1?" active":" done"):"");
|
||||||
|
$("ph2").className="phase-step"+(p>=2?(p==2?" active":" done"):"");
|
||||||
|
$("ph3").className="phase-step"+(p>=3?(p==3?" active":" done"):"");
|
||||||
|
$("secNotes").style.display=p==1?"":"none";$("secPlats").style.display=p==2?"":"none";$("secRes").style.display=p==3?"":"none";
|
||||||
|
if(p==2)updateBtn()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublish(){
|
||||||
|
if(busy)return;busy=true;
|
||||||
|
var nids=[],pids=[];SN.forEach(function(v){nids.push(v)});SP.forEach(function(v){pids.push(v)});
|
||||||
|
results=new Map();goPhase(3);
|
||||||
|
for(var ni=0;ni<nids.length;ni++){
|
||||||
|
var nid=nids[ni],nr=new Map();results.set(nid,nr);
|
||||||
|
for(var pi=0;pi<pids.length;pi++)nr.set(pids[pi],{status:"publishing"});
|
||||||
|
rr();
|
||||||
|
try{
|
||||||
|
var r=await fetch(API+"/api/publisher/publish",{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({noteId:nid,platforms:pids})});
|
||||||
|
if(r.status===401){alert("登录过期,刷新页面重新登录");busy=false;return}
|
||||||
|
var rd=await r.json();
|
||||||
|
if(rd&&rd.results){for(var ri=0;ri<rd.results.length;ri++){var r2=rd.results[ri];nr.set(r2.id||r2.platform,r2)}}
|
||||||
|
}catch(e){for(var pi=0;pi<pids.length;pi++){var r3=nr.get(pids[pi]);if(r3&&r3.status==="publishing")nr.set(pids[pi],{status:"failed",errorMessage:e.message})}}
|
||||||
|
results.set(nid,nr);rr();
|
||||||
|
}
|
||||||
|
busy=false
|
||||||
|
}
|
||||||
|
|
||||||
|
function rr(){
|
||||||
|
var el=$("resList"),h=[];
|
||||||
|
results.forEach(function(nr,nid){
|
||||||
|
var t=T.get(nid)||nid;
|
||||||
|
h.push('<div class="grp"><div class="grp-title">'+t+"</div>");
|
||||||
|
nr.forEach(function(r,pid){
|
||||||
|
var nm=pid;for(var p of platforms){if(p.id===pid){nm=p.name;break}}
|
||||||
|
var rh="";
|
||||||
|
if(r.status==="success"&&r.url)rh='<a href="'+r.url+'" target="_blank">✏️ 编辑草稿</a>';
|
||||||
|
else if(r.status==="success")rh="✅ 成功";
|
||||||
|
else if(r.status==="failed")rh="❌ "+(r.errorMessage?r.errorMessage.substring(0,60):"失败");
|
||||||
|
else if(r.status==="publishing")rh="⏳ 发布中…";
|
||||||
|
else rh="⏳";
|
||||||
|
h.push('<div class="item"><span class="name">'+nm+'</span><span class="status">'+rh+"</span></div>")
|
||||||
|
});
|
||||||
|
h.push("</div>")
|
||||||
|
});
|
||||||
|
el.innerHTML=h.length?h.join(""):'<div class="empty">无结果</div>'
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
@@ -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<any>) => void,
|
||||||
|
asyncRoute: (m: string, p: string, mw: any[], h: (req: Request, res: Response) => Promise<any>, 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);
|
||||||
|
}
|
||||||
@@ -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, "")
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user