multi-publisher 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/package.json +5 -1
- package/dist/commands/login.d.ts +0 -43
- package/dist/commands/login.js +0 -248
- package/dist/commands/publish.d.ts +0 -19
- package/dist/commands/publish.js +0 -275
package/dist/cli/index.d.ts
CHANGED
package/dist/cli/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "multi-publisher",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Markdown → 微信公众号 / 知乎 / 掘金等多平台 CLI 发布工具",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/xwh5/multi-publisher"
|
|
9
|
+
},
|
|
6
10
|
"bin": {
|
|
7
11
|
"mpub": "./dist/cli/index.js"
|
|
8
12
|
},
|
package/dist/commands/login.d.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* login 命令
|
|
3
|
-
*
|
|
4
|
-
* 核心问题:如何让用户在浏览器登录一次,然后把 Cookie 传给 CLI?
|
|
5
|
-
*
|
|
6
|
-
* 方案:本地 HTTP 接收服务(参考 browser-extension 架构)
|
|
7
|
-
*
|
|
8
|
-
* ┌─────────────┐ 用户手动登录 ┌─────────────┐
|
|
9
|
-
* │ 浏览器用户 │ ───────────────▶ │ 平台服务器 │
|
|
10
|
-
* │ │ ◀─── Set-Cookie ──│ (知乎等) │
|
|
11
|
-
* └──────┬──────┘ └─────────────┘
|
|
12
|
-
* │
|
|
13
|
-
* │ Content Script 注入:
|
|
14
|
-
* │ document.title = 'COOKIES:' + JSON.stringify(document.cookies)
|
|
15
|
-
* │
|
|
16
|
-
* ▼ 页面加载完成后,URL 变化(标题变化)
|
|
17
|
-
* ┌──────────────────────────────────────────────────────┐
|
|
18
|
-
* │ 本地 HTTP Server (mpub login --platform zhihu) │
|
|
19
|
-
* │ 1. 打开浏览器到登录页 │
|
|
20
|
-
* │ 2. 监听标题变化/Poll document.cookie │
|
|
21
|
-
* │ 3. 收到 Cookie 后写入磁盘文件 │
|
|
22
|
-
* └──────────────────────────────────────────────────────┘
|
|
23
|
-
*
|
|
24
|
-
* 更实用的方案(参考 puppeteer-extra-plugin-stealth 的思路):
|
|
25
|
-
* 用户通过浏览器插件/Browser 操作获取 Cookie,导出为 JSON 文件
|
|
26
|
-
* 然后 mpub cookie:import --platform zhihu --file cookies.json
|
|
27
|
-
*
|
|
28
|
-
* 当前实现(简化版):
|
|
29
|
-
* 1. 启动本地 HTTP server,开放一个 upload 接口
|
|
30
|
-
* 2. 用户在浏览器开发者工具手动执行一段脚本,把 document.cookies 提取出来
|
|
31
|
-
* 并 POST 到 http://localhost:38921/cookies
|
|
32
|
-
* 3. CLI 收到后写入磁盘
|
|
33
|
-
*
|
|
34
|
-
* 后续可扩展为自动方案:
|
|
35
|
-
* - 配合 Chrome Extension(手动触发 Content Script)
|
|
36
|
-
* - 或用 playwright 自动化控制浏览器登录(最可靠)
|
|
37
|
-
*/
|
|
38
|
-
export interface LoginOptions {
|
|
39
|
-
platform: string;
|
|
40
|
-
port: string;
|
|
41
|
-
timeout: string;
|
|
42
|
-
}
|
|
43
|
-
export declare function loginCommand(opts: LoginOptions): Promise<void>;
|
package/dist/commands/login.js
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* login 命令
|
|
3
|
-
*
|
|
4
|
-
* 核心问题:如何让用户在浏览器登录一次,然后把 Cookie 传给 CLI?
|
|
5
|
-
*
|
|
6
|
-
* 方案:本地 HTTP 接收服务(参考 browser-extension 架构)
|
|
7
|
-
*
|
|
8
|
-
* ┌─────────────┐ 用户手动登录 ┌─────────────┐
|
|
9
|
-
* │ 浏览器用户 │ ───────────────▶ │ 平台服务器 │
|
|
10
|
-
* │ │ ◀─── Set-Cookie ──│ (知乎等) │
|
|
11
|
-
* └──────┬──────┘ └─────────────┘
|
|
12
|
-
* │
|
|
13
|
-
* │ Content Script 注入:
|
|
14
|
-
* │ document.title = 'COOKIES:' + JSON.stringify(document.cookies)
|
|
15
|
-
* │
|
|
16
|
-
* ▼ 页面加载完成后,URL 变化(标题变化)
|
|
17
|
-
* ┌──────────────────────────────────────────────────────┐
|
|
18
|
-
* │ 本地 HTTP Server (mpub login --platform zhihu) │
|
|
19
|
-
* │ 1. 打开浏览器到登录页 │
|
|
20
|
-
* │ 2. 监听标题变化/Poll document.cookie │
|
|
21
|
-
* │ 3. 收到 Cookie 后写入磁盘文件 │
|
|
22
|
-
* └──────────────────────────────────────────────────────┘
|
|
23
|
-
*
|
|
24
|
-
* 更实用的方案(参考 puppeteer-extra-plugin-stealth 的思路):
|
|
25
|
-
* 用户通过浏览器插件/Browser 操作获取 Cookie,导出为 JSON 文件
|
|
26
|
-
* 然后 mpub cookie:import --platform zhihu --file cookies.json
|
|
27
|
-
*
|
|
28
|
-
* 当前实现(简化版):
|
|
29
|
-
* 1. 启动本地 HTTP server,开放一个 upload 接口
|
|
30
|
-
* 2. 用户在浏览器开发者工具手动执行一段脚本,把 document.cookies 提取出来
|
|
31
|
-
* 并 POST 到 http://localhost:38921/cookies
|
|
32
|
-
* 3. CLI 收到后写入磁盘
|
|
33
|
-
*
|
|
34
|
-
* 后续可扩展为自动方案:
|
|
35
|
-
* - 配合 Chrome Extension(手动触发 Content Script)
|
|
36
|
-
* - 或用 playwright 自动化控制浏览器登录(最可靠)
|
|
37
|
-
*/
|
|
38
|
-
import http from 'node:http';
|
|
39
|
-
import { networkInterfaces } from 'node:os';
|
|
40
|
-
import { CookieStore } from '../runtime/node-runtime.js';
|
|
41
|
-
export async function loginCommand(opts) {
|
|
42
|
-
const port = parseInt(opts.port, 10);
|
|
43
|
-
const timeoutSec = parseInt(opts.timeout, 10);
|
|
44
|
-
console.log(`
|
|
45
|
-
╔══════════════════════════════════════════════════════════╗
|
|
46
|
-
║ multi-publisher Cookie 采集服务 ║
|
|
47
|
-
╠══════════════════════════════════════════════════════════╣
|
|
48
|
-
║ 平台: ${opts.platform.padEnd(47)}║
|
|
49
|
-
║ 端口: ${String(port).padEnd(48)}║
|
|
50
|
-
║ 超时: ${String(timeoutSec + ' 秒').padEnd(48)}║
|
|
51
|
-
╚══════════════════════════════════════════════════════════╝
|
|
52
|
-
`);
|
|
53
|
-
// 获取本机 IP
|
|
54
|
-
const localIp = getLocalIp();
|
|
55
|
-
console.log(`本地地址: http://localhost:${port}`);
|
|
56
|
-
if (localIp) {
|
|
57
|
-
console.log(`局域网地址: http://${localIp}:${port} (同一 WiFi 下手机也能访问)`);
|
|
58
|
-
}
|
|
59
|
-
console.log();
|
|
60
|
-
// 打印各平台登录页
|
|
61
|
-
const loginUrls = {
|
|
62
|
-
zhihu: 'https://www.zhihu.com/signin',
|
|
63
|
-
juejin: 'https://juejin.cn/auth/login',
|
|
64
|
-
csdn: 'https://passport.csdn.net/login',
|
|
65
|
-
oschina: 'https://www.oschina.net/home/login',
|
|
66
|
-
segmentfault: 'https://segmentfault.com/user/login',
|
|
67
|
-
weixin: 'https://mp.weixin.qq.com/',
|
|
68
|
-
bilibili: 'https://passport.bilibili.com/login',
|
|
69
|
-
weibo: 'https://login.sina.com.cn/signup/signin.php',
|
|
70
|
-
toutiao: 'https://mp.toutiao.com/auth/page/login/',
|
|
71
|
-
};
|
|
72
|
-
const loginUrl = loginUrls[opts.platform] ?? `https://www.${opts.platform}.com/`;
|
|
73
|
-
console.log(`请在浏览器访问并登录: ${loginUrl}`);
|
|
74
|
-
console.log();
|
|
75
|
-
// 启动 HTTP Server
|
|
76
|
-
const cookieStore = new CookieStore();
|
|
77
|
-
let state = { cookies: [], resolved: false };
|
|
78
|
-
const server = http.createServer((req, res) => {
|
|
79
|
-
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
|
|
80
|
-
// CORS preflight
|
|
81
|
-
if (req.method === 'OPTIONS') {
|
|
82
|
-
res.writeHead(200, {
|
|
83
|
-
'Access-Control-Allow-Origin': '*',
|
|
84
|
-
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
85
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
86
|
-
});
|
|
87
|
-
res.end();
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
// -------------------------------------------------------------------------
|
|
91
|
-
// POST /cookies - 接收 Cookie
|
|
92
|
-
// -------------------------------------------------------------------------
|
|
93
|
-
if (url.pathname === '/cookies' && req.method === 'POST') {
|
|
94
|
-
let body = '';
|
|
95
|
-
req.on('data', chunk => { body += chunk; });
|
|
96
|
-
req.on('end', () => {
|
|
97
|
-
try {
|
|
98
|
-
const payload = JSON.parse(body);
|
|
99
|
-
if (!Array.isArray(payload.cookies)) {
|
|
100
|
-
throw new Error('cookies 必须是数组');
|
|
101
|
-
}
|
|
102
|
-
state.cookies = payload.cookies;
|
|
103
|
-
state.resolved = true;
|
|
104
|
-
cookieStore.save(opts.platform, payload.cookies);
|
|
105
|
-
console.log(`[${new Date().toISOString()}] ✓ 收到 ${payload.cookies.length} 个 Cookie,已保存`);
|
|
106
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
107
|
-
res.end(JSON.stringify({ success: true, count: payload.cookies.length }));
|
|
108
|
-
}
|
|
109
|
-
catch (err) {
|
|
110
|
-
console.error(`[ERROR] 解析 Cookie 失败:`, err);
|
|
111
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
112
|
-
res.end(JSON.stringify({ success: false, error: String(err) }));
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
// -------------------------------------------------------------------------
|
|
118
|
-
// GET /status - 查询采集状态
|
|
119
|
-
// -------------------------------------------------------------------------
|
|
120
|
-
if (url.pathname === '/status') {
|
|
121
|
-
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
122
|
-
res.end(JSON.stringify({
|
|
123
|
-
resolved: state.resolved,
|
|
124
|
-
cookieCount: state.cookies.length,
|
|
125
|
-
platforms: cookieStore.listPlatforms(),
|
|
126
|
-
}));
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
// -------------------------------------------------------------------------
|
|
130
|
-
// GET / 欢迎页 + 手动说明
|
|
131
|
-
// -------------------------------------------------------------------------
|
|
132
|
-
if (url.pathname === '/' || url.pathname === '') {
|
|
133
|
-
const html = `<!DOCTYPE html>
|
|
134
|
-
<html>
|
|
135
|
-
<head><meta charset="utf-8"><title>mpub login</title></head>
|
|
136
|
-
<body style="font-family: monospace; max-width: 700px; margin: 60px auto; padding: 0 20px;">
|
|
137
|
-
<h2>multi-publisher Cookie 采集服务</h2>
|
|
138
|
-
<p>平台: <b>${opts.platform}</b></p>
|
|
139
|
-
<p>状态: <span id="status" style="color: orange;">等待 Cookie...</span></p>
|
|
140
|
-
<hr/>
|
|
141
|
-
<h3>方法一:浏览器开发者工具(推荐)</h3>
|
|
142
|
-
<ol>
|
|
143
|
-
<li>在浏览器打开 <a href="${loginUrl}" target="_blank">${loginUrl}</a> 并完成登录</li>
|
|
144
|
-
<li>按 <kbd>F12</kbd> 打开开发者工具 → <strong>Console</strong></li>
|
|
145
|
-
<li>粘贴以下代码并回车:</li>
|
|
146
|
-
</ol>
|
|
147
|
-
<pre style="background:#f5f5f5;padding:15px;border-radius:5px;overflow-x:auto">
|
|
148
|
-
const cookies = document.cookie.split(';').map(c => {
|
|
149
|
-
const [name, ...rest] = c.trim().split('=')
|
|
150
|
-
const value = rest.join('=')
|
|
151
|
-
return { name, value, domain: '.${opts.platform.includes('.') ? opts.platform : opts.platform + '.com'}', path: '/', secure: true }
|
|
152
|
-
})
|
|
153
|
-
fetch('http://localhost:${port}/cookies', {
|
|
154
|
-
method: 'POST',
|
|
155
|
-
headers: { 'Content-Type': 'application/json' },
|
|
156
|
-
body: JSON.stringify({ cookies, platform: '${opts.platform}' })
|
|
157
|
-
}).then(r => r.json()).then(d => {
|
|
158
|
-
document.body.innerHTML = '<h2 style="color:green">✓ Cookie 已提交!数量: ' + d.count + '</h2><p>可以关闭此页面了。</p>'
|
|
159
|
-
})
|
|
160
|
-
</pre>
|
|
161
|
-
|
|
162
|
-
<h3>方法二:使用浏览器插件</h3>
|
|
163
|
-
<p>安装 <a href="https://chrome.google.com/webstore/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg" target="_blank">EditThisCookie</a>
|
|
164
|
-
或 <a href="https://chrome.google.com/webstore/detail/cookie-manager-by-ptmcli/hgpmkjhfgejjgcpfjekgmlhpegokpaf" target="_blank">Cookie-Editor</a>,
|
|
165
|
-
登录后导出 Cookie 为 JSON,保存到本地文件,然后运行:</p>
|
|
166
|
-
<pre style="background:#f5f5f5;padding:15px;border-radius:5px">
|
|
167
|
-
mpub cookie --import --platform ${opts.platform} --file ./cookies.json
|
|
168
|
-
</pre>
|
|
169
|
-
|
|
170
|
-
<h3>方法三:Playwright 自动登录(最可靠)</h3>
|
|
171
|
-
<pre style="background:#f5f5f5;padding:15px;border-radius:5px">
|
|
172
|
-
# 安装 playwright: npx playwright install chromium
|
|
173
|
-
# 自动打开浏览器,用户手动输入账号密码,登录后自动提取 Cookie
|
|
174
|
-
mpub login --platform ${opts.platform} --auto-playwright
|
|
175
|
-
</pre>
|
|
176
|
-
|
|
177
|
-
<script>
|
|
178
|
-
(async () => {
|
|
179
|
-
const poll = () => fetch('/status').then(r=>r.json()).then(d => {
|
|
180
|
-
const el = document.getElementById('status')
|
|
181
|
-
if (d.resolved) {
|
|
182
|
-
el.textContent = '✓ Cookie 已收到 (' + d.cookieCount + ' 个)'
|
|
183
|
-
el.style.color = 'green'
|
|
184
|
-
} else {
|
|
185
|
-
el.textContent = '等待中 (' + d.cookieCount + ' 个已收)'
|
|
186
|
-
el.style.color = 'orange'
|
|
187
|
-
setTimeout(poll, 2000)
|
|
188
|
-
}
|
|
189
|
-
}).catch(() => setTimeout(poll, 5000))
|
|
190
|
-
poll()
|
|
191
|
-
})()
|
|
192
|
-
</script>
|
|
193
|
-
</body>
|
|
194
|
-
</html>`;
|
|
195
|
-
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
196
|
-
res.end(html);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
res.writeHead(404);
|
|
200
|
-
res.end('Not Found');
|
|
201
|
-
});
|
|
202
|
-
// 超时控制
|
|
203
|
-
const timeoutMs = timeoutSec * 1000;
|
|
204
|
-
const timer = setTimeout(() => {
|
|
205
|
-
if (!state.resolved) {
|
|
206
|
-
console.log(`\n[超时] ${timeoutSec} 秒内未收到 Cookie`);
|
|
207
|
-
server.close();
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
}, timeoutMs);
|
|
211
|
-
server.listen(port, () => {
|
|
212
|
-
console.log(`服务已启动,等待 Cookie...\n`);
|
|
213
|
-
console.log(`登录后,有三种方式提交 Cookie:\n`);
|
|
214
|
-
console.log(` 1. [推荐] 打开 http://localhost:${port} 查看说明(浏览器开发者工具方式)`);
|
|
215
|
-
console.log(` 2. Cookie 导出插件(EditThisCookie/Cookie-Editor)导出 JSON 后:`);
|
|
216
|
-
console.log(` mpub cookie --import --platform ${opts.platform} --file ./cookies.json`);
|
|
217
|
-
console.log(` 3. Playwright 自动(需安装):`);
|
|
218
|
-
console.log(` mpub login --platform ${opts.platform} --auto-playwright\n`);
|
|
219
|
-
console.log(`按 Ctrl+C 取消\n`);
|
|
220
|
-
});
|
|
221
|
-
// 等待 Cookie 收到
|
|
222
|
-
await new Promise((resolve) => {
|
|
223
|
-
const checkInterval = setInterval(() => {
|
|
224
|
-
if (state.resolved) {
|
|
225
|
-
clearInterval(checkInterval);
|
|
226
|
-
clearTimeout(timer);
|
|
227
|
-
server.close(() => {
|
|
228
|
-
console.log(`\n✓ ${opts.platform} Cookie 采集完成 (${state.cookies.length} 个)`);
|
|
229
|
-
resolve();
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
}, 500);
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
function getLocalIp() {
|
|
236
|
-
try {
|
|
237
|
-
const nets = networkInterfaces();
|
|
238
|
-
for (const name of Object.keys(nets)) {
|
|
239
|
-
for (const iface of nets[name] ?? []) {
|
|
240
|
-
if (iface.family === 'IPv4' && !iface.internal) {
|
|
241
|
-
return iface.address;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
catch { }
|
|
247
|
-
return null;
|
|
248
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* publish 命令
|
|
3
|
-
*
|
|
4
|
-
* 工作流程:
|
|
5
|
-
* 1. 解析 MD 文件(frontmatter + 内容)
|
|
6
|
-
* 2. 构建 Article 对象
|
|
7
|
-
* 3. 为每个目标平台选择适配器
|
|
8
|
-
* 4. 调用适配器发布
|
|
9
|
-
*/
|
|
10
|
-
export interface PublishOptions {
|
|
11
|
-
file: string;
|
|
12
|
-
platforms: string;
|
|
13
|
-
theme: string;
|
|
14
|
-
appId?: string;
|
|
15
|
-
appSecret?: string;
|
|
16
|
-
dryRun?: boolean;
|
|
17
|
-
draft?: boolean;
|
|
18
|
-
}
|
|
19
|
-
export declare function publishCommand(opts: PublishOptions): Promise<void>;
|
package/dist/commands/publish.js
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* publish 命令
|
|
3
|
-
*
|
|
4
|
-
* 工作流程:
|
|
5
|
-
* 1. 解析 MD 文件(frontmatter + 内容)
|
|
6
|
-
* 2. 构建 Article 对象
|
|
7
|
-
* 3. 为每个目标平台选择适配器
|
|
8
|
-
* 4. 调用适配器发布
|
|
9
|
-
*/
|
|
10
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
11
|
-
import { extname, basename } from 'node:path';
|
|
12
|
-
import { NodeRuntime } from '../runtime/node-runtime.js';
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Frontmatter 解析(简易版,不依赖 gray-matter)
|
|
15
|
-
// ---------------------------------------------------------------------------
|
|
16
|
-
function parseFrontmatter(content) {
|
|
17
|
-
const lines = content.split('\n');
|
|
18
|
-
if (lines[0]?.trim() !== '---') {
|
|
19
|
-
return { data: {}, body: content };
|
|
20
|
-
}
|
|
21
|
-
const fenceIdx = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
22
|
-
if (fenceIdx === -1) {
|
|
23
|
-
return { data: {}, body: content };
|
|
24
|
-
}
|
|
25
|
-
const yamlLines = lines.slice(1, fenceIdx);
|
|
26
|
-
const body = lines.slice(fenceIdx + 1).join('\n').trim();
|
|
27
|
-
// 简单 YAML 解析(支持 key: value)
|
|
28
|
-
const data = {};
|
|
29
|
-
for (const line of yamlLines) {
|
|
30
|
-
const colonIdx = line.indexOf(':');
|
|
31
|
-
if (colonIdx === -1)
|
|
32
|
-
continue;
|
|
33
|
-
const key = line.slice(0, colonIdx).trim();
|
|
34
|
-
const value = line.slice(colonIdx + 1).trim();
|
|
35
|
-
data[key] = value.replace(/^["']|["']$/g, '');
|
|
36
|
-
}
|
|
37
|
-
return { data, body };
|
|
38
|
-
}
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// MD 文件解析
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
function parseMarkdownFile(filePath) {
|
|
43
|
-
if (!existsSync(filePath)) {
|
|
44
|
-
throw new Error(`文件不存在: ${filePath}`);
|
|
45
|
-
}
|
|
46
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
47
|
-
const { data: frontmatter, body } = parseFrontmatter(raw);
|
|
48
|
-
// 提取标题:优先 frontmatter.title,其次文件第一个 # 标题
|
|
49
|
-
const title = frontmatter.title
|
|
50
|
-
|| extractFirstHeading(body)
|
|
51
|
-
|| basename(filePath, extname(filePath));
|
|
52
|
-
// 提取标签(支持 yaml 数组和逗号分隔字符串)
|
|
53
|
-
let tags = [];
|
|
54
|
-
if (frontmatter.tags) {
|
|
55
|
-
if (Array.isArray(frontmatter.tags)) {
|
|
56
|
-
tags = frontmatter.tags.map(String);
|
|
57
|
-
}
|
|
58
|
-
else if (typeof frontmatter.tags === 'string') {
|
|
59
|
-
tags = frontmatter.tags.split(/[,,]/).map(t => t.trim()).filter(Boolean);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
// 摘要:优先 frontmatter.summary,其次取正文前 200 字
|
|
63
|
-
const summary = frontmatter.summary
|
|
64
|
-
|| body.replace(/[#*`\[\]]/g, '').slice(0, 200).trim() + '...';
|
|
65
|
-
const article = {
|
|
66
|
-
title,
|
|
67
|
-
markdown: body,
|
|
68
|
-
summary,
|
|
69
|
-
tags,
|
|
70
|
-
cover: frontmatter.cover,
|
|
71
|
-
};
|
|
72
|
-
return article;
|
|
73
|
-
}
|
|
74
|
-
function extractFirstHeading(md) {
|
|
75
|
-
for (const line of md.split('\n')) {
|
|
76
|
-
const m = line.match(/^#{1,6}\s+(.+)/);
|
|
77
|
-
if (m)
|
|
78
|
-
return m[1].trim();
|
|
79
|
-
}
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
// 微信公众号渲染(复用 wenyan-cli 的核心逻辑)
|
|
84
|
-
// ---------------------------------------------------------------------------
|
|
85
|
-
async function renderForWeixin(article, theme) {
|
|
86
|
-
// 延迟导入,避免 @wenyan-md/core 未安装时整个 CLI 无法启动
|
|
87
|
-
let render;
|
|
88
|
-
try {
|
|
89
|
-
// @wenyan-md/core 导出的是渲染好的 HTML
|
|
90
|
-
// 这里我们直接模拟一个简化版渲染(实际项目应 import 真实模块)
|
|
91
|
-
const { prepareRenderContext } = await import('@wenyan-md/core').catch(() => null);
|
|
92
|
-
if (prepareRenderContext) {
|
|
93
|
-
// 实际使用需要 getInputContent 等回调
|
|
94
|
-
// 简化:用 marked + juice 代替
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
// core 未安装,使用内置简化渲染
|
|
99
|
-
}
|
|
100
|
-
// 内置简化渲染(fallback)
|
|
101
|
-
return renderMarkdownFallback(article.markdown);
|
|
102
|
-
}
|
|
103
|
-
function renderMarkdownFallback(md) {
|
|
104
|
-
// 使用 Node 内置的高版本 String.prototype 用replaceAll + 简易转义
|
|
105
|
-
// 实际项目建议用 marked + highlight.js
|
|
106
|
-
let html = md
|
|
107
|
-
// 标题
|
|
108
|
-
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
109
|
-
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
110
|
-
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
111
|
-
// 粗体斜体
|
|
112
|
-
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
113
|
-
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
114
|
-
// 代码块
|
|
115
|
-
.replace(/```[\s\S]*?```/g, (m) => {
|
|
116
|
-
const code = m.replace(/```\w*\n?/, '').replace(/```$/, '');
|
|
117
|
-
return `<pre><code>${code.replace(/</g, '<').replace(/>/g, '>')}</code></pre>`;
|
|
118
|
-
})
|
|
119
|
-
// 行内代码
|
|
120
|
-
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
121
|
-
// 链接
|
|
122
|
-
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
123
|
-
// 图片
|
|
124
|
-
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1"/>')
|
|
125
|
-
// 换行
|
|
126
|
-
.replace(/\n\n/g, '</p><p>');
|
|
127
|
-
// 段落包装
|
|
128
|
-
return `<p>${html}</p>`;
|
|
129
|
-
}
|
|
130
|
-
// ---------------------------------------------------------------------------
|
|
131
|
-
// 发布到微信公众号(AppID + AppSecret)
|
|
132
|
-
// ---------------------------------------------------------------------------
|
|
133
|
-
async function publishWeixin(article, appId, appSecret, draft) {
|
|
134
|
-
console.log('[Weixin] 开始发布到微信公众号...');
|
|
135
|
-
// 1. 获取 Access Token
|
|
136
|
-
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
|
137
|
-
const tokenResp = await fetch(tokenUrl);
|
|
138
|
-
const tokenData = await tokenResp.json();
|
|
139
|
-
if (!tokenData.access_token) {
|
|
140
|
-
throw new Error(`获取 Access Token 失败: ${JSON.stringify(tokenData)}`);
|
|
141
|
-
}
|
|
142
|
-
const accessToken = tokenData.access_token;
|
|
143
|
-
console.log('[Weixin] ✓ Access Token 获取成功');
|
|
144
|
-
// 2. 渲染 HTML
|
|
145
|
-
const content = await renderForWeixin(article, 'default');
|
|
146
|
-
// 3. 新建草稿(微信不支持直接发布,只能先创建草稿)
|
|
147
|
-
const draftReq = await fetch(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`, {
|
|
148
|
-
method: 'POST',
|
|
149
|
-
headers: { 'Content-Type': 'application/json' },
|
|
150
|
-
body: JSON.stringify({
|
|
151
|
-
articles: [
|
|
152
|
-
{
|
|
153
|
-
title: article.title,
|
|
154
|
-
author: '',
|
|
155
|
-
digest: article.summary?.slice(0, 54) ?? '',
|
|
156
|
-
content: content,
|
|
157
|
-
content_source_url: '',
|
|
158
|
-
thumb_media_id: '',
|
|
159
|
-
need_open_comment: 1,
|
|
160
|
-
only_fans_can_comment: 0,
|
|
161
|
-
},
|
|
162
|
-
],
|
|
163
|
-
}),
|
|
164
|
-
});
|
|
165
|
-
const draftData = await draftReq.json();
|
|
166
|
-
if (draftData.media_id) {
|
|
167
|
-
console.log(`[Weixin] ✓ 草稿创建成功,Media ID: ${draftData.media_id}`);
|
|
168
|
-
if (!draft) {
|
|
169
|
-
console.log('[Weixin] 注意:微信平台只能保存草稿,请在手机端预览并发布');
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
throw new Error(`创建草稿失败: ${JSON.stringify(draftData)}`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
// ---------------------------------------------------------------------------
|
|
177
|
-
// 发布到其他平台(Cookie 认证)
|
|
178
|
-
// ---------------------------------------------------------------------------
|
|
179
|
-
async function publishWithCookies(article, platformId) {
|
|
180
|
-
console.log(`[${platformId}] 开始发布...`);
|
|
181
|
-
// 1. 创建 Node Runtime
|
|
182
|
-
const runtime = new NodeRuntime({ timeout: 30000 });
|
|
183
|
-
// 2. 获取适配器
|
|
184
|
-
let AdapterClass;
|
|
185
|
-
try {
|
|
186
|
-
const { getAdapter } = await import('@wechatsync/core').catch(() => ({ getAdapter: null }));
|
|
187
|
-
if (!getAdapter)
|
|
188
|
-
throw new Error('wechatsync core 未安装');
|
|
189
|
-
AdapterClass = getAdapter(platformId);
|
|
190
|
-
}
|
|
191
|
-
catch {
|
|
192
|
-
throw new Error(`不支持的平台: ${platformId},请确认已安装 @wechatsync/core 且平台可用`);
|
|
193
|
-
}
|
|
194
|
-
if (!AdapterClass) {
|
|
195
|
-
throw new Error(`不支持的平台: ${platformId}`);
|
|
196
|
-
}
|
|
197
|
-
// 3. 实例化并发布
|
|
198
|
-
const adapter = new AdapterClass();
|
|
199
|
-
await adapter.init(runtime);
|
|
200
|
-
// 4. 检查认证
|
|
201
|
-
const auth = await adapter.checkAuth();
|
|
202
|
-
if (!auth.isAuthenticated) {
|
|
203
|
-
throw new Error(`[${platformId}] 未登录或 Cookie 已过期,请运行: mpub login --platform ${platformId}`);
|
|
204
|
-
}
|
|
205
|
-
console.log(`[${platformId}] ✓ 已认证: ${auth.username ?? auth.userId}`);
|
|
206
|
-
// 5. 发布
|
|
207
|
-
const result = await adapter.publish(article, { draftOnly: false });
|
|
208
|
-
if (result.success) {
|
|
209
|
-
console.log(`[${platformId}] ✓ 发布成功: ${result.postUrl ?? result.postId}`);
|
|
210
|
-
}
|
|
211
|
-
else {
|
|
212
|
-
throw new Error(`[${platformId}] 发布失败: ${result.error}`);
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
// 主命令
|
|
217
|
-
// ---------------------------------------------------------------------------
|
|
218
|
-
export async function publishCommand(opts) {
|
|
219
|
-
const platforms = opts.platforms.split(',').map(p => p.trim()).filter(Boolean);
|
|
220
|
-
console.log(`\n=== multi-publisher ===`);
|
|
221
|
-
console.log(`文件: ${opts.file}`);
|
|
222
|
-
console.log(`平台: ${platforms.join(', ')}`);
|
|
223
|
-
console.log(`Dry Run: ${opts.dryRun ?? false}`);
|
|
224
|
-
console.log();
|
|
225
|
-
// 解析 MD
|
|
226
|
-
let article;
|
|
227
|
-
try {
|
|
228
|
-
article = parseMarkdownFile(opts.file);
|
|
229
|
-
console.log(`标题: ${article.title}`);
|
|
230
|
-
console.log(`标签: ${article.tags?.join(', ') ?? '无'}`);
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
console.error(`解析 Markdown 文件失败: ${err}`);
|
|
234
|
-
process.exit(1);
|
|
235
|
-
}
|
|
236
|
-
if (opts.dryRun) {
|
|
237
|
-
console.log('\n[Dry Run] 渲染结果预览:');
|
|
238
|
-
const html = renderMarkdownFallback(article.markdown);
|
|
239
|
-
console.log(html.slice(0, 500));
|
|
240
|
-
console.log('\n(dry-run 模式,未实际发布)');
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
// 发布到各平台
|
|
244
|
-
const results = [];
|
|
245
|
-
for (const platformId of platforms) {
|
|
246
|
-
try {
|
|
247
|
-
if (platformId === 'weixin') {
|
|
248
|
-
if (!opts.appId || !opts.appSecret) {
|
|
249
|
-
console.log('[Weixin] 提示:使用 --app-id 和 --app-secret 指定微信公众号凭据');
|
|
250
|
-
console.log(' 或设置环境变量 WX_APP_ID, WX_APP_SECRET');
|
|
251
|
-
}
|
|
252
|
-
const appId = opts.appId ?? process.env.WX_APP_ID ?? '';
|
|
253
|
-
const appSecret = opts.appSecret ?? process.env.WX_APP_SECRET ?? '';
|
|
254
|
-
await publishWeixin(article, appId, appSecret, opts.draft ?? false);
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
await publishWithCookies(article, platformId);
|
|
258
|
-
}
|
|
259
|
-
results.push({ platform: platformId, success: true });
|
|
260
|
-
}
|
|
261
|
-
catch (err) {
|
|
262
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
263
|
-
console.error(`[${platformId}] ✗ 失败: ${msg}`);
|
|
264
|
-
results.push({ platform: platformId, success: false, error: msg });
|
|
265
|
-
}
|
|
266
|
-
console.log();
|
|
267
|
-
}
|
|
268
|
-
// 汇总
|
|
269
|
-
const ok = results.filter(r => r.success).length;
|
|
270
|
-
const fail = results.filter(r => !r.success).length;
|
|
271
|
-
console.log(`=== 发布完成: ${ok} 成功,${fail} 失败 ===`);
|
|
272
|
-
if (fail > 0) {
|
|
273
|
-
process.exit(1);
|
|
274
|
-
}
|
|
275
|
-
}
|