@qcplay/cli 1.0.17 → 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,11 +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
- sanitizeArticleRichHtml
18
+ sanitizeArticleRichHtml,
19
+ uploadMediaToBackend
17
20
  } from "../lib/wechat-article.js";
21
+ import { buildRichArticleUploadPayload, parseRichArticleUploadFile } from "../lib/rich-article-upload.js";
18
22
  import { importLarkArticle, parseLarkDocumentUrl } from "../lib/lark-article.js";
19
23
  import {
20
24
  CONTENT_RULES_TEMPLATE,
@@ -23,6 +27,15 @@ import {
23
27
  loadContentRules,
24
28
  matchingContentRules
25
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";
26
39
  import {
27
40
  availableProjects,
28
41
  bilibiliPublishingPlan,
@@ -36,7 +49,8 @@ import {
36
49
  runPlatformPublishSequence,
37
50
  selectRequestedEntries,
38
51
  tapTapPublishingPlan,
39
- weiboPublishingPlan
52
+ weiboPublishingPlan,
53
+ writePlatformPublishUrls
40
54
  } from "../lib/platform-publish.js";
41
55
 
42
56
  const __filename = fileURLToPath(import.meta.url);
@@ -172,6 +186,7 @@ const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-det
172
186
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
173
187
  const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
174
188
  const ARTICLE_PREVIEW_URLS = {
189
+ "33": "https://tideng.qingcigame.com/official/news.html",
175
190
  "50": "https://mtzg.qingcijoy.com/news-details.html",
176
191
  "69": "https://hjwq.qingcijoy.com/news-details.html",
177
192
  "74": "https://sakura.qingcijoy.com/news-details.html",
@@ -225,10 +240,11 @@ Usage:
225
240
  qcplay-cli article
226
241
  qcplay-cli article init [file]
227
242
  qcplay-cli article west-init [file]
228
- qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>]
243
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>] [--backend <url>] [--local]
229
244
  qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
230
245
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
231
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>]
232
248
  qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
233
249
  qcplay-cli publish --project <项目> --list-platforms
234
250
  qcplay-cli rules [init|validate|list] [--rules-file <json>]
@@ -263,29 +279,124 @@ function printArticleHelp() {
263
279
  qcplay-cli article
264
280
  qcplay-cli article init [file]
265
281
  qcplay-cli article west-init [file]
266
- qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json>] [--project <项目>]
282
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--format html|markdown] [--rules-file <json>] [--project <项目>] [--backend <url>] [--local]
267
283
  qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
268
284
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
269
285
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
270
286
  }
271
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
+
272
354
  function parseArticleImportOptions(args) {
273
355
  const positional = [];
274
- const options = { rulesFile: "", project: "", platform: "" };
356
+ const options = { rulesFile: "", project: "", platform: "", format: "html", backend: undefined, local: false };
275
357
  for (let index = 0; index < args.length; index += 1) {
276
358
  const current = args[index];
277
- if (current === "--rules-file" || current === "--project" || current === "--platform") {
359
+ if (
360
+ current === "--rules-file" ||
361
+ current === "--project" ||
362
+ current === "--platform" ||
363
+ current === "--format" ||
364
+ current === "--backend"
365
+ ) {
278
366
  const next = args[index + 1];
279
367
  if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
280
- options[current === "--rules-file" ? "rulesFile" : current === "--project" ? "project" : "platform"] = 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
+ }
281
381
  index += 1;
282
382
  continue;
283
383
  }
384
+ if (current === "--local") {
385
+ options.local = true;
386
+ continue;
387
+ }
284
388
  if (current.startsWith("--")) throw new Error(`未知参数: ${current}`);
285
389
  positional.push(current);
286
390
  }
287
391
  if (positional.length > 2) throw new Error(`未知参数: ${positional.slice(2).join(" ")}`);
288
- 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
+ };
289
400
  }
290
401
 
291
402
  function printPublishHelp() {
@@ -305,7 +416,10 @@ Options:
305
416
  --config-file <json> 使用本地平台配置(主要用于测试)
306
417
  --rules-file <json> 使用指定内容规则;默认 ~/.qcplay/platform-rules.json
307
418
  --browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
308
- --review-draft, --review 官网外平台先准备草稿,预览后在 CMD 确认发布
419
+ --review-draft, --review 官网外平台先准备草稿,预览后由当前 AI 对话确认发布
420
+ --direct, --no-review 需配合 AI 对话确认标记后才可跳过发布前预览
421
+ --publish-draft 仅 TapTap:发布标题匹配的已有草稿,不重新写入内容
422
+ --draft-only, --draft 仅保存或停留在官网外平台草稿,不执行正式发布
309
423
  --open-page 只打开真实浏览器发布页,供登录和页面流程测试,不读取或提交文章
310
424
  --switch-account 先打开发布页切换账号,确认后继续当前发布流程
311
425
  --keep-open 浏览器提交后等待用户确认再关闭
@@ -371,6 +485,56 @@ function parseRulesOptions(args) {
371
485
  return options;
372
486
  }
373
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
+
374
538
  async function contentRulesCommand(action, args = []) {
375
539
  const options = parseRulesOptions(args);
376
540
  const file = path.resolve(options.rulesFile || CONTENT_RULES_FILE);
@@ -423,7 +587,7 @@ function printFeatures() {
423
587
  console.log(" qcplay-cli auth permissions --key www-article-list.store");
424
588
  console.log("");
425
589
  console.log(chalk.cyan("3. 发布官网文章"));
426
- console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.md");
590
+ console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.html");
427
591
  console.log(" qcplay-cli www-article-list.store article.md");
428
592
  console.log(" qcplay-cli article update [id]");
429
593
  console.log(" qcplay-cli article delete [id]");
@@ -435,6 +599,9 @@ function printFeatures() {
435
599
  console.log(chalk.cyan("5. 查看本地目录"));
436
600
  console.log(" qcplay-cli where");
437
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("");
438
605
  }
439
606
 
440
607
  function printWhere() {
@@ -806,6 +973,10 @@ function parseDistributionOptions(args) {
806
973
  weiboColumn: "",
807
974
  browserChannel: "",
808
975
  reviewDraft: false,
976
+ directPublish: false,
977
+ confirmed: false,
978
+ draftOnly: false,
979
+ publishDraft: false,
809
980
  keepOpen: false,
810
981
  openPage: false,
811
982
  switchAccount: false,
@@ -845,6 +1016,22 @@ function parseDistributionOptions(args) {
845
1016
  options.reviewDraft = true;
846
1017
  continue;
847
1018
  }
1019
+ if (current === "--direct" || current === "--no-review") {
1020
+ options.directPublish = true;
1021
+ continue;
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
+ }
848
1035
 
849
1036
  const valueFlags = new Map([
850
1037
  ["--backend", "backend"],
@@ -909,11 +1096,11 @@ function applyPublishOverrides(parsedArticle, options) {
909
1096
  ...parsedArticle.meta,
910
1097
  ...(cover
911
1098
  ? {
912
- weibo_cover: cover,
913
- taptap_cover: cover,
914
- bilibili_cover: cover,
915
- xiaohongshu_images: cover
916
- }
1099
+ weibo_cover: cover,
1100
+ taptap_cover: cover,
1101
+ bilibili_cover: cover,
1102
+ xiaohongshu_images: cover
1103
+ }
917
1104
  : {}),
918
1105
  ...(weiboColumn ? { weibo_column: weiboColumn } : {})
919
1106
  };
@@ -991,7 +1178,9 @@ function openBrowser(targetUrl) {
991
1178
 
992
1179
  if (process.platform === "win32") {
993
1180
  command = "cmd";
994
- args = ["/c", "start", "", targetUrl];
1181
+ // 关键修复:将 URL 中的 & 替换为 ^&
1182
+ const safeUrl = targetUrl.replace(/&/g, '^&');
1183
+ args = ["/c", "start", "", safeUrl];
995
1184
  } else if (process.platform === "darwin") {
996
1185
  command = "open";
997
1186
  args = [targetUrl];
@@ -1332,7 +1521,7 @@ function backendOriginsMatch(left, right) {
1332
1521
  }
1333
1522
  }
1334
1523
 
1335
- async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1524
+ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
1336
1525
  const authState = await loadAuthState();
1337
1526
  await writeJson(ARTICLE_STATE_FILE, {
1338
1527
  article_id: articleId,
@@ -1342,13 +1531,15 @@ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl
1342
1531
  backend_url: backendUrl,
1343
1532
  administrator_id: Number(authState.id || 0),
1344
1533
  account: normalizeText(authState.account),
1534
+ game_id: normalizeText(metadata.game_id),
1535
+ cate_id: normalizeText(metadata.cate_id),
1345
1536
  updated_at: Date.now()
1346
1537
  });
1347
1538
  }
1348
1539
 
1349
- async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1540
+ async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic", metadata = {}) {
1350
1541
  try {
1351
- await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target);
1542
+ await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target, metadata);
1352
1543
  return "";
1353
1544
  } catch (error) {
1354
1545
  return normalizeText(error?.message) || String(error);
@@ -1689,14 +1880,14 @@ function applyInlineMarkdown(text) {
1689
1880
  output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
1690
1881
  return storeToken(
1691
1882
  `<img src="${escapeHtml(url.trim())}" alt="${escapeHtml(alt.trim())}" ` +
1692
- '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;" />'
1693
1884
  );
1694
1885
  });
1695
1886
 
1696
1887
  output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
1697
1888
  return storeToken(
1698
1889
  `<a href="${escapeHtml(url.trim())}" target="_blank" rel="noreferrer" ` +
1699
- `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>`
1700
1891
  );
1701
1892
  });
1702
1893
 
@@ -1742,7 +1933,7 @@ function markdownToHtml(markdown) {
1742
1933
 
1743
1934
  html.push(
1744
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;">` +
1745
- `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
1936
+ `${paragraph.map(line => applyInlineMarkdown(line)).join("<br />")}</p>`
1746
1937
  );
1747
1938
  paragraph = [];
1748
1939
  }
@@ -1754,9 +1945,9 @@ function markdownToHtml(markdown) {
1754
1945
 
1755
1946
  html.push(
1756
1947
  '<blockquote style="margin:24px 0;padding:14px 18px;border-left:4px solid #17b9c2;background:#f5f7f8;color:#57606a;">' +
1757
- `<p style="margin:0;font-size:15px;line-height:1.8;">${quote
1758
- .map(line => applyInlineMarkdown(line))
1759
- .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>`
1760
1951
  );
1761
1952
  quote = [];
1762
1953
  }
@@ -1770,7 +1961,7 @@ function markdownToHtml(markdown) {
1770
1961
 
1771
1962
  html.push(
1772
1963
  `<${listType} style="margin:0 0 20px;padding-left:1.6em;font-size:16px;line-height:1.9;color:#3f3f3f;">` +
1773
- `${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}>`
1774
1965
  );
1775
1966
  listType = "";
1776
1967
  listItems = [];
@@ -1783,7 +1974,7 @@ function markdownToHtml(markdown) {
1783
1974
 
1784
1975
  html.push(
1785
1976
  '<pre style="margin:24px 0;padding:16px;overflow:auto;border-radius:4px;background:#f5f7f8;">' +
1786
- `<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
1977
+ `<code>${escapeHtml(codeLines.join("\n"))}</code></pre>`
1787
1978
  );
1788
1979
  inCodeBlock = false;
1789
1980
  codeLines = [];
@@ -1822,18 +2013,18 @@ function markdownToHtml(markdown) {
1822
2013
  "font-size:15px;line-height:1.7;overflow-wrap:anywhere;word-break:break-word;";
1823
2014
  html.push(
1824
2015
  '<div style="width:100%;margin:24px 0;overflow-x:auto;-webkit-overflow-scrolling:touch;">' +
1825
- '<table style="width:100%;min-width:560px;border-collapse:collapse;table-layout:auto;color:#3f3f3f;">' +
1826
- `<thead><tr>${header
1827
- .map(cell => `<th style="${cellStyle}background:#f5f7f8;font-weight:600;">${applyInlineMarkdown(cell)}</th>`)
1828
- .join("")}</tr></thead>` +
1829
- `<tbody>${body
1830
- .map(
1831
- row =>
1832
- `<tr>${row
1833
- .map(cell => `<td style="${cellStyle}">${applyInlineMarkdown(cell)}</td>`)
1834
- .join("")}</tr>`
1835
- )
1836
- .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>`
1837
2028
  );
1838
2029
  tableLines = [];
1839
2030
  }
@@ -1896,7 +2087,7 @@ function markdownToHtml(markdown) {
1896
2087
  const headingSize = level === 1 ? 24 : level === 2 ? 20 : 18;
1897
2088
  html.push(
1898
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;">` +
1899
- `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
2090
+ `${applyInlineMarkdown(headingMatch[2].trim())}</h${level}>`
1900
2091
  );
1901
2092
  continue;
1902
2093
  }
@@ -2062,6 +2253,23 @@ async function parseArticleFile(file) {
2062
2253
  }
2063
2254
 
2064
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
+ }
2065
2273
  const parsed = parseFrontMatter(raw);
2066
2274
  return {
2067
2275
  articleFile,
@@ -2094,7 +2302,7 @@ async function initArticleTemplate(file = "article.md", template = "article.md")
2094
2302
  console.log("");
2095
2303
  }
2096
2304
 
2097
- async function importArticleCommand(sourceUrl, file = "article.md", options = {}) {
2305
+ async function importArticleCommand(sourceUrl, file = "article.html", options = {}) {
2098
2306
  if (!sourceUrl) {
2099
2307
  throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
2100
2308
  }
@@ -2124,24 +2332,37 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
2124
2332
  if (options.rulesFile) {
2125
2333
  console.log(chalk.gray(`已加载内容规则: ${contentRules.file}(${contentRules.rules.length} 条)`));
2126
2334
  }
2335
+ const config = await loadConfig();
2336
+ const backendUrl = resolveAuthBackendUrl(options, config);
2337
+ const authState = await requireAuthState(backendUrl);
2127
2338
  article = await importWechatArticle(sourceUrl, {
2128
2339
  contentRules,
2129
2340
  project: options.project,
2130
2341
  platform: options.platform,
2342
+ uploadImage: image => uploadMediaToBackend(image, "image", backendUrl, authState.access_token),
2343
+ uploadVideo: video => uploadMediaToBackend(video, "video", backendUrl, authState.access_token),
2131
2344
  onProgress: ({ mediaType = "image", current, total }) => {
2132
2345
  const label = mediaType === "video" ? "正文视频" : "正文图片";
2133
2346
  console.log(chalk.gray(`正在转存${label} ${current}/${total}`));
2134
2347
  }
2135
2348
  });
2136
2349
  }
2137
- 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);
2138
2355
  await ensureDir(path.dirname(targetFile));
2139
2356
  await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
2140
2357
 
2141
2358
  console.log("");
2142
- console.log(chalk.green(`${sourceLabel}已转换为官网文章草稿:`));
2359
+ console.log(chalk.green(`${sourceLabel}已转换为${options.format === "html" ? "HTML 富文本文章" : "官网文章草稿"}:`));
2143
2360
  console.log(targetFile);
2144
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
+ }
2145
2366
  if (sourceLabel === "飞书文档") {
2146
2367
  console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
2147
2368
  console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
@@ -2171,7 +2392,13 @@ async function importArticleCommand(sourceUrl, file = "article.md", options = {}
2171
2392
  );
2172
2393
  }
2173
2394
  console.log("");
2174
- 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
+ );
2175
2402
  console.log("");
2176
2403
  console.log(` qcplay-cli www-article-list.store ${file}`);
2177
2404
  console.log("");
@@ -2234,9 +2461,9 @@ ${chalk.cyan("发布命令:")}
2234
2461
 
2235
2462
  qcplay-cli www-article-list.store article.md
2236
2463
 
2237
- ${chalk.cyan("从微信推文生成官网草稿:")}
2464
+ ${chalk.cyan("从微信推文拉取 HTML 富文本:")}
2238
2465
 
2239
- 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
2240
2467
 
2241
2468
  ${chalk.cyan("项目默认分类:")}
2242
2469
 
@@ -2260,6 +2487,21 @@ function inferDistributionProject(payload, explicitProject, meta = {}) {
2260
2487
  return "";
2261
2488
  }
2262
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
+
2263
2505
  function printPlatformEntries(entries) {
2264
2506
  console.log("");
2265
2507
  console.log(chalk.cyan("可用发布平台:"));
@@ -2307,9 +2549,25 @@ async function publishPlatformsCommand(file, options) {
2307
2549
  throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
2308
2550
  }
2309
2551
 
2310
- const selectedEntries = options.platforms.length
2552
+ let selectedEntries = options.platforms.length
2311
2553
  ? selectRequestedEntries(candidates, options.platforms)
2312
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);
2313
2571
  if (options.openPage) {
2314
2572
  if (selectedEntries.some(entry => entry.publisher !== "browser")) {
2315
2573
  throw new Error("--open-page 仅支持有真实浏览器发布页的平台,请使用 --platform 指定浏览器平台");
@@ -2359,8 +2617,11 @@ async function publishPlatformsCommand(file, options) {
2359
2617
  const matchedRules = matchingContentRules(contentRules, planningArticle, entry).map(rule => rule.name);
2360
2618
  return {
2361
2619
  ...publicEntrySummary(entry),
2620
+ ...(options.draftOnly ? { mode: "draft-only" } : {}),
2362
2621
  ...(matchedRules.length > 0 ? { contentRules: matchedRules } : {}),
2363
- ...(entry.platformKey === "taptap" ? { taptap: tapTapPublishingPlan(planningArticle, entry) } : {}),
2622
+ ...(entry.platformKey === "taptap"
2623
+ ? { taptap: { ...tapTapPublishingPlan(planningArticle, entry), ...(options.draftOnly ? { draft: true } : {}) } }
2624
+ : {}),
2364
2625
  ...(entry.platformKey === "bilibili" ? { bilibili: bilibiliPublishingPlan(planningArticle, entry) } : {}),
2365
2626
  ...(entry.platformKey === "weibo" ? { weibo: weiboPublishingPlan(planningArticle, entry) } : {})
2366
2627
  };
@@ -2379,7 +2640,17 @@ async function publishPlatformsCommand(file, options) {
2379
2640
  meta: parsedArticle.meta,
2380
2641
  platformInputs
2381
2642
  };
2382
- 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 => {
2383
2654
  console.log("");
2384
2655
  console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
2385
2656
  let result;
@@ -2388,12 +2659,34 @@ async function publishPlatformsCommand(file, options) {
2388
2659
  } else {
2389
2660
  result = await publishPlatformEntry(entry, article, {
2390
2661
  browserChannel: options.browserChannel,
2391
- reviewDraft: options.reviewDraft,
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,
2392
2675
  keepOpen: options.keepOpen,
2393
2676
  onProgress: message => console.log(chalk.gray(` ${message}`))
2394
2677
  });
2395
- if (entry.platformKey === "taptap" && result?.status === "draft") {
2396
- 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
+ }
2397
2690
  return result;
2398
2691
  }
2399
2692
  if (entry.platformKey === "taptap" && result?.status === "scheduled") {
@@ -2407,6 +2700,42 @@ async function publishPlatformsCommand(file, options) {
2407
2700
  failures.forEach(({ entry, error }) => {
2408
2701
  console.error(chalk.red(`${entry.platform} 发布失败: ${error.message || error}`));
2409
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
+ }
2410
2739
  if (skipped.length > 0) {
2411
2740
  console.error(chalk.yellow("已停止后续平台发布,请先处理当前浏览器平台的登录或页面问题。"));
2412
2741
  }
@@ -2543,11 +2872,20 @@ async function updateArticleStatusCommand(articleIdValue, targetStatus, options,
2543
2872
 
2544
2873
  const response = await authenticatedRequest("PATCH", backendUrl, articleApiPath(target, "status"), payload);
2545
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
+ : {};
2546
2882
  const previewUrl =
2547
2883
  responseStatus === "1"
2548
- ? 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) : "")
2549
2887
  : "";
2550
- const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target);
2888
+ const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target, previewPayload);
2551
2889
 
2552
2890
  console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
2553
2891
  console.log(`文章 ID: ${articleId}`);
@@ -2636,6 +2974,26 @@ async function main() {
2636
2974
  return;
2637
2975
  }
2638
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
+
2639
2997
  if (command === "article") {
2640
2998
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2641
2999
  if (!subcommand) {
@@ -2659,6 +3017,10 @@ async function main() {
2659
3017
  }
2660
3018
 
2661
3019
  if (subcommand === "import") {
3020
+ if (rest.length === 1 && ["--help", "-h"].includes(rest[0])) {
3021
+ printArticleHelp();
3022
+ return;
3023
+ }
2662
3024
  const parsed = parseArticleImportOptions(rest);
2663
3025
  await runWithErrorBanner("转换失败", () =>
2664
3026
  importArticleCommand(parsed.sourceUrl, parsed.file, parsed.options)