@qcplay/cli 1.0.20 → 1.0.22

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);
@@ -3105,6 +3111,10 @@ async function runWithErrorBanner(title, action) {
3105
3111
  try {
3106
3112
  await action();
3107
3113
  } catch (err) {
3114
+ if (err.authorization?.action === "platform_authorization_required") {
3115
+ console.log(JSON.stringify(err.authorization));
3116
+ return;
3117
+ }
3108
3118
  console.error("");
3109
3119
  console.error(chalk.red(`${title}:`));
3110
3120
  console.error(err.message || err);
@@ -2,6 +2,7 @@ import { spawn } from "child_process";
2
2
 
3
3
  import * as cheerio from "cheerio";
4
4
 
5
+ import { larkCliExecutable } from "./lark-cli-command.js";
5
6
  import { normalizeArticleColor, sanitizeArticleRichHtml } from "./wechat-article.js";
6
7
 
7
8
  const LARK_DOCUMENT_PATH = /^\/(?:wiki|docx)\/([A-Za-z0-9_-]+)\/?$/;
@@ -59,7 +60,7 @@ function larkError(output, fallback) {
59
60
 
60
61
  function runLarkJson(args) {
61
62
  return new Promise((resolve, reject) => {
62
- const executable = process.platform === "win32" ? "lark-cli.cmd" : "lark-cli";
63
+ const executable = larkCliExecutable();
63
64
  const child = spawn(executable, args, {
64
65
  windowsHide: true,
65
66
  shell: process.platform === "win32",
@@ -0,0 +1,16 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { fileURLToPath } from "url";
4
+
5
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
6
+
7
+ export function larkCliExecutable() {
8
+ const configured = String(process.env.QCPLAY_LARK_CLI || "").trim();
9
+ if (configured) {
10
+ return configured;
11
+ }
12
+
13
+ const executableName = process.platform === "win32" ? "lark-cli.cmd" : "lark-cli";
14
+ const localExecutable = path.resolve(moduleDirectory, "..", "node_modules", ".bin", executableName);
15
+ return fs.existsSync(localExecutable) ? localExecutable : executableName;
16
+ }
@@ -9,9 +9,62 @@ import readline from "readline";
9
9
 
10
10
  import * as cheerio from "cheerio";
11
11
  import { applyContentRules } from "./content-rules.js";
12
+ import { larkCliExecutable } from "./lark-cli-command.js";
12
13
  import { stripWechatGuidanceHtml } from "./wechat-article.js";
13
14
 
14
15
  const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
16
+ const DOMESTIC_REGION = "国内";
17
+ const DOMESTIC_PLATFORM_ROWS = [
18
+ {
19
+ 项目: "最强蜗牛",
20
+ 区域: DOMESTIC_REGION,
21
+ 平台: "TapTap",
22
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=187376&group_id=227162&show_activity=1"
23
+ },
24
+ {
25
+ 项目: "提灯与地下城",
26
+ 区域: DOMESTIC_REGION,
27
+ 平台: "TapTap",
28
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=149961&group_id=177149&show_activity=1"
29
+ },
30
+ {
31
+ 项目: "阿瑞斯病毒2",
32
+ 区域: DOMESTIC_REGION,
33
+ 平台: "TapTap",
34
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=387841&group_id=663910&show_activity=1"
35
+ },
36
+ {
37
+ 项目: "迷途之光",
38
+ 区域: DOMESTIC_REGION,
39
+ 平台: "TapTap",
40
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=moment&app_id=600170&show_activity=1"
41
+ },
42
+ {
43
+ 项目: "最强蜗牛",
44
+ 区域: DOMESTIC_REGION,
45
+ 平台: "好游快爆",
46
+ 页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=12201&type=1"
47
+ },
48
+ {
49
+ 项目: "新仙剑",
50
+ 区域: DOMESTIC_REGION,
51
+ 平台: "好游快爆",
52
+ 页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=24008&type=1"
53
+ },
54
+ {
55
+ 项目: "阿瑞斯病毒2",
56
+ 区域: DOMESTIC_REGION,
57
+ 平台: "好游快爆",
58
+ 页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=20313&type=1"
59
+ }
60
+ ];
61
+ const DOMESTIC_STANDARD_PLATFORM_ROWS = ["最强蜗牛", "提灯与地下城", "阿瑞斯病毒2", "新仙剑", "迷途之光", "魔卡少女樱"].flatMap(
62
+ 项目 => [
63
+ { 项目, 区域: DOMESTIC_REGION, 平台: "B站", 页面URL链接: "https://member.bilibili.com/platform/upload/text/new-edit" },
64
+ { 项目, 区域: DOMESTIC_REGION, 平台: "微博", 页面URL链接: "https://card.weibo.com/article/v5/editor" },
65
+ { 项目, 区域: DOMESTIC_REGION, 平台: "小红书", 页面URL链接: "https://creator.xiaohongshu.com/publish/publish?from=menu&target=image" }
66
+ ]
67
+ );
15
68
  const DISCORD_MESSAGE_LIMIT = 2000;
16
69
  const DISCORD_MESSAGE_STATE_FILE = path.join(os.homedir(), ".qcplay", "discord-messages.json");
17
70
  const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
@@ -432,7 +485,11 @@ export function normalizePlatformRows(rows) {
432
485
 
433
486
  function runJson(command, args) {
434
487
  return new Promise((resolve, reject) => {
435
- const executable = process.platform === "win32" ? `${command}.cmd` : command;
488
+ const executable = command === "lark-cli"
489
+ ? larkCliExecutable()
490
+ : process.platform === "win32"
491
+ ? `${command}.cmd`
492
+ : command;
436
493
  const child = spawn(executable, args, {
437
494
  windowsHide: true,
438
495
  shell: process.platform === "win32",
@@ -446,19 +503,99 @@ function runJson(command, args) {
446
503
  child.once("close", code => {
447
504
  const output = Buffer.concat(stdout).toString("utf8").trim();
448
505
  const diagnostics = Buffer.concat(stderr).toString("utf8").trim();
506
+ const parsed = parseJsonEnvelope(output) || parseJsonEnvelope(diagnostics);
449
507
  if (code !== 0) {
450
- reject(new Error(diagnostics || output || `${command} 执行失败 (${code})`));
508
+ const error = new Error(diagnostics || output || `${command} 执行失败 (${code})`);
509
+ error.exitCode = code;
510
+ error.payload = parsed;
511
+ reject(error);
451
512
  return;
452
513
  }
453
- try {
454
- resolve(JSON.parse(output));
455
- } catch {
514
+ if (parsed) {
515
+ resolve(parsed);
516
+ } else {
456
517
  reject(new Error(`${command} 没有返回有效 JSON`));
457
518
  }
458
519
  });
459
520
  });
460
521
  }
461
522
 
523
+ function parseJsonEnvelope(value) {
524
+ if (!value) return null;
525
+ try {
526
+ return JSON.parse(value);
527
+ } catch {
528
+ return null;
529
+ }
530
+ }
531
+
532
+ function isAuthFailure(error) {
533
+ const payload = error?.payload;
534
+ const serialized = JSON.stringify(payload || {}) + "\n" + String(error?.message || "");
535
+ return /token_invalid|invalid access token|authentication/i.test(serialized);
536
+ }
537
+
538
+ async function beginPlatformAuthorization(commandRunner = runJson) {
539
+ try {
540
+ const result = await commandRunner("lark-cli", ["auth", "login", "--recommend", "--no-wait", "--json"]);
541
+ const verificationUrl = normalizeText(result?.verification_url || result?.verification_uri_complete);
542
+ if (verificationUrl) {
543
+ return { verificationUrl, deviceCode: normalizeText(result?.device_code) };
544
+ }
545
+ return {
546
+ platformManaged: result?.action === "platform_authorization_required",
547
+ message: normalizeText(result?.message)
548
+ };
549
+ } catch (error) {
550
+ const verificationUrl = normalizeText(error?.payload?.verification_url || error?.payload?.verification_uri_complete);
551
+ return {
552
+ verificationUrl,
553
+ deviceCode: normalizeText(error?.payload?.device_code),
554
+ platformManaged: error?.payload?.action === "platform_authorization_required",
555
+ message: normalizeText(error?.payload?.message || error?.message)
556
+ };
557
+ }
558
+ }
559
+
560
+ async function throwPlatformAuthorizationError(error, commandRunner = runJson) {
561
+ if (!isAuthFailure(error)) {
562
+ throw error;
563
+ }
564
+ const authorization = await beginPlatformAuthorization(commandRunner);
565
+ if (authorization.verificationUrl) {
566
+ const authorizationError = new Error(
567
+ `飞书授权需要完成,请打开以下地址并完成授权:\n${authorization.verificationUrl}`
568
+ );
569
+ authorizationError.authorization = {
570
+ ok: true,
571
+ action: "platform_authorization_required",
572
+ provider: "lark",
573
+ verification_url: authorization.verificationUrl,
574
+ ...(authorization.deviceCode ? { device_code: authorization.deviceCode } : {}),
575
+ message: "请完成飞书授权后重试原命令"
576
+ };
577
+ throw authorizationError;
578
+ }
579
+ if (authorization.platformManaged) {
580
+ const authorizationError = new Error(
581
+ "飞书访问令牌已失效,但当前 lark-cli 由平台托管凭证,无法生成本地授权地址。请在当前平台的飞书/Lark 连接器中重新授权后重试。"
582
+ );
583
+ authorizationError.authorization = {
584
+ ok: true,
585
+ action: "platform_authorization_required",
586
+ provider: "lark",
587
+ managed_by_platform: true,
588
+ message: "飞书访问令牌已失效,请在当前平台重新授权飞书/Lark 连接器后重试"
589
+ };
590
+ throw authorizationError;
591
+ }
592
+ throw new Error(`飞书访问令牌已失效,授权启动失败:${authorization.message || "未知错误"}`);
593
+ }
594
+
595
+ function domesticPlatformRows() {
596
+ return normalizePlatformRows([...DOMESTIC_PLATFORM_ROWS, ...DOMESTIC_STANDARD_PLATFORM_ROWS]);
597
+ }
598
+
462
599
  export async function loadPlatformEntries(options = {}) {
463
600
  if (options.configFile) {
464
601
  const configFile = path.resolve(process.cwd(), options.configFile);
@@ -473,35 +610,53 @@ export async function loadPlatformEntries(options = {}) {
473
610
  return normalizePlatformRows(rows);
474
611
  }
475
612
 
476
- const workbook = await runJson("lark-cli", [
477
- "sheets",
478
- "+workbook-info",
479
- "--url",
480
- PLATFORM_CONFIG_URL,
481
- "--as",
482
- "user",
483
- "--format",
484
- "json"
485
- ]);
613
+ const domesticEntries = domesticPlatformRows();
614
+ const commandRunner = options.runJson || runJson;
615
+ const requestedRegion = normalizeKey(options.region || "");
616
+ const requestedProject = normalizeProjectKey(options.project || "");
617
+ if ((!requestedRegion || requestedRegion === normalizeKey(DOMESTIC_REGION)) && (!requestedProject || entriesForProject(domesticEntries, options.project).length > 0)) {
618
+ return domesticEntries;
619
+ }
620
+
621
+ let workbook;
622
+ try {
623
+ workbook = await commandRunner("lark-cli", [
624
+ "sheets",
625
+ "+workbook-info",
626
+ "--url",
627
+ PLATFORM_CONFIG_URL,
628
+ "--as",
629
+ "user",
630
+ "--format",
631
+ "json"
632
+ ]);
633
+ } catch (error) {
634
+ await throwPlatformAuthorizationError(error, commandRunner);
635
+ }
486
636
  const sheet = workbook?.data?.sheets?.find(candidate => !candidate.is_hidden) || workbook?.data?.sheets?.[0];
487
637
  if (!sheet?.sheet_id || !Number.isInteger(sheet.row_count) || sheet.row_count < 1) {
488
638
  throw new Error("飞书平台配置没有可读取的工作表");
489
639
  }
490
- const value = await runJson("lark-cli", [
491
- "sheets",
492
- "+csv-get",
493
- "--url",
494
- PLATFORM_CONFIG_URL,
495
- "--sheet-id",
496
- sheet.sheet_id,
497
- "--range",
498
- `A1:I${sheet.row_count}`,
499
- "--include-row-prefix=false",
500
- "--as",
501
- "user",
502
- "--format",
503
- "json"
504
- ]);
640
+ let value;
641
+ try {
642
+ value = await commandRunner("lark-cli", [
643
+ "sheets",
644
+ "+csv-get",
645
+ "--url",
646
+ PLATFORM_CONFIG_URL,
647
+ "--sheet-id",
648
+ sheet.sheet_id,
649
+ "--range",
650
+ `A1:I${sheet.row_count}`,
651
+ "--include-row-prefix=false",
652
+ "--as",
653
+ "user",
654
+ "--format",
655
+ "json"
656
+ ]);
657
+ } catch (error) {
658
+ await throwPlatformAuthorizationError(error, commandRunner);
659
+ }
505
660
  return normalizePlatformRows(rowsFromCsvEnvelope(value));
506
661
  }
507
662
 
@@ -841,7 +996,7 @@ function plainTextForPlatform(article, platformKey) {
841
996
  $("img").each((_, element) => {
842
997
  const image = $(element);
843
998
  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` : "");
999
+ image.replaceWith(platformKey === "meta" ? "\n" : source ? `\n${source}\n` : "");
845
1000
  });
846
1001
  const markdown = normalizeText($.root().text() || article.markdown)
847
1002
  .replace(/[ \t]+\n/g, "\n")
@@ -1763,6 +1918,8 @@ export function resolveWeiboSuperTopic(article, entry = {}) {
1763
1918
  }
1764
1919
 
1765
1920
  export function resolveWeiboShareCopy(article, entry = {}) {
1921
+ const configured = weiboMeta(article, "weibo_share_copy", "weibo_share_text");
1922
+ if (configured) return configured;
1766
1923
  const title = resolveWeiboArticleTitle(article);
1767
1924
  const project = normalizeText(entry.project);
1768
1925
  if (project === "最强蜗牛") {
@@ -2040,7 +2197,7 @@ export function browserPlatformPageUrl(entry, type = "topic") {
2040
2197
  case "x":
2041
2198
  return "https://x.com/compose/post";
2042
2199
  case "meta":
2043
- return (
2200
+ return metaComposerUrl(
2044
2201
  configuredUrl(entry.url, url => url.hostname.toLowerCase() === "business.facebook.com") ||
2045
2202
  configuredUrl(entry.url)
2046
2203
  );
@@ -2068,6 +2225,10 @@ function imageSourcesFromArticle(article) {
2068
2225
  .filter(Boolean);
2069
2226
  }
2070
2227
 
2228
+ export function resolveMetaPublishingOptions(article = {}) {
2229
+ return { images: imageSourcesFromArticle(article) };
2230
+ }
2231
+
2071
2232
  function splitImageSources(value) {
2072
2233
  return normalizeText(value)
2073
2234
  .split(/[,,\r\n]+/)
@@ -2758,11 +2919,74 @@ function normalizeNumberedSectionHeadings(items) {
2758
2919
  }
2759
2920
 
2760
2921
  export function buildBilibiliRichContent(article) {
2761
- return buildPlatformRichContent(article, {
2922
+ const content = buildPlatformRichContent(article, {
2762
2923
  preserveBackground: true,
2763
2924
  preserveBlockStyles: true,
2764
2925
  structuredTables: true
2765
2926
  });
2927
+ // Bilibili can drop the first paragraph's CSS alignment when it follows an
2928
+ // inline image node. Keep an explicit align attribute on that first text
2929
+ // block so the source formatting survives the editor's initial normalization.
2930
+ const firstTextIndex = content.items.findIndex(item => item.type === "html" && item.plain);
2931
+ if (firstTextIndex < 0) return content;
2932
+ const firstText = content.items[firstTextIndex];
2933
+ const alignment = normalizeText(firstText.align).toLowerCase();
2934
+ if (!alignment) return content;
2935
+ const $ = cheerio.load(firstText.html, null, false);
2936
+ const root = $.root().children().first();
2937
+ if (!root.length) return content;
2938
+ root.attr("align", alignment);
2939
+ return {
2940
+ ...content,
2941
+ items: content.items.map((item, index) => (index === firstTextIndex ? { ...item, html: $.html(root) } : item))
2942
+ };
2943
+ }
2944
+
2945
+ async function enforceBilibiliFirstTextAlignment(bodyInput, content) {
2946
+ const firstText = content.items.find(item => item.type === "html" && item.plain);
2947
+ const alignment = normalizeText(firstText?.align).toLowerCase();
2948
+ const expectedText = normalizeText(firstText?.plain);
2949
+ if (!alignment || !expectedText || typeof bodyInput.evaluate !== "function") return false;
2950
+ return bodyInput
2951
+ .evaluate(
2952
+ (element, payload) => {
2953
+ const normalize = value => String(value || "").replace(/\s+/g, " ").trim();
2954
+ const blocks = [...element.querySelectorAll("p,h1,h2,h3,h4,h5,h6,div")].filter(block => {
2955
+ const text = normalize(block.textContent);
2956
+ return text === payload.text || text.includes(payload.text) || payload.text.includes(text);
2957
+ });
2958
+ // Prefer the smallest matching block. The outer editor container often
2959
+ // contains the same text, but formatting it does not update the actual
2960
+ // paragraph node stored by Bilibili's rich-text model.
2961
+ blocks.sort((left, right) => normalize(left.textContent).length - normalize(right.textContent).length);
2962
+ const target = blocks[0];
2963
+ if (!target) return false;
2964
+ const range = document.createRange();
2965
+ range.selectNodeContents(target);
2966
+ const selection = window.getSelection();
2967
+ selection.removeAllRanges();
2968
+ selection.addRange(range);
2969
+ try {
2970
+ document.execCommand("justifyCenter", false, null);
2971
+ } catch {
2972
+ // Some Chromium builds disable execCommand; the explicit style below
2973
+ // still provides a deterministic fallback for the editor DOM.
2974
+ }
2975
+ target.style.textAlign = payload.alignment;
2976
+ target.setAttribute("align", payload.alignment);
2977
+ target.setAttribute("data-align", payload.alignment);
2978
+ target.dispatchEvent(
2979
+ new InputEvent("input", {
2980
+ bubbles: true,
2981
+ inputType: "formatBlock",
2982
+ data: null
2983
+ })
2984
+ );
2985
+ return getComputedStyle(target).textAlign === payload.alignment;
2986
+ },
2987
+ { text: expectedText, alignment }
2988
+ )
2989
+ .catch(() => false);
2766
2990
  }
2767
2991
 
2768
2992
  export function buildHaoyouRichContent(article) {
@@ -3369,6 +3593,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3369
3593
  plain: plainParts.join("\n")
3370
3594
  });
3371
3595
  await page.waitForTimeout(100);
3596
+ await enforceBilibiliFirstTextAlignment(bodyInput, content);
3372
3597
  options.onProgress?.("B站 文字正文已写入,正在回读校验");
3373
3598
  options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
3374
3599
  }
@@ -3432,6 +3657,9 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3432
3657
  }
3433
3658
  }
3434
3659
 
3660
+ // Media insertion may rerender the first paragraph and drop its alignment.
3661
+ // Reapply it after all images are settled, immediately before final reading.
3662
+ await enforceBilibiliFirstTextAlignment(bodyInput, content);
3435
3663
  options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
3436
3664
  let state = null;
3437
3665
  let previousTextLength = -1;
@@ -4378,6 +4606,24 @@ async function openPlatformLogin(page, entry, spec) {
4378
4606
  return false;
4379
4607
  }
4380
4608
 
4609
+ export function browserSessionLoginPrompt(platform) {
4610
+ return `${platform} 尚未登录。请仅在 CLI 刚打开的自动化浏览器窗口中完成登录(扫码或账号密码均可),不要改用其他浏览器;完成后保持该窗口打开并回复“已登录”。`;
4611
+ }
4612
+
4613
+ async function waitForExisting(page, selectors, timeoutMs = 15000) {
4614
+ const deadline = Date.now() + timeoutMs;
4615
+ do {
4616
+ const locator = await firstExisting(page, selectors);
4617
+ if (locator) return locator;
4618
+ await page.waitForTimeout(200);
4619
+ } while (Date.now() < deadline);
4620
+ return null;
4621
+ }
4622
+
4623
+ function browserSessionLoginFailure(platform) {
4624
+ return `${platform} 登录完成后仍未进入发布编辑器。请确认登录发生在 CLI 刚打开的自动化浏览器窗口中,并检查账号是否拥有发布权限`;
4625
+ }
4626
+
4381
4627
  async function ensureBrowserLogin(page, entry, spec, promptUser) {
4382
4628
  const alreadyLoggedIn = await openPlatformLogin(page, entry, spec);
4383
4629
  if (alreadyLoggedIn) {
@@ -4385,7 +4631,7 @@ async function ensureBrowserLogin(page, entry, spec, promptUser) {
4385
4631
  }
4386
4632
 
4387
4633
  if (spec.manualLogin) {
4388
- await promptUser(`${entry.platform} 尚未登录,请在浏览器中手动完成登录(可自行填写账号密码或扫码)`);
4634
+ await promptUser(browserSessionLoginPrompt(entry.platform));
4389
4635
  return true;
4390
4636
  }
4391
4637
 
@@ -4999,7 +5245,7 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4999
5245
  await afterNavigation(page);
5000
5246
  }
5001
5247
  if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
5002
- throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
5248
+ throw new Error(browserSessionLoginFailure("TapTap"));
5003
5249
  }
5004
5250
  const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
5005
5251
  if (options.publishDraft) {
@@ -5506,7 +5752,7 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
5506
5752
  await afterNavigation(page);
5507
5753
  }
5508
5754
  if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 15000))) {
5509
- throw new Error(`B站登录完成后仍未进入${settings.typeLabel}投稿页,请确认账号拥有投稿权限`);
5755
+ throw new Error(browserSessionLoginFailure("B站"));
5510
5756
  }
5511
5757
 
5512
5758
  let titleInput;
@@ -5650,7 +5896,10 @@ async function waitForWeiboArticleInlineImage(bodyInput, previousCount, source)
5650
5896
  if (failed > 0) {
5651
5897
  throw new Error(`微博文章正文图片上传失败: ${source}`);
5652
5898
  }
5653
- if (count > previousCount && pending === 0) {
5899
+ if (count > previousCount + 1) {
5900
+ throw new Error(`微博正文图片疑似重复插入: ${source}`);
5901
+ }
5902
+ if (count === previousCount + 1 && pending === 0) {
5654
5903
  stableChecks += 1;
5655
5904
  if (stableChecks >= 3) {
5656
5905
  return;
@@ -5767,10 +6016,24 @@ async function visibleWeiboCropDialog(page) {
5767
6016
 
5768
6017
  async function clearExistingWeiboArticleImages(page, scope) {
5769
6018
  const items = scope.locator(".image-list .image-item");
6019
+ // The gallery list is populated asynchronously after the dialog opens. Give
6020
+ // existing items a short window to appear before treating the library as empty.
6021
+ let previousCount = await items.count().catch(() => 0);
6022
+ if (previousCount === 0) {
6023
+ const deadline = Date.now() + 5000;
6024
+ do {
6025
+ await page.waitForTimeout(200);
6026
+ previousCount = await items.count().catch(() => 0);
6027
+ if (previousCount > 0) break;
6028
+ } while (Date.now() < deadline);
6029
+ }
6030
+ if (previousCount === 0) {
6031
+ return 0;
6032
+ }
5770
6033
  let cleared = 0;
5771
6034
  while (cleared < 200) {
5772
- const previousCount = await items.count().catch(() => 0);
5773
- if (previousCount === 0) {
6035
+ const currentCount = await items.count().catch(() => 0);
6036
+ if (currentCount === 0) {
5774
6037
  return cleared;
5775
6038
  }
5776
6039
  const item = items.first();
@@ -5783,10 +6046,10 @@ async function clearExistingWeiboArticleImages(page, scope) {
5783
6046
  }
5784
6047
  await remove.click();
5785
6048
  const deadline = Date.now() + 10000;
5786
- let currentCount = previousCount;
6049
+ let afterDeleteCount = currentCount;
5787
6050
  do {
5788
- currentCount = await items.count().catch(() => previousCount);
5789
- if (currentCount < previousCount) {
6051
+ afterDeleteCount = await items.count().catch(() => currentCount);
6052
+ if (afterDeleteCount < currentCount) {
5790
6053
  break;
5791
6054
  }
5792
6055
  const text = typeof scope.innerText === "function" ? await scope.innerText().catch(() => "") : "";
@@ -5795,14 +6058,26 @@ async function clearExistingWeiboArticleImages(page, scope) {
5795
6058
  }
5796
6059
  await page.waitForTimeout(200);
5797
6060
  } while (Date.now() < deadline);
5798
- if (currentCount >= previousCount) {
6061
+ if (afterDeleteCount >= currentCount) {
5799
6062
  throw new Error("微博头条文章图片库旧图片删除后未消失");
5800
6063
  }
5801
- cleared += previousCount - currentCount;
6064
+ cleared += currentCount - afterDeleteCount;
5802
6065
  }
5803
6066
  throw new Error("微博头条文章图片库旧图片超过 200 张,已停止清理");
5804
6067
  }
5805
6068
 
6069
+ async function waitForWeiboArticleImageList(page, scope, timeoutMs = 10000) {
6070
+ const list = scope.locator(".image-list");
6071
+ const deadline = Date.now() + timeoutMs;
6072
+ do {
6073
+ if ((await list.count().catch(() => 0)) > 0) {
6074
+ return true;
6075
+ }
6076
+ await page.waitForTimeout(200);
6077
+ } while (Date.now() < deadline);
6078
+ return false;
6079
+ }
6080
+
5806
6081
  async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, source, options = {}) {
5807
6082
  options.onProgress?.("正在打开图片库");
5808
6083
  const imageButton = await weiboArticleImageButton(page);
@@ -5810,7 +6085,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5810
6085
  throw new Error("微博头条文章编辑器中未找到图片库按钮");
5811
6086
  }
5812
6087
  await imageButton.click();
5813
- const input = await firstExisting(page, spec.imageInputs);
6088
+ const input = await waitForExisting(page, spec.imageInputs, 15000);
5814
6089
  if (!input || typeof input.setInputFiles !== "function") {
5815
6090
  throw new Error("微博头条文章图片库中未找到上传控件");
5816
6091
  }
@@ -5818,7 +6093,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5818
6093
  'xpath=ancestor::*[.//*[normalize-space(text())="图片库"] and .//*[normalize-space(text())="插入"]][1]'
5819
6094
  );
5820
6095
  const scope = (await modal.count().catch(() => 0)) > 0 ? modal : page;
5821
- const structuredLibrary = (await scope.locator(".image-list").count().catch(() => 0)) > 0;
6096
+ const structuredLibrary = await waitForWeiboArticleImageList(page, scope, 10000);
5822
6097
  if (structuredLibrary && options.clearExistingLibrary) {
5823
6098
  options.onProgress?.("正在清理图片库旧图");
5824
6099
  await clearExistingWeiboArticleImages(page, scope);
@@ -5839,6 +6114,9 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5839
6114
  .catch(() => [])
5840
6115
  : [];
5841
6116
  const previousImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count();
6117
+ if (options.clearExistingLibrary && (await previews.count().catch(() => 0)) !== 0) {
6118
+ throw new Error(`微博图片库旧图未清空,已停止上传: ${source}`);
6119
+ }
5842
6120
  options.onProgress?.("正在上传正文图片");
5843
6121
  await input.setInputFiles([payload]);
5844
6122
 
@@ -5885,10 +6163,12 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5885
6163
  if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
5886
6164
  candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
5887
6165
  }
5888
- if (candidateIndexes.length === 0 && previewCount > 0) {
5889
- candidateIndexes = [previewCount - 1];
6166
+ if (candidateIndexes.length === 0) {
6167
+ throw new Error(`微博文章图片上传后未识别到本次上传的缩略图: ${source}`);
5890
6168
  }
5891
- if (structuredLibrary && candidateIndexes.length > 0) {
6169
+ if (options.clearExistingLibrary && previewCount === 1) {
6170
+ uploadedPreview = previews.first();
6171
+ } else if (structuredLibrary && candidateIndexes.length > 0) {
5892
6172
  // Let the common click-and-verify path below perform the selection once.
5893
6173
  uploadedPreview = previews.nth(candidateIndexes[0]);
5894
6174
  }
@@ -5950,12 +6230,19 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5950
6230
  await insertButton.click();
5951
6231
  await page.waitForTimeout(300);
5952
6232
  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);
6233
+ // A successful click normally closes the library or opens the crop dialog.
6234
+ // Only retry when neither happened and the editor image count is unchanged;
6235
+ // an unconditional second click can insert the same image twice.
6236
+ const cropStarted = await visibleWeiboCropDialog(page);
6237
+ const currentImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count().catch(() => previousImages);
6238
+ if (!cropStarted && currentImages === previousImages) {
6239
+ const retryInsert = typeof scope.getByRole === "function"
6240
+ ? scope.getByRole("button", { name: "插入", exact: true }).last()
6241
+ : insertButton;
6242
+ if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
6243
+ await retryInsert.click({ force: true });
6244
+ await page.waitForTimeout(300);
6245
+ }
5959
6246
  }
5960
6247
  }
5961
6248
 
@@ -6011,10 +6298,15 @@ async function clearWeiboArticleBody(page, bodyInput) {
6011
6298
  await page.waitForTimeout(100);
6012
6299
  }
6013
6300
 
6014
- let remaining = await bodyInput
6015
- .evaluate(element => String(element.innerText || element.textContent || "").trim())
6016
- .catch(() => "");
6017
- if (typeof remaining === "string" && remaining) {
6301
+ const readBodyState = async () =>
6302
+ bodyInput
6303
+ .evaluate(element => ({
6304
+ text: String(element.innerText || element.textContent || "").trim(),
6305
+ images: element.querySelectorAll("img:not(.ProseMirror-separator)").length
6306
+ }))
6307
+ .catch(() => ({ text: "", images: 0 }));
6308
+ let remaining = await readBodyState();
6309
+ if (remaining.text || remaining.images > 0) {
6018
6310
  await bodyInput.evaluate(element => {
6019
6311
  element.replaceChildren(document.createElement("p"));
6020
6312
  element.dispatchEvent(
@@ -6026,15 +6318,121 @@ async function clearWeiboArticleBody(page, bodyInput) {
6026
6318
  );
6027
6319
  });
6028
6320
  await page.waitForTimeout(100);
6029
- remaining = await bodyInput
6030
- .evaluate(element => String(element.innerText || element.textContent || "").trim())
6031
- .catch(() => "");
6321
+ remaining = await readBodyState();
6032
6322
  }
6033
- if (typeof remaining === "string" && remaining) {
6323
+ if (remaining.text || remaining.images > 0) {
6034
6324
  throw new Error("微博文章正文未能清空,已停止发布以避免复用旧草稿内容");
6035
6325
  }
6036
6326
  }
6037
6327
 
6328
+ function metaComposerUrl(value) {
6329
+ const configured = normalizeText(value);
6330
+ if (!configured) return "";
6331
+ try {
6332
+ const source = new URL(configured);
6333
+ if (source.hostname.toLowerCase() !== "business.facebook.com" || !/\/latest\/composer\/?$/i.test(source.pathname)) {
6334
+ return source.toString();
6335
+ }
6336
+ const target = new URL("/latest/composer/", source.origin);
6337
+ for (const name of ["asset_id", "business_id"]) {
6338
+ const parameter = source.searchParams.get(name);
6339
+ if (parameter) target.searchParams.set(name, parameter);
6340
+ }
6341
+ return target.toString();
6342
+ } catch {
6343
+ return configured;
6344
+ }
6345
+ }
6346
+
6347
+ function genericMetaComposerUrl(value) {
6348
+ try {
6349
+ const url = new URL(value);
6350
+ return new URL("/latest/composer/", url.origin).toString();
6351
+ } catch {
6352
+ return "https://business.facebook.com/latest/composer/";
6353
+ }
6354
+ }
6355
+
6356
+ export function isMetaUnavailableContent(value) {
6357
+ return /很抱歉.{0,24}(?:无法显示内容|内容不可用)|链接可能已过期|(?:sorry,?\s*)?(?:this|the) content isn['’]t available|content isn['’]t available right now|page may only be visible to an audience/i.test(
6358
+ normalizeText(value)
6359
+ );
6360
+ }
6361
+
6362
+ async function metaUnavailablePage(page) {
6363
+ if (typeof page?.locator !== "function") return false;
6364
+ const text = await page.locator("body").innerText().catch(() => "");
6365
+ return isMetaUnavailableContent(text);
6366
+ }
6367
+
6368
+ async function metaUploadedPhotoCount(page) {
6369
+ if (typeof page?.getByText !== "function") return 0;
6370
+ return retryAcrossNavigation(page, () =>
6371
+ page.getByText(/^(?:移除|删除)(?:照片|图片)$|^Remove photo$|^Delete photo$/i, { exact: true }).count()
6372
+ );
6373
+ }
6374
+
6375
+ async function waitForMetaPhotoUploads(page, expectedCount, timeoutMs = 180000) {
6376
+ const deadline = Date.now() + timeoutMs;
6377
+ let stableChecks = 0;
6378
+ do {
6379
+ const text = await page.locator("body").innerText().catch(() => "");
6380
+ if (/上传失败|无法上传|照片.{0,12}(?:不受支持|格式错误)|处理失败|upload failed|couldn['’]t upload|unsupported/i.test(text)) {
6381
+ throw new Error("Facebook 图片上传失败,请检查页面提示或图片格式");
6382
+ }
6383
+ const uploaded = await metaUploadedPhotoCount(page);
6384
+ const pending = await pendingUploadCount(page);
6385
+ if (uploaded >= expectedCount && pending === 0) {
6386
+ stableChecks += 1;
6387
+ if (stableChecks >= 3) return uploaded;
6388
+ } else {
6389
+ stableChecks = 0;
6390
+ }
6391
+ await new Promise(resolve => setTimeout(resolve, 300));
6392
+ } while (Date.now() < deadline);
6393
+ const uploaded = await metaUploadedPhotoCount(page).catch(() => 0);
6394
+ throw new Error(`Facebook 图片上传未完成,预期 ${expectedCount} 张,页面确认 ${uploaded} 张`);
6395
+ }
6396
+
6397
+ export async function uploadMetaImages(page, article, options = {}) {
6398
+ const publishing = resolveMetaPublishingOptions(article);
6399
+ if (publishing.images.length === 0) return { images: 0 };
6400
+ if (typeof page?.waitForEvent !== "function") {
6401
+ throw new Error("Facebook 页面不支持文件选择器事件,无法上传正文图片");
6402
+ }
6403
+
6404
+ const payloads = [];
6405
+ for (let index = 0; index < publishing.images.length; index += 1) {
6406
+ payloads.push(await imageUploadPayload(publishing.images[index], article.articleFile, index, "Facebook"));
6407
+ if ((index + 1) % 5 === 0 || index + 1 === publishing.images.length) {
6408
+ options.onProgress?.(`已准备 Facebook 图片 ${index + 1}/${publishing.images.length}`);
6409
+ }
6410
+ }
6411
+
6412
+ const trigger =
6413
+ (await waitForVisibleButton(page, [/添加照片\s*\/\s*视频/i, /Add photos?\s*\/\s*videos?/i], 8000)) ||
6414
+ (await waitForVisibleTextAcrossFrames(page, /添加照片\s*\/\s*视频|Add photos?\s*\/\s*videos?/i, 3000, false));
6415
+ if (!trigger) {
6416
+ throw new Error("Facebook 发布页未找到“添加照片/视频”控件");
6417
+ }
6418
+
6419
+ const chooserPromise = page.waitForEvent("filechooser", { timeout: 10000 }).catch(() => null);
6420
+ await trigger.click();
6421
+ const chooser = await chooserPromise;
6422
+ if (!chooser) {
6423
+ throw new Error("Facebook 点击“添加照片/视频”后未打开文件选择器");
6424
+ }
6425
+ if (payloads.length > 1 && typeof chooser.isMultiple === "function" && !chooser.isMultiple()) {
6426
+ throw new Error(`Facebook 文件选择器不支持多选,无法一次上传 ${payloads.length} 张图片`);
6427
+ }
6428
+
6429
+ options.onProgress?.(`正在上传 Facebook 图片 ${payloads.length} 张`);
6430
+ await chooser.setFiles(payloads);
6431
+ const uploaded = await waitForMetaPhotoUploads(page, payloads.length, options.uploadTimeoutMs);
6432
+ options.onProgress?.(`Facebook 图片上传完成,共 ${uploaded} 张`);
6433
+ return { images: uploaded };
6434
+ }
6435
+
6038
6436
  export async function insertWeiboArticleRichContent(page, bodyInput, article, spec, fallbackText, options = {}) {
6039
6437
  if (typeof bodyInput.evaluate !== "function" || typeof bodyInput.locator !== "function") {
6040
6438
  await fillLocator(bodyInput, fallbackText);
@@ -6248,7 +6646,7 @@ export async function insertWeiboArticleRichContent(page, bodyInput, article, sp
6248
6646
  payloadCache.set(item.source, payload);
6249
6647
  }
6250
6648
  await uploadWeiboArticleInlineImage(page, activeBodyInput, spec, payload, item.source, {
6251
- clearExistingLibrary: imageIndex === 0,
6649
+ clearExistingLibrary: true,
6252
6650
  onProgress: options.onProgress
6253
6651
  });
6254
6652
  }
@@ -6786,6 +7184,43 @@ async function findWeiboShareSubmitButton(page, dialog) {
6786
7184
  return findIn(page);
6787
7185
  }
6788
7186
 
7187
+ async function preparedWeiboArticleState(page, spec, article, title) {
7188
+ if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 5000))) return null;
7189
+ const titleInput = await waitForVisible(page, spec.title, 3000);
7190
+ const bodyInput = await waitForVisible(page, spec.body, 3000);
7191
+ if (!titleInput || !bodyInput) return null;
7192
+ if (normalizeText(await readFilledValue(titleInput)) !== normalizeText(title)) return null;
7193
+
7194
+ const expected = buildWeiboRichContent(article);
7195
+ const state =
7196
+ typeof bodyInput.evaluate === "function"
7197
+ ? await bodyInput
7198
+ .evaluate(element => ({
7199
+ text: element.innerText || element.textContent || "",
7200
+ images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
7201
+ links: element.querySelectorAll("a[href]").length,
7202
+ failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length
7203
+ }))
7204
+ .catch(() => null)
7205
+ : null;
7206
+ const actualText = normalizedPresenceText(state?.text || normalizeText(await readFilledValue(bodyInput)));
7207
+ if (!actualText || state?.failedImages > 0) return null;
7208
+ if (state && state.images < expected.images.length) return null;
7209
+ const expectedFragments = expected.items
7210
+ .filter(item => item.type === "html" && item.plain)
7211
+ .map(item => normalizedPresenceText(item.plain))
7212
+ .filter(Boolean);
7213
+ if (expectedFragments.length > 0 && !hasOrderedTextFragments(actualText, expectedFragments)) return null;
7214
+ return {
7215
+ richContent: {
7216
+ rich: true,
7217
+ images: state?.images ?? expected.images.length,
7218
+ links: state?.links ?? expected.links.length,
7219
+ plainText: expected.plainText
7220
+ }
7221
+ };
7222
+ }
7223
+
6789
7224
  async function publishWeibo(page, entry, article, spec, options, promptUser) {
6790
7225
  const progress = message => options.onProgress?.(message);
6791
7226
  const preparedArticle = prepareWeiboArticleForProject(article, entry);
@@ -6800,6 +7235,24 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6800
7235
  }
6801
7236
 
6802
7237
  const editorUrl = weiboArticleEditorUrl(entry.url);
7238
+ if (options.directPublish) {
7239
+ const context = typeof page.context === "function" ? page.context() : null;
7240
+ const preparedPage = context ? await findPreparedEditorPage(context, spec) : null;
7241
+ if (preparedPage) page = preparedPage;
7242
+ }
7243
+ const directPrepared = options.directPublish
7244
+ ? await preparedWeiboArticleState(page, spec, preparedArticle, title)
7245
+ : null;
7246
+ if (options.directPublish && !directPrepared) {
7247
+ throw new Error("微博待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
7248
+ }
7249
+ let richContent = directPrepared?.richContent || null;
7250
+ let cover = {};
7251
+ let column = "";
7252
+ if (options.directPublish) {
7253
+ progress("已复用当前微博待发内容,正在提交,不重复上传正文图片或封面");
7254
+ }
7255
+ if (!options.directPublish) {
6803
7256
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
6804
7257
  await afterNavigation(page);
6805
7258
  if (!(await waitForEditorReady(page, () => editorIsReady(page, spec)))) {
@@ -6808,7 +7261,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6808
7261
  await afterNavigation(page);
6809
7262
  }
6810
7263
  if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
6811
- throw new Error("微博登录完成后仍未进入头条文章编辑器,请确认账号拥有文章发布权限");
7264
+ throw new Error(browserSessionLoginFailure("微博"));
6812
7265
  }
6813
7266
 
6814
7267
  const draftMode = await ensureWeiboArticleDraft(page, title);
@@ -6825,12 +7278,13 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6825
7278
  }
6826
7279
  await fillStableWeiboField(page, spec.title, title, "标题");
6827
7280
  const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
6828
- const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
7281
+ richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
6829
7282
  onProgress: progress
6830
7283
  });
6831
7284
  progress("正文已写入,正在设置封面");
6832
- const cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
6833
- const column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
7285
+ cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
7286
+ column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
7287
+ }
6834
7288
 
6835
7289
  const nextButton = await waitForVisibleButton(page, spec.next, 5000);
6836
7290
  if (!nextButton) {
@@ -6927,7 +7381,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6927
7381
  }
6928
7382
  const shareSubmit = await findWeiboShareSubmitButton(page, shareComposer.dialog);
6929
7383
  if (!shareSubmit) {
6930
- throw new Error("微博分享发布弹窗中未找到“发布”按钮");
7384
+ // The article submit can navigate to publish history while the share
7385
+ // composer is still being torn down. Treat that navigation as a
7386
+ // successful article submission instead of reporting a false failure.
7387
+ const historyDeadline = Date.now() + 3000;
7388
+ while (!/\/history\//i.test(page.url()) && Date.now() < historyDeadline) {
7389
+ await page.waitForTimeout(200);
7390
+ }
7391
+ if (/\/history\//i.test(page.url())) {
7392
+ progress("微博文章已提交,分享弹窗已自动关闭");
7393
+ confirmation = { confirmation: "navigation" };
7394
+ } else {
7395
+ throw new Error("微博分享发布弹窗中未找到“发布”按钮");
7396
+ }
7397
+ }
7398
+ if (confirmation) {
7399
+ progress("已收到微博发布成功回执");
7400
+ if (options.keepOpen) {
7401
+ await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
7402
+ }
7403
+ return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
6931
7404
  }
6932
7405
  if (typeof shareSubmit.isDisabled === "function" && (await shareSubmit.isDisabled().catch(() => false))) {
6933
7406
  throw new Error("微博分享发布弹窗中的“发布”按钮不可用");
@@ -7511,14 +7984,14 @@ async function launchBrowserContext(entry, browserChannel, options = {}) {
7511
7984
  const profileDir = browserProfileDir(entry);
7512
7985
  await fs.promises.mkdir(profileDir, { recursive: true, mode: 0o700 });
7513
7986
  const channel = browserChannel || process.env.QCPLAY_BROWSER_CHANNEL || (process.platform === "win32" ? "msedge" : "chrome");
7514
- if (options.awaitAIAction) {
7515
- return launchDetachedBrowserContext(profileDir, channel);
7516
- }
7517
7987
  const detachedContext = await reconnectDetachedBrowserContext(profileDir, chromium);
7518
7988
  if (detachedContext) {
7519
7989
  options.onProgress?.(`已复用当前 ${entry.platform} 授权编辑页,继续执行已确认的动作`);
7520
7990
  return detachedContext;
7521
7991
  }
7992
+ if (options.awaitAIAction) {
7993
+ return launchDetachedBrowserContext(profileDir, channel);
7994
+ }
7522
7995
  return chromium.launchPersistentContext(profileDir, {
7523
7996
  channel,
7524
7997
  headless: false,
@@ -7702,7 +8175,11 @@ export async function publishWithBrowser(entry, article, options = {}) {
7702
8175
  const ownsContext = !options.context;
7703
8176
  const context = options.context || (await launchBrowserContext(entry, options.browserChannel, options));
7704
8177
  const pages = context.pages();
7705
- const page = pages[0] || (await context.newPage());
8178
+ // A detached Edge session may briefly restore and then close its first tab.
8179
+ // Use a fresh page for AI-action flows so navigation keeps a stable target.
8180
+ let page = options.awaitAIAction && typeof context.newPage === "function"
8181
+ ? await context.newPage()
8182
+ : pages[0] || (await context.newPage());
7706
8183
  const promptUser = options.waitForUser || (message => waitForUser(message, options.streams));
7707
8184
  let haoyouEditorOpened = false;
7708
8185
  let preserveEditorOnFailure = false;
@@ -7722,13 +8199,29 @@ export async function publishWithBrowser(entry, article, options = {}) {
7722
8199
  if (entry.platformKey === "xiaohongshu") {
7723
8200
  return await publishXiaohongshu(page, entry, preparedArticle, spec, options, promptUser);
7724
8201
  }
7725
- const editorUrl = browserPlatformPageUrl(entry);
8202
+ let editorUrl = browserPlatformPageUrl(entry);
8203
+ let metaFallbackTried = false;
7726
8204
  let editorReady = false;
7727
8205
  let loginHandled = false;
7728
- const attempts = entry.platformKey === "haoyou" ? 3 : 2;
8206
+ const attempts = entry.platformKey === "haoyou" || entry.platformKey === "meta" ? 3 : 2;
7729
8207
  for (let attempt = 0; attempt < attempts; attempt += 1) {
8208
+ if (typeof page.isClosed === "function" && page.isClosed()) {
8209
+ const livePages = context.pages().filter(candidate => !(typeof candidate.isClosed === "function" && candidate.isClosed()));
8210
+ page = livePages.at(-1) || (await context.newPage());
8211
+ }
7730
8212
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
7731
8213
  await afterNavigation(page);
8214
+ if (entry.platformKey === "meta" && (await metaUnavailablePage(page))) {
8215
+ if (!metaFallbackTried) {
8216
+ metaFallbackTried = true;
8217
+ editorUrl = genericMetaComposerUrl(editorUrl);
8218
+ options.onProgress?.(`${entry.platform} 资产专用链接不可用,正在尝试 Meta 通用发布入口`);
8219
+ continue;
8220
+ }
8221
+ throw new Error(
8222
+ `${entry.platform} 当前账号无法访问配置中的 Meta 资产,请确认该账号已加入 business_id 对应的商务管理平台并拥有 asset_id 对应主页的内容发布权限`
8223
+ );
8224
+ }
7732
8225
  const readyTimeout = attempt === 0 && spec.loginPage ? 3000 : 15000;
7733
8226
  const ready = entry.platformKey === "haoyou"
7734
8227
  ? await waitForHaoyouTargetEditor(page, editorUrl, spec, readyTimeout)
@@ -7770,6 +8263,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
7770
8263
  throw new Error(`${entry.platform} 页面中未找到正文编辑器,页面结构可能已更新`);
7771
8264
  }
7772
8265
  let richContent = null;
8266
+ let platformMedia = null;
7773
8267
  if (entry.platformKey === "haoyou") {
7774
8268
  if (options.previewTextOnly) {
7775
8269
  await fillStableHaoyouValue(page, bodyInput, body, "正文");
@@ -7786,6 +8280,9 @@ export async function publishWithBrowser(entry, article, options = {}) {
7786
8280
  } else {
7787
8281
  await fillLocator(bodyInput, body);
7788
8282
  }
8283
+ if (entry.platformKey === "meta") {
8284
+ platformMedia = await uploadMetaImages(page, preparedArticle, options);
8285
+ }
7789
8286
 
7790
8287
  let submitButton = await waitForVisible(page, spec.submitSelectors, 5000);
7791
8288
  submitButton ||= await waitForVisibleButton(page, spec.submit, 5000);
@@ -7797,7 +8294,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
7797
8294
  }
7798
8295
  const review = await reviewPreparedDraft(page, entry, options);
7799
8296
  if (review) {
7800
- return { ...review, ...richContent };
8297
+ return { ...review, ...richContent, ...platformMedia };
7801
8298
  }
7802
8299
  if (options.draftOnly) {
7803
8300
  return {
@@ -7806,7 +8303,8 @@ export async function publishWithBrowser(entry, article, options = {}) {
7806
8303
  draftOnly: true,
7807
8304
  approvalRequired: true,
7808
8305
  approvalActions: ["publish", "cancel"],
7809
- ...richContent
8306
+ ...richContent,
8307
+ ...platformMedia
7810
8308
  };
7811
8309
  }
7812
8310
  if (entry.platformKey === "haoyou") {
@@ -7827,7 +8325,7 @@ export async function publishWithBrowser(entry, article, options = {}) {
7827
8325
  if (options.keepOpen) {
7828
8326
  await promptUser(`${entry.platform} 已提交,浏览器保持打开供检查`);
7829
8327
  }
7830
- return { url: page.url() };
8328
+ return { url: page.url(), ...platformMedia };
7831
8329
  } catch (error) {
7832
8330
  if ((entry.platformKey === "haoyou" && haoyouEditorOpened) || entry.platformKey === "xiaohongshu") {
7833
8331
  if (!ownsContext) throw 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.22",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "dependencies": {
41
+ "@larksuite/cli": "1.0.96",
41
42
  "cheerio": "^1.0.0-rc.12",
42
43
  "playwright-core": "1.40.1"
43
44
  }