@qcplay/cli 1.0.20 → 1.0.21
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 +9 -3
- package/lib/platform-publish.js +306 -37
- package/package.json +1 -1
package/bin/qcplay.js
CHANGED
|
@@ -2433,7 +2433,10 @@ async function parseArticleFile(file) {
|
|
|
2433
2433
|
}
|
|
2434
2434
|
|
|
2435
2435
|
const raw = await fs.promises.readFile(articleFile, "utf8");
|
|
2436
|
-
|
|
2436
|
+
const looksLikeQcplayHtml =
|
|
2437
|
+
/^(?:\uFEFF)?\s*(?:<!doctype\s+html\b|<html\b)/i.test(raw) &&
|
|
2438
|
+
/(?:id=["']qcplay-article-meta["']|data-qcplay-article-content=["']1["'])/i.test(raw);
|
|
2439
|
+
if (path.extname(articleFile).toLowerCase() === ".html" || looksLikeQcplayHtml) {
|
|
2437
2440
|
const { meta, richHtml } = parseRichArticleUploadFile(raw);
|
|
2438
2441
|
return {
|
|
2439
2442
|
articleFile,
|
|
@@ -2705,9 +2708,12 @@ async function publishPlatformsCommand(file, options) {
|
|
|
2705
2708
|
parsedArticle = await parseArticleFile(file);
|
|
2706
2709
|
parsedArticle = applyPublishOverrides(parsedArticle, options);
|
|
2707
2710
|
}
|
|
2708
|
-
const allEntries = await loadPlatformEntries({ configFile: options.configFile });
|
|
2709
|
-
|
|
2710
2711
|
const project = inferDistributionProject(parsedArticle?.payload, options.project, parsedArticle?.meta);
|
|
2712
|
+
const allEntries = await loadPlatformEntries({
|
|
2713
|
+
configFile: options.configFile,
|
|
2714
|
+
project,
|
|
2715
|
+
region: options.region
|
|
2716
|
+
});
|
|
2711
2717
|
if (!project) {
|
|
2712
2718
|
if (options.listPlatforms) {
|
|
2713
2719
|
printPlatformEntries(allEntries);
|
package/lib/platform-publish.js
CHANGED
|
@@ -12,6 +12,58 @@ import { applyContentRules } from "./content-rules.js";
|
|
|
12
12
|
import { stripWechatGuidanceHtml } from "./wechat-article.js";
|
|
13
13
|
|
|
14
14
|
const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
|
|
15
|
+
const DOMESTIC_REGION = "国内";
|
|
16
|
+
const DOMESTIC_PLATFORM_ROWS = [
|
|
17
|
+
{
|
|
18
|
+
项目: "最强蜗牛",
|
|
19
|
+
区域: DOMESTIC_REGION,
|
|
20
|
+
平台: "TapTap",
|
|
21
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=187376&group_id=227162&show_activity=1"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
项目: "提灯与地下城",
|
|
25
|
+
区域: DOMESTIC_REGION,
|
|
26
|
+
平台: "TapTap",
|
|
27
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=149961&group_id=177149&show_activity=1"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
项目: "阿瑞斯病毒2",
|
|
31
|
+
区域: DOMESTIC_REGION,
|
|
32
|
+
平台: "TapTap",
|
|
33
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=387841&group_id=663910&show_activity=1"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
项目: "迷途之光",
|
|
37
|
+
区域: DOMESTIC_REGION,
|
|
38
|
+
平台: "TapTap",
|
|
39
|
+
页面URL链接: "https://www.taptap.cn/creator/edit?type=moment&app_id=600170&show_activity=1"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
项目: "最强蜗牛",
|
|
43
|
+
区域: DOMESTIC_REGION,
|
|
44
|
+
平台: "好游快爆",
|
|
45
|
+
页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=12201&type=1"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
项目: "新仙剑",
|
|
49
|
+
区域: DOMESTIC_REGION,
|
|
50
|
+
平台: "好游快爆",
|
|
51
|
+
页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=24008&type=1"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
项目: "阿瑞斯病毒2",
|
|
55
|
+
区域: DOMESTIC_REGION,
|
|
56
|
+
平台: "好游快爆",
|
|
57
|
+
页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=20313&type=1"
|
|
58
|
+
}
|
|
59
|
+
];
|
|
60
|
+
const DOMESTIC_STANDARD_PLATFORM_ROWS = ["最强蜗牛", "提灯与地下城", "阿瑞斯病毒2", "新仙剑", "迷途之光", "魔卡少女樱"].flatMap(
|
|
61
|
+
项目 => [
|
|
62
|
+
{ 项目, 区域: DOMESTIC_REGION, 平台: "B站", 页面URL链接: "https://member.bilibili.com/platform/upload/text/new-edit" },
|
|
63
|
+
{ 项目, 区域: DOMESTIC_REGION, 平台: "微博", 页面URL链接: "https://card.weibo.com/article/v5/editor" },
|
|
64
|
+
{ 项目, 区域: DOMESTIC_REGION, 平台: "小红书", 页面URL链接: "https://creator.xiaohongshu.com/publish/publish?from=menu&target=image" }
|
|
65
|
+
]
|
|
66
|
+
);
|
|
15
67
|
const DISCORD_MESSAGE_LIMIT = 2000;
|
|
16
68
|
const DISCORD_MESSAGE_STATE_FILE = path.join(os.homedir(), ".qcplay", "discord-messages.json");
|
|
17
69
|
const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
|
|
@@ -459,6 +511,10 @@ function runJson(command, args) {
|
|
|
459
511
|
});
|
|
460
512
|
}
|
|
461
513
|
|
|
514
|
+
function domesticPlatformRows() {
|
|
515
|
+
return normalizePlatformRows([...DOMESTIC_PLATFORM_ROWS, ...DOMESTIC_STANDARD_PLATFORM_ROWS]);
|
|
516
|
+
}
|
|
517
|
+
|
|
462
518
|
export async function loadPlatformEntries(options = {}) {
|
|
463
519
|
if (options.configFile) {
|
|
464
520
|
const configFile = path.resolve(process.cwd(), options.configFile);
|
|
@@ -473,6 +529,13 @@ export async function loadPlatformEntries(options = {}) {
|
|
|
473
529
|
return normalizePlatformRows(rows);
|
|
474
530
|
}
|
|
475
531
|
|
|
532
|
+
const domesticEntries = domesticPlatformRows();
|
|
533
|
+
const requestedRegion = normalizeKey(options.region || "");
|
|
534
|
+
const requestedProject = normalizeProjectKey(options.project || "");
|
|
535
|
+
if ((!requestedRegion || requestedRegion === normalizeKey(DOMESTIC_REGION)) && (!requestedProject || entriesForProject(domesticEntries, options.project).length > 0)) {
|
|
536
|
+
return domesticEntries;
|
|
537
|
+
}
|
|
538
|
+
|
|
476
539
|
const workbook = await runJson("lark-cli", [
|
|
477
540
|
"sheets",
|
|
478
541
|
"+workbook-info",
|
|
@@ -1763,6 +1826,8 @@ export function resolveWeiboSuperTopic(article, entry = {}) {
|
|
|
1763
1826
|
}
|
|
1764
1827
|
|
|
1765
1828
|
export function resolveWeiboShareCopy(article, entry = {}) {
|
|
1829
|
+
const configured = weiboMeta(article, "weibo_share_copy", "weibo_share_text");
|
|
1830
|
+
if (configured) return configured;
|
|
1766
1831
|
const title = resolveWeiboArticleTitle(article);
|
|
1767
1832
|
const project = normalizeText(entry.project);
|
|
1768
1833
|
if (project === "最强蜗牛") {
|
|
@@ -2758,11 +2823,74 @@ function normalizeNumberedSectionHeadings(items) {
|
|
|
2758
2823
|
}
|
|
2759
2824
|
|
|
2760
2825
|
export function buildBilibiliRichContent(article) {
|
|
2761
|
-
|
|
2826
|
+
const content = buildPlatformRichContent(article, {
|
|
2762
2827
|
preserveBackground: true,
|
|
2763
2828
|
preserveBlockStyles: true,
|
|
2764
2829
|
structuredTables: true
|
|
2765
2830
|
});
|
|
2831
|
+
// Bilibili can drop the first paragraph's CSS alignment when it follows an
|
|
2832
|
+
// inline image node. Keep an explicit align attribute on that first text
|
|
2833
|
+
// block so the source formatting survives the editor's initial normalization.
|
|
2834
|
+
const firstTextIndex = content.items.findIndex(item => item.type === "html" && item.plain);
|
|
2835
|
+
if (firstTextIndex < 0) return content;
|
|
2836
|
+
const firstText = content.items[firstTextIndex];
|
|
2837
|
+
const alignment = normalizeText(firstText.align).toLowerCase();
|
|
2838
|
+
if (!alignment) return content;
|
|
2839
|
+
const $ = cheerio.load(firstText.html, null, false);
|
|
2840
|
+
const root = $.root().children().first();
|
|
2841
|
+
if (!root.length) return content;
|
|
2842
|
+
root.attr("align", alignment);
|
|
2843
|
+
return {
|
|
2844
|
+
...content,
|
|
2845
|
+
items: content.items.map((item, index) => (index === firstTextIndex ? { ...item, html: $.html(root) } : item))
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
async function enforceBilibiliFirstTextAlignment(bodyInput, content) {
|
|
2850
|
+
const firstText = content.items.find(item => item.type === "html" && item.plain);
|
|
2851
|
+
const alignment = normalizeText(firstText?.align).toLowerCase();
|
|
2852
|
+
const expectedText = normalizeText(firstText?.plain);
|
|
2853
|
+
if (!alignment || !expectedText || typeof bodyInput.evaluate !== "function") return false;
|
|
2854
|
+
return bodyInput
|
|
2855
|
+
.evaluate(
|
|
2856
|
+
(element, payload) => {
|
|
2857
|
+
const normalize = value => String(value || "").replace(/\s+/g, " ").trim();
|
|
2858
|
+
const blocks = [...element.querySelectorAll("p,h1,h2,h3,h4,h5,h6,div")].filter(block => {
|
|
2859
|
+
const text = normalize(block.textContent);
|
|
2860
|
+
return text === payload.text || text.includes(payload.text) || payload.text.includes(text);
|
|
2861
|
+
});
|
|
2862
|
+
// Prefer the smallest matching block. The outer editor container often
|
|
2863
|
+
// contains the same text, but formatting it does not update the actual
|
|
2864
|
+
// paragraph node stored by Bilibili's rich-text model.
|
|
2865
|
+
blocks.sort((left, right) => normalize(left.textContent).length - normalize(right.textContent).length);
|
|
2866
|
+
const target = blocks[0];
|
|
2867
|
+
if (!target) return false;
|
|
2868
|
+
const range = document.createRange();
|
|
2869
|
+
range.selectNodeContents(target);
|
|
2870
|
+
const selection = window.getSelection();
|
|
2871
|
+
selection.removeAllRanges();
|
|
2872
|
+
selection.addRange(range);
|
|
2873
|
+
try {
|
|
2874
|
+
document.execCommand("justifyCenter", false, null);
|
|
2875
|
+
} catch {
|
|
2876
|
+
// Some Chromium builds disable execCommand; the explicit style below
|
|
2877
|
+
// still provides a deterministic fallback for the editor DOM.
|
|
2878
|
+
}
|
|
2879
|
+
target.style.textAlign = payload.alignment;
|
|
2880
|
+
target.setAttribute("align", payload.alignment);
|
|
2881
|
+
target.setAttribute("data-align", payload.alignment);
|
|
2882
|
+
target.dispatchEvent(
|
|
2883
|
+
new InputEvent("input", {
|
|
2884
|
+
bubbles: true,
|
|
2885
|
+
inputType: "formatBlock",
|
|
2886
|
+
data: null
|
|
2887
|
+
})
|
|
2888
|
+
);
|
|
2889
|
+
return getComputedStyle(target).textAlign === payload.alignment;
|
|
2890
|
+
},
|
|
2891
|
+
{ text: expectedText, alignment }
|
|
2892
|
+
)
|
|
2893
|
+
.catch(() => false);
|
|
2766
2894
|
}
|
|
2767
2895
|
|
|
2768
2896
|
export function buildHaoyouRichContent(article) {
|
|
@@ -3369,6 +3497,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3369
3497
|
plain: plainParts.join("\n")
|
|
3370
3498
|
});
|
|
3371
3499
|
await page.waitForTimeout(100);
|
|
3500
|
+
await enforceBilibiliFirstTextAlignment(bodyInput, content);
|
|
3372
3501
|
options.onProgress?.("B站 文字正文已写入,正在回读校验");
|
|
3373
3502
|
options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
|
|
3374
3503
|
}
|
|
@@ -3432,6 +3561,9 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
|
|
|
3432
3561
|
}
|
|
3433
3562
|
}
|
|
3434
3563
|
|
|
3564
|
+
// Media insertion may rerender the first paragraph and drop its alignment.
|
|
3565
|
+
// Reapply it after all images are settled, immediately before final reading.
|
|
3566
|
+
await enforceBilibiliFirstTextAlignment(bodyInput, content);
|
|
3435
3567
|
options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
|
|
3436
3568
|
let state = null;
|
|
3437
3569
|
let previousTextLength = -1;
|
|
@@ -4378,6 +4510,24 @@ async function openPlatformLogin(page, entry, spec) {
|
|
|
4378
4510
|
return false;
|
|
4379
4511
|
}
|
|
4380
4512
|
|
|
4513
|
+
export function browserSessionLoginPrompt(platform) {
|
|
4514
|
+
return `${platform} 尚未登录。请仅在 CLI 刚打开的自动化浏览器窗口中完成登录(扫码或账号密码均可),不要改用其他浏览器;完成后保持该窗口打开并回复“已登录”。`;
|
|
4515
|
+
}
|
|
4516
|
+
|
|
4517
|
+
async function waitForExisting(page, selectors, timeoutMs = 15000) {
|
|
4518
|
+
const deadline = Date.now() + timeoutMs;
|
|
4519
|
+
do {
|
|
4520
|
+
const locator = await firstExisting(page, selectors);
|
|
4521
|
+
if (locator) return locator;
|
|
4522
|
+
await page.waitForTimeout(200);
|
|
4523
|
+
} while (Date.now() < deadline);
|
|
4524
|
+
return null;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
function browserSessionLoginFailure(platform) {
|
|
4528
|
+
return `${platform} 登录完成后仍未进入发布编辑器。请确认登录发生在 CLI 刚打开的自动化浏览器窗口中,并检查账号是否拥有发布权限`;
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4381
4531
|
async function ensureBrowserLogin(page, entry, spec, promptUser) {
|
|
4382
4532
|
const alreadyLoggedIn = await openPlatformLogin(page, entry, spec);
|
|
4383
4533
|
if (alreadyLoggedIn) {
|
|
@@ -4385,7 +4535,7 @@ async function ensureBrowserLogin(page, entry, spec, promptUser) {
|
|
|
4385
4535
|
}
|
|
4386
4536
|
|
|
4387
4537
|
if (spec.manualLogin) {
|
|
4388
|
-
await promptUser(
|
|
4538
|
+
await promptUser(browserSessionLoginPrompt(entry.platform));
|
|
4389
4539
|
return true;
|
|
4390
4540
|
}
|
|
4391
4541
|
|
|
@@ -4999,7 +5149,7 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
|
|
|
4999
5149
|
await afterNavigation(page);
|
|
5000
5150
|
}
|
|
5001
5151
|
if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
|
|
5002
|
-
throw new Error("TapTap
|
|
5152
|
+
throw new Error(browserSessionLoginFailure("TapTap"));
|
|
5003
5153
|
}
|
|
5004
5154
|
const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
|
|
5005
5155
|
if (options.publishDraft) {
|
|
@@ -5506,7 +5656,7 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
|
|
|
5506
5656
|
await afterNavigation(page);
|
|
5507
5657
|
}
|
|
5508
5658
|
if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 15000))) {
|
|
5509
|
-
throw new Error(
|
|
5659
|
+
throw new Error(browserSessionLoginFailure("B站"));
|
|
5510
5660
|
}
|
|
5511
5661
|
|
|
5512
5662
|
let titleInput;
|
|
@@ -5650,7 +5800,10 @@ async function waitForWeiboArticleInlineImage(bodyInput, previousCount, source)
|
|
|
5650
5800
|
if (failed > 0) {
|
|
5651
5801
|
throw new Error(`微博文章正文图片上传失败: ${source}`);
|
|
5652
5802
|
}
|
|
5653
|
-
if (count > previousCount
|
|
5803
|
+
if (count > previousCount + 1) {
|
|
5804
|
+
throw new Error(`微博正文图片疑似重复插入: ${source}`);
|
|
5805
|
+
}
|
|
5806
|
+
if (count === previousCount + 1 && pending === 0) {
|
|
5654
5807
|
stableChecks += 1;
|
|
5655
5808
|
if (stableChecks >= 3) {
|
|
5656
5809
|
return;
|
|
@@ -5767,10 +5920,24 @@ async function visibleWeiboCropDialog(page) {
|
|
|
5767
5920
|
|
|
5768
5921
|
async function clearExistingWeiboArticleImages(page, scope) {
|
|
5769
5922
|
const items = scope.locator(".image-list .image-item");
|
|
5923
|
+
// The gallery list is populated asynchronously after the dialog opens. Give
|
|
5924
|
+
// existing items a short window to appear before treating the library as empty.
|
|
5925
|
+
let previousCount = await items.count().catch(() => 0);
|
|
5926
|
+
if (previousCount === 0) {
|
|
5927
|
+
const deadline = Date.now() + 5000;
|
|
5928
|
+
do {
|
|
5929
|
+
await page.waitForTimeout(200);
|
|
5930
|
+
previousCount = await items.count().catch(() => 0);
|
|
5931
|
+
if (previousCount > 0) break;
|
|
5932
|
+
} while (Date.now() < deadline);
|
|
5933
|
+
}
|
|
5934
|
+
if (previousCount === 0) {
|
|
5935
|
+
return 0;
|
|
5936
|
+
}
|
|
5770
5937
|
let cleared = 0;
|
|
5771
5938
|
while (cleared < 200) {
|
|
5772
|
-
const
|
|
5773
|
-
if (
|
|
5939
|
+
const currentCount = await items.count().catch(() => 0);
|
|
5940
|
+
if (currentCount === 0) {
|
|
5774
5941
|
return cleared;
|
|
5775
5942
|
}
|
|
5776
5943
|
const item = items.first();
|
|
@@ -5783,10 +5950,10 @@ async function clearExistingWeiboArticleImages(page, scope) {
|
|
|
5783
5950
|
}
|
|
5784
5951
|
await remove.click();
|
|
5785
5952
|
const deadline = Date.now() + 10000;
|
|
5786
|
-
let
|
|
5953
|
+
let afterDeleteCount = currentCount;
|
|
5787
5954
|
do {
|
|
5788
|
-
|
|
5789
|
-
if (
|
|
5955
|
+
afterDeleteCount = await items.count().catch(() => currentCount);
|
|
5956
|
+
if (afterDeleteCount < currentCount) {
|
|
5790
5957
|
break;
|
|
5791
5958
|
}
|
|
5792
5959
|
const text = typeof scope.innerText === "function" ? await scope.innerText().catch(() => "") : "";
|
|
@@ -5795,14 +5962,26 @@ async function clearExistingWeiboArticleImages(page, scope) {
|
|
|
5795
5962
|
}
|
|
5796
5963
|
await page.waitForTimeout(200);
|
|
5797
5964
|
} while (Date.now() < deadline);
|
|
5798
|
-
if (
|
|
5965
|
+
if (afterDeleteCount >= currentCount) {
|
|
5799
5966
|
throw new Error("微博头条文章图片库旧图片删除后未消失");
|
|
5800
5967
|
}
|
|
5801
|
-
cleared +=
|
|
5968
|
+
cleared += currentCount - afterDeleteCount;
|
|
5802
5969
|
}
|
|
5803
5970
|
throw new Error("微博头条文章图片库旧图片超过 200 张,已停止清理");
|
|
5804
5971
|
}
|
|
5805
5972
|
|
|
5973
|
+
async function waitForWeiboArticleImageList(page, scope, timeoutMs = 10000) {
|
|
5974
|
+
const list = scope.locator(".image-list");
|
|
5975
|
+
const deadline = Date.now() + timeoutMs;
|
|
5976
|
+
do {
|
|
5977
|
+
if ((await list.count().catch(() => 0)) > 0) {
|
|
5978
|
+
return true;
|
|
5979
|
+
}
|
|
5980
|
+
await page.waitForTimeout(200);
|
|
5981
|
+
} while (Date.now() < deadline);
|
|
5982
|
+
return false;
|
|
5983
|
+
}
|
|
5984
|
+
|
|
5806
5985
|
async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, source, options = {}) {
|
|
5807
5986
|
options.onProgress?.("正在打开图片库");
|
|
5808
5987
|
const imageButton = await weiboArticleImageButton(page);
|
|
@@ -5810,7 +5989,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5810
5989
|
throw new Error("微博头条文章编辑器中未找到图片库按钮");
|
|
5811
5990
|
}
|
|
5812
5991
|
await imageButton.click();
|
|
5813
|
-
const input = await
|
|
5992
|
+
const input = await waitForExisting(page, spec.imageInputs, 15000);
|
|
5814
5993
|
if (!input || typeof input.setInputFiles !== "function") {
|
|
5815
5994
|
throw new Error("微博头条文章图片库中未找到上传控件");
|
|
5816
5995
|
}
|
|
@@ -5818,7 +5997,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5818
5997
|
'xpath=ancestor::*[.//*[normalize-space(text())="图片库"] and .//*[normalize-space(text())="插入"]][1]'
|
|
5819
5998
|
);
|
|
5820
5999
|
const scope = (await modal.count().catch(() => 0)) > 0 ? modal : page;
|
|
5821
|
-
const structuredLibrary =
|
|
6000
|
+
const structuredLibrary = await waitForWeiboArticleImageList(page, scope, 10000);
|
|
5822
6001
|
if (structuredLibrary && options.clearExistingLibrary) {
|
|
5823
6002
|
options.onProgress?.("正在清理图片库旧图");
|
|
5824
6003
|
await clearExistingWeiboArticleImages(page, scope);
|
|
@@ -5839,6 +6018,9 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5839
6018
|
.catch(() => [])
|
|
5840
6019
|
: [];
|
|
5841
6020
|
const previousImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count();
|
|
6021
|
+
if (options.clearExistingLibrary && (await previews.count().catch(() => 0)) !== 0) {
|
|
6022
|
+
throw new Error(`微博图片库旧图未清空,已停止上传: ${source}`);
|
|
6023
|
+
}
|
|
5842
6024
|
options.onProgress?.("正在上传正文图片");
|
|
5843
6025
|
await input.setInputFiles([payload]);
|
|
5844
6026
|
|
|
@@ -5885,10 +6067,12 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5885
6067
|
if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
|
|
5886
6068
|
candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
|
|
5887
6069
|
}
|
|
5888
|
-
if (candidateIndexes.length === 0
|
|
5889
|
-
|
|
6070
|
+
if (candidateIndexes.length === 0) {
|
|
6071
|
+
throw new Error(`微博文章图片上传后未识别到本次上传的缩略图: ${source}`);
|
|
5890
6072
|
}
|
|
5891
|
-
if (
|
|
6073
|
+
if (options.clearExistingLibrary && previewCount === 1) {
|
|
6074
|
+
uploadedPreview = previews.first();
|
|
6075
|
+
} else if (structuredLibrary && candidateIndexes.length > 0) {
|
|
5892
6076
|
// Let the common click-and-verify path below perform the selection once.
|
|
5893
6077
|
uploadedPreview = previews.nth(candidateIndexes[0]);
|
|
5894
6078
|
}
|
|
@@ -5950,12 +6134,19 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
|
|
|
5950
6134
|
await insertButton.click();
|
|
5951
6135
|
await page.waitForTimeout(300);
|
|
5952
6136
|
if (scope !== page && typeof scope.isVisible === "function" && (await scope.isVisible().catch(() => false))) {
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
6137
|
+
// A successful click normally closes the library or opens the crop dialog.
|
|
6138
|
+
// Only retry when neither happened and the editor image count is unchanged;
|
|
6139
|
+
// an unconditional second click can insert the same image twice.
|
|
6140
|
+
const cropStarted = await visibleWeiboCropDialog(page);
|
|
6141
|
+
const currentImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count().catch(() => previousImages);
|
|
6142
|
+
if (!cropStarted && currentImages === previousImages) {
|
|
6143
|
+
const retryInsert = typeof scope.getByRole === "function"
|
|
6144
|
+
? scope.getByRole("button", { name: "插入", exact: true }).last()
|
|
6145
|
+
: insertButton;
|
|
6146
|
+
if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
|
|
6147
|
+
await retryInsert.click({ force: true });
|
|
6148
|
+
await page.waitForTimeout(300);
|
|
6149
|
+
}
|
|
5959
6150
|
}
|
|
5960
6151
|
}
|
|
5961
6152
|
|
|
@@ -6011,10 +6202,15 @@ async function clearWeiboArticleBody(page, bodyInput) {
|
|
|
6011
6202
|
await page.waitForTimeout(100);
|
|
6012
6203
|
}
|
|
6013
6204
|
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
6205
|
+
const readBodyState = async () =>
|
|
6206
|
+
bodyInput
|
|
6207
|
+
.evaluate(element => ({
|
|
6208
|
+
text: String(element.innerText || element.textContent || "").trim(),
|
|
6209
|
+
images: element.querySelectorAll("img:not(.ProseMirror-separator)").length
|
|
6210
|
+
}))
|
|
6211
|
+
.catch(() => ({ text: "", images: 0 }));
|
|
6212
|
+
let remaining = await readBodyState();
|
|
6213
|
+
if (remaining.text || remaining.images > 0) {
|
|
6018
6214
|
await bodyInput.evaluate(element => {
|
|
6019
6215
|
element.replaceChildren(document.createElement("p"));
|
|
6020
6216
|
element.dispatchEvent(
|
|
@@ -6026,11 +6222,9 @@ async function clearWeiboArticleBody(page, bodyInput) {
|
|
|
6026
6222
|
);
|
|
6027
6223
|
});
|
|
6028
6224
|
await page.waitForTimeout(100);
|
|
6029
|
-
remaining = await
|
|
6030
|
-
.evaluate(element => String(element.innerText || element.textContent || "").trim())
|
|
6031
|
-
.catch(() => "");
|
|
6225
|
+
remaining = await readBodyState();
|
|
6032
6226
|
}
|
|
6033
|
-
if (
|
|
6227
|
+
if (remaining.text || remaining.images > 0) {
|
|
6034
6228
|
throw new Error("微博文章正文未能清空,已停止发布以避免复用旧草稿内容");
|
|
6035
6229
|
}
|
|
6036
6230
|
}
|
|
@@ -6248,7 +6442,7 @@ export async function insertWeiboArticleRichContent(page, bodyInput, article, sp
|
|
|
6248
6442
|
payloadCache.set(item.source, payload);
|
|
6249
6443
|
}
|
|
6250
6444
|
await uploadWeiboArticleInlineImage(page, activeBodyInput, spec, payload, item.source, {
|
|
6251
|
-
clearExistingLibrary:
|
|
6445
|
+
clearExistingLibrary: true,
|
|
6252
6446
|
onProgress: options.onProgress
|
|
6253
6447
|
});
|
|
6254
6448
|
}
|
|
@@ -6786,6 +6980,43 @@ async function findWeiboShareSubmitButton(page, dialog) {
|
|
|
6786
6980
|
return findIn(page);
|
|
6787
6981
|
}
|
|
6788
6982
|
|
|
6983
|
+
async function preparedWeiboArticleState(page, spec, article, title) {
|
|
6984
|
+
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 5000))) return null;
|
|
6985
|
+
const titleInput = await waitForVisible(page, spec.title, 3000);
|
|
6986
|
+
const bodyInput = await waitForVisible(page, spec.body, 3000);
|
|
6987
|
+
if (!titleInput || !bodyInput) return null;
|
|
6988
|
+
if (normalizeText(await readFilledValue(titleInput)) !== normalizeText(title)) return null;
|
|
6989
|
+
|
|
6990
|
+
const expected = buildWeiboRichContent(article);
|
|
6991
|
+
const state =
|
|
6992
|
+
typeof bodyInput.evaluate === "function"
|
|
6993
|
+
? await bodyInput
|
|
6994
|
+
.evaluate(element => ({
|
|
6995
|
+
text: element.innerText || element.textContent || "",
|
|
6996
|
+
images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
|
|
6997
|
+
links: element.querySelectorAll("a[href]").length,
|
|
6998
|
+
failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length
|
|
6999
|
+
}))
|
|
7000
|
+
.catch(() => null)
|
|
7001
|
+
: null;
|
|
7002
|
+
const actualText = normalizedPresenceText(state?.text || normalizeText(await readFilledValue(bodyInput)));
|
|
7003
|
+
if (!actualText || state?.failedImages > 0) return null;
|
|
7004
|
+
if (state && state.images < expected.images.length) return null;
|
|
7005
|
+
const expectedFragments = expected.items
|
|
7006
|
+
.filter(item => item.type === "html" && item.plain)
|
|
7007
|
+
.map(item => normalizedPresenceText(item.plain))
|
|
7008
|
+
.filter(Boolean);
|
|
7009
|
+
if (expectedFragments.length > 0 && !hasOrderedTextFragments(actualText, expectedFragments)) return null;
|
|
7010
|
+
return {
|
|
7011
|
+
richContent: {
|
|
7012
|
+
rich: true,
|
|
7013
|
+
images: state?.images ?? expected.images.length,
|
|
7014
|
+
links: state?.links ?? expected.links.length,
|
|
7015
|
+
plainText: expected.plainText
|
|
7016
|
+
}
|
|
7017
|
+
};
|
|
7018
|
+
}
|
|
7019
|
+
|
|
6789
7020
|
async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
6790
7021
|
const progress = message => options.onProgress?.(message);
|
|
6791
7022
|
const preparedArticle = prepareWeiboArticleForProject(article, entry);
|
|
@@ -6800,6 +7031,24 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6800
7031
|
}
|
|
6801
7032
|
|
|
6802
7033
|
const editorUrl = weiboArticleEditorUrl(entry.url);
|
|
7034
|
+
if (options.directPublish) {
|
|
7035
|
+
const context = typeof page.context === "function" ? page.context() : null;
|
|
7036
|
+
const preparedPage = context ? await findPreparedEditorPage(context, spec) : null;
|
|
7037
|
+
if (preparedPage) page = preparedPage;
|
|
7038
|
+
}
|
|
7039
|
+
const directPrepared = options.directPublish
|
|
7040
|
+
? await preparedWeiboArticleState(page, spec, preparedArticle, title)
|
|
7041
|
+
: null;
|
|
7042
|
+
if (options.directPublish && !directPrepared) {
|
|
7043
|
+
throw new Error("微博待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
|
|
7044
|
+
}
|
|
7045
|
+
let richContent = directPrepared?.richContent || null;
|
|
7046
|
+
let cover = {};
|
|
7047
|
+
let column = "";
|
|
7048
|
+
if (options.directPublish) {
|
|
7049
|
+
progress("已复用当前微博待发内容,正在提交,不重复上传正文图片或封面");
|
|
7050
|
+
}
|
|
7051
|
+
if (!options.directPublish) {
|
|
6803
7052
|
await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
6804
7053
|
await afterNavigation(page);
|
|
6805
7054
|
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec)))) {
|
|
@@ -6808,7 +7057,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6808
7057
|
await afterNavigation(page);
|
|
6809
7058
|
}
|
|
6810
7059
|
if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
|
|
6811
|
-
throw new Error("
|
|
7060
|
+
throw new Error(browserSessionLoginFailure("微博"));
|
|
6812
7061
|
}
|
|
6813
7062
|
|
|
6814
7063
|
const draftMode = await ensureWeiboArticleDraft(page, title);
|
|
@@ -6825,12 +7074,13 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6825
7074
|
}
|
|
6826
7075
|
await fillStableWeiboField(page, spec.title, title, "标题");
|
|
6827
7076
|
const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
|
|
6828
|
-
|
|
7077
|
+
richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
|
|
6829
7078
|
onProgress: progress
|
|
6830
7079
|
});
|
|
6831
7080
|
progress("正文已写入,正在设置封面");
|
|
6832
|
-
|
|
6833
|
-
|
|
7081
|
+
cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
|
|
7082
|
+
column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
|
|
7083
|
+
}
|
|
6834
7084
|
|
|
6835
7085
|
const nextButton = await waitForVisibleButton(page, spec.next, 5000);
|
|
6836
7086
|
if (!nextButton) {
|
|
@@ -6927,7 +7177,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
|
|
|
6927
7177
|
}
|
|
6928
7178
|
const shareSubmit = await findWeiboShareSubmitButton(page, shareComposer.dialog);
|
|
6929
7179
|
if (!shareSubmit) {
|
|
6930
|
-
|
|
7180
|
+
// The article submit can navigate to publish history while the share
|
|
7181
|
+
// composer is still being torn down. Treat that navigation as a
|
|
7182
|
+
// successful article submission instead of reporting a false failure.
|
|
7183
|
+
const historyDeadline = Date.now() + 3000;
|
|
7184
|
+
while (!/\/history\//i.test(page.url()) && Date.now() < historyDeadline) {
|
|
7185
|
+
await page.waitForTimeout(200);
|
|
7186
|
+
}
|
|
7187
|
+
if (/\/history\//i.test(page.url())) {
|
|
7188
|
+
progress("微博文章已提交,分享弹窗已自动关闭");
|
|
7189
|
+
confirmation = { confirmation: "navigation" };
|
|
7190
|
+
} else {
|
|
7191
|
+
throw new Error("微博分享发布弹窗中未找到“发布”按钮");
|
|
7192
|
+
}
|
|
7193
|
+
}
|
|
7194
|
+
if (confirmation) {
|
|
7195
|
+
progress("已收到微博发布成功回执");
|
|
7196
|
+
if (options.keepOpen) {
|
|
7197
|
+
await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
|
|
7198
|
+
}
|
|
7199
|
+
return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
|
|
6931
7200
|
}
|
|
6932
7201
|
if (typeof shareSubmit.isDisabled === "function" && (await shareSubmit.isDisabled().catch(() => false))) {
|
|
6933
7202
|
throw new Error("微博分享发布弹窗中的“发布”按钮不可用");
|