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