@qcplay/cli 1.0.18 → 1.0.19

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,6 +27,15 @@ 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,
@@ -37,7 +49,8 @@ import {
37
49
  runPlatformPublishSequence,
38
50
  selectRequestedEntries,
39
51
  tapTapPublishingPlan,
40
- weiboPublishingPlan
52
+ weiboPublishingPlan,
53
+ writePlatformPublishUrls
41
54
  } from "../lib/platform-publish.js";
42
55
 
43
56
  const __filename = fileURLToPath(import.meta.url);
@@ -173,6 +186,7 @@ const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-det
173
186
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
174
187
  const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
175
188
  const ARTICLE_PREVIEW_URLS = {
189
+ "33": "https://tideng.qingcigame.com/official/news.html",
176
190
  "50": "https://mtzg.qingcijoy.com/news-details.html",
177
191
  "69": "https://hjwq.qingcijoy.com/news-details.html",
178
192
  "74": "https://sakura.qingcijoy.com/news-details.html",
@@ -226,10 +240,11 @@ Usage:
226
240
  qcplay-cli article
227
241
  qcplay-cli article init [file]
228
242
  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]
243
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>] [--backend <url>] [--local]
230
244
  qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
231
245
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
232
246
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
247
+ 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
248
  qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
234
249
  qcplay-cli publish --project <项目> --list-platforms
235
250
  qcplay-cli rules [init|validate|list] [--rules-file <json>]
@@ -264,22 +279,105 @@ function printArticleHelp() {
264
279
  qcplay-cli article
265
280
  qcplay-cli article init [file]
266
281
  qcplay-cli article west-init [file]
267
- qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
282
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
268
283
  qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
269
284
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
270
285
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
271
286
  }
272
287
 
288
+ function printWechatHelp() {
289
+ console.log(`Usage:
290
+ 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>]
291
+
292
+ 只读取微信公众号接口返回的 day_total,并按游戏和月份生成表格。
293
+ 支持的 game_id: ${Object.entries(WECHAT_GAME_CATALOG).map(([id, name]) => `${id}(${name})`).join("、")}
294
+ 当前日期作为结束日期时,必须填写到昨天;超过 30 天的范围会按月份和 30 天窗口自动拆分请求。`);
295
+ }
296
+
297
+ function parseWechatArticleOptions(args) {
298
+ const options = {
299
+ gameIds: [],
300
+ beginDate: "",
301
+ endDate: "",
302
+ month: "",
303
+ output: "",
304
+ format: "markdown",
305
+ backend: undefined,
306
+ local: false
307
+ };
308
+ for (let index = 0; index < args.length; index += 1) {
309
+ const current = args[index];
310
+ if (["--game-id", "--game", "--begin-date", "--start", "--end-date", "--end", "--month", "--output", "--format", "--backend"].includes(current)) {
311
+ const next = args[index + 1];
312
+ if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
313
+ if (current === "--game-id" || current === "--game") {
314
+ options.gameIds.push(...next.split(",").map(normalizeWechatGameId));
315
+ } else if (current === "--begin-date" || current === "--start") {
316
+ options.beginDate = next;
317
+ } else if (current === "--end-date" || current === "--end") {
318
+ options.endDate = next;
319
+ } else if (current === "--month") {
320
+ options.month = next;
321
+ } else if (current === "--output") {
322
+ options.output = next;
323
+ } else if (current === "--format") {
324
+ options.format = next.toLowerCase();
325
+ } else {
326
+ options.backend = next;
327
+ }
328
+ index += 1;
329
+ continue;
330
+ }
331
+ if (current === "--local") {
332
+ options.local = true;
333
+ continue;
334
+ }
335
+ if (current === "--all-games") {
336
+ options.gameIds = Object.keys(WECHAT_GAME_CATALOG);
337
+ continue;
338
+ }
339
+ throw new Error(`未知参数: ${current}`);
340
+ }
341
+ options.gameIds = [...new Set(options.gameIds)];
342
+ if (options.gameIds.length === 0) throw new Error("请至少指定一个 --game-id");
343
+ if (options.month && (options.beginDate || options.endDate)) throw new Error("--month 不能与 --begin-date/--end-date 同时使用");
344
+ if (options.month) {
345
+ const range = monthDateRange(options.month, todayInShanghai());
346
+ options.beginDate = range.begin.toISOString().slice(0, 10);
347
+ options.endDate = range.end.toISOString().slice(0, 10);
348
+ }
349
+ if (!options.beginDate || !options.endDate) throw new Error("请指定 --month,或同时指定 --begin-date 和 --end-date");
350
+ if (!new Set(["markdown", "json"]).has(options.format)) throw new Error("--format 只能是 markdown 或 json");
351
+ return options;
352
+ }
353
+
273
354
  function parseArticleImportOptions(args) {
274
355
  const positional = [];
275
- const options = { rulesFile: "", project: "", platform: "", backend: undefined, local: false };
356
+ const options = { rulesFile: "", project: "", platform: "", format: "html", backend: undefined, local: false };
276
357
  for (let index = 0; index < args.length; index += 1) {
277
358
  const current = args[index];
278
- if (current === "--rules-file" || current === "--project" || current === "--platform" || current === "--backend") {
359
+ if (
360
+ current === "--rules-file" ||
361
+ current === "--project" ||
362
+ current === "--platform" ||
363
+ current === "--format" ||
364
+ current === "--backend"
365
+ ) {
279
366
  const next = args[index + 1];
280
367
  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;
368
+ if (current === "--format") {
369
+ options.format = next.toLowerCase();
370
+ } else {
371
+ const key =
372
+ current === "--rules-file"
373
+ ? "rulesFile"
374
+ : current === "--project"
375
+ ? "project"
376
+ : current === "--platform"
377
+ ? "platform"
378
+ : "backend";
379
+ options[key] = next;
380
+ }
283
381
  index += 1;
284
382
  continue;
285
383
  }
@@ -291,7 +389,14 @@ function parseArticleImportOptions(args) {
291
389
  positional.push(current);
292
390
  }
293
391
  if (positional.length > 2) throw new Error(`未知参数: ${positional.slice(2).join(" ")}`);
294
- return { sourceUrl: positional[0], file: positional[1] || "article.md", options };
392
+ if (!new Set(["markdown", "html"]).has(options.format)) {
393
+ throw new Error("--format 只能是 markdown 或 html");
394
+ }
395
+ return {
396
+ sourceUrl: positional[0],
397
+ file: positional[1] || (options.format === "markdown" ? "article.md" : "article.html"),
398
+ options
399
+ };
295
400
  }
296
401
 
297
402
  function printPublishHelp() {
@@ -311,8 +416,10 @@ Options:
311
416
  --config-file <json> 使用本地平台配置(主要用于测试)
312
417
  --rules-file <json> 使用指定内容规则;默认 ~/.qcplay/platform-rules.json
313
418
  --browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
314
- --review-draft, --review 官网外平台先准备草稿,预览后在 CMD 确认发布
315
- --direct, --no-review 用户已确认内容时跳过发布前二次预览确认
419
+ --review-draft, --review 官网外平台先准备草稿,预览后由当前 AI 对话确认发布
420
+ --direct, --no-review 需配合 AI 对话确认标记后才可跳过发布前预览
421
+ --publish-draft 仅 TapTap:发布标题匹配的已有草稿,不重新写入内容
422
+ --draft-only, --draft 仅保存或停留在官网外平台草稿,不执行正式发布
316
423
  --open-page 只打开真实浏览器发布页,供登录和页面流程测试,不读取或提交文章
317
424
  --switch-account 先打开发布页切换账号,确认后继续当前发布流程
318
425
  --keep-open 浏览器提交后等待用户确认再关闭
@@ -378,6 +485,56 @@ function parseRulesOptions(args) {
378
485
  return options;
379
486
  }
380
487
 
488
+ async function importWechatDataCommand(options) {
489
+ const config = await loadConfig();
490
+ const backendUrl = resolveAuthBackendUrl(options, config);
491
+ const reports = [];
492
+ for (const gameId of options.gameIds) {
493
+ const ranges = splitWechatDateRange(options.beginDate, options.endDate, todayInShanghai());
494
+ const dayTotal = new Map();
495
+ for (const range of ranges) {
496
+ const query = new URLSearchParams({
497
+ game_id: gameId,
498
+ begin_date: range.beginDate,
499
+ end_date: range.endDate
500
+ });
501
+ console.log(chalk.gray(`正在拉取${WECHAT_GAME_CATALOG[gameId]} ${range.beginDate} 至 ${range.endDate}`));
502
+ const response = await authenticatedRequest("GET", backendUrl, `/api/wechat/article?${query}`);
503
+ if (Number(response.code) !== 200) {
504
+ throw new Error(`${WECHAT_GAME_CATALOG[gameId]} ${range.beginDate} 至 ${range.endDate}:${response.message || `接口返回 code=${response.code ?? "未知"}`}`);
505
+ }
506
+ const items = Array.isArray(response.data?.day_total) ? response.data.day_total : [];
507
+ for (const item of items) {
508
+ const key = `${item.ref_date || ""}\u0000${item.msgid || ""}`;
509
+ dayTotal.set(key, item);
510
+ }
511
+ }
512
+ reports.push({
513
+ gameId,
514
+ gameName: WECHAT_GAME_CATALOG[gameId],
515
+ beginDate: options.beginDate,
516
+ endDate: options.endDate,
517
+ ranges,
518
+ dayTotal: [...dayTotal.values()].sort((left, right) => {
519
+ const byDate = String(left.ref_date || "").localeCompare(String(right.ref_date || ""));
520
+ return byDate || String(left.msgid || "").localeCompare(String(right.msgid || ""));
521
+ })
522
+ });
523
+ }
524
+
525
+ const output = options.format === "json" ? buildWechatArticleJson(reports) : renderWechatArticleMarkdown(reports);
526
+ if (options.output) {
527
+ const target = path.resolve(options.output);
528
+ if (await pathExists(target)) throw new Error(`输出文件已存在: ${target}`);
529
+ await ensureDir(path.dirname(target));
530
+ await fs.promises.writeFile(target, output, { encoding: "utf8", mode: 0o600 });
531
+ console.log(chalk.green(`微信公众号数据已生成: ${target}`));
532
+ console.log(`文章数据: ${reports.reduce((sum, report) => sum + report.dayTotal.length, 0)} 篇`);
533
+ return;
534
+ }
535
+ process.stdout.write(output);
536
+ }
537
+
381
538
  async function contentRulesCommand(action, args = []) {
382
539
  const options = parseRulesOptions(args);
383
540
  const file = path.resolve(options.rulesFile || CONTENT_RULES_FILE);
@@ -430,7 +587,7 @@ function printFeatures() {
430
587
  console.log(" qcplay-cli auth permissions --key www-article-list.store");
431
588
  console.log("");
432
589
  console.log(chalk.cyan("3. 发布官网文章"));
433
- console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.md");
590
+ console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.html");
434
591
  console.log(" qcplay-cli www-article-list.store article.md");
435
592
  console.log(" qcplay-cli article update [id]");
436
593
  console.log(" qcplay-cli article delete [id]");
@@ -442,6 +599,9 @@ function printFeatures() {
442
599
  console.log(chalk.cyan("5. 查看本地目录"));
443
600
  console.log(" qcplay-cli where");
444
601
  console.log("");
602
+ console.log(chalk.cyan("6. 拉取微信公众号数据"));
603
+ console.log(" qcplay-cli wechat article --game-id 39 --month 2026-08 --output wechat-2026-08.md");
604
+ console.log("");
445
605
  }
446
606
 
447
607
  function printWhere() {
@@ -814,6 +974,9 @@ function parseDistributionOptions(args) {
814
974
  browserChannel: "",
815
975
  reviewDraft: false,
816
976
  directPublish: false,
977
+ confirmed: false,
978
+ draftOnly: false,
979
+ publishDraft: false,
817
980
  keepOpen: false,
818
981
  openPage: false,
819
982
  switchAccount: false,
@@ -857,6 +1020,18 @@ function parseDistributionOptions(args) {
857
1020
  options.directPublish = true;
858
1021
  continue;
859
1022
  }
1023
+ if (current === "--confirmed") {
1024
+ options.confirmed = true;
1025
+ continue;
1026
+ }
1027
+ if (current === "--draft-only" || current === "--draft") {
1028
+ options.draftOnly = true;
1029
+ continue;
1030
+ }
1031
+ if (current === "--publish-draft") {
1032
+ options.publishDraft = true;
1033
+ continue;
1034
+ }
860
1035
 
861
1036
  const valueFlags = new Map([
862
1037
  ["--backend", "backend"],
@@ -921,11 +1096,11 @@ function applyPublishOverrides(parsedArticle, options) {
921
1096
  ...parsedArticle.meta,
922
1097
  ...(cover
923
1098
  ? {
924
- weibo_cover: cover,
925
- taptap_cover: cover,
926
- bilibili_cover: cover,
927
- xiaohongshu_images: cover
928
- }
1099
+ weibo_cover: cover,
1100
+ taptap_cover: cover,
1101
+ bilibili_cover: cover,
1102
+ xiaohongshu_images: cover
1103
+ }
929
1104
  : {}),
930
1105
  ...(weiboColumn ? { weibo_column: weiboColumn } : {})
931
1106
  };
@@ -1003,7 +1178,9 @@ function openBrowser(targetUrl) {
1003
1178
 
1004
1179
  if (process.platform === "win32") {
1005
1180
  command = "cmd";
1006
- args = ["/c", "start", "", targetUrl];
1181
+ // 关键修复:将 URL 中的 & 替换为 ^&
1182
+ const safeUrl = targetUrl.replace(/&/g, '^&');
1183
+ args = ["/c", "start", "", safeUrl];
1007
1184
  } else if (process.platform === "darwin") {
1008
1185
  command = "open";
1009
1186
  args = [targetUrl];
@@ -1344,7 +1521,7 @@ function backendOriginsMatch(left, right) {
1344
1521
  }
1345
1522
  }
1346
1523
 
1347
- async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1524
+ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
1348
1525
  const authState = await loadAuthState();
1349
1526
  await writeJson(ARTICLE_STATE_FILE, {
1350
1527
  article_id: articleId,
@@ -1354,13 +1531,15 @@ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl
1354
1531
  backend_url: backendUrl,
1355
1532
  administrator_id: Number(authState.id || 0),
1356
1533
  account: normalizeText(authState.account),
1534
+ game_id: normalizeText(metadata.game_id),
1535
+ cate_id: normalizeText(metadata.cate_id),
1357
1536
  updated_at: Date.now()
1358
1537
  });
1359
1538
  }
1360
1539
 
1361
- async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1540
+ async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
1362
1541
  try {
1363
- await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target);
1542
+ await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target, metadata);
1364
1543
  return "";
1365
1544
  } catch (error) {
1366
1545
  return normalizeText(error?.message) || String(error);
@@ -1701,14 +1880,14 @@ function applyInlineMarkdown(text) {
1701
1880
  output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
1702
1881
  return storeToken(
1703
1882
  `<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;" />'
1883
+ 'style="display:block;box-sizing:border-box;width:auto;max-width:100%;height:auto;object-fit:contain;margin:24px auto;" />'
1705
1884
  );
1706
1885
  });
1707
1886
 
1708
1887
  output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
1709
1888
  return storeToken(
1710
1889
  `<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>`
1890
+ `style="color:#576b95;text-decoration:none;overflow-wrap:anywhere;word-break:break-all;">${escapeHtml(label.trim())}</a>`
1712
1891
  );
1713
1892
  });
1714
1893
 
@@ -1754,7 +1933,7 @@ function markdownToHtml(markdown) {
1754
1933
 
1755
1934
  html.push(
1756
1935
  `<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>`
1936
+ `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
1758
1937
  );
1759
1938
  paragraph = [];
1760
1939
  }
@@ -1766,9 +1945,9 @@ function markdownToHtml(markdown) {
1766
1945
 
1767
1946
  html.push(
1768
1947
  '<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>`
1948
+ `<p style="margin:0;font-size:15px;line-height:1.8;">${quote
1949
+ .map(line => applyInlineMarkdown(line))
1950
+ .join("<br />")}</p></blockquote>`
1772
1951
  );
1773
1952
  quote = [];
1774
1953
  }
@@ -1782,7 +1961,7 @@ function markdownToHtml(markdown) {
1782
1961
 
1783
1962
  html.push(
1784
1963
  `<${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}>`
1964
+ `${listItems.map(item => `<li style="margin:6px 0;">${applyInlineMarkdown(item)}</li>`).join("")}</${listType}>`
1786
1965
  );
1787
1966
  listType = "";
1788
1967
  listItems = [];
@@ -1795,7 +1974,7 @@ function markdownToHtml(markdown) {
1795
1974
 
1796
1975
  html.push(
1797
1976
  '<pre style="margin:24px 0;padding:16px;overflow:auto;border-radius:4px;background:#f5f7f8;">' +
1798
- `<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
1977
+ `<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
1799
1978
  );
1800
1979
  inCodeBlock = false;
1801
1980
  codeLines = [];
@@ -1834,18 +2013,18 @@ function markdownToHtml(markdown) {
1834
2013
  "font-size:15px;line-height:1.7;overflow-wrap:anywhere;word-break:break-word;";
1835
2014
  html.push(
1836
2015
  '<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>`
2016
+ '<table style="width:100%;min-width:560px;border-collapse:collapse;table-layout:auto;color:#3f3f3f;">' +
2017
+ `<thead><tr>${header
2018
+ .map(cell => `<th style="${cellStyle}background:#f5f7f8;font-weight:600;">${applyInlineMarkdown(cell)}</th>`)
2019
+ .join("")}</tr></thead>` +
2020
+ `<tbody>${body
2021
+ .map(
2022
+ row =>
2023
+ `<tr>${row
2024
+ .map(cell => `<td style="${cellStyle}">${applyInlineMarkdown(cell)}</td>`)
2025
+ .join("")}</tr>`
2026
+ )
2027
+ .join("")}</tbody></table></div>`
1849
2028
  );
1850
2029
  tableLines = [];
1851
2030
  }
@@ -1908,7 +2087,7 @@ function markdownToHtml(markdown) {
1908
2087
  const headingSize = level === 1 ? 24 : level === 2 ? 20 : 18;
1909
2088
  html.push(
1910
2089
  `<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}>`
2090
+ `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
1912
2091
  );
1913
2092
  continue;
1914
2093
  }
@@ -2074,6 +2253,23 @@ async function parseArticleFile(file) {
2074
2253
  }
2075
2254
 
2076
2255
  const raw = await fs.promises.readFile(articleFile, "utf8");
2256
+ if (path.extname(articleFile).toLowerCase() === ".html") {
2257
+ const { meta, richHtml } = parseRichArticleUploadFile(raw);
2258
+ return {
2259
+ articleFile,
2260
+ payload: buildRichArticleUploadPayload(meta, richHtml, {
2261
+ normalizeText,
2262
+ normalizeMappedValue,
2263
+ normalizeGameAndCategory,
2264
+ normalizeBoolLike,
2265
+ statusMap: STATUS_MAP,
2266
+ areaMap: AREA_MAP,
2267
+ westLanguages: WEST_ARTICLE_LANGUAGES
2268
+ }),
2269
+ meta,
2270
+ markdown: ""
2271
+ };
2272
+ }
2077
2273
  const parsed = parseFrontMatter(raw);
2078
2274
  return {
2079
2275
  articleFile,
@@ -2106,7 +2302,7 @@ async function initArticleTemplate(file = "article.md", template = "article.md")
2106
2302
  console.log("");
2107
2303
  }
2108
2304
 
2109
- async function importArticleCommand(sourceUrl, file = "article.md", options = {}) {
2305
+ async function importArticleCommand(sourceUrl, file = "article.html", options = {}) {
2110
2306
  if (!sourceUrl) {
2111
2307
  throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
2112
2308
  }
@@ -2151,14 +2347,22 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
2151
2347
  }
2152
2348
  });
2153
2349
  }
2154
- const output = buildOfficialArticleMarkdown(article);
2350
+ const projectMetadata = importProjectMetadata(options.project);
2351
+ if (projectMetadata) {
2352
+ article = { ...article, ...projectMetadata };
2353
+ }
2354
+ const output = options.format === "html" ? buildImportedArticleHtml(article) : buildOfficialArticleMarkdown(article);
2155
2355
  await ensureDir(path.dirname(targetFile));
2156
2356
  await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
2157
2357
 
2158
2358
  console.log("");
2159
- console.log(chalk.green(`${sourceLabel}已转换为官网文章草稿:`));
2359
+ console.log(chalk.green(`${sourceLabel}已转换为${options.format === "html" ? "HTML 富文本文章" : "官网文章草稿"}:`));
2160
2360
  console.log(targetFile);
2161
2361
  console.log(chalk.gray(`标题: ${article.title}`));
2362
+ const contentSafety = article.contentSafety || analyzeArticleContentSafety(article);
2363
+ for (const warning of contentSafety.warnings) {
2364
+ console.log(chalk.yellow(`内容安全提示: ${warning}`));
2365
+ }
2162
2366
  if (sourceLabel === "飞书文档") {
2163
2367
  console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
2164
2368
  console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
@@ -2188,7 +2392,13 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
2188
2392
  );
2189
2393
  }
2190
2394
  console.log("");
2191
- console.log(sourceLabel === "飞书文档" ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:" : "请补充 cate_id 等业务字段,确认内容后执行:");
2395
+ console.log(
2396
+ options.format === "html"
2397
+ ? "可直接用于官网或各分发平台,平台规则会在发布时按原有流程处理:"
2398
+ : sourceLabel === "飞书文档"
2399
+ ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:"
2400
+ : "请补充 cate_id 等业务字段,确认内容后执行:"
2401
+ );
2192
2402
  console.log("");
2193
2403
  console.log(` qcplay-cli www-article-list.store ${file}`);
2194
2404
  console.log("");
@@ -2251,9 +2461,9 @@ ${chalk.cyan("发布命令:")}
2251
2461
 
2252
2462
  qcplay-cli www-article-list.store article.md
2253
2463
 
2254
- ${chalk.cyan("从微信推文生成官网草稿:")}
2464
+ ${chalk.cyan("从微信推文拉取 HTML 富文本:")}
2255
2465
 
2256
- qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
2466
+ qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.html
2257
2467
 
2258
2468
  ${chalk.cyan("项目默认分类:")}
2259
2469
 
@@ -2277,6 +2487,21 @@ function inferDistributionProject(payload, explicitProject, meta = {}) {
2277
2487
  return "";
2278
2488
  }
2279
2489
 
2490
+ function importProjectMetadata(project) {
2491
+ const normalizedProject = normalizeText(project);
2492
+ if (!normalizedProject) return null;
2493
+ const gameId = normalizeMappedValue(normalizedProject, GAME_MAP);
2494
+ const game = gameCatalogEntry(gameId);
2495
+ if (!game) {
2496
+ throw new Error(`未知项目: ${normalizedProject}`);
2497
+ }
2498
+ return {
2499
+ gameId,
2500
+ categoryId: game.defaultCategory,
2501
+ distributionProject: normalizedProject
2502
+ };
2503
+ }
2504
+
2280
2505
  function printPlatformEntries(entries) {
2281
2506
  console.log("");
2282
2507
  console.log(chalk.cyan("可用发布平台:"));
@@ -2324,9 +2549,25 @@ async function publishPlatformsCommand(file, options) {
2324
2549
  throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
2325
2550
  }
2326
2551
 
2327
- const selectedEntries = options.platforms.length
2552
+ let selectedEntries = options.platforms.length
2328
2553
  ? selectRequestedEntries(candidates, options.platforms)
2329
2554
  : await promptForPlatformEntries(candidates);
2555
+ if (options.draftOnly) {
2556
+ selectedEntries = selectedEntries.filter(entry => entry.platformKey !== "website");
2557
+ if (selectedEntries.length === 0) {
2558
+ throw new Error("--draft-only 仅适用于官网之外的游戏平台");
2559
+ }
2560
+ }
2561
+ if (options.draftOnly && options.publishDraft) {
2562
+ throw new Error("--draft-only 与 --publish-draft 不能同时使用");
2563
+ }
2564
+ const needsActionSelection =
2565
+ !options.dryRun &&
2566
+ !options.openPage &&
2567
+ !options.switchAccount &&
2568
+ !options.draftOnly &&
2569
+ !(options.publishDraft && options.confirmed) &&
2570
+ !(options.directPublish && options.confirmed);
2330
2571
  if (options.openPage) {
2331
2572
  if (selectedEntries.some(entry => entry.publisher !== "browser")) {
2332
2573
  throw new Error("--open-page 仅支持有真实浏览器发布页的平台,请使用 --platform 指定浏览器平台");
@@ -2376,8 +2617,11 @@ async function publishPlatformsCommand(file, options) {
2376
2617
  const matchedRules = matchingContentRules(contentRules, planningArticle, entry).map(rule => rule.name);
2377
2618
  return {
2378
2619
  ...publicEntrySummary(entry),
2620
+ ...(options.draftOnly ? { mode: "draft-only" } : {}),
2379
2621
  ...(matchedRules.length > 0 ? { contentRules: matchedRules } : {}),
2380
- ...(entry.platformKey === "taptap" ? { taptap: tapTapPublishingPlan(planningArticle, entry) } : {}),
2622
+ ...(entry.platformKey === "taptap"
2623
+ ? { taptap: { ...tapTapPublishingPlan(planningArticle, entry), ...(options.draftOnly ? { draft: true } : {}) } }
2624
+ : {}),
2381
2625
  ...(entry.platformKey === "bilibili" ? { bilibili: bilibiliPublishingPlan(planningArticle, entry) } : {}),
2382
2626
  ...(entry.platformKey === "weibo" ? { weibo: weiboPublishingPlan(planningArticle, entry) } : {})
2383
2627
  };
@@ -2396,7 +2640,17 @@ async function publishPlatformsCommand(file, options) {
2396
2640
  meta: parsedArticle.meta,
2397
2641
  platformInputs
2398
2642
  };
2399
- const { successes, failures, skipped } = await runPlatformPublishSequence(selected, async entry => {
2643
+ const contentSafety = analyzeArticleContentSafety(article);
2644
+ for (const warning of contentSafety.warnings) {
2645
+ console.log(chalk.yellow(`内容安全提示: ${warning}`));
2646
+ }
2647
+ const browserEntries = selected.filter(entry => entry.publisher === "browser");
2648
+ // Each browser platform needs its own explicit AI-conversation authorisation.
2649
+ // Preparing every selected editor before asking keeps the user waiting behind
2650
+ // long image uploads on later platforms and leaves no safe action boundary.
2651
+ const executionEntries = needsActionSelection ? browserEntries.slice(0, 1) : selected;
2652
+ const pendingApprovalEntries = needsActionSelection ? browserEntries.slice(1) : [];
2653
+ const { successes, failures, skipped } = await runPlatformPublishSequence(executionEntries, async entry => {
2400
2654
  console.log("");
2401
2655
  console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
2402
2656
  let result;
@@ -2405,13 +2659,34 @@ async function publishPlatformsCommand(file, options) {
2405
2659
  } else {
2406
2660
  result = await publishPlatformEntry(entry, article, {
2407
2661
  browserChannel: options.browserChannel,
2408
- reviewDraft: options.reviewDraft,
2409
- directPublish: options.directPublish,
2662
+ reviewDraft: needsActionSelection || options.reviewDraft,
2663
+ // A review is always approved in the current AI conversation. Never
2664
+ // fall back to the legacy terminal confirmation path.
2665
+ awaitAIAction: needsActionSelection || options.reviewDraft,
2666
+ // The initial AI-authorisation preview prepares the complete native
2667
+ // editor draft, including body media. It still never clicks save or
2668
+ // publish; only the later explicit action may submit it. A platform
2669
+ // must not reach the action selection while any image is still
2670
+ // uploading, including editors that expose their fields after upload.
2671
+ previewTextOnly: false,
2672
+ directPublish: options.directPublish && options.confirmed,
2673
+ draftOnly: options.draftOnly,
2674
+ publishDraft: options.publishDraft,
2410
2675
  keepOpen: options.keepOpen,
2411
2676
  onProgress: message => console.log(chalk.gray(` ${message}`))
2412
2677
  });
2413
- if (entry.platformKey === "taptap" && result?.status === "draft") {
2414
- console.log(chalk.green("TapTap 草稿已保存"));
2678
+ if (result?.status === "draft") {
2679
+ console.log(chalk.green(`${entry.platform} 草稿已保存,浏览器保持打开`));
2680
+ if (result.approvalRequired) {
2681
+ console.log(
2682
+ JSON.stringify({
2683
+ event: "qcplay.draft-approval-required",
2684
+ platform: entry.platform,
2685
+ title: article.title,
2686
+ actions: result.approvalActions || ["publish", "cancel"]
2687
+ })
2688
+ );
2689
+ }
2415
2690
  return result;
2416
2691
  }
2417
2692
  if (entry.platformKey === "taptap" && result?.status === "scheduled") {
@@ -2425,6 +2700,42 @@ async function publishPlatformsCommand(file, options) {
2425
2700
  failures.forEach(({ entry, error }) => {
2426
2701
  console.error(chalk.red(`${entry.platform} 发布失败: ${error.message || error}`));
2427
2702
  });
2703
+
2704
+ if (needsActionSelection) {
2705
+ if (failures.length > 0) {
2706
+ throw new Error("待授权预览准备失败,请根据上面的错误处理后重试失败平台");
2707
+ }
2708
+ const pages = successes
2709
+ .map(({ entry, result }) => ({ platform: entry.platform, url: result?.url || "" }))
2710
+ .filter(item => item.url);
2711
+ const previewResults = successes.map(({ result }) => result || {});
2712
+ const imagesPending = previewResults.reduce((sum, result) => sum + Number(result.imagesPending || 0), 0);
2713
+ const requiresMediaBeforeText = previewResults.some(result => result.requiresMediaBeforeText);
2714
+ const contentPrepared = previewResults.every(result => result.contentPrepared !== false) && imagesPending === 0;
2715
+ if (!contentPrepared || requiresMediaBeforeText) {
2716
+ throw new Error("编辑器尚未完成正文或图片回读校验,暂不提供发布授权选项");
2717
+ }
2718
+ console.log(
2719
+ JSON.stringify({
2720
+ event: "qcplay.publish-action-required",
2721
+ title: previewResults.find(result => normalizeText(result.title))?.title || article.title,
2722
+ platforms: executionEntries.map(entry => entry.platform),
2723
+ ...(pendingApprovalEntries.length > 0
2724
+ ? { pendingPlatforms: pendingApprovalEntries.map(entry => entry.platform) }
2725
+ : {}),
2726
+ pages,
2727
+ contentPrepared,
2728
+ imagesPending,
2729
+ actions: ["publish", "save_draft", "cancel"]
2730
+ })
2731
+ );
2732
+ return;
2733
+ }
2734
+
2735
+ const urlsFile = await writePlatformPublishUrls(file, successes);
2736
+ if (urlsFile) {
2737
+ console.log(chalk.green(`平台地址已写入: ${urlsFile}`));
2738
+ }
2428
2739
  if (skipped.length > 0) {
2429
2740
  console.error(chalk.yellow("已停止后续平台发布,请先处理当前浏览器平台的登录或页面问题。"));
2430
2741
  }
@@ -2561,11 +2872,20 @@ async function updateArticleStatusCommand(articleIdValue, targetStatus, options,
2561
2872
 
2562
2873
  const response = await authenticatedRequest("PATCH", backendUrl, articleApiPath(target, "status"), payload);
2563
2874
  const responseStatus = normalizeText(response.data?.status) || targetStatus;
2875
+ const recentState = await readJson(ARTICLE_STATE_FILE);
2876
+ const recentStateMatches =
2877
+ String(recentState.article_id || "") === String(articleId) &&
2878
+ (recentState.target || "domestic") === target;
2879
+ const previewPayload = recentStateMatches
2880
+ ? { game_id: recentState.game_id, cate_id: recentState.cate_id }
2881
+ : {};
2564
2882
  const previewUrl =
2565
2883
  responseStatus === "1"
2566
- ? normalizeText(response.data?.preview_url) || (target === "domestic" ? articlePreviewUrl(articleId) : "")
2884
+ ? normalizeText(response.data?.preview_url) ||
2885
+ (recentStateMatches ? normalizeText(recentState.preview_url) : "") ||
2886
+ (target === "domestic" ? articlePreviewUrl(articleId, previewPayload) : "")
2567
2887
  : "";
2568
- const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target);
2888
+ const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target, previewPayload);
2569
2889
 
2570
2890
  console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
2571
2891
  console.log(`文章 ID: ${articleId}`);
@@ -2654,6 +2974,26 @@ async function main() {
2654
2974
  return;
2655
2975
  }
2656
2976
 
2977
+ if (command === "wechat") {
2978
+ if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2979
+ printWechatHelp();
2980
+ return;
2981
+ }
2982
+ if (subcommand !== "article") {
2983
+ await runWithErrorBanner("微信公众号数据失败", async () => {
2984
+ throw new Error(`未知 wechat 命令: ${subcommand}`);
2985
+ });
2986
+ return;
2987
+ }
2988
+ if (rest.includes("-h") || rest.includes("--help")) {
2989
+ printWechatHelp();
2990
+ return;
2991
+ }
2992
+ const parsed = parseWechatArticleOptions(rest);
2993
+ await runWithErrorBanner("微信公众号数据失败", () => importWechatDataCommand(parsed));
2994
+ return;
2995
+ }
2996
+
2657
2997
  if (command === "article") {
2658
2998
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2659
2999
  if (!subcommand) {
@@ -2677,6 +3017,10 @@ async function main() {
2677
3017
  }
2678
3018
 
2679
3019
  if (subcommand === "import") {
3020
+ if (rest.length === 1 && ["--help", "-h"].includes(rest[0])) {
3021
+ printArticleHelp();
3022
+ return;
3023
+ }
2680
3024
  const parsed = parseArticleImportOptions(rest);
2681
3025
  await runWithErrorBanner("转换失败", () =>
2682
3026
  importArticleCommand(parsed.sourceUrl, parsed.file, parsed.options)