owned-account-pool-cli 0.0.0-stage → 0.2.0
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 +128 -2
- package/bin/owned-pool.js +4 -0
- package/package.json +32 -4
- package/skills/owned-account-pool/SKILL.md +89 -0
- package/src/api.js +135 -0
- package/src/cli.js +217 -0
- package/src/errors.js +12 -0
- package/src/images.js +77 -0
- package/src/prompts.js +108 -0
- package/src/skill.js +18 -0
- package/src/tasks.js +365 -0
- package/src/update.js +26 -0
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 自有账号池 CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
面向 `https://handover.luckyjun.top` 的账号池与任务登记工具。支持交互登录、选择客户和账号、录入帖子与独立评论、上传图片、编辑原稿、提交任务和失败恢复。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
需要 Node.js 24 LTS(24.15.0 起)或 26 及以上版本。
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g owned-account-pool-cli
|
|
11
|
+
owned-pool
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
也可直接运行。
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx --yes --package owned-account-pool-cli owned-pool
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 交互使用
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
owned-pool post create
|
|
24
|
+
owned-pool comment create
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
命令会依次收集缺失的账号密码、客户、帖子用途、回复目标、社区、账号来源、权重阈值、账号、标题、图片、正文、留存要求以及保存方式。正文支持直接输入、UTF-8 文件和系统编辑器,备注可留空。
|
|
28
|
+
|
|
29
|
+
评论使用自有账号,回复社区通过服务端解析目标 URL 获得。账号选择保留服务端资格限制。草稿保存与提交通过 `--draft`、`--submit` 选择,也可在终端回答。
|
|
30
|
+
|
|
31
|
+
这套流程创建系统登记任务,后续 Reddit 发布由发布人员执行。
|
|
32
|
+
|
|
33
|
+
## 登录
|
|
34
|
+
|
|
35
|
+
交互终端隐藏密码输入。可通过环境变量提供登录凭据。
|
|
36
|
+
|
|
37
|
+
| 变量 | 用途 |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `HANDOVER_USERNAME` | 系统登录账号 |
|
|
40
|
+
| `HANDOVER_PASSWORD` | 系统登录密码 |
|
|
41
|
+
| `HANDOVER_BASE_URL` | 服务根地址 |
|
|
42
|
+
|
|
43
|
+
登录账号也支持 `--username`,服务地址支持 `--base-url`。Cookie、密码及 CSRF 仅在进程内使用,结束时发起退出登录。
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
owned-pool login
|
|
47
|
+
owned-pool clients
|
|
48
|
+
owned-pool accounts
|
|
49
|
+
owned-pool candidates --client 12 --kind post --subreddit example --group current --weight 5
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 完整参数录入
|
|
53
|
+
|
|
54
|
+
以下 ID 和内容为占位示例。脚本调用需要预先设置登录环境变量。
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
owned-pool post create --client 12 --account account-opaque-id --subreddit example --group current --weight 5 --purpose external_original --title "帖子标题" --body-file ./post.md --retention-hours 24 --notes "内部说明" --draft --non-interactive --json
|
|
58
|
+
|
|
59
|
+
owned-pool comment create --client 12 --account account-opaque-id --target-url "https://www.reddit.com/r/example/comments/abc123/topic/" --group pool --weight 5 --body-file ./comment.md --retention-hours 48 --draft --non-interactive --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
客户支持 ID、唯一名称和编号。账号支持完整字符串 ID、唯一显示名和 Reddit 用户名。社区支持 `r/` 前缀,留存支持 `24/48/72/168/720` 小时,权重支持 `5/10/20/30`。帖子用途支持 `external_original/community_internal`,账号来源支持 `current/pool`。
|
|
63
|
+
|
|
64
|
+
`--non-interactive` 缺少必要值会返回 `MISSING_INPUT` 和字段、可用选项。成功结果写入 stdout,错误和更新提醒写入 stderr,错误退出码为 1,用户中断为 130。`--json` 输出紧凑 JSON,`--dry-run` 输出录入方案。
|
|
65
|
+
|
|
66
|
+
## 任务管理
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
owned-pool tasks list --view mine --page 1
|
|
70
|
+
owned-pool tasks show 123
|
|
71
|
+
owned-pool tasks edit 123 --body-file ./updated.md --draft
|
|
72
|
+
owned-pool tasks submit 123
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
编辑保留已有素材及可编辑的跟帖,写入携带任务版本。
|
|
76
|
+
|
|
77
|
+
## 图片上传
|
|
78
|
+
|
|
79
|
+
从 0.2.0 起,录入与编辑命令支持重复传入 `--image`,按传入顺序追加图片。交互模式会询问是否添加图片,随后逐个输入文件路径。
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
owned-pool post create --image ./cover.png --image "./产品展示.jpg"
|
|
83
|
+
owned-pool comment create --image ./reference.webp
|
|
84
|
+
owned-pool tasks edit 123 --image ./extra.png --draft --non-interactive
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
上传遵循当前网页的限制,支持静态 JPEG、PNG、WebP,单张最多 5 MiB,每个任务最多 10 张,包含已有素材。图片帖的正文可留空。非交互模式省略 `--image` 时继续处理现有内容。
|
|
88
|
+
|
|
89
|
+
CLI 在写入前检查全部图片,先保存原稿,再逐张以 multipart 上传 `file` 和最新 `version`。每张成功上传的素材立即保存到任务,完整上传后才执行 `--submit`。编辑时图片追加到已有素材末尾。
|
|
90
|
+
|
|
91
|
+
恢复文件会记录图片路径、内容摘要和已完成的素材 ID。遇到上传响应丢失,恢复时读取任务并下载新增原图核对摘要,确认成功后继续后续图片。出现并发修改或摘要不一致时返回错误供核对。待上传的本地图片需保持原路径和内容。
|
|
92
|
+
|
|
93
|
+
写入失败时返回任务 ID 和本地恢复文件路径。该文件包含原稿内容,保存在用户目录 `.owned-account-pool/recovery`。使用返回的恢复命令继续操作,成功后自动清理。
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
owned-pool tasks resume "/完整路径/example.recovery.json"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
创建请求超时、结果未知时,先查询 `tasks list` 确认对应记录,再通过 `--task-id` 恢复。版本冲突时,先读取最新任务并合并修改。
|
|
100
|
+
|
|
101
|
+
## 配套 skill 与更新
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
owned-pool skill install --agent codex
|
|
105
|
+
owned-pool skill install --agent claude
|
|
106
|
+
owned-pool skill path
|
|
107
|
+
owned-pool update-check
|
|
108
|
+
owned-pool update-check --json
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
skill 随包发布,可安装到 Codex、Claude Code,也可用 `skill install --dir <目录>` 指定目标。它指导 AI 查询候选项、向用户补齐缺失数据、录入任务和处理恢复文件。
|
|
112
|
+
|
|
113
|
+
每次业务命令都会并行检查 npm 最新版本,超时为 1.5 秒。有新版本时提示更新命令,相同版本、较旧版本或检查失败时静默继续。检查会在当前命令结束时输出提醒。独立 `update-check` 在没有新版本时保持安静,`--json` 可查看检查结果。
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
npm install -g owned-account-pool-cli@latest
|
|
117
|
+
owned-pool skill install --agent codex
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## 本地开发
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
npm ci
|
|
124
|
+
npm test
|
|
125
|
+
node bin/owned-pool.js --help
|
|
126
|
+
npm pack --dry-run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
接口依据本目录 2026-11-11 的 API 文档及站点前端调用。测试覆盖模拟服务的认证、CSRF、录入、图片 multipart 上传、分页选择、版本冲突、恢复、更新提示和 CLI 入口。
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "owned-account-pool-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "自有账号池 CLI,交互登录、客户与账号选择、帖子和评论任务录入",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"owned-pool": "bin/owned-pool.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md",
|
|
13
|
+
"skills/"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"start": "node bin/owned-pool.js",
|
|
17
|
+
"test": "node --test",
|
|
18
|
+
"prepublishOnly": "npm test"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^24.15.0 || >=26.0.0"
|
|
22
|
+
},
|
|
23
|
+
"license": "UNLICENSED",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@inquirer/prompts": "8.8.0",
|
|
30
|
+
"commander": "15.1.0",
|
|
31
|
+
"semver": "7.8.6",
|
|
32
|
+
"tough-cookie": "6.1.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: owned-account-pool
|
|
3
|
+
description: 使用 owned-pool CLI 查询自有账号池、客户和登记任务,录入或编辑帖子与评论原稿、上传图片、提交给发布人员,并在 npm 包有新版本时提醒用户。适用于 handover.luckyjun.top 自有账号发布系统。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 自有账号池 CLI
|
|
7
|
+
|
|
8
|
+
使用 npm 包 `owned-account-pool-cli` 提供的 `owned-pool` 命令。需要 Node.js 24 LTS(24.15.0 起)或 26 及以上版本。系统默认地址为 `https://handover.luckyjun.top`,其他部署可通过 `--base-url` 或 `HANDOVER_BASE_URL` 指定。
|
|
9
|
+
|
|
10
|
+
## 安装与版本检查
|
|
11
|
+
|
|
12
|
+
先运行 `owned-pool --version`。命令缺失时按用户安装授权运行 `npm install -g owned-account-pool-cli`,或用 `npx --yes --package owned-account-pool-cli owned-pool` 执行。
|
|
13
|
+
|
|
14
|
+
每次开始处理本系统的新请求时运行 `owned-pool update-check --json`。只有 `update_available=true` 时,向用户说明当前版本、最新版本和 `update_command`。检查结果相同、当前版本更新、网络检查失败时静默继续。业务命令也会在 stderr 输出 `[UPDATE_AVAILABLE]`,同一轮对话已经提醒过相同版本时合并提示。更新由用户授权后执行。
|
|
15
|
+
|
|
16
|
+
升级后可运行 `owned-pool skill install --agent codex` 更新本机配套 skill。Claude Code 使用 `--agent claude`。
|
|
17
|
+
|
|
18
|
+
## 登录与缺失信息
|
|
19
|
+
|
|
20
|
+
业务命令每次运行均需要登录账号与密码。交互终端会询问缺失值并隐藏密码输入。脚本调用通过 `HANDOVER_USERNAME` 和 `HANDOVER_PASSWORD` 环境变量提供凭据,账号也可用 `--username`。会话仅保存在当前进程,命令结束后退出。
|
|
21
|
+
|
|
22
|
+
用户尚未提供凭据时询问用户。通过执行环境传入密码,避免在可见命令、日志和交付文件中展示密码。自动化调用使用 `--non-interactive --json`,成功结果在 stdout,错误 JSON 在 stderr。缺失数据的 `error.code` 为 `MISSING_INPUT`,`field` 和 `choices` 提供补全线索。根据缺失字段询问用户,并复用此前已给出的信息。
|
|
23
|
+
|
|
24
|
+
选择客户、账号、社区、留存时间或提交方式时,以用户已明确提供的数据为准。缺少选择就询问用户。客户和账号可以用 ID 或唯一名称定位,同名时使用 ID。账号 ID 保持完整字符串。
|
|
25
|
+
|
|
26
|
+
## 查询与录入
|
|
27
|
+
|
|
28
|
+
使用 `owned-pool --help` 或子命令的 `--help` 查看当前参数。
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
owned-pool login
|
|
32
|
+
owned-pool clients --json
|
|
33
|
+
owned-pool accounts --json
|
|
34
|
+
owned-pool candidates --client 12 --kind post --subreddit example --group current --weight 5 --json
|
|
35
|
+
owned-pool tasks list --view mine --json
|
|
36
|
+
owned-pool tasks show 123 --json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
在交互终端运行 `owned-pool` 可进入菜单,`owned-pool post create` 和 `owned-pool comment create` 会询问缺失数据。AI 自动化应先查询客户和候选账号,把可用选项提供给用户,再带齐参数执行录入。
|
|
40
|
+
|
|
41
|
+
帖子录入示例中所有业务值都需要替换为用户确认的数据。
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
owned-pool post create --client 12 --account account-opaque-id --subreddit example --group current --weight 5 --purpose external_original --title "标题" --body-file ./post.md --retention-hours 24 --notes "内部备注" --draft --non-interactive --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
评论使用回复目标 URL,CLI 从服务端解析目标社区并以 `account_mode=owned` 选择自有账号。
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
owned-pool comment create --client 12 --account account-opaque-id --target-url "https://www.reddit.com/r/example/comments/abc123/topic/" --group pool --weight 5 --body-file ./comment.md --retention-hours 48 --draft --non-interactive --json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `--group` 取 `current` 客户账号或 `pool` 公用账号池。
|
|
54
|
+
- `--weight` 取 `5`、`10`、`20`、`30`。
|
|
55
|
+
- 帖子用途 `--purpose` 取 `external_original` 或 `community_internal`。
|
|
56
|
+
- 留存 `--retention-hours` 取 `24`、`48`、`72`、`168`、`720` 小时。
|
|
57
|
+
- `--draft` 保存草稿,`--submit` 保存后提交给发布人员。依据用户要求选择,缺少要求时询问。
|
|
58
|
+
- 长正文、多行内容使用 UTF-8 的 `--body-file`。可选备注在非交互模式下省略即为空。
|
|
59
|
+
- 图片上传需要 CLI 0.2.0 起,使用可重复的 `--image <路径>`,参数顺序即追加顺序。支持静态 JPEG、PNG、WebP,每张最多 5 MiB,每个任务包含已有素材最多 10 张。图片帖可省略正文。
|
|
60
|
+
- `--dry-run` 用于检查收集完成的录入方案。它包含登录和只读查询。
|
|
61
|
+
|
|
62
|
+
成功结果包含 `task_id`、`status`、`version`。向用户汇报任务 ID 与状态。此工作流记录系统任务,Reddit 实际发布由发布人员执行。
|
|
63
|
+
|
|
64
|
+
## 图片录入
|
|
65
|
+
|
|
66
|
+
用户提供本地图片或可访问的附件路径时,把路径加入录入或编辑命令。需要图片且路径缺失时询问用户。交互命令会询问是否添加图片,非交互模式省略图片参数时沿用现有内容。多个文件依次使用 `--image`,含空格的路径加引号。
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
owned-pool post create --image ./cover.png --image "./产品展示.jpg"
|
|
70
|
+
owned-pool tasks edit 123 --image ./extra.webp --draft --non-interactive --json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
CLI 先保存原稿,再按最新任务版本逐张上传并保存,全部成功后执行提交。上传结果的 `uploaded_assets` 包含本轮新增素材 ID,保留其原始类型。
|
|
74
|
+
|
|
75
|
+
## 编辑、提交与恢复
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
owned-pool tasks edit 123 --body-file ./updated.md --draft --non-interactive --json
|
|
79
|
+
owned-pool tasks submit 123 --non-interactive --json
|
|
80
|
+
owned-pool tasks resume "恢复文件的完整路径" --non-interactive --json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
编辑保留原稿现有素材和可编辑跟帖,使用读取时的任务版本。出现 `VERSION_CONFLICT` 时读取最新任务,结合用户要求合并修改后再次保存。
|
|
84
|
+
|
|
85
|
+
录入分为创建任务和保存原稿。写入中断时,错误中包含 `recovery_file` 和 `next_command`。优先恢复既有任务。创建请求结果未知时,先查 `tasks list`,找到对应任务,再使用 `tasks resume ... --task-id ID`。恢复文件保存在用户目录 `.owned-account-pool/recovery`,含录入正文,成功恢复后清理。
|
|
86
|
+
|
|
87
|
+
图片上传中断也沿用恢复命令。保留待上传文件的原路径与内容,CLI 会核对内容摘要,复用已确认成功的图片。上传响应丢失时,CLI 根据任务新增素材及下载原图的摘要判断结果。`UPLOAD_CONFLICT` 表示任务变化或素材摘要不匹配,需要读取最新任务并核对,随后合并用户要求。
|
|
88
|
+
|
|
89
|
+
本 CLI 覆盖账号查询与任务登记流程。客户绑定、账号资格等服务端配置问题按接口错误提示处理。
|
package/src/api.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { CookieJar } from 'tough-cookie';
|
|
2
|
+
import { CliError, assert } from './errors.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_BASE_URL = 'https://handover.luckyjun.top';
|
|
5
|
+
|
|
6
|
+
export class ApiClient {
|
|
7
|
+
constructor(baseUrl = DEFAULT_BASE_URL, { timeout = 30000, fetchImpl = fetch } = {}) {
|
|
8
|
+
const url = new URL(baseUrl);
|
|
9
|
+
assert(['https:', 'http:'].includes(url.protocol) && !url.username && !url.password,
|
|
10
|
+
'INVALID_BASE_URL', '服务地址须为 HTTP(S) 地址,且不含账号密码。');
|
|
11
|
+
assert(url.pathname === '/' && !url.search && !url.hash,
|
|
12
|
+
'INVALID_BASE_URL', '服务地址只填写站点根地址,例如 https://handover.luckyjun.top');
|
|
13
|
+
this.baseUrl = url.origin;
|
|
14
|
+
this.timeout = timeout;
|
|
15
|
+
this.fetchImpl = fetchImpl;
|
|
16
|
+
this.jar = new CookieJar();
|
|
17
|
+
this.csrf = null;
|
|
18
|
+
this.member = null;
|
|
19
|
+
this.loggedIn = false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async request(method, path, { query = {}, body, responseType = 'json', timeout = this.timeout } = {}) {
|
|
23
|
+
assert(path.startsWith('/api/'), 'INVALID_PATH', '接口路径须以 /api/ 开头。');
|
|
24
|
+
const url = new URL(path, this.baseUrl);
|
|
25
|
+
assert(url.origin === this.baseUrl, 'INVALID_PATH', '接口路径必须属于当前服务。');
|
|
26
|
+
for (const [key, value] of Object.entries(query)) {
|
|
27
|
+
if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value);
|
|
28
|
+
}
|
|
29
|
+
const multipart = body instanceof FormData;
|
|
30
|
+
const headers = { Accept: responseType === 'buffer' ? '*/*' : 'application/json' };
|
|
31
|
+
const cookie = await this.jar.getCookieString(url.href);
|
|
32
|
+
if (cookie) headers.Cookie = cookie;
|
|
33
|
+
if (body !== undefined && !multipart) headers['Content-Type'] = 'application/json';
|
|
34
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && this.csrf) {
|
|
35
|
+
headers['X-CSRF-Token'] = this.csrf;
|
|
36
|
+
}
|
|
37
|
+
let response;
|
|
38
|
+
let text;
|
|
39
|
+
try {
|
|
40
|
+
response = await this.fetchImpl(url, {
|
|
41
|
+
method, headers, body: multipart ? body : body === undefined ? undefined : JSON.stringify(body),
|
|
42
|
+
redirect: 'manual', signal: AbortSignal.timeout(timeout),
|
|
43
|
+
});
|
|
44
|
+
for (const cookieValue of response.headers.getSetCookie()) {
|
|
45
|
+
await this.jar.setCookie(cookieValue, url.href);
|
|
46
|
+
}
|
|
47
|
+
if (response.ok && responseType === 'buffer') return Buffer.from(await response.arrayBuffer());
|
|
48
|
+
text = await response.text();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const code = ['TimeoutError', 'AbortError'].includes(error.name) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR';
|
|
51
|
+
throw new CliError(code, `${method} ${url.pathname} 请求未完成,请检查连接和服务状态。`, {
|
|
52
|
+
method, path: url.pathname, outcome_unknown: method !== 'GET',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
data = text ? JSON.parse(text) : null;
|
|
58
|
+
} catch {
|
|
59
|
+
throw new CliError('INVALID_RESPONSE', `接口返回了非 JSON 数据(HTTP ${response.status})。`, {
|
|
60
|
+
status: response.status, path: url.pathname, outcome_unknown: method !== 'GET',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const error = data?.error;
|
|
65
|
+
let message = error?.message || `接口请求失败(HTTP ${response.status})。`;
|
|
66
|
+
if (body?.password) message = message.replaceAll(body.password, '[REDACTED]');
|
|
67
|
+
throw new CliError(error?.code || `HTTP_${response.status}`,
|
|
68
|
+
message, {
|
|
69
|
+
status: response.status, path: url.pathname,
|
|
70
|
+
...(!body?.password && error?.details?.fields ? { fields: error.details.fields } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async login(username, password) {
|
|
77
|
+
const result = await this.request('POST', '/api/community-workbench/auth/login', {
|
|
78
|
+
body: { username, password },
|
|
79
|
+
});
|
|
80
|
+
this.loggedIn = true;
|
|
81
|
+
this.csrf = result?.csrf;
|
|
82
|
+
let identity = result;
|
|
83
|
+
if (!this.csrf) {
|
|
84
|
+
identity = await this.request('GET', '/api/community-workbench/auth/me');
|
|
85
|
+
this.csrf = identity?.csrf;
|
|
86
|
+
}
|
|
87
|
+
assert(this.csrf, 'MISSING_CSRF', '登录响应缺少 CSRF,请检查服务端认证接口。');
|
|
88
|
+
this.member = identity.member || result.member;
|
|
89
|
+
return this.member;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async logout() {
|
|
93
|
+
try {
|
|
94
|
+
if (this.loggedIn) {
|
|
95
|
+
await this.request('POST', '/api/community-workbench/auth/logout', { timeout: 3000 });
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
this.loggedIn = false;
|
|
99
|
+
this.csrf = null;
|
|
100
|
+
this.member = null;
|
|
101
|
+
await this.jar.removeAllCookies();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function itemsOf(data, key = 'items') {
|
|
107
|
+
if (Array.isArray(data)) return data;
|
|
108
|
+
if (Array.isArray(data?.[key])) return data[key];
|
|
109
|
+
if (Array.isArray(data?.items)) return data.items;
|
|
110
|
+
throw new CliError('INVALID_RESPONSE', '接口列表格式与预期不符。');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function allPages(api, path, query = {}, key = 'items') {
|
|
114
|
+
const results = [];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (let page = 1; ; page += 1) {
|
|
117
|
+
const data = await api.request('GET', path, { query: { ...query, page } });
|
|
118
|
+
const items = itemsOf(data, key);
|
|
119
|
+
let added = 0;
|
|
120
|
+
for (const item of items) {
|
|
121
|
+
const id = String(item.id ?? item.account_id ?? JSON.stringify(item));
|
|
122
|
+
if (!seen.has(id)) {
|
|
123
|
+
seen.add(id);
|
|
124
|
+
results.push(item);
|
|
125
|
+
added += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(data) || !items.length) break;
|
|
129
|
+
if (data.total_pages !== undefined && page >= Number(data.total_pages)) break;
|
|
130
|
+
if (data.total !== undefined && results.length >= Number(data.total)) break;
|
|
131
|
+
if (!added) throw new CliError('INVALID_PAGINATION', '分页重复返回相同记录,无法读取完整列表。');
|
|
132
|
+
if (data.total_pages === undefined && data.total === undefined) break;
|
|
133
|
+
}
|
|
134
|
+
return results;
|
|
135
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { ApiClient, DEFAULT_BASE_URL } from './api.js';
|
|
3
|
+
import { Questions, positiveInteger } from './prompts.js';
|
|
4
|
+
import { CliError } from './errors.js';
|
|
5
|
+
import { candidateQuery, candidates, chooseClient, executePlan, getTask, prepareCreate,
|
|
6
|
+
prepareEdit, resumePlan, submitTask, taskClients } from './tasks.js';
|
|
7
|
+
import { checkUpdate, packageInfo, updateNotice } from './update.js';
|
|
8
|
+
import { installSkill, skillDirectory } from './skill.js';
|
|
9
|
+
|
|
10
|
+
function redact(value) {
|
|
11
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
12
|
+
if (value && typeof value === 'object') {
|
|
13
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) =>
|
|
14
|
+
!/^(password|csrf|cookie|session|token|access_token|refresh_token|authorization)$/i.test(key))
|
|
15
|
+
.map(([key, item]) => [key, redact(item)]));
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function print(value, options) {
|
|
21
|
+
process.stdout.write(JSON.stringify(redact(value), null, options.json ? undefined : 2) + '\n');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function session(options, handler) {
|
|
25
|
+
const update = checkUpdate();
|
|
26
|
+
const questions = new Questions(options);
|
|
27
|
+
let api;
|
|
28
|
+
try {
|
|
29
|
+
const username = await questions.text('username', '系统登录账号', options.username || process.env.HANDOVER_USERNAME || undefined);
|
|
30
|
+
const password = await questions.text('password', '系统登录密码', process.env.HANDOVER_PASSWORD || undefined, { secret: true });
|
|
31
|
+
api = new ApiClient(options.baseUrl ?? process.env.HANDOVER_BASE_URL ?? DEFAULT_BASE_URL);
|
|
32
|
+
await api.login(username.trim(), password);
|
|
33
|
+
return await handler(api, questions);
|
|
34
|
+
} finally {
|
|
35
|
+
if (api?.loggedIn) {
|
|
36
|
+
try {
|
|
37
|
+
await api.logout();
|
|
38
|
+
} catch {
|
|
39
|
+
process.stderr.write('会话退出请求未完成,本地会话已清理。\n');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
process.stderr.write(updateNotice(await update));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function authenticated(command, handler) {
|
|
47
|
+
command.action(async (...args) => {
|
|
48
|
+
const current = args.at(-1);
|
|
49
|
+
const options = current.optsWithGlobals();
|
|
50
|
+
const result = await session(options, (api, questions) => handler(api, questions, options, ...args.slice(0, -2)));
|
|
51
|
+
print(result, options);
|
|
52
|
+
});
|
|
53
|
+
return command;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function contentOptions(command) {
|
|
57
|
+
return command.option('--body <text>', '正文 Markdown')
|
|
58
|
+
.option('--body-file <file>', '从 UTF-8 文件读取正文')
|
|
59
|
+
.option('--image <file>', '添加图片,支持重复传入,按参数顺序上传', (file, files = []) => [...files, file])
|
|
60
|
+
.option('--notes <text>', '内部备注,可传空字符串')
|
|
61
|
+
.option('--retention-hours <hours>', '留存小时数 24/48/72/168/720')
|
|
62
|
+
.option('--draft', '保存为草稿')
|
|
63
|
+
.option('--submit', '保存后提交给发布人员');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function accountOptions(command) {
|
|
67
|
+
return command.option('--account <id-or-name>', '账号 ID、显示名或 Reddit 用户名')
|
|
68
|
+
.option('--subreddit <name>', '社区名称,支持 r/ 前缀')
|
|
69
|
+
.option('--group <group>', '账号来源 current/pool')
|
|
70
|
+
.option('--weight <number>', '社区权重阈值 5/10/20/30');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function listTasks(api, options = {}) {
|
|
74
|
+
return api.request('GET', '/api/tasks', { query: {
|
|
75
|
+
page: positiveInteger(options.page || 1, '页码'), view: options.view || 'mine',
|
|
76
|
+
client_id: options.clientId && positiveInteger(options.clientId, '客户 ID'),
|
|
77
|
+
task_type: options.type, status: options.status, q: options.search,
|
|
78
|
+
} });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function menu(api, questions, options) {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const action = await questions.choice('action', '选择操作', undefined, [
|
|
84
|
+
{ name: '录入帖子', value: 'post' },
|
|
85
|
+
{ name: '录入独立评论', value: 'comment' },
|
|
86
|
+
{ name: '查看客户', value: 'clients' },
|
|
87
|
+
{ name: '查看账号', value: 'accounts' },
|
|
88
|
+
{ name: '查看我的任务', value: 'tasks' },
|
|
89
|
+
{ name: '编辑任务原稿', value: 'edit' },
|
|
90
|
+
{ name: '提交任务', value: 'submit' },
|
|
91
|
+
{ name: '退出', value: 'exit' },
|
|
92
|
+
]);
|
|
93
|
+
if (action === 'exit') return { logged_out: true };
|
|
94
|
+
try {
|
|
95
|
+
let result;
|
|
96
|
+
if (['post', 'comment'].includes(action)) {
|
|
97
|
+
result = await executePlan(api, await prepareCreate(api, questions, action, {}), { dryRun: options.dryRun });
|
|
98
|
+
} else if (action === 'clients') {
|
|
99
|
+
result = await taskClients(api);
|
|
100
|
+
} else if (action === 'accounts') {
|
|
101
|
+
result = await api.request('GET', '/api/accounts');
|
|
102
|
+
} else if (action === 'tasks') {
|
|
103
|
+
result = await listTasks(api);
|
|
104
|
+
} else {
|
|
105
|
+
const id = await questions.text('task-id', '任务 ID', undefined);
|
|
106
|
+
result = action === 'edit'
|
|
107
|
+
? await executePlan(api, await prepareEdit(api, questions, id, {}), { dryRun: options.dryRun })
|
|
108
|
+
: await submitTask(api, await getTask(api, id), options.dryRun);
|
|
109
|
+
}
|
|
110
|
+
print(result, options);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error.details?.status === 401 || error.name === 'ExitPromptError') throw error;
|
|
113
|
+
printError(error, options);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function printError(error, options) {
|
|
119
|
+
const result = { error: { code: error.code || 'ERROR', message: error.message, ...error.details } };
|
|
120
|
+
process.stderr.write(JSON.stringify(redact(result), null, options.json ? undefined : 2) + '\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildProgram({ jsonOutput = false } = {}) {
|
|
124
|
+
const program = new Command().name('owned-pool').version(packageInfo.version)
|
|
125
|
+
.description('自有账号池 CLI,登录后交互录入帖子、评论并管理登记任务。')
|
|
126
|
+
.option('--base-url <url>', '系统根地址,默认 HANDOVER_BASE_URL 或 https://handover.luckyjun.top')
|
|
127
|
+
.option('--username <username>', '系统登录名,也可设置 HANDOVER_USERNAME')
|
|
128
|
+
.option('--non-interactive', '脚本模式,缺失数据以 MISSING_INPUT 返回')
|
|
129
|
+
.option('--json', '机器可读 JSON 输出,提示与错误写入 stderr')
|
|
130
|
+
.option('--dry-run', '收集并预览录入请求')
|
|
131
|
+
.showHelpAfterError();
|
|
132
|
+
program.exitOverride();
|
|
133
|
+
if (jsonOutput) program.configureOutput({ writeErr: () => {} });
|
|
134
|
+
authenticated(program, menu);
|
|
135
|
+
authenticated(program.command('login').description('验证账号密码及当前身份'), async api => ({
|
|
136
|
+
authenticated: true, member: api.member,
|
|
137
|
+
}));
|
|
138
|
+
authenticated(program.command('clients').description('列出任务客户'), taskClients);
|
|
139
|
+
authenticated(program.command('accounts').description('列出账号池'), api => api.request('GET', '/api/accounts'));
|
|
140
|
+
authenticated(accountOptions(program.command('candidates').description('查询指定客户与社区的候选账号'))
|
|
141
|
+
.option('--client <id-or-name>', '客户 ID、名称或编号')
|
|
142
|
+
.option('--kind <kind>', 'post/comment'), async (api, questions, options) => {
|
|
143
|
+
const client = await chooseClient(api, questions, options.client);
|
|
144
|
+
const kind = await questions.choice('kind', '内容类型', options.kind, [
|
|
145
|
+
{ name: '帖子', value: 'post' }, { name: '评论', value: 'comment' },
|
|
146
|
+
]);
|
|
147
|
+
return candidates(api, client.id, kind, await candidateQuery(questions, options));
|
|
148
|
+
});
|
|
149
|
+
for (const kind of ['post', 'comment']) {
|
|
150
|
+
const group = program.command(kind).description(kind === 'post' ? '帖子录入' : '独立评论录入');
|
|
151
|
+
const create = accountOptions(contentOptions(group.command('create').description('选择客户、账号并录入任务')))
|
|
152
|
+
.option('--client <id-or-name>', '客户 ID、名称或编号');
|
|
153
|
+
if (kind === 'post') create.option('--title <text>', '帖子标题').option('--purpose <purpose>', 'external_original/community_internal');
|
|
154
|
+
if (kind === 'comment') create.option('--target-url <url>', '回复的帖子或评论链接').option('--target-context <text>', '目标原文摘录');
|
|
155
|
+
authenticated(create, async (api, questions, options) =>
|
|
156
|
+
executePlan(api, await prepareCreate(api, questions, kind, options), { dryRun: options.dryRun }));
|
|
157
|
+
}
|
|
158
|
+
const tasks = program.command('tasks').description('任务查询、原稿编辑、提交与失败恢复');
|
|
159
|
+
authenticated(tasks.command('list').description('分页查询任务')
|
|
160
|
+
.option('--page <number>', '页码', '1').option('--view <view>', 'mine/all', 'mine')
|
|
161
|
+
.option('--client-id <id>', '客户 ID').option('--type <type>', 'post/comment/followup')
|
|
162
|
+
.option('--status <status>', '任务状态').option('--search <text>', '搜索关键词'),
|
|
163
|
+
(api, questions, options) => listTasks(api, options));
|
|
164
|
+
authenticated(tasks.command('show [id]').description('查询任务详情'), async (api, questions, options, id) =>
|
|
165
|
+
getTask(api, await questions.text('task-id', '任务 ID', id)));
|
|
166
|
+
authenticated(accountOptions(contentOptions(tasks.command('edit [id]').description('编辑原稿,保留现有素材及跟帖')))
|
|
167
|
+
.option('--title <text>', '修改帖子标题').option('--target-context <text>', '目标原文摘录'),
|
|
168
|
+
async (api, questions, options, id) => executePlan(api, await prepareEdit(api, questions,
|
|
169
|
+
await questions.text('task-id', '任务 ID', id), options), { dryRun: options.dryRun }));
|
|
170
|
+
authenticated(tasks.command('submit [id]').description('将现有原稿提交给发布人员'), async (api, questions, options, id) =>
|
|
171
|
+
submitTask(api, await getTask(api, await questions.text('task-id', '任务 ID', id)), options.dryRun));
|
|
172
|
+
authenticated(tasks.command('resume [file]').description('根据恢复文件继续未完成录入')
|
|
173
|
+
.option('--task-id <id>', '创建结果未知时,指定已创建的任务 ID'),
|
|
174
|
+
async (api, questions, options, file) => resumePlan(api, questions,
|
|
175
|
+
await questions.text('recovery-file', '恢复文件路径', file), options));
|
|
176
|
+
program.command('update-check').description('检查 npm 更新,发现新版本时提醒').action(async (_options, command) => {
|
|
177
|
+
const result = await checkUpdate();
|
|
178
|
+
if (command.optsWithGlobals().json) print(result, { json: true });
|
|
179
|
+
else process.stdout.write(updateNotice(result));
|
|
180
|
+
});
|
|
181
|
+
const skill = program.command('skill').description('配套 AI skill');
|
|
182
|
+
skill.command('path').description('输出 npm 包内的 skill 路径').action(() => process.stdout.write(skillDirectory + '\n'));
|
|
183
|
+
skill.command('install').description('安装或更新配套 skill')
|
|
184
|
+
.option('--agent <agent>', 'codex/claude').option('--dir <directory>', '自定义 skill 目标目录')
|
|
185
|
+
.action(async (_options, command) => {
|
|
186
|
+
const options = command.optsWithGlobals();
|
|
187
|
+
const questions = new Questions(options);
|
|
188
|
+
const agent = options.dir ? undefined : await questions.choice('agent', '安装到哪个 AI 工具', options.agent, [
|
|
189
|
+
{ name: 'Codex', value: 'codex' }, { name: 'Claude Code', value: 'claude' },
|
|
190
|
+
]);
|
|
191
|
+
print(await installSkill(agent, options.dir), options);
|
|
192
|
+
});
|
|
193
|
+
return program;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function main(argv = process.argv) {
|
|
197
|
+
const jsonOutput = argv.includes('--json');
|
|
198
|
+
const program = buildProgram({ jsonOutput });
|
|
199
|
+
try {
|
|
200
|
+
await program.parseAsync(argv);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (['commander.helpDisplayed', 'commander.help', 'commander.version'].includes(error.code)) return;
|
|
203
|
+
if (error.name === 'ExitPromptError') {
|
|
204
|
+
process.stderr.write('已取消。\n');
|
|
205
|
+
process.exitCode = 130;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (error.code?.startsWith('commander.')) {
|
|
209
|
+
if (jsonOutput) printError(error, { json: true });
|
|
210
|
+
process.exitCode = error.exitCode || 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!(error instanceof CliError)) error = new CliError('LOCAL_ERROR', error.message);
|
|
214
|
+
printError(error, program.opts());
|
|
215
|
+
process.exitCode = 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
constructor(code, message, details = {}) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'CliError';
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function assert(condition, code, message, details) {
|
|
11
|
+
if (!condition) throw new CliError(code, message, details);
|
|
12
|
+
}
|
package/src/images.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { assert } from './errors.js';
|
|
5
|
+
|
|
6
|
+
export const MAX_IMAGES = 10;
|
|
7
|
+
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function imageHash(bytes) {
|
|
10
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function imageType(bytes) {
|
|
14
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
|
15
|
+
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
|
|
16
|
+
for (let offset = 8; offset + 12 <= bytes.length;) {
|
|
17
|
+
const length = bytes.readUInt32BE(offset);
|
|
18
|
+
const type = bytes.toString('ascii', offset + 4, offset + 8);
|
|
19
|
+
assert(type !== 'acTL', 'INVALID_IMAGE', '请使用静态 PNG 图片。');
|
|
20
|
+
offset += length + 12;
|
|
21
|
+
}
|
|
22
|
+
return 'image/png';
|
|
23
|
+
}
|
|
24
|
+
if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
|
|
25
|
+
for (let offset = 12; offset + 8 <= bytes.length;) {
|
|
26
|
+
const type = bytes.toString('ascii', offset, offset + 4);
|
|
27
|
+
const length = bytes.readUInt32LE(offset + 4);
|
|
28
|
+
assert(type !== 'ANIM' && type !== 'ANMF' &&
|
|
29
|
+
!(type === 'VP8X' && length > 0 && (bytes[offset + 8] & 2)),
|
|
30
|
+
'INVALID_IMAGE', '请使用静态 WebP 图片。');
|
|
31
|
+
offset += 8 + length + length % 2;
|
|
32
|
+
}
|
|
33
|
+
return 'image/webp';
|
|
34
|
+
}
|
|
35
|
+
assert(false, 'INVALID_IMAGE', '图片格式须为 JPEG、PNG 或 WebP。');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readImage(file) {
|
|
39
|
+
const path = resolve(file);
|
|
40
|
+
const handle = await open(path, 'r');
|
|
41
|
+
let bytes;
|
|
42
|
+
try {
|
|
43
|
+
const stat = await handle.stat();
|
|
44
|
+
assert(stat.isFile(), 'INVALID_IMAGE', '图片路径须指向文件。', { path });
|
|
45
|
+
assert(stat.size > 0 && stat.size <= MAX_IMAGE_BYTES, 'INVALID_IMAGE', '单张图片须大于 0 且不超过 5 MiB。', { path });
|
|
46
|
+
bytes = await handle.readFile();
|
|
47
|
+
} finally {
|
|
48
|
+
await handle.close();
|
|
49
|
+
}
|
|
50
|
+
assert(bytes.length > 0 && bytes.length <= MAX_IMAGE_BYTES, 'INVALID_IMAGE', '单张图片须大于 0 且不超过 5 MiB。', { path });
|
|
51
|
+
return { path, name: basename(path), type: imageType(bytes), size: bytes.length, sha256: imageHash(bytes), bytes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function prepareImages(questions, files, existingCount = 0) {
|
|
55
|
+
let paths = files || [];
|
|
56
|
+
if (!files && questions.interactive && existingCount < MAX_IMAGES) {
|
|
57
|
+
const add = await questions.choice('images', '是否添加图片', undefined, [
|
|
58
|
+
{ name: '暂不添加', value: false }, { name: '添加本地图片', value: true },
|
|
59
|
+
]);
|
|
60
|
+
if (add) {
|
|
61
|
+
paths = [];
|
|
62
|
+
while (paths.length + existingCount < MAX_IMAGES) {
|
|
63
|
+
const path = await questions.text('image', paths.length ? '下一张图片路径(留空结束)' : '图片文件路径',
|
|
64
|
+
undefined, { optional: paths.length > 0 });
|
|
65
|
+
if (!path.trim()) break;
|
|
66
|
+
paths.push(path.trim().replace(/^"|"$/g, ''));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
assert(paths.length + existingCount <= MAX_IMAGES, 'TOO_MANY_IMAGES', '每个任务最多 10 张图片,包含现有图片。');
|
|
71
|
+
const images = [];
|
|
72
|
+
for (const path of paths) {
|
|
73
|
+
const { bytes, ...image } = await readImage(path);
|
|
74
|
+
images.push(image);
|
|
75
|
+
}
|
|
76
|
+
return images;
|
|
77
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { input, password, select, search, editor } from '@inquirer/prompts';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { assert } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export class Questions {
|
|
6
|
+
constructor({ nonInteractive = false, interactive = process.stdin.isTTY && process.stderr.isTTY } = {}) {
|
|
7
|
+
this.interactive = Boolean(interactive && !nonInteractive);
|
|
8
|
+
this.context = { input: process.stdin, output: process.stderr };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
requireInteractive(field, message, choices) {
|
|
12
|
+
assert(this.interactive, 'MISSING_INPUT', `缺少${message}。请在终端交互输入,或提供对应参数。`, {
|
|
13
|
+
field, ...(choices ? { choices } : {}),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async text(field, message, value, { optional = false, max = Infinity, secret = false, defaultValue } = {}) {
|
|
18
|
+
const validate = candidate => {
|
|
19
|
+
if (!optional && !candidate.trim()) return '请输入内容。';
|
|
20
|
+
if (candidate.length > max) return `最多 ${max} 个字符。`;
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
if (value !== undefined && value !== null) {
|
|
24
|
+
const problem = validate(String(value));
|
|
25
|
+
assert(problem === true, 'INVALID_INPUT', `${message}填写有误。${problem === true ? '' : problem}`, { field });
|
|
26
|
+
return String(value);
|
|
27
|
+
}
|
|
28
|
+
if (optional && !this.interactive) return defaultValue ?? '';
|
|
29
|
+
this.requireInteractive(field, message);
|
|
30
|
+
const ask = secret ? password : input;
|
|
31
|
+
return ask({ message, validate, ...(secret ? { mask: '*' } : { default: defaultValue }) }, this.context);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async choice(field, message, value, choices, defaultValue) {
|
|
35
|
+
if (value !== undefined && value !== null) {
|
|
36
|
+
const match = choices.find(item => String(item.value) === String(value));
|
|
37
|
+
assert(match && !match.disabled, 'INVALID_INPUT', `${message}选项无效。`, {
|
|
38
|
+
field, choices: choices.filter(item => !item.disabled).map(item => item.value),
|
|
39
|
+
});
|
|
40
|
+
return match.value;
|
|
41
|
+
}
|
|
42
|
+
this.requireInteractive(field, message, choices.filter(item => !item.disabled).map(item => item.value));
|
|
43
|
+
return select({ message, choices, default: defaultValue }, this.context);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async record(field, message, value, records, { id, name, aliases = () => [], disabled = () => false }) {
|
|
47
|
+
assert(records.length, 'NO_OPTIONS', `${message}时没有可用记录,请先在系统中补齐数据。`, { field });
|
|
48
|
+
const choices = records.map(record => ({
|
|
49
|
+
name: name(record), value: record, disabled: disabled(record),
|
|
50
|
+
}));
|
|
51
|
+
if (value !== undefined && value !== null) {
|
|
52
|
+
const exact = records.filter(record => String(id(record)) === String(value));
|
|
53
|
+
const matches = exact.length ? exact : records.filter(record => aliases(record)
|
|
54
|
+
.some(alias => String(alias).toLowerCase() === String(value).toLowerCase()));
|
|
55
|
+
if (matches.length === 1) {
|
|
56
|
+
const reason = disabled(matches[0]);
|
|
57
|
+
assert(!reason, 'UNAVAILABLE_SELECTION', String(reason), { field });
|
|
58
|
+
return matches[0];
|
|
59
|
+
}
|
|
60
|
+
assert(matches.length, 'SELECTION_NOT_FOUND', `${message}中找不到指定记录。`, { field, value });
|
|
61
|
+
this.requireInteractive(field, `${message}(存在同名记录,请按 ID 选择)`, matches.map(id));
|
|
62
|
+
return select({ message, choices: choices.filter(item => matches.includes(item.value)) }, this.context);
|
|
63
|
+
}
|
|
64
|
+
assert(choices.some(item => !item.disabled), 'NO_OPTIONS', `${message}时所有记录均不可用。`, { field });
|
|
65
|
+
this.requireInteractive(field, message, records.map(record => ({ id: id(record), name: name(record) })));
|
|
66
|
+
return search({
|
|
67
|
+
message: `${message}(可输入关键词搜索)`, pageSize: 12,
|
|
68
|
+
source: term => choices.filter(item => item.name.toLowerCase().includes((term || '').toLowerCase())),
|
|
69
|
+
}, this.context);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async body(value, file, defaultValue, { optional = false } = {}) {
|
|
73
|
+
assert(!(value !== undefined && file), 'INVALID_INPUT', '--body 与 --body-file 只能选择一项。');
|
|
74
|
+
if (file) value = await readFile(file, 'utf8');
|
|
75
|
+
if (value !== undefined) return this.text('body', '正文', value, { max: 40000, optional });
|
|
76
|
+
if (optional && !this.interactive) return defaultValue ?? '';
|
|
77
|
+
this.requireInteractive('body', '正文(--body 或 --body-file)');
|
|
78
|
+
const mode = await this.choice('body-mode', '正文输入方式', undefined, [
|
|
79
|
+
{ name: '输入一段文字', value: 'input' },
|
|
80
|
+
{ name: '读取 UTF-8 / Markdown 文件', value: 'file' },
|
|
81
|
+
{ name: '打开系统编辑器,编辑多行正文', value: 'editor' },
|
|
82
|
+
]);
|
|
83
|
+
if (mode === 'file') {
|
|
84
|
+
const path = await this.text('body-file', '正文文件路径', undefined);
|
|
85
|
+
return this.body(undefined, path.trim().replace(/^"|"$/g, ''), defaultValue, { optional });
|
|
86
|
+
}
|
|
87
|
+
if (mode === 'editor') {
|
|
88
|
+
return editor({
|
|
89
|
+
message: '编辑正文并保存退出', default: defaultValue ?? '', postfix: '.md',
|
|
90
|
+
validate: content => (optional || content.trim()) && content.length <= 40000
|
|
91
|
+
? true : '请填写正文,最多 40000 个字符。',
|
|
92
|
+
}, this.context);
|
|
93
|
+
}
|
|
94
|
+
return this.text('body', optional ? '正文(图片帖可留空)' : '正文', undefined, { max: 40000, defaultValue, optional });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function positiveInteger(value, label = 'ID') {
|
|
99
|
+
assert(/^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0,
|
|
100
|
+
'INVALID_INPUT', `${label}须为正整数。`);
|
|
101
|
+
return Number(value);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function normalizeSubreddit(value) {
|
|
105
|
+
const result = value.trim().replace(/^\/?r\//i, '').replace(/\/$/, '').toLowerCase();
|
|
106
|
+
assert(/^[a-z0-9_]{1,50}$/.test(result), 'INVALID_INPUT', '请填写社区名称,例如 r/example。');
|
|
107
|
+
return result;
|
|
108
|
+
}
|
package/src/skill.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const skillDirectory = fileURLToPath(new URL('../skills/owned-account-pool/', import.meta.url));
|
|
7
|
+
|
|
8
|
+
export async function installSkill(agent, directory) {
|
|
9
|
+
const roots = {
|
|
10
|
+
codex: join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'skills'),
|
|
11
|
+
claude: join(homedir(), '.claude', 'skills'),
|
|
12
|
+
};
|
|
13
|
+
const destination = resolve(directory || join(roots[agent], 'owned-account-pool'));
|
|
14
|
+
await mkdir(destination, { recursive: true });
|
|
15
|
+
const text = await readFile(join(skillDirectory, 'SKILL.md'), 'utf8');
|
|
16
|
+
await writeFile(join(destination, 'SKILL.md'), text, 'utf8');
|
|
17
|
+
return { installed: true, path: destination };
|
|
18
|
+
}
|
package/src/tasks.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { readFile, mkdir, writeFile, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { allPages, itemsOf } from './api.js';
|
|
7
|
+
import { CliError, assert } from './errors.js';
|
|
8
|
+
import { normalizeSubreddit, positiveInteger } from './prompts.js';
|
|
9
|
+
import { imageHash, MAX_IMAGES, prepareImages, readImage } from './images.js';
|
|
10
|
+
|
|
11
|
+
const PURPOSES = [
|
|
12
|
+
{ name: '外部原创', value: 'external_original' },
|
|
13
|
+
{ name: '社区内部', value: 'community_internal' },
|
|
14
|
+
];
|
|
15
|
+
const RETENTION = [24, 48, 72, 168, 720].map(value => ({ name: `${value} 小时`, value }));
|
|
16
|
+
const WEIGHTS = [5, 10, 20, 30].map(value => ({ name: String(value), value }));
|
|
17
|
+
const GROUPS = [
|
|
18
|
+
{ name: '客户账号', value: 'current' },
|
|
19
|
+
{ name: '公用账号池', value: 'pool' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function taskOf(result) {
|
|
23
|
+
const task = result?.task ?? result;
|
|
24
|
+
assert(task?.id !== undefined && Number.isInteger(task.version),
|
|
25
|
+
'INVALID_RESPONSE', '任务响应缺少 id 或 version。');
|
|
26
|
+
return task;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getTask(api, id) {
|
|
30
|
+
return taskOf(await api.request('GET', `/api/tasks/${positiveInteger(id, '任务 ID')}`));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function taskClients(api) {
|
|
34
|
+
return itemsOf(await api.request('GET', '/api/tasks/clients'), 'clients');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function chooseClient(api, questions, value) {
|
|
38
|
+
return questions.record('client', '选择客户', value, await taskClients(api), {
|
|
39
|
+
id: item => item.id,
|
|
40
|
+
name: item => `${item.name} [${item.id}]${item.code ? ` · ${item.code}` : ''}`,
|
|
41
|
+
aliases: item => [item.name, item.code].filter(Boolean),
|
|
42
|
+
disabled: item => item.is_archived ? '客户已归档' :
|
|
43
|
+
('upstream_client_id' in item && !item.upstream_client_id ? '客户等待管理员绑定上游客户' : false),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function candidateQuery(questions, options, subreddit) {
|
|
48
|
+
return {
|
|
49
|
+
subreddit: normalizeSubreddit(await questions.text('subreddit', '目标社区', subreddit ?? options.subreddit)),
|
|
50
|
+
group: await questions.choice('group', '账号来源', options.group, GROUPS, 'current'),
|
|
51
|
+
weight: await questions.choice('weight', '账号社区权重阈值', options.weight, WEIGHTS, 5),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function candidates(api, clientId, kind, query) {
|
|
56
|
+
return allPages(api, '/api/tasks/candidates', { client_id: clientId, kind, ...query });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function chooseCandidate(api, questions, options, clientId, kind, subreddit) {
|
|
60
|
+
const query = await candidateQuery(questions, options, subreddit);
|
|
61
|
+
const accounts = await candidates(api, clientId, kind, query);
|
|
62
|
+
const account = await questions.record('account', '选择账号', options.account, accounts, {
|
|
63
|
+
id: item => item.id,
|
|
64
|
+
name: item => `${item.display_name || item.id} · u/${item.reddit_username || '?'} [${item.id}]` +
|
|
65
|
+
(item.unavailable_reason ? ` · ${item.unavailable_reason}` : ''),
|
|
66
|
+
aliases: item => [item.display_name, item.reddit_username, `u/${item.reddit_username}`].filter(Boolean),
|
|
67
|
+
disabled: item => !item.selectable ? item.unavailable_reason || '账号当前不可用' : false,
|
|
68
|
+
});
|
|
69
|
+
return { account_id: String(account.id), subreddit: query.subreddit, weight: query.weight };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function submissionChoice(questions, options) {
|
|
73
|
+
assert(!(options.submit && options.draft), 'INVALID_INPUT', '--submit 与 --draft 只能选择一项。');
|
|
74
|
+
let mode;
|
|
75
|
+
if (options.submit) mode = 'submit';
|
|
76
|
+
if (options.draft) mode = 'draft';
|
|
77
|
+
return questions.choice('mode', '录入完成后', mode, [
|
|
78
|
+
{ name: '保存为草稿', value: 'draft' },
|
|
79
|
+
{ name: '提交给发布人员', value: 'submit' },
|
|
80
|
+
], 'draft');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function prepareCreate(api, questions, kind, options) {
|
|
84
|
+
const client = await chooseClient(api, questions, options.client);
|
|
85
|
+
let target;
|
|
86
|
+
if (kind === 'comment') {
|
|
87
|
+
const url = await questions.text('target-url', '回复目标帖子或评论 URL', options.targetUrl);
|
|
88
|
+
target = await api.request('GET', '/api/tasks/target', { query: { url: url.trim() } });
|
|
89
|
+
assert(target?.url && target?.subreddit, 'INVALID_RESPONSE', '回复目标解析缺少 url 或 subreddit。');
|
|
90
|
+
if (options.subreddit) {
|
|
91
|
+
assert(normalizeSubreddit(options.subreddit) === normalizeSubreddit(target.subreddit),
|
|
92
|
+
'INVALID_INPUT', '指定社区与回复目标所属社区不一致。');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const purpose = kind === 'post'
|
|
96
|
+
? await questions.choice('purpose', '帖子用途', options.purpose, PURPOSES) : null;
|
|
97
|
+
const candidate = await chooseCandidate(api, questions, options, client.id, kind, target?.subreddit);
|
|
98
|
+
const title = kind === 'post'
|
|
99
|
+
? await questions.text('title', '帖子标题', options.title, { max: 300 }) : '';
|
|
100
|
+
const images = await prepareImages(questions, options.image);
|
|
101
|
+
const body = await questions.body(options.body, options.bodyFile, undefined, {
|
|
102
|
+
optional: kind === 'post' && images.length > 0,
|
|
103
|
+
});
|
|
104
|
+
const notes = await questions.text('notes', '内部备注(可留空)', options.notes, { optional: true, max: 5000 });
|
|
105
|
+
const targetContext = kind === 'comment'
|
|
106
|
+
? await questions.text('target-context', '目标原文摘录(可留空)', options.targetContext, { optional: true, max: 20000 }) : '';
|
|
107
|
+
const retention = await questions.choice('retention-hours', '留存要求', options.retentionHours, RETENTION);
|
|
108
|
+
const mode = await submissionChoice(questions, options);
|
|
109
|
+
const accountMode = kind === 'comment' ? { account_mode: 'owned' } : {};
|
|
110
|
+
return {
|
|
111
|
+
schema: 1, base_url: api.baseUrl, task_id: null, expected_version: null, mode, images,
|
|
112
|
+
create: {
|
|
113
|
+
client_id: positiveInteger(client.id, '客户 ID'), kind, parent_task_id: null,
|
|
114
|
+
follow_op: false, post_purpose: purpose, ...accountMode,
|
|
115
|
+
target_url: target?.url || '', candidates: [candidate],
|
|
116
|
+
},
|
|
117
|
+
registration: {
|
|
118
|
+
content: { title, body, notes, target_context: targetContext, assets: [] },
|
|
119
|
+
retention_hours: retention, ...accountMode, candidates: [candidate], followups: [],
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function editableFollowups(task, member) {
|
|
125
|
+
return (task.children || []).filter(child => ['draft', 'returned'].includes(child.status) &&
|
|
126
|
+
(member?.role === 'admin' || String(child.creator_id) === String(member?.id))).map(child => ({
|
|
127
|
+
id: child.id, version: child.version, content: child.source,
|
|
128
|
+
follow_op: child.follow_op, account_mode: child.account_mode,
|
|
129
|
+
candidates: (child.candidates || []).map(candidateFields), removed: Boolean(child.removed),
|
|
130
|
+
}));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function candidateFields(candidate) {
|
|
134
|
+
return { account_id: String(candidate.account_id), subreddit: candidate.subreddit, weight: candidate.weight };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function prepareEdit(api, questions, id, options) {
|
|
138
|
+
const task = await getTask(api, id);
|
|
139
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '原稿编辑适用于 draft 或 returned 任务。');
|
|
140
|
+
const content = { title: '', body: '', notes: '', target_context: '', assets: [], ...task.source };
|
|
141
|
+
const images = await prepareImages(questions, options.image, content.assets.length);
|
|
142
|
+
if (task.kind === 'post' && (options.title !== undefined || !content.title)) {
|
|
143
|
+
content.title = await questions.text('title', '帖子标题', options.title, { max: 300 });
|
|
144
|
+
}
|
|
145
|
+
if (options.body !== undefined || options.bodyFile || !content.body || questions.interactive && !options.image?.length) {
|
|
146
|
+
content.body = await questions.body(options.body, options.bodyFile, content.body, {
|
|
147
|
+
optional: task.kind === 'post' && content.assets.length + images.length > 0,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
content.notes = await questions.text('notes', '内部备注(可留空)', options.notes, {
|
|
151
|
+
optional: true, max: 5000, defaultValue: content.notes,
|
|
152
|
+
});
|
|
153
|
+
if (options.targetContext !== undefined) {
|
|
154
|
+
content.target_context = await questions.text('target-context', '目标原文摘录', options.targetContext, {
|
|
155
|
+
optional: true, max: 20000,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
let chosenCandidates = (task.candidates || []).map(candidateFields);
|
|
159
|
+
const needsCandidate = !chosenCandidates.length && (task.account_mode === 'owned' || task.kind === 'post');
|
|
160
|
+
const changesCandidate = ['account', 'subreddit', 'group', 'weight'].some(key => options[key] !== undefined);
|
|
161
|
+
if (changesCandidate || needsCandidate) {
|
|
162
|
+
const subreddit = task.kind === 'comment'
|
|
163
|
+
? task.target?.subreddit || chosenCandidates[0]?.subreddit || options.subreddit
|
|
164
|
+
: options.subreddit || chosenCandidates[0]?.subreddit;
|
|
165
|
+
chosenCandidates = [await chooseCandidate(api, questions, options, task.client_id, task.kind, subreddit)];
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
schema: 1, base_url: api.baseUrl, task_id: task.id, expected_version: task.version, images,
|
|
169
|
+
mode: await submissionChoice(questions, options),
|
|
170
|
+
registration: {
|
|
171
|
+
content,
|
|
172
|
+
retention_hours: await questions.choice('retention-hours', '留存要求',
|
|
173
|
+
options.retentionHours ?? task.retention_hours, RETENTION),
|
|
174
|
+
...(task.kind === 'comment' && !task.parent_task_id ? { account_mode: task.account_mode } : {}),
|
|
175
|
+
candidates: chosenCandidates, followups: editableFollowups(task, api.member),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function writeRecovery(path, plan) {
|
|
181
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
182
|
+
try {
|
|
183
|
+
await writeFile(temporary, JSON.stringify(plan, null, 2), { mode: 0o600 });
|
|
184
|
+
await rename(temporary, path);
|
|
185
|
+
} finally {
|
|
186
|
+
await unlink(temporary).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function registrationMatches(task, registration) {
|
|
191
|
+
const source = task.source || {};
|
|
192
|
+
const contentMatches = Object.entries(registration.content).every(([key, value]) =>
|
|
193
|
+
isDeepStrictEqual(source[key], value));
|
|
194
|
+
return contentMatches && task.retention_hours === registration.retention_hours &&
|
|
195
|
+
isDeepStrictEqual((task.candidates || []).map(candidateFields), registration.candidates) &&
|
|
196
|
+
(registration.account_mode === undefined || task.account_mode === registration.account_mode) &&
|
|
197
|
+
registration.followups.every(child => {
|
|
198
|
+
const current = task.children?.find(item => item.id === child.id);
|
|
199
|
+
return current && isDeepStrictEqual(current.source, child.content) &&
|
|
200
|
+
Boolean(current.removed) === child.removed && current.follow_op === child.follow_op &&
|
|
201
|
+
current.account_mode === child.account_mode &&
|
|
202
|
+
isDeepStrictEqual((current.candidates || []).map(candidateFields), child.candidates);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function addedImage(task, plan) {
|
|
207
|
+
const previous = plan.registration.content.assets;
|
|
208
|
+
const current = task.source?.assets;
|
|
209
|
+
assert(Array.isArray(current) && current.length === previous.length + 1 &&
|
|
210
|
+
isDeepStrictEqual(current.slice(0, -1), previous) &&
|
|
211
|
+
registrationMatches({ ...task, source: { ...task.source, assets: previous } }, plan.registration),
|
|
212
|
+
'UPLOAD_CONFLICT', '任务素材或原稿发生了其他变化,请查询任务并核对恢复文件。');
|
|
213
|
+
const id = current.at(-1);
|
|
214
|
+
assert((typeof id === 'string' && id.length > 0 || typeof id === 'number' && Number.isFinite(id)) &&
|
|
215
|
+
!previous.includes(id), 'INVALID_RESPONSE', '上传响应缺少新增图片的素材 ID。');
|
|
216
|
+
return id;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rememberImage(plan, image, task, assetId) {
|
|
220
|
+
image.asset_id = assetId;
|
|
221
|
+
delete image.attempt;
|
|
222
|
+
plan.registration.content.assets = [...task.source.assets];
|
|
223
|
+
plan.expected_version = task.version;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function reconcileUpload(api, plan, task, recoveryPath) {
|
|
227
|
+
const pending = (plan.images || []).filter(image => image.attempt);
|
|
228
|
+
assert(pending.length <= 1, 'INVALID_RECOVERY', '恢复文件包含多个未确认的上传请求。');
|
|
229
|
+
if (!pending.length) return;
|
|
230
|
+
const image = pending[0];
|
|
231
|
+
if (task.version === image.attempt.version && registrationMatches(task, plan.registration)) {
|
|
232
|
+
delete image.attempt;
|
|
233
|
+
} else {
|
|
234
|
+
const id = addedImage(task, plan);
|
|
235
|
+
const bytes = await api.request('GET', `/api/tasks/${task.id}/assets/${encodeURIComponent(String(id))}`, {
|
|
236
|
+
query: { download: true }, responseType: 'buffer',
|
|
237
|
+
});
|
|
238
|
+
assert(imageHash(bytes) === image.sha256, 'UPLOAD_CONFLICT',
|
|
239
|
+
'新增素材与待恢复图片的内容不一致,请查询任务并核对上传结果。');
|
|
240
|
+
rememberImage(plan, image, task, id);
|
|
241
|
+
}
|
|
242
|
+
await writeRecovery(recoveryPath, plan);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function checkedImage(image) {
|
|
246
|
+
const current = await readImage(image.path);
|
|
247
|
+
assert(current.sha256 === image.sha256, 'IMAGE_CHANGED',
|
|
248
|
+
'图片文件在录入后发生了变化,请恢复原文件,或通过 tasks edit 使用新文件。', { path: image.path });
|
|
249
|
+
return current;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function uploadImages(api, plan, task, recoveryPath) {
|
|
253
|
+
for (const image of plan.images || []) {
|
|
254
|
+
if (image.asset_id !== undefined) continue;
|
|
255
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '当前任务状态不支持原稿图片上传。');
|
|
256
|
+
assert(task.version === plan.expected_version && registrationMatches(task, plan.registration),
|
|
257
|
+
'VERSION_CONFLICT', '任务已变化,请核对原稿后继续上传。');
|
|
258
|
+
const file = await checkedImage(image);
|
|
259
|
+
const form = new FormData();
|
|
260
|
+
form.set('file', new Blob([file.bytes], { type: file.type }), file.name);
|
|
261
|
+
form.set('version', String(task.version));
|
|
262
|
+
image.attempt = { version: task.version };
|
|
263
|
+
await writeRecovery(recoveryPath, plan);
|
|
264
|
+
const uploaded = taskOf(await api.request('POST', `/api/tasks/${task.id}/assets`, { body: form }));
|
|
265
|
+
const id = addedImage(uploaded, plan);
|
|
266
|
+
rememberImage(plan, image, uploaded, id);
|
|
267
|
+
await writeRecovery(recoveryPath, plan);
|
|
268
|
+
task = uploaded;
|
|
269
|
+
}
|
|
270
|
+
return task;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function submitTask(api, task, dryRun = false) {
|
|
274
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '可提交的原稿状态为 draft 或 returned。');
|
|
275
|
+
const body = {
|
|
276
|
+
version: task.version,
|
|
277
|
+
children: (task.children || []).filter(child => ['draft', 'returned'].includes(child.status))
|
|
278
|
+
.map(child => ({ id: child.id, version: child.version })),
|
|
279
|
+
};
|
|
280
|
+
if (dryRun) return { dry_run: true, method: 'POST', path: `/api/tasks/${task.id}/registration/submit`, body };
|
|
281
|
+
return taskOf(await api.request('POST', `/api/tasks/${task.id}/registration/submit`, { body }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function executePlan(api, plan, { recoveryPath, recoveryDir, resume = false, dryRun = false } = {}) {
|
|
285
|
+
if (dryRun) return { dry_run: true, plan };
|
|
286
|
+
const pendingImages = (plan.images || []).filter(image => image.asset_id === undefined);
|
|
287
|
+
assert(plan.registration.content.assets.length + pendingImages.length <= MAX_IMAGES,
|
|
288
|
+
'TOO_MANY_IMAGES', '每个任务最多 10 张图片,包含现有图片。');
|
|
289
|
+
for (const image of pendingImages) {
|
|
290
|
+
if (!image.attempt) await checkedImage(image);
|
|
291
|
+
}
|
|
292
|
+
const directory = recoveryDir || join(homedir(), '.owned-account-pool', 'recovery');
|
|
293
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
294
|
+
const path = recoveryPath || join(directory, `${randomUUID()}.recovery.json`);
|
|
295
|
+
let task;
|
|
296
|
+
try {
|
|
297
|
+
await writeRecovery(path, plan);
|
|
298
|
+
if (!plan.task_id) {
|
|
299
|
+
assert(!resume, 'MISSING_INPUT', '创建结果未知,请先查询任务列表,再通过 --task-id 指定已创建的任务。', { field: 'task-id' });
|
|
300
|
+
task = taskOf(await api.request('POST', '/api/tasks', { body: plan.create }));
|
|
301
|
+
plan.task_id = task.id;
|
|
302
|
+
plan.expected_version = task.version;
|
|
303
|
+
await writeRecovery(path, plan);
|
|
304
|
+
} else {
|
|
305
|
+
task = await getTask(api, plan.task_id);
|
|
306
|
+
}
|
|
307
|
+
if (plan.create) {
|
|
308
|
+
assert(task.client_id === plan.create.client_id && task.kind === plan.create.kind,
|
|
309
|
+
'RECOVERY_MISMATCH', '恢复任务的客户或类型与原录入内容不一致。');
|
|
310
|
+
}
|
|
311
|
+
await reconcileUpload(api, plan, task, path);
|
|
312
|
+
const alreadySaved = registrationMatches(task, plan.registration);
|
|
313
|
+
if (!alreadySaved) {
|
|
314
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '任务当前状态不支持保存原稿。');
|
|
315
|
+
assert(task.version === plan.expected_version, 'VERSION_CONFLICT',
|
|
316
|
+
'任务版本已变化。录入内容保存在恢复文件中,请查询任务并合并修改。', { current_version: task.version });
|
|
317
|
+
task = taskOf(await api.request('PUT', `/api/tasks/${task.id}/registration`, {
|
|
318
|
+
body: { version: plan.expected_version, ...plan.registration },
|
|
319
|
+
}));
|
|
320
|
+
plan.expected_version = task.version;
|
|
321
|
+
await writeRecovery(path, plan);
|
|
322
|
+
} else {
|
|
323
|
+
plan.expected_version = task.version;
|
|
324
|
+
await writeRecovery(path, plan);
|
|
325
|
+
}
|
|
326
|
+
task = await uploadImages(api, plan, task, path);
|
|
327
|
+
if (plan.mode === 'submit' && ['draft', 'returned'].includes(task.status)) {
|
|
328
|
+
task = await submitTask(api, task);
|
|
329
|
+
}
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!(error instanceof CliError)) error = new CliError('LOCAL_ERROR', error.message);
|
|
332
|
+
error.details = { ...error.details, task_id: plan.task_id, recovery_file: path,
|
|
333
|
+
next_command: `owned-pool tasks resume "${path}"` };
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
await unlink(path);
|
|
338
|
+
} catch {
|
|
339
|
+
return { task_id: task.id, status: task.status, version: task.version,
|
|
340
|
+
recovery_file: path, warning: '操作成功,恢复文件清理失败,可手动删除。' };
|
|
341
|
+
}
|
|
342
|
+
return { task_id: task.id, status: task.status, version: task.version,
|
|
343
|
+
...((plan.images || []).length ? { uploaded_assets: plan.images.map(image => image.asset_id) } : {}) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function resumePlan(api, questions, file, options) {
|
|
347
|
+
const path = resolve(file);
|
|
348
|
+
const plan = JSON.parse(await readFile(path, 'utf8'));
|
|
349
|
+
assert(plan.schema === 1 && plan.registration?.content && Array.isArray(plan.registration.candidates) &&
|
|
350
|
+
Array.isArray(plan.registration.content.assets) && Array.isArray(plan.registration.followups) &&
|
|
351
|
+
(plan.images === undefined || Array.isArray(plan.images) && plan.images.every(image =>
|
|
352
|
+
typeof image.path === 'string' && /^[a-f0-9]{64}$/.test(image.sha256))) &&
|
|
353
|
+
['draft', 'submit'].includes(plan.mode),
|
|
354
|
+
'INVALID_RECOVERY', '恢复文件格式无效。');
|
|
355
|
+
assert(plan.base_url === api.baseUrl, 'RECOVERY_MISMATCH', '恢复文件所属服务与当前 --base-url 不一致。');
|
|
356
|
+
if (!plan.task_id) {
|
|
357
|
+
plan.task_id = positiveInteger(await questions.text('task-id',
|
|
358
|
+
'创建结果未知,请查询任务列表并填写对应任务 ID', options.taskId), '任务 ID');
|
|
359
|
+
const task = await getTask(api, plan.task_id);
|
|
360
|
+
assert(!(task.source?.body || task.source?.assets?.length) || registrationMatches(task, plan.registration),
|
|
361
|
+
'RECOVERY_MISMATCH', '指定任务已有其他正文,请核对任务 ID 并通过 tasks edit 合并修改。');
|
|
362
|
+
plan.expected_version = task.version;
|
|
363
|
+
}
|
|
364
|
+
return executePlan(api, plan, { recoveryPath: path, resume: true, dryRun: options.dryRun });
|
|
365
|
+
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import semver from 'semver';
|
|
3
|
+
|
|
4
|
+
export const packageInfo = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
5
|
+
|
|
6
|
+
export async function checkUpdate({ fetchImpl = fetch, timeout = 1500 } = {}) {
|
|
7
|
+
const result = { package: packageInfo.name, current: packageInfo.version, checked: false, update_available: false };
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetchImpl(`https://registry.npmjs.org/${packageInfo.name}/latest`, {
|
|
10
|
+
headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeout), redirect: 'error',
|
|
11
|
+
});
|
|
12
|
+
if (!response.ok) return result;
|
|
13
|
+
const latest = (await response.json()).version;
|
|
14
|
+
if (!semver.valid(latest)) return result;
|
|
15
|
+
const available = semver.gt(latest, packageInfo.version);
|
|
16
|
+
return { ...result, checked: true, latest, update_available: available,
|
|
17
|
+
...(available ? { update_command: `npm install -g ${packageInfo.name}@latest` } : {}) };
|
|
18
|
+
} catch {
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function updateNotice(result) {
|
|
24
|
+
if (!result.update_available) return '';
|
|
25
|
+
return `[UPDATE_AVAILABLE] ${result.package} ${result.current} → ${result.latest}\n请运行 ${result.update_command}\n`;
|
|
26
|
+
}
|