md2any 1.0.0
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/README.md +136 -0
- package/SKILL.md +80 -0
- package/bin/md2any.js +227 -0
- package/lib/actions.js +262 -0
- package/lib/converter.js +864 -0
- package/lib/extract.js +195 -0
- package/lib/llm.js +281 -0
- package/lib/social.js +264 -0
- package/lib/store.js +70 -0
- package/lib/themes.js +588 -0
- package/lib/zhihu.js +666 -0
- package/package.json +65 -0
- package/scripts/social_worker.js +774 -0
- package/scripts/xhs_screenshot.js +260 -0
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* social_worker.js — 用 Playwright 真浏览器 + 注入 cookie 发布到 小红书 / Twitter(X)
|
|
5
|
+
*
|
|
6
|
+
* 安全定位:走真实浏览器 UI(模拟真人操作),比伪造签名的 HTTP 方案更不易触发风控。
|
|
7
|
+
*
|
|
8
|
+
* 子命令:
|
|
9
|
+
* login <platform> <cookieOutFile> 启动可见浏览器让用户登录,抓到有效 cookie 后写文件退出
|
|
10
|
+
* publish <platform> <jobFile> 读取 job(cookie + 文案 + 图片),注入后自动填内容、传图、发布
|
|
11
|
+
*
|
|
12
|
+
* stdout 协议(每行一个):
|
|
13
|
+
* INFO:<message> 进度
|
|
14
|
+
* NEED_INSTALL 未找到 Chromium
|
|
15
|
+
* COOKIES_SAVED 登录成功、cookie 已写出
|
|
16
|
+
* READY_TO_PUBLISH 已填好内容,停在发布前(mode=prepare)
|
|
17
|
+
* PUBLISHED:<url> 已自动发布完成(mode=auto)
|
|
18
|
+
* DIAG:<path> 失败现场截图/HTML 存放目录(便于定位选择器)
|
|
19
|
+
* ERROR:<message> 失败
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
|
|
26
|
+
const argv = process.argv.slice(2);
|
|
27
|
+
const cmd = argv[0];
|
|
28
|
+
const platform = argv[1];
|
|
29
|
+
|
|
30
|
+
function out(line) { process.stdout.write(line + '\n'); }
|
|
31
|
+
function info(m) { out('INFO:' + m); }
|
|
32
|
+
function fail(m) { out('ERROR:' + m); process.exit(1); }
|
|
33
|
+
/** 结构化进度:PROGRESS:<done>/<total>:<label> */
|
|
34
|
+
function step(done, total, label) { out(`PROGRESS:${done}/${total}:${label}`); }
|
|
35
|
+
|
|
36
|
+
// ─── 查找 Chromium ──────────────────────────────────────────────────────────
|
|
37
|
+
function findChromium() {
|
|
38
|
+
const home = os.homedir();
|
|
39
|
+
const cacheDir = path.join(home, '.cache', 'ms-playwright');
|
|
40
|
+
if (fs.existsSync(cacheDir)) {
|
|
41
|
+
for (const entry of fs.readdirSync(cacheDir).filter(e => e.startsWith('chromium'))) {
|
|
42
|
+
const cands = {
|
|
43
|
+
darwin: path.join(cacheDir, entry, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
|
44
|
+
linux: path.join(cacheDir, entry, 'chrome-linux', 'chrome'),
|
|
45
|
+
win32: path.join(cacheDir, entry, 'chrome-win', 'chrome.exe'),
|
|
46
|
+
};
|
|
47
|
+
const p = cands[process.platform];
|
|
48
|
+
if (p && fs.existsSync(p)) return p;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const system = {
|
|
52
|
+
darwin: [
|
|
53
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
54
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
55
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
56
|
+
],
|
|
57
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium-browser', '/usr/bin/chromium'],
|
|
58
|
+
win32: ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'],
|
|
59
|
+
};
|
|
60
|
+
for (const p of (system[process.platform] || [])) if (fs.existsSync(p)) return p;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── 平台定义 ───────────────────────────────────────────────────────────────
|
|
65
|
+
// 关键修复:小红书创作平台签发的是 creator 专属 cookie,不叫 web_session。
|
|
66
|
+
// 因此登录判定改成「候选 cookie 命中任意一个」+「已离开登录页」。
|
|
67
|
+
const PLATFORMS = {
|
|
68
|
+
xiaohongshu: {
|
|
69
|
+
name: '小红书',
|
|
70
|
+
loginUrl: 'https://creator.xiaohongshu.com/login',
|
|
71
|
+
// target=image 直接进「上传图文」,省掉点 tab 这一步(更稳)
|
|
72
|
+
publishUrl: 'https://creator.xiaohongshu.com/publish/publish?from=menu&target=image',
|
|
73
|
+
authCookies: [
|
|
74
|
+
'access-token-creator.xiaohongshu.com',
|
|
75
|
+
'galaxy_creator_session_id',
|
|
76
|
+
'galaxy.creator.beaker.session.id',
|
|
77
|
+
'customer-sso-sid',
|
|
78
|
+
'customerClientId',
|
|
79
|
+
'web_session',
|
|
80
|
+
],
|
|
81
|
+
loginUrlPattern: /login/i,
|
|
82
|
+
},
|
|
83
|
+
twitter: {
|
|
84
|
+
name: 'Twitter',
|
|
85
|
+
loginUrl: 'https://x.com/login',
|
|
86
|
+
publishUrl: 'https://x.com/compose/post',
|
|
87
|
+
authCookies: ['auth_token'],
|
|
88
|
+
loginUrlPattern: /\/(login|i\/flow\/login)/i,
|
|
89
|
+
},
|
|
90
|
+
zhihu: {
|
|
91
|
+
name: '知乎',
|
|
92
|
+
loginUrl: 'https://www.zhihu.com/signin',
|
|
93
|
+
publishUrl: 'https://zhuanlan.zhihu.com/write',
|
|
94
|
+
authCookies: ['z_c0'],
|
|
95
|
+
loginUrlPattern: /\/signin|\/login/i,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** 每个平台一个固定调试端口,resume 时用 connectOverCDP 重连这个还开着的浏览器 */
|
|
100
|
+
const CDP_PORT = { xiaohongshu: 9223, twitter: 9224, zhihu: 9225 };
|
|
101
|
+
function cdpEndpoint() { return `http://127.0.0.1:${CDP_PORT[platform] || 9225}`; }
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 拿一个浏览器:**优先复用已经开着的那个**(同一个调试端口),没有才新开。
|
|
105
|
+
* 这样反复登录/发布/续传都只会有一个窗口,不会开出一堆 Chrome。
|
|
106
|
+
*/
|
|
107
|
+
/** 当前浏览器引用:收到 SIGTERM(面板点「关闭浏览器」或重新发布)时干净地关掉,避免残留窗口 */
|
|
108
|
+
let _browser = null;
|
|
109
|
+
let _ownsBrowser = false;
|
|
110
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
111
|
+
process.on(sig, async () => {
|
|
112
|
+
try { if (_browser && _ownsBrowser) await _browser.close(); } catch (_) {}
|
|
113
|
+
process.exit(0);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function getBrowser(headless) {
|
|
118
|
+
const { chromium } = require('playwright-core');
|
|
119
|
+
|
|
120
|
+
// ① 先试着连上已经开着的窗口
|
|
121
|
+
try {
|
|
122
|
+
const browser = await chromium.connectOverCDP(cdpEndpoint(), { timeout: 2500 });
|
|
123
|
+
const context = browser.contexts()[0];
|
|
124
|
+
if (context) {
|
|
125
|
+
info('复用已打开的浏览器窗口');
|
|
126
|
+
_browser = browser; _ownsBrowser = false; // 连过去的,不归我们关
|
|
127
|
+
return { browser, context, reused: true };
|
|
128
|
+
}
|
|
129
|
+
await browser.close().catch(() => {});
|
|
130
|
+
} catch (_) { /* 没开着,往下新开 */ }
|
|
131
|
+
|
|
132
|
+
// ② 新开一个
|
|
133
|
+
const executablePath = findChromium();
|
|
134
|
+
if (!executablePath) { out('NEED_INSTALL'); process.exit(2); }
|
|
135
|
+
const browser = await chromium.launch({
|
|
136
|
+
executablePath,
|
|
137
|
+
headless: !!headless,
|
|
138
|
+
args: [
|
|
139
|
+
'--no-sandbox', '--disable-dev-shm-usage', '--disable-blink-features=AutomationControlled',
|
|
140
|
+
`--remote-debugging-port=${CDP_PORT[platform] || 9225}`, // 开调试端口,供复用/续传
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
const context = await browser.newContext({
|
|
144
|
+
viewport: { width: 1400, height: 950 },
|
|
145
|
+
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
146
|
+
});
|
|
147
|
+
// 知乎发布要往剪贴板写 HTML 再粘进编辑器,需要剪贴板权限
|
|
148
|
+
await context.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {});
|
|
149
|
+
_browser = browser; _ownsBrowser = true; // 我们开的,退出时负责关掉
|
|
150
|
+
return { browser, context, reused: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 复用 context 里已有的标签页,没有才新开 —— 避免开出一堆 tab */
|
|
154
|
+
async function getPage(context) {
|
|
155
|
+
const pages = context.pages();
|
|
156
|
+
return pages.length ? pages[pages.length - 1] : await context.newPage();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 失败现场存证:截图 + HTML,便于定位选择器 */
|
|
160
|
+
async function dumpDiag(page, tag) {
|
|
161
|
+
try {
|
|
162
|
+
const dir = path.join(os.tmpdir(), `m2a_diag_${platform}_${Date.now()}`);
|
|
163
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
164
|
+
await page.screenshot({ path: path.join(dir, `${tag}.png`), fullPage: true }).catch(() => {});
|
|
165
|
+
fs.writeFileSync(path.join(dir, `${tag}.html`), await page.content().catch(() => ''), 'utf8');
|
|
166
|
+
fs.writeFileSync(path.join(dir, 'url.txt'), page.url(), 'utf8');
|
|
167
|
+
out('DIAG:' + dir);
|
|
168
|
+
} catch (_) {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── login ──────────────────────────────────────────────────────────────────
|
|
172
|
+
async function doLogin(cookieOutFile) {
|
|
173
|
+
const def = PLATFORMS[platform];
|
|
174
|
+
if (!def) fail('未知平台:' + platform);
|
|
175
|
+
|
|
176
|
+
const { browser, context } = await getBrowser(false);
|
|
177
|
+
try {
|
|
178
|
+
const page = await getPage(context);
|
|
179
|
+
await page.goto(def.loginUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
180
|
+
info(`已打开 ${def.name} 登录页,请在浏览器里完成登录(扫码 / 验证码 / 密码均可)…`);
|
|
181
|
+
|
|
182
|
+
const deadline = Date.now() + 5 * 60 * 1000;
|
|
183
|
+
let lastSeen = '';
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
if (page.isClosed()) { fail('登录窗口被关闭'); return; }
|
|
186
|
+
|
|
187
|
+
const cookies = await context.cookies().catch(() => []);
|
|
188
|
+
const hits = cookies.filter(c => def.authCookies.includes(c.name) && c.value && c.value.length > 5);
|
|
189
|
+
let url = '';
|
|
190
|
+
try { url = page.url(); } catch (_) {}
|
|
191
|
+
const leftLogin = url && !def.loginUrlPattern.test(url);
|
|
192
|
+
|
|
193
|
+
// 调试可见性:cookie 一有变化就播报,方便定位
|
|
194
|
+
const seen = hits.map(c => c.name).join(',');
|
|
195
|
+
if (seen && seen !== lastSeen) { info('检测到登录 cookie:' + seen); lastSeen = seen; }
|
|
196
|
+
|
|
197
|
+
// 登录成功判定:拿到任一候选 cookie,且已经离开登录页
|
|
198
|
+
if (hits.length && leftLogin) {
|
|
199
|
+
await page.waitForTimeout(2500); // 等其余 cookie 落全
|
|
200
|
+
const all = await context.cookies();
|
|
201
|
+
fs.writeFileSync(cookieOutFile, JSON.stringify(all), 'utf8');
|
|
202
|
+
info(`登录成功,已保存 ${all.length} 条 cookie`);
|
|
203
|
+
out('COOKIES_SAVED');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await page.waitForTimeout(1500);
|
|
207
|
+
}
|
|
208
|
+
await dumpDiag(page, 'login_timeout');
|
|
209
|
+
fail('登录超时(5 分钟内未检测到登录态)');
|
|
210
|
+
} finally {
|
|
211
|
+
// 只关我们自己开的窗口;复用来的(用户正在用的发布窗口)不能关
|
|
212
|
+
if (_ownsBrowser) await browser.close().catch(() => {});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── publish ────────────────────────────────────────────────────────────────
|
|
217
|
+
async function doPublish(jobFile, resume) {
|
|
218
|
+
const def = PLATFORMS[platform];
|
|
219
|
+
if (!def) fail('未知平台:' + platform);
|
|
220
|
+
|
|
221
|
+
const job = JSON.parse(fs.readFileSync(jobFile, 'utf8'));
|
|
222
|
+
const {
|
|
223
|
+
cookies, title = '', body = '', tags = [], tweets = null,
|
|
224
|
+
images = [], link = '', mode = 'auto', headless = false,
|
|
225
|
+
autoNumber = true, linkPos = 'all', oneImagePerTweet = true, wsEndpoint = '',
|
|
226
|
+
} = job;
|
|
227
|
+
|
|
228
|
+
// 复用已开着的窗口(登录时开的那个也会被复用),没有才新开
|
|
229
|
+
const { browser, context, reused } = await getBrowser(headless);
|
|
230
|
+
// 无论新开还是复用,都注入一次存好的 cookie,保证用的是当前登录态
|
|
231
|
+
await context.addCookies(normalizeCookies(cookies, def)).catch(() => {});
|
|
232
|
+
if (resume && reused) info('已重连到原窗口,从断点继续…');
|
|
233
|
+
|
|
234
|
+
let leaveOpen = false;
|
|
235
|
+
let page;
|
|
236
|
+
try {
|
|
237
|
+
page = await getPage(context);
|
|
238
|
+
|
|
239
|
+
if (platform === 'twitter') {
|
|
240
|
+
// 单条也走串推逻辑(N=1 时不加编号)
|
|
241
|
+
const list = (tweets && tweets.length) ? tweets : [{ body, tags }];
|
|
242
|
+
await publishTwitter(page, def, { tweets: list, link, images, mode, autoNumber, linkPos, oneImagePerTweet });
|
|
243
|
+
} else if (platform === 'xiaohongshu') {
|
|
244
|
+
await publishXiaohongshu(page, def, { title, body, tags, images, mode });
|
|
245
|
+
} else if (platform === 'zhihu') {
|
|
246
|
+
await publishZhihu(page, def, { title, html: job.html || '', images, mode });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 一律不自动关窗。默认 prepare 模式下也【不替用户点发布】——
|
|
250
|
+
// 自动点发布在小红书上会撞到二次确认弹窗,反而导致"看着发了其实没发"。
|
|
251
|
+
leaveOpen = true;
|
|
252
|
+
if (mode === 'auto') {
|
|
253
|
+
out('PUBLISHED:');
|
|
254
|
+
info('✅ 已提交发布。浏览器保持打开,请自行确认结果后关闭窗口。');
|
|
255
|
+
} else {
|
|
256
|
+
out('READY_TO_PUBLISH');
|
|
257
|
+
info('✅ 内容和图片都已填好。请到浏览器里核对,然后【自己点页面上的「发布」按钮】。');
|
|
258
|
+
}
|
|
259
|
+
await new Promise(() => {}); // 挂住进程,保持浏览器打开
|
|
260
|
+
} catch (e) {
|
|
261
|
+
if (page) await dumpDiag(page, 'publish_error');
|
|
262
|
+
// 出错保留浏览器,用户可手动接管
|
|
263
|
+
leaveOpen = !headless;
|
|
264
|
+
out('ERROR:' + e.message + (leaveOpen ? '(浏览器已保留,可手动完成)' : ''));
|
|
265
|
+
if (leaveOpen) { await new Promise(() => {}); }
|
|
266
|
+
else process.exit(1);
|
|
267
|
+
} finally {
|
|
268
|
+
// 只关我们自己开的;连过去复用的窗口不能关(那是用户的窗口)
|
|
269
|
+
if (!leaveOpen && !reused) await browser.close().catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function normalizeCookies(cookies, def) {
|
|
274
|
+
return (cookies || []).map(c => ({
|
|
275
|
+
name: c.name, value: c.value,
|
|
276
|
+
domain: c.domain, path: c.path || '/',
|
|
277
|
+
expires: typeof c.expires === 'number' ? c.expires : -1,
|
|
278
|
+
httpOnly: !!c.httpOnly, secure: c.secure !== false, sameSite: c.sameSite || 'Lax',
|
|
279
|
+
})).filter(c => c.name && c.value && c.domain);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ─── Twitter:自动填文 + 传图 + 发送 ────────────────────────────────────────
|
|
283
|
+
/**
|
|
284
|
+
* Twitter:支持串推(thread)。
|
|
285
|
+
* 做法:在同一个撰写弹窗里用「+」逐条加,最后一次性 Post all —— 比发完再逐条回复可靠得多。
|
|
286
|
+
* 配图挂在第 1 条(此时只有一个 fileInput,不会选错);链接挂在最后一条。
|
|
287
|
+
*/
|
|
288
|
+
/**
|
|
289
|
+
* 图片分配:默认「一条一图」——第 i 条推文配第 i 张长图(正文与截图一一对应),
|
|
290
|
+
* 多出来的推文(如最后的总结条)不配图;多出来的图片挂到最后一条有图的推文上(最多 4 张/条)。
|
|
291
|
+
*/
|
|
292
|
+
/**
|
|
293
|
+
* 图片按【顺序切块】分给各条推文:第 i 条 = 第 [i*K, i*K+K) 张图(K = X 单条上限 4 张)。
|
|
294
|
+
* 这样第 i 条带的就是文章从上到下的第 i 段,和 LLM 写这条时被告知的图片范围完全一致 → 文图对得上。
|
|
295
|
+
*/
|
|
296
|
+
const IMG_PER_TWEET = 4;
|
|
297
|
+
function distributeImages(images, n) {
|
|
298
|
+
const per = Array.from({ length: n }, () => []);
|
|
299
|
+
if (!images.length || !n) return { per, used: 0, dropped: 0 };
|
|
300
|
+
|
|
301
|
+
let used = 0;
|
|
302
|
+
for (let i = 0; i < n; i++) {
|
|
303
|
+
const chunk = images.slice(i * IMG_PER_TWEET, (i + 1) * IMG_PER_TWEET);
|
|
304
|
+
per[i] = chunk;
|
|
305
|
+
used += chunk.length;
|
|
306
|
+
}
|
|
307
|
+
return { per, used, dropped: images.length - used };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 给第 i 条推文传图。
|
|
312
|
+
* 关键:X 不会为每条推文各渲染一个 fileInput —— 只有【当前聚焦的那条】才有一个可用的
|
|
313
|
+
* (另一个是 disabled 的)。所以必须先点进第 i 条,再取那个未 disabled 的 fileInput。
|
|
314
|
+
* 之前按 .nth(i) 取,i>=2 时元素根本不存在 → setInputFiles 卡 30s 超时。
|
|
315
|
+
*/
|
|
316
|
+
async function attachImagesToTweet(page, i, files) {
|
|
317
|
+
const ed = page.locator(`[data-testid="tweetTextarea_${i}"]`).first();
|
|
318
|
+
await ed.click(); // 聚焦这一条,X 才会把可用的 fileInput 挂给它
|
|
319
|
+
await page.waitForTimeout(400);
|
|
320
|
+
const fi = page.locator('input[data-testid="fileInput"]:not([disabled])').first();
|
|
321
|
+
await fi.waitFor({ state: 'attached', timeout: 20000 })
|
|
322
|
+
.catch(() => { throw new Error(`第 ${i + 1} 条的图片上传控件未就绪`); });
|
|
323
|
+
await fi.setInputFiles(files, { timeout: 60000 });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 等按钮真正可点再点(X 在超字数/图片上传中会禁用「+」和「发帖」) */
|
|
327
|
+
async function clickWhenEnabled(page, locator, label, timeoutMs = 120000) {
|
|
328
|
+
await locator.waitFor({ state: 'visible', timeout: 30000 })
|
|
329
|
+
.catch(() => { throw new Error(`未找到${label}`); });
|
|
330
|
+
const deadline = Date.now() + timeoutMs;
|
|
331
|
+
while (Date.now() < deadline) {
|
|
332
|
+
const ariaDis = await locator.getAttribute('aria-disabled').catch(() => null);
|
|
333
|
+
if (ariaDis !== 'true') {
|
|
334
|
+
try { await locator.click({ timeout: 5000 }); return; } catch (_) { /* 重试 */ }
|
|
335
|
+
}
|
|
336
|
+
await page.waitForTimeout(1000);
|
|
337
|
+
}
|
|
338
|
+
throw new Error(`${label}一直不可点击:通常是某条超出 280 字上限(中文算 2 个字符!),或图片仍在上传`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function publishTwitter(page, def, { tweets, link, images, mode, autoNumber, linkPos, oneImagePerTweet }) {
|
|
342
|
+
const list = (tweets || []).filter(t => (t.body || '').trim());
|
|
343
|
+
if (!list.length) throw new Error('推文内容为空');
|
|
344
|
+
|
|
345
|
+
const N = list.length;
|
|
346
|
+
const pos = linkPos || 'all';
|
|
347
|
+
const texts = list.map((t, i) => {
|
|
348
|
+
const num = (autoNumber !== false && N > 1) ? `${i + 1}/${N} ` : '';
|
|
349
|
+
const showLink = link && (pos === 'all' || (pos === 'first' && i === 0) || (pos === 'last' && i === N - 1));
|
|
350
|
+
// 关键:按 X 的加权字数(中文=2、URL=23)算,而不是 JS 的 .length
|
|
351
|
+
return num + composeText(t.body, t.tags, showLink ? link : '', 280 - xLen(num), false, true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// 发布前自检:加权字数
|
|
355
|
+
texts.forEach((t, i) => {
|
|
356
|
+
if (xLen(t) > 280) throw new Error(`第 ${i + 1} 条超出 X 字数上限(${xLen(t)}/280,中文按 2 个字符计),请精简`);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const { per, used, dropped } = distributeImages(images || [], N);
|
|
360
|
+
const totalSteps = N + 1; // N 条 + 最后发帖
|
|
361
|
+
info(`共 ${N} 条推文,${used} 张配图按顺序均分到各条${dropped ? `,超出容量丢弃 ${dropped} 张` : ''}`);
|
|
362
|
+
|
|
363
|
+
step(0, totalSteps, '打开撰写页');
|
|
364
|
+
await page.goto('https://x.com/compose/post', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
365
|
+
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
|
366
|
+
|
|
367
|
+
if (def.loginUrlPattern.test(page.url())) throw new Error('Twitter cookie 已失效,请重新登录');
|
|
368
|
+
|
|
369
|
+
await page.locator('[data-testid="tweetTextarea_0"]').first()
|
|
370
|
+
.waitFor({ state: 'visible', timeout: 40000 })
|
|
371
|
+
.catch(() => { throw new Error('撰写框未出现(cookie 可能已失效)'); });
|
|
372
|
+
|
|
373
|
+
// resume:已经填好的条数(重连时从断点续填)
|
|
374
|
+
const startAt = await countFilledTweets(page);
|
|
375
|
+
if (startAt > 0) info(`检测到已填好 ${startAt} 条,从第 ${startAt + 1} 条继续`);
|
|
376
|
+
|
|
377
|
+
for (let i = startAt; i < N; i++) {
|
|
378
|
+
step(i, totalSteps, `写第 ${i + 1}/${N} 条`);
|
|
379
|
+
if (i > 0) {
|
|
380
|
+
await clickWhenEnabled(page, page.locator('[data-testid="addButton"]').first(), `串推「+」按钮(加第 ${i + 1} 条)`);
|
|
381
|
+
await page.waitForTimeout(600);
|
|
382
|
+
}
|
|
383
|
+
const ed = page.locator(`[data-testid="tweetTextarea_${i}"]`).first();
|
|
384
|
+
await ed.waitFor({ state: 'visible', timeout: 25000 })
|
|
385
|
+
.catch(() => { throw new Error(`第 ${i + 1} 条的编辑框未出现`); });
|
|
386
|
+
await fillEditor(page, ed, texts[i], `第 ${i + 1} 条`);
|
|
387
|
+
|
|
388
|
+
if (per[i].length) {
|
|
389
|
+
info(`第 ${i + 1} 条上传 ${per[i].length} 张图…`);
|
|
390
|
+
await attachImagesToTweet(page, i, per[i]);
|
|
391
|
+
await page.waitForTimeout(2000);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
step(N, totalSteps, '内容就绪');
|
|
395
|
+
info(`串推已就绪,共 ${N} 条`);
|
|
396
|
+
|
|
397
|
+
if (mode !== 'auto') { info('请在浏览器里核对后点「发帖」。'); return; }
|
|
398
|
+
|
|
399
|
+
step(N, totalSteps, '发送中');
|
|
400
|
+
await clickWhenEnabled(page, page.locator('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]').first(), '「全部发帖」按钮');
|
|
401
|
+
info('已点击发帖,等待确认…');
|
|
402
|
+
await page.waitForTimeout(6000);
|
|
403
|
+
|
|
404
|
+
const still = await page.locator('[data-testid="tweetTextarea_0"]').count().catch(() => 0);
|
|
405
|
+
step(totalSteps, totalSteps, still ? '需人工确认' : '已发出');
|
|
406
|
+
if (still) info('提示:撰写框仍在,可能未发出,请在浏览器确认');
|
|
407
|
+
else info(`串推已发出 ✅(共 ${N} 条,${used} 张图)`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** 数一下撰写弹窗里已经有内容的推文条数(用于 resume 断点续填) */
|
|
411
|
+
async function countFilledTweets(page) {
|
|
412
|
+
let n = 0;
|
|
413
|
+
for (let i = 0; i < 25; i++) {
|
|
414
|
+
const loc = page.locator(`[data-testid="tweetTextarea_${i}"]`).first();
|
|
415
|
+
if (!(await loc.count().catch(() => 0))) break;
|
|
416
|
+
const txt = (await loc.innerText().catch(() => '')) || '';
|
|
417
|
+
if (!txt.trim()) break;
|
|
418
|
+
n++;
|
|
419
|
+
}
|
|
420
|
+
return n;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** 往编辑器里填字,insertText 不生效时退回逐字输入,并校验确实进去了 */
|
|
424
|
+
async function fillEditor(page, locator, text, label) {
|
|
425
|
+
await locator.click();
|
|
426
|
+
await page.waitForTimeout(300);
|
|
427
|
+
await page.keyboard.insertText(text);
|
|
428
|
+
await page.waitForTimeout(500);
|
|
429
|
+
let got = (await locator.innerText().catch(() => '')) || '';
|
|
430
|
+
if (!got.trim()) {
|
|
431
|
+
await locator.click();
|
|
432
|
+
await page.keyboard.type(text, { delay: 10 });
|
|
433
|
+
await page.waitForTimeout(500);
|
|
434
|
+
got = (await locator.innerText().catch(() => '')) || '';
|
|
435
|
+
}
|
|
436
|
+
if (!got.trim()) throw new Error(`${label} 填充失败(编辑器未接收文本)`);
|
|
437
|
+
info(`已填入${label}(${got.trim().length} 字)`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ─── 小红书:自动传图 + 填文 + 发布 ─────────────────────────────────────────
|
|
441
|
+
async function publishXiaohongshu(page, def, { title, body, tags, images, mode }) {
|
|
442
|
+
if (!images || !images.length) throw new Error('小红书发布至少需要 1 张图片,请先「一键导出全部」生成长图');
|
|
443
|
+
|
|
444
|
+
info('打开小红书创作平台发布页(图文)…');
|
|
445
|
+
await page.goto(def.publishUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
446
|
+
|
|
447
|
+
// 关键:这是个 Vue SPA,domcontentloaded 时页面还是空白的。
|
|
448
|
+
// 必须「等元素真正出现」,不能死等固定秒数(之前就是等 3s 就去找,结果页面还没渲染)。
|
|
449
|
+
await page.waitForLoadState('networkidle', { timeout: 45000 }).catch(() => {});
|
|
450
|
+
|
|
451
|
+
if (def.loginUrlPattern.test(page.url())) throw new Error('小红书 cookie 已失效,请重新登录');
|
|
452
|
+
|
|
453
|
+
// 图片上传控件(排除视频框:它 accept 里是 mp4 且没有 multiple)
|
|
454
|
+
const imgInputSel = 'input[type="file"]:not([accept*="mp4"])';
|
|
455
|
+
let imgInput = page.locator(imgInputSel).first();
|
|
456
|
+
|
|
457
|
+
let ready = await imgInput.waitFor({ state: 'attached', timeout: 45000 }).then(() => true).catch(() => false);
|
|
458
|
+
|
|
459
|
+
// 兜底:万一没直接落在图文页,再去点一次「上传图文」tab
|
|
460
|
+
if (!ready) {
|
|
461
|
+
info('未直接进入图文页,尝试点击「上传图文」…');
|
|
462
|
+
const tab = page.getByText('上传图文', { exact: true }).first();
|
|
463
|
+
await tab.waitFor({ state: 'visible', timeout: 30000 })
|
|
464
|
+
.catch(() => { throw new Error('页面未渲染出「上传图文」(可能加载超时或结构已变化)'); });
|
|
465
|
+
await tab.click({ timeout: 10000 });
|
|
466
|
+
imgInput = page.locator(imgInputSel).first();
|
|
467
|
+
ready = await imgInput.waitFor({ state: 'attached', timeout: 45000 }).then(() => true).catch(() => false);
|
|
468
|
+
if (!ready) throw new Error('切到图文页后仍未出现图片上传控件');
|
|
469
|
+
}
|
|
470
|
+
info('已进入图文发布页');
|
|
471
|
+
|
|
472
|
+
// 有的上传控件没有 multiple 属性,一次只能收一个文件 → 退化为逐张上传
|
|
473
|
+
const multiple = await imgInput.evaluate(el => !!el.multiple).catch(() => false);
|
|
474
|
+
info(`正在上传 ${images.length} 张图${multiple ? '' : '(控件不支持多选,逐张上传)'}…`);
|
|
475
|
+
if (multiple) {
|
|
476
|
+
await imgInput.setInputFiles(images);
|
|
477
|
+
} else {
|
|
478
|
+
for (const img of images) {
|
|
479
|
+
await page.locator(imgInputSel).first().setInputFiles(img);
|
|
480
|
+
await page.waitForTimeout(1500);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 上传完成的标志:标题输入框出现
|
|
485
|
+
const titleInput = page.locator('input[placeholder*="标题"], input[placeholder*="填写标题"]').first();
|
|
486
|
+
await titleInput.waitFor({ state: 'visible', timeout: 120000 })
|
|
487
|
+
.catch(() => { throw new Error('图片上传后未进入编辑页(可能上传失败)'); });
|
|
488
|
+
info('图片上传完成');
|
|
489
|
+
|
|
490
|
+
// 标题(小红书限 20 字)
|
|
491
|
+
await titleInput.click();
|
|
492
|
+
await titleInput.fill(String(title || '').slice(0, 20));
|
|
493
|
+
info('已填标题');
|
|
494
|
+
|
|
495
|
+
// 正文 + 标签
|
|
496
|
+
const bodyText = composeText(body, tags, '', 1000, true);
|
|
497
|
+
const bodyBox = page.locator('div[contenteditable="true"]').first();
|
|
498
|
+
await bodyBox.waitFor({ state: 'visible', timeout: 20000 })
|
|
499
|
+
.catch(() => { throw new Error('未找到正文编辑框'); });
|
|
500
|
+
await bodyBox.click();
|
|
501
|
+
await page.waitForTimeout(300);
|
|
502
|
+
await page.keyboard.insertText(bodyText);
|
|
503
|
+
await page.waitForTimeout(600);
|
|
504
|
+
let filled = (await bodyBox.innerText().catch(() => '')) || '';
|
|
505
|
+
if (!filled.trim()) {
|
|
506
|
+
info('insertText 未生效,改用逐字输入…');
|
|
507
|
+
await bodyBox.click();
|
|
508
|
+
await page.keyboard.type(bodyText, { delay: 8 });
|
|
509
|
+
await page.waitForTimeout(600);
|
|
510
|
+
filled = (await bodyBox.innerText().catch(() => '')) || '';
|
|
511
|
+
}
|
|
512
|
+
if (!filled.trim()) throw new Error('正文填充失败(编辑器未接收文本)');
|
|
513
|
+
info('已填正文与标签');
|
|
514
|
+
|
|
515
|
+
if (mode !== 'auto') { info('内容已就绪,请在页面里点「发布」。'); return; }
|
|
516
|
+
|
|
517
|
+
// 自动发布
|
|
518
|
+
let pubBtn = page.getByRole('button', { name: /^\s*发布\s*$/ }).first();
|
|
519
|
+
if (!await pubBtn.count().catch(() => 0)) {
|
|
520
|
+
pubBtn = page.locator('button:has-text("发布"), div[class*="publish"]:has-text("发布")').first();
|
|
521
|
+
}
|
|
522
|
+
await pubBtn.waitFor({ state: 'visible', timeout: 20000 })
|
|
523
|
+
.catch(() => { throw new Error('未找到「发布」按钮'); });
|
|
524
|
+
await pubBtn.click();
|
|
525
|
+
info('已点击发布,等待平台响应…');
|
|
526
|
+
await page.waitForTimeout(1500);
|
|
527
|
+
|
|
528
|
+
// 点发布后可能弹二次确认框(用户看到"一闪而过"的多半就是它)。
|
|
529
|
+
// 不确认的话笔记根本不会提交 —— 这是之前"提示成功但主页没有"的元凶之一。
|
|
530
|
+
for (const label of ['确认发布', '确定发布', '确认', '确定', '继续发布']) {
|
|
531
|
+
const btn = page.getByRole('button', { name: new RegExp('^\\s*' + label + '\\s*$') }).first();
|
|
532
|
+
if (await btn.count().catch(() => 0) && await btn.isVisible().catch(() => false)) {
|
|
533
|
+
await btn.click().catch(() => {});
|
|
534
|
+
info(`已确认弹窗「${label}」`);
|
|
535
|
+
await page.waitForTimeout(1500);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 严格校验是否真的发出去了:只认「跳离发布页」这个强信号,
|
|
541
|
+
// 不再用文案匹配(页面里藏着模板文字会误判成功)。
|
|
542
|
+
let ok = false;
|
|
543
|
+
for (let i = 0; i < 40; i++) {
|
|
544
|
+
await page.waitForTimeout(1000);
|
|
545
|
+
const url = page.url();
|
|
546
|
+
if (!/\/publish\/publish/i.test(url)) { ok = true; break; } // 已离开发布页 = 提交成功
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 无论成败都留一份现场,便于复盘
|
|
550
|
+
await dumpDiag(page, ok ? 'xhs_publish_ok' : 'xhs_publish_unconfirmed');
|
|
551
|
+
|
|
552
|
+
if (ok) info('✅ 小红书发布成功(已跳离发布页)');
|
|
553
|
+
else info('⚠️ 仍停在发布页,说明【没有真正提交成功】。请在浏览器里手动检查(可能有未处理的弹窗/校验失败)。窗口不会自动关闭。');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ─── X 的加权字数 ───────────────────────────────────────────────────────────
|
|
557
|
+
/**
|
|
558
|
+
* X(Twitter) 不是按 JS 的 .length 算字数:
|
|
559
|
+
* - 中日韩字符算 2
|
|
560
|
+
* - 任何 URL 无论多长固定算 23(t.co 缩短)
|
|
561
|
+
* - 其余算 1
|
|
562
|
+
* 之前按 .length 判断导致中文推文实际超限 → 「+」和「发帖」被禁用 → 点击超时。
|
|
563
|
+
*/
|
|
564
|
+
function xLen(text) {
|
|
565
|
+
let n = 0;
|
|
566
|
+
const stripped = String(text || '').replace(/https?:\/\/\S+/g, () => { n += 23; return ''; });
|
|
567
|
+
for (const ch of stripped) {
|
|
568
|
+
const c = ch.codePointAt(0);
|
|
569
|
+
const wide =
|
|
570
|
+
(c >= 0x1100 && c <= 0x11FF) || (c >= 0x2E80 && c <= 0xA4CF) ||
|
|
571
|
+
(c >= 0xA960 && c <= 0xA97F) || (c >= 0xAC00 && c <= 0xD7FF) ||
|
|
572
|
+
(c >= 0xF900 && c <= 0xFAFF) || (c >= 0xFE10 && c <= 0xFE19) ||
|
|
573
|
+
(c >= 0xFE30 && c <= 0xFE6F) || (c >= 0xFF00 && c <= 0xFF60) ||
|
|
574
|
+
(c >= 0xFFE0 && c <= 0xFFE6) || (c >= 0x20000 && c <= 0x3FFFD);
|
|
575
|
+
n += wide ? 2 : 1;
|
|
576
|
+
}
|
|
577
|
+
return n;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** 按加权字数截断正文 */
|
|
581
|
+
function truncateByWeight(text, budget) {
|
|
582
|
+
if (xLen(text) <= budget) return text;
|
|
583
|
+
let out = '';
|
|
584
|
+
for (const ch of String(text)) {
|
|
585
|
+
if (xLen(out + ch) > budget - 1) break;
|
|
586
|
+
out += ch;
|
|
587
|
+
}
|
|
588
|
+
return out + '…';
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ─── 知乎:用编辑器自己的通道传图 + 粘贴干净 HTML ────────────────────────────
|
|
592
|
+
/**
|
|
593
|
+
* 知乎为什么必须走浏览器:
|
|
594
|
+
* 知乎没有公开 API,且会主动改内部接口来搞挂第三方工具 —— 直接调 api.zhihu.com/images
|
|
595
|
+
* 传图迟早(且已经)失效。用编辑器自己的上传通道,走的是它自家的正常流程,最稳。
|
|
596
|
+
*
|
|
597
|
+
* 流程:
|
|
598
|
+
* ① 打开 /write,填标题
|
|
599
|
+
* ② 把本地图片依次喂给编辑器的 file input,让【知乎自己】把图传上去,
|
|
600
|
+
* 回读编辑器里生成的 <img> 的 CDN 地址(zhimg.com)
|
|
601
|
+
* ③ 清空编辑器,把 CDN 地址替换进我们那份干净 HTML
|
|
602
|
+
* ④ 通过剪贴板把 HTML 整体粘进编辑器(公式 eeimg / 代码 pre lang 都能被知乎识别)
|
|
603
|
+
* ⑤ 停在发布前,由用户自己点「发布」
|
|
604
|
+
*/
|
|
605
|
+
/**
|
|
606
|
+
* 起一个临时本地图床(带 CORS),把本地图片变成真正的 http:// 地址。
|
|
607
|
+
* 这样粘贴进知乎编辑器时,它会把这些图当成"从网页复制来的远程图",
|
|
608
|
+
* 自己抓取并上传到 zhimg 图床 —— 全程不用碰它那些弹窗和文件对话框。
|
|
609
|
+
*/
|
|
610
|
+
function startImageServer(images) {
|
|
611
|
+
const http = require('http');
|
|
612
|
+
const map = new Map(); // /i0.png -> 本地路径
|
|
613
|
+
images.forEach((p, i) => map.set(`/i${i}${path.extname(p) || '.png'}`, p));
|
|
614
|
+
|
|
615
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
616
|
+
|
|
617
|
+
const server = http.createServer((req, res) => {
|
|
618
|
+
const key = decodeURIComponent((req.url || '').split('?')[0]);
|
|
619
|
+
const file = map.get(key);
|
|
620
|
+
res.setHeader('Access-Control-Allow-Origin', '*'); // 关键:让知乎的页面能跨域抓
|
|
621
|
+
if (!file || !fs.existsSync(file)) { res.statusCode = 404; res.end('not found'); return; }
|
|
622
|
+
res.setHeader('Content-Type', MIME[path.extname(file).toLowerCase()] || 'application/octet-stream');
|
|
623
|
+
fs.createReadStream(file).pipe(res);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
return new Promise((resolve) => {
|
|
627
|
+
server.listen(0, '127.0.0.1', () => {
|
|
628
|
+
const port = server.address().port;
|
|
629
|
+
resolve({
|
|
630
|
+
server, port,
|
|
631
|
+
urls: images.map((p, i) => `http://127.0.0.1:${port}/i${i}${path.extname(p) || '.png'}`),
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function publishZhihu(page, def, { title, html, images, mode }) {
|
|
638
|
+
const totalSteps = 4;
|
|
639
|
+
step(0, totalSteps, '打开知乎写文章页');
|
|
640
|
+
await page.goto(def.publishUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
641
|
+
await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => {});
|
|
642
|
+
|
|
643
|
+
if (def.loginUrlPattern.test(page.url())) throw new Error('知乎 cookie 已失效,请重新登录');
|
|
644
|
+
|
|
645
|
+
// ── 标题 ──
|
|
646
|
+
const titleBox = page.locator('textarea[placeholder*="标题"], input[placeholder*="标题"]').first();
|
|
647
|
+
await titleBox.waitFor({ state: 'visible', timeout: 40000 })
|
|
648
|
+
.catch(() => { throw new Error('未找到标题输入框(页面结构可能已变化)'); });
|
|
649
|
+
await titleBox.click();
|
|
650
|
+
await titleBox.fill(String(title || '').slice(0, 100));
|
|
651
|
+
info('已填标题');
|
|
652
|
+
|
|
653
|
+
// ── 正文编辑器 ──
|
|
654
|
+
const editor = page.locator('.public-DraftEditor-content, div[contenteditable="true"]').first();
|
|
655
|
+
await editor.waitFor({ state: 'visible', timeout: 30000 })
|
|
656
|
+
.catch(() => { throw new Error('未找到正文编辑器'); });
|
|
657
|
+
|
|
658
|
+
// ── ② 把本地图变成 http:// 地址(临时本地图床,带 CORS)──
|
|
659
|
+
//
|
|
660
|
+
// 之前三轮都栽在同一个地方:跟知乎编辑器的【图片弹窗 / 文件对话框】搏斗。
|
|
661
|
+
// 那条路上到处是坑(附件框和图片框长得一样、传完还得点「插入图片」、弹窗会叠加…),
|
|
662
|
+
// 每修一处就冒出新的一处。
|
|
663
|
+
//
|
|
664
|
+
// 现在彻底绕开它:编辑器对【粘贴进来的远程图片】本来就会自动抓取并上传到自己图床,
|
|
665
|
+
// 它不认的只是 data: 开头的本地图。所以把图片用本地 HTTP 服务暴露成真正的 http:// 地址,
|
|
666
|
+
// 连同正文一起粘贴 —— 一个弹窗都不用碰。
|
|
667
|
+
step(1, totalSteps, '准备图片');
|
|
668
|
+
let imgServer = null;
|
|
669
|
+
let finalHtml = html;
|
|
670
|
+
|
|
671
|
+
if (images && images.length) {
|
|
672
|
+
imgServer = await startImageServer(images);
|
|
673
|
+
info(`已起临时本地图床(端口 ${imgServer.port}),${images.length} 张图`);
|
|
674
|
+
let i = 0;
|
|
675
|
+
finalHtml = finalHtml.replace(/src="data:image[^"]*"/g, () => {
|
|
676
|
+
const u = imgServer.urls[i++];
|
|
677
|
+
return u ? `src="${u}"` : 'src=""';
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── ③ 整篇粘贴(正文 + 图片一次性进去)──
|
|
682
|
+
step(2, totalSteps, '粘贴正文和图片');
|
|
683
|
+
const plain = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
684
|
+
|
|
685
|
+
await editor.click();
|
|
686
|
+
await editor.evaluate((el, { h, p }) => {
|
|
687
|
+
el.focus();
|
|
688
|
+
const dt = new DataTransfer();
|
|
689
|
+
dt.setData('text/html', h);
|
|
690
|
+
dt.setData('text/plain', p);
|
|
691
|
+
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
692
|
+
}, { h: finalHtml, p: plain }).catch(() => {});
|
|
693
|
+
|
|
694
|
+
await page.waitForTimeout(3000);
|
|
695
|
+
|
|
696
|
+
let got = (await editor.innerText().catch(() => '')) || '';
|
|
697
|
+
if (got.trim().length < Math.min(40, plain.length * 0.3)) {
|
|
698
|
+
await dumpDiag(page, 'zhihu_paste_failed');
|
|
699
|
+
throw new Error(`正文没能填进编辑器(只有 ${got.trim().length} 字,预期约 ${plain.length} 字)`);
|
|
700
|
+
}
|
|
701
|
+
info(`正文已填入(${got.trim().length} 字)`);
|
|
702
|
+
|
|
703
|
+
// ── ④ 等知乎把这些远程图抓走、换成它自己的 zhimg 图床地址 ──
|
|
704
|
+
if (imgServer) {
|
|
705
|
+
step(3, totalSteps, '等知乎抓取图片');
|
|
706
|
+
info('等待知乎把图片抓取并上传到它自己的图床…');
|
|
707
|
+
let zhimg = 0, localLeft = images.length;
|
|
708
|
+
const deadline = Date.now() + 180000;
|
|
709
|
+
while (Date.now() < deadline) {
|
|
710
|
+
const st = await editor.evaluate(el => {
|
|
711
|
+
const srcs = [...el.querySelectorAll('img')].map(i => i.getAttribute('src') || i.src || '');
|
|
712
|
+
return {
|
|
713
|
+
zhimg: srcs.filter(s => /zhimg\.com/.test(s)).length,
|
|
714
|
+
local: srcs.filter(s => /127\.0\.0\.1/.test(s)).length,
|
|
715
|
+
};
|
|
716
|
+
}).catch(() => ({ zhimg: 0, local: 0 }));
|
|
717
|
+
zhimg = st.zhimg; localLeft = st.local;
|
|
718
|
+
if (zhimg >= images.length || localLeft === 0) break;
|
|
719
|
+
await page.waitForTimeout(2000);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (zhimg >= images.length) {
|
|
723
|
+
info(`✅ 知乎已把 ${zhimg} 张图全部上传到自己的图床`);
|
|
724
|
+
} else if (zhimg > 0) {
|
|
725
|
+
info(`⚠️ 只有 ${zhimg}/${images.length} 张图被知乎接管,其余可能要手动补(预览区「📋 复制图片」→ 粘贴)`);
|
|
726
|
+
} else {
|
|
727
|
+
await dumpDiag(page, 'zhihu_images_not_fetched');
|
|
728
|
+
info(`⚠️ 知乎没有自动抓取图片(正文和代码都在)。请用预览区图片上的「📋 复制图片」按钮,逐张粘贴到对应位置。`);
|
|
729
|
+
}
|
|
730
|
+
// 图床要留到浏览器关掉为止(知乎可能延迟抓取)
|
|
731
|
+
if (imgServer && zhimg >= images.length) {
|
|
732
|
+
setTimeout(() => { try { imgServer.server.close(); } catch (_) {} }, 30000);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
step(4, totalSteps, '内容就绪');
|
|
737
|
+
if (mode !== 'auto') { info('✅ 标题、正文、图片都已填好。请核对后【自己点页面上的「发布」】。'); return; }
|
|
738
|
+
|
|
739
|
+
const pub = page.getByRole('button', { name: /发布/ }).first();
|
|
740
|
+
await pub.waitFor({ state: 'visible', timeout: 20000 }).catch(() => { throw new Error('未找到「发布」按钮'); });
|
|
741
|
+
await pub.click();
|
|
742
|
+
await page.waitForTimeout(5000);
|
|
743
|
+
info('已点击发布,请在浏览器确认结果');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// ─── 文案拼接 ───────────────────────────────────────────────────────────────
|
|
747
|
+
/** weighted=true 时按 X 的加权规则算字数(中文=2、URL=23) */
|
|
748
|
+
function composeText(body, tags, link, limit, hashInline, weighted) {
|
|
749
|
+
let t = String(body || '').trim();
|
|
750
|
+
const tagStr = (tags || []).map(x => '#' + String(x).replace(/^#/, '').trim()).filter(s => s.length > 1).join(' ');
|
|
751
|
+
const linkPart = link ? `\n\n全文:${link}` : '';
|
|
752
|
+
const len = weighted ? xLen : (s => s.length);
|
|
753
|
+
|
|
754
|
+
let full = t + (tagStr ? `\n\n${tagStr}` : '') + linkPart;
|
|
755
|
+
if (limit && len(full) > limit) {
|
|
756
|
+
const reserve = (tagStr ? len(tagStr) + 2 : 0) + len(linkPart);
|
|
757
|
+
const budget = Math.max(0, limit - reserve);
|
|
758
|
+
t = weighted ? truncateByWeight(t, budget) : (t.slice(0, Math.max(0, budget - 1)) + '…');
|
|
759
|
+
full = t + (tagStr ? `\n\n${tagStr}` : '') + linkPart;
|
|
760
|
+
}
|
|
761
|
+
return full;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ─── 入口 ───────────────────────────────────────────────────────────────────
|
|
765
|
+
(async () => {
|
|
766
|
+
try {
|
|
767
|
+
if (cmd === 'login') await doLogin(argv[2]);
|
|
768
|
+
else if (cmd === 'publish') await doPublish(argv[2], false);
|
|
769
|
+
else if (cmd === 'resume') await doPublish(argv[2], true);
|
|
770
|
+
else fail('未知子命令:' + cmd);
|
|
771
|
+
} catch (e) {
|
|
772
|
+
fail(e.message);
|
|
773
|
+
}
|
|
774
|
+
})();
|