@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 CHANGED
@@ -2433,7 +2433,10 @@ async function parseArticleFile(file) {
2433
2433
  }
2434
2434
 
2435
2435
  const raw = await fs.promises.readFile(articleFile, "utf8");
2436
- if (path.extname(articleFile).toLowerCase() === ".html") {
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);
@@ -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
- return buildPlatformRichContent(article, {
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(`${entry.platform} 尚未登录,请在浏览器中手动完成登录(可自行填写账号密码或扫码)`);
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(`B站登录完成后仍未进入${settings.typeLabel}投稿页,请确认账号拥有投稿权限`);
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 && pending === 0) {
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 previousCount = await items.count().catch(() => 0);
5773
- if (previousCount === 0) {
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 currentCount = previousCount;
5953
+ let afterDeleteCount = currentCount;
5787
5954
  do {
5788
- currentCount = await items.count().catch(() => previousCount);
5789
- if (currentCount < previousCount) {
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 (currentCount >= previousCount) {
5965
+ if (afterDeleteCount >= currentCount) {
5799
5966
  throw new Error("微博头条文章图片库旧图片删除后未消失");
5800
5967
  }
5801
- cleared += previousCount - currentCount;
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 firstExisting(page, spec.imageInputs);
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 = (await scope.locator(".image-list").count().catch(() => 0)) > 0;
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 && previewCount > 0) {
5889
- candidateIndexes = [previewCount - 1];
6070
+ if (candidateIndexes.length === 0) {
6071
+ throw new Error(`微博文章图片上传后未识别到本次上传的缩略图: ${source}`);
5890
6072
  }
5891
- if (structuredLibrary && candidateIndexes.length > 0) {
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
- const retryInsert = typeof scope.getByRole === "function"
5954
- ? scope.getByRole("button", { name: "插入", exact: true }).last()
5955
- : insertButton;
5956
- if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
5957
- await retryInsert.click({ force: true });
5958
- await page.waitForTimeout(300);
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
- let remaining = await bodyInput
6015
- .evaluate(element => String(element.innerText || element.textContent || "").trim())
6016
- .catch(() => "");
6017
- if (typeof remaining === "string" && remaining) {
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 bodyInput
6030
- .evaluate(element => String(element.innerText || element.textContent || "").trim())
6031
- .catch(() => "");
6225
+ remaining = await readBodyState();
6032
6226
  }
6033
- if (typeof remaining === "string" && remaining) {
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: imageIndex === 0,
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
- const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
7077
+ richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
6829
7078
  onProgress: progress
6830
7079
  });
6831
7080
  progress("正文已写入,正在设置封面");
6832
- const cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
6833
- const column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
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
- throw new Error("微博分享发布弹窗中未找到“发布”按钮");
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("微博分享发布弹窗中的“发布”按钮不可用");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {