@qcplay/cli 1.0.18 → 1.0.19

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.
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "child_process";
2
+ import { createHash } from "crypto";
2
3
  import fs from "fs";
3
4
  import http from "http";
4
5
  import https from "https";
@@ -12,6 +13,8 @@ import { stripWechatGuidanceHtml } from "./wechat-article.js";
12
13
 
13
14
  const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
14
15
  const DISCORD_MESSAGE_LIMIT = 2000;
16
+ const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
17
+ const BROWSER_SUBMISSION_STATE_FILE = path.join(os.homedir(), ".qcplay", "browser-submissions.json");
15
18
  const ARES_PROJECT = "阿瑞斯病毒2";
16
19
  const ARES_BILIBILI_RECHARGE_RULE = {
17
20
  name: "阿瑞斯病毒2 B站删除官充章节",
@@ -1527,6 +1530,26 @@ export function resolveTapTapPublishingOptions(article, entry = {}) {
1527
1530
  };
1528
1531
  }
1529
1532
 
1533
+ export function resolveTapTapTitle(article, limit = 30) {
1534
+ const explicitTitle = tapTapMeta(article, "taptap_title");
1535
+ const sourceTitle = explicitTitle || normalizeText(article.title || article.payload?.article_title);
1536
+ const characters = Array.from(sourceTitle);
1537
+ if (characters.length <= limit || explicitTitle) {
1538
+ return sourceTitle;
1539
+ }
1540
+
1541
+ const prefix = characters.slice(0, limit).join("");
1542
+ const sentenceEnd = Math.max(...["。", "!", "?", "!", "?"].map(mark => prefix.lastIndexOf(mark)));
1543
+ if (sentenceEnd >= 12) {
1544
+ return prefix.slice(0, sentenceEnd + 1);
1545
+ }
1546
+ const phraseEnd = Math.max(...[",", ",", "、", ";", ";"].map(mark => prefix.lastIndexOf(mark)));
1547
+ if (phraseEnd >= 12) {
1548
+ return prefix.slice(0, phraseEnd + 1);
1549
+ }
1550
+ return `${characters.slice(0, Math.max(0, limit - 1)).join("")}…`;
1551
+ }
1552
+
1530
1553
  export function tapTapPublishingPlan(article, entry = {}) {
1531
1554
  const preparedArticle = prepareTapTapArticleForProject(article, entry);
1532
1555
  const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
@@ -1560,7 +1583,26 @@ function weiboMeta(article, ...keys) {
1560
1583
  }
1561
1584
 
1562
1585
  export function resolveWeiboArticleTitle(article) {
1563
- return weiboMeta(article, "weibo_title") || normalizeText(article.title || article.payload?.article_title);
1586
+ const explicitTitle = weiboMeta(article, "weibo_title");
1587
+ if (explicitTitle) {
1588
+ return explicitTitle;
1589
+ }
1590
+ const sourceTitle = normalizeText(article.title || article.payload?.article_title);
1591
+ const characters = Array.from(sourceTitle);
1592
+ if (characters.length <= 32) {
1593
+ return sourceTitle;
1594
+ }
1595
+
1596
+ const prefix = characters.slice(0, 32).join("");
1597
+ const sentenceEnd = Math.max(...["。", "!", "?", "!", "?"].map(mark => prefix.lastIndexOf(mark)));
1598
+ if (sentenceEnd >= 12) {
1599
+ return prefix.slice(0, sentenceEnd + 1);
1600
+ }
1601
+ const phraseEnd = Math.max(...[",", ",", "、", ";", ";"].map(mark => prefix.lastIndexOf(mark)));
1602
+ if (phraseEnd >= 12) {
1603
+ return prefix.slice(0, phraseEnd + 1);
1604
+ }
1605
+ return `${characters.slice(0, 31).join("")}…`;
1564
1606
  }
1565
1607
 
1566
1608
  export function resolveWeiboSuperTopic(article, entry = {}) {
@@ -1616,7 +1658,7 @@ export function buildWeiboRichContent(article) {
1616
1658
  structuredTables: true
1617
1659
  });
1618
1660
  const seenImages = new Set();
1619
- const items = content.items.filter(item => {
1661
+ const filteredItems = content.items.filter(item => {
1620
1662
  if (item.type !== "image") {
1621
1663
  return true;
1622
1664
  }
@@ -1629,8 +1671,9 @@ export function buildWeiboRichContent(article) {
1629
1671
  });
1630
1672
  return {
1631
1673
  ...content,
1632
- items,
1633
- images: items.filter(item => item.type === "image")
1674
+ items: filteredItems,
1675
+ plainText: filteredItems.map(item => normalizeText(item.plain)).filter(Boolean).join("\n"),
1676
+ images: filteredItems.filter(item => item.type === "image")
1634
1677
  };
1635
1678
  }
1636
1679
 
@@ -2006,6 +2049,14 @@ function safeRichCssLength(value, options = {}) {
2006
2049
  return pattern.test(length) ? length : "";
2007
2050
  }
2008
2051
 
2052
+ function safeRichFontFamily(value) {
2053
+ const family = normalizeText(value).replace(/\s*!important\s*$/i, "");
2054
+ if (!family || !/^[\w\s,'"-]+$/u.test(family)) {
2055
+ return "";
2056
+ }
2057
+ return family;
2058
+ }
2059
+
2009
2060
  function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
2010
2061
  const wrappers = [];
2011
2062
  const add = name => {
@@ -2033,12 +2084,16 @@ function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
2033
2084
  const letterSpacing = preserveStyles
2034
2085
  ? safeRichCssLength(styleValue.match(/(?:^|;)\s*letter-spacing\s*:\s*([^;]+)/i)?.[1])
2035
2086
  : "";
2087
+ const fontFamily = preserveStyles
2088
+ ? safeRichFontFamily(styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1])
2089
+ : "";
2036
2090
  const spanStyles = [
2037
2091
  safeColor ? `color:${safeColor}` : "",
2038
2092
  safeBackground ? `background-color:${safeBackground}` : "",
2039
2093
  fontSize ? `font-size:${fontSize}` : "",
2040
2094
  lineHeight ? `line-height:${lineHeight}` : "",
2041
- letterSpacing ? `letter-spacing:${letterSpacing}` : ""
2095
+ letterSpacing ? `letter-spacing:${letterSpacing}` : "",
2096
+ fontFamily ? `font-family:${fontFamily}` : ""
2042
2097
  ]
2043
2098
  .filter(Boolean)
2044
2099
  .join(";");
@@ -2111,6 +2166,10 @@ function buildPlatformRichContent(article, options = {}) {
2111
2166
  );
2112
2167
  if (value) blockStyles.push(`${property}:${value}`);
2113
2168
  }
2169
+ const fontFamily = safeRichFontFamily(
2170
+ styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1]
2171
+ );
2172
+ if (fontFamily) blockStyles.push(`font-family:${fontFamily}`);
2114
2173
  }
2115
2174
  return blockStyles.length > 0 ? ` style="${blockStyles.join(";")}"` : "";
2116
2175
  };
@@ -2479,17 +2538,66 @@ function buildPlatformRichContent(article, options = {}) {
2479
2538
  const fallback = normalizeText(article.markdown);
2480
2539
  items.push({ type: "html", html: `<p>${escapeTapTapHtml(fallback)}</p>`, plain: fallback });
2481
2540
  }
2541
+ const normalizedItems = normalizeNumberedSectionHeadings(items);
2482
2542
  return {
2483
- items,
2543
+ items: normalizedItems,
2484
2544
  images,
2485
2545
  links,
2486
- plainText: items
2546
+ plainText: normalizedItems
2487
2547
  .filter(item => item.type === "html")
2488
2548
  .map(item => item.plain)
2489
2549
  .join("\n")
2490
2550
  };
2491
2551
  }
2492
2552
 
2553
+ function isStandaloneSectionNumber(value) {
2554
+ return /^(?:(?:第\s*)?\d{1,3}|[一二三四五六七八九十]+)(?:[、..::])?$/.test(normalizeText(value));
2555
+ }
2556
+
2557
+ function mergeNumberedSectionHeading(numberItem, headingItem) {
2558
+ const number = normalizeText(numberItem.plain);
2559
+ const title = normalizeText(headingItem.plain);
2560
+ const opening = String(headingItem.html || "").match(/^<(p|h[1-3])([^>]*)>/i);
2561
+ const closing = String(headingItem.html || "").match(/<\/(p|h[1-3])>$/i);
2562
+ if (!opening || !closing) return null;
2563
+ const headingHtml = String(headingItem.html || "").replace(/^<(p|h[1-3])[^>]*>|<\/(p|h[1-3])>$/gi, "");
2564
+ return {
2565
+ ...headingItem,
2566
+ html: `<${opening[1]}${opening[2]}>${escapeTapTapHtml(number)} ${headingHtml}</${closing[1]}>`,
2567
+ plain: `${number} ${title}`
2568
+ };
2569
+ }
2570
+
2571
+ // WeChat articles sometimes split a section marker and its title into two blocks.
2572
+ // Keep the heading readable on one line while retaining the intended blank space.
2573
+ function normalizeNumberedSectionHeadings(items) {
2574
+ const normalized = [];
2575
+ for (let index = 0; index < items.length; index += 1) {
2576
+ const current = items[index];
2577
+ const following = items[index + 1];
2578
+ const canMerge =
2579
+ current?.type === "html" &&
2580
+ following?.type === "html" &&
2581
+ current.block === "p" &&
2582
+ isStandaloneSectionNumber(current.plain) &&
2583
+ (/^h[1-3]$/.test(following.block || "") || /^\d{2}$/.test(normalizeText(current.plain)));
2584
+ if (!canMerge) {
2585
+ normalized.push(current);
2586
+ continue;
2587
+ }
2588
+ const merged = mergeNumberedSectionHeading(current, following);
2589
+ if (!merged) {
2590
+ normalized.push(current);
2591
+ continue;
2592
+ }
2593
+ normalized.push({ type: "html", html: "<p><br></p>", plain: "", block: "p", align: "" });
2594
+ normalized.push(merged);
2595
+ normalized.push({ type: "html", html: "<p><br></p>", plain: "", block: "p", align: "" });
2596
+ index += 1;
2597
+ }
2598
+ return normalized;
2599
+ }
2600
+
2493
2601
  export function buildBilibiliRichContent(article) {
2494
2602
  return buildPlatformRichContent(article, {
2495
2603
  preserveBackground: true,
@@ -2507,9 +2615,29 @@ export function buildHaoyouRichContent(article) {
2507
2615
  structuredTables: true
2508
2616
  });
2509
2617
  const flattenInlineSpans = isWechatImportedArticle(article);
2618
+ // Most nested WeChat spans are flattened for the Haoyou Quill clipboard
2619
+ // parser, but a secret code is commonly the coloured line immediately before
2620
+ // a “red text is the code” hint. Keeping that tiny group intact preserves the
2621
+ // source visual distinction without exposing the rest of the article to the
2622
+ // parser issue.
2623
+ const secretStyleItems = new Set();
2624
+ content.items.forEach((item, index) => {
2625
+ if (item.type !== "html") return;
2626
+ const text = normalizeText(item.plain);
2627
+ if (!/(?:密令|兑换码|礼包码|福利码)/.test(text)) return;
2628
+ secretStyleItems.add(index);
2629
+ if (/红字[^。;;\n]{0,20}(?:密令|兑换码|礼包码|福利码)/.test(text)) {
2630
+ for (let previous = index - 1; previous >= 0; previous -= 1) {
2631
+ if (content.items[previous].type === "html") {
2632
+ secretStyleItems.add(previous);
2633
+ break;
2634
+ }
2635
+ }
2636
+ }
2637
+ });
2510
2638
  return {
2511
2639
  ...content,
2512
- items: content.items.map(item =>
2640
+ items: content.items.map((item, index) =>
2513
2641
  item.type === "html"
2514
2642
  ? {
2515
2643
  ...item,
@@ -2520,13 +2648,39 @@ export function buildHaoyouRichContent(article) {
2520
2648
  // WeChat's nested styled spans can also truncate their trailing
2521
2649
  // text in the Haoyou Quill clipboard parser. Keep their text and
2522
2650
  // semantic children while dropping only the wrapper.
2523
- .replace(flattenInlineSpans ? /<\/?span\b[^>]*>/gi : /$^/, "")
2651
+ .replace(flattenInlineSpans && !secretStyleItems.has(index) ? /<\/?span\b[^>]*>/gi : /$^/, "")
2524
2652
  }
2525
2653
  : item
2526
2654
  )
2527
2655
  };
2528
2656
  }
2529
2657
 
2658
+ export function buildHaoyouPublishRichContent(article) {
2659
+ const content = buildHaoyouRichContent(article);
2660
+ return {
2661
+ ...content,
2662
+ items: content.items.map(item => {
2663
+ if (item.type !== "html") return item;
2664
+ const $ = cheerio.load(item.html, null, false);
2665
+ $("em,i").each((_, element) => $(element).replaceWith($(element).contents()));
2666
+ $("[style]").each((_, element) => {
2667
+ const style = String($(element).attr("style") || "")
2668
+ .split(";")
2669
+ .filter(rule => !/^\s*(?:color|background(?:-color)?|font-style)\s*:/i.test(rule))
2670
+ .map(rule => rule.trim())
2671
+ .filter(Boolean)
2672
+ .join(";");
2673
+ if (style) {
2674
+ $(element).attr("style", style);
2675
+ } else {
2676
+ $(element).removeAttr("style");
2677
+ }
2678
+ });
2679
+ return { ...item, html: $.html() };
2680
+ })
2681
+ };
2682
+ }
2683
+
2530
2684
  function contentTypeForFile(fileName, fallback = "application/octet-stream") {
2531
2685
  const extension = path.extname(fileName).toLowerCase();
2532
2686
  return (
@@ -2748,7 +2902,7 @@ async function waitForTapTapRichEditorState(page, bodyInput, content, options =
2748
2902
  throw new Error("TapTap 正文写入后未能稳定完成回读校验");
2749
2903
  }
2750
2904
 
2751
- export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText) {
2905
+ export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText, options = {}) {
2752
2906
  if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
2753
2907
  await fillLocator(bodyInput, fallbackText);
2754
2908
  await verifyFilledValue(bodyInput, fallbackText, "正文");
@@ -2758,7 +2912,12 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2758
2912
  const content = buildTapTapRichContent(article);
2759
2913
  const inlineImages = settings.type === "topic";
2760
2914
  const toolbarImageInput = inlineImages
2761
- ? await firstExisting(page, ['.tap-editor-toolbar input[type="file"][accept*="image"]'])
2915
+ ? await firstExisting(page, [
2916
+ '.tap-editor-toolbar input[type="file"][accept*="image"]',
2917
+ '.tap-editor__toolbar input[type="file"][accept*="image"]',
2918
+ '[class*="tap-editor"][class*="toolbar"] input[type="file"][accept*="image"]',
2919
+ 'input[type="file"][accept*="image"]:not([class*="cover"]):not([class*="post-setting"])'
2920
+ ])
2762
2921
  : null;
2763
2922
  if (inlineImages && content.images.length > 0 && (!toolbarImageInput || typeof toolbarImageInput.setInputFiles !== "function")) {
2764
2923
  throw new Error("TapTap 长帖编辑器中未找到正文图片上传控件");
@@ -2777,6 +2936,9 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2777
2936
  plainParts.push(item.plain);
2778
2937
  continue;
2779
2938
  }
2939
+ if (options.skipImages) {
2940
+ continue;
2941
+ }
2780
2942
  if (inlineImages) {
2781
2943
  const marker = `${markerPrefix}${imageMarkers.length}`;
2782
2944
  htmlParts.push(`<p>${marker}</p>`);
@@ -3021,6 +3183,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3021
3183
  }
3022
3184
 
3023
3185
  const content = buildBilibiliRichContent(article);
3186
+ options.onProgress?.(`B站 正在写入文字正文(${content.plainText.length} 字)`);
3024
3187
  await bodyInput.fill("");
3025
3188
  await bodyInput.click();
3026
3189
  const markerPrefix = `QCPLAY_BILIBILI_IMAGE_${Date.now()}_`;
@@ -3045,11 +3208,26 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3045
3208
  plain: plainParts.join("\n")
3046
3209
  });
3047
3210
  await page.waitForTimeout(100);
3211
+ options.onProgress?.("B站 文字正文已写入,正在回读校验");
3212
+ options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
3213
+ }
3214
+
3215
+ if (options.previewOnly) {
3216
+ options.onProgress?.("B站 文字正文已写入,等待用户在页面中核对");
3217
+ return {
3218
+ rich: true,
3219
+ images: 0,
3220
+ imagesPending: content.images.length,
3221
+ links: content.links.length,
3222
+ plainText: content.plainText
3223
+ };
3048
3224
  }
3049
3225
 
3050
3226
  const payloadCache = new Map();
3051
3227
  let removedImages = 0;
3052
- for (const { marker, item, index } of imageMarkers) {
3228
+ for (let markerIndex = 0; markerIndex < imageMarkers.length; markerIndex += 1) {
3229
+ const { marker, item, index } = imageMarkers[markerIndex];
3230
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在定位插入位置`);
3053
3231
  const selected = await bodyInput.evaluate((element, markerValue) => {
3054
3232
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
3055
3233
  let textNode = walker.nextNode();
@@ -3073,6 +3251,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3073
3251
  throw new Error(`B站正文图片占位位置丢失: ${item.source}`);
3074
3252
  }
3075
3253
  await page.keyboard.press("Backspace");
3254
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在上传`);
3076
3255
  let payload = payloadCache.get(item.source);
3077
3256
  if (!payload) {
3078
3257
  payload = await imageUploadPayload(item.source, article.articleFile, index, "B站");
@@ -3086,26 +3265,38 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3086
3265
  });
3087
3266
  if (uploadResult?.removed) {
3088
3267
  removedImages += 1;
3268
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:不合规,已从正文移除`);
3269
+ } else {
3270
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:上传回执已确认`);
3089
3271
  }
3090
3272
  }
3091
3273
 
3274
+ options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
3092
3275
  let state = null;
3093
3276
  let previousTextLength = -1;
3094
3277
  let stableChecks = 0;
3095
3278
  for (let attempt = 0; attempt < 40; attempt += 1) {
3096
- state = await bodyInput.evaluate(element => ({
3097
- text: element.innerText,
3098
- images: element.querySelectorAll("img").length,
3099
- failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
3100
- links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
3101
- }));
3279
+ state = await bodyInput
3280
+ .evaluate(
3281
+ element => ({
3282
+ text: element.innerText,
3283
+ images: element.querySelectorAll("img").length,
3284
+ failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
3285
+ links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
3286
+ }),
3287
+ undefined,
3288
+ { timeout: 15000 }
3289
+ )
3290
+ .catch(error => {
3291
+ throw new Error(`B站正文回读超时或失败:${error.message || error}`);
3292
+ });
3102
3293
  const currentLength = normalizeText(state.text).length;
3103
3294
  stableChecks = currentLength === previousTextLength ? stableChecks + 1 : 0;
3104
3295
  previousTextLength = currentLength;
3105
3296
  if (stableChecks >= 3) break;
3106
3297
  await page.waitForTimeout(250);
3107
3298
  }
3108
- const expectedImages = content.images.length - removedImages;
3299
+ const expectedImages = options.skipImages ? 0 : content.images.length - removedImages;
3109
3300
  if (state.failedImages > 0 || state.images < expectedImages) {
3110
3301
  throw new Error(`B站正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
3111
3302
  }
@@ -3128,7 +3319,14 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3128
3319
  throw new Error(`B站正文超链接未正确写入: ${link.href}`);
3129
3320
  }
3130
3321
  }
3131
- return { rich: true, images: state.images, links: content.links.length, plainText: content.plainText };
3322
+ options.onProgress?.("B站 文字正文回读校验通过");
3323
+ return {
3324
+ rich: true,
3325
+ images: state.images,
3326
+ ...(options.skipImages ? { imagesPending: content.images.length } : {}),
3327
+ links: content.links.length,
3328
+ plainText: content.plainText
3329
+ };
3132
3330
  }
3133
3331
 
3134
3332
  function haoyouImageUploadPayload(payload, index) {
@@ -3197,8 +3395,8 @@ async function waitForHaoyouInlineImage(page, bodyInput, imageId, source) {
3197
3395
  throw new Error(`好游快爆正文图片上传超时: ${source}`);
3198
3396
  }
3199
3397
 
3200
- export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText) {
3201
- const content = buildHaoyouRichContent(article);
3398
+ export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText, options = {}) {
3399
+ const content = buildHaoyouPublishRichContent(article);
3202
3400
  if (typeof bodyInput.evaluate !== "function") {
3203
3401
  await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
3204
3402
  return { rich: false, images: 0, links: 0, plainText: fallbackText };
@@ -3240,6 +3438,10 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3240
3438
  continue;
3241
3439
  }
3242
3440
 
3441
+ if (options.skipImages) {
3442
+ imageIndex += 1;
3443
+ continue;
3444
+ }
3243
3445
  let upload = payloadCache.get(item.source);
3244
3446
  if (!upload) {
3245
3447
  upload = haoyouImageUploadPayload(
@@ -3338,8 +3540,9 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3338
3540
  if (stableChecks >= 3) break;
3339
3541
  await page.waitForTimeout(250);
3340
3542
  }
3341
- if (state.failedImages > 0 || state.pendingImages > 0 || state.images < content.images.length) {
3342
- throw new Error(`好游快爆正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
3543
+ const expectedImages = options.skipImages ? 0 : content.images.length;
3544
+ if (state.failedImages > 0 || state.pendingImages > 0 || state.images < expectedImages) {
3545
+ throw new Error(`好游快爆正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
3343
3546
  }
3344
3547
  const expectedFragments = content.items
3345
3548
  .filter(item => item.type === "html" && item.plain)
@@ -3358,6 +3561,7 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3358
3561
  return {
3359
3562
  rich: true,
3360
3563
  images: state.images,
3564
+ ...(options.skipImages ? { imagesPending: content.images.length } : {}),
3361
3565
  links: state.links,
3362
3566
  headings: state.headings,
3363
3567
  formatted: state.formatted,
@@ -3372,6 +3576,51 @@ function safeProfileName(entry) {
3372
3576
  .slice(0, 80);
3373
3577
  }
3374
3578
 
3579
+ function browserProfileDir(entry) {
3580
+ return path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
3581
+ }
3582
+
3583
+ function browserSubmissionKey(entry, article) {
3584
+ const source = normalizeText(article.meta?.source_url || article.payload?.source_url);
3585
+ const content = normalizeText(article.html || article.markdown || "");
3586
+ return createHash("sha256")
3587
+ .update(`${entry.platformKey}\n${entry.project}\n${entry.region}\n${source}\n${article.title}\n${content}`)
3588
+ .digest("hex");
3589
+ }
3590
+
3591
+ async function readBrowserSubmissionState() {
3592
+ try {
3593
+ const value = JSON.parse(await fs.promises.readFile(BROWSER_SUBMISSION_STATE_FILE, "utf8"));
3594
+ return value && typeof value === "object" ? value : {};
3595
+ } catch {
3596
+ return {};
3597
+ }
3598
+ }
3599
+
3600
+ async function ensureBrowserSubmissionCooldown(entry, article, options) {
3601
+ if (!options.directPublish && !options.publishDraft) return "";
3602
+ const key = browserSubmissionKey(entry, article);
3603
+ const previous = Number((await readBrowserSubmissionState())[key] || 0);
3604
+ const remaining = BROWSER_SUBMISSION_COOLDOWN_MS - (Date.now() - previous);
3605
+ if (remaining > 0) {
3606
+ throw new Error(`${entry.platform} 同一篇文章刚刚已提交,请等待 ${Math.ceil(remaining / 1000)} 秒后再重试,避免触发平台风控或重复发布`);
3607
+ }
3608
+ return key;
3609
+ }
3610
+
3611
+ async function recordBrowserSubmission(key) {
3612
+ if (!key) return;
3613
+ const state = await readBrowserSubmissionState();
3614
+ state[key] = Date.now();
3615
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
3616
+ for (const [candidate, timestamp] of Object.entries(state)) {
3617
+ if (!Number.isFinite(Number(timestamp)) || Number(timestamp) < cutoff) delete state[candidate];
3618
+ }
3619
+ await fs.promises.mkdir(path.dirname(BROWSER_SUBMISSION_STATE_FILE), { recursive: true, mode: 0o700 });
3620
+ await fs.promises.writeFile(BROWSER_SUBMISSION_STATE_FILE, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
3621
+ await fs.promises.chmod(BROWSER_SUBMISSION_STATE_FILE, 0o600).catch(() => {});
3622
+ }
3623
+
3375
3624
  function isNavigationContextError(error) {
3376
3625
  const message = String(error?.message || error);
3377
3626
  return /execution context was destroyed|cannot find context with specified id|frame was detached|navigation.*interrupted/i.test(
@@ -3664,11 +3913,25 @@ async function waitForUser(message, streams = {}) {
3664
3913
  }
3665
3914
 
3666
3915
  function reviewApprovalAccepted(answer) {
3667
- return ["", "发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
3916
+ return ["发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
3668
3917
  }
3669
3918
 
3670
3919
  function reviewApprovalCancelled(answer) {
3671
- return ["取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
3920
+ return ["", "取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
3921
+ }
3922
+
3923
+ export function draftApprovalAction(answer) {
3924
+ const key = normalizeKey(answer);
3925
+ if (["发布", "上线", "正式发布", "publish", "online", "yes", "y"].includes(key)) {
3926
+ return "publish";
3927
+ }
3928
+ if (["保存草稿", "保存草稿箱", "保存到草稿箱", "存草稿", "保留草稿", "草稿", "keep-draft", "keepdraft", "draft"].includes(key)) {
3929
+ return "draft";
3930
+ }
3931
+ if (reviewApprovalCancelled(key)) {
3932
+ return "cancel";
3933
+ }
3934
+ return "";
3672
3935
  }
3673
3936
 
3674
3937
  async function waitForReviewApproval(message, options = {}) {
@@ -3678,7 +3941,7 @@ async function waitForReviewApproval(message, options = {}) {
3678
3941
  if (reviewApprovalCancelled(answer)) {
3679
3942
  throw new Error("用户已取消正式发布,草稿未提交");
3680
3943
  }
3681
- throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或直接回车`);
3944
+ throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或“取消”`);
3682
3945
  }
3683
3946
  const input = options.streams?.input || process.stdin;
3684
3947
  const output = options.streams?.output || process.stdout;
@@ -3690,13 +3953,13 @@ async function waitForReviewApproval(message, options = {}) {
3690
3953
  for (;;) {
3691
3954
  const answer = await question(
3692
3955
  rl,
3693
- `${message}\n输入“可以发布”或直接按 Enter 继续,输入“取消”停止: `
3956
+ `${message}\n输入“可以发布”继续;直接按 Enter 默认取消,输入“取消”停止: `
3694
3957
  );
3695
3958
  if (reviewApprovalAccepted(answer)) return;
3696
3959
  if (reviewApprovalCancelled(answer)) {
3697
3960
  throw new Error("用户已取消正式发布,草稿未提交");
3698
3961
  }
3699
- output.write("无法识别输入,请输入“可以发布”、直接按 Enter 或输入“取消”。\n");
3962
+ output.write("无法识别输入,请输入“可以发布”或“取消”(直接按 Enter 会取消)。\n");
3700
3963
  }
3701
3964
  } finally {
3702
3965
  rl.close();
@@ -3708,6 +3971,19 @@ async function waitForReviewApproval(message, options = {}) {
3708
3971
  * This is intentionally separate from publishWithBrowser so page structure and login
3709
3972
  * flows can be checked against the real site before running an article publish.
3710
3973
  */
3974
+ async function releaseAwaitingActionBrowser(page) {
3975
+ const context = typeof page?.context === "function" ? page.context() : null;
3976
+ if (typeof context?.__qcplayDetach !== "function") {
3977
+ return;
3978
+ }
3979
+ await context.__qcplayDetach();
3980
+ }
3981
+
3982
+ async function awaitingActionResult(page, result) {
3983
+ await releaseAwaitingActionBrowser(page);
3984
+ return result;
3985
+ }
3986
+
3711
3987
  export async function openPublishPage(entry, options = {}) {
3712
3988
  const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
3713
3989
  if (!spec || entry.publisher !== "browser") {
@@ -3718,13 +3994,21 @@ export async function openPublishPage(entry, options = {}) {
3718
3994
  throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
3719
3995
  }
3720
3996
 
3721
- const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
3997
+ const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
3722
3998
  const ownsContext = !options.context;
3723
3999
  try {
3724
4000
  const page = context.pages()[0] || (await context.newPage());
3725
4001
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
3726
4002
  await afterNavigation(page);
3727
4003
  options.onProgress?.(`已打开真实${entry.platform}发布页面: ${page.url()}`);
4004
+ if (options.awaitAIAction) {
4005
+ return awaitingActionResult(page, {
4006
+ url: page.url(),
4007
+ status: "awaiting_action",
4008
+ approvalRequired: true,
4009
+ approvalActions: ["publish", "save_draft", "cancel"]
4010
+ });
4011
+ }
3728
4012
  const prompt = options.waitForUser || (message => waitForUser(message, options.streams));
3729
4013
  const promptMessage = options.accountSwitch
3730
4014
  ? `${entry.platform}账号切换页面已打开。请在浏览器中完成账号切换,确认当前账号正确后按 Enter 继续后续发布流程`
@@ -3732,22 +4016,32 @@ export async function openPublishPage(entry, options = {}) {
3732
4016
  await prompt(promptMessage);
3733
4017
  return { url: page.url(), status: "opened" };
3734
4018
  } finally {
3735
- if (ownsContext) {
3736
- await context.close().catch(() => {});
4019
+ if (ownsContext && !options.awaitAIAction) {
4020
+ await closeBrowserContext(context);
3737
4021
  }
3738
4022
  }
3739
4023
  }
3740
4024
 
3741
4025
  async function reviewPreparedDraft(page, entry, options) {
3742
4026
  if (!options.reviewDraft) {
3743
- return;
4027
+ return null;
3744
4028
  }
3745
4029
  await page.waitForTimeout(500);
4030
+ if (options.awaitAIAction) {
4031
+ options.onProgress?.(`${entry.platform} 标题和正文已填入,正在等待 AI 对话授权`);
4032
+ return awaitingActionResult(page, {
4033
+ url: page.url(),
4034
+ status: "awaiting_action",
4035
+ approvalRequired: true,
4036
+ approvalActions: ["publish", "save_draft", "cancel"]
4037
+ });
4038
+ }
3746
4039
  await waitForReviewApproval(
3747
4040
  `${entry.platform} 待发布稿已准备,请在浏览器中预览标题、正文、图片和发布设置`,
3748
4041
  options
3749
4042
  );
3750
4043
  options.onProgress?.(`${entry.platform} 草稿预览已确认,正在正式发布`);
4044
+ return null;
3751
4045
  }
3752
4046
 
3753
4047
  async function editorIsReady(page, spec) {
@@ -4379,16 +4673,16 @@ async function uploadTapTapCover(page, source, articleFile) {
4379
4673
 
4380
4674
  async function publishTapTap(page, entry, article, spec, options, promptUser) {
4381
4675
  let preparedArticle = prepareTapTapArticleForProject(article, entry);
4382
- const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
4676
+ const settings = {
4677
+ ...resolveTapTapPublishingOptions(preparedArticle, entry),
4678
+ ...(options.draftOnly ? { draft: true, scheduled: "" } : {})
4679
+ };
4383
4680
  const titleLimit = settings.type === "moment" ? 20 : 30;
4384
- if (preparedArticle.title.length > titleLimit) {
4385
- if (normalizeText(entry.project) === "迷途之光" && settings.type === "moment") {
4386
- preparedArticle.title = Array.from(preparedArticle.title).slice(0, titleLimit).join("");
4387
- } else {
4388
- throw new Error(
4389
- `TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${preparedArticle.title.length} 个字符`
4390
- );
4391
- }
4681
+ preparedArticle = { ...preparedArticle, title: resolveTapTapTitle(preparedArticle, titleLimit) };
4682
+ if (Array.from(preparedArticle.title).length > titleLimit) {
4683
+ throw new Error(
4684
+ `TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${Array.from(preparedArticle.title).length} 个字符`
4685
+ );
4392
4686
  }
4393
4687
  const richContent = buildTapTapRichContent(preparedArticle);
4394
4688
  const body = richContent.plainText || plainTextForPlatform(preparedArticle, "taptap");
@@ -4413,7 +4707,14 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4413
4707
  if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
4414
4708
  throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
4415
4709
  }
4416
- await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
4710
+ const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
4711
+ if (options.publishDraft) {
4712
+ if (!resumedDraft) {
4713
+ throw new Error("未找到标题匹配的 TapTap 草稿,已停止以避免重新写入内容");
4714
+ }
4715
+ const confirmation = await submitTapTap(page, { ...settings, draft: false, scheduled: "" }, options, promptUser);
4716
+ return { url: page.url(), status: "published", type: settings.type, resumedDraft: true, ...confirmation };
4717
+ }
4417
4718
 
4418
4719
  if (settings.type === "moment") {
4419
4720
  const imageSources = settings.images.length
@@ -4450,9 +4751,54 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4450
4751
  await applyTapTapForum(page, settings.forum, promptUser, { project: entry.project });
4451
4752
  await applyTapTapSchedule(page, settings.scheduled, promptUser);
4452
4753
  if (!settings.draft) {
4453
- await reviewPreparedDraft(page, entry, options);
4754
+ const review = await reviewPreparedDraft(page, entry, options);
4755
+ if (review) {
4756
+ return { ...review, type: settings.type };
4757
+ }
4454
4758
  }
4455
4759
  const confirmation = await submitTapTap(page, settings, options, promptUser);
4760
+ if (options.draftOnly) {
4761
+ return {
4762
+ url: page.url(),
4763
+ status: "draft",
4764
+ type: settings.type,
4765
+ draftOnly: true,
4766
+ approvalRequired: true,
4767
+ approvalActions: ["publish", "cancel"],
4768
+ ...confirmation
4769
+ };
4770
+ }
4771
+ if (settings.draft && typeof options.waitForDraftApproval === "function") {
4772
+ const answer = await options.waitForDraftApproval(
4773
+ "TapTap 草稿已保存,页面保持打开。请选择“发布/上线”“保存草稿”或“取消”"
4774
+ );
4775
+ const action = draftApprovalAction(answer);
4776
+ if (action === "publish") {
4777
+ const publishConfirmation = await submitTapTap(
4778
+ page,
4779
+ { ...settings, draft: false },
4780
+ { ...options, reviewDraft: false, directPublish: true },
4781
+ promptUser
4782
+ );
4783
+ return {
4784
+ url: page.url(),
4785
+ status: "published",
4786
+ type: settings.type,
4787
+ draftConfirmation: confirmation.confirmation || "response",
4788
+ ...publishConfirmation
4789
+ };
4790
+ }
4791
+ if (!action) {
4792
+ throw new Error(`无法识别草稿授权“${normalizeText(answer)}”,请选择发布、保存草稿或取消`);
4793
+ }
4794
+ return {
4795
+ url: page.url(),
4796
+ status: "draft",
4797
+ type: settings.type,
4798
+ draftApproval: action,
4799
+ ...confirmation
4800
+ };
4801
+ }
4456
4802
  if (options.keepOpen) {
4457
4803
  await promptUser(
4458
4804
  `TapTap ${settings.draft ? "草稿保存" : settings.scheduled ? "定时发布" : "发布"}已收到成功回执,浏览器保持打开供检查`
@@ -4801,9 +5147,63 @@ async function submitBilibili(page, spec, settings, article, metadataApplied, op
4801
5147
  }
4802
5148
  }
4803
5149
 
5150
+ async function preparedBilibiliArticleState(page, spec, settings, article) {
5151
+ if (settings.type !== "article") return null;
5152
+ if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 1000))) return null;
5153
+ const titleInput = await waitForVisible(page, spec.title, 1000);
5154
+ const bodyInput = await waitForVisible(page, spec.body, 1000);
5155
+ if (!titleInput || !bodyInput) return null;
5156
+ const title = normalizeText(
5157
+ typeof titleInput.inputValue === "function"
5158
+ ? await titleInput.inputValue().catch(() => "")
5159
+ : typeof titleInput.innerText === "function"
5160
+ ? await titleInput.innerText().catch(() => "")
5161
+ : ""
5162
+ );
5163
+ if (title !== normalizeText(article.title)) return null;
5164
+
5165
+ const content = buildBilibiliRichContent(article);
5166
+ const state = await bodyInput
5167
+ .evaluate(element => ({
5168
+ text: element.innerText,
5169
+ images: element.querySelectorAll("img").length,
5170
+ failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
5171
+ links: [...element.querySelectorAll("a[href]")].map(link => link.href)
5172
+ }))
5173
+ .catch(() => null);
5174
+ if (!state || state.failedImages > 0 || state.images < content.images.length) return null;
5175
+ const actualText = normalizedPresenceText(state.text);
5176
+ if (content.items.some(item => item.type === "html" && item.plain && !actualText.includes(normalizedPresenceText(item.plain)))) {
5177
+ return null;
5178
+ }
5179
+ const actualLinks = new Set(state.links.map(safeTapTapHref));
5180
+ if (content.links.some(link => !actualLinks.has(link.href))) return null;
5181
+ return {
5182
+ richContent: { rich: true, images: state.images, links: content.links.length, plainText: content.plainText },
5183
+ metadataApplied: { category: true, tags: true, topic: true, cover: true }
5184
+ };
5185
+ }
5186
+
4804
5187
  async function publishBilibili(page, entry, article, spec, options, promptUser) {
4805
5188
  const settings = resolveBilibiliPublishingOptions(article, entry);
4806
5189
  const editorUrl = browserPlatformPageUrl(entry, settings.type);
5190
+ if (options.directPublish) {
5191
+ const prepared = await preparedBilibiliArticleState(page, spec, settings, article);
5192
+ if (!prepared) {
5193
+ throw new Error("B站待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
5194
+ }
5195
+ options.onProgress?.("已复用 B站 待发布内容,正在提交,不重复上传正文图片或封面");
5196
+ const confirmation = await submitBilibili(
5197
+ page,
5198
+ spec,
5199
+ settings,
5200
+ article,
5201
+ prepared.metadataApplied,
5202
+ options,
5203
+ promptUser
5204
+ );
5205
+ return { url: page.url(), status: "published", type: settings.type, ...prepared.richContent, ...confirmation };
5206
+ }
4807
5207
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
4808
5208
  await afterNavigation(page);
4809
5209
  if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings)))) {
@@ -4836,8 +5236,16 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
4836
5236
  await validateBilibiliFieldLength(bodyInput, body, settings.type === "video" ? "简介" : "正文");
4837
5237
  await fillLocator(titleInput, article.title);
4838
5238
  await verifyFilledValue(titleInput, article.title, "标题", "B站");
4839
- if (settings.type === "article") {
4840
- await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
5239
+ let richContent = null;
5240
+ if (options.previewTextOnly) {
5241
+ richContent = await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
5242
+ skipImages: settings.type === "article",
5243
+ previewOnly: true,
5244
+ onProgress: options.onProgress
5245
+ });
5246
+ } else if (settings.type === "article") {
5247
+ richContent = await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
5248
+ onProgress: options.onProgress,
4841
5249
  removeNoncompliant: normalizeText(entry.project) === "最强蜗牛"
4842
5250
  });
4843
5251
  } else {
@@ -4848,9 +5256,27 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
4848
5256
 
4849
5257
  const metadataApplied =
4850
5258
  settings.type === "article"
4851
- ? await applyBilibiliMetadata(page, spec, settings, article, true)
5259
+ ? options.previewTextOnly
5260
+ ? { category: false, tags: false, topic: false, cover: false, deferred: true }
5261
+ : await applyBilibiliMetadata(page, spec, settings, article, true)
4852
5262
  : { category: true, tags: true, topic: true, cover: true };
4853
- await reviewPreparedDraft(page, entry, options);
5263
+ if (!options.draftOnly) {
5264
+ const review = await reviewPreparedDraft(page, entry, options);
5265
+ if (review) {
5266
+ return { ...review, type: settings.type, metadataApplied, ...richContent };
5267
+ }
5268
+ }
5269
+ if (options.draftOnly) {
5270
+ return {
5271
+ url: page.url(),
5272
+ status: "draft",
5273
+ type: settings.type,
5274
+ draftOnly: true,
5275
+ approvalRequired: true,
5276
+ approvalActions: ["publish", "cancel"],
5277
+ metadataApplied
5278
+ };
5279
+ }
4854
5280
  const confirmation = await submitBilibili(page, spec, settings, article, metadataApplied, options, promptUser);
4855
5281
  if (options.keepOpen) {
4856
5282
  await promptUser(`B站${settings.typeLabel}投稿已收到成功回执,浏览器保持打开供检查`);
@@ -5147,6 +5573,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5147
5573
  } while (true);
5148
5574
 
5149
5575
  let uploadedPreview = previews.last();
5576
+ let candidateIndexes = [];
5150
5577
  if (previousPreviewKeys.length > 0 && typeof previews.evaluateAll === "function") {
5151
5578
  const previewKeys = await previews
5152
5579
  .evaluateAll(elements =>
@@ -5156,10 +5583,20 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5156
5583
  })
5157
5584
  )
5158
5585
  .catch(() => []);
5159
- const uploadedIndex = previewKeys.findIndex(key => key && !previousPreviewKeys.includes(key));
5160
- if (uploadedIndex >= 0) {
5161
- uploadedPreview = previews.nth(uploadedIndex);
5162
- }
5586
+ candidateIndexes = previewKeys
5587
+ .map((key, index) => (key && !previousPreviewKeys.includes(key) ? index : -1))
5588
+ .filter(index => index >= 0);
5589
+ }
5590
+ const previewCount = await previews.count().catch(() => 0);
5591
+ if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
5592
+ candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
5593
+ }
5594
+ if (candidateIndexes.length === 0 && previewCount > 0) {
5595
+ candidateIndexes = [previewCount - 1];
5596
+ }
5597
+ if (structuredLibrary && candidateIndexes.length > 0) {
5598
+ // Let the common click-and-verify path below perform the selection once.
5599
+ uploadedPreview = previews.nth(candidateIndexes[0]);
5163
5600
  }
5164
5601
  if ((await uploadedPreview.count()) > 0 && (await uploadedPreview.isVisible().catch(() => false))) {
5165
5602
  await uploadedPreview.click();
@@ -6064,8 +6501,8 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6064
6501
  if (!body) {
6065
6502
  throw new Error("微博文章正文不能为空");
6066
6503
  }
6067
- if (title.length > 32) {
6068
- throw new Error(`微博文章标题最多 32 个字符,当前为 ${title.length} 个字符`);
6504
+ if (Array.from(title).length > 32) {
6505
+ throw new Error(`微博文章标题最多 32 个字符,当前为 ${Array.from(title).length} 个字符`);
6069
6506
  }
6070
6507
 
6071
6508
  const editorUrl = weiboArticleEditorUrl(entry.url);
@@ -6109,7 +6546,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6109
6546
  throw new Error("微博文章“下一步”按钮不可用,请检查标题、正文和封面设置");
6110
6547
  }
6111
6548
 
6112
- await reviewPreparedDraft(page, entry, options);
6549
+ if (!options.draftOnly) {
6550
+ const review = await reviewPreparedDraft(page, entry, options);
6551
+ if (review) {
6552
+ return { ...review, type: "article", title, ...richContent, ...cover, column };
6553
+ }
6554
+ }
6555
+ if (options.draftOnly) {
6556
+ return {
6557
+ url: page.url(),
6558
+ status: "draft",
6559
+ type: "article",
6560
+ title,
6561
+ draftOnly: true,
6562
+ approvalRequired: true,
6563
+ approvalActions: ["publish", "cancel"],
6564
+ ...richContent,
6565
+ ...cover,
6566
+ column
6567
+ };
6568
+ }
6113
6569
  const initialUrl = page.url();
6114
6570
  progress("正在进入发布确认页");
6115
6571
  let submitButton = null;
@@ -6197,7 +6653,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6197
6653
  if (options.keepOpen) {
6198
6654
  await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
6199
6655
  }
6200
- return { url: page.url(), status: "published", type: "article", ...richContent, ...cover, column, ...confirmation };
6656
+ return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
6201
6657
  }
6202
6658
  throw new Error("微博发布确认页中未找到最终发布按钮");
6203
6659
  }
@@ -6238,7 +6694,10 @@ async function publishWeiboQuick(page, entry, article, options, promptUser) {
6238
6694
  await fillLocator(bodyInput, body);
6239
6695
  const submit = await waitForVisibleButton(page, [/^发送$/, /^发布$/], 5000);
6240
6696
  if (!submit) throw new Error("微博快捷发布页面中未找到发送按钮");
6241
- await reviewPreparedDraft(page, entry, options);
6697
+ const review = await reviewPreparedDraft(page, entry, options);
6698
+ if (review) {
6699
+ return { ...review, type: "quick" };
6700
+ }
6242
6701
  await submit.click();
6243
6702
  await page.waitForTimeout(1500);
6244
6703
  return { url: page.url(), status: "published", type: "quick" };
@@ -6248,8 +6707,56 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6248
6707
  const editorUrl = browserPlatformPageUrl(entry);
6249
6708
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
6250
6709
  await afterNavigation(page);
6251
- const publishing = resolveXiaohongshuPublishingOptions(article);
6252
6710
  const body = buildTapTapRichContent(article).plainText || plainTextForPlatform(article, "xiaohongshu");
6711
+ let publishing;
6712
+ try {
6713
+ publishing = resolveXiaohongshuPublishingOptions(article);
6714
+ } catch (error) {
6715
+ if (!options.previewTextOnly) throw error;
6716
+ publishing = {
6717
+ images: [],
6718
+ collection: "",
6719
+ imageSource: "unavailable",
6720
+ validationError: error.message || String(error)
6721
+ };
6722
+ }
6723
+
6724
+ // Kept for callers that deliberately ask for a text-only editor inspection.
6725
+ // Standard AI authorisation never uses this path: it waits for all images and
6726
+ // the editor readback before offering an action.
6727
+ if (options.previewTextOnly) {
6728
+ const titleInput = await waitForVisible(page, spec.title, 1000);
6729
+ const bodyInput = await waitForVisible(page, spec.body, 1000);
6730
+ const titleFits = Array.from(article.title).length <= 20;
6731
+ const bodyFits = Array.from(body).length <= 1000;
6732
+ if (titleInput && bodyInput && titleFits && bodyFits) {
6733
+ await fillLocator(titleInput, article.title);
6734
+ await fillLocator(bodyInput, body);
6735
+ options.onProgress?.("小红书 标题和正文已填入,正在等待 AI 对话授权");
6736
+ return awaitingActionResult(page, {
6737
+ url: page.url(),
6738
+ status: "awaiting_action",
6739
+ approvalRequired: true,
6740
+ approvalActions: ["publish", "save_draft", "cancel"],
6741
+ contentPrepared: true,
6742
+ imagesPending: publishing.images.length,
6743
+ type: "note",
6744
+ ...publishing
6745
+ });
6746
+ }
6747
+ options.onProgress?.("小红书需要先上传图片才能进入笔记编辑器;页面已打开,正在等待 AI 对话授权");
6748
+ return awaitingActionResult(page, {
6749
+ url: page.url(),
6750
+ status: "awaiting_action",
6751
+ approvalRequired: true,
6752
+ approvalActions: ["publish", "save_draft", "cancel"],
6753
+ contentPrepared: false,
6754
+ requiresMediaBeforeText: true,
6755
+ imagesPending: publishing.images.length,
6756
+ type: "note",
6757
+ ...publishing
6758
+ });
6759
+ }
6253
6760
  if (Array.from(body).length > 1000) {
6254
6761
  throw new Error(`小红书正文最多 1000 个字符,当前为 ${Array.from(body).length} 个字符;请先精简内容后再发布`);
6255
6762
  }
@@ -6277,12 +6784,20 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6277
6784
  }
6278
6785
  options.onProgress?.(`正在上传小红书图片 ${payloads.length} 张`);
6279
6786
  await imageInput.setInputFiles(payloads);
6280
- await waitForUploadsSettled(page, "小红书图片", 120000);
6787
+ await waitForUploadsSettled(page, "小红书图片", 120000).catch(error => {
6788
+ // The creator can replace the uploader with a new note-editor tab as soon
6789
+ // as the cover is accepted. The editor lookup below owns that transition.
6790
+ if (!/Target page, context or browser has been closed/i.test(String(error?.message || error))) {
6791
+ throw error;
6792
+ }
6793
+ });
6281
6794
  options.onProgress?.("图片上传完成,正在填写笔记内容");
6282
6795
 
6283
- if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
6796
+ const editorPage = await waitForXiaohongshuEditorPage(page, spec, 120000);
6797
+ if (!editorPage) {
6284
6798
  throw new Error("小红书图片上传完成后未进入笔记编辑器,请检查图片格式或账号发布权限");
6285
6799
  }
6800
+ page = editorPage;
6286
6801
  const titleInput = await waitForVisible(page, spec.title, 8000);
6287
6802
  const bodyInput = await waitForVisible(page, spec.body, 8000);
6288
6803
  if (!titleInput || !bodyInput) throw new Error("小红书发布编辑器未找到标题或正文控件");
@@ -6316,11 +6831,25 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6316
6831
  if (typeof submit.isDisabled === "function" && await submit.isDisabled().catch(() => false)) {
6317
6832
  throw new Error("小红书发布按钮不可用,请检查正文和图片上传状态");
6318
6833
  }
6834
+ if (options.draftOnly) {
6835
+ return {
6836
+ url: page.url(),
6837
+ status: "draft",
6838
+ draftOnly: true,
6839
+ approvalRequired: true,
6840
+ approvalActions: ["publish", "cancel"],
6841
+ type: "note",
6842
+ ...publishing
6843
+ };
6844
+ }
6319
6845
  if (options.keepOpen && !isInteractiveTerminal(options.streams)) {
6320
6846
  options.onProgress?.("小红书待发布内容已准备完成,请在浏览器中核对后手动点击“发布”;程序将等待并验证发布结果");
6321
6847
  return waitForManualXiaohongshuSubmission(page, 600000);
6322
6848
  }
6323
- await reviewPreparedDraft(page, entry, options);
6849
+ const review = await reviewPreparedDraft(page, entry, options);
6850
+ if (review) {
6851
+ return { ...review, type: "note", ...publishing };
6852
+ }
6324
6853
  const initialUrl = page.url();
6325
6854
  const mutationPromise =
6326
6855
  typeof page.waitForResponse === "function"
@@ -6331,6 +6860,32 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6331
6860
  return { url: page.url(), status: "published", type: "note", ...publishing, ...confirmation };
6332
6861
  }
6333
6862
 
6863
+ async function waitForXiaohongshuEditorPage(initialPage, spec, timeoutMs) {
6864
+ const context = typeof initialPage?.context === "function" ? initialPage.context() : null;
6865
+ const deadline = Date.now() + timeoutMs;
6866
+ do {
6867
+ const candidates = [initialPage, ...(typeof context?.pages === "function" ? context.pages() : [])]
6868
+ .filter((candidate, index, pages) => candidate && pages.indexOf(candidate) === index)
6869
+ .filter(candidate => !(typeof candidate.isClosed === "function" && candidate.isClosed()));
6870
+ for (const candidate of candidates) {
6871
+ try {
6872
+ if (await editorIsReady(candidate, spec)) return candidate;
6873
+ } catch (error) {
6874
+ if (!/Target page, context or browser has been closed/i.test(String(error?.message || error))) {
6875
+ throw error;
6876
+ }
6877
+ }
6878
+ }
6879
+ const waitPage = candidates[0];
6880
+ if (waitPage?.waitForTimeout) {
6881
+ await waitPage.waitForTimeout(300).catch(() => {});
6882
+ } else {
6883
+ await new Promise(resolve => setTimeout(resolve, 300));
6884
+ }
6885
+ } while (Date.now() < deadline);
6886
+ return null;
6887
+ }
6888
+
6334
6889
  function isInteractiveTerminal(streams = {}) {
6335
6890
  const input = streams.input || process.stdin;
6336
6891
  const output = streams.output || process.stdout;
@@ -6367,11 +6922,64 @@ async function waitForManualXiaohongshuSubmission(page, timeoutMs) {
6367
6922
  throw new Error("小红书待发布页面等待超时,未检测到手动发布成功回执");
6368
6923
  }
6369
6924
 
6370
- function xiaohongshuMutationResponse(response) {
6925
+ export function xiaohongshuMutationResponse(response) {
6371
6926
  const request = response.request();
6372
6927
  if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) return false;
6373
6928
  const url = response.url();
6374
- return /xiaohongshu\.com/i.test(url) && /(?:publish|note|feed|create|upload)/i.test(url);
6929
+ if (!/xiaohongshu\.com/i.test(url)) return false;
6930
+ // Media transfers can still finish after the user presses Publish. They are
6931
+ // not proof that a note was created and must never satisfy this wait.
6932
+ if (/(?:^|[./-])(?:ros-)?upload(?:[./-]|$)/i.test(url)) return false;
6933
+ return /(?:publish|note|feed|create)/i.test(url);
6934
+ }
6935
+
6936
+ export function xiaohongshuPublishedReceipt(payload = {}) {
6937
+ const data = payload?.data || {};
6938
+ const note = data?.note || payload?.note || {};
6939
+ const publishedUrl = [
6940
+ payload?.published_url,
6941
+ payload?.publishedUrl,
6942
+ payload?.note_url,
6943
+ payload?.noteUrl,
6944
+ payload?.share_url,
6945
+ payload?.shareUrl,
6946
+ data?.published_url,
6947
+ data?.publishedUrl,
6948
+ data?.note_url,
6949
+ data?.noteUrl,
6950
+ data?.share_url,
6951
+ data?.shareUrl,
6952
+ note?.url,
6953
+ note?.share_url,
6954
+ note?.shareUrl
6955
+ ]
6956
+ .map(normalizeText)
6957
+ .find(value => {
6958
+ try {
6959
+ const url = new URL(value);
6960
+ return /(?:^|\.)xiaohongshu\.com$/i.test(url.hostname) && !/^creator\./i.test(url.hostname);
6961
+ } catch {
6962
+ return false;
6963
+ }
6964
+ }) || "";
6965
+ const noteId = [
6966
+ payload?.note_id,
6967
+ payload?.noteId,
6968
+ payload?.id,
6969
+ data?.note_id,
6970
+ data?.noteId,
6971
+ data?.id,
6972
+ note?.note_id,
6973
+ note?.noteId,
6974
+ note?.id
6975
+ ]
6976
+ .map(normalizeText)
6977
+ .find(Boolean) || "";
6978
+ if (!publishedUrl && !noteId) return null;
6979
+ return {
6980
+ noteId,
6981
+ publishedUrl: publishedUrl || `https://www.xiaohongshu.com/explore/${encodeURIComponent(noteId)}`
6982
+ };
6375
6983
  }
6376
6984
 
6377
6985
  async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, options) {
@@ -6382,7 +6990,11 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
6382
6990
  if (response.status() >= 400 || payload?.success === false || Number(payload?.code) > 0) {
6383
6991
  throw new Error(`小红书发布失败: ${normalizeText(payload?.msg || payload?.message || `接口状态 ${response.status()}`)}`);
6384
6992
  }
6385
- return { confirmation: "response", responseUrl: response.url() };
6993
+ const receipt = xiaohongshuPublishedReceipt(payload);
6994
+ if (!receipt) {
6995
+ throw new Error("小红书发布结果未返回笔记 ID 或公开笔记链接,无法确认发布记录");
6996
+ }
6997
+ return { confirmation: "response", responseUrl: response.url(), ...receipt };
6386
6998
  }
6387
6999
  const deadline = Date.now() + 15000;
6388
7000
  do {
@@ -6390,19 +7002,229 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
6390
7002
  if (/发布失败|上传失败|内容违规|请重试/.test(pageText)) {
6391
7003
  throw new Error(`小红书发布失败: ${normalizeText(pageText.match(/(?:发布失败|上传失败|内容违规)[^\n]*/)?.[0] || "页面返回失败提示")}`);
6392
7004
  }
6393
- if (/发布成功|笔记发布成功|发布完成/.test(pageText) || page.url() !== initialUrl) {
6394
- return { confirmation: "page" };
7005
+ if (/发布成功|笔记发布成功|发布完成/.test(pageText)) {
7006
+ throw new Error("小红书页面提示发布完成,但未返回笔记 ID 或公开笔记链接,无法确认发布记录");
6395
7007
  }
6396
7008
  await page.waitForTimeout(500);
6397
7009
  } while (Date.now() < deadline);
6398
7010
  throw new Error("小红书发布后未检测到成功回执");
6399
7011
  }
6400
7012
 
6401
- async function launchBrowserContext(entry, browserChannel) {
7013
+ async function findAvailableLoopbackPort() {
7014
+ return new Promise((resolve, reject) => {
7015
+ const server = http.createServer();
7016
+ server.once("error", reject);
7017
+ server.listen(0, "127.0.0.1", () => {
7018
+ const address = server.address();
7019
+ server.close(error => (error ? reject(error) : resolve(address.port)));
7020
+ });
7021
+ });
7022
+ }
7023
+
7024
+ async function readCdpVersion(port) {
7025
+ return new Promise((resolve, reject) => {
7026
+ const request = http.get(
7027
+ { host: "127.0.0.1", port, path: "/json/version", timeout: 1500 },
7028
+ response => {
7029
+ let payload = "";
7030
+ response.setEncoding("utf8");
7031
+ response.on("data", chunk => {
7032
+ payload += chunk;
7033
+ });
7034
+ response.on("end", () => {
7035
+ try {
7036
+ const parsed = JSON.parse(payload);
7037
+ if (parsed.webSocketDebuggerUrl) {
7038
+ resolve(parsed.webSocketDebuggerUrl);
7039
+ return;
7040
+ }
7041
+ reject(new Error("调试端点未返回 WebSocket 地址"));
7042
+ } catch (error) {
7043
+ reject(error);
7044
+ }
7045
+ });
7046
+ }
7047
+ );
7048
+ request.once("error", reject);
7049
+ request.once("timeout", () => request.destroy(new Error("调试端点请求超时")));
7050
+ });
7051
+ }
7052
+
7053
+ async function waitForCdpEndpoint(port, timeoutMs = 15000) {
7054
+ const deadline = Date.now() + timeoutMs;
7055
+ let lastError;
7056
+ do {
7057
+ try {
7058
+ return await readCdpVersion(port);
7059
+ } catch (error) {
7060
+ lastError = error;
7061
+ await new Promise(resolve => setTimeout(resolve, 150));
7062
+ }
7063
+ } while (Date.now() < deadline);
7064
+ throw new Error(`浏览器调试会话启动超时:${lastError?.message || "未返回调试端点"}`);
7065
+ }
7066
+
7067
+ async function resolveDetachedBrowserExecutable(channel) {
7068
+ if (process.platform !== "win32") {
7069
+ return "";
7070
+ }
7071
+ const normalizedChannel = normalizeKey(channel || "msedge");
7072
+ const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
7073
+ const product = /chrome/.test(normalizedChannel) ? ["Google", "Chrome", "Application", "chrome.exe"] : ["Microsoft", "Edge", "Application", "msedge.exe"];
7074
+ const candidates = [process.env.QCPLAY_BROWSER_EXECUTABLE, ...programFiles.map(root => path.join(root, ...product))].filter(Boolean);
7075
+ for (const candidate of candidates) {
7076
+ if (await fs.promises.access(candidate, fs.constants.X_OK).then(() => true).catch(() => false)) {
7077
+ return candidate;
7078
+ }
7079
+ }
7080
+ return "";
7081
+ }
7082
+
7083
+ async function launchDetachedBrowserContext(profileDir, channel) {
7084
+ const executable = await resolveDetachedBrowserExecutable(channel);
7085
+ if (!executable) {
7086
+ throw new Error("未找到可分离的 Edge/Chrome 浏览器,无法在保留编辑页的同时返回 AI 授权事件");
7087
+ }
7088
+ let lastError;
7089
+ for (let attempt = 0; attempt < 3; attempt += 1) {
7090
+ const port = await findAvailableLoopbackPort();
7091
+ const child = spawn(
7092
+ executable,
7093
+ [
7094
+ "--no-first-run",
7095
+ "--no-default-browser-check",
7096
+ "--remote-debugging-address=127.0.0.1",
7097
+ "--remote-allow-origins=http://localhost,http://127.0.0.1",
7098
+ `--remote-debugging-port=${port}`,
7099
+ `--user-data-dir=${profileDir}`
7100
+ ],
7101
+ { detached: true, stdio: "ignore", windowsHide: true }
7102
+ );
7103
+ child.unref();
7104
+ try {
7105
+ const endpoint = await waitForCdpEndpoint(port);
7106
+ const { chromium } = await import("playwright-core");
7107
+ const browser = await chromium.connectOverCDP(endpoint);
7108
+ const context = browser.contexts()[0];
7109
+ if (!context) {
7110
+ await browser.close().catch(() => {});
7111
+ throw new Error("浏览器调试会话未返回默认上下文");
7112
+ }
7113
+ await saveDetachedBrowserSession(profileDir, endpoint);
7114
+ attachDetachedBrowserContext(context, browser, profileDir);
7115
+ return context;
7116
+ } catch (error) {
7117
+ lastError = error;
7118
+ child.kill();
7119
+ if (attempt < 2) {
7120
+ await new Promise(resolve => setTimeout(resolve, 300));
7121
+ }
7122
+ }
7123
+ }
7124
+ throw lastError;
7125
+ }
7126
+
7127
+ function detachedBrowserSessionFile(profileDir) {
7128
+ return path.join(profileDir, ".qcplay-detached-session.json");
7129
+ }
7130
+
7131
+ async function saveDetachedBrowserSession(profileDir, endpoint) {
7132
+ const sessionFile = detachedBrowserSessionFile(profileDir);
7133
+ await fs.promises.writeFile(
7134
+ sessionFile,
7135
+ `${JSON.stringify({ endpoint, createdAt: new Date().toISOString() })}\n`,
7136
+ { encoding: "utf8", mode: 0o600 }
7137
+ );
7138
+ }
7139
+
7140
+ async function loadDetachedBrowserSession(profileDir) {
7141
+ const sessionFile = detachedBrowserSessionFile(profileDir);
7142
+ try {
7143
+ const session = JSON.parse(await fs.promises.readFile(sessionFile, "utf8"));
7144
+ if (
7145
+ typeof session?.endpoint !== "string" ||
7146
+ !/^(?:http|ws):\/\/127\.0\.0\.1:\d+(?:\/devtools\/browser\/[^/]+)?\/?$/i.test(session.endpoint)
7147
+ ) {
7148
+ throw new Error("无效的调试会话地址");
7149
+ }
7150
+ return { ...session, sessionFile };
7151
+ } catch {
7152
+ return null;
7153
+ }
7154
+ }
7155
+
7156
+ async function removeDetachedBrowserSession(profileDir) {
7157
+ await fs.promises.unlink(detachedBrowserSessionFile(profileDir)).catch(() => {});
7158
+ }
7159
+
7160
+ function attachDetachedBrowserContext(context, browser, profileDir) {
7161
+ Object.defineProperty(context, "__qcplayDetach", {
7162
+ // `browser.close()` sends a Browser.close command over CDP. A detached
7163
+ // session is owned by the user-facing Edge process, so only close the
7164
+ // Playwright transport and leave that editor window intact.
7165
+ value: async () => {
7166
+ if (typeof browser?._connection?.close === "function") {
7167
+ browser._connection.close();
7168
+ return;
7169
+ }
7170
+ // Older Playwright builds do not expose a public disconnect method.
7171
+ // Do not fall back to browser.close(), which would close the editor.
7172
+ if (typeof browser?.disconnect === "function") {
7173
+ await browser.disconnect();
7174
+ }
7175
+ },
7176
+ enumerable: false
7177
+ });
7178
+ Object.defineProperty(context, "__qcplayDetachedProfileDir", {
7179
+ value: profileDir,
7180
+ enumerable: false
7181
+ });
7182
+ return context;
7183
+ }
7184
+
7185
+ async function reconnectDetachedBrowserContext(profileDir, chromium) {
7186
+ const savedSession = await loadDetachedBrowserSession(profileDir);
7187
+ const endpoint = normalizeText(process.env.QCPLAY_BROWSER_CDP_ENDPOINT) || savedSession?.endpoint;
7188
+ if (!endpoint || !/^(?:http|ws):\/\/127\.0\.0\.1:\d+(?:\/devtools\/browser\/[^/]+)?\/?$/i.test(endpoint)) {
7189
+ return null;
7190
+ }
7191
+ try {
7192
+ const browser = await chromium.connectOverCDP(endpoint);
7193
+ const context = browser.contexts()[0];
7194
+ if (!context) {
7195
+ await browser.close().catch(() => {});
7196
+ throw new Error("浏览器调试会话未返回默认上下文");
7197
+ }
7198
+ return attachDetachedBrowserContext(context, browser, profileDir);
7199
+ } catch {
7200
+ if (savedSession) {
7201
+ await removeDetachedBrowserSession(profileDir);
7202
+ }
7203
+ return null;
7204
+ }
7205
+ }
7206
+
7207
+ async function closeBrowserContext(context) {
7208
+ if (typeof context?.__qcplayDetach === "function") {
7209
+ await context.__qcplayDetach().catch(() => {});
7210
+ return;
7211
+ }
7212
+ await context?.close?.().catch(() => {});
7213
+ }
7214
+
7215
+ async function launchBrowserContext(entry, browserChannel, options = {}) {
6402
7216
  const { chromium } = await import("playwright-core");
6403
- const profileDir = path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
7217
+ const profileDir = browserProfileDir(entry);
6404
7218
  await fs.promises.mkdir(profileDir, { recursive: true, mode: 0o700 });
6405
7219
  const channel = browserChannel || process.env.QCPLAY_BROWSER_CHANNEL || (process.platform === "win32" ? "msedge" : "chrome");
7220
+ if (options.awaitAIAction) {
7221
+ return launchDetachedBrowserContext(profileDir, channel);
7222
+ }
7223
+ const detachedContext = await reconnectDetachedBrowserContext(profileDir, chromium);
7224
+ if (detachedContext) {
7225
+ options.onProgress?.(`已复用当前 ${entry.platform} 授权编辑页,继续执行已确认的动作`);
7226
+ return detachedContext;
7227
+ }
6406
7228
  return chromium.launchPersistentContext(profileDir, {
6407
7229
  channel,
6408
7230
  headless: false,
@@ -6559,7 +7381,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
6559
7381
  }
6560
7382
 
6561
7383
  const contentPolicy = resolvePlatformContentRequirements(entry);
6562
- if (contentPolicy.optionalSectionReview && !options.reviewDraft && !options.directPublish) {
7384
+ if (contentPolicy.optionalSectionReview && !options.reviewDraft && !options.directPublish && !options.draftOnly) {
6563
7385
  options = { ...options, reviewDraft: true };
6564
7386
  options.onProgress?.(
6565
7387
  `${entry.platform} 的飞书特殊要求包含可选删除板块,已切换为发布前人工预览确认`
@@ -6574,7 +7396,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
6574
7396
 
6575
7397
  const body =
6576
7398
  entry.platformKey === "haoyou"
6577
- ? buildHaoyouRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
7399
+ ? buildHaoyouPublishRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
6578
7400
  : plainTextForPlatform(preparedArticle, entry.platformKey);
6579
7401
  if (entry.platformKey === "x" && body.length > 280) {
6580
7402
  throw new Error(`X 内容共 ${body.length} 个字符,超过 280 字符限制`);
@@ -6584,11 +7406,12 @@ export async function publishWithBrowser(entry, article, options = {}) {
6584
7406
  }
6585
7407
 
6586
7408
  const ownsContext = !options.context;
6587
- const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
7409
+ const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
6588
7410
  const pages = context.pages();
6589
7411
  const page = pages[0] || (await context.newPage());
6590
7412
  const promptUser = options.waitForUser || (message => waitForUser(message, options.streams));
6591
7413
  let haoyouEditorOpened = false;
7414
+ let preserveEditorOnFailure = false;
6592
7415
  try {
6593
7416
  if (entry.platformKey === "taptap") {
6594
7417
  return await publishTapTap(page, entry, preparedArticle, spec, options, promptUser);
@@ -6654,7 +7477,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
6654
7477
  }
6655
7478
  let richContent = null;
6656
7479
  if (entry.platformKey === "haoyou") {
6657
- richContent = await insertHaoyouRichContent(page, bodyInput, preparedArticle, body);
7480
+ if (options.previewTextOnly) {
7481
+ await fillStableHaoyouValue(page, bodyInput, body, "正文");
7482
+ richContent = {
7483
+ rich: false,
7484
+ images: 0,
7485
+ imagesPending: buildHaoyouPublishRichContent(preparedArticle).images.length,
7486
+ links: 0,
7487
+ plainText: body
7488
+ };
7489
+ } else {
7490
+ richContent = await insertHaoyouRichContent(page, bodyInput, preparedArticle, body);
7491
+ }
6658
7492
  } else {
6659
7493
  await fillLocator(bodyInput, body);
6660
7494
  }
@@ -6665,10 +7499,22 @@ export async function publishWithBrowser(entry, article, options = {}) {
6665
7499
  if (entry.platformKey === "haoyou" && (await firstVisible(page, [".editSubmit"]))) {
6666
7500
  throw new Error("好游快爆标题和正文已写入,但发布按钮仍不可用,请检查发布设置");
6667
7501
  }
6668
- await promptUser(`${entry.platform} 页面中未找到发布按钮,正文已填入,请检查并手动发布`);
6669
- return { url: page.url(), manualSubmit: true };
7502
+ throw new Error(`${entry.platform} 页面中未找到发布按钮,正文已填入但尚未满足提交条件`);
7503
+ }
7504
+ const review = await reviewPreparedDraft(page, entry, options);
7505
+ if (review) {
7506
+ return { ...review, ...richContent };
7507
+ }
7508
+ if (options.draftOnly) {
7509
+ return {
7510
+ url: page.url(),
7511
+ status: "draft",
7512
+ draftOnly: true,
7513
+ approvalRequired: true,
7514
+ approvalActions: ["publish", "cancel"],
7515
+ ...richContent
7516
+ };
6670
7517
  }
6671
- await reviewPreparedDraft(page, entry, options);
6672
7518
  if (entry.platformKey === "haoyou") {
6673
7519
  const initialUrl = page.url();
6674
7520
  const mutationPromise =
@@ -6689,12 +7535,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
6689
7535
  }
6690
7536
  return { url: page.url() };
6691
7537
  } catch (error) {
6692
- if (entry.platformKey === "haoyou" && ownsContext && haoyouEditorOpened) {
6693
- await promptUser(`${error.message},浏览器将保持打开供检查`).catch(() => {});
7538
+ if ((entry.platformKey === "haoyou" && haoyouEditorOpened) || entry.platformKey === "xiaohongshu") {
7539
+ if (!ownsContext) throw error;
7540
+ preserveEditorOnFailure = true;
7541
+ options.onProgress?.(`${error.message};已保留${entry.platform}编辑页供检查`);
6694
7542
  }
6695
7543
  throw error;
6696
7544
  } finally {
6697
- await context.close().catch(() => {});
7545
+ // A draft is deliberately left visible for the current AI conversation.
7546
+ // Closing the persistent context here also closes the platform's editor page.
7547
+ if (ownsContext && !options.draftOnly && !options.keepOpen && !options.awaitAIAction && !preserveEditorOnFailure) {
7548
+ await closeBrowserContext(context);
7549
+ }
6698
7550
  }
6699
7551
  }
6700
7552
 
@@ -6703,7 +7555,13 @@ export async function publishPlatformEntry(entry, article, options = {}) {
6703
7555
  return publishDiscord(entry, article, options);
6704
7556
  }
6705
7557
  if (entry.publisher === "browser") {
6706
- return publishWithBrowser(entry, article, options);
7558
+ const submissionKey = await ensureBrowserSubmissionCooldown(entry, article, options);
7559
+ const result = await publishWithBrowser(entry, article, options);
7560
+ if (result?.status === "published") {
7561
+ await recordBrowserSubmission(submissionKey);
7562
+ await removeDetachedBrowserSession(browserProfileDir(entry));
7563
+ }
7564
+ return result;
6707
7565
  }
6708
7566
  if (entry.publisher === "blocked") {
6709
7567
  throw new Error("Reddit 配置明确提示非官方工具可能导致封号;未配置 Reddit 官方应用,因此拒绝自动发布");
@@ -6714,6 +7572,46 @@ export async function publishPlatformEntry(entry, article, options = {}) {
6714
7572
  throw new Error(`${entry.platform} 需要由官网发布流程处理`);
6715
7573
  }
6716
7574
 
7575
+ /**
7576
+ * Persist non-official platform addresses next to the article as a small,
7577
+ * reviewable project artifact. Credentials and configuration URLs are never
7578
+ * copied; only addresses returned by a completed platform workflow are kept.
7579
+ */
7580
+ export async function writePlatformPublishUrls(articleFile, successes) {
7581
+ const records = successes
7582
+ .filter(({ entry }) => entry?.platformKey && entry.platformKey !== "website")
7583
+ .map(({ entry, result }) => {
7584
+ const url = normalizeText(
7585
+ result?.status === "draft" ? result?.url : result?.publishedUrl || result?.responseUrl || result?.url
7586
+ );
7587
+ let isInternalApi = /\/webapi(?:v\d+)?\//i.test(url);
7588
+ try {
7589
+ const parsedUrl = new URL(url);
7590
+ isInternalApi ||= parsedUrl.searchParams.get("m") === "api" || /\/api(?:\/|$)/i.test(parsedUrl.pathname);
7591
+ } catch {
7592
+ // Non-URL values are omitted by the subsequent truthy URL filter.
7593
+ }
7594
+ return {
7595
+ platform: entry.platform,
7596
+ platformKey: entry.platformKey,
7597
+ region: entry.region,
7598
+ status: result?.status || "completed",
7599
+ // A draft must point back to the editor. Never persist a mutation API
7600
+ // endpoint as though it were a public article address.
7601
+ url: isInternalApi ? "" : url
7602
+ };
7603
+ })
7604
+ .filter(record => record.url);
7605
+ if (!records.length) return "";
7606
+ const target = `${path.resolve(articleFile)}.platform-urls.json`;
7607
+ await fs.promises.writeFile(
7608
+ target,
7609
+ `${JSON.stringify({ version: 1, updated_at: new Date().toISOString(), platforms: records }, null, 2)}\n`,
7610
+ "utf8"
7611
+ );
7612
+ return target;
7613
+ }
7614
+
6717
7615
  export async function runPlatformPublishSequence(entries, publishOne) {
6718
7616
  const successes = [];
6719
7617
  const failures = [];