@qcplay/cli 1.0.14 → 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
@@ -16,11 +16,20 @@ import {
16
16
  sanitizeArticleRichHtml
17
17
  } from "../lib/wechat-article.js";
18
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";
19
26
  import {
20
27
  availableProjects,
21
28
  bilibiliPublishingPlan,
22
29
  entriesForProject,
23
30
  loadPlatformEntries,
31
+ openPublishPage,
32
+ prepareArticleForPlatform,
24
33
  promptForPlatformEntries,
25
34
  publicEntrySummary,
26
35
  publishPlatformEntry,
@@ -52,6 +61,48 @@ function resolveAuthPagePath() {
52
61
  return path.resolve(__dirname, "../web/auth.html");
53
62
  }
54
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
+
55
106
  const CATEGORY_MAP = {
56
107
  "2": "2",
57
108
  "3": "3",
@@ -73,6 +124,13 @@ const CATEGORY_MAP = {
73
124
  "视频攻略": "29"
74
125
  };
75
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
+
76
134
  const AREA_MAP = {
77
135
  "1": "1",
78
136
  "3": "3",
@@ -83,11 +141,6 @@ const AREA_MAP = {
83
141
  "公益网站": "4"
84
142
  };
85
143
 
86
- const GAME_MAP = {
87
- "39": "39",
88
- "最强蜗牛": "39"
89
- };
90
-
91
144
  const STATUS_MAP = {
92
145
  "0": "0",
93
146
  "1": "1",
@@ -114,8 +167,16 @@ const AGENTS_DIR = path.join(os.homedir(), ".agents");
114
167
  const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
115
168
  const AUTH_FILE = path.join(QCPLAY_DIR, "auth.json");
116
169
  const ARTICLE_STATE_FILE = path.join(QCPLAY_DIR, "article-state.json");
170
+ const CONTENT_RULES_FILE = DEFAULT_CONTENT_RULES_FILE;
117
171
  const ARTICLE_PREVIEW_BASE_URL = "https://snail.qingcigame.com/official/news-details.html";
118
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
+ };
119
180
 
120
181
  function readPackageMetadata() {
121
182
  try {
@@ -163,15 +224,20 @@ Usage:
163
224
  qcplay-cli auth permissions [--local] [--key <key>] [--json]
164
225
  qcplay-cli article
165
226
  qcplay-cli article init [file]
166
- qcplay-cli article import <wechat-or-feishu-url> [file]
167
- 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]
168
230
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
169
231
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
170
- qcplay-cli publish [file] [--project <项目>] [--region <区域>] [--platform <平台>]
232
+ qcplay-cli publish [file] [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--project <项目>] [--region <区域>] [--platform <平台>]
171
233
  qcplay-cli publish --project <项目> --list-platforms
172
- qcplay-cli www-article-list.store <file> [--local] [--backend <url>] [--dry-run]
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]
173
236
  qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
174
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]
175
241
  qcplay-cli features
176
242
  qcplay-cli where
177
243
 
@@ -196,32 +262,64 @@ function printArticleHelp() {
196
262
  console.log(`Usage:
197
263
  qcplay-cli article
198
264
  qcplay-cli article init [file]
199
- qcplay-cli article import <wechat-or-feishu-url> [file]
200
- 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]
201
268
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
202
269
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
203
270
  }
204
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
+
205
291
  function printPublishHelp() {
206
292
  console.log(`Usage:
207
- qcplay-cli publish <file> --project <项目> [--region <区域>] [--platform <平台> ...]
293
+ qcplay-cli publish <file> --project <项目> [--title <标题>] [--cover <图片>] [--weibo-column <专栏>] [--region <区域>] [--platform <平台> ...]
208
294
  qcplay-cli publish --project <项目> [--region <区域>] --list-platforms
295
+ qcplay-cli publish --project <项目> --platform <平台> --open-page
209
296
 
210
297
  Options:
211
298
  --project <项目> 项目名称;game_id=39 时默认使用“最强蜗牛”
212
299
  --region <区域> 国内、日本、欧美等区域
300
+ --title <标题> 本次发布临时替换标题,不改动 Markdown 文件
301
+ --cover <图片> 本次发布临时替换封面;浏览器平台支持本地路径或 URL,官网使用图片 URL
302
+ --weibo-column <专栏> 仅微博:设置头条文章专栏;不填写则不设置专栏
213
303
  -p, --platform <平台> 发布平台,可重复使用或用逗号分隔
214
304
  --list-platforms 列出匹配的平台,不发布
215
305
  --config-file <json> 使用本地平台配置(主要用于测试)
306
+ --rules-file <json> 使用指定内容规则;默认 ~/.qcplay/platform-rules.json
216
307
  --browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
217
308
  --review-draft, --review 官网外平台先准备草稿,预览后在 CMD 确认发布
309
+ --open-page 只打开真实浏览器发布页,供登录和页面流程测试,不读取或提交文章
310
+ --switch-account 先打开发布页切换账号,确认后继续当前发布流程
218
311
  --keep-open 浏览器提交后等待用户确认再关闭
219
312
  --dry-run 只展示发布计划,不登录或发布
220
313
  --local 官网发布使用本地后端
221
314
  --backend <url> 官网发布后端地址
222
315
 
316
+ TapTap command inputs:
317
+ --taptap-secret-code <密令> 飞书“特殊要求”要求的本次福利密令
318
+ --taptap-secret-period <有效期> 本次密令生效时间或有效期
319
+ --taptap-official-group <群号> 仅当飞书群号为动态占位符时提供
320
+
223
321
  TapTap Front Matter:
224
- taptap_type long-post(默认)、image-text 或 video
322
+ taptap_type 留空时跟随飞书页面 URL,也可指定 long-postimage-text 或 video
225
323
  taptap_forum 默认使用当前项目同名论坛;填 0 时不添加论坛
226
324
  taptap_scheduled 填 1 时勾选定时发布
227
325
  taptap_draft 填 1 时保存草稿而非发布
@@ -238,7 +336,78 @@ B站 Front Matter:
238
336
  bilibili_topic 投稿话题;填写后搜索并选择精确同名话题
239
337
 
240
338
  微博 Front Matter:
241
- weibo_intro 头条文章导语,最多 44 个字符;默认使用 article_excerpt`);
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 || "空"}`);
242
411
  }
243
412
 
244
413
  function printFeatures() {
@@ -331,6 +500,16 @@ async function ensureLocalDirs() {
331
500
  await ensureDir(SKILLS_DIR);
332
501
  }
333
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
+
334
513
  async function filesAreEqual(sourcePath, targetPath) {
335
514
  try {
336
515
  const [sourceContent, targetContent] = await Promise.all([
@@ -440,6 +619,28 @@ function normalizeMappedValue(value, map) {
440
619
  return map[text] ?? map[text.toLowerCase?.() || ""] ?? text;
441
620
  }
442
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
+
443
644
  function normalizeBoolLike(value, defaultValue = "0") {
444
645
  const text = normalizeText(value);
445
646
  if (!text) {
@@ -531,7 +732,12 @@ function parsePublishOptions(args) {
531
732
  const options = {
532
733
  backend: undefined,
533
734
  local: false,
534
- dryRun: false
735
+ dryRun: false,
736
+ project: "",
737
+ region: "",
738
+ rulesFile: "",
739
+ title: "",
740
+ cover: ""
535
741
  };
536
742
  const positional = [];
537
743
 
@@ -543,13 +749,21 @@ function parsePublishOptions(args) {
543
749
  continue;
544
750
  }
545
751
 
546
- if (current === "--backend") {
752
+ if (["--backend", "--project", "--region", "--rules-file", "--title", "--cover"].includes(current)) {
547
753
  const next = args[index + 1];
548
754
  if (!next || next.startsWith("--")) {
549
- throw new Error("--backend 缺少参数值");
755
+ throw new Error(`${current} 缺少参数值`);
550
756
  }
551
757
 
552
- 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;
553
767
  index += 1;
554
768
  continue;
555
769
  }
@@ -586,9 +800,18 @@ function parseDistributionOptions(args) {
586
800
  platforms: [],
587
801
  listPlatforms: false,
588
802
  configFile: "",
803
+ rulesFile: "",
804
+ title: "",
805
+ cover: "",
806
+ weiboColumn: "",
589
807
  browserChannel: "",
590
808
  reviewDraft: false,
591
- keepOpen: false
809
+ keepOpen: false,
810
+ openPage: false,
811
+ switchAccount: false,
812
+ taptapSecretCode: "",
813
+ taptapSecretPeriod: "",
814
+ taptapOfficialGroup: ""
592
815
  };
593
816
  const positional = [];
594
817
 
@@ -610,6 +833,14 @@ function parseDistributionOptions(args) {
610
833
  options.keepOpen = true;
611
834
  continue;
612
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
+ }
613
844
  if (current === "--review-draft" || current === "--review") {
614
845
  options.reviewDraft = true;
615
846
  continue;
@@ -620,7 +851,14 @@ function parseDistributionOptions(args) {
620
851
  ["--project", "project"],
621
852
  ["--region", "region"],
622
853
  ["--config-file", "configFile"],
623
- ["--browser-channel", "browserChannel"]
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"]
624
862
  ]);
625
863
  if (valueFlags.has(current)) {
626
864
  const next = args[index + 1];
@@ -654,6 +892,34 @@ function parseDistributionOptions(args) {
654
892
  return { file: positional[0], options };
655
893
  }
656
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
+
657
923
  function parseArticleMutationOptions(args) {
658
924
  const parsed = parsePublishOptions(args);
659
925
  return {
@@ -1048,9 +1314,13 @@ function normalizeArticleId(value) {
1048
1314
  return articleId;
1049
1315
  }
1050
1316
 
1051
- function articlePreviewUrl(articleId) {
1052
- 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));
1053
1320
  url.searchParams.set("id", String(articleId));
1321
+ if (gameId === "33") {
1322
+ url.searchParams.set("cate_id", normalizeText(payload.cate_id) || "40");
1323
+ }
1054
1324
  return url.toString();
1055
1325
  }
1056
1326
 
@@ -1062,12 +1332,13 @@ function backendOriginsMatch(left, right) {
1062
1332
  }
1063
1333
  }
1064
1334
 
1065
- async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "") {
1335
+ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1066
1336
  const authState = await loadAuthState();
1067
1337
  await writeJson(ARTICLE_STATE_FILE, {
1068
1338
  article_id: articleId,
1069
1339
  status: String(status),
1070
1340
  preview_url: previewUrl,
1341
+ target,
1071
1342
  backend_url: backendUrl,
1072
1343
  administrator_id: Number(authState.id || 0),
1073
1344
  account: normalizeText(authState.account),
@@ -1075,16 +1346,16 @@ async function saveRecentArticleState(articleId, status, backendUrl, previewUrl
1075
1346
  });
1076
1347
  }
1077
1348
 
1078
- async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "") {
1349
+ async function trySaveRecentArticleState(articleId, status, backendUrl, previewUrl = "", target = "domestic") {
1079
1350
  try {
1080
- await saveRecentArticleState(articleId, status, backendUrl, previewUrl);
1351
+ await saveRecentArticleState(articleId, status, backendUrl, previewUrl, target);
1081
1352
  return "";
1082
1353
  } catch (error) {
1083
1354
  return normalizeText(error?.message) || String(error);
1084
1355
  }
1085
1356
  }
1086
1357
 
1087
- async function resolveArticleId(value, backendUrl) {
1358
+ async function resolveArticleId(value, backendUrl, target = "domestic") {
1088
1359
  if (normalizeText(value)) {
1089
1360
  return normalizeArticleId(value);
1090
1361
  }
@@ -1096,6 +1367,9 @@ async function resolveArticleId(value, backendUrl) {
1096
1367
  if (!backendOriginsMatch(recentArticle.backend_url, backendUrl)) {
1097
1368
  throw new Error("最近文章属于其他后端,请手动输入文章 ID");
1098
1369
  }
1370
+ if ((recentArticle.target || "domestic") !== target) {
1371
+ throw new Error("最近文章属于其他站点,请手动输入文章 ID");
1372
+ }
1099
1373
  if (String(recentArticle.status) !== "0") {
1100
1374
  throw new Error("最近文章不是未上线状态,请手动输入文章 ID");
1101
1375
  }
@@ -1206,6 +1480,9 @@ async function installCommand(options) {
1206
1480
 
1207
1481
  await ensureLocalDirs();
1208
1482
  console.log(chalk.green("✔ 本地目录已创建"));
1483
+ const rulesCreated = await ensureContentRulesFile();
1484
+ console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则已保留"));
1485
+ console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1209
1486
 
1210
1487
  const skillsInstalled = await installSkills();
1211
1488
  if (skillsInstalled.available) {
@@ -1258,6 +1535,7 @@ async function updateCommand() {
1258
1535
  console.log("");
1259
1536
 
1260
1537
  await ensureLocalDirs();
1538
+ const rulesCreated = await ensureContentRulesFile();
1261
1539
  await runCommand(getNpmCommand(), ["install", "-g", `${PACKAGE_NAME}@latest`]);
1262
1540
 
1263
1541
  const latestPackage = readPackageMetadata();
@@ -1272,6 +1550,8 @@ async function updateCommand() {
1272
1550
  console.log(chalk.gray(`版本: ${latestPackage.version}`));
1273
1551
  }
1274
1552
  printSkillsSyncResult(skillsInstalled);
1553
+ console.log(chalk.green(rulesCreated ? "✔ 用户内容规则已创建" : "✔ 用户内容规则未覆盖"));
1554
+ console.log(`内容规则: ${chalk.gray(CONTENT_RULES_FILE)}`);
1275
1555
  console.log("");
1276
1556
  }
1277
1557
 
@@ -1715,29 +1995,38 @@ function parseFrontMatter(raw) {
1715
1995
  }
1716
1996
 
1717
1997
  function normalizeArticlePayload(meta, content) {
1998
+ const articleContent = markdownToHtml(content);
1999
+ const { gameId, category } = normalizeGameAndCategory(meta);
1718
2000
  const payload = {
1719
2001
  article_title: normalizeText(meta.article_title),
1720
2002
  thumbnail: normalizeText(meta.thumbnail),
1721
2003
  move_thumbnail: normalizeText(meta.move_thumbnail),
1722
- article_content: markdownToHtml(content),
2004
+ article_content: articleContent,
1723
2005
  article_excerpt: normalizeText(meta.article_excerpt),
1724
2006
  article_url: normalizeText(meta.article_url),
1725
2007
  origin: normalizeText(meta.origin),
1726
2008
  status: normalizeMappedValue(meta.status, STATUS_MAP) || "0",
1727
- cate_id: normalizeMappedValue(meta.cate_id, CATEGORY_MAP),
2009
+ cate_id: category,
1728
2010
  video_link: normalizeText(meta.video_link),
1729
2011
  is_hot: normalizeBoolLike(meta.is_hot, "0"),
1730
2012
  is_index: normalizeBoolLike(meta.is_index, "0"),
1731
2013
  release_time: normalizeText(meta.release_time),
1732
2014
  area: normalizeMappedValue(meta.area, AREA_MAP) || "1",
1733
2015
  sort: normalizeText(meta.sort) || "1",
1734
- game_id: normalizeMappedValue(meta.game_id, GAME_MAP) || "39",
2016
+ game_id: gameId,
1735
2017
  is_index2: "0",
1736
2018
  index_pc_img: normalizeText(meta.index_pc_img),
1737
2019
  index_move_img: normalizeText(meta.index_move_img),
1738
2020
  type: "1"
1739
2021
  };
1740
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
+
1741
2030
  if (!payload.article_title) {
1742
2031
  throw new Error("article_title 不能为空");
1743
2032
  }
@@ -1753,6 +2042,19 @@ function normalizeArticlePayload(meta, content) {
1753
2042
  return payload;
1754
2043
  }
1755
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
+
1756
2058
  async function parseArticleFile(file) {
1757
2059
  const articleFile = path.resolve(process.cwd(), file);
1758
2060
  if (!(await pathExists(articleFile))) {
@@ -1769,8 +2071,8 @@ async function parseArticleFile(file) {
1769
2071
  };
1770
2072
  }
1771
2073
 
1772
- async function initArticleTemplate(file = "article.md") {
1773
- 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}`);
1774
2076
  const targetFile = path.resolve(process.cwd(), file);
1775
2077
 
1776
2078
  if (!(await pathExists(sourceFile))) {
@@ -1788,11 +2090,11 @@ async function initArticleTemplate(file = "article.md") {
1788
2090
  console.log("");
1789
2091
  console.log("请编辑该文件后执行:");
1790
2092
  console.log("");
1791
- 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}`);
1792
2094
  console.log("");
1793
2095
  }
1794
2096
 
1795
- async function importArticleCommand(sourceUrl, file = "article.md") {
2097
+ async function importArticleCommand(sourceUrl, file = "article.md", options = {}) {
1796
2098
  if (!sourceUrl) {
1797
2099
  throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
1798
2100
  }
@@ -1816,9 +2118,19 @@ async function importArticleCommand(sourceUrl, file = "article.md") {
1816
2118
  }
1817
2119
  sourceLabel = "微信文章";
1818
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
+ }
1819
2127
  article = await importWechatArticle(sourceUrl, {
1820
- onProgress: ({ current, total }) => {
1821
- console.log(chalk.gray(`正在转存正文图片 ${current}/${total}`));
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}`));
1822
2134
  }
1823
2135
  });
1824
2136
  }
@@ -1834,7 +2146,22 @@ async function importArticleCommand(sourceUrl, file = "article.md") {
1834
2146
  console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
1835
2147
  console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
1836
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
+ }
1837
2161
  console.log(chalk.gray(`已转存图片: ${article.imageCount} 张`));
2162
+ if (article.videoCount > 0) {
2163
+ console.log(chalk.gray(`已转存视频: ${article.videoCount} 个`));
2164
+ }
1838
2165
  console.log(chalk.gray(`已提取正文: ${article.textSegmentCount} 段`));
1839
2166
  console.log(chalk.gray(`已保留文字颜色: ${article.colorCount} 处`));
1840
2167
  console.log(
@@ -1877,9 +2204,10 @@ release_time: 2026-07-16
1877
2204
  area: pc
1878
2205
  sort: 100
1879
2206
  game_id: 最强蜗牛
2207
+ distribution_project: ""
1880
2208
  index_pc_img: ""
1881
2209
  index_move_img: ""
1882
- taptap_type: long-post
2210
+ taptap_type: ""
1883
2211
  taptap_forum: ""
1884
2212
  taptap_scheduled: "0"
1885
2213
  taptap_draft: "0"
@@ -1893,6 +2221,8 @@ bilibili_category: ""
1893
2221
  bilibili_tags: ""
1894
2222
  bilibili_topic: ""
1895
2223
  weibo_intro: ""
2224
+ weibo_super_topic: ""
2225
+ weibo_cover: ""
1896
2226
  ---
1897
2227
 
1898
2228
  # 文章标题
@@ -1907,13 +2237,23 @@ ${chalk.cyan("发布命令:")}
1907
2237
  ${chalk.cyan("从微信推文生成官网草稿:")}
1908
2238
 
1909
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)
1910
2247
  `);
1911
2248
  }
1912
2249
 
1913
- function inferDistributionProject(payload, explicitProject) {
2250
+ function inferDistributionProject(payload, explicitProject, meta = {}) {
1914
2251
  if (normalizeText(explicitProject)) {
1915
2252
  return normalizeText(explicitProject);
1916
2253
  }
2254
+ if (normalizeText(meta.distribution_project)) {
2255
+ return normalizeText(meta.distribution_project);
2256
+ }
1917
2257
  if (payload?.game_id === "39") {
1918
2258
  return "最强蜗牛";
1919
2259
  }
@@ -1927,6 +2267,12 @@ function printPlatformEntries(entries) {
1927
2267
  entries.forEach(entry => {
1928
2268
  const status = entry.publisher === "blocked" || entry.publisher === "unavailable" ? "不可自动发布" : entry.publisher;
1929
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
+ }
1930
2276
  });
1931
2277
  console.log("");
1932
2278
  }
@@ -1935,10 +2281,11 @@ async function publishPlatformsCommand(file, options) {
1935
2281
  let parsedArticle;
1936
2282
  if (file) {
1937
2283
  parsedArticle = await parseArticleFile(file);
2284
+ parsedArticle = applyPublishOverrides(parsedArticle, options);
1938
2285
  }
1939
2286
  const allEntries = await loadPlatformEntries({ configFile: options.configFile });
1940
2287
 
1941
- const project = inferDistributionProject(parsedArticle?.payload, options.project);
2288
+ const project = inferDistributionProject(parsedArticle?.payload, options.project, parsedArticle?.meta);
1942
2289
  if (!project) {
1943
2290
  if (options.listPlatforms) {
1944
2291
  printPlatformEntries(allEntries);
@@ -1956,24 +2303,68 @@ async function publishPlatformsCommand(file, options) {
1956
2303
  printPlatformEntries(candidates);
1957
2304
  return;
1958
2305
  }
1959
- if (!parsedArticle) {
2306
+ if (!parsedArticle && !options.openPage) {
1960
2307
  throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
1961
2308
  }
1962
2309
 
1963
- const selected = options.platforms.length
2310
+ const selectedEntries = options.platforms.length
1964
2311
  ? selectRequestedEntries(candidates, options.platforms)
1965
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
+ };
1966
2352
  const planningArticle = {
1967
2353
  ...parsedArticle,
1968
2354
  title: parsedArticle.payload.article_title,
1969
- html: parsedArticle.payload.article_content
2355
+ html: parsedArticle.payload.article_content,
2356
+ platformInputs
1970
2357
  };
1971
- const plan = selected.map(entry => ({
1972
- ...publicEntrySummary(entry),
1973
- ...(entry.platformKey === "taptap" ? { taptap: tapTapPublishingPlan(planningArticle, entry) } : {}),
1974
- ...(entry.platformKey === "bilibili" ? { bilibili: bilibiliPublishingPlan(planningArticle) } : {}),
1975
- ...(entry.platformKey === "weibo" ? { weibo: weiboPublishingPlan(planningArticle) } : {})
1976
- }));
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
+ });
1977
2368
  if (options.dryRun) {
1978
2369
  console.log(JSON.stringify({ article: parsedArticle.articleFile, project, platforms: plan }, null, 2));
1979
2370
  return;
@@ -1985,14 +2376,15 @@ async function publishPlatformsCommand(file, options) {
1985
2376
  markdown: parsedArticle.markdown,
1986
2377
  html: parsedArticle.payload.article_content,
1987
2378
  payload: parsedArticle.payload,
1988
- meta: parsedArticle.meta
2379
+ meta: parsedArticle.meta,
2380
+ platformInputs
1989
2381
  };
1990
2382
  const { successes, failures, skipped } = await runPlatformPublishSequence(selected, async entry => {
1991
2383
  console.log("");
1992
2384
  console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
1993
2385
  let result;
1994
2386
  if (entry.publisher === "official-api") {
1995
- await publishArticleCommand(file, options);
2387
+ await publishArticleCommand(file, options, entry);
1996
2388
  } else {
1997
2389
  result = await publishPlatformEntry(entry, article, {
1998
2390
  browserChannel: options.browserChannel,
@@ -2032,13 +2424,58 @@ async function publishPlatformsCommand(file, options) {
2032
2424
  }
2033
2425
  }
2034
2426
 
2035
- async function publishArticleCommand(file, options) {
2427
+ async function publishArticleCommand(file, options, platformEntry = null, expectedTarget = "") {
2036
2428
  if (!file) {
2037
2429
  throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
2038
2430
  }
2039
2431
 
2040
- 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
+ }
2041
2474
  payload.status = "0";
2475
+ const target = articleTarget(payload);
2476
+ if (expectedTarget && target !== expectedTarget) {
2477
+ throw new Error("欧美蜗牛官网文章必须使用 game_id: 64 和有效的 language");
2478
+ }
2042
2479
  const config = await loadConfig();
2043
2480
  const backendUrl = resolvePublishBackendUrl(options, config);
2044
2481
 
@@ -2048,40 +2485,46 @@ async function publishArticleCommand(file, options) {
2048
2485
  }
2049
2486
 
2050
2487
  console.log("");
2051
- console.log(chalk.cyan("正在保存官网文章为未上线状态..."));
2488
+ console.log(chalk.cyan(target === "west" ? "正在保存欧美蜗牛官网文章为未上线状态..." : "正在保存官网文章为未上线状态..."));
2052
2489
  console.log(chalk.gray(`文章文件: ${articleFile}`));
2053
2490
  console.log("");
2054
2491
 
2055
- const response = await authenticatedRequest("POST", backendUrl, "/api/articles/publish", payload);
2492
+ const response = await authenticatedRequest("POST", backendUrl, articleApiPath(target, "publish"), payload);
2056
2493
  const rawArticleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
2057
2494
  const articleId = normalizeArticleId(rawArticleId);
2058
- const previewUrl = normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId);
2059
- 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);
2060
2497
 
2061
2498
  console.log(chalk.green(response.message || "文章已保存为未上线状态"));
2062
2499
  console.log(`文章 ID: ${articleId}`);
2063
2500
  console.log("文章状态: 未上线");
2064
- console.log(`预览地址: ${previewUrl}`);
2501
+ if (previewUrl) {
2502
+ console.log(`预览地址: ${previewUrl}`);
2503
+ } else {
2504
+ console.log(chalk.yellow("欧美官网预览地址尚未配置,请在后台确认草稿内容和排版。"));
2505
+ }
2065
2506
  if (stateWarning) {
2066
2507
  console.log(chalk.yellow(`本地未能记录最近文章 ID,后续操作请手动输入 ${articleId}: ${stateWarning}`));
2067
2508
  }
2068
2509
  console.log("");
2069
- try {
2070
- await openBrowser(previewUrl);
2071
- console.log(chalk.cyan("已打开文章预览页面,请由用户检查文章内容和排版。"));
2072
- } catch {
2073
- 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
+ }
2074
2517
  }
2075
2518
  console.log("用户确认无误后再执行:");
2076
2519
  console.log("");
2077
- console.log(` qcplay-cli article update ${articleId}`);
2520
+ console.log(` qcplay-cli ${articleMutationCommand(target)} ${articleId}`);
2078
2521
  console.log("");
2079
2522
  }
2080
2523
 
2081
- async function updateArticleStatusCommand(articleIdValue, targetStatus, options) {
2524
+ async function updateArticleStatusCommand(articleIdValue, targetStatus, options, target = "domestic") {
2082
2525
  const config = await loadConfig();
2083
2526
  const backendUrl = resolvePublishBackendUrl(options, config);
2084
- const articleId = await resolveArticleId(articleIdValue, backendUrl);
2527
+ const articleId = await resolveArticleId(articleIdValue, backendUrl, target);
2085
2528
  const payload = {
2086
2529
  id: articleId,
2087
2530
  status: targetStatus
@@ -2094,17 +2537,17 @@ async function updateArticleStatusCommand(articleIdValue, targetStatus, options)
2094
2537
 
2095
2538
  const action = targetStatus === "2" ? "删除" : "上线";
2096
2539
  console.log("");
2097
- console.log(chalk.cyan(`正在${action}官网文章...`));
2540
+ console.log(chalk.cyan(`正在${action}${target === "west" ? "欧美蜗牛" : ""}官网文章...`));
2098
2541
  console.log(chalk.gray(`文章 ID: ${articleId}`));
2099
2542
  console.log("");
2100
2543
 
2101
- const response = await authenticatedRequest("PATCH", backendUrl, "/api/articles/status", payload);
2544
+ const response = await authenticatedRequest("PATCH", backendUrl, articleApiPath(target, "status"), payload);
2102
2545
  const responseStatus = normalizeText(response.data?.status) || targetStatus;
2103
2546
  const previewUrl =
2104
2547
  responseStatus === "1"
2105
- ? normalizeText(response.data?.preview_url) || articlePreviewUrl(articleId)
2548
+ ? normalizeText(response.data?.preview_url) || (target === "domestic" ? articlePreviewUrl(articleId) : "")
2106
2549
  : "";
2107
- const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl);
2550
+ const stateWarning = await trySaveRecentArticleState(articleId, responseStatus, backendUrl, previewUrl, target);
2108
2551
 
2109
2552
  console.log(chalk.green(response.message || (responseStatus === "2" ? "文章已删除" : "文章已上线")));
2110
2553
  console.log(`文章 ID: ${articleId}`);
@@ -2178,6 +2621,21 @@ async function main() {
2178
2621
  return;
2179
2622
  }
2180
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
+
2181
2639
  if (command === "article") {
2182
2640
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
2183
2641
  if (!subcommand) {
@@ -2194,16 +2652,17 @@ async function main() {
2194
2652
  return;
2195
2653
  }
2196
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
+
2197
2661
  if (subcommand === "import") {
2198
- const sourceUrl = rest[0];
2199
- const file = rest[1] || "article.md";
2200
- if (rest.length > 2) {
2201
- await runWithErrorBanner("转换失败", async () => {
2202
- throw new Error(`未知参数: ${rest.slice(2).join(" ")}`);
2203
- });
2204
- return;
2205
- }
2206
- await runWithErrorBanner("转换失败", () => importArticleCommand(sourceUrl, file));
2662
+ const parsed = parseArticleImportOptions(rest);
2663
+ await runWithErrorBanner("转换失败", () =>
2664
+ importArticleCommand(parsed.sourceUrl, parsed.file, parsed.options)
2665
+ );
2207
2666
  return;
2208
2667
  }
2209
2668
 
@@ -2243,6 +2702,22 @@ async function main() {
2243
2702
  return;
2244
2703
  }
2245
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
+
2246
2721
  if (command === "features") {
2247
2722
  printFeatures();
2248
2723
  return;