@qcplay/cli 1.0.18 → 1.0.20

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,9 @@ 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 DISCORD_MESSAGE_STATE_FILE = path.join(os.homedir(), ".qcplay", "discord-messages.json");
17
+ const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
18
+ const BROWSER_SUBMISSION_STATE_FILE = path.join(os.homedir(), ".qcplay", "browser-submissions.json");
15
19
  const ARES_PROJECT = "阿瑞斯病毒2";
16
20
  const ARES_BILIBILI_RECHARGE_RULE = {
17
21
  name: "阿瑞斯病毒2 B站删除官充章节",
@@ -660,7 +664,11 @@ function postJson(urlValue, payload) {
660
664
  reject(new Error(`Discord webhook 返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
661
665
  return;
662
666
  }
663
- resolve();
667
+ try {
668
+ resolve(responseBody ? JSON.parse(responseBody) : {});
669
+ } catch {
670
+ resolve({});
671
+ }
664
672
  });
665
673
  }
666
674
  );
@@ -669,6 +677,110 @@ function postJson(urlValue, payload) {
669
677
  });
670
678
  }
671
679
 
680
+ function deleteJson(urlValue) {
681
+ return new Promise((resolve, reject) => {
682
+ const url = new URL(urlValue);
683
+ const request = https.request(
684
+ url,
685
+ {
686
+ method: "DELETE",
687
+ headers: {
688
+ Accept: "application/json",
689
+ "User-Agent": "qcplay-cli"
690
+ }
691
+ },
692
+ response => {
693
+ const chunks = [];
694
+ response.on("data", chunk => chunks.push(chunk));
695
+ response.on("end", () => {
696
+ const responseBody = Buffer.concat(chunks).toString("utf8");
697
+ if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
698
+ reject(new Error(`Discord webhook 删除消息返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
699
+ return;
700
+ }
701
+ resolve();
702
+ });
703
+ }
704
+ );
705
+ request.once("error", reject);
706
+ request.end();
707
+ });
708
+ }
709
+
710
+ function discordMessageStateKey(entry, article) {
711
+ const sourceUrl = normalizeText(article.meta?.source_url || article.payload?.source_url);
712
+ return [entry.project, entry.region, entry.platformKey || "discord", sourceUrl || article.title].map(normalizeText).join("\u0000");
713
+ }
714
+
715
+ async function readDiscordMessageState() {
716
+ try {
717
+ const value = JSON.parse(await fs.promises.readFile(DISCORD_MESSAGE_STATE_FILE, "utf8"));
718
+ return Array.isArray(value?.records) ? value.records : [];
719
+ } catch {
720
+ return [];
721
+ }
722
+ }
723
+
724
+ async function writeDiscordMessageState(records) {
725
+ await fs.promises.mkdir(path.dirname(DISCORD_MESSAGE_STATE_FILE), { recursive: true, mode: 0o700 });
726
+ await fs.promises.writeFile(
727
+ DISCORD_MESSAGE_STATE_FILE,
728
+ `${JSON.stringify({ version: 1, records }, null, 2)}\n`,
729
+ { encoding: "utf8", mode: 0o600 }
730
+ );
731
+ await fs.promises.chmod(DISCORD_MESSAGE_STATE_FILE, 0o600).catch(() => {});
732
+ }
733
+
734
+ async function recordDiscordMessages(entry, article, messageIds) {
735
+ if (!messageIds.length) return;
736
+ const record = {
737
+ key: discordMessageStateKey(entry, article),
738
+ project: normalizeText(entry.project),
739
+ region: normalizeText(entry.region),
740
+ platform: normalizeText(entry.platform),
741
+ platformKey: entry.platformKey || "discord",
742
+ title: normalizeText(article.title),
743
+ sourceUrl: normalizeText(article.meta?.source_url || article.payload?.source_url),
744
+ messageIds,
745
+ createdAt: new Date().toISOString()
746
+ };
747
+ const records = (await readDiscordMessageState()).filter(item => item?.key !== record.key);
748
+ records.unshift(record);
749
+ await writeDiscordMessageState(records.slice(0, 100));
750
+ }
751
+
752
+ export async function deleteDiscordMessages(entry, messageIds, options = {}) {
753
+ const webhook = discordWebhookFromEntry(entry);
754
+ if (!webhook) {
755
+ throw new Error("Discord 配置缺少 webhook,无法删除消息");
756
+ }
757
+ const ids = [...new Set(messageIds.map(value => normalizeText(value)).filter(Boolean))];
758
+ if (!ids.length) {
759
+ throw new Error("没有可删除的 Discord 消息 ID");
760
+ }
761
+ const remove = options.deleteJson || (messageId => {
762
+ const url = new URL(webhook);
763
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/messages/${encodeURIComponent(messageId)}`;
764
+ return deleteJson(url.toString());
765
+ });
766
+ for (const messageId of ids) {
767
+ await remove(messageId);
768
+ }
769
+ return { deleted: ids.length };
770
+ }
771
+
772
+ export async function findDiscordMessageRecord(article, entry) {
773
+ const key = discordMessageStateKey(entry, article);
774
+ const records = await readDiscordMessageState();
775
+ return records.find(item => item?.key === key) || null;
776
+ }
777
+
778
+ export async function removeDiscordMessageRecord(article, entry) {
779
+ const key = discordMessageStateKey(entry, article);
780
+ const records = (await readDiscordMessageState()).filter(item => item?.key !== key);
781
+ await writeDiscordMessageState(records);
782
+ }
783
+
672
784
  export async function publishDiscord(entry, article, options = {}) {
673
785
  const webhook = discordWebhookFromEntry(entry);
674
786
  if (!webhook) {
@@ -680,21 +792,56 @@ export async function publishDiscord(entry, article, options = {}) {
680
792
  : preparedArticle.markdown;
681
793
  const chunks = splitDiscordContent(preparedArticle.title, markdown);
682
794
  const send = options.postJson || postJson;
795
+ const messageIds = [];
683
796
  for (const chunk of chunks) {
684
- await send(webhook, { content: chunk, allowed_mentions: { parse: [] } });
797
+ const response = await send(`${webhook}${webhook.includes("?") ? "&" : "?"}wait=true`, {
798
+ content: chunk,
799
+ allowed_mentions: { parse: [] }
800
+ });
801
+ const messageId = normalizeText(response?.id);
802
+ if (messageId) messageIds.push(messageId);
803
+ }
804
+ let messageRecordSaved = false;
805
+ if (messageIds.length === chunks.length) {
806
+ try {
807
+ await recordDiscordMessages(entry, preparedArticle, messageIds);
808
+ messageRecordSaved = true;
809
+ } catch {
810
+ // A successful Discord post must not be reported as failed when local
811
+ // bookkeeping is unavailable; deletion can still be done manually.
812
+ }
685
813
  }
686
- return { messages: chunks.length };
814
+ return { messages: chunks.length, messageIds, messageRecordSaved };
687
815
  }
688
816
 
689
817
  function plainTextForPlatform(article, platformKey) {
690
818
  const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
819
+ // WeChat exports decorative empty spans containing <br> for layout. They
820
+ // must not become real line breaks in native editors; preserve only breaks
821
+ // that sit inside a node with actual text or media content.
822
+ $("br").each((_, element) => {
823
+ const parent = $(element).parent();
824
+ const hasMeaningfulSibling = parent
825
+ .contents()
826
+ .toArray()
827
+ .some(node => {
828
+ if (node === element) return false;
829
+ if (node.type === "text") return normalizeText(node.data).trim().length > 0;
830
+ if (node.type !== "tag") return false;
831
+ return $(node).is("img") || normalizeText($(node).text()).trim().length > 0;
832
+ });
833
+ if (!hasMeaningfulSibling) {
834
+ $(element).remove();
835
+ }
836
+ });
691
837
  $("br").replaceWith("\n");
692
838
  $("p,section,div,h1,h2,h3,h4,h5,h6,li,blockquote,tr").each((_, element) => {
693
839
  $(element).append("\n");
694
840
  });
695
841
  $("img").each((_, element) => {
696
842
  const image = $(element);
697
- image.replaceWith(image.attr("src") ? `\n${image.attr("src")}\n` : "");
843
+ const source = image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original");
844
+ image.replaceWith(source ? `\n${source}\n` : "");
698
845
  });
699
846
  const markdown = normalizeText($.root().text() || article.markdown)
700
847
  .replace(/[ \t]+\n/g, "\n")
@@ -1527,6 +1674,26 @@ export function resolveTapTapPublishingOptions(article, entry = {}) {
1527
1674
  };
1528
1675
  }
1529
1676
 
1677
+ export function resolveTapTapTitle(article, limit = 30) {
1678
+ const explicitTitle = tapTapMeta(article, "taptap_title");
1679
+ const sourceTitle = explicitTitle || normalizeText(article.title || article.payload?.article_title);
1680
+ const characters = Array.from(sourceTitle);
1681
+ if (characters.length <= limit || explicitTitle) {
1682
+ return sourceTitle;
1683
+ }
1684
+
1685
+ const prefix = characters.slice(0, limit).join("");
1686
+ const sentenceEnd = Math.max(...["。", "!", "?", "!", "?"].map(mark => prefix.lastIndexOf(mark)));
1687
+ if (sentenceEnd >= 12) {
1688
+ return prefix.slice(0, sentenceEnd + 1);
1689
+ }
1690
+ const phraseEnd = Math.max(...[",", ",", "、", ";", ";"].map(mark => prefix.lastIndexOf(mark)));
1691
+ if (phraseEnd >= 12) {
1692
+ return prefix.slice(0, phraseEnd + 1);
1693
+ }
1694
+ return `${characters.slice(0, Math.max(0, limit - 1)).join("")}…`;
1695
+ }
1696
+
1530
1697
  export function tapTapPublishingPlan(article, entry = {}) {
1531
1698
  const preparedArticle = prepareTapTapArticleForProject(article, entry);
1532
1699
  const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
@@ -1560,7 +1727,26 @@ function weiboMeta(article, ...keys) {
1560
1727
  }
1561
1728
 
1562
1729
  export function resolveWeiboArticleTitle(article) {
1563
- return weiboMeta(article, "weibo_title") || normalizeText(article.title || article.payload?.article_title);
1730
+ const explicitTitle = weiboMeta(article, "weibo_title");
1731
+ if (explicitTitle) {
1732
+ return explicitTitle;
1733
+ }
1734
+ const sourceTitle = normalizeText(article.title || article.payload?.article_title);
1735
+ const characters = Array.from(sourceTitle);
1736
+ if (characters.length <= 32) {
1737
+ return sourceTitle;
1738
+ }
1739
+
1740
+ const prefix = characters.slice(0, 32).join("");
1741
+ const sentenceEnd = Math.max(...["。", "!", "?", "!", "?"].map(mark => prefix.lastIndexOf(mark)));
1742
+ if (sentenceEnd >= 12) {
1743
+ return prefix.slice(0, sentenceEnd + 1);
1744
+ }
1745
+ const phraseEnd = Math.max(...[",", ",", "、", ";", ";"].map(mark => prefix.lastIndexOf(mark)));
1746
+ if (phraseEnd >= 12) {
1747
+ return prefix.slice(0, phraseEnd + 1);
1748
+ }
1749
+ return `${characters.slice(0, 31).join("")}…`;
1564
1750
  }
1565
1751
 
1566
1752
  export function resolveWeiboSuperTopic(article, entry = {}) {
@@ -1616,7 +1802,7 @@ export function buildWeiboRichContent(article) {
1616
1802
  structuredTables: true
1617
1803
  });
1618
1804
  const seenImages = new Set();
1619
- const items = content.items.filter(item => {
1805
+ const filteredItems = content.items.filter(item => {
1620
1806
  if (item.type !== "image") {
1621
1807
  return true;
1622
1808
  }
@@ -1629,8 +1815,9 @@ export function buildWeiboRichContent(article) {
1629
1815
  });
1630
1816
  return {
1631
1817
  ...content,
1632
- items,
1633
- images: items.filter(item => item.type === "image")
1818
+ items: filteredItems,
1819
+ plainText: filteredItems.map(item => normalizeText(item.plain)).filter(Boolean).join("\n"),
1820
+ images: filteredItems.filter(item => item.type === "image")
1634
1821
  };
1635
1822
  }
1636
1823
 
@@ -1870,8 +2057,13 @@ export function browserPlatformPageUrl(entry, type = "topic") {
1870
2057
 
1871
2058
  function imageSourcesFromArticle(article) {
1872
2059
  const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
1873
- return $("img[src]")
1874
- .map((_, element) => normalizeText($(element).attr("src")))
2060
+ return $("img")
2061
+ .map((_, element) => {
2062
+ const image = $(element);
2063
+ return normalizeText(
2064
+ image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original")
2065
+ );
2066
+ })
1875
2067
  .get()
1876
2068
  .filter(Boolean);
1877
2069
  }
@@ -2006,6 +2198,14 @@ function safeRichCssLength(value, options = {}) {
2006
2198
  return pattern.test(length) ? length : "";
2007
2199
  }
2008
2200
 
2201
+ function safeRichFontFamily(value) {
2202
+ const family = normalizeText(value).replace(/\s*!important\s*$/i, "");
2203
+ if (!family || !/^[\w\s,'"-]+$/u.test(family)) {
2204
+ return "";
2205
+ }
2206
+ return family;
2207
+ }
2208
+
2009
2209
  function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
2010
2210
  const wrappers = [];
2011
2211
  const add = name => {
@@ -2033,12 +2233,16 @@ function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
2033
2233
  const letterSpacing = preserveStyles
2034
2234
  ? safeRichCssLength(styleValue.match(/(?:^|;)\s*letter-spacing\s*:\s*([^;]+)/i)?.[1])
2035
2235
  : "";
2236
+ const fontFamily = preserveStyles
2237
+ ? safeRichFontFamily(styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1])
2238
+ : "";
2036
2239
  const spanStyles = [
2037
2240
  safeColor ? `color:${safeColor}` : "",
2038
2241
  safeBackground ? `background-color:${safeBackground}` : "",
2039
2242
  fontSize ? `font-size:${fontSize}` : "",
2040
2243
  lineHeight ? `line-height:${lineHeight}` : "",
2041
- letterSpacing ? `letter-spacing:${letterSpacing}` : ""
2244
+ letterSpacing ? `letter-spacing:${letterSpacing}` : "",
2245
+ fontFamily ? `font-family:${fontFamily}` : ""
2042
2246
  ]
2043
2247
  .filter(Boolean)
2044
2248
  .join(";");
@@ -2111,6 +2315,10 @@ function buildPlatformRichContent(article, options = {}) {
2111
2315
  );
2112
2316
  if (value) blockStyles.push(`${property}:${value}`);
2113
2317
  }
2318
+ const fontFamily = safeRichFontFamily(
2319
+ styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1]
2320
+ );
2321
+ if (fontFamily) blockStyles.push(`font-family:${fontFamily}`);
2114
2322
  }
2115
2323
  return blockStyles.length > 0 ? ` style="${blockStyles.join(";")}"` : "";
2116
2324
  };
@@ -2318,7 +2526,12 @@ function buildPlatformRichContent(article, options = {}) {
2318
2526
  });
2319
2527
  }
2320
2528
  for (const element of tableImages) {
2321
- const source = normalizeText($(element).attr("src") || $(element).attr("data-src"));
2529
+ const source = normalizeText(
2530
+ $(element).attr("src") ||
2531
+ $(element).attr("data-qcplay-src") ||
2532
+ $(element).attr("data-src") ||
2533
+ $(element).attr("data-original")
2534
+ );
2322
2535
  if (!source) continue;
2323
2536
  const caption = normalizeText($(element).closest("figure").find("figcaption").first().text()) ||
2324
2537
  normalizeText($(element).attr("alt") || $(element).attr("title")).slice(0, 50);
@@ -2348,7 +2561,12 @@ function buildPlatformRichContent(article, options = {}) {
2348
2561
  }
2349
2562
  if (tag === "img") {
2350
2563
  flushHtml();
2351
- const source = normalizeText(element.attr("src") || element.attr("data-src"));
2564
+ const source = normalizeText(
2565
+ element.attr("src") ||
2566
+ element.attr("data-qcplay-src") ||
2567
+ element.attr("data-src") ||
2568
+ element.attr("data-original")
2569
+ );
2352
2570
  if (source) {
2353
2571
  const caption = normalizeText(element.closest("figure").find("figcaption").first().text()) ||
2354
2572
  normalizeText(element.attr("alt") || element.attr("title")).slice(0, 50);
@@ -2479,17 +2697,66 @@ function buildPlatformRichContent(article, options = {}) {
2479
2697
  const fallback = normalizeText(article.markdown);
2480
2698
  items.push({ type: "html", html: `<p>${escapeTapTapHtml(fallback)}</p>`, plain: fallback });
2481
2699
  }
2700
+ const normalizedItems = normalizeNumberedSectionHeadings(items);
2482
2701
  return {
2483
- items,
2702
+ items: normalizedItems,
2484
2703
  images,
2485
2704
  links,
2486
- plainText: items
2705
+ plainText: normalizedItems
2487
2706
  .filter(item => item.type === "html")
2488
2707
  .map(item => item.plain)
2489
2708
  .join("\n")
2490
2709
  };
2491
2710
  }
2492
2711
 
2712
+ function isStandaloneSectionNumber(value) {
2713
+ return /^(?:(?:第\s*)?\d{1,3}|[一二三四五六七八九十]+)(?:[、..::])?$/.test(normalizeText(value));
2714
+ }
2715
+
2716
+ function mergeNumberedSectionHeading(numberItem, headingItem) {
2717
+ const number = normalizeText(numberItem.plain);
2718
+ const title = normalizeText(headingItem.plain);
2719
+ const opening = String(headingItem.html || "").match(/^<(p|h[1-3])([^>]*)>/i);
2720
+ const closing = String(headingItem.html || "").match(/<\/(p|h[1-3])>$/i);
2721
+ if (!opening || !closing) return null;
2722
+ const headingHtml = String(headingItem.html || "").replace(/^<(p|h[1-3])[^>]*>|<\/(p|h[1-3])>$/gi, "");
2723
+ return {
2724
+ ...headingItem,
2725
+ html: `<${opening[1]}${opening[2]}>${escapeTapTapHtml(number)} ${headingHtml}</${closing[1]}>`,
2726
+ plain: `${number} ${title}`
2727
+ };
2728
+ }
2729
+
2730
+ // WeChat articles sometimes split a section marker and its title into two blocks.
2731
+ // Keep the heading readable on one line while retaining the intended blank space.
2732
+ function normalizeNumberedSectionHeadings(items) {
2733
+ const normalized = [];
2734
+ for (let index = 0; index < items.length; index += 1) {
2735
+ const current = items[index];
2736
+ const following = items[index + 1];
2737
+ const canMerge =
2738
+ current?.type === "html" &&
2739
+ following?.type === "html" &&
2740
+ current.block === "p" &&
2741
+ isStandaloneSectionNumber(current.plain) &&
2742
+ (/^h[1-3]$/.test(following.block || "") || /^\d{2}$/.test(normalizeText(current.plain)));
2743
+ if (!canMerge) {
2744
+ normalized.push(current);
2745
+ continue;
2746
+ }
2747
+ const merged = mergeNumberedSectionHeading(current, following);
2748
+ if (!merged) {
2749
+ normalized.push(current);
2750
+ continue;
2751
+ }
2752
+ normalized.push({ type: "html", html: "<p><br></p>", plain: "", block: "p", align: "" });
2753
+ normalized.push(merged);
2754
+ normalized.push({ type: "html", html: "<p><br></p>", plain: "", block: "p", align: "" });
2755
+ index += 1;
2756
+ }
2757
+ return normalized;
2758
+ }
2759
+
2493
2760
  export function buildBilibiliRichContent(article) {
2494
2761
  return buildPlatformRichContent(article, {
2495
2762
  preserveBackground: true,
@@ -2507,9 +2774,29 @@ export function buildHaoyouRichContent(article) {
2507
2774
  structuredTables: true
2508
2775
  });
2509
2776
  const flattenInlineSpans = isWechatImportedArticle(article);
2777
+ // Most nested WeChat spans are flattened for the Haoyou Quill clipboard
2778
+ // parser, but a secret code is commonly the coloured line immediately before
2779
+ // a “red text is the code” hint. Keeping that tiny group intact preserves the
2780
+ // source visual distinction without exposing the rest of the article to the
2781
+ // parser issue.
2782
+ const secretStyleItems = new Set();
2783
+ content.items.forEach((item, index) => {
2784
+ if (item.type !== "html") return;
2785
+ const text = normalizeText(item.plain);
2786
+ if (!/(?:密令|兑换码|礼包码|福利码)/.test(text)) return;
2787
+ secretStyleItems.add(index);
2788
+ if (/红字[^。;;\n]{0,20}(?:密令|兑换码|礼包码|福利码)/.test(text)) {
2789
+ for (let previous = index - 1; previous >= 0; previous -= 1) {
2790
+ if (content.items[previous].type === "html") {
2791
+ secretStyleItems.add(previous);
2792
+ break;
2793
+ }
2794
+ }
2795
+ }
2796
+ });
2510
2797
  return {
2511
2798
  ...content,
2512
- items: content.items.map(item =>
2799
+ items: content.items.map((item, index) =>
2513
2800
  item.type === "html"
2514
2801
  ? {
2515
2802
  ...item,
@@ -2520,13 +2807,39 @@ export function buildHaoyouRichContent(article) {
2520
2807
  // WeChat's nested styled spans can also truncate their trailing
2521
2808
  // text in the Haoyou Quill clipboard parser. Keep their text and
2522
2809
  // semantic children while dropping only the wrapper.
2523
- .replace(flattenInlineSpans ? /<\/?span\b[^>]*>/gi : /$^/, "")
2810
+ .replace(flattenInlineSpans && !secretStyleItems.has(index) ? /<\/?span\b[^>]*>/gi : /$^/, "")
2524
2811
  }
2525
2812
  : item
2526
2813
  )
2527
2814
  };
2528
2815
  }
2529
2816
 
2817
+ export function buildHaoyouPublishRichContent(article) {
2818
+ const content = buildHaoyouRichContent(article);
2819
+ return {
2820
+ ...content,
2821
+ items: content.items.map(item => {
2822
+ if (item.type !== "html") return item;
2823
+ const $ = cheerio.load(item.html, null, false);
2824
+ $("em,i").each((_, element) => $(element).replaceWith($(element).contents()));
2825
+ $("[style]").each((_, element) => {
2826
+ const style = String($(element).attr("style") || "")
2827
+ .split(";")
2828
+ .filter(rule => !/^\s*(?:color|background(?:-color)?|font-style)\s*:/i.test(rule))
2829
+ .map(rule => rule.trim())
2830
+ .filter(Boolean)
2831
+ .join(";");
2832
+ if (style) {
2833
+ $(element).attr("style", style);
2834
+ } else {
2835
+ $(element).removeAttr("style");
2836
+ }
2837
+ });
2838
+ return { ...item, html: $.html() };
2839
+ })
2840
+ };
2841
+ }
2842
+
2530
2843
  function contentTypeForFile(fileName, fallback = "application/octet-stream") {
2531
2844
  const extension = path.extname(fileName).toLowerCase();
2532
2845
  return (
@@ -2748,7 +3061,7 @@ async function waitForTapTapRichEditorState(page, bodyInput, content, options =
2748
3061
  throw new Error("TapTap 正文写入后未能稳定完成回读校验");
2749
3062
  }
2750
3063
 
2751
- export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText) {
3064
+ export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText, options = {}) {
2752
3065
  if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
2753
3066
  await fillLocator(bodyInput, fallbackText);
2754
3067
  await verifyFilledValue(bodyInput, fallbackText, "正文");
@@ -2758,7 +3071,12 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2758
3071
  const content = buildTapTapRichContent(article);
2759
3072
  const inlineImages = settings.type === "topic";
2760
3073
  const toolbarImageInput = inlineImages
2761
- ? await firstExisting(page, ['.tap-editor-toolbar input[type="file"][accept*="image"]'])
3074
+ ? await firstExisting(page, [
3075
+ '.tap-editor-toolbar input[type="file"][accept*="image"]',
3076
+ '.tap-editor__toolbar input[type="file"][accept*="image"]',
3077
+ '[class*="tap-editor"][class*="toolbar"] input[type="file"][accept*="image"]',
3078
+ 'input[type="file"][accept*="image"]:not([class*="cover"]):not([class*="post-setting"])'
3079
+ ])
2762
3080
  : null;
2763
3081
  if (inlineImages && content.images.length > 0 && (!toolbarImageInput || typeof toolbarImageInput.setInputFiles !== "function")) {
2764
3082
  throw new Error("TapTap 长帖编辑器中未找到正文图片上传控件");
@@ -2777,6 +3095,9 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
2777
3095
  plainParts.push(item.plain);
2778
3096
  continue;
2779
3097
  }
3098
+ if (options.skipImages) {
3099
+ continue;
3100
+ }
2780
3101
  if (inlineImages) {
2781
3102
  const marker = `${markerPrefix}${imageMarkers.length}`;
2782
3103
  htmlParts.push(`<p>${marker}</p>`);
@@ -2973,7 +3294,7 @@ async function removeBilibiliFailedImage(bodyInput) {
2973
3294
  });
2974
3295
  }
2975
3296
 
2976
- async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
3297
+ export async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
2977
3298
  const deadline = Date.now() + 120000;
2978
3299
  const startedAt = Date.now();
2979
3300
  let stableChecks = 0;
@@ -2982,7 +3303,6 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
2982
3303
  const failed = await bodyInput
2983
3304
  .locator('.upload-fail, .image-upload-error, [class*="upload-error"], [class*="retry"]')
2984
3305
  .count();
2985
- const pending = await pendingUploadCount(bodyInput);
2986
3306
  if (failed > 0) {
2987
3307
  const failureText = await bodyInput.evaluate(element => {
2988
3308
  const nodes = [...element.querySelectorAll(
@@ -3000,7 +3320,10 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
3000
3320
  }
3001
3321
  throw new Error(`B站正文图片上传失败: ${source}`);
3002
3322
  }
3003
- if (count > previousCount && pending === 0 && Date.now() - startedAt >= 1200) {
3323
+ // The B站 editor may keep a visible toolbar spinner after the image node
3324
+ // is inserted. Treat the new, stable image node as the upload receipt;
3325
+ // failure selectors above still catch explicit upload errors.
3326
+ if (count > previousCount && Date.now() - startedAt >= 1200) {
3004
3327
  stableChecks += 1;
3005
3328
  if (stableChecks >= 3) {
3006
3329
  return;
@@ -3021,6 +3344,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3021
3344
  }
3022
3345
 
3023
3346
  const content = buildBilibiliRichContent(article);
3347
+ options.onProgress?.(`B站 正在写入文字正文(${content.plainText.length} 字)`);
3024
3348
  await bodyInput.fill("");
3025
3349
  await bodyInput.click();
3026
3350
  const markerPrefix = `QCPLAY_BILIBILI_IMAGE_${Date.now()}_`;
@@ -3045,11 +3369,26 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3045
3369
  plain: plainParts.join("\n")
3046
3370
  });
3047
3371
  await page.waitForTimeout(100);
3372
+ options.onProgress?.("B站 文字正文已写入,正在回读校验");
3373
+ options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
3374
+ }
3375
+
3376
+ if (options.previewOnly) {
3377
+ options.onProgress?.("B站 文字正文已写入,等待用户在页面中核对");
3378
+ return {
3379
+ rich: true,
3380
+ images: 0,
3381
+ imagesPending: content.images.length,
3382
+ links: content.links.length,
3383
+ plainText: content.plainText
3384
+ };
3048
3385
  }
3049
3386
 
3050
3387
  const payloadCache = new Map();
3051
3388
  let removedImages = 0;
3052
- for (const { marker, item, index } of imageMarkers) {
3389
+ for (let markerIndex = 0; markerIndex < imageMarkers.length; markerIndex += 1) {
3390
+ const { marker, item, index } = imageMarkers[markerIndex];
3391
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在定位插入位置`);
3053
3392
  const selected = await bodyInput.evaluate((element, markerValue) => {
3054
3393
  const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
3055
3394
  let textNode = walker.nextNode();
@@ -3073,6 +3412,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3073
3412
  throw new Error(`B站正文图片占位位置丢失: ${item.source}`);
3074
3413
  }
3075
3414
  await page.keyboard.press("Backspace");
3415
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在上传`);
3076
3416
  let payload = payloadCache.get(item.source);
3077
3417
  if (!payload) {
3078
3418
  payload = await imageUploadPayload(item.source, article.articleFile, index, "B站");
@@ -3086,26 +3426,38 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3086
3426
  });
3087
3427
  if (uploadResult?.removed) {
3088
3428
  removedImages += 1;
3429
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:不合规,已从正文移除`);
3430
+ } else {
3431
+ options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:上传回执已确认`);
3089
3432
  }
3090
3433
  }
3091
3434
 
3435
+ options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
3092
3436
  let state = null;
3093
3437
  let previousTextLength = -1;
3094
3438
  let stableChecks = 0;
3095
3439
  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
- }));
3440
+ state = await bodyInput
3441
+ .evaluate(
3442
+ element => ({
3443
+ text: element.innerText,
3444
+ images: element.querySelectorAll("img").length,
3445
+ failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
3446
+ links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
3447
+ }),
3448
+ undefined,
3449
+ { timeout: 15000 }
3450
+ )
3451
+ .catch(error => {
3452
+ throw new Error(`B站正文回读超时或失败:${error.message || error}`);
3453
+ });
3102
3454
  const currentLength = normalizeText(state.text).length;
3103
3455
  stableChecks = currentLength === previousTextLength ? stableChecks + 1 : 0;
3104
3456
  previousTextLength = currentLength;
3105
3457
  if (stableChecks >= 3) break;
3106
3458
  await page.waitForTimeout(250);
3107
3459
  }
3108
- const expectedImages = content.images.length - removedImages;
3460
+ const expectedImages = options.skipImages ? 0 : content.images.length - removedImages;
3109
3461
  if (state.failedImages > 0 || state.images < expectedImages) {
3110
3462
  throw new Error(`B站正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
3111
3463
  }
@@ -3128,7 +3480,14 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3128
3480
  throw new Error(`B站正文超链接未正确写入: ${link.href}`);
3129
3481
  }
3130
3482
  }
3131
- return { rich: true, images: state.images, links: content.links.length, plainText: content.plainText };
3483
+ options.onProgress?.("B站 文字正文回读校验通过");
3484
+ return {
3485
+ rich: true,
3486
+ images: state.images,
3487
+ ...(options.skipImages ? { imagesPending: content.images.length } : {}),
3488
+ links: content.links.length,
3489
+ plainText: content.plainText
3490
+ };
3132
3491
  }
3133
3492
 
3134
3493
  function haoyouImageUploadPayload(payload, index) {
@@ -3197,8 +3556,8 @@ async function waitForHaoyouInlineImage(page, bodyInput, imageId, source) {
3197
3556
  throw new Error(`好游快爆正文图片上传超时: ${source}`);
3198
3557
  }
3199
3558
 
3200
- export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText) {
3201
- const content = buildHaoyouRichContent(article);
3559
+ export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText, options = {}) {
3560
+ const content = buildHaoyouPublishRichContent(article);
3202
3561
  if (typeof bodyInput.evaluate !== "function") {
3203
3562
  await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
3204
3563
  return { rich: false, images: 0, links: 0, plainText: fallbackText };
@@ -3240,6 +3599,10 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3240
3599
  continue;
3241
3600
  }
3242
3601
 
3602
+ if (options.skipImages) {
3603
+ imageIndex += 1;
3604
+ continue;
3605
+ }
3243
3606
  let upload = payloadCache.get(item.source);
3244
3607
  if (!upload) {
3245
3608
  upload = haoyouImageUploadPayload(
@@ -3338,8 +3701,9 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3338
3701
  if (stableChecks >= 3) break;
3339
3702
  await page.waitForTimeout(250);
3340
3703
  }
3341
- if (state.failedImages > 0 || state.pendingImages > 0 || state.images < content.images.length) {
3342
- throw new Error(`好游快爆正文图片写入不完整,预期 ${content.images.length} 张,实际 ${state.images} 张`);
3704
+ const expectedImages = options.skipImages ? 0 : content.images.length;
3705
+ if (state.failedImages > 0 || state.pendingImages > 0 || state.images < expectedImages) {
3706
+ throw new Error(`好游快爆正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
3343
3707
  }
3344
3708
  const expectedFragments = content.items
3345
3709
  .filter(item => item.type === "html" && item.plain)
@@ -3358,6 +3722,7 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
3358
3722
  return {
3359
3723
  rich: true,
3360
3724
  images: state.images,
3725
+ ...(options.skipImages ? { imagesPending: content.images.length } : {}),
3361
3726
  links: state.links,
3362
3727
  headings: state.headings,
3363
3728
  formatted: state.formatted,
@@ -3372,6 +3737,51 @@ function safeProfileName(entry) {
3372
3737
  .slice(0, 80);
3373
3738
  }
3374
3739
 
3740
+ function browserProfileDir(entry) {
3741
+ return path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
3742
+ }
3743
+
3744
+ function browserSubmissionKey(entry, article) {
3745
+ const source = normalizeText(article.meta?.source_url || article.payload?.source_url);
3746
+ const content = normalizeText(article.html || article.markdown || "");
3747
+ return createHash("sha256")
3748
+ .update(`${entry.platformKey}\n${entry.project}\n${entry.region}\n${source}\n${article.title}\n${content}`)
3749
+ .digest("hex");
3750
+ }
3751
+
3752
+ async function readBrowserSubmissionState() {
3753
+ try {
3754
+ const value = JSON.parse(await fs.promises.readFile(BROWSER_SUBMISSION_STATE_FILE, "utf8"));
3755
+ return value && typeof value === "object" ? value : {};
3756
+ } catch {
3757
+ return {};
3758
+ }
3759
+ }
3760
+
3761
+ async function ensureBrowserSubmissionCooldown(entry, article, options) {
3762
+ if (!options.directPublish && !options.publishDraft) return "";
3763
+ const key = browserSubmissionKey(entry, article);
3764
+ const previous = Number((await readBrowserSubmissionState())[key] || 0);
3765
+ const remaining = BROWSER_SUBMISSION_COOLDOWN_MS - (Date.now() - previous);
3766
+ if (remaining > 0) {
3767
+ throw new Error(`${entry.platform} 同一篇文章刚刚已提交,请等待 ${Math.ceil(remaining / 1000)} 秒后再重试,避免触发平台风控或重复发布`);
3768
+ }
3769
+ return key;
3770
+ }
3771
+
3772
+ async function recordBrowserSubmission(key) {
3773
+ if (!key) return;
3774
+ const state = await readBrowserSubmissionState();
3775
+ state[key] = Date.now();
3776
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
3777
+ for (const [candidate, timestamp] of Object.entries(state)) {
3778
+ if (!Number.isFinite(Number(timestamp)) || Number(timestamp) < cutoff) delete state[candidate];
3779
+ }
3780
+ await fs.promises.mkdir(path.dirname(BROWSER_SUBMISSION_STATE_FILE), { recursive: true, mode: 0o700 });
3781
+ await fs.promises.writeFile(BROWSER_SUBMISSION_STATE_FILE, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
3782
+ await fs.promises.chmod(BROWSER_SUBMISSION_STATE_FILE, 0o600).catch(() => {});
3783
+ }
3784
+
3375
3785
  function isNavigationContextError(error) {
3376
3786
  const message = String(error?.message || error);
3377
3787
  return /execution context was destroyed|cannot find context with specified id|frame was detached|navigation.*interrupted/i.test(
@@ -3595,6 +4005,85 @@ async function fillLocator(locator, value) {
3595
4005
  await locator.fill(value);
3596
4006
  }
3597
4007
 
4008
+ export function replaceFirstTextValue(value, find, replacement) {
4009
+ const source = String(value ?? "");
4010
+ const needle = String(find ?? "");
4011
+ const next = String(replacement ?? "");
4012
+ if (!needle) {
4013
+ throw new Error("待替换的原文不能为空");
4014
+ }
4015
+ const index = source.indexOf(needle);
4016
+ if (index < 0) {
4017
+ throw new Error("当前编辑页中未找到指定原文");
4018
+ }
4019
+ return `${source.slice(0, index)}${next}${source.slice(index + needle.length)}`;
4020
+ }
4021
+
4022
+ async function replaceTextInEditable(locator, find, replacement) {
4023
+ const before = String(
4024
+ typeof locator.inputValue === "function"
4025
+ ? await locator.inputValue().catch(() => "")
4026
+ : await locator.innerText().catch(() => "")
4027
+ );
4028
+ replaceFirstTextValue(before, find, replacement);
4029
+ const result = await locator.evaluate(
4030
+ (element, values) => {
4031
+ const source = "value" in element ? String(element.value ?? "") : String(element.innerText ?? element.textContent ?? "");
4032
+ const index = source.indexOf(values.find);
4033
+ if (index < 0) return { changed: false, reason: "not-found" };
4034
+
4035
+ if ("value" in element) {
4036
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
4037
+ const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
4038
+ const next = `${source.slice(0, index)}${values.replacement}${source.slice(index + values.find.length)}`;
4039
+ if (setter) {
4040
+ setter.call(element, next);
4041
+ } else {
4042
+ element.value = next;
4043
+ }
4044
+ element.dispatchEvent(new Event("input", { bubbles: true }));
4045
+ element.dispatchEvent(new Event("change", { bubbles: true }));
4046
+ return { changed: true, value: next };
4047
+ }
4048
+
4049
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
4050
+ let node;
4051
+ while ((node = walker.nextNode())) {
4052
+ const nodeIndex = node.data.indexOf(values.find);
4053
+ if (nodeIndex < 0) continue;
4054
+ const range = document.createRange();
4055
+ range.setStart(node, nodeIndex);
4056
+ range.setEnd(node, nodeIndex + values.find.length);
4057
+ range.deleteContents();
4058
+ const fragment = document.createDocumentFragment();
4059
+ const lines = values.replacement.replace(/\r\n?/g, "\n").split("\n");
4060
+ lines.forEach((line, lineIndex) => {
4061
+ if (lineIndex > 0) fragment.append(document.createElement("br"));
4062
+ if (line) fragment.append(document.createTextNode(line));
4063
+ });
4064
+ range.insertNode(fragment);
4065
+ element.dispatchEvent(new Event("input", { bubbles: true }));
4066
+ return { changed: true, value: String(element.innerText ?? element.textContent ?? "") };
4067
+ }
4068
+ return { changed: false, reason: "cross-node" };
4069
+ },
4070
+ { find, replacement }
4071
+ );
4072
+ if (!result?.changed) {
4073
+ const detail = result?.reason === "cross-node" ? "原文跨越多个富文本节点,无法在不破坏排版的情况下替换" : "当前编辑页中未找到指定原文";
4074
+ throw new Error(detail);
4075
+ }
4076
+ const actual = String(
4077
+ typeof locator.inputValue === "function"
4078
+ ? await locator.inputValue().catch(() => "")
4079
+ : await locator.innerText().catch(() => "")
4080
+ );
4081
+ if (!actual.includes(replacement)) {
4082
+ throw new Error("编辑器未回读到修改后的文本");
4083
+ }
4084
+ return actual;
4085
+ }
4086
+
3598
4087
  async function verifyFilledValue(locator, expected, label, platform = "TapTap") {
3599
4088
  let actual = "";
3600
4089
  if (typeof locator.inputValue === "function") {
@@ -3664,11 +4153,25 @@ async function waitForUser(message, streams = {}) {
3664
4153
  }
3665
4154
 
3666
4155
  function reviewApprovalAccepted(answer) {
3667
- return ["", "发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
4156
+ return ["发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
3668
4157
  }
3669
4158
 
3670
4159
  function reviewApprovalCancelled(answer) {
3671
- return ["取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
4160
+ return ["", "取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
4161
+ }
4162
+
4163
+ export function draftApprovalAction(answer) {
4164
+ const key = normalizeKey(answer);
4165
+ if (["发布", "上线", "正式发布", "publish", "online", "yes", "y"].includes(key)) {
4166
+ return "publish";
4167
+ }
4168
+ if (["保存草稿", "保存草稿箱", "保存到草稿箱", "存草稿", "保留草稿", "草稿", "keep-draft", "keepdraft", "draft"].includes(key)) {
4169
+ return "draft";
4170
+ }
4171
+ if (reviewApprovalCancelled(key)) {
4172
+ return "cancel";
4173
+ }
4174
+ return "";
3672
4175
  }
3673
4176
 
3674
4177
  async function waitForReviewApproval(message, options = {}) {
@@ -3678,7 +4181,7 @@ async function waitForReviewApproval(message, options = {}) {
3678
4181
  if (reviewApprovalCancelled(answer)) {
3679
4182
  throw new Error("用户已取消正式发布,草稿未提交");
3680
4183
  }
3681
- throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或直接回车`);
4184
+ throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或“取消”`);
3682
4185
  }
3683
4186
  const input = options.streams?.input || process.stdin;
3684
4187
  const output = options.streams?.output || process.stdout;
@@ -3690,13 +4193,13 @@ async function waitForReviewApproval(message, options = {}) {
3690
4193
  for (;;) {
3691
4194
  const answer = await question(
3692
4195
  rl,
3693
- `${message}\n输入“可以发布”或直接按 Enter 继续,输入“取消”停止: `
4196
+ `${message}\n输入“可以发布”继续;直接按 Enter 默认取消,输入“取消”停止: `
3694
4197
  );
3695
4198
  if (reviewApprovalAccepted(answer)) return;
3696
4199
  if (reviewApprovalCancelled(answer)) {
3697
4200
  throw new Error("用户已取消正式发布,草稿未提交");
3698
4201
  }
3699
- output.write("无法识别输入,请输入“可以发布”、直接按 Enter 或输入“取消”。\n");
4202
+ output.write("无法识别输入,请输入“可以发布”或“取消”(直接按 Enter 会取消)。\n");
3700
4203
  }
3701
4204
  } finally {
3702
4205
  rl.close();
@@ -3708,6 +4211,19 @@ async function waitForReviewApproval(message, options = {}) {
3708
4211
  * This is intentionally separate from publishWithBrowser so page structure and login
3709
4212
  * flows can be checked against the real site before running an article publish.
3710
4213
  */
4214
+ async function releaseAwaitingActionBrowser(page) {
4215
+ const context = typeof page?.context === "function" ? page.context() : null;
4216
+ if (typeof context?.__qcplayDetach !== "function") {
4217
+ return;
4218
+ }
4219
+ await context.__qcplayDetach();
4220
+ }
4221
+
4222
+ async function awaitingActionResult(page, result) {
4223
+ await releaseAwaitingActionBrowser(page);
4224
+ return result;
4225
+ }
4226
+
3711
4227
  export async function openPublishPage(entry, options = {}) {
3712
4228
  const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
3713
4229
  if (!spec || entry.publisher !== "browser") {
@@ -3718,13 +4234,21 @@ export async function openPublishPage(entry, options = {}) {
3718
4234
  throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
3719
4235
  }
3720
4236
 
3721
- const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
4237
+ const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
3722
4238
  const ownsContext = !options.context;
3723
4239
  try {
3724
4240
  const page = context.pages()[0] || (await context.newPage());
3725
4241
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
3726
4242
  await afterNavigation(page);
3727
4243
  options.onProgress?.(`已打开真实${entry.platform}发布页面: ${page.url()}`);
4244
+ if (options.awaitAIAction) {
4245
+ return awaitingActionResult(page, {
4246
+ url: page.url(),
4247
+ status: "awaiting_action",
4248
+ approvalRequired: true,
4249
+ approvalActions: ["publish", "save_draft", "cancel"]
4250
+ });
4251
+ }
3728
4252
  const prompt = options.waitForUser || (message => waitForUser(message, options.streams));
3729
4253
  const promptMessage = options.accountSwitch
3730
4254
  ? `${entry.platform}账号切换页面已打开。请在浏览器中完成账号切换,确认当前账号正确后按 Enter 继续后续发布流程`
@@ -3732,22 +4256,32 @@ export async function openPublishPage(entry, options = {}) {
3732
4256
  await prompt(promptMessage);
3733
4257
  return { url: page.url(), status: "opened" };
3734
4258
  } finally {
3735
- if (ownsContext) {
3736
- await context.close().catch(() => {});
4259
+ if (ownsContext && !options.awaitAIAction) {
4260
+ await closeBrowserContext(context);
3737
4261
  }
3738
4262
  }
3739
4263
  }
3740
4264
 
3741
4265
  async function reviewPreparedDraft(page, entry, options) {
3742
4266
  if (!options.reviewDraft) {
3743
- return;
4267
+ return null;
3744
4268
  }
3745
4269
  await page.waitForTimeout(500);
4270
+ if (options.awaitAIAction) {
4271
+ options.onProgress?.(`${entry.platform} 标题和正文已填入,正在等待 AI 对话授权`);
4272
+ return awaitingActionResult(page, {
4273
+ url: page.url(),
4274
+ status: "awaiting_action",
4275
+ approvalRequired: true,
4276
+ approvalActions: ["publish", "save_draft", "cancel"]
4277
+ });
4278
+ }
3746
4279
  await waitForReviewApproval(
3747
4280
  `${entry.platform} 待发布稿已准备,请在浏览器中预览标题、正文、图片和发布设置`,
3748
4281
  options
3749
4282
  );
3750
4283
  options.onProgress?.(`${entry.platform} 草稿预览已确认,正在正式发布`);
4284
+ return null;
3751
4285
  }
3752
4286
 
3753
4287
  async function editorIsReady(page, spec) {
@@ -3758,6 +4292,60 @@ async function editorIsReady(page, spec) {
3758
4292
  return Boolean(await firstVisible(page, spec.body));
3759
4293
  }
3760
4294
 
4295
+ async function findPreparedEditorPage(context, spec) {
4296
+ const deadline = Date.now() + 10000;
4297
+ do {
4298
+ const pages = context.pages().filter(page => !(typeof page.isClosed === "function" && page.isClosed()));
4299
+ for (const page of [...pages].reverse()) {
4300
+ if (await editorIsReady(page, spec)) return page;
4301
+ }
4302
+ const page = pages.at(-1);
4303
+ if (page && typeof page.waitForTimeout === "function") {
4304
+ await page.waitForTimeout(200);
4305
+ } else {
4306
+ await new Promise(resolve => setTimeout(resolve, 200));
4307
+ }
4308
+ } while (Date.now() < deadline);
4309
+ return null;
4310
+ }
4311
+
4312
+ export async function editPreparedPlatformPage(entry, revision, options = {}) {
4313
+ const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
4314
+ if (!spec || entry.publisher !== "browser") {
4315
+ throw new Error(`${entry.platform} 不支持编辑已准备页面`);
4316
+ }
4317
+ const target = normalizeKey(revision?.target || "body");
4318
+ if (!new Set(["title", "body"]).has(target)) {
4319
+ throw new Error("编辑位置只能是 title 或 body");
4320
+ }
4321
+ const find = String(revision?.find ?? "");
4322
+ const replacement = String(revision?.replacement ?? "");
4323
+ if (!find || !replacement) {
4324
+ throw new Error("编辑需要同时提供原文和新文");
4325
+ }
4326
+
4327
+ const { chromium } = await import("playwright-core");
4328
+ const context = await reconnectDetachedBrowserContext(browserProfileDir(entry), chromium);
4329
+ if (!context) {
4330
+ throw new Error(`${entry.platform} 没有可继续编辑的已准备页面,请先重新准备该平台的发布页`);
4331
+ }
4332
+ try {
4333
+ const page = await findPreparedEditorPage(context, spec);
4334
+ if (!page) {
4335
+ throw new Error(`${entry.platform} 已准备页面中未找到标题和正文编辑器`);
4336
+ }
4337
+ const field = await waitForVisible(page, target === "title" ? spec.title : spec.body, 3000);
4338
+ if (!field) {
4339
+ throw new Error(`${entry.platform} 页面中未找到${target === "title" ? "标题" : "正文"}编辑器`);
4340
+ }
4341
+ const value = await replaceTextInEditable(field, find, replacement);
4342
+ options.onProgress?.(`${entry.platform}${target === "title" ? "标题" : "正文"}已修改并回读确认`);
4343
+ return { url: page.url(), target, value };
4344
+ } finally {
4345
+ await closeBrowserContext(context);
4346
+ }
4347
+ }
4348
+
3761
4349
  async function openPlatformLogin(page, entry, spec) {
3762
4350
  if (spec.loginPage) {
3763
4351
  await page.goto(spec.loginPage, { waitUntil: "domcontentloaded", timeout: 60000 });
@@ -4379,16 +4967,16 @@ async function uploadTapTapCover(page, source, articleFile) {
4379
4967
 
4380
4968
  async function publishTapTap(page, entry, article, spec, options, promptUser) {
4381
4969
  let preparedArticle = prepareTapTapArticleForProject(article, entry);
4382
- const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
4970
+ const settings = {
4971
+ ...resolveTapTapPublishingOptions(preparedArticle, entry),
4972
+ ...(options.draftOnly ? { draft: true, scheduled: "" } : {})
4973
+ };
4383
4974
  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
- }
4975
+ preparedArticle = { ...preparedArticle, title: resolveTapTapTitle(preparedArticle, titleLimit) };
4976
+ if (Array.from(preparedArticle.title).length > titleLimit) {
4977
+ throw new Error(
4978
+ `TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${Array.from(preparedArticle.title).length} 个字符`
4979
+ );
4392
4980
  }
4393
4981
  const richContent = buildTapTapRichContent(preparedArticle);
4394
4982
  const body = richContent.plainText || plainTextForPlatform(preparedArticle, "taptap");
@@ -4413,7 +5001,14 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4413
5001
  if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
4414
5002
  throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
4415
5003
  }
4416
- await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
5004
+ const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
5005
+ if (options.publishDraft) {
5006
+ if (!resumedDraft) {
5007
+ throw new Error("未找到标题匹配的 TapTap 草稿,已停止以避免重新写入内容");
5008
+ }
5009
+ const confirmation = await submitTapTap(page, { ...settings, draft: false, scheduled: "" }, options, promptUser);
5010
+ return { url: page.url(), status: "published", type: settings.type, resumedDraft: true, ...confirmation };
5011
+ }
4417
5012
 
4418
5013
  if (settings.type === "moment") {
4419
5014
  const imageSources = settings.images.length
@@ -4450,9 +5045,54 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4450
5045
  await applyTapTapForum(page, settings.forum, promptUser, { project: entry.project });
4451
5046
  await applyTapTapSchedule(page, settings.scheduled, promptUser);
4452
5047
  if (!settings.draft) {
4453
- await reviewPreparedDraft(page, entry, options);
5048
+ const review = await reviewPreparedDraft(page, entry, options);
5049
+ if (review) {
5050
+ return { ...review, type: settings.type };
5051
+ }
4454
5052
  }
4455
5053
  const confirmation = await submitTapTap(page, settings, options, promptUser);
5054
+ if (options.draftOnly) {
5055
+ return {
5056
+ url: page.url(),
5057
+ status: "draft",
5058
+ type: settings.type,
5059
+ draftOnly: true,
5060
+ approvalRequired: true,
5061
+ approvalActions: ["publish", "cancel"],
5062
+ ...confirmation
5063
+ };
5064
+ }
5065
+ if (settings.draft && typeof options.waitForDraftApproval === "function") {
5066
+ const answer = await options.waitForDraftApproval(
5067
+ "TapTap 草稿已保存,页面保持打开。请选择“发布/上线”“保存草稿”或“取消”"
5068
+ );
5069
+ const action = draftApprovalAction(answer);
5070
+ if (action === "publish") {
5071
+ const publishConfirmation = await submitTapTap(
5072
+ page,
5073
+ { ...settings, draft: false },
5074
+ { ...options, reviewDraft: false, directPublish: true },
5075
+ promptUser
5076
+ );
5077
+ return {
5078
+ url: page.url(),
5079
+ status: "published",
5080
+ type: settings.type,
5081
+ draftConfirmation: confirmation.confirmation || "response",
5082
+ ...publishConfirmation
5083
+ };
5084
+ }
5085
+ if (!action) {
5086
+ throw new Error(`无法识别草稿授权“${normalizeText(answer)}”,请选择发布、保存草稿或取消`);
5087
+ }
5088
+ return {
5089
+ url: page.url(),
5090
+ status: "draft",
5091
+ type: settings.type,
5092
+ draftApproval: action,
5093
+ ...confirmation
5094
+ };
5095
+ }
4456
5096
  if (options.keepOpen) {
4457
5097
  await promptUser(
4458
5098
  `TapTap ${settings.draft ? "草稿保存" : settings.scheduled ? "定时发布" : "发布"}已收到成功回执,浏览器保持打开供检查`
@@ -4801,9 +5441,63 @@ async function submitBilibili(page, spec, settings, article, metadataApplied, op
4801
5441
  }
4802
5442
  }
4803
5443
 
5444
+ async function preparedBilibiliArticleState(page, spec, settings, article) {
5445
+ if (settings.type !== "article") return null;
5446
+ if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 1000))) return null;
5447
+ const titleInput = await waitForVisible(page, spec.title, 1000);
5448
+ const bodyInput = await waitForVisible(page, spec.body, 1000);
5449
+ if (!titleInput || !bodyInput) return null;
5450
+ const title = normalizeText(
5451
+ typeof titleInput.inputValue === "function"
5452
+ ? await titleInput.inputValue().catch(() => "")
5453
+ : typeof titleInput.innerText === "function"
5454
+ ? await titleInput.innerText().catch(() => "")
5455
+ : ""
5456
+ );
5457
+ if (title !== normalizeText(article.title)) return null;
5458
+
5459
+ const content = buildBilibiliRichContent(article);
5460
+ const state = await bodyInput
5461
+ .evaluate(element => ({
5462
+ text: element.innerText,
5463
+ images: element.querySelectorAll("img").length,
5464
+ failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
5465
+ links: [...element.querySelectorAll("a[href]")].map(link => link.href)
5466
+ }))
5467
+ .catch(() => null);
5468
+ if (!state || state.failedImages > 0 || state.images < content.images.length) return null;
5469
+ const actualText = normalizedPresenceText(state.text);
5470
+ if (content.items.some(item => item.type === "html" && item.plain && !actualText.includes(normalizedPresenceText(item.plain)))) {
5471
+ return null;
5472
+ }
5473
+ const actualLinks = new Set(state.links.map(safeTapTapHref));
5474
+ if (content.links.some(link => !actualLinks.has(link.href))) return null;
5475
+ return {
5476
+ richContent: { rich: true, images: state.images, links: content.links.length, plainText: content.plainText },
5477
+ metadataApplied: { category: true, tags: true, topic: true, cover: true }
5478
+ };
5479
+ }
5480
+
4804
5481
  async function publishBilibili(page, entry, article, spec, options, promptUser) {
4805
5482
  const settings = resolveBilibiliPublishingOptions(article, entry);
4806
5483
  const editorUrl = browserPlatformPageUrl(entry, settings.type);
5484
+ if (options.directPublish) {
5485
+ const prepared = await preparedBilibiliArticleState(page, spec, settings, article);
5486
+ if (!prepared) {
5487
+ throw new Error("B站待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
5488
+ }
5489
+ options.onProgress?.("已复用 B站 待发布内容,正在提交,不重复上传正文图片或封面");
5490
+ const confirmation = await submitBilibili(
5491
+ page,
5492
+ spec,
5493
+ settings,
5494
+ article,
5495
+ prepared.metadataApplied,
5496
+ options,
5497
+ promptUser
5498
+ );
5499
+ return { url: page.url(), status: "published", type: settings.type, ...prepared.richContent, ...confirmation };
5500
+ }
4807
5501
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
4808
5502
  await afterNavigation(page);
4809
5503
  if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings)))) {
@@ -4836,8 +5530,16 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
4836
5530
  await validateBilibiliFieldLength(bodyInput, body, settings.type === "video" ? "简介" : "正文");
4837
5531
  await fillLocator(titleInput, article.title);
4838
5532
  await verifyFilledValue(titleInput, article.title, "标题", "B站");
4839
- if (settings.type === "article") {
4840
- await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
5533
+ let richContent = null;
5534
+ if (options.previewTextOnly) {
5535
+ richContent = await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
5536
+ skipImages: settings.type === "article",
5537
+ previewOnly: true,
5538
+ onProgress: options.onProgress
5539
+ });
5540
+ } else if (settings.type === "article") {
5541
+ richContent = await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
5542
+ onProgress: options.onProgress,
4841
5543
  removeNoncompliant: normalizeText(entry.project) === "最强蜗牛"
4842
5544
  });
4843
5545
  } else {
@@ -4848,9 +5550,27 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
4848
5550
 
4849
5551
  const metadataApplied =
4850
5552
  settings.type === "article"
4851
- ? await applyBilibiliMetadata(page, spec, settings, article, true)
5553
+ ? options.previewTextOnly
5554
+ ? { category: false, tags: false, topic: false, cover: false, deferred: true }
5555
+ : await applyBilibiliMetadata(page, spec, settings, article, true)
4852
5556
  : { category: true, tags: true, topic: true, cover: true };
4853
- await reviewPreparedDraft(page, entry, options);
5557
+ if (!options.draftOnly) {
5558
+ const review = await reviewPreparedDraft(page, entry, options);
5559
+ if (review) {
5560
+ return { ...review, type: settings.type, metadataApplied, ...richContent };
5561
+ }
5562
+ }
5563
+ if (options.draftOnly) {
5564
+ return {
5565
+ url: page.url(),
5566
+ status: "draft",
5567
+ type: settings.type,
5568
+ draftOnly: true,
5569
+ approvalRequired: true,
5570
+ approvalActions: ["publish", "cancel"],
5571
+ metadataApplied
5572
+ };
5573
+ }
4854
5574
  const confirmation = await submitBilibili(page, spec, settings, article, metadataApplied, options, promptUser);
4855
5575
  if (options.keepOpen) {
4856
5576
  await promptUser(`B站${settings.typeLabel}投稿已收到成功回执,浏览器保持打开供检查`);
@@ -5147,6 +5867,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5147
5867
  } while (true);
5148
5868
 
5149
5869
  let uploadedPreview = previews.last();
5870
+ let candidateIndexes = [];
5150
5871
  if (previousPreviewKeys.length > 0 && typeof previews.evaluateAll === "function") {
5151
5872
  const previewKeys = await previews
5152
5873
  .evaluateAll(elements =>
@@ -5156,10 +5877,20 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5156
5877
  })
5157
5878
  )
5158
5879
  .catch(() => []);
5159
- const uploadedIndex = previewKeys.findIndex(key => key && !previousPreviewKeys.includes(key));
5160
- if (uploadedIndex >= 0) {
5161
- uploadedPreview = previews.nth(uploadedIndex);
5162
- }
5880
+ candidateIndexes = previewKeys
5881
+ .map((key, index) => (key && !previousPreviewKeys.includes(key) ? index : -1))
5882
+ .filter(index => index >= 0);
5883
+ }
5884
+ const previewCount = await previews.count().catch(() => 0);
5885
+ if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
5886
+ candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
5887
+ }
5888
+ if (candidateIndexes.length === 0 && previewCount > 0) {
5889
+ candidateIndexes = [previewCount - 1];
5890
+ }
5891
+ if (structuredLibrary && candidateIndexes.length > 0) {
5892
+ // Let the common click-and-verify path below perform the selection once.
5893
+ uploadedPreview = previews.nth(candidateIndexes[0]);
5163
5894
  }
5164
5895
  if ((await uploadedPreview.count()) > 0 && (await uploadedPreview.isVisible().catch(() => false))) {
5165
5896
  await uploadedPreview.click();
@@ -6064,8 +6795,8 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6064
6795
  if (!body) {
6065
6796
  throw new Error("微博文章正文不能为空");
6066
6797
  }
6067
- if (title.length > 32) {
6068
- throw new Error(`微博文章标题最多 32 个字符,当前为 ${title.length} 个字符`);
6798
+ if (Array.from(title).length > 32) {
6799
+ throw new Error(`微博文章标题最多 32 个字符,当前为 ${Array.from(title).length} 个字符`);
6069
6800
  }
6070
6801
 
6071
6802
  const editorUrl = weiboArticleEditorUrl(entry.url);
@@ -6109,7 +6840,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6109
6840
  throw new Error("微博文章“下一步”按钮不可用,请检查标题、正文和封面设置");
6110
6841
  }
6111
6842
 
6112
- await reviewPreparedDraft(page, entry, options);
6843
+ if (!options.draftOnly) {
6844
+ const review = await reviewPreparedDraft(page, entry, options);
6845
+ if (review) {
6846
+ return { ...review, type: "article", title, ...richContent, ...cover, column };
6847
+ }
6848
+ }
6849
+ if (options.draftOnly) {
6850
+ return {
6851
+ url: page.url(),
6852
+ status: "draft",
6853
+ type: "article",
6854
+ title,
6855
+ draftOnly: true,
6856
+ approvalRequired: true,
6857
+ approvalActions: ["publish", "cancel"],
6858
+ ...richContent,
6859
+ ...cover,
6860
+ column
6861
+ };
6862
+ }
6113
6863
  const initialUrl = page.url();
6114
6864
  progress("正在进入发布确认页");
6115
6865
  let submitButton = null;
@@ -6197,7 +6947,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6197
6947
  if (options.keepOpen) {
6198
6948
  await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
6199
6949
  }
6200
- return { url: page.url(), status: "published", type: "article", ...richContent, ...cover, column, ...confirmation };
6950
+ return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
6201
6951
  }
6202
6952
  throw new Error("微博发布确认页中未找到最终发布按钮");
6203
6953
  }
@@ -6238,7 +6988,10 @@ async function publishWeiboQuick(page, entry, article, options, promptUser) {
6238
6988
  await fillLocator(bodyInput, body);
6239
6989
  const submit = await waitForVisibleButton(page, [/^发送$/, /^发布$/], 5000);
6240
6990
  if (!submit) throw new Error("微博快捷发布页面中未找到发送按钮");
6241
- await reviewPreparedDraft(page, entry, options);
6991
+ const review = await reviewPreparedDraft(page, entry, options);
6992
+ if (review) {
6993
+ return { ...review, type: "quick" };
6994
+ }
6242
6995
  await submit.click();
6243
6996
  await page.waitForTimeout(1500);
6244
6997
  return { url: page.url(), status: "published", type: "quick" };
@@ -6248,8 +7001,56 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6248
7001
  const editorUrl = browserPlatformPageUrl(entry);
6249
7002
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
6250
7003
  await afterNavigation(page);
6251
- const publishing = resolveXiaohongshuPublishingOptions(article);
6252
7004
  const body = buildTapTapRichContent(article).plainText || plainTextForPlatform(article, "xiaohongshu");
7005
+ let publishing;
7006
+ try {
7007
+ publishing = resolveXiaohongshuPublishingOptions(article);
7008
+ } catch (error) {
7009
+ if (!options.previewTextOnly) throw error;
7010
+ publishing = {
7011
+ images: [],
7012
+ collection: "",
7013
+ imageSource: "unavailable",
7014
+ validationError: error.message || String(error)
7015
+ };
7016
+ }
7017
+
7018
+ // Kept for callers that deliberately ask for a text-only editor inspection.
7019
+ // Standard AI authorisation never uses this path: it waits for all images and
7020
+ // the editor readback before offering an action.
7021
+ if (options.previewTextOnly) {
7022
+ const titleInput = await waitForVisible(page, spec.title, 1000);
7023
+ const bodyInput = await waitForVisible(page, spec.body, 1000);
7024
+ const titleFits = Array.from(article.title).length <= 20;
7025
+ const bodyFits = Array.from(body).length <= 1000;
7026
+ if (titleInput && bodyInput && titleFits && bodyFits) {
7027
+ await fillLocator(titleInput, article.title);
7028
+ await fillLocator(bodyInput, body);
7029
+ options.onProgress?.("小红书 标题和正文已填入,正在等待 AI 对话授权");
7030
+ return awaitingActionResult(page, {
7031
+ url: page.url(),
7032
+ status: "awaiting_action",
7033
+ approvalRequired: true,
7034
+ approvalActions: ["publish", "save_draft", "cancel"],
7035
+ contentPrepared: true,
7036
+ imagesPending: publishing.images.length,
7037
+ type: "note",
7038
+ ...publishing
7039
+ });
7040
+ }
7041
+ options.onProgress?.("小红书需要先上传图片才能进入笔记编辑器;页面已打开,正在等待 AI 对话授权");
7042
+ return awaitingActionResult(page, {
7043
+ url: page.url(),
7044
+ status: "awaiting_action",
7045
+ approvalRequired: true,
7046
+ approvalActions: ["publish", "save_draft", "cancel"],
7047
+ contentPrepared: false,
7048
+ requiresMediaBeforeText: true,
7049
+ imagesPending: publishing.images.length,
7050
+ type: "note",
7051
+ ...publishing
7052
+ });
7053
+ }
6253
7054
  if (Array.from(body).length > 1000) {
6254
7055
  throw new Error(`小红书正文最多 1000 个字符,当前为 ${Array.from(body).length} 个字符;请先精简内容后再发布`);
6255
7056
  }
@@ -6277,12 +7078,20 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6277
7078
  }
6278
7079
  options.onProgress?.(`正在上传小红书图片 ${payloads.length} 张`);
6279
7080
  await imageInput.setInputFiles(payloads);
6280
- await waitForUploadsSettled(page, "小红书图片", 120000);
7081
+ await waitForUploadsSettled(page, "小红书图片", 120000).catch(error => {
7082
+ // The creator can replace the uploader with a new note-editor tab as soon
7083
+ // as the cover is accepted. The editor lookup below owns that transition.
7084
+ if (!/Target page, context or browser has been closed/i.test(String(error?.message || error))) {
7085
+ throw error;
7086
+ }
7087
+ });
6281
7088
  options.onProgress?.("图片上传完成,正在填写笔记内容");
6282
7089
 
6283
- if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
7090
+ const editorPage = await waitForXiaohongshuEditorPage(page, spec, 120000);
7091
+ if (!editorPage) {
6284
7092
  throw new Error("小红书图片上传完成后未进入笔记编辑器,请检查图片格式或账号发布权限");
6285
7093
  }
7094
+ page = editorPage;
6286
7095
  const titleInput = await waitForVisible(page, spec.title, 8000);
6287
7096
  const bodyInput = await waitForVisible(page, spec.body, 8000);
6288
7097
  if (!titleInput || !bodyInput) throw new Error("小红书发布编辑器未找到标题或正文控件");
@@ -6316,11 +7125,25 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6316
7125
  if (typeof submit.isDisabled === "function" && await submit.isDisabled().catch(() => false)) {
6317
7126
  throw new Error("小红书发布按钮不可用,请检查正文和图片上传状态");
6318
7127
  }
7128
+ if (options.draftOnly) {
7129
+ return {
7130
+ url: page.url(),
7131
+ status: "draft",
7132
+ draftOnly: true,
7133
+ approvalRequired: true,
7134
+ approvalActions: ["publish", "cancel"],
7135
+ type: "note",
7136
+ ...publishing
7137
+ };
7138
+ }
6319
7139
  if (options.keepOpen && !isInteractiveTerminal(options.streams)) {
6320
7140
  options.onProgress?.("小红书待发布内容已准备完成,请在浏览器中核对后手动点击“发布”;程序将等待并验证发布结果");
6321
7141
  return waitForManualXiaohongshuSubmission(page, 600000);
6322
7142
  }
6323
- await reviewPreparedDraft(page, entry, options);
7143
+ const review = await reviewPreparedDraft(page, entry, options);
7144
+ if (review) {
7145
+ return { ...review, type: "note", ...publishing };
7146
+ }
6324
7147
  const initialUrl = page.url();
6325
7148
  const mutationPromise =
6326
7149
  typeof page.waitForResponse === "function"
@@ -6331,6 +7154,32 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
6331
7154
  return { url: page.url(), status: "published", type: "note", ...publishing, ...confirmation };
6332
7155
  }
6333
7156
 
7157
+ async function waitForXiaohongshuEditorPage(initialPage, spec, timeoutMs) {
7158
+ const context = typeof initialPage?.context === "function" ? initialPage.context() : null;
7159
+ const deadline = Date.now() + timeoutMs;
7160
+ do {
7161
+ const candidates = [initialPage, ...(typeof context?.pages === "function" ? context.pages() : [])]
7162
+ .filter((candidate, index, pages) => candidate && pages.indexOf(candidate) === index)
7163
+ .filter(candidate => !(typeof candidate.isClosed === "function" && candidate.isClosed()));
7164
+ for (const candidate of candidates) {
7165
+ try {
7166
+ if (await editorIsReady(candidate, spec)) return candidate;
7167
+ } catch (error) {
7168
+ if (!/Target page, context or browser has been closed/i.test(String(error?.message || error))) {
7169
+ throw error;
7170
+ }
7171
+ }
7172
+ }
7173
+ const waitPage = candidates[0];
7174
+ if (waitPage?.waitForTimeout) {
7175
+ await waitPage.waitForTimeout(300).catch(() => {});
7176
+ } else {
7177
+ await new Promise(resolve => setTimeout(resolve, 300));
7178
+ }
7179
+ } while (Date.now() < deadline);
7180
+ return null;
7181
+ }
7182
+
6334
7183
  function isInteractiveTerminal(streams = {}) {
6335
7184
  const input = streams.input || process.stdin;
6336
7185
  const output = streams.output || process.stdout;
@@ -6367,11 +7216,64 @@ async function waitForManualXiaohongshuSubmission(page, timeoutMs) {
6367
7216
  throw new Error("小红书待发布页面等待超时,未检测到手动发布成功回执");
6368
7217
  }
6369
7218
 
6370
- function xiaohongshuMutationResponse(response) {
7219
+ export function xiaohongshuMutationResponse(response) {
6371
7220
  const request = response.request();
6372
7221
  if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) return false;
6373
7222
  const url = response.url();
6374
- return /xiaohongshu\.com/i.test(url) && /(?:publish|note|feed|create|upload)/i.test(url);
7223
+ if (!/xiaohongshu\.com/i.test(url)) return false;
7224
+ // Media transfers can still finish after the user presses Publish. They are
7225
+ // not proof that a note was created and must never satisfy this wait.
7226
+ if (/(?:^|[./-])(?:ros-)?upload(?:[./-]|$)/i.test(url)) return false;
7227
+ return /(?:publish|note|feed|create)/i.test(url);
7228
+ }
7229
+
7230
+ export function xiaohongshuPublishedReceipt(payload = {}) {
7231
+ const data = payload?.data || {};
7232
+ const note = data?.note || payload?.note || {};
7233
+ const publishedUrl = [
7234
+ payload?.published_url,
7235
+ payload?.publishedUrl,
7236
+ payload?.note_url,
7237
+ payload?.noteUrl,
7238
+ payload?.share_url,
7239
+ payload?.shareUrl,
7240
+ data?.published_url,
7241
+ data?.publishedUrl,
7242
+ data?.note_url,
7243
+ data?.noteUrl,
7244
+ data?.share_url,
7245
+ data?.shareUrl,
7246
+ note?.url,
7247
+ note?.share_url,
7248
+ note?.shareUrl
7249
+ ]
7250
+ .map(normalizeText)
7251
+ .find(value => {
7252
+ try {
7253
+ const url = new URL(value);
7254
+ return /(?:^|\.)xiaohongshu\.com$/i.test(url.hostname) && !/^creator\./i.test(url.hostname);
7255
+ } catch {
7256
+ return false;
7257
+ }
7258
+ }) || "";
7259
+ const noteId = [
7260
+ payload?.note_id,
7261
+ payload?.noteId,
7262
+ payload?.id,
7263
+ data?.note_id,
7264
+ data?.noteId,
7265
+ data?.id,
7266
+ note?.note_id,
7267
+ note?.noteId,
7268
+ note?.id
7269
+ ]
7270
+ .map(normalizeText)
7271
+ .find(Boolean) || "";
7272
+ if (!publishedUrl && !noteId) return null;
7273
+ return {
7274
+ noteId,
7275
+ publishedUrl: publishedUrl || `https://www.xiaohongshu.com/explore/${encodeURIComponent(noteId)}`
7276
+ };
6375
7277
  }
6376
7278
 
6377
7279
  async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, options) {
@@ -6382,7 +7284,11 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
6382
7284
  if (response.status() >= 400 || payload?.success === false || Number(payload?.code) > 0) {
6383
7285
  throw new Error(`小红书发布失败: ${normalizeText(payload?.msg || payload?.message || `接口状态 ${response.status()}`)}`);
6384
7286
  }
6385
- return { confirmation: "response", responseUrl: response.url() };
7287
+ const receipt = xiaohongshuPublishedReceipt(payload);
7288
+ if (!receipt) {
7289
+ throw new Error("小红书发布结果未返回笔记 ID 或公开笔记链接,无法确认发布记录");
7290
+ }
7291
+ return { confirmation: "response", responseUrl: response.url(), ...receipt };
6386
7292
  }
6387
7293
  const deadline = Date.now() + 15000;
6388
7294
  do {
@@ -6390,19 +7296,229 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
6390
7296
  if (/发布失败|上传失败|内容违规|请重试/.test(pageText)) {
6391
7297
  throw new Error(`小红书发布失败: ${normalizeText(pageText.match(/(?:发布失败|上传失败|内容违规)[^\n]*/)?.[0] || "页面返回失败提示")}`);
6392
7298
  }
6393
- if (/发布成功|笔记发布成功|发布完成/.test(pageText) || page.url() !== initialUrl) {
6394
- return { confirmation: "page" };
7299
+ if (/发布成功|笔记发布成功|发布完成/.test(pageText)) {
7300
+ throw new Error("小红书页面提示发布完成,但未返回笔记 ID 或公开笔记链接,无法确认发布记录");
6395
7301
  }
6396
7302
  await page.waitForTimeout(500);
6397
7303
  } while (Date.now() < deadline);
6398
7304
  throw new Error("小红书发布后未检测到成功回执");
6399
7305
  }
6400
7306
 
6401
- async function launchBrowserContext(entry, browserChannel) {
7307
+ async function findAvailableLoopbackPort() {
7308
+ return new Promise((resolve, reject) => {
7309
+ const server = http.createServer();
7310
+ server.once("error", reject);
7311
+ server.listen(0, "127.0.0.1", () => {
7312
+ const address = server.address();
7313
+ server.close(error => (error ? reject(error) : resolve(address.port)));
7314
+ });
7315
+ });
7316
+ }
7317
+
7318
+ async function readCdpVersion(port) {
7319
+ return new Promise((resolve, reject) => {
7320
+ const request = http.get(
7321
+ { host: "127.0.0.1", port, path: "/json/version", timeout: 1500 },
7322
+ response => {
7323
+ let payload = "";
7324
+ response.setEncoding("utf8");
7325
+ response.on("data", chunk => {
7326
+ payload += chunk;
7327
+ });
7328
+ response.on("end", () => {
7329
+ try {
7330
+ const parsed = JSON.parse(payload);
7331
+ if (parsed.webSocketDebuggerUrl) {
7332
+ resolve(parsed.webSocketDebuggerUrl);
7333
+ return;
7334
+ }
7335
+ reject(new Error("调试端点未返回 WebSocket 地址"));
7336
+ } catch (error) {
7337
+ reject(error);
7338
+ }
7339
+ });
7340
+ }
7341
+ );
7342
+ request.once("error", reject);
7343
+ request.once("timeout", () => request.destroy(new Error("调试端点请求超时")));
7344
+ });
7345
+ }
7346
+
7347
+ async function waitForCdpEndpoint(port, timeoutMs = 15000) {
7348
+ const deadline = Date.now() + timeoutMs;
7349
+ let lastError;
7350
+ do {
7351
+ try {
7352
+ return await readCdpVersion(port);
7353
+ } catch (error) {
7354
+ lastError = error;
7355
+ await new Promise(resolve => setTimeout(resolve, 150));
7356
+ }
7357
+ } while (Date.now() < deadline);
7358
+ throw new Error(`浏览器调试会话启动超时:${lastError?.message || "未返回调试端点"}`);
7359
+ }
7360
+
7361
+ async function resolveDetachedBrowserExecutable(channel) {
7362
+ if (process.platform !== "win32") {
7363
+ return "";
7364
+ }
7365
+ const normalizedChannel = normalizeKey(channel || "msedge");
7366
+ const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
7367
+ const product = /chrome/.test(normalizedChannel) ? ["Google", "Chrome", "Application", "chrome.exe"] : ["Microsoft", "Edge", "Application", "msedge.exe"];
7368
+ const candidates = [process.env.QCPLAY_BROWSER_EXECUTABLE, ...programFiles.map(root => path.join(root, ...product))].filter(Boolean);
7369
+ for (const candidate of candidates) {
7370
+ if (await fs.promises.access(candidate, fs.constants.X_OK).then(() => true).catch(() => false)) {
7371
+ return candidate;
7372
+ }
7373
+ }
7374
+ return "";
7375
+ }
7376
+
7377
+ async function launchDetachedBrowserContext(profileDir, channel) {
7378
+ const executable = await resolveDetachedBrowserExecutable(channel);
7379
+ if (!executable) {
7380
+ throw new Error("未找到可分离的 Edge/Chrome 浏览器,无法在保留编辑页的同时返回 AI 授权事件");
7381
+ }
7382
+ let lastError;
7383
+ for (let attempt = 0; attempt < 3; attempt += 1) {
7384
+ const port = await findAvailableLoopbackPort();
7385
+ const child = spawn(
7386
+ executable,
7387
+ [
7388
+ "--no-first-run",
7389
+ "--no-default-browser-check",
7390
+ "--remote-debugging-address=127.0.0.1",
7391
+ "--remote-allow-origins=http://localhost,http://127.0.0.1",
7392
+ `--remote-debugging-port=${port}`,
7393
+ `--user-data-dir=${profileDir}`
7394
+ ],
7395
+ { detached: true, stdio: "ignore", windowsHide: true }
7396
+ );
7397
+ child.unref();
7398
+ try {
7399
+ const endpoint = await waitForCdpEndpoint(port);
7400
+ const { chromium } = await import("playwright-core");
7401
+ const browser = await chromium.connectOverCDP(endpoint);
7402
+ const context = browser.contexts()[0];
7403
+ if (!context) {
7404
+ await browser.close().catch(() => {});
7405
+ throw new Error("浏览器调试会话未返回默认上下文");
7406
+ }
7407
+ await saveDetachedBrowserSession(profileDir, endpoint);
7408
+ attachDetachedBrowserContext(context, browser, profileDir);
7409
+ return context;
7410
+ } catch (error) {
7411
+ lastError = error;
7412
+ child.kill();
7413
+ if (attempt < 2) {
7414
+ await new Promise(resolve => setTimeout(resolve, 300));
7415
+ }
7416
+ }
7417
+ }
7418
+ throw lastError;
7419
+ }
7420
+
7421
+ function detachedBrowserSessionFile(profileDir) {
7422
+ return path.join(profileDir, ".qcplay-detached-session.json");
7423
+ }
7424
+
7425
+ async function saveDetachedBrowserSession(profileDir, endpoint) {
7426
+ const sessionFile = detachedBrowserSessionFile(profileDir);
7427
+ await fs.promises.writeFile(
7428
+ sessionFile,
7429
+ `${JSON.stringify({ endpoint, createdAt: new Date().toISOString() })}\n`,
7430
+ { encoding: "utf8", mode: 0o600 }
7431
+ );
7432
+ }
7433
+
7434
+ async function loadDetachedBrowserSession(profileDir) {
7435
+ const sessionFile = detachedBrowserSessionFile(profileDir);
7436
+ try {
7437
+ const session = JSON.parse(await fs.promises.readFile(sessionFile, "utf8"));
7438
+ if (
7439
+ typeof session?.endpoint !== "string" ||
7440
+ !/^(?:http|ws):\/\/127\.0\.0\.1:\d+(?:\/devtools\/browser\/[^/]+)?\/?$/i.test(session.endpoint)
7441
+ ) {
7442
+ throw new Error("无效的调试会话地址");
7443
+ }
7444
+ return { ...session, sessionFile };
7445
+ } catch {
7446
+ return null;
7447
+ }
7448
+ }
7449
+
7450
+ async function removeDetachedBrowserSession(profileDir) {
7451
+ await fs.promises.unlink(detachedBrowserSessionFile(profileDir)).catch(() => {});
7452
+ }
7453
+
7454
+ function attachDetachedBrowserContext(context, browser, profileDir) {
7455
+ Object.defineProperty(context, "__qcplayDetach", {
7456
+ // `browser.close()` sends a Browser.close command over CDP. A detached
7457
+ // session is owned by the user-facing Edge process, so only close the
7458
+ // Playwright transport and leave that editor window intact.
7459
+ value: async () => {
7460
+ if (typeof browser?._connection?.close === "function") {
7461
+ browser._connection.close();
7462
+ return;
7463
+ }
7464
+ // Older Playwright builds do not expose a public disconnect method.
7465
+ // Do not fall back to browser.close(), which would close the editor.
7466
+ if (typeof browser?.disconnect === "function") {
7467
+ await browser.disconnect();
7468
+ }
7469
+ },
7470
+ enumerable: false
7471
+ });
7472
+ Object.defineProperty(context, "__qcplayDetachedProfileDir", {
7473
+ value: profileDir,
7474
+ enumerable: false
7475
+ });
7476
+ return context;
7477
+ }
7478
+
7479
+ async function reconnectDetachedBrowserContext(profileDir, chromium) {
7480
+ const savedSession = await loadDetachedBrowserSession(profileDir);
7481
+ const endpoint = normalizeText(process.env.QCPLAY_BROWSER_CDP_ENDPOINT) || savedSession?.endpoint;
7482
+ if (!endpoint || !/^(?:http|ws):\/\/127\.0\.0\.1:\d+(?:\/devtools\/browser\/[^/]+)?\/?$/i.test(endpoint)) {
7483
+ return null;
7484
+ }
7485
+ try {
7486
+ const browser = await chromium.connectOverCDP(endpoint);
7487
+ const context = browser.contexts()[0];
7488
+ if (!context) {
7489
+ await browser.close().catch(() => {});
7490
+ throw new Error("浏览器调试会话未返回默认上下文");
7491
+ }
7492
+ return attachDetachedBrowserContext(context, browser, profileDir);
7493
+ } catch {
7494
+ if (savedSession) {
7495
+ await removeDetachedBrowserSession(profileDir);
7496
+ }
7497
+ return null;
7498
+ }
7499
+ }
7500
+
7501
+ async function closeBrowserContext(context) {
7502
+ if (typeof context?.__qcplayDetach === "function") {
7503
+ await context.__qcplayDetach().catch(() => {});
7504
+ return;
7505
+ }
7506
+ await context?.close?.().catch(() => {});
7507
+ }
7508
+
7509
+ async function launchBrowserContext(entry, browserChannel, options = {}) {
6402
7510
  const { chromium } = await import("playwright-core");
6403
- const profileDir = path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
7511
+ const profileDir = browserProfileDir(entry);
6404
7512
  await fs.promises.mkdir(profileDir, { recursive: true, mode: 0o700 });
6405
7513
  const channel = browserChannel || process.env.QCPLAY_BROWSER_CHANNEL || (process.platform === "win32" ? "msedge" : "chrome");
7514
+ if (options.awaitAIAction) {
7515
+ return launchDetachedBrowserContext(profileDir, channel);
7516
+ }
7517
+ const detachedContext = await reconnectDetachedBrowserContext(profileDir, chromium);
7518
+ if (detachedContext) {
7519
+ options.onProgress?.(`已复用当前 ${entry.platform} 授权编辑页,继续执行已确认的动作`);
7520
+ return detachedContext;
7521
+ }
6406
7522
  return chromium.launchPersistentContext(profileDir, {
6407
7523
  channel,
6408
7524
  headless: false,
@@ -6559,7 +7675,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
6559
7675
  }
6560
7676
 
6561
7677
  const contentPolicy = resolvePlatformContentRequirements(entry);
6562
- if (contentPolicy.optionalSectionReview && !options.reviewDraft && !options.directPublish) {
7678
+ if (contentPolicy.optionalSectionReview && !options.reviewDraft && !options.directPublish && !options.draftOnly) {
6563
7679
  options = { ...options, reviewDraft: true };
6564
7680
  options.onProgress?.(
6565
7681
  `${entry.platform} 的飞书特殊要求包含可选删除板块,已切换为发布前人工预览确认`
@@ -6574,7 +7690,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
6574
7690
 
6575
7691
  const body =
6576
7692
  entry.platformKey === "haoyou"
6577
- ? buildHaoyouRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
7693
+ ? buildHaoyouPublishRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
6578
7694
  : plainTextForPlatform(preparedArticle, entry.platformKey);
6579
7695
  if (entry.platformKey === "x" && body.length > 280) {
6580
7696
  throw new Error(`X 内容共 ${body.length} 个字符,超过 280 字符限制`);
@@ -6584,11 +7700,12 @@ export async function publishWithBrowser(entry, article, options = {}) {
6584
7700
  }
6585
7701
 
6586
7702
  const ownsContext = !options.context;
6587
- const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
7703
+ const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
6588
7704
  const pages = context.pages();
6589
7705
  const page = pages[0] || (await context.newPage());
6590
7706
  const promptUser = options.waitForUser || (message => waitForUser(message, options.streams));
6591
7707
  let haoyouEditorOpened = false;
7708
+ let preserveEditorOnFailure = false;
6592
7709
  try {
6593
7710
  if (entry.platformKey === "taptap") {
6594
7711
  return await publishTapTap(page, entry, preparedArticle, spec, options, promptUser);
@@ -6654,7 +7771,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
6654
7771
  }
6655
7772
  let richContent = null;
6656
7773
  if (entry.platformKey === "haoyou") {
6657
- richContent = await insertHaoyouRichContent(page, bodyInput, preparedArticle, body);
7774
+ if (options.previewTextOnly) {
7775
+ await fillStableHaoyouValue(page, bodyInput, body, "正文");
7776
+ richContent = {
7777
+ rich: false,
7778
+ images: 0,
7779
+ imagesPending: buildHaoyouPublishRichContent(preparedArticle).images.length,
7780
+ links: 0,
7781
+ plainText: body
7782
+ };
7783
+ } else {
7784
+ richContent = await insertHaoyouRichContent(page, bodyInput, preparedArticle, body);
7785
+ }
6658
7786
  } else {
6659
7787
  await fillLocator(bodyInput, body);
6660
7788
  }
@@ -6665,10 +7793,22 @@ export async function publishWithBrowser(entry, article, options = {}) {
6665
7793
  if (entry.platformKey === "haoyou" && (await firstVisible(page, [".editSubmit"]))) {
6666
7794
  throw new Error("好游快爆标题和正文已写入,但发布按钮仍不可用,请检查发布设置");
6667
7795
  }
6668
- await promptUser(`${entry.platform} 页面中未找到发布按钮,正文已填入,请检查并手动发布`);
6669
- return { url: page.url(), manualSubmit: true };
7796
+ throw new Error(`${entry.platform} 页面中未找到发布按钮,正文已填入但尚未满足提交条件`);
7797
+ }
7798
+ const review = await reviewPreparedDraft(page, entry, options);
7799
+ if (review) {
7800
+ return { ...review, ...richContent };
7801
+ }
7802
+ if (options.draftOnly) {
7803
+ return {
7804
+ url: page.url(),
7805
+ status: "draft",
7806
+ draftOnly: true,
7807
+ approvalRequired: true,
7808
+ approvalActions: ["publish", "cancel"],
7809
+ ...richContent
7810
+ };
6670
7811
  }
6671
- await reviewPreparedDraft(page, entry, options);
6672
7812
  if (entry.platformKey === "haoyou") {
6673
7813
  const initialUrl = page.url();
6674
7814
  const mutationPromise =
@@ -6689,12 +7829,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
6689
7829
  }
6690
7830
  return { url: page.url() };
6691
7831
  } catch (error) {
6692
- if (entry.platformKey === "haoyou" && ownsContext && haoyouEditorOpened) {
6693
- await promptUser(`${error.message},浏览器将保持打开供检查`).catch(() => {});
7832
+ if ((entry.platformKey === "haoyou" && haoyouEditorOpened) || entry.platformKey === "xiaohongshu") {
7833
+ if (!ownsContext) throw error;
7834
+ preserveEditorOnFailure = true;
7835
+ options.onProgress?.(`${error.message};已保留${entry.platform}编辑页供检查`);
6694
7836
  }
6695
7837
  throw error;
6696
7838
  } finally {
6697
- await context.close().catch(() => {});
7839
+ // A draft is deliberately left visible for the current AI conversation.
7840
+ // Closing the persistent context here also closes the platform's editor page.
7841
+ if (ownsContext && !options.draftOnly && !options.keepOpen && !options.awaitAIAction && !preserveEditorOnFailure) {
7842
+ await closeBrowserContext(context);
7843
+ }
6698
7844
  }
6699
7845
  }
6700
7846
 
@@ -6703,7 +7849,13 @@ export async function publishPlatformEntry(entry, article, options = {}) {
6703
7849
  return publishDiscord(entry, article, options);
6704
7850
  }
6705
7851
  if (entry.publisher === "browser") {
6706
- return publishWithBrowser(entry, article, options);
7852
+ const submissionKey = await ensureBrowserSubmissionCooldown(entry, article, options);
7853
+ const result = await publishWithBrowser(entry, article, options);
7854
+ if (result?.status === "published") {
7855
+ await recordBrowserSubmission(submissionKey);
7856
+ await removeDetachedBrowserSession(browserProfileDir(entry));
7857
+ }
7858
+ return result;
6707
7859
  }
6708
7860
  if (entry.publisher === "blocked") {
6709
7861
  throw new Error("Reddit 配置明确提示非官方工具可能导致封号;未配置 Reddit 官方应用,因此拒绝自动发布");
@@ -6714,6 +7866,46 @@ export async function publishPlatformEntry(entry, article, options = {}) {
6714
7866
  throw new Error(`${entry.platform} 需要由官网发布流程处理`);
6715
7867
  }
6716
7868
 
7869
+ /**
7870
+ * Persist non-official platform addresses next to the article as a small,
7871
+ * reviewable project artifact. Credentials and configuration URLs are never
7872
+ * copied; only addresses returned by a completed platform workflow are kept.
7873
+ */
7874
+ export async function writePlatformPublishUrls(articleFile, successes) {
7875
+ const records = successes
7876
+ .filter(({ entry }) => entry?.platformKey && entry.platformKey !== "website")
7877
+ .map(({ entry, result }) => {
7878
+ const url = normalizeText(
7879
+ result?.status === "draft" ? result?.url : result?.publishedUrl || result?.responseUrl || result?.url
7880
+ );
7881
+ let isInternalApi = /\/webapi(?:v\d+)?\//i.test(url);
7882
+ try {
7883
+ const parsedUrl = new URL(url);
7884
+ isInternalApi ||= parsedUrl.searchParams.get("m") === "api" || /\/api(?:\/|$)/i.test(parsedUrl.pathname);
7885
+ } catch {
7886
+ // Non-URL values are omitted by the subsequent truthy URL filter.
7887
+ }
7888
+ return {
7889
+ platform: entry.platform,
7890
+ platformKey: entry.platformKey,
7891
+ region: entry.region,
7892
+ status: result?.status || "completed",
7893
+ // A draft must point back to the editor. Never persist a mutation API
7894
+ // endpoint as though it were a public article address.
7895
+ url: isInternalApi ? "" : url
7896
+ };
7897
+ })
7898
+ .filter(record => record.url);
7899
+ if (!records.length) return "";
7900
+ const target = `${path.resolve(articleFile)}.platform-urls.json`;
7901
+ await fs.promises.writeFile(
7902
+ target,
7903
+ `${JSON.stringify({ version: 1, updated_at: new Date().toISOString(), platforms: records }, null, 2)}\n`,
7904
+ "utf8"
7905
+ );
7906
+ return target;
7907
+ }
7908
+
6717
7909
  export async function runPlatformPublishSequence(entries, publishOne) {
6718
7910
  const successes = [];
6719
7911
  const failures = [];