@qcplay/cli 1.0.17 → 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站删除官充章节",
@@ -172,7 +175,6 @@ const BROWSER_PLATFORM_SPECS = {
172
175
  password: ['input[name="password"]', 'input[type="password"]'],
173
176
  loginButtons: [/登录/],
174
177
  title: ['textarea[placeholder="请输入标题"]', 'textarea[placeholder*="请输入标题"]'],
175
- intro: ['textarea[placeholder*="导语"]'],
176
178
  body: ['.tiptap.ProseMirror[contenteditable="true"]', '.wb-editor.ProseMirror[contenteditable="true"]'],
177
179
  imageInputs: ['input[type="file"][accept*=".jpg"]', 'input[type="file"][accept*="image"]'],
178
180
  next: [/^下一步$/],
@@ -1528,6 +1530,26 @@ export function resolveTapTapPublishingOptions(article, entry = {}) {
1528
1530
  };
1529
1531
  }
1530
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
+
1531
1553
  export function tapTapPublishingPlan(article, entry = {}) {
1532
1554
  const preparedArticle = prepareTapTapArticleForProject(article, entry);
1533
1555
  const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
@@ -1560,22 +1582,32 @@ function weiboMeta(article, ...keys) {
1560
1582
  return "";
1561
1583
  }
1562
1584
 
1585
+ export function resolveWeiboArticleTitle(article) {
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("")}…`;
1606
+ }
1607
+
1563
1608
  export function resolveWeiboSuperTopic(article, entry = {}) {
1564
- const requirements = normalizeText(entry.requirements);
1565
- const markedTopic = requirements.match(/([@#])([^@#\s,。;;]{1,40}超话)(#?)/);
1566
- const listedTopic = requirements.match(/加上超话\s*[::]?\s*(?:\r?\n)+\s*([^\r\n,。;;]{1,40})/)?.[1] || "";
1567
- const project = normalizeText(entry.project);
1568
- const defaultTopic =
1569
- project === "最强蜗牛"
1570
- ? "#最强蜗牛超话#"
1571
- : project === "提灯与地下城"
1572
- ? "#提灯与地下城超话#"
1573
- : project.startsWith("魔卡少女樱")
1574
- ? ""
1575
- : project;
1576
- const requested =
1577
- weiboMeta(article, "weibo_super_topic", "weibo_topic") ||
1578
- (project.startsWith("魔卡少女樱") ? markedTopic?.[0] || listedTopic : defaultTopic || markedTopic?.[0] || listedTopic);
1609
+ const requested = weiboMeta(article, "weibo_super_topic", "weibo_topic");
1610
+ if (!requested) return "";
1579
1611
  const marker = requested.startsWith("#") ? "#" : "@";
1580
1612
  const closeHash = marker === "#" && requested.endsWith("#") ? "#" : "";
1581
1613
  const topic = normalizeText(requested)
@@ -1586,30 +1618,19 @@ export function resolveWeiboSuperTopic(article, entry = {}) {
1586
1618
  return topic ? `${marker}${topic}超话${closeHash}` : "";
1587
1619
  }
1588
1620
 
1589
- function weiboProjectCopy(article, entry) {
1590
- const title = normalizeText(article.title || article.payload?.article_title);
1621
+ export function resolveWeiboShareCopy(article, entry = {}) {
1622
+ const title = resolveWeiboArticleTitle(article);
1591
1623
  const project = normalizeText(entry.project);
1592
- const superTopic = resolveWeiboSuperTopic(article, entry);
1593
- if (!title) return null;
1594
1624
  if (project === "最强蜗牛") {
1595
- return {
1596
- prefix: [title, superTopic, "性感春姬,在线播报", "大家好,我是你们的小姬!", "本期推文为大家带来"],
1597
- suffix: []
1598
- };
1625
+ return [title, "#最强蜗牛超话#", "性感春姬,在线播报", "大家好,我是你们的小姬!"].filter(Boolean).join("\n");
1599
1626
  }
1600
1627
  if (project === "提灯与地下城") {
1601
- return {
1602
- prefix: [title, superTopic, "大噶好我是五郎!", "一起来看看本次的更新资讯吧~"],
1603
- suffix: []
1604
- };
1628
+ return [title, "#提灯与地下城超话#", "大噶好~俺是五郎!", "一起来看看本次的更新资讯吧~"].filter(Boolean).join("\n");
1605
1629
  }
1606
1630
  if (project.startsWith("魔卡少女樱")) {
1607
- return {
1608
- prefix: [title],
1609
- suffix: ["#魔卡少女樱#", "#魔卡少女樱回忆钥匙#"]
1610
- };
1631
+ return "#魔卡少女樱回忆钥匙#";
1611
1632
  }
1612
- return null;
1633
+ return "";
1613
1634
  }
1614
1635
 
1615
1636
  export function prepareWeiboArticleForProject(article, entry = {}) {
@@ -1623,49 +1644,7 @@ export function prepareWeiboArticleForProject(article, entry = {}) {
1623
1644
  markdown: `${String(article.markdown || "").trim()}\n\n![](${requestedCover})`.trim()
1624
1645
  }
1625
1646
  : article;
1626
- const copy = weiboProjectCopy(articleWithCover, entry);
1627
- if (copy) {
1628
- const document = cheerio.load(String(articleWithCover.html || ""), null, false);
1629
- if (document('[data-qcplay-weibo-copy="1"]').length === 0) {
1630
- const paragraph = text => {
1631
- const node = cheerio.load("<p></p>", null, false);
1632
- node("p").attr("data-qcplay-weibo-copy", "1").text(text);
1633
- return node.root().html();
1634
- };
1635
- const prefix = copy.prefix
1636
- .filter(Boolean)
1637
- .map(paragraph)
1638
- .join("");
1639
- const suffix = copy.suffix
1640
- .filter(Boolean)
1641
- .map(paragraph)
1642
- .join("");
1643
- return {
1644
- ...articleWithCover,
1645
- html: `${prefix}${String(articleWithCover.html || "")}${suffix}`.trim(),
1646
- markdown: `${copy.prefix.join("\n\n")}\n\n${String(articleWithCover.markdown || "").trim()}${copy.suffix.length ? `\n\n${copy.suffix.join("\n\n")}` : ""}`.trim(),
1647
- weiboSuperTopic: resolveWeiboSuperTopic(articleWithCover, entry)
1648
- };
1649
- }
1650
- return articleWithCover;
1651
- }
1652
- const superTopic = resolveWeiboSuperTopic(articleWithCover, entry);
1653
- if (!superTopic) {
1654
- return articleWithCover;
1655
- }
1656
- const document = cheerio.load(String(articleWithCover.html || ""), null, false);
1657
- const existingText = normalizeText(document.root().text() || articleWithCover.markdown);
1658
- if (existingText.includes(superTopic)) {
1659
- return articleWithCover;
1660
- }
1661
- const mention = cheerio.load("<p></p>", null, false);
1662
- mention("p").attr("data-qcplay-weibo-super-topic", "1").text(superTopic);
1663
- return {
1664
- ...articleWithCover,
1665
- html: `${String(articleWithCover.html || "")}\n${mention.root().html()}`.trim(),
1666
- markdown: `${String(articleWithCover.markdown || "").trim()}\n\n${superTopic}`.trim(),
1667
- weiboSuperTopic: superTopic
1668
- };
1647
+ return articleWithCover;
1669
1648
  }
1670
1649
 
1671
1650
  export function buildWeiboText(article) {
@@ -1679,7 +1658,7 @@ export function buildWeiboRichContent(article) {
1679
1658
  structuredTables: true
1680
1659
  });
1681
1660
  const seenImages = new Set();
1682
- const items = content.items.filter(item => {
1661
+ const filteredItems = content.items.filter(item => {
1683
1662
  if (item.type !== "image") {
1684
1663
  return true;
1685
1664
  }
@@ -1692,14 +1671,14 @@ export function buildWeiboRichContent(article) {
1692
1671
  });
1693
1672
  return {
1694
1673
  ...content,
1695
- items,
1696
- 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")
1697
1677
  };
1698
1678
  }
1699
1679
 
1700
1680
  export function resolveWeiboPublishingOptions(article) {
1701
1681
  return {
1702
- intro: weiboMeta(article, "weibo_intro") || normalizeText(article.payload?.article_excerpt),
1703
1682
  cover: weiboMeta(article, "weibo_cover"),
1704
1683
  column: weiboMeta(article, "weibo_column")
1705
1684
  };
@@ -1715,16 +1694,13 @@ export function weiboPublishingPlan(article, entry = {}) {
1715
1694
  const preparedArticle = prepareWeiboArticleForProject(prepareArticleForPlatform(article, entry), entry);
1716
1695
  const settings = resolveWeiboPublishingOptions(preparedArticle);
1717
1696
  const richContent = buildWeiboRichContent(preparedArticle);
1718
- const superTopic = resolveWeiboSuperTopic(preparedArticle, entry);
1719
1697
  return {
1720
1698
  type: "头条文章",
1721
1699
  characters: richContent.plainText.length,
1722
1700
  images: richContent.images.length,
1723
1701
  links: richContent.links.length,
1724
- intro: Boolean(settings.intro),
1725
1702
  cover: settings.cover ? "explicit" : richContent.images.length > 0 ? "first-image" : "",
1726
- column: settings.column || "",
1727
- ...(superTopic ? { superTopic } : {})
1703
+ column: settings.column || ""
1728
1704
  };
1729
1705
  }
1730
1706
 
@@ -2073,6 +2049,14 @@ function safeRichCssLength(value, options = {}) {
2073
2049
  return pattern.test(length) ? length : "";
2074
2050
  }
2075
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
+
2076
2060
  function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
2077
2061
  const wrappers = [];
2078
2062
  const add = name => {
@@ -2100,12 +2084,16 @@ function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
2100
2084
  const letterSpacing = preserveStyles
2101
2085
  ? safeRichCssLength(styleValue.match(/(?:^|;)\s*letter-spacing\s*:\s*([^;]+)/i)?.[1])
2102
2086
  : "";
2087
+ const fontFamily = preserveStyles
2088
+ ? safeRichFontFamily(styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1])
2089
+ : "";
2103
2090
  const spanStyles = [
2104
2091
  safeColor ? `color:${safeColor}` : "",
2105
2092
  safeBackground ? `background-color:${safeBackground}` : "",
2106
2093
  fontSize ? `font-size:${fontSize}` : "",
2107
2094
  lineHeight ? `line-height:${lineHeight}` : "",
2108
- letterSpacing ? `letter-spacing:${letterSpacing}` : ""
2095
+ letterSpacing ? `letter-spacing:${letterSpacing}` : "",
2096
+ fontFamily ? `font-family:${fontFamily}` : ""
2109
2097
  ]
2110
2098
  .filter(Boolean)
2111
2099
  .join(";");
@@ -2178,6 +2166,10 @@ function buildPlatformRichContent(article, options = {}) {
2178
2166
  );
2179
2167
  if (value) blockStyles.push(`${property}:${value}`);
2180
2168
  }
2169
+ const fontFamily = safeRichFontFamily(
2170
+ styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1]
2171
+ );
2172
+ if (fontFamily) blockStyles.push(`font-family:${fontFamily}`);
2181
2173
  }
2182
2174
  return blockStyles.length > 0 ? ` style="${blockStyles.join(";")}"` : "";
2183
2175
  };
@@ -2546,17 +2538,66 @@ function buildPlatformRichContent(article, options = {}) {
2546
2538
  const fallback = normalizeText(article.markdown);
2547
2539
  items.push({ type: "html", html: `<p>${escapeTapTapHtml(fallback)}</p>`, plain: fallback });
2548
2540
  }
2541
+ const normalizedItems = normalizeNumberedSectionHeadings(items);
2549
2542
  return {
2550
- items,
2543
+ items: normalizedItems,
2551
2544
  images,
2552
2545
  links,
2553
- plainText: items
2546
+ plainText: normalizedItems
2554
2547
  .filter(item => item.type === "html")
2555
2548
  .map(item => item.plain)
2556
2549
  .join("\n")
2557
2550
  };
2558
2551
  }
2559
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
+
2560
2601
  export function buildBilibiliRichContent(article) {
2561
2602
  return buildPlatformRichContent(article, {
2562
2603
  preserveBackground: true,
@@ -2574,9 +2615,29 @@ export function buildHaoyouRichContent(article) {
2574
2615
  structuredTables: true
2575
2616
  });
2576
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
+ });
2577
2638
  return {
2578
2639
  ...content,
2579
- items: content.items.map(item =>
2640
+ items: content.items.map((item, index) =>
2580
2641
  item.type === "html"
2581
2642
  ? {
2582
2643
  ...item,
@@ -2587,13 +2648,39 @@ export function buildHaoyouRichContent(article) {
2587
2648
  // WeChat's nested styled spans can also truncate their trailing
2588
2649
  // text in the Haoyou Quill clipboard parser. Keep their text and
2589
2650
  // semantic children while dropping only the wrapper.
2590
- .replace(flattenInlineSpans ? /<\/?span\b[^>]*>/gi : /$^/, "")
2651
+ .replace(flattenInlineSpans && !secretStyleItems.has(index) ? /<\/?span\b[^>]*>/gi : /$^/, "")
2591
2652
  }
2592
2653
  : item
2593
2654
  )
2594
2655
  };
2595
2656
  }
2596
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
+
2597
2684
  function contentTypeForFile(fileName, fallback = "application/octet-stream") {
2598
2685
  const extension = path.extname(fileName).toLowerCase();
2599
2686
  return (
@@ -2815,7 +2902,7 @@ async function waitForTapTapRichEditorState(page, bodyInput, content, options =
2815
2902
  throw new Error("TapTap 正文写入后未能稳定完成回读校验");
2816
2903
  }
2817
2904
 
2818
- export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText) {
2905
+ export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText, options = {}) {
2819
2906
  if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
2820
2907
  await fillLocator(bodyInput, fallbackText);
2821
2908
  await verifyFilledValue(bodyInput, fallbackText, "正文");
@@ -2825,7 +2912,12 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2825
2912
  const content = buildTapTapRichContent(article);
2826
2913
  const inlineImages = settings.type === "topic";
2827
2914
  const toolbarImageInput = inlineImages
2828
- ? 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
+ ])
2829
2921
  : null;
2830
2922
  if (inlineImages && content.images.length > 0 && (!toolbarImageInput || typeof toolbarImageInput.setInputFiles !== "function")) {
2831
2923
  throw new Error("TapTap 长帖编辑器中未找到正文图片上传控件");
@@ -2844,6 +2936,9 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2844
2936
  plainParts.push(item.plain);
2845
2937
  continue;
2846
2938
  }
2939
+ if (options.skipImages) {
2940
+ continue;
2941
+ }
2847
2942
  if (inlineImages) {
2848
2943
  const marker = `${markerPrefix}${imageMarkers.length}`;
2849
2944
  htmlParts.push(`<p>${marker}</p>`);
@@ -2863,7 +2958,9 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2863
2958
 
2864
2959
  const payloadCache = new Map();
2865
2960
  let uploadedImages = 0;
2866
- for (const { marker, item, index } of imageMarkers) {
2961
+ // Upload from the end so a media-type-specific editor rerender cannot invalidate
2962
+ // placeholders that still need to be replaced later in the document.
2963
+ for (const { marker, item, index } of [...imageMarkers].reverse()) {
2867
2964
  let removed = false;
2868
2965
  for (let attempt = 0; attempt < 3; attempt += 1) {
2869
2966
  await bodyInput.focus().catch(() => {});
@@ -2898,13 +2995,64 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2898
2995
  }, marker);
2899
2996
  if (!selected) break;
2900
2997
  await page.waitForTimeout(80);
2901
- await page.keyboard.press("Backspace");
2998
+ if (typeof bodyInput.press === "function") {
2999
+ await bodyInput.press("Backspace").catch(() => {});
3000
+ } else {
3001
+ await page.keyboard.press("Backspace");
3002
+ }
2902
3003
  await page.waitForTimeout(100);
2903
3004
  removed = !(await bodyInput.evaluate((element, markerValue) => {
2904
3005
  return (element.innerText || element.textContent || "").includes(markerValue);
2905
3006
  }, marker));
3007
+ if (!removed) {
3008
+ if (typeof bodyInput.press === "function") {
3009
+ await bodyInput.press("Delete").catch(() => {});
3010
+ } else {
3011
+ await page.keyboard.press("Delete");
3012
+ }
3013
+ await page.waitForTimeout(100);
3014
+ removed = !(await bodyInput.evaluate((element, markerValue) => {
3015
+ return (element.innerText || element.textContent || "").includes(markerValue);
3016
+ }, marker));
3017
+ }
2906
3018
  if (removed) break;
2907
3019
  }
3020
+ if (!removed) {
3021
+ removed = await bodyInput.evaluate((element, markerValue) => {
3022
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
3023
+ const nodes = [];
3024
+ let node = walker.nextNode();
3025
+ let combined = "";
3026
+ while (node) {
3027
+ nodes.push({ node, start: combined.length });
3028
+ combined += node.data;
3029
+ node = walker.nextNode();
3030
+ }
3031
+ const markerStart = combined.indexOf(markerValue);
3032
+ if (markerStart < 0) return false;
3033
+ const markerEnd = markerStart + markerValue.length;
3034
+ const start = [...nodes].reverse().find(item => item.start <= markerStart);
3035
+ const end = nodes.find(item => item.start + item.node.data.length >= markerEnd);
3036
+ if (!start || !end) return false;
3037
+ const range = document.createRange();
3038
+ range.setStart(start.node, markerStart - start.start);
3039
+ range.setEnd(end.node, markerEnd - end.start);
3040
+ const selection = window.getSelection();
3041
+ selection.removeAllRanges();
3042
+ selection.addRange(range);
3043
+ const executed = document.execCommand("delete");
3044
+ if (executed && !(element.innerText || element.textContent || "").includes(markerValue)) {
3045
+ return true;
3046
+ }
3047
+ range.deleteContents();
3048
+ element.dispatchEvent(new InputEvent("input", {
3049
+ bubbles: true,
3050
+ inputType: "deleteContentBackward",
3051
+ data: null
3052
+ }));
3053
+ return !(element.innerText || element.textContent || "").includes(markerValue);
3054
+ }, marker).catch(() => false);
3055
+ }
2908
3056
  if (!removed) {
2909
3057
  throw new Error(`TapTap 正文图片占位符未能完整清理: ${item.source}`);
2910
3058
  }
@@ -3035,6 +3183,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3035
3183
  }
3036
3184
 
3037
3185
  const content = buildBilibiliRichContent(article);
3186
+ options.onProgress?.(`B站 正在写入文字正文(${content.plainText.length} 字)`);
3038
3187
  await bodyInput.fill("");
3039
3188
  await bodyInput.click();
3040
3189
  const markerPrefix = `QCPLAY_BILIBILI_IMAGE_${Date.now()}_`;
@@ -3059,11 +3208,26 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3059
3208
  plain: plainParts.join("\n")
3060
3209
  });
3061
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
+ };
3062
3224
  }
3063
3225
 
3064
3226
  const payloadCache = new Map();
3065
3227
  let removedImages = 0;
3066
- 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}:正在定位插入位置`);
3067
3231
  const selected = await bodyInput.evaluate((element, markerValue) => {
3068
3232
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
3069
3233
  let textNode = walker.nextNode();
@@ -3087,6 +3251,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3087
3251
  throw new Error(`B站正文图片占位位置丢失: ${item.source}`);
3088
3252
  }
3089
3253
  await page.keyboard.press("Backspace");
3254
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在上传`);
3090
3255
  let payload = payloadCache.get(item.source);
3091
3256
  if (!payload) {
3092
3257
  payload = await imageUploadPayload(item.source, article.articleFile, index, "B站");
@@ -3100,26 +3265,38 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3100
3265
  });
3101
3266
  if (uploadResult?.removed) {
3102
3267
  removedImages += 1;
3268
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:不合规,已从正文移除`);
3269
+ } else {
3270
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:上传回执已确认`);
3103
3271
  }
3104
3272
  }
3105
3273
 
3274
+ options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
3106
3275
  let state = null;
3107
3276
  let previousTextLength = -1;
3108
3277
  let stableChecks = 0;
3109
3278
  for (let attempt = 0; attempt < 40; attempt += 1) {
3110
- state = await bodyInput.evaluate(element => ({
3111
- text: element.innerText,
3112
- images: element.querySelectorAll("img").length,
3113
- failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
3114
- links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
3115
- }));
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
+ });
3116
3293
  const currentLength = normalizeText(state.text).length;
3117
3294
  stableChecks = currentLength === previousTextLength ? stableChecks + 1 : 0;
3118
3295
  previousTextLength = currentLength;
3119
3296
  if (stableChecks >= 3) break;
3120
3297
  await page.waitForTimeout(250);
3121
3298
  }
3122
- const expectedImages = content.images.length - removedImages;
3299
+ const expectedImages = options.skipImages ? 0 : content.images.length - removedImages;
3123
3300
  if (state.failedImages > 0 || state.images < expectedImages) {
3124
3301
  throw new Error(`B站正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
3125
3302
  }
@@ -3142,7 +3319,14 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3142
3319
  throw new Error(`B站正文超链接未正确写入: ${link.href}`);
3143
3320
  }
3144
3321
  }
3145
- 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
+ };
3146
3330
  }
3147
3331
 
3148
3332
  function haoyouImageUploadPayload(payload, index) {
@@ -3211,8 +3395,8 @@ async function waitForHaoyouInlineImage(page, bodyInput, imageId, source) {
3211
3395
  throw new Error(`好游快爆正文图片上传超时: ${source}`);
3212
3396
  }
3213
3397
 
3214
- export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText) {
3215
- const content = buildHaoyouRichContent(article);
3398
+ export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText, options = {}) {
3399
+ const content = buildHaoyouPublishRichContent(article);
3216
3400
  if (typeof bodyInput.evaluate !== "function") {
3217
3401
  await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
3218
3402
  return { rich: false, images: 0, links: 0, plainText: fallbackText };
@@ -3254,6 +3438,10 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3254
3438
  continue;
3255
3439
  }
3256
3440
 
3441
+ if (options.skipImages) {
3442
+ imageIndex += 1;
3443
+ continue;
3444
+ }
3257
3445
  let upload = payloadCache.get(item.source);
3258
3446
  if (!upload) {
3259
3447
  upload = haoyouImageUploadPayload(
@@ -3352,8 +3540,9 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3352
3540
  if (stableChecks >= 3) break;
3353
3541
  await page.waitForTimeout(250);
3354
3542
  }
3355
- if (state.failedImages > 0 || state.pendingImages > 0 || state.images < content.images.length) {
3356
- 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} 张`);
3357
3546
  }
3358
3547
  const expectedFragments = content.items
3359
3548
  .filter(item => item.type === "html" && item.plain)
@@ -3372,6 +3561,7 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3372
3561
  return {
3373
3562
  rich: true,
3374
3563
  images: state.images,
3564
+ ...(options.skipImages ? { imagesPending: content.images.length } : {}),
3375
3565
  links: state.links,
3376
3566
  headings: state.headings,
3377
3567
  formatted: state.formatted,
@@ -3386,6 +3576,51 @@ function safeProfileName(entry) {
3386
3576
  .slice(0, 80);
3387
3577
  }
3388
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
+
3389
3624
  function isNavigationContextError(error) {
3390
3625
  const message = String(error?.message || error);
3391
3626
  return /execution context was destroyed|cannot find context with specified id|frame was detached|navigation.*interrupted/i.test(
@@ -3678,11 +3913,25 @@ async function waitForUser(message, streams = {}) {
3678
3913
  }
3679
3914
 
3680
3915
  function reviewApprovalAccepted(answer) {
3681
- return ["", "发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
3916
+ return ["发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
3682
3917
  }
3683
3918
 
3684
3919
  function reviewApprovalCancelled(answer) {
3685
- 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 "";
3686
3935
  }
3687
3936
 
3688
3937
  async function waitForReviewApproval(message, options = {}) {
@@ -3692,7 +3941,7 @@ async function waitForReviewApproval(message, options = {}) {
3692
3941
  if (reviewApprovalCancelled(answer)) {
3693
3942
  throw new Error("用户已取消正式发布,草稿未提交");
3694
3943
  }
3695
- throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或直接回车`);
3944
+ throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或“取消”`);
3696
3945
  }
3697
3946
  const input = options.streams?.input || process.stdin;
3698
3947
  const output = options.streams?.output || process.stdout;
@@ -3704,13 +3953,13 @@ async function waitForReviewApproval(message, options = {}) {
3704
3953
  for (;;) {
3705
3954
  const answer = await question(
3706
3955
  rl,
3707
- `${message}\n输入“可以发布”或直接按 Enter 继续,输入“取消”停止: `
3956
+ `${message}\n输入“可以发布”继续;直接按 Enter 默认取消,输入“取消”停止: `
3708
3957
  );
3709
3958
  if (reviewApprovalAccepted(answer)) return;
3710
3959
  if (reviewApprovalCancelled(answer)) {
3711
3960
  throw new Error("用户已取消正式发布,草稿未提交");
3712
3961
  }
3713
- output.write("无法识别输入,请输入“可以发布”、直接按 Enter 或输入“取消”。\n");
3962
+ output.write("无法识别输入,请输入“可以发布”或“取消”(直接按 Enter 会取消)。\n");
3714
3963
  }
3715
3964
  } finally {
3716
3965
  rl.close();
@@ -3722,6 +3971,19 @@ async function waitForReviewApproval(message, options = {}) {
3722
3971
  * This is intentionally separate from publishWithBrowser so page structure and login
3723
3972
  * flows can be checked against the real site before running an article publish.
3724
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
+
3725
3987
  export async function openPublishPage(entry, options = {}) {
3726
3988
  const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
3727
3989
  if (!spec || entry.publisher !== "browser") {
@@ -3732,13 +3994,21 @@ export async function openPublishPage(entry, options = {}) {
3732
3994
  throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
3733
3995
  }
3734
3996
 
3735
- const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
3997
+ const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
3736
3998
  const ownsContext = !options.context;
3737
3999
  try {
3738
4000
  const page = context.pages()[0] || (await context.newPage());
3739
4001
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
3740
4002
  await afterNavigation(page);
3741
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
+ }
3742
4012
  const prompt = options.waitForUser || (message => waitForUser(message, options.streams));
3743
4013
  const promptMessage = options.accountSwitch
3744
4014
  ? `${entry.platform}账号切换页面已打开。请在浏览器中完成账号切换,确认当前账号正确后按 Enter 继续后续发布流程`
@@ -3746,22 +4016,32 @@ export async function openPublishPage(entry, options = {}) {
3746
4016
  await prompt(promptMessage);
3747
4017
  return { url: page.url(), status: "opened" };
3748
4018
  } finally {
3749
- if (ownsContext) {
3750
- await context.close().catch(() => {});
4019
+ if (ownsContext && !options.awaitAIAction) {
4020
+ await closeBrowserContext(context);
3751
4021
  }
3752
4022
  }
3753
4023
  }
3754
4024
 
3755
4025
  async function reviewPreparedDraft(page, entry, options) {
3756
4026
  if (!options.reviewDraft) {
3757
- return;
4027
+ return null;
3758
4028
  }
3759
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
+ }
3760
4039
  await waitForReviewApproval(
3761
4040
  `${entry.platform} 待发布稿已准备,请在浏览器中预览标题、正文、图片和发布设置`,
3762
4041
  options
3763
4042
  );
3764
4043
  options.onProgress?.(`${entry.platform} 草稿预览已确认,正在正式发布`);
4044
+ return null;
3765
4045
  }
3766
4046
 
3767
4047
  async function editorIsReady(page, spec) {
@@ -4325,7 +4605,7 @@ async function submitTapTap(page, settings, options, promptUser) {
4325
4605
  }
4326
4606
  }
4327
4607
 
4328
- async function resumeMatchingTapTapDraft(page, spec, articleTitle) {
4608
+ async function resumeMatchingTapTapDraft(page, spec, articleTitle, editorUrl = "") {
4329
4609
  if (typeof page.getByText !== "function") {
4330
4610
  return false;
4331
4611
  }
@@ -4354,6 +4634,16 @@ async function resumeMatchingTapTapDraft(page, spec, articleTitle) {
4354
4634
  await page.waitForTimeout(100);
4355
4635
  } while (Date.now() < deadline);
4356
4636
  if (draftTitle && draftTitle !== articleTitle) {
4637
+ if (editorUrl) {
4638
+ await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
4639
+ await afterNavigation(page);
4640
+ const ignore = page.getByText("忽略", { exact: true }).first();
4641
+ if ((await ignore.count().catch(() => 0)) > 0 && (await ignore.isVisible().catch(() => false))) {
4642
+ await ignore.click();
4643
+ await page.waitForTimeout(500);
4644
+ return false;
4645
+ }
4646
+ }
4357
4647
  throw new Error(`TapTap 存在其他未发布草稿“${draftTitle}”,已停止以避免覆盖或产生额外草稿`);
4358
4648
  }
4359
4649
  return true;
@@ -4383,16 +4673,16 @@ async function uploadTapTapCover(page, source, articleFile) {
4383
4673
 
4384
4674
  async function publishTapTap(page, entry, article, spec, options, promptUser) {
4385
4675
  let preparedArticle = prepareTapTapArticleForProject(article, entry);
4386
- const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
4676
+ const settings = {
4677
+ ...resolveTapTapPublishingOptions(preparedArticle, entry),
4678
+ ...(options.draftOnly ? { draft: true, scheduled: "" } : {})
4679
+ };
4387
4680
  const titleLimit = settings.type === "moment" ? 20 : 30;
4388
- if (preparedArticle.title.length > titleLimit) {
4389
- if (normalizeText(entry.project) === "迷途之光" && settings.type === "moment") {
4390
- preparedArticle.title = Array.from(preparedArticle.title).slice(0, titleLimit).join("");
4391
- } else {
4392
- throw new Error(
4393
- `TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${preparedArticle.title.length} 个字符`
4394
- );
4395
- }
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
+ );
4396
4686
  }
4397
4687
  const richContent = buildTapTapRichContent(preparedArticle);
4398
4688
  const body = richContent.plainText || plainTextForPlatform(preparedArticle, "taptap");
@@ -4417,7 +4707,14 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4417
4707
  if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
4418
4708
  throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
4419
4709
  }
4420
- await resumeMatchingTapTapDraft(page, spec, preparedArticle.title);
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
+ }
4421
4718
 
4422
4719
  if (settings.type === "moment") {
4423
4720
  const imageSources = settings.images.length
@@ -4454,9 +4751,54 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4454
4751
  await applyTapTapForum(page, settings.forum, promptUser, { project: entry.project });
4455
4752
  await applyTapTapSchedule(page, settings.scheduled, promptUser);
4456
4753
  if (!settings.draft) {
4457
- await reviewPreparedDraft(page, entry, options);
4754
+ const review = await reviewPreparedDraft(page, entry, options);
4755
+ if (review) {
4756
+ return { ...review, type: settings.type };
4757
+ }
4458
4758
  }
4459
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
+ }
4460
4802
  if (options.keepOpen) {
4461
4803
  await promptUser(
4462
4804
  `TapTap ${settings.draft ? "草稿保存" : settings.scheduled ? "定时发布" : "发布"}已收到成功回执,浏览器保持打开供检查`
@@ -4805,9 +5147,63 @@ async function submitBilibili(page, spec, settings, article, metadataApplied, op
4805
5147
  }
4806
5148
  }
4807
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
+
4808
5187
  async function publishBilibili(page, entry, article, spec, options, promptUser) {
4809
5188
  const settings = resolveBilibiliPublishingOptions(article, entry);
4810
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
+ }
4811
5207
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
4812
5208
  await afterNavigation(page);
4813
5209
  if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings)))) {
@@ -4840,8 +5236,16 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
4840
5236
  await validateBilibiliFieldLength(bodyInput, body, settings.type === "video" ? "简介" : "正文");
4841
5237
  await fillLocator(titleInput, article.title);
4842
5238
  await verifyFilledValue(titleInput, article.title, "标题", "B站");
4843
- if (settings.type === "article") {
4844
- 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,
4845
5249
  removeNoncompliant: normalizeText(entry.project) === "最强蜗牛"
4846
5250
  });
4847
5251
  } else {
@@ -4852,9 +5256,27 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
4852
5256
 
4853
5257
  const metadataApplied =
4854
5258
  settings.type === "article"
4855
- ? 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)
4856
5262
  : { category: true, tags: true, topic: true, cover: true };
4857
- 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
+ }
4858
5280
  const confirmation = await submitBilibili(page, spec, settings, article, metadataApplied, options, promptUser);
4859
5281
  if (options.keepOpen) {
4860
5282
  await promptUser(`B站${settings.typeLabel}投稿已收到成功回执,浏览器保持打开供检查`);
@@ -5151,6 +5573,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5151
5573
  } while (true);
5152
5574
 
5153
5575
  let uploadedPreview = previews.last();
5576
+ let candidateIndexes = [];
5154
5577
  if (previousPreviewKeys.length > 0 && typeof previews.evaluateAll === "function") {
5155
5578
  const previewKeys = await previews
5156
5579
  .evaluateAll(elements =>
@@ -5160,10 +5583,20 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5160
5583
  })
5161
5584
  )
5162
5585
  .catch(() => []);
5163
- const uploadedIndex = previewKeys.findIndex(key => key && !previousPreviewKeys.includes(key));
5164
- if (uploadedIndex >= 0) {
5165
- uploadedPreview = previews.nth(uploadedIndex);
5166
- }
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]);
5167
5600
  }
5168
5601
  if ((await uploadedPreview.count()) > 0 && (await uploadedPreview.isVisible().catch(() => false))) {
5169
5602
  await uploadedPreview.click();
@@ -5972,19 +6405,104 @@ async function waitForWeiboSecurityVerification(page, progress) {
5972
6405
  throw new Error("微博身份安全验证等待超时,请完成滑块验证后重试");
5973
6406
  }
5974
6407
 
6408
+ async function findWeiboShareComposer(page) {
6409
+ const containers = ['[role="dialog"]', '[class*="modal"]', '[class*="dialog"]', '[class*="pop"]'];
6410
+ const fields = ['textarea', '[contenteditable="true"][role="textbox"]', '[contenteditable="true"]'];
6411
+ for (const scope of locatorScopes(page)) {
6412
+ for (const selector of containers) {
6413
+ const candidates = scope.locator(selector);
6414
+ const count = await candidates.count().catch(() => 0);
6415
+ for (let index = count - 1; index >= 0; index -= 1) {
6416
+ const dialog = candidates.nth(index);
6417
+ if (!(await dialog.isVisible().catch(() => false))) continue;
6418
+ const text = normalizeText(await dialog.innerText().catch(() => ""));
6419
+ if (!/发布|公开|头条文章/.test(text)) continue;
6420
+ for (const fieldSelector of fields) {
6421
+ const field = dialog.locator(fieldSelector).first();
6422
+ if ((await field.count().catch(() => 0)) > 0 && (await field.isVisible().catch(() => false))) {
6423
+ return { dialog, field };
6424
+ }
6425
+ }
6426
+ }
6427
+ }
6428
+ }
6429
+ return null;
6430
+ }
6431
+
6432
+ async function waitForWeiboShareComposer(page, timeoutMs = 3500) {
6433
+ const deadline = Date.now() + timeoutMs;
6434
+ do {
6435
+ const composer = await findWeiboShareComposer(page);
6436
+ if (composer) return composer;
6437
+ await page.waitForTimeout(200);
6438
+ } while (Date.now() < deadline);
6439
+ return null;
6440
+ }
6441
+
6442
+ async function fillWeiboShareComposer(page, field, value) {
6443
+ const expected = normalizeText(value).replace(/\r\n/g, "\n");
6444
+ let actual = "";
6445
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
6446
+ await fillLocator(field, value);
6447
+ await page.waitForTimeout(200);
6448
+ actual =
6449
+ typeof field.inputValue === "function"
6450
+ ? await field.inputValue().catch(() => "")
6451
+ : typeof field.innerText === "function"
6452
+ ? await field.innerText().catch(() => "")
6453
+ : "";
6454
+ if (normalizeText(actual).replace(/\r\n/g, "\n") === expected) return;
6455
+ }
6456
+ throw new Error(`微博分享文案未正确写入发布弹窗,实际为“${normalizeText(actual).slice(0, 40)}”`);
6457
+ }
6458
+
6459
+ async function findWeiboShareSubmitButton(page, dialog) {
6460
+ const selectors = [
6461
+ 'button',
6462
+ '[role="button"]',
6463
+ 'a[class*="btn"]',
6464
+ '[class*="btn"]',
6465
+ '[class*="button"]'
6466
+ ];
6467
+ const findIn = async container => {
6468
+ for (const selector of selectors) {
6469
+ const candidates = container.locator(selector);
6470
+ const count = await candidates.count().catch(() => 0);
6471
+ for (let index = count - 1; index >= 0; index -= 1) {
6472
+ const candidate = candidates.nth(index);
6473
+ if (!(await candidate.isVisible().catch(() => false))) continue;
6474
+ if (typeof candidate.isDisabled === "function" && (await candidate.isDisabled().catch(() => false))) continue;
6475
+ const attribute = async name =>
6476
+ typeof candidate.getAttribute === "function" ? await candidate.getAttribute(name).catch(() => "") : "";
6477
+ const text = normalizeText(
6478
+ (await candidate.innerText().catch(() => "")) ||
6479
+ (await attribute("aria-label")) ||
6480
+ (await attribute("title")) ||
6481
+ (await attribute("value"))
6482
+ );
6483
+ if (/^(发布|立即发布|确认发布)$/.test(text)) return candidate;
6484
+ }
6485
+ }
6486
+ return null;
6487
+ };
6488
+ const scoped = await findIn(dialog);
6489
+ if (scoped) return scoped;
6490
+ // Some Weibo versions render the modal text field and action bar in sibling
6491
+ // nodes, so fall back to the last visible exact publish control on the page.
6492
+ return findIn(page);
6493
+ }
6494
+
5975
6495
  async function publishWeibo(page, entry, article, spec, options, promptUser) {
5976
6496
  const progress = message => options.onProgress?.(message);
5977
6497
  const preparedArticle = prepareWeiboArticleForProject(article, entry);
5978
6498
  const settings = resolveWeiboPublishingOptions(preparedArticle);
6499
+ const title = resolveWeiboArticleTitle(preparedArticle);
5979
6500
  const body = buildWeiboText(preparedArticle);
5980
6501
  if (!body) {
5981
6502
  throw new Error("微博文章正文不能为空");
5982
6503
  }
5983
- if (article.title.length > 32) {
5984
- throw new Error(`微博文章标题最多 32 个字符,当前为 ${article.title.length} 个字符`);
5985
- }
5986
- if (settings.intro.length > 44) {
5987
- throw new Error(`微博文章导语最多 44 个字符,当前为 ${settings.intro.length} 个字符`);
6504
+ if (Array.from(title).length > 32) {
6505
+ throw new Error(`微博文章标题最多 32 个字符,当前为 ${Array.from(title).length} 个字符`);
5988
6506
  }
5989
6507
 
5990
6508
  const editorUrl = weiboArticleEditorUrl(entry.url);
@@ -5999,7 +6517,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
5999
6517
  throw new Error("微博登录完成后仍未进入头条文章编辑器,请确认账号拥有文章发布权限");
6000
6518
  }
6001
6519
 
6002
- const draftMode = await ensureWeiboArticleDraft(page, article.title);
6520
+ const draftMode = await ensureWeiboArticleDraft(page, title);
6003
6521
  progress(
6004
6522
  draftMode === "resumed"
6005
6523
  ? "已复用最新同标题微博草稿,正在重写文章内容"
@@ -6011,14 +6529,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6011
6529
  if (!titleInput || !bodyInput) {
6012
6530
  throw new Error("微博头条文章编辑器未完整加载,未找到标题或正文控件");
6013
6531
  }
6014
- await fillStableWeiboField(page, spec.title, article.title, "标题");
6015
- const introInput = await waitForVisible(page, spec.intro, 3000);
6016
- if (introInput) {
6017
- // Empty input is intentional: drafts may retain the previous article's intro.
6018
- await fillStableWeiboField(page, spec.intro, settings.intro, "导语");
6019
- } else if (settings.intro) {
6020
- throw new Error("微博头条文章编辑器中未找到导语输入框");
6021
- }
6532
+ await fillStableWeiboField(page, spec.title, title, "标题");
6022
6533
  const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
6023
6534
  const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
6024
6535
  onProgress: progress
@@ -6035,7 +6546,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6035
6546
  throw new Error("微博文章“下一步”按钮不可用,请检查标题、正文和封面设置");
6036
6547
  }
6037
6548
 
6038
- 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
+ }
6039
6569
  const initialUrl = page.url();
6040
6570
  progress("正在进入发布确认页");
6041
6571
  let submitButton = null;
@@ -6087,19 +6617,43 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6087
6617
  if (typeof submitButton.isDisabled === "function" && (await submitButton.isDisabled().catch(() => false))) {
6088
6618
  throw new Error("微博文章最终发布按钮不可用,请检查发布设置");
6089
6619
  }
6090
- const mutationPromise =
6620
+ const articleMutationPromise =
6091
6621
  typeof page.waitForResponse === "function"
6092
6622
  ? page.waitForResponse(weiboMutationResponse, { timeout: 30000 }).catch(() => null)
6093
6623
  : Promise.resolve(null);
6094
6624
  progress("正在提交微博头条文章");
6095
6625
  await submitButton.click();
6096
- progress("已提交,正在等待微博成功回执");
6097
- const confirmation = await waitForWeiboSubmission(page, initialUrl, mutationPromise, options);
6626
+ const shareComposer = await waitForWeiboShareComposer(page);
6627
+ let confirmation;
6628
+ if (shareComposer) {
6629
+ const shareCopy = resolveWeiboShareCopy(preparedArticle, entry);
6630
+ if (shareCopy) {
6631
+ await fillWeiboShareComposer(page, shareComposer.field, shareCopy);
6632
+ progress("微博分享弹窗文案已替换,正在发布动态");
6633
+ }
6634
+ const shareSubmit = await findWeiboShareSubmitButton(page, shareComposer.dialog);
6635
+ if (!shareSubmit) {
6636
+ throw new Error("微博分享发布弹窗中未找到“发布”按钮");
6637
+ }
6638
+ if (typeof shareSubmit.isDisabled === "function" && (await shareSubmit.isDisabled().catch(() => false))) {
6639
+ throw new Error("微博分享发布弹窗中的“发布”按钮不可用");
6640
+ }
6641
+ const shareMutationPromise =
6642
+ typeof page.waitForResponse === "function"
6643
+ ? page.waitForResponse(weiboMutationResponse, { timeout: 30000 }).catch(() => null)
6644
+ : Promise.resolve(null);
6645
+ await shareSubmit.click();
6646
+ progress("动态已提交,正在等待微博成功回执");
6647
+ confirmation = await waitForWeiboSubmission(page, initialUrl, shareMutationPromise, options);
6648
+ } else {
6649
+ progress("已提交,正在等待微博成功回执");
6650
+ confirmation = await waitForWeiboSubmission(page, initialUrl, articleMutationPromise, options);
6651
+ }
6098
6652
  progress("已收到微博发布成功回执");
6099
6653
  if (options.keepOpen) {
6100
6654
  await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
6101
6655
  }
6102
- 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 };
6103
6657
  }
6104
6658
  throw new Error("微博发布确认页中未找到最终发布按钮");
6105
6659
  }
@@ -6140,7 +6694,10 @@ async function publishWeiboQuick(page, entry, article, options, promptUser) {
6140
6694
  await fillLocator(bodyInput, body);
6141
6695
  const submit = await waitForVisibleButton(page, [/^发送$/, /^发布$/], 5000);
6142
6696
  if (!submit) throw new Error("微博快捷发布页面中未找到发送按钮");
6143
- await reviewPreparedDraft(page, entry, options);
6697
+ const review = await reviewPreparedDraft(page, entry, options);
6698
+ if (review) {
6699
+ return { ...review, type: "quick" };
6700
+ }
6144
6701
  await submit.click();
6145
6702
  await page.waitForTimeout(1500);
6146
6703
  return { url: page.url(), status: "published", type: "quick" };
@@ -6150,8 +6707,56 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6150
6707
  const editorUrl = browserPlatformPageUrl(entry);
6151
6708
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
6152
6709
  await afterNavigation(page);
6153
- const publishing = resolveXiaohongshuPublishingOptions(article);
6154
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
+ }
6155
6760
  if (Array.from(body).length > 1000) {
6156
6761
  throw new Error(`小红书正文最多 1000 个字符,当前为 ${Array.from(body).length} 个字符;请先精简内容后再发布`);
6157
6762
  }
@@ -6179,12 +6784,20 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6179
6784
  }
6180
6785
  options.onProgress?.(`正在上传小红书图片 ${payloads.length} 张`);
6181
6786
  await imageInput.setInputFiles(payloads);
6182
- 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
+ });
6183
6794
  options.onProgress?.("图片上传完成,正在填写笔记内容");
6184
6795
 
6185
- if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
6796
+ const editorPage = await waitForXiaohongshuEditorPage(page, spec, 120000);
6797
+ if (!editorPage) {
6186
6798
  throw new Error("小红书图片上传完成后未进入笔记编辑器,请检查图片格式或账号发布权限");
6187
6799
  }
6800
+ page = editorPage;
6188
6801
  const titleInput = await waitForVisible(page, spec.title, 8000);
6189
6802
  const bodyInput = await waitForVisible(page, spec.body, 8000);
6190
6803
  if (!titleInput || !bodyInput) throw new Error("小红书发布编辑器未找到标题或正文控件");
@@ -6218,11 +6831,25 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6218
6831
  if (typeof submit.isDisabled === "function" && await submit.isDisabled().catch(() => false)) {
6219
6832
  throw new Error("小红书发布按钮不可用,请检查正文和图片上传状态");
6220
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
+ }
6221
6845
  if (options.keepOpen && !isInteractiveTerminal(options.streams)) {
6222
6846
  options.onProgress?.("小红书待发布内容已准备完成,请在浏览器中核对后手动点击“发布”;程序将等待并验证发布结果");
6223
6847
  return waitForManualXiaohongshuSubmission(page, 600000);
6224
6848
  }
6225
- await reviewPreparedDraft(page, entry, options);
6849
+ const review = await reviewPreparedDraft(page, entry, options);
6850
+ if (review) {
6851
+ return { ...review, type: "note", ...publishing };
6852
+ }
6226
6853
  const initialUrl = page.url();
6227
6854
  const mutationPromise =
6228
6855
  typeof page.waitForResponse === "function"
@@ -6233,6 +6860,32 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6233
6860
  return { url: page.url(), status: "published", type: "note", ...publishing, ...confirmation };
6234
6861
  }
6235
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
+
6236
6889
  function isInteractiveTerminal(streams = {}) {
6237
6890
  const input = streams.input || process.stdin;
6238
6891
  const output = streams.output || process.stdout;
@@ -6269,11 +6922,64 @@ async function waitForManualXiaohongshuSubmission(page, timeoutMs) {
6269
6922
  throw new Error("小红书待发布页面等待超时,未检测到手动发布成功回执");
6270
6923
  }
6271
6924
 
6272
- function xiaohongshuMutationResponse(response) {
6925
+ export function xiaohongshuMutationResponse(response) {
6273
6926
  const request = response.request();
6274
6927
  if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) return false;
6275
6928
  const url = response.url();
6276
- 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
+ };
6277
6983
  }
6278
6984
 
6279
6985
  async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, options) {
@@ -6284,7 +6990,11 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
6284
6990
  if (response.status() >= 400 || payload?.success === false || Number(payload?.code) > 0) {
6285
6991
  throw new Error(`小红书发布失败: ${normalizeText(payload?.msg || payload?.message || `接口状态 ${response.status()}`)}`);
6286
6992
  }
6287
- 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 };
6288
6998
  }
6289
6999
  const deadline = Date.now() + 15000;
6290
7000
  do {
@@ -6292,19 +7002,229 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
6292
7002
  if (/发布失败|上传失败|内容违规|请重试/.test(pageText)) {
6293
7003
  throw new Error(`小红书发布失败: ${normalizeText(pageText.match(/(?:发布失败|上传失败|内容违规)[^\n]*/)?.[0] || "页面返回失败提示")}`);
6294
7004
  }
6295
- if (/发布成功|笔记发布成功|发布完成/.test(pageText) || page.url() !== initialUrl) {
6296
- return { confirmation: "page" };
7005
+ if (/发布成功|笔记发布成功|发布完成/.test(pageText)) {
7006
+ throw new Error("小红书页面提示发布完成,但未返回笔记 ID 或公开笔记链接,无法确认发布记录");
6297
7007
  }
6298
7008
  await page.waitForTimeout(500);
6299
7009
  } while (Date.now() < deadline);
6300
7010
  throw new Error("小红书发布后未检测到成功回执");
6301
7011
  }
6302
7012
 
6303
- 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 = {}) {
6304
7216
  const { chromium } = await import("playwright-core");
6305
- const profileDir = path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
7217
+ const profileDir = browserProfileDir(entry);
6306
7218
  await fs.promises.mkdir(profileDir, { recursive: true, mode: 0o700 });
6307
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
+ }
6308
7228
  return chromium.launchPersistentContext(profileDir, {
6309
7229
  channel,
6310
7230
  headless: false,
@@ -6461,7 +7381,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
6461
7381
  }
6462
7382
 
6463
7383
  const contentPolicy = resolvePlatformContentRequirements(entry);
6464
- if (contentPolicy.optionalSectionReview && !options.reviewDraft) {
7384
+ if (contentPolicy.optionalSectionReview && !options.reviewDraft && !options.directPublish && !options.draftOnly) {
6465
7385
  options = { ...options, reviewDraft: true };
6466
7386
  options.onProgress?.(
6467
7387
  `${entry.platform} 的飞书特殊要求包含可选删除板块,已切换为发布前人工预览确认`
@@ -6476,7 +7396,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
6476
7396
 
6477
7397
  const body =
6478
7398
  entry.platformKey === "haoyou"
6479
- ? buildHaoyouRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
7399
+ ? buildHaoyouPublishRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
6480
7400
  : plainTextForPlatform(preparedArticle, entry.platformKey);
6481
7401
  if (entry.platformKey === "x" && body.length > 280) {
6482
7402
  throw new Error(`X 内容共 ${body.length} 个字符,超过 280 字符限制`);
@@ -6486,11 +7406,12 @@ export async function publishWithBrowser(entry, article, options = {}) {
6486
7406
  }
6487
7407
 
6488
7408
  const ownsContext = !options.context;
6489
- const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
7409
+ const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
6490
7410
  const pages = context.pages();
6491
7411
  const page = pages[0] || (await context.newPage());
6492
7412
  const promptUser = options.waitForUser || (message => waitForUser(message, options.streams));
6493
7413
  let haoyouEditorOpened = false;
7414
+ let preserveEditorOnFailure = false;
6494
7415
  try {
6495
7416
  if (entry.platformKey === "taptap") {
6496
7417
  return await publishTapTap(page, entry, preparedArticle, spec, options, promptUser);
@@ -6556,7 +7477,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
6556
7477
  }
6557
7478
  let richContent = null;
6558
7479
  if (entry.platformKey === "haoyou") {
6559
- 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
+ }
6560
7492
  } else {
6561
7493
  await fillLocator(bodyInput, body);
6562
7494
  }
@@ -6567,10 +7499,22 @@ export async function publishWithBrowser(entry, article, options = {}) {
6567
7499
  if (entry.platformKey === "haoyou" && (await firstVisible(page, [".editSubmit"]))) {
6568
7500
  throw new Error("好游快爆标题和正文已写入,但发布按钮仍不可用,请检查发布设置");
6569
7501
  }
6570
- await promptUser(`${entry.platform} 页面中未找到发布按钮,正文已填入,请检查并手动发布`);
6571
- 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
+ };
6572
7517
  }
6573
- await reviewPreparedDraft(page, entry, options);
6574
7518
  if (entry.platformKey === "haoyou") {
6575
7519
  const initialUrl = page.url();
6576
7520
  const mutationPromise =
@@ -6591,12 +7535,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
6591
7535
  }
6592
7536
  return { url: page.url() };
6593
7537
  } catch (error) {
6594
- if (entry.platformKey === "haoyou" && ownsContext && haoyouEditorOpened) {
6595
- 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}编辑页供检查`);
6596
7542
  }
6597
7543
  throw error;
6598
7544
  } finally {
6599
- 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
+ }
6600
7550
  }
6601
7551
  }
6602
7552
 
@@ -6605,7 +7555,13 @@ export async function publishPlatformEntry(entry, article, options = {}) {
6605
7555
  return publishDiscord(entry, article, options);
6606
7556
  }
6607
7557
  if (entry.publisher === "browser") {
6608
- 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;
6609
7565
  }
6610
7566
  if (entry.publisher === "blocked") {
6611
7567
  throw new Error("Reddit 配置明确提示非官方工具可能导致封号;未配置 Reddit 官方应用,因此拒绝自动发布");
@@ -6616,6 +7572,46 @@ export async function publishPlatformEntry(entry, article, options = {}) {
6616
7572
  throw new Error(`${entry.platform} 需要由官网发布流程处理`);
6617
7573
  }
6618
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
+
6619
7615
  export async function runPlatformPublishSequence(entries, publishOne) {
6620
7616
  const successes = [];
6621
7617
  const failures = [];