opencode-claude-code-ua 0.3.6 → 0.3.9
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 +94 -90
- package/index.mjs +2 -6
- package/lib/ua-protocol.mjs +8 -0
- package/package.json +1 -1
- package/setup.mjs +1 -1
- package/src/account-usage.mjs +33 -0
- package/src/accounts.mjs +96 -23
- package/src/key-usage.mjs +73 -0
- package/src/newapi.mjs +106 -13
- package/src/plugin.mjs +4 -34
- package/src/scheduler.mjs +2 -2
- package/src/setup-server.mjs +16 -3
- package/src/ua-plugin.mjs +57 -14
- package/src/ua-runtime.mjs +1 -1
- package/src/ua-service.mjs +38 -6
- package/src/ua-store.mjs +4 -1
- package/src/ua-usage.mjs +64 -0
- package/ui/accounts.css +1 -1
- package/ui/accounts.html +21 -11
- package/ui/accounts.js +230 -26
- package/ui/pro.css +1 -0
- package/ui/pro.html +10 -11
- package/ui/pro.js +82 -12
package/README.md
CHANGED
|
@@ -1,65 +1,70 @@
|
|
|
1
1
|
# UA PRO
|
|
2
2
|
|
|
3
|
-
Magpie
|
|
3
|
+
独立的 Magpie 出站请求配置插件,内置 **Codex / Claude Code 请求模板**,支持 Responses、Anthropic Messages、Chat Completions 和 Gemini。模板随插件提供,可按连接或模型应用,支持版本保存、测试与回退。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
npm 包名:[`opencode-claude-code-ua`](https://www.npmjs.com/package/opencode-claude-code-ua)。供应商 ID:`ua-pro`。无 npm 运行依赖。
|
|
6
|
+
|
|
7
|
+
> 当前源码版本为 **0.3.9**,尚未发布到 npm。通过 npm 安装获取的是当前已发布版本。
|
|
6
8
|
|
|
7
9
|
## 安装与打开管理页
|
|
8
10
|
|
|
9
|
-
在 Magpie「插件 →
|
|
11
|
+
在 Magpie「插件 → 添加插件」中填写:
|
|
10
12
|
|
|
11
13
|
```text
|
|
12
|
-
opencode-claude-code-ua
|
|
14
|
+
opencode-claude-code-ua
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
或使用终端:
|
|
16
18
|
|
|
17
19
|
```sh
|
|
18
|
-
magpie plugin add
|
|
20
|
+
magpie plugin add opencode-claude-code-ua
|
|
19
21
|
magpie plugin login ua-pro
|
|
20
22
|
```
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
选择 **打开 UA PRO 管理**,已有账号时也可再次执行登录命令。
|
|
25
|
+
|
|
26
|
+
在 **管理连接**中查看、编辑已保存连接;它打开“连接与模型”,不执行模型请求。从 Magpie 打开管理页保存普通连接后,连接显示在 **UA PRO → 账号列表**中,而不是新增一个同名供应商。概览会显示实际注册状态。
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
若连接由独立 `node setup.mjs` 保存,需从 Magpie 打开管理页,点击 **同步到宿主**,再刷新供应商的模型列表。只有勾选独立显示才会有单独供应商;新增独立站点保存后,需要重载 UA PRO 插件。
|
|
29
|
+
|
|
30
|
+
也支持 GitHub 源码来源。在 Magpie 的手动添加框填写,或使用终端:
|
|
25
31
|
|
|
26
32
|
```sh
|
|
27
|
-
magpie plugin add "github:1466109846/ua-pro"
|
|
33
|
+
magpie plugin add "github:1466109846/ua-pro#main"
|
|
28
34
|
```
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
**发现页的 GitHub 卡片不一定安装源码**:当仓库对应的 npm 包存在时,Magpie 会优先安装 npm 的 `latest`。若 npm 尚未发布新版本,反复卸载重装该卡片仍会得到相同版本。使用上面的 `#main` 来源可明确选择 GitHub 默认分支。
|
|
37
|
+
|
|
38
|
+
npm 与 GitHub 来源二选一,不要重复安装。管理页侧栏显示实际运行版本,用于核对安装是否更新。
|
|
31
39
|
|
|
32
|
-
###
|
|
40
|
+
### 独立打开管理页
|
|
33
41
|
|
|
34
|
-
需要 Node.js 22
|
|
42
|
+
需要 Node.js 22 或以上。在下载的插件目录运行:
|
|
35
43
|
|
|
36
44
|
```sh
|
|
37
45
|
node setup.mjs
|
|
38
46
|
```
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
打开终端输出的本机链接。管理服务仅监听回环地址,链接只在本次会话有效。保存连接后,在 Magpie 重载插件;两处需要使用同一个 UA 状态文件。
|
|
41
49
|
|
|
42
50
|
## 快速配置
|
|
43
51
|
|
|
44
|
-
1. 打开
|
|
45
|
-
2. 点击
|
|
46
|
-
3.
|
|
47
|
-
4.
|
|
48
|
-
5.
|
|
49
|
-
6. 在 Magpie 重新获取模型或重载插件,让宿主载入保存的连接和路由。
|
|
52
|
+
1. 打开 **连接与模型 → 添加连接**,填写名称、上游 Base URL、认证方式和 API key。
|
|
53
|
+
2. 点击 **获取模型**,选择需要的模型,追加或替换到列表,再保存连接。
|
|
54
|
+
3. 使用自动模板,或在 **应用模板**中选择一个已保存的模板版本。
|
|
55
|
+
4. 打开 **测试**,检查最终地址、模型、协议和模板,再确认发送请求。
|
|
56
|
+
5. 在 Magpie 重新获取模型或重载插件,载入保存的连接与路由。
|
|
50
57
|
|
|
51
|
-
|
|
58
|
+
客户端连接 **Magpie 本地网关**,使用 Magpie 网关密钥,不是上游 API key。模型引用可从测试页复制:
|
|
52
59
|
|
|
53
60
|
- 主池:`ua-pro/<模型 ID>`。
|
|
54
61
|
- 独立站点:`ua-pro-<siteId>/<模型 ID>`。
|
|
55
62
|
|
|
56
|
-
|
|
63
|
+
勾选 **在 Magpie 中独立显示此站点**并填写站点 ID,可将连接显示为独立供应商。最多 8 个独立站点,每个站点可配置多个账号;站点增删后需要重载插件。
|
|
57
64
|
|
|
58
65
|
## 连接与模型
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。插件去除末尾版本段,再按协议拼接路径;自定义根路径可以保留部署前缀,例如 `anthropic: "/anthropic/v1"`。不能填写外域 URL、查询参数或路径穿越。远程地址要求 HTTPS,本机回环可使用 HTTP。
|
|
67
|
+
Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。远程地址要求 HTTPS,本机回环可使用 HTTP。自定义协议根路径可保留部署前缀,例如 `/anthropic/v1`;不要填写外域 URL、查询参数或具体请求路径。
|
|
63
68
|
|
|
64
69
|
| 认证方式 | 发送方式 |
|
|
65
70
|
|---|---|
|
|
@@ -68,125 +73,124 @@ Base URL 可填写站点根地址,或以 `/v1`、`/v1beta` 结尾的地址。
|
|
|
68
73
|
| `custom` | 填写完整 Authorization 值 |
|
|
69
74
|
| `none` | 不发送认证 |
|
|
70
75
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
模型查询只读取列表,不发送生成请求、不改变模板绑定,也不会清空手填模型。追加/替换只修改表单草稿,保存后才持久化。失败、取消和空列表都会保留手填内容;目标或凭据变更会使旧查询结果失效。
|
|
74
|
-
|
|
75
|
-
宿主与管理页共用按目标和凭据隔离的模型发现目录,显示来源和时间。点击 **获取模型**始终尝试实时查询;网络失败时可显示过去 24 小时的同作用域目录,并标明缓存及失败原因。401、403、无效响应或主动取消不会用缓存冒充成功,成功的空列表会替换旧目录。
|
|
76
|
-
|
|
77
|
-
默认多个账号共用 `ua-pro` 主池。勾选独立显示并填写 `siteId` 后,供应商为 `ua-pro-<siteId>`;最多 8 个独立站点槽,每个站点可有多个账号。站点增删需要重载宿主。
|
|
76
|
+
编辑同一地址的连接时,留空 key 可保留已有密钥;更换地址需要重新填写凭据。通过 Magpie 保存的账号凭据由宿主管理,不会复制到模板。
|
|
78
77
|
|
|
79
|
-
|
|
78
|
+
模型查询只读取列表,不发送生成请求。选择模型只修改草稿,保存后才生效;失败、取消或空列表不会清空手填内容。缓存模型会标明来源与时间,不会冒充本次查询成功。
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
每个连接可以设置默认协议、模型级协议覆盖,以及连接级或模型级模板绑定。自动协议是按模型名推断的,不保证上游支持;明确知道接口类型时,应保存固定协议。
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
插件不在失败后自动更换 key、模型或协议。Magpie 自身的路由与 fallback 需单独配置。
|
|
82
|
+
测试页显示 **已保存的生产上游**。仅切换测试协议不会修改连接;需要保存时,点击 **将本次协议与模板保存为此模型路由**。插件不会在请求失败后自动更换 key、模型或协议。
|
|
86
83
|
|
|
87
84
|
## 模板与版本
|
|
88
85
|
|
|
89
|
-
|
|
86
|
+
插件内提供三份初始模板:
|
|
90
87
|
|
|
91
|
-
| 模板 |
|
|
92
|
-
|
|
93
|
-
| Codex |
|
|
94
|
-
| Claude Code |
|
|
95
|
-
| Claude Code VS Code 2.1.316 |
|
|
88
|
+
| 模板 | 用途 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| Codex | 内置只读的 Responses 请求规则 |
|
|
91
|
+
| Claude Code | 内置只读的 Messages 请求规则 |
|
|
92
|
+
| Claude Code VS Code 2.1.316 | 可编辑的 Messages 请求模板 |
|
|
96
93
|
|
|
97
|
-
|
|
94
|
+
自动绑定按最终上游协议选择:Responses → Codex,Messages → Claude Code,Chat / Gemini → 普通转发。
|
|
98
95
|
|
|
99
|
-
|
|
96
|
+
要自定义规则:
|
|
100
97
|
|
|
101
|
-
1. 在 **模板库 →
|
|
102
|
-
2.
|
|
103
|
-
3.
|
|
98
|
+
1. 在 **模板库 → 新增模板**中选择一个模板作为基线。
|
|
99
|
+
2. 修改名称、UA、请求头或正文规则,**保存新版本**。
|
|
100
|
+
3. 预览或测试后,**应用**到指定连接或模型。
|
|
104
101
|
|
|
105
|
-
|
|
102
|
+
保存与应用分开。保存 v2 不影响仍绑定 v1 的连接;回退时选择历史版本重新应用。被引用的版本不能删除,归档不会中断已有绑定。JSON 导入只创建草稿,不自动保存或应用。
|
|
106
103
|
|
|
107
|
-
|
|
104
|
+
请求头支持覆盖、补缺、删除和保留;参数按 `defaults → overrides → remove` 处理。模板不能覆盖 Authorization、API key、Cookie 等认证头,也不执行用户脚本。默认保护模型、消息、工具与流式选择;计数接口不执行生成参数覆盖。
|
|
108
105
|
|
|
109
|
-
|
|
106
|
+
内置模板的会话与正文兼容规则可能影响提示前缀、token 用量或缓存。可复制为自定义模板后查看、修改或关闭。UA 和兼容字段不证明官方客户端身份,也不提供额外账号权限。
|
|
110
107
|
|
|
111
|
-
|
|
108
|
+
## 余额与签到
|
|
112
109
|
|
|
113
|
-
|
|
110
|
+
### 在 Magpie 用量页查看余额
|
|
114
111
|
|
|
115
|
-
|
|
112
|
+
UA PRO 中已配置的连接可以复用模型 Key 查询站点支持的用量接口:
|
|
116
113
|
|
|
117
|
-
|
|
114
|
+
- **NewAPI**:显示 Key 剩余额度;站点明确授权账号级查询时,才显示账号余额。Key 额度不等于账号总余额。
|
|
115
|
+
- **不限额 Key**:不会显示成零余额。站点额度倍率无法确认时,金额会标注估算。
|
|
116
|
+
- **仅支持累计消耗的站点**:显示已用金额与“余额未知”,不把未知余额当成 0。
|
|
118
117
|
|
|
119
|
-
|
|
118
|
+
在 **余额与签到**页点击 **刷新全部余额**,可同时读取站点账号余额和已配置连接的 Key 用量。Key 余额不依赖签到凭据;签到账号缺少凭据、读取失败或被停用时,不会阻止对应连接的 Key 余额查询。两类读数分别展示,不混合汇总。
|
|
120
119
|
|
|
121
|
-
|
|
120
|
+
用量刷新只查询数据,不会自动登录或签到。用量页显示的是添加到 UA PRO 的连接,余额是否可读取取决于站点接口与权限;目前不提供余额趋势图。
|
|
122
121
|
|
|
123
|
-
|
|
122
|
+
### 添加签到账号
|
|
124
123
|
|
|
125
|
-
|
|
124
|
+
打开 **余额与签到 → 添加账号**,选择站点类型:
|
|
126
125
|
|
|
127
|
-
|
|
|
126
|
+
| 站点类型 | 签到所需凭据 |
|
|
128
127
|
|---|---|
|
|
129
|
-
|
|
|
130
|
-
|
|
|
131
|
-
| 实际模型请求 | 明确确认后直测所选连接,支持普通/流式,可能产生费用,不自动回退 |
|
|
128
|
+
| NewAPI | 站点根地址、数字用户 ID、登录 Cookie 或有签到权限的系统访问令牌 |
|
|
129
|
+
| AgentRouter | 站点地址、登录用户名和密码;用户 ID 可留空,由成功登录确认 |
|
|
132
130
|
|
|
133
|
-
|
|
131
|
+
NewAPI 使用站点签到接口。AgentRouter 通过登录领取每日额度;成功返回的 session 仅保存在本机,用于之后的余额查询。模型 API key 不能替代签到账号凭据。
|
|
134
132
|
|
|
135
|
-
|
|
133
|
+
添加账号后:
|
|
136
134
|
|
|
137
|
-
|
|
135
|
+
1. 在 **连接与模型 → 编辑连接 → 余额与签到账号**中选择对应账号并保存。
|
|
136
|
+
2. 重载插件,在 Magpie **用量**页刷新余额或点击 **立即签到**。
|
|
138
137
|
|
|
139
|
-
|
|
138
|
+
绑定不修改模型 Key、协议或模板。同站点、同数字用户 ID 的账号修改签到类型或登录用户名时,已有 PAT/Cookie 和余额记录保留;更换站点或用户 ID 不会沿用原账号凭据。
|
|
140
139
|
|
|
141
|
-
|
|
140
|
+
一般只能绑定同站点账号;AgentRouter 支持 `agentrouter.org` 与 `ps.air-outer.com` 的双地址配置。已删除或异站绑定会提示失效,不会自动换成另一个账号。站点要求验证码时,需前往站点完成验证。
|
|
142
141
|
|
|
143
|
-
|
|
144
|
-
编辑草稿 → 保存模板版本 → 预览/测试 → 应用到连接或模型
|
|
145
|
-
```
|
|
142
|
+
### 每日定时签到
|
|
146
143
|
|
|
147
|
-
|
|
144
|
+
在账号编辑页开启定时签到,设置每天的时间和 IANA 时区,例如 `Asia/Shanghai`。
|
|
148
145
|
|
|
149
|
-
|
|
146
|
+
- 需要 Magpie 插件进程保持运行;关闭管理页面不影响定时任务。
|
|
147
|
+
- 独立运行 `node setup.mjs` 不启动定时签到。
|
|
148
|
+
- 开启定时后仍可手动签到;同日已尝试或领取时,插件定时器不会重复提交。
|
|
149
|
+
- 错过指定分钟不补跑,失败或结果未知不自动重试。
|
|
150
|
+
- 不要同时开启 Magpie 自身的自动签到计划,以免两套计划重复执行。
|
|
150
151
|
|
|
151
|
-
|
|
152
|
+
签到是否领取成功以站点返回为准。余额变化和消耗增量来自两次成功读取,不等于“今日用量”。
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
## 测试请求
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
| 测试层次 | 行为 |
|
|
157
|
+
|---|---|
|
|
158
|
+
| 本地转换预览 | 不联网,展示脱敏后的 URL、请求头、正文和规则变化 |
|
|
159
|
+
| 模型列表检查 | 读取模型目录,不证明模型可以生成回复 |
|
|
160
|
+
| 实际模型请求 | 经确认后发送一次请求,支持普通/流式,可能产生费用 |
|
|
161
|
+
|
|
162
|
+
发送前的确认框展示最终地址、模型、协议和模板。测试路径覆盖只影响本次测试;自定义正文不会因切换模型或协议而被静默覆盖。HTTP 200 中的错误 JSON 或错误 SSE 仍显示为失败。
|
|
156
163
|
|
|
157
164
|
## 数据与安全
|
|
158
165
|
|
|
159
166
|
| 默认文件 | 用途 |
|
|
160
167
|
|---|---|
|
|
161
|
-
| `runtime/ua-pro.json` |
|
|
162
|
-
| `runtime/ua-pro.json.models.json` |
|
|
163
|
-
| `runtime/
|
|
164
|
-
| `runtime/accounts.json` | New API 站点账号 |
|
|
168
|
+
| `runtime/ua-pro.json` | 模板、连接、绑定及本地保存的连接凭据 |
|
|
169
|
+
| `runtime/ua-pro.json.models.json` | 模型目录、来源与时间,不含 key |
|
|
170
|
+
| `runtime/accounts.json` | 站点账号、Cookie 和显式保存的登录凭据 |
|
|
165
171
|
| `runtime/schedule-claims/` | 每日签到防重复记录 |
|
|
166
172
|
|
|
167
|
-
`uaFile` 插件选项或 `UA_PRO_FILE` 环境变量可指定 UA
|
|
168
|
-
|
|
169
|
-
本地凭据以文件权限保护的明文保存,不是加密保险库。POSIX 使用 0600 / 0700,Windows 依赖 NTFS ACL。不要分享或提交 runtime、配置副本或附加秘密请求头。npm 包仅包含运行代码、图标、README 和必要许可证,不含用户数据或开发测试。
|
|
173
|
+
`uaFile` 插件选项或 `UA_PRO_FILE` 环境变量可指定 UA 状态文件的绝对路径。
|
|
170
174
|
|
|
171
|
-
|
|
175
|
+
本地凭据以文件权限保护的明文保存,不是加密保险库。不要分享 `runtime/`、配置副本或含秘密的自定义请求头。仅连接自己拥有或已获授权的 API。
|
|
172
176
|
|
|
173
177
|
## 常见问题
|
|
174
178
|
|
|
175
|
-
|
|
179
|
+
**如何再次打开管理页?**
|
|
176
180
|
|
|
177
|
-
执行 `magpie plugin login ua-pro`,选择 **打开 UA PRO
|
|
181
|
+
执行 `magpie plugin login ua-pro`,选择 **打开 UA PRO 管理**。管理会话过期后需重新打开,刷新页面不能延长授权。
|
|
178
182
|
|
|
179
183
|
**宿主能获取模型,独立管理工具却失败?**
|
|
180
184
|
|
|
181
|
-
|
|
185
|
+
独立 Node 进程不继承 Magpie 账号代理。可从 Magpie 打开管理页,或为独立进程配置网络环境。
|
|
182
186
|
|
|
183
|
-
|
|
187
|
+
**模型列表成功,生成却返回 404?**
|
|
184
188
|
|
|
185
|
-
|
|
189
|
+
检查上游协议、协议根路径和模型引用。目录读取成功不代表站点支持该生成接口。
|
|
186
190
|
|
|
187
|
-
|
|
191
|
+
**保存后为什么没有生效?**
|
|
188
192
|
|
|
189
|
-
|
|
193
|
+
模板要先保存、再应用。连接、站点或模型协议更新后,在 Magpie 重新获取模型或重载插件,并重新打开管理页。
|
|
190
194
|
|
|
191
195
|
## 反馈与许可
|
|
192
196
|
|
package/index.mjs
CHANGED
|
@@ -2,14 +2,10 @@ import { createPlugin } from './src/plugin.mjs'
|
|
|
2
2
|
import { pluginOptions } from './src/options.mjs'
|
|
3
3
|
import { createUAPlugin, createUASite } from './src/ua-plugin.mjs'
|
|
4
4
|
|
|
5
|
-
export default async function
|
|
5
|
+
export default async function UAProPlugin(input, options) {
|
|
6
6
|
if (options !== undefined && (!options || typeof options !== 'object' || Array.isArray(options))) throw new TypeError('Plugin options must be an object')
|
|
7
|
-
|
|
8
|
-
delete legacy.uaFile
|
|
9
|
-
return createPlugin(input, legacy)
|
|
7
|
+
return createUAPlugin(input, options)
|
|
10
8
|
}
|
|
11
|
-
|
|
12
|
-
export const UAProPlugin = async (input, options) => createUAPlugin(input, options)
|
|
13
9
|
export const UAProSite1 = async (input, options) => createUASite(input, options, 0)
|
|
14
10
|
export const UAProSite2 = async (input, options) => createUASite(input, options, 1)
|
|
15
11
|
export const UAProSite3 = async (input, options) => createUASite(input, options, 2)
|
package/lib/ua-protocol.mjs
CHANGED
|
@@ -24,6 +24,14 @@ export function siteRoot(baseURL) {
|
|
|
24
24
|
return url.href.replace(/\/+$/, '').replace(/\/(v1|v1beta)$/, '')
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
export function matchesAccountSite(baseURL, account) {
|
|
28
|
+
const modelRoot = siteRoot(baseURL)
|
|
29
|
+
const accountRoot = siteRoot(account.siteUrl)
|
|
30
|
+
if (modelRoot === accountRoot) return true
|
|
31
|
+
const agentRouterSites = ['https://agentrouter.org', 'https://ps.air-outer.com']
|
|
32
|
+
return account.siteType === 'agentrouter' && agentRouterSites.includes(modelRoot) && agentRouterSites.includes(accountRoot)
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export function apiBase(connection, protocol) {
|
|
28
36
|
const path = connection.apiPaths?.[protocol] ?? PATHS[protocol]
|
|
29
37
|
if (typeof path !== 'string' || !/^\/(?!\/)[A-Za-z0-9/_-]*$/.test(path)) throw err('协议根路径无效')
|
package/package.json
CHANGED
package/setup.mjs
CHANGED
|
@@ -15,7 +15,7 @@ async function main() {
|
|
|
15
15
|
const store = createProfileStore(resolveProfilePath({ configPath: args.configPath }));
|
|
16
16
|
const accountManager = createAccountManager({ path: join(dirname(store.path), 'accounts.json') });
|
|
17
17
|
const uaStore = createUAStore(process.env.UA_PRO_FILE || join(dirname(store.path), 'ua-pro.json'));
|
|
18
|
-
const uaService = createUAService({ store: uaStore, legacyStore: store });
|
|
18
|
+
const uaService = createUAService({ store: uaStore, legacyStore: store, accountManager });
|
|
19
19
|
const session = await startSetupServer({ store, accountManager, uaService, port: args.port, finishOnSave: false });
|
|
20
20
|
console.log(`配置页面:${session.url}\n账号管理:${new URL('accounts', session.url).href}\n配置文件:${store.path}\n页面链接仅本次有效,请勿分享。`);
|
|
21
21
|
if (args.open && !await openBrowser(session.url)) console.log('未找到可用的浏览器,请手动打开上面的本地链接。');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const success = new Set(['success', 'claimed'])
|
|
2
|
+
const done = new Set(['already', 'already_checked', 'checked', 'done'])
|
|
3
|
+
const captcha = new Set(['requires_verification', 'verification_required', 'captcha'])
|
|
4
|
+
const amount = value => Number.isFinite(value) ? value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 }) : '—'
|
|
5
|
+
|
|
6
|
+
export function accountUsage(account) {
|
|
7
|
+
const snapshot = account.snapshot
|
|
8
|
+
const unit = account.unit ?? 'USD'
|
|
9
|
+
const currency = unit.toLowerCase() === 'usd'
|
|
10
|
+
const stale = account.status?.stale === true
|
|
11
|
+
const remaining = snapshot?.remaining
|
|
12
|
+
const used = snapshot?.used
|
|
13
|
+
const total = Number.isFinite(remaining) && Number.isFinite(used) ? remaining + used : null
|
|
14
|
+
return {
|
|
15
|
+
plan: account.siteType === 'agentrouter' ? 'AgentRouter' : 'New API', user: account.name, signIn: 'kept',
|
|
16
|
+
...(Number.isFinite(remaining) ? { balance: `${currency ? '$' : ''}${amount(remaining)}${currency ? '' : ` ${unit}`}${stale ? '(缓存)' : ''}` } : {}),
|
|
17
|
+
...(account.status?.error ? { error: account.status.error } : {}),
|
|
18
|
+
windows: [{ name: account.name, aside: true, unit: currency ? 'usd' : unit,
|
|
19
|
+
...(Number.isFinite(used) ? { amount: used } : {}),
|
|
20
|
+
...(Number.isFinite(total) && total >= 0 ? { limit: total, used: total > 0 ? Math.min(100, Math.max(0, used / total * 100)) : 0 } : {}),
|
|
21
|
+
display: snapshot ? `余额 ${amount(remaining)} ${unit};累计消耗 ${amount(used)};消耗增量 ${amount(snapshot.deltaUsed)};余额变化 ${amount(snapshot.deltaBalance)}${stale ? '(缓存)' : ''}${account.status?.error ? `;${account.status.error}` : ''}` : account.status?.error || '尚未读取余额' }],
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function accountCheckin(account) {
|
|
26
|
+
if (!account?.enabled || !account.checkinEnabled) return { outcome: 'inactive', message: '当前账号未启用签到' }
|
|
27
|
+
const state = account.checkin?.state
|
|
28
|
+
const claimed = success.has(state)
|
|
29
|
+
const outcome = claimed ? 'claimed' : done.has(state) ? 'done' : captcha.has(state) ? 'captcha' : state === 'disabled' ? 'inactive' : 'failed'
|
|
30
|
+
const reward = account.checkin?.reward
|
|
31
|
+
return { outcome, ...(claimed && Number.isFinite(reward) ? { credit: reward } : {}),
|
|
32
|
+
message: `${account.name}:${claimed ? `签到成功${reward == null ? '' : `,奖励 ${reward} ${account.unit}`}` : done.has(state) ? '今日已签到' : captcha.has(state) ? '需要在站点完成验证' : state === 'disabled' ? '站点未开启签到' : `签到失败(${account.checkin?.message || '未完成'})`}` }
|
|
33
|
+
}
|
package/src/accounts.mjs
CHANGED
|
@@ -2,22 +2,33 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { mkdir, open, readFile, rename, unlink, stat } from 'node:fs/promises';
|
|
3
3
|
import { dirname, isAbsolute, resolve } from 'node:path';
|
|
4
4
|
import { AccountValidationError, createNewApiClient, NewApiError, normalizeAccountConfig, safeNewApiMessage } from './newapi.mjs';
|
|
5
|
-
import { scheduleOccurrence } from './schedule.mjs';
|
|
5
|
+
import { scheduleDate, scheduleOccurrence } from './schedule.mjs';
|
|
6
6
|
|
|
7
7
|
export { AccountValidationError } from './newapi.mjs';
|
|
8
8
|
const MAX_ACCOUNTS = 100;
|
|
9
9
|
const MAX_HISTORY = 90;
|
|
10
10
|
const MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
11
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
11
12
|
// All managers sharing a file share the transaction queue and network deduplication.
|
|
12
13
|
const sessions = new Map();
|
|
13
14
|
const freshStatus = () => ({ stale: false, error: null, lastAttemptAt: null });
|
|
14
|
-
const freshCheckin = () => ({ date: null, checkedAt: null, rewardQuota: null, state: 'unknown', message: '' });
|
|
15
|
-
const identity = a => JSON.stringify([a.siteUrl, a.userId]);
|
|
15
|
+
const freshCheckin = () => ({ date: null, checkedAt: null, lastAttemptAt: null, rewardQuota: null, state: 'unknown', message: '' });
|
|
16
|
+
const identity = a => JSON.stringify([a.siteUrl, a.siteType, a.userId || `username:${a.username}`]);
|
|
17
|
+
const balanceIdentity = a => JSON.stringify([a.siteUrl, a.userId || `username:${a.username}`]);
|
|
18
|
+
const loginIdentity = a => JSON.stringify([a.siteUrl, a.siteType, a.userId, a.username]);
|
|
16
19
|
const missing = () => new AccountValidationError('账号不存在。', 404);
|
|
17
20
|
const own = (state, id) => { const account = state.accounts.find(a => a.id === id); if (!account) throw missing(); return account; };
|
|
18
21
|
const successful = result => ['success', 'already', 'already_checked'].includes(result?.state);
|
|
19
22
|
const day = value => new Date(value).toISOString().slice(0, 10);
|
|
20
23
|
const amount = (quota, divisor) => quota === null || quota === undefined || !Number.isFinite(quota / divisor) ? null : quota / divisor;
|
|
24
|
+
const localDay = (value, timeZone) => {
|
|
25
|
+
const at = value instanceof Date ? value : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? Date.parse(value) : null;
|
|
26
|
+
if (at === null) return null;
|
|
27
|
+
try { return scheduleDate(at, timeZone); } catch { return null; }
|
|
28
|
+
};
|
|
29
|
+
function attemptDay(checkin, timeZone) {
|
|
30
|
+
return localDay(checkin?.lastAttemptAt, timeZone) ?? localDay(checkin?.checkedAt, timeZone);
|
|
31
|
+
}
|
|
21
32
|
|
|
22
33
|
function projectSnapshot(snapshot, divisor) {
|
|
23
34
|
if (!snapshot) return null;
|
|
@@ -29,14 +40,32 @@ function projectSnapshot(snapshot, divisor) {
|
|
|
29
40
|
|
|
30
41
|
function publicAccount(account) {
|
|
31
42
|
const config = normalizeAccountConfig(account);
|
|
32
|
-
|
|
43
|
+
const secrets = { accessToken: Boolean(account.accessToken), cookie: Boolean(account.cookie) };
|
|
44
|
+
// Keep legacy New API clients byte-compatible; AgentRouter always needs the password flag.
|
|
45
|
+
if (config.siteType === 'agentrouter' || config.password) secrets.password = Boolean(account.password);
|
|
46
|
+
return { id: account.id, revision: account.revision, ...config, accessToken: '', cookie: '', password: '', secrets,
|
|
33
47
|
snapshot: projectSnapshot(account.snapshot, account.quotaPerUnit),
|
|
34
48
|
history: account.history.map(item => projectSnapshot(item, account.quotaPerUnit)),
|
|
35
49
|
status: { ...account.status },
|
|
36
|
-
checkin: { date: account.checkin.date, checkedAt: account.checkin.checkedAt,
|
|
50
|
+
checkin: { date: account.checkin.date, checkedAt: account.checkin.checkedAt,
|
|
51
|
+
lastAttemptAt: account.checkin.lastAttemptAt ?? null, rewardQuota: account.checkin.rewardQuota,
|
|
37
52
|
reward: amount(account.checkin.rewardQuota, account.quotaPerUnit), state: account.checkin.state, message: account.checkin.message } };
|
|
38
53
|
}
|
|
39
54
|
|
|
55
|
+
function alreadyResult(account, source) {
|
|
56
|
+
const current = publicAccount(account);
|
|
57
|
+
const recorded = source ?? account;
|
|
58
|
+
const rewardQuota = Number.isSafeInteger(recorded.checkin?.rewardQuota) && recorded.checkin.rewardQuota >= 0
|
|
59
|
+
? recorded.checkin.rewardQuota : current.checkin.rewardQuota;
|
|
60
|
+
return { ...current, checkin: { ...current.checkin,
|
|
61
|
+
date: recorded.checkin?.date ?? current.checkin.date,
|
|
62
|
+
checkedAt: recorded.checkin?.checkedAt ?? current.checkin.checkedAt,
|
|
63
|
+
lastAttemptAt: recorded.checkin?.lastAttemptAt ?? current.checkin.lastAttemptAt,
|
|
64
|
+
rewardQuota,
|
|
65
|
+
reward: amount(rewardQuota, account.quotaPerUnit),
|
|
66
|
+
state: 'already', message: '今日已签到。' } };
|
|
67
|
+
}
|
|
68
|
+
|
|
40
69
|
function totalsFor(accounts) {
|
|
41
70
|
const groups = new Map();
|
|
42
71
|
for (const account of accounts) {
|
|
@@ -116,7 +145,7 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
|
|
|
116
145
|
async function get(id) { return locked(async () => publicAccount(own(await read(), id))); }
|
|
117
146
|
|
|
118
147
|
async function save(raw, clearSecrets = []) {
|
|
119
|
-
if (!Array.isArray(clearSecrets) || clearSecrets.some(name => !['accessToken', 'cookie'].includes(name))) throw new AccountValidationError('清除凭据的字段无效。');
|
|
148
|
+
if (!Array.isArray(clearSecrets) || clearSecrets.some(name => !['accessToken', 'cookie', 'password'].includes(name))) throw new AccountValidationError('清除凭据的字段无效。');
|
|
120
149
|
const config = normalizeAccountConfig(raw);
|
|
121
150
|
return locked(async () => {
|
|
122
151
|
const state = await read();
|
|
@@ -128,13 +157,17 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
|
|
|
128
157
|
'schedule.enabled': '同一站点账号的别名只能设置一份自动签到,请先关闭另一份',
|
|
129
158
|
});
|
|
130
159
|
}
|
|
131
|
-
const
|
|
132
|
-
|
|
160
|
+
const sameAccount = previous && balanceIdentity(previous) === balanceIdentity(config);
|
|
161
|
+
const sameLogin = previous && loginIdentity(previous) === loginIdentity(config);
|
|
162
|
+
for (const secret of ['accessToken', 'cookie', 'password']) {
|
|
163
|
+
const preserve = secret === 'password' ? sameLogin : sameAccount;
|
|
164
|
+
config[secret] = config[secret] || (!preserve || clearSecrets.includes(secret) ? '' : previous?.[secret] ?? '');
|
|
165
|
+
}
|
|
133
166
|
const account = { ...config, id: previous?.id ?? uuid(), revision: randomUUID(),
|
|
134
|
-
snapshot:
|
|
135
|
-
history:
|
|
136
|
-
status:
|
|
137
|
-
checkin:
|
|
167
|
+
snapshot: sameAccount ? previous.snapshot : null,
|
|
168
|
+
history: sameAccount ? previous.history : [],
|
|
169
|
+
status: sameAccount ? previous.status : freshStatus(),
|
|
170
|
+
checkin: sameLogin ? previous.checkin : freshCheckin() };
|
|
138
171
|
if (typeof account.id !== 'string' || !account.id || state.accounts.some(a => a.id === account.id && a !== previous)) throw new AccountValidationError('无法创建唯一账号 ID。', 409);
|
|
139
172
|
if (previous) state.accounts[state.accounts.indexOf(previous)] = account;
|
|
140
173
|
else state.accounts.push(account);
|
|
@@ -186,33 +219,60 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
|
|
|
186
219
|
return session.refreshes.get(requestKey);
|
|
187
220
|
}
|
|
188
221
|
|
|
189
|
-
function checkinResult(result, at) {
|
|
222
|
+
function checkinResult(result, at, timeZone = 'UTC') {
|
|
190
223
|
const states = ['success', 'already', 'disabled', 'requires_verification', 'unknown', 'error'];
|
|
191
224
|
const state = result?.state === 'already_checked' ? 'already' : states.includes(result?.state) ? result.state : 'error';
|
|
192
225
|
const validDate = /^\d{4}-\d{2}-\d{2}$/u.test(result?.date ?? '') && Number.isFinite(Date.parse(result.date)) && day(result.date) === result.date;
|
|
226
|
+
const fallbackDay = localDay(at, timeZone) ?? day(at);
|
|
193
227
|
const messages = { success: '签到成功。', already: '今日已签到。', disabled: '站点未启用签到。', requires_verification: '请前往站点完成验证码后签到。', unknown: '签到结果未确认,请在站点确认记录;不会自动重试。', error: '签到未完成,请前往站点检查。' };
|
|
194
|
-
return { date: successful({ state }) ? validDate ? result.date :
|
|
228
|
+
return { date: successful({ state }) ? validDate ? result.date : fallbackDay : null,
|
|
195
229
|
checkedAt: successful({ state }) ? at : null,
|
|
230
|
+
lastAttemptAt: at,
|
|
196
231
|
rewardQuota: successful({ state }) && Number.isSafeInteger(result.rewardQuota) && result.rewardQuota >= 0 ? result.rewardQuota : null,
|
|
197
232
|
state, message: messages[state],
|
|
198
|
-
// Returned site dates are authoritative;
|
|
199
|
-
dedupeDay: successful({ state }) ?
|
|
233
|
+
// Returned site dates are authoritative; the configured schedule zone is the fallback.
|
|
234
|
+
dedupeDay: successful({ state }) ? fallbackDay : null };
|
|
200
235
|
}
|
|
201
236
|
|
|
202
237
|
async function performCheckin(captured, aliases, options) {
|
|
203
|
-
const at = timestamp(); let result;
|
|
204
|
-
try {
|
|
205
|
-
|
|
238
|
+
const at = timestamp(); let result; let authUpdate = null;
|
|
239
|
+
try {
|
|
240
|
+
const response = await api.checkin({ ...captured }, options);
|
|
241
|
+
result = checkinResult(response, at, captured.schedule.timeZone);
|
|
242
|
+
if (response?.authenticated === true) {
|
|
243
|
+
const session = typeof response.cookie === 'string' && response.cookie
|
|
244
|
+
&& response.cookie.length <= 32768 && !CONTROL.test(response.cookie) ? response.cookie : null;
|
|
245
|
+
if (typeof response.userId === 'string' && /^[1-9]\d*$/u.test(response.userId) && response.userId.length <= 32) {
|
|
246
|
+
authUpdate = { ...authUpdate, userId: response.userId };
|
|
247
|
+
}
|
|
248
|
+
if (session) {
|
|
249
|
+
authUpdate = { ...authUpdate, cookie: session };
|
|
250
|
+
// A password-only AgentRouter login can immediately use its new session.
|
|
251
|
+
// Existing PATs are never downgraded or replaced.
|
|
252
|
+
if (captured.authMode === 'token' && !captured.accessToken) authUpdate.authMode = 'cookie';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
206
256
|
const code = error instanceof NewApiError ? error.code : null;
|
|
207
257
|
const state = code === 'turnstile' ? 'requires_verification' : code === 'ambiguous' ? 'unknown' : 'error';
|
|
208
|
-
result = { ...checkinResult({ state }, at), message: safeNewApiMessage(error) };
|
|
258
|
+
result = { ...checkinResult({ state }, at, captured.schedule.timeZone), message: safeNewApiMessage(error) };
|
|
209
259
|
}
|
|
260
|
+
const capturedIdentity = identity(captured);
|
|
210
261
|
const updated = await locked(async () => {
|
|
211
262
|
const state = await read(); const initiator = state.accounts.find(a => a.id === captured.id);
|
|
212
263
|
if (!initiator || initiator.revision !== captured.revision) return false;
|
|
264
|
+
if (authUpdate?.userId) initiator.userId = authUpdate.userId;
|
|
265
|
+
if (authUpdate?.cookie) initiator.cookie = authUpdate.cookie;
|
|
266
|
+
if (authUpdate?.authMode) initiator.authMode = authUpdate.authMode;
|
|
267
|
+
initiator.checkin = { ...result };
|
|
213
268
|
for (const alias of aliases) {
|
|
214
269
|
const current = state.accounts.find(a => a.id === alias.id);
|
|
215
|
-
if (current && current.revision === alias.revision && identity(current) ===
|
|
270
|
+
if (current && current !== initiator && current.revision === alias.revision && identity(current) === capturedIdentity) {
|
|
271
|
+
if (authUpdate?.userId) current.userId = authUpdate.userId;
|
|
272
|
+
if (authUpdate?.cookie) current.cookie = authUpdate.cookie;
|
|
273
|
+
if (authUpdate?.authMode && current.authMode === 'token' && !current.accessToken) current.authMode = authUpdate.authMode;
|
|
274
|
+
current.checkin = { ...result };
|
|
275
|
+
}
|
|
216
276
|
}
|
|
217
277
|
await write(state); return true;
|
|
218
278
|
});
|
|
@@ -241,8 +301,21 @@ export function createAccountManager({ path, fetchImpl, now = () => new Date(),
|
|
|
241
301
|
const requestKey = identity(captured);
|
|
242
302
|
if (session.checkins.has(requestKey)) return { pending: session.checkins.get(requestKey) };
|
|
243
303
|
const sameIdentity = state.accounts.filter(a => identity(a) === identity(captured));
|
|
244
|
-
const
|
|
245
|
-
if (
|
|
304
|
+
const scheduleDay = localDay(now(), captured.schedule.timeZone);
|
|
305
|
+
if (expectedRevision !== undefined && scheduleDay) {
|
|
306
|
+
// A manual or earlier timer attempt at any result blocks a second automatic POST
|
|
307
|
+
// for the same local schedule day; the persisted first record is never rewritten.
|
|
308
|
+
const attempted = sameIdentity.find(a => attemptDay(a.checkin, captured.schedule.timeZone) === scheduleDay);
|
|
309
|
+
if (attempted) {
|
|
310
|
+
return { skip: successful(attempted.checkin) ? alreadyResult(captured, attempted) : publicAccount(captured) };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const authoritative = captured.siteType === 'newapi' && api.authoritativeCheckinDay;
|
|
314
|
+
const known = sameIdentity.find(a => successful(a.checkin)
|
|
315
|
+
&& (attemptDay(a.checkin, captured.schedule.timeZone) ?? a.checkin.dedupeDay ?? a.checkin.date) === scheduleDay);
|
|
316
|
+
if (known && !authoritative) {
|
|
317
|
+
// A repeated AgentRouter query reports done/already without touching the first reward record.
|
|
318
|
+
if (captured.siteType === 'agentrouter') return { skip: alreadyResult(captured, known) };
|
|
246
319
|
if (captured !== known) { captured.checkin = { ...known.checkin }; await write(state); }
|
|
247
320
|
return { skip: publicAccount(captured) };
|
|
248
321
|
}
|