@qcplay/cli 1.0.13 → 1.0.15

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
@@ -15,6 +15,29 @@ import {
15
15
  normalizeArticleColor,
16
16
  sanitizeArticleRichHtml
17
17
  } from "../lib/wechat-article.js";
18
+ import { importLarkArticle, parseLarkDocumentUrl } from "../lib/lark-article.js";
19
+ import {
20
+ CONTENT_RULES_TEMPLATE,
21
+ DEFAULT_CONTENT_RULES_FILE,
22
+ applyContentRules,
23
+ loadContentRules,
24
+ matchingContentRules
25
+ } from "../lib/content-rules.js";
26
+ import {
27
+ availableProjects,
28
+ bilibiliPublishingPlan,
29
+ entriesForProject,
30
+ loadPlatformEntries,
31
+ openPublishPage,
32
+ prepareArticleForPlatform,
33
+ promptForPlatformEntries,
34
+ publicEntrySummary,
35
+ publishPlatformEntry,
36
+ runPlatformPublishSequence,
37
+ selectRequestedEntries,
38
+ tapTapPublishingPlan,
39
+ weiboPublishingPlan
40
+ } from "../lib/platform-publish.js";
18
41
 
19
42
  const __filename = fileURLToPath(import.meta.url);
20
43
  const __dirname = path.dirname(__filename);
@@ -38,6 +61,48 @@ function resolveAuthPagePath() {
38
61
  return path.resolve(__dirname, "../web/auth.html");
39
62
  }
40
63
 
64
+ const GAME_CATALOG = {
65
+ "39": {
66
+ names: ["39", "最强蜗牛"],
67
+ defaultCategory: "2",
68
+ categories: { "2": "综合", "3": "活动", "7": "游戏攻略", "8": "视频中心", "9": "萌新入门", "25": "萌新入门-攻略专区", "26": "高手进阶", "27": "活动攻略", "29": "视频攻略" }
69
+ },
70
+ "41": {
71
+ names: ["41", "阿瑞斯病毒2", "阿瑞斯病毒"],
72
+ defaultCategory: "2",
73
+ categories: { "2": "综合", "3": "活动", "7": "游戏攻略", "8": "视频中心", "9": "萌新入门", "25": "萌新入门-攻略专区", "26": "高手进阶", "27": "活动攻略", "29": "视频攻略" }
74
+ },
75
+ "69": {
76
+ names: ["69", "新仙剑", "新仙剑奇侠传之挥剑问情手游"],
77
+ defaultCategory: "61",
78
+ categories: { "61": "全部", "62": "活动", "63": "公告" }
79
+ },
80
+ "33": {
81
+ names: ["33", "提灯与地下城"],
82
+ defaultCategory: "40",
83
+ categories: { "40": "新闻", "41": "公告", "42": "活动" }
84
+ },
85
+ "74": {
86
+ names: ["74", "魔卡少女樱", "魔卡少女樱(情报站)", "魔卡少女樱情报站", "魔卡少女樱:回忆钥匙"],
87
+ defaultCategory: "71",
88
+ categories: { "71": "最新", "72": "公告", "73": "活动" }
89
+ },
90
+ "50": {
91
+ names: ["50", "迷途之光"],
92
+ defaultCategory: "109",
93
+ categories: { "109": "全部", "110": "公告", "111": "活动" }
94
+ },
95
+ "64": {
96
+ names: ["64", "最强蜗牛欧美", "最强蜗牛(欧美)", "最强蜗牛国际服"],
97
+ defaultCategory: "2",
98
+ categories: { "2": "综合", "3": "活动", "7": "游戏攻略", "8": "视频中心", "9": "萌新入门", "25": "萌新入门-攻略专区", "26": "高手进阶", "27": "活动攻略", "29": "视频攻略" }
99
+ }
100
+ };
101
+
102
+ const GAME_MAP = Object.fromEntries(
103
+ Object.entries(GAME_CATALOG).flatMap(([gameId, config]) => config.names.map(name => [name, gameId]))
104
+ );
105
+
41
106
  const CATEGORY_MAP = {
42
107
  "2": "2",
43
108
  "3": "3",
@@ -59,6 +124,13 @@ const CATEGORY_MAP = {
59
124
  "视频攻略": "29"
60
125
  };
61
126
 
127
+ for (const config of Object.values(GAME_CATALOG)) {
128
+ for (const [id, name] of Object.entries(config.categories)) {
129
+ CATEGORY_MAP[id] = id;
130
+ if (!CATEGORY_MAP[name]) CATEGORY_MAP[name] = id;
131
+ }
132
+ }
133
+
62
134
  const AREA_MAP = {
63
135
  "1": "1",
64
136
  "3": "3",
@@ -69,11 +141,6 @@ const AREA_MAP = {
69
141
  "公益网站": "4"
70
142
  };
71
143
 
72
- const GAME_MAP = {
73
- "39": "39",
74
- "最强蜗牛": "39"
75
- };
76
-
77
144
  const STATUS_MAP = {
78
145
  "0": "0",
79
146
  "1": "1",
@@ -100,8 +167,16 @@ const AGENTS_DIR = path.join(os.homedir(), ".agents");
100
167
  const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
101
168
  const AUTH_FILE = path.join(QCPLAY_DIR, "auth.json");
102
169
  const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
170
+ const CONTENT_RULES_FILE = DEFAULT_CONTENT_RULES_FILE;
103
171
  const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
104
172
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
173
+ const WEST_ARTICLE_LANGUAGES = new Set(["portuguese", "german", "spanish", "italian", "french", "america_en"]);
174
+ const ARTICLE_PREVIEW_URLS = {
175
+ "50": "https://mtzg.qingcijoy.com/news-details.html",
176
+ "69": "https://hjwq.qingcijoy.com/news-details.html",
177
+ "74": "https://sakura.qingcijoy.com/news-details.html",
178
+ "64": "https://snail-na.qcplay.com/official/news-details-mobile.html"
179
+ };
105
180
 
106
181
  function readPackageMetadata() {
107
182
  try {
@@ -149,13 +224,20 @@ Usage:
149
224
  qcplay-cli auth permissions [--local] [--key <key>] [--json]
150
225
  qcplay-cli article
151
226
  qcplay-cli article init [file]
152
- qcplay-cli article import <wechat-url> [file]
153
- qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
227
+ qcplay-cli article west-init [file]
228
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json|txt|docx>] [--project <项目>] [--platform <平台>]
229
+ qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
154
230
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
155
231
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
156
- qcplay-cli www-article-list.store <file> [--local] [--backend <url>] [--dry-run]
232
+ qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
233
+ qcplay-cli publish --project <项目> --list-platforms
234
+ qcplay-cli rules [init|validate|list] [--rules-file <json>]
235
+ qcplay-cli www-article-list.store <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
157
236
  qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
158
237
  qcplay-cli www-article-list.delete [id] [--local] [--backend <url>] [--dry-run]
238
+ qcplay-cli west-article-list.store <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
239
+ qcplay-cli west-article-list.update [id] [--local] [--backend <url>] [--dry-run]
240
+ qcplay-cli west-article-list.delete [id] [--local] [--backend <url>] [--dry-run]
159
241
  qcplay-cli features
160
242
  qcplay-cli where
161
243
 
@@ -180,12 +262,154 @@ function printArticleHelp() {
180
262
  console.log(`Usage:
181
263
  qcplay-cli article
182
264
  qcplay-cli article init [file]
183
- qcplay-cli article import <wechat-url> [file]
184
- qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
265
+ qcplay-cli article west-init [file]
266
+ qcplay-cli article import <wechat-or-feishu-url> [file] [--rules-file <json>] [--project <项目>]
267
+ qcplay-cli article publish <file> [--title <标题>] [--cover <图片>] [--project <项目>] [--region <区域>] [--rules-file <json>] [--dry-run]
185
268
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
186
269
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
187
270
  }
188
271
 
272
+ function parseArticleImportOptions(args) {
273
+ const positional = [];
274
+ const options = { rulesFile: "", project: "", platform: "" };
275
+ for (let index = 0; index < args.length; index += 1) {
276
+ const current = args[index];
277
+ if (current === "--rules-file" || current === "--project" || current === "--platform") {
278
+ const next = args[index + 1];
279
+ if (!next || next.startsWith("--")) throw new Error(`${current} 缺少参数值`);
280
+ options[current === "--rules-file" ? "rulesFile" : current === "--project" ? "project" : "platform"] = next;
281
+ index += 1;
282
+ continue;
283
+ }
284
+ if (current.startsWith("--")) throw new Error(`未知参数: ${current}`);
285
+ positional.push(current);
286
+ }
287
+ if (positional.length > 2) throw new Error(`未知参数: ${positional.slice(2).join(" ")}`);
288
+ return { sourceUrl: positional[0], file: positional[1] || "article.md", options };
289
+ }
290
+
291
+ function printPublishHelp() {
292
+ console.log(`Usage:
293
+ qcplay-cli publish <file> --project <项目> [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--region <区域>] [--platform <平台> ...]
294
+ qcplay-cli publish --project <项目> [--region <区域>] --list-platforms
295
+ qcplay-cli publish --project <项目> --platform <平台> --open-page
296
+
297
+ Options:
298
+ --project <项目> 项目名称;game_id=39 时默认使用“最强蜗牛”
299
+ --region <区域> 国内、日本、欧美等区域
300
+ --title <标题> 本次发布临时替换标题,不改动 Markdown 文件
301
+ --cover <图片> 本次发布临时替换封面;浏览器平台支持本地路径或 URL,官网使用图片 URL
302
+ --weibo-column <专栏> 仅微博:设置头条文章专栏;不填写则不设置专栏
303
+ -p, --platform <平台> 发布平台,可重复使用或用逗号分隔
304
+ --list-platforms 列出匹配的平台,不发布
305
+ --config-file <json> 使用本地平台配置(主要用于测试)
306
+ --rules-file <json> 使用指定内容规则;默认 ~/.qcplay/platform-rules.json
307
+ --browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
308
+ --review-draft, --review 官网外平台先准备草稿,预览后在 CMD 确认发布
309
+ --open-page 只打开真实浏览器发布页,供登录和页面流程测试,不读取或提交文章
310
+ --switch-account 先打开发布页切换账号,确认后继续当前发布流程
311
+ --keep-open 浏览器提交后等待用户确认再关闭
312
+ --dry-run 只展示发布计划,不登录或发布
313
+ --local 官网发布使用本地后端
314
+ --backend <url> 官网发布后端地址
315
+
316
+ TapTap command inputs:
317
+ --taptap-secret-code <密令> 飞书“特殊要求”要求的本次福利密令
318
+ --taptap-secret-period <有效期> 本次密令生效时间或有效期
319
+ --taptap-official-group <群号> 仅当飞书群号为动态占位符时提供
320
+
321
+ TapTap Front Matter:
322
+ taptap_type 留空时跟随飞书页面 URL,也可指定 long-post、image-text 或 video
323
+ taptap_forum 默认使用当前项目同名论坛;填 0 时不添加论坛
324
+ taptap_scheduled 填 1 时勾选定时发布
325
+ taptap_draft 填 1 时保存草稿而非发布
326
+ taptap_images 图文图片路径或 URL,多个用逗号分隔
327
+ taptap_video 视频模式使用的本地视频路径
328
+ taptap_cover 长帖封面路径或 URL
329
+
330
+ B站 Front Matter:
331
+ bilibili_type article(默认)或 video
332
+ bilibili_video 视频投稿使用的本地视频路径
333
+ bilibili_cover 投稿封面本地路径或 URL
334
+ bilibili_category 投稿分区,可用“一级/二级”表示层级
335
+ bilibili_tags 投稿标签,多个用逗号分隔
336
+ bilibili_topic 投稿话题;填写后搜索并选择精确同名话题
337
+
338
+ 微博 Front Matter:
339
+ weibo_intro 头条文章导语,最多 44 个字符;默认使用 article_excerpt
340
+ weibo_super_topic 可选;自定义超话名称
341
+ weibo_cover 头条文章封面;可填写正文图片或本地路径/URL
342
+ weibo_column 可选;微博头条文章专栏,留空则不设置
343
+
344
+ 小红书 Front Matter:
345
+ xiaohongshu_images 可选;本地路径或 URL,多个以逗号分隔;留空时使用正文第 1 张图片
346
+ xiaohongshu_collection 可选;精确合集名称,留空则不加入合集`);
347
+ }
348
+
349
+ function printRulesHelp() {
350
+ console.log(`Usage:
351
+ qcplay-cli rules init [--rules-file <json>]
352
+ qcplay-cli rules validate [--rules-file <json|txt|docx>]
353
+ qcplay-cli rules list [--rules-file <json|txt|docx>]
354
+
355
+ 默认规则文件: ${CONTENT_RULES_FILE}`);
356
+ }
357
+
358
+ function parseRulesOptions(args) {
359
+ const options = { rulesFile: "" };
360
+ for (let index = 0; index < args.length; index += 1) {
361
+ const current = args[index];
362
+ if (current === "--rules-file") {
363
+ const next = args[index + 1];
364
+ if (!next || next.startsWith("--")) throw new Error("--rules-file 缺少参数值");
365
+ options.rulesFile = next;
366
+ index += 1;
367
+ continue;
368
+ }
369
+ throw new Error(`未知参数: ${current}`);
370
+ }
371
+ return options;
372
+ }
373
+
374
+ async function contentRulesCommand(action, args = []) {
375
+ const options = parseRulesOptions(args);
376
+ const file = path.resolve(options.rulesFile || CONTENT_RULES_FILE);
377
+ if (action === "init") {
378
+ if (await pathExists(file)) throw new Error(`内容规则文件已存在: ${file}`);
379
+ await ensureDir(path.dirname(file));
380
+ await fs.promises.writeFile(file, `${JSON.stringify(CONTENT_RULES_TEMPLATE, null, 2)}\n`, {
381
+ encoding: "utf8",
382
+ mode: 0o600
383
+ });
384
+ console.log(chalk.green("内容规则模板已创建"));
385
+ console.log(file);
386
+ return;
387
+ }
388
+ const config = await loadContentRules(file);
389
+ if (action === "validate") {
390
+ console.log(chalk.green(`内容规则校验通过,共 ${config.rules.length} 条`));
391
+ console.log(file);
392
+ return;
393
+ }
394
+ if (action === "list") {
395
+ console.log(JSON.stringify({
396
+ file,
397
+ version: config.version,
398
+ rules: config.rules.map(rule => ({
399
+ name: rule.name,
400
+ enabled: rule.enabled !== false,
401
+ source: rule.source || "*",
402
+ project: rule.project || "*",
403
+ region: rule.region || "*",
404
+ platform: rule.platform || "*",
405
+ actions: rule.actions.map(action => action.type)
406
+ }))
407
+ }, null, 2));
408
+ return;
409
+ }
410
+ throw new Error(`未知 rules 命令: ${action || "空"}`);
411
+ }
412
+
189
413
  function printFeatures() {
190
414
  console.log("");
191
415
  console.log(chalk.green("你现在可以使用以下功能:"));
@@ -199,12 +423,16 @@ function printFeatures() {
199
423
  console.log(" qcplay-cli auth permissions --key www-article-list.store");
200
424
  console.log("");
201
425
  console.log(chalk.cyan("3. 发布官网文章"));
202
- console.log(" qcplay-cli article import <微信文章链接> article.md");
426
+ console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.md");
203
427
  console.log(" qcplay-cli www-article-list.store article.md");
204
428
  console.log(" qcplay-cli article update [id]");
205
429
  console.log(" qcplay-cli article delete [id]");
206
430
  console.log("");
207
- console.log(chalk.cyan("4. 查看本地目录"));
431
+ console.log(chalk.cyan("4. 多平台分发"));
432
+ console.log(" qcplay-cli publish article.md --project 最强蜗牛");
433
+ console.log(" qcplay-cli publish --project 最强蜗牛 --list-platforms");
434
+ console.log("");
435
+ console.log(chalk.cyan("5. 查看本地目录"));
208
436
  console.log(" qcplay-cli where");
209
437
  console.log("");
210
438
  }
@@ -272,6 +500,16 @@ async function ensureLocalDirs() {
272
500
  await ensureDir(SKILLS_DIR);
273
501
  }
274
502
 
503
+ async function ensureContentRulesFile() {
504
+ if (await pathExists(CONTENT_RULES_FILE)) return false;
505
+ await ensureDir(path.dirname(CONTENT_RULES_FILE));
506
+ await fs.promises.writeFile(CONTENT_RULES_FILE, `${JSON.stringify(CONTENT_RULES_TEMPLATE, null, 2)}\n`, {
507
+ encoding: "utf8",
508
+ mode: 0o600
509
+ });
510
+ return true;
511
+ }
512
+
275
513
  async function filesAreEqual(sourcePath, targetPath) {
276
514
  try {
277
515
  const [sourceContent, targetContent] = await Promise.all([
@@ -381,6 +619,28 @@ function normalizeMappedValue(value, map) {
381
619
  return map[text] ?? map[text.toLowerCase?.() || ""] ?? text;
382
620
  }
383
621
 
622
+ function gameCatalogEntry(gameId) {
623
+ return GAME_CATALOG[gameId] || null;
624
+ }
625
+
626
+ function normalizeGameAndCategory(meta) {
627
+ const rawGame = normalizeText(meta.game_id);
628
+ const gameId = normalizeMappedValue(rawGame, GAME_MAP) || "39";
629
+ const game = gameCatalogEntry(gameId);
630
+ if (!game) {
631
+ throw new Error(`未知 game_id: ${gameId}`);
632
+ }
633
+ const rawCategory = normalizeText(meta.cate_id);
634
+ const category = rawCategory
635
+ ? Object.entries(game.categories).find(([, name]) => name === rawCategory)?.[0] || normalizeMappedValue(rawCategory, CATEGORY_MAP)
636
+ : game.defaultCategory;
637
+ if (!game.categories[category]) {
638
+ const allowed = Object.entries(game.categories).map(([id, name]) => `${id}(${name})`).join("、");
639
+ throw new Error(`项目“${game.names.at(-1)}”的 cate_id 不合法:当前允许 ${allowed}`);
640
+ }
641
+ return { gameId, category };
642
+ }
643
+
384
644
  function normalizeBoolLike(value, defaultValue = "0") {
385
645
  const text = normalizeText(value);
386
646
  if (!text) {
@@ -472,7 +732,12 @@ function parsePublishOptions(args) {
472
732
  const options = {
473
733
  backend: undefined,
474
734
  local: false,
475
- dryRun: false
735
+ dryRun: false,
736
+ project: "",
737
+ region: "",
738
+ rulesFile: "",
739
+ title: "",
740
+ cover: ""
476
741
  };
477
742
  const positional = [];
478
743
 
@@ -484,13 +749,21 @@ function parsePublishOptions(args) {
484
749
  continue;
485
750
  }
486
751
 
487
- if (current === "--backend") {
752
+ if (["--backend", "--project", "--region", "--rules-file", "--title", "--cover"].includes(current)) {
488
753
  const next = args[index + 1];
489
754
  if (!next || next.startsWith("--")) {
490
- throw new Error("--backend 缺少参数值");
755
+ throw new Error(`${current} 缺少参数值`);
491
756
  }
492
757
 
493
- options.backend = next;
758
+ const key = {
759
+ "--backend": "backend",
760
+ "--project": "project",
761
+ "--region": "region",
762
+ "--rules-file": "rulesFile",
763
+ "--title": "title",
764
+ "--cover": "cover"
765
+ }[current];
766
+ options[key] = next;
494
767
  index += 1;
495
768
  continue;
496
769
  }
@@ -517,6 +790,136 @@ function parsePublishOptions(args) {
517
790
  };
518
791
  }
519
792
 
793
+ function parseDistributionOptions(args) {
794
+ const options = {
795
+ backend: undefined,
796
+ local: false,
797
+ dryRun: false,
798
+ project: "",
799
+ region: "",
800
+ platforms: [],
801
+ listPlatforms: false,
802
+ configFile: "",
803
+ rulesFile: "",
804
+ title: "",
805
+ cover: "",
806
+ weiboColumn: "",
807
+ browserChannel: "",
808
+ reviewDraft: false,
809
+ keepOpen: false,
810
+ openPage: false,
811
+ switchAccount: false,
812
+ taptapSecretCode: "",
813
+ taptapSecretPeriod: "",
814
+ taptapOfficialGroup: ""
815
+ };
816
+ const positional = [];
817
+
818
+ for (let index = 0; index < args.length; index += 1) {
819
+ const current = args[index];
820
+ if (current === "--dry-run") {
821
+ options.dryRun = true;
822
+ continue;
823
+ }
824
+ if (current === "--local") {
825
+ options.local = true;
826
+ continue;
827
+ }
828
+ if (current === "--list-platforms") {
829
+ options.listPlatforms = true;
830
+ continue;
831
+ }
832
+ if (current === "--keep-open") {
833
+ options.keepOpen = true;
834
+ continue;
835
+ }
836
+ if (current === "--open-page") {
837
+ options.openPage = true;
838
+ continue;
839
+ }
840
+ if (current === "--switch-account") {
841
+ options.switchAccount = true;
842
+ continue;
843
+ }
844
+ if (current === "--review-draft" || current === "--review") {
845
+ options.reviewDraft = true;
846
+ continue;
847
+ }
848
+
849
+ const valueFlags = new Map([
850
+ ["--backend", "backend"],
851
+ ["--project", "project"],
852
+ ["--region", "region"],
853
+ ["--config-file", "configFile"],
854
+ ["--rules-file", "rulesFile"],
855
+ ["--title", "title"],
856
+ ["--cover", "cover"],
857
+ ["--weibo-column", "weiboColumn"],
858
+ ["--browser-channel", "browserChannel"],
859
+ ["--taptap-secret-code", "taptapSecretCode"],
860
+ ["--taptap-secret-period", "taptapSecretPeriod"],
861
+ ["--taptap-official-group", "taptapOfficialGroup"]
862
+ ]);
863
+ if (valueFlags.has(current)) {
864
+ const next = args[index + 1];
865
+ if (!next || next.startsWith("--")) {
866
+ throw new Error(`${current} 缺少参数值`);
867
+ }
868
+ options[valueFlags.get(current)] = next;
869
+ index += 1;
870
+ continue;
871
+ }
872
+
873
+ if (current === "--platform" || current === "-p") {
874
+ const next = args[index + 1];
875
+ if (!next || next.startsWith("--")) {
876
+ throw new Error(`${current} 缺少参数值`);
877
+ }
878
+ options.platforms.push(...next.split(/[,,]/).map(value => value.trim()).filter(Boolean));
879
+ index += 1;
880
+ continue;
881
+ }
882
+
883
+ if (current.startsWith("--")) {
884
+ throw new Error(`未知参数: ${current}`);
885
+ }
886
+ positional.push(current);
887
+ }
888
+
889
+ if (positional.length > 1) {
890
+ throw new Error(`未知参数: ${positional.slice(1).join(" ")}`);
891
+ }
892
+ return { file: positional[0], options };
893
+ }
894
+
895
+ function applyPublishOverrides(parsedArticle, options) {
896
+ const title = normalizeText(options.title);
897
+ const cover = normalizeText(options.cover);
898
+ const weiboColumn = normalizeText(options.weiboColumn);
899
+ if (!title && !cover && !weiboColumn) {
900
+ return parsedArticle;
901
+ }
902
+
903
+ const payload = {
904
+ ...parsedArticle.payload,
905
+ ...(title ? { article_title: title } : {}),
906
+ ...(cover && /^https?:\/\//i.test(cover) ? { thumbnail: cover, move_thumbnail: cover } : {})
907
+ };
908
+ const meta = {
909
+ ...parsedArticle.meta,
910
+ ...(cover
911
+ ? {
912
+ weibo_cover: cover,
913
+ taptap_cover: cover,
914
+ bilibili_cover: cover,
915
+ xiaohongshu_images: cover
916
+ }
917
+ : {}),
918
+ ...(weiboColumn ? { weibo_column: weiboColumn } : {})
919
+ };
920
+ return { ...parsedArticle, payload, meta };
921
+ }
922
+
520
923
  function parseArticleMutationOptions(args) {
521
924
  const parsed = parsePublishOptions(args);
522
925
  return {
@@ -911,9 +1314,13 @@ function normalizeArticleId(value) {
911
1314
  return articleId;
912
1315
  }
913
1316
 
914
- function articlePreviewUrl(articleId) {
915
- const url = new URL(ARTICLE_PREVIEW_BASE_URL);
1317
+ function articlePreviewUrl(articleId, payload = {}) {
1318
+ const gameId = normalizeText(payload.game_id);
1319
+ const url = new URL(ARTICLE_PREVIEW_URLS[gameId] || (gameId === "33" ? "https://tideng.qingcigame.com/official/news.html" : ARTICLE_PREVIEW_BASE_URL));
916
1320
  url.searchParams.set("id", String(articleId));
1321
+ if (gameId === "33") {
1322
+ url.searchParams.set("cate_id", normalizeText(payload.cate_id) || "40");
1323
+ }
917
1324
  return url.toString();
918
1325
  }
919
1326
 
@@ -925,12 +1332,13 @@ function backendOriginsMatch(left, right) {
925
1332
  }
926
1333
  }
927
1334
 
928
- async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "") {
1335
+ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
929
1336
  const authState = await loadAuthState();
930
1337
  await writeJson(ARTICLE_STATE_FILE, {
931
1338
  article_id: articleId,
932
1339
  status: String(status),
933
1340
  preview_url: previewUrl,
1341
+ target,
934
1342
  backend_url: backendUrl,
935
1343
  administrator_id: Number(authState.id || 0),
936
1344
  account: normalizeText(authState.account),
@@ -938,16 +1346,16 @@ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl
938
1346
  });
939
1347
  }
940
1348
 
941
- async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "") {
1349
+ async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
942
1350
  try {
943
- await saveRecentArticleState(articleId, status, backendUrl, previewUrl);
1351
+ await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target);
944
1352
  return "";
945
1353
  } catch (error) {
946
1354
  return normalizeText(error?.message) || String(error);
947
1355
  }
948
1356
  }
949
1357
 
950
- async function resolveArticleId(value, backendUrl) {
1358
+ async function resolveArticleId(value, backendUrl, target = "domestic") {
951
1359
  if (normalizeText(value)) {
952
1360
  return normalizeArticleId(value);
953
1361
  }
@@ -959,6 +1367,9 @@ async function resolveArticleId(value, backendUrl) {
959
1367
  if (!backendOriginsMatch(recentArticle.backend_url, backendUrl)) {
960
1368
  throw new Error("最近文章属于其他后端,请手动输入文章 ID");
961
1369
  }
1370
+ if ((recentArticle.target || "domestic") !== target) {
1371
+ throw new Error("最近文章属于其他站点,请手动输入文章 ID");
1372
+ }
962
1373
  if (String(recentArticle.status) !== "0") {
963
1374
  throw new Error("最近文章不是未上线状态,请手动输入文章 ID");
964
1375
  }
@@ -1069,6 +1480,9 @@ async function installCommand(options) {
1069
1480
 
1070
1481
  await ensureLocalDirs();
1071
1482
  console.log(chalk.green("✔ 本地目录已创建"));
1483
+ const rulesCreated = await ensureContentRulesFile();
1484
+ console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则已保留"));
1485
+ console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1072
1486
 
1073
1487
  const skillsInstalled = await installSkills();
1074
1488
  if (skillsInstalled.available) {
@@ -1121,6 +1535,7 @@ async function updateCommand() {
1121
1535
  console.log("");
1122
1536
 
1123
1537
  await ensureLocalDirs();
1538
+ const rulesCreated = await ensureContentRulesFile();
1124
1539
  await runCommand(getNpmCommand(), ["install", "-g", `${PACKAGE_NAME}@latest`]);
1125
1540
 
1126
1541
  const latestPackage = readPackageMetadata();
@@ -1135,6 +1550,8 @@ async function updateCommand() {
1135
1550
  console.log(chalk.gray(`版本: ${latestPackage.version}`));
1136
1551
  }
1137
1552
  printSkillsSyncResult(skillsInstalled);
1553
+ console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则未覆盖"));
1554
+ console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1138
1555
  console.log("");
1139
1556
  }
1140
1557
 
@@ -1578,29 +1995,38 @@ function parseFrontMatter(raw) {
1578
1995
  }
1579
1996
 
1580
1997
  function normalizeArticlePayload(meta, content) {
1998
+ const articleContent = markdownToHtml(content);
1999
+ const { gameId, category } = normalizeGameAndCategory(meta);
1581
2000
  const payload = {
1582
2001
  article_title: normalizeText(meta.article_title),
1583
2002
  thumbnail: normalizeText(meta.thumbnail),
1584
2003
  move_thumbnail: normalizeText(meta.move_thumbnail),
1585
- article_content: markdownToHtml(content),
2004
+ article_content: articleContent,
1586
2005
  article_excerpt: normalizeText(meta.article_excerpt),
1587
2006
  article_url: normalizeText(meta.article_url),
1588
2007
  origin: normalizeText(meta.origin),
1589
2008
  status: normalizeMappedValue(meta.status, STATUS_MAP) || "0",
1590
- cate_id: normalizeMappedValue(meta.cate_id, CATEGORY_MAP),
2009
+ cate_id: category,
1591
2010
  video_link: normalizeText(meta.video_link),
1592
2011
  is_hot: normalizeBoolLike(meta.is_hot, "0"),
1593
2012
  is_index: normalizeBoolLike(meta.is_index, "0"),
1594
2013
  release_time: normalizeText(meta.release_time),
1595
2014
  area: normalizeMappedValue(meta.area, AREA_MAP) || "1",
1596
2015
  sort: normalizeText(meta.sort) || "1",
1597
- game_id: normalizeMappedValue(meta.game_id, GAME_MAP) || "39",
2016
+ game_id: gameId,
1598
2017
  is_index2: "0",
1599
2018
  index_pc_img: normalizeText(meta.index_pc_img),
1600
2019
  index_move_img: normalizeText(meta.index_move_img),
1601
2020
  type: "1"
1602
2021
  };
1603
2022
 
2023
+ if (gameId === "64") {
2024
+ payload.language = normalizeText(meta.language).toLowerCase();
2025
+ if (!WEST_ARTICLE_LANGUAGES.has(payload.language)) {
2026
+ throw new Error("欧美蜗牛文章的 language 必须是 portuguese、german、spanish、italian、french 或 america_en");
2027
+ }
2028
+ }
2029
+
1604
2030
  if (!payload.article_title) {
1605
2031
  throw new Error("article_title 不能为空");
1606
2032
  }
@@ -1616,6 +2042,19 @@ function normalizeArticlePayload(meta, content) {
1616
2042
  return payload;
1617
2043
  }
1618
2044
 
2045
+ function articleTarget(payload) {
2046
+ return payload.game_id === "64" ? "west" : "domestic";
2047
+ }
2048
+
2049
+ function articleApiPath(target, action) {
2050
+ const prefix = target === "west" ? "/api/west-articles" : "/api/articles";
2051
+ return action === "publish" ? `${prefix}/publish` : `${prefix}/status`;
2052
+ }
2053
+
2054
+ function articleMutationCommand(target) {
2055
+ return target === "west" ? "west-article-list.update" : "article update";
2056
+ }
2057
+
1619
2058
  async function parseArticleFile(file) {
1620
2059
  const articleFile = path.resolve(process.cwd(), file);
1621
2060
  if (!(await pathExists(articleFile))) {
@@ -1626,12 +2065,14 @@ async function parseArticleFile(file) {
1626
2065
  const parsed = parseFrontMatter(raw);
1627
2066
  return {
1628
2067
  articleFile,
1629
- payload: normalizeArticlePayload(parsed.meta, parsed.content)
2068
+ payload: normalizeArticlePayload(parsed.meta, parsed.content),
2069
+ meta: parsed.meta,
2070
+ markdown: parsed.content
1630
2071
  };
1631
2072
  }
1632
2073
 
1633
- async function initArticleTemplate(file = "article.md") {
1634
- const sourceFile = path.resolve(__dirname, "../templates/skills/qcplay-publish-article/article/article.md");
2074
+ async function initArticleTemplate(file = "article.md", template = "article.md") {
2075
+ const sourceFile = path.resolve(__dirname, `../templates/skills/qcplay-publish-article/article/${template}`);
1635
2076
  const targetFile = path.resolve(process.cwd(), file);
1636
2077
 
1637
2078
  if (!(await pathExists(sourceFile))) {
@@ -1649,13 +2090,13 @@ async function initArticleTemplate(file = "article.md") {
1649
2090
  console.log("");
1650
2091
  console.log("请编辑该文件后执行:");
1651
2092
  console.log("");
1652
- console.log(` qcplay-cli www-article-list.store ${file}`);
2093
+ console.log(` qcplay-cli ${template === "west-article.md" ? "west-article-list.store" : "www-article-list.store"} ${file}`);
1653
2094
  console.log("");
1654
2095
  }
1655
2096
 
1656
- async function importWechatArticleCommand(sourceUrl, file = "article.md") {
2097
+ async function importArticleCommand(sourceUrl, file = "article.md", options = {}) {
1657
2098
  if (!sourceUrl) {
1658
- throw new Error("缺少微信文章链接,例如:qcplay-cli article import https://mp.weixin.qq.com/s/... article.md");
2099
+ throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
1659
2100
  }
1660
2101
 
1661
2102
  const targetFile = path.resolve(process.cwd(), file);
@@ -1664,30 +2105,73 @@ async function importWechatArticleCommand(sourceUrl, file = "article.md") {
1664
2105
  }
1665
2106
 
1666
2107
  console.log("");
1667
- console.log(chalk.cyan("正在获取微信文章..."));
1668
- const article = await importWechatArticle(sourceUrl, {
1669
- onProgress: ({ current, total }) => {
1670
- console.log(chalk.gray(`正在转存正文图片 ${current}/${total}`));
2108
+ let article;
2109
+ let sourceLabel;
2110
+ try {
2111
+ parseLarkDocumentUrl(sourceUrl);
2112
+ sourceLabel = "飞书文档";
2113
+ console.log(chalk.cyan("正在获取飞书文档完整格式..."));
2114
+ article = await importLarkArticle(sourceUrl);
2115
+ } catch (larkError) {
2116
+ if (!/飞书文档链接无效|仅支持飞书 Docx 或 Wiki 文档链接/.test(larkError.message)) {
2117
+ throw larkError;
1671
2118
  }
1672
- });
2119
+ sourceLabel = "微信文章";
2120
+ console.log(chalk.cyan("正在获取微信文章..."));
2121
+ const contentRules = await loadContentRules(options.rulesFile || CONTENT_RULES_FILE, {
2122
+ optional: !options.rulesFile
2123
+ });
2124
+ if (options.rulesFile) {
2125
+ console.log(chalk.gray(`已加载内容规则: ${contentRules.file}(${contentRules.rules.length} 条)`));
2126
+ }
2127
+ article = await importWechatArticle(sourceUrl, {
2128
+ contentRules,
2129
+ project: options.project,
2130
+ platform: options.platform,
2131
+ onProgress: ({ mediaType = "image", current, total }) => {
2132
+ const label = mediaType === "video" ? "正文视频" : "正文图片";
2133
+ console.log(chalk.gray(`正在转存${label} ${current}/${total}`));
2134
+ }
2135
+ });
2136
+ }
1673
2137
  const output = buildOfficialArticleMarkdown(article);
1674
2138
  await ensureDir(path.dirname(targetFile));
1675
2139
  await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
1676
2140
 
1677
2141
  console.log("");
1678
- console.log(chalk.green("微信文章已转换为官网文章草稿:"));
2142
+ console.log(chalk.green(`${sourceLabel}已转换为官网文章草稿:`));
1679
2143
  console.log(targetFile);
1680
2144
  console.log(chalk.gray(`标题: ${article.title}`));
1681
- console.log(chalk.gray(`已转存图片: ${article.imageCount} 张`));
1682
- console.log(chalk.gray(`已提取正文: ${article.textSegmentCount} 段`));
1683
- console.log(chalk.gray(`已保留文字颜色: ${article.colorCount} 处`));
1684
- console.log(
1685
- chalk.gray(
1686
- `特殊内容: ${article.specialElementCount} 处,媒体: ${article.mediaCount} 处,表格: ${article.tableCount} 个`
1687
- )
1688
- );
2145
+ if (sourceLabel === "飞书文档") {
2146
+ console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
2147
+ console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
2148
+ } else {
2149
+ if (article.matchedContentRules?.length) {
2150
+ console.log(chalk.gray(`导入匹配规则: ${article.matchedContentRules.join("、")}`));
2151
+ console.log(
2152
+ chalk.gray(
2153
+ article.appliedContentRules?.length
2154
+ ? `已应用规则: ${article.appliedContentRules.join("、")}`
2155
+ : "匹配规则未改变文章内容"
2156
+ )
2157
+ );
2158
+ } else if (options.rulesFile) {
2159
+ console.log(chalk.yellow("未匹配任何导入规则,请检查规则的项目、平台、来源和阶段范围"));
2160
+ }
2161
+ console.log(chalk.gray(`已转存图片: ${article.imageCount} 张`));
2162
+ if (article.videoCount > 0) {
2163
+ console.log(chalk.gray(`已转存视频: ${article.videoCount} 个`));
2164
+ }
2165
+ console.log(chalk.gray(`已提取正文: ${article.textSegmentCount} 段`));
2166
+ console.log(chalk.gray(`已保留文字颜色: ${article.colorCount} 处`));
2167
+ console.log(
2168
+ chalk.gray(
2169
+ `特殊内容: ${article.specialElementCount} 处,媒体: ${article.mediaCount} 处,表格: ${article.tableCount} 个`
2170
+ )
2171
+ );
2172
+ }
1689
2173
  console.log("");
1690
- console.log("请补充 cate_id 等业务字段,确认内容后执行:");
2174
+ console.log(sourceLabel === "飞书文档" ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:" : "请补充 cate_id 等业务字段,确认内容后执行:");
1691
2175
  console.log("");
1692
2176
  console.log(` qcplay-cli www-article-list.store ${file}`);
1693
2177
  console.log("");
@@ -1720,8 +2204,25 @@ release_time: 2026-07-16
1720
2204
  area: pc
1721
2205
  sort: 100
1722
2206
  game_id: 最强蜗牛
2207
+ distribution_project: ""
1723
2208
  index_pc_img: ""
1724
2209
  index_move_img: ""
2210
+ taptap_type: ""
2211
+ taptap_forum: ""
2212
+ taptap_scheduled: "0"
2213
+ taptap_draft: "0"
2214
+ taptap_images: ""
2215
+ taptap_video: ""
2216
+ taptap_cover: ""
2217
+ bilibili_type: article
2218
+ bilibili_video: ""
2219
+ bilibili_cover: ""
2220
+ bilibili_category: ""
2221
+ bilibili_tags: ""
2222
+ bilibili_topic: ""
2223
+ weibo_intro: ""
2224
+ weibo_super_topic: ""
2225
+ weibo_cover: ""
1725
2226
  ---
1726
2227
 
1727
2228
  # 文章标题
@@ -1736,16 +2237,245 @@ ${chalk.cyan("发布命令:")}
1736
2237
  ${chalk.cyan("从微信推文生成官网草稿:")}
1737
2238
 
1738
2239
  qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
2240
+
2241
+ ${chalk.cyan("项目默认分类:")}
2242
+
2243
+ 新仙剑(game_id=69):全部(cate_id=61)
2244
+ 提灯与地下城(game_id=33):新闻(cate_id=40)
2245
+ 魔卡少女樱(game_id=74):最新(cate_id=71)
2246
+ 迷途之光(game_id=50):全部(cate_id=109)
1739
2247
  `);
1740
2248
  }
1741
2249
 
1742
- async function publishArticleCommand(file, options) {
2250
+ function inferDistributionProject(payload, explicitProject, meta = {}) {
2251
+ if (normalizeText(explicitProject)) {
2252
+ return normalizeText(explicitProject);
2253
+ }
2254
+ if (normalizeText(meta.distribution_project)) {
2255
+ return normalizeText(meta.distribution_project);
2256
+ }
2257
+ if (payload?.game_id === "39") {
2258
+ return "最强蜗牛";
2259
+ }
2260
+ return "";
2261
+ }
2262
+
2263
+ function printPlatformEntries(entries) {
2264
+ console.log("");
2265
+ console.log(chalk.cyan("可用发布平台:"));
2266
+ console.log("");
2267
+ entries.forEach(entry => {
2268
+ const status = entry.publisher === "blocked" || entry.publisher === "unavailable" ? "不可自动发布" : entry.publisher;
2269
+ console.log(` ${entry.project} / ${entry.region} / ${entry.platform} [${status}; ${entry.authMode}]`);
2270
+ const requiredInputs = publicEntrySummary(entry).contentRequirements?.requiredUserInputs || [];
2271
+ if (requiredInputs.length > 0) {
2272
+ console.log(
2273
+ chalk.yellow(` 飞书特殊要求需用户提供: ${requiredInputs.map(item => `${item.label} (${item.field})`).join("、")}`)
2274
+ );
2275
+ }
2276
+ });
2277
+ console.log("");
2278
+ }
2279
+
2280
+ async function publishPlatformsCommand(file, options) {
2281
+ let parsedArticle;
2282
+ if (file) {
2283
+ parsedArticle = await parseArticleFile(file);
2284
+ parsedArticle = applyPublishOverrides(parsedArticle, options);
2285
+ }
2286
+ const allEntries = await loadPlatformEntries({ configFile: options.configFile });
2287
+
2288
+ const project = inferDistributionProject(parsedArticle?.payload, options.project, parsedArticle?.meta);
2289
+ if (!project) {
2290
+ if (options.listPlatforms) {
2291
+ printPlatformEntries(allEntries);
2292
+ return;
2293
+ }
2294
+ throw new Error(`缺少项目名称。可用项目: ${availableProjects(allEntries).join("、")}。请使用 --project <项目>`);
2295
+ }
2296
+
2297
+ const candidates = entriesForProject(allEntries, project, options.region);
2298
+ if (candidates.length === 0) {
2299
+ const regionHint = options.region ? ` / ${options.region}` : "";
2300
+ throw new Error(`平台配置中没有找到项目 ${project}${regionHint}`);
2301
+ }
2302
+ if (options.listPlatforms) {
2303
+ printPlatformEntries(candidates);
2304
+ return;
2305
+ }
2306
+ if (!parsedArticle && !options.openPage) {
2307
+ throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
2308
+ }
2309
+
2310
+ const selectedEntries = options.platforms.length
2311
+ ? selectRequestedEntries(candidates, options.platforms)
2312
+ : await promptForPlatformEntries(candidates);
2313
+ if (options.openPage) {
2314
+ if (selectedEntries.some(entry => entry.publisher !== "browser")) {
2315
+ throw new Error("--open-page 仅支持有真实浏览器发布页的平台,请使用 --platform 指定浏览器平台");
2316
+ }
2317
+ for (const entry of selectedEntries) {
2318
+ console.log("");
2319
+ console.log(chalk.cyan(`正在打开 ${entry.platform}(${entry.region})真实发布页面...`));
2320
+ await openPublishPage(entry, {
2321
+ browserChannel: options.browserChannel,
2322
+ onProgress: message => console.log(chalk.gray(` ${message}`))
2323
+ });
2324
+ console.log(chalk.green(`${entry.platform} 页面流程测试结束`));
2325
+ }
2326
+ return;
2327
+ }
2328
+ if (options.switchAccount) {
2329
+ if (selectedEntries.some(entry => entry.publisher !== "browser")) {
2330
+ throw new Error("--switch-account 仅支持有真实浏览器发布页的平台,请使用 --platform 指定浏览器平台");
2331
+ }
2332
+ for (const entry of selectedEntries) {
2333
+ console.log("");
2334
+ console.log(chalk.cyan(`正在打开 ${entry.platform}(${entry.region})页面切换账号...`));
2335
+ await openPublishPage(entry, {
2336
+ browserChannel: options.browserChannel,
2337
+ accountSwitch: true,
2338
+ onProgress: message => console.log(chalk.gray(` ${message}`))
2339
+ });
2340
+ console.log(chalk.green(`${entry.platform} 账号切换已确认,继续发布流程`));
2341
+ }
2342
+ }
2343
+ const contentRules = await loadContentRules(options.rulesFile || CONTENT_RULES_FILE, {
2344
+ optional: !options.rulesFile
2345
+ });
2346
+ const selected = selectedEntries.map(entry => ({ ...entry, contentRules }));
2347
+ const platformInputs = {
2348
+ taptap_secret_code: options.taptapSecretCode,
2349
+ taptap_secret_period: options.taptapSecretPeriod,
2350
+ taptap_official_group: options.taptapOfficialGroup
2351
+ };
2352
+ const planningArticle = {
2353
+ ...parsedArticle,
2354
+ title: parsedArticle.payload.article_title,
2355
+ html: parsedArticle.payload.article_content,
2356
+ platformInputs
2357
+ };
2358
+ const plan = selected.map(entry => {
2359
+ const matchedRules = matchingContentRules(contentRules, planningArticle, entry).map(rule => rule.name);
2360
+ return {
2361
+ ...publicEntrySummary(entry),
2362
+ ...(matchedRules.length > 0 ? { contentRules: matchedRules } : {}),
2363
+ ...(entry.platformKey === "taptap" ? { taptap: tapTapPublishingPlan(planningArticle, entry) } : {}),
2364
+ ...(entry.platformKey === "bilibili" ? { bilibili: bilibiliPublishingPlan(planningArticle, entry) } : {}),
2365
+ ...(entry.platformKey === "weibo" ? { weibo: weiboPublishingPlan(planningArticle, entry) } : {})
2366
+ };
2367
+ });
2368
+ if (options.dryRun) {
2369
+ console.log(JSON.stringify({ article: parsedArticle.articleFile, project, platforms: plan }, null, 2));
2370
+ return;
2371
+ }
2372
+
2373
+ const article = {
2374
+ articleFile: parsedArticle.articleFile,
2375
+ title: parsedArticle.payload.article_title,
2376
+ markdown: parsedArticle.markdown,
2377
+ html: parsedArticle.payload.article_content,
2378
+ payload: parsedArticle.payload,
2379
+ meta: parsedArticle.meta,
2380
+ platformInputs
2381
+ };
2382
+ const { successes, failures, skipped } = await runPlatformPublishSequence(selected, async entry => {
2383
+ console.log("");
2384
+ console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
2385
+ let result;
2386
+ if (entry.publisher === "official-api") {
2387
+ await publishArticleCommand(file, options, entry);
2388
+ } else {
2389
+ result = await publishPlatformEntry(entry, article, {
2390
+ browserChannel: options.browserChannel,
2391
+ reviewDraft: options.reviewDraft,
2392
+ keepOpen: options.keepOpen,
2393
+ onProgress: message => console.log(chalk.gray(` ${message}`))
2394
+ });
2395
+ if (entry.platformKey === "taptap" && result?.status === "draft") {
2396
+ console.log(chalk.green("TapTap 草稿已保存"));
2397
+ return result;
2398
+ }
2399
+ if (entry.platformKey === "taptap" && result?.status === "scheduled") {
2400
+ console.log(chalk.green("TapTap 定时发布已提交"));
2401
+ return result;
2402
+ }
2403
+ }
2404
+ console.log(chalk.green(`${entry.platform} 发布流程完成`));
2405
+ return result;
2406
+ });
2407
+ failures.forEach(({ entry, error }) => {
2408
+ console.error(chalk.red(`${entry.platform} 发布失败: ${error.message || error}`));
2409
+ });
2410
+ if (skipped.length > 0) {
2411
+ console.error(chalk.yellow("已停止后续平台发布,请先处理当前浏览器平台的登录或页面问题。"));
2412
+ }
2413
+
2414
+ console.log("");
2415
+ console.log(`发布完成: ${successes.length} 个成功,${failures.length} 个失败,${skipped.length} 个未执行`);
2416
+ failures.forEach(({ entry, error }) => {
2417
+ console.log(` - ${entry.platform}(${entry.region}): ${error.message || error}`);
2418
+ });
2419
+ skipped.forEach(entry => {
2420
+ console.log(` - ${entry.platform}(${entry.region}): 未执行`);
2421
+ });
2422
+ if (failures.length > 0) {
2423
+ throw new Error("部分平台发布失败,请根据上面的错误处理后重试失败平台");
2424
+ }
2425
+ }
2426
+
2427
+ async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
1743
2428
  if (!file) {
1744
2429
  throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
1745
2430
  }
1746
2431
 
1747
- const { articleFile, payload } = await parseArticleFile(file);
2432
+ const parsedArticle = await parseArticleFile(file);
2433
+ const { articleFile } = parsedArticle;
2434
+ let payload = parsedArticle.payload;
2435
+ if (normalizeText(options.title)) {
2436
+ payload = { ...payload, article_title: normalizeText(options.title) };
2437
+ }
2438
+ if (normalizeText(options.cover)) {
2439
+ const cover = normalizeText(options.cover);
2440
+ if (!/^https?:\/\//i.test(cover)) {
2441
+ throw new Error("官网封面必须使用 http 或 https 图片地址");
2442
+ }
2443
+ payload = { ...payload, thumbnail: cover, move_thumbnail: cover };
2444
+ }
2445
+ let effectiveEntry = platformEntry;
2446
+ let prepareArticle = prepareArticleForPlatform;
2447
+ if (!effectiveEntry) {
2448
+ const contentRules = await loadContentRules(options.rulesFile || CONTENT_RULES_FILE, {
2449
+ optional: !options.rulesFile
2450
+ });
2451
+ effectiveEntry = {
2452
+ project: inferDistributionProject(payload, options.project, parsedArticle.meta),
2453
+ region: options.region || "",
2454
+ platform: "官网",
2455
+ platformKey: "website",
2456
+ contentRules
2457
+ };
2458
+ prepareArticle = (article, entry) => applyContentRules(article, entry);
2459
+ }
2460
+ if (effectiveEntry) {
2461
+ const preparedArticle = prepareArticle(
2462
+ {
2463
+ articleFile,
2464
+ title: payload.article_title,
2465
+ markdown: parsedArticle.markdown,
2466
+ html: payload.article_content,
2467
+ payload,
2468
+ meta: parsedArticle.meta
2469
+ },
2470
+ effectiveEntry
2471
+ );
2472
+ payload = { ...payload, article_content: preparedArticle.html };
2473
+ }
1748
2474
  payload.status = "0";
2475
+ const target = articleTarget(payload);
2476
+ if (expectedTarget && target !== expectedTarget) {
2477
+ throw new Error("欧美蜗牛官网文章必须使用 game_id: 64 和有效的 language");
2478
+ }
1749
2479
  const config = await loadConfig();
1750
2480
  const backendUrl = resolvePublishBackendUrl(options, config);
1751
2481
 
@@ -1755,40 +2485,46 @@ async function publishArticleCommand(file, options) {
1755
2485
  }
1756
2486
 
1757
2487
  console.log("");
1758
- console.log(chalk.cyan("正在保存官网文章为未上线状态..."));
2488
+ console.log(chalk.cyan(target === "west" ? "正在保存欧美蜗牛官网文章为未上线状态..." : "正在保存官网文章为未上线状态..."));
1759
2489
  console.log(chalk.gray(`文章文件: ${articleFile}`));
1760
2490
  console.log("");
1761
2491
 
1762
- const response = await authenticatedRequest("POST", backendUrl, "/api/articles/publish", payload);
2492
+ const response = await authenticatedRequest("POST", backendUrl, articleApiPath(target, "publish"), payload);
1763
2493
  const rawArticleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
1764
2494
  const articleId = normalizeArticleId(rawArticleId);
1765
- const previewUrl = normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId);
1766
- const stateWarning = await trySaveRecentArticleState(articleId, "0", backendUrl, previewUrl);
2495
+ const previewUrl = normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId, payload);
2496
+ const stateWarning = await trySaveRecentArticleState(articleId, "0", backendUrl, previewUrl, target);
1767
2497
 
1768
2498
  console.log(chalk.green(response.message || "文章已保存为未上线状态"));
1769
2499
  console.log(`文章 ID: ${articleId}`);
1770
2500
  console.log("文章状态: 未上线");
1771
- console.log(`预览地址: ${previewUrl}`);
2501
+ if (previewUrl) {
2502
+ console.log(`预览地址: ${previewUrl}`);
2503
+ } else {
2504
+ console.log(chalk.yellow("欧美官网预览地址尚未配置,请在后台确认草稿内容和排版。"));
2505
+ }
1772
2506
  if (stateWarning) {
1773
2507
  console.log(chalk.yellow(`本地未能记录最近文章 ID,后续操作请手动输入 ${articleId}: ${stateWarning}`));
1774
2508
  }
1775
2509
  console.log("");
1776
- try {
1777
- await openBrowser(previewUrl);
1778
- console.log(chalk.cyan("已打开文章预览页面,请由用户检查文章内容和排版。"));
1779
- } catch {
1780
- console.log(chalk.yellow("自动打开预览页面失败,请手动打开上面的预览地址。"));
2510
+ if (previewUrl) {
2511
+ try {
2512
+ await openBrowser(previewUrl);
2513
+ console.log(chalk.cyan("已打开文章预览页面,请由用户检查文章内容和排版。"));
2514
+ } catch {
2515
+ console.log(chalk.yellow("自动打开预览页面失败,请手动打开上面的预览地址。"));
2516
+ }
1781
2517
  }
1782
2518
  console.log("用户确认无误后再执行:");
1783
2519
  console.log("");
1784
- console.log(` qcplay-cli article update ${articleId}`);
2520
+ console.log(` qcplay-cli ${articleMutationCommand(target)} ${articleId}`);
1785
2521
  console.log("");
1786
2522
  }
1787
2523
 
1788
- async function updateArticleStatusCommand(articleIdValue, targetStatus, options) {
2524
+ async function updateArticleStatusCommand(articleIdValue, targetStatus, options, target = "domestic") {
1789
2525
  const config = await loadConfig();
1790
2526
  const backendUrl = resolvePublishBackendUrl(options, config);
1791
- const articleId = await resolveArticleId(articleIdValue, backendUrl);
2527
+ const articleId = await resolveArticleId(articleIdValue, backendUrl, target);
1792
2528
  const payload = {
1793
2529
  id: articleId,
1794
2530
  status: targetStatus
@@ -1801,17 +2537,17 @@ async function updateArticleStatusCommand(articleIdValue, targetStatus, options)
1801
2537
 
1802
2538
  const action = targetStatus === "2" ? "删除" : "上线";
1803
2539
  console.log("");
1804
- console.log(chalk.cyan(`正在${action}官网文章...`));
2540
+ console.log(chalk.cyan(`正在${action}${target === "west" ? "欧美蜗牛" : ""}官网文章...`));
1805
2541
  console.log(chalk.gray(`文章 ID: ${articleId}`));
1806
2542
  console.log("");
1807
2543
 
1808
- const response = await authenticatedRequest("PATCH", backendUrl, "/api/articles/status", payload);
2544
+ const response = await authenticatedRequest("PATCH", backendUrl, articleApiPath(target, "status"), payload);
1809
2545
  const responseStatus = normalizeText(response.data?.status) || targetStatus;
1810
2546
  const previewUrl =
1811
2547
  responseStatus === "1"
1812
- ? normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId)
2548
+ ? normalizeText(response.data?.preview_url) || (target === "domestic" ? articlePreviewUrl(articleId) : "")
1813
2549
  : "";
1814
- const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl);
2550
+ const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target);
1815
2551
 
1816
2552
  console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
1817
2553
  console.log(`文章 ID: ${articleId}`);
@@ -1875,6 +2611,31 @@ async function main() {
1875
2611
  return;
1876
2612
  }
1877
2613
 
2614
+ if (command === "publish") {
2615
+ if (subcommand === "-h" || subcommand === "--help") {
2616
+ printPublishHelp();
2617
+ return;
2618
+ }
2619
+ const parsed = parseDistributionOptions([subcommand, ...rest].filter(Boolean));
2620
+ await runWithErrorBanner("多平台发布失败", () => publishPlatformsCommand(parsed.file, parsed.options));
2621
+ return;
2622
+ }
2623
+
2624
+ if (command === "rules") {
2625
+ if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2626
+ printRulesHelp();
2627
+ return;
2628
+ }
2629
+ if (!new Set(["init", "validate", "list"]).has(subcommand)) {
2630
+ await runWithErrorBanner("内容规则失败", async () => {
2631
+ throw new Error(`未知 rules 命令: ${subcommand}`);
2632
+ });
2633
+ return;
2634
+ }
2635
+ await runWithErrorBanner("内容规则失败", () => contentRulesCommand(subcommand, rest));
2636
+ return;
2637
+ }
2638
+
1878
2639
  if (command === "article") {
1879
2640
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
1880
2641
  if (!subcommand) {
@@ -1891,16 +2652,17 @@ async function main() {
1891
2652
  return;
1892
2653
  }
1893
2654
 
2655
+ if (subcommand === "west-init") {
2656
+ const file = rest[0] || "west-article.md";
2657
+ await runWithErrorBanner("创建模板失败", () => initArticleTemplate(file, "west-article.md"));
2658
+ return;
2659
+ }
2660
+
1894
2661
  if (subcommand === "import") {
1895
- const sourceUrl = rest[0];
1896
- const file = rest[1] || "article.md";
1897
- if (rest.length > 2) {
1898
- await runWithErrorBanner("转换失败", async () => {
1899
- throw new Error(`未知参数: ${rest.slice(2).join(" ")}`);
1900
- });
1901
- return;
1902
- }
1903
- await runWithErrorBanner("转换失败", () => importWechatArticleCommand(sourceUrl, file));
2662
+ const parsed = parseArticleImportOptions(rest);
2663
+ await runWithErrorBanner("转换失败", () =>
2664
+ importArticleCommand(parsed.sourceUrl, parsed.file, parsed.options)
2665
+ );
1904
2666
  return;
1905
2667
  }
1906
2668
 
@@ -1940,6 +2702,22 @@ async function main() {
1940
2702
  return;
1941
2703
  }
1942
2704
 
2705
+ if (command === "west-article-list.store") {
2706
+ const parsed = parsePublishOptions([subcommand, ...rest].filter(Boolean));
2707
+ await runWithErrorBanner("发布失败", () => publishArticleCommand(parsed.file, parsed.options, null, "west"));
2708
+ return;
2709
+ }
2710
+
2711
+ if (command === "west-article-list.update" || command === "west-article-list.delete") {
2712
+ const parsed = parseArticleMutationOptions([subcommand, ...rest].filter(Boolean));
2713
+ const targetStatus = command === "west-article-list.delete" ? "2" : "1";
2714
+ const errorTitle = targetStatus === "2" ? "删除失败" : "上线失败";
2715
+ await runWithErrorBanner(errorTitle, () =>
2716
+ updateArticleStatusCommand(parsed.articleId, targetStatus, parsed.options, "west")
2717
+ );
2718
+ return;
2719
+ }
2720
+
1943
2721
  if (command === "features") {
1944
2722
  printFeatures();
1945
2723
  return;