@qcplay/cli 1.0.13 → 1.0.14

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/README.md CHANGED
@@ -11,6 +11,7 @@
11
11
  - 登录凭证保存在当前用户本机的 `~/.qcplay/auth.json`,不同客户端之间不共享账号
12
12
  - 状态、权限和文章请求通过 Bearer Token 识别当前账号
13
13
  - `qcplay-cli article import <微信文章链接> [文件]` 会提取正文、转存图片并生成未发布的官网文章草稿
14
+ - `qcplay-cli publish <文件>` 会按项目、区域和平台把文章分发到多个渠道
14
15
  - `qcplay-cli update` 会更新 npm 全局包,并同步 `~/.agents/skills`
15
16
  - 本地调试时可直接使用 `--local`
16
17
  - `--local` 默认指向 `http://127.0.0.1:8787`
@@ -18,7 +19,113 @@
18
19
  - 如果本地域名不是 `127.0.0.1:8787`,可设置 `QCPLAY_LOCAL_BASE_URL=http://cli.local.test`
19
20
  - 安装说明:仓库根目录的 `cli-installation-guide.md`
20
21
 
21
- ## 微信文章转官网草稿
22
+ ## 多平台分发
23
+
24
+ 平台配置默认从飞书 Wiki“内容一键分发信息表”实时读取,账号、密码和 webhook 不会写入项目文件,也不会出现在 dry-run 或平台列表中。首次使用需要当前用户已通过 `lark-cli` 授予 `wiki:node:retrieve` 和 `sheets:spreadsheet:read` 只读权限。
25
+
26
+ 查看某个项目可用的平台:
27
+
28
+ ```bash
29
+ qcplay-cli publish --project 最强蜗牛 --list-platforms
30
+ ```
31
+
32
+ 指定一个或多个平台:
33
+
34
+ ```bash
35
+ qcplay-cli publish article.md --project 最强蜗牛 --region 国内 --platform TapTap --platform B站
36
+ qcplay-cli publish article.md --project 最强蜗牛 --region 欧美 --platform Discord
37
+ ```
38
+
39
+ 不传 `--platform` 时,交互终端会列出匹配的平台供选择;CI 等非交互环境会报错并提示可用平台。`game_id: "39"` 可自动推断项目为“最强蜗牛”。发布前可检查脱敏计划:
40
+
41
+ ```bash
42
+ qcplay-cli publish article.md --project 最强蜗牛 --region 国内 --platform TapTap --dry-run
43
+ ```
44
+
45
+ 官网外的浏览器平台可在最终提交前暂停,先在浏览器中预览已填好的内容:
46
+
47
+ ```bash
48
+ qcplay-cli publish article.md --project 最强蜗牛 --region 国内 --platform TapTap --review-draft
49
+ ```
50
+
51
+ 默认仍按原流程直接发布。使用 `--review-draft`(简写 `--review`)时,CLI 会完成标题、富文本、图片、封面和发布设置后保持浏览器打开;确认无误后在 CMD 输入“可以发布”“发布”或直接按 Enter,输入“取消”则停止且不点击最终发布。官网发布流程不受该参数影响。好游快爆没有可复用的原生草稿箱,因此会保持当前编辑页供预览。
52
+
53
+ 发布方式:
54
+
55
+ - 官网继续使用现有 QCPlay API,并保持“先保存未上线草稿、用户预览后再上线”的原逻辑。
56
+ - 配置了 webhook 的 Discord 使用 API 分发,长内容自动拆分且禁用 `@everyone` 等自动提及。
57
+ - TapTap、好游快爆、B站、微博、X、Facebook/Instagram 使用可见的本机 Edge/Chrome 和独立持久登录态。TapTap、好游快爆、B站和微博的账号密码配置会在加载后丢弃;未登录时由用户在浏览器中自行填写登录信息或扫码,CLI 不会向登录页写入账号密码,也不会点击登录提交。验证码、二次验证和页面反爬验证同样由用户完成,不绕过平台验证。
58
+ - 已存在有效登录态时,CLI 会等待发布编辑器加载完成并直接填充内容,不再重复提示登录;只有明确进入登录页或检测不到有效会话时才进入登录流程。
59
+ - 多个浏览器平台按顺序处理:当前平台需要扫码或验证时会停留等待,当前浏览器平台失败时停止后续平台,避免跳过登录继续发布。
60
+ - 图片和视频必须确认上传或处理完成后才能提交;上传失败或超过有限等待时间会停止当前平台,避免发布按钮卡死或提交不完整内容。
61
+ - 好游快爆只使用飞书配置中当前游戏对应的 `bbs.3839.com` 发布链接,并保留有效 `fid`;例如最强蜗牛使用 `fid=12201`,缺失或为 `0` 会直接停止。未登录时先进入官网首页并点击头像登录,在官方 iframe 中完成登录;登录成功后最多重试 3 次返回原游戏发布页,不会改用其他游戏或通用链接。
62
+ - 好游快爆新版发帖页使用 `#threadTitle` 可编辑标题、Quill `.ql-editor` 正文和 `.editSubmit.active` 发布控件。CLI 会忽略 Quill 对空白和换行的重排,连续回读确认内容稳定;标题超过 48 字、正文仍为空或发布控件不可用时停止。点击发布后只有 `setThread` 接口返回 `code: 100`、出现成功提示或跳转到论坛/帖子页才判定成功;没有成功回执时会报错,并保留浏览器窗口供检查后再关闭。
63
+ - Reddit 配置明确提示非官方工具存在封号风险;未配置官方 Reddit 应用时,CLI 会拒绝自动发布。
64
+ - 配置缺少发布 URL、webhook 或适配器的平台会明确报错,不伪造成功结果。
65
+
66
+ ### TapTap 发布设置
67
+
68
+ TapTap 文章默认按“长帖”发布。需要图文、视频、论坛、定时或草稿时,在文章 Front Matter 中增加对应字段;发布命令仍然只传文章文件:
69
+
70
+ ```yaml
71
+ taptap_type: long-post # long-post、image-text、video
72
+ taptap_forum: "" # 留空默认使用项目同名论坛;填 0 不添加论坛
73
+ taptap_scheduled: "1" # 勾选定时发布,具体时间可在打开的页面确认
74
+ taptap_draft: "0" # 1 保存草稿,0 正式提交
75
+ taptap_images: image-1.png,image-2.png
76
+ taptap_video: ./video.mp4
77
+ taptap_cover: ./cover.png
78
+ ```
79
+
80
+ - 图文至少需要 1 张图片、最多 18 张;未设置 `taptap_images` 时会依次使用正文图片,正文无图片时使用 `thumbnail`。
81
+ - 视频必须使用 `taptap_video` 指向本地文件,远程 `video_link` 不能直接交给 TapTap 的文件上传控件。
82
+ - `taptap_scheduled` 与 `taptap_draft` 不能同时启用。
83
+ - 添加论坛时会依次点击搜索建议和精确同名论坛结果行,再执行“返回 → 完成”;“完成”仍为 CSS 禁用状态、弹窗未关闭或主页面未回读到论坛时都不会继续发布。已选中同名论坛时不会重复点击反选。
84
+ - 长帖标题最多 30 个字符;图文标题最多 20 个字符、正文最多 5000 个字符。超过限制会停止并提示修改,不会静默截断。
85
+ - 长帖正文按原文顺序写入:普通文字和 `http/https` 超链接使用 TapTap 富文本粘贴,图片会先下载,再通过 TapTap 官方正文图片控件上传。不会再把图片降级为 URL 文本,链接会保留为可点击跳转。
86
+ - Markdown 会先转换为富文本;TapTap 支持的标题强调、段落间距、有序/无序列表、引用、粗体、斜体、下划线和删除线会保留。原内容中的对齐和颜色也会传入,但最终效果以 TapTap 编辑器支持范围为准。
87
+ - 图文模式的正文图片仍按 TapTap 规则进入顶部图片区;正文中的超链接保留为可点击链接。视频模式同样保留正文超链接。
88
+ - CLI 会在填写后回读标题和正文,精确点击编辑器页脚的发布/草稿按钮,并等待成功提示、页面跳转或发布接口成功响应。未检测到回执时按失败处理,不会输出成功。
89
+ - TapTap 编辑器的自动保存请求不会被拦截,但正式发布时不会把自动草稿创建/更新误判为成功;CLI 会继续等待正式发布接口成功后才关闭浏览器。显式设置 `taptap_draft: "1"` 时才以草稿保存回执结束流程。
90
+ - 自动化测试使用模拟浏览器;真实编辑器的格式探测必须在断网后进行,禁止保存或发布测试图片草稿。
91
+
92
+ ### 微博发布设置
93
+
94
+ 微博使用头条文章编辑器发布富文本文章,而不是首页的短微博发送框:
95
+
96
+ ```yaml
97
+ weibo_intro: "" # 可选;留空使用 article_excerpt,最多 44 字
98
+ weibo_cover: "" # 可选;必须引用正文图片,留空默认使用正文第 1 张图
99
+ ```
100
+
101
+ - 文章标题独立写入头条文章标题框,最多 32 个字符;正文写入 Tiptap/ProseMirror 富文本编辑器。
102
+ - 草稿箱为空时,CLI 会先点击“写文章”,等待新草稿 ID 生成且编辑器遮罩消失,再重写标题、导语和正文;导语为空时也会主动清除旧值。
103
+ - 正文标题、段落、列表、引用、粗体、斜体、下划线、删除线和 `http/https` 超链接会在编辑器支持范围内保留。
104
+ - 正文图片按原文位置和顺序执行“图片库 → 上传完成 → 选中 `.image-list .image-item` 缩略图 → 等待 `is-selected` → 插入”。首次打开图片库时,CLI 会通过 `.ico_delpic` 逐张删除既有图片并等待缩略图消失,再上传本次文章图片;后续打开不再清理,因此发布结束时图片库只保留本次文章使用的图片。同一图片地址重复出现时只保留首次出现的一张。出现裁剪对话框时会等待裁剪区域连续稳定,再确认并验证弹窗关闭。
105
+ - 正文含图片时,CLI 会在发布设置中进入“正文图片”,选择 `weibo_cover` 指定的正文图;未指定时选择第 1 张,并完成“下一步 → 图片裁剪 → 确定”。选图和裁剪弹窗按当前可见文本动态定位,避免前一个弹窗关闭后 DOM 下标变化导致等待失效;若首次确认无响应或微博提示“图片处理失败”,会自动重试一次。封面没有回显时不会继续发布。
106
+ - 封面确认并回显后,CLI 立即按“下一步 → 发布”完成投稿。只有检测到发布接口、成功提示或发布历史页面跳转的成功回执时才报告成功。
107
+
108
+ ### B站投稿设置
109
+
110
+ B站默认按专栏图文发布;视频投稿通过 Front Matter 切换到官方视频投稿页:
111
+
112
+ ```yaml
113
+ bilibili_type: article # article(专栏图文)或 video(视频投稿)
114
+ bilibili_video: ./video.mp4 # 视频投稿必须是本地文件
115
+ bilibili_cover: ./cover.png
116
+ bilibili_category: 游戏/手机游戏
117
+ bilibili_tags: 最强蜗牛,攻略
118
+ bilibili_topic: 最强蜗牛攻略
119
+ ```
120
+
121
+ - 专栏正文会转换成富文本,在编辑器支持范围内保留标题、段落、列表、引用、粗体、斜体、下划线、删除线、颜色和对齐。
122
+ - 正文图片按原文位置通过 B站官方图片控件上传;`http/https` 链接保留为可点击超链接。图片、文字或链接回读不完整时会停止投稿。
123
+ - 视频必须使用 `bilibili_video` 指向本地文件,文件需小于 16GB;建议使用 MP4、MOV 或 MKV。CLI 会先上传视频,等待稿件表单出现后再填写标题和简介。
124
+ - 视频简介不提供专栏富文本能力;带文字的超链接会转换为“文字(URL)”,使目标地址不丢失。
125
+ - `bilibili_category` 可用 `/` 表示多级分区,`bilibili_tags` 最多 10 个。填写 `bilibili_topic` 后会搜索并选择精确同名话题;找不到时停止投稿,不会引用相似话题。
126
+ - 只有检测到 B站成功提示、投稿后页面跳转或成功接口回执时才报告成功;仅点击投稿按钮不算成功。
127
+
128
+ ## 微信或飞书文章转官网草稿
22
129
 
23
130
  ```bash
24
131
  qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
@@ -26,6 +133,14 @@ qcplay-cli article import "https://mp.weixin.qq.com/s/..." article.md
26
133
 
27
134
  转换流程会读取微信文章标题、来源、发布日期和完整正文,经过安全清理后保留微信原文的 HTML 层级、内联样式、字号、颜色、对齐、间距、媒体链接和表格,并转存正文图片、CSS 背景图及封面图。背景图片仍保留在原容器中,不会额外生成一张普通图片。正文保持微信的 `677px` 阅读宽度并兼容窄屏;独占一行的图片会使用块级布局,纯图片标题容器会转换为普通区块,避免官网 PC 标题样式造成相邻图片重叠。任何正文节点遗漏或图片处理失败都会终止转换,不生成不完整草稿。输出固定为 `status: "0"`,并保留空的 `cate_id` 供发布前确认。
28
135
 
136
+ 飞书 Docx/Wiki 文档使用同一命令:
137
+
138
+ ```bash
139
+ qcplay-cli article import "https://tenant.feishu.cn/wiki/..." article.md
140
+ ```
141
+
142
+ 飞书来源会读取完整 XML 块结构,而不是有损的 Markdown 导出;标题层级、粗体/斜体、文字颜色、背景高亮、段落对齐、引用、编号列表、链接、表格列宽和合并单元格会转换为安全富文本。宽表格保持原列宽并在窄屏横向滚动。导入仍固定生成未上线草稿,缩略图和业务字段需在发布前确认。
143
+
29
144
  确认草稿内容和业务字段后保存为未上线文章:
30
145
 
31
146
  ```bash
package/bin/qcplay.js CHANGED
@@ -15,6 +15,20 @@ 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
+ availableProjects,
21
+ bilibiliPublishingPlan,
22
+ entriesForProject,
23
+ loadPlatformEntries,
24
+ promptForPlatformEntries,
25
+ publicEntrySummary,
26
+ publishPlatformEntry,
27
+ runPlatformPublishSequence,
28
+ selectRequestedEntries,
29
+ tapTapPublishingPlan,
30
+ weiboPublishingPlan
31
+ } from "../lib/platform-publish.js";
18
32
 
19
33
  const __filename = fileURLToPath(import.meta.url);
20
34
  const __dirname = path.dirname(__filename);
@@ -149,10 +163,12 @@ Usage:
149
163
  qcplay-cli auth permissions [--local] [--key <key>] [--json]
150
164
  qcplay-cli article
151
165
  qcplay-cli article init [file]
152
- qcplay-cli article import <wechat-url> [file]
166
+ qcplay-cli article import <wechat-or-feishu-url> [file]
153
167
  qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
154
168
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
155
169
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]
170
+ qcplay-cli publish [file] [--project <项目>] [--region <区域>] [--platform <平台>]
171
+ qcplay-cli publish --project <项目> --list-platforms
156
172
  qcplay-cli www-article-list.store <file> [--local] [--backend <url>] [--dry-run]
157
173
  qcplay-cli www-article-list.update [id] [--local] [--backend <url>] [--dry-run]
158
174
  qcplay-cli www-article-list.delete [id] [--local] [--backend <url>] [--dry-run]
@@ -180,12 +196,51 @@ function printArticleHelp() {
180
196
  console.log(`Usage:
181
197
  qcplay-cli article
182
198
  qcplay-cli article init [file]
183
- qcplay-cli article import <wechat-url> [file]
199
+ qcplay-cli article import <wechat-or-feishu-url> [file]
184
200
  qcplay-cli article publish <file> [--local] [--backend <url>] [--dry-run]
185
201
  qcplay-cli article update [id] [--local] [--backend <url>] [--dry-run]
186
202
  qcplay-cli article delete [id] [--local] [--backend <url>] [--dry-run]`);
187
203
  }
188
204
 
205
+ function printPublishHelp() {
206
+ console.log(`Usage:
207
+ qcplay-cli publish <file> --project <项目> [--region <区域>] [--platform <平台> ...]
208
+ qcplay-cli publish --project <项目> [--region <区域>] --list-platforms
209
+
210
+ Options:
211
+ --project <项目> 项目名称;game_id=39 时默认使用“最强蜗牛”
212
+ --region <区域> 国内、日本、欧美等区域
213
+ -p, --platform <平台> 发布平台,可重复使用或用逗号分隔
214
+ --list-platforms 列出匹配的平台,不发布
215
+ --config-file <json> 使用本地平台配置(主要用于测试)
216
+ --browser-channel <name> Playwright 浏览器通道,Windows 默认 msedge
217
+ --review-draft, --review 官网外平台先准备草稿,预览后在 CMD 确认发布
218
+ --keep-open 浏览器提交后等待用户确认再关闭
219
+ --dry-run 只展示发布计划,不登录或发布
220
+ --local 官网发布使用本地后端
221
+ --backend <url> 官网发布后端地址
222
+
223
+ TapTap Front Matter:
224
+ taptap_type long-post(默认)、image-text 或 video
225
+ taptap_forum 默认使用当前项目同名论坛;填 0 时不添加论坛
226
+ taptap_scheduled 填 1 时勾选定时发布
227
+ taptap_draft 填 1 时保存草稿而非发布
228
+ taptap_images 图文图片路径或 URL,多个用逗号分隔
229
+ taptap_video 视频模式使用的本地视频路径
230
+ taptap_cover 长帖封面路径或 URL
231
+
232
+ B站 Front Matter:
233
+ bilibili_type article(默认)或 video
234
+ bilibili_video 视频投稿使用的本地视频路径
235
+ bilibili_cover 投稿封面本地路径或 URL
236
+ bilibili_category 投稿分区,可用“一级/二级”表示层级
237
+ bilibili_tags 投稿标签,多个用逗号分隔
238
+ bilibili_topic 投稿话题;填写后搜索并选择精确同名话题
239
+
240
+ 微博 Front Matter:
241
+ weibo_intro 头条文章导语,最多 44 个字符;默认使用 article_excerpt`);
242
+ }
243
+
189
244
  function printFeatures() {
190
245
  console.log("");
191
246
  console.log(chalk.green("你现在可以使用以下功能:"));
@@ -199,12 +254,16 @@ function printFeatures() {
199
254
  console.log(" qcplay-cli auth permissions --key www-article-list.store");
200
255
  console.log("");
201
256
  console.log(chalk.cyan("3. 发布官网文章"));
202
- console.log(" qcplay-cli article import <微信文章链接> article.md");
257
+ console.log(" qcplay-cli article import <微信文章或飞书文档链接> article.md");
203
258
  console.log(" qcplay-cli www-article-list.store article.md");
204
259
  console.log(" qcplay-cli article update [id]");
205
260
  console.log(" qcplay-cli article delete [id]");
206
261
  console.log("");
207
- console.log(chalk.cyan("4. 查看本地目录"));
262
+ console.log(chalk.cyan("4. 多平台分发"));
263
+ console.log(" qcplay-cli publish article.md --project 最强蜗牛");
264
+ console.log(" qcplay-cli publish --project 最强蜗牛 --list-platforms");
265
+ console.log("");
266
+ console.log(chalk.cyan("5. 查看本地目录"));
208
267
  console.log(" qcplay-cli where");
209
268
  console.log("");
210
269
  }
@@ -517,6 +576,84 @@ function parsePublishOptions(args) {
517
576
  };
518
577
  }
519
578
 
579
+ function parseDistributionOptions(args) {
580
+ const options = {
581
+ backend: undefined,
582
+ local: false,
583
+ dryRun: false,
584
+ project: "",
585
+ region: "",
586
+ platforms: [],
587
+ listPlatforms: false,
588
+ configFile: "",
589
+ browserChannel: "",
590
+ reviewDraft: false,
591
+ keepOpen: false
592
+ };
593
+ const positional = [];
594
+
595
+ for (let index = 0; index < args.length; index += 1) {
596
+ const current = args[index];
597
+ if (current === "--dry-run") {
598
+ options.dryRun = true;
599
+ continue;
600
+ }
601
+ if (current === "--local") {
602
+ options.local = true;
603
+ continue;
604
+ }
605
+ if (current === "--list-platforms") {
606
+ options.listPlatforms = true;
607
+ continue;
608
+ }
609
+ if (current === "--keep-open") {
610
+ options.keepOpen = true;
611
+ continue;
612
+ }
613
+ if (current === "--review-draft" || current === "--review") {
614
+ options.reviewDraft = true;
615
+ continue;
616
+ }
617
+
618
+ const valueFlags = new Map([
619
+ ["--backend", "backend"],
620
+ ["--project", "project"],
621
+ ["--region", "region"],
622
+ ["--config-file", "configFile"],
623
+ ["--browser-channel", "browserChannel"]
624
+ ]);
625
+ if (valueFlags.has(current)) {
626
+ const next = args[index + 1];
627
+ if (!next || next.startsWith("--")) {
628
+ throw new Error(`${current} 缺少参数值`);
629
+ }
630
+ options[valueFlags.get(current)] = next;
631
+ index += 1;
632
+ continue;
633
+ }
634
+
635
+ if (current === "--platform" || current === "-p") {
636
+ const next = args[index + 1];
637
+ if (!next || next.startsWith("--")) {
638
+ throw new Error(`${current} 缺少参数值`);
639
+ }
640
+ options.platforms.push(...next.split(/[,,]/).map(value => value.trim()).filter(Boolean));
641
+ index += 1;
642
+ continue;
643
+ }
644
+
645
+ if (current.startsWith("--")) {
646
+ throw new Error(`未知参数: ${current}`);
647
+ }
648
+ positional.push(current);
649
+ }
650
+
651
+ if (positional.length > 1) {
652
+ throw new Error(`未知参数: ${positional.slice(1).join(" ")}`);
653
+ }
654
+ return { file: positional[0], options };
655
+ }
656
+
520
657
  function parseArticleMutationOptions(args) {
521
658
  const parsed = parsePublishOptions(args);
522
659
  return {
@@ -1626,7 +1763,9 @@ async function parseArticleFile(file) {
1626
1763
  const parsed = parseFrontMatter(raw);
1627
1764
  return {
1628
1765
  articleFile,
1629
- payload: normalizeArticlePayload(parsed.meta, parsed.content)
1766
+ payload: normalizeArticlePayload(parsed.meta, parsed.content),
1767
+ meta: parsed.meta,
1768
+ markdown: parsed.content
1630
1769
  };
1631
1770
  }
1632
1771
 
@@ -1653,9 +1792,9 @@ async function initArticleTemplate(file = "article.md") {
1653
1792
  console.log("");
1654
1793
  }
1655
1794
 
1656
- async function importWechatArticleCommand(sourceUrl, file = "article.md") {
1795
+ async function importArticleCommand(sourceUrl, file = "article.md") {
1657
1796
  if (!sourceUrl) {
1658
- throw new Error("缺少微信文章链接,例如:qcplay-cli article import https://mp.weixin.qq.com/s/... article.md");
1797
+ throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
1659
1798
  }
1660
1799
 
1661
1800
  const targetFile = path.resolve(process.cwd(), file);
@@ -1664,30 +1803,48 @@ async function importWechatArticleCommand(sourceUrl, file = "article.md") {
1664
1803
  }
1665
1804
 
1666
1805
  console.log("");
1667
- console.log(chalk.cyan("正在获取微信文章..."));
1668
- const article = await importWechatArticle(sourceUrl, {
1669
- onProgress: ({ current, total }) => {
1670
- console.log(chalk.gray(`正在转存正文图片 ${current}/${total}`));
1806
+ let article;
1807
+ let sourceLabel;
1808
+ try {
1809
+ parseLarkDocumentUrl(sourceUrl);
1810
+ sourceLabel = "飞书文档";
1811
+ console.log(chalk.cyan("正在获取飞书文档完整格式..."));
1812
+ article = await importLarkArticle(sourceUrl);
1813
+ } catch (larkError) {
1814
+ if (!/飞书文档链接无效|仅支持飞书 Docx 或 Wiki 文档链接/.test(larkError.message)) {
1815
+ throw larkError;
1671
1816
  }
1672
- });
1817
+ sourceLabel = "微信文章";
1818
+ console.log(chalk.cyan("正在获取微信文章..."));
1819
+ article = await importWechatArticle(sourceUrl, {
1820
+ onProgress: ({ current, total }) => {
1821
+ console.log(chalk.gray(`正在转存正文图片 ${current}/${total}`));
1822
+ }
1823
+ });
1824
+ }
1673
1825
  const output = buildOfficialArticleMarkdown(article);
1674
1826
  await ensureDir(path.dirname(targetFile));
1675
1827
  await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
1676
1828
 
1677
1829
  console.log("");
1678
- console.log(chalk.green("微信文章已转换为官网文章草稿:"));
1830
+ console.log(chalk.green(`${sourceLabel}已转换为官网文章草稿:`));
1679
1831
  console.log(targetFile);
1680
1832
  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
- );
1833
+ if (sourceLabel === "飞书文档") {
1834
+ console.log(chalk.gray(`已保留图片: ${article.imageCount} 张,表格: ${article.tableCount} 个`));
1835
+ console.log(chalk.gray("已保留标题、列表、引用、颜色、高亮、对齐、列宽及合并单元格"));
1836
+ } else {
1837
+ console.log(chalk.gray(`已转存图片: ${article.imageCount} 张`));
1838
+ console.log(chalk.gray(`已提取正文: ${article.textSegmentCount} 段`));
1839
+ console.log(chalk.gray(`已保留文字颜色: ${article.colorCount} 处`));
1840
+ console.log(
1841
+ chalk.gray(
1842
+ `特殊内容: ${article.specialElementCount} 处,媒体: ${article.mediaCount} 处,表格: ${article.tableCount} 个`
1843
+ )
1844
+ );
1845
+ }
1689
1846
  console.log("");
1690
- console.log("请补充 cate_id 等业务字段,确认内容后执行:");
1847
+ console.log(sourceLabel === "飞书文档" ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:" : "请补充 cate_id 等业务字段,确认内容后执行:");
1691
1848
  console.log("");
1692
1849
  console.log(` qcplay-cli www-article-list.store ${file}`);
1693
1850
  console.log("");
@@ -1722,6 +1879,20 @@ sort: 100
1722
1879
  game_id: 最强蜗牛
1723
1880
  index_pc_img: ""
1724
1881
  index_move_img: ""
1882
+ taptap_type: long-post
1883
+ taptap_forum: ""
1884
+ taptap_scheduled: "0"
1885
+ taptap_draft: "0"
1886
+ taptap_images: ""
1887
+ taptap_video: ""
1888
+ taptap_cover: ""
1889
+ bilibili_type: article
1890
+ bilibili_video: ""
1891
+ bilibili_cover: ""
1892
+ bilibili_category: ""
1893
+ bilibili_tags: ""
1894
+ bilibili_topic: ""
1895
+ weibo_intro: ""
1725
1896
  ---
1726
1897
 
1727
1898
  # 文章标题
@@ -1739,6 +1910,128 @@ ${chalk.cyan("从微信推文生成官网草稿:")}
1739
1910
  `);
1740
1911
  }
1741
1912
 
1913
+ function inferDistributionProject(payload, explicitProject) {
1914
+ if (normalizeText(explicitProject)) {
1915
+ return normalizeText(explicitProject);
1916
+ }
1917
+ if (payload?.game_id === "39") {
1918
+ return "最强蜗牛";
1919
+ }
1920
+ return "";
1921
+ }
1922
+
1923
+ function printPlatformEntries(entries) {
1924
+ console.log("");
1925
+ console.log(chalk.cyan("可用发布平台:"));
1926
+ console.log("");
1927
+ entries.forEach(entry => {
1928
+ const status = entry.publisher === "blocked" || entry.publisher === "unavailable" ? "不可自动发布" : entry.publisher;
1929
+ console.log(` ${entry.project} / ${entry.region} / ${entry.platform} [${status}; ${entry.authMode}]`);
1930
+ });
1931
+ console.log("");
1932
+ }
1933
+
1934
+ async function publishPlatformsCommand(file, options) {
1935
+ let parsedArticle;
1936
+ if (file) {
1937
+ parsedArticle = await parseArticleFile(file);
1938
+ }
1939
+ const allEntries = await loadPlatformEntries({ configFile: options.configFile });
1940
+
1941
+ const project = inferDistributionProject(parsedArticle?.payload, options.project);
1942
+ if (!project) {
1943
+ if (options.listPlatforms) {
1944
+ printPlatformEntries(allEntries);
1945
+ return;
1946
+ }
1947
+ throw new Error(`缺少项目名称。可用项目: ${availableProjects(allEntries).join("、")}。请使用 --project <项目>`);
1948
+ }
1949
+
1950
+ const candidates = entriesForProject(allEntries, project, options.region);
1951
+ if (candidates.length === 0) {
1952
+ const regionHint = options.region ? ` / ${options.region}` : "";
1953
+ throw new Error(`平台配置中没有找到项目 ${project}${regionHint}`);
1954
+ }
1955
+ if (options.listPlatforms) {
1956
+ printPlatformEntries(candidates);
1957
+ return;
1958
+ }
1959
+ if (!parsedArticle) {
1960
+ throw new Error("缺少文章文件,例如:qcplay-cli publish article.md --platform TapTap");
1961
+ }
1962
+
1963
+ const selected = options.platforms.length
1964
+ ? selectRequestedEntries(candidates, options.platforms)
1965
+ : await promptForPlatformEntries(candidates);
1966
+ const planningArticle = {
1967
+ ...parsedArticle,
1968
+ title: parsedArticle.payload.article_title,
1969
+ html: parsedArticle.payload.article_content
1970
+ };
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
+ }));
1977
+ if (options.dryRun) {
1978
+ console.log(JSON.stringify({ article: parsedArticle.articleFile, project, platforms: plan }, null, 2));
1979
+ return;
1980
+ }
1981
+
1982
+ const article = {
1983
+ articleFile: parsedArticle.articleFile,
1984
+ title: parsedArticle.payload.article_title,
1985
+ markdown: parsedArticle.markdown,
1986
+ html: parsedArticle.payload.article_content,
1987
+ payload: parsedArticle.payload,
1988
+ meta: parsedArticle.meta
1989
+ };
1990
+ const { successes, failures, skipped } = await runPlatformPublishSequence(selected, async entry => {
1991
+ console.log("");
1992
+ console.log(chalk.cyan(`正在发布到 ${entry.platform}(${entry.region})...`));
1993
+ let result;
1994
+ if (entry.publisher === "official-api") {
1995
+ await publishArticleCommand(file, options);
1996
+ } else {
1997
+ result = await publishPlatformEntry(entry, article, {
1998
+ browserChannel: options.browserChannel,
1999
+ reviewDraft: options.reviewDraft,
2000
+ keepOpen: options.keepOpen,
2001
+ onProgress: message => console.log(chalk.gray(` ${message}`))
2002
+ });
2003
+ if (entry.platformKey === "taptap" && result?.status === "draft") {
2004
+ console.log(chalk.green("TapTap 草稿已保存"));
2005
+ return result;
2006
+ }
2007
+ if (entry.platformKey === "taptap" && result?.status === "scheduled") {
2008
+ console.log(chalk.green("TapTap 定时发布已提交"));
2009
+ return result;
2010
+ }
2011
+ }
2012
+ console.log(chalk.green(`${entry.platform} 发布流程完成`));
2013
+ return result;
2014
+ });
2015
+ failures.forEach(({ entry, error }) => {
2016
+ console.error(chalk.red(`${entry.platform} 发布失败: ${error.message || error}`));
2017
+ });
2018
+ if (skipped.length > 0) {
2019
+ console.error(chalk.yellow("已停止后续平台发布,请先处理当前浏览器平台的登录或页面问题。"));
2020
+ }
2021
+
2022
+ console.log("");
2023
+ console.log(`发布完成: ${successes.length} 个成功,${failures.length} 个失败,${skipped.length} 个未执行`);
2024
+ failures.forEach(({ entry, error }) => {
2025
+ console.log(` - ${entry.platform}(${entry.region}): ${error.message || error}`);
2026
+ });
2027
+ skipped.forEach(entry => {
2028
+ console.log(` - ${entry.platform}(${entry.region}): 未执行`);
2029
+ });
2030
+ if (failures.length > 0) {
2031
+ throw new Error("部分平台发布失败,请根据上面的错误处理后重试失败平台");
2032
+ }
2033
+ }
2034
+
1742
2035
  async function publishArticleCommand(file, options) {
1743
2036
  if (!file) {
1744
2037
  throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
@@ -1875,6 +2168,16 @@ async function main() {
1875
2168
  return;
1876
2169
  }
1877
2170
 
2171
+ if (command === "publish") {
2172
+ if (subcommand === "-h" || subcommand === "--help") {
2173
+ printPublishHelp();
2174
+ return;
2175
+ }
2176
+ const parsed = parseDistributionOptions([subcommand, ...rest].filter(Boolean));
2177
+ await runWithErrorBanner("多平台发布失败", () => publishPlatformsCommand(parsed.file, parsed.options));
2178
+ return;
2179
+ }
2180
+
1878
2181
  if (command === "article") {
1879
2182
  if (!subcommand || subcommand === "-h" || subcommand === "--help") {
1880
2183
  if (!subcommand) {
@@ -1900,7 +2203,7 @@ async function main() {
1900
2203
  });
1901
2204
  return;
1902
2205
  }
1903
- await runWithErrorBanner("转换失败", () => importWechatArticleCommand(sourceUrl, file));
2206
+ await runWithErrorBanner("转换失败", () => importArticleCommand(sourceUrl, file));
1904
2207
  return;
1905
2208
  }
1906
2209