@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 +13 -3
- package/lib/lark-article.js +2 -1
- package/lib/lark-cli-command.js +16 -0
- package/lib/platform-publish.js +576 -78
- package/package.json +2 -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);
|
|
@@ -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);
|
package/lib/lark-article.js
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/lib/platform-publish.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
resolve(
|
|
455
|
-
}
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
"
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
5773
|
-
if (
|
|
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
|
|
6049
|
+
let afterDeleteCount = currentCount;
|
|
5787
6050
|
do {
|
|
5788
|
-
|
|
5789
|
-
if (
|
|
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 (
|
|
6061
|
+
if (afterDeleteCount >= currentCount) {
|
|
5799
6062
|
throw new Error("微博头条文章图片库旧图片删除后未消失");
|
|
5800
6063
|
}
|
|
5801
|
-
cleared +=
|
|
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
|
|
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 =
|
|
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
|
|
5889
|
-
|
|
6166
|
+
if (candidateIndexes.length === 0) {
|
|
6167
|
+
throw new Error(`微博文章图片上传后未识别到本次上传的缩略图: ${source}`);
|
|
5890
6168
|
}
|
|
5891
|
-
if (
|
|
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
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
|
|
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
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6017
|
-
|
|
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
|
|
6030
|
-
.evaluate(element => String(element.innerText || element.textContent || "").trim())
|
|
6031
|
-
.catch(() => "");
|
|
6321
|
+
remaining = await readBodyState();
|
|
6032
6322
|
}
|
|
6033
|
-
if (
|
|
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:
|
|
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
|
-
|
|
7281
|
+
richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
|
|
6829
7282
|
onProgress: progress
|
|
6830
7283
|
});
|
|
6831
7284
|
progress("正文已写入,正在设置封面");
|
|
6832
|
-
|
|
6833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|