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