@qcplay/cli 1.0.18 → 1.0.20

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
@@ -10,12 +10,15 @@ import readline from "readline";
10
10
  import { fileURLToPath, pathToFileURL } from "url";
11
11
 
12
12
  import {
13
+ analyzeArticleContentSafety,
14
+ buildImportedArticleHtml,
13
15
  buildOfficialArticleMarkdown,
14
16
  importWechatArticle,
15
17
  normalizeArticleColor,
16
18
  sanitizeArticleRichHtml,
17
19
  uploadMediaToBackend
18
20
  } from "../lib/wechat-article.js";
21
+ import { buildRichArticleUploadPayload, parseRichArticleUploadFile } from "../lib/rich-article-upload.js";
19
22
  import { importLarkArticle, parseLarkDocumentUrl } from "../lib/lark-article.js";
20
23
  import {
21
24
  CONTENT_RULES_TEMPLATE,
@@ -24,9 +27,19 @@ import {
24
27
  loadContentRules,
25
28
  matchingContentRules
26
29
  } from "../lib/content-rules.js";
30
+ import {
31
+ WECHAT_GAME_CATALOG,
32
+ buildWechatArticleJson,
33
+ normalizeWechatGameId,
34
+ monthDateRange,
35
+ renderWechatArticleMarkdown,
36
+ splitWechatDateRange,
37
+ todayInShanghai
38
+ } from "../lib/wechat-data.js";
27
39
  import {
28
40
  availableProjects,
29
41
  bilibiliPublishingPlan,
42
+ editPreparedPlatformPage,
30
43
  entriesForProject,
31
44
  loadPlatformEntries,
32
45
  openPublishPage,
@@ -37,7 +50,11 @@ import {
37
50
  runPlatformPublishSequence,
38
51
  selectRequestedEntries,
39
52
  tapTapPublishingPlan,
40
- weiboPublishingPlan
53
+ deleteDiscordMessages,
54
+ findDiscordMessageRecord,
55
+ removeDiscordMessageRecord,
56
+ weiboPublishingPlan,
57
+ writePlatformPublishUrls
41
58
  } from "../lib/platform-publish.js";
42
59
 
43
60
  const __filename = fileURLToPath(import.meta.url);
@@ -171,8 +188,10 @@ const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
171
188
  const CONTENT_RULES_FILE = DEFAULT_CONTENT_RULES_FILE;
172
189
  const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
173
190
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
191
+ const LARK_SKILL_NAMES = ["lark-shared", "lark-sheets", "lark-doc", "lark-wiki"];
174
192
  const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
175
193
  const ARTICLE_PREVIEW_URLS = {
194
+ "33": "https://tideng.qingcigame.com/official/news.html",
176
195
  "50": "https://mtzg.qingcijoy.com/news-details.html",
177
196
  "69": "https://hjwq.qingcijoy.com/news-details.html",
178
197
  "74": "https://sakura.qingcijoy.com/news-details.html",
@@ -226,12 +245,15 @@ Usage:
226
245
  qcplay-cli article
227
246
  qcplay-cli article init [file]
228
247
  qcplay-cli article west-init [file]
229
- qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>] [--backend <url>] [--local]
248
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>] [--backend <url>] [--local]
230
249
  qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
231
250
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
232
251
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
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>]
233
253
  qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
234
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 <区域>
235
257
  qcplay-cli rules [init|validate|list] [--rules-file <json>]
236
258
  qcplay-cli www-article-list.store <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
237
259
  qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
@@ -253,6 +275,14 @@ function printUpdateHelp() {
253
275
  console.log(" qcplay-cli update");
254
276
  }
255
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
+
256
286
  function printAuthHelp() {
257
287
  console.log("Usage:");
258
288
  console.log(" qcplay-cli auth [login|status|logout] [--local] [--backend <url>] [--auth-page <url>]");
@@ -264,22 +294,105 @@ function printArticleHelp() {
264
294
  qcplay-cli article
265
295
  qcplay-cli article init [file]
266
296
  qcplay-cli article west-init [file]
267
- qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
297
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
268
298
  qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
269
299
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
270
300
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
271
301
  }
272
302
 
303
+ function printWechatHelp() {
304
+ console.log(`Usage:
305
+ 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>]
306
+
307
+ 只读取微信公众号接口返回的 day_total,并按游戏和月份生成表格。
308
+ 支持的 game_id: ${Object.entries(WECHAT_GAME_CATALOG).map(([id, name]) => `${id}(${name})`).join("、")}
309
+ 当前日期作为结束日期时,必须填写到昨天;超过 30 天的范围会按月份和 30 天窗口自动拆分请求。`);
310
+ }
311
+
312
+ function parseWechatArticleOptions(args) {
313
+ const options = {
314
+ gameIds: [],
315
+ beginDate: "",
316
+ endDate: "",
317
+ month: "",
318
+ output: "",
319
+ format: "markdown",
320
+ backend: undefined,
321
+ local: false
322
+ };
323
+ for (let index = 0; index < args.length; index += 1) {
324
+ const current = args[index];
325
+ if (["--game-id", "--game", "--begin-date", "--start", "--end-date", "--end", "--month", "--output", "--format", "--backend"].includes(current)) {
326
+ const next = args[index + 1];
327
+ if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
328
+ if (current === "--game-id" || current === "--game") {
329
+ options.gameIds.push(...next.split(",").map(normalizeWechatGameId));
330
+ } else if (current === "--begin-date" || current === "--start") {
331
+ options.beginDate = next;
332
+ } else if (current === "--end-date" || current === "--end") {
333
+ options.endDate = next;
334
+ } else if (current === "--month") {
335
+ options.month = next;
336
+ } else if (current === "--output") {
337
+ options.output = next;
338
+ } else if (current === "--format") {
339
+ options.format = next.toLowerCase();
340
+ } else {
341
+ options.backend = next;
342
+ }
343
+ index += 1;
344
+ continue;
345
+ }
346
+ if (current === "--local") {
347
+ options.local = true;
348
+ continue;
349
+ }
350
+ if (current === "--all-games") {
351
+ options.gameIds = Object.keys(WECHAT_GAME_CATALOG);
352
+ continue;
353
+ }
354
+ throw new Error(`未知参数: ${current}`);
355
+ }
356
+ options.gameIds = [...new Set(options.gameIds)];
357
+ if (options.gameIds.length === 0) throw new Error("请至少指定一个 --game-id");
358
+ if (options.month && (options.beginDate || options.endDate)) throw new Error("--month 不能与 --begin-date/--end-date 同时使用");
359
+ if (options.month) {
360
+ const range = monthDateRange(options.month, todayInShanghai());
361
+ options.beginDate = range.begin.toISOString().slice(0, 10);
362
+ options.endDate = range.end.toISOString().slice(0, 10);
363
+ }
364
+ if (!options.beginDate || !options.endDate) throw new Error("请指定 --month,或同时指定 --begin-date 和 --end-date");
365
+ if (!new Set(["markdown", "json"]).has(options.format)) throw new Error("--format 只能是 markdown 或 json");
366
+ return options;
367
+ }
368
+
273
369
  function parseArticleImportOptions(args) {
274
370
  const positional = [];
275
- const options = { rulesFile: "", project: "", platform: "", backend: undefined, local: false };
371
+ const options = { rulesFile: "", project: "", platform: "", format: "html", backend: undefined, local: false };
276
372
  for (let index = 0; index < args.length; index += 1) {
277
373
  const current = args[index];
278
- if (current === "--rules-file" || current === "--project" || current === "--platform" || current === "--backend") {
374
+ if (
375
+ current === "--rules-file" ||
376
+ current === "--project" ||
377
+ current === "--platform" ||
378
+ current === "--format" ||
379
+ current === "--backend"
380
+ ) {
279
381
  const next = args[index + 1];
280
382
  if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
281
- const key = current === "--rules-file" ? "rulesFile" : current === "--project" ? "project" : current === "--platform" ? "platform" : "backend";
282
- options[key] = next;
383
+ if (current === "--format") {
384
+ options.format = next.toLowerCase();
385
+ } else {
386
+ const key =
387
+ current === "--rules-file"
388
+ ? "rulesFile"
389
+ : current === "--project"
390
+ ? "project"
391
+ : current === "--platform"
392
+ ? "platform"
393
+ : "backend";
394
+ options[key] = next;
395
+ }
283
396
  index += 1;
284
397
  continue;
285
398
  }
@@ -291,7 +404,14 @@ function parseArticleImportOptions(args) {
291
404
  positional.push(current);
292
405
  }
293
406
  if (positional.length > 2) throw new Error(`未知参数: ${positional.slice(2).join(" ")}`);
294
- return { sourceUrl: positional[0], file: positional[1] || "article.md", options };
407
+ if (!new Set(["markdown", "html"]).has(options.format)) {
408
+ throw new Error("--format 只能是 markdown 或 html");
409
+ }
410
+ return {
411
+ sourceUrl: positional[0],
412
+ file: positional[1] || (options.format === "markdown" ? "article.md" : "article.html"),
413
+ options
414
+ };
295
415
  }
296
416
 
297
417
  function printPublishHelp() {
@@ -311,8 +431,10 @@ Options:
311
431
  --config-file <json> 使用本地平台配置(主要用于测试)
312
432
  --rules-file <json> 使用指定内容规则;默认 ~/.qcplay/platform-rules.json
313
433
  --browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
314
- --review-draft, --review 官网外平台先准备草稿,预览后在 CMD 确认发布
315
- --direct, --no-review 用户已确认内容时跳过发布前二次预览确认
434
+ --review-draft, --review 官网外平台先准备草稿,预览后由当前 AI 对话确认发布
435
+ --direct, --no-review 需配合 AI 对话确认标记后才可跳过发布前预览
436
+ --publish-draft 仅 TapTap:发布标题匹配的已有草稿,不重新写入内容
437
+ --draft-only, --draft 仅保存或停留在官网外平台草稿,不执行正式发布
316
438
  --open-page 只打开真实浏览器发布页,供登录和页面流程测试,不读取或提交文章
317
439
  --switch-account 先打开发布页切换账号,确认后继续当前发布流程
318
440
  --keep-open 浏览器提交后等待用户确认再关闭
@@ -378,6 +500,56 @@ function parseRulesOptions(args) {
378
500
  return options;
379
501
  }
380
502
 
503
+ async function importWechatDataCommand(options) {
504
+ const config = await loadConfig();
505
+ const backendUrl = resolveAuthBackendUrl(options, config);
506
+ const reports = [];
507
+ for (const gameId of options.gameIds) {
508
+ const ranges = splitWechatDateRange(options.beginDate, options.endDate, todayInShanghai());
509
+ const dayTotal = new Map();
510
+ for (const range of ranges) {
511
+ const query = new URLSearchParams({
512
+ game_id: gameId,
513
+ begin_date: range.beginDate,
514
+ end_date: range.endDate
515
+ });
516
+ console.log(chalk.gray(`正在拉取${WECHAT_GAME_CATALOG[gameId]} ${range.beginDate} 至 ${range.endDate}`));
517
+ const response = await authenticatedRequest("GET", backendUrl, `/api/wechat/article?${query}`);
518
+ if (Number(response.code) !== 200) {
519
+ throw new Error(`${WECHAT_GAME_CATALOG[gameId]} ${range.beginDate} 至 ${range.endDate}:${response.message || `接口返回 code=${response.code ?? "未知"}`}`);
520
+ }
521
+ const items = Array.isArray(response.data?.day_total) ? response.data.day_total : [];
522
+ for (const item of items) {
523
+ const key = `${item.ref_date || ""}\u0000${item.msgid || ""}`;
524
+ dayTotal.set(key, item);
525
+ }
526
+ }
527
+ reports.push({
528
+ gameId,
529
+ gameName: WECHAT_GAME_CATALOG[gameId],
530
+ beginDate: options.beginDate,
531
+ endDate: options.endDate,
532
+ ranges,
533
+ dayTotal: [...dayTotal.values()].sort((left, right) => {
534
+ const byDate = String(left.ref_date || "").localeCompare(String(right.ref_date || ""));
535
+ return byDate || String(left.msgid || "").localeCompare(String(right.msgid || ""));
536
+ })
537
+ });
538
+ }
539
+
540
+ const output = options.format === "json" ? buildWechatArticleJson(reports) : renderWechatArticleMarkdown(reports);
541
+ if (options.output) {
542
+ const target = path.resolve(options.output);
543
+ if (await pathExists(target)) throw new Error(`输出文件已存在: ${target}`);
544
+ await ensureDir(path.dirname(target));
545
+ await fs.promises.writeFile(target, output, { encoding: "utf8", mode: 0o600 });
546
+ console.log(chalk.green(`微信公众号数据已生成: ${target}`));
547
+ console.log(`文章数据: ${reports.reduce((sum, report) => sum + report.dayTotal.length, 0)} 篇`);
548
+ return;
549
+ }
550
+ process.stdout.write(output);
551
+ }
552
+
381
553
  async function contentRulesCommand(action, args = []) {
382
554
  const options = parseRulesOptions(args);
383
555
  const file = path.resolve(options.rulesFile || CONTENT_RULES_FILE);
@@ -430,7 +602,7 @@ function printFeatures() {
430
602
  console.log(" qcplay-cli auth permissions --key www-article-list.store");
431
603
  console.log("");
432
604
  console.log(chalk.cyan("3. 发布官网文章"));
433
- console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.md");
605
+ console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.html");
434
606
  console.log(" qcplay-cli www-article-list.store article.md");
435
607
  console.log(" qcplay-cli article update [id]");
436
608
  console.log(" qcplay-cli article delete [id]");
@@ -442,6 +614,9 @@ function printFeatures() {
442
614
  console.log(chalk.cyan("5. 查看本地目录"));
443
615
  console.log(" qcplay-cli where");
444
616
  console.log("");
617
+ console.log(chalk.cyan("6. 拉取微信公众号数据"));
618
+ console.log(" qcplay-cli wechat article --game-id 39 --month 2026-08 --output wechat-2026-08.md");
619
+ console.log("");
445
620
  }
446
621
 
447
622
  function printWhere() {
@@ -583,6 +758,25 @@ async function installSkills() {
583
758
  };
584
759
  }
585
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
+
586
780
  async function saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode = false) {
587
781
  await writeJson(CONFIG_FILE, {
588
782
  cli: "qcplay-cli",
@@ -814,6 +1008,9 @@ function parseDistributionOptions(args) {
814
1008
  browserChannel: "",
815
1009
  reviewDraft: false,
816
1010
  directPublish: false,
1011
+ confirmed: false,
1012
+ draftOnly: false,
1013
+ publishDraft: false,
817
1014
  keepOpen: false,
818
1015
  openPage: false,
819
1016
  switchAccount: false,
@@ -857,6 +1054,18 @@ function parseDistributionOptions(args) {
857
1054
  options.directPublish = true;
858
1055
  continue;
859
1056
  }
1057
+ if (current === "--confirmed") {
1058
+ options.confirmed = true;
1059
+ continue;
1060
+ }
1061
+ if (current === "--draft-only" || current === "--draft") {
1062
+ options.draftOnly = true;
1063
+ continue;
1064
+ }
1065
+ if (current === "--publish-draft") {
1066
+ options.publishDraft = true;
1067
+ continue;
1068
+ }
860
1069
 
861
1070
  const valueFlags = new Map([
862
1071
  ["--backend", "backend"],
@@ -904,6 +1113,139 @@ function parseDistributionOptions(args) {
904
1113
  return { file: positional[0], options };
905
1114
  }
906
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
+
907
1249
  function applyPublishOverrides(parsedArticle, options) {
908
1250
  const title = normalizeText(options.title);
909
1251
  const cover = normalizeText(options.cover);
@@ -921,11 +1263,11 @@ function applyPublishOverrides(parsedArticle, options) {
921
1263
  ...parsedArticle.meta,
922
1264
  ...(cover
923
1265
  ? {
924
- weibo_cover: cover,
925
- taptap_cover: cover,
926
- bilibili_cover: cover,
927
- xiaohongshu_images: cover
928
- }
1266
+ weibo_cover: cover,
1267
+ taptap_cover: cover,
1268
+ bilibili_cover: cover,
1269
+ xiaohongshu_images: cover
1270
+ }
929
1271
  : {}),
930
1272
  ...(weiboColumn ? { weibo_column: weiboColumn } : {})
931
1273
  };
@@ -1003,7 +1345,9 @@ function openBrowser(targetUrl) {
1003
1345
 
1004
1346
  if (process.platform === "win32") {
1005
1347
  command = "cmd";
1006
- args = ["/c", "start", "", targetUrl];
1348
+ // 关键修复:将 URL 中的 & 替换为 ^&
1349
+ const safeUrl = targetUrl.replace(/&/g, '^&');
1350
+ args = ["/c", "start", "", safeUrl];
1007
1351
  } else if (process.platform === "darwin") {
1008
1352
  command = "open";
1009
1353
  args = [targetUrl];
@@ -1029,6 +1373,10 @@ function getNpmCommand() {
1029
1373
  return process.platform === "win32" ? "npm.cmd" : "npm";
1030
1374
  }
1031
1375
 
1376
+ function getNpxCommand() {
1377
+ return process.platform === "win32" ? "npx.cmd" : "npx";
1378
+ }
1379
+
1032
1380
  function runCommand(command, args) {
1033
1381
  return new Promise((resolve, reject) => {
1034
1382
  const child = spawn(command, args, {
@@ -1047,6 +1395,11 @@ function runCommand(command, args) {
1047
1395
  });
1048
1396
  }
1049
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
+
1050
1403
  function delay(ms) {
1051
1404
  return new Promise(resolve => {
1052
1405
  setTimeout(resolve, ms);
@@ -1344,7 +1697,7 @@ function backendOriginsMatch(left, right) {
1344
1697
  }
1345
1698
  }
1346
1699
 
1347
- async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1700
+ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
1348
1701
  const authState = await loadAuthState();
1349
1702
  await writeJson(ARTICLE_STATE_FILE, {
1350
1703
  article_id: articleId,
@@ -1354,13 +1707,15 @@ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl
1354
1707
  backend_url: backendUrl,
1355
1708
  administrator_id: Number(authState.id || 0),
1356
1709
  account: normalizeText(authState.account),
1710
+ game_id: normalizeText(metadata.game_id),
1711
+ cate_id: normalizeText(metadata.cate_id),
1357
1712
  updated_at: Date.now()
1358
1713
  });
1359
1714
  }
1360
1715
 
1361
- async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1716
+ async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
1362
1717
  try {
1363
- await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target);
1718
+ await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target, metadata);
1364
1719
  return "";
1365
1720
  } catch (error) {
1366
1721
  return normalizeText(error?.message) || String(error);
@@ -1497,6 +1852,7 @@ async function installCommand(options) {
1497
1852
  console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1498
1853
 
1499
1854
  const skillsInstalled = await installSkills();
1855
+ await installLarkCli();
1500
1856
  if (skillsInstalled.available) {
1501
1857
  console.log(chalk.green("✔ Skills 已安装"));
1502
1858
  console.log(`Skills 目录: ${chalk.gray(SKILLS_DIR)}`);
@@ -1504,6 +1860,7 @@ async function installCommand(options) {
1504
1860
  console.log(chalk.yellow("! 未找到 Skills 模板,已跳过"));
1505
1861
  }
1506
1862
 
1863
+ printLarkSkillStatus(await detectLarkSkills());
1507
1864
  await saveConfig(authBackendUrl, publishBackendUrl, authPageUrl, localMode);
1508
1865
  console.log(chalk.green("✔ 配置文件已生成"));
1509
1866
  console.log(chalk.gray(`认证后端: ${authBackendUrl}`));
@@ -1553,6 +1910,7 @@ async function updateCommand() {
1553
1910
  const latestPackage = readPackageMetadata();
1554
1911
  await updateStoredConfigVersion(latestPackage.version);
1555
1912
  const skillsInstalled = await installSkills();
1913
+ await installLarkCli();
1556
1914
 
1557
1915
  console.log("");
1558
1916
  console.log(chalk.green("CLI 更新完成"));
@@ -1562,6 +1920,7 @@ async function updateCommand() {
1562
1920
  console.log(chalk.gray(`版本: ${latestPackage.version}`));
1563
1921
  }
1564
1922
  printSkillsSyncResult(skillsInstalled);
1923
+ printLarkSkillStatus(await detectLarkSkills());
1565
1924
  console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则未覆盖"));
1566
1925
  console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1567
1926
  console.log("");
@@ -1701,14 +2060,14 @@ function applyInlineMarkdown(text) {
1701
2060
  output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
1702
2061
  return storeToken(
1703
2062
  `<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" ` +
1704
- 'style="display:block;box-sizing:border-box;width:auto;max-width:100%;height:auto;object-fit:contain;margin:24px auto;" />'
2063
+ 'style="display:block;box-sizing:border-box;width:auto;max-width:100%;height:auto;object-fit:contain;margin:24px auto;" />'
1705
2064
  );
1706
2065
  });
1707
2066
 
1708
2067
  output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
1709
2068
  return storeToken(
1710
2069
  `<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer" ` +
1711
- `style="color:#576b95;text-decoration:none;overflow-wrap:anywhere;word-break:break-all;">${escapeHtml(label.trim())}</a>`
2070
+ `style="color:#576b95;text-decoration:none;overflow-wrap:anywhere;word-break:break-all;">${escapeHtml(label.trim())}</a>`
1712
2071
  );
1713
2072
  });
1714
2073
 
@@ -1754,7 +2113,7 @@ function markdownToHtml(markdown) {
1754
2113
 
1755
2114
  html.push(
1756
2115
  `<p style="margin:0 0 20px;font-size:16px;line-height:1.9;color:#3f3f3f;text-align:justify;overflow-wrap:anywhere;word-break:break-word;">` +
1757
- `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
2116
+ `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
1758
2117
  );
1759
2118
  paragraph = [];
1760
2119
  }
@@ -1766,9 +2125,9 @@ function markdownToHtml(markdown) {
1766
2125
 
1767
2126
  html.push(
1768
2127
  '<blockquote style="margin:24px 0;padding:14px 18px;border-left:4px solid #17b9c2;background:#f5f7f8;color:#57606a;">' +
1769
- `<p style="margin:0;font-size:15px;line-height:1.8;">${quote
1770
- .map(line => applyInlineMarkdown(line))
1771
- .join("<br />")}</p></blockquote>`
2128
+ `<p style="margin:0;font-size:15px;line-height:1.8;">${quote
2129
+ .map(line => applyInlineMarkdown(line))
2130
+ .join("<br />")}</p></blockquote>`
1772
2131
  );
1773
2132
  quote = [];
1774
2133
  }
@@ -1782,7 +2141,7 @@ function markdownToHtml(markdown) {
1782
2141
 
1783
2142
  html.push(
1784
2143
  `<${listType} style="margin:0 0 20px;padding-left:1.6em;font-size:16px;line-height:1.9;color:#3f3f3f;">` +
1785
- `${listItems.map(item => `<li style="margin:6px 0;">${applyInlineMarkdown(item)}</li>`).join("")}</${listType}>`
2144
+ `${listItems.map(item => `<li style="margin:6px 0;">${applyInlineMarkdown(item)}</li>`).join("")}</${listType}>`
1786
2145
  );
1787
2146
  listType = "";
1788
2147
  listItems = [];
@@ -1795,7 +2154,7 @@ function markdownToHtml(markdown) {
1795
2154
 
1796
2155
  html.push(
1797
2156
  '<pre style="margin:24px 0;padding:16px;overflow:auto;border-radius:4px;background:#f5f7f8;">' +
1798
- `<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
2157
+ `<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
1799
2158
  );
1800
2159
  inCodeBlock = false;
1801
2160
  codeLines = [];
@@ -1834,18 +2193,18 @@ function markdownToHtml(markdown) {
1834
2193
  "font-size:15px;line-height:1.7;overflow-wrap:anywhere;word-break:break-word;";
1835
2194
  html.push(
1836
2195
  '<div style="width:100%;margin:24px 0;overflow-x:auto;-webkit-overflow-scrolling:touch;">' +
1837
- '<table style="width:100%;min-width:560px;border-collapse:collapse;table-layout:auto;color:#3f3f3f;">' +
1838
- `<thead><tr>${header
1839
- .map(cell => `<th style="${cellStyle}background:#f5f7f8;font-weight:600;">${applyInlineMarkdown(cell)}</th>`)
1840
- .join("")}</tr></thead>` +
1841
- `<tbody>${body
1842
- .map(
1843
- row =>
1844
- `<tr>${row
1845
- .map(cell => `<td style="${cellStyle}">${applyInlineMarkdown(cell)}</td>`)
1846
- .join("")}</tr>`
1847
- )
1848
- .join("")}</tbody></table></div>`
2196
+ '<table style="width:100%;min-width:560px;border-collapse:collapse;table-layout:auto;color:#3f3f3f;">' +
2197
+ `<thead><tr>${header
2198
+ .map(cell => `<th style="${cellStyle}background:#f5f7f8;font-weight:600;">${applyInlineMarkdown(cell)}</th>`)
2199
+ .join("")}</tr></thead>` +
2200
+ `<tbody>${body
2201
+ .map(
2202
+ row =>
2203
+ `<tr>${row
2204
+ .map(cell => `<td style="${cellStyle}">${applyInlineMarkdown(cell)}</td>`)
2205
+ .join("")}</tr>`
2206
+ )
2207
+ .join("")}</tbody></table></div>`
1849
2208
  );
1850
2209
  tableLines = [];
1851
2210
  }
@@ -1908,7 +2267,7 @@ function markdownToHtml(markdown) {
1908
2267
  const headingSize = level === 1 ? 24 : level === 2 ? 20 : 18;
1909
2268
  html.push(
1910
2269
  `<h${level} style="margin:32px 0 16px;font-size:${headingSize}px;line-height:1.5;font-weight:700;color:#24292f;overflow-wrap:anywhere;word-break:break-word;">` +
1911
- `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
2270
+ `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
1912
2271
  );
1913
2272
  continue;
1914
2273
  }
@@ -2074,6 +2433,23 @@ async function parseArticleFile(file) {
2074
2433
  }
2075
2434
 
2076
2435
  const raw = await fs.promises.readFile(articleFile, "utf8");
2436
+ if (path.extname(articleFile).toLowerCase() === ".html") {
2437
+ const { meta, richHtml } = parseRichArticleUploadFile(raw);
2438
+ return {
2439
+ articleFile,
2440
+ payload: buildRichArticleUploadPayload(meta, richHtml, {
2441
+ normalizeText,
2442
+ normalizeMappedValue,
2443
+ normalizeGameAndCategory,
2444
+ normalizeBoolLike,
2445
+ statusMap: STATUS_MAP,
2446
+ areaMap: AREA_MAP,
2447
+ westLanguages: WEST_ARTICLE_LANGUAGES
2448
+ }),
2449
+ meta,
2450
+ markdown: ""
2451
+ };
2452
+ }
2077
2453
  const parsed = parseFrontMatter(raw);
2078
2454
  return {
2079
2455
  articleFile,
@@ -2106,7 +2482,7 @@ async function initArticleTemplate(file = "article.md", template = "article.md")
2106
2482
  console.log("");
2107
2483
  }
2108
2484
 
2109
- async function importArticleCommand(sourceUrl, file = "article.md", options = {}) {
2485
+ async function importArticleCommand(sourceUrl, file = "article.html", options = {}) {
2110
2486
  if (!sourceUrl) {
2111
2487
  throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
2112
2488
  }
@@ -2151,14 +2527,22 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
2151
2527
  }
2152
2528
  });
2153
2529
  }
2154
- const output = buildOfficialArticleMarkdown(article);
2530
+ const projectMetadata = importProjectMetadata(options.project);
2531
+ if (projectMetadata) {
2532
+ article = { ...article, ...projectMetadata };
2533
+ }
2534
+ const output = options.format === "html" ? buildImportedArticleHtml(article) : buildOfficialArticleMarkdown(article);
2155
2535
  await ensureDir(path.dirname(targetFile));
2156
2536
  await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
2157
2537
 
2158
2538
  console.log("");
2159
- console.log(chalk.green(`${sourceLabel}已转换为官网文章草稿:`));
2539
+ console.log(chalk.green(`${sourceLabel}已转换为${options.format === "html" ? "HTML 富文本文章" : "官网文章草稿"}:`));
2160
2540
  console.log(targetFile);
2161
2541
  console.log(chalk.gray(`标题: ${article.title}`));
2542
+ const contentSafety = article.contentSafety || analyzeArticleContentSafety(article);
2543
+ for (const warning of contentSafety.warnings) {
2544
+ console.log(chalk.yellow(`内容安全提示: ${warning}`));
2545
+ }
2162
2546
  if (sourceLabel === "飞书文档") {
2163
2547
  console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
2164
2548
  console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
@@ -2188,7 +2572,13 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
2188
2572
  );
2189
2573
  }
2190
2574
  console.log("");
2191
- console.log(sourceLabel === "飞书文档" ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:" : "请补充 cate_id 等业务字段,确认内容后执行:");
2575
+ console.log(
2576
+ options.format === "html"
2577
+ ? "可直接用于官网或各分发平台,平台规则会在发布时按原有流程处理:"
2578
+ : sourceLabel === "飞书文档"
2579
+ ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:"
2580
+ : "请补充 cate_id 等业务字段,确认内容后执行:"
2581
+ );
2192
2582
  console.log("");
2193
2583
  console.log(` qcplay-cli www-article-list.store ${file}`);
2194
2584
  console.log("");
@@ -2251,9 +2641,9 @@ ${chalk.cyan("发布命令:")}
2251
2641
 
2252
2642
  qcplay-cli www-article-list.store article.md
2253
2643
 
2254
- ${chalk.cyan("从微信推文生成官网草稿:")}
2644
+ ${chalk.cyan("从微信推文拉取 HTML 富文本:")}
2255
2645
 
2256
- qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
2646
+ qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.html
2257
2647
 
2258
2648
  ${chalk.cyan("项目默认分类:")}
2259
2649
 
@@ -2277,6 +2667,21 @@ function inferDistributionProject(payload, explicitProject, meta = {}) {
2277
2667
  return "";
2278
2668
  }
2279
2669
 
2670
+ function importProjectMetadata(project) {
2671
+ const normalizedProject = normalizeText(project);
2672
+ if (!normalizedProject) return null;
2673
+ const gameId = normalizeMappedValue(normalizedProject, GAME_MAP);
2674
+ const game = gameCatalogEntry(gameId);
2675
+ if (!game) {
2676
+ throw new Error(`未知项目: ${normalizedProject}`);
2677
+ }
2678
+ return {
2679
+ gameId,
2680
+ categoryId: game.defaultCategory,
2681
+ distributionProject: normalizedProject
2682
+ };
2683
+ }
2684
+
2280
2685
  function printPlatformEntries(entries) {
2281
2686
  console.log("");
2282
2687
  console.log(chalk.cyan("可用发布平台:"));
@@ -2324,9 +2729,25 @@ async function publishPlatformsCommand(file, options) {
2324
2729
  throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
2325
2730
  }
2326
2731
 
2327
- const selectedEntries = options.platforms.length
2732
+ let selectedEntries = options.platforms.length
2328
2733
  ? selectRequestedEntries(candidates, options.platforms)
2329
2734
  : await promptForPlatformEntries(candidates);
2735
+ if (options.draftOnly) {
2736
+ selectedEntries = selectedEntries.filter(entry => entry.platformKey !== "website");
2737
+ if (selectedEntries.length === 0) {
2738
+ throw new Error("--draft-only 仅适用于官网之外的游戏平台");
2739
+ }
2740
+ }
2741
+ if (options.draftOnly && options.publishDraft) {
2742
+ throw new Error("--draft-only 与 --publish-draft 不能同时使用");
2743
+ }
2744
+ const needsActionSelection =
2745
+ !options.dryRun &&
2746
+ !options.openPage &&
2747
+ !options.switchAccount &&
2748
+ !options.draftOnly &&
2749
+ !(options.publishDraft && options.confirmed) &&
2750
+ !(options.directPublish && options.confirmed);
2330
2751
  if (options.openPage) {
2331
2752
  if (selectedEntries.some(entry => entry.publisher !== "browser")) {
2332
2753
  throw new Error("--open-page 仅支持有真实浏览器发布页的平台,请使用 --platform 指定浏览器平台");
@@ -2376,8 +2797,11 @@ async function publishPlatformsCommand(file, options) {
2376
2797
  const matchedRules = matchingContentRules(contentRules, planningArticle, entry).map(rule => rule.name);
2377
2798
  return {
2378
2799
  ...publicEntrySummary(entry),
2800
+ ...(options.draftOnly ? { mode: "draft-only" } : {}),
2379
2801
  ...(matchedRules.length > 0 ? { contentRules: matchedRules } : {}),
2380
- ...(entry.platformKey === "taptap" ? { taptap: tapTapPublishingPlan(planningArticle, entry) } : {}),
2802
+ ...(entry.platformKey === "taptap"
2803
+ ? { taptap: { ...tapTapPublishingPlan(planningArticle, entry), ...(options.draftOnly ? { draft: true } : {}) } }
2804
+ : {}),
2381
2805
  ...(entry.platformKey === "bilibili" ? { bilibili: bilibiliPublishingPlan(planningArticle, entry) } : {}),
2382
2806
  ...(entry.platformKey === "weibo" ? { weibo: weiboPublishingPlan(planningArticle, entry) } : {})
2383
2807
  };
@@ -2396,7 +2820,17 @@ async function publishPlatformsCommand(file, options) {
2396
2820
  meta: parsedArticle.meta,
2397
2821
  platformInputs
2398
2822
  };
2399
- const { successes, failures, skipped } = await runPlatformPublishSequence(selected, async entry => {
2823
+ const contentSafety = analyzeArticleContentSafety(article);
2824
+ for (const warning of contentSafety.warnings) {
2825
+ console.log(chalk.yellow(`内容安全提示: ${warning}`));
2826
+ }
2827
+ const browserEntries = selected.filter(entry => entry.publisher === "browser");
2828
+ // Each browser platform needs its own explicit AI-conversation authorisation.
2829
+ // Preparing every selected editor before asking keeps the user waiting behind
2830
+ // long image uploads on later platforms and leaves no safe action boundary.
2831
+ const executionEntries = needsActionSelection ? browserEntries.slice(0, 1) : selected;
2832
+ const pendingApprovalEntries = needsActionSelection ? browserEntries.slice(1) : [];
2833
+ const { successes, failures, skipped } = await runPlatformPublishSequence(executionEntries, async entry => {
2400
2834
  console.log("");
2401
2835
  console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
2402
2836
  let result;
@@ -2405,13 +2839,34 @@ async function publishPlatformsCommand(file, options) {
2405
2839
  } else {
2406
2840
  result = await publishPlatformEntry(entry, article, {
2407
2841
  browserChannel: options.browserChannel,
2408
- reviewDraft: options.reviewDraft,
2409
- directPublish: options.directPublish,
2842
+ reviewDraft: needsActionSelection || options.reviewDraft,
2843
+ // A review is always approved in the current AI conversation. Never
2844
+ // fall back to the legacy terminal confirmation path.
2845
+ awaitAIAction: needsActionSelection || options.reviewDraft,
2846
+ // The initial AI-authorisation preview prepares the complete native
2847
+ // editor draft, including body media. It still never clicks save or
2848
+ // publish; only the later explicit action may submit it. A platform
2849
+ // must not reach the action selection while any image is still
2850
+ // uploading, including editors that expose their fields after upload.
2851
+ previewTextOnly: false,
2852
+ directPublish: options.directPublish && options.confirmed,
2853
+ draftOnly: options.draftOnly,
2854
+ publishDraft: options.publishDraft,
2410
2855
  keepOpen: options.keepOpen,
2411
2856
  onProgress: message => console.log(chalk.gray(` ${message}`))
2412
2857
  });
2413
- if (entry.platformKey === "taptap" && result?.status === "draft") {
2414
- console.log(chalk.green("TapTap 草稿已保存"));
2858
+ if (result?.status === "draft") {
2859
+ console.log(chalk.green(`${entry.platform} 草稿已保存,浏览器保持打开`));
2860
+ if (result.approvalRequired) {
2861
+ console.log(
2862
+ JSON.stringify({
2863
+ event: "qcplay.draft-approval-required",
2864
+ platform: entry.platform,
2865
+ title: article.title,
2866
+ actions: result.approvalActions || ["publish", "cancel"]
2867
+ })
2868
+ );
2869
+ }
2415
2870
  return result;
2416
2871
  }
2417
2872
  if (entry.platformKey === "taptap" && result?.status === "scheduled") {
@@ -2425,6 +2880,42 @@ async function publishPlatformsCommand(file, options) {
2425
2880
  failures.forEach(({ entry, error }) => {
2426
2881
  console.error(chalk.red(`${entry.platform} 发布失败: ${error.message || error}`));
2427
2882
  });
2883
+
2884
+ if (needsActionSelection) {
2885
+ if (failures.length > 0) {
2886
+ throw new Error("待授权预览准备失败,请根据上面的错误处理后重试失败平台");
2887
+ }
2888
+ const pages = successes
2889
+ .map(({ entry, result }) => ({ platform: entry.platform, url: result?.url || "" }))
2890
+ .filter(item => item.url);
2891
+ const previewResults = successes.map(({ result }) => result || {});
2892
+ const imagesPending = previewResults.reduce((sum, result) => sum + Number(result.imagesPending || 0), 0);
2893
+ const requiresMediaBeforeText = previewResults.some(result => result.requiresMediaBeforeText);
2894
+ const contentPrepared = previewResults.every(result => result.contentPrepared !== false) && imagesPending === 0;
2895
+ if (!contentPrepared || requiresMediaBeforeText) {
2896
+ throw new Error("编辑器尚未完成正文或图片回读校验,暂不提供发布授权选项");
2897
+ }
2898
+ console.log(
2899
+ JSON.stringify({
2900
+ event: "qcplay.publish-action-required",
2901
+ title: previewResults.find(result => normalizeText(result.title))?.title || article.title,
2902
+ platforms: executionEntries.map(entry => entry.platform),
2903
+ ...(pendingApprovalEntries.length > 0
2904
+ ? { pendingPlatforms: pendingApprovalEntries.map(entry => entry.platform) }
2905
+ : {}),
2906
+ pages,
2907
+ contentPrepared,
2908
+ imagesPending,
2909
+ actions: ["publish", "save_draft", "cancel"]
2910
+ })
2911
+ );
2912
+ return;
2913
+ }
2914
+
2915
+ const urlsFile = await writePlatformPublishUrls(file, successes);
2916
+ if (urlsFile) {
2917
+ console.log(chalk.green(`平台地址已写入: ${urlsFile}`));
2918
+ }
2428
2919
  if (skipped.length > 0) {
2429
2920
  console.error(chalk.yellow("已停止后续平台发布,请先处理当前浏览器平台的登录或页面问题。"));
2430
2921
  }
@@ -2442,6 +2933,29 @@ async function publishPlatformsCommand(file, options) {
2442
2933
  }
2443
2934
  }
2444
2935
 
2936
+ async function editPreparedPageCommand(options) {
2937
+ const allEntries = await loadPlatformEntries({ configFile: options.configFile });
2938
+ const candidates = entriesForProject(allEntries, options.project, options.region);
2939
+ const selected = selectRequestedEntries(candidates, [options.platform]);
2940
+ if (selected.length !== 1 || selected[0].publisher !== "browser") {
2941
+ throw new Error(`${options.platform} 没有可编辑的浏览器发布页面配置`);
2942
+ }
2943
+ const entry = selected[0];
2944
+ const result = await editPreparedPlatformPage(
2945
+ entry,
2946
+ { target: options.target, find: options.find, replacement: options.replacement },
2947
+ { browserChannel: options.browserChannel, onProgress: message => console.log(chalk.gray(` ${message}`)) }
2948
+ );
2949
+ console.log(
2950
+ JSON.stringify({
2951
+ event: "qcplay.page-edit-complete",
2952
+ platform: entry.platform,
2953
+ target: result.target,
2954
+ url: result.url
2955
+ })
2956
+ );
2957
+ }
2958
+
2445
2959
  async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
2446
2960
  if (!file) {
2447
2961
  throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
@@ -2561,11 +3075,20 @@ async function updateArticleStatusCommand(articleIdValue, targetStatus, options,
2561
3075
 
2562
3076
  const response = await authenticatedRequest("PATCH", backendUrl, articleApiPath(target, "status"), payload);
2563
3077
  const responseStatus = normalizeText(response.data?.status) || targetStatus;
3078
+ const recentState = await readJson(ARTICLE_STATE_FILE);
3079
+ const recentStateMatches =
3080
+ String(recentState.article_id || "") === String(articleId) &&
3081
+ (recentState.target || "domestic") === target;
3082
+ const previewPayload = recentStateMatches
3083
+ ? { game_id: recentState.game_id, cate_id: recentState.cate_id }
3084
+ : {};
2564
3085
  const previewUrl =
2565
3086
  responseStatus === "1"
2566
- ? normalizeText(response.data?.preview_url) || (target === "domestic" ? articlePreviewUrl(articleId) : "")
3087
+ ? normalizeText(response.data?.preview_url) ||
3088
+ (recentStateMatches ? normalizeText(recentState.preview_url) : "") ||
3089
+ (target === "domestic" ? articlePreviewUrl(articleId, previewPayload) : "")
2567
3090
  : "";
2568
- const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target);
3091
+ const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target, previewPayload);
2569
3092
 
2570
3093
  console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
2571
3094
  console.log(`文章 ID: ${articleId}`);
@@ -2639,6 +3162,32 @@ async function main() {
2639
3162
  return;
2640
3163
  }
2641
3164
 
3165
+ if (command === "edit-page") {
3166
+ if (subcommand === "-h" || subcommand === "--help") {
3167
+ printEditPageHelp();
3168
+ return;
3169
+ }
3170
+ const options = parseEditPageOptions([subcommand, ...rest].filter(Boolean));
3171
+ await runWithErrorBanner("编辑已准备页面失败", () => editPreparedPageCommand(options));
3172
+ return;
3173
+ }
3174
+
3175
+ if (command === "discord") {
3176
+ if (subcommand === "-h" || subcommand === "--help") {
3177
+ console.log("Usage:\n qcplay-cli discord delete <文章文件> --project <项目> --region <区域>");
3178
+ return;
3179
+ }
3180
+ if (subcommand !== "delete") {
3181
+ await runWithErrorBanner("Discord 操作失败", async () => {
3182
+ throw new Error(`未知 discord 命令: ${subcommand || ""}`);
3183
+ });
3184
+ return;
3185
+ }
3186
+ const parsed = parseDiscordDeleteOptions(rest);
3187
+ await runWithErrorBanner("Discord 删除失败", () => deleteDiscordCommand(parsed.file, parsed.options));
3188
+ return;
3189
+ }
3190
+
2642
3191
  if (command === "rules") {
2643
3192
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2644
3193
  printRulesHelp();
@@ -2654,6 +3203,26 @@ async function main() {
2654
3203
  return;
2655
3204
  }
2656
3205
 
3206
+ if (command === "wechat") {
3207
+ if (!subcommand || subcommand === "-h" || subcommand === "--help") {
3208
+ printWechatHelp();
3209
+ return;
3210
+ }
3211
+ if (subcommand !== "article") {
3212
+ await runWithErrorBanner("微信公众号数据失败", async () => {
3213
+ throw new Error(`未知 wechat 命令: ${subcommand}`);
3214
+ });
3215
+ return;
3216
+ }
3217
+ if (rest.includes("-h") || rest.includes("--help")) {
3218
+ printWechatHelp();
3219
+ return;
3220
+ }
3221
+ const parsed = parseWechatArticleOptions(rest);
3222
+ await runWithErrorBanner("微信公众号数据失败", () => importWechatDataCommand(parsed));
3223
+ return;
3224
+ }
3225
+
2657
3226
  if (command === "article") {
2658
3227
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2659
3228
  if (!subcommand) {
@@ -2677,6 +3246,10 @@ async function main() {
2677
3246
  }
2678
3247
 
2679
3248
  if (subcommand === "import") {
3249
+ if (rest.length === 1 && ["--help", "-h"].includes(rest[0])) {
3250
+ printArticleHelp();
3251
+ return;
3252
+ }
2680
3253
  const parsed = parseArticleImportOptions(rest);
2681
3254
  await runWithErrorBanner("转换失败", () =>
2682
3255
  importArticleCommand(parsed.sourceUrl, parsed.file, parsed.options)