@qcplay/cli 1.0.17 → 1.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -329
- package/bin/qcplay.js +416 -54
- package/lib/platform-publish.js +1173 -177
- package/lib/rich-article-upload.js +116 -0
- package/lib/wechat-article.js +126 -29
- package/lib/wechat-data.js +155 -0
- package/package.json +2 -1
- package/templates/skills/qcplay-publish-article/SKILL.md +46 -18
- 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站删除官充章节",
|
|
@@ -172,7 +175,6 @@ const BROWSER_PLATFORM_SPECS = {
|
|
|
172
175
|
password: ['input[name="password"]', 'input[type="password"]'],
|
|
173
176
|
loginButtons: [/登录/],
|
|
174
177
|
title: ['textarea[placeholder="请输入标题"]', 'textarea[placeholder*="请输入标题"]'],
|
|
175
|
-
intro: ['textarea[placeholder*="导语"]'],
|
|
176
178
|
body: ['.tiptap.ProseMirror[contenteditable="true"]', '.wb-editor.ProseMirror[contenteditable="true"]'],
|
|
177
179
|
imageInputs: ['input[type="file"][accept*=".jpg"]', 'input[type="file"][accept*="image"]'],
|
|
178
180
|
next: [/^下一步$/],
|
|
@@ -1528,6 +1530,26 @@ export function resolveTapTapPublishingOptions(article, entry = {}) {
|
|
|
1528
1530
|
};
|
|
1529
1531
|
}
|
|
1530
1532
|
|
|
1533
|
+
export function resolveTapTapTitle(article, limit = 30) {
|
|
1534
|
+
const explicitTitle = tapTapMeta(article, "taptap_title");
|
|
1535
|
+
const sourceTitle = explicitTitle || normalizeText(article.title || article.payload?.article_title);
|
|
1536
|
+
const characters = Array.from(sourceTitle);
|
|
1537
|
+
if (characters.length <= limit || explicitTitle) {
|
|
1538
|
+
return sourceTitle;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const prefix = characters.slice(0, limit).join("");
|
|
1542
|
+
const sentenceEnd = Math.max(...["。", "!", "?", "!", "?"].map(mark => prefix.lastIndexOf(mark)));
|
|
1543
|
+
if (sentenceEnd >= 12) {
|
|
1544
|
+
return prefix.slice(0, sentenceEnd + 1);
|
|
1545
|
+
}
|
|
1546
|
+
const phraseEnd = Math.max(...[",", ",", "、", ";", ";"].map(mark => prefix.lastIndexOf(mark)));
|
|
1547
|
+
if (phraseEnd >= 12) {
|
|
1548
|
+
return prefix.slice(0, phraseEnd + 1);
|
|
1549
|
+
}
|
|
1550
|
+
return `${characters.slice(0, Math.max(0, limit - 1)).join("")}…`;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1531
1553
|
export function tapTapPublishingPlan(article, entry = {}) {
|
|
1532
1554
|
const preparedArticle = prepareTapTapArticleForProject(article, entry);
|
|
1533
1555
|
const settings = resolveTapTapPublishingOptions(preparedArticle, entry);
|
|
@@ -1560,22 +1582,32 @@ function weiboMeta(article, ...keys) {
|
|
|
1560
1582
|
return "";
|
|
1561
1583
|
}
|
|
1562
1584
|
|
|
1585
|
+
export function resolveWeiboArticleTitle(article) {
|
|
1586
|
+
const explicitTitle = weiboMeta(article, "weibo_title");
|
|
1587
|
+
if (explicitTitle) {
|
|
1588
|
+
return explicitTitle;
|
|
1589
|
+
}
|
|
1590
|
+
const sourceTitle = normalizeText(article.title || article.payload?.article_title);
|
|
1591
|
+
const characters = Array.from(sourceTitle);
|
|
1592
|
+
if (characters.length <= 32) {
|
|
1593
|
+
return sourceTitle;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
const prefix = characters.slice(0, 32).join("");
|
|
1597
|
+
const sentenceEnd = Math.max(...["。", "!", "?", "!", "?"].map(mark => prefix.lastIndexOf(mark)));
|
|
1598
|
+
if (sentenceEnd >= 12) {
|
|
1599
|
+
return prefix.slice(0, sentenceEnd + 1);
|
|
1600
|
+
}
|
|
1601
|
+
const phraseEnd = Math.max(...[",", ",", "、", ";", ";"].map(mark => prefix.lastIndexOf(mark)));
|
|
1602
|
+
if (phraseEnd >= 12) {
|
|
1603
|
+
return prefix.slice(0, phraseEnd + 1);
|
|
1604
|
+
}
|
|
1605
|
+
return `${characters.slice(0, 31).join("")}…`;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1563
1608
|
export function resolveWeiboSuperTopic(article, entry = {}) {
|
|
1564
|
-
const
|
|
1565
|
-
|
|
1566
|
-
const listedTopic = requirements.match(/加上超话\s*[::]?\s*(?:\r?\n)+\s*([^\r\n,。;;]{1,40})/)?.[1] || "";
|
|
1567
|
-
const project = normalizeText(entry.project);
|
|
1568
|
-
const defaultTopic =
|
|
1569
|
-
project === "最强蜗牛"
|
|
1570
|
-
? "#最强蜗牛超话#"
|
|
1571
|
-
: project === "提灯与地下城"
|
|
1572
|
-
? "#提灯与地下城超话#"
|
|
1573
|
-
: project.startsWith("魔卡少女樱")
|
|
1574
|
-
? ""
|
|
1575
|
-
: project;
|
|
1576
|
-
const requested =
|
|
1577
|
-
weiboMeta(article, "weibo_super_topic", "weibo_topic") ||
|
|
1578
|
-
(project.startsWith("魔卡少女樱") ? markedTopic?.[0] || listedTopic : defaultTopic || markedTopic?.[0] || listedTopic);
|
|
1609
|
+
const requested = weiboMeta(article, "weibo_super_topic", "weibo_topic");
|
|
1610
|
+
if (!requested) return "";
|
|
1579
1611
|
const marker = requested.startsWith("#") ? "#" : "@";
|
|
1580
1612
|
const closeHash = marker === "#" && requested.endsWith("#") ? "#" : "";
|
|
1581
1613
|
const topic = normalizeText(requested)
|
|
@@ -1586,30 +1618,19 @@ export function resolveWeiboSuperTopic(article, entry = {}) {
|
|
|
1586
1618
|
return topic ? `${marker}${topic}超话${closeHash}` : "";
|
|
1587
1619
|
}
|
|
1588
1620
|
|
|
1589
|
-
function
|
|
1590
|
-
const title =
|
|
1621
|
+
export function resolveWeiboShareCopy(article, entry = {}) {
|
|
1622
|
+
const title = resolveWeiboArticleTitle(article);
|
|
1591
1623
|
const project = normalizeText(entry.project);
|
|
1592
|
-
const superTopic = resolveWeiboSuperTopic(article, entry);
|
|
1593
|
-
if (!title) return null;
|
|
1594
1624
|
if (project === "最强蜗牛") {
|
|
1595
|
-
return
|
|
1596
|
-
prefix: [title, superTopic, "性感春姬,在线播报", "大家好,我是你们的小姬!", "本期推文为大家带来"],
|
|
1597
|
-
suffix: []
|
|
1598
|
-
};
|
|
1625
|
+
return [title, "#最强蜗牛超话#", "性感春姬,在线播报", "大家好,我是你们的小姬!"].filter(Boolean).join("\n");
|
|
1599
1626
|
}
|
|
1600
1627
|
if (project === "提灯与地下城") {
|
|
1601
|
-
return
|
|
1602
|
-
prefix: [title, superTopic, "大噶好我是五郎!", "一起来看看本次的更新资讯吧~"],
|
|
1603
|
-
suffix: []
|
|
1604
|
-
};
|
|
1628
|
+
return [title, "#提灯与地下城超话#", "大噶好~俺是五郎!", "一起来看看本次的更新资讯吧~"].filter(Boolean).join("\n");
|
|
1605
1629
|
}
|
|
1606
1630
|
if (project.startsWith("魔卡少女樱")) {
|
|
1607
|
-
return
|
|
1608
|
-
prefix: [title],
|
|
1609
|
-
suffix: ["#魔卡少女樱#", "#魔卡少女樱回忆钥匙#"]
|
|
1610
|
-
};
|
|
1631
|
+
return "#魔卡少女樱回忆钥匙#";
|
|
1611
1632
|
}
|
|
1612
|
-
return
|
|
1633
|
+
return "";
|
|
1613
1634
|
}
|
|
1614
1635
|
|
|
1615
1636
|
export function prepareWeiboArticleForProject(article, entry = {}) {
|
|
@@ -1623,49 +1644,7 @@ export function prepareWeiboArticleForProject(article, entry = {}) {
|
|
|
1623
1644
|
markdown: `${String(article.markdown || "").trim()}\n\n`.trim()
|
|
1624
1645
|
}
|
|
1625
1646
|
: article;
|
|
1626
|
-
|
|
1627
|
-
if (copy) {
|
|
1628
|
-
const document = cheerio.load(String(articleWithCover.html || ""), null, false);
|
|
1629
|
-
if (document('[data-qcplay-weibo-copy="1"]').length === 0) {
|
|
1630
|
-
const paragraph = text => {
|
|
1631
|
-
const node = cheerio.load("<p></p>", null, false);
|
|
1632
|
-
node("p").attr("data-qcplay-weibo-copy", "1").text(text);
|
|
1633
|
-
return node.root().html();
|
|
1634
|
-
};
|
|
1635
|
-
const prefix = copy.prefix
|
|
1636
|
-
.filter(Boolean)
|
|
1637
|
-
.map(paragraph)
|
|
1638
|
-
.join("");
|
|
1639
|
-
const suffix = copy.suffix
|
|
1640
|
-
.filter(Boolean)
|
|
1641
|
-
.map(paragraph)
|
|
1642
|
-
.join("");
|
|
1643
|
-
return {
|
|
1644
|
-
...articleWithCover,
|
|
1645
|
-
html: `${prefix}${String(articleWithCover.html || "")}${suffix}`.trim(),
|
|
1646
|
-
markdown: `${copy.prefix.join("\n\n")}\n\n${String(articleWithCover.markdown || "").trim()}${copy.suffix.length ? `\n\n${copy.suffix.join("\n\n")}` : ""}`.trim(),
|
|
1647
|
-
weiboSuperTopic: resolveWeiboSuperTopic(articleWithCover, entry)
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
return articleWithCover;
|
|
1651
|
-
}
|
|
1652
|
-
const superTopic = resolveWeiboSuperTopic(articleWithCover, entry);
|
|
1653
|
-
if (!superTopic) {
|
|
1654
|
-
return articleWithCover;
|
|
1655
|
-
}
|
|
1656
|
-
const document = cheerio.load(String(articleWithCover.html || ""), null, false);
|
|
1657
|
-
const existingText = normalizeText(document.root().text() || articleWithCover.markdown);
|
|
1658
|
-
if (existingText.includes(superTopic)) {
|
|
1659
|
-
return articleWithCover;
|
|
1660
|
-
}
|
|
1661
|
-
const mention = cheerio.load("<p></p>", null, false);
|
|
1662
|
-
mention("p").attr("data-qcplay-weibo-super-topic", "1").text(superTopic);
|
|
1663
|
-
return {
|
|
1664
|
-
...articleWithCover,
|
|
1665
|
-
html: `${String(articleWithCover.html || "")}\n${mention.root().html()}`.trim(),
|
|
1666
|
-
markdown: `${String(articleWithCover.markdown || "").trim()}\n\n${superTopic}`.trim(),
|
|
1667
|
-
weiboSuperTopic: superTopic
|
|
1668
|
-
};
|
|
1647
|
+
return articleWithCover;
|
|
1669
1648
|
}
|
|
1670
1649
|
|
|
1671
1650
|
export function buildWeiboText(article) {
|
|
@@ -1679,7 +1658,7 @@ export function buildWeiboRichContent(article) {
|
|
|
1679
1658
|
structuredTables: true
|
|
1680
1659
|
});
|
|
1681
1660
|
const seenImages = new Set();
|
|
1682
|
-
const
|
|
1661
|
+
const filteredItems = content.items.filter(item => {
|
|
1683
1662
|
if (item.type !== "image") {
|
|
1684
1663
|
return true;
|
|
1685
1664
|
}
|
|
@@ -1692,14 +1671,14 @@ export function buildWeiboRichContent(article) {
|
|
|
1692
1671
|
});
|
|
1693
1672
|
return {
|
|
1694
1673
|
...content,
|
|
1695
|
-
items,
|
|
1696
|
-
|
|
1674
|
+
items: filteredItems,
|
|
1675
|
+
plainText: filteredItems.map(item => normalizeText(item.plain)).filter(Boolean).join("\n"),
|
|
1676
|
+
images: filteredItems.filter(item => item.type === "image")
|
|
1697
1677
|
};
|
|
1698
1678
|
}
|
|
1699
1679
|
|
|
1700
1680
|
export function resolveWeiboPublishingOptions(article) {
|
|
1701
1681
|
return {
|
|
1702
|
-
intro: weiboMeta(article, "weibo_intro") || normalizeText(article.payload?.article_excerpt),
|
|
1703
1682
|
cover: weiboMeta(article, "weibo_cover"),
|
|
1704
1683
|
column: weiboMeta(article, "weibo_column")
|
|
1705
1684
|
};
|
|
@@ -1715,16 +1694,13 @@ export function weiboPublishingPlan(article, entry = {}) {
|
|
|
1715
1694
|
const preparedArticle = prepareWeiboArticleForProject(prepareArticleForPlatform(article, entry), entry);
|
|
1716
1695
|
const settings = resolveWeiboPublishingOptions(preparedArticle);
|
|
1717
1696
|
const richContent = buildWeiboRichContent(preparedArticle);
|
|
1718
|
-
const superTopic = resolveWeiboSuperTopic(preparedArticle, entry);
|
|
1719
1697
|
return {
|
|
1720
1698
|
type: "头条文章",
|
|
1721
1699
|
characters: richContent.plainText.length,
|
|
1722
1700
|
images: richContent.images.length,
|
|
1723
1701
|
links: richContent.links.length,
|
|
1724
|
-
intro: Boolean(settings.intro),
|
|
1725
1702
|
cover: settings.cover ? "explicit" : richContent.images.length > 0 ? "first-image" : "",
|
|
1726
|
-
column: settings.column || ""
|
|
1727
|
-
...(superTopic ? { superTopic } : {})
|
|
1703
|
+
column: settings.column || ""
|
|
1728
1704
|
};
|
|
1729
1705
|
}
|
|
1730
1706
|
|
|
@@ -2073,6 +2049,14 @@ function safeRichCssLength(value, options = {}) {
|
|
|
2073
2049
|
return pattern.test(length) ? length : "";
|
|
2074
2050
|
}
|
|
2075
2051
|
|
|
2052
|
+
function safeRichFontFamily(value) {
|
|
2053
|
+
const family = normalizeText(value).replace(/\s*!important\s*$/i, "");
|
|
2054
|
+
if (!family || !/^[\w\s,'"-]+$/u.test(family)) {
|
|
2055
|
+
return "";
|
|
2056
|
+
}
|
|
2057
|
+
return family;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2076
2060
|
function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
|
|
2077
2061
|
const wrappers = [];
|
|
2078
2062
|
const add = name => {
|
|
@@ -2100,12 +2084,16 @@ function tapTapInlineWrappers(tag, styleValue = "", options = {}) {
|
|
|
2100
2084
|
const letterSpacing = preserveStyles
|
|
2101
2085
|
? safeRichCssLength(styleValue.match(/(?:^|;)\s*letter-spacing\s*:\s*([^;]+)/i)?.[1])
|
|
2102
2086
|
: "";
|
|
2087
|
+
const fontFamily = preserveStyles
|
|
2088
|
+
? safeRichFontFamily(styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1])
|
|
2089
|
+
: "";
|
|
2103
2090
|
const spanStyles = [
|
|
2104
2091
|
safeColor ? `color:${safeColor}` : "",
|
|
2105
2092
|
safeBackground ? `background-color:${safeBackground}` : "",
|
|
2106
2093
|
fontSize ? `font-size:${fontSize}` : "",
|
|
2107
2094
|
lineHeight ? `line-height:${lineHeight}` : "",
|
|
2108
|
-
letterSpacing ? `letter-spacing:${letterSpacing}` : ""
|
|
2095
|
+
letterSpacing ? `letter-spacing:${letterSpacing}` : "",
|
|
2096
|
+
fontFamily ? `font-family:${fontFamily}` : ""
|
|
2109
2097
|
]
|
|
2110
2098
|
.filter(Boolean)
|
|
2111
2099
|
.join(";");
|
|
@@ -2178,6 +2166,10 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
2178
2166
|
);
|
|
2179
2167
|
if (value) blockStyles.push(`${property}:${value}`);
|
|
2180
2168
|
}
|
|
2169
|
+
const fontFamily = safeRichFontFamily(
|
|
2170
|
+
styleValue.match(/(?:^|;)\s*font-family\s*:\s*([^;]+)/i)?.[1]
|
|
2171
|
+
);
|
|
2172
|
+
if (fontFamily) blockStyles.push(`font-family:${fontFamily}`);
|
|
2181
2173
|
}
|
|
2182
2174
|
return blockStyles.length > 0 ? ` style="${blockStyles.join(";")}"` : "";
|
|
2183
2175
|
};
|
|
@@ -2546,17 +2538,66 @@ function buildPlatformRichContent(article, options = {}) {
|
|
|
2546
2538
|
const fallback = normalizeText(article.markdown);
|
|
2547
2539
|
items.push({ type: "html", html: `<p>${escapeTapTapHtml(fallback)}</p>`, plain: fallback });
|
|
2548
2540
|
}
|
|
2541
|
+
const normalizedItems = normalizeNumberedSectionHeadings(items);
|
|
2549
2542
|
return {
|
|
2550
|
-
items,
|
|
2543
|
+
items: normalizedItems,
|
|
2551
2544
|
images,
|
|
2552
2545
|
links,
|
|
2553
|
-
plainText:
|
|
2546
|
+
plainText: normalizedItems
|
|
2554
2547
|
.filter(item => item.type === "html")
|
|
2555
2548
|
.map(item => item.plain)
|
|
2556
2549
|
.join("\n")
|
|
2557
2550
|
};
|
|
2558
2551
|
}
|
|
2559
2552
|
|
|
2553
|
+
function isStandaloneSectionNumber(value) {
|
|
2554
|
+
return /^(?:(?:第\s*)?\d{1,3}|[一二三四五六七八九十]+)(?:[、..::])?$/.test(normalizeText(value));
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function mergeNumberedSectionHeading(numberItem, headingItem) {
|
|
2558
|
+
const number = normalizeText(numberItem.plain);
|
|
2559
|
+
const title = normalizeText(headingItem.plain);
|
|
2560
|
+
const opening = String(headingItem.html || "").match(/^<(p|h[1-3])([^>]*)>/i);
|
|
2561
|
+
const closing = String(headingItem.html || "").match(/<\/(p|h[1-3])>$/i);
|
|
2562
|
+
if (!opening || !closing) return null;
|
|
2563
|
+
const headingHtml = String(headingItem.html || "").replace(/^<(p|h[1-3])[^>]*>|<\/(p|h[1-3])>$/gi, "");
|
|
2564
|
+
return {
|
|
2565
|
+
...headingItem,
|
|
2566
|
+
html: `<${opening[1]}${opening[2]}>${escapeTapTapHtml(number)} ${headingHtml}</${closing[1]}>`,
|
|
2567
|
+
plain: `${number} ${title}`
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
// WeChat articles sometimes split a section marker and its title into two blocks.
|
|
2572
|
+
// Keep the heading readable on one line while retaining the intended blank space.
|
|
2573
|
+
function normalizeNumberedSectionHeadings(items) {
|
|
2574
|
+
const normalized = [];
|
|
2575
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
2576
|
+
const current = items[index];
|
|
2577
|
+
const following = items[index + 1];
|
|
2578
|
+
const canMerge =
|
|
2579
|
+
current?.type === "html" &&
|
|
2580
|
+
following?.type === "html" &&
|
|
2581
|
+
current.block === "p" &&
|
|
2582
|
+
isStandaloneSectionNumber(current.plain) &&
|
|
2583
|
+
(/^h[1-3]$/.test(following.block || "") || /^\d{2}$/.test(normalizeText(current.plain)));
|
|
2584
|
+
if (!canMerge) {
|
|
2585
|
+
normalized.push(current);
|
|
2586
|
+
continue;
|
|
2587
|
+
}
|
|
2588
|
+
const merged = mergeNumberedSectionHeading(current, following);
|
|
2589
|
+
if (!merged) {
|
|
2590
|
+
normalized.push(current);
|
|
2591
|
+
continue;
|
|
2592
|
+
}
|
|
2593
|
+
normalized.push({ type: "html", html: "<p><br></p>", plain: "", block: "p", align: "" });
|
|
2594
|
+
normalized.push(merged);
|
|
2595
|
+
normalized.push({ type: "html", html: "<p><br></p>", plain: "", block: "p", align: "" });
|
|
2596
|
+
index += 1;
|
|
2597
|
+
}
|
|
2598
|
+
return normalized;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2560
2601
|
export function buildBilibiliRichContent(article) {
|
|
2561
2602
|
return buildPlatformRichContent(article, {
|
|
2562
2603
|
preserveBackground: true,
|
|
@@ -2574,9 +2615,29 @@ export function buildHaoyouRichContent(article) {
|
|
|
2574
2615
|
structuredTables: true
|
|
2575
2616
|
});
|
|
2576
2617
|
const flattenInlineSpans = isWechatImportedArticle(article);
|
|
2618
|
+
// Most nested WeChat spans are flattened for the Haoyou Quill clipboard
|
|
2619
|
+
// parser, but a secret code is commonly the coloured line immediately before
|
|
2620
|
+
// a “red text is the code” hint. Keeping that tiny group intact preserves the
|
|
2621
|
+
// source visual distinction without exposing the rest of the article to the
|
|
2622
|
+
// parser issue.
|
|
2623
|
+
const secretStyleItems = new Set();
|
|
2624
|
+
content.items.forEach((item, index) => {
|
|
2625
|
+
if (item.type !== "html") return;
|
|
2626
|
+
const text = normalizeText(item.plain);
|
|
2627
|
+
if (!/(?:密令|兑换码|礼包码|福利码)/.test(text)) return;
|
|
2628
|
+
secretStyleItems.add(index);
|
|
2629
|
+
if (/红字[^。;;\n]{0,20}(?:密令|兑换码|礼包码|福利码)/.test(text)) {
|
|
2630
|
+
for (let previous = index - 1; previous >= 0; previous -= 1) {
|
|
2631
|
+
if (content.items[previous].type === "html") {
|
|
2632
|
+
secretStyleItems.add(previous);
|
|
2633
|
+
break;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
});
|
|
2577
2638
|
return {
|
|
2578
2639
|
...content,
|
|
2579
|
-
items: content.items.map(item =>
|
|
2640
|
+
items: content.items.map((item, index) =>
|
|
2580
2641
|
item.type === "html"
|
|
2581
2642
|
? {
|
|
2582
2643
|
...item,
|
|
@@ -2587,13 +2648,39 @@ export function buildHaoyouRichContent(article) {
|
|
|
2587
2648
|
// WeChat's nested styled spans can also truncate their trailing
|
|
2588
2649
|
// text in the Haoyou Quill clipboard parser. Keep their text and
|
|
2589
2650
|
// semantic children while dropping only the wrapper.
|
|
2590
|
-
.replace(flattenInlineSpans ? /<\/?span\b[^>]*>/gi : /$^/, "")
|
|
2651
|
+
.replace(flattenInlineSpans && !secretStyleItems.has(index) ? /<\/?span\b[^>]*>/gi : /$^/, "")
|
|
2591
2652
|
}
|
|
2592
2653
|
: item
|
|
2593
2654
|
)
|
|
2594
2655
|
};
|
|
2595
2656
|
}
|
|
2596
2657
|
|
|
2658
|
+
export function buildHaoyouPublishRichContent(article) {
|
|
2659
|
+
const content = buildHaoyouRichContent(article);
|
|
2660
|
+
return {
|
|
2661
|
+
...content,
|
|
2662
|
+
items: content.items.map(item => {
|
|
2663
|
+
if (item.type !== "html") return item;
|
|
2664
|
+
const $ = cheerio.load(item.html, null, false);
|
|
2665
|
+
$("em,i").each((_, element) => $(element).replaceWith($(element).contents()));
|
|
2666
|
+
$("[style]").each((_, element) => {
|
|
2667
|
+
const style = String($(element).attr("style") || "")
|
|
2668
|
+
.split(";")
|
|
2669
|
+
.filter(rule => !/^\s*(?:color|background(?:-color)?|font-style)\s*:/i.test(rule))
|
|
2670
|
+
.map(rule => rule.trim())
|
|
2671
|
+
.filter(Boolean)
|
|
2672
|
+
.join(";");
|
|
2673
|
+
if (style) {
|
|
2674
|
+
$(element).attr("style", style);
|
|
2675
|
+
} else {
|
|
2676
|
+
$(element).removeAttr("style");
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
return { ...item, html: $.html() };
|
|
2680
|
+
})
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2597
2684
|
function contentTypeForFile(fileName, fallback = "application/octet-stream") {
|
|
2598
2685
|
const extension = path.extname(fileName).toLowerCase();
|
|
2599
2686
|
return (
|
|
@@ -2815,7 +2902,7 @@ async function waitForTapTapRichEditorState(page, bodyInput, content, options =
|
|
|
2815
2902
|
throw new Error("TapTap 正文写入后未能稳定完成回读校验");
|
|
2816
2903
|
}
|
|
2817
2904
|
|
|
2818
|
-
export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText) {
|
|
2905
|
+
export async function insertTapTapRichContent(page, bodyInput, article, settings, fallbackText, options = {}) {
|
|
2819
2906
|
if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
|
|
2820
2907
|
await fillLocator(bodyInput, fallbackText);
|
|
2821
2908
|
await verifyFilledValue(bodyInput, fallbackText, "正文");
|
|
@@ -2825,7 +2912,12 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
|
|
|
2825
2912
|
const content = buildTapTapRichContent(article);
|
|
2826
2913
|
const inlineImages = settings.type === "topic";
|
|
2827
2914
|
const toolbarImageInput = inlineImages
|
|
2828
|
-
? await firstExisting(page, [
|
|
2915
|
+
? await firstExisting(page, [
|
|
2916
|
+
'.tap-editor-toolbar input[type="file"][accept*="image"]',
|
|
2917
|
+
'.tap-editor__toolbar input[type="file"][accept*="image"]',
|
|
2918
|
+
'[class*="tap-editor"][class*="toolbar"] input[type="file"][accept*="image"]',
|
|
2919
|
+
'input[type="file"][accept*="image"]:not([class*="cover"]):not([class*="post-setting"])'
|
|
2920
|
+
])
|
|
2829
2921
|
: null;
|
|
2830
2922
|
if (inlineImages && content.images.length > 0 && (!toolbarImageInput || typeof toolbarImageInput.setInputFiles !== "function")) {
|
|
2831
2923
|
throw new Error("TapTap 长帖编辑器中未找到正文图片上传控件");
|
|
@@ -2844,6 +2936,9 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
|
|
|
2844
2936
|
plainParts.push(item.plain);
|
|
2845
2937
|
continue;
|
|
2846
2938
|
}
|
|
2939
|
+
if (options.skipImages) {
|
|
2940
|
+
continue;
|
|
2941
|
+
}
|
|
2847
2942
|
if (inlineImages) {
|
|
2848
2943
|
const marker = `${markerPrefix}${imageMarkers.length}`;
|
|
2849
2944
|
htmlParts.push(`<p>${marker}</p>`);
|
|
@@ -2863,7 +2958,9 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
|
|
|
2863
2958
|
|
|
2864
2959
|
const payloadCache = new Map();
|
|
2865
2960
|
let uploadedImages = 0;
|
|
2866
|
-
|
|
2961
|
+
// Upload from the end so a media-type-specific editor rerender cannot invalidate
|
|
2962
|
+
// placeholders that still need to be replaced later in the document.
|
|
2963
|
+
for (const { marker, item, index } of [...imageMarkers].reverse()) {
|
|
2867
2964
|
let removed = false;
|
|
2868
2965
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
2869
2966
|
await bodyInput.focus().catch(() => {});
|
|
@@ -2898,13 +2995,64 @@ export async function insertTapTapRichContent(page, bodyInput, article, settings
|
|
|
2898
2995
|
}, marker);
|
|
2899
2996
|
if (!selected) break;
|
|
2900
2997
|
await page.waitForTimeout(80);
|
|
2901
|
-
|
|
2998
|
+
if (typeof bodyInput.press === "function") {
|
|
2999
|
+
await bodyInput.press("Backspace").catch(() => {});
|
|
3000
|
+
} else {
|
|
3001
|
+
await page.keyboard.press("Backspace");
|
|
3002
|
+
}
|
|
2902
3003
|
await page.waitForTimeout(100);
|
|
2903
3004
|
removed = !(await bodyInput.evaluate((element, markerValue) => {
|
|
2904
3005
|
return (element.innerText || element.textContent || "").includes(markerValue);
|
|
2905
3006
|
}, marker));
|
|
3007
|
+
if (!removed) {
|
|
3008
|
+
if (typeof bodyInput.press === "function") {
|
|
3009
|
+
await bodyInput.press("Delete").catch(() => {});
|
|
3010
|
+
} else {
|
|
3011
|
+
await page.keyboard.press("Delete");
|
|
3012
|
+
}
|
|
3013
|
+
await page.waitForTimeout(100);
|
|
3014
|
+
removed = !(await bodyInput.evaluate((element, markerValue) => {
|
|
3015
|
+
return (element.innerText || element.textContent || "").includes(markerValue);
|
|
3016
|
+
}, marker));
|
|
3017
|
+
}
|
|
2906
3018
|
if (removed) break;
|
|
2907
3019
|
}
|
|
3020
|
+
if (!removed) {
|
|
3021
|
+
removed = await bodyInput.evaluate((element, markerValue) => {
|
|
3022
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
3023
|
+
const nodes = [];
|
|
3024
|
+
let node = walker.nextNode();
|
|
3025
|
+
let combined = "";
|
|
3026
|
+
while (node) {
|
|
3027
|
+
nodes.push({ node, start: combined.length });
|
|
3028
|
+
combined += node.data;
|
|
3029
|
+
node = walker.nextNode();
|
|
3030
|
+
}
|
|
3031
|
+
const markerStart = combined.indexOf(markerValue);
|
|
3032
|
+
if (markerStart < 0) return false;
|
|
3033
|
+
const markerEnd = markerStart + markerValue.length;
|
|
3034
|
+
const start = [...nodes].reverse().find(item => item.start <= markerStart);
|
|
3035
|
+
const end = nodes.find(item => item.start + item.node.data.length >= markerEnd);
|
|
3036
|
+
if (!start || !end) return false;
|
|
3037
|
+
const range = document.createRange();
|
|
3038
|
+
range.setStart(start.node, markerStart - start.start);
|
|
3039
|
+
range.setEnd(end.node, markerEnd - end.start);
|
|
3040
|
+
const selection = window.getSelection();
|
|
3041
|
+
selection.removeAllRanges();
|
|
3042
|
+
selection.addRange(range);
|
|
3043
|
+
const executed = document.execCommand("delete");
|
|
3044
|
+
if (executed && !(element.innerText || element.textContent || "").includes(markerValue)) {
|
|
3045
|
+
return true;
|
|
3046
|
+
}
|
|
3047
|
+
range.deleteContents();
|
|
3048
|
+
element.dispatchEvent(new InputEvent("input", {
|
|
3049
|
+
bubbles: true,
|
|
3050
|
+
inputType: "deleteContentBackward",
|
|
3051
|
+
data: null
|
|
3052
|
+
}));
|
|
3053
|
+
return !(element.innerText || element.textContent || "").includes(markerValue);
|
|
3054
|
+
}, marker).catch(() => false);
|
|
3055
|
+
}
|
|
2908
3056
|
if (!removed) {
|
|
2909
3057
|
throw new Error(`TapTap 正文图片占位符未能完整清理: ${item.source}`);
|
|
2910
3058
|
}
|
|
@@ -3035,6 +3183,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3035
3183
|
}
|
|
3036
3184
|
|
|
3037
3185
|
const content = buildBilibiliRichContent(article);
|
|
3186
|
+
options.onProgress?.(`B站 正在写入文字正文(${content.plainText.length} 字)`);
|
|
3038
3187
|
await bodyInput.fill("");
|
|
3039
3188
|
await bodyInput.click();
|
|
3040
3189
|
const markerPrefix = `QCPLAY_BILIBILI_IMAGE_${Date.now()}_`;
|
|
@@ -3059,11 +3208,26 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3059
3208
|
plain: plainParts.join("\n")
|
|
3060
3209
|
});
|
|
3061
3210
|
await page.waitForTimeout(100);
|
|
3211
|
+
options.onProgress?.("B站 文字正文已写入,正在回读校验");
|
|
3212
|
+
options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
if (options.previewOnly) {
|
|
3216
|
+
options.onProgress?.("B站 文字正文已写入,等待用户在页面中核对");
|
|
3217
|
+
return {
|
|
3218
|
+
rich: true,
|
|
3219
|
+
images: 0,
|
|
3220
|
+
imagesPending: content.images.length,
|
|
3221
|
+
links: content.links.length,
|
|
3222
|
+
plainText: content.plainText
|
|
3223
|
+
};
|
|
3062
3224
|
}
|
|
3063
3225
|
|
|
3064
3226
|
const payloadCache = new Map();
|
|
3065
3227
|
let removedImages = 0;
|
|
3066
|
-
for (
|
|
3228
|
+
for (let markerIndex = 0; markerIndex < imageMarkers.length; markerIndex += 1) {
|
|
3229
|
+
const { marker, item, index } = imageMarkers[markerIndex];
|
|
3230
|
+
options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在定位插入位置`);
|
|
3067
3231
|
const selected = await bodyInput.evaluate((element, markerValue) => {
|
|
3068
3232
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
3069
3233
|
let textNode = walker.nextNode();
|
|
@@ -3087,6 +3251,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3087
3251
|
throw new Error(`B站正文图片占位位置丢失: ${item.source}`);
|
|
3088
3252
|
}
|
|
3089
3253
|
await page.keyboard.press("Backspace");
|
|
3254
|
+
options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:正在上传`);
|
|
3090
3255
|
let payload = payloadCache.get(item.source);
|
|
3091
3256
|
if (!payload) {
|
|
3092
3257
|
payload = await imageUploadPayload(item.source, article.articleFile, index, "B站");
|
|
@@ -3100,26 +3265,38 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3100
3265
|
});
|
|
3101
3266
|
if (uploadResult?.removed) {
|
|
3102
3267
|
removedImages += 1;
|
|
3268
|
+
options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:不合规,已从正文移除`);
|
|
3269
|
+
} else {
|
|
3270
|
+
options.onProgress?.(`B站 正文图片 ${markerIndex + 1}/${imageMarkers.length}:上传回执已确认`);
|
|
3103
3271
|
}
|
|
3104
3272
|
}
|
|
3105
3273
|
|
|
3274
|
+
options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
|
|
3106
3275
|
let state = null;
|
|
3107
3276
|
let previousTextLength = -1;
|
|
3108
3277
|
let stableChecks = 0;
|
|
3109
3278
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
3110
|
-
state = await bodyInput
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3279
|
+
state = await bodyInput
|
|
3280
|
+
.evaluate(
|
|
3281
|
+
element => ({
|
|
3282
|
+
text: element.innerText,
|
|
3283
|
+
images: element.querySelectorAll("img").length,
|
|
3284
|
+
failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
|
|
3285
|
+
links: [...element.querySelectorAll("a[href]")].map(link => ({ href: link.href, label: link.innerText }))
|
|
3286
|
+
}),
|
|
3287
|
+
undefined,
|
|
3288
|
+
{ timeout: 15000 }
|
|
3289
|
+
)
|
|
3290
|
+
.catch(error => {
|
|
3291
|
+
throw new Error(`B站正文回读超时或失败:${error.message || error}`);
|
|
3292
|
+
});
|
|
3116
3293
|
const currentLength = normalizeText(state.text).length;
|
|
3117
3294
|
stableChecks = currentLength === previousTextLength ? stableChecks + 1 : 0;
|
|
3118
3295
|
previousTextLength = currentLength;
|
|
3119
3296
|
if (stableChecks >= 3) break;
|
|
3120
3297
|
await page.waitForTimeout(250);
|
|
3121
3298
|
}
|
|
3122
|
-
const expectedImages = content.images.length - removedImages;
|
|
3299
|
+
const expectedImages = options.skipImages ? 0 : content.images.length - removedImages;
|
|
3123
3300
|
if (state.failedImages > 0 || state.images < expectedImages) {
|
|
3124
3301
|
throw new Error(`B站正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
|
|
3125
3302
|
}
|
|
@@ -3142,7 +3319,14 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3142
3319
|
throw new Error(`B站正文超链接未正确写入: ${link.href}`);
|
|
3143
3320
|
}
|
|
3144
3321
|
}
|
|
3145
|
-
|
|
3322
|
+
options.onProgress?.("B站 文字正文回读校验通过");
|
|
3323
|
+
return {
|
|
3324
|
+
rich: true,
|
|
3325
|
+
images: state.images,
|
|
3326
|
+
...(options.skipImages ? { imagesPending: content.images.length } : {}),
|
|
3327
|
+
links: content.links.length,
|
|
3328
|
+
plainText: content.plainText
|
|
3329
|
+
};
|
|
3146
3330
|
}
|
|
3147
3331
|
|
|
3148
3332
|
function haoyouImageUploadPayload(payload, index) {
|
|
@@ -3211,8 +3395,8 @@ async function waitForHaoyouInlineImage(page, bodyInput, imageId, source) {
|
|
|
3211
3395
|
throw new Error(`好游快爆正文图片上传超时: ${source}`);
|
|
3212
3396
|
}
|
|
3213
3397
|
|
|
3214
|
-
export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText) {
|
|
3215
|
-
const content =
|
|
3398
|
+
export async function insertHaoyouRichContent(page, bodyInput, article, fallbackText, options = {}) {
|
|
3399
|
+
const content = buildHaoyouPublishRichContent(article);
|
|
3216
3400
|
if (typeof bodyInput.evaluate !== "function") {
|
|
3217
3401
|
await fillStableHaoyouValue(page, bodyInput, fallbackText, "正文");
|
|
3218
3402
|
return { rich: false, images: 0, links: 0, plainText: fallbackText };
|
|
@@ -3254,6 +3438,10 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
3254
3438
|
continue;
|
|
3255
3439
|
}
|
|
3256
3440
|
|
|
3441
|
+
if (options.skipImages) {
|
|
3442
|
+
imageIndex += 1;
|
|
3443
|
+
continue;
|
|
3444
|
+
}
|
|
3257
3445
|
let upload = payloadCache.get(item.source);
|
|
3258
3446
|
if (!upload) {
|
|
3259
3447
|
upload = haoyouImageUploadPayload(
|
|
@@ -3352,8 +3540,9 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
3352
3540
|
if (stableChecks >= 3) break;
|
|
3353
3541
|
await page.waitForTimeout(250);
|
|
3354
3542
|
}
|
|
3355
|
-
|
|
3356
|
-
|
|
3543
|
+
const expectedImages = options.skipImages ? 0 : content.images.length;
|
|
3544
|
+
if (state.failedImages > 0 || state.pendingImages > 0 || state.images < expectedImages) {
|
|
3545
|
+
throw new Error(`好游快爆正文图片写入不完整,预期 ${expectedImages} 张,实际 ${state.images} 张`);
|
|
3357
3546
|
}
|
|
3358
3547
|
const expectedFragments = content.items
|
|
3359
3548
|
.filter(item => item.type === "html" && item.plain)
|
|
@@ -3372,6 +3561,7 @@ export async function insertHaoyouRichContent(page, bodyInput, article, fallback
|
|
|
3372
3561
|
return {
|
|
3373
3562
|
rich: true,
|
|
3374
3563
|
images: state.images,
|
|
3564
|
+
...(options.skipImages ? { imagesPending: content.images.length } : {}),
|
|
3375
3565
|
links: state.links,
|
|
3376
3566
|
headings: state.headings,
|
|
3377
3567
|
formatted: state.formatted,
|
|
@@ -3386,6 +3576,51 @@ function safeProfileName(entry) {
|
|
|
3386
3576
|
.slice(0, 80);
|
|
3387
3577
|
}
|
|
3388
3578
|
|
|
3579
|
+
function browserProfileDir(entry) {
|
|
3580
|
+
return path.join(os.homedir(), ".qcplay", "browser-profiles", safeProfileName(entry));
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
function browserSubmissionKey(entry, article) {
|
|
3584
|
+
const source = normalizeText(article.meta?.source_url || article.payload?.source_url);
|
|
3585
|
+
const content = normalizeText(article.html || article.markdown || "");
|
|
3586
|
+
return createHash("sha256")
|
|
3587
|
+
.update(`${entry.platformKey}\n${entry.project}\n${entry.region}\n${source}\n${article.title}\n${content}`)
|
|
3588
|
+
.digest("hex");
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
async function readBrowserSubmissionState() {
|
|
3592
|
+
try {
|
|
3593
|
+
const value = JSON.parse(await fs.promises.readFile(BROWSER_SUBMISSION_STATE_FILE, "utf8"));
|
|
3594
|
+
return value && typeof value === "object" ? value : {};
|
|
3595
|
+
} catch {
|
|
3596
|
+
return {};
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
|
|
3600
|
+
async function ensureBrowserSubmissionCooldown(entry, article, options) {
|
|
3601
|
+
if (!options.directPublish && !options.publishDraft) return "";
|
|
3602
|
+
const key = browserSubmissionKey(entry, article);
|
|
3603
|
+
const previous = Number((await readBrowserSubmissionState())[key] || 0);
|
|
3604
|
+
const remaining = BROWSER_SUBMISSION_COOLDOWN_MS - (Date.now() - previous);
|
|
3605
|
+
if (remaining > 0) {
|
|
3606
|
+
throw new Error(`${entry.platform} 同一篇文章刚刚已提交,请等待 ${Math.ceil(remaining / 1000)} 秒后再重试,避免触发平台风控或重复发布`);
|
|
3607
|
+
}
|
|
3608
|
+
return key;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
async function recordBrowserSubmission(key) {
|
|
3612
|
+
if (!key) return;
|
|
3613
|
+
const state = await readBrowserSubmissionState();
|
|
3614
|
+
state[key] = Date.now();
|
|
3615
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
3616
|
+
for (const [candidate, timestamp] of Object.entries(state)) {
|
|
3617
|
+
if (!Number.isFinite(Number(timestamp)) || Number(timestamp) < cutoff) delete state[candidate];
|
|
3618
|
+
}
|
|
3619
|
+
await fs.promises.mkdir(path.dirname(BROWSER_SUBMISSION_STATE_FILE), { recursive: true, mode: 0o700 });
|
|
3620
|
+
await fs.promises.writeFile(BROWSER_SUBMISSION_STATE_FILE, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
3621
|
+
await fs.promises.chmod(BROWSER_SUBMISSION_STATE_FILE, 0o600).catch(() => {});
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3389
3624
|
function isNavigationContextError(error) {
|
|
3390
3625
|
const message = String(error?.message || error);
|
|
3391
3626
|
return /execution context was destroyed|cannot find context with specified id|frame was detached|navigation.*interrupted/i.test(
|
|
@@ -3678,11 +3913,25 @@ async function waitForUser(message, streams = {}) {
|
|
|
3678
3913
|
}
|
|
3679
3914
|
|
|
3680
3915
|
function reviewApprovalAccepted(answer) {
|
|
3681
|
-
return ["
|
|
3916
|
+
return ["发布", "可以发布", "publish", "yes", "y"].includes(normalizeKey(answer));
|
|
3682
3917
|
}
|
|
3683
3918
|
|
|
3684
3919
|
function reviewApprovalCancelled(answer) {
|
|
3685
|
-
return ["取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
|
|
3920
|
+
return ["", "取消", "停止", "cancel", "quit", "q", "no", "n"].includes(normalizeKey(answer));
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3923
|
+
export function draftApprovalAction(answer) {
|
|
3924
|
+
const key = normalizeKey(answer);
|
|
3925
|
+
if (["发布", "上线", "正式发布", "publish", "online", "yes", "y"].includes(key)) {
|
|
3926
|
+
return "publish";
|
|
3927
|
+
}
|
|
3928
|
+
if (["保存草稿", "保存草稿箱", "保存到草稿箱", "存草稿", "保留草稿", "草稿", "keep-draft", "keepdraft", "draft"].includes(key)) {
|
|
3929
|
+
return "draft";
|
|
3930
|
+
}
|
|
3931
|
+
if (reviewApprovalCancelled(key)) {
|
|
3932
|
+
return "cancel";
|
|
3933
|
+
}
|
|
3934
|
+
return "";
|
|
3686
3935
|
}
|
|
3687
3936
|
|
|
3688
3937
|
async function waitForReviewApproval(message, options = {}) {
|
|
@@ -3692,7 +3941,7 @@ async function waitForReviewApproval(message, options = {}) {
|
|
|
3692
3941
|
if (reviewApprovalCancelled(answer)) {
|
|
3693
3942
|
throw new Error("用户已取消正式发布,草稿未提交");
|
|
3694
3943
|
}
|
|
3695
|
-
throw new Error(`无法识别草稿确认输入“${answer}
|
|
3944
|
+
throw new Error(`无法识别草稿确认输入“${answer}”,请输入“可以发布”或“取消”`);
|
|
3696
3945
|
}
|
|
3697
3946
|
const input = options.streams?.input || process.stdin;
|
|
3698
3947
|
const output = options.streams?.output || process.stdout;
|
|
@@ -3704,13 +3953,13 @@ async function waitForReviewApproval(message, options = {}) {
|
|
|
3704
3953
|
for (;;) {
|
|
3705
3954
|
const answer = await question(
|
|
3706
3955
|
rl,
|
|
3707
|
-
`${message}\n
|
|
3956
|
+
`${message}\n输入“可以发布”继续;直接按 Enter 默认取消,输入“取消”停止: `
|
|
3708
3957
|
);
|
|
3709
3958
|
if (reviewApprovalAccepted(answer)) return;
|
|
3710
3959
|
if (reviewApprovalCancelled(answer)) {
|
|
3711
3960
|
throw new Error("用户已取消正式发布,草稿未提交");
|
|
3712
3961
|
}
|
|
3713
|
-
output.write("
|
|
3962
|
+
output.write("无法识别输入,请输入“可以发布”或“取消”(直接按 Enter 会取消)。\n");
|
|
3714
3963
|
}
|
|
3715
3964
|
} finally {
|
|
3716
3965
|
rl.close();
|
|
@@ -3722,6 +3971,19 @@ async function waitForReviewApproval(message, options = {}) {
|
|
|
3722
3971
|
* This is intentionally separate from publishWithBrowser so page structure and login
|
|
3723
3972
|
* flows can be checked against the real site before running an article publish.
|
|
3724
3973
|
*/
|
|
3974
|
+
async function releaseAwaitingActionBrowser(page) {
|
|
3975
|
+
const context = typeof page?.context === "function" ? page.context() : null;
|
|
3976
|
+
if (typeof context?.__qcplayDetach !== "function") {
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
3979
|
+
await context.__qcplayDetach();
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
async function awaitingActionResult(page, result) {
|
|
3983
|
+
await releaseAwaitingActionBrowser(page);
|
|
3984
|
+
return result;
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3725
3987
|
export async function openPublishPage(entry, options = {}) {
|
|
3726
3988
|
const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
|
|
3727
3989
|
if (!spec || entry.publisher !== "browser") {
|
|
@@ -3732,13 +3994,21 @@ export async function openPublishPage(entry, options = {}) {
|
|
|
3732
3994
|
throw new Error(`${entry.platform} 配置缺少发布页面 URL`);
|
|
3733
3995
|
}
|
|
3734
3996
|
|
|
3735
|
-
const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
|
|
3997
|
+
const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
|
|
3736
3998
|
const ownsContext = !options.context;
|
|
3737
3999
|
try {
|
|
3738
4000
|
const page = context.pages()[0] || (await context.newPage());
|
|
3739
4001
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
3740
4002
|
await afterNavigation(page);
|
|
3741
4003
|
options.onProgress?.(`已打开真实${entry.platform}发布页面: ${page.url()}`);
|
|
4004
|
+
if (options.awaitAIAction) {
|
|
4005
|
+
return awaitingActionResult(page, {
|
|
4006
|
+
url: page.url(),
|
|
4007
|
+
status: "awaiting_action",
|
|
4008
|
+
approvalRequired: true,
|
|
4009
|
+
approvalActions: ["publish", "save_draft", "cancel"]
|
|
4010
|
+
});
|
|
4011
|
+
}
|
|
3742
4012
|
const prompt = options.waitForUser || (message => waitForUser(message, options.streams));
|
|
3743
4013
|
const promptMessage = options.accountSwitch
|
|
3744
4014
|
? `${entry.platform}账号切换页面已打开。请在浏览器中完成账号切换,确认当前账号正确后按 Enter 继续后续发布流程`
|
|
@@ -3746,22 +4016,32 @@ export async function openPublishPage(entry, options = {}) {
|
|
|
3746
4016
|
await prompt(promptMessage);
|
|
3747
4017
|
return { url: page.url(), status: "opened" };
|
|
3748
4018
|
} finally {
|
|
3749
|
-
if (ownsContext) {
|
|
3750
|
-
await context
|
|
4019
|
+
if (ownsContext && !options.awaitAIAction) {
|
|
4020
|
+
await closeBrowserContext(context);
|
|
3751
4021
|
}
|
|
3752
4022
|
}
|
|
3753
4023
|
}
|
|
3754
4024
|
|
|
3755
4025
|
async function reviewPreparedDraft(page, entry, options) {
|
|
3756
4026
|
if (!options.reviewDraft) {
|
|
3757
|
-
return;
|
|
4027
|
+
return null;
|
|
3758
4028
|
}
|
|
3759
4029
|
await page.waitForTimeout(500);
|
|
4030
|
+
if (options.awaitAIAction) {
|
|
4031
|
+
options.onProgress?.(`${entry.platform} 标题和正文已填入,正在等待 AI 对话授权`);
|
|
4032
|
+
return awaitingActionResult(page, {
|
|
4033
|
+
url: page.url(),
|
|
4034
|
+
status: "awaiting_action",
|
|
4035
|
+
approvalRequired: true,
|
|
4036
|
+
approvalActions: ["publish", "save_draft", "cancel"]
|
|
4037
|
+
});
|
|
4038
|
+
}
|
|
3760
4039
|
await waitForReviewApproval(
|
|
3761
4040
|
`${entry.platform} 待发布稿已准备,请在浏览器中预览标题、正文、图片和发布设置`,
|
|
3762
4041
|
options
|
|
3763
4042
|
);
|
|
3764
4043
|
options.onProgress?.(`${entry.platform} 草稿预览已确认,正在正式发布`);
|
|
4044
|
+
return null;
|
|
3765
4045
|
}
|
|
3766
4046
|
|
|
3767
4047
|
async function editorIsReady(page, spec) {
|
|
@@ -4325,7 +4605,7 @@ async function submitTapTap(page, settings, options, promptUser) {
|
|
|
4325
4605
|
}
|
|
4326
4606
|
}
|
|
4327
4607
|
|
|
4328
|
-
async function resumeMatchingTapTapDraft(page, spec, articleTitle) {
|
|
4608
|
+
async function resumeMatchingTapTapDraft(page, spec, articleTitle, editorUrl = "") {
|
|
4329
4609
|
if (typeof page.getByText !== "function") {
|
|
4330
4610
|
return false;
|
|
4331
4611
|
}
|
|
@@ -4354,6 +4634,16 @@ async function resumeMatchingTapTapDraft(page, spec, articleTitle) {
|
|
|
4354
4634
|
await page.waitForTimeout(100);
|
|
4355
4635
|
} while (Date.now() < deadline);
|
|
4356
4636
|
if (draftTitle && draftTitle !== articleTitle) {
|
|
4637
|
+
if (editorUrl) {
|
|
4638
|
+
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4639
|
+
await afterNavigation(page);
|
|
4640
|
+
const ignore = page.getByText("忽略", { exact: true }).first();
|
|
4641
|
+
if ((await ignore.count().catch(() => 0)) > 0 && (await ignore.isVisible().catch(() => false))) {
|
|
4642
|
+
await ignore.click();
|
|
4643
|
+
await page.waitForTimeout(500);
|
|
4644
|
+
return false;
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4357
4647
|
throw new Error(`TapTap 存在其他未发布草稿“${draftTitle}”,已停止以避免覆盖或产生额外草稿`);
|
|
4358
4648
|
}
|
|
4359
4649
|
return true;
|
|
@@ -4383,16 +4673,16 @@ async function uploadTapTapCover(page, source, articleFile) {
|
|
|
4383
4673
|
|
|
4384
4674
|
async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
4385
4675
|
let preparedArticle = prepareTapTapArticleForProject(article, entry);
|
|
4386
|
-
const settings =
|
|
4676
|
+
const settings = {
|
|
4677
|
+
...resolveTapTapPublishingOptions(preparedArticle, entry),
|
|
4678
|
+
...(options.draftOnly ? { draft: true, scheduled: "" } : {})
|
|
4679
|
+
};
|
|
4387
4680
|
const titleLimit = settings.type === "moment" ? 20 : 30;
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
`TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${preparedArticle.title.length} 个字符`
|
|
4394
|
-
);
|
|
4395
|
-
}
|
|
4681
|
+
preparedArticle = { ...preparedArticle, title: resolveTapTapTitle(preparedArticle, titleLimit) };
|
|
4682
|
+
if (Array.from(preparedArticle.title).length > titleLimit) {
|
|
4683
|
+
throw new Error(
|
|
4684
|
+
`TapTap ${settings.typeLabel}标题最多 ${titleLimit} 个字符,当前为 ${Array.from(preparedArticle.title).length} 个字符`
|
|
4685
|
+
);
|
|
4396
4686
|
}
|
|
4397
4687
|
const richContent = buildTapTapRichContent(preparedArticle);
|
|
4398
4688
|
const body = richContent.plainText || plainTextForPlatform(preparedArticle, "taptap");
|
|
@@ -4417,7 +4707,14 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
|
4417
4707
|
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
|
|
4418
4708
|
throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
|
|
4419
4709
|
}
|
|
4420
|
-
await resumeMatchingTapTapDraft(page, spec, preparedArticle.title);
|
|
4710
|
+
const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
|
|
4711
|
+
if (options.publishDraft) {
|
|
4712
|
+
if (!resumedDraft) {
|
|
4713
|
+
throw new Error("未找到标题匹配的 TapTap 草稿,已停止以避免重新写入内容");
|
|
4714
|
+
}
|
|
4715
|
+
const confirmation = await submitTapTap(page, { ...settings, draft: false, scheduled: "" }, options, promptUser);
|
|
4716
|
+
return { url: page.url(), status: "published", type: settings.type, resumedDraft: true, ...confirmation };
|
|
4717
|
+
}
|
|
4421
4718
|
|
|
4422
4719
|
if (settings.type === "moment") {
|
|
4423
4720
|
const imageSources = settings.images.length
|
|
@@ -4454,9 +4751,54 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
|
4454
4751
|
await applyTapTapForum(page, settings.forum, promptUser, { project: entry.project });
|
|
4455
4752
|
await applyTapTapSchedule(page, settings.scheduled, promptUser);
|
|
4456
4753
|
if (!settings.draft) {
|
|
4457
|
-
await reviewPreparedDraft(page, entry, options);
|
|
4754
|
+
const review = await reviewPreparedDraft(page, entry, options);
|
|
4755
|
+
if (review) {
|
|
4756
|
+
return { ...review, type: settings.type };
|
|
4757
|
+
}
|
|
4458
4758
|
}
|
|
4459
4759
|
const confirmation = await submitTapTap(page, settings, options, promptUser);
|
|
4760
|
+
if (options.draftOnly) {
|
|
4761
|
+
return {
|
|
4762
|
+
url: page.url(),
|
|
4763
|
+
status: "draft",
|
|
4764
|
+
type: settings.type,
|
|
4765
|
+
draftOnly: true,
|
|
4766
|
+
approvalRequired: true,
|
|
4767
|
+
approvalActions: ["publish", "cancel"],
|
|
4768
|
+
...confirmation
|
|
4769
|
+
};
|
|
4770
|
+
}
|
|
4771
|
+
if (settings.draft && typeof options.waitForDraftApproval === "function") {
|
|
4772
|
+
const answer = await options.waitForDraftApproval(
|
|
4773
|
+
"TapTap 草稿已保存,页面保持打开。请选择“发布/上线”“保存草稿”或“取消”"
|
|
4774
|
+
);
|
|
4775
|
+
const action = draftApprovalAction(answer);
|
|
4776
|
+
if (action === "publish") {
|
|
4777
|
+
const publishConfirmation = await submitTapTap(
|
|
4778
|
+
page,
|
|
4779
|
+
{ ...settings, draft: false },
|
|
4780
|
+
{ ...options, reviewDraft: false, directPublish: true },
|
|
4781
|
+
promptUser
|
|
4782
|
+
);
|
|
4783
|
+
return {
|
|
4784
|
+
url: page.url(),
|
|
4785
|
+
status: "published",
|
|
4786
|
+
type: settings.type,
|
|
4787
|
+
draftConfirmation: confirmation.confirmation || "response",
|
|
4788
|
+
...publishConfirmation
|
|
4789
|
+
};
|
|
4790
|
+
}
|
|
4791
|
+
if (!action) {
|
|
4792
|
+
throw new Error(`无法识别草稿授权“${normalizeText(answer)}”,请选择发布、保存草稿或取消`);
|
|
4793
|
+
}
|
|
4794
|
+
return {
|
|
4795
|
+
url: page.url(),
|
|
4796
|
+
status: "draft",
|
|
4797
|
+
type: settings.type,
|
|
4798
|
+
draftApproval: action,
|
|
4799
|
+
...confirmation
|
|
4800
|
+
};
|
|
4801
|
+
}
|
|
4460
4802
|
if (options.keepOpen) {
|
|
4461
4803
|
await promptUser(
|
|
4462
4804
|
`TapTap ${settings.draft ? "草稿保存" : settings.scheduled ? "定时发布" : "发布"}已收到成功回执,浏览器保持打开供检查`
|
|
@@ -4805,9 +5147,63 @@ async function submitBilibili(page, spec, settings, article, metadataApplied, op
|
|
|
4805
5147
|
}
|
|
4806
5148
|
}
|
|
4807
5149
|
|
|
5150
|
+
async function preparedBilibiliArticleState(page, spec, settings, article) {
|
|
5151
|
+
if (settings.type !== "article") return null;
|
|
5152
|
+
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 1000))) return null;
|
|
5153
|
+
const titleInput = await waitForVisible(page, spec.title, 1000);
|
|
5154
|
+
const bodyInput = await waitForVisible(page, spec.body, 1000);
|
|
5155
|
+
if (!titleInput || !bodyInput) return null;
|
|
5156
|
+
const title = normalizeText(
|
|
5157
|
+
typeof titleInput.inputValue === "function"
|
|
5158
|
+
? await titleInput.inputValue().catch(() => "")
|
|
5159
|
+
: typeof titleInput.innerText === "function"
|
|
5160
|
+
? await titleInput.innerText().catch(() => "")
|
|
5161
|
+
: ""
|
|
5162
|
+
);
|
|
5163
|
+
if (title !== normalizeText(article.title)) return null;
|
|
5164
|
+
|
|
5165
|
+
const content = buildBilibiliRichContent(article);
|
|
5166
|
+
const state = await bodyInput
|
|
5167
|
+
.evaluate(element => ({
|
|
5168
|
+
text: element.innerText,
|
|
5169
|
+
images: element.querySelectorAll("img").length,
|
|
5170
|
+
failedImages: element.querySelectorAll('.upload-fail, .image-upload-error, [class*="upload-error"]').length,
|
|
5171
|
+
links: [...element.querySelectorAll("a[href]")].map(link => link.href)
|
|
5172
|
+
}))
|
|
5173
|
+
.catch(() => null);
|
|
5174
|
+
if (!state || state.failedImages > 0 || state.images < content.images.length) return null;
|
|
5175
|
+
const actualText = normalizedPresenceText(state.text);
|
|
5176
|
+
if (content.items.some(item => item.type === "html" && item.plain && !actualText.includes(normalizedPresenceText(item.plain)))) {
|
|
5177
|
+
return null;
|
|
5178
|
+
}
|
|
5179
|
+
const actualLinks = new Set(state.links.map(safeTapTapHref));
|
|
5180
|
+
if (content.links.some(link => !actualLinks.has(link.href))) return null;
|
|
5181
|
+
return {
|
|
5182
|
+
richContent: { rich: true, images: state.images, links: content.links.length, plainText: content.plainText },
|
|
5183
|
+
metadataApplied: { category: true, tags: true, topic: true, cover: true }
|
|
5184
|
+
};
|
|
5185
|
+
}
|
|
5186
|
+
|
|
4808
5187
|
async function publishBilibili(page, entry, article, spec, options, promptUser) {
|
|
4809
5188
|
const settings = resolveBilibiliPublishingOptions(article, entry);
|
|
4810
5189
|
const editorUrl = browserPlatformPageUrl(entry, settings.type);
|
|
5190
|
+
if (options.directPublish) {
|
|
5191
|
+
const prepared = await preparedBilibiliArticleState(page, spec, settings, article);
|
|
5192
|
+
if (!prepared) {
|
|
5193
|
+
throw new Error("B站待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
|
|
5194
|
+
}
|
|
5195
|
+
options.onProgress?.("已复用 B站 待发布内容,正在提交,不重复上传正文图片或封面");
|
|
5196
|
+
const confirmation = await submitBilibili(
|
|
5197
|
+
page,
|
|
5198
|
+
spec,
|
|
5199
|
+
settings,
|
|
5200
|
+
article,
|
|
5201
|
+
prepared.metadataApplied,
|
|
5202
|
+
options,
|
|
5203
|
+
promptUser
|
|
5204
|
+
);
|
|
5205
|
+
return { url: page.url(), status: "published", type: settings.type, ...prepared.richContent, ...confirmation };
|
|
5206
|
+
}
|
|
4811
5207
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
4812
5208
|
await afterNavigation(page);
|
|
4813
5209
|
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings)))) {
|
|
@@ -4840,8 +5236,16 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
|
|
|
4840
5236
|
await validateBilibiliFieldLength(bodyInput, body, settings.type === "video" ? "简介" : "正文");
|
|
4841
5237
|
await fillLocator(titleInput, article.title);
|
|
4842
5238
|
await verifyFilledValue(titleInput, article.title, "标题", "B站");
|
|
4843
|
-
|
|
4844
|
-
|
|
5239
|
+
let richContent = null;
|
|
5240
|
+
if (options.previewTextOnly) {
|
|
5241
|
+
richContent = await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
|
|
5242
|
+
skipImages: settings.type === "article",
|
|
5243
|
+
previewOnly: true,
|
|
5244
|
+
onProgress: options.onProgress
|
|
5245
|
+
});
|
|
5246
|
+
} else if (settings.type === "article") {
|
|
5247
|
+
richContent = await insertBilibiliRichContent(page, bodyInput, article, spec, body, {
|
|
5248
|
+
onProgress: options.onProgress,
|
|
4845
5249
|
removeNoncompliant: normalizeText(entry.project) === "最强蜗牛"
|
|
4846
5250
|
});
|
|
4847
5251
|
} else {
|
|
@@ -4852,9 +5256,27 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
|
|
|
4852
5256
|
|
|
4853
5257
|
const metadataApplied =
|
|
4854
5258
|
settings.type === "article"
|
|
4855
|
-
?
|
|
5259
|
+
? options.previewTextOnly
|
|
5260
|
+
? { category: false, tags: false, topic: false, cover: false, deferred: true }
|
|
5261
|
+
: await applyBilibiliMetadata(page, spec, settings, article, true)
|
|
4856
5262
|
: { category: true, tags: true, topic: true, cover: true };
|
|
4857
|
-
|
|
5263
|
+
if (!options.draftOnly) {
|
|
5264
|
+
const review = await reviewPreparedDraft(page, entry, options);
|
|
5265
|
+
if (review) {
|
|
5266
|
+
return { ...review, type: settings.type, metadataApplied, ...richContent };
|
|
5267
|
+
}
|
|
5268
|
+
}
|
|
5269
|
+
if (options.draftOnly) {
|
|
5270
|
+
return {
|
|
5271
|
+
url: page.url(),
|
|
5272
|
+
status: "draft",
|
|
5273
|
+
type: settings.type,
|
|
5274
|
+
draftOnly: true,
|
|
5275
|
+
approvalRequired: true,
|
|
5276
|
+
approvalActions: ["publish", "cancel"],
|
|
5277
|
+
metadataApplied
|
|
5278
|
+
};
|
|
5279
|
+
}
|
|
4858
5280
|
const confirmation = await submitBilibili(page, spec, settings, article, metadataApplied, options, promptUser);
|
|
4859
5281
|
if (options.keepOpen) {
|
|
4860
5282
|
await promptUser(`B站${settings.typeLabel}投稿已收到成功回执,浏览器保持打开供检查`);
|
|
@@ -5151,6 +5573,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5151
5573
|
} while (true);
|
|
5152
5574
|
|
|
5153
5575
|
let uploadedPreview = previews.last();
|
|
5576
|
+
let candidateIndexes = [];
|
|
5154
5577
|
if (previousPreviewKeys.length > 0 && typeof previews.evaluateAll === "function") {
|
|
5155
5578
|
const previewKeys = await previews
|
|
5156
5579
|
.evaluateAll(elements =>
|
|
@@ -5160,10 +5583,20 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5160
5583
|
})
|
|
5161
5584
|
)
|
|
5162
5585
|
.catch(() => []);
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5586
|
+
candidateIndexes = previewKeys
|
|
5587
|
+
.map((key, index) => (key && !previousPreviewKeys.includes(key) ? index : -1))
|
|
5588
|
+
.filter(index => index >= 0);
|
|
5589
|
+
}
|
|
5590
|
+
const previewCount = await previews.count().catch(() => 0);
|
|
5591
|
+
if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
|
|
5592
|
+
candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
|
|
5593
|
+
}
|
|
5594
|
+
if (candidateIndexes.length === 0 && previewCount > 0) {
|
|
5595
|
+
candidateIndexes = [previewCount - 1];
|
|
5596
|
+
}
|
|
5597
|
+
if (structuredLibrary && candidateIndexes.length > 0) {
|
|
5598
|
+
// Let the common click-and-verify path below perform the selection once.
|
|
5599
|
+
uploadedPreview = previews.nth(candidateIndexes[0]);
|
|
5167
5600
|
}
|
|
5168
5601
|
if ((await uploadedPreview.count()) > 0 && (await uploadedPreview.isVisible().catch(() => false))) {
|
|
5169
5602
|
await uploadedPreview.click();
|
|
@@ -5972,19 +6405,104 @@ async function waitForWeiboSecurityVerification(page, progress) {
|
|
|
5972
6405
|
throw new Error("微博身份安全验证等待超时,请完成滑块验证后重试");
|
|
5973
6406
|
}
|
|
5974
6407
|
|
|
6408
|
+
async function findWeiboShareComposer(page) {
|
|
6409
|
+
const containers = ['[role="dialog"]', '[class*="modal"]', '[class*="dialog"]', '[class*="pop"]'];
|
|
6410
|
+
const fields = ['textarea', '[contenteditable="true"][role="textbox"]', '[contenteditable="true"]'];
|
|
6411
|
+
for (const scope of locatorScopes(page)) {
|
|
6412
|
+
for (const selector of containers) {
|
|
6413
|
+
const candidates = scope.locator(selector);
|
|
6414
|
+
const count = await candidates.count().catch(() => 0);
|
|
6415
|
+
for (let index = count - 1; index >= 0; index -= 1) {
|
|
6416
|
+
const dialog = candidates.nth(index);
|
|
6417
|
+
if (!(await dialog.isVisible().catch(() => false))) continue;
|
|
6418
|
+
const text = normalizeText(await dialog.innerText().catch(() => ""));
|
|
6419
|
+
if (!/发布|公开|头条文章/.test(text)) continue;
|
|
6420
|
+
for (const fieldSelector of fields) {
|
|
6421
|
+
const field = dialog.locator(fieldSelector).first();
|
|
6422
|
+
if ((await field.count().catch(() => 0)) > 0 && (await field.isVisible().catch(() => false))) {
|
|
6423
|
+
return { dialog, field };
|
|
6424
|
+
}
|
|
6425
|
+
}
|
|
6426
|
+
}
|
|
6427
|
+
}
|
|
6428
|
+
}
|
|
6429
|
+
return null;
|
|
6430
|
+
}
|
|
6431
|
+
|
|
6432
|
+
async function waitForWeiboShareComposer(page, timeoutMs = 3500) {
|
|
6433
|
+
const deadline = Date.now() + timeoutMs;
|
|
6434
|
+
do {
|
|
6435
|
+
const composer = await findWeiboShareComposer(page);
|
|
6436
|
+
if (composer) return composer;
|
|
6437
|
+
await page.waitForTimeout(200);
|
|
6438
|
+
} while (Date.now() < deadline);
|
|
6439
|
+
return null;
|
|
6440
|
+
}
|
|
6441
|
+
|
|
6442
|
+
async function fillWeiboShareComposer(page, field, value) {
|
|
6443
|
+
const expected = normalizeText(value).replace(/\r\n/g, "\n");
|
|
6444
|
+
let actual = "";
|
|
6445
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
6446
|
+
await fillLocator(field, value);
|
|
6447
|
+
await page.waitForTimeout(200);
|
|
6448
|
+
actual =
|
|
6449
|
+
typeof field.inputValue === "function"
|
|
6450
|
+
? await field.inputValue().catch(() => "")
|
|
6451
|
+
: typeof field.innerText === "function"
|
|
6452
|
+
? await field.innerText().catch(() => "")
|
|
6453
|
+
: "";
|
|
6454
|
+
if (normalizeText(actual).replace(/\r\n/g, "\n") === expected) return;
|
|
6455
|
+
}
|
|
6456
|
+
throw new Error(`微博分享文案未正确写入发布弹窗,实际为“${normalizeText(actual).slice(0, 40)}”`);
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
async function findWeiboShareSubmitButton(page, dialog) {
|
|
6460
|
+
const selectors = [
|
|
6461
|
+
'button',
|
|
6462
|
+
'[role="button"]',
|
|
6463
|
+
'a[class*="btn"]',
|
|
6464
|
+
'[class*="btn"]',
|
|
6465
|
+
'[class*="button"]'
|
|
6466
|
+
];
|
|
6467
|
+
const findIn = async container => {
|
|
6468
|
+
for (const selector of selectors) {
|
|
6469
|
+
const candidates = container.locator(selector);
|
|
6470
|
+
const count = await candidates.count().catch(() => 0);
|
|
6471
|
+
for (let index = count - 1; index >= 0; index -= 1) {
|
|
6472
|
+
const candidate = candidates.nth(index);
|
|
6473
|
+
if (!(await candidate.isVisible().catch(() => false))) continue;
|
|
6474
|
+
if (typeof candidate.isDisabled === "function" && (await candidate.isDisabled().catch(() => false))) continue;
|
|
6475
|
+
const attribute = async name =>
|
|
6476
|
+
typeof candidate.getAttribute === "function" ? await candidate.getAttribute(name).catch(() => "") : "";
|
|
6477
|
+
const text = normalizeText(
|
|
6478
|
+
(await candidate.innerText().catch(() => "")) ||
|
|
6479
|
+
(await attribute("aria-label")) ||
|
|
6480
|
+
(await attribute("title")) ||
|
|
6481
|
+
(await attribute("value"))
|
|
6482
|
+
);
|
|
6483
|
+
if (/^(发布|立即发布|确认发布)$/.test(text)) return candidate;
|
|
6484
|
+
}
|
|
6485
|
+
}
|
|
6486
|
+
return null;
|
|
6487
|
+
};
|
|
6488
|
+
const scoped = await findIn(dialog);
|
|
6489
|
+
if (scoped) return scoped;
|
|
6490
|
+
// Some Weibo versions render the modal text field and action bar in sibling
|
|
6491
|
+
// nodes, so fall back to the last visible exact publish control on the page.
|
|
6492
|
+
return findIn(page);
|
|
6493
|
+
}
|
|
6494
|
+
|
|
5975
6495
|
async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
5976
6496
|
const progress = message => options.onProgress?.(message);
|
|
5977
6497
|
const preparedArticle = prepareWeiboArticleForProject(article, entry);
|
|
5978
6498
|
const settings = resolveWeiboPublishingOptions(preparedArticle);
|
|
6499
|
+
const title = resolveWeiboArticleTitle(preparedArticle);
|
|
5979
6500
|
const body = buildWeiboText(preparedArticle);
|
|
5980
6501
|
if (!body) {
|
|
5981
6502
|
throw new Error("微博文章正文不能为空");
|
|
5982
6503
|
}
|
|
5983
|
-
if (
|
|
5984
|
-
throw new Error(`微博文章标题最多 32 个字符,当前为 ${
|
|
5985
|
-
}
|
|
5986
|
-
if (settings.intro.length > 44) {
|
|
5987
|
-
throw new Error(`微博文章导语最多 44 个字符,当前为 ${settings.intro.length} 个字符`);
|
|
6504
|
+
if (Array.from(title).length > 32) {
|
|
6505
|
+
throw new Error(`微博文章标题最多 32 个字符,当前为 ${Array.from(title).length} 个字符`);
|
|
5988
6506
|
}
|
|
5989
6507
|
|
|
5990
6508
|
const editorUrl = weiboArticleEditorUrl(entry.url);
|
|
@@ -5999,7 +6517,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
5999
6517
|
throw new Error("微博登录完成后仍未进入头条文章编辑器,请确认账号拥有文章发布权限");
|
|
6000
6518
|
}
|
|
6001
6519
|
|
|
6002
|
-
const draftMode = await ensureWeiboArticleDraft(page,
|
|
6520
|
+
const draftMode = await ensureWeiboArticleDraft(page, title);
|
|
6003
6521
|
progress(
|
|
6004
6522
|
draftMode === "resumed"
|
|
6005
6523
|
? "已复用最新同标题微博草稿,正在重写文章内容"
|
|
@@ -6011,14 +6529,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6011
6529
|
if (!titleInput || !bodyInput) {
|
|
6012
6530
|
throw new Error("微博头条文章编辑器未完整加载,未找到标题或正文控件");
|
|
6013
6531
|
}
|
|
6014
|
-
await fillStableWeiboField(page, spec.title,
|
|
6015
|
-
const introInput = await waitForVisible(page, spec.intro, 3000);
|
|
6016
|
-
if (introInput) {
|
|
6017
|
-
// Empty input is intentional: drafts may retain the previous article's intro.
|
|
6018
|
-
await fillStableWeiboField(page, spec.intro, settings.intro, "导语");
|
|
6019
|
-
} else if (settings.intro) {
|
|
6020
|
-
throw new Error("微博头条文章编辑器中未找到导语输入框");
|
|
6021
|
-
}
|
|
6532
|
+
await fillStableWeiboField(page, spec.title, title, "标题");
|
|
6022
6533
|
const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
|
|
6023
6534
|
const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
|
|
6024
6535
|
onProgress: progress
|
|
@@ -6035,7 +6546,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6035
6546
|
throw new Error("微博文章“下一步”按钮不可用,请检查标题、正文和封面设置");
|
|
6036
6547
|
}
|
|
6037
6548
|
|
|
6038
|
-
|
|
6549
|
+
if (!options.draftOnly) {
|
|
6550
|
+
const review = await reviewPreparedDraft(page, entry, options);
|
|
6551
|
+
if (review) {
|
|
6552
|
+
return { ...review, type: "article", title, ...richContent, ...cover, column };
|
|
6553
|
+
}
|
|
6554
|
+
}
|
|
6555
|
+
if (options.draftOnly) {
|
|
6556
|
+
return {
|
|
6557
|
+
url: page.url(),
|
|
6558
|
+
status: "draft",
|
|
6559
|
+
type: "article",
|
|
6560
|
+
title,
|
|
6561
|
+
draftOnly: true,
|
|
6562
|
+
approvalRequired: true,
|
|
6563
|
+
approvalActions: ["publish", "cancel"],
|
|
6564
|
+
...richContent,
|
|
6565
|
+
...cover,
|
|
6566
|
+
column
|
|
6567
|
+
};
|
|
6568
|
+
}
|
|
6039
6569
|
const initialUrl = page.url();
|
|
6040
6570
|
progress("正在进入发布确认页");
|
|
6041
6571
|
let submitButton = null;
|
|
@@ -6087,19 +6617,43 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6087
6617
|
if (typeof submitButton.isDisabled === "function" && (await submitButton.isDisabled().catch(() => false))) {
|
|
6088
6618
|
throw new Error("微博文章最终发布按钮不可用,请检查发布设置");
|
|
6089
6619
|
}
|
|
6090
|
-
const
|
|
6620
|
+
const articleMutationPromise =
|
|
6091
6621
|
typeof page.waitForResponse === "function"
|
|
6092
6622
|
? page.waitForResponse(weiboMutationResponse, { timeout: 30000 }).catch(() => null)
|
|
6093
6623
|
: Promise.resolve(null);
|
|
6094
6624
|
progress("正在提交微博头条文章");
|
|
6095
6625
|
await submitButton.click();
|
|
6096
|
-
|
|
6097
|
-
|
|
6626
|
+
const shareComposer = await waitForWeiboShareComposer(page);
|
|
6627
|
+
let confirmation;
|
|
6628
|
+
if (shareComposer) {
|
|
6629
|
+
const shareCopy = resolveWeiboShareCopy(preparedArticle, entry);
|
|
6630
|
+
if (shareCopy) {
|
|
6631
|
+
await fillWeiboShareComposer(page, shareComposer.field, shareCopy);
|
|
6632
|
+
progress("微博分享弹窗文案已替换,正在发布动态");
|
|
6633
|
+
}
|
|
6634
|
+
const shareSubmit = await findWeiboShareSubmitButton(page, shareComposer.dialog);
|
|
6635
|
+
if (!shareSubmit) {
|
|
6636
|
+
throw new Error("微博分享发布弹窗中未找到“发布”按钮");
|
|
6637
|
+
}
|
|
6638
|
+
if (typeof shareSubmit.isDisabled === "function" && (await shareSubmit.isDisabled().catch(() => false))) {
|
|
6639
|
+
throw new Error("微博分享发布弹窗中的“发布”按钮不可用");
|
|
6640
|
+
}
|
|
6641
|
+
const shareMutationPromise =
|
|
6642
|
+
typeof page.waitForResponse === "function"
|
|
6643
|
+
? page.waitForResponse(weiboMutationResponse, { timeout: 30000 }).catch(() => null)
|
|
6644
|
+
: Promise.resolve(null);
|
|
6645
|
+
await shareSubmit.click();
|
|
6646
|
+
progress("动态已提交,正在等待微博成功回执");
|
|
6647
|
+
confirmation = await waitForWeiboSubmission(page, initialUrl, shareMutationPromise, options);
|
|
6648
|
+
} else {
|
|
6649
|
+
progress("已提交,正在等待微博成功回执");
|
|
6650
|
+
confirmation = await waitForWeiboSubmission(page, initialUrl, articleMutationPromise, options);
|
|
6651
|
+
}
|
|
6098
6652
|
progress("已收到微博发布成功回执");
|
|
6099
6653
|
if (options.keepOpen) {
|
|
6100
6654
|
await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
|
|
6101
6655
|
}
|
|
6102
|
-
return { url: page.url(), status: "published", type: "article", ...richContent, ...cover, column, ...confirmation };
|
|
6656
|
+
return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
|
|
6103
6657
|
}
|
|
6104
6658
|
throw new Error("微博发布确认页中未找到最终发布按钮");
|
|
6105
6659
|
}
|
|
@@ -6140,7 +6694,10 @@ async function publishWeiboQuick(page, entry, article, options, promptUser) {
|
|
|
6140
6694
|
await fillLocator(bodyInput, body);
|
|
6141
6695
|
const submit = await waitForVisibleButton(page, [/^发送$/, /^发布$/], 5000);
|
|
6142
6696
|
if (!submit) throw new Error("微博快捷发布页面中未找到发送按钮");
|
|
6143
|
-
await reviewPreparedDraft(page, entry, options);
|
|
6697
|
+
const review = await reviewPreparedDraft(page, entry, options);
|
|
6698
|
+
if (review) {
|
|
6699
|
+
return { ...review, type: "quick" };
|
|
6700
|
+
}
|
|
6144
6701
|
await submit.click();
|
|
6145
6702
|
await page.waitForTimeout(1500);
|
|
6146
6703
|
return { url: page.url(), status: "published", type: "quick" };
|
|
@@ -6150,8 +6707,56 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
|
|
|
6150
6707
|
const editorUrl = browserPlatformPageUrl(entry);
|
|
6151
6708
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6152
6709
|
await afterNavigation(page);
|
|
6153
|
-
const publishing = resolveXiaohongshuPublishingOptions(article);
|
|
6154
6710
|
const body = buildTapTapRichContent(article).plainText || plainTextForPlatform(article, "xiaohongshu");
|
|
6711
|
+
let publishing;
|
|
6712
|
+
try {
|
|
6713
|
+
publishing = resolveXiaohongshuPublishingOptions(article);
|
|
6714
|
+
} catch (error) {
|
|
6715
|
+
if (!options.previewTextOnly) throw error;
|
|
6716
|
+
publishing = {
|
|
6717
|
+
images: [],
|
|
6718
|
+
collection: "",
|
|
6719
|
+
imageSource: "unavailable",
|
|
6720
|
+
validationError: error.message || String(error)
|
|
6721
|
+
};
|
|
6722
|
+
}
|
|
6723
|
+
|
|
6724
|
+
// Kept for callers that deliberately ask for a text-only editor inspection.
|
|
6725
|
+
// Standard AI authorisation never uses this path: it waits for all images and
|
|
6726
|
+
// the editor readback before offering an action.
|
|
6727
|
+
if (options.previewTextOnly) {
|
|
6728
|
+
const titleInput = await waitForVisible(page, spec.title, 1000);
|
|
6729
|
+
const bodyInput = await waitForVisible(page, spec.body, 1000);
|
|
6730
|
+
const titleFits = Array.from(article.title).length <= 20;
|
|
6731
|
+
const bodyFits = Array.from(body).length <= 1000;
|
|
6732
|
+
if (titleInput && bodyInput && titleFits && bodyFits) {
|
|
6733
|
+
await fillLocator(titleInput, article.title);
|
|
6734
|
+
await fillLocator(bodyInput, body);
|
|
6735
|
+
options.onProgress?.("小红书 标题和正文已填入,正在等待 AI 对话授权");
|
|
6736
|
+
return awaitingActionResult(page, {
|
|
6737
|
+
url: page.url(),
|
|
6738
|
+
status: "awaiting_action",
|
|
6739
|
+
approvalRequired: true,
|
|
6740
|
+
approvalActions: ["publish", "save_draft", "cancel"],
|
|
6741
|
+
contentPrepared: true,
|
|
6742
|
+
imagesPending: publishing.images.length,
|
|
6743
|
+
type: "note",
|
|
6744
|
+
...publishing
|
|
6745
|
+
});
|
|
6746
|
+
}
|
|
6747
|
+
options.onProgress?.("小红书需要先上传图片才能进入笔记编辑器;页面已打开,正在等待 AI 对话授权");
|
|
6748
|
+
return awaitingActionResult(page, {
|
|
6749
|
+
url: page.url(),
|
|
6750
|
+
status: "awaiting_action",
|
|
6751
|
+
approvalRequired: true,
|
|
6752
|
+
approvalActions: ["publish", "save_draft", "cancel"],
|
|
6753
|
+
contentPrepared: false,
|
|
6754
|
+
requiresMediaBeforeText: true,
|
|
6755
|
+
imagesPending: publishing.images.length,
|
|
6756
|
+
type: "note",
|
|
6757
|
+
...publishing
|
|
6758
|
+
});
|
|
6759
|
+
}
|
|
6155
6760
|
if (Array.from(body).length > 1000) {
|
|
6156
6761
|
throw new Error(`小红书正文最多 1000 个字符,当前为 ${Array.from(body).length} 个字符;请先精简内容后再发布`);
|
|
6157
6762
|
}
|
|
@@ -6179,12 +6784,20 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
|
|
|
6179
6784
|
}
|
|
6180
6785
|
options.onProgress?.(`正在上传小红书图片 ${payloads.length} 张`);
|
|
6181
6786
|
await imageInput.setInputFiles(payloads);
|
|
6182
|
-
await waitForUploadsSettled(page, "小红书图片", 120000)
|
|
6787
|
+
await waitForUploadsSettled(page, "小红书图片", 120000).catch(error => {
|
|
6788
|
+
// The creator can replace the uploader with a new note-editor tab as soon
|
|
6789
|
+
// as the cover is accepted. The editor lookup below owns that transition.
|
|
6790
|
+
if (!/Target page, context or browser has been closed/i.test(String(error?.message || error))) {
|
|
6791
|
+
throw error;
|
|
6792
|
+
}
|
|
6793
|
+
});
|
|
6183
6794
|
options.onProgress?.("图片上传完成,正在填写笔记内容");
|
|
6184
6795
|
|
|
6185
|
-
|
|
6796
|
+
const editorPage = await waitForXiaohongshuEditorPage(page, spec, 120000);
|
|
6797
|
+
if (!editorPage) {
|
|
6186
6798
|
throw new Error("小红书图片上传完成后未进入笔记编辑器,请检查图片格式或账号发布权限");
|
|
6187
6799
|
}
|
|
6800
|
+
page = editorPage;
|
|
6188
6801
|
const titleInput = await waitForVisible(page, spec.title, 8000);
|
|
6189
6802
|
const bodyInput = await waitForVisible(page, spec.body, 8000);
|
|
6190
6803
|
if (!titleInput || !bodyInput) throw new Error("小红书发布编辑器未找到标题或正文控件");
|
|
@@ -6218,11 +6831,25 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
|
|
|
6218
6831
|
if (typeof submit.isDisabled === "function" && await submit.isDisabled().catch(() => false)) {
|
|
6219
6832
|
throw new Error("小红书发布按钮不可用,请检查正文和图片上传状态");
|
|
6220
6833
|
}
|
|
6834
|
+
if (options.draftOnly) {
|
|
6835
|
+
return {
|
|
6836
|
+
url: page.url(),
|
|
6837
|
+
status: "draft",
|
|
6838
|
+
draftOnly: true,
|
|
6839
|
+
approvalRequired: true,
|
|
6840
|
+
approvalActions: ["publish", "cancel"],
|
|
6841
|
+
type: "note",
|
|
6842
|
+
...publishing
|
|
6843
|
+
};
|
|
6844
|
+
}
|
|
6221
6845
|
if (options.keepOpen && !isInteractiveTerminal(options.streams)) {
|
|
6222
6846
|
options.onProgress?.("小红书待发布内容已准备完成,请在浏览器中核对后手动点击“发布”;程序将等待并验证发布结果");
|
|
6223
6847
|
return waitForManualXiaohongshuSubmission(page, 600000);
|
|
6224
6848
|
}
|
|
6225
|
-
await reviewPreparedDraft(page, entry, options);
|
|
6849
|
+
const review = await reviewPreparedDraft(page, entry, options);
|
|
6850
|
+
if (review) {
|
|
6851
|
+
return { ...review, type: "note", ...publishing };
|
|
6852
|
+
}
|
|
6226
6853
|
const initialUrl = page.url();
|
|
6227
6854
|
const mutationPromise =
|
|
6228
6855
|
typeof page.waitForResponse === "function"
|
|
@@ -6233,6 +6860,32 @@ async function publishXiaohongshu(page, entry, article, spec, options, promptUse
|
|
|
6233
6860
|
return { url: page.url(), status: "published", type: "note", ...publishing, ...confirmation };
|
|
6234
6861
|
}
|
|
6235
6862
|
|
|
6863
|
+
async function waitForXiaohongshuEditorPage(initialPage, spec, timeoutMs) {
|
|
6864
|
+
const context = typeof initialPage?.context === "function" ? initialPage.context() : null;
|
|
6865
|
+
const deadline = Date.now() + timeoutMs;
|
|
6866
|
+
do {
|
|
6867
|
+
const candidates = [initialPage, ...(typeof context?.pages === "function" ? context.pages() : [])]
|
|
6868
|
+
.filter((candidate, index, pages) => candidate && pages.indexOf(candidate) === index)
|
|
6869
|
+
.filter(candidate => !(typeof candidate.isClosed === "function" && candidate.isClosed()));
|
|
6870
|
+
for (const candidate of candidates) {
|
|
6871
|
+
try {
|
|
6872
|
+
if (await editorIsReady(candidate, spec)) return candidate;
|
|
6873
|
+
} catch (error) {
|
|
6874
|
+
if (!/Target page, context or browser has been closed/i.test(String(error?.message || error))) {
|
|
6875
|
+
throw error;
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
6878
|
+
}
|
|
6879
|
+
const waitPage = candidates[0];
|
|
6880
|
+
if (waitPage?.waitForTimeout) {
|
|
6881
|
+
await waitPage.waitForTimeout(300).catch(() => {});
|
|
6882
|
+
} else {
|
|
6883
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
6884
|
+
}
|
|
6885
|
+
} while (Date.now() < deadline);
|
|
6886
|
+
return null;
|
|
6887
|
+
}
|
|
6888
|
+
|
|
6236
6889
|
function isInteractiveTerminal(streams = {}) {
|
|
6237
6890
|
const input = streams.input || process.stdin;
|
|
6238
6891
|
const output = streams.output || process.stdout;
|
|
@@ -6269,11 +6922,64 @@ async function waitForManualXiaohongshuSubmission(page, timeoutMs) {
|
|
|
6269
6922
|
throw new Error("小红书待发布页面等待超时,未检测到手动发布成功回执");
|
|
6270
6923
|
}
|
|
6271
6924
|
|
|
6272
|
-
function xiaohongshuMutationResponse(response) {
|
|
6925
|
+
export function xiaohongshuMutationResponse(response) {
|
|
6273
6926
|
const request = response.request();
|
|
6274
6927
|
if (!/^(?:POST|PUT|PATCH)$/i.test(request.method())) return false;
|
|
6275
6928
|
const url = response.url();
|
|
6276
|
-
|
|
6929
|
+
if (!/xiaohongshu\.com/i.test(url)) return false;
|
|
6930
|
+
// Media transfers can still finish after the user presses Publish. They are
|
|
6931
|
+
// not proof that a note was created and must never satisfy this wait.
|
|
6932
|
+
if (/(?:^|[./-])(?:ros-)?upload(?:[./-]|$)/i.test(url)) return false;
|
|
6933
|
+
return /(?:publish|note|feed|create)/i.test(url);
|
|
6934
|
+
}
|
|
6935
|
+
|
|
6936
|
+
export function xiaohongshuPublishedReceipt(payload = {}) {
|
|
6937
|
+
const data = payload?.data || {};
|
|
6938
|
+
const note = data?.note || payload?.note || {};
|
|
6939
|
+
const publishedUrl = [
|
|
6940
|
+
payload?.published_url,
|
|
6941
|
+
payload?.publishedUrl,
|
|
6942
|
+
payload?.note_url,
|
|
6943
|
+
payload?.noteUrl,
|
|
6944
|
+
payload?.share_url,
|
|
6945
|
+
payload?.shareUrl,
|
|
6946
|
+
data?.published_url,
|
|
6947
|
+
data?.publishedUrl,
|
|
6948
|
+
data?.note_url,
|
|
6949
|
+
data?.noteUrl,
|
|
6950
|
+
data?.share_url,
|
|
6951
|
+
data?.shareUrl,
|
|
6952
|
+
note?.url,
|
|
6953
|
+
note?.share_url,
|
|
6954
|
+
note?.shareUrl
|
|
6955
|
+
]
|
|
6956
|
+
.map(normalizeText)
|
|
6957
|
+
.find(value => {
|
|
6958
|
+
try {
|
|
6959
|
+
const url = new URL(value);
|
|
6960
|
+
return /(?:^|\.)xiaohongshu\.com$/i.test(url.hostname) && !/^creator\./i.test(url.hostname);
|
|
6961
|
+
} catch {
|
|
6962
|
+
return false;
|
|
6963
|
+
}
|
|
6964
|
+
}) || "";
|
|
6965
|
+
const noteId = [
|
|
6966
|
+
payload?.note_id,
|
|
6967
|
+
payload?.noteId,
|
|
6968
|
+
payload?.id,
|
|
6969
|
+
data?.note_id,
|
|
6970
|
+
data?.noteId,
|
|
6971
|
+
data?.id,
|
|
6972
|
+
note?.note_id,
|
|
6973
|
+
note?.noteId,
|
|
6974
|
+
note?.id
|
|
6975
|
+
]
|
|
6976
|
+
.map(normalizeText)
|
|
6977
|
+
.find(Boolean) || "";
|
|
6978
|
+
if (!publishedUrl && !noteId) return null;
|
|
6979
|
+
return {
|
|
6980
|
+
noteId,
|
|
6981
|
+
publishedUrl: publishedUrl || `https://www.xiaohongshu.com/explore/${encodeURIComponent(noteId)}`
|
|
6982
|
+
};
|
|
6277
6983
|
}
|
|
6278
6984
|
|
|
6279
6985
|
async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, options) {
|
|
@@ -6284,7 +6990,11 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
|
|
|
6284
6990
|
if (response.status() >= 400 || payload?.success === false || Number(payload?.code) > 0) {
|
|
6285
6991
|
throw new Error(`小红书发布失败: ${normalizeText(payload?.msg || payload?.message || `接口状态 ${response.status()}`)}`);
|
|
6286
6992
|
}
|
|
6287
|
-
|
|
6993
|
+
const receipt = xiaohongshuPublishedReceipt(payload);
|
|
6994
|
+
if (!receipt) {
|
|
6995
|
+
throw new Error("小红书发布结果未返回笔记 ID 或公开笔记链接,无法确认发布记录");
|
|
6996
|
+
}
|
|
6997
|
+
return { confirmation: "response", responseUrl: response.url(), ...receipt };
|
|
6288
6998
|
}
|
|
6289
6999
|
const deadline = Date.now() + 15000;
|
|
6290
7000
|
do {
|
|
@@ -6292,19 +7002,229 @@ async function waitForXiaohongshuSubmission(page, initialUrl, mutationPromise, o
|
|
|
6292
7002
|
if (/发布失败|上传失败|内容违规|请重试/.test(pageText)) {
|
|
6293
7003
|
throw new Error(`小红书发布失败: ${normalizeText(pageText.match(/(?:发布失败|上传失败|内容违规)[^\n]*/)?.[0] || "页面返回失败提示")}`);
|
|
6294
7004
|
}
|
|
6295
|
-
if (/发布成功|笔记发布成功|发布完成/.test(pageText)
|
|
6296
|
-
|
|
7005
|
+
if (/发布成功|笔记发布成功|发布完成/.test(pageText)) {
|
|
7006
|
+
throw new Error("小红书页面提示发布完成,但未返回笔记 ID 或公开笔记链接,无法确认发布记录");
|
|
6297
7007
|
}
|
|
6298
7008
|
await page.waitForTimeout(500);
|
|
6299
7009
|
} while (Date.now() < deadline);
|
|
6300
7010
|
throw new Error("小红书发布后未检测到成功回执");
|
|
6301
7011
|
}
|
|
6302
7012
|
|
|
6303
|
-
async function
|
|
7013
|
+
async function findAvailableLoopbackPort() {
|
|
7014
|
+
return new Promise((resolve, reject) => {
|
|
7015
|
+
const server = http.createServer();
|
|
7016
|
+
server.once("error", reject);
|
|
7017
|
+
server.listen(0, "127.0.0.1", () => {
|
|
7018
|
+
const address = server.address();
|
|
7019
|
+
server.close(error => (error ? reject(error) : resolve(address.port)));
|
|
7020
|
+
});
|
|
7021
|
+
});
|
|
7022
|
+
}
|
|
7023
|
+
|
|
7024
|
+
async function readCdpVersion(port) {
|
|
7025
|
+
return new Promise((resolve, reject) => {
|
|
7026
|
+
const request = http.get(
|
|
7027
|
+
{ host: "127.0.0.1", port, path: "/json/version", timeout: 1500 },
|
|
7028
|
+
response => {
|
|
7029
|
+
let payload = "";
|
|
7030
|
+
response.setEncoding("utf8");
|
|
7031
|
+
response.on("data", chunk => {
|
|
7032
|
+
payload += chunk;
|
|
7033
|
+
});
|
|
7034
|
+
response.on("end", () => {
|
|
7035
|
+
try {
|
|
7036
|
+
const parsed = JSON.parse(payload);
|
|
7037
|
+
if (parsed.webSocketDebuggerUrl) {
|
|
7038
|
+
resolve(parsed.webSocketDebuggerUrl);
|
|
7039
|
+
return;
|
|
7040
|
+
}
|
|
7041
|
+
reject(new Error("调试端点未返回 WebSocket 地址"));
|
|
7042
|
+
} catch (error) {
|
|
7043
|
+
reject(error);
|
|
7044
|
+
}
|
|
7045
|
+
});
|
|
7046
|
+
}
|
|
7047
|
+
);
|
|
7048
|
+
request.once("error", reject);
|
|
7049
|
+
request.once("timeout", () => request.destroy(new Error("调试端点请求超时")));
|
|
7050
|
+
});
|
|
7051
|
+
}
|
|
7052
|
+
|
|
7053
|
+
async function waitForCdpEndpoint(port, timeoutMs = 15000) {
|
|
7054
|
+
const deadline = Date.now() + timeoutMs;
|
|
7055
|
+
let lastError;
|
|
7056
|
+
do {
|
|
7057
|
+
try {
|
|
7058
|
+
return await readCdpVersion(port);
|
|
7059
|
+
} catch (error) {
|
|
7060
|
+
lastError = error;
|
|
7061
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
7062
|
+
}
|
|
7063
|
+
} while (Date.now() < deadline);
|
|
7064
|
+
throw new Error(`浏览器调试会话启动超时:${lastError?.message || "未返回调试端点"}`);
|
|
7065
|
+
}
|
|
7066
|
+
|
|
7067
|
+
async function resolveDetachedBrowserExecutable(channel) {
|
|
7068
|
+
if (process.platform !== "win32") {
|
|
7069
|
+
return "";
|
|
7070
|
+
}
|
|
7071
|
+
const normalizedChannel = normalizeKey(channel || "msedge");
|
|
7072
|
+
const programFiles = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
|
|
7073
|
+
const product = /chrome/.test(normalizedChannel) ? ["Google", "Chrome", "Application", "chrome.exe"] : ["Microsoft", "Edge", "Application", "msedge.exe"];
|
|
7074
|
+
const candidates = [process.env.QCPLAY_BROWSER_EXECUTABLE, ...programFiles.map(root => path.join(root, ...product))].filter(Boolean);
|
|
7075
|
+
for (const candidate of candidates) {
|
|
7076
|
+
if (await fs.promises.access(candidate, fs.constants.X_OK).then(() => true).catch(() => false)) {
|
|
7077
|
+
return candidate;
|
|
7078
|
+
}
|
|
7079
|
+
}
|
|
7080
|
+
return "";
|
|
7081
|
+
}
|
|
7082
|
+
|
|
7083
|
+
async function launchDetachedBrowserContext(profileDir, channel) {
|
|
7084
|
+
const executable = await resolveDetachedBrowserExecutable(channel);
|
|
7085
|
+
if (!executable) {
|
|
7086
|
+
throw new Error("未找到可分离的 Edge/Chrome 浏览器,无法在保留编辑页的同时返回 AI 授权事件");
|
|
7087
|
+
}
|
|
7088
|
+
let lastError;
|
|
7089
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
7090
|
+
const port = await findAvailableLoopbackPort();
|
|
7091
|
+
const child = spawn(
|
|
7092
|
+
executable,
|
|
7093
|
+
[
|
|
7094
|
+
"--no-first-run",
|
|
7095
|
+
"--no-default-browser-check",
|
|
7096
|
+
"--remote-debugging-address=127.0.0.1",
|
|
7097
|
+
"--remote-allow-origins=http://localhost,http://127.0.0.1",
|
|
7098
|
+
`--remote-debugging-port=${port}`,
|
|
7099
|
+
`--user-data-dir=${profileDir}`
|
|
7100
|
+
],
|
|
7101
|
+
{ detached: true, stdio: "ignore", windowsHide: true }
|
|
7102
|
+
);
|
|
7103
|
+
child.unref();
|
|
7104
|
+
try {
|
|
7105
|
+
const endpoint = await waitForCdpEndpoint(port);
|
|
7106
|
+
const { chromium } = await import("playwright-core");
|
|
7107
|
+
const browser = await chromium.connectOverCDP(endpoint);
|
|
7108
|
+
const context = browser.contexts()[0];
|
|
7109
|
+
if (!context) {
|
|
7110
|
+
await browser.close().catch(() => {});
|
|
7111
|
+
throw new Error("浏览器调试会话未返回默认上下文");
|
|
7112
|
+
}
|
|
7113
|
+
await saveDetachedBrowserSession(profileDir, endpoint);
|
|
7114
|
+
attachDetachedBrowserContext(context, browser, profileDir);
|
|
7115
|
+
return context;
|
|
7116
|
+
} catch (error) {
|
|
7117
|
+
lastError = error;
|
|
7118
|
+
child.kill();
|
|
7119
|
+
if (attempt < 2) {
|
|
7120
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
7121
|
+
}
|
|
7122
|
+
}
|
|
7123
|
+
}
|
|
7124
|
+
throw lastError;
|
|
7125
|
+
}
|
|
7126
|
+
|
|
7127
|
+
function detachedBrowserSessionFile(profileDir) {
|
|
7128
|
+
return path.join(profileDir, ".qcplay-detached-session.json");
|
|
7129
|
+
}
|
|
7130
|
+
|
|
7131
|
+
async function saveDetachedBrowserSession(profileDir, endpoint) {
|
|
7132
|
+
const sessionFile = detachedBrowserSessionFile(profileDir);
|
|
7133
|
+
await fs.promises.writeFile(
|
|
7134
|
+
sessionFile,
|
|
7135
|
+
`${JSON.stringify({ endpoint, createdAt: new Date().toISOString() })}\n`,
|
|
7136
|
+
{ encoding: "utf8", mode: 0o600 }
|
|
7137
|
+
);
|
|
7138
|
+
}
|
|
7139
|
+
|
|
7140
|
+
async function loadDetachedBrowserSession(profileDir) {
|
|
7141
|
+
const sessionFile = detachedBrowserSessionFile(profileDir);
|
|
7142
|
+
try {
|
|
7143
|
+
const session = JSON.parse(await fs.promises.readFile(sessionFile, "utf8"));
|
|
7144
|
+
if (
|
|
7145
|
+
typeof session?.endpoint !== "string" ||
|
|
7146
|
+
!/^(?:http|ws):\/\/127\.0\.0\.1:\d+(?:\/devtools\/browser\/[^/]+)?\/?$/i.test(session.endpoint)
|
|
7147
|
+
) {
|
|
7148
|
+
throw new Error("无效的调试会话地址");
|
|
7149
|
+
}
|
|
7150
|
+
return { ...session, sessionFile };
|
|
7151
|
+
} catch {
|
|
7152
|
+
return null;
|
|
7153
|
+
}
|
|
7154
|
+
}
|
|
7155
|
+
|
|
7156
|
+
async function removeDetachedBrowserSession(profileDir) {
|
|
7157
|
+
await fs.promises.unlink(detachedBrowserSessionFile(profileDir)).catch(() => {});
|
|
7158
|
+
}
|
|
7159
|
+
|
|
7160
|
+
function attachDetachedBrowserContext(context, browser, profileDir) {
|
|
7161
|
+
Object.defineProperty(context, "__qcplayDetach", {
|
|
7162
|
+
// `browser.close()` sends a Browser.close command over CDP. A detached
|
|
7163
|
+
// session is owned by the user-facing Edge process, so only close the
|
|
7164
|
+
// Playwright transport and leave that editor window intact.
|
|
7165
|
+
value: async () => {
|
|
7166
|
+
if (typeof browser?._connection?.close === "function") {
|
|
7167
|
+
browser._connection.close();
|
|
7168
|
+
return;
|
|
7169
|
+
}
|
|
7170
|
+
// Older Playwright builds do not expose a public disconnect method.
|
|
7171
|
+
// Do not fall back to browser.close(), which would close the editor.
|
|
7172
|
+
if (typeof browser?.disconnect === "function") {
|
|
7173
|
+
await browser.disconnect();
|
|
7174
|
+
}
|
|
7175
|
+
},
|
|
7176
|
+
enumerable: false
|
|
7177
|
+
});
|
|
7178
|
+
Object.defineProperty(context, "__qcplayDetachedProfileDir", {
|
|
7179
|
+
value: profileDir,
|
|
7180
|
+
enumerable: false
|
|
7181
|
+
});
|
|
7182
|
+
return context;
|
|
7183
|
+
}
|
|
7184
|
+
|
|
7185
|
+
async function reconnectDetachedBrowserContext(profileDir, chromium) {
|
|
7186
|
+
const savedSession = await loadDetachedBrowserSession(profileDir);
|
|
7187
|
+
const endpoint = normalizeText(process.env.QCPLAY_BROWSER_CDP_ENDPOINT) || savedSession?.endpoint;
|
|
7188
|
+
if (!endpoint || !/^(?:http|ws):\/\/127\.0\.0\.1:\d+(?:\/devtools\/browser\/[^/]+)?\/?$/i.test(endpoint)) {
|
|
7189
|
+
return null;
|
|
7190
|
+
}
|
|
7191
|
+
try {
|
|
7192
|
+
const browser = await chromium.connectOverCDP(endpoint);
|
|
7193
|
+
const context = browser.contexts()[0];
|
|
7194
|
+
if (!context) {
|
|
7195
|
+
await browser.close().catch(() => {});
|
|
7196
|
+
throw new Error("浏览器调试会话未返回默认上下文");
|
|
7197
|
+
}
|
|
7198
|
+
return attachDetachedBrowserContext(context, browser, profileDir);
|
|
7199
|
+
} catch {
|
|
7200
|
+
if (savedSession) {
|
|
7201
|
+
await removeDetachedBrowserSession(profileDir);
|
|
7202
|
+
}
|
|
7203
|
+
return null;
|
|
7204
|
+
}
|
|
7205
|
+
}
|
|
7206
|
+
|
|
7207
|
+
async function closeBrowserContext(context) {
|
|
7208
|
+
if (typeof context?.__qcplayDetach === "function") {
|
|
7209
|
+
await context.__qcplayDetach().catch(() => {});
|
|
7210
|
+
return;
|
|
7211
|
+
}
|
|
7212
|
+
await context?.close?.().catch(() => {});
|
|
7213
|
+
}
|
|
7214
|
+
|
|
7215
|
+
async function launchBrowserContext(entry, browserChannel, options = {}) {
|
|
6304
7216
|
const { chromium } = await import("playwright-core");
|
|
6305
|
-
const profileDir =
|
|
7217
|
+
const profileDir = browserProfileDir(entry);
|
|
6306
7218
|
await fs.promises.mkdir(profileDir, { recursive: true, mode: 0o700 });
|
|
6307
7219
|
const channel = browserChannel || process.env.QCPLAY_BROWSER_CHANNEL || (process.platform === "win32" ? "msedge" : "chrome");
|
|
7220
|
+
if (options.awaitAIAction) {
|
|
7221
|
+
return launchDetachedBrowserContext(profileDir, channel);
|
|
7222
|
+
}
|
|
7223
|
+
const detachedContext = await reconnectDetachedBrowserContext(profileDir, chromium);
|
|
7224
|
+
if (detachedContext) {
|
|
7225
|
+
options.onProgress?.(`已复用当前 ${entry.platform} 授权编辑页,继续执行已确认的动作`);
|
|
7226
|
+
return detachedContext;
|
|
7227
|
+
}
|
|
6308
7228
|
return chromium.launchPersistentContext(profileDir, {
|
|
6309
7229
|
channel,
|
|
6310
7230
|
headless: false,
|
|
@@ -6461,7 +7381,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
6461
7381
|
}
|
|
6462
7382
|
|
|
6463
7383
|
const contentPolicy = resolvePlatformContentRequirements(entry);
|
|
6464
|
-
if (contentPolicy.optionalSectionReview && !options.reviewDraft) {
|
|
7384
|
+
if (contentPolicy.optionalSectionReview && !options.reviewDraft && !options.directPublish && !options.draftOnly) {
|
|
6465
7385
|
options = { ...options, reviewDraft: true };
|
|
6466
7386
|
options.onProgress?.(
|
|
6467
7387
|
`${entry.platform} 的飞书特殊要求包含可选删除板块,已切换为发布前人工预览确认`
|
|
@@ -6476,7 +7396,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
6476
7396
|
|
|
6477
7397
|
const body =
|
|
6478
7398
|
entry.platformKey === "haoyou"
|
|
6479
|
-
?
|
|
7399
|
+
? buildHaoyouPublishRichContent(preparedArticle).plainText || plainTextForPlatform(preparedArticle, entry.platformKey)
|
|
6480
7400
|
: plainTextForPlatform(preparedArticle, entry.platformKey);
|
|
6481
7401
|
if (entry.platformKey === "x" && body.length > 280) {
|
|
6482
7402
|
throw new Error(`X 内容共 ${body.length} 个字符,超过 280 字符限制`);
|
|
@@ -6486,11 +7406,12 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
6486
7406
|
}
|
|
6487
7407
|
|
|
6488
7408
|
const ownsContext = !options.context;
|
|
6489
|
-
const context = options.context || (await launchBrowserContext(entry, options.browserChannel));
|
|
7409
|
+
const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
|
|
6490
7410
|
const pages = context.pages();
|
|
6491
7411
|
const page = pages[0] || (await context.newPage());
|
|
6492
7412
|
const promptUser = options.waitForUser || (message => waitForUser(message, options.streams));
|
|
6493
7413
|
let haoyouEditorOpened = false;
|
|
7414
|
+
let preserveEditorOnFailure = false;
|
|
6494
7415
|
try {
|
|
6495
7416
|
if (entry.platformKey === "taptap") {
|
|
6496
7417
|
return await publishTapTap(page, entry, preparedArticle, spec, options, promptUser);
|
|
@@ -6556,7 +7477,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
6556
7477
|
}
|
|
6557
7478
|
let richContent = null;
|
|
6558
7479
|
if (entry.platformKey === "haoyou") {
|
|
6559
|
-
|
|
7480
|
+
if (options.previewTextOnly) {
|
|
7481
|
+
await fillStableHaoyouValue(page, bodyInput, body, "正文");
|
|
7482
|
+
richContent = {
|
|
7483
|
+
rich: false,
|
|
7484
|
+
images: 0,
|
|
7485
|
+
imagesPending: buildHaoyouPublishRichContent(preparedArticle).images.length,
|
|
7486
|
+
links: 0,
|
|
7487
|
+
plainText: body
|
|
7488
|
+
};
|
|
7489
|
+
} else {
|
|
7490
|
+
richContent = await insertHaoyouRichContent(page, bodyInput, preparedArticle, body);
|
|
7491
|
+
}
|
|
6560
7492
|
} else {
|
|
6561
7493
|
await fillLocator(bodyInput, body);
|
|
6562
7494
|
}
|
|
@@ -6567,10 +7499,22 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
6567
7499
|
if (entry.platformKey === "haoyou" && (await firstVisible(page, [".editSubmit"]))) {
|
|
6568
7500
|
throw new Error("好游快爆标题和正文已写入,但发布按钮仍不可用,请检查发布设置");
|
|
6569
7501
|
}
|
|
6570
|
-
|
|
6571
|
-
|
|
7502
|
+
throw new Error(`${entry.platform} 页面中未找到发布按钮,正文已填入但尚未满足提交条件`);
|
|
7503
|
+
}
|
|
7504
|
+
const review = await reviewPreparedDraft(page, entry, options);
|
|
7505
|
+
if (review) {
|
|
7506
|
+
return { ...review, ...richContent };
|
|
7507
|
+
}
|
|
7508
|
+
if (options.draftOnly) {
|
|
7509
|
+
return {
|
|
7510
|
+
url: page.url(),
|
|
7511
|
+
status: "draft",
|
|
7512
|
+
draftOnly: true,
|
|
7513
|
+
approvalRequired: true,
|
|
7514
|
+
approvalActions: ["publish", "cancel"],
|
|
7515
|
+
...richContent
|
|
7516
|
+
};
|
|
6572
7517
|
}
|
|
6573
|
-
await reviewPreparedDraft(page, entry, options);
|
|
6574
7518
|
if (entry.platformKey === "haoyou") {
|
|
6575
7519
|
const initialUrl = page.url();
|
|
6576
7520
|
const mutationPromise =
|
|
@@ -6591,12 +7535,18 @@ export async function publishWithBrowser(entry, article, options = {}) {
|
|
|
6591
7535
|
}
|
|
6592
7536
|
return { url: page.url() };
|
|
6593
7537
|
} catch (error) {
|
|
6594
|
-
if (entry.platformKey === "haoyou" &&
|
|
6595
|
-
|
|
7538
|
+
if ((entry.platformKey === "haoyou" && haoyouEditorOpened) || entry.platformKey === "xiaohongshu") {
|
|
7539
|
+
if (!ownsContext) throw error;
|
|
7540
|
+
preserveEditorOnFailure = true;
|
|
7541
|
+
options.onProgress?.(`${error.message};已保留${entry.platform}编辑页供检查`);
|
|
6596
7542
|
}
|
|
6597
7543
|
throw error;
|
|
6598
7544
|
} finally {
|
|
6599
|
-
|
|
7545
|
+
// A draft is deliberately left visible for the current AI conversation.
|
|
7546
|
+
// Closing the persistent context here also closes the platform's editor page.
|
|
7547
|
+
if (ownsContext && !options.draftOnly && !options.keepOpen && !options.awaitAIAction && !preserveEditorOnFailure) {
|
|
7548
|
+
await closeBrowserContext(context);
|
|
7549
|
+
}
|
|
6600
7550
|
}
|
|
6601
7551
|
}
|
|
6602
7552
|
|
|
@@ -6605,7 +7555,13 @@ export async function publishPlatformEntry(entry, article, options = {}) {
|
|
|
6605
7555
|
return publishDiscord(entry, article, options);
|
|
6606
7556
|
}
|
|
6607
7557
|
if (entry.publisher === "browser") {
|
|
6608
|
-
|
|
7558
|
+
const submissionKey = await ensureBrowserSubmissionCooldown(entry, article, options);
|
|
7559
|
+
const result = await publishWithBrowser(entry, article, options);
|
|
7560
|
+
if (result?.status === "published") {
|
|
7561
|
+
await recordBrowserSubmission(submissionKey);
|
|
7562
|
+
await removeDetachedBrowserSession(browserProfileDir(entry));
|
|
7563
|
+
}
|
|
7564
|
+
return result;
|
|
6609
7565
|
}
|
|
6610
7566
|
if (entry.publisher === "blocked") {
|
|
6611
7567
|
throw new Error("Reddit 配置明确提示非官方工具可能导致封号;未配置 Reddit 官方应用,因此拒绝自动发布");
|
|
@@ -6616,6 +7572,46 @@ export async function publishPlatformEntry(entry, article, options = {}) {
|
|
|
6616
7572
|
throw new Error(`${entry.platform} 需要由官网发布流程处理`);
|
|
6617
7573
|
}
|
|
6618
7574
|
|
|
7575
|
+
/**
|
|
7576
|
+
* Persist non-official platform addresses next to the article as a small,
|
|
7577
|
+
* reviewable project artifact. Credentials and configuration URLs are never
|
|
7578
|
+
* copied; only addresses returned by a completed platform workflow are kept.
|
|
7579
|
+
*/
|
|
7580
|
+
export async function writePlatformPublishUrls(articleFile, successes) {
|
|
7581
|
+
const records = successes
|
|
7582
|
+
.filter(({ entry }) => entry?.platformKey && entry.platformKey !== "website")
|
|
7583
|
+
.map(({ entry, result }) => {
|
|
7584
|
+
const url = normalizeText(
|
|
7585
|
+
result?.status === "draft" ? result?.url : result?.publishedUrl || result?.responseUrl || result?.url
|
|
7586
|
+
);
|
|
7587
|
+
let isInternalApi = /\/webapi(?:v\d+)?\//i.test(url);
|
|
7588
|
+
try {
|
|
7589
|
+
const parsedUrl = new URL(url);
|
|
7590
|
+
isInternalApi ||= parsedUrl.searchParams.get("m") === "api" || /\/api(?:\/|$)/i.test(parsedUrl.pathname);
|
|
7591
|
+
} catch {
|
|
7592
|
+
// Non-URL values are omitted by the subsequent truthy URL filter.
|
|
7593
|
+
}
|
|
7594
|
+
return {
|
|
7595
|
+
platform: entry.platform,
|
|
7596
|
+
platformKey: entry.platformKey,
|
|
7597
|
+
region: entry.region,
|
|
7598
|
+
status: result?.status || "completed",
|
|
7599
|
+
// A draft must point back to the editor. Never persist a mutation API
|
|
7600
|
+
// endpoint as though it were a public article address.
|
|
7601
|
+
url: isInternalApi ? "" : url
|
|
7602
|
+
};
|
|
7603
|
+
})
|
|
7604
|
+
.filter(record => record.url);
|
|
7605
|
+
if (!records.length) return "";
|
|
7606
|
+
const target = `${path.resolve(articleFile)}.platform-urls.json`;
|
|
7607
|
+
await fs.promises.writeFile(
|
|
7608
|
+
target,
|
|
7609
|
+
`${JSON.stringify({ version: 1, updated_at: new Date().toISOString(), platforms: records }, null, 2)}\n`,
|
|
7610
|
+
"utf8"
|
|
7611
|
+
);
|
|
7612
|
+
return target;
|
|
7613
|
+
}
|
|
7614
|
+
|
|
6619
7615
|
export async function runPlatformPublishSequence(entries, publishOne) {
|
|
6620
7616
|
const successes = [];
|
|
6621
7617
|
const failures = [];
|