@qcplay/cli 1.0.14 → 1.0.16

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.
@@ -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
- } else {
323
- value = await runJson("lark-cli", [
324
- "sheets",
325
- "+table-get",
326
- "--url",
327
- PLATFORM_CONFIG_URL,
328
- "--as",
329
- "user",
330
- "--format",
331
- "json"
332
- ]);
333
- }
334
-
335
- const rows = Array.isArray(value) ? value : Array.isArray(value.rows) ? value.rows : rowsFromLarkEnvelope(value);
336
- return normalizePlatformRows(rows);
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 = normalizeKey(project);
507
+ const projectKey = normalizeProjectKey(project);
349
508
  const regionKey = normalizeKey(region);
350
509
  return entries.filter(
351
- entry => normalizeKey(entry.project) === projectKey && (!regionKey || normalizeKey(entry.region) === regionKey)
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
- return {
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 chunks = splitDiscordContent(article.title, article.markdown);
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.html || "", null, false);
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小草,获取最新资讯不迷路~~(*ω&lt; )</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>&gt;&gt;&gt;福利密令:</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(tapTapMeta(article, "taptap_type", "taptap_mode") || "long-post");
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
- ? normalizeText(entry.project)
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 settings = resolveTapTapPublishingOptions(article, entry);
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(article), normalizeText(article.payload?.thumbnail)].filter(Boolean))].length;
583
- return {
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, "&amp;").replace(/"/g, "&quot;")}"></p>`,
1616
+ markdown: `${String(article.markdown || "").trim()}\n\n![](${requestedCover})`.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 weiboPublishingPlan(article) {
640
- const settings = resolveWeiboPublishingOptions(article);
641
- const richContent = buildWeiboRichContent(article);
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 settings = resolveBilibiliPublishingOptions(article);
691
- const richContent = buildBilibiliRichContent(article);
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 url = new URL(entryUrl);
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 url = new URL(entryUrl);
714
- url.searchParams.set("type", type);
715
- return url.toString();
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
- url = new URL(entryUrl);
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 weiboArticleEditorUrl() {
743
- return "https://card.weibo.com/article/v5/editor";
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.html || "", null, false);
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 spanStyles = [safeColor ? `color:${safeColor}` : "", safeBackground ? `background-color:${safeBackground}` : ""]
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
- return buildPlatformRichContent(article);
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.html || "", null, false);
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 = /^(?:left|center|right|justify)$/i.test(alignment)
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: normalizeText($(element).attr("alt")).slice(0, 50)
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: normalizeText(element.attr("alt")).slice(0, 50)
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
- return buildPlatformRichContent(article, {
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.waitForTimeout(100);
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
- const markerText = bodyInput.locator('[data-slate-string="true"]').filter({ hasText: marker }).first();
1465
- if ((await markerText.count()) === 0) {
1466
- throw new Error(`TapTap 正文图片占位位置丢失: ${item.source}`);
1467
- }
1468
- const selected = await markerText.evaluate((element, markerValue) => {
1469
- const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
1470
- let textNode = walker.nextNode();
1471
- while (textNode) {
1472
- const start = textNode.data.indexOf(markerValue);
1473
- if (start >= 0) {
1474
- const range = document.createRange();
1475
- range.setStart(textNode, start);
1476
- range.setEnd(textNode, start + markerValue.length);
1477
- const selection = window.getSelection();
1478
- selection.removeAllRanges();
1479
- selection.addRange(range);
1480
- document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
1481
- return true;
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
- textNode = walker.nextNode();
1484
- }
1485
- return false;
1486
- }, marker);
1487
- if (!selected) {
1488
- throw new Error(`TapTap 正文图片占位文本无法选中: ${item.source}`);
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
- const state = await bodyInput.evaluate(element => ({
1512
- text: element.innerText,
1513
- images: element.querySelectorAll(".tap-editor-image").length,
1514
- failedImages: element.querySelectorAll(".tap-editor-image__retry").length,
1515
- links: [...element.querySelectorAll("a.tap-editor-link[href]")].map(link => ({
1516
- href: link.href,
1517
- label: link.innerText
1518
- }))
1519
- }));
1520
- if (state.failedImages > 0 || (inlineImages && state.images < content.images.length)) {
1521
- throw new Error(`TapTap 正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
1522
- }
1523
- const actualText = normalizedTapTapText(state.text);
1524
- if (actualText.includes(markerPrefix)) {
1525
- throw new Error("TapTap 正文图片占位符未正确替换");
1526
- }
1527
- for (const item of content.items) {
1528
- if (item.type === "html" && item.plain && !actualText.includes(normalizedTapTapText(item.plain))) {
1529
- throw new Error("TapTap 正文文字未完整写入编辑器");
1530
- }
1531
- }
1532
- const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
1533
- for (const link of content.links) {
1534
- if (!actualLinks.has(link.href)) {
1535
- throw new Error(`TapTap 正文超链接未正确写入: ${link.href}`);
1536
- }
1537
- }
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
- async function waitForBilibiliInlineImage(bodyInput, previousCount, source) {
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
- if (state.failedImages > 0 || state.images < content.images.length) {
1686
- throw new Error(`B站正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
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 && !actualText.includes(normalizedStructuredEditorText(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: content.images.length, links: content.links.length, plainText: content.plainText };
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
- const supportedExtensions = new Set([".jpg", ".jpeg", ".png", ".gif"]);
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 markerPrefix = `QCPLAY_HAOYOU_IMAGE_${Date.now()}_`;
1783
- const imageMarkers = [];
1784
- const htmlParts = [];
1785
- for (let index = 0; index < content.items.length; index += 1) {
1786
- const item = content.items[index];
1787
- if (item.type === "html") {
1788
- htmlParts.push(item.html);
1789
- } else {
1790
- const imageIndex = imageMarkers.length;
1791
- const marker = `${markerPrefix}${imageIndex}`;
1792
- htmlParts.push(`<p>${marker}</p>`);
1793
- imageMarkers.push({ marker, item, index: imageIndex });
1794
- }
1795
- }
1796
- const html = htmlParts.join("") || `<p>${escapeTapTapHtml(fallbackText)}</p>`;
1797
- const initialized = await bodyInput.evaluate((element, richHtml) => {
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("", "silent");
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
- }, html);
3222
+ });
1808
3223
  if (!initialized) {
1809
- if (imageMarkers.length > 0) {
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
- for (const { marker, item, index } of imageMarkers) {
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, index, "好游快爆"),
1823
- index
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
- const markerIndex = quill.getText().indexOf(payload.marker);
1836
- if (markerIndex < 0) {
3266
+ if (typeof quill.getLength !== "function") {
1837
3267
  return "";
1838
3268
  }
1839
- quill.deleteText(markerIndex, payload.marker.length, "api");
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
- { ...upload, marker }
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(`好游快爆正文图片占位位置丢失或图片上传接口不可用: ${item.source}`);
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
- text: element.innerText,
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 actualText = normalizedStructuredEditorText(state.text);
1905
- if (actualText.includes(markerPrefix)) {
1906
- throw new Error("好游快爆正文图片占位符未正确替换");
1907
- }
1908
- for (const item of content.items) {
1909
- if (item.type === "html" && item.plain && !actualText.includes(normalizedStructuredEditorText(item.plain))) {
1910
- const missing = normalizedStructuredEditorText(item.plain).slice(0, 80);
1911
- throw new Error(
1912
- `好游快爆正文文字未完整写入富文本编辑器,首个缺失片段“${missing}”(预期 ${content.plainText.length} 字,实际 ${state.text.length} 字)`
1913
- );
1914
- }
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}。当前不是交互终端,无法确认草稿,请移除 --review-draft 直接发布或在本地 CMD 重试`);
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
- export async function applyTapTapForum(page, forum, promptUser) {
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 existingForums = page.locator(".create-config__forum-name");
2536
- if (typeof existingForums.filter === "function") {
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 || response.status() < 200 || response.status() >= 300) {
4258
+ if (!response) {
2708
4259
  return null;
2709
4260
  }
2710
- const payload = await response.json().catch(() => null);
2711
- if (payload && typeof payload === "object") {
2712
- if (payload.success === false) {
2713
- return null;
2714
- }
2715
- if (payload.code !== undefined && ![0, 200, "0", "200"].includes(payload.code)) {
2716
- return null;
2717
- }
2718
- if (/失败|错误|fail|error/i.test(normalizeText(payload.message))) {
2719
- return null;
2720
- }
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
- const settings = resolveTapTapPublishingOptions(article, entry);
4378
+ let preparedArticle = prepareTapTapArticleForProject(article, entry);
4379
+ const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
2833
4380
  const titleLimit = settings.type === "moment" ? 20 : 30;
2834
- if (article.title.length > titleLimit) {
2835
- throw new Error(`TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${article.title.length} 个字符`);
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(article);
2838
- const body = richContent.plainText || plainTextForPlatform(article, "taptap");
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
- await page.goto(tapTapEditorUrl(entry.url, settings.type), {
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(tapTapEditorUrl(entry.url, settings.type), {
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, article.title);
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
- : [...new Set([...imageSourcesFromArticle(article), normalizeText(article.payload?.thumbnail)].filter(Boolean))];
2865
- await uploadTapTapImages(page, settings.images.length ? imageSources : imageSources.slice(0, 18), article.articleFile);
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, article.articleFile);
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, article.title);
2879
- await verifyFilledValue(titleInput, article.title, "标题");
2880
- await insertTapTapRichContent(page, bodyInput, article, settings, body);
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, article.articleFile);
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 = bilibiliEditorUrl(entry.url, settings.type);
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.fill("");
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.waitForTimeout(100);
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
- const selected = await bodyInput.evaluate((element, markerValue) => {
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
- const start = textNode.data.indexOf(markerValue);
3686
- if (start >= 0) {
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(textNode, start);
3689
- range.setEnd(textNode, start + markerValue.length);
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
- document.dispatchEvent(new Event("selectionchange", { bubbles: true }));
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
- return false;
3699
- }, marker);
3700
- if (!selected) {
3701
- throw new Error(`微博文章正文图片占位位置丢失: ${item.source}`);
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, bodyInput, spec, payload, item.source, {
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
- const state = await bodyInput.evaluate(element => ({
3717
- text: element.innerText,
3718
- images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
3719
- failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length,
3720
- links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
3721
- }));
3722
- if (state.failedImages > 0 || state.images < content.images.length) {
3723
- throw new Error(`微博文章正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
3724
- }
3725
- const actualText = normalizedTapTapText(state.text);
3726
- if (actualText.includes(markerPrefix)) {
3727
- throw new Error("微博文章正文图片占位符未正确替换");
3728
- }
3729
- for (const item of content.items) {
3730
- if (item.type === "html" && item.plain && !actualText.includes(normalizedTapTapText(item.plain))) {
3731
- throw new Error("微博文章正文文字未完整写入编辑器");
3732
- }
3733
- }
3734
- const actualLinks = new Set(state.links.map(link => safeTapTapHref(link.href)));
3735
- for (const link of content.links) {
3736
- if (!actualLinks.has(link.href)) {
3737
- throw new Error(`微博文章正文超链接未正确写入: ${link.href}`);
3738
- }
3739
- }
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 settings = resolveWeiboPublishingOptions(article);
4118
- const body = buildWeiboText(article);
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), 15000))) {
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, article, spec, body, {
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, article, settings, { onProgress: progress });
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
- const confirmationDeadline = Date.now() + 10000;
4183
- do {
4184
- if (await weiboSecurityChallengeVisible(page)) {
4185
- securityDetected = true;
4186
- break;
4187
- }
4188
- submitButton = await firstVisibleButton(page, spec.submit);
4189
- if (submitButton) break;
4190
- await page.waitForTimeout(250);
4191
- } while (Date.now() < confirmationDeadline);
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
- return { confirmation: "response", responseUrl: response.url() };
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(article).plainText || plainTextForPlatform(article, entry.platformKey)
4328
- : plainTextForPlatform(article, entry.platformKey);
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(article.title).length > 48) {
4333
- throw new Error(`好游快爆标题最多 48 个字符,当前为 ${Array.from(article.title).length} 个字符`);
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, article, spec, options, promptUser);
6476
+ return await publishTapTap(page, entry, preparedArticle, spec, options, promptUser);
4345
6477
  }
4346
6478
  if (entry.platformKey === "bilibili") {
4347
- return await publishBilibili(page, entry, article, spec, options, promptUser);
6479
+ return await publishBilibili(page, entry, preparedArticle, spec, options, promptUser);
4348
6480
  }
4349
6481
  if (entry.platformKey === "weibo") {
4350
- return await publishWeibo(page, entry, article, spec, options, promptUser);
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
- const editorUrl = entry.platformKey === "haoyou" ? haoyouEditorUrl(entry.url) : entry.url;
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
- if (await waitForEditorReady(page, () => editorIsReady(page, spec), readyTimeout)) {
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, article, body);
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.click();
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);