@qcplay/cli 1.0.12 → 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 +118 -3
- package/bin/qcplay.js +338 -27
- package/lib/lark-article.js +294 -0
- package/lib/platform-publish.js +4473 -0
- package/lib/wechat-article.js +15 -4
- package/package.json +5 -2
- package/templates/skills/qcplay-publish-article/SKILL.md +122 -7
- package/templates/skills/qcplay-publish-article/article/article.md +15 -0
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,19 +133,27 @@ 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
|
|
32
147
|
qcplay-cli www-article-list.store article.md
|
|
33
148
|
```
|
|
34
149
|
|
|
35
|
-
保存成功后,CLI 会输出文章 ID、`未上线`
|
|
150
|
+
保存成功后,CLI 会输出文章 ID、`未上线` 状态和可点击的排版预览地址,并自动在浏览器中打开该页面:
|
|
36
151
|
|
|
37
152
|
```text
|
|
38
153
|
https://snail.qingcigame.com/official/news-details.html?id=<文章ID>
|
|
39
154
|
```
|
|
40
155
|
|
|
41
|
-
|
|
156
|
+
文章内容和排版只由用户在打开的预览页面中检查,CLI 或 AI 不自动抓取、分析或判断预览内容。用户明确确认无误后再上线。ID 可以省略,此时只使用当前账号最近一次保存的未上线文章:
|
|
42
157
|
|
|
43
158
|
```bash
|
|
44
159
|
qcplay-cli article update [id]
|
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
|
|
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 {
|
|
@@ -602,9 +739,11 @@ function openBrowser(targetUrl) {
|
|
|
602
739
|
stdio: "ignore"
|
|
603
740
|
});
|
|
604
741
|
|
|
605
|
-
child.
|
|
606
|
-
child.
|
|
607
|
-
|
|
742
|
+
child.once("error", reject);
|
|
743
|
+
child.once("spawn", () => {
|
|
744
|
+
child.unref();
|
|
745
|
+
resolve();
|
|
746
|
+
});
|
|
608
747
|
});
|
|
609
748
|
}
|
|
610
749
|
|
|
@@ -1624,7 +1763,9 @@ async function parseArticleFile(file) {
|
|
|
1624
1763
|
const parsed = parseFrontMatter(raw);
|
|
1625
1764
|
return {
|
|
1626
1765
|
articleFile,
|
|
1627
|
-
payload: normalizeArticlePayload(parsed.meta, parsed.content)
|
|
1766
|
+
payload: normalizeArticlePayload(parsed.meta, parsed.content),
|
|
1767
|
+
meta: parsed.meta,
|
|
1768
|
+
markdown: parsed.content
|
|
1628
1769
|
};
|
|
1629
1770
|
}
|
|
1630
1771
|
|
|
@@ -1651,9 +1792,9 @@ async function initArticleTemplate(file = "article.md") {
|
|
|
1651
1792
|
console.log("");
|
|
1652
1793
|
}
|
|
1653
1794
|
|
|
1654
|
-
async function
|
|
1795
|
+
async function importArticleCommand(sourceUrl, file = "article.md") {
|
|
1655
1796
|
if (!sourceUrl) {
|
|
1656
|
-
throw new Error("
|
|
1797
|
+
throw new Error("缺少文章来源链接,请提供微信公众号文章或飞书 Docx/Wiki 文档链接");
|
|
1657
1798
|
}
|
|
1658
1799
|
|
|
1659
1800
|
const targetFile = path.resolve(process.cwd(), file);
|
|
@@ -1662,30 +1803,48 @@ async function importWechatArticleCommand(sourceUrl, file = "article.md") {
|
|
|
1662
1803
|
}
|
|
1663
1804
|
|
|
1664
1805
|
console.log("");
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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;
|
|
1669
1816
|
}
|
|
1670
|
-
|
|
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
|
+
}
|
|
1671
1825
|
const output = buildOfficialArticleMarkdown(article);
|
|
1672
1826
|
await ensureDir(path.dirname(targetFile));
|
|
1673
1827
|
await fs.promises.writeFile(targetFile, output, { encoding: "utf8", flag: "wx" });
|
|
1674
1828
|
|
|
1675
1829
|
console.log("");
|
|
1676
|
-
console.log(chalk.green(
|
|
1830
|
+
console.log(chalk.green(`${sourceLabel}已转换为官网文章草稿:`));
|
|
1677
1831
|
console.log(targetFile);
|
|
1678
1832
|
console.log(chalk.gray(`标题: ${article.title}`));
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
chalk.gray(
|
|
1684
|
-
|
|
1685
|
-
)
|
|
1686
|
-
|
|
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
|
+
}
|
|
1687
1846
|
console.log("");
|
|
1688
|
-
console.log("请补充 cate_id 等业务字段,确认内容后执行:");
|
|
1847
|
+
console.log(sourceLabel === "飞书文档" ? "请补充 thumbnail、cate_id 等业务字段,确认内容后执行:" : "请补充 cate_id 等业务字段,确认内容后执行:");
|
|
1689
1848
|
console.log("");
|
|
1690
1849
|
console.log(` qcplay-cli www-article-list.store ${file}`);
|
|
1691
1850
|
console.log("");
|
|
@@ -1720,6 +1879,20 @@ sort: 100
|
|
|
1720
1879
|
game_id: 最强蜗牛
|
|
1721
1880
|
index_pc_img: ""
|
|
1722
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: ""
|
|
1723
1896
|
---
|
|
1724
1897
|
|
|
1725
1898
|
# 文章标题
|
|
@@ -1737,6 +1910,128 @@ ${chalk.cyan("从微信推文生成官网草稿:")}
|
|
|
1737
1910
|
`);
|
|
1738
1911
|
}
|
|
1739
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
|
+
|
|
1740
2035
|
async function publishArticleCommand(file, options) {
|
|
1741
2036
|
if (!file) {
|
|
1742
2037
|
throw new Error("缺少文章文件,例如:qcplay-cli www-article-list.store article.md");
|
|
@@ -1771,7 +2066,13 @@ async function publishArticleCommand(file, options) {
|
|
|
1771
2066
|
console.log(chalk.yellow(`本地未能记录最近文章 ID,后续操作请手动输入 ${articleId}: ${stateWarning}`));
|
|
1772
2067
|
}
|
|
1773
2068
|
console.log("");
|
|
1774
|
-
|
|
2069
|
+
try {
|
|
2070
|
+
await openBrowser(previewUrl);
|
|
2071
|
+
console.log(chalk.cyan("已打开文章预览页面,请由用户检查文章内容和排版。"));
|
|
2072
|
+
} catch {
|
|
2073
|
+
console.log(chalk.yellow("自动打开预览页面失败,请手动打开上面的预览地址。"));
|
|
2074
|
+
}
|
|
2075
|
+
console.log("用户确认无误后再执行:");
|
|
1775
2076
|
console.log("");
|
|
1776
2077
|
console.log(` qcplay-cli article update ${articleId}`);
|
|
1777
2078
|
console.log("");
|
|
@@ -1867,6 +2168,16 @@ async function main() {
|
|
|
1867
2168
|
return;
|
|
1868
2169
|
}
|
|
1869
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
|
+
|
|
1870
2181
|
if (command === "article") {
|
|
1871
2182
|
if (!subcommand || subcommand === "-h" || subcommand === "--help") {
|
|
1872
2183
|
if (!subcommand) {
|
|
@@ -1892,7 +2203,7 @@ async function main() {
|
|
|
1892
2203
|
});
|
|
1893
2204
|
return;
|
|
1894
2205
|
}
|
|
1895
|
-
await runWithErrorBanner("转换失败", () =>
|
|
2206
|
+
await runWithErrorBanner("转换失败", () => importArticleCommand(sourceUrl, file));
|
|
1896
2207
|
return;
|
|
1897
2208
|
}
|
|
1898
2209
|
|