owned-account-pool-cli 0.0.0-stage → 0.1.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 +112 -2
- package/bin/owned-pool.js +4 -0
- package/package.json +32 -4
- package/skills/owned-account-pool/SKILL.md +75 -0
- package/src/api.js +133 -0
- package/src/cli.js +216 -0
- package/src/errors.js +12 -0
- package/src/prompts.js +106 -0
- package/src/skill.js +18 -0
- package/src/tasks.js +276 -0
- package/src/update.js +26 -0
package/README.md
CHANGED
|
@@ -1,3 +1,113 @@
|
|
|
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
|
+
写入失败时返回任务 ID 和本地恢复文件路径。该文件包含原稿内容,保存在用户目录 `.owned-account-pool/recovery`。使用返回的恢复命令继续操作,成功后自动清理。
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
owned-pool tasks resume "/完整路径/example.recovery.json"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
创建请求超时、结果未知时,先查询 `tasks list` 确认对应记录,再通过 `--task-id` 恢复。版本冲突时,先读取最新任务并合并修改。
|
|
84
|
+
|
|
85
|
+
## 配套 skill 与更新
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
owned-pool skill install --agent codex
|
|
89
|
+
owned-pool skill install --agent claude
|
|
90
|
+
owned-pool skill path
|
|
91
|
+
owned-pool update-check
|
|
92
|
+
owned-pool update-check --json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
skill 随包发布,可安装到 Codex、Claude Code,也可用 `skill install --dir <目录>` 指定目标。它指导 AI 查询候选项、向用户补齐缺失数据、录入任务和处理恢复文件。
|
|
96
|
+
|
|
97
|
+
每次业务命令都会并行检查 npm 最新版本,超时为 1.5 秒。有新版本时提示更新命令,相同版本、较旧版本或检查失败时静默继续。检查会在当前命令结束时输出提醒。独立 `update-check` 在没有新版本时保持安静,`--json` 可查看检查结果。
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
npm install -g owned-account-pool-cli@latest
|
|
101
|
+
owned-pool skill install --agent codex
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 本地开发
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
npm ci
|
|
108
|
+
npm test
|
|
109
|
+
node bin/owned-pool.js --help
|
|
110
|
+
npm pack --dry-run
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
接口依据本目录 2026-11-11 的 API 文档及站点前端调用。测试覆盖模拟服务的认证、CSRF、录入、分页选择、版本冲突、恢复、更新提示和 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.1.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,75 @@
|
|
|
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
|
+
- `--dry-run` 用于检查收集完成的录入方案。它包含登录和只读查询。
|
|
60
|
+
|
|
61
|
+
成功结果包含 `task_id`、`status`、`version`。向用户汇报任务 ID 与状态。此工作流记录系统任务,Reddit 实际发布由发布人员执行。
|
|
62
|
+
|
|
63
|
+
## 编辑、提交与恢复
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
owned-pool tasks edit 123 --body-file ./updated.md --draft --non-interactive --json
|
|
67
|
+
owned-pool tasks submit 123 --non-interactive --json
|
|
68
|
+
owned-pool tasks resume "恢复文件的完整路径" --non-interactive --json
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
编辑保留原稿现有素材和可编辑跟帖,使用读取时的任务版本。出现 `VERSION_CONFLICT` 时读取最新任务,结合用户要求合并修改后再次保存。
|
|
72
|
+
|
|
73
|
+
录入分为创建任务和保存原稿。写入中断时,错误中包含 `recovery_file` 和 `next_command`。优先恢复既有任务。创建请求结果未知时,先查 `tasks list`,找到对应任务,再使用 `tasks resume ... --task-id ID`。恢复文件保存在用户目录 `.owned-account-pool/recovery`,含录入正文,成功恢复后清理。
|
|
74
|
+
|
|
75
|
+
本 CLI 覆盖账号查询与任务登记流程。客户绑定、账号资格等服务端配置问题按接口错误提示处理。
|
package/src/api.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
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, 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 headers = { Accept: 'application/json' };
|
|
30
|
+
const cookie = await this.jar.getCookieString(url.href);
|
|
31
|
+
if (cookie) headers.Cookie = cookie;
|
|
32
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
33
|
+
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && this.csrf) {
|
|
34
|
+
headers['X-CSRF-Token'] = this.csrf;
|
|
35
|
+
}
|
|
36
|
+
let response;
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
response = await this.fetchImpl(url, {
|
|
40
|
+
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
|
|
41
|
+
redirect: 'manual', signal: AbortSignal.timeout(timeout),
|
|
42
|
+
});
|
|
43
|
+
for (const cookieValue of response.headers.getSetCookie()) {
|
|
44
|
+
await this.jar.setCookie(cookieValue, url.href);
|
|
45
|
+
}
|
|
46
|
+
text = await response.text();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
const code = ['TimeoutError', 'AbortError'].includes(error.name) ? 'REQUEST_TIMEOUT' : 'NETWORK_ERROR';
|
|
49
|
+
throw new CliError(code, `${method} ${url.pathname} 请求未完成,请检查连接和服务状态。`, {
|
|
50
|
+
method, path: url.pathname, outcome_unknown: method !== 'GET',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
let data;
|
|
54
|
+
try {
|
|
55
|
+
data = text ? JSON.parse(text) : null;
|
|
56
|
+
} catch {
|
|
57
|
+
throw new CliError('INVALID_RESPONSE', `接口返回了非 JSON 数据(HTTP ${response.status})。`, {
|
|
58
|
+
status: response.status, path: url.pathname, outcome_unknown: method !== 'GET',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const error = data?.error;
|
|
63
|
+
let message = error?.message || `接口请求失败(HTTP ${response.status})。`;
|
|
64
|
+
if (body?.password) message = message.replaceAll(body.password, '[REDACTED]');
|
|
65
|
+
throw new CliError(error?.code || `HTTP_${response.status}`,
|
|
66
|
+
message, {
|
|
67
|
+
status: response.status, path: url.pathname,
|
|
68
|
+
...(!body?.password && error?.details?.fields ? { fields: error.details.fields } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async login(username, password) {
|
|
75
|
+
const result = await this.request('POST', '/api/community-workbench/auth/login', {
|
|
76
|
+
body: { username, password },
|
|
77
|
+
});
|
|
78
|
+
this.loggedIn = true;
|
|
79
|
+
this.csrf = result?.csrf;
|
|
80
|
+
let identity = result;
|
|
81
|
+
if (!this.csrf) {
|
|
82
|
+
identity = await this.request('GET', '/api/community-workbench/auth/me');
|
|
83
|
+
this.csrf = identity?.csrf;
|
|
84
|
+
}
|
|
85
|
+
assert(this.csrf, 'MISSING_CSRF', '登录响应缺少 CSRF,请检查服务端认证接口。');
|
|
86
|
+
this.member = identity.member || result.member;
|
|
87
|
+
return this.member;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async logout() {
|
|
91
|
+
try {
|
|
92
|
+
if (this.loggedIn) {
|
|
93
|
+
await this.request('POST', '/api/community-workbench/auth/logout', { timeout: 3000 });
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
this.loggedIn = false;
|
|
97
|
+
this.csrf = null;
|
|
98
|
+
this.member = null;
|
|
99
|
+
await this.jar.removeAllCookies();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function itemsOf(data, key = 'items') {
|
|
105
|
+
if (Array.isArray(data)) return data;
|
|
106
|
+
if (Array.isArray(data?.[key])) return data[key];
|
|
107
|
+
if (Array.isArray(data?.items)) return data.items;
|
|
108
|
+
throw new CliError('INVALID_RESPONSE', '接口列表格式与预期不符。');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function allPages(api, path, query = {}, key = 'items') {
|
|
112
|
+
const results = [];
|
|
113
|
+
const seen = new Set();
|
|
114
|
+
for (let page = 1; ; page += 1) {
|
|
115
|
+
const data = await api.request('GET', path, { query: { ...query, page } });
|
|
116
|
+
const items = itemsOf(data, key);
|
|
117
|
+
let added = 0;
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const id = String(item.id ?? item.account_id ?? JSON.stringify(item));
|
|
120
|
+
if (!seen.has(id)) {
|
|
121
|
+
seen.add(id);
|
|
122
|
+
results.push(item);
|
|
123
|
+
added += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(data) || !items.length) break;
|
|
127
|
+
if (data.total_pages !== undefined && page >= Number(data.total_pages)) break;
|
|
128
|
+
if (data.total !== undefined && results.length >= Number(data.total)) break;
|
|
129
|
+
if (!added) throw new CliError('INVALID_PAGINATION', '分页重复返回相同记录,无法读取完整列表。');
|
|
130
|
+
if (data.total_pages === undefined && data.total === undefined) break;
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
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('--notes <text>', '内部备注,可传空字符串')
|
|
60
|
+
.option('--retention-hours <hours>', '留存小时数 24/48/72/168/720')
|
|
61
|
+
.option('--draft', '保存为草稿')
|
|
62
|
+
.option('--submit', '保存后提交给发布人员');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function accountOptions(command) {
|
|
66
|
+
return command.option('--account <id-or-name>', '账号 ID、显示名或 Reddit 用户名')
|
|
67
|
+
.option('--subreddit <name>', '社区名称,支持 r/ 前缀')
|
|
68
|
+
.option('--group <group>', '账号来源 current/pool')
|
|
69
|
+
.option('--weight <number>', '社区权重阈值 5/10/20/30');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function listTasks(api, options = {}) {
|
|
73
|
+
return api.request('GET', '/api/tasks', { query: {
|
|
74
|
+
page: positiveInteger(options.page || 1, '页码'), view: options.view || 'mine',
|
|
75
|
+
client_id: options.clientId && positiveInteger(options.clientId, '客户 ID'),
|
|
76
|
+
task_type: options.type, status: options.status, q: options.search,
|
|
77
|
+
} });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function menu(api, questions, options) {
|
|
81
|
+
for (;;) {
|
|
82
|
+
const action = await questions.choice('action', '选择操作', undefined, [
|
|
83
|
+
{ name: '录入帖子', value: 'post' },
|
|
84
|
+
{ name: '录入独立评论', value: 'comment' },
|
|
85
|
+
{ name: '查看客户', value: 'clients' },
|
|
86
|
+
{ name: '查看账号', value: 'accounts' },
|
|
87
|
+
{ name: '查看我的任务', value: 'tasks' },
|
|
88
|
+
{ name: '编辑任务原稿', value: 'edit' },
|
|
89
|
+
{ name: '提交任务', value: 'submit' },
|
|
90
|
+
{ name: '退出', value: 'exit' },
|
|
91
|
+
]);
|
|
92
|
+
if (action === 'exit') return { logged_out: true };
|
|
93
|
+
try {
|
|
94
|
+
let result;
|
|
95
|
+
if (['post', 'comment'].includes(action)) {
|
|
96
|
+
result = await executePlan(api, await prepareCreate(api, questions, action, {}), { dryRun: options.dryRun });
|
|
97
|
+
} else if (action === 'clients') {
|
|
98
|
+
result = await taskClients(api);
|
|
99
|
+
} else if (action === 'accounts') {
|
|
100
|
+
result = await api.request('GET', '/api/accounts');
|
|
101
|
+
} else if (action === 'tasks') {
|
|
102
|
+
result = await listTasks(api);
|
|
103
|
+
} else {
|
|
104
|
+
const id = await questions.text('task-id', '任务 ID', undefined);
|
|
105
|
+
result = action === 'edit'
|
|
106
|
+
? await executePlan(api, await prepareEdit(api, questions, id, {}), { dryRun: options.dryRun })
|
|
107
|
+
: await submitTask(api, await getTask(api, id), options.dryRun);
|
|
108
|
+
}
|
|
109
|
+
print(result, options);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (error.details?.status === 401 || error.name === 'ExitPromptError') throw error;
|
|
112
|
+
printError(error, options);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function printError(error, options) {
|
|
118
|
+
const result = { error: { code: error.code || 'ERROR', message: error.message, ...error.details } };
|
|
119
|
+
process.stderr.write(JSON.stringify(redact(result), null, options.json ? undefined : 2) + '\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildProgram({ jsonOutput = false } = {}) {
|
|
123
|
+
const program = new Command().name('owned-pool').version(packageInfo.version)
|
|
124
|
+
.description('自有账号池 CLI,登录后交互录入帖子、评论并管理登记任务。')
|
|
125
|
+
.option('--base-url <url>', '系统根地址,默认 HANDOVER_BASE_URL 或 https://handover.luckyjun.top')
|
|
126
|
+
.option('--username <username>', '系统登录名,也可设置 HANDOVER_USERNAME')
|
|
127
|
+
.option('--non-interactive', '脚本模式,缺失数据以 MISSING_INPUT 返回')
|
|
128
|
+
.option('--json', '机器可读 JSON 输出,提示与错误写入 stderr')
|
|
129
|
+
.option('--dry-run', '收集并预览录入请求')
|
|
130
|
+
.showHelpAfterError();
|
|
131
|
+
program.exitOverride();
|
|
132
|
+
if (jsonOutput) program.configureOutput({ writeErr: () => {} });
|
|
133
|
+
authenticated(program, menu);
|
|
134
|
+
authenticated(program.command('login').description('验证账号密码及当前身份'), async api => ({
|
|
135
|
+
authenticated: true, member: api.member,
|
|
136
|
+
}));
|
|
137
|
+
authenticated(program.command('clients').description('列出任务客户'), taskClients);
|
|
138
|
+
authenticated(program.command('accounts').description('列出账号池'), api => api.request('GET', '/api/accounts'));
|
|
139
|
+
authenticated(accountOptions(program.command('candidates').description('查询指定客户与社区的候选账号'))
|
|
140
|
+
.option('--client <id-or-name>', '客户 ID、名称或编号')
|
|
141
|
+
.option('--kind <kind>', 'post/comment'), async (api, questions, options) => {
|
|
142
|
+
const client = await chooseClient(api, questions, options.client);
|
|
143
|
+
const kind = await questions.choice('kind', '内容类型', options.kind, [
|
|
144
|
+
{ name: '帖子', value: 'post' }, { name: '评论', value: 'comment' },
|
|
145
|
+
]);
|
|
146
|
+
return candidates(api, client.id, kind, await candidateQuery(questions, options));
|
|
147
|
+
});
|
|
148
|
+
for (const kind of ['post', 'comment']) {
|
|
149
|
+
const group = program.command(kind).description(kind === 'post' ? '帖子录入' : '独立评论录入');
|
|
150
|
+
const create = accountOptions(contentOptions(group.command('create').description('选择客户、账号并录入任务')))
|
|
151
|
+
.option('--client <id-or-name>', '客户 ID、名称或编号');
|
|
152
|
+
if (kind === 'post') create.option('--title <text>', '帖子标题').option('--purpose <purpose>', 'external_original/community_internal');
|
|
153
|
+
if (kind === 'comment') create.option('--target-url <url>', '回复的帖子或评论链接').option('--target-context <text>', '目标原文摘录');
|
|
154
|
+
authenticated(create, async (api, questions, options) =>
|
|
155
|
+
executePlan(api, await prepareCreate(api, questions, kind, options), { dryRun: options.dryRun }));
|
|
156
|
+
}
|
|
157
|
+
const tasks = program.command('tasks').description('任务查询、原稿编辑、提交与失败恢复');
|
|
158
|
+
authenticated(tasks.command('list').description('分页查询任务')
|
|
159
|
+
.option('--page <number>', '页码', '1').option('--view <view>', 'mine/all', 'mine')
|
|
160
|
+
.option('--client-id <id>', '客户 ID').option('--type <type>', 'post/comment/followup')
|
|
161
|
+
.option('--status <status>', '任务状态').option('--search <text>', '搜索关键词'),
|
|
162
|
+
(api, questions, options) => listTasks(api, options));
|
|
163
|
+
authenticated(tasks.command('show [id]').description('查询任务详情'), async (api, questions, options, id) =>
|
|
164
|
+
getTask(api, await questions.text('task-id', '任务 ID', id)));
|
|
165
|
+
authenticated(accountOptions(contentOptions(tasks.command('edit [id]').description('编辑原稿,保留现有素材及跟帖')))
|
|
166
|
+
.option('--title <text>', '修改帖子标题').option('--target-context <text>', '目标原文摘录'),
|
|
167
|
+
async (api, questions, options, id) => executePlan(api, await prepareEdit(api, questions,
|
|
168
|
+
await questions.text('task-id', '任务 ID', id), options), { dryRun: options.dryRun }));
|
|
169
|
+
authenticated(tasks.command('submit [id]').description('将现有原稿提交给发布人员'), async (api, questions, options, id) =>
|
|
170
|
+
submitTask(api, await getTask(api, await questions.text('task-id', '任务 ID', id)), options.dryRun));
|
|
171
|
+
authenticated(tasks.command('resume [file]').description('根据恢复文件继续未完成录入')
|
|
172
|
+
.option('--task-id <id>', '创建结果未知时,指定已创建的任务 ID'),
|
|
173
|
+
async (api, questions, options, file) => resumePlan(api, questions,
|
|
174
|
+
await questions.text('recovery-file', '恢复文件路径', file), options));
|
|
175
|
+
program.command('update-check').description('检查 npm 更新,发现新版本时提醒').action(async (_options, command) => {
|
|
176
|
+
const result = await checkUpdate();
|
|
177
|
+
if (command.optsWithGlobals().json) print(result, { json: true });
|
|
178
|
+
else process.stdout.write(updateNotice(result));
|
|
179
|
+
});
|
|
180
|
+
const skill = program.command('skill').description('配套 AI skill');
|
|
181
|
+
skill.command('path').description('输出 npm 包内的 skill 路径').action(() => process.stdout.write(skillDirectory + '\n'));
|
|
182
|
+
skill.command('install').description('安装或更新配套 skill')
|
|
183
|
+
.option('--agent <agent>', 'codex/claude').option('--dir <directory>', '自定义 skill 目标目录')
|
|
184
|
+
.action(async (_options, command) => {
|
|
185
|
+
const options = command.optsWithGlobals();
|
|
186
|
+
const questions = new Questions(options);
|
|
187
|
+
const agent = options.dir ? undefined : await questions.choice('agent', '安装到哪个 AI 工具', options.agent, [
|
|
188
|
+
{ name: 'Codex', value: 'codex' }, { name: 'Claude Code', value: 'claude' },
|
|
189
|
+
]);
|
|
190
|
+
print(await installSkill(agent, options.dir), options);
|
|
191
|
+
});
|
|
192
|
+
return program;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function main(argv = process.argv) {
|
|
196
|
+
const jsonOutput = argv.includes('--json');
|
|
197
|
+
const program = buildProgram({ jsonOutput });
|
|
198
|
+
try {
|
|
199
|
+
await program.parseAsync(argv);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if (['commander.helpDisplayed', 'commander.help', 'commander.version'].includes(error.code)) return;
|
|
202
|
+
if (error.name === 'ExitPromptError') {
|
|
203
|
+
process.stderr.write('已取消。\n');
|
|
204
|
+
process.exitCode = 130;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (error.code?.startsWith('commander.')) {
|
|
208
|
+
if (jsonOutput) printError(error, { json: true });
|
|
209
|
+
process.exitCode = error.exitCode || 1;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!(error instanceof CliError)) error = new CliError('LOCAL_ERROR', error.message);
|
|
213
|
+
printError(error, program.opts());
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
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/prompts.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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) {
|
|
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 });
|
|
76
|
+
this.requireInteractive('body', '正文(--body 或 --body-file)');
|
|
77
|
+
const mode = await this.choice('body-mode', '正文输入方式', undefined, [
|
|
78
|
+
{ name: '输入一段文字', value: 'input' },
|
|
79
|
+
{ name: '读取 UTF-8 / Markdown 文件', value: 'file' },
|
|
80
|
+
{ name: '打开系统编辑器,编辑多行正文', value: 'editor' },
|
|
81
|
+
]);
|
|
82
|
+
if (mode === 'file') {
|
|
83
|
+
const path = await this.text('body-file', '正文文件路径', undefined);
|
|
84
|
+
return this.body(undefined, path.trim().replace(/^"|"$/g, ''));
|
|
85
|
+
}
|
|
86
|
+
if (mode === 'editor') {
|
|
87
|
+
return editor({
|
|
88
|
+
message: '编辑正文并保存退出', default: defaultValue ?? '', postfix: '.md',
|
|
89
|
+
validate: content => content.trim() && content.length <= 40000 ? true : '正文须为 1–40000 个字符。',
|
|
90
|
+
}, this.context);
|
|
91
|
+
}
|
|
92
|
+
return this.text('body', '正文', undefined, { max: 40000, defaultValue });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function positiveInteger(value, label = 'ID') {
|
|
97
|
+
assert(/^\d+$/.test(String(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0,
|
|
98
|
+
'INVALID_INPUT', `${label}须为正整数。`);
|
|
99
|
+
return Number(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeSubreddit(value) {
|
|
103
|
+
const result = value.trim().replace(/^\/?r\//i, '').replace(/\/$/, '').toLowerCase();
|
|
104
|
+
assert(/^[a-z0-9_]{1,50}$/.test(result), 'INVALID_INPUT', '请填写社区名称,例如 r/example。');
|
|
105
|
+
return result;
|
|
106
|
+
}
|
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,276 @@
|
|
|
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
|
+
|
|
10
|
+
const PURPOSES = [
|
|
11
|
+
{ name: '外部原创', value: 'external_original' },
|
|
12
|
+
{ name: '社区内部', value: 'community_internal' },
|
|
13
|
+
];
|
|
14
|
+
const RETENTION = [24, 48, 72, 168, 720].map(value => ({ name: `${value} 小时`, value }));
|
|
15
|
+
const WEIGHTS = [5, 10, 20, 30].map(value => ({ name: String(value), value }));
|
|
16
|
+
const GROUPS = [
|
|
17
|
+
{ name: '客户账号', value: 'current' },
|
|
18
|
+
{ name: '公用账号池', value: 'pool' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function taskOf(result) {
|
|
22
|
+
const task = result?.task ?? result;
|
|
23
|
+
assert(task?.id !== undefined && Number.isInteger(task.version),
|
|
24
|
+
'INVALID_RESPONSE', '任务响应缺少 id 或 version。');
|
|
25
|
+
return task;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function getTask(api, id) {
|
|
29
|
+
return taskOf(await api.request('GET', `/api/tasks/${positiveInteger(id, '任务 ID')}`));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function taskClients(api) {
|
|
33
|
+
return itemsOf(await api.request('GET', '/api/tasks/clients'), 'clients');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function chooseClient(api, questions, value) {
|
|
37
|
+
return questions.record('client', '选择客户', value, await taskClients(api), {
|
|
38
|
+
id: item => item.id,
|
|
39
|
+
name: item => `${item.name} [${item.id}]${item.code ? ` · ${item.code}` : ''}`,
|
|
40
|
+
aliases: item => [item.name, item.code].filter(Boolean),
|
|
41
|
+
disabled: item => item.is_archived ? '客户已归档' :
|
|
42
|
+
('upstream_client_id' in item && !item.upstream_client_id ? '客户等待管理员绑定上游客户' : false),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function candidateQuery(questions, options, subreddit) {
|
|
47
|
+
return {
|
|
48
|
+
subreddit: normalizeSubreddit(await questions.text('subreddit', '目标社区', subreddit ?? options.subreddit)),
|
|
49
|
+
group: await questions.choice('group', '账号来源', options.group, GROUPS, 'current'),
|
|
50
|
+
weight: await questions.choice('weight', '账号社区权重阈值', options.weight, WEIGHTS, 5),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function candidates(api, clientId, kind, query) {
|
|
55
|
+
return allPages(api, '/api/tasks/candidates', { client_id: clientId, kind, ...query });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function chooseCandidate(api, questions, options, clientId, kind, subreddit) {
|
|
59
|
+
const query = await candidateQuery(questions, options, subreddit);
|
|
60
|
+
const accounts = await candidates(api, clientId, kind, query);
|
|
61
|
+
const account = await questions.record('account', '选择账号', options.account, accounts, {
|
|
62
|
+
id: item => item.id,
|
|
63
|
+
name: item => `${item.display_name || item.id} · u/${item.reddit_username || '?'} [${item.id}]` +
|
|
64
|
+
(item.unavailable_reason ? ` · ${item.unavailable_reason}` : ''),
|
|
65
|
+
aliases: item => [item.display_name, item.reddit_username, `u/${item.reddit_username}`].filter(Boolean),
|
|
66
|
+
disabled: item => !item.selectable ? item.unavailable_reason || '账号当前不可用' : false,
|
|
67
|
+
});
|
|
68
|
+
return { account_id: String(account.id), subreddit: query.subreddit, weight: query.weight };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function submissionChoice(questions, options) {
|
|
72
|
+
assert(!(options.submit && options.draft), 'INVALID_INPUT', '--submit 与 --draft 只能选择一项。');
|
|
73
|
+
let mode;
|
|
74
|
+
if (options.submit) mode = 'submit';
|
|
75
|
+
if (options.draft) mode = 'draft';
|
|
76
|
+
return questions.choice('mode', '录入完成后', mode, [
|
|
77
|
+
{ name: '保存为草稿', value: 'draft' },
|
|
78
|
+
{ name: '提交给发布人员', value: 'submit' },
|
|
79
|
+
], 'draft');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function prepareCreate(api, questions, kind, options) {
|
|
83
|
+
const client = await chooseClient(api, questions, options.client);
|
|
84
|
+
let target;
|
|
85
|
+
if (kind === 'comment') {
|
|
86
|
+
const url = await questions.text('target-url', '回复目标帖子或评论 URL', options.targetUrl);
|
|
87
|
+
target = await api.request('GET', '/api/tasks/target', { query: { url: url.trim() } });
|
|
88
|
+
assert(target?.url && target?.subreddit, 'INVALID_RESPONSE', '回复目标解析缺少 url 或 subreddit。');
|
|
89
|
+
if (options.subreddit) {
|
|
90
|
+
assert(normalizeSubreddit(options.subreddit) === normalizeSubreddit(target.subreddit),
|
|
91
|
+
'INVALID_INPUT', '指定社区与回复目标所属社区不一致。');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const purpose = kind === 'post'
|
|
95
|
+
? await questions.choice('purpose', '帖子用途', options.purpose, PURPOSES) : null;
|
|
96
|
+
const candidate = await chooseCandidate(api, questions, options, client.id, kind, target?.subreddit);
|
|
97
|
+
const title = kind === 'post'
|
|
98
|
+
? await questions.text('title', '帖子标题', options.title, { max: 300 }) : '';
|
|
99
|
+
const body = await questions.body(options.body, options.bodyFile);
|
|
100
|
+
const notes = await questions.text('notes', '内部备注(可留空)', options.notes, { optional: true, max: 5000 });
|
|
101
|
+
const targetContext = kind === 'comment'
|
|
102
|
+
? await questions.text('target-context', '目标原文摘录(可留空)', options.targetContext, { optional: true, max: 20000 }) : '';
|
|
103
|
+
const retention = await questions.choice('retention-hours', '留存要求', options.retentionHours, RETENTION);
|
|
104
|
+
const mode = await submissionChoice(questions, options);
|
|
105
|
+
const accountMode = kind === 'comment' ? { account_mode: 'owned' } : {};
|
|
106
|
+
return {
|
|
107
|
+
schema: 1, base_url: api.baseUrl, task_id: null, expected_version: null, mode,
|
|
108
|
+
create: {
|
|
109
|
+
client_id: positiveInteger(client.id, '客户 ID'), kind, parent_task_id: null,
|
|
110
|
+
follow_op: false, post_purpose: purpose, ...accountMode,
|
|
111
|
+
target_url: target?.url || '', candidates: [candidate],
|
|
112
|
+
},
|
|
113
|
+
registration: {
|
|
114
|
+
content: { title, body, notes, target_context: targetContext, assets: [] },
|
|
115
|
+
retention_hours: retention, ...accountMode, candidates: [candidate], followups: [],
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function editableFollowups(task, member) {
|
|
121
|
+
return (task.children || []).filter(child => ['draft', 'returned'].includes(child.status) &&
|
|
122
|
+
(member?.role === 'admin' || String(child.creator_id) === String(member?.id))).map(child => ({
|
|
123
|
+
id: child.id, version: child.version, content: child.source,
|
|
124
|
+
follow_op: child.follow_op, account_mode: child.account_mode,
|
|
125
|
+
candidates: (child.candidates || []).map(candidateFields), removed: Boolean(child.removed),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function candidateFields(candidate) {
|
|
130
|
+
return { account_id: String(candidate.account_id), subreddit: candidate.subreddit, weight: candidate.weight };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function prepareEdit(api, questions, id, options) {
|
|
134
|
+
const task = await getTask(api, id);
|
|
135
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '原稿编辑适用于 draft 或 returned 任务。');
|
|
136
|
+
const content = { title: '', body: '', notes: '', target_context: '', assets: [], ...task.source };
|
|
137
|
+
if (task.kind === 'post' && (options.title !== undefined || !content.title)) {
|
|
138
|
+
content.title = await questions.text('title', '帖子标题', options.title, { max: 300 });
|
|
139
|
+
}
|
|
140
|
+
if (options.body !== undefined || options.bodyFile || !content.body || questions.interactive) {
|
|
141
|
+
content.body = await questions.body(options.body, options.bodyFile, content.body);
|
|
142
|
+
}
|
|
143
|
+
content.notes = await questions.text('notes', '内部备注(可留空)', options.notes, {
|
|
144
|
+
optional: true, max: 5000, defaultValue: content.notes,
|
|
145
|
+
});
|
|
146
|
+
if (options.targetContext !== undefined) {
|
|
147
|
+
content.target_context = await questions.text('target-context', '目标原文摘录', options.targetContext, {
|
|
148
|
+
optional: true, max: 20000,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
let chosenCandidates = (task.candidates || []).map(candidateFields);
|
|
152
|
+
const needsCandidate = !chosenCandidates.length && (task.account_mode === 'owned' || task.kind === 'post');
|
|
153
|
+
const changesCandidate = ['account', 'subreddit', 'group', 'weight'].some(key => options[key] !== undefined);
|
|
154
|
+
if (changesCandidate || needsCandidate) {
|
|
155
|
+
const subreddit = task.kind === 'comment'
|
|
156
|
+
? task.target?.subreddit || chosenCandidates[0]?.subreddit || options.subreddit
|
|
157
|
+
: options.subreddit || chosenCandidates[0]?.subreddit;
|
|
158
|
+
chosenCandidates = [await chooseCandidate(api, questions, options, task.client_id, task.kind, subreddit)];
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
schema: 1, base_url: api.baseUrl, task_id: task.id, expected_version: task.version,
|
|
162
|
+
mode: await submissionChoice(questions, options),
|
|
163
|
+
registration: {
|
|
164
|
+
content,
|
|
165
|
+
retention_hours: await questions.choice('retention-hours', '留存要求',
|
|
166
|
+
options.retentionHours ?? task.retention_hours, RETENTION),
|
|
167
|
+
...(task.kind === 'comment' && !task.parent_task_id ? { account_mode: task.account_mode } : {}),
|
|
168
|
+
candidates: chosenCandidates, followups: editableFollowups(task, api.member),
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function writeRecovery(path, plan) {
|
|
174
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
175
|
+
try {
|
|
176
|
+
await writeFile(temporary, JSON.stringify(plan, null, 2), { mode: 0o600 });
|
|
177
|
+
await rename(temporary, path);
|
|
178
|
+
} finally {
|
|
179
|
+
await unlink(temporary).catch(() => {});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function registrationMatches(task, registration) {
|
|
184
|
+
const source = task.source || {};
|
|
185
|
+
const contentMatches = Object.entries(registration.content).every(([key, value]) =>
|
|
186
|
+
isDeepStrictEqual(source[key], value));
|
|
187
|
+
return contentMatches && task.retention_hours === registration.retention_hours &&
|
|
188
|
+
isDeepStrictEqual((task.candidates || []).map(candidateFields), registration.candidates) &&
|
|
189
|
+
(registration.account_mode === undefined || task.account_mode === registration.account_mode) &&
|
|
190
|
+
registration.followups.every(child => {
|
|
191
|
+
const current = task.children?.find(item => item.id === child.id);
|
|
192
|
+
return current && isDeepStrictEqual(current.source, child.content) &&
|
|
193
|
+
Boolean(current.removed) === child.removed && current.follow_op === child.follow_op &&
|
|
194
|
+
current.account_mode === child.account_mode &&
|
|
195
|
+
isDeepStrictEqual((current.candidates || []).map(candidateFields), child.candidates);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function submitTask(api, task, dryRun = false) {
|
|
200
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '可提交的原稿状态为 draft 或 returned。');
|
|
201
|
+
const body = {
|
|
202
|
+
version: task.version,
|
|
203
|
+
children: (task.children || []).filter(child => ['draft', 'returned'].includes(child.status))
|
|
204
|
+
.map(child => ({ id: child.id, version: child.version })),
|
|
205
|
+
};
|
|
206
|
+
if (dryRun) return { dry_run: true, method: 'POST', path: `/api/tasks/${task.id}/registration/submit`, body };
|
|
207
|
+
return taskOf(await api.request('POST', `/api/tasks/${task.id}/registration/submit`, { body }));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function executePlan(api, plan, { recoveryPath, recoveryDir, resume = false, dryRun = false } = {}) {
|
|
211
|
+
if (dryRun) return { dry_run: true, plan };
|
|
212
|
+
const directory = recoveryDir || join(homedir(), '.owned-account-pool', 'recovery');
|
|
213
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
214
|
+
const path = recoveryPath || join(directory, `${randomUUID()}.recovery.json`);
|
|
215
|
+
let task;
|
|
216
|
+
try {
|
|
217
|
+
await writeRecovery(path, plan);
|
|
218
|
+
if (!plan.task_id) {
|
|
219
|
+
assert(!resume, 'MISSING_INPUT', '创建结果未知,请先查询任务列表,再通过 --task-id 指定已创建的任务。', { field: 'task-id' });
|
|
220
|
+
task = taskOf(await api.request('POST', '/api/tasks', { body: plan.create }));
|
|
221
|
+
plan.task_id = task.id;
|
|
222
|
+
plan.expected_version = task.version;
|
|
223
|
+
await writeRecovery(path, plan);
|
|
224
|
+
} else {
|
|
225
|
+
task = await getTask(api, plan.task_id);
|
|
226
|
+
}
|
|
227
|
+
if (plan.create) {
|
|
228
|
+
assert(task.client_id === plan.create.client_id && task.kind === plan.create.kind,
|
|
229
|
+
'RECOVERY_MISMATCH', '恢复任务的客户或类型与原录入内容不一致。');
|
|
230
|
+
}
|
|
231
|
+
const alreadySaved = registrationMatches(task, plan.registration);
|
|
232
|
+
if (!alreadySaved) {
|
|
233
|
+
assert(['draft', 'returned'].includes(task.status), 'INVALID_TASK_STATE', '任务当前状态不支持保存原稿。');
|
|
234
|
+
assert(task.version === plan.expected_version, 'VERSION_CONFLICT',
|
|
235
|
+
'任务版本已变化。录入内容保存在恢复文件中,请查询任务并合并修改。', { current_version: task.version });
|
|
236
|
+
task = taskOf(await api.request('PUT', `/api/tasks/${task.id}/registration`, {
|
|
237
|
+
body: { version: plan.expected_version, ...plan.registration },
|
|
238
|
+
}));
|
|
239
|
+
plan.expected_version = task.version;
|
|
240
|
+
await writeRecovery(path, plan);
|
|
241
|
+
}
|
|
242
|
+
if (plan.mode === 'submit' && ['draft', 'returned'].includes(task.status)) {
|
|
243
|
+
task = await submitTask(api, task);
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!(error instanceof CliError)) error = new CliError('LOCAL_ERROR', error.message);
|
|
247
|
+
error.details = { ...error.details, task_id: plan.task_id, recovery_file: path,
|
|
248
|
+
next_command: `owned-pool tasks resume "${path}"` };
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await unlink(path);
|
|
253
|
+
} catch {
|
|
254
|
+
return { task_id: task.id, status: task.status, version: task.version,
|
|
255
|
+
recovery_file: path, warning: '操作成功,恢复文件清理失败,可手动删除。' };
|
|
256
|
+
}
|
|
257
|
+
return { task_id: task.id, status: task.status, version: task.version };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function resumePlan(api, questions, file, options) {
|
|
261
|
+
const path = resolve(file);
|
|
262
|
+
const plan = JSON.parse(await readFile(path, 'utf8'));
|
|
263
|
+
assert(plan.schema === 1 && plan.registration?.content && Array.isArray(plan.registration.candidates) &&
|
|
264
|
+
Array.isArray(plan.registration.followups) && ['draft', 'submit'].includes(plan.mode),
|
|
265
|
+
'INVALID_RECOVERY', '恢复文件格式无效。');
|
|
266
|
+
assert(plan.base_url === api.baseUrl, 'RECOVERY_MISMATCH', '恢复文件所属服务与当前 --base-url 不一致。');
|
|
267
|
+
if (!plan.task_id) {
|
|
268
|
+
plan.task_id = positiveInteger(await questions.text('task-id',
|
|
269
|
+
'创建结果未知,请查询任务列表并填写对应任务 ID', options.taskId), '任务 ID');
|
|
270
|
+
const task = await getTask(api, plan.task_id);
|
|
271
|
+
assert(!task.source?.body || registrationMatches(task, plan.registration),
|
|
272
|
+
'RECOVERY_MISMATCH', '指定任务已有其他正文,请核对任务 ID 并通过 tasks edit 合并修改。');
|
|
273
|
+
plan.expected_version = task.version;
|
|
274
|
+
}
|
|
275
|
+
return executePlan(api, plan, { recoveryPath: path, resume: true, dryRun: options.dryRun });
|
|
276
|
+
}
|
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
|
+
}
|