magicrew-auth 0.3.26 → 0.3.28
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/AGENTS.md +4 -1
- package/CHANGELOG.md +280 -0
- package/README.md +133 -59
- package/docs/internals.md +122 -0
- package/package.json +1 -1
- package/src/captcha.mjs +146 -43
- package/src/headless.mjs +79 -29
- package/src/hooks/loader.mjs +163 -0
- package/src/hooks/methods.mjs +238 -147
- package/src/vision.mjs +83 -25
package/docs/internals.md
CHANGED
|
@@ -1169,6 +1169,128 @@ HTTP 400
|
|
|
1169
1169
|
`raw → 400 tools.40.../items`;过 `rewriteRequestBody` 后 `→ 200`。
|
|
1170
1170
|
国内站、国际站都验过。
|
|
1171
1171
|
|
|
1172
|
+
### 三、`items` **子树内部**的 union / 空 schema → `400 invalid JSON Schema`
|
|
1173
|
+
|
|
1174
|
+
**症状**(2026-10-28,DSH 经 magpie 网关调 DeepSeek-V4.1-Flash):
|
|
1175
|
+
|
|
1176
|
+
```
|
|
1177
|
+
本轮运行失败 400: {"code":null,"message":"超级麦吉: HTTP 400 请求错误:
|
|
1178
|
+
{\"error\":{\"code\":\"InvalidParameter\",\"message\":\"tools.40.function.parameters/
|
|
1179
|
+
properties/functions/items: invalid JSON Schema; check the schema keywords
|
|
1180
|
+
and references Request id: ...\",\"param\":\"\",\"type\":\"BadRequest\"}}",
|
|
1181
|
+
"param":null,"type":"invalid_request_error"}
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
**报错路径与上面第二节一模一样,但请求里根本没有空的 `items`** —— 这是本节存在的理由。
|
|
1185
|
+
0.3.24 的 `loosenEmptyItems()` 只删 `items` **自己**的空对象,管不到 `items` **内部**。
|
|
1186
|
+
|
|
1187
|
+
#### 根因:上游对数组元素的 schema 比对根节点更严
|
|
1188
|
+
|
|
1189
|
+
**实测**(2026-10-28~29,直打上游、同一把 key、同一份请求、只改这一个字段;
|
|
1190
|
+
复跑脚本 `_probe/items-subtree-upstream.mjs`,稳定性统计 `_probe/items-subtree-stability.mjs`):
|
|
1191
|
+
|
|
1192
|
+
| 位置 | 形态 | 结果 |
|
|
1193
|
+
|---|---|---|
|
|
1194
|
+
| `items` 内部 | `anyOf` / `oneOf` / `allOf` | **400** |
|
|
1195
|
+
| `items` 内部 | `not` / `const` | **400** |
|
|
1196
|
+
| `items` 内部 | `if` + `then`(`else` 同理) | **400** |
|
|
1197
|
+
| `items` 内部 | `{}`(空 schema) | **400** |
|
|
1198
|
+
| `items` 内部 | `items` 本身是**数组**(draft-04 tuple 形式) | 国内站 200 / **国际站 400**(两站不一致) |
|
|
1199
|
+
| `items` 内部 | `type` / `enum` / `pattern` / `minLength` / `minItems` / `format` / `$ref` / 数值边界 | 200 |
|
|
1200
|
+
| `items` 内部 | `type:["object","string","number","boolean","array","null"]` | 200(**「任意值」的合法写法**) |
|
|
1201
|
+
| **顶层 parameters** | `anyOf` / `const` / `not` / `{}` | **200** |
|
|
1202
|
+
|
|
1203
|
+
⇒ **同样形态放在顶层上游收,放进 `items` 子树就炸**。这条边界是本节的关键:
|
|
1204
|
+
改写必须精确落在 `items` 之下,全局摊平 union 会白白丢掉顶层那些上游**认**的约束。
|
|
1205
|
+
|
|
1206
|
+
⇒ 还有一条纪律:**「任意值」不能写成 `{type:"object"}`** —— 那会把「任意」收紧成
|
|
1207
|
+
「只能是对象」。`{}` 与 `true` 在这层都被拒,只能落成显式的六类型 `type` 数组。
|
|
1208
|
+
|
|
1209
|
+
#### 命中来源:MCP 工具的 schema
|
|
1210
|
+
|
|
1211
|
+
不是客户端手写,而是 **MCP 服务器**声明的工具 schema。REA MCP 的
|
|
1212
|
+
`mcp__rea__build_call_path`、`mcp__rea__compare_application_versions` 等
|
|
1213
|
+
**7 个工具**的 `functions` 数组参数,元素 schema 用了:
|
|
1214
|
+
|
|
1215
|
+
- `anyOf: [{…}, {type:"null"}]` —— 可选参数的标准写法;
|
|
1216
|
+
- `oneOf` —— 同类互斥分支;
|
|
1217
|
+
- `{}` —— 空 schema(如 `normalized_result`、`raw_result` 的分支)。
|
|
1218
|
+
|
|
1219
|
+
一份 65 工具的请求里 7 个工具中招 ⇒ **整轮 400**(上游一次只报第一个坏位置,
|
|
1220
|
+
所以报错里的 `tools.N` 序号会随工具表顺序变 —— 别拿序号定位问题)。
|
|
1221
|
+
|
|
1222
|
+
#### 修法:`loosenItemsSchema()`
|
|
1223
|
+
|
|
1224
|
+
只作用于 `items` 子树:
|
|
1225
|
+
|
|
1226
|
+
```
|
|
1227
|
+
anyOf / oneOf / allOf → 取一个分支内联(分支本身先递归修)
|
|
1228
|
+
⚠ 优先取**非纯 null** 的分支 —— `anyOf:[{…},{type:"null"}]`
|
|
1229
|
+
取第一个正好;顺序反过来时照抄第一个会把「可选」压成
|
|
1230
|
+
「只能 null」,那是收紧
|
|
1231
|
+
not / const → 删除
|
|
1232
|
+
if / then / else → 删除
|
|
1233
|
+
{} → {type:["object","string","number","boolean","array","null"]}
|
|
1234
|
+
items 是数组(tuple) → {type:[同上]} ← 国际站对数组形式回 400
|
|
1235
|
+
```
|
|
1236
|
+
|
|
1237
|
+
全部是**降级放宽**,方向安全(宁可约束松一点,也不要整轮失败):
|
|
1238
|
+
|
|
1239
|
+
- union 取一个分支:`anyOf:[{…},{type:"null"}]` 的分支正是「非空」那个;
|
|
1240
|
+
- `const` 删除 ⇒ 「只能是这一个值」变「任意值」,放宽;
|
|
1241
|
+
- `not` / `if+then+else` 无等价物,删掉同样是放宽;
|
|
1242
|
+
- `{}` → 六类型 `type` 数组:**与空 schema 严格等价**(六种 JSON 类型全覆盖 = 任意值),
|
|
1243
|
+
不是「等价于 `{type:"object"}`」—— 那会收紧。
|
|
1244
|
+
|
|
1245
|
+
⚠ **删除会删空节点**:`{const:"x"}`、`{not:{…}}`、`{if:…,then:…}` 删完就成 `{}`,
|
|
1246
|
+
而空 schema 在 `items` 子树里**仍然 400**。所以删完必须在同一个函数里再查一次空对象并补成
|
|
1247
|
+
「任意值」—— 漏了这一步,改写看起来生效、实际仍然整轮 400(PR #10 初版就漏在这里,
|
|
1248
|
+
`not` / `const` 两例改写后实测仍 400)。
|
|
1249
|
+
|
|
1250
|
+
递归同样遵守 schema 位置纪律(`walkSchema` 那套 `SCHEMA_MAPS` / `SCHEMA_LIST` /
|
|
1251
|
+
`SCHEMA_ONE`):只进 `properties` 的**值**、union 的**分支**、`items` 的**值**……
|
|
1252
|
+
不碰 `enum` / `default` / `examples` 里的字面值。
|
|
1253
|
+
|
|
1254
|
+
#### 端到端验证(2026-10-29)
|
|
1255
|
+
|
|
1256
|
+
真实报障形态的 65 工具请求体(58 个填充 + 7 个中招的 REA 工具,脚本里现成构造):
|
|
1257
|
+
|
|
1258
|
+
| 站点 | 原样 | 过 `rewriteRequestBody` |
|
|
1259
|
+
|---|---|---|
|
|
1260
|
+
| 国内站 `i-magic-service.letsmagic.cn`(deepseek-v4.1-flash) | `400 … invalid JSON Schema` | **200** |
|
|
1261
|
+
| 国际站 `magic-service.magicrew.ai`(deepseek-v4.1-flash) | `400 … tools.58…/items` | **200** |
|
|
1262
|
+
|
|
1263
|
+
单点矩阵(21 个形态 × raw / 改写后,两站各跑一遍):**所有 `items` 子树非法形态
|
|
1264
|
+
400 → 200**,顶层对照形态保持 200 不动,合法关键字(`enum` / `pattern` / `$ref`)原样保留。
|
|
1265
|
+
|
|
1266
|
+
复跑方式:
|
|
1267
|
+
|
|
1268
|
+
```bash
|
|
1269
|
+
# 国内站(node 直连即可)
|
|
1270
|
+
node ./_probe/items-subtree-upstream.mjs
|
|
1271
|
+
# 国际站(node 连不上该域名,用 magpie 自带的 bun)
|
|
1272
|
+
~/.cache/magpie/bun/1.4.4/magpie-bun.exe ./_probe/items-subtree-upstream.mjs intl
|
|
1273
|
+
# 稳定性统计(同一形态连发 N 轮,判「恒 400」还是「偶发」)
|
|
1274
|
+
node ./_probe/items-subtree-stability.mjs 3 cn
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
⚠ **上游的校验会变,别把单次探针当定论**。2026-10-29 这一夜实测到的完整时间线:
|
|
1278
|
+
|
|
1279
|
+
| 本地时间(UTC+8) | 站点 | `items` 子树非法形态(A 组 7 个) | tuple 形式 / `if+then` |
|
|
1280
|
+
|---|---|---|---|
|
|
1281
|
+
| 00:19 | 国内 | **稳定 400**(7/7) | 200 / 400 |
|
|
1282
|
+
| 00:24 | 国际 | **稳定 400**(7/7) | 200 / 400 |
|
|
1283
|
+
| 00:29(3 轮 ×21 形态统计) | 国内 | 已全变 **200** | 200 |
|
|
1284
|
+
| 00:35 | 国内 | 仍全 **200**(含真实报障形态的 raw) | 200 |
|
|
1285
|
+
|
|
1286
|
+
⇒ 上游在 00:25~00:29 之间放宽了这层校验(灰度或发布,未确认)。
|
|
1287
|
+
**这不改变本节的结论**:改写现在是「保险」而不是「必需」——上游随时可能收回去
|
|
1288
|
+
(同一夜里已经出现过波动),而改写的代价只是一次遍历。
|
|
1289
|
+
|
|
1290
|
+
所以判「某个形态要不要改写」要看多轮统计,别信单次探针:
|
|
1291
|
+
`_probe/items-subtree-stability.mjs` 就是为这件事写的(`N 轮 × 每形态 raw/改写各一次`,
|
|
1292
|
+
输出 `400 次数 / 轮数`)。
|
|
1293
|
+
|
|
1172
1294
|
### 为什么算插件的事,不算客户端的事
|
|
1173
1295
|
|
|
1174
1296
|
按规范 `{}` 与 `true` 等价,客户端没写错;**上游的兼容性不足**。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magicrew-auth",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.28",
|
|
4
4
|
"description": "超级麦吉 / MagiCrew for magpie and OpenCode — one package, two providers: letsmagic.cn (国内) and magicrew.ai (international). Password or web-JWT sign-in, the account's own live model list, points balance, newcomer rewards, invite codes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.mjs",
|
package/src/captcha.mjs
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
|
-
// 无头登录的底层:旋转验证码 +
|
|
1
|
+
// 无头登录的底层:旋转验证码 + 手机/邮箱验证码换取登录态
|
|
2
2
|
//
|
|
3
3
|
// 全部是**纯 HTTP**,不开浏览器、不读 localStorage。协议是从网页版
|
|
4
|
-
// `SMSVerificationCodeForm` chunk 里逆出来的,每一步都实测过
|
|
4
|
+
// `SMSVerificationCodeForm` / `LoginCoreForm` chunk 里逆出来的,每一步都实测过
|
|
5
5
|
// (见 docs/internals.md「无头登录」一节):
|
|
6
6
|
//
|
|
7
|
-
// ① POST /captcha/generate { account_type, mobile, state_code }
|
|
7
|
+
// ① POST /captcha/generate { account_type, mobile, state_code } 手机
|
|
8
|
+
// { account_type, email } 邮箱
|
|
8
9
|
// → { token, image(data:image/webp;base64,…), expire_time }
|
|
9
10
|
// ② 算角度 → angle = enc(deg, n)
|
|
10
11
|
// ③ POST /captcha/verify { token, angle, account_type, mobile, state_code }
|
|
12
|
+
// { token, angle, account_type, email }
|
|
11
13
|
// → { verified: true, business_token: "biz_…" }
|
|
12
|
-
// ④
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// ④ 发验证码(两条路**是两个端点**,别混):
|
|
15
|
+
// 手机 POST /v4/user/send-sms { type, phone, state_code }
|
|
16
|
+
// 邮箱 POST /v4/user/send-email { email, type }
|
|
17
|
+
// + header `X-Captcha-Token: <business_token>`,type 都是 "account_login"
|
|
18
|
+
// ⑤ 换登录态(同样是两个端点):
|
|
19
|
+
// 手机 POST /v4/user/login { type:"phone_captcha", phone, state_code, code, device }
|
|
20
|
+
// 邮箱 POST /v4/user/login-email { email, captcha, device }
|
|
21
|
+
//
|
|
22
|
+
// ⚠ 邮箱那条为什么走 `/v4/user/login-email` 而不是 `/v4/user/login`:
|
|
23
|
+
// 实测(2026-10-28,两站)——往 `/v4/user/login` 发任何 type(email / email_captcha
|
|
24
|
+
// / email_code)都回 2000「phone 字段是必须的」,服务端根本不认邮箱。
|
|
25
|
+
// 前端 `UserApi` 里也是两条独立方法:`loginByEmail()` → `/v4/user/login-email`,
|
|
26
|
+
// 并且它显式 `delete("organization-code")`。
|
|
27
|
+
//
|
|
28
|
+
// ⚠ 邮箱那条的**验证码字段名是 `captcha`,不是 `code`** —— 实测发 `code` 回
|
|
29
|
+
// 2000「captcha 字段是必须的」;发 `captcha` 回 2168「验证码错误」。
|
|
15
30
|
//
|
|
16
31
|
// ⚠ 三个域名各管一段,别混:
|
|
17
32
|
// www.<站点> → 前端页面(只用来取 Origin,别的没用)
|
|
@@ -23,6 +38,34 @@
|
|
|
23
38
|
// · 「验证不存在或已过期」 expire_time 给 10 分钟
|
|
24
39
|
// · 「验证提交过于频繁」 连续批量提交会被拦
|
|
25
40
|
// ⇒ 不能枚举角度,一次要算准;失败就换图重来。
|
|
41
|
+
//
|
|
42
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
43
|
+
// 那张旋转图到底能不能绕开(2026-10-29 逐条实测,别再来一遍)
|
|
44
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
45
|
+
//
|
|
46
|
+
// 结论:**只有「这个号已注册」这一条免费通道**,其余一律必须看懂图。
|
|
47
|
+
//
|
|
48
|
+
// ① 已注册的号 —— 服务端**不出图**:`/captcha/generate` 直接回 `business_token`。
|
|
49
|
+
// 实测:已注册邮箱 → business_token;没注册过的邮箱 → token + image。
|
|
50
|
+
// ⇒ 「把我已有的账号加进 magpie」根本不需要识图模型(见 headless.mjs 里
|
|
51
|
+
// startCodeLogin 的「先取图、再判模型」顺序)。
|
|
52
|
+
//
|
|
53
|
+
// ② 新号的答案**不在前端**,也不在图里:
|
|
54
|
+
// · 前端提交的 `angle` 就是**用户拖动的角度**(`verify(r)` → 这个文件里的
|
|
55
|
+
// encodeAngle),前端不知道正确答案 —— 知道的话就不会让你拖了;
|
|
56
|
+
// · 图里没有任何元数据:解剖 webp 只有 `VP8X` / `ALPH` / `VP8` 三个 chunk,
|
|
57
|
+
// 没有 EXIF / XMP / ICC / 自定义块,字符串扫描也是空的;
|
|
58
|
+
// · 服务端强制:`verify` 不传 angle → 「angle 字段是必须的」;乱填 →
|
|
59
|
+
// 「验证失败,请重试」;发码不带 `X-Captcha-Token`(哪怕带上账号自己的 JWT
|
|
60
|
+
// 和 organization-code)→ 「需要验证码验证」。
|
|
61
|
+
// ⇒ 答案 = 服务端生成图时随机定的角度,存在它自己的会话里(10 分钟过期)。
|
|
62
|
+
// 要过它只有两条路:识图模型,或人眼看。
|
|
63
|
+
//
|
|
64
|
+
// ③ 不经过验证码的登录入口只有两条,都不是邮箱验证码的替代品:
|
|
65
|
+
// · 手机号 + 密码 → `POST /api/v1/sessions {type:"phone_password"}`
|
|
66
|
+
// · 本机浏览器里已登录 → 读它的 localStorage(见 local-browser.mjs)
|
|
67
|
+
// 邮箱 + 密码(`type:"email_password"`)端点认这个 type,但要账号**事先设过
|
|
68
|
+
// 密码** —— 实测对一个已注册邮箱回 `33002 账号不存在`,不能当通用路。
|
|
26
69
|
|
|
27
70
|
/** 服务端只认这一套 Unicode;错误码直接用数字,日志里好 grep */
|
|
28
71
|
export const CAPTCHA_CODE = {
|
|
@@ -44,6 +87,33 @@ export function keewoodOf(site) {
|
|
|
44
87
|
return site.id === "magicrew" ? "https://i-keewood-service.teamshare.cn" : "https://keewood-service.magicrew.ai"
|
|
45
88
|
}
|
|
46
89
|
|
|
90
|
+
/**
|
|
91
|
+
* 「这条链路在给谁发验证码」→ 服务端要的那几个字段。
|
|
92
|
+
*
|
|
93
|
+
* 手机和邮箱走的是**同一套 captcha 协议**,只有三处不同,全收敛在这里:
|
|
94
|
+
* ① 标识字段:`mobile` + `state_code` vs `email`
|
|
95
|
+
* ② `account_type`:`"mobile"` vs `"email"`
|
|
96
|
+
* ③ 端点:`/v4/user/send-sms` + `/v4/user/login` vs
|
|
97
|
+
* `/v4/user/send-email` + `/v4/user/login-email`
|
|
98
|
+
*
|
|
99
|
+
* ⚠ 邮箱**不带 `state_code`** —— 前端邮箱表单的初始值里就没有这个字段
|
|
100
|
+
* (实测抓到的请求体逐字符是 `{"account_type":"email","email":"…"}`)。
|
|
101
|
+
*
|
|
102
|
+
* @param {{mobile?:string, stateCode?:string, email?:string}} ident
|
|
103
|
+
* @returns {{account_type:"mobile"|"email", mobile?:string, state_code?:string, email?:string}}
|
|
104
|
+
*/
|
|
105
|
+
export function identFields(ident = {}) {
|
|
106
|
+
const email = String(ident?.email ?? "").trim()
|
|
107
|
+
if (email) return { account_type: "email", email }
|
|
108
|
+
const mobile = String(ident?.mobile ?? "").replace(/[\s-]/g, "")
|
|
109
|
+
return { account_type: "mobile", mobile, state_code: ident?.stateCode || "+86" }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 这条链路是手机还是邮箱 —— 决定走哪两个端点 */
|
|
113
|
+
export function identKind(ident = {}) {
|
|
114
|
+
return String(ident?.email ?? "").trim() ? "email" : "mobile"
|
|
115
|
+
}
|
|
116
|
+
|
|
47
117
|
/**
|
|
48
118
|
* 拖动角度 → 服务端要的 angle 字符串。
|
|
49
119
|
*
|
|
@@ -109,14 +179,12 @@ async function postJSON(url, body, { headers = {}, timeoutMs = 20_000 } = {}) {
|
|
|
109
179
|
return { status: res.status, text, json }
|
|
110
180
|
}
|
|
111
181
|
|
|
112
|
-
/** ①
|
|
113
|
-
export async function generateCaptcha(site,
|
|
182
|
+
/** ① 取图。服务端认为这个号**已经注册**时直接给 business_token(跳过验证) */
|
|
183
|
+
export async function generateCaptcha(site, ident = {}) {
|
|
114
184
|
const host = keewoodOf(site)
|
|
115
|
-
const { json } = await postJSON(`${host}/captcha/generate`, {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
state_code: stateCode,
|
|
119
|
-
}, { headers: { origin: site.web } })
|
|
185
|
+
const { json } = await postJSON(`${host}/captcha/generate`, identFields(ident), {
|
|
186
|
+
headers: { origin: site.web },
|
|
187
|
+
})
|
|
120
188
|
|
|
121
189
|
const data = json?.data ?? {}
|
|
122
190
|
if (data.business_token) return { registered: true, businessToken: data.business_token }
|
|
@@ -131,16 +199,14 @@ export async function generateCaptcha(site, { mobile, stateCode = "+86" } = {})
|
|
|
131
199
|
}
|
|
132
200
|
}
|
|
133
201
|
|
|
134
|
-
/** ③
|
|
135
|
-
export async function verifyCaptcha(site, { token, deg,
|
|
202
|
+
/** ③ 提交角度(手机 / 邮箱同一套,标识字段由 identFields 决定) */
|
|
203
|
+
export async function verifyCaptcha(site, { token, deg, ...ident }) {
|
|
136
204
|
const host = keewoodOf(site)
|
|
137
205
|
const n = randomN()
|
|
138
206
|
const { json } = await postJSON(`${host}/captcha/verify`, {
|
|
139
|
-
account_type: "mobile",
|
|
140
207
|
token,
|
|
141
208
|
angle: encodeAngle(deg, n),
|
|
142
|
-
|
|
143
|
-
state_code: stateCode,
|
|
209
|
+
...identFields(ident),
|
|
144
210
|
}, { headers: { origin: site.web } })
|
|
145
211
|
|
|
146
212
|
const data = json?.data ?? {}
|
|
@@ -149,28 +215,42 @@ export async function verifyCaptcha(site, { token, deg, mobile, stateCode = "+86
|
|
|
149
215
|
}
|
|
150
216
|
|
|
151
217
|
/**
|
|
152
|
-
* ④
|
|
218
|
+
* ④ 发验证码。businessToken 缺了/假的都会被打回来,所以它必须是真的。
|
|
153
219
|
*
|
|
154
220
|
* ⚠ `type` 是 **`"account_login"`**,不是 `"login"`、也不是登录枚举里的 `phone_captcha`。
|
|
155
221
|
* 填错的表现是「服务异常,请联系管理员」(9999) —— 后端内部炸了,不是参数校验失败,
|
|
156
222
|
* 所以从错误信息上完全看不出是 type 的问题。这个值是从浏览器真实请求里抓到的:
|
|
157
|
-
* {"type":"account_login","phone":"199…","state_code":"+86"}
|
|
158
|
-
*
|
|
223
|
+
* 手机 {"type":"account_login","phone":"199…","state_code":"+86"}
|
|
224
|
+
* 邮箱 {"email":"…","type":"account_login"}
|
|
225
|
+
* ⇒ 别再按直觉改它。两站实测:邮箱发 `type:"login"` 同样回 2000「服务异常」。
|
|
226
|
+
*
|
|
227
|
+
* ⚠ 邮箱和手机是**两个端点**:`/v4/user/send-email` vs `/v4/user/send-sms`。
|
|
228
|
+
* 把邮箱发去 send-sms 会 404(`/v4/user/send-sms` 只认 phone)。
|
|
159
229
|
*/
|
|
160
|
-
export async function
|
|
230
|
+
export async function sendCode(site, ident = {}, { businessToken, type = "account_login" } = {}) {
|
|
161
231
|
const host = keewoodOf(site)
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
232
|
+
const f = identFields(ident)
|
|
233
|
+
const isEmail = f.account_type === "email"
|
|
234
|
+
const path = isEmail ? "/v4/user/send-email" : "/v4/user/send-sms"
|
|
235
|
+
const body = isEmail
|
|
236
|
+
? { email: f.email, type }
|
|
237
|
+
: { type, phone: f.mobile, state_code: f.state_code }
|
|
238
|
+
|
|
239
|
+
const { json } = await postJSON(`${host}${path}`, body, {
|
|
240
|
+
headers: { origin: site.web, "X-Captcha-Token": businessToken || "" },
|
|
241
|
+
})
|
|
167
242
|
|
|
168
243
|
if (json?.code === CAPTCHA_CODE.OK) return { ok: true }
|
|
169
|
-
return { ok: false, message: json?.message ?? "
|
|
244
|
+
return { ok: false, message: json?.message ?? "发送验证码失败" }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 旧签名兼容:手机号发短信 */
|
|
248
|
+
export async function sendSms(site, { phone, stateCode = "+86", businessToken, type = "account_login" }) {
|
|
249
|
+
return sendCode(site, { mobile: phone, stateCode }, { businessToken, type })
|
|
170
250
|
}
|
|
171
251
|
|
|
172
252
|
/**
|
|
173
|
-
* ⑤
|
|
253
|
+
* ⑤ 验证码换登录态(手机 / 邮箱两个端点)。
|
|
174
254
|
*
|
|
175
255
|
* ⚠ `device` 是必须的,而且结构是一层层试出来的:
|
|
176
256
|
* 缺 device → 「系统异常」(9999)
|
|
@@ -179,7 +259,7 @@ export async function sendSms(site, { phone, stateCode = "+86", businessToken, t
|
|
|
179
259
|
* { id, name } → 放行,返回 2168「验证码错误」
|
|
180
260
|
* ⇒ { id: 64 位 hex, name: "web" } 就够。
|
|
181
261
|
*
|
|
182
|
-
* ⚠ `auto_register` / `redirect` 一个都不能省(这是网页表单的初始值,抠出来的):
|
|
262
|
+
* ⚠ 手机那条的 `auto_register` / `redirect` 一个都不能省(这是网页表单的初始值,抠出来的):
|
|
183
263
|
*
|
|
184
264
|
* { phone, state_code, code, auto_register: true,
|
|
185
265
|
* type: "phone_captcha", redirect: window.location.href }
|
|
@@ -188,29 +268,52 @@ export async function sendSms(site, { phone, stateCode = "+86", businessToken, t
|
|
|
188
268
|
* · `redirect` 是**当前页面的 URL**,服务端从它上面取 `?invitation=<码>`。
|
|
189
269
|
* ⇒ **邀请码是靠 redirect 传的,不是独立字段**。这就是为什么浏览器登录的
|
|
190
270
|
* URL 必须带 `?invitation=`:不带,新注册的号就没有邀请关系。
|
|
271
|
+
*
|
|
272
|
+
* ⚠ 邮箱那条**验证码字段叫 `captcha`,不叫 `code`**(实测:发 code 回
|
|
273
|
+
* 2000「captcha 字段是必须的」;发 captcha 回 2168「验证码错误」)。
|
|
274
|
+
* 端点也不同:`/v4/user/login-email`,前端调它时会先 `delete("organization-code")`。
|
|
275
|
+
* `auto_register` / `redirect` 前端没传,但我们**照传** —— 实测多这两个字段
|
|
276
|
+
* 服务端不报错(仍回 2168),而新号注册和邀请码都指着它们。
|
|
191
277
|
*/
|
|
192
|
-
export async function
|
|
278
|
+
export async function loginByCode(
|
|
193
279
|
site,
|
|
194
|
-
{
|
|
280
|
+
{ code, device, autoRegister = true, redirect, ...ident },
|
|
195
281
|
) {
|
|
196
282
|
const host = keewoodOf(site)
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
283
|
+
const f = identFields(ident)
|
|
284
|
+
const isEmail = f.account_type === "email"
|
|
285
|
+
|
|
286
|
+
const body = isEmail
|
|
287
|
+
? {
|
|
288
|
+
email: f.email,
|
|
289
|
+
captcha: code,
|
|
290
|
+
auto_register: autoRegister,
|
|
291
|
+
device: device ?? { id: makeDeviceId(), name: "web" },
|
|
292
|
+
}
|
|
293
|
+
: {
|
|
294
|
+
type: "phone_captcha",
|
|
295
|
+
phone: f.mobile,
|
|
296
|
+
state_code: f.state_code,
|
|
297
|
+
code,
|
|
298
|
+
auto_register: autoRegister,
|
|
299
|
+
device: device ?? { id: makeDeviceId(), name: "web" },
|
|
300
|
+
}
|
|
205
301
|
if (redirect) body.redirect = redirect
|
|
206
302
|
|
|
207
|
-
const { json, status, text } = await postJSON(
|
|
208
|
-
|
|
209
|
-
|
|
303
|
+
const { json, status, text } = await postJSON(
|
|
304
|
+
`${host}${isEmail ? "/v4/user/login-email" : "/v4/user/login"}`,
|
|
305
|
+
body,
|
|
306
|
+
{ headers: { origin: site.web } },
|
|
307
|
+
)
|
|
210
308
|
|
|
211
309
|
return { json, status, text }
|
|
212
310
|
}
|
|
213
311
|
|
|
312
|
+
/** 旧签名兼容:手机号验证码换登录态 */
|
|
313
|
+
export async function loginBySmsCode(site, { phone, stateCode = "+86", ...rest }) {
|
|
314
|
+
return loginByCode(site, { mobile: phone, stateCode, ...rest })
|
|
315
|
+
}
|
|
316
|
+
|
|
214
317
|
/** 64 位 hex,形状与网页版 cus.device_id 一致 */
|
|
215
318
|
export function makeDeviceId() {
|
|
216
319
|
const bytes = new Uint8Array(32)
|
package/src/headless.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// 无头登录的编排:一条纯 HTTP 的链路,不开浏览器
|
|
2
2
|
//
|
|
3
|
-
// 取图 → 识图判角度 → 过验证 →
|
|
3
|
+
// 取图 → 识图判角度 → 过验证 → 发验证码 → (用户填码)→ 换登录态 → 拿组织码
|
|
4
4
|
//
|
|
5
|
-
//
|
|
5
|
+
// **手机号和邮箱走同一条编排** —— 协议、风控、识图全一样,只有「标识字段」
|
|
6
|
+
// 和「发码 / 登录两个端点」不同,那些差异全在 captcha.mjs 里按 identFields 分派。
|
|
7
|
+
//
|
|
8
|
+
// 每一步都实测过(协议逐字段记在 src/captcha.mjs 头部;两站实测记录见
|
|
9
|
+
// CHANGELOG 0.3.17「手机号验证码」与 0.3.28「邮箱验证码 + 已有账号免图」):
|
|
6
10
|
// · 取图/过验证在 keewood 用户中心,纯 JSON,不需要 cookie 或指纹
|
|
7
11
|
// · 角度必须「一次算准」:同一 token 试错次数有限,且高频会被拦
|
|
8
12
|
// · 识图模型是用户在 magpie 里选的那个(settings.json 的 vision)
|
|
@@ -14,8 +18,9 @@
|
|
|
14
18
|
import {
|
|
15
19
|
extractCreds,
|
|
16
20
|
generateCaptcha,
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
identKind,
|
|
22
|
+
loginByCode,
|
|
23
|
+
sendCode,
|
|
19
24
|
verifyCaptcha,
|
|
20
25
|
} from "./captcha.mjs"
|
|
21
26
|
import { decideDegFromImage, readVisionModel } from "./vision.mjs"
|
|
@@ -30,14 +35,20 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
30
35
|
/**
|
|
31
36
|
* 过掉旋转验证,拿到 business_token。
|
|
32
37
|
*
|
|
38
|
+
* 手机和邮箱共用:只有传给服务端的「标识字段」不同(`{mobile, stateCode}` / `{email}`)。
|
|
39
|
+
*
|
|
40
|
+
* @param {object} site 站点对象(要有 id / web)
|
|
41
|
+
* @param {{mobile?:string, stateCode?:string, email?:string}} ident
|
|
42
|
+
* @param {object|null} firstCap 已经取到的那张图(调用方为了让「已注册免图」
|
|
43
|
+
* 这条短路先跑,会自己先调一次 generateCaptcha)—— 传进来就省一次取图
|
|
33
44
|
* @returns {Promise<{businessToken:string, rounds:number, log:string[]}>}
|
|
34
45
|
*/
|
|
35
|
-
export async function passCaptcha(site,
|
|
46
|
+
export async function passCaptcha(site, ident = {}, firstCap = null) {
|
|
36
47
|
const log = []
|
|
37
48
|
let lastReason = ""
|
|
38
49
|
|
|
39
50
|
for (let round = 1; round <= MAX_CAPTCHA_ROUNDS; round++) {
|
|
40
|
-
const cap = await generateCaptcha(site,
|
|
51
|
+
const cap = round === 1 && firstCap ? firstCap : await generateCaptcha(site, ident)
|
|
41
52
|
// 已注册的号,服务端直接给 business_token,连图都不用看
|
|
42
53
|
if (cap.registered) {
|
|
43
54
|
log.push(`第 ${round} 轮:服务端直接放行(该号已注册)`)
|
|
@@ -53,7 +64,7 @@ export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
|
|
|
53
64
|
continue
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
const res = await verifyCaptcha(site, { token: cap.token, deg: decided.deg,
|
|
67
|
+
const res = await verifyCaptcha(site, { token: cap.token, deg: decided.deg, ...ident })
|
|
57
68
|
if (res.ok) {
|
|
58
69
|
log.push(`第 ${round} 轮:deg=${decided.deg}(钟点 ${decided.clock?.toFixed?.(2) ?? "?"},分歧 ${decided.spread?.toFixed?.(1) ?? 0})→ 通过`)
|
|
59
70
|
return { businessToken: res.businessToken, rounds: round, log }
|
|
@@ -71,45 +82,79 @@ export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
|
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/**
|
|
74
|
-
* 第 1
|
|
85
|
+
* 第 1 步:发验证码(短信 / 邮件)。成功即代表「验证已经过了」——服务端把 business_token 当门票。
|
|
86
|
+
*
|
|
87
|
+
* ⚠ **顺序很重要:先取图,再决定要不要识图模型。**
|
|
88
|
+
*
|
|
89
|
+
* 服务端对**已注册**的号根本不要求过旋转验证 —— `POST /captcha/generate` 直接回
|
|
90
|
+
* `business_token`,连图都不给(实测:2026-10-29,已注册邮箱 `…@qq.com` → 免图;
|
|
91
|
+
* 同一个接口换一个没注册过的邮箱 → 给了 token+image)。
|
|
92
|
+
*
|
|
93
|
+
* 所以「加一个我自己已有的账号」这条最常见的路,**根本不需要识图模型**。
|
|
94
|
+
* 早先把 `readVisionModel()` 的判断放在取图**之前**,等于让没配识图模型的人
|
|
95
|
+
* 连自己已有的账号都加不进来 —— 那是白拦。现在只有确实要给新号过图时才要求它。
|
|
96
|
+
*
|
|
97
|
+
* @throws 需要过图但没配识图模型时,抛带 `code: "no-vision-model"` 的错误,
|
|
98
|
+
* 让调用方去回退浏览器(而不是在这里瞎猜角度)
|
|
75
99
|
*/
|
|
76
|
-
export async function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
100
|
+
export async function startCodeLogin(site, ident = {}) {
|
|
101
|
+
const kind = identKind(ident)
|
|
102
|
+
const what = kind === "email" ? "邮件" : "短信"
|
|
103
|
+
|
|
104
|
+
// ① 先探一次。这一步是纯 JSON、不需要模型,也**不会真的发码**。
|
|
105
|
+
const first = await generateCaptcha(site, ident)
|
|
106
|
+
|
|
107
|
+
let businessToken
|
|
108
|
+
let rounds
|
|
109
|
+
let log
|
|
110
|
+
|
|
111
|
+
if (first.registered) {
|
|
112
|
+
// ⚡ 已注册 → 免图直接发码。识图模型在这条路上完全用不到。
|
|
113
|
+
businessToken = first.businessToken
|
|
114
|
+
rounds = 0
|
|
115
|
+
log = ["服务端认为该号已注册 → 免旋转验证"]
|
|
116
|
+
} else {
|
|
117
|
+
// ② 新号必须过图:这时才需要识图模型
|
|
118
|
+
if (!readVisionModel()) {
|
|
119
|
+
throw Object.assign(new Error("这个号还没注册,注册要过旋转验证,而你没配识图模型"), {
|
|
120
|
+
code: "no-vision-model",
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
const r = await passCaptcha(site, ident, first)
|
|
124
|
+
businessToken = r.businessToken
|
|
125
|
+
rounds = r.rounds
|
|
126
|
+
log = r.log
|
|
80
127
|
}
|
|
81
128
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
throw Object.assign(new Error(`发短信失败:${sms.message}`), { captchaLog: log })
|
|
129
|
+
const sent = await sendCode(site, ident, { businessToken })
|
|
130
|
+
if (!sent.ok) {
|
|
131
|
+
throw Object.assign(new Error(`发${what}失败:${sent.message}`), { captchaLog: log })
|
|
86
132
|
}
|
|
87
|
-
return { businessToken, rounds, log }
|
|
133
|
+
return { businessToken, rounds, log, skippedCaptcha: Boolean(first.registered) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 旧签名兼容:手机号发短信 */
|
|
137
|
+
export async function startSmsLogin(site, { mobile, stateCode = "+86" } = {}) {
|
|
138
|
+
return startCodeLogin(site, { mobile, stateCode })
|
|
88
139
|
}
|
|
89
140
|
|
|
90
141
|
/**
|
|
91
142
|
* 第 2 步:验证码换登录态,并补齐组织码。
|
|
92
143
|
*
|
|
93
|
-
* ⚠ 登录响应里 jwt /
|
|
94
|
-
* 只能验到 2168
|
|
144
|
+
* ⚠ 登录响应里 jwt / 组织码的**确切形状没实测过**(手上没有能收短信/收信的号,
|
|
145
|
+
* 只能验到 2168「验证码错误」——手机和邮箱都一样)。所以:
|
|
95
146
|
* · jwt 用 extractCreds 从响应里按特征找(以 eyJ 开头的三段式)
|
|
96
147
|
* · 组织码找不着就调 `/api/v1/aggregation/login-data`(带 authorization 头,
|
|
97
148
|
* 服务端会回这个账号所在的组织列表)—— 这条路径是文档里查得到的
|
|
98
149
|
*/
|
|
99
|
-
export async function
|
|
100
|
-
const { json, status, text } = await
|
|
101
|
-
phone: mobile,
|
|
102
|
-
stateCode,
|
|
103
|
-
code,
|
|
104
|
-
device,
|
|
105
|
-
autoRegister: true,
|
|
106
|
-
redirect,
|
|
107
|
-
})
|
|
150
|
+
export async function finishCodeLogin(site, { code, device, redirect, ...ident } = {}) {
|
|
151
|
+
const { json, status, text } = await loginByCode(site, { code, device, redirect, ...ident })
|
|
108
152
|
if (!json) {
|
|
109
153
|
throw new Error(`登录接口回了非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`)
|
|
110
154
|
}
|
|
111
155
|
if (json.code !== 1000) {
|
|
112
|
-
// 2168
|
|
156
|
+
// 2168(手机 / 邮箱都是它)= 验证码错;2167 = 短信验证码无效或已过期;
|
|
157
|
+
// 其余原样透出,别把它吞成「登录失败」
|
|
113
158
|
throw Object.assign(new Error(`登录失败:${json.message ?? json.code}`), {
|
|
114
159
|
code: json.code,
|
|
115
160
|
raw: json,
|
|
@@ -140,6 +185,11 @@ export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, de
|
|
|
140
185
|
return { jwt, org: orgs[0], orgs, raw: json }
|
|
141
186
|
}
|
|
142
187
|
|
|
188
|
+
/** 旧签名兼容:手机号验证码换登录态 */
|
|
189
|
+
export async function finishSmsLogin(site, { mobile, stateCode = "+86", ...rest } = {}) {
|
|
190
|
+
return finishCodeLogin(site, { mobile, stateCode, ...rest })
|
|
191
|
+
}
|
|
192
|
+
|
|
143
193
|
/**
|
|
144
194
|
* 把一段 JSON 的形状描述出来:**只有键名和类型,没有任何值**。
|
|
145
195
|
*
|