@qcplay/cli 1.0.12 → 1.0.14
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 +118 -3
- package/bin/qcplay.js +338 -27
- package/lib/lark-article.js +294 -0
- package/lib/platform-publish.js +4473 -0
- package/lib/wechat-article.js +15 -4
- package/package.json +5 -2
- package/templates/skills/qcplay-publish-article/SKILL.md +122 -7
- package/templates/skills/qcplay-publish-article/article/article.md +15 -0
|
@@ -0,0 +1,4473 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import http from "http";
|
|
4
|
+
import https from "https";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import readline from "readline";
|
|
8
|
+
|
|
9
|
+
import * as cheerio from "cheerio";
|
|
10
|
+
|
|
11
|
+
const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
|
|
12
|
+
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
13
|
+
|
|
14
|
+
const PLATFORM_ALIASES = new Map([
|
|
15
|
+
["官网", "website"],
|
|
16
|
+
["website", "website"],
|
|
17
|
+
["taptap", "taptap"],
|
|
18
|
+
["好游快爆", "haoyou"],
|
|
19
|
+
["haoyou", "haoyou"],
|
|
20
|
+
["b站", "bilibili"],
|
|
21
|
+
["哔哩哔哩", "bilibili"],
|
|
22
|
+
["bilibili", "bilibili"],
|
|
23
|
+
["微博", "weibo"],
|
|
24
|
+
["weibo", "weibo"],
|
|
25
|
+
["discord", "discord"],
|
|
26
|
+
["reddit", "reddit"],
|
|
27
|
+
["x", "x"],
|
|
28
|
+
["x(旧twitter)", "x"],
|
|
29
|
+
["twitter", "x"],
|
|
30
|
+
["facebook&ins", "meta"],
|
|
31
|
+
["facebook&ins", "meta"],
|
|
32
|
+
["meta(主instagram)", "meta"],
|
|
33
|
+
["meta", "meta"],
|
|
34
|
+
["instagram", "instagram"],
|
|
35
|
+
["ins", "instagram"],
|
|
36
|
+
["line", "line"],
|
|
37
|
+
["youtube", "youtube"]
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const BROWSER_PLATFORM_SPECS = {
|
|
41
|
+
taptap: {
|
|
42
|
+
manualLogin: true,
|
|
43
|
+
username: ['input[type="tel"]', 'input[name="phone"]', 'input[placeholder*="手机号"]'],
|
|
44
|
+
password: ['input[type="password"]'],
|
|
45
|
+
loginTriggerButtons: [/登录/, /Log in/i],
|
|
46
|
+
loginButtons: [/登录/, /Log in/i],
|
|
47
|
+
title: ['input[placeholder^="有意义的标题"]', 'input[placeholder*="标题"]', 'textarea[placeholder*="标题"]'],
|
|
48
|
+
body: ['#tap-editor__slate-context', '.tap-editor[contenteditable="true"]', '[contenteditable="true"]'],
|
|
49
|
+
submitSelectors: ['button.tap-button__editor.tap-button--primary'],
|
|
50
|
+
submit: [/^发布$/, /^发表$/]
|
|
51
|
+
},
|
|
52
|
+
haoyou: {
|
|
53
|
+
manualLogin: true,
|
|
54
|
+
loginPage: "https://www.3839.com/",
|
|
55
|
+
loginTriggerSelectors: ["#bannerU .avatar a", "#login_4399user"],
|
|
56
|
+
loggedInSelectors: ["#bannerU .loginInfoDiv .avatar a", "#bannerU .loginInfoDiv", ".loginInfoDiv .avatar"],
|
|
57
|
+
passwordLoginSelectors: ["#show_pwd_login_tc"],
|
|
58
|
+
username: ["#login_accout"],
|
|
59
|
+
password: ["#login_pwd"],
|
|
60
|
+
loginSubmitSelectors: ["#login_pwd_btn"],
|
|
61
|
+
loginButtons: [/登录/],
|
|
62
|
+
title: [
|
|
63
|
+
'#threadTitle[contenteditable="true"]',
|
|
64
|
+
'input[name="subject"]',
|
|
65
|
+
'input[name="title"]',
|
|
66
|
+
'input[placeholder*="标题"]'
|
|
67
|
+
],
|
|
68
|
+
body: [
|
|
69
|
+
'.ql-editor[contenteditable="true"][data-placeholder*="请输入内容"]',
|
|
70
|
+
'textarea[name="content"]',
|
|
71
|
+
'textarea[name="message"]',
|
|
72
|
+
'[contenteditable="true"]'
|
|
73
|
+
],
|
|
74
|
+
submitSelectors: [".editSubmit.active"],
|
|
75
|
+
submit: [/^发布$/, /^发表$/, /^提交$/]
|
|
76
|
+
},
|
|
77
|
+
bilibili: {
|
|
78
|
+
manualLogin: true,
|
|
79
|
+
username: ['input[placeholder*="账号"]', 'input[placeholder*="手机号"]', 'input[type="tel"]'],
|
|
80
|
+
password: ['input[placeholder*="密码"]', 'input[type="password"]'],
|
|
81
|
+
loginButtons: [/登录/],
|
|
82
|
+
title: [
|
|
83
|
+
'input[placeholder*="稿件标题"]',
|
|
84
|
+
'input[placeholder*="文章标题"]',
|
|
85
|
+
'input[placeholder*="标题"]',
|
|
86
|
+
'textarea[placeholder*="标题"]'
|
|
87
|
+
],
|
|
88
|
+
body: [
|
|
89
|
+
'.ql-editor[contenteditable="true"]',
|
|
90
|
+
'.ProseMirror[contenteditable="true"]',
|
|
91
|
+
'.article-editor [contenteditable="true"]',
|
|
92
|
+
'[contenteditable="true"][role="textbox"]'
|
|
93
|
+
],
|
|
94
|
+
videoBody: [
|
|
95
|
+
'textarea[placeholder*="简介"]',
|
|
96
|
+
'textarea[placeholder*="描述"]',
|
|
97
|
+
'.desc-text-wrp textarea',
|
|
98
|
+
'.ql-editor[contenteditable="true"]',
|
|
99
|
+
'[contenteditable="true"][data-placeholder*="简介"]',
|
|
100
|
+
'[contenteditable="true"][aria-label*="简介"]',
|
|
101
|
+
'[contenteditable="true"][role="textbox"]'
|
|
102
|
+
],
|
|
103
|
+
articleImageButtons: [
|
|
104
|
+
'eva3-toolbar-image',
|
|
105
|
+
'.eva3-toolbar eva3-toolbar-image.eva3-toolbar-item',
|
|
106
|
+
'button.ql-image',
|
|
107
|
+
'.ql-toolbar .ql-image',
|
|
108
|
+
'[data-title="图片"]',
|
|
109
|
+
'[aria-label*="图片"]'
|
|
110
|
+
],
|
|
111
|
+
articleImageInputs: [
|
|
112
|
+
'.article-editor input[type="file"][accept*="image"]',
|
|
113
|
+
'.ql-toolbar input[type="file"][accept*="image"]',
|
|
114
|
+
'.editor-toolbar input[type="file"][accept*="image"]'
|
|
115
|
+
],
|
|
116
|
+
videoInputs: [
|
|
117
|
+
'input[type="file"][accept*="video"]',
|
|
118
|
+
'.upload-v2-container input[type="file"]',
|
|
119
|
+
'.video-upload input[type="file"]'
|
|
120
|
+
],
|
|
121
|
+
coverInputs: [
|
|
122
|
+
'.cover-v2-container input[type="file"][accept*="image"]',
|
|
123
|
+
'.cover-upload input[type="file"][accept*="image"]',
|
|
124
|
+
'.cover-box input[type="file"][accept*="image"]'
|
|
125
|
+
],
|
|
126
|
+
category: [
|
|
127
|
+
'input[placeholder*="分区"]',
|
|
128
|
+
'[class*="partition"] .select-controller',
|
|
129
|
+
'[class*="partition"] [role="combobox"]'
|
|
130
|
+
],
|
|
131
|
+
tags: [
|
|
132
|
+
'.tag-input-wrp input',
|
|
133
|
+
'input[placeholder*="回车"][placeholder*="标签"]',
|
|
134
|
+
'input[placeholder*="标签"]'
|
|
135
|
+
],
|
|
136
|
+
topic: [
|
|
137
|
+
'input[placeholder*="搜索话题"]',
|
|
138
|
+
'input[placeholder*="添加话题"]',
|
|
139
|
+
'input[placeholder*="选择话题"]',
|
|
140
|
+
'.topic-selector input',
|
|
141
|
+
'[class*="topic"] input[placeholder]'
|
|
142
|
+
],
|
|
143
|
+
submitSelectors: ['button.submit-add', 'button.submit-btn', '.submit-add', '.submit-btn'],
|
|
144
|
+
submit: [/^发布$/, /^立即发布$/, /^提交文章$/, /^立即投稿$/, /^投稿$/]
|
|
145
|
+
},
|
|
146
|
+
weibo: {
|
|
147
|
+
manualLogin: true,
|
|
148
|
+
username: ['input[name="username"]', 'input[placeholder*="手机号"]'],
|
|
149
|
+
password: ['input[name="password"]', 'input[type="password"]'],
|
|
150
|
+
loginButtons: [/登录/],
|
|
151
|
+
title: ['textarea[placeholder="请输入标题"]', 'textarea[placeholder*="请输入标题"]'],
|
|
152
|
+
intro: ['textarea[placeholder*="导语"]'],
|
|
153
|
+
body: ['.tiptap.ProseMirror[contenteditable="true"]', '.wb-editor.ProseMirror[contenteditable="true"]'],
|
|
154
|
+
imageInputs: ['input[type="file"][accept*=".jpg"]', 'input[type="file"][accept*="image"]'],
|
|
155
|
+
next: [/^下一步$/],
|
|
156
|
+
submit: [/^发布$/, /^确认发布$/, /^立即发布$/]
|
|
157
|
+
},
|
|
158
|
+
x: {
|
|
159
|
+
username: ['input[autocomplete="username"]', 'input[name="text"]'],
|
|
160
|
+
password: ['input[name="password"]', 'input[type="password"]'],
|
|
161
|
+
loginButtons: [/Next/i, /Log in/i],
|
|
162
|
+
body: ['[data-testid="tweetTextarea_0"]', '[contenteditable="true"]'],
|
|
163
|
+
submitSelectors: ['[data-testid="tweetButton"]', '[data-testid="tweetButtonInline"]'],
|
|
164
|
+
submit: [/^Post$/i]
|
|
165
|
+
},
|
|
166
|
+
meta: {
|
|
167
|
+
username: ['input[name="email"]', 'input[type="email"]'],
|
|
168
|
+
password: ['input[name="pass"]', 'input[type="password"]'],
|
|
169
|
+
loginButtons: [/Log in/i, /登录/],
|
|
170
|
+
body: ['[contenteditable="true"]', 'textarea'],
|
|
171
|
+
submit: [/^Publish$/i, /^Post$/i, /^发布$/]
|
|
172
|
+
},
|
|
173
|
+
instagram: {
|
|
174
|
+
username: ['input[name="username"]'],
|
|
175
|
+
password: ['input[name="password"]'],
|
|
176
|
+
loginButtons: [/Log in/i, /登录/],
|
|
177
|
+
body: ['textarea', '[contenteditable="true"]'],
|
|
178
|
+
submit: [/^Share$/i, /^分享$/]
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
function normalizeText(value) {
|
|
183
|
+
return String(value ?? "").trim();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeKey(value) {
|
|
187
|
+
return normalizeText(value).toLowerCase().replace(/\s+/g, "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeAccount(value) {
|
|
191
|
+
const text = normalizeText(value);
|
|
192
|
+
if (text === "/") {
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
if (/^\d+(?:\.\d+)?e\+\d+$/i.test(text)) {
|
|
196
|
+
const number = Number(text);
|
|
197
|
+
if (Number.isSafeInteger(number)) {
|
|
198
|
+
return number.toFixed(0);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return text;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function canonicalPlatformName(value) {
|
|
205
|
+
const key = normalizeKey(value);
|
|
206
|
+
return PLATFORM_ALIASES.get(key) || key;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function authModeForEntry(entry) {
|
|
210
|
+
const platformKey = entry.platformKey || canonicalPlatformName(entry.platform);
|
|
211
|
+
if (platformKey === "website" || (platformKey === "discord" && discordWebhookFromEntry(entry))) {
|
|
212
|
+
return "api";
|
|
213
|
+
}
|
|
214
|
+
if (BROWSER_PLATFORM_SPECS[platformKey]?.manualLogin) {
|
|
215
|
+
return "manual";
|
|
216
|
+
}
|
|
217
|
+
const account = normalizeText(entry.account);
|
|
218
|
+
const password = normalizeText(entry.password);
|
|
219
|
+
const requirements = normalizeText(entry.requirements);
|
|
220
|
+
const combined = `${account}\n${password}\n${requirements}`;
|
|
221
|
+
if (/扫码/.test(combined)) {
|
|
222
|
+
return "qr";
|
|
223
|
+
}
|
|
224
|
+
if (/验证码|验证器|手机验证/.test(combined)) {
|
|
225
|
+
return "verification";
|
|
226
|
+
}
|
|
227
|
+
if (account && password && !/^(密码登录|验证码登录)$/i.test(password)) {
|
|
228
|
+
return "password";
|
|
229
|
+
}
|
|
230
|
+
return "session";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function platformPublisherForEntry(entry) {
|
|
234
|
+
if (entry.platformKey === "website") {
|
|
235
|
+
return "official-api";
|
|
236
|
+
}
|
|
237
|
+
if (entry.platformKey === "discord" && discordWebhookFromEntry(entry)) {
|
|
238
|
+
return "discord-webhook";
|
|
239
|
+
}
|
|
240
|
+
if (entry.platformKey === "reddit") {
|
|
241
|
+
return "blocked";
|
|
242
|
+
}
|
|
243
|
+
if (BROWSER_PLATFORM_SPECS[entry.platformKey]) {
|
|
244
|
+
return "browser";
|
|
245
|
+
}
|
|
246
|
+
return "unavailable";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function rowsFromLarkEnvelope(value) {
|
|
250
|
+
const sheet = value?.data?.sheets?.[0];
|
|
251
|
+
if (!sheet || !Array.isArray(sheet.columns) || !Array.isArray(sheet.data)) {
|
|
252
|
+
throw new Error("飞书平台配置返回格式不正确");
|
|
253
|
+
}
|
|
254
|
+
return sheet.data.map(row =>
|
|
255
|
+
Object.fromEntries(sheet.columns.map((column, index) => [column, row[index] ?? null]))
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function normalizePlatformRows(rows) {
|
|
260
|
+
return rows
|
|
261
|
+
.map((row, index) => {
|
|
262
|
+
const platform = normalizeText(row["平台"] ?? row.platform);
|
|
263
|
+
const project = normalizeText(row["项目"] ?? row.project);
|
|
264
|
+
if (!platform || !project) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
const platformKey = canonicalPlatformName(platform);
|
|
268
|
+
const manualLogin = Boolean(BROWSER_PLATFORM_SPECS[platformKey]?.manualLogin);
|
|
269
|
+
const entry = {
|
|
270
|
+
rowNumber: index + 2,
|
|
271
|
+
project,
|
|
272
|
+
region: normalizeText(row["区域"] ?? row.region) || "未指定",
|
|
273
|
+
source: normalizeText(row["分发来源"] ?? row.source),
|
|
274
|
+
platform,
|
|
275
|
+
platformKey,
|
|
276
|
+
url: normalizeText(row["页面URL链接"] ?? row.url),
|
|
277
|
+
account: manualLogin ? "" : normalizeAccount(row["账号"] ?? row.account),
|
|
278
|
+
password: manualLogin ? "" : normalizeAccount(row["密码"] ?? row.password),
|
|
279
|
+
requirements: normalizeText(row["特殊要求"] ?? row.requirements)
|
|
280
|
+
};
|
|
281
|
+
entry.authMode = authModeForEntry(entry);
|
|
282
|
+
entry.publisher = platformPublisherForEntry(entry);
|
|
283
|
+
return entry;
|
|
284
|
+
})
|
|
285
|
+
.filter(Boolean);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function runJson(command, args) {
|
|
289
|
+
return new Promise((resolve, reject) => {
|
|
290
|
+
const executable = process.platform === "win32" ? `${command}.cmd` : command;
|
|
291
|
+
const child = spawn(executable, args, {
|
|
292
|
+
windowsHide: true,
|
|
293
|
+
shell: process.platform === "win32",
|
|
294
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
295
|
+
});
|
|
296
|
+
const stdout = [];
|
|
297
|
+
const stderr = [];
|
|
298
|
+
child.stdout.on("data", chunk => stdout.push(chunk));
|
|
299
|
+
child.stderr.on("data", chunk => stderr.push(chunk));
|
|
300
|
+
child.once("error", error => reject(new Error(`无法启动 ${command}: ${error.message}`)));
|
|
301
|
+
child.once("close", code => {
|
|
302
|
+
const output = Buffer.concat(stdout).toString("utf8").trim();
|
|
303
|
+
const diagnostics = Buffer.concat(stderr).toString("utf8").trim();
|
|
304
|
+
if (code !== 0) {
|
|
305
|
+
reject(new Error(diagnostics || output || `${command} 执行失败 (${code})`));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
resolve(JSON.parse(output));
|
|
310
|
+
} catch {
|
|
311
|
+
reject(new Error(`${command} 没有返回有效 JSON`));
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function loadPlatformEntries(options = {}) {
|
|
318
|
+
let value;
|
|
319
|
+
if (options.configFile) {
|
|
320
|
+
const configFile = path.resolve(process.cwd(), options.configFile);
|
|
321
|
+
value = JSON.parse(await fs.promises.readFile(configFile, "utf8"));
|
|
322
|
+
} else {
|
|
323
|
+
value = await runJson("lark-cli", [
|
|
324
|
+
"sheets",
|
|
325
|
+
"+table-get",
|
|
326
|
+
"--url",
|
|
327
|
+
PLATFORM_CONFIG_URL,
|
|
328
|
+
"--as",
|
|
329
|
+
"user",
|
|
330
|
+
"--format",
|
|
331
|
+
"json"
|
|
332
|
+
]);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const rows = Array.isArray(value) ? value : Array.isArray(value.rows) ? value.rows : rowsFromLarkEnvelope(value);
|
|
336
|
+
return normalizePlatformRows(rows);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function entryLabel(entry) {
|
|
340
|
+
return `${entry.platform}(${entry.region})`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function availableProjects(entries) {
|
|
344
|
+
return [...new Set(entries.map(entry => entry.project))];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function entriesForProject(entries, project, region = "") {
|
|
348
|
+
const projectKey = normalizeKey(project);
|
|
349
|
+
const regionKey = normalizeKey(region);
|
|
350
|
+
return entries.filter(
|
|
351
|
+
entry => normalizeKey(entry.project) === projectKey && (!regionKey || normalizeKey(entry.region) === regionKey)
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function selectRequestedEntries(entries, requestedPlatforms) {
|
|
356
|
+
const selected = [];
|
|
357
|
+
for (const requested of requestedPlatforms) {
|
|
358
|
+
const key = canonicalPlatformName(requested);
|
|
359
|
+
const matches = entries.filter(entry => entry.platformKey === key);
|
|
360
|
+
if (matches.length === 0) {
|
|
361
|
+
throw new Error(`当前项目没有平台: ${requested}`);
|
|
362
|
+
}
|
|
363
|
+
if (matches.length > 1) {
|
|
364
|
+
throw new Error(`平台 ${requested} 对应多个区域,请同时使用 --region 指定区域`);
|
|
365
|
+
}
|
|
366
|
+
if (!selected.includes(matches[0])) {
|
|
367
|
+
selected.push(matches[0]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return selected;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function question(rl, prompt) {
|
|
374
|
+
return new Promise(resolve => rl.question(prompt, answer => resolve(answer.trim())));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function promptForPlatformEntries(entries, streams = {}) {
|
|
378
|
+
const input = streams.input || process.stdin;
|
|
379
|
+
const output = streams.output || process.stdout;
|
|
380
|
+
if (!input.isTTY || !output.isTTY) {
|
|
381
|
+
const choices = entries.map(entry => entryLabel(entry)).join("、");
|
|
382
|
+
throw new Error(`未指定发布平台。可用平台: ${choices}。请使用 --platform <平台> 指定`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
output.write("\n可发布平台:\n\n");
|
|
386
|
+
entries.forEach((entry, index) => {
|
|
387
|
+
output.write(` ${index + 1}. ${entryLabel(entry)} [${entry.publisher}]\n`);
|
|
388
|
+
});
|
|
389
|
+
output.write("\n");
|
|
390
|
+
|
|
391
|
+
const rl = readline.createInterface({ input, output });
|
|
392
|
+
try {
|
|
393
|
+
const answer = await question(rl, "请选择平台编号(多个用逗号分隔,all 表示全部): ");
|
|
394
|
+
if (normalizeKey(answer) === "all") {
|
|
395
|
+
return [...entries];
|
|
396
|
+
}
|
|
397
|
+
const indexes = answer
|
|
398
|
+
.split(/[,,\s]+/)
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.map(value => Number(value));
|
|
401
|
+
if (indexes.length === 0 || indexes.some(index => !Number.isInteger(index) || index < 1 || index > entries.length)) {
|
|
402
|
+
throw new Error("平台编号无效");
|
|
403
|
+
}
|
|
404
|
+
return [...new Set(indexes)].map(index => entries[index - 1]);
|
|
405
|
+
} finally {
|
|
406
|
+
rl.close();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function publicEntrySummary(entry) {
|
|
411
|
+
return {
|
|
412
|
+
project: entry.project,
|
|
413
|
+
region: entry.region,
|
|
414
|
+
platform: entry.platform,
|
|
415
|
+
auth: entry.authMode,
|
|
416
|
+
publisher: entry.publisher,
|
|
417
|
+
configured: Boolean(entry.url || entry.publisher === "official-api")
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function discordWebhookFromEntry(entry) {
|
|
422
|
+
const match = `${entry.url}\n${entry.requirements}`.match(/https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[^\s]+/i);
|
|
423
|
+
return match?.[0] || "";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function splitDiscordContent(title, markdown) {
|
|
427
|
+
const source = `**${title}**\n\n${normalizeText(markdown)}`.trim();
|
|
428
|
+
const chunks = [];
|
|
429
|
+
let remaining = source;
|
|
430
|
+
while (remaining.length > DISCORD_MESSAGE_LIMIT) {
|
|
431
|
+
let splitAt = remaining.lastIndexOf("\n", DISCORD_MESSAGE_LIMIT);
|
|
432
|
+
if (splitAt < DISCORD_MESSAGE_LIMIT / 2) {
|
|
433
|
+
splitAt = DISCORD_MESSAGE_LIMIT;
|
|
434
|
+
}
|
|
435
|
+
chunks.push(remaining.slice(0, splitAt).trim());
|
|
436
|
+
remaining = remaining.slice(splitAt).trim();
|
|
437
|
+
}
|
|
438
|
+
if (remaining) {
|
|
439
|
+
chunks.push(remaining);
|
|
440
|
+
}
|
|
441
|
+
return chunks;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function postJson(urlValue, payload) {
|
|
445
|
+
return new Promise((resolve, reject) => {
|
|
446
|
+
const url = new URL(urlValue);
|
|
447
|
+
const body = JSON.stringify(payload);
|
|
448
|
+
const request = https.request(
|
|
449
|
+
url,
|
|
450
|
+
{
|
|
451
|
+
method: "POST",
|
|
452
|
+
headers: {
|
|
453
|
+
Accept: "application/json",
|
|
454
|
+
"Content-Type": "application/json",
|
|
455
|
+
"Content-Length": Buffer.byteLength(body),
|
|
456
|
+
"User-Agent": "qcplay-cli"
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
response => {
|
|
460
|
+
const chunks = [];
|
|
461
|
+
response.on("data", chunk => chunks.push(chunk));
|
|
462
|
+
response.on("end", () => {
|
|
463
|
+
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
464
|
+
if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
|
|
465
|
+
reject(new Error(`Discord webhook 返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
resolve();
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
);
|
|
472
|
+
request.once("error", reject);
|
|
473
|
+
request.end(body);
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export async function publishDiscord(entry, article, options = {}) {
|
|
478
|
+
const webhook = discordWebhookFromEntry(entry);
|
|
479
|
+
if (!webhook) {
|
|
480
|
+
throw new Error("Discord 配置缺少 webhook,无法使用 API 发布");
|
|
481
|
+
}
|
|
482
|
+
const chunks = splitDiscordContent(article.title, article.markdown);
|
|
483
|
+
const send = options.postJson || postJson;
|
|
484
|
+
for (const chunk of chunks) {
|
|
485
|
+
await send(webhook, { content: chunk, allowed_mentions: { parse: [] } });
|
|
486
|
+
}
|
|
487
|
+
return { messages: chunks.length };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function plainTextForPlatform(article, platformKey) {
|
|
491
|
+
const $ = cheerio.load(article.html || "", null, false);
|
|
492
|
+
$("br").replaceWith("\n");
|
|
493
|
+
$("p,section,div,h1,h2,h3,h4,h5,h6,li,blockquote,tr").each((_, element) => {
|
|
494
|
+
$(element).append("\n");
|
|
495
|
+
});
|
|
496
|
+
$("img").each((_, element) => {
|
|
497
|
+
const image = $(element);
|
|
498
|
+
image.replaceWith(image.attr("src") ? `\n${image.attr("src")}\n` : "");
|
|
499
|
+
});
|
|
500
|
+
const markdown = normalizeText($.root().text() || article.markdown)
|
|
501
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
502
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
503
|
+
if (platformKey === "x") {
|
|
504
|
+
return `${article.title}\n\n${markdown}`.trim();
|
|
505
|
+
}
|
|
506
|
+
return markdown;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const TAPTAP_TYPES = new Map([
|
|
510
|
+
["long-post", "topic"],
|
|
511
|
+
["longpost", "topic"],
|
|
512
|
+
["topic", "topic"],
|
|
513
|
+
["长帖", "topic"],
|
|
514
|
+
["长贴", "topic"],
|
|
515
|
+
["image-text", "moment"],
|
|
516
|
+
["imagetext", "moment"],
|
|
517
|
+
["moment", "moment"],
|
|
518
|
+
["图文", "moment"],
|
|
519
|
+
["video", "video"],
|
|
520
|
+
["视频", "video"]
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
function boolLike(value) {
|
|
524
|
+
return /^(?:1|true|yes|on|是|启用|需要|定时|草稿)$/i.test(normalizeText(value));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function falseLike(value) {
|
|
528
|
+
return /^(?:0|false|no|off|否|禁用|不添加|无)$/i.test(normalizeText(value));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function tapTapMeta(article, ...keys) {
|
|
532
|
+
for (const key of keys) {
|
|
533
|
+
const value = article.meta?.[key];
|
|
534
|
+
if (normalizeText(value)) {
|
|
535
|
+
return normalizeText(value);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return "";
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function splitTapTapFiles(value) {
|
|
542
|
+
return normalizeText(value)
|
|
543
|
+
.split(/[,,;;|]/)
|
|
544
|
+
.map(item => item.trim())
|
|
545
|
+
.filter(Boolean);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
export function resolveTapTapPublishingOptions(article, entry = {}) {
|
|
549
|
+
const requestedType = normalizeKey(tapTapMeta(article, "taptap_type", "taptap_mode") || "long-post");
|
|
550
|
+
const type = TAPTAP_TYPES.get(requestedType);
|
|
551
|
+
if (!type) {
|
|
552
|
+
throw new Error("taptap_type 仅支持 long-post、image-text 或 video");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const forumValue = tapTapMeta(article, "taptap_forum");
|
|
556
|
+
const forum = falseLike(forumValue)
|
|
557
|
+
? ""
|
|
558
|
+
: boolLike(forumValue) || !forumValue
|
|
559
|
+
? normalizeText(entry.project)
|
|
560
|
+
: forumValue;
|
|
561
|
+
const scheduled = boolLike(tapTapMeta(article, "taptap_scheduled", "taptap_schedule"));
|
|
562
|
+
const draft = boolLike(tapTapMeta(article, "taptap_draft"));
|
|
563
|
+
if (scheduled && draft) {
|
|
564
|
+
throw new Error("taptap_scheduled 与 taptap_draft 不能同时启用");
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
type,
|
|
568
|
+
typeLabel: type === "topic" ? "长帖" : type === "moment" ? "图文" : "视频",
|
|
569
|
+
forum,
|
|
570
|
+
scheduled,
|
|
571
|
+
draft,
|
|
572
|
+
images: splitTapTapFiles(tapTapMeta(article, "taptap_images")),
|
|
573
|
+
video: tapTapMeta(article, "taptap_video") || normalizeText(article.payload?.video_link),
|
|
574
|
+
cover: tapTapMeta(article, "taptap_cover")
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export function tapTapPublishingPlan(article, entry = {}) {
|
|
579
|
+
const settings = resolveTapTapPublishingOptions(article, entry);
|
|
580
|
+
const imageCount = settings.images.length
|
|
581
|
+
? settings.images.length
|
|
582
|
+
: [...new Set([...imageSourcesFromArticle(article), normalizeText(article.payload?.thumbnail)].filter(Boolean))].length;
|
|
583
|
+
return {
|
|
584
|
+
type: settings.typeLabel,
|
|
585
|
+
forum: settings.forum || "",
|
|
586
|
+
scheduled: settings.scheduled,
|
|
587
|
+
draft: settings.draft,
|
|
588
|
+
media: settings.type === "video" ? Boolean(settings.video) : settings.type === "moment" ? Math.min(imageCount, 18) : 0,
|
|
589
|
+
cover: settings.type === "topic" && Boolean(settings.cover)
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function weiboMeta(article, ...keys) {
|
|
594
|
+
for (const key of keys) {
|
|
595
|
+
const value = article.meta?.[key];
|
|
596
|
+
if (normalizeText(value)) {
|
|
597
|
+
return normalizeText(value);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return "";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function buildWeiboText(article) {
|
|
604
|
+
return buildTapTapRichContent(article).plainText || plainTextForPlatform(article, "weibo");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function buildWeiboRichContent(article) {
|
|
608
|
+
const content = buildPlatformRichContent(article, {
|
|
609
|
+
preserveBackground: true,
|
|
610
|
+
preserveBlockStyles: true,
|
|
611
|
+
structuredTables: true
|
|
612
|
+
});
|
|
613
|
+
const seenImages = new Set();
|
|
614
|
+
const items = content.items.filter(item => {
|
|
615
|
+
if (item.type !== "image") {
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
const key = weiboImageSourceKey(item.source);
|
|
619
|
+
if (!key || seenImages.has(key)) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
seenImages.add(key);
|
|
623
|
+
return true;
|
|
624
|
+
});
|
|
625
|
+
return {
|
|
626
|
+
...content,
|
|
627
|
+
items,
|
|
628
|
+
images: items.filter(item => item.type === "image")
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function resolveWeiboPublishingOptions(article) {
|
|
633
|
+
return {
|
|
634
|
+
intro: weiboMeta(article, "weibo_intro") || normalizeText(article.payload?.article_excerpt),
|
|
635
|
+
cover: weiboMeta(article, "weibo_cover")
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function weiboPublishingPlan(article) {
|
|
640
|
+
const settings = resolveWeiboPublishingOptions(article);
|
|
641
|
+
const richContent = buildWeiboRichContent(article);
|
|
642
|
+
return {
|
|
643
|
+
type: "头条文章",
|
|
644
|
+
characters: richContent.plainText.length,
|
|
645
|
+
images: richContent.images.length,
|
|
646
|
+
links: richContent.links.length,
|
|
647
|
+
intro: Boolean(settings.intro),
|
|
648
|
+
cover: settings.cover ? "explicit" : richContent.images.length > 0 ? "first-image" : ""
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const BILIBILI_TYPES = new Map([
|
|
653
|
+
["article", "article"],
|
|
654
|
+
["text", "article"],
|
|
655
|
+
["column", "article"],
|
|
656
|
+
["专栏", "article"],
|
|
657
|
+
["图文", "article"],
|
|
658
|
+
["video", "video"],
|
|
659
|
+
["视频", "video"]
|
|
660
|
+
]);
|
|
661
|
+
|
|
662
|
+
function bilibiliMeta(article, ...keys) {
|
|
663
|
+
for (const key of keys) {
|
|
664
|
+
const value = article.meta?.[key];
|
|
665
|
+
if (normalizeText(value)) {
|
|
666
|
+
return normalizeText(value);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return "";
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function resolveBilibiliPublishingOptions(article) {
|
|
673
|
+
const requestedType = normalizeKey(bilibiliMeta(article, "bilibili_type", "bilibili_mode") || "article");
|
|
674
|
+
const type = BILIBILI_TYPES.get(requestedType);
|
|
675
|
+
if (!type) {
|
|
676
|
+
throw new Error("bilibili_type 仅支持 article 或 video");
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
type,
|
|
680
|
+
typeLabel: type === "video" ? "视频" : "专栏",
|
|
681
|
+
video: bilibiliMeta(article, "bilibili_video") || normalizeText(article.payload?.video_link),
|
|
682
|
+
cover: bilibiliMeta(article, "bilibili_cover"),
|
|
683
|
+
category: bilibiliMeta(article, "bilibili_category"),
|
|
684
|
+
tags: splitTapTapFiles(bilibiliMeta(article, "bilibili_tags")),
|
|
685
|
+
topic: bilibiliMeta(article, "bilibili_topic")
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export function bilibiliPublishingPlan(article) {
|
|
690
|
+
const settings = resolveBilibiliPublishingOptions(article);
|
|
691
|
+
const richContent = buildBilibiliRichContent(article);
|
|
692
|
+
return {
|
|
693
|
+
type: settings.typeLabel,
|
|
694
|
+
video: settings.type === "video" && Boolean(settings.video),
|
|
695
|
+
cover: Boolean(settings.cover),
|
|
696
|
+
category: settings.category || "",
|
|
697
|
+
tags: settings.tags.length,
|
|
698
|
+
topic: settings.topic || "",
|
|
699
|
+
images: settings.type === "article" ? richContent.images.length : 0,
|
|
700
|
+
links: richContent.links.length
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function bilibiliEditorUrl(entryUrl, type) {
|
|
705
|
+
const url = new URL(entryUrl);
|
|
706
|
+
url.pathname = type === "video" ? "/platform/upload/video/frame" : "/platform/upload/text/new-edit";
|
|
707
|
+
url.search = "";
|
|
708
|
+
url.hash = "";
|
|
709
|
+
return url.toString();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function tapTapEditorUrl(entryUrl, type) {
|
|
713
|
+
const url = new URL(entryUrl);
|
|
714
|
+
url.searchParams.set("type", type);
|
|
715
|
+
return url.toString();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function haoyouEditorUrl(entryUrl) {
|
|
719
|
+
let url;
|
|
720
|
+
try {
|
|
721
|
+
url = new URL(entryUrl);
|
|
722
|
+
} catch {
|
|
723
|
+
throw new Error("好游快爆配置的发布页面 URL 无效");
|
|
724
|
+
}
|
|
725
|
+
if (url.hostname.toLowerCase() !== "bbs.3839.com") {
|
|
726
|
+
throw new Error("好游快爆配置必须使用 bbs.3839.com 对应游戏的发布链接");
|
|
727
|
+
}
|
|
728
|
+
const fid = normalizeText(url.searchParams.get("fid"));
|
|
729
|
+
if (!fid || fid === "0") {
|
|
730
|
+
throw new Error("好游快爆配置缺少对应游戏的有效 fid");
|
|
731
|
+
}
|
|
732
|
+
url.pathname = "/index.php";
|
|
733
|
+
url.searchParams.set("m", "api");
|
|
734
|
+
url.searchParams.set("c", "thread");
|
|
735
|
+
url.searchParams.set("a", "sendThreadShow");
|
|
736
|
+
url.searchParams.set("fid", fid);
|
|
737
|
+
url.searchParams.set("type", "1");
|
|
738
|
+
url.hash = "";
|
|
739
|
+
return url.toString();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function weiboArticleEditorUrl() {
|
|
743
|
+
return "https://card.weibo.com/article/v5/editor";
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function imageSourcesFromArticle(article) {
|
|
747
|
+
const $ = cheerio.load(article.html || "", null, false);
|
|
748
|
+
return $("img[src]")
|
|
749
|
+
.map((_, element) => normalizeText($(element).attr("src")))
|
|
750
|
+
.get()
|
|
751
|
+
.filter(Boolean);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const TAPTAP_BLOCK_TAGS = new Set([
|
|
755
|
+
"article",
|
|
756
|
+
"aside",
|
|
757
|
+
"blockquote",
|
|
758
|
+
"div",
|
|
759
|
+
"figcaption",
|
|
760
|
+
"figure",
|
|
761
|
+
"h1",
|
|
762
|
+
"h2",
|
|
763
|
+
"h3",
|
|
764
|
+
"h4",
|
|
765
|
+
"h5",
|
|
766
|
+
"h6",
|
|
767
|
+
"header",
|
|
768
|
+
"li",
|
|
769
|
+
"main",
|
|
770
|
+
"ol",
|
|
771
|
+
"p",
|
|
772
|
+
"section",
|
|
773
|
+
"table",
|
|
774
|
+
"td",
|
|
775
|
+
"th",
|
|
776
|
+
"tr",
|
|
777
|
+
"ul"
|
|
778
|
+
]);
|
|
779
|
+
const TAPTAP_INLINE_TAGS = new Map([
|
|
780
|
+
["b", "strong"],
|
|
781
|
+
["strong", "strong"],
|
|
782
|
+
["i", "em"],
|
|
783
|
+
["em", "em"],
|
|
784
|
+
["u", "u"],
|
|
785
|
+
["s", "s"],
|
|
786
|
+
["del", "s"],
|
|
787
|
+
["code", "code"]
|
|
788
|
+
]);
|
|
789
|
+
|
|
790
|
+
function escapeTapTapHtml(value) {
|
|
791
|
+
return String(value ?? "")
|
|
792
|
+
.replace(/&/g, "&")
|
|
793
|
+
.replace(/</g, "<")
|
|
794
|
+
.replace(/>/g, ">")
|
|
795
|
+
.replace(/"/g, """);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function safeTapTapHref(value) {
|
|
799
|
+
try {
|
|
800
|
+
const url = new URL(normalizeText(value));
|
|
801
|
+
return /^(?:https?):$/.test(url.protocol) ? url.toString() : "";
|
|
802
|
+
} catch {
|
|
803
|
+
return "";
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function normalizedTapTapText(value) {
|
|
808
|
+
return normalizeText(value)
|
|
809
|
+
.replace(/[\u200b-\u200d\ufeff]/g, "")
|
|
810
|
+
.replace(/\s+/g, " ");
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function normalizedStructuredEditorText(value) {
|
|
814
|
+
return normalizedTapTapText(value).replace(/\s*:\s*/g, ":");
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function safeRichTextColor(value) {
|
|
818
|
+
const color = normalizeText(value);
|
|
819
|
+
return /^(?:#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]+)$/i.test(color) ? color : "";
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
|
|
823
|
+
const wrappers = [];
|
|
824
|
+
const add = name => {
|
|
825
|
+
if (!wrappers.includes(name)) {
|
|
826
|
+
wrappers.push(name);
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
const mappedTag = TAPTAP_INLINE_TAGS.get(tag);
|
|
830
|
+
if (mappedTag) add(mappedTag);
|
|
831
|
+
if (/font-weight\s*:\s*(?:bold|[6-9]00)/i.test(styleValue)) add("strong");
|
|
832
|
+
if (/font-style\s*:\s*italic/i.test(styleValue)) add("em");
|
|
833
|
+
if (/text-decoration[^;]*underline/i.test(styleValue)) add("u");
|
|
834
|
+
if (/text-decoration[^;]*line-through/i.test(styleValue)) add("s");
|
|
835
|
+
const safeColor = safeRichTextColor(styleValue.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i)?.[1]);
|
|
836
|
+
const safeBackground = options.preserveBackground
|
|
837
|
+
? safeRichTextColor(styleValue.match(/(?:^|;)\s*background(?:-color)?\s*:\s*([^;]+)/i)?.[1])
|
|
838
|
+
: "";
|
|
839
|
+
const spanStyles = [safeColor ? `color:${safeColor}` : "", safeBackground ? `background-color:${safeBackground}` : ""]
|
|
840
|
+
.filter(Boolean)
|
|
841
|
+
.join(";");
|
|
842
|
+
return {
|
|
843
|
+
open: wrappers.map(name => `<${name}>`).join("") + (spanStyles ? `<span style="${spanStyles}">` : ""),
|
|
844
|
+
close: (spanStyles ? "</span>" : "") + [...wrappers].reverse().map(name => `</${name}>`).join("")
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export function buildTapTapRichContent(article) {
|
|
849
|
+
return buildPlatformRichContent(article);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function buildPlatformRichContent(article, options = {}) {
|
|
853
|
+
const $ = cheerio.load(article.html || "", null, false);
|
|
854
|
+
$("script,style,noscript,iframe,svg,audio,video").remove();
|
|
855
|
+
const items = [];
|
|
856
|
+
const links = [];
|
|
857
|
+
const images = [];
|
|
858
|
+
let html = "";
|
|
859
|
+
let plain = "";
|
|
860
|
+
let blockContext = { tag: "p", align: "", style: "" };
|
|
861
|
+
|
|
862
|
+
const appendText = value => {
|
|
863
|
+
let collapsed = String(value ?? "").replace(/\s+/g, " ");
|
|
864
|
+
if (!collapsed.trim()) {
|
|
865
|
+
if (plain && !plain.endsWith(" ")) {
|
|
866
|
+
html += " ";
|
|
867
|
+
plain += " ";
|
|
868
|
+
}
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (!plain) {
|
|
872
|
+
collapsed = collapsed.trimStart();
|
|
873
|
+
}
|
|
874
|
+
html += escapeTapTapHtml(collapsed);
|
|
875
|
+
plain += collapsed;
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
const blockStyleAttribute = (styleValue, alignmentValue = "") => {
|
|
879
|
+
const blockStyles = [];
|
|
880
|
+
if (/^(?:left|center|right|justify)$/.test(alignmentValue)) {
|
|
881
|
+
blockStyles.push(`text-align:${alignmentValue}`);
|
|
882
|
+
}
|
|
883
|
+
if (options.preserveBlockStyles) {
|
|
884
|
+
const color = safeRichTextColor(styleValue.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i)?.[1]);
|
|
885
|
+
const background = safeRichTextColor(
|
|
886
|
+
styleValue.match(/(?:^|;)\s*background(?:-color)?\s*:\s*([^;]+)/i)?.[1]
|
|
887
|
+
);
|
|
888
|
+
if (color) blockStyles.push(`color:${color}`);
|
|
889
|
+
if (background) blockStyles.push(`background-color:${background}`);
|
|
890
|
+
}
|
|
891
|
+
return blockStyles.length > 0 ? ` style="${blockStyles.join(";")}"` : "";
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const flushHtml = () => {
|
|
895
|
+
const normalizedPlain = normalizedTapTapText(plain);
|
|
896
|
+
if (normalizedPlain) {
|
|
897
|
+
const style = blockStyleAttribute(blockContext.style, blockContext.align);
|
|
898
|
+
const blockTag = /^h[1-3]$/.test(blockContext.tag)
|
|
899
|
+
? blockContext.tag
|
|
900
|
+
: blockContext.tag === "blockquote"
|
|
901
|
+
? "blockquote"
|
|
902
|
+
: "p";
|
|
903
|
+
const blockHtml = blockTag === "blockquote" ? `<em>${html.trim()}</em>` : html.trim();
|
|
904
|
+
const outputTag = blockTag === "blockquote" ? options.quoteTag || blockTag : blockTag;
|
|
905
|
+
items.push({
|
|
906
|
+
type: "html",
|
|
907
|
+
html: `<${outputTag}${style}>${blockHtml}</${outputTag}>`,
|
|
908
|
+
plain: normalizedPlain,
|
|
909
|
+
block: blockTag,
|
|
910
|
+
align: blockContext.align
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
html = "";
|
|
914
|
+
plain = "";
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const inlineFragment = node => {
|
|
918
|
+
if (node.type === "text") {
|
|
919
|
+
const value = String(node.data ?? "").replace(/\s+/g, " ");
|
|
920
|
+
return { html: escapeTapTapHtml(value), plain: value };
|
|
921
|
+
}
|
|
922
|
+
if (node.type !== "tag") {
|
|
923
|
+
return { html: "", plain: "" };
|
|
924
|
+
}
|
|
925
|
+
const tag = String(node.name || node.tagName || "").toLowerCase();
|
|
926
|
+
const element = $(node);
|
|
927
|
+
if (/display\s*:\s*none/i.test(element.attr("style") || "")) {
|
|
928
|
+
return { html: "", plain: "" };
|
|
929
|
+
}
|
|
930
|
+
if (tag === "br") {
|
|
931
|
+
return { html: "<br>", plain: "\n" };
|
|
932
|
+
}
|
|
933
|
+
if (tag === "a") {
|
|
934
|
+
const label = normalizedTapTapText(element.text());
|
|
935
|
+
const href = safeTapTapHref(element.attr("href"));
|
|
936
|
+
if (label && href) {
|
|
937
|
+
links.push({ href, label });
|
|
938
|
+
return {
|
|
939
|
+
html: `<a href="${escapeTapTapHtml(href)}">${escapeTapTapHtml(label)}</a>`,
|
|
940
|
+
plain: label
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
const children = (node.children || []).map(inlineFragment);
|
|
945
|
+
const childHtml = children.map(child => child.html).join("");
|
|
946
|
+
const childPlain = children.map(child => child.plain).join("");
|
|
947
|
+
const wrappers = tapTapInlineWrappers(tag, element.attr("style") || "", options);
|
|
948
|
+
return { html: `${wrappers.open}${childHtml}${wrappers.close}`, plain: childPlain };
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
const tableGrid = table => {
|
|
952
|
+
const rows = [];
|
|
953
|
+
let activeRowspans = [];
|
|
954
|
+
for (const row of $(table).find("tr").toArray()) {
|
|
955
|
+
const cells = [];
|
|
956
|
+
const nextRowspans = [];
|
|
957
|
+
for (let column = 0; column < activeRowspans.length; column += 1) {
|
|
958
|
+
const active = activeRowspans[column];
|
|
959
|
+
if (!active) continue;
|
|
960
|
+
cells[column] = active.node;
|
|
961
|
+
if (active.remaining > 1) {
|
|
962
|
+
nextRowspans[column] = { node: active.node, remaining: active.remaining - 1 };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
let column = 0;
|
|
966
|
+
for (const cell of $(row).children("th,td").toArray()) {
|
|
967
|
+
while (cells[column]) column += 1;
|
|
968
|
+
const colspan = Math.max(1, Number.parseInt($(cell).attr("colspan"), 10) || 1);
|
|
969
|
+
const rowspan = Math.max(1, Number.parseInt($(cell).attr("rowspan"), 10) || 1);
|
|
970
|
+
for (let offset = 0; offset < colspan; offset += 1) {
|
|
971
|
+
cells[column + offset] = cell;
|
|
972
|
+
if (rowspan > 1) {
|
|
973
|
+
nextRowspans[column + offset] = { node: cell, remaining: rowspan - 1 };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
column += colspan;
|
|
977
|
+
}
|
|
978
|
+
rows.push(cells);
|
|
979
|
+
activeRowspans = nextRowspans;
|
|
980
|
+
}
|
|
981
|
+
return rows;
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
const tableCellContent = cell => {
|
|
985
|
+
const cellElement = $(cell);
|
|
986
|
+
const parts = [];
|
|
987
|
+
const plainParts = [];
|
|
988
|
+
const renderInlineChildren = node => {
|
|
989
|
+
const fragments = (node.children || []).map(inlineFragment);
|
|
990
|
+
return {
|
|
991
|
+
html: fragments.map(fragment => fragment.html).join(""),
|
|
992
|
+
plain: normalizedTapTapText(fragments.map(fragment => fragment.plain).join(""))
|
|
993
|
+
};
|
|
994
|
+
};
|
|
995
|
+
for (const child of cellElement.contents().toArray()) {
|
|
996
|
+
if (child.type === "text") {
|
|
997
|
+
const value = normalizedTapTapText(child.data);
|
|
998
|
+
if (value) {
|
|
999
|
+
parts.push(`<p>${escapeTapTapHtml(value)}</p>`);
|
|
1000
|
+
plainParts.push(value);
|
|
1001
|
+
}
|
|
1002
|
+
continue;
|
|
1003
|
+
}
|
|
1004
|
+
if (child.type !== "tag") continue;
|
|
1005
|
+
const tag = String(child.name || child.tagName || "").toLowerCase();
|
|
1006
|
+
const childElement = $(child);
|
|
1007
|
+
if (tag === "ol" || tag === "ul") {
|
|
1008
|
+
const listItems = childElement
|
|
1009
|
+
.children("li")
|
|
1010
|
+
.toArray()
|
|
1011
|
+
.map(item => renderInlineChildren(item))
|
|
1012
|
+
.filter(item => item.plain);
|
|
1013
|
+
if (listItems.length > 0) {
|
|
1014
|
+
const start = tag === "ol" && /^\d+$/.test(normalizeText(childElement.attr("start")))
|
|
1015
|
+
? ` start="${normalizeText(childElement.attr("start"))}"`
|
|
1016
|
+
: "";
|
|
1017
|
+
parts.push(`<${tag}${start}>${listItems.map(item => `<li>${item.html}</li>`).join("")}</${tag}>`);
|
|
1018
|
+
plainParts.push(listItems.map(item => item.plain).join("\n"));
|
|
1019
|
+
}
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
const fragment = renderInlineChildren(child);
|
|
1023
|
+
if (!fragment.plain) continue;
|
|
1024
|
+
const styleValue = childElement.attr("style") || "";
|
|
1025
|
+
const alignment =
|
|
1026
|
+
normalizeText(childElement.attr("align")) ||
|
|
1027
|
+
normalizeText(styleValue.match(/(?:^|;)\s*text-align\s*:\s*([^;]+)/i)?.[1]);
|
|
1028
|
+
const alignmentStyle = /^(?:left|center|right|justify)$/i.test(alignment)
|
|
1029
|
+
? ` style="text-align:${alignment.toLowerCase()}"`
|
|
1030
|
+
: "";
|
|
1031
|
+
const blockTag = /^h[1-3]$/.test(tag) ? tag : tag === "blockquote" ? options.quoteTag || "blockquote" : "p";
|
|
1032
|
+
parts.push(`<${blockTag}${alignmentStyle}>${fragment.html}</${blockTag}>`);
|
|
1033
|
+
plainParts.push(fragment.plain);
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
html: parts.join(""),
|
|
1037
|
+
plain: plainParts.filter(Boolean).join("\n")
|
|
1038
|
+
};
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
const appendStructuredTable = table => {
|
|
1042
|
+
const rows = tableGrid(table);
|
|
1043
|
+
if (rows.length === 0) return;
|
|
1044
|
+
const headers = rows[0].map((cell, index) => {
|
|
1045
|
+
const fragment = inlineFragment(cell);
|
|
1046
|
+
const plain = normalizedTapTapText(fragment.plain) || `第 ${index + 1} 列`;
|
|
1047
|
+
return { plain, html: normalizedTapTapText(fragment.plain) ? fragment.html : escapeTapTapHtml(plain) };
|
|
1048
|
+
});
|
|
1049
|
+
const dataRows = rows.length > 1 ? rows.slice(1) : rows;
|
|
1050
|
+
const tableImages = new Set();
|
|
1051
|
+
const tableTag = options.structuredTableTag || "blockquote";
|
|
1052
|
+
if (rows.length > 1) {
|
|
1053
|
+
items.push({
|
|
1054
|
+
type: "html",
|
|
1055
|
+
html: `<${tableTag}><p>${headers.map(header => header.html).join(" / ")}</p></${tableTag}>`,
|
|
1056
|
+
plain: headers.map(header => header.plain).join(" / "),
|
|
1057
|
+
block: "table-header",
|
|
1058
|
+
align: "",
|
|
1059
|
+
structuredTableHeader: true
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
for (const row of dataRows) {
|
|
1063
|
+
const fields = [];
|
|
1064
|
+
const seenCells = new Set();
|
|
1065
|
+
for (let column = 0; column < row.length; column += 1) {
|
|
1066
|
+
const cell = row[column];
|
|
1067
|
+
if (!cell || seenCells.has(cell)) continue;
|
|
1068
|
+
seenCells.add(cell);
|
|
1069
|
+
for (const image of $(cell).find("img").toArray()) {
|
|
1070
|
+
tableImages.add(image);
|
|
1071
|
+
}
|
|
1072
|
+
const content = tableCellContent(cell);
|
|
1073
|
+
if (!content.plain) continue;
|
|
1074
|
+
fields.push({
|
|
1075
|
+
label: headers[column]?.plain || `第 ${column + 1} 列`,
|
|
1076
|
+
...content
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
if (fields.length === 0) continue;
|
|
1080
|
+
const title = fields.shift();
|
|
1081
|
+
const rowHtml = [
|
|
1082
|
+
`<p><strong>${escapeTapTapHtml(title.label)}:</strong></p>${title.html}`,
|
|
1083
|
+
...fields.map(field => `<p><strong>${escapeTapTapHtml(field.label)}:</strong></p>${field.html}`)
|
|
1084
|
+
].join("");
|
|
1085
|
+
const rowPlain = [
|
|
1086
|
+
`${title.label}:${title.plain}`,
|
|
1087
|
+
...fields.map(field => `${field.label}:${field.plain}`)
|
|
1088
|
+
].join("\n");
|
|
1089
|
+
items.push({
|
|
1090
|
+
type: "html",
|
|
1091
|
+
html: `<${tableTag}>${rowHtml}</${tableTag}>`,
|
|
1092
|
+
plain: rowPlain,
|
|
1093
|
+
block: "table-row",
|
|
1094
|
+
align: "",
|
|
1095
|
+
structuredTable: true
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
for (const element of tableImages) {
|
|
1099
|
+
const source = normalizeText($(element).attr("src") || $(element).attr("data-src"));
|
|
1100
|
+
if (!source) continue;
|
|
1101
|
+
const image = {
|
|
1102
|
+
type: "image",
|
|
1103
|
+
source,
|
|
1104
|
+
alt: normalizeText($(element).attr("alt")).slice(0, 50)
|
|
1105
|
+
};
|
|
1106
|
+
items.push(image);
|
|
1107
|
+
images.push(image);
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
const walk = node => {
|
|
1112
|
+
if (node.type === "text") {
|
|
1113
|
+
appendText(node.data);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (node.type !== "tag") {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
const tag = String(node.name || node.tagName || "").toLowerCase();
|
|
1120
|
+
const element = $(node);
|
|
1121
|
+
if (/display\s*:\s*none/i.test(element.attr("style") || "")) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
if (tag === "img") {
|
|
1125
|
+
flushHtml();
|
|
1126
|
+
const source = normalizeText(element.attr("src") || element.attr("data-src"));
|
|
1127
|
+
if (source) {
|
|
1128
|
+
const image = {
|
|
1129
|
+
type: "image",
|
|
1130
|
+
source,
|
|
1131
|
+
alt: normalizeText(element.attr("alt")).slice(0, 50)
|
|
1132
|
+
};
|
|
1133
|
+
items.push(image);
|
|
1134
|
+
images.push(image);
|
|
1135
|
+
}
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (tag === "br") {
|
|
1139
|
+
html += "<br>";
|
|
1140
|
+
plain += "\n";
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (tag === "a") {
|
|
1144
|
+
const label = normalizedTapTapText(element.text());
|
|
1145
|
+
const href = safeTapTapHref(element.attr("href"));
|
|
1146
|
+
const linkedImages = element.find("img").toArray();
|
|
1147
|
+
if (!label && linkedImages.length === 0) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (label && href) {
|
|
1151
|
+
html += `<a href="${escapeTapTapHtml(href)}">${escapeTapTapHtml(label)}</a>`;
|
|
1152
|
+
plain += label;
|
|
1153
|
+
links.push({ href, label });
|
|
1154
|
+
} else if (label) {
|
|
1155
|
+
appendText(label);
|
|
1156
|
+
}
|
|
1157
|
+
if (linkedImages.length > 0) {
|
|
1158
|
+
flushHtml();
|
|
1159
|
+
for (const image of linkedImages) {
|
|
1160
|
+
walk(image);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (tag === "table" && options.structuredTables) {
|
|
1167
|
+
flushHtml();
|
|
1168
|
+
appendStructuredTable(node);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (tag === "blockquote" && options.preserveBlockStyles) {
|
|
1173
|
+
flushHtml();
|
|
1174
|
+
const content = tableCellContent(node);
|
|
1175
|
+
if (content.plain) {
|
|
1176
|
+
const styleValue = element.attr("style") || "";
|
|
1177
|
+
const alignment =
|
|
1178
|
+
normalizeText(element.attr("align")) ||
|
|
1179
|
+
normalizeText(styleValue.match(/(?:^|;)\s*text-align\s*:\s*([^;]+)/i)?.[1]);
|
|
1180
|
+
items.push({
|
|
1181
|
+
type: "html",
|
|
1182
|
+
html: `<${options.quoteTag || "blockquote"}${blockStyleAttribute(styleValue, alignment.toLowerCase())}>${content.html}</${options.quoteTag || "blockquote"}>`,
|
|
1183
|
+
plain: content.plain,
|
|
1184
|
+
block: "blockquote",
|
|
1185
|
+
align: alignment.toLowerCase()
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const isBlock = TAPTAP_BLOCK_TAGS.has(tag);
|
|
1192
|
+
if (isBlock) {
|
|
1193
|
+
flushHtml();
|
|
1194
|
+
if ((tag === "ul" || tag === "ol") && element.find("img").length === 0) {
|
|
1195
|
+
const listItems = element
|
|
1196
|
+
.children("li")
|
|
1197
|
+
.toArray()
|
|
1198
|
+
.map(item => inlineFragment(item));
|
|
1199
|
+
if (listItems.length > 0) {
|
|
1200
|
+
items.push({
|
|
1201
|
+
type: "html",
|
|
1202
|
+
html: `<${tag}>${listItems.map(item => `<li>${item.html}</li>`).join("")}</${tag}>`,
|
|
1203
|
+
plain: listItems.map(item => normalizedTapTapText(item.plain)).join("\n"),
|
|
1204
|
+
block: tag,
|
|
1205
|
+
align: blockContext.align
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
const previousContext = blockContext;
|
|
1211
|
+
const styleValue = element.attr("style") || "";
|
|
1212
|
+
const alignment =
|
|
1213
|
+
normalizeText(element.attr("align")) ||
|
|
1214
|
+
normalizeText(styleValue.match(/(?:^|;)\s*text-align\s*:\s*([^;]+)/i)?.[1]) ||
|
|
1215
|
+
previousContext.align;
|
|
1216
|
+
blockContext = {
|
|
1217
|
+
tag: /^h[1-3]$/.test(tag) ? tag : /^h[4-6]$/.test(tag) ? "h3" : tag === "blockquote" ? tag : "p",
|
|
1218
|
+
align: alignment.toLowerCase(),
|
|
1219
|
+
style: styleValue
|
|
1220
|
+
};
|
|
1221
|
+
for (const child of node.children || []) {
|
|
1222
|
+
walk(child);
|
|
1223
|
+
}
|
|
1224
|
+
flushHtml();
|
|
1225
|
+
blockContext = previousContext;
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const wrappers = tapTapInlineWrappers(tag, element.attr("style") || "", options);
|
|
1229
|
+
const canWrapInline = wrappers.open && element.find("img").length === 0;
|
|
1230
|
+
if (canWrapInline) {
|
|
1231
|
+
html += wrappers.open;
|
|
1232
|
+
}
|
|
1233
|
+
for (const child of node.children || []) {
|
|
1234
|
+
walk(child);
|
|
1235
|
+
}
|
|
1236
|
+
if (canWrapInline) {
|
|
1237
|
+
html += wrappers.close;
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
for (const node of $.root().contents().toArray()) {
|
|
1242
|
+
walk(node);
|
|
1243
|
+
}
|
|
1244
|
+
flushHtml();
|
|
1245
|
+
if (items.length === 0 && normalizeText(article.markdown)) {
|
|
1246
|
+
const fallback = normalizeText(article.markdown);
|
|
1247
|
+
items.push({ type: "html", html: `<p>${escapeTapTapHtml(fallback)}</p>`, plain: fallback });
|
|
1248
|
+
}
|
|
1249
|
+
return {
|
|
1250
|
+
items,
|
|
1251
|
+
images,
|
|
1252
|
+
links,
|
|
1253
|
+
plainText: items
|
|
1254
|
+
.filter(item => item.type === "html")
|
|
1255
|
+
.map(item => item.plain)
|
|
1256
|
+
.join("\n")
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export function buildBilibiliRichContent(article) {
|
|
1261
|
+
return buildPlatformRichContent(article, {
|
|
1262
|
+
preserveBackground: true,
|
|
1263
|
+
preserveBlockStyles: true,
|
|
1264
|
+
structuredTables: true
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export function buildHaoyouRichContent(article) {
|
|
1269
|
+
return buildPlatformRichContent(article, {
|
|
1270
|
+
preserveBackground: true,
|
|
1271
|
+
preserveBlockStyles: true,
|
|
1272
|
+
quoteTag: "div",
|
|
1273
|
+
structuredTableTag: "div",
|
|
1274
|
+
structuredTables: true
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function contentTypeForFile(fileName, fallback = "application/octet-stream") {
|
|
1279
|
+
const extension = path.extname(fileName).toLowerCase();
|
|
1280
|
+
return (
|
|
1281
|
+
{
|
|
1282
|
+
".jpg": "image/jpeg",
|
|
1283
|
+
".jpeg": "image/jpeg",
|
|
1284
|
+
".png": "image/png",
|
|
1285
|
+
".gif": "image/gif",
|
|
1286
|
+
".webp": "image/webp",
|
|
1287
|
+
".bmp": "image/bmp",
|
|
1288
|
+
".mp4": "video/mp4",
|
|
1289
|
+
".mov": "video/quicktime",
|
|
1290
|
+
".wmv": "video/x-ms-wmv",
|
|
1291
|
+
".avi": "video/x-msvideo",
|
|
1292
|
+
".mkv": "video/x-matroska"
|
|
1293
|
+
}[extension] || fallback
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function downloadBuffer(urlValue, redirects = 5) {
|
|
1298
|
+
return new Promise((resolve, reject) => {
|
|
1299
|
+
const url = new URL(urlValue);
|
|
1300
|
+
const client = url.protocol === "http:" ? http : https;
|
|
1301
|
+
const request = client.get(
|
|
1302
|
+
url,
|
|
1303
|
+
{ headers: { "User-Agent": "qcplay-cli" } },
|
|
1304
|
+
response => {
|
|
1305
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
1306
|
+
response.resume();
|
|
1307
|
+
if (redirects <= 0) {
|
|
1308
|
+
reject(new Error(`媒体下载重定向次数过多: ${urlValue}`));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
resolve(downloadBuffer(new URL(response.headers.location, url).toString(), redirects - 1));
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
|
|
1315
|
+
response.resume();
|
|
1316
|
+
reject(new Error(`媒体下载失败 (${response.statusCode}): ${urlValue}`));
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const chunks = [];
|
|
1320
|
+
let size = 0;
|
|
1321
|
+
response.on("data", chunk => {
|
|
1322
|
+
size += chunk.length;
|
|
1323
|
+
if (size > 30 * 1024 * 1024) {
|
|
1324
|
+
request.destroy(new Error(`图片超过 30MB: ${urlValue}`));
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
chunks.push(chunk);
|
|
1328
|
+
});
|
|
1329
|
+
response.once("end", () =>
|
|
1330
|
+
resolve({
|
|
1331
|
+
buffer: Buffer.concat(chunks),
|
|
1332
|
+
contentType: normalizeText(response.headers["content-type"]).split(";")[0]
|
|
1333
|
+
})
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
);
|
|
1337
|
+
request.once("error", reject);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
async function imageUploadPayload(source, articleFile, index, platform = "平台") {
|
|
1342
|
+
const dataImage = source.match(/^data:([^;,]+);base64,([a-z0-9+/=\s]+)$/i);
|
|
1343
|
+
if (dataImage) {
|
|
1344
|
+
return {
|
|
1345
|
+
name: `image-${index + 1}.${dataImage[1].split("/")[1] || "png"}`,
|
|
1346
|
+
mimeType: dataImage[1],
|
|
1347
|
+
buffer: Buffer.from(dataImage[2].replace(/\s+/g, ""), "base64")
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
if (source.startsWith("//")) {
|
|
1351
|
+
source = `https:${source}`;
|
|
1352
|
+
}
|
|
1353
|
+
if (/^https?:\/\//i.test(source)) {
|
|
1354
|
+
const downloaded = await downloadBuffer(source);
|
|
1355
|
+
const url = new URL(source);
|
|
1356
|
+
const baseName = path.basename(decodeURIComponent(url.pathname)) || `image-${index + 1}.png`;
|
|
1357
|
+
return {
|
|
1358
|
+
name: baseName.replace(/[^a-zA-Z0-9._-]/g, "-") || `image-${index + 1}.png`,
|
|
1359
|
+
mimeType: downloaded.contentType || contentTypeForFile(baseName, "image/png"),
|
|
1360
|
+
buffer: downloaded.buffer
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
const absolutePath = path.resolve(articleFile ? path.dirname(articleFile) : process.cwd(), source);
|
|
1364
|
+
const buffer = await fs.promises.readFile(absolutePath).catch(error => {
|
|
1365
|
+
throw new Error(`${platform} 图片文件无法读取: ${absolutePath} (${error.message})`);
|
|
1366
|
+
});
|
|
1367
|
+
return {
|
|
1368
|
+
name: path.basename(absolutePath),
|
|
1369
|
+
mimeType: contentTypeForFile(absolutePath, "image/png"),
|
|
1370
|
+
buffer
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async function pasteRichHtml(bodyInput, item) {
|
|
1375
|
+
await bodyInput.evaluate(
|
|
1376
|
+
(element, payload) => {
|
|
1377
|
+
element.focus();
|
|
1378
|
+
const clipboard = new DataTransfer();
|
|
1379
|
+
clipboard.setData("text/html", payload.html);
|
|
1380
|
+
clipboard.setData("text/plain", payload.plain);
|
|
1381
|
+
element.dispatchEvent(
|
|
1382
|
+
new ClipboardEvent("paste", {
|
|
1383
|
+
bubbles: true,
|
|
1384
|
+
cancelable: true,
|
|
1385
|
+
clipboardData: clipboard
|
|
1386
|
+
})
|
|
1387
|
+
);
|
|
1388
|
+
},
|
|
1389
|
+
item
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function waitForTapTapInlineImage(bodyInput, previousCount, source) {
|
|
1394
|
+
const deadline = Date.now() + 90000;
|
|
1395
|
+
const startedAt = Date.now();
|
|
1396
|
+
let stableChecks = 0;
|
|
1397
|
+
do {
|
|
1398
|
+
const count = await bodyInput.locator(".tap-editor-image").count();
|
|
1399
|
+
const failed = await bodyInput.locator(".tap-editor-image__retry").count();
|
|
1400
|
+
const pending = await pendingUploadCount(bodyInput);
|
|
1401
|
+
if (failed > 0) {
|
|
1402
|
+
throw new Error(`TapTap 正文图片上传失败: ${source}`);
|
|
1403
|
+
}
|
|
1404
|
+
if (count > previousCount && pending === 0 && Date.now() - startedAt >= 1500) {
|
|
1405
|
+
stableChecks += 1;
|
|
1406
|
+
if (stableChecks >= 3) {
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
} else {
|
|
1410
|
+
stableChecks = 0;
|
|
1411
|
+
}
|
|
1412
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1413
|
+
} while (Date.now() < deadline);
|
|
1414
|
+
throw new Error(`TapTap 正文图片上传超时: ${source}`);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText) {
|
|
1418
|
+
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
1419
|
+
await fillLocator(bodyInput, fallbackText);
|
|
1420
|
+
await verifyFilledValue(bodyInput, fallbackText, "正文");
|
|
1421
|
+
return { rich: false, images: 0, links: 0 };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
const content = buildTapTapRichContent(article);
|
|
1425
|
+
const inlineImages = settings.type === "topic";
|
|
1426
|
+
const toolbarImageInput = inlineImages
|
|
1427
|
+
? await firstExisting(page, ['.tap-editor-toolbar input[type="file"][accept*="image"]'])
|
|
1428
|
+
: null;
|
|
1429
|
+
if (inlineImages && content.images.length > 0 && (!toolbarImageInput || typeof toolbarImageInput.setInputFiles !== "function")) {
|
|
1430
|
+
throw new Error("TapTap 长帖编辑器中未找到正文图片上传控件");
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
await bodyInput.fill("");
|
|
1434
|
+
await bodyInput.click();
|
|
1435
|
+
const markerPrefix = `QCPLAY_IMAGE_${Date.now()}_`;
|
|
1436
|
+
const imageMarkers = [];
|
|
1437
|
+
const htmlParts = [];
|
|
1438
|
+
const plainParts = [];
|
|
1439
|
+
for (let index = 0; index < content.items.length; index += 1) {
|
|
1440
|
+
const item = content.items[index];
|
|
1441
|
+
if (item.type === "html") {
|
|
1442
|
+
htmlParts.push(item.html);
|
|
1443
|
+
plainParts.push(item.plain);
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
if (inlineImages) {
|
|
1447
|
+
const marker = `${markerPrefix}${imageMarkers.length}`;
|
|
1448
|
+
htmlParts.push(`<p>${marker}</p>`);
|
|
1449
|
+
plainParts.push(marker);
|
|
1450
|
+
imageMarkers.push({ marker, item, index });
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (htmlParts.length > 0) {
|
|
1454
|
+
await pasteRichHtml(bodyInput, {
|
|
1455
|
+
html: htmlParts.join("<br><br>"),
|
|
1456
|
+
plain: plainParts.join("\n")
|
|
1457
|
+
});
|
|
1458
|
+
await page.waitForTimeout(100);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const payloadCache = new Map();
|
|
1462
|
+
let uploadedImages = 0;
|
|
1463
|
+
for (const { marker, item, index } of imageMarkers) {
|
|
1464
|
+
const markerText = bodyInput.locator('[data-slate-string="true"]').filter({ hasText: marker }).first();
|
|
1465
|
+
if ((await markerText.count()) === 0) {
|
|
1466
|
+
throw new Error(`TapTap 正文图片占位位置丢失: ${item.source}`);
|
|
1467
|
+
}
|
|
1468
|
+
const selected = await markerText.evaluate((element, markerValue) => {
|
|
1469
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
1470
|
+
let textNode = walker.nextNode();
|
|
1471
|
+
while (textNode) {
|
|
1472
|
+
const start = textNode.data.indexOf(markerValue);
|
|
1473
|
+
if (start >= 0) {
|
|
1474
|
+
const range = document.createRange();
|
|
1475
|
+
range.setStart(textNode, start);
|
|
1476
|
+
range.setEnd(textNode, start + markerValue.length);
|
|
1477
|
+
const selection = window.getSelection();
|
|
1478
|
+
selection.removeAllRanges();
|
|
1479
|
+
selection.addRange(range);
|
|
1480
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
textNode = walker.nextNode();
|
|
1484
|
+
}
|
|
1485
|
+
return false;
|
|
1486
|
+
}, marker);
|
|
1487
|
+
if (!selected) {
|
|
1488
|
+
throw new Error(`TapTap 正文图片占位文本无法选中: ${item.source}`);
|
|
1489
|
+
}
|
|
1490
|
+
await page.waitForTimeout(50);
|
|
1491
|
+
await page.keyboard.press("Backspace");
|
|
1492
|
+
await page.waitForTimeout(50);
|
|
1493
|
+
let payload = payloadCache.get(item.source);
|
|
1494
|
+
if (!payload) {
|
|
1495
|
+
payload = await imageUploadPayload(item.source, article.articleFile, index, "TapTap");
|
|
1496
|
+
payloadCache.set(item.source, payload);
|
|
1497
|
+
}
|
|
1498
|
+
const previousCount = await bodyInput.locator(".tap-editor-image").count();
|
|
1499
|
+
await toolbarImageInput.setInputFiles([payload]);
|
|
1500
|
+
await waitForTapTapInlineImage(bodyInput, previousCount, item.source);
|
|
1501
|
+
uploadedImages += 1;
|
|
1502
|
+
if (item.alt) {
|
|
1503
|
+
const captions = bodyInput.locator("textarea.tap-editor-image__text");
|
|
1504
|
+
const captionCount = await captions.count();
|
|
1505
|
+
if (captionCount > 0) {
|
|
1506
|
+
await captions.nth(captionCount - 1).fill(item.alt);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
const state = await bodyInput.evaluate(element => ({
|
|
1512
|
+
text: element.innerText,
|
|
1513
|
+
images: element.querySelectorAll(".tap-editor-image").length,
|
|
1514
|
+
failedImages: element.querySelectorAll(".tap-editor-image__retry").length,
|
|
1515
|
+
links: [...element.querySelectorAll("a.tap-editor-link[href]")].map(link => ({
|
|
1516
|
+
href: link.href,
|
|
1517
|
+
label: link.innerText
|
|
1518
|
+
}))
|
|
1519
|
+
}));
|
|
1520
|
+
if (state.failedImages > 0 || (inlineImages && state.images < content.images.length)) {
|
|
1521
|
+
throw new Error(`TapTap 正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
|
|
1522
|
+
}
|
|
1523
|
+
const actualText = normalizedTapTapText(state.text);
|
|
1524
|
+
if (actualText.includes(markerPrefix)) {
|
|
1525
|
+
throw new Error("TapTap 正文图片占位符未正确替换");
|
|
1526
|
+
}
|
|
1527
|
+
for (const item of content.items) {
|
|
1528
|
+
if (item.type === "html" && item.plain && !actualText.includes(normalizedTapTapText(item.plain))) {
|
|
1529
|
+
throw new Error("TapTap 正文文字未完整写入编辑器");
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
|
|
1533
|
+
for (const link of content.links) {
|
|
1534
|
+
if (!actualLinks.has(link.href)) {
|
|
1535
|
+
throw new Error(`TapTap 正文超链接未正确写入: ${link.href}`);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
return { rich: true, images: uploadedImages, links: content.links.length, plainText: content.plainText };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async function uploadBilibiliInlineImage(page, spec, payload) {
|
|
1542
|
+
let input = await firstExisting(page, spec.articleImageInputs);
|
|
1543
|
+
if (input && typeof input.setInputFiles === "function") {
|
|
1544
|
+
await input.setInputFiles([payload]);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
const imageButton = await firstVisible(page, spec.articleImageButtons);
|
|
1549
|
+
if (imageButton) {
|
|
1550
|
+
const fileChooserPromise =
|
|
1551
|
+
typeof page.waitForEvent === "function"
|
|
1552
|
+
? page.waitForEvent("filechooser", { timeout: 5000 }).catch(() => null)
|
|
1553
|
+
: Promise.resolve(null);
|
|
1554
|
+
await imageButton.click();
|
|
1555
|
+
const fileChooser = await fileChooserPromise;
|
|
1556
|
+
if (fileChooser) {
|
|
1557
|
+
await fileChooser.setFiles([payload]);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
input = await firstExisting(page, [
|
|
1563
|
+
'[role="dialog"] input[type="file"][accept*="image"]',
|
|
1564
|
+
'.bcc-dialog input[type="file"][accept*="image"]',
|
|
1565
|
+
'.image-upload input[type="file"][accept*="image"]',
|
|
1566
|
+
'input[type="file"][accept*="image"]'
|
|
1567
|
+
]);
|
|
1568
|
+
if (!input || typeof input.setInputFiles !== "function") {
|
|
1569
|
+
throw new Error("B站专栏编辑器中未找到正文图片上传控件");
|
|
1570
|
+
}
|
|
1571
|
+
await input.setInputFiles([payload]);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
async function waitForBilibiliInlineImage(bodyInput, previousCount, source) {
|
|
1575
|
+
const deadline = Date.now() + 120000;
|
|
1576
|
+
const startedAt = Date.now();
|
|
1577
|
+
let stableChecks = 0;
|
|
1578
|
+
do {
|
|
1579
|
+
const count = await bodyInput.locator("img").count();
|
|
1580
|
+
const failed = await bodyInput
|
|
1581
|
+
.locator('.upload-fail, .image-upload-error, [class*="upload-error"], [class*="retry"]')
|
|
1582
|
+
.count();
|
|
1583
|
+
const pending = await pendingUploadCount(bodyInput);
|
|
1584
|
+
if (failed > 0) {
|
|
1585
|
+
throw new Error(`B站正文图片上传失败: ${source}`);
|
|
1586
|
+
}
|
|
1587
|
+
if (count > previousCount && pending === 0 && Date.now() - startedAt >= 1200) {
|
|
1588
|
+
stableChecks += 1;
|
|
1589
|
+
if (stableChecks >= 3) {
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
} else {
|
|
1593
|
+
stableChecks = 0;
|
|
1594
|
+
}
|
|
1595
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
1596
|
+
} while (Date.now() < deadline);
|
|
1597
|
+
throw new Error(`B站正文图片上传超时: ${source}`);
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
export async function insertBilibiliRichContent(page, bodyInput, article, spec, fallbackText) {
|
|
1601
|
+
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
1602
|
+
await fillLocator(bodyInput, fallbackText);
|
|
1603
|
+
await verifyFilledValue(bodyInput, fallbackText, "正文", "B站");
|
|
1604
|
+
return { rich: false, images: 0, links: 0 };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const content = buildBilibiliRichContent(article);
|
|
1608
|
+
await bodyInput.fill("");
|
|
1609
|
+
await bodyInput.click();
|
|
1610
|
+
const markerPrefix = `QCPLAY_BILIBILI_IMAGE_${Date.now()}_`;
|
|
1611
|
+
const imageMarkers = [];
|
|
1612
|
+
const htmlParts = [];
|
|
1613
|
+
const plainParts = [];
|
|
1614
|
+
for (let index = 0; index < content.items.length; index += 1) {
|
|
1615
|
+
const item = content.items[index];
|
|
1616
|
+
if (item.type === "html") {
|
|
1617
|
+
htmlParts.push(item.html);
|
|
1618
|
+
plainParts.push(item.plain);
|
|
1619
|
+
continue;
|
|
1620
|
+
}
|
|
1621
|
+
const marker = `${markerPrefix}${imageMarkers.length}`;
|
|
1622
|
+
htmlParts.push(`<p>${marker}</p>`);
|
|
1623
|
+
plainParts.push(marker);
|
|
1624
|
+
imageMarkers.push({ marker, item, index });
|
|
1625
|
+
}
|
|
1626
|
+
if (htmlParts.length > 0) {
|
|
1627
|
+
await pasteRichHtml(bodyInput, {
|
|
1628
|
+
html: htmlParts.join(""),
|
|
1629
|
+
plain: plainParts.join("\n")
|
|
1630
|
+
});
|
|
1631
|
+
await page.waitForTimeout(100);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const payloadCache = new Map();
|
|
1635
|
+
for (const { marker, item, index } of imageMarkers) {
|
|
1636
|
+
const selected = await bodyInput.evaluate((element, markerValue) => {
|
|
1637
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
1638
|
+
let textNode = walker.nextNode();
|
|
1639
|
+
while (textNode) {
|
|
1640
|
+
const start = textNode.data.indexOf(markerValue);
|
|
1641
|
+
if (start >= 0) {
|
|
1642
|
+
const range = document.createRange();
|
|
1643
|
+
range.setStart(textNode, start);
|
|
1644
|
+
range.setEnd(textNode, start + markerValue.length);
|
|
1645
|
+
const selection = window.getSelection();
|
|
1646
|
+
selection.removeAllRanges();
|
|
1647
|
+
selection.addRange(range);
|
|
1648
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
1649
|
+
return true;
|
|
1650
|
+
}
|
|
1651
|
+
textNode = walker.nextNode();
|
|
1652
|
+
}
|
|
1653
|
+
return false;
|
|
1654
|
+
}, marker);
|
|
1655
|
+
if (!selected) {
|
|
1656
|
+
throw new Error(`B站正文图片占位位置丢失: ${item.source}`);
|
|
1657
|
+
}
|
|
1658
|
+
await page.keyboard.press("Backspace");
|
|
1659
|
+
let payload = payloadCache.get(item.source);
|
|
1660
|
+
if (!payload) {
|
|
1661
|
+
payload = await imageUploadPayload(item.source, article.articleFile, index, "B站");
|
|
1662
|
+
payloadCache.set(item.source, payload);
|
|
1663
|
+
}
|
|
1664
|
+
const previousCount = await bodyInput.locator("img").count();
|
|
1665
|
+
await uploadBilibiliInlineImage(page, spec, payload);
|
|
1666
|
+
await waitForBilibiliInlineImage(bodyInput, previousCount, item.source);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
let state = null;
|
|
1670
|
+
let previousTextLength = -1;
|
|
1671
|
+
let stableChecks = 0;
|
|
1672
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
1673
|
+
state = await bodyInput.evaluate(element => ({
|
|
1674
|
+
text: element.innerText,
|
|
1675
|
+
images: element.querySelectorAll("img").length,
|
|
1676
|
+
failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
|
|
1677
|
+
links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
|
|
1678
|
+
}));
|
|
1679
|
+
const currentLength = normalizeText(state.text).length;
|
|
1680
|
+
stableChecks = currentLength === previousTextLength ? stableChecks + 1 : 0;
|
|
1681
|
+
previousTextLength = currentLength;
|
|
1682
|
+
if (stableChecks >= 3) break;
|
|
1683
|
+
await page.waitForTimeout(250);
|
|
1684
|
+
}
|
|
1685
|
+
if (state.failedImages > 0 || state.images < content.images.length) {
|
|
1686
|
+
throw new Error(`B站正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
|
|
1687
|
+
}
|
|
1688
|
+
const actualText = normalizedStructuredEditorText(state.text);
|
|
1689
|
+
if (actualText.includes(markerPrefix)) {
|
|
1690
|
+
throw new Error("B站正文图片占位符未正确替换");
|
|
1691
|
+
}
|
|
1692
|
+
for (const item of content.items) {
|
|
1693
|
+
if (item.type === "html" && item.plain && !actualText.includes(normalizedStructuredEditorText(item.plain))) {
|
|
1694
|
+
const missing = normalizedStructuredEditorText(item.plain).slice(0, 80);
|
|
1695
|
+
throw new Error(
|
|
1696
|
+
`B站正文文字未完整写入编辑器,首个缺失片段“${missing}”(预期 ${content.plainText.length} 字,实际 ${state.text.length} 字)`
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
|
|
1701
|
+
for (const link of content.links) {
|
|
1702
|
+
if (!actualLinks.has(link.href)) {
|
|
1703
|
+
throw new Error(`B站正文超链接未正确写入: ${link.href}`);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
return { rich: true, images: content.images.length, links: content.links.length, plainText: content.plainText };
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function haoyouImageUploadPayload(payload, index) {
|
|
1710
|
+
if (payload.buffer.length > 12 * 1024 * 1024) {
|
|
1711
|
+
throw new Error(`好游快爆正文第 ${index + 1} 张图片超过 12MB,无法上传`);
|
|
1712
|
+
}
|
|
1713
|
+
const supportedExtensions = new Set([".jpg", ".jpeg", ".png", ".gif"]);
|
|
1714
|
+
const extensionByMime = new Map([
|
|
1715
|
+
["image/jpeg", ".jpg"],
|
|
1716
|
+
["image/png", ".png"],
|
|
1717
|
+
["image/gif", ".gif"]
|
|
1718
|
+
]);
|
|
1719
|
+
const sourceExtension = path.extname(payload.name).toLowerCase();
|
|
1720
|
+
const nativeExtension = supportedExtensions.has(sourceExtension)
|
|
1721
|
+
? sourceExtension
|
|
1722
|
+
: extensionByMime.get(normalizeText(payload.mimeType).toLowerCase());
|
|
1723
|
+
const reportedMimeType = normalizeText(payload.mimeType).toLowerCase();
|
|
1724
|
+
const inferredMimeType = contentTypeForFile(payload.name, "");
|
|
1725
|
+
const sourceMimeType = reportedMimeType.startsWith("image/") ? reportedMimeType : inferredMimeType;
|
|
1726
|
+
if (!nativeExtension && !sourceMimeType.startsWith("image/")) {
|
|
1727
|
+
throw new Error(`好游快爆正文第 ${index + 1} 张图片格式无法识别`);
|
|
1728
|
+
}
|
|
1729
|
+
const stem = path.basename(payload.name, path.extname(payload.name)).replace(/[^a-zA-Z0-9._-]/g, "-") || `image-${index + 1}`;
|
|
1730
|
+
const extension = nativeExtension || ".png";
|
|
1731
|
+
return {
|
|
1732
|
+
name: `${stem}${extension}`,
|
|
1733
|
+
mimeType: nativeExtension
|
|
1734
|
+
? extension === ".jpg" || extension === ".jpeg"
|
|
1735
|
+
? "image/jpeg"
|
|
1736
|
+
: `image/${extension.slice(1)}`
|
|
1737
|
+
: sourceMimeType,
|
|
1738
|
+
convertToPng: !nativeExtension,
|
|
1739
|
+
base64: payload.buffer.toString("base64")
|
|
1740
|
+
};
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
async function waitForHaoyouInlineImage(page, bodyInput, imageId, source) {
|
|
1744
|
+
const deadline = Date.now() + 90000;
|
|
1745
|
+
let placeholderSeen = false;
|
|
1746
|
+
do {
|
|
1747
|
+
const state = await bodyInput.evaluate((element, id) => {
|
|
1748
|
+
const image = document.getElementById(id);
|
|
1749
|
+
if (!image || !element.contains(image)) {
|
|
1750
|
+
return { exists: false, complete: false, failed: false };
|
|
1751
|
+
}
|
|
1752
|
+
return {
|
|
1753
|
+
exists: true,
|
|
1754
|
+
complete: Boolean(image.querySelector("img[src]")),
|
|
1755
|
+
failed: Boolean(image.querySelector(".fail"))
|
|
1756
|
+
};
|
|
1757
|
+
}, imageId);
|
|
1758
|
+
if (state.exists) {
|
|
1759
|
+
placeholderSeen = true;
|
|
1760
|
+
}
|
|
1761
|
+
if (state.failed) {
|
|
1762
|
+
throw new Error(`好游快爆正文图片上传失败: ${source}`);
|
|
1763
|
+
}
|
|
1764
|
+
if (state.complete) {
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
if (placeholderSeen && !state.exists) {
|
|
1768
|
+
throw new Error(`好游快爆正文图片上传失败,图片占位卡片已被移除: ${source}`);
|
|
1769
|
+
}
|
|
1770
|
+
await page.waitForTimeout(300);
|
|
1771
|
+
} while (Date.now() < deadline);
|
|
1772
|
+
throw new Error(`好游快爆正文图片上传超时: ${source}`);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText) {
|
|
1776
|
+
const content = buildHaoyouRichContent(article);
|
|
1777
|
+
if (typeof bodyInput.evaluate !== "function") {
|
|
1778
|
+
await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
|
|
1779
|
+
return { rich: false, images: 0, links: 0, plainText: fallbackText };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
const markerPrefix = `QCPLAY_HAOYOU_IMAGE_${Date.now()}_`;
|
|
1783
|
+
const imageMarkers = [];
|
|
1784
|
+
const htmlParts = [];
|
|
1785
|
+
for (let index = 0; index < content.items.length; index += 1) {
|
|
1786
|
+
const item = content.items[index];
|
|
1787
|
+
if (item.type === "html") {
|
|
1788
|
+
htmlParts.push(item.html);
|
|
1789
|
+
} else {
|
|
1790
|
+
const imageIndex = imageMarkers.length;
|
|
1791
|
+
const marker = `${markerPrefix}${imageIndex}`;
|
|
1792
|
+
htmlParts.push(`<p>${marker}</p>`);
|
|
1793
|
+
imageMarkers.push({ marker, item, index: imageIndex });
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
const html = htmlParts.join("") || `<p>${escapeTapTapHtml(fallbackText)}</p>`;
|
|
1797
|
+
const initialized = await bodyInput.evaluate((element, richHtml) => {
|
|
1798
|
+
const quill = element.parentElement?.__quill;
|
|
1799
|
+
if (!quill?.clipboard?.dangerouslyPasteHTML) {
|
|
1800
|
+
return false;
|
|
1801
|
+
}
|
|
1802
|
+
quill.setText("", "silent");
|
|
1803
|
+
quill.clipboard.dangerouslyPasteHTML(0, richHtml, "api");
|
|
1804
|
+
quill.setSelection(quill.getLength(), 0, "silent");
|
|
1805
|
+
quill.history?.clear?.();
|
|
1806
|
+
return true;
|
|
1807
|
+
}, html);
|
|
1808
|
+
if (!initialized) {
|
|
1809
|
+
if (imageMarkers.length > 0) {
|
|
1810
|
+
throw new Error("好游快爆富文本编辑器接口不可用,无法安全上传正文图片");
|
|
1811
|
+
}
|
|
1812
|
+
await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
|
|
1813
|
+
return { rich: false, images: 0, links: 0, plainText: fallbackText };
|
|
1814
|
+
}
|
|
1815
|
+
await page.waitForTimeout(200);
|
|
1816
|
+
|
|
1817
|
+
const payloadCache = new Map();
|
|
1818
|
+
for (const { marker, item, index } of imageMarkers) {
|
|
1819
|
+
let upload = payloadCache.get(item.source);
|
|
1820
|
+
if (!upload) {
|
|
1821
|
+
upload = haoyouImageUploadPayload(
|
|
1822
|
+
await imageUploadPayload(item.source, article.articleFile, index, "好游快爆"),
|
|
1823
|
+
index
|
|
1824
|
+
);
|
|
1825
|
+
payloadCache.set(item.source, upload);
|
|
1826
|
+
}
|
|
1827
|
+
const imageId = await bodyInput
|
|
1828
|
+
.evaluate(
|
|
1829
|
+
async (element, payload) => {
|
|
1830
|
+
const quill = element.parentElement?.__quill;
|
|
1831
|
+
const editor = globalThis.RE;
|
|
1832
|
+
if (!quill || !editor?.insertPicDelta || !editor?.cb?.picUpload || !editor?.tool?.idRandom) {
|
|
1833
|
+
return "";
|
|
1834
|
+
}
|
|
1835
|
+
const markerIndex = quill.getText().indexOf(payload.marker);
|
|
1836
|
+
if (markerIndex < 0) {
|
|
1837
|
+
return "";
|
|
1838
|
+
}
|
|
1839
|
+
quill.deleteText(markerIndex, payload.marker.length, "api");
|
|
1840
|
+
quill.setSelection(markerIndex, 0, "silent");
|
|
1841
|
+
const binary = atob(payload.base64);
|
|
1842
|
+
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
1843
|
+
let imageBlob = new Blob([bytes], { type: payload.mimeType });
|
|
1844
|
+
if (payload.convertToPng) {
|
|
1845
|
+
const bitmap = await createImageBitmap(imageBlob);
|
|
1846
|
+
const canvas = document.createElement("canvas");
|
|
1847
|
+
canvas.width = bitmap.width;
|
|
1848
|
+
canvas.height = bitmap.height;
|
|
1849
|
+
const context = canvas.getContext("2d");
|
|
1850
|
+
if (!context) {
|
|
1851
|
+
throw new Error("图片转换画布不可用");
|
|
1852
|
+
}
|
|
1853
|
+
context.drawImage(bitmap, 0, 0);
|
|
1854
|
+
bitmap.close?.();
|
|
1855
|
+
imageBlob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
|
1856
|
+
if (!imageBlob) {
|
|
1857
|
+
throw new Error("图片转换为 PNG 失败");
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
if (imageBlob.size > 12 * 1024 * 1024) {
|
|
1861
|
+
throw new Error("图片处理后超过 12MB");
|
|
1862
|
+
}
|
|
1863
|
+
const file = new File([imageBlob], payload.name, {
|
|
1864
|
+
type: payload.convertToPng ? "image/png" : payload.mimeType
|
|
1865
|
+
});
|
|
1866
|
+
const id = editor.tool.idRandom();
|
|
1867
|
+
editor.insertPicDelta(id);
|
|
1868
|
+
editor.cb.picUpload(id, file);
|
|
1869
|
+
return id;
|
|
1870
|
+
},
|
|
1871
|
+
{ ...upload, marker }
|
|
1872
|
+
)
|
|
1873
|
+
.catch(error => {
|
|
1874
|
+
throw new Error(`好游快爆正文图片转换或上传初始化失败: ${item.source} (${error.message})`);
|
|
1875
|
+
});
|
|
1876
|
+
if (!imageId) {
|
|
1877
|
+
throw new Error(`好游快爆正文图片占位位置丢失或图片上传接口不可用: ${item.source}`);
|
|
1878
|
+
}
|
|
1879
|
+
await waitForHaoyouInlineImage(page, bodyInput, imageId, item.source);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
let state = null;
|
|
1883
|
+
let previousTextLength = -1;
|
|
1884
|
+
let stableChecks = 0;
|
|
1885
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
1886
|
+
state = await bodyInput.evaluate(element => ({
|
|
1887
|
+
text: element.innerText,
|
|
1888
|
+
images: element.querySelectorAll('.panel-pic img[src]').length,
|
|
1889
|
+
failedImages: element.querySelectorAll('.panel-pic .fail').length,
|
|
1890
|
+
pendingImages: element.querySelectorAll('.panel-pic .ing').length,
|
|
1891
|
+
links: element.querySelectorAll('a[href]').length,
|
|
1892
|
+
headings: element.querySelectorAll('h1,h2,h3').length,
|
|
1893
|
+
formatted: element.querySelectorAll('strong,b,em,i,ul,ol,blockquote').length
|
|
1894
|
+
}));
|
|
1895
|
+
const currentLength = normalizeText(state.text).length;
|
|
1896
|
+
stableChecks = currentLength === previousTextLength ? stableChecks + 1 : 0;
|
|
1897
|
+
previousTextLength = currentLength;
|
|
1898
|
+
if (stableChecks >= 3) break;
|
|
1899
|
+
await page.waitForTimeout(250);
|
|
1900
|
+
}
|
|
1901
|
+
if (state.failedImages > 0 || state.pendingImages > 0 || state.images < content.images.length) {
|
|
1902
|
+
throw new Error(`好游快爆正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
|
|
1903
|
+
}
|
|
1904
|
+
const actualText = normalizedStructuredEditorText(state.text);
|
|
1905
|
+
if (actualText.includes(markerPrefix)) {
|
|
1906
|
+
throw new Error("好游快爆正文图片占位符未正确替换");
|
|
1907
|
+
}
|
|
1908
|
+
for (const item of content.items) {
|
|
1909
|
+
if (item.type === "html" && item.plain && !actualText.includes(normalizedStructuredEditorText(item.plain))) {
|
|
1910
|
+
const missing = normalizedStructuredEditorText(item.plain).slice(0, 80);
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
`好游快爆正文文字未完整写入富文本编辑器,首个缺失片段“${missing}”(预期 ${content.plainText.length} 字,实际 ${state.text.length} 字)`
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return {
|
|
1917
|
+
rich: true,
|
|
1918
|
+
images: state.images,
|
|
1919
|
+
links: state.links,
|
|
1920
|
+
headings: state.headings,
|
|
1921
|
+
formatted: state.formatted,
|
|
1922
|
+
plainText: content.plainText
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function safeProfileName(entry) {
|
|
1927
|
+
return `${entry.platformKey}-${entry.project}-${entry.region}`
|
|
1928
|
+
.normalize("NFKD")
|
|
1929
|
+
.replace(/[^a-zA-Z0-9\u4e00-\u9fff_-]+/g, "-")
|
|
1930
|
+
.slice(0, 80);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
function isNavigationContextError(error) {
|
|
1934
|
+
const message = String(error?.message || error);
|
|
1935
|
+
return /execution context was destroyed|cannot find context with specified id|frame was detached|navigation.*interrupted/i.test(
|
|
1936
|
+
message
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
async function afterNavigation(page) {
|
|
1941
|
+
await page.waitForLoadState("domcontentloaded", { timeout: 5000 }).catch(() => {});
|
|
1942
|
+
await page.waitForTimeout(250).catch(() => {});
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
async function retryAcrossNavigation(page, action, attempts = 8) {
|
|
1946
|
+
let lastError;
|
|
1947
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1948
|
+
try {
|
|
1949
|
+
return await action();
|
|
1950
|
+
} catch (error) {
|
|
1951
|
+
if (!isNavigationContextError(error)) {
|
|
1952
|
+
throw error;
|
|
1953
|
+
}
|
|
1954
|
+
lastError = error;
|
|
1955
|
+
if (attempt < attempts) {
|
|
1956
|
+
await afterNavigation(page);
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
throw lastError;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function locatorScopes(page) {
|
|
1964
|
+
if (typeof page.frames !== "function") {
|
|
1965
|
+
return [page];
|
|
1966
|
+
}
|
|
1967
|
+
const frames = page.frames();
|
|
1968
|
+
return [page, ...frames.filter(frame => frame !== page.mainFrame?.())];
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
async function firstVisible(page, selectors) {
|
|
1972
|
+
return retryAcrossNavigation(page, async () => {
|
|
1973
|
+
for (const scope of locatorScopes(page)) {
|
|
1974
|
+
for (const selector of selectors || []) {
|
|
1975
|
+
const locator = scope.locator(selector).first();
|
|
1976
|
+
if ((await locator.count()) > 0 && (await locator.isVisible())) {
|
|
1977
|
+
return locator;
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
return null;
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async function firstVisibleButton(page, names) {
|
|
1986
|
+
return retryAcrossNavigation(page, async () => {
|
|
1987
|
+
for (const scope of locatorScopes(page)) {
|
|
1988
|
+
for (const name of names || []) {
|
|
1989
|
+
const locator = scope.getByRole("button", { name }).first();
|
|
1990
|
+
if ((await locator.count()) > 0 && (await locator.isVisible())) {
|
|
1991
|
+
return locator;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return null;
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
async function firstExisting(page, selectors) {
|
|
2000
|
+
return retryAcrossNavigation(page, async () => {
|
|
2001
|
+
for (const scope of locatorScopes(page)) {
|
|
2002
|
+
for (const selector of selectors || []) {
|
|
2003
|
+
const locator = scope.locator(selector).first();
|
|
2004
|
+
if ((await locator.count()) > 0) {
|
|
2005
|
+
return locator;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
return null;
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
async function firstVisibleText(page, text, exact = true) {
|
|
2014
|
+
if (typeof page.getByText !== "function") {
|
|
2015
|
+
return null;
|
|
2016
|
+
}
|
|
2017
|
+
return retryAcrossNavigation(page, async () => {
|
|
2018
|
+
for (const scope of locatorScopes(page)) {
|
|
2019
|
+
if (typeof scope.getByText !== "function") {
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
const locator = scope.getByText(text, { exact }).first();
|
|
2023
|
+
if ((await locator.count()) > 0 && (await locator.isVisible())) {
|
|
2024
|
+
return locator;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return null;
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
async function waitForVisibleTextAcrossFrames(page, text, timeoutMs = 8000, exact = true) {
|
|
2032
|
+
if (typeof page.getByText !== "function") {
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
const deadline = Date.now() + timeoutMs;
|
|
2036
|
+
do {
|
|
2037
|
+
const locator = await firstVisibleText(page, text, exact);
|
|
2038
|
+
if (locator) {
|
|
2039
|
+
return locator;
|
|
2040
|
+
}
|
|
2041
|
+
await page.waitForTimeout(200);
|
|
2042
|
+
} while (Date.now() < deadline);
|
|
2043
|
+
return null;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
async function waitForVisible(page, selectors, timeoutMs = 8000) {
|
|
2047
|
+
if (!selectors?.length) {
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
const deadline = Date.now() + timeoutMs;
|
|
2051
|
+
do {
|
|
2052
|
+
const locator = await firstVisible(page, selectors);
|
|
2053
|
+
if (locator) {
|
|
2054
|
+
return locator;
|
|
2055
|
+
}
|
|
2056
|
+
await page.waitForTimeout(200);
|
|
2057
|
+
} while (Date.now() < deadline);
|
|
2058
|
+
return null;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
async function waitForVisibleButton(page, names, timeoutMs = 8000) {
|
|
2062
|
+
if (!names?.length) {
|
|
2063
|
+
return null;
|
|
2064
|
+
}
|
|
2065
|
+
const deadline = Date.now() + timeoutMs;
|
|
2066
|
+
do {
|
|
2067
|
+
const locator = await firstVisibleButton(page, names);
|
|
2068
|
+
if (locator) {
|
|
2069
|
+
return locator;
|
|
2070
|
+
}
|
|
2071
|
+
await page.waitForTimeout(200);
|
|
2072
|
+
} while (Date.now() < deadline);
|
|
2073
|
+
return null;
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function visibleLocatorCount(locator) {
|
|
2077
|
+
if (!locator) {
|
|
2078
|
+
return 0;
|
|
2079
|
+
}
|
|
2080
|
+
if (typeof locator.evaluateAll === "function") {
|
|
2081
|
+
return locator
|
|
2082
|
+
.evaluateAll(elements =>
|
|
2083
|
+
elements.filter(element => {
|
|
2084
|
+
const style = window.getComputedStyle(element);
|
|
2085
|
+
const rect = element.getBoundingClientRect();
|
|
2086
|
+
return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
|
|
2087
|
+
}).length
|
|
2088
|
+
)
|
|
2089
|
+
.catch(() => 0);
|
|
2090
|
+
}
|
|
2091
|
+
return locator.count().catch(() => 0);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
async function pendingUploadCount(scope) {
|
|
2095
|
+
if (!scope || typeof scope.locator !== "function") {
|
|
2096
|
+
return 0;
|
|
2097
|
+
}
|
|
2098
|
+
return visibleLocatorCount(
|
|
2099
|
+
scope.locator(
|
|
2100
|
+
'[aria-busy="true"], [class*="upload"][class*="loading"], [class*="upload"][class*="progress"], ' +
|
|
2101
|
+
'[class*="image"][class*="loading"], [class*="image"][class*="progress"], ' +
|
|
2102
|
+
'[class*="spinner"], [class~="spin"], [class$="-spin"], svg[class*="loading"]'
|
|
2103
|
+
)
|
|
2104
|
+
);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
async function waitForUploadsSettled(scope, label, timeoutMs = 120000, minimumWaitMs = 1200) {
|
|
2108
|
+
const deadline = Date.now() + timeoutMs;
|
|
2109
|
+
const startedAt = Date.now();
|
|
2110
|
+
let stableChecks = 0;
|
|
2111
|
+
do {
|
|
2112
|
+
const text = typeof scope?.innerText === "function" ? await scope.innerText().catch(() => "") : "";
|
|
2113
|
+
if (/上传失败|处理失败|转码失败|格式不支持|重新上传|upload failed/i.test(text)) {
|
|
2114
|
+
throw new Error(`${label}上传失败,请检查页面提示`);
|
|
2115
|
+
}
|
|
2116
|
+
const pending = await pendingUploadCount(scope);
|
|
2117
|
+
if (pending === 0 && Date.now() - startedAt >= minimumWaitMs) {
|
|
2118
|
+
stableChecks += 1;
|
|
2119
|
+
if (stableChecks >= 3) {
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
} else {
|
|
2123
|
+
stableChecks = 0;
|
|
2124
|
+
}
|
|
2125
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
2126
|
+
} while (Date.now() < deadline);
|
|
2127
|
+
throw new Error(`${label}上传超时,已停止发布以避免提交未完成内容`);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function explicitLoginPage(page) {
|
|
2131
|
+
const url = typeof page?.url === "function" ? page.url() : "";
|
|
2132
|
+
return /(?:accounts?|passport|login|signin|sso)[./]/i.test(url) || /\/(?:login|signin)(?:[/?#]|$)/i.test(url);
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
async function waitForEditorReady(page, ready, timeoutMs = 12000) {
|
|
2136
|
+
const intervalMs = 300;
|
|
2137
|
+
const attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs));
|
|
2138
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
2139
|
+
if (await ready()) {
|
|
2140
|
+
return true;
|
|
2141
|
+
}
|
|
2142
|
+
if (explicitLoginPage(page)) {
|
|
2143
|
+
return false;
|
|
2144
|
+
}
|
|
2145
|
+
if (attempt + 1 < attempts) {
|
|
2146
|
+
await page.waitForTimeout(intervalMs);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
return false;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
async function fillLocator(locator, value) {
|
|
2153
|
+
await locator.fill(value);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
async function verifyFilledValue(locator, expected, label, platform = "TapTap") {
|
|
2157
|
+
let actual = "";
|
|
2158
|
+
if (typeof locator.inputValue === "function") {
|
|
2159
|
+
actual = await locator.inputValue().catch(() => "");
|
|
2160
|
+
}
|
|
2161
|
+
if (!actual && typeof locator.innerText === "function") {
|
|
2162
|
+
actual = await locator.innerText().catch(() => "");
|
|
2163
|
+
}
|
|
2164
|
+
if (typeof locator.inputValue !== "function" && typeof locator.innerText !== "function") {
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
const normalizedActual = normalizeText(actual).replace(/\r\n/g, "\n");
|
|
2168
|
+
const normalizedExpected = normalizeText(expected).replace(/\r\n/g, "\n");
|
|
2169
|
+
if (!normalizedActual || normalizedActual !== normalizedExpected) {
|
|
2170
|
+
throw new Error(`${platform} ${label}未正确写入编辑器`);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
function normalizeEditorText(value) {
|
|
2175
|
+
return normalizeText(value).replace(/\s+/g, " ");
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
async function readFilledValue(locator) {
|
|
2179
|
+
let actual = "";
|
|
2180
|
+
if (typeof locator.inputValue === "function") {
|
|
2181
|
+
actual = await locator.inputValue().catch(() => "");
|
|
2182
|
+
}
|
|
2183
|
+
if (!actual && typeof locator.innerText === "function") {
|
|
2184
|
+
actual = await locator.innerText().catch(() => "");
|
|
2185
|
+
}
|
|
2186
|
+
return actual;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
async function fillStableHaoyouValue(page, locator, expected, label) {
|
|
2190
|
+
const normalizedExpected = normalizeEditorText(expected);
|
|
2191
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2192
|
+
await fillLocator(locator, expected);
|
|
2193
|
+
let stableChecks = 0;
|
|
2194
|
+
for (let check = 0; check < 4; check += 1) {
|
|
2195
|
+
await page.waitForTimeout(200);
|
|
2196
|
+
const normalizedActual = normalizeEditorText(await readFilledValue(locator));
|
|
2197
|
+
if (normalizedActual && normalizedActual === normalizedExpected) {
|
|
2198
|
+
stableChecks += 1;
|
|
2199
|
+
if (stableChecks >= 2) {
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
} else {
|
|
2203
|
+
stableChecks = 0;
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
throw new Error(`好游快爆 ${label}未正确写入编辑器`);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
async function waitForUser(message, streams = {}) {
|
|
2211
|
+
const input = streams.input || process.stdin;
|
|
2212
|
+
const output = streams.output || process.stdout;
|
|
2213
|
+
if (!input.isTTY || !output.isTTY) {
|
|
2214
|
+
throw new Error(`${message}。当前不是交互终端,请在本地终端重新执行该命令`);
|
|
2215
|
+
}
|
|
2216
|
+
const rl = readline.createInterface({ input, output });
|
|
2217
|
+
try {
|
|
2218
|
+
await question(rl, `${message},完成后按 Enter 继续: `);
|
|
2219
|
+
} finally {
|
|
2220
|
+
rl.close();
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function reviewApprovalAccepted(answer) {
|
|
2225
|
+
return ["", "发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function reviewApprovalCancelled(answer) {
|
|
2229
|
+
return ["取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
async function waitForReviewApproval(message, options = {}) {
|
|
2233
|
+
if (typeof options.waitForReview === "function") {
|
|
2234
|
+
const answer = normalizeText(await options.waitForReview(message));
|
|
2235
|
+
if (reviewApprovalAccepted(answer)) return;
|
|
2236
|
+
if (reviewApprovalCancelled(answer)) {
|
|
2237
|
+
throw new Error("用户已取消正式发布,草稿未提交");
|
|
2238
|
+
}
|
|
2239
|
+
throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或直接回车`);
|
|
2240
|
+
}
|
|
2241
|
+
const input = options.streams?.input || process.stdin;
|
|
2242
|
+
const output = options.streams?.output || process.stdout;
|
|
2243
|
+
if (!input.isTTY || !output.isTTY) {
|
|
2244
|
+
throw new Error(`${message}。当前不是交互终端,无法确认草稿,请移除 --review-draft 直接发布或在本地 CMD 重试`);
|
|
2245
|
+
}
|
|
2246
|
+
const rl = readline.createInterface({ input, output });
|
|
2247
|
+
try {
|
|
2248
|
+
for (;;) {
|
|
2249
|
+
const answer = await question(
|
|
2250
|
+
rl,
|
|
2251
|
+
`${message}\n输入“可以发布”或直接按 Enter 继续,输入“取消”停止: `
|
|
2252
|
+
);
|
|
2253
|
+
if (reviewApprovalAccepted(answer)) return;
|
|
2254
|
+
if (reviewApprovalCancelled(answer)) {
|
|
2255
|
+
throw new Error("用户已取消正式发布,草稿未提交");
|
|
2256
|
+
}
|
|
2257
|
+
output.write("无法识别输入,请输入“可以发布”、直接按 Enter 或输入“取消”。\n");
|
|
2258
|
+
}
|
|
2259
|
+
} finally {
|
|
2260
|
+
rl.close();
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
async function reviewPreparedDraft(page, entry, options) {
|
|
2265
|
+
if (!options.reviewDraft) {
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
await page.waitForTimeout(500);
|
|
2269
|
+
await waitForReviewApproval(
|
|
2270
|
+
`${entry.platform} 待发布稿已准备,请在浏览器中预览标题、正文、图片和发布设置`,
|
|
2271
|
+
options
|
|
2272
|
+
);
|
|
2273
|
+
options.onProgress?.(`${entry.platform} 草稿预览已确认,正在正式发布`);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
async function editorIsReady(page, spec) {
|
|
2277
|
+
const titleInput = spec.title ? await firstVisible(page, spec.title) : true;
|
|
2278
|
+
if (!titleInput) {
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2281
|
+
return Boolean(await firstVisible(page, spec.body));
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
async function openPlatformLogin(page, entry, spec) {
|
|
2285
|
+
if (spec.loginPage) {
|
|
2286
|
+
await page.goto(spec.loginPage, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
2287
|
+
await afterNavigation(page);
|
|
2288
|
+
const loggedIn = await waitForVisible(page, spec.loggedInSelectors, 2000);
|
|
2289
|
+
if (loggedIn) {
|
|
2290
|
+
return true;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
if (entry.authMode === "qr" && /\/(?:login|signin)(?:[/?#]|$)/i.test(page.url())) {
|
|
2295
|
+
return false;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
if (explicitLoginPage(page)) {
|
|
2299
|
+
return false;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
let trigger = await waitForVisible(page, spec.loginTriggerSelectors, 5000);
|
|
2303
|
+
if (!trigger && !spec.manualLogin) {
|
|
2304
|
+
trigger = await waitForVisibleButton(page, spec.loginTriggerButtons, 5000);
|
|
2305
|
+
}
|
|
2306
|
+
if (spec.loginPage && !trigger) {
|
|
2307
|
+
throw new Error(`${entry.platform} 官方首页中未找到登录入口,页面结构可能已更新`);
|
|
2308
|
+
}
|
|
2309
|
+
if (trigger) {
|
|
2310
|
+
await trigger.click();
|
|
2311
|
+
await page.waitForTimeout(500);
|
|
2312
|
+
}
|
|
2313
|
+
return false;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
async function ensureBrowserLogin(page, entry, spec, promptUser) {
|
|
2317
|
+
const alreadyLoggedIn = await openPlatformLogin(page, entry, spec);
|
|
2318
|
+
if (alreadyLoggedIn) {
|
|
2319
|
+
return true;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
if (spec.manualLogin) {
|
|
2323
|
+
await promptUser(`${entry.platform} 尚未登录,请在浏览器中手动完成登录(可自行填写账号密码或扫码)`);
|
|
2324
|
+
return true;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
if (entry.authMode === "qr") {
|
|
2328
|
+
await promptUser(`${entry.platform} 需要扫码登录,请在浏览器中完成扫码`);
|
|
2329
|
+
return true;
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
const passwordLogin = await waitForVisible(page, spec.passwordLoginSelectors, 3000);
|
|
2333
|
+
if (passwordLogin) {
|
|
2334
|
+
await passwordLogin.click();
|
|
2335
|
+
await page.waitForTimeout(250);
|
|
2336
|
+
}
|
|
2337
|
+
const usernameInput = await waitForVisible(page, spec.username);
|
|
2338
|
+
const passwordInput = await waitForVisible(page, spec.password);
|
|
2339
|
+
const hasAccount = entry.account && !/扫码登录|验证码登录/.test(entry.account);
|
|
2340
|
+
const hasPassword = entry.password && !/验证码登录|密码登录/.test(entry.password);
|
|
2341
|
+
if (hasAccount && usernameInput) {
|
|
2342
|
+
await fillLocator(usernameInput, entry.account);
|
|
2343
|
+
}
|
|
2344
|
+
if (hasPassword && passwordInput) {
|
|
2345
|
+
await fillLocator(passwordInput, entry.password);
|
|
2346
|
+
}
|
|
2347
|
+
if (hasAccount || hasPassword) {
|
|
2348
|
+
let loginButton = await waitForVisible(page, spec.loginSubmitSelectors, 3000);
|
|
2349
|
+
loginButton ||= await waitForVisibleButton(page, spec.loginButtons, 3000);
|
|
2350
|
+
if (loginButton) {
|
|
2351
|
+
await loginButton.click();
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
const reason = entry.authMode === "verification" ? "验证码或二次验证" : "登录确认";
|
|
2356
|
+
await promptUser(`${entry.platform} 需要${reason},请在浏览器中完成`);
|
|
2357
|
+
return true;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
async function tapTapCreatorReady(page, spec) {
|
|
2361
|
+
if (await firstVisible(page, [".creator-publish__tab", 'a[href*="/creator/edit?type="]'])) {
|
|
2362
|
+
return true;
|
|
2363
|
+
}
|
|
2364
|
+
return editorIsReady(page, spec);
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
async function waitForTapTapEditor(page, spec, timeoutMs = 15000) {
|
|
2368
|
+
const deadline = Date.now() + timeoutMs;
|
|
2369
|
+
do {
|
|
2370
|
+
if (await editorIsReady(page, spec)) {
|
|
2371
|
+
return true;
|
|
2372
|
+
}
|
|
2373
|
+
await page.waitForTimeout(300);
|
|
2374
|
+
} while (Date.now() < deadline);
|
|
2375
|
+
return false;
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
async function uploadTapTapImages(page, sources, articleFile) {
|
|
2379
|
+
if (sources.length === 0) {
|
|
2380
|
+
throw new Error("TapTap 图文至少需要 1 张图片;请在 taptap_images 中填写本地路径或图片 URL");
|
|
2381
|
+
}
|
|
2382
|
+
if (sources.length > 18) {
|
|
2383
|
+
throw new Error(`TapTap 图文最多支持 18 张图片,当前为 ${sources.length} 张`);
|
|
2384
|
+
}
|
|
2385
|
+
const input = await firstExisting(page, ['input[type="file"][accept*="image"]', ".upload-img-v2__btn-input-file"]);
|
|
2386
|
+
if (!input || typeof input.setInputFiles !== "function") {
|
|
2387
|
+
throw new Error("TapTap 页面中未找到图片上传控件,页面结构可能已更新");
|
|
2388
|
+
}
|
|
2389
|
+
const payloads = [];
|
|
2390
|
+
for (let index = 0; index < sources.length; index += 1) {
|
|
2391
|
+
payloads.push(await imageUploadPayload(sources[index], articleFile, index, "TapTap"));
|
|
2392
|
+
}
|
|
2393
|
+
await input.setInputFiles(payloads);
|
|
2394
|
+
|
|
2395
|
+
const deadline = Date.now() + 120000;
|
|
2396
|
+
const startedAt = Date.now();
|
|
2397
|
+
let stableChecks = 0;
|
|
2398
|
+
do {
|
|
2399
|
+
const bodyText = await page.locator("body").innerText().catch(() => "");
|
|
2400
|
+
if (/上传失败|图片格式不支持|重新上传/.test(bodyText)) {
|
|
2401
|
+
throw new Error("TapTap 图片上传失败,请检查页面提示");
|
|
2402
|
+
}
|
|
2403
|
+
const counter = bodyText.match(/上传图片\s*(\d+)\s*\/\s*\d+/);
|
|
2404
|
+
const previewCount = await page
|
|
2405
|
+
.locator('.upload-img-v2__item, .upload-img-v2__preview, [class*="upload-img"] [class*="preview"]')
|
|
2406
|
+
.count()
|
|
2407
|
+
.catch(() => 0);
|
|
2408
|
+
const uploadedCount = counter ? Number(counter[1]) : previewCount;
|
|
2409
|
+
const pending = await pendingUploadCount(page);
|
|
2410
|
+
if (uploadedCount >= sources.length && pending === 0 && Date.now() - startedAt >= 1200) {
|
|
2411
|
+
stableChecks += 1;
|
|
2412
|
+
if (stableChecks >= 3) {
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
} else {
|
|
2416
|
+
stableChecks = 0;
|
|
2417
|
+
}
|
|
2418
|
+
await page.waitForTimeout(500);
|
|
2419
|
+
} while (Date.now() < deadline);
|
|
2420
|
+
throw new Error(`TapTap 图片上传超时,预期 ${sources.length} 张图片全部完成,已停止发布`);
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
async function uploadTapTapVideo(page, source, articleFile) {
|
|
2424
|
+
if (!source) {
|
|
2425
|
+
throw new Error("TapTap 视频模式缺少视频;请设置 taptap_video 为本地视频文件路径");
|
|
2426
|
+
}
|
|
2427
|
+
if (/^https?:\/\//i.test(source)) {
|
|
2428
|
+
throw new Error("TapTap 视频上传仅支持本地文件,请把 taptap_video 设置为本地视频路径");
|
|
2429
|
+
}
|
|
2430
|
+
const videoPath = path.resolve(articleFile ? path.dirname(articleFile) : process.cwd(), source);
|
|
2431
|
+
await fs.promises.access(videoPath, fs.constants.R_OK).catch(error => {
|
|
2432
|
+
throw new Error(`TapTap 视频文件无法读取: ${videoPath} (${error.message})`);
|
|
2433
|
+
});
|
|
2434
|
+
const input = await firstExisting(page, ['input[type="file"][accept*="video"]', 'input[type="file"]']);
|
|
2435
|
+
if (!input || typeof input.setInputFiles !== "function") {
|
|
2436
|
+
throw new Error("TapTap 页面中未找到视频上传控件,页面结构可能已更新");
|
|
2437
|
+
}
|
|
2438
|
+
await input.setInputFiles(videoPath);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
async function tapTapForumAction(page, scope, label) {
|
|
2442
|
+
const scopes = scope === page ? [page] : [scope, page];
|
|
2443
|
+
for (const currentScope of scopes) {
|
|
2444
|
+
if (typeof currentScope.getByRole === "function") {
|
|
2445
|
+
const buttons = currentScope.getByRole("button", { name: new RegExp(`^${label}$`) });
|
|
2446
|
+
const count = await buttons.count().catch(() => 0);
|
|
2447
|
+
for (let index = 0; index < count; index += 1) {
|
|
2448
|
+
const button = buttons.nth(index);
|
|
2449
|
+
if (await button.isVisible().catch(() => false)) {
|
|
2450
|
+
return button;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
if (typeof currentScope.getByText !== "function") {
|
|
2455
|
+
continue;
|
|
2456
|
+
}
|
|
2457
|
+
const labels = currentScope.getByText(label, { exact: true });
|
|
2458
|
+
const count = await labels.count().catch(() => 0);
|
|
2459
|
+
for (let index = 0; index < count; index += 1) {
|
|
2460
|
+
const text = labels.nth(index);
|
|
2461
|
+
if (!(await text.isVisible().catch(() => false))) {
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
if (typeof text.locator === "function") {
|
|
2465
|
+
const control = text.locator('xpath=ancestor-or-self::*[self::button or @role="button"][1]');
|
|
2466
|
+
if ((await control.count().catch(() => 0)) > 0 && (await control.isVisible().catch(() => false))) {
|
|
2467
|
+
return control;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
return text;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
return null;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
async function tapTapControlDisabled(action) {
|
|
2477
|
+
if (typeof action.isDisabled === "function" && (await action.isDisabled().catch(() => false))) {
|
|
2478
|
+
return true;
|
|
2479
|
+
}
|
|
2480
|
+
if (typeof action.evaluate !== "function") {
|
|
2481
|
+
return false;
|
|
2482
|
+
}
|
|
2483
|
+
return action
|
|
2484
|
+
.evaluate(element =>
|
|
2485
|
+
element.getAttribute("aria-disabled") === "true" ||
|
|
2486
|
+
element.classList.contains("tap-button--disabled") ||
|
|
2487
|
+
element.classList.contains("is-disabled")
|
|
2488
|
+
)
|
|
2489
|
+
.catch(() => false);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
async function clickTapTapForumAction(page, scope, search, label, expectClose) {
|
|
2493
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2494
|
+
const action = await tapTapForumAction(page, scope, label);
|
|
2495
|
+
if (!action) {
|
|
2496
|
+
return false;
|
|
2497
|
+
}
|
|
2498
|
+
if (await tapTapControlDisabled(action)) {
|
|
2499
|
+
await page.waitForTimeout(250);
|
|
2500
|
+
continue;
|
|
2501
|
+
}
|
|
2502
|
+
if (typeof action.scrollIntoViewIfNeeded === "function") {
|
|
2503
|
+
await action.scrollIntoViewIfNeeded().catch(() => {});
|
|
2504
|
+
}
|
|
2505
|
+
try {
|
|
2506
|
+
if (attempt === 0) {
|
|
2507
|
+
await action.click();
|
|
2508
|
+
} else if (attempt === 1) {
|
|
2509
|
+
await action.click({ force: true });
|
|
2510
|
+
} else if (typeof action.evaluate === "function") {
|
|
2511
|
+
await action.evaluate(element => element.click());
|
|
2512
|
+
} else {
|
|
2513
|
+
await action.click({ force: true });
|
|
2514
|
+
}
|
|
2515
|
+
} catch (error) {
|
|
2516
|
+
if (attempt === 2) {
|
|
2517
|
+
throw new Error(`TapTap 论坛选择弹窗的“${label}”按钮点击失败: ${error.message}`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
await page.waitForTimeout(500);
|
|
2521
|
+
if (!expectClose || !(await search.isVisible().catch(() => false))) {
|
|
2522
|
+
return true;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
if (await tapTapForumAction(page, scope, label)) {
|
|
2526
|
+
throw new Error(`TapTap 论坛选择弹窗的“${label}”按钮点击后未生效`);
|
|
2527
|
+
}
|
|
2528
|
+
return false;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
export async function applyTapTapForum(page, forum, promptUser) {
|
|
2532
|
+
if (!forum) {
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
const existingForums = page.locator(".create-config__forum-name");
|
|
2536
|
+
if (typeof existingForums.filter === "function") {
|
|
2537
|
+
const existingForum = existingForums.filter({ hasText: forum }).first();
|
|
2538
|
+
if ((await existingForum.count().catch(() => 0)) > 0 && (await existingForum.isVisible().catch(() => false))) {
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
const openButton = await firstVisibleText(page, "添加发布的游戏论坛");
|
|
2543
|
+
if (!openButton) {
|
|
2544
|
+
if (typeof promptUser !== "function") {
|
|
2545
|
+
throw new Error("TapTap 页面中未找到“添加发布的游戏论坛”入口");
|
|
2546
|
+
}
|
|
2547
|
+
await promptUser(`TapTap 页面结构已更新,请在浏览器中手动选择游戏论坛“${forum}”并确认`);
|
|
2548
|
+
const selected = await firstVisibleText(page, forum);
|
|
2549
|
+
if (!selected) {
|
|
2550
|
+
throw new Error(`TapTap 手动选择后仍未回读到游戏论坛: ${forum}`);
|
|
2551
|
+
}
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
await openButton.click();
|
|
2555
|
+
const search = await waitForVisible(page, ['input[placeholder="搜索论坛"]'], 5000);
|
|
2556
|
+
if (!search) {
|
|
2557
|
+
throw new Error("TapTap 论坛选择窗口未正常打开");
|
|
2558
|
+
}
|
|
2559
|
+
await search.fill(forum);
|
|
2560
|
+
await page.waitForTimeout(500);
|
|
2561
|
+
const dialog = search.locator(
|
|
2562
|
+
'xpath=ancestor::*[.//*[normalize-space(text())="选择发布的论坛"]][1]'
|
|
2563
|
+
);
|
|
2564
|
+
const scope = (await dialog.count().catch(() => 0)) > 0 ? dialog : page;
|
|
2565
|
+
const results = scope.getByText(forum, { exact: true });
|
|
2566
|
+
let result = null;
|
|
2567
|
+
const resultCount = await results.count();
|
|
2568
|
+
for (let index = 0; index < resultCount; index += 1) {
|
|
2569
|
+
const candidate = results.nth(index);
|
|
2570
|
+
if (await candidate.isVisible().catch(() => false)) {
|
|
2571
|
+
result = candidate;
|
|
2572
|
+
break;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
if (!result) {
|
|
2576
|
+
throw new Error(`TapTap 中未找到游戏论坛: ${forum}`);
|
|
2577
|
+
}
|
|
2578
|
+
let suggestion = result;
|
|
2579
|
+
let usedSuggestion = false;
|
|
2580
|
+
if (typeof result.locator === "function") {
|
|
2581
|
+
const suggestionRow = result.locator(
|
|
2582
|
+
'xpath=ancestor-or-self::*[contains(concat(" ", normalize-space(@class), " "), " select-multi-forums__suggest-item ")][1]'
|
|
2583
|
+
);
|
|
2584
|
+
if ((await suggestionRow.count().catch(() => 0)) > 0) {
|
|
2585
|
+
suggestion = suggestionRow;
|
|
2586
|
+
usedSuggestion = true;
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
await suggestion.click();
|
|
2590
|
+
await page.waitForTimeout(300);
|
|
2591
|
+
|
|
2592
|
+
if (typeof scope.locator === "function") {
|
|
2593
|
+
const forumRows = scope.locator(".select-multi-forums__row").filter({ hasText: forum });
|
|
2594
|
+
let forumRow = null;
|
|
2595
|
+
const rowCount = await forumRows.count().catch(() => 0);
|
|
2596
|
+
for (let index = 0; index < rowCount; index += 1) {
|
|
2597
|
+
const candidate = forumRows.nth(index);
|
|
2598
|
+
if (await candidate.isVisible().catch(() => false)) {
|
|
2599
|
+
forumRow = candidate;
|
|
2600
|
+
break;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
if (forumRow) {
|
|
2604
|
+
await forumRow.click();
|
|
2605
|
+
await page.waitForTimeout(300);
|
|
2606
|
+
} else if (usedSuggestion) {
|
|
2607
|
+
throw new Error(`TapTap 搜索到论坛“${forum}”,但未加载可选择的论坛结果行`);
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
if (await search.isVisible().catch(() => false)) {
|
|
2611
|
+
const done = await tapTapForumAction(page, scope, "完成");
|
|
2612
|
+
if (!done) {
|
|
2613
|
+
const returned = await clickTapTapForumAction(page, scope, search, "返回", false);
|
|
2614
|
+
if (!returned) {
|
|
2615
|
+
throw new Error(`TapTap 已选择论坛“${forum}”,但弹窗中未找到“返回”按钮`);
|
|
2616
|
+
}
|
|
2617
|
+
await page.waitForTimeout(300);
|
|
2618
|
+
}
|
|
2619
|
+
const completed = await clickTapTapForumAction(page, scope, search, "完成", true);
|
|
2620
|
+
if (!completed) {
|
|
2621
|
+
throw new Error(`TapTap 已选择论坛“${forum}”,但弹窗中未找到“完成”按钮`);
|
|
2622
|
+
}
|
|
2623
|
+
}
|
|
2624
|
+
const deadline = Date.now() + 5000;
|
|
2625
|
+
do {
|
|
2626
|
+
if (!(await search.isVisible().catch(() => false))) {
|
|
2627
|
+
const selected = await firstVisibleText(page, forum);
|
|
2628
|
+
if (!selected) {
|
|
2629
|
+
throw new Error(`TapTap 论坛弹窗已关闭,但未回读到已选论坛: ${forum}`);
|
|
2630
|
+
}
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
await page.waitForTimeout(100);
|
|
2634
|
+
} while (Date.now() < deadline);
|
|
2635
|
+
throw new Error(`TapTap 选择论坛“${forum}”后弹窗未关闭`);
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
async function applyTapTapSchedule(page, scheduled, promptUser) {
|
|
2639
|
+
if (!scheduled) {
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const schedule = await firstVisibleText(page, "定时发布");
|
|
2643
|
+
if (!schedule) {
|
|
2644
|
+
throw new Error("TapTap 页面中未找到“定时发布”设置");
|
|
2645
|
+
}
|
|
2646
|
+
await schedule.click();
|
|
2647
|
+
await page.waitForTimeout(300);
|
|
2648
|
+
if (!(await firstVisibleText(page, "选择发布的日期与时间"))) {
|
|
2649
|
+
throw new Error("TapTap 定时发布未成功勾选");
|
|
2650
|
+
}
|
|
2651
|
+
await promptUser("TapTap 已勾选定时发布,请在页面中确认具体日期与时间");
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
export function tapTapMutationResponse(response, action = "publish") {
|
|
2655
|
+
const request = response.request();
|
|
2656
|
+
if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) {
|
|
2657
|
+
return false;
|
|
2658
|
+
}
|
|
2659
|
+
const url = response.url();
|
|
2660
|
+
if (!/taptap\.cn/i.test(url)) {
|
|
2661
|
+
return false;
|
|
2662
|
+
}
|
|
2663
|
+
let pathname;
|
|
2664
|
+
try {
|
|
2665
|
+
pathname = new URL(url).pathname.toLowerCase().replace(/\/+$/, "");
|
|
2666
|
+
} catch {
|
|
2667
|
+
return false;
|
|
2668
|
+
}
|
|
2669
|
+
if (action === "draft") {
|
|
2670
|
+
return /\/(?:moment|topic|review)-draft\/v\d+\/(?:create|update)(?:-topic)?$/.test(pathname);
|
|
2671
|
+
}
|
|
2672
|
+
return (
|
|
2673
|
+
/\/(?:moment|topic)-draft\/v\d+\/publish(?:-topic)?$/.test(pathname) ||
|
|
2674
|
+
/\/moment\/v\d+\/create-topic$/.test(pathname) ||
|
|
2675
|
+
/\/topic\/v\d+\/create$/.test(pathname)
|
|
2676
|
+
);
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
async function waitForTapTapSubmission(page, initialUrl, action, mutationPromise, options) {
|
|
2680
|
+
if (options.skipSubmissionVerification) {
|
|
2681
|
+
return { confirmation: "test" };
|
|
2682
|
+
}
|
|
2683
|
+
const successPattern = action === "draft" ? /草稿(?:保存)?成功|已保存至草稿/i : /发布成功|提交成功|已发布/i;
|
|
2684
|
+
const outcomes = [];
|
|
2685
|
+
if (typeof page.waitForURL === "function") {
|
|
2686
|
+
outcomes.push(
|
|
2687
|
+
page
|
|
2688
|
+
.waitForURL(url => url.toString() !== initialUrl && !/\/creator\/edit(?:[/?#]|$)/i.test(url.toString()), {
|
|
2689
|
+
timeout: 12000
|
|
2690
|
+
})
|
|
2691
|
+
.then(() => ({ confirmation: "navigation" }))
|
|
2692
|
+
.catch(() => null)
|
|
2693
|
+
);
|
|
2694
|
+
}
|
|
2695
|
+
if (typeof page.getByText === "function") {
|
|
2696
|
+
outcomes.push(
|
|
2697
|
+
page
|
|
2698
|
+
.getByText(successPattern)
|
|
2699
|
+
.first()
|
|
2700
|
+
.waitFor({ state: "visible", timeout: 12000 })
|
|
2701
|
+
.then(() => ({ confirmation: "message" }))
|
|
2702
|
+
.catch(() => null)
|
|
2703
|
+
);
|
|
2704
|
+
}
|
|
2705
|
+
outcomes.push(
|
|
2706
|
+
mutationPromise.then(async response => {
|
|
2707
|
+
if (!response || response.status() < 200 || response.status() >= 300) {
|
|
2708
|
+
return null;
|
|
2709
|
+
}
|
|
2710
|
+
const payload = await response.json().catch(() => null);
|
|
2711
|
+
if (payload && typeof payload === "object") {
|
|
2712
|
+
if (payload.success === false) {
|
|
2713
|
+
return null;
|
|
2714
|
+
}
|
|
2715
|
+
if (payload.code !== undefined && ![0, 200, "0", "200"].includes(payload.code)) {
|
|
2716
|
+
return null;
|
|
2717
|
+
}
|
|
2718
|
+
if (/失败|错误|fail|error/i.test(normalizeText(payload.message))) {
|
|
2719
|
+
return null;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
return { confirmation: "response", responseUrl: response.url() };
|
|
2723
|
+
})
|
|
2724
|
+
);
|
|
2725
|
+
outcomes.push(page.waitForTimeout(20500).then(() => null));
|
|
2726
|
+
|
|
2727
|
+
const pending = new Set(outcomes);
|
|
2728
|
+
while (pending.size > 0) {
|
|
2729
|
+
const tagged = [...pending].map(promise => promise.then(value => ({ promise, value })));
|
|
2730
|
+
const { promise, value } = await Promise.race(tagged);
|
|
2731
|
+
pending.delete(promise);
|
|
2732
|
+
if (value) {
|
|
2733
|
+
return value;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
throw new Error(
|
|
2737
|
+
`TapTap ${action === "draft" ? "草稿保存" : "发布"}后未检测到成功回执,不能判定为成功`
|
|
2738
|
+
);
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
async function submitTapTap(page, settings, options, promptUser) {
|
|
2742
|
+
let actionButton;
|
|
2743
|
+
if (settings.draft) {
|
|
2744
|
+
actionButton = await firstVisibleText(page, "保存草稿");
|
|
2745
|
+
} else {
|
|
2746
|
+
actionButton = await firstVisible(page, ["button.tap-button__editor.tap-button--primary"]);
|
|
2747
|
+
}
|
|
2748
|
+
if (!actionButton && !settings.draft) {
|
|
2749
|
+
actionButton = await waitForVisibleButton(page, [/^发布$/], 3000);
|
|
2750
|
+
}
|
|
2751
|
+
if (!actionButton) {
|
|
2752
|
+
throw new Error(`TapTap 页面中未找到${settings.draft ? "保存草稿" : "发布"}按钮`);
|
|
2753
|
+
}
|
|
2754
|
+
if (typeof actionButton.isDisabled === "function" && (await actionButton.isDisabled().catch(() => false))) {
|
|
2755
|
+
throw new Error(`TapTap ${settings.draft ? "保存草稿" : "发布"}按钮不可用,请检查必填内容和媒体上传状态`);
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
const initialUrl = page.url();
|
|
2759
|
+
const action = settings.draft ? "draft" : "publish";
|
|
2760
|
+
const mutationPromise =
|
|
2761
|
+
typeof page.waitForResponse === "function"
|
|
2762
|
+
? page.waitForResponse(response => tapTapMutationResponse(response, action), { timeout: 20000 }).catch(() => null)
|
|
2763
|
+
: Promise.resolve(null);
|
|
2764
|
+
await actionButton.click();
|
|
2765
|
+
try {
|
|
2766
|
+
return await waitForTapTapSubmission(page, initialUrl, action, mutationPromise, options);
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
if (options.keepOpen) {
|
|
2769
|
+
await promptUser(`${error.message},请在浏览器中检查页面提示`);
|
|
2770
|
+
}
|
|
2771
|
+
throw error;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
async function resumeMatchingTapTapDraft(page, spec, articleTitle) {
|
|
2776
|
+
if (typeof page.getByText !== "function") {
|
|
2777
|
+
return false;
|
|
2778
|
+
}
|
|
2779
|
+
const prompt = page.getByText(/你还有上次未发布的内容,是否继续编辑/).first();
|
|
2780
|
+
if ((await prompt.count().catch(() => 0)) === 0 || !(await prompt.isVisible().catch(() => false))) {
|
|
2781
|
+
return false;
|
|
2782
|
+
}
|
|
2783
|
+
const resume = page.getByText("继续编辑", { exact: true }).first();
|
|
2784
|
+
if ((await resume.count().catch(() => 0)) === 0 || !(await resume.isVisible().catch(() => false))) {
|
|
2785
|
+
throw new Error("TapTap 检测到未发布内容,但页面中未找到“继续编辑”按钮");
|
|
2786
|
+
}
|
|
2787
|
+
await resume.click();
|
|
2788
|
+
await afterNavigation(page);
|
|
2789
|
+
const titleInput = await waitForVisible(page, spec.title, 10000);
|
|
2790
|
+
if (!titleInput) {
|
|
2791
|
+
throw new Error("TapTap 继续编辑草稿后未找到标题控件");
|
|
2792
|
+
}
|
|
2793
|
+
let draftTitle = "";
|
|
2794
|
+
const deadline = Date.now() + 5000;
|
|
2795
|
+
do {
|
|
2796
|
+
draftTitle =
|
|
2797
|
+
typeof titleInput.inputValue === "function"
|
|
2798
|
+
? normalizeText(await titleInput.inputValue().catch(() => ""))
|
|
2799
|
+
: normalizeText(await titleInput.innerText?.().catch(() => ""));
|
|
2800
|
+
if (draftTitle) break;
|
|
2801
|
+
await page.waitForTimeout(100);
|
|
2802
|
+
} while (Date.now() < deadline);
|
|
2803
|
+
if (draftTitle && draftTitle !== articleTitle) {
|
|
2804
|
+
throw new Error(`TapTap 存在其他未发布草稿“${draftTitle}”,已停止以避免覆盖或产生额外草稿`);
|
|
2805
|
+
}
|
|
2806
|
+
return true;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
async function uploadTapTapCover(page, source, articleFile) {
|
|
2810
|
+
const coverInput = await firstExisting(page, [
|
|
2811
|
+
'input.editor-post-setting-cover-upload__option-upload-button[type="file"][accept*="image"]',
|
|
2812
|
+
'.editor-post-setting-cover-upload__option input[type="file"][accept*="image"]',
|
|
2813
|
+
'[class*="cover-upload"] input[type="file"][accept*="image"]'
|
|
2814
|
+
]);
|
|
2815
|
+
if (!coverInput || typeof coverInput.setInputFiles !== "function") {
|
|
2816
|
+
throw new Error("TapTap 长帖页面中未找到独立封面上传控件");
|
|
2817
|
+
}
|
|
2818
|
+
await coverInput.setInputFiles([await imageUploadPayload(source, articleFile, 0, "TapTap")]);
|
|
2819
|
+
let coverScope = page;
|
|
2820
|
+
if (typeof coverInput.locator === "function") {
|
|
2821
|
+
const candidate = coverInput.locator(
|
|
2822
|
+
'xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " editor-post-setting-cover-upload ")][1]'
|
|
2823
|
+
);
|
|
2824
|
+
if ((await candidate.count().catch(() => 0)) > 0) {
|
|
2825
|
+
coverScope = candidate;
|
|
2826
|
+
}
|
|
2827
|
+
}
|
|
2828
|
+
await waitForUploadsSettled(coverScope, "TapTap 封面", 60000);
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
2832
|
+
const settings = resolveTapTapPublishingOptions(article, entry);
|
|
2833
|
+
const titleLimit = settings.type === "moment" ? 20 : 30;
|
|
2834
|
+
if (article.title.length > titleLimit) {
|
|
2835
|
+
throw new Error(`TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${article.title.length} 个字符`);
|
|
2836
|
+
}
|
|
2837
|
+
const richContent = buildTapTapRichContent(article);
|
|
2838
|
+
const body = richContent.plainText || plainTextForPlatform(article, "taptap");
|
|
2839
|
+
if (settings.type === "moment" && body.length > 5000) {
|
|
2840
|
+
throw new Error(`TapTap 图文正文最多 5000 个字符,当前为 ${body.length} 个字符`);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
await page.goto(tapTapEditorUrl(entry.url, settings.type), {
|
|
2844
|
+
waitUntil: "domcontentloaded",
|
|
2845
|
+
timeout: 60000
|
|
2846
|
+
});
|
|
2847
|
+
await afterNavigation(page);
|
|
2848
|
+
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec)))) {
|
|
2849
|
+
await ensureBrowserLogin(page, entry, spec, promptUser);
|
|
2850
|
+
await page.goto(tapTapEditorUrl(entry.url, settings.type), {
|
|
2851
|
+
waitUntil: "domcontentloaded",
|
|
2852
|
+
timeout: 60000
|
|
2853
|
+
});
|
|
2854
|
+
await afterNavigation(page);
|
|
2855
|
+
}
|
|
2856
|
+
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
|
|
2857
|
+
throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
|
|
2858
|
+
}
|
|
2859
|
+
await resumeMatchingTapTapDraft(page, spec, article.title);
|
|
2860
|
+
|
|
2861
|
+
if (settings.type === "moment") {
|
|
2862
|
+
const imageSources = settings.images.length
|
|
2863
|
+
? settings.images
|
|
2864
|
+
: [...new Set([...imageSourcesFromArticle(article), normalizeText(article.payload?.thumbnail)].filter(Boolean))];
|
|
2865
|
+
await uploadTapTapImages(page, settings.images.length ? imageSources : imageSources.slice(0, 18), article.articleFile);
|
|
2866
|
+
} else if (settings.type === "video") {
|
|
2867
|
+
await uploadTapTapVideo(page, settings.video, article.articleFile);
|
|
2868
|
+
if (!(await waitForTapTapEditor(page, spec, 10 * 60 * 1000))) {
|
|
2869
|
+
throw new Error("TapTap 视频上传或处理超时,未进入内容编辑步骤");
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
const titleInput = await waitForVisible(page, spec.title, 10000);
|
|
2874
|
+
const bodyInput = await waitForVisible(page, spec.body, 10000);
|
|
2875
|
+
if (!titleInput || !bodyInput) {
|
|
2876
|
+
throw new Error(`TapTap ${settings.typeLabel}编辑器未完整加载,未找到标题或正文控件`);
|
|
2877
|
+
}
|
|
2878
|
+
await fillLocator(titleInput, article.title);
|
|
2879
|
+
await verifyFilledValue(titleInput, article.title, "标题");
|
|
2880
|
+
await insertTapTapRichContent(page, bodyInput, article, settings, body);
|
|
2881
|
+
|
|
2882
|
+
if (settings.type === "topic" && settings.cover) {
|
|
2883
|
+
await uploadTapTapCover(page, settings.cover, article.articleFile);
|
|
2884
|
+
}
|
|
2885
|
+
await applyTapTapForum(page, settings.forum, promptUser);
|
|
2886
|
+
await applyTapTapSchedule(page, settings.scheduled, promptUser);
|
|
2887
|
+
if (!settings.draft) {
|
|
2888
|
+
await reviewPreparedDraft(page, entry, options);
|
|
2889
|
+
}
|
|
2890
|
+
const confirmation = await submitTapTap(page, settings, options, promptUser);
|
|
2891
|
+
if (options.keepOpen) {
|
|
2892
|
+
await promptUser(
|
|
2893
|
+
`TapTap ${settings.draft ? "草稿保存" : settings.scheduled ? "定时发布" : "发布"}已收到成功回执,浏览器保持打开供检查`
|
|
2894
|
+
);
|
|
2895
|
+
}
|
|
2896
|
+
return {
|
|
2897
|
+
url: page.url(),
|
|
2898
|
+
status: settings.draft ? "draft" : settings.scheduled ? "scheduled" : "published",
|
|
2899
|
+
type: settings.type,
|
|
2900
|
+
...confirmation
|
|
2901
|
+
};
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
function bilibiliVideoDescription(article) {
|
|
2905
|
+
const $ = cheerio.load(article.html || "", null, false);
|
|
2906
|
+
$("script,style,noscript,iframe,img,audio,video").remove();
|
|
2907
|
+
$("a").each((_, element) => {
|
|
2908
|
+
const link = $(element);
|
|
2909
|
+
const label = normalizeText(link.text());
|
|
2910
|
+
const href = safeTapTapHref(link.attr("href"));
|
|
2911
|
+
link.replaceWith(href ? (label && label !== href ? `${label}(${href})` : href) : label);
|
|
2912
|
+
});
|
|
2913
|
+
return plainTextForPlatform({ ...article, html: $.html() }, "bilibili");
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
async function validateBilibiliFieldLength(locator, value, label) {
|
|
2917
|
+
if (typeof locator.evaluate !== "function") {
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
const limit = await locator
|
|
2921
|
+
.evaluate(element => {
|
|
2922
|
+
const property = Number(element.maxLength);
|
|
2923
|
+
const attribute = Number(element.getAttribute("maxlength"));
|
|
2924
|
+
return property > 0 ? property : attribute > 0 ? attribute : 0;
|
|
2925
|
+
})
|
|
2926
|
+
.catch(() => 0);
|
|
2927
|
+
if (limit > 0 && value.length > limit) {
|
|
2928
|
+
throw new Error(`B站${label}最多 ${limit} 个字符,当前为 ${value.length} 个字符`);
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
async function bilibiliEditorIsReady(page, spec, settings) {
|
|
2933
|
+
if (settings.type === "video") {
|
|
2934
|
+
if (await firstExisting(page, spec.videoInputs)) {
|
|
2935
|
+
return true;
|
|
2936
|
+
}
|
|
2937
|
+
return Boolean(await firstVisibleText(page, "上传视频"));
|
|
2938
|
+
}
|
|
2939
|
+
return editorIsReady(page, spec);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
async function uploadBilibiliVideo(page, source, articleFile, spec) {
|
|
2943
|
+
if (!source) {
|
|
2944
|
+
throw new Error("B站视频投稿缺少视频;请设置 bilibili_video 为本地视频文件路径");
|
|
2945
|
+
}
|
|
2946
|
+
if (/^(?:https?:)?\/\//i.test(source)) {
|
|
2947
|
+
throw new Error("B站视频上传仅支持本地文件,请把 bilibili_video 设置为本地视频路径");
|
|
2948
|
+
}
|
|
2949
|
+
const videoPath = path.resolve(articleFile ? path.dirname(articleFile) : process.cwd(), source);
|
|
2950
|
+
const stats = await fs.promises.stat(videoPath).catch(error => {
|
|
2951
|
+
throw new Error(`B站视频文件无法读取: ${videoPath} (${error.message})`);
|
|
2952
|
+
});
|
|
2953
|
+
if (!stats.isFile()) {
|
|
2954
|
+
throw new Error(`B站视频路径不是文件: ${videoPath}`);
|
|
2955
|
+
}
|
|
2956
|
+
const supportedExtensions = new Set([".mp4", ".mov", ".mkv", ".flv", ".avi", ".wmv", ".mpeg", ".mpg", ".webm"]);
|
|
2957
|
+
if (!supportedExtensions.has(path.extname(videoPath).toLowerCase())) {
|
|
2958
|
+
throw new Error("B站视频格式无法识别,建议使用 MP4、MOV 或 MKV 文件");
|
|
2959
|
+
}
|
|
2960
|
+
if (stats.size >= 16 * 1024 * 1024 * 1024) {
|
|
2961
|
+
throw new Error(`B站视频必须小于 16GB,当前文件为 ${(stats.size / 1024 / 1024 / 1024).toFixed(2)}GB`);
|
|
2962
|
+
}
|
|
2963
|
+
const input = await firstExisting(page, [...spec.videoInputs, 'input[type="file"]']);
|
|
2964
|
+
if (!input || typeof input.setInputFiles !== "function") {
|
|
2965
|
+
throw new Error("B站视频投稿页中未找到视频上传控件,页面结构可能已更新");
|
|
2966
|
+
}
|
|
2967
|
+
await input.setInputFiles(videoPath);
|
|
2968
|
+
return videoPath;
|
|
2969
|
+
}
|
|
2970
|
+
|
|
2971
|
+
async function waitForBilibiliVideoForm(page, spec, timeoutMs = 2 * 60 * 60 * 1000) {
|
|
2972
|
+
const deadline = Date.now() + timeoutMs;
|
|
2973
|
+
do {
|
|
2974
|
+
const titleInput = await firstVisible(page, spec.title);
|
|
2975
|
+
const bodyInput = await firstVisible(page, spec.videoBody);
|
|
2976
|
+
if (titleInput && bodyInput) {
|
|
2977
|
+
return { titleInput, bodyInput };
|
|
2978
|
+
}
|
|
2979
|
+
const bodyText = await page.locator("body").innerText().catch(() => "");
|
|
2980
|
+
if (/上传失败|转码失败|文件格式不支持|视频处理失败/i.test(bodyText)) {
|
|
2981
|
+
throw new Error("B站视频上传或处理失败,请检查页面提示和视频文件");
|
|
2982
|
+
}
|
|
2983
|
+
await page.waitForTimeout(1000);
|
|
2984
|
+
} while (Date.now() < deadline);
|
|
2985
|
+
throw new Error("B站视频上传或处理超时,未进入稿件信息填写步骤");
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
async function applyBilibiliCategory(page, spec, category, optional = false) {
|
|
2989
|
+
if (!category) {
|
|
2990
|
+
return true;
|
|
2991
|
+
}
|
|
2992
|
+
let opener = await waitForVisible(page, spec.category, optional ? 500 : 3000);
|
|
2993
|
+
opener ||= await firstVisibleText(page, "请选择分区");
|
|
2994
|
+
if (!opener) {
|
|
2995
|
+
if (optional) {
|
|
2996
|
+
return false;
|
|
2997
|
+
}
|
|
2998
|
+
throw new Error("B站页面中未找到稿件分区控件");
|
|
2999
|
+
}
|
|
3000
|
+
await opener.click();
|
|
3001
|
+
const parts = category
|
|
3002
|
+
.split(/\s*(?:\/|>|>)\s*/)
|
|
3003
|
+
.map(item => item.trim())
|
|
3004
|
+
.filter(Boolean);
|
|
3005
|
+
for (const part of parts) {
|
|
3006
|
+
const option = await firstVisibleText(page, part);
|
|
3007
|
+
if (!option) {
|
|
3008
|
+
throw new Error(`B站投稿分区中未找到: ${part}`);
|
|
3009
|
+
}
|
|
3010
|
+
await option.click();
|
|
3011
|
+
await page.waitForTimeout(200);
|
|
3012
|
+
}
|
|
3013
|
+
return true;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
async function applyBilibiliTags(page, spec, tags, optional = false) {
|
|
3017
|
+
if (tags.length === 0) {
|
|
3018
|
+
return true;
|
|
3019
|
+
}
|
|
3020
|
+
if (tags.length > 10) {
|
|
3021
|
+
throw new Error(`B站投稿最多配置 10 个标签,当前为 ${tags.length} 个`);
|
|
3022
|
+
}
|
|
3023
|
+
const input = await waitForVisible(page, spec.tags, optional ? 500 : 3000);
|
|
3024
|
+
if (!input) {
|
|
3025
|
+
if (optional) {
|
|
3026
|
+
return false;
|
|
3027
|
+
}
|
|
3028
|
+
throw new Error("B站页面中未找到标签输入框");
|
|
3029
|
+
}
|
|
3030
|
+
for (const tag of tags) {
|
|
3031
|
+
await input.fill(tag);
|
|
3032
|
+
if (typeof input.press === "function") {
|
|
3033
|
+
await input.press("Enter");
|
|
3034
|
+
} else {
|
|
3035
|
+
await page.keyboard.press("Enter");
|
|
3036
|
+
}
|
|
3037
|
+
await page.waitForTimeout(100);
|
|
3038
|
+
}
|
|
3039
|
+
return true;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
export async function applyBilibiliTopic(page, spec, topic, optional = false) {
|
|
3043
|
+
if (!topic) {
|
|
3044
|
+
return true;
|
|
3045
|
+
}
|
|
3046
|
+
let input = await waitForVisible(page, spec.topic, optional ? 500 : 3000);
|
|
3047
|
+
if (!input) {
|
|
3048
|
+
const opener =
|
|
3049
|
+
(await firstVisibleText(page, "添加话题")) ||
|
|
3050
|
+
(await firstVisibleText(page, "选择话题"));
|
|
3051
|
+
if (opener) {
|
|
3052
|
+
await opener.click();
|
|
3053
|
+
input = await waitForVisible(page, spec.topic, 3000);
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
if (!input) {
|
|
3057
|
+
if (optional) {
|
|
3058
|
+
return false;
|
|
3059
|
+
}
|
|
3060
|
+
throw new Error("B站页面中未找到话题输入框");
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
await input.fill(topic);
|
|
3064
|
+
await page.waitForTimeout(300);
|
|
3065
|
+
const candidates = page.getByText(topic, { exact: true });
|
|
3066
|
+
let option = null;
|
|
3067
|
+
const count = await candidates.count();
|
|
3068
|
+
for (let index = 0; index < count; index += 1) {
|
|
3069
|
+
const candidate = candidates.nth(index);
|
|
3070
|
+
if (await candidate.isVisible().catch(() => false)) {
|
|
3071
|
+
option = candidate;
|
|
3072
|
+
break;
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
if (!option) {
|
|
3076
|
+
throw new Error(`B站话题列表中未找到: ${topic}`);
|
|
3077
|
+
}
|
|
3078
|
+
await option.click();
|
|
3079
|
+
await page.waitForTimeout(200);
|
|
3080
|
+
const selected = await firstVisibleText(page, topic);
|
|
3081
|
+
if (!selected) {
|
|
3082
|
+
throw new Error(`B站话题未正确选中: ${topic}`);
|
|
3083
|
+
}
|
|
3084
|
+
return true;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
async function uploadBilibiliCover(page, spec, source, articleFile, optional = false) {
|
|
3088
|
+
if (!source) {
|
|
3089
|
+
return true;
|
|
3090
|
+
}
|
|
3091
|
+
const input = await firstExisting(page, spec.coverInputs);
|
|
3092
|
+
if (!input || typeof input.setInputFiles !== "function") {
|
|
3093
|
+
if (optional) {
|
|
3094
|
+
return false;
|
|
3095
|
+
}
|
|
3096
|
+
throw new Error("B站页面中未找到封面上传控件");
|
|
3097
|
+
}
|
|
3098
|
+
await input.setInputFiles([await imageUploadPayload(source, articleFile, 0, "B站")]);
|
|
3099
|
+
await waitForUploadsSettled(page, "B站封面", 120000);
|
|
3100
|
+
return true;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
async function applyBilibiliMetadata(page, spec, settings, article, optional = false) {
|
|
3104
|
+
return {
|
|
3105
|
+
category: await applyBilibiliCategory(page, spec, settings.category, optional),
|
|
3106
|
+
tags: await applyBilibiliTags(page, spec, settings.tags, optional),
|
|
3107
|
+
topic: await applyBilibiliTopic(page, spec, settings.topic, optional),
|
|
3108
|
+
cover: await uploadBilibiliCover(page, spec, settings.cover, article.articleFile, optional)
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function bilibiliMutationResponse(response) {
|
|
3113
|
+
const request = response.request();
|
|
3114
|
+
if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) {
|
|
3115
|
+
return false;
|
|
3116
|
+
}
|
|
3117
|
+
const url = response.url();
|
|
3118
|
+
return (
|
|
3119
|
+
/(?:bilibili\.com|bilibili\.tv)/i.test(url) &&
|
|
3120
|
+
/article|archive|submit|publish|add|edit/i.test(url) &&
|
|
3121
|
+
!/upload|preupload|cover|image|tag|topic|log|track|report|event/i.test(url)
|
|
3122
|
+
);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
async function waitForBilibiliSubmission(page, initialUrl, mutationPromise, options) {
|
|
3126
|
+
if (options.skipSubmissionVerification) {
|
|
3127
|
+
return { confirmation: "test" };
|
|
3128
|
+
}
|
|
3129
|
+
const outcomes = [];
|
|
3130
|
+
if (typeof page.waitForURL === "function") {
|
|
3131
|
+
outcomes.push(
|
|
3132
|
+
page
|
|
3133
|
+
.waitForURL(
|
|
3134
|
+
url => {
|
|
3135
|
+
const current = url.toString();
|
|
3136
|
+
return current !== initialUrl && /upload-manager|archive|article\/list|platform\/home/i.test(current);
|
|
3137
|
+
},
|
|
3138
|
+
{ timeout: 30000 }
|
|
3139
|
+
)
|
|
3140
|
+
.then(() => ({ confirmation: "navigation" }))
|
|
3141
|
+
.catch(() => null)
|
|
3142
|
+
);
|
|
3143
|
+
}
|
|
3144
|
+
outcomes.push(
|
|
3145
|
+
waitForVisibleTextAcrossFrames(
|
|
3146
|
+
page,
|
|
3147
|
+
/投稿成功|发布成功|提交成功|稿件投递成功|已提交审核/i,
|
|
3148
|
+
30000,
|
|
3149
|
+
false
|
|
3150
|
+
).then(locator => (locator ? { confirmation: "message" } : null))
|
|
3151
|
+
);
|
|
3152
|
+
outcomes.push(
|
|
3153
|
+
mutationPromise.then(async response => {
|
|
3154
|
+
if (!response) {
|
|
3155
|
+
return null;
|
|
3156
|
+
}
|
|
3157
|
+
const payload = await response.json().catch(() => null);
|
|
3158
|
+
const message = normalizeText(payload?.message || payload?.msg);
|
|
3159
|
+
const successfulStatus = response.status() >= 200 && response.status() < 300;
|
|
3160
|
+
const successfulCode = !payload || payload.code === undefined || [0, 200, "0", "200"].includes(payload.code);
|
|
3161
|
+
if (!successfulStatus || !successfulCode || /失败|错误|fail|error/i.test(message)) {
|
|
3162
|
+
return { error: message || `接口返回 HTTP ${response.status()}` };
|
|
3163
|
+
}
|
|
3164
|
+
return { confirmation: "response", responseUrl: response.url() };
|
|
3165
|
+
})
|
|
3166
|
+
);
|
|
3167
|
+
outcomes.push(page.waitForTimeout(30500).then(() => null));
|
|
3168
|
+
|
|
3169
|
+
const pending = new Set(outcomes);
|
|
3170
|
+
while (pending.size > 0) {
|
|
3171
|
+
const tagged = [...pending].map(promise => promise.then(value => ({ promise, value })));
|
|
3172
|
+
const { promise, value } = await Promise.race(tagged);
|
|
3173
|
+
pending.delete(promise);
|
|
3174
|
+
if (value?.error) {
|
|
3175
|
+
throw new Error(`B站投稿失败: ${value.error}`);
|
|
3176
|
+
}
|
|
3177
|
+
if (value) {
|
|
3178
|
+
return value;
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
throw new Error("B站提交后未检测到成功回执,不能判定为发布成功");
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
async function submitBilibili(page, spec, settings, article, metadataApplied, options, promptUser) {
|
|
3185
|
+
let actionButton = await waitForVisible(page, spec.submitSelectors, 5000);
|
|
3186
|
+
actionButton ||= await waitForVisibleButton(page, [...spec.submit, /^下一步$/], 5000);
|
|
3187
|
+
if (!actionButton) {
|
|
3188
|
+
throw new Error("B站页面中未找到投稿按钮,页面结构可能已更新");
|
|
3189
|
+
}
|
|
3190
|
+
if (typeof actionButton.isDisabled === "function" && (await actionButton.isDisabled().catch(() => false))) {
|
|
3191
|
+
throw new Error("B站投稿按钮不可用,请检查分区、标签、封面和媒体上传状态");
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
const initialUrl = page.url();
|
|
3195
|
+
const mutationPromise =
|
|
3196
|
+
typeof page.waitForResponse === "function"
|
|
3197
|
+
? page.waitForResponse(bilibiliMutationResponse, { timeout: 30000 }).catch(() => null)
|
|
3198
|
+
: Promise.resolve(null);
|
|
3199
|
+
const actionText =
|
|
3200
|
+
typeof actionButton.innerText === "function" ? normalizeText(await actionButton.innerText().catch(() => "")) : "";
|
|
3201
|
+
await actionButton.click();
|
|
3202
|
+
await page.waitForTimeout(500);
|
|
3203
|
+
|
|
3204
|
+
if (settings.type === "article" && (settings.category || settings.tags.length > 0 || settings.topic || settings.cover)) {
|
|
3205
|
+
await applyBilibiliMetadata(
|
|
3206
|
+
page,
|
|
3207
|
+
spec,
|
|
3208
|
+
{
|
|
3209
|
+
...settings,
|
|
3210
|
+
category: metadataApplied.category ? "" : settings.category,
|
|
3211
|
+
tags: metadataApplied.tags ? [] : settings.tags,
|
|
3212
|
+
topic: metadataApplied.topic ? "" : settings.topic,
|
|
3213
|
+
cover: metadataApplied.cover ? "" : settings.cover
|
|
3214
|
+
},
|
|
3215
|
+
article
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
let confirmationButton = await waitForVisibleButton(page, [/^确认发布$/, /^确认投稿$/, /^确定提交$/], 2000);
|
|
3219
|
+
if (!confirmationButton && /^下一步$/.test(actionText)) {
|
|
3220
|
+
confirmationButton = await waitForVisibleButton(page, [/^发布$/, /^立即发布$/, /^提交文章$/, /^立即投稿$/], 3000);
|
|
3221
|
+
}
|
|
3222
|
+
if (confirmationButton) {
|
|
3223
|
+
if (typeof confirmationButton.isDisabled === "function" && (await confirmationButton.isDisabled().catch(() => false))) {
|
|
3224
|
+
throw new Error("B站最终提交按钮不可用,请检查投稿设置中的必填项");
|
|
3225
|
+
}
|
|
3226
|
+
await confirmationButton.click();
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
try {
|
|
3230
|
+
return await waitForBilibiliSubmission(page, initialUrl, mutationPromise, options);
|
|
3231
|
+
} catch (error) {
|
|
3232
|
+
if (options.keepOpen) {
|
|
3233
|
+
await promptUser(`${error.message},请在浏览器中检查页面提示`);
|
|
3234
|
+
}
|
|
3235
|
+
throw error;
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
async function publishBilibili(page, entry, article, spec, options, promptUser) {
|
|
3240
|
+
const settings = resolveBilibiliPublishingOptions(article);
|
|
3241
|
+
const editorUrl = bilibiliEditorUrl(entry.url, settings.type);
|
|
3242
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
3243
|
+
await afterNavigation(page);
|
|
3244
|
+
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings)))) {
|
|
3245
|
+
await ensureBrowserLogin(page, entry, spec, promptUser);
|
|
3246
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
3247
|
+
await afterNavigation(page);
|
|
3248
|
+
}
|
|
3249
|
+
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 15000))) {
|
|
3250
|
+
throw new Error(`B站登录完成后仍未进入${settings.typeLabel}投稿页,请确认账号拥有投稿权限`);
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
let titleInput;
|
|
3254
|
+
let bodyInput;
|
|
3255
|
+
if (settings.type === "video") {
|
|
3256
|
+
await uploadBilibiliVideo(page, settings.video, article.articleFile, spec);
|
|
3257
|
+
({ titleInput, bodyInput } = await waitForBilibiliVideoForm(page, spec));
|
|
3258
|
+
} else {
|
|
3259
|
+
titleInput = await waitForVisible(page, spec.title, 10000);
|
|
3260
|
+
bodyInput = await waitForVisible(page, spec.body, 10000);
|
|
3261
|
+
}
|
|
3262
|
+
if (!titleInput || !bodyInput) {
|
|
3263
|
+
throw new Error(`B站${settings.typeLabel}编辑器未完整加载,未找到标题或正文控件`);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
const body =
|
|
3267
|
+
settings.type === "video"
|
|
3268
|
+
? bilibiliVideoDescription(article)
|
|
3269
|
+
: buildBilibiliRichContent(article).plainText || plainTextForPlatform(article, "bilibili");
|
|
3270
|
+
await validateBilibiliFieldLength(titleInput, article.title, "标题");
|
|
3271
|
+
await validateBilibiliFieldLength(bodyInput, body, settings.type === "video" ? "简介" : "正文");
|
|
3272
|
+
await fillLocator(titleInput, article.title);
|
|
3273
|
+
await verifyFilledValue(titleInput, article.title, "标题", "B站");
|
|
3274
|
+
if (settings.type === "article") {
|
|
3275
|
+
await insertBilibiliRichContent(page, bodyInput, article, spec, body);
|
|
3276
|
+
} else {
|
|
3277
|
+
await fillLocator(bodyInput, body);
|
|
3278
|
+
await verifyFilledValue(bodyInput, body, "简介", "B站");
|
|
3279
|
+
await applyBilibiliMetadata(page, spec, settings, article);
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
const metadataApplied =
|
|
3283
|
+
settings.type === "article"
|
|
3284
|
+
? await applyBilibiliMetadata(page, spec, settings, article, true)
|
|
3285
|
+
: { category: true, tags: true, topic: true, cover: true };
|
|
3286
|
+
await reviewPreparedDraft(page, entry, options);
|
|
3287
|
+
const confirmation = await submitBilibili(page, spec, settings, article, metadataApplied, options, promptUser);
|
|
3288
|
+
if (options.keepOpen) {
|
|
3289
|
+
await promptUser(`B站${settings.typeLabel}投稿已收到成功回执,浏览器保持打开供检查`);
|
|
3290
|
+
}
|
|
3291
|
+
return {
|
|
3292
|
+
url: page.url(),
|
|
3293
|
+
status: "published",
|
|
3294
|
+
type: settings.type,
|
|
3295
|
+
...confirmation
|
|
3296
|
+
};
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
async function weiboArticleImageButton(page) {
|
|
3300
|
+
const imagePath = page.locator('svg path[d^="M18 4C18.6"]').first();
|
|
3301
|
+
if ((await imagePath.count()) === 0 || !(await imagePath.isVisible().catch(() => false))) {
|
|
3302
|
+
return null;
|
|
3303
|
+
}
|
|
3304
|
+
const button = imagePath.locator("xpath=../..");
|
|
3305
|
+
return (await button.count()) > 0 ? button : null;
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
async function weiboEditorIsSpinning(page) {
|
|
3309
|
+
if (typeof page.locator !== "function") {
|
|
3310
|
+
return false;
|
|
3311
|
+
}
|
|
3312
|
+
const spinner = page.locator(".n-spin-body.wb-editor-spin").first();
|
|
3313
|
+
return (await spinner.count().catch(() => 0)) > 0 && (await spinner.isVisible().catch(() => false));
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
async function ensureWeiboArticleDraft(page, articleTitle) {
|
|
3317
|
+
let createClicked = false;
|
|
3318
|
+
let matchingDraftClicked = false;
|
|
3319
|
+
let matchingDraftChecked = false;
|
|
3320
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
3321
|
+
const hasDraftId = /#\/draft\/[^/?#]+/.test(page.url());
|
|
3322
|
+
const spinning = await weiboEditorIsSpinning(page);
|
|
3323
|
+
if (hasDraftId && !spinning) {
|
|
3324
|
+
return matchingDraftClicked ? "resumed" : createClicked ? "created" : "existing";
|
|
3325
|
+
}
|
|
3326
|
+
if (!matchingDraftChecked && articleTitle && typeof page.getByText === "function") {
|
|
3327
|
+
matchingDraftChecked = true;
|
|
3328
|
+
const matches = page.getByText(articleTitle, { exact: true });
|
|
3329
|
+
const count = await matches.count().catch(() => 0);
|
|
3330
|
+
for (let index = 0; index < count; index += 1) {
|
|
3331
|
+
const candidate = matches.nth(index);
|
|
3332
|
+
if (!(await candidate.isVisible().catch(() => false))) continue;
|
|
3333
|
+
await candidate.click();
|
|
3334
|
+
matchingDraftClicked = true;
|
|
3335
|
+
break;
|
|
3336
|
+
}
|
|
3337
|
+
if (matchingDraftClicked) {
|
|
3338
|
+
await page.waitForTimeout(500);
|
|
3339
|
+
continue;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
if (!matchingDraftClicked && !createClicked) {
|
|
3343
|
+
const createButton = await firstVisibleButton(page, [/写文章/]);
|
|
3344
|
+
if (createButton) {
|
|
3345
|
+
await createButton.click();
|
|
3346
|
+
createClicked = true;
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
await page.waitForTimeout(250);
|
|
3350
|
+
}
|
|
3351
|
+
throw new Error("微博草稿箱为空且未能创建新头条文章草稿");
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
async function waitForWeiboArticleInlineImage(bodyInput, previousCount, source) {
|
|
3355
|
+
const deadline = Date.now() + 90000;
|
|
3356
|
+
let stableChecks = 0;
|
|
3357
|
+
do {
|
|
3358
|
+
const count = await bodyInput.locator("img:not(.ProseMirror-separator)").count();
|
|
3359
|
+
const failed = await bodyInput
|
|
3360
|
+
.locator('[class*="upload-fail"], [class*="upload-error"], [class*="retry"]')
|
|
3361
|
+
.count();
|
|
3362
|
+
const pending = await pendingUploadCount(bodyInput);
|
|
3363
|
+
if (failed > 0) {
|
|
3364
|
+
throw new Error(`微博文章正文图片上传失败: ${source}`);
|
|
3365
|
+
}
|
|
3366
|
+
if (count > previousCount && pending === 0) {
|
|
3367
|
+
stableChecks += 1;
|
|
3368
|
+
if (stableChecks >= 3) {
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
3371
|
+
} else {
|
|
3372
|
+
stableChecks = 0;
|
|
3373
|
+
}
|
|
3374
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
3375
|
+
} while (Date.now() < deadline);
|
|
3376
|
+
throw new Error(`微博文章正文图片插入超时: ${source}`);
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
async function weiboControlDisabled(control) {
|
|
3380
|
+
if (typeof control.isDisabled === "function" && (await control.isDisabled().catch(() => false))) {
|
|
3381
|
+
return true;
|
|
3382
|
+
}
|
|
3383
|
+
if (typeof control.evaluate !== "function") {
|
|
3384
|
+
return false;
|
|
3385
|
+
}
|
|
3386
|
+
return control
|
|
3387
|
+
.evaluate(element =>
|
|
3388
|
+
Boolean(element.disabled) ||
|
|
3389
|
+
element.getAttribute("aria-disabled") === "true" ||
|
|
3390
|
+
element.classList.contains("n-button--disabled") ||
|
|
3391
|
+
element.classList.contains("is-disabled")
|
|
3392
|
+
)
|
|
3393
|
+
.catch(() => false);
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
async function visibleWeiboCropDialog(page) {
|
|
3397
|
+
if (typeof page.locator !== "function") {
|
|
3398
|
+
return null;
|
|
3399
|
+
}
|
|
3400
|
+
const visibleDialogs = page.locator('[role="dialog"]:visible');
|
|
3401
|
+
if (typeof visibleDialogs.filter === "function") {
|
|
3402
|
+
const matching = visibleDialogs.filter({ hasText: /裁剪图片|图片裁剪|裁剪/ }).last();
|
|
3403
|
+
if ((await matching.count().catch(() => 0)) > 0 && (await matching.isVisible().catch(() => false))) {
|
|
3404
|
+
return matching;
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
const dialogs = page.locator('[role="dialog"]');
|
|
3408
|
+
const count = await dialogs.count().catch(() => 0);
|
|
3409
|
+
for (let index = 0; index < count; index += 1) {
|
|
3410
|
+
const dialog = dialogs.nth(index);
|
|
3411
|
+
if (!(await dialog.isVisible().catch(() => false))) {
|
|
3412
|
+
continue;
|
|
3413
|
+
}
|
|
3414
|
+
const text = typeof dialog.innerText === "function" ? await dialog.innerText().catch(() => "") : "";
|
|
3415
|
+
if (/裁剪图片|图片裁剪|裁剪/.test(text)) {
|
|
3416
|
+
return dialog;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
return null;
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
async function clearExistingWeiboArticleImages(page, scope) {
|
|
3423
|
+
const items = scope.locator(".image-list .image-item");
|
|
3424
|
+
let cleared = 0;
|
|
3425
|
+
while (cleared < 200) {
|
|
3426
|
+
const previousCount = await items.count().catch(() => 0);
|
|
3427
|
+
if (previousCount === 0) {
|
|
3428
|
+
return cleared;
|
|
3429
|
+
}
|
|
3430
|
+
const item = items.first();
|
|
3431
|
+
if (typeof item.hover === "function") {
|
|
3432
|
+
await item.hover().catch(() => {});
|
|
3433
|
+
}
|
|
3434
|
+
const remove = item.locator(".ico_delpic");
|
|
3435
|
+
if ((await remove.count().catch(() => 0)) === 0) {
|
|
3436
|
+
throw new Error("微博头条文章图片库中未找到旧图片删除控件");
|
|
3437
|
+
}
|
|
3438
|
+
await remove.click();
|
|
3439
|
+
const deadline = Date.now() + 10000;
|
|
3440
|
+
let currentCount = previousCount;
|
|
3441
|
+
do {
|
|
3442
|
+
currentCount = await items.count().catch(() => previousCount);
|
|
3443
|
+
if (currentCount < previousCount) {
|
|
3444
|
+
break;
|
|
3445
|
+
}
|
|
3446
|
+
const text = typeof scope.innerText === "function" ? await scope.innerText().catch(() => "") : "";
|
|
3447
|
+
if (/删除失败|操作失败|网络异常/.test(text)) {
|
|
3448
|
+
throw new Error("微博头条文章图片库旧图片删除失败");
|
|
3449
|
+
}
|
|
3450
|
+
await page.waitForTimeout(200);
|
|
3451
|
+
} while (Date.now() < deadline);
|
|
3452
|
+
if (currentCount >= previousCount) {
|
|
3453
|
+
throw new Error("微博头条文章图片库旧图片删除后未消失");
|
|
3454
|
+
}
|
|
3455
|
+
cleared += previousCount - currentCount;
|
|
3456
|
+
}
|
|
3457
|
+
throw new Error("微博头条文章图片库旧图片超过 200 张,已停止清理");
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, source, options = {}) {
|
|
3461
|
+
options.onProgress?.("正在打开图片库");
|
|
3462
|
+
const imageButton = await weiboArticleImageButton(page);
|
|
3463
|
+
if (!imageButton) {
|
|
3464
|
+
throw new Error("微博头条文章编辑器中未找到图片库按钮");
|
|
3465
|
+
}
|
|
3466
|
+
await imageButton.click();
|
|
3467
|
+
const input = await firstExisting(page, spec.imageInputs);
|
|
3468
|
+
if (!input || typeof input.setInputFiles !== "function") {
|
|
3469
|
+
throw new Error("微博头条文章图片库中未找到上传控件");
|
|
3470
|
+
}
|
|
3471
|
+
const modal = input.locator(
|
|
3472
|
+
'xpath=ancestor::*[.//*[normalize-space(text())="图片库"] and .//*[normalize-space(text())="插入"]][1]'
|
|
3473
|
+
);
|
|
3474
|
+
const scope = (await modal.count().catch(() => 0)) > 0 ? modal : page;
|
|
3475
|
+
const structuredLibrary = (await scope.locator(".image-list").count().catch(() => 0)) > 0;
|
|
3476
|
+
if (structuredLibrary && options.clearExistingLibrary) {
|
|
3477
|
+
options.onProgress?.("正在清理图片库旧图");
|
|
3478
|
+
await clearExistingWeiboArticleImages(page, scope);
|
|
3479
|
+
}
|
|
3480
|
+
const previews = structuredLibrary
|
|
3481
|
+
? scope.locator(".image-list .image-item")
|
|
3482
|
+
: scope.locator('img[src], [style*="background-image"]');
|
|
3483
|
+
const previousPreviews = await previews.count().catch(() => 0);
|
|
3484
|
+
const previousPreviewKeys =
|
|
3485
|
+
typeof previews.evaluateAll === "function"
|
|
3486
|
+
? await previews
|
|
3487
|
+
.evaluateAll(elements =>
|
|
3488
|
+
elements.map(element => {
|
|
3489
|
+
const image = element.matches("img") ? element : element.querySelector("img");
|
|
3490
|
+
return image?.currentSrc || image?.src || element.style.backgroundImage;
|
|
3491
|
+
})
|
|
3492
|
+
)
|
|
3493
|
+
.catch(() => [])
|
|
3494
|
+
: [];
|
|
3495
|
+
const previousImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count();
|
|
3496
|
+
options.onProgress?.("正在上传正文图片");
|
|
3497
|
+
await input.setInputFiles([payload]);
|
|
3498
|
+
|
|
3499
|
+
const uploadDeadline = Date.now() + 120000;
|
|
3500
|
+
let stablePreviews = 0;
|
|
3501
|
+
do {
|
|
3502
|
+
const modalText = typeof scope.innerText === "function" ? await scope.innerText().catch(() => "") : "";
|
|
3503
|
+
if (/上传失败|图片格式不支持|重新上传/.test(modalText)) {
|
|
3504
|
+
throw new Error(`微博文章图片上传失败: ${source}`);
|
|
3505
|
+
}
|
|
3506
|
+
const previewReady = (await previews.count().catch(() => previousPreviews)) > previousPreviews;
|
|
3507
|
+
const pending = await pendingUploadCount(scope);
|
|
3508
|
+
if (previewReady && pending === 0 && !/上传中|正在上传|处理中/.test(modalText)) {
|
|
3509
|
+
stablePreviews += 1;
|
|
3510
|
+
if (stablePreviews >= 3) {
|
|
3511
|
+
options.onProgress?.("正文图片上传完成,正在插入正文");
|
|
3512
|
+
break;
|
|
3513
|
+
}
|
|
3514
|
+
} else {
|
|
3515
|
+
stablePreviews = 0;
|
|
3516
|
+
}
|
|
3517
|
+
await page.waitForTimeout(250);
|
|
3518
|
+
if (Date.now() >= uploadDeadline) {
|
|
3519
|
+
throw new Error(`微博文章图片上传超时: ${source}`);
|
|
3520
|
+
}
|
|
3521
|
+
} while (true);
|
|
3522
|
+
|
|
3523
|
+
let uploadedPreview = previews.last();
|
|
3524
|
+
if (previousPreviewKeys.length > 0 && typeof previews.evaluateAll === "function") {
|
|
3525
|
+
const previewKeys = await previews
|
|
3526
|
+
.evaluateAll(elements =>
|
|
3527
|
+
elements.map(element => {
|
|
3528
|
+
const image = element.matches("img") ? element : element.querySelector("img");
|
|
3529
|
+
return image?.currentSrc || image?.src || element.style.backgroundImage;
|
|
3530
|
+
})
|
|
3531
|
+
)
|
|
3532
|
+
.catch(() => []);
|
|
3533
|
+
const uploadedIndex = previewKeys.findIndex(key => key && !previousPreviewKeys.includes(key));
|
|
3534
|
+
if (uploadedIndex >= 0) {
|
|
3535
|
+
uploadedPreview = previews.nth(uploadedIndex);
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
if ((await uploadedPreview.count()) > 0 && (await uploadedPreview.isVisible().catch(() => false))) {
|
|
3539
|
+
await uploadedPreview.click();
|
|
3540
|
+
} else {
|
|
3541
|
+
throw new Error(`微博文章图片上传完成后未找到可选缩略图: ${source}`);
|
|
3542
|
+
}
|
|
3543
|
+
await page.waitForTimeout(200);
|
|
3544
|
+
if (structuredLibrary && typeof uploadedPreview.evaluate === "function") {
|
|
3545
|
+
let selected = await uploadedPreview
|
|
3546
|
+
.evaluate(element => element.classList.contains("is-selected") || element.getAttribute("aria-selected") === "true")
|
|
3547
|
+
.catch(() => false);
|
|
3548
|
+
if (!selected) {
|
|
3549
|
+
await uploadedPreview.click({ force: true });
|
|
3550
|
+
await page.waitForTimeout(200);
|
|
3551
|
+
selected = await uploadedPreview
|
|
3552
|
+
.evaluate(element => element.classList.contains("is-selected") || element.getAttribute("aria-selected") === "true")
|
|
3553
|
+
.catch(() => false);
|
|
3554
|
+
}
|
|
3555
|
+
if (!selected) {
|
|
3556
|
+
throw new Error(`微博文章图片上传完成后未成功选中缩略图: ${source}`);
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
let insertButton = null;
|
|
3561
|
+
if (typeof scope.getByRole === "function") {
|
|
3562
|
+
const buttons = scope.getByRole("button", { name: "插入", exact: true });
|
|
3563
|
+
const count = await buttons.count().catch(() => 0);
|
|
3564
|
+
for (let index = count - 1; index >= 0; index -= 1) {
|
|
3565
|
+
const candidate = buttons.nth(index);
|
|
3566
|
+
if (await candidate.isVisible().catch(() => false)) {
|
|
3567
|
+
insertButton = candidate;
|
|
3568
|
+
break;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
const insertLabel = !insertButton && typeof scope.getByText === "function"
|
|
3573
|
+
? scope.getByText("插入", { exact: true }).last()
|
|
3574
|
+
: null;
|
|
3575
|
+
if (!insertButton && insertLabel && (await insertLabel.count()) > 0 && (await insertLabel.isVisible().catch(() => false))) {
|
|
3576
|
+
insertButton = insertLabel;
|
|
3577
|
+
}
|
|
3578
|
+
if (!insertButton) {
|
|
3579
|
+
throw new Error("微博头条文章图片库中未找到“插入”按钮");
|
|
3580
|
+
}
|
|
3581
|
+
if (insertButton === insertLabel && typeof insertLabel.locator === "function") {
|
|
3582
|
+
const control = insertLabel.locator('xpath=ancestor-or-self::*[self::button or @role="button"][1]');
|
|
3583
|
+
if ((await control.count().catch(() => 0)) > 0) {
|
|
3584
|
+
insertButton = control;
|
|
3585
|
+
}
|
|
3586
|
+
}
|
|
3587
|
+
for (let attempt = 0; attempt < 12 && (await weiboControlDisabled(insertButton)); attempt += 1) {
|
|
3588
|
+
await page.waitForTimeout(150);
|
|
3589
|
+
}
|
|
3590
|
+
if (await weiboControlDisabled(insertButton)) {
|
|
3591
|
+
throw new Error("微博头条文章图片上传后“插入”按钮仍不可用");
|
|
3592
|
+
}
|
|
3593
|
+
await insertButton.click();
|
|
3594
|
+
await page.waitForTimeout(300);
|
|
3595
|
+
if (scope !== page && typeof scope.isVisible === "function" && (await scope.isVisible().catch(() => false))) {
|
|
3596
|
+
const retryInsert = typeof scope.getByRole === "function"
|
|
3597
|
+
? scope.getByRole("button", { name: "插入", exact: true }).last()
|
|
3598
|
+
: insertButton;
|
|
3599
|
+
if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
|
|
3600
|
+
await retryInsert.click({ force: true });
|
|
3601
|
+
await page.waitForTimeout(300);
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
const cropDialog = await visibleWeiboCropDialog(page);
|
|
3606
|
+
if (cropDialog) {
|
|
3607
|
+
let cropInsert = null;
|
|
3608
|
+
if (typeof cropDialog.getByRole === "function") {
|
|
3609
|
+
cropInsert = cropDialog.getByRole("button", { name: /^(?:插入|确定|完成)$/ }).last();
|
|
3610
|
+
if ((await cropInsert.count().catch(() => 0)) === 0 || !(await cropInsert.isVisible().catch(() => false))) {
|
|
3611
|
+
cropInsert = null;
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
cropInsert ||= typeof cropDialog.getByText === "function"
|
|
3615
|
+
? cropDialog.getByText(/^(?:插入|确定|完成)$/).last()
|
|
3616
|
+
: null;
|
|
3617
|
+
if (!cropInsert) {
|
|
3618
|
+
throw new Error("微博图片裁剪页面中未找到默认插入按钮");
|
|
3619
|
+
}
|
|
3620
|
+
if (await weiboControlDisabled(cropInsert)) {
|
|
3621
|
+
throw new Error("微博图片默认裁剪尚未完成,无法插入正文");
|
|
3622
|
+
}
|
|
3623
|
+
let cropClosed = false;
|
|
3624
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
3625
|
+
options.onProgress?.(`正在等待正文图片裁剪就绪${attempt > 0 ? "(重试)" : ""}`);
|
|
3626
|
+
await waitForWeiboCropReady(page, cropDialog, cropInsert);
|
|
3627
|
+
options.onProgress?.("正在确认正文图片裁剪");
|
|
3628
|
+
await cropInsert.click({ force: true });
|
|
3629
|
+
for (let check = 0; check < 40; check += 1) {
|
|
3630
|
+
if (!(await cropDialog.isVisible().catch(() => false))) {
|
|
3631
|
+
cropClosed = true;
|
|
3632
|
+
break;
|
|
3633
|
+
}
|
|
3634
|
+
await page.waitForTimeout(250);
|
|
3635
|
+
}
|
|
3636
|
+
if (cropClosed) {
|
|
3637
|
+
break;
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
if (!cropClosed) {
|
|
3641
|
+
throw new Error("微博正文图片裁剪确认后弹窗未关闭");
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
await waitForWeiboArticleInlineImage(bodyInput, previousImages, source);
|
|
3645
|
+
options.onProgress?.("正文图片已插入");
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
export async function insertWeiboArticleRichContent(page, bodyInput, article, spec, fallbackText, options = {}) {
|
|
3649
|
+
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
3650
|
+
await fillLocator(bodyInput, fallbackText);
|
|
3651
|
+
await verifyFilledValue(bodyInput, fallbackText, "正文", "微博文章");
|
|
3652
|
+
return { rich: false, images: 0, links: 0 };
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
const content = buildWeiboRichContent(article);
|
|
3656
|
+
await bodyInput.fill("");
|
|
3657
|
+
const markerPrefix = `QCPLAY_WEIBO_IMAGE_${Date.now()}_`;
|
|
3658
|
+
const imageMarkers = [];
|
|
3659
|
+
const htmlParts = [];
|
|
3660
|
+
const plainParts = [];
|
|
3661
|
+
for (let index = 0; index < content.items.length; index += 1) {
|
|
3662
|
+
const item = content.items[index];
|
|
3663
|
+
if (item.type === "html") {
|
|
3664
|
+
htmlParts.push(item.html);
|
|
3665
|
+
plainParts.push(item.plain);
|
|
3666
|
+
continue;
|
|
3667
|
+
}
|
|
3668
|
+
const marker = `${markerPrefix}${imageMarkers.length}`;
|
|
3669
|
+
htmlParts.push(`<p>${marker}</p>`);
|
|
3670
|
+
plainParts.push(marker);
|
|
3671
|
+
imageMarkers.push({ marker, item, index });
|
|
3672
|
+
}
|
|
3673
|
+
if (htmlParts.length > 0) {
|
|
3674
|
+
await pasteRichHtml(bodyInput, { html: htmlParts.join(""), plain: plainParts.join("\n") });
|
|
3675
|
+
await page.waitForTimeout(100);
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
const payloadCache = new Map();
|
|
3679
|
+
for (let imageIndex = 0; imageIndex < imageMarkers.length; imageIndex += 1) {
|
|
3680
|
+
const { marker, item, index } = imageMarkers[imageIndex];
|
|
3681
|
+
const selected = await bodyInput.evaluate((element, markerValue) => {
|
|
3682
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
3683
|
+
let textNode = walker.nextNode();
|
|
3684
|
+
while (textNode) {
|
|
3685
|
+
const start = textNode.data.indexOf(markerValue);
|
|
3686
|
+
if (start >= 0) {
|
|
3687
|
+
const range = document.createRange();
|
|
3688
|
+
range.setStart(textNode, start);
|
|
3689
|
+
range.setEnd(textNode, start + markerValue.length);
|
|
3690
|
+
const selection = window.getSelection();
|
|
3691
|
+
selection.removeAllRanges();
|
|
3692
|
+
selection.addRange(range);
|
|
3693
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
3694
|
+
return true;
|
|
3695
|
+
}
|
|
3696
|
+
textNode = walker.nextNode();
|
|
3697
|
+
}
|
|
3698
|
+
return false;
|
|
3699
|
+
}, marker);
|
|
3700
|
+
if (!selected) {
|
|
3701
|
+
throw new Error(`微博文章正文图片占位位置丢失: ${item.source}`);
|
|
3702
|
+
}
|
|
3703
|
+
await page.keyboard.press("Backspace");
|
|
3704
|
+
let payload = payloadCache.get(item.source);
|
|
3705
|
+
if (!payload) {
|
|
3706
|
+
options.onProgress?.(`正在读取第 ${imageIndex + 1} 张正文图片`);
|
|
3707
|
+
payload = await imageUploadPayload(item.source, article.articleFile, index, "微博文章");
|
|
3708
|
+
payloadCache.set(item.source, payload);
|
|
3709
|
+
}
|
|
3710
|
+
await uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, item.source, {
|
|
3711
|
+
clearExistingLibrary: imageIndex === 0,
|
|
3712
|
+
onProgress: options.onProgress
|
|
3713
|
+
});
|
|
3714
|
+
}
|
|
3715
|
+
|
|
3716
|
+
const state = await bodyInput.evaluate(element => ({
|
|
3717
|
+
text: element.innerText,
|
|
3718
|
+
images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
|
|
3719
|
+
failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length,
|
|
3720
|
+
links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
|
|
3721
|
+
}));
|
|
3722
|
+
if (state.failedImages > 0 || state.images < content.images.length) {
|
|
3723
|
+
throw new Error(`微博文章正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
|
|
3724
|
+
}
|
|
3725
|
+
const actualText = normalizedTapTapText(state.text);
|
|
3726
|
+
if (actualText.includes(markerPrefix)) {
|
|
3727
|
+
throw new Error("微博文章正文图片占位符未正确替换");
|
|
3728
|
+
}
|
|
3729
|
+
for (const item of content.items) {
|
|
3730
|
+
if (item.type === "html" && item.plain && !actualText.includes(normalizedTapTapText(item.plain))) {
|
|
3731
|
+
throw new Error("微博文章正文文字未完整写入编辑器");
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
|
|
3735
|
+
for (const link of content.links) {
|
|
3736
|
+
if (!actualLinks.has(link.href)) {
|
|
3737
|
+
throw new Error(`微博文章正文超链接未正确写入: ${link.href}`);
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
return { rich: true, images: content.images.length, links: content.links.length, plainText: content.plainText };
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
async function waitForWeiboDialog(page, pattern, timeoutMs = 8000) {
|
|
3744
|
+
const deadline = Date.now() + timeoutMs;
|
|
3745
|
+
do {
|
|
3746
|
+
const visibleDialogs = page.locator('[role="dialog"]:visible');
|
|
3747
|
+
if (typeof visibleDialogs.filter === "function") {
|
|
3748
|
+
const matching = visibleDialogs.filter({ hasText: pattern }).last();
|
|
3749
|
+
if ((await matching.count().catch(() => 0)) > 0 && (await matching.isVisible().catch(() => false))) {
|
|
3750
|
+
return matching;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
const dialogs = page.locator('[role="dialog"]');
|
|
3754
|
+
const count = await dialogs.count().catch(() => 0);
|
|
3755
|
+
for (let index = count - 1; index >= 0; index -= 1) {
|
|
3756
|
+
const dialog = dialogs.nth(index);
|
|
3757
|
+
if (!(await dialog.isVisible().catch(() => false))) {
|
|
3758
|
+
continue;
|
|
3759
|
+
}
|
|
3760
|
+
const text = typeof dialog.innerText === "function" ? await dialog.innerText().catch(() => "") : "";
|
|
3761
|
+
if (pattern.test(text)) {
|
|
3762
|
+
return dialog;
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
await page.waitForTimeout(200);
|
|
3766
|
+
} while (Date.now() < deadline);
|
|
3767
|
+
return null;
|
|
3768
|
+
}
|
|
3769
|
+
|
|
3770
|
+
function weiboImageSourceKey(value) {
|
|
3771
|
+
return normalizeText(value).replace(/\\/g, "/").replace(/^(?:\.\/)+/, "");
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
async function waitForWeiboCropReady(page, cropDialog, confirmButton) {
|
|
3775
|
+
let stableChecks = 0;
|
|
3776
|
+
let previousSignature = "";
|
|
3777
|
+
const canInspectCropper = typeof cropDialog.evaluate === "function";
|
|
3778
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
3779
|
+
const cropState =
|
|
3780
|
+
canInspectCropper
|
|
3781
|
+
? await cropDialog
|
|
3782
|
+
.evaluate(element => {
|
|
3783
|
+
const cropImage = element.querySelector("cropper-image");
|
|
3784
|
+
const selection = element.querySelector("cropper-selection");
|
|
3785
|
+
if (!cropImage || !selection) {
|
|
3786
|
+
return { supported: false, ready: false };
|
|
3787
|
+
}
|
|
3788
|
+
const source = cropImage.getAttribute("src") || "";
|
|
3789
|
+
const selectionRect = selection.getBoundingClientRect();
|
|
3790
|
+
const width = Number(selection.getAttribute("width")) || selectionRect.width;
|
|
3791
|
+
const height = Number(selection.getAttribute("height")) || selectionRect.height;
|
|
3792
|
+
const transform = cropImage.style.transform || getComputedStyle(cropImage).transform;
|
|
3793
|
+
const spinning = Boolean(
|
|
3794
|
+
element.querySelector('.n-spin-body, [aria-busy="true"], [class*="loading"], [class*="processing"]')
|
|
3795
|
+
);
|
|
3796
|
+
return {
|
|
3797
|
+
supported: true,
|
|
3798
|
+
ready:
|
|
3799
|
+
Boolean(source) &&
|
|
3800
|
+
width > 0 &&
|
|
3801
|
+
height > 0 &&
|
|
3802
|
+
Boolean(transform && transform !== "none") &&
|
|
3803
|
+
!spinning,
|
|
3804
|
+
signature: `${source}|${Math.round(width)}x${Math.round(height)}|${Math.round(selectionRect.x)},${Math.round(selectionRect.y)}|${transform}`
|
|
3805
|
+
};
|
|
3806
|
+
})
|
|
3807
|
+
.catch(() => ({ supported: false, ready: false }))
|
|
3808
|
+
: { supported: false, ready: true };
|
|
3809
|
+
const ready = canInspectCropper
|
|
3810
|
+
? cropState.supported && cropState.ready
|
|
3811
|
+
: !(await weiboControlDisabled(confirmButton));
|
|
3812
|
+
if (ready && !(await weiboControlDisabled(confirmButton))) {
|
|
3813
|
+
if (!cropState.signature || cropState.signature === previousSignature) {
|
|
3814
|
+
stableChecks += 1;
|
|
3815
|
+
} else {
|
|
3816
|
+
stableChecks = 1;
|
|
3817
|
+
}
|
|
3818
|
+
previousSignature = cropState.signature || previousSignature;
|
|
3819
|
+
if (stableChecks >= 3) {
|
|
3820
|
+
return;
|
|
3821
|
+
}
|
|
3822
|
+
} else {
|
|
3823
|
+
stableChecks = 0;
|
|
3824
|
+
previousSignature = "";
|
|
3825
|
+
}
|
|
3826
|
+
await page.waitForTimeout(250);
|
|
3827
|
+
}
|
|
3828
|
+
throw new Error("微博文章封面裁剪图片未完成加载,无法确认");
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
async function visibleWeiboCoverProcessingError(page) {
|
|
3832
|
+
if (typeof page.getByText !== "function") {
|
|
3833
|
+
return false;
|
|
3834
|
+
}
|
|
3835
|
+
const message = page.getByText(/图片处理失败|裁剪失败|封面处理失败/).last();
|
|
3836
|
+
return (await message.count().catch(() => 0)) > 0 && (await message.isVisible().catch(() => false));
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
async function waitForWeiboCoverProcessingErrorToClear(page) {
|
|
3840
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
3841
|
+
if (!(await visibleWeiboCoverProcessingError(page))) {
|
|
3842
|
+
return;
|
|
3843
|
+
}
|
|
3844
|
+
await page.waitForTimeout(250);
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
|
|
3848
|
+
async function waitForWeiboCoverApplyOutcome(page, cropDialog, timeoutMs = 20000) {
|
|
3849
|
+
const deadline = Date.now() + timeoutMs;
|
|
3850
|
+
do {
|
|
3851
|
+
if (await visibleWeiboCoverProcessingError(page)) {
|
|
3852
|
+
return "processing-error";
|
|
3853
|
+
}
|
|
3854
|
+
const cropVisible = await cropDialog.isVisible().catch(() => false);
|
|
3855
|
+
const emptyCover = page.locator(".cover-empty").first();
|
|
3856
|
+
const emptyVisible =
|
|
3857
|
+
(await emptyCover.count().catch(() => 0)) > 0 && (await emptyCover.isVisible().catch(() => false));
|
|
3858
|
+
const dialogVisible = (await page.locator('[role="dialog"]:visible').count().catch(() => 0)) > 0;
|
|
3859
|
+
if (!cropVisible && !dialogVisible && !emptyVisible) {
|
|
3860
|
+
return "applied";
|
|
3861
|
+
}
|
|
3862
|
+
await page.waitForTimeout(250);
|
|
3863
|
+
} while (Date.now() < deadline);
|
|
3864
|
+
return "timeout";
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3867
|
+
export async function applyWeiboArticleCover(
|
|
3868
|
+
page,
|
|
3869
|
+
article,
|
|
3870
|
+
settings = resolveWeiboPublishingOptions(article),
|
|
3871
|
+
options = {}
|
|
3872
|
+
) {
|
|
3873
|
+
const images = buildWeiboRichContent(article).images;
|
|
3874
|
+
if (images.length === 0) {
|
|
3875
|
+
if (settings.cover) {
|
|
3876
|
+
throw new Error("weibo_cover 必须引用一张正文图片");
|
|
3877
|
+
}
|
|
3878
|
+
return { cover: false, coverSource: "" };
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
let coverIndex = 0;
|
|
3882
|
+
if (settings.cover) {
|
|
3883
|
+
const requested = weiboImageSourceKey(settings.cover);
|
|
3884
|
+
coverIndex = images.findIndex(image => weiboImageSourceKey(image.source) === requested);
|
|
3885
|
+
if (coverIndex < 0) {
|
|
3886
|
+
throw new Error("weibo_cover 必须引用正文中已有的图片");
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
let opener = await firstVisible(page, [".cover-empty"]);
|
|
3891
|
+
if (!opener && typeof page.getByText === "function") {
|
|
3892
|
+
const label = page.getByText("设置封面", { exact: true }).first();
|
|
3893
|
+
if ((await label.count().catch(() => 0)) > 0 && (await label.isVisible().catch(() => false))) {
|
|
3894
|
+
const section = label.locator("xpath=..");
|
|
3895
|
+
const candidate = section.locator('.cover-empty, [class*="cover"]').first();
|
|
3896
|
+
if ((await candidate.count().catch(() => 0)) > 0 && (await candidate.isVisible().catch(() => false))) {
|
|
3897
|
+
opener = candidate;
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
if (!opener) {
|
|
3902
|
+
throw new Error("微博文章发布设置中未找到封面入口");
|
|
3903
|
+
}
|
|
3904
|
+
options.onProgress?.("正在打开封面设置");
|
|
3905
|
+
await opener.click();
|
|
3906
|
+
|
|
3907
|
+
const chooser = await waitForWeiboDialog(page, /正文图片.*图片库|请从正文图片中选择一张作为封面图/s);
|
|
3908
|
+
if (!chooser) {
|
|
3909
|
+
throw new Error("微博文章封面选择弹窗未打开");
|
|
3910
|
+
}
|
|
3911
|
+
const choices = chooser.locator(".image-list .image-item");
|
|
3912
|
+
const choiceDeadline = Date.now() + 10000;
|
|
3913
|
+
while ((await choices.count().catch(() => 0)) <= coverIndex && Date.now() < choiceDeadline) {
|
|
3914
|
+
await page.waitForTimeout(200);
|
|
3915
|
+
}
|
|
3916
|
+
if ((await choices.count().catch(() => 0)) <= coverIndex) {
|
|
3917
|
+
throw new Error(`微博文章封面弹窗中未找到第 ${coverIndex + 1} 张正文图片`);
|
|
3918
|
+
}
|
|
3919
|
+
const choice = choices.nth(coverIndex);
|
|
3920
|
+
options.onProgress?.("正在选择封面图片");
|
|
3921
|
+
await choice.click();
|
|
3922
|
+
await page.waitForTimeout(200);
|
|
3923
|
+
let selected = await choice
|
|
3924
|
+
.evaluate(element => element.classList.contains("is-selected") || element.getAttribute("aria-selected") === "true")
|
|
3925
|
+
.catch(() => false);
|
|
3926
|
+
if (!selected) {
|
|
3927
|
+
await choice.click({ force: true });
|
|
3928
|
+
await page.waitForTimeout(200);
|
|
3929
|
+
selected = await choice
|
|
3930
|
+
.evaluate(element => element.classList.contains("is-selected") || element.getAttribute("aria-selected") === "true")
|
|
3931
|
+
.catch(() => false);
|
|
3932
|
+
}
|
|
3933
|
+
if (!selected) {
|
|
3934
|
+
throw new Error("微博文章封面图片未成功选中");
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
const nextButton = chooser.getByRole("button", { name: "下一步", exact: true }).last();
|
|
3938
|
+
if ((await nextButton.count().catch(() => 0)) === 0 || (await weiboControlDisabled(nextButton))) {
|
|
3939
|
+
throw new Error("微博文章封面选图后“下一步”按钮仍不可用");
|
|
3940
|
+
}
|
|
3941
|
+
await nextButton.click();
|
|
3942
|
+
|
|
3943
|
+
const cropDialog = await waitForWeiboDialog(page, /图片裁剪/);
|
|
3944
|
+
if (!cropDialog) {
|
|
3945
|
+
throw new Error("微博文章封面裁剪页面未打开");
|
|
3946
|
+
}
|
|
3947
|
+
const confirmButton = cropDialog.getByRole("button", { name: "确定", exact: true }).last();
|
|
3948
|
+
if ((await confirmButton.count().catch(() => 0)) === 0 || (await weiboControlDisabled(confirmButton))) {
|
|
3949
|
+
throw new Error("微博文章封面裁剪尚未完成,无法确认");
|
|
3950
|
+
}
|
|
3951
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
3952
|
+
options.onProgress?.(`正在等待封面裁剪就绪${attempt > 0 ? "(重试)" : ""}`);
|
|
3953
|
+
await waitForWeiboCropReady(page, cropDialog, confirmButton);
|
|
3954
|
+
options.onProgress?.("正在确认封面裁剪");
|
|
3955
|
+
await confirmButton.click({ force: true });
|
|
3956
|
+
const outcome = await waitForWeiboCoverApplyOutcome(page, cropDialog);
|
|
3957
|
+
if (outcome === "applied") {
|
|
3958
|
+
options.onProgress?.("封面已确认,正在等待图片处理完成");
|
|
3959
|
+
await waitForUploadsSettled(page, "微博文章封面", 120000);
|
|
3960
|
+
options.onProgress?.("封面处理完成");
|
|
3961
|
+
return { cover: true, coverSource: images[coverIndex].source, coverIndex };
|
|
3962
|
+
}
|
|
3963
|
+
if (outcome === "processing-error" && attempt === 0) {
|
|
3964
|
+
await waitForWeiboCoverProcessingErrorToClear(page);
|
|
3965
|
+
continue;
|
|
3966
|
+
}
|
|
3967
|
+
if (outcome === "timeout" && attempt === 0 && (await cropDialog.isVisible().catch(() => false))) {
|
|
3968
|
+
continue;
|
|
3969
|
+
}
|
|
3970
|
+
if (outcome === "processing-error") {
|
|
3971
|
+
throw new Error("微博文章封面图片处理失败,自动重试后仍未成功");
|
|
3972
|
+
}
|
|
3973
|
+
throw new Error("微博文章封面确认后未显示在发布设置中");
|
|
3974
|
+
}
|
|
3975
|
+
throw new Error("微博文章封面图片处理失败");
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
function weiboMutationResponse(response) {
|
|
3979
|
+
const request = response.request();
|
|
3980
|
+
if (!/^POST$/i.test(request.method())) {
|
|
3981
|
+
return false;
|
|
3982
|
+
}
|
|
3983
|
+
const url = response.url();
|
|
3984
|
+
return (
|
|
3985
|
+
/(?:^|\.)weibo\.com/i.test(new URL(url).hostname) &&
|
|
3986
|
+
/publish/i.test(url) &&
|
|
3987
|
+
!/upload|pic|image|log|track|report|event/i.test(url)
|
|
3988
|
+
);
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
async function waitForWeiboSubmission(page, initialUrl, mutationPromise, options) {
|
|
3992
|
+
if (options.skipSubmissionVerification) {
|
|
3993
|
+
return { confirmation: "test" };
|
|
3994
|
+
}
|
|
3995
|
+
const outcomes = [];
|
|
3996
|
+
if (typeof page.waitForURL === "function") {
|
|
3997
|
+
outcomes.push(
|
|
3998
|
+
page
|
|
3999
|
+
.waitForURL(
|
|
4000
|
+
url => url.toString() !== initialUrl && /article.*(?:history|publish|show)|ttarticle/i.test(url.toString()),
|
|
4001
|
+
{ timeout: 30000 }
|
|
4002
|
+
)
|
|
4003
|
+
.then(() => ({ confirmation: "navigation" }))
|
|
4004
|
+
.catch(() => null)
|
|
4005
|
+
);
|
|
4006
|
+
}
|
|
4007
|
+
if (typeof page.getByText === "function") {
|
|
4008
|
+
outcomes.push(
|
|
4009
|
+
page
|
|
4010
|
+
.getByText(/发布成功|提交成功|文章发布成功|已提交审核/i)
|
|
4011
|
+
.first()
|
|
4012
|
+
.waitFor({ state: "visible", timeout: 30000 })
|
|
4013
|
+
.then(() => ({ confirmation: "message" }))
|
|
4014
|
+
.catch(() => null)
|
|
4015
|
+
);
|
|
4016
|
+
}
|
|
4017
|
+
outcomes.push(
|
|
4018
|
+
mutationPromise.then(async response => {
|
|
4019
|
+
if (!response) {
|
|
4020
|
+
return null;
|
|
4021
|
+
}
|
|
4022
|
+
const payload = await response.json().catch(() => null);
|
|
4023
|
+
const message = normalizeText(payload?.message || payload?.msg || payload?.error);
|
|
4024
|
+
const successfulStatus = response.status() >= 200 && response.status() < 300;
|
|
4025
|
+
const failedPayload =
|
|
4026
|
+
payload?.ok === 0 ||
|
|
4027
|
+
payload?.success === false ||
|
|
4028
|
+
(payload?.code !== undefined && ![0, 1, 200, 100000, "0", "1", "200", "100000"].includes(payload.code)) ||
|
|
4029
|
+
/失败|错误|fail|error/i.test(message);
|
|
4030
|
+
if (!successfulStatus || failedPayload) {
|
|
4031
|
+
return { error: message || `接口返回 HTTP ${response.status()}` };
|
|
4032
|
+
}
|
|
4033
|
+
return { confirmation: "response", responseUrl: response.url() };
|
|
4034
|
+
})
|
|
4035
|
+
);
|
|
4036
|
+
outcomes.push(page.waitForTimeout(30500).then(() => null));
|
|
4037
|
+
|
|
4038
|
+
const pending = new Set(outcomes);
|
|
4039
|
+
while (pending.size > 0) {
|
|
4040
|
+
const tagged = [...pending].map(promise => promise.then(value => ({ promise, value })));
|
|
4041
|
+
const { promise, value } = await Promise.race(tagged);
|
|
4042
|
+
pending.delete(promise);
|
|
4043
|
+
if (value?.error) {
|
|
4044
|
+
throw new Error(`微博文章发布失败: ${value.error}`);
|
|
4045
|
+
}
|
|
4046
|
+
if (value) {
|
|
4047
|
+
return value;
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
throw new Error("微博文章提交后未检测到成功回执,不能判定为发布成功");
|
|
4051
|
+
}
|
|
4052
|
+
|
|
4053
|
+
async function fillStableWeiboField(page, selectors, value, label) {
|
|
4054
|
+
const expected = normalizeText(value).replace(/\r\n/g, "\n");
|
|
4055
|
+
let lastActual = "";
|
|
4056
|
+
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
4057
|
+
const input = await waitForVisible(page, selectors, 3000);
|
|
4058
|
+
if (!input) {
|
|
4059
|
+
break;
|
|
4060
|
+
}
|
|
4061
|
+
try {
|
|
4062
|
+
await fillLocator(input, value);
|
|
4063
|
+
await page.waitForTimeout(250);
|
|
4064
|
+
const current = await firstVisible(page, selectors);
|
|
4065
|
+
if (!current) {
|
|
4066
|
+
continue;
|
|
4067
|
+
}
|
|
4068
|
+
if (typeof current.inputValue === "function") {
|
|
4069
|
+
lastActual = await current.inputValue().catch(() => "");
|
|
4070
|
+
} else if (typeof current.innerText === "function") {
|
|
4071
|
+
lastActual = await current.innerText().catch(() => "");
|
|
4072
|
+
}
|
|
4073
|
+
if (normalizeText(lastActual).replace(/\r\n/g, "\n") === expected) {
|
|
4074
|
+
return current;
|
|
4075
|
+
}
|
|
4076
|
+
} catch (error) {
|
|
4077
|
+
if (!isNavigationContextError(error)) {
|
|
4078
|
+
throw error;
|
|
4079
|
+
}
|
|
4080
|
+
await afterNavigation(page);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
const actualHint = normalizeText(lastActual) ? `,实际为“${normalizeText(lastActual).slice(0, 40)}”` : ",实际为空";
|
|
4084
|
+
throw new Error(`微博文章 ${label}未正确写入编辑器${actualHint}`);
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
async function weiboSecurityChallengeVisible(page) {
|
|
4088
|
+
if (/security\.weibo\.com\/captcha|geetest/i.test(page.url())) {
|
|
4089
|
+
return true;
|
|
4090
|
+
}
|
|
4091
|
+
if (typeof page.getByText !== "function") {
|
|
4092
|
+
return false;
|
|
4093
|
+
}
|
|
4094
|
+
const challenge = page.getByText(/请先验证身份|向右拖动滑块填充拼图|安全验证/).first();
|
|
4095
|
+
return (await challenge.count().catch(() => 0)) > 0 && (await challenge.isVisible().catch(() => false));
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
async function waitForWeiboSecurityVerification(page, progress) {
|
|
4099
|
+
if (!(await weiboSecurityChallengeVisible(page))) {
|
|
4100
|
+
return false;
|
|
4101
|
+
}
|
|
4102
|
+
progress("微博触发身份安全验证,请在浏览器中完成滑块验证");
|
|
4103
|
+
const deadline = Date.now() + 10 * 60 * 1000;
|
|
4104
|
+
do {
|
|
4105
|
+
if (!(await weiboSecurityChallengeVisible(page))) {
|
|
4106
|
+
await afterNavigation(page);
|
|
4107
|
+
progress("微博身份验证已完成,正在继续发布");
|
|
4108
|
+
return true;
|
|
4109
|
+
}
|
|
4110
|
+
await page.waitForTimeout(500);
|
|
4111
|
+
} while (Date.now() < deadline);
|
|
4112
|
+
throw new Error("微博身份安全验证等待超时,请完成滑块验证后重试");
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
4116
|
+
const progress = message => options.onProgress?.(message);
|
|
4117
|
+
const settings = resolveWeiboPublishingOptions(article);
|
|
4118
|
+
const body = buildWeiboText(article);
|
|
4119
|
+
if (!body) {
|
|
4120
|
+
throw new Error("微博文章正文不能为空");
|
|
4121
|
+
}
|
|
4122
|
+
if (article.title.length > 32) {
|
|
4123
|
+
throw new Error(`微博文章标题最多 32 个字符,当前为 ${article.title.length} 个字符`);
|
|
4124
|
+
}
|
|
4125
|
+
if (settings.intro.length > 44) {
|
|
4126
|
+
throw new Error(`微博文章导语最多 44 个字符,当前为 ${settings.intro.length} 个字符`);
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
const editorUrl = weiboArticleEditorUrl(entry.url);
|
|
4130
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4131
|
+
await afterNavigation(page);
|
|
4132
|
+
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec)))) {
|
|
4133
|
+
await ensureBrowserLogin(page, entry, spec, promptUser);
|
|
4134
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4135
|
+
await afterNavigation(page);
|
|
4136
|
+
}
|
|
4137
|
+
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 15000))) {
|
|
4138
|
+
throw new Error("微博登录完成后仍未进入头条文章编辑器,请确认账号拥有文章发布权限");
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
const draftMode = await ensureWeiboArticleDraft(page, article.title);
|
|
4142
|
+
progress(
|
|
4143
|
+
draftMode === "resumed"
|
|
4144
|
+
? "已复用最新同标题微博草稿,正在重写文章内容"
|
|
4145
|
+
: "微博草稿已就绪,正在重写文章内容"
|
|
4146
|
+
);
|
|
4147
|
+
await page.waitForTimeout(1000);
|
|
4148
|
+
const titleInput = await waitForVisible(page, spec.title, 8000);
|
|
4149
|
+
const bodyInput = await waitForVisible(page, spec.body, 8000);
|
|
4150
|
+
if (!titleInput || !bodyInput) {
|
|
4151
|
+
throw new Error("微博头条文章编辑器未完整加载,未找到标题或正文控件");
|
|
4152
|
+
}
|
|
4153
|
+
await fillStableWeiboField(page, spec.title, article.title, "标题");
|
|
4154
|
+
const introInput = await waitForVisible(page, spec.intro, 3000);
|
|
4155
|
+
if (introInput) {
|
|
4156
|
+
// Empty input is intentional: drafts may retain the previous article's intro.
|
|
4157
|
+
await fillStableWeiboField(page, spec.intro, settings.intro, "导语");
|
|
4158
|
+
} else if (settings.intro) {
|
|
4159
|
+
throw new Error("微博头条文章编辑器中未找到导语输入框");
|
|
4160
|
+
}
|
|
4161
|
+
const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
|
|
4162
|
+
const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, article, spec, body, {
|
|
4163
|
+
onProgress: progress
|
|
4164
|
+
});
|
|
4165
|
+
progress("正文已写入,正在设置封面");
|
|
4166
|
+
const cover = await applyWeiboArticleCover(page, article, settings, { onProgress: progress });
|
|
4167
|
+
|
|
4168
|
+
const nextButton = await waitForVisibleButton(page, spec.next, 5000);
|
|
4169
|
+
if (!nextButton) {
|
|
4170
|
+
throw new Error("微博头条文章编辑器中未找到“下一步”按钮");
|
|
4171
|
+
}
|
|
4172
|
+
if (typeof nextButton.isDisabled === "function" && (await nextButton.isDisabled().catch(() => false))) {
|
|
4173
|
+
throw new Error("微博文章“下一步”按钮不可用,请检查标题、正文和封面设置");
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
await reviewPreparedDraft(page, entry, options);
|
|
4177
|
+
const initialUrl = page.url();
|
|
4178
|
+
progress("正在进入发布确认页");
|
|
4179
|
+
await nextButton.click();
|
|
4180
|
+
let submitButton = null;
|
|
4181
|
+
let securityDetected = false;
|
|
4182
|
+
const confirmationDeadline = Date.now() + 10000;
|
|
4183
|
+
do {
|
|
4184
|
+
if (await weiboSecurityChallengeVisible(page)) {
|
|
4185
|
+
securityDetected = true;
|
|
4186
|
+
break;
|
|
4187
|
+
}
|
|
4188
|
+
submitButton = await firstVisibleButton(page, spec.submit);
|
|
4189
|
+
if (submitButton) break;
|
|
4190
|
+
await page.waitForTimeout(250);
|
|
4191
|
+
} while (Date.now() < confirmationDeadline);
|
|
4192
|
+
if (securityDetected) {
|
|
4193
|
+
await waitForWeiboSecurityVerification(page, progress);
|
|
4194
|
+
submitButton = await waitForVisibleButton(page, spec.submit, 3000);
|
|
4195
|
+
if (!submitButton) {
|
|
4196
|
+
const resumedNext = await waitForVisibleButton(page, spec.next, 8000);
|
|
4197
|
+
if (!resumedNext) {
|
|
4198
|
+
throw new Error("微博身份验证完成后未返回文章发布页面");
|
|
4199
|
+
}
|
|
4200
|
+
progress("身份验证完成,正在重新进入发布确认页");
|
|
4201
|
+
await resumedNext.click();
|
|
4202
|
+
await page.waitForTimeout(1000);
|
|
4203
|
+
await waitForWeiboSecurityVerification(page, progress);
|
|
4204
|
+
submitButton = await waitForVisibleButton(page, spec.submit, 5000);
|
|
4205
|
+
}
|
|
4206
|
+
}
|
|
4207
|
+
if (submitButton) {
|
|
4208
|
+
if (typeof submitButton.isDisabled === "function" && (await submitButton.isDisabled().catch(() => false))) {
|
|
4209
|
+
throw new Error("微博文章最终发布按钮不可用,请检查发布设置");
|
|
4210
|
+
}
|
|
4211
|
+
const mutationPromise =
|
|
4212
|
+
typeof page.waitForResponse === "function"
|
|
4213
|
+
? page.waitForResponse(weiboMutationResponse, { timeout: 30000 }).catch(() => null)
|
|
4214
|
+
: Promise.resolve(null);
|
|
4215
|
+
progress("正在提交微博头条文章");
|
|
4216
|
+
await submitButton.click();
|
|
4217
|
+
progress("已提交,正在等待微博成功回执");
|
|
4218
|
+
const confirmation = await waitForWeiboSubmission(page, initialUrl, mutationPromise, options);
|
|
4219
|
+
progress("已收到微博发布成功回执");
|
|
4220
|
+
if (options.keepOpen) {
|
|
4221
|
+
await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
|
|
4222
|
+
}
|
|
4223
|
+
return { url: page.url(), status: "published", type: "article", ...richContent, ...cover, ...confirmation };
|
|
4224
|
+
}
|
|
4225
|
+
throw new Error("微博发布确认页中未找到最终发布按钮");
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
async function launchBrowserContext(entry, browserChannel) {
|
|
4229
|
+
const { chromium } = await import("playwright-core");
|
|
4230
|
+
const profileDir = path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
|
|
4231
|
+
await fs.promises.mkdir(profileDir, { recursive: true, mode: 0o700 });
|
|
4232
|
+
const channel = browserChannel || process.env.QCPLAY_BROWSER_CHANNEL || (process.platform === "win32" ? "msedge" : "chrome");
|
|
4233
|
+
return chromium.launchPersistentContext(profileDir, {
|
|
4234
|
+
channel,
|
|
4235
|
+
headless: false,
|
|
4236
|
+
slowMo: 50,
|
|
4237
|
+
viewport: null
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
|
|
4241
|
+
function haoyouMutationResponse(response) {
|
|
4242
|
+
const request = response.request();
|
|
4243
|
+
if (!/^POST$/i.test(request.method())) {
|
|
4244
|
+
return false;
|
|
4245
|
+
}
|
|
4246
|
+
try {
|
|
4247
|
+
const url = new URL(response.url());
|
|
4248
|
+
return (
|
|
4249
|
+
/(?:^|\.)bbs\.3839\.com$/i.test(url.hostname) &&
|
|
4250
|
+
url.searchParams.get("m") === "api" &&
|
|
4251
|
+
url.searchParams.get("c") === "thread" &&
|
|
4252
|
+
url.searchParams.get("a") === "setThread"
|
|
4253
|
+
);
|
|
4254
|
+
} catch {
|
|
4255
|
+
return false;
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
async function waitForHaoyouSubmission(page, initialUrl, mutationPromise, options) {
|
|
4260
|
+
if (options.skipSubmissionVerification) {
|
|
4261
|
+
return { confirmation: "test" };
|
|
4262
|
+
}
|
|
4263
|
+
const outcomes = [];
|
|
4264
|
+
if (typeof page.waitForURL === "function") {
|
|
4265
|
+
outcomes.push(
|
|
4266
|
+
page
|
|
4267
|
+
.waitForURL(
|
|
4268
|
+
url => {
|
|
4269
|
+
const current = url.toString();
|
|
4270
|
+
return current !== initialUrl && /(?:forum|thread)-\d+\.htm(?:[?#]|$)/i.test(current);
|
|
4271
|
+
},
|
|
4272
|
+
{ timeout: 15000 }
|
|
4273
|
+
)
|
|
4274
|
+
.then(() => ({ confirmation: "navigation" }))
|
|
4275
|
+
.catch(() => null)
|
|
4276
|
+
);
|
|
4277
|
+
}
|
|
4278
|
+
outcomes.push(
|
|
4279
|
+
waitForVisibleTextAcrossFrames(page, /发布成功|发帖成功|提交成功/i, 15000, false).then(locator =>
|
|
4280
|
+
locator ? { confirmation: "message" } : null
|
|
4281
|
+
)
|
|
4282
|
+
);
|
|
4283
|
+
outcomes.push(
|
|
4284
|
+
mutationPromise.then(async response => {
|
|
4285
|
+
if (!response) {
|
|
4286
|
+
return null;
|
|
4287
|
+
}
|
|
4288
|
+
const payload = await response.json().catch(() => null);
|
|
4289
|
+
const message = normalizeText(payload?.msg || payload?.message);
|
|
4290
|
+
if (response.status() < 200 || response.status() >= 300) {
|
|
4291
|
+
return { error: message || `接口返回 HTTP ${response.status()}` };
|
|
4292
|
+
}
|
|
4293
|
+
if (!payload || Number(payload.code) !== 100) {
|
|
4294
|
+
return { error: message || `接口返回 code ${payload?.code ?? "未知"}` };
|
|
4295
|
+
}
|
|
4296
|
+
return { confirmation: "response", responseUrl: response.url() };
|
|
4297
|
+
})
|
|
4298
|
+
);
|
|
4299
|
+
outcomes.push(page.waitForTimeout(15500).then(() => null));
|
|
4300
|
+
|
|
4301
|
+
const pending = new Set(outcomes);
|
|
4302
|
+
while (pending.size > 0) {
|
|
4303
|
+
const tagged = [...pending].map(promise => promise.then(value => ({ promise, value })));
|
|
4304
|
+
const { promise, value } = await Promise.race(tagged);
|
|
4305
|
+
pending.delete(promise);
|
|
4306
|
+
if (value?.error) {
|
|
4307
|
+
throw new Error(`好游快爆发布失败: ${value.error}`);
|
|
4308
|
+
}
|
|
4309
|
+
if (value) {
|
|
4310
|
+
return value;
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
throw new Error("好游快爆提交后未检测到成功回执,不能判定为发布成功");
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
export async function publishWithBrowser(entry, article, options = {}) {
|
|
4317
|
+
const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
|
|
4318
|
+
if (!spec) {
|
|
4319
|
+
throw new Error(`${entry.platform} 尚无可用的 API 或浏览器适配器`);
|
|
4320
|
+
}
|
|
4321
|
+
if (!entry.url) {
|
|
4322
|
+
throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
|
|
4323
|
+
}
|
|
4324
|
+
|
|
4325
|
+
const body =
|
|
4326
|
+
entry.platformKey === "haoyou"
|
|
4327
|
+
? buildHaoyouRichContent(article).plainText || plainTextForPlatform(article, entry.platformKey)
|
|
4328
|
+
: plainTextForPlatform(article, entry.platformKey);
|
|
4329
|
+
if (entry.platformKey === "x" && body.length > 280) {
|
|
4330
|
+
throw new Error(`X 内容共 ${body.length} 个字符,超过 280 字符限制`);
|
|
4331
|
+
}
|
|
4332
|
+
if (entry.platformKey === "haoyou" && Array.from(article.title).length > 48) {
|
|
4333
|
+
throw new Error(`好游快爆标题最多 48 个字符,当前为 ${Array.from(article.title).length} 个字符`);
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
const ownsContext = !options.context;
|
|
4337
|
+
const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
|
|
4338
|
+
const pages = context.pages();
|
|
4339
|
+
const page = pages[0] || (await context.newPage());
|
|
4340
|
+
const promptUser = options.waitForUser || (message => waitForUser(message, options.streams));
|
|
4341
|
+
let haoyouEditorOpened = false;
|
|
4342
|
+
try {
|
|
4343
|
+
if (entry.platformKey === "taptap") {
|
|
4344
|
+
return await publishTapTap(page, entry, article, spec, options, promptUser);
|
|
4345
|
+
}
|
|
4346
|
+
if (entry.platformKey === "bilibili") {
|
|
4347
|
+
return await publishBilibili(page, entry, article, spec, options, promptUser);
|
|
4348
|
+
}
|
|
4349
|
+
if (entry.platformKey === "weibo") {
|
|
4350
|
+
return await publishWeibo(page, entry, article, spec, options, promptUser);
|
|
4351
|
+
}
|
|
4352
|
+
const editorUrl = entry.platformKey === "haoyou" ? haoyouEditorUrl(entry.url) : entry.url;
|
|
4353
|
+
let editorReady = false;
|
|
4354
|
+
let loginHandled = false;
|
|
4355
|
+
const attempts = entry.platformKey === "haoyou" ? 3 : 2;
|
|
4356
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
4357
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4358
|
+
await afterNavigation(page);
|
|
4359
|
+
const readyTimeout = attempt === 0 && spec.loginPage ? 3000 : 15000;
|
|
4360
|
+
if (await waitForEditorReady(page, () => editorIsReady(page, spec), readyTimeout)) {
|
|
4361
|
+
editorReady = true;
|
|
4362
|
+
break;
|
|
4363
|
+
}
|
|
4364
|
+
if (!loginHandled) {
|
|
4365
|
+
await ensureBrowserLogin(page, entry, spec, promptUser);
|
|
4366
|
+
loginHandled = true;
|
|
4367
|
+
} else {
|
|
4368
|
+
await page.waitForTimeout(500);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
4371
|
+
if (!editorReady) {
|
|
4372
|
+
const targetHint = entry.platformKey === "haoyou" ? `(目标地址: ${editorUrl})` : "";
|
|
4373
|
+
throw new Error(`${entry.platform} 登录完成后仍未进入发布编辑器${targetHint},请确认账号拥有发布权限`);
|
|
4374
|
+
}
|
|
4375
|
+
haoyouEditorOpened = entry.platformKey === "haoyou";
|
|
4376
|
+
|
|
4377
|
+
const titleInput = await waitForVisible(page, spec.title, 5000);
|
|
4378
|
+
if (spec.title && !titleInput) {
|
|
4379
|
+
throw new Error(`${entry.platform} 页面中未找到标题输入框,页面结构可能已更新`);
|
|
4380
|
+
}
|
|
4381
|
+
if (titleInput) {
|
|
4382
|
+
if (entry.platformKey === "haoyou") {
|
|
4383
|
+
await fillStableHaoyouValue(page, titleInput, article.title, "标题");
|
|
4384
|
+
} else {
|
|
4385
|
+
await fillLocator(titleInput, article.title);
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
const bodyInput = await waitForVisible(page, spec.body, 5000);
|
|
4390
|
+
if (!bodyInput) {
|
|
4391
|
+
throw new Error(`${entry.platform} 页面中未找到正文编辑器,页面结构可能已更新`);
|
|
4392
|
+
}
|
|
4393
|
+
let richContent = null;
|
|
4394
|
+
if (entry.platformKey === "haoyou") {
|
|
4395
|
+
richContent = await insertHaoyouRichContent(page, bodyInput, article, body);
|
|
4396
|
+
} else {
|
|
4397
|
+
await fillLocator(bodyInput, body);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
let submitButton = await waitForVisible(page, spec.submitSelectors, 5000);
|
|
4401
|
+
submitButton ||= await waitForVisibleButton(page, spec.submit, 5000);
|
|
4402
|
+
if (!submitButton) {
|
|
4403
|
+
if (entry.platformKey === "haoyou" && (await firstVisible(page, [".editSubmit"]))) {
|
|
4404
|
+
throw new Error("好游快爆标题和正文已写入,但发布按钮仍不可用,请检查发布设置");
|
|
4405
|
+
}
|
|
4406
|
+
await promptUser(`${entry.platform} 页面中未找到发布按钮,正文已填入,请检查并手动发布`);
|
|
4407
|
+
return { url: page.url(), manualSubmit: true };
|
|
4408
|
+
}
|
|
4409
|
+
await reviewPreparedDraft(page, entry, options);
|
|
4410
|
+
if (entry.platformKey === "haoyou") {
|
|
4411
|
+
const initialUrl = page.url();
|
|
4412
|
+
const mutationPromise =
|
|
4413
|
+
typeof page.waitForResponse === "function"
|
|
4414
|
+
? page.waitForResponse(haoyouMutationResponse, { timeout: 15000 }).catch(() => null)
|
|
4415
|
+
: Promise.resolve(null);
|
|
4416
|
+
await submitButton.click();
|
|
4417
|
+
const receipt = await waitForHaoyouSubmission(page, initialUrl, mutationPromise, options);
|
|
4418
|
+
if (options.keepOpen) {
|
|
4419
|
+
await promptUser(`${entry.platform} 已确认发布成功,浏览器保持打开供检查`);
|
|
4420
|
+
}
|
|
4421
|
+
return { url: page.url(), status: "published", ...richContent, ...receipt };
|
|
4422
|
+
}
|
|
4423
|
+
await submitButton.click();
|
|
4424
|
+
await page.waitForTimeout(3000);
|
|
4425
|
+
if (options.keepOpen) {
|
|
4426
|
+
await promptUser(`${entry.platform} 已提交,浏览器保持打开供检查`);
|
|
4427
|
+
}
|
|
4428
|
+
return { url: page.url() };
|
|
4429
|
+
} catch (error) {
|
|
4430
|
+
if (entry.platformKey === "haoyou" && ownsContext && haoyouEditorOpened) {
|
|
4431
|
+
await promptUser(`${error.message},浏览器将保持打开供检查`).catch(() => {});
|
|
4432
|
+
}
|
|
4433
|
+
throw error;
|
|
4434
|
+
} finally {
|
|
4435
|
+
await context.close().catch(() => {});
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
export async function publishPlatformEntry(entry, article, options = {}) {
|
|
4440
|
+
if (entry.publisher === "discord-webhook") {
|
|
4441
|
+
return publishDiscord(entry, article, options);
|
|
4442
|
+
}
|
|
4443
|
+
if (entry.publisher === "browser") {
|
|
4444
|
+
return publishWithBrowser(entry, article, options);
|
|
4445
|
+
}
|
|
4446
|
+
if (entry.publisher === "blocked") {
|
|
4447
|
+
throw new Error("Reddit 配置明确提示非官方工具可能导致封号;未配置 Reddit 官方应用,因此拒绝自动发布");
|
|
4448
|
+
}
|
|
4449
|
+
if (entry.publisher === "unavailable") {
|
|
4450
|
+
throw new Error(`${entry.platform} 缺少可用 API、发布地址或适配器`);
|
|
4451
|
+
}
|
|
4452
|
+
throw new Error(`${entry.platform} 需要由官网发布流程处理`);
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
export async function runPlatformPublishSequence(entries, publishOne) {
|
|
4456
|
+
const successes = [];
|
|
4457
|
+
const failures = [];
|
|
4458
|
+
let skipped = [];
|
|
4459
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
4460
|
+
const entry = entries[index];
|
|
4461
|
+
try {
|
|
4462
|
+
const result = await publishOne(entry);
|
|
4463
|
+
successes.push({ entry, result });
|
|
4464
|
+
} catch (error) {
|
|
4465
|
+
failures.push({ entry, error });
|
|
4466
|
+
if (entry.publisher === "browser") {
|
|
4467
|
+
skipped = entries.slice(index + 1);
|
|
4468
|
+
break;
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
return { successes, failures, skipped };
|
|
4473
|
+
}
|