@qcplay/cli 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/qcplay.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  import {
40
40
  availableProjects,
41
41
  bilibiliPublishingPlan,
42
+ editPreparedPlatformPage,
42
43
  entriesForProject,
43
44
  loadPlatformEntries,
44
45
  openPublishPage,
@@ -49,6 +50,9 @@ import {
49
50
  runPlatformPublishSequence,
50
51
  selectRequestedEntries,
51
52
  tapTapPublishingPlan,
53
+ deleteDiscordMessages,
54
+ findDiscordMessageRecord,
55
+ removeDiscordMessageRecord,
52
56
  weiboPublishingPlan,
53
57
  writePlatformPublishUrls
54
58
  } from "../lib/platform-publish.js";
@@ -184,6 +188,7 @@ const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
184
188
  const CONTENT_RULES_FILE = DEFAULT_CONTENT_RULES_FILE;
185
189
  const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
186
190
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
191
+ const LARK_SKILL_NAMES = ["lark-shared", "lark-sheets", "lark-doc", "lark-wiki"];
187
192
  const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
188
193
  const ARTICLE_PREVIEW_URLS = {
189
194
  "33": "https://tideng.qingcigame.com/official/news.html",
@@ -247,6 +252,8 @@ Usage:
247
252
  qcplay-cli wechat article --game-id <id> [--game-id <id> ...] (--month <YYYY-MM> | --begin-date <YYYY-MM-DD> --end-date <YYYY-MM-DD>) [--output <file>] [--format markdown|json] [--local] [--backend <url>]
248
253
  qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
249
254
  qcplay-cli publish --project <项目> --list-platforms
255
+ qcplay-cli edit-page --project <项目> --region <区域> --platform <平台> --target <title|body> --find <原文> --replace <新文>
256
+ qcplay-cli discord delete <文章文件> --project <项目> --region <区域>
250
257
  qcplay-cli rules [init|validate|list] [--rules-file <json>]
251
258
  qcplay-cli www-article-list.store <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
252
259
  qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
@@ -268,6 +275,14 @@ function printUpdateHelp() {
268
275
  console.log(" qcplay-cli update");
269
276
  }
270
277
 
278
+ function printEditPageHelp() {
279
+ console.log(`Usage:
280
+ qcplay-cli edit-page --project <项目> --region <区域> --platform <平台> --target <title|body> --find <原文> --replace <新文>
281
+
282
+ Edits one exact text occurrence in a platform editor page that is already prepared and still open.
283
+ This command never saves a draft or publishes.`);
284
+ }
285
+
271
286
  function printAuthHelp() {
272
287
  console.log("Usage:");
273
288
  console.log(" qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]");
@@ -743,6 +758,25 @@ async function installSkills() {
743
758
  };
744
759
  }
745
760
 
761
+ async function detectLarkSkills() {
762
+ const installed = [];
763
+ const missing = [];
764
+ for (const name of LARK_SKILL_NAMES) {
765
+ const skillFile = path.join(SKILLS_DIR, name, "SKILL.md");
766
+ (await pathExists(skillFile) ? installed : missing).push(name);
767
+ }
768
+ return { installed, missing };
769
+ }
770
+
771
+ function printLarkSkillStatus(status) {
772
+ if (status.missing.length === 0) {
773
+ console.log(chalk.green("Feishu Skills detected: lark-shared, lark-sheets, lark-doc, lark-wiki"));
774
+ return;
775
+ }
776
+ console.log(chalk.yellow(`Feishu Skills incomplete; missing: ${status.missing.join(", ")}`));
777
+ console.log(chalk.gray("Install command: npx skills add larksuite/cli -g -y"));
778
+ }
779
+
746
780
  async function saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode = false) {
747
781
  await writeJson(CONFIG_FILE, {
748
782
  cli: "qcplay-cli",
@@ -1079,6 +1113,139 @@ function parseDistributionOptions(args) {
1079
1113
  return { file: positional[0], options };
1080
1114
  }
1081
1115
 
1116
+ function parseEditPageOptions(args) {
1117
+ const options = {
1118
+ project: "",
1119
+ region: "",
1120
+ platform: "",
1121
+ target: "body",
1122
+ find: "",
1123
+ replacement: "",
1124
+ configFile: "",
1125
+ browserChannel: ""
1126
+ };
1127
+ const valueFlags = new Map([
1128
+ ["--project", "project"],
1129
+ ["--region", "region"],
1130
+ ["--platform", "platform"],
1131
+ ["--target", "target"],
1132
+ ["--find", "find"],
1133
+ ["--replace", "replacement"],
1134
+ ["--config-file", "configFile"],
1135
+ ["--browser-channel", "browserChannel"]
1136
+ ]);
1137
+ for (let index = 0; index < args.length; index += 1) {
1138
+ const current = args[index];
1139
+ const key = valueFlags.get(current);
1140
+ if (!key) throw new Error(`未知参数: ${current}`);
1141
+ const value = args[index + 1];
1142
+ if (!value || value.startsWith("--")) throw new Error(`${current} 缺少参数值`);
1143
+ options[key] = value;
1144
+ index += 1;
1145
+ }
1146
+ for (const key of ["project", "region", "platform", "find", "replacement"]) {
1147
+ if (!String(options[key]).trim()) throw new Error(`edit-page 缺少 --${key === "replacement" ? "replace" : key}`);
1148
+ }
1149
+ if (!new Set(["title", "body"]).has(String(options.target).trim().toLowerCase())) {
1150
+ throw new Error("--target 只能是 title 或 body");
1151
+ }
1152
+ return options;
1153
+ }
1154
+
1155
+ /*
1156
+ function parseDiscordDeleteOptions(args) {
1157
+ const positional = [];
1158
+ const options = { project: "", region: "", platform: "Discord" };
1159
+ for (let index = 0; index < args.length; index += 1) {
1160
+ const current = args[index];
1161
+ if (current === "--project" || current === "--region" || current === "--platform") {
1162
+ const next = args[index + 1];
1163
+ if (!next || next.startsWith("--")) throw new Error(`${current} 缂哄皯鍙傛暟鍊糮);
1164
+ options[current.slice(2)] = next;
1165
+ index += 1;
1166
+ continue;
1167
+ }
1168
+ if (current.startsWith("--")) throw new Error(`鏈煡鍙傛暟: ${current}`);
1169
+ positional.push(current);
1170
+ }
1171
+ if (positional.length !== 1) throw new Error("discord delete 闇€瑕佹寚瀹氫竴涓枃绔犳枃浠?");
1172
+ if (!options.project || !options.region) throw new Error("discord delete 闇€瑕佹寚瀹氭爣椤圭洰鍜屽尯鍩?");
1173
+ return { file: positional[0], options };
1174
+ }
1175
+
1176
+ async function deleteDiscordCommand(file, options) {
1177
+ const parsedArticle = await parseArticleFile(file);
1178
+ const allEntries = await loadPlatformEntries({});
1179
+ const candidates = entriesForProject(allEntries, options.project, options.region);
1180
+ const selectedEntries = selectRequestedEntries(candidates, [options.platform]);
1181
+ const entry = selectedEntries.find(candidate => candidate.platformKey === "discord");
1182
+ if (!entry || entry.publisher !== "discord-webhook") {
1183
+ throw new Error(`${options.project} / ${options.region} 娌℃湁鍙敤鐨?Discord Webhook 閰嶇疆`);
1184
+ }
1185
+ const article = {
1186
+ title: parsedArticle.payload.article_title,
1187
+ markdown: parsedArticle.markdown,
1188
+ html: parsedArticle.payload.article_content,
1189
+ payload: parsedArticle.payload,
1190
+ meta: parsedArticle.meta
1191
+ };
1192
+ const record = await findDiscordMessageRecord(article, entry);
1193
+ if (!record) {
1194
+ throw new Error("娌℃湁鎵惧埌杩欑瘒鏂囩珷鐨?Discord 鍙戝竷璁板綍锛涚幇鏈夊彂甯冩棤娉曠敤 CLI 鑷姩瀹氫綅");
1195
+ }
1196
+ const result = await deleteDiscordMessages(entry, record.messageIds);
1197
+ await removeDiscordMessageRecord(article, entry);
1198
+ console.log(chalk.green(`Discord 消息已删除: ${result.deleted} 条`));
1199
+ }
1200
+
1201
+ }
1202
+ */
1203
+
1204
+ function parseDiscordDeleteOptions(args) {
1205
+ const positional = [];
1206
+ const options = { project: "", region: "", platform: "Discord" };
1207
+ for (let index = 0; index < args.length; index += 1) {
1208
+ const current = args[index];
1209
+ if (current === "--project" || current === "--region" || current === "--platform") {
1210
+ const next = args[index + 1];
1211
+ if (!next || next.startsWith("--")) throw new Error(`${current} requires a value`);
1212
+ options[current.slice(2)] = next;
1213
+ index += 1;
1214
+ continue;
1215
+ }
1216
+ if (current.startsWith("--")) throw new Error(`Unknown option: ${current}`);
1217
+ positional.push(current);
1218
+ }
1219
+ if (positional.length !== 1) throw new Error("discord delete requires exactly one article file");
1220
+ if (!options.project || !options.region) throw new Error("discord delete requires --project and --region");
1221
+ return { file: positional[0], options };
1222
+ }
1223
+
1224
+ async function deleteDiscordCommand(file, options) {
1225
+ const parsedArticle = await parseArticleFile(file);
1226
+ const allEntries = await loadPlatformEntries({});
1227
+ const candidates = entriesForProject(allEntries, options.project, options.region);
1228
+ const selectedEntries = selectRequestedEntries(candidates, [options.platform]);
1229
+ const entry = selectedEntries.find(candidate => candidate.platformKey === "discord");
1230
+ if (!entry || entry.publisher !== "discord-webhook") {
1231
+ throw new Error(`${options.project} / ${options.region} has no usable Discord Webhook configuration`);
1232
+ }
1233
+ const article = {
1234
+ title: parsedArticle.payload.article_title,
1235
+ markdown: parsedArticle.markdown,
1236
+ html: parsedArticle.payload.article_content,
1237
+ payload: parsedArticle.payload,
1238
+ meta: parsedArticle.meta
1239
+ };
1240
+ const record = await findDiscordMessageRecord(article, entry);
1241
+ if (!record) {
1242
+ throw new Error("No Discord publish record found for this article; older messages cannot be located automatically");
1243
+ }
1244
+ const result = await deleteDiscordMessages(entry, record.messageIds);
1245
+ await removeDiscordMessageRecord(article, entry);
1246
+ console.log(chalk.green(`Discord messages deleted: ${result.deleted}`));
1247
+ }
1248
+
1082
1249
  function applyPublishOverrides(parsedArticle, options) {
1083
1250
  const title = normalizeText(options.title);
1084
1251
  const cover = normalizeText(options.cover);
@@ -1206,6 +1373,10 @@ function getNpmCommand() {
1206
1373
  return process.platform === "win32" ? "npm.cmd" : "npm";
1207
1374
  }
1208
1375
 
1376
+ function getNpxCommand() {
1377
+ return process.platform === "win32" ? "npx.cmd" : "npx";
1378
+ }
1379
+
1209
1380
  function runCommand(command, args) {
1210
1381
  return new Promise((resolve, reject) => {
1211
1382
  const child = spawn(command, args, {
@@ -1224,6 +1395,11 @@ function runCommand(command, args) {
1224
1395
  });
1225
1396
  }
1226
1397
 
1398
+ async function installLarkCli() {
1399
+ console.log(chalk.cyan("Installing/updating Feishu CLI and skills..."));
1400
+ await runCommand(getNpxCommand(), ["@larksuite/cli@latest", "install"]);
1401
+ }
1402
+
1227
1403
  function delay(ms) {
1228
1404
  return new Promise(resolve => {
1229
1405
  setTimeout(resolve, ms);
@@ -1676,6 +1852,7 @@ async function installCommand(options) {
1676
1852
  console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1677
1853
 
1678
1854
  const skillsInstalled = await installSkills();
1855
+ await installLarkCli();
1679
1856
  if (skillsInstalled.available) {
1680
1857
  console.log(chalk.green("✔ Skills 已安装"));
1681
1858
  console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
@@ -1683,6 +1860,7 @@ async function installCommand(options) {
1683
1860
  console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
1684
1861
  }
1685
1862
 
1863
+ printLarkSkillStatus(await detectLarkSkills());
1686
1864
  await saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode);
1687
1865
  console.log(chalk.green("✔ 配置文件已生成"));
1688
1866
  console.log(chalk.gray(`认证后端: ${authBackendUrl}`));
@@ -1732,6 +1910,7 @@ async function updateCommand() {
1732
1910
  const latestPackage = readPackageMetadata();
1733
1911
  await updateStoredConfigVersion(latestPackage.version);
1734
1912
  const skillsInstalled = await installSkills();
1913
+ await installLarkCli();
1735
1914
 
1736
1915
  console.log("");
1737
1916
  console.log(chalk.green("CLI 更新完成"));
@@ -1741,6 +1920,7 @@ async function updateCommand() {
1741
1920
  console.log(chalk.gray(`版本: ${latestPackage.version}`));
1742
1921
  }
1743
1922
  printSkillsSyncResult(skillsInstalled);
1923
+ printLarkSkillStatus(await detectLarkSkills());
1744
1924
  console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则未覆盖"));
1745
1925
  console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1746
1926
  console.log("");
@@ -2253,7 +2433,10 @@ async function parseArticleFile(file) {
2253
2433
  }
2254
2434
 
2255
2435
  const raw = await fs.promises.readFile(articleFile, "utf8");
2256
- 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) {
2257
2440
  const { meta, richHtml } = parseRichArticleUploadFile(raw);
2258
2441
  return {
2259
2442
  articleFile,
@@ -2525,9 +2708,12 @@ async function publishPlatformsCommand(file, options) {
2525
2708
  parsedArticle = await parseArticleFile(file);
2526
2709
  parsedArticle = applyPublishOverrides(parsedArticle, options);
2527
2710
  }
2528
- const allEntries = await loadPlatformEntries({ configFile: options.configFile });
2529
-
2530
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
+ });
2531
2717
  if (!project) {
2532
2718
  if (options.listPlatforms) {
2533
2719
  printPlatformEntries(allEntries);
@@ -2753,6 +2939,29 @@ async function publishPlatformsCommand(file, options) {
2753
2939
  }
2754
2940
  }
2755
2941
 
2942
+ async function editPreparedPageCommand(options) {
2943
+ const allEntries = await loadPlatformEntries({ configFile: options.configFile });
2944
+ const candidates = entriesForProject(allEntries, options.project, options.region);
2945
+ const selected = selectRequestedEntries(candidates, [options.platform]);
2946
+ if (selected.length !== 1 || selected[0].publisher !== "browser") {
2947
+ throw new Error(`${options.platform} 没有可编辑的浏览器发布页面配置`);
2948
+ }
2949
+ const entry = selected[0];
2950
+ const result = await editPreparedPlatformPage(
2951
+ entry,
2952
+ { target: options.target, find: options.find, replacement: options.replacement },
2953
+ { browserChannel: options.browserChannel, onProgress: message => console.log(chalk.gray(` ${message}`)) }
2954
+ );
2955
+ console.log(
2956
+ JSON.stringify({
2957
+ event: "qcplay.page-edit-complete",
2958
+ platform: entry.platform,
2959
+ target: result.target,
2960
+ url: result.url
2961
+ })
2962
+ );
2963
+ }
2964
+
2756
2965
  async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
2757
2966
  if (!file) {
2758
2967
  throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
@@ -2959,6 +3168,32 @@ async function main() {
2959
3168
  return;
2960
3169
  }
2961
3170
 
3171
+ if (command === "edit-page") {
3172
+ if (subcommand === "-h" || subcommand === "--help") {
3173
+ printEditPageHelp();
3174
+ return;
3175
+ }
3176
+ const options = parseEditPageOptions([subcommand, ...rest].filter(Boolean));
3177
+ await runWithErrorBanner("编辑已准备页面失败", () => editPreparedPageCommand(options));
3178
+ return;
3179
+ }
3180
+
3181
+ if (command === "discord") {
3182
+ if (subcommand === "-h" || subcommand === "--help") {
3183
+ console.log("Usage:\n qcplay-cli discord delete <文章文件> --project <项目> --region <区域>");
3184
+ return;
3185
+ }
3186
+ if (subcommand !== "delete") {
3187
+ await runWithErrorBanner("Discord 操作失败", async () => {
3188
+ throw new Error(`未知 discord 命令: ${subcommand || ""}`);
3189
+ });
3190
+ return;
3191
+ }
3192
+ const parsed = parseDiscordDeleteOptions(rest);
3193
+ await runWithErrorBanner("Discord 删除失败", () => deleteDiscordCommand(parsed.file, parsed.options));
3194
+ return;
3195
+ }
3196
+
2962
3197
  if (command === "rules") {
2963
3198
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2964
3199
  printRulesHelp();
@@ -12,7 +12,60 @@ import { applyContentRules } from "./content-rules.js";
12
12
  import { stripWechatGuidanceHtml } from "./wechat-article.js";
13
13
 
14
14
  const PLATFORM_CONFIG_URL = "https://t4blw8ys5w.feishu.cn/wiki/OqH7wkx9PiBaTYkKeWNc35SonGf";
15
+ const DOMESTIC_REGION = "国内";
16
+ const DOMESTIC_PLATFORM_ROWS = [
17
+ {
18
+ 项目: "最强蜗牛",
19
+ 区域: DOMESTIC_REGION,
20
+ 平台: "TapTap",
21
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=187376&group_id=227162&show_activity=1"
22
+ },
23
+ {
24
+ 项目: "提灯与地下城",
25
+ 区域: DOMESTIC_REGION,
26
+ 平台: "TapTap",
27
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=149961&group_id=177149&show_activity=1"
28
+ },
29
+ {
30
+ 项目: "阿瑞斯病毒2",
31
+ 区域: DOMESTIC_REGION,
32
+ 平台: "TapTap",
33
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=topic&app_id=387841&group_id=663910&show_activity=1"
34
+ },
35
+ {
36
+ 项目: "迷途之光",
37
+ 区域: DOMESTIC_REGION,
38
+ 平台: "TapTap",
39
+ 页面URL链接: "https://www.taptap.cn/creator/edit?type=moment&app_id=600170&show_activity=1"
40
+ },
41
+ {
42
+ 项目: "最强蜗牛",
43
+ 区域: DOMESTIC_REGION,
44
+ 平台: "好游快爆",
45
+ 页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=12201&type=1"
46
+ },
47
+ {
48
+ 项目: "新仙剑",
49
+ 区域: DOMESTIC_REGION,
50
+ 平台: "好游快爆",
51
+ 页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=24008&type=1"
52
+ },
53
+ {
54
+ 项目: "阿瑞斯病毒2",
55
+ 区域: DOMESTIC_REGION,
56
+ 平台: "好游快爆",
57
+ 页面URL链接: "https://bbs.3839.com/index.php?m=api&c=thread&a=sendThreadShow&fid=20313&type=1"
58
+ }
59
+ ];
60
+ const DOMESTIC_STANDARD_PLATFORM_ROWS = ["最强蜗牛", "提灯与地下城", "阿瑞斯病毒2", "新仙剑", "迷途之光", "魔卡少女樱"].flatMap(
61
+ 项目 => [
62
+ { 项目, 区域: DOMESTIC_REGION, 平台: "B站", 页面URL链接: "https://member.bilibili.com/platform/upload/text/new-edit" },
63
+ { 项目, 区域: DOMESTIC_REGION, 平台: "微博", 页面URL链接: "https://card.weibo.com/article/v5/editor" },
64
+ { 项目, 区域: DOMESTIC_REGION, 平台: "小红书", 页面URL链接: "https://creator.xiaohongshu.com/publish/publish?from=menu&target=image" }
65
+ ]
66
+ );
15
67
  const DISCORD_MESSAGE_LIMIT = 2000;
68
+ const DISCORD_MESSAGE_STATE_FILE = path.join(os.homedir(), ".qcplay", "discord-messages.json");
16
69
  const BROWSER_SUBMISSION_COOLDOWN_MS = 60_000;
17
70
  const BROWSER_SUBMISSION_STATE_FILE = path.join(os.homedir(), ".qcplay", "browser-submissions.json");
18
71
  const ARES_PROJECT = "阿瑞斯病毒2";
@@ -458,6 +511,10 @@ function runJson(command, args) {
458
511
  });
459
512
  }
460
513
 
514
+ function domesticPlatformRows() {
515
+ return normalizePlatformRows([...DOMESTIC_PLATFORM_ROWS, ...DOMESTIC_STANDARD_PLATFORM_ROWS]);
516
+ }
517
+
461
518
  export async function loadPlatformEntries(options = {}) {
462
519
  if (options.configFile) {
463
520
  const configFile = path.resolve(process.cwd(), options.configFile);
@@ -472,6 +529,13 @@ export async function loadPlatformEntries(options = {}) {
472
529
  return normalizePlatformRows(rows);
473
530
  }
474
531
 
532
+ const domesticEntries = domesticPlatformRows();
533
+ const requestedRegion = normalizeKey(options.region || "");
534
+ const requestedProject = normalizeProjectKey(options.project || "");
535
+ if ((!requestedRegion || requestedRegion === normalizeKey(DOMESTIC_REGION)) && (!requestedProject || entriesForProject(domesticEntries, options.project).length > 0)) {
536
+ return domesticEntries;
537
+ }
538
+
475
539
  const workbook = await runJson("lark-cli", [
476
540
  "sheets",
477
541
  "+workbook-info",
@@ -663,7 +727,11 @@ function postJson(urlValue, payload) {
663
727
  reject(new Error(`Discord webhook 返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
664
728
  return;
665
729
  }
666
- resolve();
730
+ try {
731
+ resolve(responseBody ? JSON.parse(responseBody) : {});
732
+ } catch {
733
+ resolve({});
734
+ }
667
735
  });
668
736
  }
669
737
  );
@@ -672,6 +740,110 @@ function postJson(urlValue, payload) {
672
740
  });
673
741
  }
674
742
 
743
+ function deleteJson(urlValue) {
744
+ return new Promise((resolve, reject) => {
745
+ const url = new URL(urlValue);
746
+ const request = https.request(
747
+ url,
748
+ {
749
+ method: "DELETE",
750
+ headers: {
751
+ Accept: "application/json",
752
+ "User-Agent": "qcplay-cli"
753
+ }
754
+ },
755
+ response => {
756
+ const chunks = [];
757
+ response.on("data", chunk => chunks.push(chunk));
758
+ response.on("end", () => {
759
+ const responseBody = Buffer.concat(chunks).toString("utf8");
760
+ if ((response.statusCode || 0) < 200 || (response.statusCode || 0) >= 300) {
761
+ reject(new Error(`Discord webhook 删除消息返回 ${response.statusCode}: ${responseBody.slice(0, 160)}`));
762
+ return;
763
+ }
764
+ resolve();
765
+ });
766
+ }
767
+ );
768
+ request.once("error", reject);
769
+ request.end();
770
+ });
771
+ }
772
+
773
+ function discordMessageStateKey(entry, article) {
774
+ const sourceUrl = normalizeText(article.meta?.source_url || article.payload?.source_url);
775
+ return [entry.project, entry.region, entry.platformKey || "discord", sourceUrl || article.title].map(normalizeText).join("\u0000");
776
+ }
777
+
778
+ async function readDiscordMessageState() {
779
+ try {
780
+ const value = JSON.parse(await fs.promises.readFile(DISCORD_MESSAGE_STATE_FILE, "utf8"));
781
+ return Array.isArray(value?.records) ? value.records : [];
782
+ } catch {
783
+ return [];
784
+ }
785
+ }
786
+
787
+ async function writeDiscordMessageState(records) {
788
+ await fs.promises.mkdir(path.dirname(DISCORD_MESSAGE_STATE_FILE), { recursive: true, mode: 0o700 });
789
+ await fs.promises.writeFile(
790
+ DISCORD_MESSAGE_STATE_FILE,
791
+ `${JSON.stringify({ version: 1, records }, null, 2)}\n`,
792
+ { encoding: "utf8", mode: 0o600 }
793
+ );
794
+ await fs.promises.chmod(DISCORD_MESSAGE_STATE_FILE, 0o600).catch(() => {});
795
+ }
796
+
797
+ async function recordDiscordMessages(entry, article, messageIds) {
798
+ if (!messageIds.length) return;
799
+ const record = {
800
+ key: discordMessageStateKey(entry, article),
801
+ project: normalizeText(entry.project),
802
+ region: normalizeText(entry.region),
803
+ platform: normalizeText(entry.platform),
804
+ platformKey: entry.platformKey || "discord",
805
+ title: normalizeText(article.title),
806
+ sourceUrl: normalizeText(article.meta?.source_url || article.payload?.source_url),
807
+ messageIds,
808
+ createdAt: new Date().toISOString()
809
+ };
810
+ const records = (await readDiscordMessageState()).filter(item => item?.key !== record.key);
811
+ records.unshift(record);
812
+ await writeDiscordMessageState(records.slice(0, 100));
813
+ }
814
+
815
+ export async function deleteDiscordMessages(entry, messageIds, options = {}) {
816
+ const webhook = discordWebhookFromEntry(entry);
817
+ if (!webhook) {
818
+ throw new Error("Discord 配置缺少 webhook,无法删除消息");
819
+ }
820
+ const ids = [...new Set(messageIds.map(value => normalizeText(value)).filter(Boolean))];
821
+ if (!ids.length) {
822
+ throw new Error("没有可删除的 Discord 消息 ID");
823
+ }
824
+ const remove = options.deleteJson || (messageId => {
825
+ const url = new URL(webhook);
826
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}/messages/${encodeURIComponent(messageId)}`;
827
+ return deleteJson(url.toString());
828
+ });
829
+ for (const messageId of ids) {
830
+ await remove(messageId);
831
+ }
832
+ return { deleted: ids.length };
833
+ }
834
+
835
+ export async function findDiscordMessageRecord(article, entry) {
836
+ const key = discordMessageStateKey(entry, article);
837
+ const records = await readDiscordMessageState();
838
+ return records.find(item => item?.key === key) || null;
839
+ }
840
+
841
+ export async function removeDiscordMessageRecord(article, entry) {
842
+ const key = discordMessageStateKey(entry, article);
843
+ const records = (await readDiscordMessageState()).filter(item => item?.key !== key);
844
+ await writeDiscordMessageState(records);
845
+ }
846
+
675
847
  export async function publishDiscord(entry, article, options = {}) {
676
848
  const webhook = discordWebhookFromEntry(entry);
677
849
  if (!webhook) {
@@ -683,21 +855,56 @@ export async function publishDiscord(entry, article, options = {}) {
683
855
  : preparedArticle.markdown;
684
856
  const chunks = splitDiscordContent(preparedArticle.title, markdown);
685
857
  const send = options.postJson || postJson;
858
+ const messageIds = [];
686
859
  for (const chunk of chunks) {
687
- await send(webhook, { content: chunk, allowed_mentions: { parse: [] } });
860
+ const response = await send(`${webhook}${webhook.includes("?") ? "&" : "?"}wait=true`, {
861
+ content: chunk,
862
+ allowed_mentions: { parse: [] }
863
+ });
864
+ const messageId = normalizeText(response?.id);
865
+ if (messageId) messageIds.push(messageId);
866
+ }
867
+ let messageRecordSaved = false;
868
+ if (messageIds.length === chunks.length) {
869
+ try {
870
+ await recordDiscordMessages(entry, preparedArticle, messageIds);
871
+ messageRecordSaved = true;
872
+ } catch {
873
+ // A successful Discord post must not be reported as failed when local
874
+ // bookkeeping is unavailable; deletion can still be done manually.
875
+ }
688
876
  }
689
- return { messages: chunks.length };
877
+ return { messages: chunks.length, messageIds, messageRecordSaved };
690
878
  }
691
879
 
692
880
  function plainTextForPlatform(article, platformKey) {
693
881
  const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
882
+ // WeChat exports decorative empty spans containing <br> for layout. They
883
+ // must not become real line breaks in native editors; preserve only breaks
884
+ // that sit inside a node with actual text or media content.
885
+ $("br").each((_, element) => {
886
+ const parent = $(element).parent();
887
+ const hasMeaningfulSibling = parent
888
+ .contents()
889
+ .toArray()
890
+ .some(node => {
891
+ if (node === element) return false;
892
+ if (node.type === "text") return normalizeText(node.data).trim().length > 0;
893
+ if (node.type !== "tag") return false;
894
+ return $(node).is("img") || normalizeText($(node).text()).trim().length > 0;
895
+ });
896
+ if (!hasMeaningfulSibling) {
897
+ $(element).remove();
898
+ }
899
+ });
694
900
  $("br").replaceWith("\n");
695
901
  $("p,section,div,h1,h2,h3,h4,h5,h6,li,blockquote,tr").each((_, element) => {
696
902
  $(element).append("\n");
697
903
  });
698
904
  $("img").each((_, element) => {
699
905
  const image = $(element);
700
- image.replaceWith(image.attr("src") ? `\n${image.attr("src")}\n` : "");
906
+ const source = image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original");
907
+ image.replaceWith(source ? `\n${source}\n` : "");
701
908
  });
702
909
  const markdown = normalizeText($.root().text() || article.markdown)
703
910
  .replace(/[ \t]+\n/g, "\n")
@@ -1619,6 +1826,8 @@ export function resolveWeiboSuperTopic(article, entry = {}) {
1619
1826
  }
1620
1827
 
1621
1828
  export function resolveWeiboShareCopy(article, entry = {}) {
1829
+ const configured = weiboMeta(article, "weibo_share_copy", "weibo_share_text");
1830
+ if (configured) return configured;
1622
1831
  const title = resolveWeiboArticleTitle(article);
1623
1832
  const project = normalizeText(entry.project);
1624
1833
  if (project === "最强蜗牛") {
@@ -1913,8 +2122,13 @@ export function browserPlatformPageUrl(entry, type = "topic") {
1913
2122
 
1914
2123
  function imageSourcesFromArticle(article) {
1915
2124
  const $ = cheerio.load(stripWechatPlatformGuidance(article), null, false);
1916
- return $("img[src]")
1917
- .map((_, element) => normalizeText($(element).attr("src")))
2125
+ return $("img")
2126
+ .map((_, element) => {
2127
+ const image = $(element);
2128
+ return normalizeText(
2129
+ image.attr("src") || image.attr("data-qcplay-src") || image.attr("data-src") || image.attr("data-original")
2130
+ );
2131
+ })
1918
2132
  .get()
1919
2133
  .filter(Boolean);
1920
2134
  }
@@ -2377,7 +2591,12 @@ function buildPlatformRichContent(article, options = {}) {
2377
2591
  });
2378
2592
  }
2379
2593
  for (const element of tableImages) {
2380
- const source = normalizeText($(element).attr("src") || $(element).attr("data-src"));
2594
+ const source = normalizeText(
2595
+ $(element).attr("src") ||
2596
+ $(element).attr("data-qcplay-src") ||
2597
+ $(element).attr("data-src") ||
2598
+ $(element).attr("data-original")
2599
+ );
2381
2600
  if (!source) continue;
2382
2601
  const caption = normalizeText($(element).closest("figure").find("figcaption").first().text()) ||
2383
2602
  normalizeText($(element).attr("alt") || $(element).attr("title")).slice(0, 50);
@@ -2407,7 +2626,12 @@ function buildPlatformRichContent(article, options = {}) {
2407
2626
  }
2408
2627
  if (tag === "img") {
2409
2628
  flushHtml();
2410
- const source = normalizeText(element.attr("src") || element.attr("data-src"));
2629
+ const source = normalizeText(
2630
+ element.attr("src") ||
2631
+ element.attr("data-qcplay-src") ||
2632
+ element.attr("data-src") ||
2633
+ element.attr("data-original")
2634
+ );
2411
2635
  if (source) {
2412
2636
  const caption = normalizeText(element.closest("figure").find("figcaption").first().text()) ||
2413
2637
  normalizeText(element.attr("alt") || element.attr("title")).slice(0, 50);
@@ -2599,11 +2823,74 @@ function normalizeNumberedSectionHeadings(items) {
2599
2823
  }
2600
2824
 
2601
2825
  export function buildBilibiliRichContent(article) {
2602
- return buildPlatformRichContent(article, {
2826
+ const content = buildPlatformRichContent(article, {
2603
2827
  preserveBackground: true,
2604
2828
  preserveBlockStyles: true,
2605
2829
  structuredTables: true
2606
2830
  });
2831
+ // Bilibili can drop the first paragraph's CSS alignment when it follows an
2832
+ // inline image node. Keep an explicit align attribute on that first text
2833
+ // block so the source formatting survives the editor's initial normalization.
2834
+ const firstTextIndex = content.items.findIndex(item => item.type === "html" && item.plain);
2835
+ if (firstTextIndex < 0) return content;
2836
+ const firstText = content.items[firstTextIndex];
2837
+ const alignment = normalizeText(firstText.align).toLowerCase();
2838
+ if (!alignment) return content;
2839
+ const $ = cheerio.load(firstText.html, null, false);
2840
+ const root = $.root().children().first();
2841
+ if (!root.length) return content;
2842
+ root.attr("align", alignment);
2843
+ return {
2844
+ ...content,
2845
+ items: content.items.map((item, index) => (index === firstTextIndex ? { ...item, html: $.html(root) } : item))
2846
+ };
2847
+ }
2848
+
2849
+ async function enforceBilibiliFirstTextAlignment(bodyInput, content) {
2850
+ const firstText = content.items.find(item => item.type === "html" && item.plain);
2851
+ const alignment = normalizeText(firstText?.align).toLowerCase();
2852
+ const expectedText = normalizeText(firstText?.plain);
2853
+ if (!alignment || !expectedText || typeof bodyInput.evaluate !== "function") return false;
2854
+ return bodyInput
2855
+ .evaluate(
2856
+ (element, payload) => {
2857
+ const normalize = value => String(value || "").replace(/\s+/g, " ").trim();
2858
+ const blocks = [...element.querySelectorAll("p,h1,h2,h3,h4,h5,h6,div")].filter(block => {
2859
+ const text = normalize(block.textContent);
2860
+ return text === payload.text || text.includes(payload.text) || payload.text.includes(text);
2861
+ });
2862
+ // Prefer the smallest matching block. The outer editor container often
2863
+ // contains the same text, but formatting it does not update the actual
2864
+ // paragraph node stored by Bilibili's rich-text model.
2865
+ blocks.sort((left, right) => normalize(left.textContent).length - normalize(right.textContent).length);
2866
+ const target = blocks[0];
2867
+ if (!target) return false;
2868
+ const range = document.createRange();
2869
+ range.selectNodeContents(target);
2870
+ const selection = window.getSelection();
2871
+ selection.removeAllRanges();
2872
+ selection.addRange(range);
2873
+ try {
2874
+ document.execCommand("justifyCenter", false, null);
2875
+ } catch {
2876
+ // Some Chromium builds disable execCommand; the explicit style below
2877
+ // still provides a deterministic fallback for the editor DOM.
2878
+ }
2879
+ target.style.textAlign = payload.alignment;
2880
+ target.setAttribute("align", payload.alignment);
2881
+ target.setAttribute("data-align", payload.alignment);
2882
+ target.dispatchEvent(
2883
+ new InputEvent("input", {
2884
+ bubbles: true,
2885
+ inputType: "formatBlock",
2886
+ data: null
2887
+ })
2888
+ );
2889
+ return getComputedStyle(target).textAlign === payload.alignment;
2890
+ },
2891
+ { text: expectedText, alignment }
2892
+ )
2893
+ .catch(() => false);
2607
2894
  }
2608
2895
 
2609
2896
  export function buildHaoyouRichContent(article) {
@@ -3135,7 +3422,7 @@ async function removeBilibiliFailedImage(bodyInput) {
3135
3422
  });
3136
3423
  }
3137
3424
 
3138
- async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
3425
+ export async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source, options = {}) {
3139
3426
  const deadline = Date.now() + 120000;
3140
3427
  const startedAt = Date.now();
3141
3428
  let stableChecks = 0;
@@ -3144,7 +3431,6 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
3144
3431
  const failed = await bodyInput
3145
3432
  .locator('.upload-fail, .image-upload-error, [class*="upload-error"], [class*="retry"]')
3146
3433
  .count();
3147
- const pending = await pendingUploadCount(bodyInput);
3148
3434
  if (failed > 0) {
3149
3435
  const failureText = await bodyInput.evaluate(element => {
3150
3436
  const nodes = [...element.querySelectorAll(
@@ -3162,7 +3448,10 @@ async function waitForBilibiliInlineImage(page, bodyInput, previousCount, source
3162
3448
  }
3163
3449
  throw new Error(`B站正文图片上传失败: ${source}`);
3164
3450
  }
3165
- if (count > previousCount && pending === 0 && Date.now() - startedAt >= 1200) {
3451
+ // The B站 editor may keep a visible toolbar spinner after the image node
3452
+ // is inserted. Treat the new, stable image node as the upload receipt;
3453
+ // failure selectors above still catch explicit upload errors.
3454
+ if (count > previousCount && Date.now() - startedAt >= 1200) {
3166
3455
  stableChecks += 1;
3167
3456
  if (stableChecks >= 3) {
3168
3457
  return;
@@ -3208,6 +3497,7 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3208
3497
  plain: plainParts.join("\n")
3209
3498
  });
3210
3499
  await page.waitForTimeout(100);
3500
+ await enforceBilibiliFirstTextAlignment(bodyInput, content);
3211
3501
  options.onProgress?.("B站 文字正文已写入,正在回读校验");
3212
3502
  options.onProgress?.(`B站 已写入 ${content.items.length} 个正文区块,准备处理 ${imageMarkers.length} 张正文图片`);
3213
3503
  }
@@ -3271,6 +3561,9 @@ export async function insertBilibiliRichContent(page, bodyInput, article, spec,
3271
3561
  }
3272
3562
  }
3273
3563
 
3564
+ // Media insertion may rerender the first paragraph and drop its alignment.
3565
+ // Reapply it after all images are settled, immediately before final reading.
3566
+ await enforceBilibiliFirstTextAlignment(bodyInput, content);
3274
3567
  options.onProgress?.("B站 正文媒体已处理,正在最终回读校验");
3275
3568
  let state = null;
3276
3569
  let previousTextLength = -1;
@@ -3844,6 +4137,85 @@ async function fillLocator(locator, value) {
3844
4137
  await locator.fill(value);
3845
4138
  }
3846
4139
 
4140
+ export function replaceFirstTextValue(value, find, replacement) {
4141
+ const source = String(value ?? "");
4142
+ const needle = String(find ?? "");
4143
+ const next = String(replacement ?? "");
4144
+ if (!needle) {
4145
+ throw new Error("待替换的原文不能为空");
4146
+ }
4147
+ const index = source.indexOf(needle);
4148
+ if (index < 0) {
4149
+ throw new Error("当前编辑页中未找到指定原文");
4150
+ }
4151
+ return `${source.slice(0, index)}${next}${source.slice(index + needle.length)}`;
4152
+ }
4153
+
4154
+ async function replaceTextInEditable(locator, find, replacement) {
4155
+ const before = String(
4156
+ typeof locator.inputValue === "function"
4157
+ ? await locator.inputValue().catch(() => "")
4158
+ : await locator.innerText().catch(() => "")
4159
+ );
4160
+ replaceFirstTextValue(before, find, replacement);
4161
+ const result = await locator.evaluate(
4162
+ (element, values) => {
4163
+ const source = "value" in element ? String(element.value ?? "") : String(element.innerText ?? element.textContent ?? "");
4164
+ const index = source.indexOf(values.find);
4165
+ if (index < 0) return { changed: false, reason: "not-found" };
4166
+
4167
+ if ("value" in element) {
4168
+ const prototype = element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
4169
+ const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
4170
+ const next = `${source.slice(0, index)}${values.replacement}${source.slice(index + values.find.length)}`;
4171
+ if (setter) {
4172
+ setter.call(element, next);
4173
+ } else {
4174
+ element.value = next;
4175
+ }
4176
+ element.dispatchEvent(new Event("input", { bubbles: true }));
4177
+ element.dispatchEvent(new Event("change", { bubbles: true }));
4178
+ return { changed: true, value: next };
4179
+ }
4180
+
4181
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
4182
+ let node;
4183
+ while ((node = walker.nextNode())) {
4184
+ const nodeIndex = node.data.indexOf(values.find);
4185
+ if (nodeIndex < 0) continue;
4186
+ const range = document.createRange();
4187
+ range.setStart(node, nodeIndex);
4188
+ range.setEnd(node, nodeIndex + values.find.length);
4189
+ range.deleteContents();
4190
+ const fragment = document.createDocumentFragment();
4191
+ const lines = values.replacement.replace(/\r\n?/g, "\n").split("\n");
4192
+ lines.forEach((line, lineIndex) => {
4193
+ if (lineIndex > 0) fragment.append(document.createElement("br"));
4194
+ if (line) fragment.append(document.createTextNode(line));
4195
+ });
4196
+ range.insertNode(fragment);
4197
+ element.dispatchEvent(new Event("input", { bubbles: true }));
4198
+ return { changed: true, value: String(element.innerText ?? element.textContent ?? "") };
4199
+ }
4200
+ return { changed: false, reason: "cross-node" };
4201
+ },
4202
+ { find, replacement }
4203
+ );
4204
+ if (!result?.changed) {
4205
+ const detail = result?.reason === "cross-node" ? "原文跨越多个富文本节点,无法在不破坏排版的情况下替换" : "当前编辑页中未找到指定原文";
4206
+ throw new Error(detail);
4207
+ }
4208
+ const actual = String(
4209
+ typeof locator.inputValue === "function"
4210
+ ? await locator.inputValue().catch(() => "")
4211
+ : await locator.innerText().catch(() => "")
4212
+ );
4213
+ if (!actual.includes(replacement)) {
4214
+ throw new Error("编辑器未回读到修改后的文本");
4215
+ }
4216
+ return actual;
4217
+ }
4218
+
3847
4219
  async function verifyFilledValue(locator, expected, label, platform = "TapTap") {
3848
4220
  let actual = "";
3849
4221
  if (typeof locator.inputValue === "function") {
@@ -4052,6 +4424,60 @@ async function editorIsReady(page, spec) {
4052
4424
  return Boolean(await firstVisible(page, spec.body));
4053
4425
  }
4054
4426
 
4427
+ async function findPreparedEditorPage(context, spec) {
4428
+ const deadline = Date.now() + 10000;
4429
+ do {
4430
+ const pages = context.pages().filter(page => !(typeof page.isClosed === "function" && page.isClosed()));
4431
+ for (const page of [...pages].reverse()) {
4432
+ if (await editorIsReady(page, spec)) return page;
4433
+ }
4434
+ const page = pages.at(-1);
4435
+ if (page && typeof page.waitForTimeout === "function") {
4436
+ await page.waitForTimeout(200);
4437
+ } else {
4438
+ await new Promise(resolve => setTimeout(resolve, 200));
4439
+ }
4440
+ } while (Date.now() < deadline);
4441
+ return null;
4442
+ }
4443
+
4444
+ export async function editPreparedPlatformPage(entry, revision, options = {}) {
4445
+ const spec = BROWSER_PLATFORM_SPECS[entry.platformKey];
4446
+ if (!spec || entry.publisher !== "browser") {
4447
+ throw new Error(`${entry.platform} 不支持编辑已准备页面`);
4448
+ }
4449
+ const target = normalizeKey(revision?.target || "body");
4450
+ if (!new Set(["title", "body"]).has(target)) {
4451
+ throw new Error("编辑位置只能是 title 或 body");
4452
+ }
4453
+ const find = String(revision?.find ?? "");
4454
+ const replacement = String(revision?.replacement ?? "");
4455
+ if (!find || !replacement) {
4456
+ throw new Error("编辑需要同时提供原文和新文");
4457
+ }
4458
+
4459
+ const { chromium } = await import("playwright-core");
4460
+ const context = await reconnectDetachedBrowserContext(browserProfileDir(entry), chromium);
4461
+ if (!context) {
4462
+ throw new Error(`${entry.platform} 没有可继续编辑的已准备页面,请先重新准备该平台的发布页`);
4463
+ }
4464
+ try {
4465
+ const page = await findPreparedEditorPage(context, spec);
4466
+ if (!page) {
4467
+ throw new Error(`${entry.platform} 已准备页面中未找到标题和正文编辑器`);
4468
+ }
4469
+ const field = await waitForVisible(page, target === "title" ? spec.title : spec.body, 3000);
4470
+ if (!field) {
4471
+ throw new Error(`${entry.platform} 页面中未找到${target === "title" ? "标题" : "正文"}编辑器`);
4472
+ }
4473
+ const value = await replaceTextInEditable(field, find, replacement);
4474
+ options.onProgress?.(`${entry.platform}${target === "title" ? "标题" : "正文"}已修改并回读确认`);
4475
+ return { url: page.url(), target, value };
4476
+ } finally {
4477
+ await closeBrowserContext(context);
4478
+ }
4479
+ }
4480
+
4055
4481
  async function openPlatformLogin(page, entry, spec) {
4056
4482
  if (spec.loginPage) {
4057
4483
  await page.goto(spec.loginPage, { waitUntil: "domcontentloaded", timeout: 60000 });
@@ -4084,6 +4510,24 @@ async function openPlatformLogin(page, entry, spec) {
4084
4510
  return false;
4085
4511
  }
4086
4512
 
4513
+ export function browserSessionLoginPrompt(platform) {
4514
+ return `${platform} 尚未登录。请仅在 CLI 刚打开的自动化浏览器窗口中完成登录(扫码或账号密码均可),不要改用其他浏览器;完成后保持该窗口打开并回复“已登录”。`;
4515
+ }
4516
+
4517
+ async function waitForExisting(page, selectors, timeoutMs = 15000) {
4518
+ const deadline = Date.now() + timeoutMs;
4519
+ do {
4520
+ const locator = await firstExisting(page, selectors);
4521
+ if (locator) return locator;
4522
+ await page.waitForTimeout(200);
4523
+ } while (Date.now() < deadline);
4524
+ return null;
4525
+ }
4526
+
4527
+ function browserSessionLoginFailure(platform) {
4528
+ return `${platform} 登录完成后仍未进入发布编辑器。请确认登录发生在 CLI 刚打开的自动化浏览器窗口中,并检查账号是否拥有发布权限`;
4529
+ }
4530
+
4087
4531
  async function ensureBrowserLogin(page, entry, spec, promptUser) {
4088
4532
  const alreadyLoggedIn = await openPlatformLogin(page, entry, spec);
4089
4533
  if (alreadyLoggedIn) {
@@ -4091,7 +4535,7 @@ async function ensureBrowserLogin(page, entry, spec, promptUser) {
4091
4535
  }
4092
4536
 
4093
4537
  if (spec.manualLogin) {
4094
- await promptUser(`${entry.platform} 尚未登录,请在浏览器中手动完成登录(可自行填写账号密码或扫码)`);
4538
+ await promptUser(browserSessionLoginPrompt(entry.platform));
4095
4539
  return true;
4096
4540
  }
4097
4541
 
@@ -4705,7 +5149,7 @@ async function publishTapTap(page, entry, article, spec, options, promptUser) {
4705
5149
  await afterNavigation(page);
4706
5150
  }
4707
5151
  if (!(await waitForEditorReady(page, () => tapTapCreatorReady(page, spec), 15000))) {
4708
- throw new Error("TapTap 登录完成后仍未进入创作者发布页,请确认账号拥有发布权限");
5152
+ throw new Error(browserSessionLoginFailure("TapTap"));
4709
5153
  }
4710
5154
  const resumedDraft = await resumeMatchingTapTapDraft(page, spec, preparedArticle.title, editorUrl);
4711
5155
  if (options.publishDraft) {
@@ -5212,7 +5656,7 @@ async function publishBilibili(page, entry, article, spec, options, promptUser)
5212
5656
  await afterNavigation(page);
5213
5657
  }
5214
5658
  if (!(await waitForEditorReady(page, () => bilibiliEditorIsReady(page, spec, settings), 15000))) {
5215
- throw new Error(`B站登录完成后仍未进入${settings.typeLabel}投稿页,请确认账号拥有投稿权限`);
5659
+ throw new Error(browserSessionLoginFailure("B站"));
5216
5660
  }
5217
5661
 
5218
5662
  let titleInput;
@@ -5356,7 +5800,10 @@ async function waitForWeiboArticleInlineImage(bodyInput, previousCount, source)
5356
5800
  if (failed > 0) {
5357
5801
  throw new Error(`微博文章正文图片上传失败: ${source}`);
5358
5802
  }
5359
- if (count > previousCount && pending === 0) {
5803
+ if (count > previousCount + 1) {
5804
+ throw new Error(`微博正文图片疑似重复插入: ${source}`);
5805
+ }
5806
+ if (count === previousCount + 1 && pending === 0) {
5360
5807
  stableChecks += 1;
5361
5808
  if (stableChecks >= 3) {
5362
5809
  return;
@@ -5473,10 +5920,24 @@ async function visibleWeiboCropDialog(page) {
5473
5920
 
5474
5921
  async function clearExistingWeiboArticleImages(page, scope) {
5475
5922
  const items = scope.locator(".image-list .image-item");
5923
+ // The gallery list is populated asynchronously after the dialog opens. Give
5924
+ // existing items a short window to appear before treating the library as empty.
5925
+ let previousCount = await items.count().catch(() => 0);
5926
+ if (previousCount === 0) {
5927
+ const deadline = Date.now() + 5000;
5928
+ do {
5929
+ await page.waitForTimeout(200);
5930
+ previousCount = await items.count().catch(() => 0);
5931
+ if (previousCount > 0) break;
5932
+ } while (Date.now() < deadline);
5933
+ }
5934
+ if (previousCount === 0) {
5935
+ return 0;
5936
+ }
5476
5937
  let cleared = 0;
5477
5938
  while (cleared < 200) {
5478
- const previousCount = await items.count().catch(() => 0);
5479
- if (previousCount === 0) {
5939
+ const currentCount = await items.count().catch(() => 0);
5940
+ if (currentCount === 0) {
5480
5941
  return cleared;
5481
5942
  }
5482
5943
  const item = items.first();
@@ -5489,10 +5950,10 @@ async function clearExistingWeiboArticleImages(page, scope) {
5489
5950
  }
5490
5951
  await remove.click();
5491
5952
  const deadline = Date.now() + 10000;
5492
- let currentCount = previousCount;
5953
+ let afterDeleteCount = currentCount;
5493
5954
  do {
5494
- currentCount = await items.count().catch(() => previousCount);
5495
- if (currentCount < previousCount) {
5955
+ afterDeleteCount = await items.count().catch(() => currentCount);
5956
+ if (afterDeleteCount < currentCount) {
5496
5957
  break;
5497
5958
  }
5498
5959
  const text = typeof scope.innerText === "function" ? await scope.innerText().catch(() => "") : "";
@@ -5501,14 +5962,26 @@ async function clearExistingWeiboArticleImages(page, scope) {
5501
5962
  }
5502
5963
  await page.waitForTimeout(200);
5503
5964
  } while (Date.now() < deadline);
5504
- if (currentCount >= previousCount) {
5965
+ if (afterDeleteCount >= currentCount) {
5505
5966
  throw new Error("微博头条文章图片库旧图片删除后未消失");
5506
5967
  }
5507
- cleared += previousCount - currentCount;
5968
+ cleared += currentCount - afterDeleteCount;
5508
5969
  }
5509
5970
  throw new Error("微博头条文章图片库旧图片超过 200 张,已停止清理");
5510
5971
  }
5511
5972
 
5973
+ async function waitForWeiboArticleImageList(page, scope, timeoutMs = 10000) {
5974
+ const list = scope.locator(".image-list");
5975
+ const deadline = Date.now() + timeoutMs;
5976
+ do {
5977
+ if ((await list.count().catch(() => 0)) > 0) {
5978
+ return true;
5979
+ }
5980
+ await page.waitForTimeout(200);
5981
+ } while (Date.now() < deadline);
5982
+ return false;
5983
+ }
5984
+
5512
5985
  async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, source, options = {}) {
5513
5986
  options.onProgress?.("正在打开图片库");
5514
5987
  const imageButton = await weiboArticleImageButton(page);
@@ -5516,7 +5989,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5516
5989
  throw new Error("微博头条文章编辑器中未找到图片库按钮");
5517
5990
  }
5518
5991
  await imageButton.click();
5519
- const input = await firstExisting(page, spec.imageInputs);
5992
+ const input = await waitForExisting(page, spec.imageInputs, 15000);
5520
5993
  if (!input || typeof input.setInputFiles !== "function") {
5521
5994
  throw new Error("微博头条文章图片库中未找到上传控件");
5522
5995
  }
@@ -5524,7 +5997,7 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5524
5997
  'xpath=ancestor::*[.//*[normalize-space(text())="图片库"] and .//*[normalize-space(text())="插入"]][1]'
5525
5998
  );
5526
5999
  const scope = (await modal.count().catch(() => 0)) > 0 ? modal : page;
5527
- const structuredLibrary = (await scope.locator(".image-list").count().catch(() => 0)) > 0;
6000
+ const structuredLibrary = await waitForWeiboArticleImageList(page, scope, 10000);
5528
6001
  if (structuredLibrary && options.clearExistingLibrary) {
5529
6002
  options.onProgress?.("正在清理图片库旧图");
5530
6003
  await clearExistingWeiboArticleImages(page, scope);
@@ -5545,6 +6018,9 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5545
6018
  .catch(() => [])
5546
6019
  : [];
5547
6020
  const previousImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count();
6021
+ if (options.clearExistingLibrary && (await previews.count().catch(() => 0)) !== 0) {
6022
+ throw new Error(`微博图片库旧图未清空,已停止上传: ${source}`);
6023
+ }
5548
6024
  options.onProgress?.("正在上传正文图片");
5549
6025
  await input.setInputFiles([payload]);
5550
6026
 
@@ -5591,10 +6067,12 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5591
6067
  if (candidateIndexes.length === 0 && previewCount > previousPreviews) {
5592
6068
  candidateIndexes = Array.from({ length: previewCount - previousPreviews }, (_, offset) => previousPreviews + offset);
5593
6069
  }
5594
- if (candidateIndexes.length === 0 && previewCount > 0) {
5595
- candidateIndexes = [previewCount - 1];
6070
+ if (candidateIndexes.length === 0) {
6071
+ throw new Error(`微博文章图片上传后未识别到本次上传的缩略图: ${source}`);
5596
6072
  }
5597
- if (structuredLibrary && candidateIndexes.length > 0) {
6073
+ if (options.clearExistingLibrary && previewCount === 1) {
6074
+ uploadedPreview = previews.first();
6075
+ } else if (structuredLibrary && candidateIndexes.length > 0) {
5598
6076
  // Let the common click-and-verify path below perform the selection once.
5599
6077
  uploadedPreview = previews.nth(candidateIndexes[0]);
5600
6078
  }
@@ -5656,12 +6134,19 @@ async function uploadWeiboArticleInlineImage(page, bodyInput, spec, payload, sou
5656
6134
  await insertButton.click();
5657
6135
  await page.waitForTimeout(300);
5658
6136
  if (scope !== page && typeof scope.isVisible === "function" && (await scope.isVisible().catch(() => false))) {
5659
- const retryInsert = typeof scope.getByRole === "function"
5660
- ? scope.getByRole("button", { name: "插入", exact: true }).last()
5661
- : insertButton;
5662
- if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
5663
- await retryInsert.click({ force: true });
5664
- await page.waitForTimeout(300);
6137
+ // A successful click normally closes the library or opens the crop dialog.
6138
+ // Only retry when neither happened and the editor image count is unchanged;
6139
+ // an unconditional second click can insert the same image twice.
6140
+ const cropStarted = await visibleWeiboCropDialog(page);
6141
+ const currentImages = await bodyInput.locator("img:not(.ProseMirror-separator)").count().catch(() => previousImages);
6142
+ if (!cropStarted && currentImages === previousImages) {
6143
+ const retryInsert = typeof scope.getByRole === "function"
6144
+ ? scope.getByRole("button", { name: "插入", exact: true }).last()
6145
+ : insertButton;
6146
+ if ((await retryInsert.count().catch(() => 0)) > 0 && !(await weiboControlDisabled(retryInsert))) {
6147
+ await retryInsert.click({ force: true });
6148
+ await page.waitForTimeout(300);
6149
+ }
5665
6150
  }
5666
6151
  }
5667
6152
 
@@ -5717,10 +6202,15 @@ async function clearWeiboArticleBody(page, bodyInput) {
5717
6202
  await page.waitForTimeout(100);
5718
6203
  }
5719
6204
 
5720
- let remaining = await bodyInput
5721
- .evaluate(element => String(element.innerText || element.textContent || "").trim())
5722
- .catch(() => "");
5723
- if (typeof remaining === "string" && remaining) {
6205
+ const readBodyState = async () =>
6206
+ bodyInput
6207
+ .evaluate(element => ({
6208
+ text: String(element.innerText || element.textContent || "").trim(),
6209
+ images: element.querySelectorAll("img:not(.ProseMirror-separator)").length
6210
+ }))
6211
+ .catch(() => ({ text: "", images: 0 }));
6212
+ let remaining = await readBodyState();
6213
+ if (remaining.text || remaining.images > 0) {
5724
6214
  await bodyInput.evaluate(element => {
5725
6215
  element.replaceChildren(document.createElement("p"));
5726
6216
  element.dispatchEvent(
@@ -5732,11 +6222,9 @@ async function clearWeiboArticleBody(page, bodyInput) {
5732
6222
  );
5733
6223
  });
5734
6224
  await page.waitForTimeout(100);
5735
- remaining = await bodyInput
5736
- .evaluate(element => String(element.innerText || element.textContent || "").trim())
5737
- .catch(() => "");
6225
+ remaining = await readBodyState();
5738
6226
  }
5739
- if (typeof remaining === "string" && remaining) {
6227
+ if (remaining.text || remaining.images > 0) {
5740
6228
  throw new Error("微博文章正文未能清空,已停止发布以避免复用旧草稿内容");
5741
6229
  }
5742
6230
  }
@@ -5954,7 +6442,7 @@ export async function insertWeiboArticleRichContent(page, bodyInput, article, sp
5954
6442
  payloadCache.set(item.source, payload);
5955
6443
  }
5956
6444
  await uploadWeiboArticleInlineImage(page, activeBodyInput, spec, payload, item.source, {
5957
- clearExistingLibrary: imageIndex === 0,
6445
+ clearExistingLibrary: true,
5958
6446
  onProgress: options.onProgress
5959
6447
  });
5960
6448
  }
@@ -6492,6 +6980,43 @@ async function findWeiboShareSubmitButton(page, dialog) {
6492
6980
  return findIn(page);
6493
6981
  }
6494
6982
 
6983
+ async function preparedWeiboArticleState(page, spec, article, title) {
6984
+ if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 5000))) return null;
6985
+ const titleInput = await waitForVisible(page, spec.title, 3000);
6986
+ const bodyInput = await waitForVisible(page, spec.body, 3000);
6987
+ if (!titleInput || !bodyInput) return null;
6988
+ if (normalizeText(await readFilledValue(titleInput)) !== normalizeText(title)) return null;
6989
+
6990
+ const expected = buildWeiboRichContent(article);
6991
+ const state =
6992
+ typeof bodyInput.evaluate === "function"
6993
+ ? await bodyInput
6994
+ .evaluate(element => ({
6995
+ text: element.innerText || element.textContent || "",
6996
+ images: element.querySelectorAll("img:not(.ProseMirror-separator)").length,
6997
+ links: element.querySelectorAll("a[href]").length,
6998
+ failedImages: element.querySelectorAll('[class*="upload-fail"], [class*="upload-error"]').length
6999
+ }))
7000
+ .catch(() => null)
7001
+ : null;
7002
+ const actualText = normalizedPresenceText(state?.text || normalizeText(await readFilledValue(bodyInput)));
7003
+ if (!actualText || state?.failedImages > 0) return null;
7004
+ if (state && state.images < expected.images.length) return null;
7005
+ const expectedFragments = expected.items
7006
+ .filter(item => item.type === "html" && item.plain)
7007
+ .map(item => normalizedPresenceText(item.plain))
7008
+ .filter(Boolean);
7009
+ if (expectedFragments.length > 0 && !hasOrderedTextFragments(actualText, expectedFragments)) return null;
7010
+ return {
7011
+ richContent: {
7012
+ rich: true,
7013
+ images: state?.images ?? expected.images.length,
7014
+ links: state?.links ?? expected.links.length,
7015
+ plainText: expected.plainText
7016
+ }
7017
+ };
7018
+ }
7019
+
6495
7020
  async function publishWeibo(page, entry, article, spec, options, promptUser) {
6496
7021
  const progress = message => options.onProgress?.(message);
6497
7022
  const preparedArticle = prepareWeiboArticleForProject(article, entry);
@@ -6506,6 +7031,24 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6506
7031
  }
6507
7032
 
6508
7033
  const editorUrl = weiboArticleEditorUrl(entry.url);
7034
+ if (options.directPublish) {
7035
+ const context = typeof page.context === "function" ? page.context() : null;
7036
+ const preparedPage = context ? await findPreparedEditorPage(context, spec) : null;
7037
+ if (preparedPage) page = preparedPage;
7038
+ }
7039
+ const directPrepared = options.directPublish
7040
+ ? await preparedWeiboArticleState(page, spec, preparedArticle, title)
7041
+ : null;
7042
+ if (options.directPublish && !directPrepared) {
7043
+ throw new Error("微博待发布编辑页未找到与当前文章一致的已准备内容;请重新准备后再授权发布");
7044
+ }
7045
+ let richContent = directPrepared?.richContent || null;
7046
+ let cover = {};
7047
+ let column = "";
7048
+ if (options.directPublish) {
7049
+ progress("已复用当前微博待发内容,正在提交,不重复上传正文图片或封面");
7050
+ }
7051
+ if (!options.directPublish) {
6509
7052
  await page.goto(editorUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
6510
7053
  await afterNavigation(page);
6511
7054
  if (!(await waitForEditorReady(page, () => editorIsReady(page, spec)))) {
@@ -6514,7 +7057,7 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6514
7057
  await afterNavigation(page);
6515
7058
  }
6516
7059
  if (!(await waitForEditorReady(page, () => editorIsReady(page, spec), 120000))) {
6517
- throw new Error("微博登录完成后仍未进入头条文章编辑器,请确认账号拥有文章发布权限");
7060
+ throw new Error(browserSessionLoginFailure("微博"));
6518
7061
  }
6519
7062
 
6520
7063
  const draftMode = await ensureWeiboArticleDraft(page, title);
@@ -6531,12 +7074,13 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6531
7074
  }
6532
7075
  await fillStableWeiboField(page, spec.title, title, "标题");
6533
7076
  const stableBodyInput = (await waitForVisible(page, spec.body, 3000)) || bodyInput;
6534
- const richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
7077
+ richContent = await insertWeiboArticleRichContent(page, stableBodyInput, preparedArticle, spec, body, {
6535
7078
  onProgress: progress
6536
7079
  });
6537
7080
  progress("正文已写入,正在设置封面");
6538
- const cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
6539
- const column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
7081
+ cover = await applyWeiboArticleCover(page, preparedArticle, settings, { onProgress: progress });
7082
+ column = await applyWeiboArticleColumn(page, settings.column, { onProgress: progress });
7083
+ }
6540
7084
 
6541
7085
  const nextButton = await waitForVisibleButton(page, spec.next, 5000);
6542
7086
  if (!nextButton) {
@@ -6633,7 +7177,26 @@ async function publishWeibo(page, entry, article, spec, options, promptUser) {
6633
7177
  }
6634
7178
  const shareSubmit = await findWeiboShareSubmitButton(page, shareComposer.dialog);
6635
7179
  if (!shareSubmit) {
6636
- throw new Error("微博分享发布弹窗中未找到“发布”按钮");
7180
+ // The article submit can navigate to publish history while the share
7181
+ // composer is still being torn down. Treat that navigation as a
7182
+ // successful article submission instead of reporting a false failure.
7183
+ const historyDeadline = Date.now() + 3000;
7184
+ while (!/\/history\//i.test(page.url()) && Date.now() < historyDeadline) {
7185
+ await page.waitForTimeout(200);
7186
+ }
7187
+ if (/\/history\//i.test(page.url())) {
7188
+ progress("微博文章已提交,分享弹窗已自动关闭");
7189
+ confirmation = { confirmation: "navigation" };
7190
+ } else {
7191
+ throw new Error("微博分享发布弹窗中未找到“发布”按钮");
7192
+ }
7193
+ }
7194
+ if (confirmation) {
7195
+ progress("已收到微博发布成功回执");
7196
+ if (options.keepOpen) {
7197
+ await promptUser("微博头条文章已收到发布成功回执,浏览器保持打开供检查");
7198
+ }
7199
+ return { url: page.url(), status: "published", type: "article", title, ...richContent, ...cover, column, ...confirmation };
6637
7200
  }
6638
7201
  if (typeof shareSubmit.isDisabled === "function" && (await shareSubmit.isDisabled().catch(() => false))) {
6639
7202
  throw new Error("微博分享发布弹窗中的“发布”按钮不可用");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,6 +60,18 @@ qcplay publish --project 最强蜗牛 --list-platforms
60
60
 
61
61
  默认 `qcplay publish` 不得进入正式发布或填入文章;它会先打开每个浏览器平台的真实编辑器页面,再返回 `qcplay.publish-action-required` 事件。Agent 收到该事件后,必须立即在当前 AI 对话提供“发布”“保存草稿箱”“取消发布”三个选项,不能用终端提问。用户明确选择发布后才添加 `--direct`;选择保存草稿箱后才添加 `--draft-only`。用户说“保存到草稿箱”“发布到草稿箱”时,直接使用 `--draft-only`,不得调用默认发布命令。`--draft-only` 填完内容后只保存原生草稿或停留在编辑页,且草稿页面保持打开。草稿成功后,CLI 会立即返回 `qcplay.draft-approval-required` 事件;Agent 必须立刻在 AI 对话提供“发布”“取消”两个选项,不能等待用户在网页中操作,也不能等待终端输入。用户明确要求先查看官网外平台的待发布稿时,增加 `--review-draft`(或 `--review`):CLI 填完标题、富文本、图片、封面和发布设置后,在最终提交前保持浏览器打开;必须回到当前 AI 对话询问用户“发布”“保存草稿”或“取消”,并等待明确回复,禁止要求用户在 CMD/终端输入确认或按 Enter。用户明确授权跳过二次确认时使用 `--direct`(或 `--no-review`),该参数仅跳过发布前预览确认,不跳过登录、验证码或安全验证。该参数不改变官网流程。TapTap 已保存草稿且用户明确要求发布时,使用 `--publish-draft`,只恢复标题匹配的已有草稿并提交,禁止再次覆盖正文、图片或封面。好游快爆没有原生草稿箱,只能保持已填好的编辑页供用户预览。
62
62
 
63
+ ## 已准备页面的对话修改
64
+
65
+ 当浏览器平台已经返回 `qcplay.publish-action-required`,且编辑页仍保持打开时,用户可在当前 AI 对话中明确说明平台、标题或正文位置、待替换原文及新文。此类单次修改不写入 `rule.docx` 或 `platform-rules.json`,也不改变本地 `article.md`。
66
+
67
+ AI 应将用户已明确的修改转换为一次受限页面编辑:
68
+
69
+ ```bash
70
+ qcplay-cli edit-page --project <项目> --region <区域> --platform <平台> --target <title|body> --find "原文" --replace "新文"
71
+ ```
72
+
73
+ 该命令只连接已打开的待确认编辑页、替换第一处完全匹配的文本并回读验证;新文中的真实换行会保留为富文本换行。不保存草稿、不点击发布,也不会重新打开或覆盖编辑页。原文跨越多个富文本节点、未找到原文、页面已关闭或已发布时必须停止并反馈失败。用户未同时给出原文和新文,或未说明目标位置时,AI 应在当前对话中请求补充,不得猜测或修改规则文件。
74
+
63
75
  ## AI 对话授权与反馈
64
76
 
65
77
  发布或保存草稿前,只要流程需要用户确认,必须在当前 AI 对话中询问并等待明确回复,不得唤起终端或要求用户在 CMD/终端输入“发布”“可以发布”或按 Enter。询问时说明目标平台、文章标题或文章 ID(不得包含 token),并提供以下动作: