@qcplay/cli 1.0.14 → 1.0.15
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 +200 -9
- package/bin/qcplay.js +552 -77
- package/lib/content-rules.js +807 -0
- package/lib/lark-article.js +1 -1
- package/lib/platform-publish.js +2414 -270
- package/lib/wechat-article.js +400 -25
- package/package.json +4 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +38 -4
- package/templates/skills/qcplay-publish-article/article/article.md +3 -1
- package/templates/skills/qcplay-publish-article/article/west-article.md +22 -0
package/lib/platform-publish.js
CHANGED
|
@@ -7,13 +7,30 @@ import path from "path";
|
|
|
7
7
|
import readline from "readline";
|
|
8
8
|
|
|
9
9
|
import * as cheerio from "cheerio";
|
|
10
|
+
import { applyContentRules } from "./content-rules.js";
|
|
11
|
+
import { stripWechatGuidanceHtml } from "./wechat-article.js";
|
|
10
12
|
|
|
11
13
|
const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
|
|
12
14
|
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
15
|
+
const ARES_PROJECT = "阿瑞斯病毒2";
|
|
16
|
+
const ARES_BILIBILI_RECHARGE_RULE = {
|
|
17
|
+
name: "阿瑞斯病毒2 B站删除官充章节",
|
|
18
|
+
enabled: true,
|
|
19
|
+
source: "*",
|
|
20
|
+
project: ARES_PROJECT,
|
|
21
|
+
platform: "bilibili",
|
|
22
|
+
actions: [
|
|
23
|
+
{
|
|
24
|
+
type: "remove_section_until_next_heading",
|
|
25
|
+
match: { text: "官充", operator: "contains" }
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
};
|
|
13
29
|
|
|
14
30
|
const PLATFORM_ALIASES = new Map([
|
|
15
31
|
["官网", "website"],
|
|
16
32
|
["website", "website"],
|
|
33
|
+
["tap", "taptap"],
|
|
17
34
|
["taptap", "taptap"],
|
|
18
35
|
["好游快爆", "haoyou"],
|
|
19
36
|
["haoyou", "haoyou"],
|
|
@@ -37,6 +54,12 @@ const PLATFORM_ALIASES = new Map([
|
|
|
37
54
|
["youtube", "youtube"]
|
|
38
55
|
]);
|
|
39
56
|
|
|
57
|
+
const XIAOHONGSHU_PLATFORM_ALIASES = new Map([
|
|
58
|
+
["小红书", "xiaohongshu"],
|
|
59
|
+
["xiaohongshu", "xiaohongshu"],
|
|
60
|
+
["xhs", "xiaohongshu"]
|
|
61
|
+
]);
|
|
62
|
+
|
|
40
63
|
const BROWSER_PLATFORM_SPECS = {
|
|
41
64
|
taptap: {
|
|
42
65
|
manualLogin: true,
|
|
@@ -176,6 +199,42 @@ const BROWSER_PLATFORM_SPECS = {
|
|
|
176
199
|
loginButtons: [/Log in/i, /登录/],
|
|
177
200
|
body: ['textarea', '[contenteditable="true"]'],
|
|
178
201
|
submit: [/^Share$/i, /^分享$/]
|
|
202
|
+
},
|
|
203
|
+
xiaohongshu: {
|
|
204
|
+
manualLogin: true,
|
|
205
|
+
username: ['input[placeholder*="手机号"]', 'input[type="tel"]'],
|
|
206
|
+
password: ['input[type="password"]'],
|
|
207
|
+
loginButtons: [/登录/],
|
|
208
|
+
title: [
|
|
209
|
+
'input[placeholder*="标题"]',
|
|
210
|
+
'textarea[placeholder*="标题"]',
|
|
211
|
+
'input[placeholder*="填写标题"]',
|
|
212
|
+
'input[name="title"]'
|
|
213
|
+
],
|
|
214
|
+
body: [
|
|
215
|
+
'[contenteditable="true"][role="textbox"]',
|
|
216
|
+
'[contenteditable="true"]',
|
|
217
|
+
'textarea[placeholder*="正文"]',
|
|
218
|
+
'textarea[placeholder*="内容"]'
|
|
219
|
+
],
|
|
220
|
+
imageInputs: [
|
|
221
|
+
'input[type="file"][accept*="image"]',
|
|
222
|
+
'input[type="file"][accept*=".jpg"]',
|
|
223
|
+
'input[type="file"]'
|
|
224
|
+
],
|
|
225
|
+
collection: [
|
|
226
|
+
'input[placeholder*="搜索合集"]',
|
|
227
|
+
'input[placeholder*="合集"]',
|
|
228
|
+
'[class*="collection"] input',
|
|
229
|
+
'[class*="album"] input'
|
|
230
|
+
],
|
|
231
|
+
submitSelectors: [
|
|
232
|
+
'button[type="submit"]',
|
|
233
|
+
'button[class*="publish"]',
|
|
234
|
+
'[role="button"][class*="publish"]',
|
|
235
|
+
'.publish-video .btn-wrapper'
|
|
236
|
+
],
|
|
237
|
+
submit: [/^发布$/, /^立即发布$/, /^发布笔记$/]
|
|
179
238
|
}
|
|
180
239
|
};
|
|
181
240
|
|
|
@@ -187,6 +246,22 @@ function normalizeKey(value) {
|
|
|
187
246
|
return normalizeText(value).toLowerCase().replace(/\s+/g, "");
|
|
188
247
|
}
|
|
189
248
|
|
|
249
|
+
function configuredUrls(value) {
|
|
250
|
+
return (normalizeText(value).match(/https?:\/\/[^\s<>"']+/gi) || []).map(value =>
|
|
251
|
+
value.replace(/[,。;;))\]}]+$/g, "")
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function configuredUrl(value, predicate = () => true) {
|
|
256
|
+
return configuredUrls(value).find(candidate => {
|
|
257
|
+
try {
|
|
258
|
+
return predicate(new URL(candidate));
|
|
259
|
+
} catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}) || "";
|
|
263
|
+
}
|
|
264
|
+
|
|
190
265
|
function normalizeAccount(value) {
|
|
191
266
|
const text = normalizeText(value);
|
|
192
267
|
if (text === "/") {
|
|
@@ -203,7 +278,7 @@ function normalizeAccount(value) {
|
|
|
203
278
|
|
|
204
279
|
export function canonicalPlatformName(value) {
|
|
205
280
|
const key = normalizeKey(value);
|
|
206
|
-
return PLATFORM_ALIASES.get(key) || key;
|
|
281
|
+
return PLATFORM_ALIASES.get(key) || XIAOHONGSHU_PLATFORM_ALIASES.get(key) || key;
|
|
207
282
|
}
|
|
208
283
|
|
|
209
284
|
export function authModeForEntry(entry) {
|
|
@@ -256,6 +331,66 @@ function rowsFromLarkEnvelope(value) {
|
|
|
256
331
|
);
|
|
257
332
|
}
|
|
258
333
|
|
|
334
|
+
export function parsePlatformConfigCsv(value) {
|
|
335
|
+
const source = normalizeText(value);
|
|
336
|
+
if (!source) return [];
|
|
337
|
+
const records = [];
|
|
338
|
+
let record = [];
|
|
339
|
+
let field = "";
|
|
340
|
+
let quoted = false;
|
|
341
|
+
|
|
342
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
343
|
+
const character = source[index];
|
|
344
|
+
if (quoted) {
|
|
345
|
+
if (character === '"') {
|
|
346
|
+
if (source[index + 1] === '"') {
|
|
347
|
+
field += '"';
|
|
348
|
+
index += 1;
|
|
349
|
+
} else {
|
|
350
|
+
quoted = false;
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
field += character;
|
|
354
|
+
}
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (character === '"') {
|
|
358
|
+
quoted = true;
|
|
359
|
+
} else if (character === ",") {
|
|
360
|
+
record.push(field);
|
|
361
|
+
field = "";
|
|
362
|
+
} else if (character === "\n" || character === "\r") {
|
|
363
|
+
if (character === "\r" && source[index + 1] === "\n") index += 1;
|
|
364
|
+
record.push(field);
|
|
365
|
+
records.push(record);
|
|
366
|
+
record = [];
|
|
367
|
+
field = "";
|
|
368
|
+
} else {
|
|
369
|
+
field += character;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (field || record.length > 0) {
|
|
373
|
+
record.push(field);
|
|
374
|
+
records.push(record);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const cleaned = records.map(row => {
|
|
378
|
+
const next = [...row];
|
|
379
|
+
next[0] = normalizeText(next[0]).replace(/^\[row=\d+\]\s*/, "");
|
|
380
|
+
return next;
|
|
381
|
+
});
|
|
382
|
+
const columns = cleaned.shift() || [];
|
|
383
|
+
return cleaned.map(row => Object.fromEntries(columns.map((column, index) => [column, row[index] ?? null])));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function rowsFromCsvEnvelope(value) {
|
|
387
|
+
const csv = value?.data?.annotated_csv;
|
|
388
|
+
if (typeof csv !== "string") {
|
|
389
|
+
throw new Error("飞书平台配置 CSV 返回格式不正确");
|
|
390
|
+
}
|
|
391
|
+
return parsePlatformConfigCsv(csv);
|
|
392
|
+
}
|
|
393
|
+
|
|
259
394
|
export function normalizePlatformRows(rows) {
|
|
260
395
|
return rows
|
|
261
396
|
.map((row, index) => {
|
|
@@ -315,25 +450,49 @@ function runJson(command, args) {
|
|
|
315
450
|
}
|
|
316
451
|
|
|
317
452
|
export async function loadPlatformEntries(options = {}) {
|
|
318
|
-
let value;
|
|
319
453
|
if (options.configFile) {
|
|
320
454
|
const configFile = path.resolve(process.cwd(), options.configFile);
|
|
321
|
-
value = JSON.parse(await fs.promises.readFile(configFile, "utf8"));
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
455
|
+
const value = JSON.parse(await fs.promises.readFile(configFile, "utf8"));
|
|
456
|
+
const rows = Array.isArray(value)
|
|
457
|
+
? value
|
|
458
|
+
: Array.isArray(value.rows)
|
|
459
|
+
? value.rows
|
|
460
|
+
: typeof value?.data?.annotated_csv === "string"
|
|
461
|
+
? rowsFromCsvEnvelope(value)
|
|
462
|
+
: rowsFromLarkEnvelope(value);
|
|
463
|
+
return normalizePlatformRows(rows);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const workbook = await runJson("lark-cli", [
|
|
467
|
+
"sheets",
|
|
468
|
+
"+workbook-info",
|
|
469
|
+
"--url",
|
|
470
|
+
PLATFORM_CONFIG_URL,
|
|
471
|
+
"--as",
|
|
472
|
+
"user",
|
|
473
|
+
"--format",
|
|
474
|
+
"json"
|
|
475
|
+
]);
|
|
476
|
+
const sheet = workbook?.data?.sheets?.find(candidate => !candidate.is_hidden) || workbook?.data?.sheets?.[0];
|
|
477
|
+
if (!sheet?.sheet_id || !Number.isInteger(sheet.row_count) || sheet.row_count < 1) {
|
|
478
|
+
throw new Error("飞书平台配置没有可读取的工作表");
|
|
479
|
+
}
|
|
480
|
+
const value = await runJson("lark-cli", [
|
|
481
|
+
"sheets",
|
|
482
|
+
"+csv-get",
|
|
483
|
+
"--url",
|
|
484
|
+
PLATFORM_CONFIG_URL,
|
|
485
|
+
"--sheet-id",
|
|
486
|
+
sheet.sheet_id,
|
|
487
|
+
"--range",
|
|
488
|
+
`A1:I${sheet.row_count}`,
|
|
489
|
+
"--include-row-prefix=false",
|
|
490
|
+
"--as",
|
|
491
|
+
"user",
|
|
492
|
+
"--format",
|
|
493
|
+
"json"
|
|
494
|
+
]);
|
|
495
|
+
return normalizePlatformRows(rowsFromCsvEnvelope(value));
|
|
337
496
|
}
|
|
338
497
|
|
|
339
498
|
function entryLabel(entry) {
|
|
@@ -345,13 +504,24 @@ export function availableProjects(entries) {
|
|
|
345
504
|
}
|
|
346
505
|
|
|
347
506
|
export function entriesForProject(entries, project, region = "") {
|
|
348
|
-
const projectKey =
|
|
507
|
+
const projectKey = normalizeProjectKey(project);
|
|
349
508
|
const regionKey = normalizeKey(region);
|
|
350
509
|
return entries.filter(
|
|
351
|
-
entry =>
|
|
510
|
+
entry => normalizeProjectKey(entry.project) === projectKey && (!regionKey || normalizeKey(entry.region) === regionKey)
|
|
352
511
|
);
|
|
353
512
|
}
|
|
354
513
|
|
|
514
|
+
const PROJECT_ALIASES = new Map([
|
|
515
|
+
["魔卡少女樱", "魔卡少女樱"],
|
|
516
|
+
["魔卡少女樱情报站", "魔卡少女樱"],
|
|
517
|
+
["魔卡少女樱回忆钥匙", "魔卡少女樱"]
|
|
518
|
+
]);
|
|
519
|
+
|
|
520
|
+
function normalizeProjectKey(value) {
|
|
521
|
+
const key = normalizeKey(value).replace(/[::()()【】\[\]「」]/g, "");
|
|
522
|
+
return normalizeKey(PROJECT_ALIASES.get(key) || key);
|
|
523
|
+
}
|
|
524
|
+
|
|
355
525
|
export function selectRequestedEntries(entries, requestedPlatforms) {
|
|
356
526
|
const selected = [];
|
|
357
527
|
for (const requested of requestedPlatforms) {
|
|
@@ -408,7 +578,7 @@ export async function promptForPlatformEntries(entries, streams = {}) {
|
|
|
408
578
|
}
|
|
409
579
|
|
|
410
580
|
export function publicEntrySummary(entry) {
|
|
411
|
-
|
|
581
|
+
const summary = {
|
|
412
582
|
project: entry.project,
|
|
413
583
|
region: entry.region,
|
|
414
584
|
platform: entry.platform,
|
|
@@ -416,6 +586,11 @@ export function publicEntrySummary(entry) {
|
|
|
416
586
|
publisher: entry.publisher,
|
|
417
587
|
configured: Boolean(entry.url || entry.publisher === "official-api")
|
|
418
588
|
};
|
|
589
|
+
const contentRequirements = summarizePlatformContentRequirements(entry);
|
|
590
|
+
if (contentRequirements) {
|
|
591
|
+
summary.contentRequirements = contentRequirements;
|
|
592
|
+
}
|
|
593
|
+
return summary;
|
|
419
594
|
}
|
|
420
595
|
|
|
421
596
|
function discordWebhookFromEntry(entry) {
|
|
@@ -441,6 +616,20 @@ function splitDiscordContent(title, markdown) {
|
|
|
441
616
|
return chunks;
|
|
442
617
|
}
|
|
443
618
|
|
|
619
|
+
function isWechatImportedArticle(article) {
|
|
620
|
+
const source = normalizeText(article.meta?.source_url || article.payload?.source_url);
|
|
621
|
+
if (!source) return false;
|
|
622
|
+
try {
|
|
623
|
+
return new URL(source).hostname.toLowerCase() === "mp.weixin.qq.com";
|
|
624
|
+
} catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function stripWechatPlatformGuidance(article) {
|
|
630
|
+
return normalizeText(article.html);
|
|
631
|
+
}
|
|
632
|
+
|
|
444
633
|
function postJson(urlValue, payload) {
|
|
445
634
|
return new Promise((resolve, reject) => {
|
|
446
635
|
const url = new URL(urlValue);
|
|
@@ -479,7 +668,11 @@ export async function publishDiscord(entry, article, options = {}) {
|
|
|
479
668
|
if (!webhook) {
|
|
480
669
|
throw new Error("Discord 配置缺少 webhook,无法使用 API 发布");
|
|
481
670
|
}
|
|
482
|
-
const
|
|
671
|
+
const preparedArticle = prepareArticleForPlatform(article, entry);
|
|
672
|
+
const markdown = isWechatImportedArticle(preparedArticle)
|
|
673
|
+
? plainTextForPlatform(preparedArticle, "discord")
|
|
674
|
+
: preparedArticle.markdown;
|
|
675
|
+
const chunks = splitDiscordContent(preparedArticle.title, markdown);
|
|
483
676
|
const send = options.postJson || postJson;
|
|
484
677
|
for (const chunk of chunks) {
|
|
485
678
|
await send(webhook, { content: chunk, allowed_mentions: { parse: [] } });
|
|
@@ -488,7 +681,7 @@ export async function publishDiscord(entry, article, options = {}) {
|
|
|
488
681
|
}
|
|
489
682
|
|
|
490
683
|
function plainTextForPlatform(article, platformKey) {
|
|
491
|
-
const $ = cheerio.load(article
|
|
684
|
+
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
492
685
|
$("br").replaceWith("\n");
|
|
493
686
|
$("p,section,div,h1,h2,h3,h4,h5,h6,li,blockquote,tr").each((_, element) => {
|
|
494
687
|
$(element).append("\n");
|
|
@@ -538,6 +731,743 @@ function tapTapMeta(article, ...keys) {
|
|
|
538
731
|
return "";
|
|
539
732
|
}
|
|
540
733
|
|
|
734
|
+
function tapTapCommandInput(article, field) {
|
|
735
|
+
return normalizeText(article.platformInputs?.[field]);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const USER_PROVIDED_REQUIREMENT_VALUE = /(?:[xXX]{2,}|根据用户提供|由用户(?:输入|提供|填写)|用户(?:输入|提供|填写)|待(?:输入|提供|填写|确认)|另行提供)/;
|
|
739
|
+
|
|
740
|
+
function tapTapOfficialGroupRequirement(requirements) {
|
|
741
|
+
const mentioned = /(?:TapTap|Tap)?官方群/.test(requirements);
|
|
742
|
+
if (!mentioned) return { required: false, value: "" };
|
|
743
|
+
if (/(?:不需要|无需|不用|不提供|不要求|无)[^,。;;\n]{0,12}(?:TapTap|Tap)?官方群/.test(requirements)) {
|
|
744
|
+
return { required: false, value: "" };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const configured = normalizeText(requirements.match(/(?:TapTap|Tap)?官方群\s*[::]\s*([^\r\n]*)/)?.[1]);
|
|
748
|
+
if (!configured || USER_PROVIDED_REQUIREMENT_VALUE.test(configured)) {
|
|
749
|
+
return { required: true, value: "" };
|
|
750
|
+
}
|
|
751
|
+
const groupNumber = configured.match(/\d{5,}/)?.[0];
|
|
752
|
+
return groupNumber ? { required: false, value: groupNumber } : { required: true, value: "" };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function tapTapSecretOptionalProject(project) {
|
|
756
|
+
return new Set(["最强蜗牛", "新仙剑", "新仙剑奇侠传之挥剑问情手游"]).has(normalizeText(project));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const TAPTAP_USER_INPUT_FIELDS = [
|
|
760
|
+
{
|
|
761
|
+
field: "taptap_secret_code",
|
|
762
|
+
label: "福利密令",
|
|
763
|
+
aliases: ["taptap_code"],
|
|
764
|
+
matches: requirements =>
|
|
765
|
+
/(?:福利)?密令/.test(requirements) &&
|
|
766
|
+
!/(?:不需要|无需|不用|不提供|不要求|无)[^,。;;\n]{0,12}(?:福利)?密令|(?:福利)?密令[^,。;;\n]{0,12}(?:不需要|无需|不用|不提供|不要求)/.test(
|
|
767
|
+
requirements
|
|
768
|
+
)
|
|
769
|
+
},
|
|
770
|
+
{
|
|
771
|
+
field: "taptap_secret_period",
|
|
772
|
+
label: "密令生效时间",
|
|
773
|
+
aliases: ["taptap_secret_time"],
|
|
774
|
+
matches: requirements =>
|
|
775
|
+
/生效时间|有效时间|有效期|起止时间|时间要求/.test(requirements) &&
|
|
776
|
+
!/(?:不需要|无需|不用|不提供|不要求|无)[^,。;;\n]{0,12}(?:生效时间|有效时间|有效期|起止时间|时间要求)/.test(
|
|
777
|
+
requirements
|
|
778
|
+
)
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
field: "taptap_official_group",
|
|
782
|
+
label: "TapTap 官方群",
|
|
783
|
+
aliases: ["taptap_group"],
|
|
784
|
+
matches: requirements => tapTapOfficialGroupRequirement(requirements).required
|
|
785
|
+
}
|
|
786
|
+
];
|
|
787
|
+
|
|
788
|
+
export function resolvePlatformContentRequirements(entry = {}) {
|
|
789
|
+
const requirements = normalizeText(entry.requirements);
|
|
790
|
+
const platformKey = entry.platformKey || canonicalPlatformName(entry.platform);
|
|
791
|
+
const strongestSnailSharedPolicy =
|
|
792
|
+
normalizeText(entry.project) === "最强蜗牛" &&
|
|
793
|
+
new Set(["website", "bilibili", "taptap", "weibo", "haoyou"]).has(platformKey);
|
|
794
|
+
if (!requirements && !strongestSnailSharedPolicy) {
|
|
795
|
+
return {
|
|
796
|
+
configured: false,
|
|
797
|
+
requiredUserInputs: [],
|
|
798
|
+
preserveFormatting: true,
|
|
799
|
+
removeGuidance: false,
|
|
800
|
+
removeBoundaryGuidanceMedia: false,
|
|
801
|
+
removeLeadingGuidanceMedia: false,
|
|
802
|
+
removeNamedSections: [],
|
|
803
|
+
removeNamedSectionTails: [],
|
|
804
|
+
preserveSecretSections: false,
|
|
805
|
+
optionalSectionReview: false,
|
|
806
|
+
addSectionDividers: false,
|
|
807
|
+
removePoemPrompts: false,
|
|
808
|
+
textReplacements: [],
|
|
809
|
+
openingReplacement: null,
|
|
810
|
+
secretFormat: "",
|
|
811
|
+
configuredValues: {}
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const officialGroup = platformKey === "taptap" ? tapTapOfficialGroupRequirement(requirements) : { value: "" };
|
|
816
|
+
const configuredUserInputs =
|
|
817
|
+
platformKey === "taptap"
|
|
818
|
+
? TAPTAP_USER_INPUT_FIELDS.filter(item => item.matches(requirements)).map(item => ({
|
|
819
|
+
field: item.field,
|
|
820
|
+
label: item.label,
|
|
821
|
+
aliases: item.aliases
|
|
822
|
+
}))
|
|
823
|
+
: [];
|
|
824
|
+
const optionalSecret = platformKey === "taptap" && tapTapSecretOptionalProject(entry.project);
|
|
825
|
+
const requiredUserInputs = configuredUserInputs.filter(
|
|
826
|
+
item => !(optionalSecret && /taptap_secret_(code|period)/.test(item.field))
|
|
827
|
+
);
|
|
828
|
+
const preserveFormatting = !/(?:不需要|无需|不用|不保留|不要求)[^,。;;\n]{0,12}(?:排版|富文本|格式)/.test(
|
|
829
|
+
requirements
|
|
830
|
+
);
|
|
831
|
+
const removalIntent = /去掉|删除|移除|不搬运|不要搬运/;
|
|
832
|
+
const guidanceSubject = /关注引导|引导(?:图|内容)|引流社群|游戏社群|社群|官方群|交流群|QQ群|求关注|公众号二维码|扫码|阅读原文|后台回复|星标图|官网充值/;
|
|
833
|
+
const removeGuidance = strongestSnailSharedPolicy || (removalIntent.test(requirements) && guidanceSubject.test(requirements));
|
|
834
|
+
const removeBoundaryGuidanceMedia =
|
|
835
|
+
removeGuidance &&
|
|
836
|
+
/(?:头尾|首尾|开头|末尾|头部|尾部)[^。;;\n]{0,24}(?:关注|求关注|引导)[^。;;\n]{0,12}(?:图|图片)|(?:关注|求关注|引导)[^。;;\n]{0,12}(?:图|图片)[^。;;\n]{0,24}(?:头尾|首尾|开头|末尾|头部|尾部)/.test(
|
|
837
|
+
requirements
|
|
838
|
+
);
|
|
839
|
+
const removeLeadingGuidanceMedia =
|
|
840
|
+
removeGuidance &&
|
|
841
|
+
(strongestSnailSharedPolicy || /求关注[^。;;\n]{0,12}(?:配图|图片|图)/.test(requirements));
|
|
842
|
+
const removeNamedSections = [];
|
|
843
|
+
const removeNamedSectionTails = [];
|
|
844
|
+
if (removalIntent.test(requirements) && /小姬有话说/.test(requirements)) {
|
|
845
|
+
removeNamedSections.push("小姬有话说");
|
|
846
|
+
if (/小姬有话说[^。;;\n]{0,16}(?:所有内容|全部内容)/.test(requirements)) {
|
|
847
|
+
removeNamedSectionTails.push("小姬有话说");
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
if (strongestSnailSharedPolicy) {
|
|
851
|
+
if (!removeNamedSections.includes("小姬有话说")) {
|
|
852
|
+
removeNamedSections.push("小姬有话说");
|
|
853
|
+
}
|
|
854
|
+
if (!removeNamedSectionTails.includes("小姬有话说")) {
|
|
855
|
+
removeNamedSectionTails.push("小姬有话说");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (removalIntent.test(requirements) && /春姬播报图/.test(requirements)) {
|
|
859
|
+
removeNamedSections.push("春姬播报");
|
|
860
|
+
}
|
|
861
|
+
const preserveSecretSections =
|
|
862
|
+
strongestSnailSharedPolicy ||
|
|
863
|
+
/密令[^。;;\n]{0,20}(?:保留|不删|不删除|不要删除|不可删除|不能删除)|(?:保留|不删|不删除|不要删除|不可删除|不能删除)[^。;;\n]{0,20}密令/.test(
|
|
864
|
+
requirements
|
|
865
|
+
);
|
|
866
|
+
const optionalSectionReview = /(?:活动|板块)[^。;;\n]{0,24}可选择是否删除|可选择是否删除[^。;;\n]{0,24}(?:活动|板块)/.test(
|
|
867
|
+
requirements
|
|
868
|
+
);
|
|
869
|
+
const addSectionDividers = platformKey === "taptap" && /分割线/.test(requirements) && /板块之间/.test(requirements);
|
|
870
|
+
const removePoemPrompts =
|
|
871
|
+
/(?:没有|无|去掉|删除|移除|不需要|无需|不保留)[^。;;\n]{0,16}吟诗|吟诗[^。;;\n]{0,16}(?:没有|去掉|删除|移除|不需要|无需|不保留)/.test(
|
|
872
|
+
requirements
|
|
873
|
+
);
|
|
874
|
+
const textReplacements = [...requirements.matchAll(/将([^,。;;\n]{1,24}?)(?:更换为|替换为|改为|换成)([^,。;;\n]{1,24})/g)]
|
|
875
|
+
.map(match => ({ from: normalizeText(match[1]), to: normalizeText(match[2]) }))
|
|
876
|
+
.filter(item => item.from && item.to);
|
|
877
|
+
const openingMatch = requirements.match(
|
|
878
|
+
/(?:公众号)?推文开头[::]\s*([\s\S]*?)\s*修改为[::]\s*([\s\S]*?)(?=\n\s*\d+\s*[.、.)]|$)/
|
|
879
|
+
);
|
|
880
|
+
const requirementLines = value =>
|
|
881
|
+
normalizeText(value)
|
|
882
|
+
.split(/\r?\n/)
|
|
883
|
+
.map(line => line.trim().replace(/^['"“”]+|['"“”]+$/g, ""))
|
|
884
|
+
.filter(Boolean);
|
|
885
|
+
const openingReplacement = openingMatch
|
|
886
|
+
? {
|
|
887
|
+
from: requirementLines(openingMatch[1]),
|
|
888
|
+
to: requirementLines(openingMatch[2])
|
|
889
|
+
}
|
|
890
|
+
: null;
|
|
891
|
+
const hasSecretRequirement =
|
|
892
|
+
platformKey === "taptap" && configuredUserInputs.some(item => item.field === "taptap_secret_code");
|
|
893
|
+
const secretFormat = !hasSecretRequirement
|
|
894
|
+
? ""
|
|
895
|
+
: /小草的福利密令|旧的回忆[^\n]{0,16}新的故事/.test(requirements)
|
|
896
|
+
? "xianjian"
|
|
897
|
+
: />>>\s*福利密令|【\s*(?:x+|密令)[^】]*】|白蝌蚪/.test(requirements)
|
|
898
|
+
? "snail"
|
|
899
|
+
: "generic";
|
|
900
|
+
|
|
901
|
+
return {
|
|
902
|
+
configured: true,
|
|
903
|
+
requiredUserInputs,
|
|
904
|
+
preserveFormatting,
|
|
905
|
+
removeGuidance,
|
|
906
|
+
removeBoundaryGuidanceMedia,
|
|
907
|
+
removeLeadingGuidanceMedia,
|
|
908
|
+
removeNamedSections,
|
|
909
|
+
removeNamedSectionTails,
|
|
910
|
+
preserveSecretSections,
|
|
911
|
+
optionalSectionReview,
|
|
912
|
+
addSectionDividers,
|
|
913
|
+
removePoemPrompts,
|
|
914
|
+
textReplacements,
|
|
915
|
+
openingReplacement,
|
|
916
|
+
secretFormat,
|
|
917
|
+
optionalSecret,
|
|
918
|
+
configuredValues: officialGroup.value ? { taptap_official_group: officialGroup.value } : {}
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function summarizePlatformContentRequirements(entry = {}) {
|
|
923
|
+
const policy = resolvePlatformContentRequirements(entry);
|
|
924
|
+
if (!policy.configured) return null;
|
|
925
|
+
return {
|
|
926
|
+
source: "飞书特殊要求",
|
|
927
|
+
requiredUserInputs: policy.requiredUserInputs.map(item => ({ field: item.field, label: item.label })),
|
|
928
|
+
preserveFormatting: policy.preserveFormatting,
|
|
929
|
+
removeGuidance: policy.removeGuidance,
|
|
930
|
+
removeBoundaryGuidanceMedia: policy.removeBoundaryGuidanceMedia,
|
|
931
|
+
removeLeadingGuidanceMedia: policy.removeLeadingGuidanceMedia,
|
|
932
|
+
preserveSecretSections: policy.preserveSecretSections,
|
|
933
|
+
optionalSectionReview: policy.optionalSectionReview,
|
|
934
|
+
addSectionDividers: policy.addSectionDividers
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function requiredTapTapMeta(article, entry, input) {
|
|
939
|
+
const value = tapTapCommandInput(article, input.field);
|
|
940
|
+
if (value) return value;
|
|
941
|
+
throw new Error(
|
|
942
|
+
`公众号来源的${normalizeText(entry.project) || "当前游戏"}文章发布到 TapTap 前,飞书“特殊要求”规定必须通过命令行参数 --${input.field.replaceAll("_", "-")} 提供${input.label},CLI 不会从 Markdown 读取或自动编造`
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function removeEmptyWechatBlocks($) {
|
|
947
|
+
$("p,section,div,h1,h2,h3,h4,h5,h6,blockquote").each((_, element) => {
|
|
948
|
+
const block = $(element);
|
|
949
|
+
if (!normalizeText(block.text()) && block.find("img,video,audio,iframe").length === 0) {
|
|
950
|
+
block.remove();
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function replaceWechatTextNodes($, replacer) {
|
|
956
|
+
const visit = node => {
|
|
957
|
+
if (node.type === "text") {
|
|
958
|
+
node.data = replacer(String(node.data || ""));
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
for (const child of node.children || []) {
|
|
962
|
+
visit(child);
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
for (const node of $.root().contents().toArray()) {
|
|
966
|
+
visit(node);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function promoteWechatTapTapHeadings($) {
|
|
971
|
+
const blockSelector = "p,section,div,h1,h2,h3,h4,h5,h6,blockquote,ul,ol,table";
|
|
972
|
+
for (const node of $("p,section,div").toArray()) {
|
|
973
|
+
const element = $(node);
|
|
974
|
+
const text = normalizedTapTapText(element.text());
|
|
975
|
+
if (!text || text.length > 40 || /[。!?!?]$/.test(text) || element.find(blockSelector).length > 0) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const styleText = [element.attr("style") || "", ...element.find("[style]").map((_, child) => $(child).attr("style") || "").get()]
|
|
979
|
+
.join(";");
|
|
980
|
+
const fontSizes = [...styleText.matchAll(/font-size\s*:\s*([\d.]+)px/gi)].map(match => Number(match[1]));
|
|
981
|
+
const largestFont = fontSizes.length > 0 ? Math.max(...fontSizes) : 0;
|
|
982
|
+
const bold = /font-weight\s*:\s*(?:bold|[6-9]00)/i.test(styleText) || element.find("strong,b").length > 0;
|
|
983
|
+
const heading = largestFont >= 20 ? "h2" : largestFont >= 16 && bold ? "h3" : "";
|
|
984
|
+
if (heading) {
|
|
985
|
+
node.name = heading;
|
|
986
|
+
node.tagName = heading;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function replaceWithTapTapHeading($, node, tag, text) {
|
|
992
|
+
if (!node) return null;
|
|
993
|
+
const element = $(node);
|
|
994
|
+
const style = normalizeText(element.attr("style"));
|
|
995
|
+
const replacement = $(`<${tag}></${tag}>`);
|
|
996
|
+
if (style) replacement.attr("style", style);
|
|
997
|
+
replacement.html(`<strong>${escapeTapTapHtml(text)}</strong>`);
|
|
998
|
+
element.replaceWith(replacement);
|
|
999
|
+
return replacement;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function richBlockVisualStyle($, node) {
|
|
1003
|
+
const element = $(node);
|
|
1004
|
+
const styleText = [element.attr("style") || "", ...element.find("[style]").map((_, child) => $(child).attr("style") || "").get()]
|
|
1005
|
+
.join(";");
|
|
1006
|
+
const fontSizes = [...styleText.matchAll(/font-size\s*:\s*([\d.]+)px/gi)].map(match => Number(match[1]));
|
|
1007
|
+
const colors = [...styleText.matchAll(/(?:^|;)\s*color\s*:\s*([^;]+)/gi)].map(match =>
|
|
1008
|
+
normalizeText(match[1]).toLowerCase()
|
|
1009
|
+
);
|
|
1010
|
+
return {
|
|
1011
|
+
largestFont: fontSizes.length > 0 ? Math.max(...fontSizes) : 0,
|
|
1012
|
+
colors,
|
|
1013
|
+
bold: /font-weight\s*:\s*(?:bold|[6-9]00)/i.test(styleText) || element.find("strong,b").length > 0
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function prepareAresTapTapStructure($) {
|
|
1018
|
+
const blockSelector = "p,section,div,h1,h2,h3,h4,h5,h6";
|
|
1019
|
+
const leafBlocks = () =>
|
|
1020
|
+
$(blockSelector)
|
|
1021
|
+
.toArray()
|
|
1022
|
+
.filter(node => !$(node).find(blockSelector).length)
|
|
1023
|
+
.filter(node => normalizedTapTapText($(node).text()));
|
|
1024
|
+
|
|
1025
|
+
for (const node of leafBlocks()) {
|
|
1026
|
+
const text = normalizedTapTapText($(node).text());
|
|
1027
|
+
const visual = richBlockVisualStyle($, node);
|
|
1028
|
+
if (
|
|
1029
|
+
text.length <= 40 &&
|
|
1030
|
+
visual.bold &&
|
|
1031
|
+
visual.largestFont >= 16 &&
|
|
1032
|
+
visual.colors.some(color => /^#6c6ba7(?:ff)?$/.test(color))
|
|
1033
|
+
) {
|
|
1034
|
+
replaceWithTapTapHeading($, node, "h2", text);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const numberNodes = leafBlocks().filter(node => /^\d{2}$/.test(normalizedTapTapText($(node).text())));
|
|
1039
|
+
for (const numberNode of numberNodes) {
|
|
1040
|
+
const number = normalizedTapTapText($(numberNode).text());
|
|
1041
|
+
const leaves = leafBlocks();
|
|
1042
|
+
const numberIndex = leaves.indexOf(numberNode);
|
|
1043
|
+
const labelNode = leaves.slice(numberIndex + 1, numberIndex + 4).find(node => {
|
|
1044
|
+
const text = normalizedTapTapText($(node).text());
|
|
1045
|
+
return text && text.length <= 40;
|
|
1046
|
+
});
|
|
1047
|
+
if (!labelNode) continue;
|
|
1048
|
+
const label = normalizedTapTapText($(labelNode).text());
|
|
1049
|
+
$(numberNode).remove();
|
|
1050
|
+
replaceWithTapTapHeading($, labelNode, "h3", `${number}——${label}`);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
for (const node of $("p,section,div").toArray()) {
|
|
1054
|
+
const element = $(node);
|
|
1055
|
+
const text = normalizedTapTapText(element.text());
|
|
1056
|
+
if (!text || text.length > 40 || element.find("p,section,div,h1,h2,h3,h4,h5,h6").length > 0) continue;
|
|
1057
|
+
const styleText = [element.attr("style") || "", ...element.find("[style]").map((_, child) => $(child).attr("style") || "").get()]
|
|
1058
|
+
.join(";");
|
|
1059
|
+
const colored = /(?:^|;)\s*color\s*:/i.test(styleText);
|
|
1060
|
+
const bold = /font-weight\s*:\s*(?:bold|[6-9]00)/i.test(styleText) || element.find("strong,b").length > 0;
|
|
1061
|
+
if (bold && colored && /^(?:[♦◆●■]\s*)|(?:第[一二三四五六七八九十]+站[,,])/.test(text)) {
|
|
1062
|
+
element.html(`<strong>${element.html()}</strong>`);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function removeAresTapTapImageDescriptions($) {
|
|
1068
|
+
$("figure figcaption").remove();
|
|
1069
|
+
$("img").each((_, node) => {
|
|
1070
|
+
const image = $(node);
|
|
1071
|
+
// TapTap turns image alt text into the visible caption below an uploaded image.
|
|
1072
|
+
image.removeAttr("alt").removeAttr("title").removeAttr("aria-label");
|
|
1073
|
+
const parent = image.closest("figure,p,div,section").first();
|
|
1074
|
+
if (!parent.length || parent.find("img").length !== 1 || normalizeText(parent.text())) return;
|
|
1075
|
+
const next = parent.next("p,div,section,figcaption").first();
|
|
1076
|
+
if (!next.length || next.find("img").length > 0) return;
|
|
1077
|
+
const text = normalizedTapTapText(next.text());
|
|
1078
|
+
if (text && text.length <= 80) next.remove();
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function removeXianjianPoemPrompts($) {
|
|
1083
|
+
const blockSelector = "p,section,div,h1,h2,h3,h4,h5,h6,blockquote,li";
|
|
1084
|
+
$(blockSelector).each((_, node) => {
|
|
1085
|
+
const element = $(node);
|
|
1086
|
+
if (/吟诗/.test(element.text()) && element.find(blockSelector).length === 0) {
|
|
1087
|
+
element.remove();
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function addTapTapSectionDividers($) {
|
|
1093
|
+
const headings = $("h1,h2").toArray();
|
|
1094
|
+
for (const heading of headings.slice(1)) {
|
|
1095
|
+
const element = $(heading);
|
|
1096
|
+
if (!element.prev().is("hr")) {
|
|
1097
|
+
element.before("<hr>");
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function replaceAllText(value, search, replacement) {
|
|
1103
|
+
return search ? value.split(search).join(replacement) : value;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function applyWechatRequirementTextReplacements($, policy) {
|
|
1107
|
+
const replacements = [];
|
|
1108
|
+
if (policy.openingReplacement) {
|
|
1109
|
+
policy.openingReplacement.from.forEach((from, index) => {
|
|
1110
|
+
replacements.push({ from, to: policy.openingReplacement.to[index] || "" });
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
replacements.push(...policy.textReplacements);
|
|
1114
|
+
replaceWechatTextNodes($, value =>
|
|
1115
|
+
replacements.reduce((current, replacement) => replaceAllText(current, replacement.from, replacement.to), value)
|
|
1116
|
+
);
|
|
1117
|
+
removeEmptyWechatBlocks($);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const PRESERVED_SECRET_TEXT_PATTERN = /(?:后台回复|回复关键词)[^。;;\n]{0,30}(?:密令|兑换码|礼包码)|(?:本期密令|密令领取时间|福利密令)/;
|
|
1121
|
+
|
|
1122
|
+
function removeNamedSectionTail($, node, preserveTextPatterns) {
|
|
1123
|
+
let cursor = $(node);
|
|
1124
|
+
let removeCursor = true;
|
|
1125
|
+
while (cursor.length) {
|
|
1126
|
+
let reachedPreservedContent = false;
|
|
1127
|
+
for (const siblingNode of cursor.nextAll().toArray()) {
|
|
1128
|
+
const sibling = $(siblingNode);
|
|
1129
|
+
if (preserveTextPatterns.some(pattern => pattern.test(normalizeText(sibling.text())))) {
|
|
1130
|
+
reachedPreservedContent = true;
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
sibling.remove();
|
|
1134
|
+
}
|
|
1135
|
+
const parent = cursor.parent();
|
|
1136
|
+
if (removeCursor) cursor.remove();
|
|
1137
|
+
if (reachedPreservedContent || !parent.length || parent.is($.root())) break;
|
|
1138
|
+
cursor = parent;
|
|
1139
|
+
removeCursor = false;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function removePreviousNamedSectionMedia($, target) {
|
|
1144
|
+
let anchor = target;
|
|
1145
|
+
const targetText = normalizeText(target.text());
|
|
1146
|
+
for (const parentNode of target.parents("section,div").toArray()) {
|
|
1147
|
+
const parent = $(parentNode);
|
|
1148
|
+
if (normalizeText(parent.text()) !== targetText || parent.find("img,video,audio,iframe,table").length > 0) {
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
anchor = parent;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
let sibling = anchor.prev();
|
|
1155
|
+
while (
|
|
1156
|
+
sibling.length &&
|
|
1157
|
+
!normalizeText(sibling.text()) &&
|
|
1158
|
+
sibling.find("img,video,audio,iframe,table,hr").length === 0
|
|
1159
|
+
) {
|
|
1160
|
+
const previous = sibling.prev();
|
|
1161
|
+
sibling.remove();
|
|
1162
|
+
sibling = previous;
|
|
1163
|
+
}
|
|
1164
|
+
if (sibling.length && !normalizeText(sibling.text()) && sibling.find("img").length > 0) {
|
|
1165
|
+
sibling.remove();
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const trailingImage = sibling.find("img").last();
|
|
1170
|
+
if (!trailingImage.length) return;
|
|
1171
|
+
let passedTrailingImage = false;
|
|
1172
|
+
let hasTextAfterImage = false;
|
|
1173
|
+
const visit = node => {
|
|
1174
|
+
if (node === trailingImage.get(0)) {
|
|
1175
|
+
passedTrailingImage = true;
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (node.type === "text" && passedTrailingImage && normalizeText(node.data)) {
|
|
1179
|
+
hasTextAfterImage = true;
|
|
1180
|
+
return;
|
|
1181
|
+
}
|
|
1182
|
+
for (const child of node.children || []) visit(child);
|
|
1183
|
+
};
|
|
1184
|
+
for (const node of sibling.contents().toArray()) visit(node);
|
|
1185
|
+
if (hasTextAfterImage) return;
|
|
1186
|
+
|
|
1187
|
+
const container = trailingImage.closest("p,figure,div,section").first();
|
|
1188
|
+
if (
|
|
1189
|
+
container.length &&
|
|
1190
|
+
!normalizeText(container.text()) &&
|
|
1191
|
+
container.find("img").length === 1 &&
|
|
1192
|
+
container.find("video,audio,iframe,table").length === 0
|
|
1193
|
+
) {
|
|
1194
|
+
container.remove();
|
|
1195
|
+
} else {
|
|
1196
|
+
trailingImage.remove();
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function removeRequirementNamedSections($, labels, tailLabels = [], preserveTextPatterns = []) {
|
|
1201
|
+
const blockSelector = "p,li,blockquote,figcaption,h1,h2,h3,h4,h5,h6,div,section,aside,footer";
|
|
1202
|
+
const normalizedLabels = labels.map(label => normalizeText(label)).filter(Boolean);
|
|
1203
|
+
const normalizedTailLabels = tailLabels.map(label => normalizeText(label)).filter(Boolean);
|
|
1204
|
+
if (normalizedLabels.length === 0) return;
|
|
1205
|
+
|
|
1206
|
+
const matchesLabel = value => normalizedLabels.some(label => normalizeText(value).includes(label));
|
|
1207
|
+
$("img").each((_, node) => {
|
|
1208
|
+
const image = $(node);
|
|
1209
|
+
const description = [image.attr("alt"), image.attr("title"), image.attr("aria-label")]
|
|
1210
|
+
.filter(Boolean)
|
|
1211
|
+
.join(" ");
|
|
1212
|
+
if (!matchesLabel(description)) return;
|
|
1213
|
+
const container = image.closest("p,figure,div,section").first();
|
|
1214
|
+
if (container.length && !normalizeText(container.text()) && container.find("img").length === 1) {
|
|
1215
|
+
container.remove();
|
|
1216
|
+
} else {
|
|
1217
|
+
image.remove();
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
const leafMatches = $(blockSelector)
|
|
1222
|
+
.toArray()
|
|
1223
|
+
.filter(node => matchesLabel($(node).text()))
|
|
1224
|
+
.filter(node => !$(node).find(blockSelector).toArray().some(child => matchesLabel($(child).text())));
|
|
1225
|
+
for (const node of leafMatches) {
|
|
1226
|
+
let target = $(node);
|
|
1227
|
+
let dedicatedContainer = false;
|
|
1228
|
+
for (const parentNode of target.parents("section,div").toArray()) {
|
|
1229
|
+
const parent = $(parentNode);
|
|
1230
|
+
const text = normalizeText(parent.text());
|
|
1231
|
+
const nestedBlocks = parent.find(blockSelector).length;
|
|
1232
|
+
const firstLabelOffset = Math.min(
|
|
1233
|
+
...normalizedLabels.map(label => text.indexOf(label)).filter(offset => offset >= 0)
|
|
1234
|
+
);
|
|
1235
|
+
const hasSectionContent =
|
|
1236
|
+
parent.find("img").length > 0 ||
|
|
1237
|
+
parent
|
|
1238
|
+
.find(blockSelector)
|
|
1239
|
+
.toArray()
|
|
1240
|
+
.some(child => {
|
|
1241
|
+
if (child === node) return false;
|
|
1242
|
+
const childText = normalizeText($(child).text());
|
|
1243
|
+
return Boolean(childText && !matchesLabel(childText));
|
|
1244
|
+
});
|
|
1245
|
+
if (text.length <= 1200 && nestedBlocks <= 12 && firstLabelOffset <= 80 && hasSectionContent) {
|
|
1246
|
+
target = parent;
|
|
1247
|
+
dedicatedContainer = true;
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const removeTail = normalizedTailLabels.some(label => normalizeText($(node).text()).includes(label));
|
|
1252
|
+
if (removeTail && !dedicatedContainer) {
|
|
1253
|
+
removePreviousNamedSectionMedia($, $(node));
|
|
1254
|
+
removeNamedSectionTail($, node, preserveTextPatterns);
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
const previous = target.prev();
|
|
1258
|
+
const next = target.next();
|
|
1259
|
+
for (const sibling of [previous, next]) {
|
|
1260
|
+
if (sibling.length && !normalizeText(sibling.text()) && sibling.find("img").length > 0) {
|
|
1261
|
+
sibling.remove();
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
target.remove();
|
|
1265
|
+
}
|
|
1266
|
+
removeEmptyWechatBlocks($);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function removeBoundaryWechatImages($, options = {}) {
|
|
1270
|
+
const images = $("img").toArray();
|
|
1271
|
+
if (images.length === 0) return;
|
|
1272
|
+
const removeLeading = options.leading !== false;
|
|
1273
|
+
const removeTrailing = options.trailing !== false;
|
|
1274
|
+
|
|
1275
|
+
const tokens = [];
|
|
1276
|
+
const visit = node => {
|
|
1277
|
+
if (node.type === "text") {
|
|
1278
|
+
if (normalizeText(node.data)) tokens.push({ type: "text" });
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
if (node.type === "tag" && node.name === "img") {
|
|
1282
|
+
tokens.push({ type: "image", node });
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
for (const child of node.children || []) visit(child);
|
|
1286
|
+
};
|
|
1287
|
+
for (const node of $.root().contents().toArray()) visit(node);
|
|
1288
|
+
|
|
1289
|
+
const firstImageIndex = tokens.findIndex(token => token.node === images[0]);
|
|
1290
|
+
const lastImageIndex = tokens.findIndex(token => token.node === images.at(-1));
|
|
1291
|
+
const targets = [];
|
|
1292
|
+
if (
|
|
1293
|
+
removeLeading &&
|
|
1294
|
+
firstImageIndex >= 0 &&
|
|
1295
|
+
!tokens.slice(0, firstImageIndex).some(token => token.type === "text")
|
|
1296
|
+
) {
|
|
1297
|
+
targets.push(images[0]);
|
|
1298
|
+
}
|
|
1299
|
+
if (
|
|
1300
|
+
removeTrailing &&
|
|
1301
|
+
lastImageIndex >= 0 &&
|
|
1302
|
+
!tokens.slice(lastImageIndex + 1).some(token => token.type === "text") &&
|
|
1303
|
+
images.at(-1) !== targets[0]
|
|
1304
|
+
) {
|
|
1305
|
+
targets.push(images.at(-1));
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
for (const node of targets) {
|
|
1309
|
+
const image = $(node);
|
|
1310
|
+
const container = image.closest("p,figure,div,section").first();
|
|
1311
|
+
if (
|
|
1312
|
+
container.length &&
|
|
1313
|
+
!normalizeText(container.text()) &&
|
|
1314
|
+
container.find("img").length === 1 &&
|
|
1315
|
+
container.find("video,audio,iframe,table").length === 0
|
|
1316
|
+
) {
|
|
1317
|
+
container.remove();
|
|
1318
|
+
} else {
|
|
1319
|
+
image.remove();
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
removeEmptyWechatBlocks($);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function contentRulesForEntry(entry = {}) {
|
|
1326
|
+
const configured = entry.contentRules;
|
|
1327
|
+
if (normalizeText(entry.project) !== ARES_PROJECT || (entry.platformKey || canonicalPlatformName(entry.platform)) !== "bilibili") {
|
|
1328
|
+
return configured;
|
|
1329
|
+
}
|
|
1330
|
+
const rules = Array.isArray(configured?.rules) ? configured.rules : [];
|
|
1331
|
+
if (rules.some(rule => normalizeText(rule.name) === ARES_BILIBILI_RECHARGE_RULE.name)) {
|
|
1332
|
+
return configured;
|
|
1333
|
+
}
|
|
1334
|
+
return {
|
|
1335
|
+
...(configured || { version: 1 }),
|
|
1336
|
+
rules: [...rules, ARES_BILIBILI_RECHARGE_RULE]
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
export function prepareArticleForPlatform(article, entry = {}) {
|
|
1341
|
+
const platformKey = entry.platformKey || canonicalPlatformName(entry.platform);
|
|
1342
|
+
if (article.contentRulesPreparedFor === platformKey) return article;
|
|
1343
|
+
if (!isWechatImportedArticle(article)) {
|
|
1344
|
+
const prepared = applyContentRules(article, { ...entry, contentRules: contentRulesForEntry(entry) });
|
|
1345
|
+
return prepared === article ? article : { ...prepared, contentRulesPreparedFor: platformKey };
|
|
1346
|
+
}
|
|
1347
|
+
const policy = resolvePlatformContentRequirements(entry);
|
|
1348
|
+
const sourceHtml = String(article.html || "");
|
|
1349
|
+
let html = sourceHtml;
|
|
1350
|
+
if (
|
|
1351
|
+
!policy.removeGuidance &&
|
|
1352
|
+
!policy.removeBoundaryGuidanceMedia &&
|
|
1353
|
+
!policy.removeLeadingGuidanceMedia &&
|
|
1354
|
+
policy.removeNamedSections.length === 0
|
|
1355
|
+
) {
|
|
1356
|
+
const builtInPrepared = html === sourceHtml ? article : { ...article, html, platformContentPolicy: policy };
|
|
1357
|
+
return {
|
|
1358
|
+
...applyContentRules(builtInPrepared, { ...entry, contentRules: contentRulesForEntry(entry) }),
|
|
1359
|
+
contentRulesPreparedFor: platformKey
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
if (policy.removeNamedSections.length > 0 || policy.removeBoundaryGuidanceMedia || policy.removeLeadingGuidanceMedia) {
|
|
1363
|
+
const $ = cheerio.load(html, null, false);
|
|
1364
|
+
const preserveTextPatterns = policy.preserveSecretSections ? [PRESERVED_SECRET_TEXT_PATTERN] : [];
|
|
1365
|
+
removeRequirementNamedSections(
|
|
1366
|
+
$,
|
|
1367
|
+
policy.removeNamedSections,
|
|
1368
|
+
policy.removeNamedSectionTails,
|
|
1369
|
+
preserveTextPatterns
|
|
1370
|
+
);
|
|
1371
|
+
if (policy.removeBoundaryGuidanceMedia || policy.removeLeadingGuidanceMedia) {
|
|
1372
|
+
removeBoundaryWechatImages($, {
|
|
1373
|
+
leading: true,
|
|
1374
|
+
trailing: policy.removeBoundaryGuidanceMedia
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
html = $.root().html();
|
|
1378
|
+
}
|
|
1379
|
+
if (policy.removeGuidance) {
|
|
1380
|
+
html = stripWechatGuidanceHtml(html, {
|
|
1381
|
+
preserveTextPatterns: policy.preserveSecretSections ? [PRESERVED_SECRET_TEXT_PATTERN] : []
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
const builtInPrepared = {
|
|
1385
|
+
...article,
|
|
1386
|
+
html,
|
|
1387
|
+
platformContentPolicy: policy
|
|
1388
|
+
};
|
|
1389
|
+
return {
|
|
1390
|
+
...applyContentRules(builtInPrepared, { ...entry, contentRules: contentRulesForEntry(entry) }),
|
|
1391
|
+
contentRulesPreparedFor: platformKey
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
export function prepareTapTapArticleForProject(article, entry = {}) {
|
|
1396
|
+
if (!isWechatImportedArticle(article)) return prepareArticleForPlatform(article, entry);
|
|
1397
|
+
if (article.tapTapProjectPrepared) return article;
|
|
1398
|
+
|
|
1399
|
+
const platformArticle = prepareArticleForPlatform(article, entry);
|
|
1400
|
+
const policy = resolvePlatformContentRequirements(entry);
|
|
1401
|
+
const aresTapTap = normalizeText(entry.project) === ARES_PROJECT;
|
|
1402
|
+
if (!policy.configured && !aresTapTap) return platformArticle;
|
|
1403
|
+
const $ = cheerio.load(stripWechatPlatformGuidance(platformArticle), null, false);
|
|
1404
|
+
const userInputs = Object.fromEntries(
|
|
1405
|
+
policy.requiredUserInputs.map(input => [input.field, requiredTapTapMeta(article, entry, input)])
|
|
1406
|
+
);
|
|
1407
|
+
const requirementValues = { ...policy.configuredValues, ...userInputs };
|
|
1408
|
+
|
|
1409
|
+
if (aresTapTap) {
|
|
1410
|
+
prepareAresTapTapStructure($);
|
|
1411
|
+
removeAresTapTapImageDescriptions($);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (policy.preserveFormatting) {
|
|
1415
|
+
if (!aresTapTap) promoteWechatTapTapHeadings($);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
if (policy.removePoemPrompts) {
|
|
1419
|
+
removeXianjianPoemPrompts($);
|
|
1420
|
+
}
|
|
1421
|
+
if (policy.addSectionDividers) {
|
|
1422
|
+
addTapTapSectionDividers($);
|
|
1423
|
+
}
|
|
1424
|
+
if (policy.textReplacements.length > 0 || policy.openingReplacement) {
|
|
1425
|
+
applyWechatRequirementTextReplacements($, policy);
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const code = requirementValues.taptap_secret_code;
|
|
1429
|
+
const period = requirementValues.taptap_secret_period;
|
|
1430
|
+
const group = requirementValues.taptap_official_group;
|
|
1431
|
+
if (policy.secretFormat === "xianjian" && code) {
|
|
1432
|
+
const parts = [
|
|
1433
|
+
'<section data-qcplay-taptap-special="xianjian-secret">' +
|
|
1434
|
+
'<h2 style="color:#d83931"><strong>⭐-旧的回忆,新的故事-⭐</strong></h2>' +
|
|
1435
|
+
`<p><strong>小草的福利密令:${escapeTapTapHtml(code)}</strong></p>`
|
|
1436
|
+
];
|
|
1437
|
+
if (period) {
|
|
1438
|
+
parts.push(
|
|
1439
|
+
`<p>生效时间:${escapeTapTapHtml(period)},在游戏内点击设置-礼包兑换输入上方文字后,即可领取奖励,密令在光子服、官服、B服均可使用</p>`
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
if (group) parts.push(`<p>TapTap官方群:${escapeTapTapHtml(group)}</p>`);
|
|
1443
|
+
parts.push('<p>(关注十里坡BOSS小草,获取最新资讯不迷路~~(*ω< )</p>', "</section>");
|
|
1444
|
+
$.root().append(parts.join(""));
|
|
1445
|
+
} else if (policy.secretFormat === "snail" && code) {
|
|
1446
|
+
$.root().append(
|
|
1447
|
+
'<section data-qcplay-taptap-special="snail-secret">' +
|
|
1448
|
+
'<h2><strong>>>>福利密令:</strong></h2>' +
|
|
1449
|
+
`<p><strong>【${escapeTapTapHtml(code)}】</strong></p>` +
|
|
1450
|
+
"</section>"
|
|
1451
|
+
);
|
|
1452
|
+
} else if (code || (!policy.optionalSecret && (period || group))) {
|
|
1453
|
+
const parts = ['<section data-qcplay-taptap-special="requirements">'];
|
|
1454
|
+
if (code) {
|
|
1455
|
+
parts.push('<h2><strong>福利密令:</strong></h2>', `<p><strong>【${escapeTapTapHtml(code)}】</strong></p>`);
|
|
1456
|
+
}
|
|
1457
|
+
if (period) parts.push(`<p>生效时间:${escapeTapTapHtml(period)}</p>`);
|
|
1458
|
+
if (group) parts.push(`<p>TapTap官方群:${escapeTapTapHtml(group)}</p>`);
|
|
1459
|
+
parts.push("</section>");
|
|
1460
|
+
$.root().append(parts.join(""));
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
return {
|
|
1464
|
+
...platformArticle,
|
|
1465
|
+
html: $.html(),
|
|
1466
|
+
tapTapProjectPrepared: true,
|
|
1467
|
+
tapTapPreserveWechatFormatting: policy.preserveFormatting
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
|
|
541
1471
|
function splitTapTapFiles(value) {
|
|
542
1472
|
return normalizeText(value)
|
|
543
1473
|
.split(/[,,;;|]/)
|
|
@@ -545,21 +1475,37 @@ function splitTapTapFiles(value) {
|
|
|
545
1475
|
.filter(Boolean);
|
|
546
1476
|
}
|
|
547
1477
|
|
|
1478
|
+
function configuredTapTapType(entry = {}) {
|
|
1479
|
+
const configured = configuredUrl(entry.url, url => /(?:^|\.)taptap\.cn$/i.test(url.hostname));
|
|
1480
|
+
if (!configured) return "";
|
|
1481
|
+
const url = new URL(configured);
|
|
1482
|
+
const queryType = TAPTAP_TYPES.get(normalizeKey(url.searchParams.get("type")));
|
|
1483
|
+
if (queryType) return queryType;
|
|
1484
|
+
return /^\/app\/\d+\/topic(?:\/|$)/.test(url.pathname) ? "topic" : "";
|
|
1485
|
+
}
|
|
1486
|
+
|
|
548
1487
|
export function resolveTapTapPublishingOptions(article, entry = {}) {
|
|
549
|
-
const requestedType = normalizeKey(
|
|
1488
|
+
const requestedType = normalizeKey(
|
|
1489
|
+
tapTapMeta(article, "taptap_type", "taptap_mode") || configuredTapTapType(entry) || "long-post"
|
|
1490
|
+
);
|
|
550
1491
|
const type = TAPTAP_TYPES.get(requestedType);
|
|
551
1492
|
if (!type) {
|
|
552
1493
|
throw new Error("taptap_type 仅支持 long-post、image-text 或 video");
|
|
553
1494
|
}
|
|
554
1495
|
|
|
555
1496
|
const forumValue = tapTapMeta(article, "taptap_forum");
|
|
1497
|
+
const defaultForum = normalizeText(entry.project) === "魔卡少女樱" ? "魔卡少女樱:回忆钥匙" : normalizeText(entry.project);
|
|
556
1498
|
const forum = falseLike(forumValue)
|
|
557
1499
|
? ""
|
|
558
1500
|
: boolLike(forumValue) || !forumValue
|
|
559
|
-
?
|
|
1501
|
+
? defaultForum
|
|
560
1502
|
: forumValue;
|
|
561
1503
|
const scheduled = boolLike(tapTapMeta(article, "taptap_scheduled", "taptap_schedule"));
|
|
562
1504
|
const draft = boolLike(tapTapMeta(article, "taptap_draft"));
|
|
1505
|
+
const importedWechatCover =
|
|
1506
|
+
normalizeText(entry.project) === "阿瑞斯病毒2" && isWechatImportedArticle(article)
|
|
1507
|
+
? normalizeText(article.payload?.thumbnail || article.meta?.thumbnail)
|
|
1508
|
+
: "";
|
|
563
1509
|
if (scheduled && draft) {
|
|
564
1510
|
throw new Error("taptap_scheduled 与 taptap_draft 不能同时启用");
|
|
565
1511
|
}
|
|
@@ -571,16 +1517,19 @@ export function resolveTapTapPublishingOptions(article, entry = {}) {
|
|
|
571
1517
|
draft,
|
|
572
1518
|
images: splitTapTapFiles(tapTapMeta(article, "taptap_images")),
|
|
573
1519
|
video: tapTapMeta(article, "taptap_video") || normalizeText(article.payload?.video_link),
|
|
574
|
-
cover: tapTapMeta(article, "taptap_cover")
|
|
1520
|
+
cover: tapTapMeta(article, "taptap_cover") || importedWechatCover
|
|
575
1521
|
};
|
|
576
1522
|
}
|
|
577
1523
|
|
|
578
1524
|
export function tapTapPublishingPlan(article, entry = {}) {
|
|
579
|
-
const
|
|
1525
|
+
const preparedArticle = prepareTapTapArticleForProject(article, entry);
|
|
1526
|
+
const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
|
|
1527
|
+
const contentRequirements = isWechatImportedArticle(article) ? summarizePlatformContentRequirements(entry) : null;
|
|
580
1528
|
const imageCount = settings.images.length
|
|
581
1529
|
? settings.images.length
|
|
582
|
-
: [...new Set([...imageSourcesFromArticle(
|
|
583
|
-
|
|
1530
|
+
: [...new Set([...imageSourcesFromArticle(preparedArticle), normalizeText(preparedArticle.payload?.thumbnail)].filter(Boolean))]
|
|
1531
|
+
.length;
|
|
1532
|
+
const plan = {
|
|
584
1533
|
type: settings.typeLabel,
|
|
585
1534
|
forum: settings.forum || "",
|
|
586
1535
|
scheduled: settings.scheduled,
|
|
@@ -588,6 +1537,10 @@ export function tapTapPublishingPlan(article, entry = {}) {
|
|
|
588
1537
|
media: settings.type === "video" ? Boolean(settings.video) : settings.type === "moment" ? Math.min(imageCount, 18) : 0,
|
|
589
1538
|
cover: settings.type === "topic" && Boolean(settings.cover)
|
|
590
1539
|
};
|
|
1540
|
+
if (contentRequirements) {
|
|
1541
|
+
plan.contentRequirements = contentRequirements;
|
|
1542
|
+
}
|
|
1543
|
+
return plan;
|
|
591
1544
|
}
|
|
592
1545
|
|
|
593
1546
|
function weiboMeta(article, ...keys) {
|
|
@@ -600,6 +1553,114 @@ function weiboMeta(article, ...keys) {
|
|
|
600
1553
|
return "";
|
|
601
1554
|
}
|
|
602
1555
|
|
|
1556
|
+
export function resolveWeiboSuperTopic(article, entry = {}) {
|
|
1557
|
+
const requirements = normalizeText(entry.requirements);
|
|
1558
|
+
const markedTopic = requirements.match(/([@#])([^@#\s,。;;]{1,40}超话)(#?)/);
|
|
1559
|
+
const listedTopic = requirements.match(/加上超话\s*[::]?\s*(?:\r?\n)+\s*([^\r\n,。;;]{1,40})/)?.[1] || "";
|
|
1560
|
+
const project = normalizeText(entry.project);
|
|
1561
|
+
const defaultTopic =
|
|
1562
|
+
project === "最强蜗牛"
|
|
1563
|
+
? "#最强蜗牛超话#"
|
|
1564
|
+
: project === "提灯与地下城"
|
|
1565
|
+
? "#提灯与地下城超话#"
|
|
1566
|
+
: project.startsWith("魔卡少女樱")
|
|
1567
|
+
? ""
|
|
1568
|
+
: project;
|
|
1569
|
+
const requested =
|
|
1570
|
+
weiboMeta(article, "weibo_super_topic", "weibo_topic") ||
|
|
1571
|
+
(project.startsWith("魔卡少女樱") ? markedTopic?.[0] || listedTopic : defaultTopic || markedTopic?.[0] || listedTopic);
|
|
1572
|
+
const marker = requested.startsWith("#") ? "#" : "@";
|
|
1573
|
+
const closeHash = marker === "#" && requested.endsWith("#") ? "#" : "";
|
|
1574
|
+
const topic = normalizeText(requested)
|
|
1575
|
+
.replace(/^[@#]+/, "")
|
|
1576
|
+
.replace(/#+$/, "")
|
|
1577
|
+
.replace(/\s+/g, "")
|
|
1578
|
+
.replace(/超话$/, "");
|
|
1579
|
+
return topic ? `${marker}${topic}超话${closeHash}` : "";
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function weiboProjectCopy(article, entry) {
|
|
1583
|
+
const title = normalizeText(article.title || article.payload?.article_title);
|
|
1584
|
+
const project = normalizeText(entry.project);
|
|
1585
|
+
const superTopic = resolveWeiboSuperTopic(article, entry);
|
|
1586
|
+
if (!title) return null;
|
|
1587
|
+
if (project === "最强蜗牛") {
|
|
1588
|
+
return {
|
|
1589
|
+
prefix: [title, superTopic, "性感春姬,在线播报", "大家好,我是你们的小姬!", "本期推文为大家带来"],
|
|
1590
|
+
suffix: []
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
if (project === "提灯与地下城") {
|
|
1594
|
+
return {
|
|
1595
|
+
prefix: [title, superTopic, "大噶好我是五郎!", "一起来看看本次的更新资讯吧~"],
|
|
1596
|
+
suffix: []
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
if (project.startsWith("魔卡少女樱")) {
|
|
1600
|
+
return {
|
|
1601
|
+
prefix: [title],
|
|
1602
|
+
suffix: ["#魔卡少女樱#", "#魔卡少女樱回忆钥匙#"]
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
export function prepareWeiboArticleForProject(article, entry = {}) {
|
|
1609
|
+
const requestedCover = weiboMeta(article, "weibo_cover");
|
|
1610
|
+
const existingImages = imageSourcesFromArticle(article).map(weiboImageSourceKey);
|
|
1611
|
+
const articleWithCover =
|
|
1612
|
+
requestedCover && !existingImages.includes(weiboImageSourceKey(requestedCover))
|
|
1613
|
+
? {
|
|
1614
|
+
...article,
|
|
1615
|
+
html: `${String(article.html || "")}<p data-qcplay-weibo-cover-source="1"><img src="${requestedCover.replace(/&/g, "&").replace(/"/g, """)}"></p>`,
|
|
1616
|
+
markdown: `${String(article.markdown || "").trim()}\n\n`.trim()
|
|
1617
|
+
}
|
|
1618
|
+
: article;
|
|
1619
|
+
const copy = weiboProjectCopy(articleWithCover, entry);
|
|
1620
|
+
if (copy) {
|
|
1621
|
+
const document = cheerio.load(String(articleWithCover.html || ""), null, false);
|
|
1622
|
+
if (document('[data-qcplay-weibo-copy="1"]').length === 0) {
|
|
1623
|
+
const paragraph = text => {
|
|
1624
|
+
const node = cheerio.load("<p></p>", null, false);
|
|
1625
|
+
node("p").attr("data-qcplay-weibo-copy", "1").text(text);
|
|
1626
|
+
return node.root().html();
|
|
1627
|
+
};
|
|
1628
|
+
const prefix = copy.prefix
|
|
1629
|
+
.filter(Boolean)
|
|
1630
|
+
.map(paragraph)
|
|
1631
|
+
.join("");
|
|
1632
|
+
const suffix = copy.suffix
|
|
1633
|
+
.filter(Boolean)
|
|
1634
|
+
.map(paragraph)
|
|
1635
|
+
.join("");
|
|
1636
|
+
return {
|
|
1637
|
+
...articleWithCover,
|
|
1638
|
+
html: `${prefix}${String(articleWithCover.html || "")}${suffix}`.trim(),
|
|
1639
|
+
markdown: `${copy.prefix.join("\n\n")}\n\n${String(articleWithCover.markdown || "").trim()}${copy.suffix.length ? `\n\n${copy.suffix.join("\n\n")}` : ""}`.trim(),
|
|
1640
|
+
weiboSuperTopic: resolveWeiboSuperTopic(articleWithCover, entry)
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
return articleWithCover;
|
|
1644
|
+
}
|
|
1645
|
+
const superTopic = resolveWeiboSuperTopic(articleWithCover, entry);
|
|
1646
|
+
if (!superTopic) {
|
|
1647
|
+
return articleWithCover;
|
|
1648
|
+
}
|
|
1649
|
+
const document = cheerio.load(String(articleWithCover.html || ""), null, false);
|
|
1650
|
+
const existingText = normalizeText(document.root().text() || articleWithCover.markdown);
|
|
1651
|
+
if (existingText.includes(superTopic)) {
|
|
1652
|
+
return articleWithCover;
|
|
1653
|
+
}
|
|
1654
|
+
const mention = cheerio.load("<p></p>", null, false);
|
|
1655
|
+
mention("p").attr("data-qcplay-weibo-super-topic", "1").text(superTopic);
|
|
1656
|
+
return {
|
|
1657
|
+
...articleWithCover,
|
|
1658
|
+
html: `${String(articleWithCover.html || "")}\n${mention.root().html()}`.trim(),
|
|
1659
|
+
markdown: `${String(articleWithCover.markdown || "").trim()}\n\n${superTopic}`.trim(),
|
|
1660
|
+
weiboSuperTopic: superTopic
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
|
|
603
1664
|
export function buildWeiboText(article) {
|
|
604
1665
|
return buildTapTapRichContent(article).plainText || plainTextForPlatform(article, "weibo");
|
|
605
1666
|
}
|
|
@@ -632,20 +1693,31 @@ export function buildWeiboRichContent(article) {
|
|
|
632
1693
|
export function resolveWeiboPublishingOptions(article) {
|
|
633
1694
|
return {
|
|
634
1695
|
intro: weiboMeta(article, "weibo_intro") || normalizeText(article.payload?.article_excerpt),
|
|
635
|
-
cover: weiboMeta(article, "weibo_cover")
|
|
1696
|
+
cover: weiboMeta(article, "weibo_cover"),
|
|
1697
|
+
column: weiboMeta(article, "weibo_column")
|
|
636
1698
|
};
|
|
637
1699
|
}
|
|
638
1700
|
|
|
639
|
-
export function
|
|
640
|
-
const
|
|
641
|
-
const
|
|
1701
|
+
export function resolveWeiboColumnSelection(column) {
|
|
1702
|
+
const requested = normalizeText(column);
|
|
1703
|
+
const index = /^\d+$/.test(requested) && Number(requested) > 0 ? Number(requested) - 1 : null;
|
|
1704
|
+
return { requested, index };
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
export function weiboPublishingPlan(article, entry = {}) {
|
|
1708
|
+
const preparedArticle = prepareWeiboArticleForProject(prepareArticleForPlatform(article, entry), entry);
|
|
1709
|
+
const settings = resolveWeiboPublishingOptions(preparedArticle);
|
|
1710
|
+
const richContent = buildWeiboRichContent(preparedArticle);
|
|
1711
|
+
const superTopic = resolveWeiboSuperTopic(preparedArticle, entry);
|
|
642
1712
|
return {
|
|
643
1713
|
type: "头条文章",
|
|
644
1714
|
characters: richContent.plainText.length,
|
|
645
1715
|
images: richContent.images.length,
|
|
646
1716
|
links: richContent.links.length,
|
|
647
1717
|
intro: Boolean(settings.intro),
|
|
648
|
-
cover: settings.cover ? "explicit" : richContent.images.length > 0 ? "first-image" : ""
|
|
1718
|
+
cover: settings.cover ? "explicit" : richContent.images.length > 0 ? "first-image" : "",
|
|
1719
|
+
column: settings.column || "",
|
|
1720
|
+
...(superTopic ? { superTopic } : {})
|
|
649
1721
|
};
|
|
650
1722
|
}
|
|
651
1723
|
|
|
@@ -669,12 +1741,15 @@ function bilibiliMeta(article, ...keys) {
|
|
|
669
1741
|
return "";
|
|
670
1742
|
}
|
|
671
1743
|
|
|
672
|
-
export function resolveBilibiliPublishingOptions(article) {
|
|
1744
|
+
export function resolveBilibiliPublishingOptions(article, entry = {}) {
|
|
673
1745
|
const requestedType = normalizeKey(bilibiliMeta(article, "bilibili_type", "bilibili_mode") || "article");
|
|
674
1746
|
const type = BILIBILI_TYPES.get(requestedType);
|
|
675
1747
|
if (!type) {
|
|
676
1748
|
throw new Error("bilibili_type 仅支持 article 或 video");
|
|
677
1749
|
}
|
|
1750
|
+
const configuredTopic = normalizeText(entry.requirements).match(
|
|
1751
|
+
/(?:添加|选择|加入)话题[^\r\n::]{0,12}[::]\s*([^\r\n,。;;]{1,40})/
|
|
1752
|
+
)?.[1];
|
|
678
1753
|
return {
|
|
679
1754
|
type,
|
|
680
1755
|
typeLabel: type === "video" ? "视频" : "专栏",
|
|
@@ -682,13 +1757,14 @@ export function resolveBilibiliPublishingOptions(article) {
|
|
|
682
1757
|
cover: bilibiliMeta(article, "bilibili_cover"),
|
|
683
1758
|
category: bilibiliMeta(article, "bilibili_category"),
|
|
684
1759
|
tags: splitTapTapFiles(bilibiliMeta(article, "bilibili_tags")),
|
|
685
|
-
topic: bilibiliMeta(article, "bilibili_topic")
|
|
1760
|
+
topic: bilibiliMeta(article, "bilibili_topic") || normalizeText(configuredTopic)
|
|
686
1761
|
};
|
|
687
1762
|
}
|
|
688
1763
|
|
|
689
|
-
export function bilibiliPublishingPlan(article) {
|
|
690
|
-
const
|
|
691
|
-
const
|
|
1764
|
+
export function bilibiliPublishingPlan(article, entry = {}) {
|
|
1765
|
+
const preparedArticle = prepareArticleForPlatform(article, entry);
|
|
1766
|
+
const settings = resolveBilibiliPublishingOptions(preparedArticle, entry);
|
|
1767
|
+
const richContent = buildBilibiliRichContent(preparedArticle);
|
|
692
1768
|
return {
|
|
693
1769
|
type: settings.typeLabel,
|
|
694
1770
|
video: settings.type === "video" && Boolean(settings.video),
|
|
@@ -702,23 +1778,60 @@ export function bilibiliPublishingPlan(article) {
|
|
|
702
1778
|
}
|
|
703
1779
|
|
|
704
1780
|
function bilibiliEditorUrl(entryUrl, type) {
|
|
705
|
-
const
|
|
1781
|
+
const configured = configuredUrl(entryUrl, url => /(?:^|\.)bilibili\.com$|(?:^|\.)biligame\.com$/i.test(url.hostname));
|
|
1782
|
+
const url = new URL(configured || "https://member.bilibili.com/");
|
|
1783
|
+
url.protocol = "https:";
|
|
1784
|
+
url.hostname = "member.bilibili.com";
|
|
1785
|
+
url.port = "";
|
|
706
1786
|
url.pathname = type === "video" ? "/platform/upload/video/frame" : "/platform/upload/text/new-edit";
|
|
707
1787
|
url.search = "";
|
|
708
1788
|
url.hash = "";
|
|
709
1789
|
return url.toString();
|
|
710
1790
|
}
|
|
711
1791
|
|
|
712
|
-
function tapTapEditorUrl(entryUrl, type) {
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
1792
|
+
export function tapTapEditorUrl(entryUrl, type, requirements = "") {
|
|
1793
|
+
const configured = configuredUrl(entryUrl, url => /(?:^|\.)taptap\.cn$/i.test(url.hostname));
|
|
1794
|
+
const source = configured ? new URL(configured) : null;
|
|
1795
|
+
const editor =
|
|
1796
|
+
source && source.pathname.replace(/\/+$/, "") === "/creator/edit"
|
|
1797
|
+
? source
|
|
1798
|
+
: new URL("https://www.taptap.cn/creator/edit");
|
|
1799
|
+
const appId =
|
|
1800
|
+
normalizeText(source?.searchParams.get("app_id")) ||
|
|
1801
|
+
normalizeText(source?.pathname.match(/^\/app\/(\d+)(?:\/|$)/)?.[1]);
|
|
1802
|
+
const relatedUrls = configuredUrls(`${entryUrl}\n${requirements}`);
|
|
1803
|
+
const groupId =
|
|
1804
|
+
normalizeText(source?.searchParams.get("group_id")) ||
|
|
1805
|
+
relatedUrls
|
|
1806
|
+
.map(candidate => {
|
|
1807
|
+
try {
|
|
1808
|
+
return new URL(candidate).searchParams.get("group_id");
|
|
1809
|
+
} catch {
|
|
1810
|
+
return "";
|
|
1811
|
+
}
|
|
1812
|
+
})
|
|
1813
|
+
.find(Boolean) ||
|
|
1814
|
+
"";
|
|
1815
|
+
|
|
1816
|
+
editor.protocol = "https:";
|
|
1817
|
+
editor.hostname = "www.taptap.cn";
|
|
1818
|
+
editor.port = "";
|
|
1819
|
+
editor.pathname = "/creator/edit";
|
|
1820
|
+
editor.searchParams.set("type", type);
|
|
1821
|
+
if (appId) editor.searchParams.set("app_id", appId);
|
|
1822
|
+
if (groupId) editor.searchParams.set("group_id", groupId);
|
|
1823
|
+
if ((appId || groupId) && !editor.searchParams.has("show_activity")) {
|
|
1824
|
+
editor.searchParams.set("show_activity", "1");
|
|
1825
|
+
}
|
|
1826
|
+
editor.hash = "";
|
|
1827
|
+
return editor.toString();
|
|
716
1828
|
}
|
|
717
1829
|
|
|
718
1830
|
function haoyouEditorUrl(entryUrl) {
|
|
719
1831
|
let url;
|
|
720
1832
|
try {
|
|
721
|
-
|
|
1833
|
+
const configured = configuredUrl(entryUrl, candidate => candidate.hostname.toLowerCase() === "bbs.3839.com");
|
|
1834
|
+
url = new URL(configured);
|
|
722
1835
|
} catch {
|
|
723
1836
|
throw new Error("好游快爆配置的发布页面 URL 无效");
|
|
724
1837
|
}
|
|
@@ -739,18 +1852,111 @@ function haoyouEditorUrl(entryUrl) {
|
|
|
739
1852
|
return url.toString();
|
|
740
1853
|
}
|
|
741
1854
|
|
|
742
|
-
function
|
|
743
|
-
|
|
1855
|
+
function haoyouEditorTargetMatches(actualUrl, expectedUrl) {
|
|
1856
|
+
try {
|
|
1857
|
+
const actual = new URL(actualUrl);
|
|
1858
|
+
const expected = new URL(expectedUrl);
|
|
1859
|
+
const expectedParams = ["m", "c", "a", "fid", "type"];
|
|
1860
|
+
return (
|
|
1861
|
+
actual.hostname.toLowerCase() === "bbs.3839.com" &&
|
|
1862
|
+
actual.pathname === "/index.php" &&
|
|
1863
|
+
expectedParams.every(param => actual.searchParams.get(param) === expected.searchParams.get(param))
|
|
1864
|
+
);
|
|
1865
|
+
} catch {
|
|
1866
|
+
return false;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
async function waitForHaoyouTargetEditor(page, editorUrl, spec, timeoutMs = 12000) {
|
|
1871
|
+
const deadline = Date.now() + timeoutMs;
|
|
1872
|
+
do {
|
|
1873
|
+
if (haoyouEditorTargetMatches(page.url(), editorUrl) && (await editorIsReady(page, spec))) {
|
|
1874
|
+
return true;
|
|
1875
|
+
}
|
|
1876
|
+
await page.waitForTimeout(300);
|
|
1877
|
+
} while (Date.now() < deadline);
|
|
1878
|
+
return false;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function weiboArticleEditorUrl(value = "") {
|
|
1882
|
+
return (
|
|
1883
|
+
configuredUrl(
|
|
1884
|
+
value,
|
|
1885
|
+
url => /(?:^|\.)card\.weibo\.com$/i.test(url.hostname) && /\/article\//i.test(url.pathname)
|
|
1886
|
+
) || "https://card.weibo.com/article/v5/editor"
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
function isWeiboQuickPublishEntry(entry = {}) {
|
|
1891
|
+
return (
|
|
1892
|
+
normalizeText(entry.project).includes("情报站") ||
|
|
1893
|
+
/快捷发布|直接在微博发内容|不用发布文章|直接使用快捷发布/.test(normalizeText(entry.requirements))
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function weiboQuickPublishUrl(entry) {
|
|
1898
|
+
return configuredUrl(entry.url, url => /(?:^|\.)weibo\.com$/i.test(url.hostname)) || "https://weibo.com/";
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
export function browserPlatformPageUrl(entry, type = "topic") {
|
|
1902
|
+
switch (entry.platformKey) {
|
|
1903
|
+
case "taptap":
|
|
1904
|
+
return tapTapEditorUrl(entry.url, type, entry.requirements);
|
|
1905
|
+
case "bilibili":
|
|
1906
|
+
return bilibiliEditorUrl(entry.url, type);
|
|
1907
|
+
case "weibo":
|
|
1908
|
+
return isWeiboQuickPublishEntry(entry) ? weiboQuickPublishUrl(entry) : weiboArticleEditorUrl(entry.url);
|
|
1909
|
+
case "haoyou":
|
|
1910
|
+
return haoyouEditorUrl(entry.url);
|
|
1911
|
+
case "xiaohongshu":
|
|
1912
|
+
return "https://creator.xiaohongshu.com/publish/publish?from=menu&target=image";
|
|
1913
|
+
case "x":
|
|
1914
|
+
return "https://x.com/compose/post";
|
|
1915
|
+
case "meta":
|
|
1916
|
+
return (
|
|
1917
|
+
configuredUrl(entry.url, url => url.hostname.toLowerCase() === "business.facebook.com") ||
|
|
1918
|
+
configuredUrl(entry.url)
|
|
1919
|
+
);
|
|
1920
|
+
case "instagram":
|
|
1921
|
+
return (
|
|
1922
|
+
configuredUrl(entry.url, url => url.hostname.toLowerCase() === "business.facebook.com") ||
|
|
1923
|
+
configuredUrl(entry.url, url => /(?:^|\.)instagram\.com$/i.test(url.hostname)) ||
|
|
1924
|
+
configuredUrl(entry.url)
|
|
1925
|
+
);
|
|
1926
|
+
default:
|
|
1927
|
+
return configuredUrl(entry.url) || normalizeText(entry.url);
|
|
1928
|
+
}
|
|
744
1929
|
}
|
|
745
1930
|
|
|
746
1931
|
function imageSourcesFromArticle(article) {
|
|
747
|
-
const $ = cheerio.load(article
|
|
1932
|
+
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
748
1933
|
return $("img[src]")
|
|
749
1934
|
.map((_, element) => normalizeText($(element).attr("src")))
|
|
750
1935
|
.get()
|
|
751
1936
|
.filter(Boolean);
|
|
752
1937
|
}
|
|
753
1938
|
|
|
1939
|
+
function splitImageSources(value) {
|
|
1940
|
+
return normalizeText(value)
|
|
1941
|
+
.split(/[,,\r\n]+/)
|
|
1942
|
+
.map(source => normalizeText(source))
|
|
1943
|
+
.filter(Boolean);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
export function resolveXiaohongshuPublishingOptions(article = {}) {
|
|
1947
|
+
const explicitImages = splitImageSources(article.meta?.xiaohongshu_images || article.meta?.xiaohongshu_image);
|
|
1948
|
+
const fallbackImages = imageSourcesFromArticle(article).slice(0, 1);
|
|
1949
|
+
const images = explicitImages.length > 0 ? explicitImages : fallbackImages;
|
|
1950
|
+
const collection = normalizeText(article.meta?.xiaohongshu_collection);
|
|
1951
|
+
if (images.length === 0) {
|
|
1952
|
+
throw new Error("小红书图文笔记至少需要 1 张图片;请提供 xiaohongshu_images 或在飞书文档中插入图片");
|
|
1953
|
+
}
|
|
1954
|
+
if (images.length > 18) {
|
|
1955
|
+
throw new Error(`小红书图文笔记最多支持 18 张图片,当前为 ${images.length} 张`);
|
|
1956
|
+
}
|
|
1957
|
+
return { images, collection, imageSource: explicitImages.length > 0 ? "explicit" : "first-article-image" };
|
|
1958
|
+
}
|
|
1959
|
+
|
|
754
1960
|
const TAPTAP_BLOCK_TAGS = new Set([
|
|
755
1961
|
"article",
|
|
756
1962
|
"aside",
|
|
@@ -810,15 +2016,56 @@ function normalizedTapTapText(value) {
|
|
|
810
2016
|
.replace(/\s+/g, " ");
|
|
811
2017
|
}
|
|
812
2018
|
|
|
2019
|
+
function normalizedTapTapVerificationText(value) {
|
|
2020
|
+
return normalizedTapTapText(value).replace(/\s+/g, "");
|
|
2021
|
+
}
|
|
2022
|
+
|
|
813
2023
|
function normalizedStructuredEditorText(value) {
|
|
814
2024
|
return normalizedTapTapText(value).replace(/\s*:\s*/g, ":");
|
|
815
2025
|
}
|
|
816
2026
|
|
|
2027
|
+
function normalizedPresenceText(value) {
|
|
2028
|
+
return normalizedStructuredEditorText(value).replace(/[\s\p{P}\p{S}]+/gu, "");
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
function orderedPresenceText(value) {
|
|
2032
|
+
return normalizedPresenceText(value).replace(/[\u200b-\u200d\ufeff]/g, "");
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
function hasOrderedTextFragments(actual, expected) {
|
|
2036
|
+
let offset = 0;
|
|
2037
|
+
for (const fragment of expected) {
|
|
2038
|
+
const needle = orderedPresenceText(fragment);
|
|
2039
|
+
if (!needle) continue;
|
|
2040
|
+
const found = actual.indexOf(needle, offset);
|
|
2041
|
+
if (found < 0) return false;
|
|
2042
|
+
offset = found + needle.length;
|
|
2043
|
+
}
|
|
2044
|
+
return true;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
function verificationFragments(value, size = 8) {
|
|
2048
|
+
const text = [...orderedPresenceText(value)];
|
|
2049
|
+
const fragments = [];
|
|
2050
|
+
for (let index = 0; index < text.length; index += size) {
|
|
2051
|
+
fragments.push(text.slice(index, index + size).join(""));
|
|
2052
|
+
}
|
|
2053
|
+
return fragments.filter(Boolean);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
817
2056
|
function safeRichTextColor(value) {
|
|
818
2057
|
const color = normalizeText(value);
|
|
819
2058
|
return /^(?:#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|[a-z]+)$/i.test(color) ? color : "";
|
|
820
2059
|
}
|
|
821
2060
|
|
|
2061
|
+
function safeRichCssLength(value, options = {}) {
|
|
2062
|
+
const length = normalizeText(value).replace(/\s*!important\s*$/i, "");
|
|
2063
|
+
const pattern = options.unitless
|
|
2064
|
+
? /^(?:0|\d+(?:\.\d+)?(?:px|pt|em|rem|%|vh|vw)?)$/i
|
|
2065
|
+
: /^(?:0|\d+(?:\.\d+)?(?:px|pt|em|rem|%|vh|vw))$/i;
|
|
2066
|
+
return pattern.test(length) ? length : "";
|
|
2067
|
+
}
|
|
2068
|
+
|
|
822
2069
|
function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
|
|
823
2070
|
const wrappers = [];
|
|
824
2071
|
const add = name => {
|
|
@@ -836,7 +2083,23 @@ function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
|
|
|
836
2083
|
const safeBackground = options.preserveBackground
|
|
837
2084
|
? safeRichTextColor(styleValue.match(/(?:^|;)\s*background(?:-color)?\s*:\s*([^;]+)/i)?.[1])
|
|
838
2085
|
: "";
|
|
839
|
-
const
|
|
2086
|
+
const preserveStyles = options.preserveInlineStyles || options.preserveBlockStyles;
|
|
2087
|
+
const fontSize = preserveStyles
|
|
2088
|
+
? safeRichCssLength(styleValue.match(/(?:^|;)\s*font-size\s*:\s*([^;]+)/i)?.[1])
|
|
2089
|
+
: "";
|
|
2090
|
+
const lineHeight = preserveStyles
|
|
2091
|
+
? safeRichCssLength(styleValue.match(/(?:^|;)\s*line-height\s*:\s*([^;]+)/i)?.[1], { unitless: true })
|
|
2092
|
+
: "";
|
|
2093
|
+
const letterSpacing = preserveStyles
|
|
2094
|
+
? safeRichCssLength(styleValue.match(/(?:^|;)\s*letter-spacing\s*:\s*([^;]+)/i)?.[1])
|
|
2095
|
+
: "";
|
|
2096
|
+
const spanStyles = [
|
|
2097
|
+
safeColor ? `color:${safeColor}` : "",
|
|
2098
|
+
safeBackground ? `background-color:${safeBackground}` : "",
|
|
2099
|
+
fontSize ? `font-size:${fontSize}` : "",
|
|
2100
|
+
lineHeight ? `line-height:${lineHeight}` : "",
|
|
2101
|
+
letterSpacing ? `letter-spacing:${letterSpacing}` : ""
|
|
2102
|
+
]
|
|
840
2103
|
.filter(Boolean)
|
|
841
2104
|
.join(";");
|
|
842
2105
|
return {
|
|
@@ -846,11 +2109,15 @@ function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
|
|
|
846
2109
|
}
|
|
847
2110
|
|
|
848
2111
|
export function buildTapTapRichContent(article) {
|
|
849
|
-
|
|
2112
|
+
const preserveWechatStyles = isWechatImportedArticle(article) || article.tapTapPreserveWechatFormatting;
|
|
2113
|
+
return buildPlatformRichContent(article, {
|
|
2114
|
+
preserveBackground: preserveWechatStyles,
|
|
2115
|
+
preserveBlockStyles: preserveWechatStyles
|
|
2116
|
+
});
|
|
850
2117
|
}
|
|
851
2118
|
|
|
852
2119
|
function buildPlatformRichContent(article, options = {}) {
|
|
853
|
-
const $ = cheerio.load(article
|
|
2120
|
+
const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
|
|
854
2121
|
$("script,style,noscript,iframe,svg,audio,video").remove();
|
|
855
2122
|
const items = [];
|
|
856
2123
|
const links = [];
|
|
@@ -887,6 +2154,23 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
887
2154
|
);
|
|
888
2155
|
if (color) blockStyles.push(`color:${color}`);
|
|
889
2156
|
if (background) blockStyles.push(`background-color:${background}`);
|
|
2157
|
+
const supportedLengths = [
|
|
2158
|
+
["font-size", false],
|
|
2159
|
+
["line-height", true],
|
|
2160
|
+
["letter-spacing", false],
|
|
2161
|
+
["margin-top", false],
|
|
2162
|
+
["margin-bottom", false],
|
|
2163
|
+
["padding-top", false],
|
|
2164
|
+
["padding-bottom", false],
|
|
2165
|
+
["text-indent", false]
|
|
2166
|
+
];
|
|
2167
|
+
for (const [property, unitless] of supportedLengths) {
|
|
2168
|
+
const value = safeRichCssLength(
|
|
2169
|
+
styleValue.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i"))?.[1],
|
|
2170
|
+
{ unitless }
|
|
2171
|
+
);
|
|
2172
|
+
if (value) blockStyles.push(`${property}:${value}`);
|
|
2173
|
+
}
|
|
890
2174
|
}
|
|
891
2175
|
return blockStyles.length > 0 ? ` style="${blockStyles.join(";")}"` : "";
|
|
892
2176
|
};
|
|
@@ -1025,9 +2309,7 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
1025
2309
|
const alignment =
|
|
1026
2310
|
normalizeText(childElement.attr("align")) ||
|
|
1027
2311
|
normalizeText(styleValue.match(/(?:^|;)\s*text-align\s*:\s*([^;]+)/i)?.[1]);
|
|
1028
|
-
const alignmentStyle =
|
|
1029
|
-
? ` style="text-align:${alignment.toLowerCase()}"`
|
|
1030
|
-
: "";
|
|
2312
|
+
const alignmentStyle = blockStyleAttribute(styleValue, alignment.toLowerCase());
|
|
1031
2313
|
const blockTag = /^h[1-3]$/.test(tag) ? tag : tag === "blockquote" ? options.quoteTag || "blockquote" : "p";
|
|
1032
2314
|
parts.push(`<${blockTag}${alignmentStyle}>${fragment.html}</${blockTag}>`);
|
|
1033
2315
|
plainParts.push(fragment.plain);
|
|
@@ -1098,10 +2380,13 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
1098
2380
|
for (const element of tableImages) {
|
|
1099
2381
|
const source = normalizeText($(element).attr("src") || $(element).attr("data-src"));
|
|
1100
2382
|
if (!source) continue;
|
|
2383
|
+
const caption = normalizeText($(element).closest("figure").find("figcaption").first().text()) ||
|
|
2384
|
+
normalizeText($(element).attr("alt") || $(element).attr("title")).slice(0, 50);
|
|
1101
2385
|
const image = {
|
|
1102
2386
|
type: "image",
|
|
1103
2387
|
source,
|
|
1104
|
-
alt:
|
|
2388
|
+
alt: caption,
|
|
2389
|
+
caption
|
|
1105
2390
|
};
|
|
1106
2391
|
items.push(image);
|
|
1107
2392
|
images.push(image);
|
|
@@ -1125,10 +2410,13 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
1125
2410
|
flushHtml();
|
|
1126
2411
|
const source = normalizeText(element.attr("src") || element.attr("data-src"));
|
|
1127
2412
|
if (source) {
|
|
2413
|
+
const caption = normalizeText(element.closest("figure").find("figcaption").first().text()) ||
|
|
2414
|
+
normalizeText(element.attr("alt") || element.attr("title")).slice(0, 50);
|
|
1128
2415
|
const image = {
|
|
1129
2416
|
type: "image",
|
|
1130
2417
|
source,
|
|
1131
|
-
alt:
|
|
2418
|
+
alt: caption,
|
|
2419
|
+
caption
|
|
1132
2420
|
};
|
|
1133
2421
|
items.push(image);
|
|
1134
2422
|
images.push(image);
|
|
@@ -1140,6 +2428,11 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
1140
2428
|
plain += "\n";
|
|
1141
2429
|
return;
|
|
1142
2430
|
}
|
|
2431
|
+
if (tag === "hr") {
|
|
2432
|
+
flushHtml();
|
|
2433
|
+
items.push({ type: "html", html: "<hr>", plain: "", block: "hr", align: "" });
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
1143
2436
|
if (tag === "a") {
|
|
1144
2437
|
const label = normalizedTapTapText(element.text());
|
|
1145
2438
|
const href = safeTapTapHref(element.attr("href"));
|
|
@@ -1242,7 +2535,7 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
1242
2535
|
walk(node);
|
|
1243
2536
|
}
|
|
1244
2537
|
flushHtml();
|
|
1245
|
-
if (items.length === 0 && normalizeText(article.markdown)) {
|
|
2538
|
+
if (items.length === 0 && normalizeText(article.markdown) && !isWechatImportedArticle(article)) {
|
|
1246
2539
|
const fallback = normalizeText(article.markdown);
|
|
1247
2540
|
items.push({ type: "html", html: `<p>${escapeTapTapHtml(fallback)}</p>`, plain: fallback });
|
|
1248
2541
|
}
|
|
@@ -1266,13 +2559,32 @@ export function buildBilibiliRichContent(article) {
|
|
|
1266
2559
|
}
|
|
1267
2560
|
|
|
1268
2561
|
export function buildHaoyouRichContent(article) {
|
|
1269
|
-
|
|
2562
|
+
const content = buildPlatformRichContent(article, {
|
|
1270
2563
|
preserveBackground: true,
|
|
1271
2564
|
preserveBlockStyles: true,
|
|
1272
2565
|
quoteTag: "div",
|
|
1273
2566
|
structuredTableTag: "div",
|
|
1274
2567
|
structuredTables: true
|
|
1275
2568
|
});
|
|
2569
|
+
const flattenInlineSpans = isWechatImportedArticle(article);
|
|
2570
|
+
return {
|
|
2571
|
+
...content,
|
|
2572
|
+
items: content.items.map(item =>
|
|
2573
|
+
item.type === "html"
|
|
2574
|
+
? {
|
|
2575
|
+
...item,
|
|
2576
|
+
// The Haoyou Quill sanitizer drops a transparent background span
|
|
2577
|
+
// together with its children instead of unwrapping it.
|
|
2578
|
+
html: item.html
|
|
2579
|
+
.replace(/\s*background(?:-color)?\s*:\s*transparent\s*;?/gi, "")
|
|
2580
|
+
// WeChat's nested styled spans can also truncate their trailing
|
|
2581
|
+
// text in the Haoyou Quill clipboard parser. Keep their text and
|
|
2582
|
+
// semantic children while dropping only the wrapper.
|
|
2583
|
+
.replace(flattenInlineSpans ? /<\/?span\b[^>]*>/gi : /$^/, "")
|
|
2584
|
+
}
|
|
2585
|
+
: item
|
|
2586
|
+
)
|
|
2587
|
+
};
|
|
1276
2588
|
}
|
|
1277
2589
|
|
|
1278
2590
|
function contentTypeForFile(fileName, fallback = "application/octet-stream") {
|
|
@@ -1414,6 +2726,88 @@ async function waitForTapTapInlineImage(bodyInput, previousCount, source) {
|
|
|
1414
2726
|
throw new Error(`TapTap 正文图片上传超时: ${source}`);
|
|
1415
2727
|
}
|
|
1416
2728
|
|
|
2729
|
+
async function waitForTapTapRichEditorState(page, bodyInput, content, options = {}) {
|
|
2730
|
+
const expectedMarkers = options.expectedMarkers || [];
|
|
2731
|
+
const expectedImages = options.expectedImages || 0;
|
|
2732
|
+
const markerPrefix = options.forbiddenMarkerPrefix || "";
|
|
2733
|
+
const deadline = Date.now() + (options.timeoutMs || 15000);
|
|
2734
|
+
let previousSignature = "";
|
|
2735
|
+
let stableChecks = 0;
|
|
2736
|
+
let state = { text: "", images: 0, failedImages: 0, links: [] };
|
|
2737
|
+
let missingText = [];
|
|
2738
|
+
let missingLinks = [];
|
|
2739
|
+
|
|
2740
|
+
do {
|
|
2741
|
+
state = await bodyInput.evaluate(element => ({
|
|
2742
|
+
text: (() => {
|
|
2743
|
+
const slateText = [...element.querySelectorAll('[data-slate-string="true"]')]
|
|
2744
|
+
.map(node => node.textContent || "")
|
|
2745
|
+
.join("\n");
|
|
2746
|
+
return slateText || element.innerText || element.textContent || "";
|
|
2747
|
+
})(),
|
|
2748
|
+
images: element.querySelectorAll(".tap-editor-image").length,
|
|
2749
|
+
failedImages: element.querySelectorAll(".tap-editor-image__retry").length,
|
|
2750
|
+
links: [...element.querySelectorAll("a.tap-editor-link[href]")].map(link => ({
|
|
2751
|
+
href: link.href,
|
|
2752
|
+
label: link.innerText
|
|
2753
|
+
}))
|
|
2754
|
+
}));
|
|
2755
|
+
const actualText = normalizedTapTapVerificationText(state.text);
|
|
2756
|
+
const presenceText = normalizedPresenceText(state.text);
|
|
2757
|
+
missingText = content.items.filter(
|
|
2758
|
+
item =>
|
|
2759
|
+
item.type === "html" &&
|
|
2760
|
+
item.plain &&
|
|
2761
|
+
!presenceText.includes(normalizedPresenceText(item.plain))
|
|
2762
|
+
);
|
|
2763
|
+
const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
|
|
2764
|
+
missingLinks = content.links.filter(link => !actualLinks.has(link.href));
|
|
2765
|
+
const markersReady = expectedMarkers.every(marker => actualText.includes(marker));
|
|
2766
|
+
const markersRemoved = !markerPrefix || !actualText.includes(markerPrefix);
|
|
2767
|
+
const ready =
|
|
2768
|
+
state.failedImages === 0 &&
|
|
2769
|
+
state.images >= expectedImages &&
|
|
2770
|
+
missingText.length === 0 &&
|
|
2771
|
+
missingLinks.length === 0 &&
|
|
2772
|
+
markersReady &&
|
|
2773
|
+
markersRemoved;
|
|
2774
|
+
const signature = `${actualText.length}:${state.images}:${state.failedImages}:${state.links.length}`;
|
|
2775
|
+
if (ready) {
|
|
2776
|
+
stableChecks = signature === previousSignature ? stableChecks + 1 : 1;
|
|
2777
|
+
if (stableChecks >= 2) {
|
|
2778
|
+
return state;
|
|
2779
|
+
}
|
|
2780
|
+
} else {
|
|
2781
|
+
stableChecks = 0;
|
|
2782
|
+
}
|
|
2783
|
+
previousSignature = signature;
|
|
2784
|
+
await page.waitForTimeout(250);
|
|
2785
|
+
} while (Date.now() < deadline);
|
|
2786
|
+
|
|
2787
|
+
if (state.failedImages > 0) {
|
|
2788
|
+
throw new Error("TapTap 正文图片上传失败,请检查编辑器中的重试提示");
|
|
2789
|
+
}
|
|
2790
|
+
if (state.images < expectedImages) {
|
|
2791
|
+
throw new Error(`TapTap 正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
|
|
2792
|
+
}
|
|
2793
|
+
if (expectedMarkers.some(marker => !normalizedTapTapVerificationText(state.text).includes(marker))) {
|
|
2794
|
+
throw new Error("TapTap 正文粘贴尚未完成,图片占位位置未全部写入");
|
|
2795
|
+
}
|
|
2796
|
+
if (markerPrefix && normalizedTapTapVerificationText(state.text).includes(markerPrefix)) {
|
|
2797
|
+
throw new Error("TapTap 正文图片占位符未正确替换");
|
|
2798
|
+
}
|
|
2799
|
+
if (missingText.length > 0) {
|
|
2800
|
+
const missing = normalizedTapTapText(missingText[0].plain).slice(0, 80);
|
|
2801
|
+
throw new Error(
|
|
2802
|
+
`TapTap 正文文字未完整写入编辑器,首个缺失片段“${missing}”(预期 ${content.plainText.length} 字,实际 ${state.text.length} 字)`
|
|
2803
|
+
);
|
|
2804
|
+
}
|
|
2805
|
+
if (missingLinks.length > 0) {
|
|
2806
|
+
throw new Error(`TapTap 正文超链接未正确写入: ${missingLinks[0].href}`);
|
|
2807
|
+
}
|
|
2808
|
+
throw new Error("TapTap 正文写入后未能稳定完成回读校验");
|
|
2809
|
+
}
|
|
2810
|
+
|
|
1417
2811
|
export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText) {
|
|
1418
2812
|
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
1419
2813
|
await fillLocator(bodyInput, fallbackText);
|
|
@@ -1455,41 +2849,58 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
|
|
|
1455
2849
|
html: htmlParts.join("<br><br>"),
|
|
1456
2850
|
plain: plainParts.join("\n")
|
|
1457
2851
|
});
|
|
1458
|
-
await page
|
|
2852
|
+
await waitForTapTapRichEditorState(page, bodyInput, content, {
|
|
2853
|
+
expectedMarkers: imageMarkers.map(item => item.marker)
|
|
2854
|
+
});
|
|
1459
2855
|
}
|
|
1460
2856
|
|
|
1461
2857
|
const payloadCache = new Map();
|
|
1462
2858
|
let uploadedImages = 0;
|
|
1463
2859
|
for (const { marker, item, index } of imageMarkers) {
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
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;
|
|
2860
|
+
let removed = false;
|
|
2861
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2862
|
+
await bodyInput.focus().catch(() => {});
|
|
2863
|
+
const selected = await bodyInput.evaluate((element, markerValue) => {
|
|
2864
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
2865
|
+
const nodes = [];
|
|
2866
|
+
let textNode = walker.nextNode();
|
|
2867
|
+
while (textNode) {
|
|
2868
|
+
nodes.push(textNode);
|
|
2869
|
+
textNode = walker.nextNode();
|
|
1482
2870
|
}
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
2871
|
+
let combined = "";
|
|
2872
|
+
const offsets = nodes.map(node => {
|
|
2873
|
+
const start = combined.length;
|
|
2874
|
+
combined += node.data;
|
|
2875
|
+
return { node, start };
|
|
2876
|
+
});
|
|
2877
|
+
const markerStart = combined.indexOf(markerValue);
|
|
2878
|
+
if (markerStart < 0) return false;
|
|
2879
|
+
const markerEnd = markerStart + markerValue.length;
|
|
2880
|
+
const start = [...offsets].reverse().find(entry => entry.start <= markerStart);
|
|
2881
|
+
const end = offsets.find(entry => entry.start + entry.node.data.length >= markerEnd);
|
|
2882
|
+
if (!start || !end) return false;
|
|
2883
|
+
const range = document.createRange();
|
|
2884
|
+
range.setStart(start.node, markerStart - start.start);
|
|
2885
|
+
range.setEnd(end.node, markerEnd - end.start);
|
|
2886
|
+
const selection = window.getSelection();
|
|
2887
|
+
selection.removeAllRanges();
|
|
2888
|
+
selection.addRange(range);
|
|
2889
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
2890
|
+
return true;
|
|
2891
|
+
}, marker);
|
|
2892
|
+
if (!selected) break;
|
|
2893
|
+
await page.waitForTimeout(80);
|
|
2894
|
+
await page.keyboard.press("Backspace");
|
|
2895
|
+
await page.waitForTimeout(100);
|
|
2896
|
+
removed = !(await bodyInput.evaluate((element, markerValue) => {
|
|
2897
|
+
return (element.innerText || element.textContent || "").includes(markerValue);
|
|
2898
|
+
}, marker));
|
|
2899
|
+
if (removed) break;
|
|
2900
|
+
}
|
|
2901
|
+
if (!removed) {
|
|
2902
|
+
throw new Error(`TapTap 正文图片占位符未能完整清理: ${item.source}`);
|
|
1489
2903
|
}
|
|
1490
|
-
await page.waitForTimeout(50);
|
|
1491
|
-
await page.keyboard.press("Backspace");
|
|
1492
|
-
await page.waitForTimeout(50);
|
|
1493
2904
|
let payload = payloadCache.get(item.source);
|
|
1494
2905
|
if (!payload) {
|
|
1495
2906
|
payload = await imageUploadPayload(item.source, article.articleFile, index, "TapTap");
|
|
@@ -1508,33 +2919,10 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
|
|
|
1508
2919
|
}
|
|
1509
2920
|
}
|
|
1510
2921
|
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
}
|
|
2922
|
+
await waitForTapTapRichEditorState(page, bodyInput, content, {
|
|
2923
|
+
expectedImages: inlineImages ? content.images.length : 0,
|
|
2924
|
+
forbiddenMarkerPrefix: markerPrefix
|
|
2925
|
+
});
|
|
1538
2926
|
return { rich: true, images: uploadedImages, links: content.links.length, plainText: content.plainText };
|
|
1539
2927
|
}
|
|
1540
2928
|
|
|
@@ -1571,7 +2959,28 @@ async function uploadBilibiliInlineImage(page, spec, payload) {
|
|
|
1571
2959
|
await input.setInputFiles([payload]);
|
|
1572
2960
|
}
|
|
1573
2961
|
|
|
1574
|
-
|
|
2962
|
+
export function isBilibiliImageComplianceError(value) {
|
|
2963
|
+
return /图片(?:内容)?(?:不合规|违规|审核不通过|审核失败|不符合规范)|内容不合规|图片审核/.test(normalizeText(value));
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
async function removeBilibiliFailedImage(bodyInput) {
|
|
2967
|
+
return bodyInput.evaluate(element => {
|
|
2968
|
+
const failedNodes = [...element.querySelectorAll(
|
|
2969
|
+
'.upload-fail, .image-upload-error, [class*="upload-error"], [class*="审核"], [class*="违规"]'
|
|
2970
|
+
)];
|
|
2971
|
+
let removed = 0;
|
|
2972
|
+
for (const failedNode of failedNodes) {
|
|
2973
|
+
const image = failedNode.closest("figure, .image-item, .image-upload, .bili-image, p")?.querySelector("img") ||
|
|
2974
|
+
failedNode.closest("img");
|
|
2975
|
+
const target = image?.closest("figure, .image-item, .image-upload, .bili-image, p") || image || failedNode;
|
|
2976
|
+
target?.remove();
|
|
2977
|
+
if (target) removed += 1;
|
|
2978
|
+
}
|
|
2979
|
+
return removed > 0;
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
|
|
1575
2984
|
const deadline = Date.now() + 120000;
|
|
1576
2985
|
const startedAt = Date.now();
|
|
1577
2986
|
let stableChecks = 0;
|
|
@@ -1582,6 +2991,20 @@ async function waitForBilibiliInlineImage(bodyInput, previousCount, source) {
|
|
|
1582
2991
|
.count();
|
|
1583
2992
|
const pending = await pendingUploadCount(bodyInput);
|
|
1584
2993
|
if (failed > 0) {
|
|
2994
|
+
const failureText = await bodyInput.evaluate(element => {
|
|
2995
|
+
const nodes = [...element.querySelectorAll(
|
|
2996
|
+
'.upload-fail, .image-upload-error, [class*="upload-error"], [class*="审核"], [class*="违规"]'
|
|
2997
|
+
)];
|
|
2998
|
+
return nodes.map(node => node.innerText || node.textContent || "").join(" ");
|
|
2999
|
+
}).catch(() => "");
|
|
3000
|
+
const pageText = options.page?.evaluate
|
|
3001
|
+
? await options.page.evaluate(() => document.body?.innerText || "").catch(() => "")
|
|
3002
|
+
: "";
|
|
3003
|
+
if (options.removeNoncompliant && isBilibiliImageComplianceError(`${failureText} ${pageText}`)) {
|
|
3004
|
+
if (await removeBilibiliFailedImage(bodyInput)) {
|
|
3005
|
+
return { removed: true };
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
1585
3008
|
throw new Error(`B站正文图片上传失败: ${source}`);
|
|
1586
3009
|
}
|
|
1587
3010
|
if (count > previousCount && pending === 0 && Date.now() - startedAt >= 1200) {
|
|
@@ -1597,7 +3020,7 @@ async function waitForBilibiliInlineImage(bodyInput, previousCount, source) {
|
|
|
1597
3020
|
throw new Error(`B站正文图片上传超时: ${source}`);
|
|
1598
3021
|
}
|
|
1599
3022
|
|
|
1600
|
-
export async function insertBilibiliRichContent(page, bodyInput, article, spec, fallbackText) {
|
|
3023
|
+
export async function insertBilibiliRichContent(page, bodyInput, article, spec, fallbackText, options = {}) {
|
|
1601
3024
|
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
1602
3025
|
await fillLocator(bodyInput, fallbackText);
|
|
1603
3026
|
await verifyFilledValue(bodyInput, fallbackText, "正文", "B站");
|
|
@@ -1632,6 +3055,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
1632
3055
|
}
|
|
1633
3056
|
|
|
1634
3057
|
const payloadCache = new Map();
|
|
3058
|
+
let removedImages = 0;
|
|
1635
3059
|
for (const { marker, item, index } of imageMarkers) {
|
|
1636
3060
|
const selected = await bodyInput.evaluate((element, markerValue) => {
|
|
1637
3061
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
@@ -1663,7 +3087,13 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
1663
3087
|
}
|
|
1664
3088
|
const previousCount = await bodyInput.locator("img").count();
|
|
1665
3089
|
await uploadBilibiliInlineImage(page, spec, payload);
|
|
1666
|
-
await waitForBilibiliInlineImage(bodyInput, previousCount, item.source
|
|
3090
|
+
const uploadResult = await waitForBilibiliInlineImage(page, bodyInput, previousCount, item.source, {
|
|
3091
|
+
page,
|
|
3092
|
+
removeNoncompliant: options.removeNoncompliant === true
|
|
3093
|
+
});
|
|
3094
|
+
if (uploadResult?.removed) {
|
|
3095
|
+
removedImages += 1;
|
|
3096
|
+
}
|
|
1667
3097
|
}
|
|
1668
3098
|
|
|
1669
3099
|
let state = null;
|
|
@@ -1682,15 +3112,17 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
1682
3112
|
if (stableChecks >= 3) break;
|
|
1683
3113
|
await page.waitForTimeout(250);
|
|
1684
3114
|
}
|
|
1685
|
-
|
|
1686
|
-
|
|
3115
|
+
const expectedImages = content.images.length - removedImages;
|
|
3116
|
+
if (state.failedImages > 0 || state.images < expectedImages) {
|
|
3117
|
+
throw new Error(`B站正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
|
|
1687
3118
|
}
|
|
1688
3119
|
const actualText = normalizedStructuredEditorText(state.text);
|
|
3120
|
+
const actualPresenceText = normalizedPresenceText(state.text);
|
|
1689
3121
|
if (actualText.includes(markerPrefix)) {
|
|
1690
3122
|
throw new Error("B站正文图片占位符未正确替换");
|
|
1691
3123
|
}
|
|
1692
3124
|
for (const item of content.items) {
|
|
1693
|
-
if (item.type === "html" && item.plain && !
|
|
3125
|
+
if (item.type === "html" && item.plain && !actualPresenceText.includes(normalizedPresenceText(item.plain))) {
|
|
1694
3126
|
const missing = normalizedStructuredEditorText(item.plain).slice(0, 80);
|
|
1695
3127
|
throw new Error(
|
|
1696
3128
|
`B站正文文字未完整写入编辑器,首个缺失片段“${missing}”(预期 ${content.plainText.length} 字,实际 ${state.text.length} 字)`
|
|
@@ -1703,18 +3135,18 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
1703
3135
|
throw new Error(`B站正文超链接未正确写入: ${link.href}`);
|
|
1704
3136
|
}
|
|
1705
3137
|
}
|
|
1706
|
-
return { rich: true, images:
|
|
3138
|
+
return { rich: true, images: state.images, links: content.links.length, plainText: content.plainText };
|
|
1707
3139
|
}
|
|
1708
3140
|
|
|
1709
3141
|
function haoyouImageUploadPayload(payload, index) {
|
|
1710
3142
|
if (payload.buffer.length > 12 * 1024 * 1024) {
|
|
1711
3143
|
throw new Error(`好游快爆正文第 ${index + 1} 张图片超过 12MB,无法上传`);
|
|
1712
3144
|
}
|
|
1713
|
-
|
|
3145
|
+
// 好游快爆编辑器的图片回调对 GIF/WebP 等格式并不稳定;统一转 PNG,避免占位卡片被回调移除。
|
|
3146
|
+
const supportedExtensions = new Set([".jpg", ".jpeg", ".png"]);
|
|
1714
3147
|
const extensionByMime = new Map([
|
|
1715
3148
|
["image/jpeg", ".jpg"],
|
|
1716
|
-
["image/png", ".png"]
|
|
1717
|
-
["image/gif", ".gif"]
|
|
3149
|
+
["image/png", ".png"]
|
|
1718
3150
|
]);
|
|
1719
3151
|
const sourceExtension = path.extname(payload.name).toLowerCase();
|
|
1720
3152
|
const nativeExtension = supportedExtensions.has(sourceExtension)
|
|
@@ -1779,34 +3211,17 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
1779
3211
|
return { rich: false, images: 0, links: 0, plainText: fallbackText };
|
|
1780
3212
|
}
|
|
1781
3213
|
|
|
1782
|
-
const
|
|
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) => {
|
|
3214
|
+
const initialized = await bodyInput.evaluate(element => {
|
|
1798
3215
|
const quill = element.parentElement?.__quill;
|
|
1799
3216
|
if (!quill?.clipboard?.dangerouslyPasteHTML) {
|
|
1800
3217
|
return false;
|
|
1801
3218
|
}
|
|
1802
|
-
quill.setText("", "
|
|
1803
|
-
quill.clipboard.dangerouslyPasteHTML(0, richHtml, "api");
|
|
1804
|
-
quill.setSelection(quill.getLength(), 0, "silent");
|
|
3219
|
+
quill.setText("", "api");
|
|
1805
3220
|
quill.history?.clear?.();
|
|
1806
3221
|
return true;
|
|
1807
|
-
}
|
|
3222
|
+
});
|
|
1808
3223
|
if (!initialized) {
|
|
1809
|
-
if (
|
|
3224
|
+
if (content.images.length > 0) {
|
|
1810
3225
|
throw new Error("好游快爆富文本编辑器接口不可用,无法安全上传正文图片");
|
|
1811
3226
|
}
|
|
1812
3227
|
await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
|
|
@@ -1815,12 +3230,28 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
1815
3230
|
await page.waitForTimeout(200);
|
|
1816
3231
|
|
|
1817
3232
|
const payloadCache = new Map();
|
|
1818
|
-
|
|
3233
|
+
let imageIndex = 0;
|
|
3234
|
+
for (const item of content.items) {
|
|
3235
|
+
if (item.type === "html") {
|
|
3236
|
+
const inserted = await bodyInput.evaluate((element, richHtml) => {
|
|
3237
|
+
const quill = element.parentElement?.__quill;
|
|
3238
|
+
if (!quill?.clipboard?.dangerouslyPasteHTML || typeof quill.getLength !== "function") {
|
|
3239
|
+
return false;
|
|
3240
|
+
}
|
|
3241
|
+
quill.clipboard.dangerouslyPasteHTML(Math.max(0, quill.getLength() - 1), richHtml, "api");
|
|
3242
|
+
return true;
|
|
3243
|
+
}, item.html);
|
|
3244
|
+
if (!inserted) {
|
|
3245
|
+
throw new Error("好游快爆富文本编辑器接口不可用,无法安全写入正文");
|
|
3246
|
+
}
|
|
3247
|
+
continue;
|
|
3248
|
+
}
|
|
3249
|
+
|
|
1819
3250
|
let upload = payloadCache.get(item.source);
|
|
1820
3251
|
if (!upload) {
|
|
1821
3252
|
upload = haoyouImageUploadPayload(
|
|
1822
|
-
await imageUploadPayload(item.source, article.articleFile,
|
|
1823
|
-
|
|
3253
|
+
await imageUploadPayload(item.source, article.articleFile, imageIndex, "好游快爆"),
|
|
3254
|
+
imageIndex
|
|
1824
3255
|
);
|
|
1825
3256
|
payloadCache.set(item.source, upload);
|
|
1826
3257
|
}
|
|
@@ -1832,11 +3263,10 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
1832
3263
|
if (!quill || !editor?.insertPicDelta || !editor?.cb?.picUpload || !editor?.tool?.idRandom) {
|
|
1833
3264
|
return "";
|
|
1834
3265
|
}
|
|
1835
|
-
|
|
1836
|
-
if (markerIndex < 0) {
|
|
3266
|
+
if (typeof quill.getLength !== "function") {
|
|
1837
3267
|
return "";
|
|
1838
3268
|
}
|
|
1839
|
-
|
|
3269
|
+
const markerIndex = Math.max(0, quill.getLength() - 1);
|
|
1840
3270
|
quill.setSelection(markerIndex, 0, "silent");
|
|
1841
3271
|
const binary = atob(payload.base64);
|
|
1842
3272
|
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
@@ -1865,26 +3295,43 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
1865
3295
|
});
|
|
1866
3296
|
const id = editor.tool.idRandom();
|
|
1867
3297
|
editor.insertPicDelta(id);
|
|
3298
|
+
// insertPicDelta defers the embed with setTimeout. Do not start the
|
|
3299
|
+
// upload or append the next content item until the embed owns this range.
|
|
3300
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
3301
|
+
if (typeof document !== "undefined" && !document.getElementById(id)) {
|
|
3302
|
+
return "";
|
|
3303
|
+
}
|
|
1868
3304
|
editor.cb.picUpload(id, file);
|
|
1869
3305
|
return id;
|
|
1870
3306
|
},
|
|
1871
|
-
|
|
3307
|
+
upload
|
|
1872
3308
|
)
|
|
1873
3309
|
.catch(error => {
|
|
1874
3310
|
throw new Error(`好游快爆正文图片转换或上传初始化失败: ${item.source} (${error.message})`);
|
|
1875
3311
|
});
|
|
1876
3312
|
if (!imageId) {
|
|
1877
|
-
throw new Error(
|
|
3313
|
+
throw new Error(`好游快爆正文图片插入位置丢失或图片上传接口不可用: ${item.source}`);
|
|
1878
3314
|
}
|
|
1879
3315
|
await waitForHaoyouInlineImage(page, bodyInput, imageId, item.source);
|
|
3316
|
+
imageIndex += 1;
|
|
1880
3317
|
}
|
|
1881
3318
|
|
|
3319
|
+
// Image callbacks continue normalizing nearby Quill blocks after their
|
|
3320
|
+
// preview has appeared. Give the editor time to finish before read-back.
|
|
3321
|
+
await page.waitForTimeout(3000);
|
|
3322
|
+
|
|
1882
3323
|
let state = null;
|
|
1883
3324
|
let previousTextLength = -1;
|
|
1884
3325
|
let stableChecks = 0;
|
|
1885
3326
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
1886
3327
|
state = await bodyInput.evaluate(element => ({
|
|
1887
|
-
|
|
3328
|
+
// Quill's document model is authoritative. The rendered editor can
|
|
3329
|
+
// temporarily omit text between image panels from innerText.
|
|
3330
|
+
text:
|
|
3331
|
+
typeof element.parentElement?.__quill?.getText === "function"
|
|
3332
|
+
? element.parentElement.__quill.getText()
|
|
3333
|
+
: element.innerText,
|
|
3334
|
+
renderedText: element.innerText,
|
|
1888
3335
|
images: element.querySelectorAll('.panel-pic img[src]').length,
|
|
1889
3336
|
failedImages: element.querySelectorAll('.panel-pic .fail').length,
|
|
1890
3337
|
pendingImages: element.querySelectorAll('.panel-pic .ing').length,
|
|
@@ -1901,17 +3348,19 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
1901
3348
|
if (state.failedImages > 0 || state.pendingImages > 0 || state.images < content.images.length) {
|
|
1902
3349
|
throw new Error(`好游快爆正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
|
|
1903
3350
|
}
|
|
1904
|
-
const
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
3351
|
+
const expectedFragments = content.items
|
|
3352
|
+
.filter(item => item.type === "html" && item.plain)
|
|
3353
|
+
.flatMap(item => verificationFragments(item.plain));
|
|
3354
|
+
const modelText = orderedPresenceText(state.text);
|
|
3355
|
+
const renderedText = orderedPresenceText(state.renderedText || state.text);
|
|
3356
|
+
if (!hasOrderedTextFragments(modelText, expectedFragments) && !hasOrderedTextFragments(renderedText, expectedFragments)) {
|
|
3357
|
+
const missing = expectedFragments.find(fragment => {
|
|
3358
|
+
const expected = orderedPresenceText(fragment);
|
|
3359
|
+
return !modelText.includes(expected) && !renderedText.includes(expected);
|
|
3360
|
+
});
|
|
3361
|
+
throw new Error(
|
|
3362
|
+
`好游快爆正文写入回读失败,首个缺失片段“${normalizedStructuredEditorText(missing || content.plainText).slice(0, 80)}”(预期 ${content.plainText.length} 字,编辑器实际回读 ${state.text.length} 字);文章拉取内容未在此步骤判定为失败`
|
|
3363
|
+
);
|
|
1915
3364
|
}
|
|
1916
3365
|
return {
|
|
1917
3366
|
rich: true,
|
|
@@ -2241,7 +3690,7 @@ async function waitForReviewApproval(message, options = {}) {
|
|
|
2241
3690
|
const input = options.streams?.input || process.stdin;
|
|
2242
3691
|
const output = options.streams?.output || process.stdout;
|
|
2243
3692
|
if (!input.isTTY || !output.isTTY) {
|
|
2244
|
-
throw new Error(`${message}
|
|
3693
|
+
throw new Error(`${message}。当前不是交互终端,请在本地 CMD/PowerShell 交互终端重试;该项目包含可选删除内容,不能跳过预览确认`);
|
|
2245
3694
|
}
|
|
2246
3695
|
const rl = readline.createInterface({ input, output });
|
|
2247
3696
|
try {
|
|
@@ -2257,7 +3706,42 @@ async function waitForReviewApproval(message, options = {}) {
|
|
|
2257
3706
|
output.write("无法识别输入,请输入“可以发布”、直接按 Enter 或输入“取消”。\n");
|
|
2258
3707
|
}
|
|
2259
3708
|
} finally {
|
|
2260
|
-
rl.close();
|
|
3709
|
+
rl.close();
|
|
3710
|
+
}
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
/**
|
|
3714
|
+
* Open a configured platform editor without touching its form or submitting anything.
|
|
3715
|
+
* This is intentionally separate from publishWithBrowser so page structure and login
|
|
3716
|
+
* flows can be checked against the real site before running an article publish.
|
|
3717
|
+
*/
|
|
3718
|
+
export async function openPublishPage(entry, options = {}) {
|
|
3719
|
+
const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
|
|
3720
|
+
if (!spec || entry.publisher !== "browser") {
|
|
3721
|
+
throw new Error(`${entry.platform} 缺少可用的真实浏览器发布页面`);
|
|
3722
|
+
}
|
|
3723
|
+
const editorUrl = browserPlatformPageUrl(entry, options.type || "topic");
|
|
3724
|
+
if (!editorUrl) {
|
|
3725
|
+
throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
|
|
3729
|
+
const ownsContext = !options.context;
|
|
3730
|
+
try {
|
|
3731
|
+
const page = context.pages()[0] || (await context.newPage());
|
|
3732
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
3733
|
+
await afterNavigation(page);
|
|
3734
|
+
options.onProgress?.(`已打开真实${entry.platform}发布页面: ${page.url()}`);
|
|
3735
|
+
const prompt = options.waitForUser || (message => waitForUser(message, options.streams));
|
|
3736
|
+
const promptMessage = options.accountSwitch
|
|
3737
|
+
? `${entry.platform}账号切换页面已打开。请在浏览器中完成账号切换,确认当前账号正确后按 Enter 继续后续发布流程`
|
|
3738
|
+
: `${entry.platform}真实发布页面已打开。请完成登录或页面流程测试,结束后按 Enter 关闭浏览器`;
|
|
3739
|
+
await prompt(promptMessage);
|
|
3740
|
+
return { url: page.url(), status: "opened" };
|
|
3741
|
+
} finally {
|
|
3742
|
+
if (ownsContext) {
|
|
3743
|
+
await context.close().catch(() => {});
|
|
3744
|
+
}
|
|
2261
3745
|
}
|
|
2262
3746
|
}
|
|
2263
3747
|
|
|
@@ -2528,17 +4012,66 @@ async function clickTapTapForumAction(page, scope, search, label, expectClose) {
|
|
|
2528
4012
|
return false;
|
|
2529
4013
|
}
|
|
2530
4014
|
|
|
2531
|
-
|
|
4015
|
+
async function removeUnexpectedTapTapForums(page, forum) {
|
|
4016
|
+
const forumNames = page.locator(".create-config__forum-chip-text, .create-config__forum-name");
|
|
4017
|
+
const count = await forumNames.count().catch(() => 0);
|
|
4018
|
+
const handled = new Set();
|
|
4019
|
+
for (let index = 0; index < count; index += 1) {
|
|
4020
|
+
const current = forumNames.nth(index);
|
|
4021
|
+
if (!(await current.isVisible().catch(() => false))) continue;
|
|
4022
|
+
const name = normalizeText(await current.innerText().catch(() => ""));
|
|
4023
|
+
if (!name || name.includes(forum) || handled.has(name)) continue;
|
|
4024
|
+
handled.add(name);
|
|
4025
|
+
|
|
4026
|
+
let chip = current.locator(
|
|
4027
|
+
'xpath=ancestor::*[contains(@class, "create-config__forum-chip") or contains(@class, "create-config__forum-card")][1]'
|
|
4028
|
+
);
|
|
4029
|
+
if ((await chip.count().catch(() => 0)) === 0) {
|
|
4030
|
+
chip = current.locator("xpath=parent::*[1]");
|
|
4031
|
+
}
|
|
4032
|
+
const remove = await firstVisible(chip, [
|
|
4033
|
+
'button[aria-label*="删除"]',
|
|
4034
|
+
'[aria-label*="删除"]',
|
|
4035
|
+
'[data-testid*="remove"]',
|
|
4036
|
+
'[class*="forum"][class*="remove"]',
|
|
4037
|
+
'[class*="forum"][class*="delete"]',
|
|
4038
|
+
'[class*="forum"][class*="close"]'
|
|
4039
|
+
]);
|
|
4040
|
+
if (!remove) {
|
|
4041
|
+
throw new Error(`TapTap 已预选非目标游戏论坛“${name}”,但未找到移除控件;已停止以避免同时发布到错误论坛`);
|
|
4042
|
+
}
|
|
4043
|
+
await remove.click();
|
|
4044
|
+
await page.waitForTimeout(300);
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
export async function applyTapTapForum(page, forum, promptUser, options = {}) {
|
|
2532
4049
|
if (!forum) {
|
|
2533
4050
|
return;
|
|
2534
4051
|
}
|
|
2535
|
-
const
|
|
2536
|
-
|
|
4052
|
+
const existingForumSelectors = [
|
|
4053
|
+
".create-config__forum-chip-text",
|
|
4054
|
+
".create-config__forum-name",
|
|
4055
|
+
".create-config__forum-card"
|
|
4056
|
+
];
|
|
4057
|
+
for (const selector of existingForumSelectors) {
|
|
4058
|
+
const existingForums = page.locator(selector);
|
|
4059
|
+
if (typeof existingForums.filter !== "function") {
|
|
4060
|
+
continue;
|
|
4061
|
+
}
|
|
2537
4062
|
const existingForum = existingForums.filter({ hasText: forum }).first();
|
|
2538
4063
|
if ((await existingForum.count().catch(() => 0)) > 0 && (await existingForum.isVisible().catch(() => false))) {
|
|
2539
4064
|
return;
|
|
2540
4065
|
}
|
|
2541
4066
|
}
|
|
4067
|
+
if (options.project === ARES_PROJECT) {
|
|
4068
|
+
const edit = await firstVisibleText(page, "编辑");
|
|
4069
|
+
if (edit) {
|
|
4070
|
+
await edit.click();
|
|
4071
|
+
await page.waitForTimeout(300);
|
|
4072
|
+
}
|
|
4073
|
+
await removeUnexpectedTapTapForums(page, forum);
|
|
4074
|
+
}
|
|
2542
4075
|
const openButton = await firstVisibleText(page, "添加发布的游戏论坛");
|
|
2543
4076
|
if (!openButton) {
|
|
2544
4077
|
if (typeof promptUser !== "function") {
|
|
@@ -2676,6 +4209,24 @@ export function tapTapMutationResponse(response, action = "publish") {
|
|
|
2676
4209
|
);
|
|
2677
4210
|
}
|
|
2678
4211
|
|
|
4212
|
+
async function tapTapMutationResult(response) {
|
|
4213
|
+
const status = typeof response.status === "function" ? response.status() : 0;
|
|
4214
|
+
if (status < 200 || status >= 300) {
|
|
4215
|
+
return { failure: `HTTP ${status || "未知状态"}`, payload: null };
|
|
4216
|
+
}
|
|
4217
|
+
const payload = await response.json().catch(() => null);
|
|
4218
|
+
if (!payload || typeof payload !== "object") return { failure: "接口未返回有效 JSON", payload };
|
|
4219
|
+
const code = payload.code ?? payload.data?.code;
|
|
4220
|
+
const message = normalizeText(payload.message ?? payload.msg ?? payload.data?.message ?? payload.data?.msg);
|
|
4221
|
+
const failedCode = code !== undefined && ![0, 200, "0", "200"].includes(code);
|
|
4222
|
+
const failedMessage = /失败|错误|fail|error|forbidden|unauthorized|拒绝|禁止/i.test(message);
|
|
4223
|
+
if (!failedCode && !failedMessage && payload.success !== false) return { failure: "", payload };
|
|
4224
|
+
return {
|
|
4225
|
+
failure: [code !== undefined ? `code=${code}` : "", message].filter(Boolean).join(": ") || "接口返回失败",
|
|
4226
|
+
payload
|
|
4227
|
+
};
|
|
4228
|
+
}
|
|
4229
|
+
|
|
2679
4230
|
async function waitForTapTapSubmission(page, initialUrl, action, mutationPromise, options) {
|
|
2680
4231
|
if (options.skipSubmissionVerification) {
|
|
2681
4232
|
return { confirmation: "test" };
|
|
@@ -2704,20 +4255,12 @@ async function waitForTapTapSubmission(page, initialUrl, action, mutationPromise
|
|
|
2704
4255
|
}
|
|
2705
4256
|
outcomes.push(
|
|
2706
4257
|
mutationPromise.then(async response => {
|
|
2707
|
-
if (!response
|
|
4258
|
+
if (!response) {
|
|
2708
4259
|
return null;
|
|
2709
4260
|
}
|
|
2710
|
-
const
|
|
2711
|
-
if (
|
|
2712
|
-
|
|
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
|
-
}
|
|
4261
|
+
const result = await tapTapMutationResult(response);
|
|
4262
|
+
if (result.failure) {
|
|
4263
|
+
return { error: `TapTap 接口返回失败: ${result.failure}` };
|
|
2721
4264
|
}
|
|
2722
4265
|
return { confirmation: "response", responseUrl: response.url() };
|
|
2723
4266
|
})
|
|
@@ -2729,6 +4272,9 @@ async function waitForTapTapSubmission(page, initialUrl, action, mutationPromise
|
|
|
2729
4272
|
const tagged = [...pending].map(promise => promise.then(value => ({ promise, value })));
|
|
2730
4273
|
const { promise, value } = await Promise.race(tagged);
|
|
2731
4274
|
pending.delete(promise);
|
|
4275
|
+
if (value?.error) {
|
|
4276
|
+
throw new Error(value.error);
|
|
4277
|
+
}
|
|
2732
4278
|
if (value) {
|
|
2733
4279
|
return value;
|
|
2734
4280
|
}
|
|
@@ -2829,25 +4375,33 @@ async function uploadTapTapCover(page, source, articleFile) {
|
|
|
2829
4375
|
}
|
|
2830
4376
|
|
|
2831
4377
|
async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
2832
|
-
|
|
4378
|
+
let preparedArticle = prepareTapTapArticleForProject(article, entry);
|
|
4379
|
+
const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
|
|
2833
4380
|
const titleLimit = settings.type === "moment" ? 20 : 30;
|
|
2834
|
-
if (
|
|
2835
|
-
|
|
4381
|
+
if (preparedArticle.title.length > titleLimit) {
|
|
4382
|
+
if (normalizeText(entry.project) === "迷途之光" && settings.type === "moment") {
|
|
4383
|
+
preparedArticle.title = Array.from(preparedArticle.title).slice(0, titleLimit).join("");
|
|
4384
|
+
} else {
|
|
4385
|
+
throw new Error(
|
|
4386
|
+
`TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${preparedArticle.title.length} 个字符`
|
|
4387
|
+
);
|
|
4388
|
+
}
|
|
2836
4389
|
}
|
|
2837
|
-
const richContent = buildTapTapRichContent(
|
|
2838
|
-
const body = richContent.plainText || plainTextForPlatform(
|
|
4390
|
+
const richContent = buildTapTapRichContent(preparedArticle);
|
|
4391
|
+
const body = richContent.plainText || plainTextForPlatform(preparedArticle, "taptap");
|
|
2839
4392
|
if (settings.type === "moment" && body.length > 5000) {
|
|
2840
4393
|
throw new Error(`TapTap 图文正文最多 5000 个字符,当前为 ${body.length} 个字符`);
|
|
2841
4394
|
}
|
|
2842
4395
|
|
|
2843
|
-
|
|
4396
|
+
const editorUrl = browserPlatformPageUrl(entry, settings.type);
|
|
4397
|
+
await page.goto(editorUrl, {
|
|
2844
4398
|
waitUntil: "domcontentloaded",
|
|
2845
4399
|
timeout: 60000
|
|
2846
4400
|
});
|
|
2847
4401
|
await afterNavigation(page);
|
|
2848
4402
|
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec)))) {
|
|
2849
4403
|
await ensureBrowserLogin(page, entry, spec, promptUser);
|
|
2850
|
-
await page.goto(
|
|
4404
|
+
await page.goto(editorUrl, {
|
|
2851
4405
|
waitUntil: "domcontentloaded",
|
|
2852
4406
|
timeout: 60000
|
|
2853
4407
|
});
|
|
@@ -2856,15 +4410,23 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
|
2856
4410
|
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
|
|
2857
4411
|
throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
|
|
2858
4412
|
}
|
|
2859
|
-
await resumeMatchingTapTapDraft(page, spec,
|
|
4413
|
+
await resumeMatchingTapTapDraft(page, spec, preparedArticle.title);
|
|
2860
4414
|
|
|
2861
4415
|
if (settings.type === "moment") {
|
|
2862
4416
|
const imageSources = settings.images.length
|
|
2863
4417
|
? settings.images
|
|
2864
|
-
: [
|
|
2865
|
-
|
|
4418
|
+
: [
|
|
4419
|
+
...new Set(
|
|
4420
|
+
[...imageSourcesFromArticle(preparedArticle), normalizeText(preparedArticle.payload?.thumbnail)].filter(Boolean)
|
|
4421
|
+
)
|
|
4422
|
+
];
|
|
4423
|
+
await uploadTapTapImages(
|
|
4424
|
+
page,
|
|
4425
|
+
settings.images.length ? imageSources : imageSources.slice(0, 18),
|
|
4426
|
+
preparedArticle.articleFile
|
|
4427
|
+
);
|
|
2866
4428
|
} else if (settings.type === "video") {
|
|
2867
|
-
await uploadTapTapVideo(page, settings.video,
|
|
4429
|
+
await uploadTapTapVideo(page, settings.video, preparedArticle.articleFile);
|
|
2868
4430
|
if (!(await waitForTapTapEditor(page, spec, 10 * 60 * 1000))) {
|
|
2869
4431
|
throw new Error("TapTap 视频上传或处理超时,未进入内容编辑步骤");
|
|
2870
4432
|
}
|
|
@@ -2875,14 +4437,14 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
|
2875
4437
|
if (!titleInput || !bodyInput) {
|
|
2876
4438
|
throw new Error(`TapTap ${settings.typeLabel}编辑器未完整加载,未找到标题或正文控件`);
|
|
2877
4439
|
}
|
|
2878
|
-
await fillLocator(titleInput,
|
|
2879
|
-
await verifyFilledValue(titleInput,
|
|
2880
|
-
await insertTapTapRichContent(page, bodyInput,
|
|
4440
|
+
await fillLocator(titleInput, preparedArticle.title);
|
|
4441
|
+
await verifyFilledValue(titleInput, preparedArticle.title, "标题");
|
|
4442
|
+
await insertTapTapRichContent(page, bodyInput, preparedArticle, settings, body);
|
|
2881
4443
|
|
|
2882
4444
|
if (settings.type === "topic" && settings.cover) {
|
|
2883
|
-
await uploadTapTapCover(page, settings.cover,
|
|
4445
|
+
await uploadTapTapCover(page, settings.cover, preparedArticle.articleFile);
|
|
2884
4446
|
}
|
|
2885
|
-
await applyTapTapForum(page, settings.forum, promptUser);
|
|
4447
|
+
await applyTapTapForum(page, settings.forum, promptUser, { project: entry.project });
|
|
2886
4448
|
await applyTapTapSchedule(page, settings.scheduled, promptUser);
|
|
2887
4449
|
if (!settings.draft) {
|
|
2888
4450
|
await reviewPreparedDraft(page, entry, options);
|
|
@@ -3237,8 +4799,8 @@ async function submitBilibili(page, spec, settings, article, metadataApplied, op
|
|
|
3237
4799
|
}
|
|
3238
4800
|
|
|
3239
4801
|
async function publishBilibili(page, entry, article, spec, options, promptUser) {
|
|
3240
|
-
const settings = resolveBilibiliPublishingOptions(article);
|
|
3241
|
-
const editorUrl =
|
|
4802
|
+
const settings = resolveBilibiliPublishingOptions(article, entry);
|
|
4803
|
+
const editorUrl = browserPlatformPageUrl(entry, settings.type);
|
|
3242
4804
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
3243
4805
|
await afterNavigation(page);
|
|
3244
4806
|
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings)))) {
|
|
@@ -3272,7 +4834,9 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
|
|
|
3272
4834
|
await fillLocator(titleInput, article.title);
|
|
3273
4835
|
await verifyFilledValue(titleInput, article.title, "标题", "B站");
|
|
3274
4836
|
if (settings.type === "article") {
|
|
3275
|
-
await insertBilibiliRichContent(page, bodyInput, article, spec, body
|
|
4837
|
+
await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
|
|
4838
|
+
removeNoncompliant: normalizeText(entry.project) === "最强蜗牛"
|
|
4839
|
+
});
|
|
3276
4840
|
} else {
|
|
3277
4841
|
await fillLocator(bodyInput, body);
|
|
3278
4842
|
await verifyFilledValue(bodyInput, body, "简介", "B站");
|
|
@@ -3376,6 +4940,65 @@ async function waitForWeiboArticleInlineImage(bodyInput, previousCount, source)
|
|
|
3376
4940
|
throw new Error(`微博文章正文图片插入超时: ${source}`);
|
|
3377
4941
|
}
|
|
3378
4942
|
|
|
4943
|
+
async function waitForWeiboArticleEditorState(page, bodyInput, content, markerPrefix = "", expectedImages = 0) {
|
|
4944
|
+
const deadline = Date.now() + 15000;
|
|
4945
|
+
let state = { text: "", images: 0, failedImages: 0, links: [] };
|
|
4946
|
+
let missingText = [];
|
|
4947
|
+
let missingLinks = [];
|
|
4948
|
+
let previousSignature = "";
|
|
4949
|
+
let stableChecks = 0;
|
|
4950
|
+
do {
|
|
4951
|
+
state = await bodyInput.evaluate(element => ({
|
|
4952
|
+
text: (() => {
|
|
4953
|
+
const structuredText = [...element.querySelectorAll(".ProseMirror, [data-slate-string=\"true\"]")]
|
|
4954
|
+
.map(node => node.innerText || node.textContent || "")
|
|
4955
|
+
.join("\n");
|
|
4956
|
+
return structuredText || element.innerText || element.textContent || "";
|
|
4957
|
+
})(),
|
|
4958
|
+
images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
|
|
4959
|
+
failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length,
|
|
4960
|
+
links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
|
|
4961
|
+
}));
|
|
4962
|
+
const actualText = normalizedTapTapVerificationText(state.text);
|
|
4963
|
+
const presenceText = normalizedPresenceText(state.text);
|
|
4964
|
+
missingText = content.items.filter(
|
|
4965
|
+
item => item.type === "html" && item.plain && !presenceText.includes(normalizedPresenceText(item.plain))
|
|
4966
|
+
);
|
|
4967
|
+
const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
|
|
4968
|
+
missingLinks = content.links.filter(link => !actualLinks.has(link.href));
|
|
4969
|
+
const ready =
|
|
4970
|
+
state.failedImages === 0 &&
|
|
4971
|
+
state.images >= expectedImages &&
|
|
4972
|
+
missingText.length === 0 &&
|
|
4973
|
+
missingLinks.length === 0 &&
|
|
4974
|
+
(!markerPrefix || !actualText.includes(markerPrefix));
|
|
4975
|
+
const signature = `${actualText.length}:${state.images}:${state.failedImages}:${state.links.length}`;
|
|
4976
|
+
if (ready) {
|
|
4977
|
+
stableChecks = signature === previousSignature ? stableChecks + 1 : 1;
|
|
4978
|
+
if (stableChecks >= 2) return state;
|
|
4979
|
+
} else {
|
|
4980
|
+
stableChecks = 0;
|
|
4981
|
+
}
|
|
4982
|
+
previousSignature = signature;
|
|
4983
|
+
await page.waitForTimeout(250);
|
|
4984
|
+
} while (Date.now() < deadline);
|
|
4985
|
+
if (state.failedImages > 0) throw new Error("微博文章正文图片上传失败,请检查编辑器中的重试提示");
|
|
4986
|
+
if (state.images < expectedImages) {
|
|
4987
|
+
throw new Error(`微博文章正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
|
|
4988
|
+
}
|
|
4989
|
+
if (markerPrefix && normalizedTapTapVerificationText(state.text).includes(markerPrefix)) {
|
|
4990
|
+
throw new Error("微博文章正文图片占位符未正确替换");
|
|
4991
|
+
}
|
|
4992
|
+
if (missingText.length > 0) {
|
|
4993
|
+
const missing = normalizedTapTapText(missingText[0].plain).slice(0, 80);
|
|
4994
|
+
throw new Error(
|
|
4995
|
+
`微博文章正文文字未完整写入编辑器,首个缺失片段“${missing}”(预期 ${content.plainText.length} 字,实际 ${state.text.length} 字)`
|
|
4996
|
+
);
|
|
4997
|
+
}
|
|
4998
|
+
if (missingLinks.length > 0) throw new Error(`微博文章正文超链接未正确写入: ${missingLinks[0].href}`);
|
|
4999
|
+
throw new Error("微博文章正文写入后未能稳定完成回读校验");
|
|
5000
|
+
}
|
|
5001
|
+
|
|
3379
5002
|
async function weiboControlDisabled(control) {
|
|
3380
5003
|
if (typeof control.isDisabled === "function" && (await control.isDisabled().catch(() => false))) {
|
|
3381
5004
|
return true;
|
|
@@ -3645,6 +5268,39 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
3645
5268
|
options.onProgress?.("正文图片已插入");
|
|
3646
5269
|
}
|
|
3647
5270
|
|
|
5271
|
+
async function clearWeiboArticleBody(page, bodyInput) {
|
|
5272
|
+
await bodyInput.fill("");
|
|
5273
|
+
await bodyInput.focus?.().catch(() => {});
|
|
5274
|
+
if (page.keyboard?.press) {
|
|
5275
|
+
await page.keyboard.press("Control+A");
|
|
5276
|
+
await page.keyboard.press("Backspace");
|
|
5277
|
+
await page.waitForTimeout(100);
|
|
5278
|
+
}
|
|
5279
|
+
|
|
5280
|
+
let remaining = await bodyInput
|
|
5281
|
+
.evaluate(element => String(element.innerText || element.textContent || "").trim())
|
|
5282
|
+
.catch(() => "");
|
|
5283
|
+
if (typeof remaining === "string" && remaining) {
|
|
5284
|
+
await bodyInput.evaluate(element => {
|
|
5285
|
+
element.replaceChildren(document.createElement("p"));
|
|
5286
|
+
element.dispatchEvent(
|
|
5287
|
+
new InputEvent("input", {
|
|
5288
|
+
bubbles: true,
|
|
5289
|
+
inputType: "deleteContentBackward",
|
|
5290
|
+
data: null
|
|
5291
|
+
})
|
|
5292
|
+
);
|
|
5293
|
+
});
|
|
5294
|
+
await page.waitForTimeout(100);
|
|
5295
|
+
remaining = await bodyInput
|
|
5296
|
+
.evaluate(element => String(element.innerText || element.textContent || "").trim())
|
|
5297
|
+
.catch(() => "");
|
|
5298
|
+
}
|
|
5299
|
+
if (typeof remaining === "string" && remaining) {
|
|
5300
|
+
throw new Error("微博文章正文未能清空,已停止发布以避免复用旧草稿内容");
|
|
5301
|
+
}
|
|
5302
|
+
}
|
|
5303
|
+
|
|
3648
5304
|
export async function insertWeiboArticleRichContent(page, bodyInput, article, spec, fallbackText, options = {}) {
|
|
3649
5305
|
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
3650
5306
|
await fillLocator(bodyInput, fallbackText);
|
|
@@ -3653,7 +5309,7 @@ export async function insertWeiboArticleRichContent(page, bodyInput, article, sp
|
|
|
3653
5309
|
}
|
|
3654
5310
|
|
|
3655
5311
|
const content = buildWeiboRichContent(article);
|
|
3656
|
-
await bodyInput
|
|
5312
|
+
await clearWeiboArticleBody(page, bodyInput);
|
|
3657
5313
|
const markerPrefix = `QCPLAY_WEIBO_IMAGE_${Date.now()}_`;
|
|
3658
5314
|
const imageMarkers = [];
|
|
3659
5315
|
const htmlParts = [];
|
|
@@ -3672,71 +5328,198 @@ export async function insertWeiboArticleRichContent(page, bodyInput, article, sp
|
|
|
3672
5328
|
}
|
|
3673
5329
|
if (htmlParts.length > 0) {
|
|
3674
5330
|
await pasteRichHtml(bodyInput, { html: htmlParts.join(""), plain: plainParts.join("\n") });
|
|
3675
|
-
await page
|
|
5331
|
+
await waitForWeiboArticleEditorState(page, bodyInput, content);
|
|
3676
5332
|
}
|
|
3677
5333
|
|
|
3678
5334
|
const payloadCache = new Map();
|
|
5335
|
+
let activeBodyInput = bodyInput;
|
|
3679
5336
|
for (let imageIndex = 0; imageIndex < imageMarkers.length; imageIndex += 1) {
|
|
3680
5337
|
const { marker, item, index } = imageMarkers[imageIndex];
|
|
3681
|
-
|
|
5338
|
+
// Closing Weibo's image library can replace the visible Tiptap node.
|
|
5339
|
+
// Reacquire it before selecting the next marker instead of retaining a
|
|
5340
|
+
// locator that now targets the previous editor instance.
|
|
5341
|
+
activeBodyInput = (await waitForVisible(page, spec.body, 3000)) || activeBodyInput;
|
|
5342
|
+
let removed = false;
|
|
5343
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
5344
|
+
// Weibo normalizes the selection when the editor is not focused, causing
|
|
5345
|
+
// Backspace to be ignored while the marker remains in the draft.
|
|
5346
|
+
await activeBodyInput.focus?.().catch(() => {});
|
|
5347
|
+
const markerNode =
|
|
5348
|
+
typeof page.getByText === "function" ? page.getByText(marker, { exact: true }).last() : null;
|
|
5349
|
+
let selected = false;
|
|
5350
|
+
if (markerNode && (await markerNode.count().catch(() => 0)) > 0 && (await markerNode.isVisible().catch(() => false))) {
|
|
5351
|
+
selected = await markerNode.evaluate(node => {
|
|
5352
|
+
const range = document.createRange();
|
|
5353
|
+
range.selectNodeContents(node);
|
|
5354
|
+
const selection = window.getSelection();
|
|
5355
|
+
selection.removeAllRanges();
|
|
5356
|
+
selection.addRange(range);
|
|
5357
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
5358
|
+
return true;
|
|
5359
|
+
});
|
|
5360
|
+
} else {
|
|
5361
|
+
selected = await activeBodyInput.evaluate((element, markerValue) => {
|
|
5362
|
+
const markerBlocks = [...element.querySelectorAll("p, div, li")].filter(node => {
|
|
5363
|
+
if (String(node.textContent || "").trim() !== markerValue) return false;
|
|
5364
|
+
return ![...node.children].some(child => String(child.textContent || "").trim() === markerValue);
|
|
5365
|
+
});
|
|
5366
|
+
if (markerBlocks.length > 0) {
|
|
5367
|
+
const range = document.createRange();
|
|
5368
|
+
range.selectNodeContents(markerBlocks[markerBlocks.length - 1]);
|
|
5369
|
+
const selection = window.getSelection();
|
|
5370
|
+
selection.removeAllRanges();
|
|
5371
|
+
selection.addRange(range);
|
|
5372
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
5373
|
+
return true;
|
|
5374
|
+
}
|
|
3682
5375
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
5376
|
+
const nodes = [];
|
|
3683
5377
|
let textNode = walker.nextNode();
|
|
3684
5378
|
while (textNode) {
|
|
3685
|
-
|
|
3686
|
-
|
|
5379
|
+
nodes.push(textNode);
|
|
5380
|
+
textNode = walker.nextNode();
|
|
5381
|
+
}
|
|
5382
|
+
let combined = "";
|
|
5383
|
+
const offsets = nodes.map(node => {
|
|
5384
|
+
const start = combined.length;
|
|
5385
|
+
combined += node.data;
|
|
5386
|
+
return { node, start };
|
|
5387
|
+
});
|
|
5388
|
+
const markerStart = combined.indexOf(markerValue);
|
|
5389
|
+
if (markerStart < 0) return false;
|
|
5390
|
+
const markerEnd = markerStart + markerValue.length;
|
|
5391
|
+
const start = [...offsets].reverse().find(entry => entry.start <= markerStart);
|
|
5392
|
+
const end = offsets.find(entry => entry.start + entry.node.data.length >= markerEnd);
|
|
5393
|
+
if (!start || !end) return false;
|
|
5394
|
+
const range = document.createRange();
|
|
5395
|
+
range.setStart(start.node, markerStart - start.start);
|
|
5396
|
+
range.setEnd(end.node, markerEnd - end.start);
|
|
5397
|
+
const selection = window.getSelection();
|
|
5398
|
+
selection.removeAllRanges();
|
|
5399
|
+
selection.addRange(range);
|
|
5400
|
+
document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
|
|
5401
|
+
return true;
|
|
5402
|
+
}, marker);
|
|
5403
|
+
}
|
|
5404
|
+
if (selected) {
|
|
5405
|
+
await page.waitForTimeout(80);
|
|
5406
|
+
await page.keyboard.press("Backspace");
|
|
5407
|
+
await page.waitForTimeout(100);
|
|
5408
|
+
removed = markerNode
|
|
5409
|
+
? !(await markerNode.isVisible().catch(() => false))
|
|
5410
|
+
: !(await activeBodyInput.evaluate((element, markerValue) => {
|
|
5411
|
+
return String(element.textContent || "").includes(markerValue);
|
|
5412
|
+
}, marker));
|
|
5413
|
+
}
|
|
5414
|
+
if (removed) break;
|
|
5415
|
+
|
|
5416
|
+
// Some ProseMirror builds split pasted marker text across text nodes and
|
|
5417
|
+
// ignore a keyboard deletion after the selection has been normalized.
|
|
5418
|
+
// A reused draft can also contain the same marker more than once, so the
|
|
5419
|
+
// fallback removes every matching marker before the global verification.
|
|
5420
|
+
if (markerNode && (await markerNode.isVisible().catch(() => false))) {
|
|
5421
|
+
removed = await markerNode.evaluate((node, markerValue) => {
|
|
5422
|
+
const editor = node.closest('[contenteditable="true"]');
|
|
5423
|
+
const parent = node.parentNode;
|
|
5424
|
+
const childIndex = parent ? [...parent.childNodes].indexOf(node) : -1;
|
|
5425
|
+
if (!editor || !parent || childIndex < 0 || String(node.textContent || "").trim() !== markerValue) return false;
|
|
3687
5426
|
const range = document.createRange();
|
|
3688
|
-
range.setStart(
|
|
3689
|
-
range.
|
|
5427
|
+
range.setStart(parent, childIndex);
|
|
5428
|
+
range.collapse(true);
|
|
5429
|
+
node.remove();
|
|
3690
5430
|
const selection = window.getSelection();
|
|
3691
5431
|
selection.removeAllRanges();
|
|
3692
5432
|
selection.addRange(range);
|
|
3693
|
-
|
|
5433
|
+
editor.dispatchEvent(new InputEvent("input", {
|
|
5434
|
+
bubbles: true,
|
|
5435
|
+
inputType: "deleteContentBackward",
|
|
5436
|
+
data: null
|
|
5437
|
+
}));
|
|
3694
5438
|
return true;
|
|
3695
|
-
}
|
|
3696
|
-
textNode = walker.nextNode();
|
|
5439
|
+
}, marker).catch(() => false);
|
|
3697
5440
|
}
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
5441
|
+
if (removed) break;
|
|
5442
|
+
removed = await activeBodyInput.evaluate((element, markerValue) => {
|
|
5443
|
+
const markerBlocks = [...element.querySelectorAll("p, div, li")].filter(
|
|
5444
|
+
node =>
|
|
5445
|
+
String(node.textContent || "").trim() === markerValue &&
|
|
5446
|
+
![...node.children].some(child => String(child.textContent || "").trim() === markerValue)
|
|
5447
|
+
);
|
|
5448
|
+
const insertionBlock = markerBlocks[markerBlocks.length - 1];
|
|
5449
|
+
const parent = insertionBlock?.parentNode;
|
|
5450
|
+
const childIndex = parent ? [...parent.childNodes].indexOf(insertionBlock) : -1;
|
|
5451
|
+
for (const block of markerBlocks) {
|
|
5452
|
+
block.remove();
|
|
5453
|
+
}
|
|
5454
|
+
if (markerBlocks.length > 0) {
|
|
5455
|
+
if (parent && childIndex >= 0) {
|
|
5456
|
+
const range = document.createRange();
|
|
5457
|
+
range.setStart(parent, Math.min(childIndex, parent.childNodes.length));
|
|
5458
|
+
range.collapse(true);
|
|
5459
|
+
const selection = window.getSelection();
|
|
5460
|
+
selection.removeAllRanges();
|
|
5461
|
+
selection.addRange(range);
|
|
5462
|
+
}
|
|
5463
|
+
element.dispatchEvent(new InputEvent("input", {
|
|
5464
|
+
bubbles: true,
|
|
5465
|
+
inputType: "deleteContentBackward",
|
|
5466
|
+
data: null
|
|
5467
|
+
}));
|
|
5468
|
+
return !String(element.textContent || "").includes(markerValue);
|
|
5469
|
+
}
|
|
5470
|
+
|
|
5471
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
5472
|
+
for (;;) {
|
|
5473
|
+
const nodes = [];
|
|
5474
|
+
let node = walker.nextNode();
|
|
5475
|
+
while (node) {
|
|
5476
|
+
nodes.push(node);
|
|
5477
|
+
node = walker.nextNode();
|
|
5478
|
+
}
|
|
5479
|
+
let combined = "";
|
|
5480
|
+
const offsets = nodes.map(textNode => {
|
|
5481
|
+
const start = combined.length;
|
|
5482
|
+
combined += textNode.data;
|
|
5483
|
+
return { node: textNode, start };
|
|
5484
|
+
});
|
|
5485
|
+
const markerStart = combined.indexOf(markerValue);
|
|
5486
|
+
if (markerStart < 0) break;
|
|
5487
|
+
const markerEnd = markerStart + markerValue.length;
|
|
5488
|
+
const start = [...offsets].reverse().find(entry => entry.start <= markerStart);
|
|
5489
|
+
const end = offsets.find(entry => entry.start + entry.node.data.length >= markerEnd);
|
|
5490
|
+
if (!start || !end) break;
|
|
5491
|
+
const range = document.createRange();
|
|
5492
|
+
range.setStart(start.node, markerStart - start.start);
|
|
5493
|
+
range.setEnd(end.node, markerEnd - end.start);
|
|
5494
|
+
range.deleteContents();
|
|
5495
|
+
}
|
|
5496
|
+
const selection = window.getSelection();
|
|
5497
|
+
selection.removeAllRanges();
|
|
5498
|
+
element.dispatchEvent(new InputEvent("input", {
|
|
5499
|
+
bubbles: true,
|
|
5500
|
+
inputType: "deleteContentBackward",
|
|
5501
|
+
data: null
|
|
5502
|
+
}));
|
|
5503
|
+
return !String(element.textContent || "").includes(markerValue);
|
|
5504
|
+
}, marker).catch(() => false);
|
|
5505
|
+
if (removed) break;
|
|
5506
|
+
}
|
|
5507
|
+
if (!removed) {
|
|
5508
|
+
throw new Error(`微博文章正文图片占位符未能完整清理: ${item.source}`);
|
|
3702
5509
|
}
|
|
3703
|
-
await page.keyboard.press("Backspace");
|
|
3704
5510
|
let payload = payloadCache.get(item.source);
|
|
3705
5511
|
if (!payload) {
|
|
3706
5512
|
options.onProgress?.(`正在读取第 ${imageIndex + 1} 张正文图片`);
|
|
3707
5513
|
payload = await imageUploadPayload(item.source, article.articleFile, index, "微博文章");
|
|
3708
5514
|
payloadCache.set(item.source, payload);
|
|
3709
5515
|
}
|
|
3710
|
-
await uploadWeiboArticleInlineImage(page,
|
|
5516
|
+
await uploadWeiboArticleInlineImage(page, activeBodyInput, spec, payload, item.source, {
|
|
3711
5517
|
clearExistingLibrary: imageIndex === 0,
|
|
3712
5518
|
onProgress: options.onProgress
|
|
3713
5519
|
});
|
|
3714
5520
|
}
|
|
3715
5521
|
|
|
3716
|
-
|
|
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
|
-
}
|
|
5522
|
+
await waitForWeiboArticleEditorState(page, activeBodyInput, content, markerPrefix, content.images.length);
|
|
3740
5523
|
return { rich: true, images: content.images.length, links: content.links.length, plainText: content.plainText };
|
|
3741
5524
|
}
|
|
3742
5525
|
|
|
@@ -3975,6 +5758,76 @@ export async function applyWeiboArticleCover(
|
|
|
3975
5758
|
throw new Error("微博文章封面图片处理失败");
|
|
3976
5759
|
}
|
|
3977
5760
|
|
|
5761
|
+
async function applyWeiboArticleColumn(page, column, options = {}) {
|
|
5762
|
+
const { requested, index } = resolveWeiboColumnSelection(column);
|
|
5763
|
+
if (!requested) return false;
|
|
5764
|
+
|
|
5765
|
+
options.onProgress?.("正在设置微博文章专栏");
|
|
5766
|
+
let trigger = await firstVisible(page, [
|
|
5767
|
+
'[class*="column"] [role="button"]',
|
|
5768
|
+
'[class*="column"] button',
|
|
5769
|
+
'[class*="column"]'
|
|
5770
|
+
]);
|
|
5771
|
+
if (!trigger && typeof page.getByText === "function") {
|
|
5772
|
+
const label = page.getByText(/专栏|添加至专栏|选择专栏/).last();
|
|
5773
|
+
if ((await label.count().catch(() => 0)) > 0 && (await label.isVisible().catch(() => false))) {
|
|
5774
|
+
trigger = label;
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
if (!trigger) {
|
|
5778
|
+
throw new Error("微博文章发布设置中未找到专栏入口");
|
|
5779
|
+
}
|
|
5780
|
+
await trigger.click();
|
|
5781
|
+
await page.waitForTimeout?.(300);
|
|
5782
|
+
|
|
5783
|
+
const namedOption = typeof page.getByText === "function" ? page.getByText(requested, { exact: true }).last() : null;
|
|
5784
|
+
if (namedOption && (await namedOption.count().catch(() => 0)) > 0 && (await namedOption.isVisible().catch(() => false))) {
|
|
5785
|
+
await namedOption.click();
|
|
5786
|
+
const confirm = await waitForVisibleButton(page, [/^确认$/], 2000);
|
|
5787
|
+
if (confirm) await confirm.click();
|
|
5788
|
+
options.onProgress?.(`微博文章专栏已设置为 ${requested}`);
|
|
5789
|
+
return true;
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
if (index !== null) {
|
|
5793
|
+
const optionSelector = [
|
|
5794
|
+
'[role="dialog"] [role="option"]',
|
|
5795
|
+
'[role="dialog"] [role="menuitem"]',
|
|
5796
|
+
'[role="dialog"] li',
|
|
5797
|
+
'[role="listbox"] [role="option"]',
|
|
5798
|
+
'[role="listbox"] li',
|
|
5799
|
+
'[class*="modal"] [class*="column"] li',
|
|
5800
|
+
'[class*="modal"] [class*="Column"] li'
|
|
5801
|
+
].join(", ");
|
|
5802
|
+
const optionList = page.locator?.(optionSelector);
|
|
5803
|
+
let visibleIndex = 0;
|
|
5804
|
+
const count = await optionList?.count?.().catch(() => 0);
|
|
5805
|
+
for (let optionIndex = 0; optionIndex < count; optionIndex += 1) {
|
|
5806
|
+
const candidate = optionList.nth(optionIndex);
|
|
5807
|
+
if (!(await candidate.isVisible().catch(() => false))) continue;
|
|
5808
|
+
if (visibleIndex === index) {
|
|
5809
|
+
await candidate.click();
|
|
5810
|
+
const confirm = await waitForVisibleButton(page, [/^确认$/], 2000);
|
|
5811
|
+
if (confirm) await confirm.click();
|
|
5812
|
+
options.onProgress?.(`微博文章专栏已设置为第 ${index + 1} 个`);
|
|
5813
|
+
return true;
|
|
5814
|
+
}
|
|
5815
|
+
visibleIndex += 1;
|
|
5816
|
+
}
|
|
5817
|
+
throw new Error(`微博文章中未找到第 ${index + 1} 个专栏`);
|
|
5818
|
+
}
|
|
5819
|
+
|
|
5820
|
+
const option = typeof page.getByText === "function" ? page.getByText(requested, { exact: true }).last() : null;
|
|
5821
|
+
if (!option || (await option.count().catch(() => 0)) === 0) {
|
|
5822
|
+
throw new Error(`微博文章中未找到专栏: ${requested}`);
|
|
5823
|
+
}
|
|
5824
|
+
await option.click();
|
|
5825
|
+
const confirm = await waitForVisibleButton(page, [/^确认$/], 2000);
|
|
5826
|
+
if (confirm) await confirm.click();
|
|
5827
|
+
options.onProgress?.("微博文章专栏已设置");
|
|
5828
|
+
return true;
|
|
5829
|
+
}
|
|
5830
|
+
|
|
3978
5831
|
function weiboMutationResponse(response) {
|
|
3979
5832
|
const request = response.request();
|
|
3980
5833
|
if (!/^POST$/i.test(request.method())) {
|
|
@@ -4114,8 +5967,9 @@ async function waitForWeiboSecurityVerification(page, progress) {
|
|
|
4114
5967
|
|
|
4115
5968
|
async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
4116
5969
|
const progress = message => options.onProgress?.(message);
|
|
4117
|
-
const
|
|
4118
|
-
const
|
|
5970
|
+
const preparedArticle = prepareWeiboArticleForProject(article, entry);
|
|
5971
|
+
const settings = resolveWeiboPublishingOptions(preparedArticle);
|
|
5972
|
+
const body = buildWeiboText(preparedArticle);
|
|
4119
5973
|
if (!body) {
|
|
4120
5974
|
throw new Error("微博文章正文不能为空");
|
|
4121
5975
|
}
|
|
@@ -4134,7 +5988,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
4134
5988
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4135
5989
|
await afterNavigation(page);
|
|
4136
5990
|
}
|
|
4137
|
-
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec),
|
|
5991
|
+
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
|
|
4138
5992
|
throw new Error("微博登录完成后仍未进入头条文章编辑器,请确认账号拥有文章发布权限");
|
|
4139
5993
|
}
|
|
4140
5994
|
|
|
@@ -4159,11 +6013,12 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
4159
6013
|
throw new Error("微博头条文章编辑器中未找到导语输入框");
|
|
4160
6014
|
}
|
|
4161
6015
|
const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
|
|
4162
|
-
const richContent = await insertWeiboArticleRichContent(page, stableBodyInput,
|
|
6016
|
+
const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
|
|
4163
6017
|
onProgress: progress
|
|
4164
6018
|
});
|
|
4165
6019
|
progress("正文已写入,正在设置封面");
|
|
4166
|
-
const cover = await applyWeiboArticleCover(page,
|
|
6020
|
+
const cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
|
|
6021
|
+
const column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
|
|
4167
6022
|
|
|
4168
6023
|
const nextButton = await waitForVisibleButton(page, spec.next, 5000);
|
|
4169
6024
|
if (!nextButton) {
|
|
@@ -4176,19 +6031,36 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
4176
6031
|
await reviewPreparedDraft(page, entry, options);
|
|
4177
6032
|
const initialUrl = page.url();
|
|
4178
6033
|
progress("正在进入发布确认页");
|
|
4179
|
-
await nextButton.click();
|
|
4180
6034
|
let submitButton = null;
|
|
4181
6035
|
let securityDetected = false;
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
6036
|
+
let nextAttempt = 0;
|
|
6037
|
+
while (!submitButton && !securityDetected && nextAttempt < 4) {
|
|
6038
|
+
const currentNext = nextAttempt === 0 ? nextButton : await waitForVisibleButton(page, spec.next, 3000);
|
|
6039
|
+
if (!currentNext) break;
|
|
6040
|
+
await currentNext.click();
|
|
6041
|
+
const confirmationDeadline = Date.now() + 10000;
|
|
6042
|
+
let adjustedFontSize = false;
|
|
6043
|
+
do {
|
|
6044
|
+
if (await weiboSecurityChallengeVisible(page)) {
|
|
6045
|
+
securityDetected = true;
|
|
6046
|
+
break;
|
|
6047
|
+
}
|
|
6048
|
+
submitButton = await firstVisibleButton(page, spec.submit);
|
|
6049
|
+
if (submitButton) break;
|
|
6050
|
+
const adjustButton = await waitForVisibleButton(page, [/^点击调整$/], 250);
|
|
6051
|
+
if (adjustButton) {
|
|
6052
|
+
progress("微博检测到正文小字号,正在自动调整后重试发布");
|
|
6053
|
+
await adjustButton.click();
|
|
6054
|
+
await page.waitForTimeout(800);
|
|
6055
|
+
adjustedFontSize = true;
|
|
6056
|
+
break;
|
|
6057
|
+
}
|
|
6058
|
+
await page.waitForTimeout(250);
|
|
6059
|
+
} while (Date.now() < confirmationDeadline);
|
|
6060
|
+
if (submitButton || securityDetected) break;
|
|
6061
|
+
nextAttempt += 1;
|
|
6062
|
+
if (adjustedFontSize) await page.waitForTimeout(500);
|
|
6063
|
+
}
|
|
4192
6064
|
if (securityDetected) {
|
|
4193
6065
|
await waitForWeiboSecurityVerification(page, progress);
|
|
4194
6066
|
submitButton = await waitForVisibleButton(page, spec.submit, 3000);
|
|
@@ -4220,11 +6092,194 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
4220
6092
|
if (options.keepOpen) {
|
|
4221
6093
|
await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
|
|
4222
6094
|
}
|
|
4223
|
-
return { url: page.url(), status: "published", type: "article", ...richContent, ...cover, ...confirmation };
|
|
6095
|
+
return { url: page.url(), status: "published", type: "article", ...richContent, ...cover, column, ...confirmation };
|
|
4224
6096
|
}
|
|
4225
6097
|
throw new Error("微博发布确认页中未找到最终发布按钮");
|
|
4226
6098
|
}
|
|
4227
6099
|
|
|
6100
|
+
async function publishWeiboQuick(page, entry, article, options, promptUser) {
|
|
6101
|
+
const editorUrl = weiboQuickPublishUrl(entry);
|
|
6102
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6103
|
+
await afterNavigation(page);
|
|
6104
|
+
const body = buildWeiboText(article);
|
|
6105
|
+
let bodyInput = await waitForVisible(page, [
|
|
6106
|
+
'textarea[placeholder*="新鲜事"]',
|
|
6107
|
+
'textarea[placeholder*="分享"]',
|
|
6108
|
+
'[contenteditable="true"][role="textbox"]',
|
|
6109
|
+
'[contenteditable="true"]'
|
|
6110
|
+
], 8000);
|
|
6111
|
+
if (!bodyInput) {
|
|
6112
|
+
await ensureBrowserLogin(page, entry, BROWSER_PLATFORM_SPECS.weibo, promptUser);
|
|
6113
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6114
|
+
await afterNavigation(page);
|
|
6115
|
+
const quickTrigger = await firstVisible(page, [
|
|
6116
|
+
'button[aria-label*="发布"]',
|
|
6117
|
+
'button[title*="发布"]',
|
|
6118
|
+
'a[href*="compose"]',
|
|
6119
|
+
'[data-testid*="compose"]'
|
|
6120
|
+
]);
|
|
6121
|
+
if (quickTrigger) {
|
|
6122
|
+
await quickTrigger.click();
|
|
6123
|
+
await page.waitForTimeout(500);
|
|
6124
|
+
}
|
|
6125
|
+
bodyInput = await waitForVisible(page, [
|
|
6126
|
+
'textarea[placeholder*="新鲜事"]',
|
|
6127
|
+
'textarea[placeholder*="分享"]',
|
|
6128
|
+
'[contenteditable="true"][role="textbox"]',
|
|
6129
|
+
'[contenteditable="true"]'
|
|
6130
|
+
], 15000);
|
|
6131
|
+
}
|
|
6132
|
+
if (!bodyInput) throw new Error("微博快捷发布页面中未找到内容输入框");
|
|
6133
|
+
await fillLocator(bodyInput, body);
|
|
6134
|
+
const submit = await waitForVisibleButton(page, [/^发送$/, /^发布$/], 5000);
|
|
6135
|
+
if (!submit) throw new Error("微博快捷发布页面中未找到发送按钮");
|
|
6136
|
+
await reviewPreparedDraft(page, entry, options);
|
|
6137
|
+
await submit.click();
|
|
6138
|
+
await page.waitForTimeout(1500);
|
|
6139
|
+
return { url: page.url(), status: "published", type: "quick" };
|
|
6140
|
+
}
|
|
6141
|
+
|
|
6142
|
+
async function publishXiaohongshu(page, entry, article, spec, options, promptUser) {
|
|
6143
|
+
const editorUrl = browserPlatformPageUrl(entry);
|
|
6144
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6145
|
+
await afterNavigation(page);
|
|
6146
|
+
const publishing = resolveXiaohongshuPublishingOptions(article);
|
|
6147
|
+
const body = buildTapTapRichContent(article).plainText || plainTextForPlatform(article, "xiaohongshu");
|
|
6148
|
+
if (Array.from(body).length > 1000) {
|
|
6149
|
+
throw new Error(`小红书正文最多 1000 个字符,当前为 ${Array.from(body).length} 个字符;请先精简内容后再发布`);
|
|
6150
|
+
}
|
|
6151
|
+
let imageInput = await firstExisting(page, spec.imageInputs);
|
|
6152
|
+
if (!imageInput) {
|
|
6153
|
+
if (options.keepOpen && !isInteractiveTerminal(options.streams)) {
|
|
6154
|
+
await openPlatformLogin(page, entry, spec);
|
|
6155
|
+
options.onProgress?.("小红书尚未登录,请在已打开的浏览器中扫码;登录完成后将自动继续上传图片");
|
|
6156
|
+
imageInput = await waitForXiaohongshuUploadInput(page, spec, 600000);
|
|
6157
|
+
} else {
|
|
6158
|
+
await ensureBrowserLogin(page, entry, spec, promptUser);
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
6161
|
+
if (!imageInput) {
|
|
6162
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6163
|
+
await afterNavigation(page);
|
|
6164
|
+
imageInput = await firstExisting(page, spec.imageInputs);
|
|
6165
|
+
}
|
|
6166
|
+
if (!imageInput || typeof imageInput.setInputFiles !== "function") {
|
|
6167
|
+
throw new Error("小红书图文发布页未找到图片上传控件,请确认账号拥有发布权限");
|
|
6168
|
+
}
|
|
6169
|
+
const payloads = [];
|
|
6170
|
+
for (let index = 0; index < publishing.images.length; index += 1) {
|
|
6171
|
+
payloads.push(await imageUploadPayload(publishing.images[index], article.articleFile, index, "小红书"));
|
|
6172
|
+
}
|
|
6173
|
+
options.onProgress?.(`正在上传小红书图片 ${payloads.length} 张`);
|
|
6174
|
+
await imageInput.setInputFiles(payloads);
|
|
6175
|
+
await waitForUploadsSettled(page, "小红书图片", 120000);
|
|
6176
|
+
options.onProgress?.("图片上传完成,正在填写笔记内容");
|
|
6177
|
+
|
|
6178
|
+
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
|
|
6179
|
+
throw new Error("小红书图片上传完成后未进入笔记编辑器,请检查图片格式或账号发布权限");
|
|
6180
|
+
}
|
|
6181
|
+
const titleInput = await waitForVisible(page, spec.title, 8000);
|
|
6182
|
+
const bodyInput = await waitForVisible(page, spec.body, 8000);
|
|
6183
|
+
if (!titleInput || !bodyInput) throw new Error("小红书发布编辑器未找到标题或正文控件");
|
|
6184
|
+
if (Array.from(article.title).length > 20) {
|
|
6185
|
+
throw new Error(`小红书标题最多 20 个字符,当前为 ${Array.from(article.title).length} 个字符`);
|
|
6186
|
+
}
|
|
6187
|
+
await fillLocator(titleInput, article.title);
|
|
6188
|
+
await fillLocator(bodyInput, body);
|
|
6189
|
+
if (publishing.collection) {
|
|
6190
|
+
const collectionTrigger = await waitForVisibleButton(page, [/添加合集/, /^合集$/], 3000);
|
|
6191
|
+
if (!collectionTrigger) throw new Error("小红书页面未找到“添加合集”控件");
|
|
6192
|
+
await collectionTrigger.click();
|
|
6193
|
+
const collectionInput = await waitForVisible(page, spec.collection, 5000);
|
|
6194
|
+
if (!collectionInput) throw new Error("小红书合集选择面板未找到搜索控件");
|
|
6195
|
+
await fillLocator(collectionInput, publishing.collection);
|
|
6196
|
+
const collectionOption = await waitForVisibleButton(page, [new RegExp(`^${escapeRegExp(publishing.collection)}$`)], 5000);
|
|
6197
|
+
if (!collectionOption) throw new Error(`小红书未找到精确合集: ${publishing.collection}`);
|
|
6198
|
+
await collectionOption.click();
|
|
6199
|
+
}
|
|
6200
|
+
const submit = await waitForVisibleButton(page, spec.submit, 8000) || await waitForVisible(page, spec.submitSelectors, 3000);
|
|
6201
|
+
if (!submit) throw new Error("小红书发布编辑器中未找到发布按钮");
|
|
6202
|
+
if (typeof submit.isDisabled === "function" && await submit.isDisabled().catch(() => false)) {
|
|
6203
|
+
throw new Error("小红书发布按钮不可用,请检查正文和图片上传状态");
|
|
6204
|
+
}
|
|
6205
|
+
if (options.keepOpen && !isInteractiveTerminal(options.streams)) {
|
|
6206
|
+
options.onProgress?.("小红书待发布内容已准备完成,请在浏览器中核对后手动点击“发布”;程序将等待并验证发布结果");
|
|
6207
|
+
return waitForManualXiaohongshuSubmission(page, 600000);
|
|
6208
|
+
}
|
|
6209
|
+
await reviewPreparedDraft(page, entry, { ...options, reviewDraft: true });
|
|
6210
|
+
const initialUrl = page.url();
|
|
6211
|
+
const mutationPromise =
|
|
6212
|
+
typeof page.waitForResponse === "function"
|
|
6213
|
+
? page.waitForResponse(xiaohongshuMutationResponse, { timeout: 30000 }).catch(() => null)
|
|
6214
|
+
: Promise.resolve(null);
|
|
6215
|
+
await submit.click();
|
|
6216
|
+
const confirmation = await waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, options);
|
|
6217
|
+
return { url: page.url(), status: "published", type: "note", ...publishing, ...confirmation };
|
|
6218
|
+
}
|
|
6219
|
+
|
|
6220
|
+
function isInteractiveTerminal(streams = {}) {
|
|
6221
|
+
const input = streams.input || process.stdin;
|
|
6222
|
+
const output = streams.output || process.stdout;
|
|
6223
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
6224
|
+
}
|
|
6225
|
+
|
|
6226
|
+
async function waitForXiaohongshuUploadInput(page, spec, timeoutMs) {
|
|
6227
|
+
const deadline = Date.now() + timeoutMs;
|
|
6228
|
+
do {
|
|
6229
|
+
const imageInput = await firstExisting(page, spec.imageInputs);
|
|
6230
|
+
if (imageInput) return imageInput;
|
|
6231
|
+
await page.waitForTimeout(500);
|
|
6232
|
+
} while (Date.now() < deadline);
|
|
6233
|
+
return null;
|
|
6234
|
+
}
|
|
6235
|
+
|
|
6236
|
+
async function waitForManualXiaohongshuSubmission(page, timeoutMs) {
|
|
6237
|
+
const initialUrl = page.url();
|
|
6238
|
+
const deadline = Date.now() + timeoutMs;
|
|
6239
|
+
do {
|
|
6240
|
+
const pageText = await page.locator("body").innerText().catch(() => "");
|
|
6241
|
+
if (/发布失败|上传失败|内容违规|请重试/.test(pageText)) {
|
|
6242
|
+
throw new Error(`小红书发布失败: ${normalizeText(pageText.match(/(?:发布失败|上传失败|内容违规)[^\n]*/)?.[0] || "页面返回失败提示")}`);
|
|
6243
|
+
}
|
|
6244
|
+
if (/发布成功|笔记发布成功|发布完成/.test(pageText) || page.url() !== initialUrl) {
|
|
6245
|
+
return { url: page.url(), status: "published", type: "note", confirmation: "manual-page" };
|
|
6246
|
+
}
|
|
6247
|
+
await page.waitForTimeout(500);
|
|
6248
|
+
} while (Date.now() < deadline);
|
|
6249
|
+
throw new Error("小红书待发布页面等待超时,未检测到手动发布成功回执");
|
|
6250
|
+
}
|
|
6251
|
+
|
|
6252
|
+
function xiaohongshuMutationResponse(response) {
|
|
6253
|
+
const request = response.request();
|
|
6254
|
+
if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) return false;
|
|
6255
|
+
const url = response.url();
|
|
6256
|
+
return /xiaohongshu\.com/i.test(url) && /(?:publish|note|feed|create|upload)/i.test(url);
|
|
6257
|
+
}
|
|
6258
|
+
|
|
6259
|
+
async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, options) {
|
|
6260
|
+
if (options.skipSubmissionVerification) return { confirmation: "test" };
|
|
6261
|
+
const response = await mutationPromise;
|
|
6262
|
+
if (response) {
|
|
6263
|
+
const payload = await response.json().catch(() => null);
|
|
6264
|
+
if (response.status() >= 400 || payload?.success === false || Number(payload?.code) > 0) {
|
|
6265
|
+
throw new Error(`小红书发布失败: ${normalizeText(payload?.msg || payload?.message || `接口状态 ${response.status()}`)}`);
|
|
6266
|
+
}
|
|
6267
|
+
return { confirmation: "response", responseUrl: response.url() };
|
|
6268
|
+
}
|
|
6269
|
+
const deadline = Date.now() + 15000;
|
|
6270
|
+
do {
|
|
6271
|
+
const pageText = await page.locator("body").innerText().catch(() => "");
|
|
6272
|
+
if (/发布失败|上传失败|内容违规|请重试/.test(pageText)) {
|
|
6273
|
+
throw new Error(`小红书发布失败: ${normalizeText(pageText.match(/(?:发布失败|上传失败|内容违规)[^\n]*/)?.[0] || "页面返回失败提示")}`);
|
|
6274
|
+
}
|
|
6275
|
+
if (/发布成功|笔记发布成功|发布完成/.test(pageText) || page.url() !== initialUrl) {
|
|
6276
|
+
return { confirmation: "page" };
|
|
6277
|
+
}
|
|
6278
|
+
await page.waitForTimeout(500);
|
|
6279
|
+
} while (Date.now() < deadline);
|
|
6280
|
+
throw new Error("小红书发布后未检测到成功回执");
|
|
6281
|
+
}
|
|
6282
|
+
|
|
4228
6283
|
async function launchBrowserContext(entry, browserChannel) {
|
|
4229
6284
|
const { chromium } = await import("playwright-core");
|
|
4230
6285
|
const profileDir = path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
|
|
@@ -4293,7 +6348,9 @@ async function waitForHaoyouSubmission(page, initialUrl, mutationPromise, option
|
|
|
4293
6348
|
if (!payload || Number(payload.code) !== 100) {
|
|
4294
6349
|
return { error: message || `接口返回 code ${payload?.code ?? "未知"}` };
|
|
4295
6350
|
}
|
|
4296
|
-
|
|
6351
|
+
const responseUrl = response.url();
|
|
6352
|
+
const redirectUrl = haoyouPublishedUrl(payload, responseUrl);
|
|
6353
|
+
return { confirmation: "response", responseUrl, ...(redirectUrl ? { publishedUrl: redirectUrl } : {}) };
|
|
4297
6354
|
})
|
|
4298
6355
|
);
|
|
4299
6356
|
outcomes.push(page.waitForTimeout(15500).then(() => null));
|
|
@@ -4313,6 +6370,67 @@ async function waitForHaoyouSubmission(page, initialUrl, mutationPromise, option
|
|
|
4313
6370
|
throw new Error("好游快爆提交后未检测到成功回执,不能判定为发布成功");
|
|
4314
6371
|
}
|
|
4315
6372
|
|
|
6373
|
+
function haoyouPublishedUrl(payload, responseUrl = "") {
|
|
6374
|
+
const candidates = [
|
|
6375
|
+
payload?.url,
|
|
6376
|
+
payload?.data?.url,
|
|
6377
|
+
payload?.data?.thread_url,
|
|
6378
|
+
payload?.data?.threadUrl,
|
|
6379
|
+
payload?.data?.href,
|
|
6380
|
+
payload?.thread_url,
|
|
6381
|
+
payload?.threadUrl
|
|
6382
|
+
].map(normalizeText).filter(Boolean);
|
|
6383
|
+
const id = payload?.tid || payload?.thread_id || payload?.data?.tid || payload?.data?.thread_id;
|
|
6384
|
+
if (id && /^\d+$/.test(String(id))) {
|
|
6385
|
+
candidates.push(`https://bbs.3839.com/thread-${id}.htm`);
|
|
6386
|
+
}
|
|
6387
|
+
candidates.push(responseUrl);
|
|
6388
|
+
return candidates.find(candidate => {
|
|
6389
|
+
try {
|
|
6390
|
+
const url = new URL(candidate);
|
|
6391
|
+
return /(?:^|\.)bbs\.3839\.com$/i.test(url.hostname) && /(?:forum|thread)-\d+\.htm/i.test(url.pathname);
|
|
6392
|
+
} catch {
|
|
6393
|
+
return false;
|
|
6394
|
+
}
|
|
6395
|
+
}) || "";
|
|
6396
|
+
}
|
|
6397
|
+
|
|
6398
|
+
async function clickHaoyouSubmitButton(submitButton) {
|
|
6399
|
+
try {
|
|
6400
|
+
if (typeof submitButton.scrollIntoViewIfNeeded === "function") {
|
|
6401
|
+
await submitButton.scrollIntoViewIfNeeded().catch(() => {});
|
|
6402
|
+
}
|
|
6403
|
+
await submitButton.click();
|
|
6404
|
+
return;
|
|
6405
|
+
} catch (error) {
|
|
6406
|
+
if (!/outside of the viewport/i.test(String(error?.message || error))) {
|
|
6407
|
+
throw error;
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
|
|
6411
|
+
const clicked = await submitButton
|
|
6412
|
+
.evaluate(element => {
|
|
6413
|
+
const style = getComputedStyle(element);
|
|
6414
|
+
const rect = element.getBoundingClientRect();
|
|
6415
|
+
const disabled =
|
|
6416
|
+
element.matches("[disabled], .disabled") || element.getAttribute("aria-disabled") === "true";
|
|
6417
|
+
const visible =
|
|
6418
|
+
style.display !== "none" &&
|
|
6419
|
+
style.visibility !== "hidden" &&
|
|
6420
|
+
Number(style.opacity || 1) > 0 &&
|
|
6421
|
+
rect.width > 0 &&
|
|
6422
|
+
rect.height > 0;
|
|
6423
|
+
if (disabled || !visible) return false;
|
|
6424
|
+
element.scrollIntoView({ block: "center", inline: "center" });
|
|
6425
|
+
element.click();
|
|
6426
|
+
return true;
|
|
6427
|
+
})
|
|
6428
|
+
.catch(() => false);
|
|
6429
|
+
if (!clicked) {
|
|
6430
|
+
throw new Error("好游快爆发布按钮位于视口外且无法安全触发");
|
|
6431
|
+
}
|
|
6432
|
+
}
|
|
6433
|
+
|
|
4316
6434
|
export async function publishWithBrowser(entry, article, options = {}) {
|
|
4317
6435
|
const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
|
|
4318
6436
|
if (!spec) {
|
|
@@ -4322,15 +6440,29 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
4322
6440
|
throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
|
|
4323
6441
|
}
|
|
4324
6442
|
|
|
6443
|
+
const contentPolicy = resolvePlatformContentRequirements(entry);
|
|
6444
|
+
if (contentPolicy.optionalSectionReview && !options.reviewDraft) {
|
|
6445
|
+
options = { ...options, reviewDraft: true };
|
|
6446
|
+
options.onProgress?.(
|
|
6447
|
+
`${entry.platform} 的飞书特殊要求包含可选删除板块,已切换为发布前人工预览确认`
|
|
6448
|
+
);
|
|
6449
|
+
}
|
|
6450
|
+
|
|
6451
|
+
const requirementPreparedArticle = prepareArticleForPlatform(article, entry);
|
|
6452
|
+
const preparedArticle =
|
|
6453
|
+
entry.platformKey === "taptap"
|
|
6454
|
+
? prepareTapTapArticleForProject(requirementPreparedArticle, entry)
|
|
6455
|
+
: requirementPreparedArticle;
|
|
6456
|
+
|
|
4325
6457
|
const body =
|
|
4326
6458
|
entry.platformKey === "haoyou"
|
|
4327
|
-
? buildHaoyouRichContent(
|
|
4328
|
-
: plainTextForPlatform(
|
|
6459
|
+
? buildHaoyouRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
|
|
6460
|
+
: plainTextForPlatform(preparedArticle, entry.platformKey);
|
|
4329
6461
|
if (entry.platformKey === "x" && body.length > 280) {
|
|
4330
6462
|
throw new Error(`X 内容共 ${body.length} 个字符,超过 280 字符限制`);
|
|
4331
6463
|
}
|
|
4332
|
-
if (entry.platformKey === "haoyou" && Array.from(
|
|
4333
|
-
throw new Error(`好游快爆标题最多 48 个字符,当前为 ${Array.from(
|
|
6464
|
+
if (entry.platformKey === "haoyou" && Array.from(preparedArticle.title).length > 48) {
|
|
6465
|
+
throw new Error(`好游快爆标题最多 48 个字符,当前为 ${Array.from(preparedArticle.title).length} 个字符`);
|
|
4334
6466
|
}
|
|
4335
6467
|
|
|
4336
6468
|
const ownsContext = !options.context;
|
|
@@ -4341,15 +6473,21 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
4341
6473
|
let haoyouEditorOpened = false;
|
|
4342
6474
|
try {
|
|
4343
6475
|
if (entry.platformKey === "taptap") {
|
|
4344
|
-
return await publishTapTap(page, entry,
|
|
6476
|
+
return await publishTapTap(page, entry, preparedArticle, spec, options, promptUser);
|
|
4345
6477
|
}
|
|
4346
6478
|
if (entry.platformKey === "bilibili") {
|
|
4347
|
-
return await publishBilibili(page, entry,
|
|
6479
|
+
return await publishBilibili(page, entry, preparedArticle, spec, options, promptUser);
|
|
4348
6480
|
}
|
|
4349
6481
|
if (entry.platformKey === "weibo") {
|
|
4350
|
-
|
|
6482
|
+
if (isWeiboQuickPublishEntry(entry)) {
|
|
6483
|
+
return await publishWeiboQuick(page, entry, preparedArticle, options, promptUser);
|
|
6484
|
+
}
|
|
6485
|
+
return await publishWeibo(page, entry, preparedArticle, spec, options, promptUser);
|
|
4351
6486
|
}
|
|
4352
|
-
|
|
6487
|
+
if (entry.platformKey === "xiaohongshu") {
|
|
6488
|
+
return await publishXiaohongshu(page, entry, preparedArticle, spec, options, promptUser);
|
|
6489
|
+
}
|
|
6490
|
+
const editorUrl = browserPlatformPageUrl(entry);
|
|
4353
6491
|
let editorReady = false;
|
|
4354
6492
|
let loginHandled = false;
|
|
4355
6493
|
const attempts = entry.platformKey === "haoyou" ? 3 : 2;
|
|
@@ -4357,7 +6495,10 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
4357
6495
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4358
6496
|
await afterNavigation(page);
|
|
4359
6497
|
const readyTimeout = attempt === 0 && spec.loginPage ? 3000 : 15000;
|
|
4360
|
-
|
|
6498
|
+
const ready = entry.platformKey === "haoyou"
|
|
6499
|
+
? await waitForHaoyouTargetEditor(page, editorUrl, spec, readyTimeout)
|
|
6500
|
+
: await waitForEditorReady(page, () => editorIsReady(page, spec), readyTimeout);
|
|
6501
|
+
if (ready) {
|
|
4361
6502
|
editorReady = true;
|
|
4362
6503
|
break;
|
|
4363
6504
|
}
|
|
@@ -4370,6 +6511,9 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
4370
6511
|
}
|
|
4371
6512
|
if (!editorReady) {
|
|
4372
6513
|
const targetHint = entry.platformKey === "haoyou" ? `(目标地址: ${editorUrl})` : "";
|
|
6514
|
+
if (entry.platformKey === "haoyou" && !haoyouEditorTargetMatches(page.url(), editorUrl)) {
|
|
6515
|
+
throw new Error(`好游快爆登录后未重定向到当前游戏发布页(当前地址: ${page.url()},目标地址: ${editorUrl})`);
|
|
6516
|
+
}
|
|
4373
6517
|
throw new Error(`${entry.platform} 登录完成后仍未进入发布编辑器${targetHint},请确认账号拥有发布权限`);
|
|
4374
6518
|
}
|
|
4375
6519
|
haoyouEditorOpened = entry.platformKey === "haoyou";
|
|
@@ -4392,7 +6536,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
4392
6536
|
}
|
|
4393
6537
|
let richContent = null;
|
|
4394
6538
|
if (entry.platformKey === "haoyou") {
|
|
4395
|
-
richContent = await insertHaoyouRichContent(page, bodyInput,
|
|
6539
|
+
richContent = await insertHaoyouRichContent(page, bodyInput, preparedArticle, body);
|
|
4396
6540
|
} else {
|
|
4397
6541
|
await fillLocator(bodyInput, body);
|
|
4398
6542
|
}
|
|
@@ -4413,12 +6557,12 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
4413
6557
|
typeof page.waitForResponse === "function"
|
|
4414
6558
|
? page.waitForResponse(haoyouMutationResponse, { timeout: 15000 }).catch(() => null)
|
|
4415
6559
|
: Promise.resolve(null);
|
|
4416
|
-
await submitButton
|
|
6560
|
+
await clickHaoyouSubmitButton(submitButton);
|
|
4417
6561
|
const receipt = await waitForHaoyouSubmission(page, initialUrl, mutationPromise, options);
|
|
4418
6562
|
if (options.keepOpen) {
|
|
4419
6563
|
await promptUser(`${entry.platform} 已确认发布成功,浏览器保持打开供检查`);
|
|
4420
6564
|
}
|
|
4421
|
-
return { url: page.url(), status: "published", ...richContent, ...receipt };
|
|
6565
|
+
return { url: receipt.publishedUrl || page.url(), status: "published", ...richContent, ...receipt };
|
|
4422
6566
|
}
|
|
4423
6567
|
await submitButton.click();
|
|
4424
6568
|
await page.waitForTimeout(3000);
|