magicrew-auth 0.3.16 → 0.3.18
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 +21 -0
- package/CHANGELOG.md +110 -0
- package/docs/internals.md +23 -0
- package/package.json +1 -1
- package/src/captcha.mjs +275 -0
- package/src/headless.mjs +180 -0
- package/src/hooks/methods.mjs +192 -0
- package/src/vision.mjs +193 -0
package/AGENTS.md
CHANGED
|
@@ -8,6 +8,27 @@
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## 零、magpie 升级时:只看稳定版
|
|
12
|
+
|
|
13
|
+
**判断「要不要跟着 magpie 改这个插件」看稳定渠道,不看每版本渠道。**
|
|
14
|
+
magpie 设置里有「更新渠道:每个版本 / 稳定版」,用户默认跑后者,插件要跟的也是后者。
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
curl -s "https://usemagpie.ai/api/latest?channel=stable" | grep -o '"version":"[^"]*"'
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
实测(2026-10-28):两档差 **400 多个版本**(每版本 0.1.1763 / 稳定版 0.1.1327),
|
|
21
|
+
但把两边内嵌契约都提取出来逐字段比对后 —— **插件相关的字段一条不差**。
|
|
22
|
+
多出来的段是 GUI 历史,不是插件契约。⇒ 追每版本几乎全是白跑。
|
|
23
|
+
|
|
24
|
+
**稳定版版本号变了**,才需要重提取契约(脚手架的 `extract-contract.py`)→
|
|
25
|
+
跑 `contract-diff.mjs` → 看差集里有没有「不写会关掉某个能力」的字段。
|
|
26
|
+
完整判据见脚手架的
|
|
27
|
+
[HOOKS.md](https://github.com/MYCF711/magpie-plugin-scaffold/blob/main/HOOKS.md) 顶部
|
|
28
|
+
与 [UPDATING.md](https://github.com/MYCF711/magpie-plugin-scaffold/blob/main/UPDATING.md) 第七节。
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
11
32
|
## 一、先定位你在哪一步
|
|
12
33
|
|
|
13
34
|
| 你在做什么 | 必读(按顺序) | 做完的判据 |
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## 0.3.18 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
- **AGENTS 新增「零、magpie 升级时:只看稳定版」** —— magpie 有「更新渠道:每个版本 / 稳定版」,用户默认跑稳定版。两渠道实测差 **400 多个版本**(每版本 0.1.1763 / 稳定版 0.1.1327),但插件相关的契约字段**一条不差** ⇒ 追每版本几乎全是白跑。判断依据只有一条:**稳定版版本号变了**才需要重新同步
|
|
6
|
+
|
|
7
|
+
## 0.3.17
|
|
8
|
+
|
|
9
|
+
**不用开浏览器也能登录了 —— 手机号 + 短信验证码,纯 HTTP 走完**
|
|
10
|
+
|
|
11
|
+
那个卡了很久的「拖动滑块使图片角度为正」现在能自动过:用 magpie 设置里你自己选的
|
|
12
|
+
识图模型(`settings.json` 的 `vision`)判断旋转角度,然后纯 HTTP 把整条链走完。
|
|
13
|
+
|
|
14
|
+
### 之前为什么做不了,以及真相
|
|
15
|
+
|
|
16
|
+
旧文档(internals 第九节)写着「magicrew 没有这条路」。**那是错的,原因是找错了域名。**
|
|
17
|
+
登录用的用户中心不在 `i-magic-service.letsmagic.cn`,而在另一套 keewood 服务上:
|
|
18
|
+
|
|
19
|
+
| 站点 | 用户中心 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| 国内 letsmagic.cn | `https://i-keewood-service.teamshare.cn` |
|
|
22
|
+
| 国际 magicrew.ai | `https://keewood-service.magicrew.ai`(**没有 `i-` 前缀**) |
|
|
23
|
+
|
|
24
|
+
换成这个域名,整条链一个 404 都没有。
|
|
25
|
+
|
|
26
|
+
### 五步链路(全部实测)
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
POST /captcha/generate {account_type:"mobile", mobile, state_code}
|
|
30
|
+
→ {token, image(data:image/webp;base64,…), expire_time} ← 纯 JSON,无 cookie、无指纹
|
|
31
|
+
POST /captcha/verify {token, angle, account_type, mobile, state_code}
|
|
32
|
+
→ {verified:true, business_token:"biz_…"}
|
|
33
|
+
POST /v4/user/send-sms {type:"account_login", phone, state_code}
|
|
34
|
+
+ header X-Captcha-Token: <business_token> ← 这个 type 填错会炸
|
|
35
|
+
POST /v4/user/login {type:"phone_captcha", phone, state_code, code, device:{id,name}}
|
|
36
|
+
→ jwt + 组织
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**`angle` 不是一个裸角度,是加密过的整数**,前端那么算:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
l = deg, u = n (n = Date.now()%90000+10000)
|
|
43
|
+
d = 52249517 × (l + u) + 5976906165
|
|
44
|
+
angle = String(d & 0xFFFFFFFF) + String(n)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`deg` 就是「图片要顺时针转多少度」,**1 px = 1.6°,满行程 225 px = 360°**。
|
|
48
|
+
验证方式:抓浏览器真实提交的 4 组 angle,对每组正向穷举 0…359 **都唯一命中**,
|
|
49
|
+
命中值与拖动像素严格成 1.6 倍;另用图像互相关独立验算,拖 100px 前后帧相差 160°±0.25°。
|
|
50
|
+
|
|
51
|
+
### 识图判角度:问钟点,不问角度
|
|
52
|
+
|
|
53
|
+
**不要问模型「这图歪了多少度」** —— 实测填空 6 次错 5 次。改问
|
|
54
|
+
「主体自己的『上方向』指向钟面的几点」(12=正上、3=正右),再 `deg = (12 - clock) × 30`。
|
|
55
|
+
|
|
56
|
+
而且**必须问两遍比分歧**:实测的失败样本是两个判官给出 1.7 和 7.5 钟点(相差 174°),
|
|
57
|
+
其中一个把方向看反了 —— 这种情况取平均毫无意义,只能换图重来。
|
|
58
|
+
分歧阈值 1.5 钟点(45°)。
|
|
59
|
+
|
|
60
|
+
服务端**容差实测 ≥ ±10°**(正确答案 120°,故意提交 130° 仍然通过),所以钟点法的
|
|
61
|
+
精度是够的:24 格 15° 网格的那点误差根本不影响。
|
|
62
|
+
|
|
63
|
+
⚠ `Bun.Image.rotate()` **只支持 90° 的倍数**(`rotate: only multiples of 90 are supported`),
|
|
64
|
+
`bytes()`/`buffer()` 给的是编码后的 PNG、拿不到原始像素 —— 所以「旋转 24 格网格」
|
|
65
|
+
这条路在插件里走不通,才改用钟点法。
|
|
66
|
+
|
|
67
|
+
### 新增
|
|
68
|
+
|
|
69
|
+
- `src/captcha.mjs` —— 取图 / 角度加密 / 验证 / 发短信 / 验证码换登录态 / 三站域名映射
|
|
70
|
+
- `src/vision.mjs` —— 读 `settings.json` 的 `vision`、找 magpie 自己的网关
|
|
71
|
+
(默认 `127.0.0.1:3425`,`MAGPIE_GATEWAY_PORT` 可覆盖)、webp→png、钟点双问与分歧判定
|
|
72
|
+
- `src/headless.mjs` —— 编排:最多换 4 张图、每张只提交一次(风控不允许反复试同一 token)、
|
|
73
|
+
失败把每轮原因带出来
|
|
74
|
+
- 登录方法「手机号验证码」—— `prompts` 问手机号,`authorize` 里过验证并发短信,
|
|
75
|
+
然后 `method: "code"` 等用户填 6 位验证码,`callback(code)` 换登录态并建号
|
|
76
|
+
|
|
77
|
+
### 两个坑,都是实测撞出来的
|
|
78
|
+
|
|
79
|
+
1. **`send-sms` 的 `type` 必须是 `"account_login"`**。填 `"login"` / `"phone_captcha"`
|
|
80
|
+
/ `"sms_login"` 全部返回 `9999 服务异常,请联系管理员` —— 后端内部炸了,不是参数校验,
|
|
81
|
+
所以从错误信息上完全看不出是 type 的问题。这个值是从浏览器真实请求里抓出来的。
|
|
82
|
+
2. **`device` 是必填且结构要试**:缺 → 9999;给字符串 → 9999;
|
|
83
|
+
`{device_id}` → 「id 字段是必须的」;`{id, name}` → 放行。
|
|
84
|
+
⇒ `{id: 64 位 hex, name: "web"}`。
|
|
85
|
+
|
|
86
|
+
### 另外两个更贵的修正(第一版发出去就踩了)
|
|
87
|
+
|
|
88
|
+
1. **`opened` 必须是 `true`。** 这条链路全在后台走完,不需要任何浏览器;写成 `false`
|
|
89
|
+
会让 magpie 拿 `url` 去开**系统默认浏览器** —— 而那个浏览器里往往本来就登着这个站,
|
|
90
|
+
用户看到的是已登录界面,进不了「添加新号」,还以为插件把他登进了旧账号。
|
|
91
|
+
这正是浏览器登录那条路修过的坑,这次在新方法里又踩了一遍。
|
|
92
|
+
2. **新号注册要带 `auto_register: true` 和 `redirect`,而邀请码是靠 `redirect` 传的。**
|
|
93
|
+
网页表单的初始值就是
|
|
94
|
+
`{ phone, state_code, code, auto_register: true, type: "phone_captcha", redirect: window.location.href }` —
|
|
95
|
+
服务端从 `redirect` 上取 `?invitation=<码>`,`invite_code` 不是独立字段。
|
|
96
|
+
⇒ 无头这条路没有「打开带邀请码的页面」这一步,所以必须自己拼出带邀请码的
|
|
97
|
+
`redirect: ${web}/login?invitation=<码>`。少了它,新注册的号就没有邀请关系。
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
### 没配识图模型时
|
|
101
|
+
|
|
102
|
+
**不瞎猜。** 容差 ±10° 意味着瞎猜命中率只有约 5.6%(20/360),平均要试十几次,
|
|
103
|
+
先撞上的会是「验证提交过于频繁」。这种情况直接报错并指路:去 magpie 设置里配一个
|
|
104
|
+
识图模型,或者改用「浏览器登录」。
|
|
105
|
+
|
|
106
|
+
### ⚠ 未实测的一步
|
|
107
|
+
|
|
108
|
+
`/v4/user/login` 成功之后**响应里 jwt / 组织码的确切结构没验到** —— 手上没有能收短信的
|
|
109
|
+
号码,只能验到 `2168 验证码错误`(这已经证明参数结构对了)。所以那一步写成:
|
|
110
|
+
按 `eyJ` 三段式特征找 JWT、组织码找不着就用拿到的 JWT 调
|
|
111
|
+
`/api/v1/aggregation/login-data` 补;两条都拿不到时把原始响应带进错误信息。
|
|
112
|
+
|
|
3
113
|
## 0.3.16
|
|
4
114
|
|
|
5
115
|
**加了给 AI 执行者的入口 —— 因为「发布」这一步真的被漏过一次**
|
package/docs/internals.md
CHANGED
|
@@ -340,6 +340,29 @@ cat "<安装目录>/resources/app-update.yml"
|
|
|
340
340
|
|
|
341
341
|
## 九、为什么做不到「点一下全自动」
|
|
342
342
|
|
|
343
|
+
> ### ⚠ 更正(2026-10-28):这一节原来的结论是**错的**
|
|
344
|
+
>
|
|
345
|
+
> 原文断言「magicrew 没有这条路」,理由是「登录类型全是拿凭据换 token,没有设备码、
|
|
346
|
+
> 没有一次性 ticket」。**真正的错因是找错了域名** —— 登录用的用户中心不在
|
|
347
|
+
> `i-magic-service.letsmagic.cn`,而在 keewood:
|
|
348
|
+
>
|
|
349
|
+
> | 站点 | 用户中心 |
|
|
350
|
+
> |---|---|
|
|
351
|
+
> | 国内 | `https://i-keewood-service.teamshare.cn` |
|
|
352
|
+
> | 国际 | `https://keewood-service.magicrew.ai`(**没有 `i-` 前缀**) |
|
|
353
|
+
>
|
|
354
|
+
> 换成这个域名,整条链一个 404 都没有:`/captcha/generate` → `/captcha/verify` →
|
|
355
|
+
> `/v4/user/send-sms` → `/v4/user/login`,**全程纯 HTTP、不开浏览器**。
|
|
356
|
+
> 那个「拖动滑块使图片角度为正」的答案就是提交参数里的 `angle`(加密过的整数),
|
|
357
|
+
> 用 magpie 设置里的识图模型判「主体上方向指向钟面几点」就能算出来。
|
|
358
|
+
>
|
|
359
|
+
> 实现见 `src/captcha.mjs` / `src/vision.mjs` / `src/headless.mjs`,
|
|
360
|
+
> 以及登录方法「手机号验证码」。下面保留原文,只为了说明**当时为什么判断错了**。
|
|
361
|
+
|
|
362
|
+
下面的分析**在换对域名之前**成立,其中「① 13 种登录类型」和「③ 试过的三个端点」
|
|
363
|
+
仍然是事实,只是它们不能推出「没有这条路」——真正缺的那个域名当时没找到。
|
|
364
|
+
|
|
365
|
+
|
|
343
366
|
对比 OfficeAce:它能做到「浏览器登录完,magpie 窗口自动继续」,因为华为云 OAuth 的
|
|
344
367
|
`redirect_uri` 指向一个**可控的服务**(`${CLAW_BASE}/v1/claw/auth/callback`),
|
|
345
368
|
插件轮询那个服务取结果。magicrew 没有这条通道:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magicrew-auth",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
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
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// 无头登录的底层:旋转验证码 + 手机验证码换取登录态
|
|
2
|
+
//
|
|
3
|
+
// 全部是**纯 HTTP**,不开浏览器、不读 localStorage。协议是从网页版
|
|
4
|
+
// `SMSVerificationCodeForm` chunk 里逆出来的,每一步都实测过
|
|
5
|
+
// (见 docs/internals.md「无头登录」一节):
|
|
6
|
+
//
|
|
7
|
+
// ① POST /captcha/generate { account_type, mobile, state_code }
|
|
8
|
+
// → { token, image(data:image/webp;base64,…), expire_time }
|
|
9
|
+
// ② 算角度 → angle = enc(deg, n)
|
|
10
|
+
// ③ POST /captcha/verify { token, angle, account_type, mobile, state_code }
|
|
11
|
+
// → { verified: true, business_token: "biz_…" }
|
|
12
|
+
// ④ POST /v4/user/send-sms { type, phone, state_code }
|
|
13
|
+
// + header `X-Captcha-Token: <business_token>`
|
|
14
|
+
// ⑤ POST /v4/user/login { type: "phone_captcha", phone, state_code, code, device }
|
|
15
|
+
//
|
|
16
|
+
// ⚠ 三个域名各管一段,别混:
|
|
17
|
+
// www.<站点> → 前端页面(只用来取 Origin,别的没用)
|
|
18
|
+
// i-keewood-service… → captcha / v4 用户中心(①②③④⑤ 全在这)
|
|
19
|
+
// 模型网关 → 与登录无关
|
|
20
|
+
//
|
|
21
|
+
// ⚠ 风控是真的(实测):
|
|
22
|
+
// · 「验证次数已超限」 同一 token 试错有上限
|
|
23
|
+
// · 「验证不存在或已过期」 expire_time 给 10 分钟
|
|
24
|
+
// · 「验证提交过于频繁」 连续批量提交会被拦
|
|
25
|
+
// ⇒ 不能枚举角度,一次要算准;失败就换图重来。
|
|
26
|
+
|
|
27
|
+
/** 服务端只认这一套 Unicode;错误码直接用数字,日志里好 grep */
|
|
28
|
+
export const CAPTCHA_CODE = {
|
|
29
|
+
OK: 1000,
|
|
30
|
+
BAD: 2000,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 用户中心的域名按站点分,**两站的命名规则还不一样**(别照抄前缀):
|
|
35
|
+
*
|
|
36
|
+
* 国内 www.letsmagic.cn → https://i-keewood-service.teamshare.cn ← 有 `i-`,根域是 teamshare.cn
|
|
37
|
+
* 国际 www.magicrew.ai → https://keewood-service.magicrew.ai ← 没有 `i-`
|
|
38
|
+
*
|
|
39
|
+
* 出处:桌面客户端内嵌配置里的 `MAGIC_SERVICE_KEEWOOD_BASE_URL`
|
|
40
|
+
* (`GET https://www.magicrew.ai/workspace` 的 HTML 里能直接读到)。
|
|
41
|
+
* 两个都实测过 `POST /captcha/generate` 回 200。
|
|
42
|
+
*/
|
|
43
|
+
export function keewoodOf(site) {
|
|
44
|
+
return site.id === "magicrew" ? "https://i-keewood-service.teamshare.cn" : "https://keewood-service.magicrew.ai"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 拖动角度 → 服务端要的 angle 字符串。
|
|
49
|
+
*
|
|
50
|
+
* 前端是这么算的(`SMSVerificationCodeForm` chunk,混淆过但结构清楚):
|
|
51
|
+
*
|
|
52
|
+
* Se(deg, n) = ((52249517 * (deg + n) + 5976906165) & 0xFFFFFFFF)
|
|
53
|
+
* angle = String(Se(deg, n)) + String(n) // n = Date.now() % 90000 + 10000
|
|
54
|
+
*
|
|
55
|
+
* 验证方式:抓浏览器真实提交的 4 组 angle,对每组正向穷举 deg 0…359,
|
|
56
|
+
* **每组都唯一命中**,且命中值与拖动像素严格成 1.6°/px(225px = 360°)。
|
|
57
|
+
* 所以这里照抄,别改系数。
|
|
58
|
+
*
|
|
59
|
+
* ⚠ deg 是「图片顺时针旋转的角度」,0…359。不是「还差多少度」。
|
|
60
|
+
*/
|
|
61
|
+
export function encodeAngle(deg, n) {
|
|
62
|
+
const d = ((BigInt(deg) % 360n) + 360n) % 360n
|
|
63
|
+
const nn = BigInt(n)
|
|
64
|
+
const v = (52249517n * (d + nn) + 5976906165n) & 0xffffffffn
|
|
65
|
+
return String(v) + String(n)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 服务端给的 n 是 5 位(10000…99999);我们自己造一个同样形状的 */
|
|
69
|
+
export function randomN() {
|
|
70
|
+
return Math.floor(Math.random() * 90000) + 10000
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 钟点 → deg。
|
|
75
|
+
*
|
|
76
|
+
* 判官回答的是「主体自己的上方向指向钟面的几点」(12=正上、3=正右)。
|
|
77
|
+
* 图要**顺时针**转多少度才立起来 = (12 - clock) * 30。
|
|
78
|
+
*/
|
|
79
|
+
export function clockToDeg(clock) {
|
|
80
|
+
const x = Number(clock)
|
|
81
|
+
if (!Number.isFinite(x)) return null
|
|
82
|
+
return Math.round((((12 - x) * 30) % 360 + 360) % 360)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 两个判官分歧多大(按钟点,取最短环距 0…6) */
|
|
86
|
+
export function clockSpread(a, b) {
|
|
87
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return 6
|
|
88
|
+
const d = Math.abs(((a - b) % 12 + 12) % 12)
|
|
89
|
+
return Math.min(d, 12 - d)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 分歧超过这个值就认为「有人把方向看反了」,宁可重来也不要瞎提交 */
|
|
93
|
+
export const CLOCK_MAX_SPREAD = 1.5
|
|
94
|
+
|
|
95
|
+
async function postJSON(url, body, { headers = {}, timeoutMs = 20_000 } = {}) {
|
|
96
|
+
const res = await fetch(url, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "content-type": "application/json", ...headers },
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
})
|
|
102
|
+
const text = await res.text()
|
|
103
|
+
let json = null
|
|
104
|
+
try {
|
|
105
|
+
json = JSON.parse(text)
|
|
106
|
+
} catch {
|
|
107
|
+
/* 非 JSON 就当文本报出去 */
|
|
108
|
+
}
|
|
109
|
+
return { status: res.status, text, json }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** ① 取图。返回 null 表示服务端认为这个号**已经注册**,直接给了 business_token(跳过验证) */
|
|
113
|
+
export async function generateCaptcha(site, { mobile, stateCode = "+86" } = {}) {
|
|
114
|
+
const host = keewoodOf(site)
|
|
115
|
+
const { json } = await postJSON(`${host}/captcha/generate`, {
|
|
116
|
+
account_type: "mobile",
|
|
117
|
+
mobile,
|
|
118
|
+
state_code: stateCode,
|
|
119
|
+
}, { headers: { origin: site.web } })
|
|
120
|
+
|
|
121
|
+
const data = json?.data ?? {}
|
|
122
|
+
if (data.business_token) return { registered: true, businessToken: data.business_token }
|
|
123
|
+
if (!data.token || !data.image) {
|
|
124
|
+
throw new Error(`取验证码失败:${json?.message ?? "没有 token/image"}`)
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
registered: false,
|
|
128
|
+
token: data.token,
|
|
129
|
+
image: String(data.image),
|
|
130
|
+
expireAt: data.expire_time ? Number(data.expire_time) * 1000 : null,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ③ 提交角度 */
|
|
135
|
+
export async function verifyCaptcha(site, { token, deg, mobile, stateCode = "+86" }) {
|
|
136
|
+
const host = keewoodOf(site)
|
|
137
|
+
const n = randomN()
|
|
138
|
+
const { json } = await postJSON(`${host}/captcha/verify`, {
|
|
139
|
+
account_type: "mobile",
|
|
140
|
+
token,
|
|
141
|
+
angle: encodeAngle(deg, n),
|
|
142
|
+
mobile,
|
|
143
|
+
state_code: stateCode,
|
|
144
|
+
}, { headers: { origin: site.web } })
|
|
145
|
+
|
|
146
|
+
const data = json?.data ?? {}
|
|
147
|
+
if (data.verified && data.business_token) return { ok: true, businessToken: data.business_token }
|
|
148
|
+
return { ok: false, message: json?.message ?? "验证失败" }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* ④ 发短信。businessToken 缺了/假的都会被打回来,所以它必须是真的。
|
|
153
|
+
*
|
|
154
|
+
* ⚠ `type` 是 **`"account_login"`**,不是 `"login"`、也不是登录枚举里的 `phone_captcha`。
|
|
155
|
+
* 填错的表现是「服务异常,请联系管理员」(9999) —— 后端内部炸了,不是参数校验失败,
|
|
156
|
+
* 所以从错误信息上完全看不出是 type 的问题。这个值是从浏览器真实请求里抓到的:
|
|
157
|
+
* {"type":"account_login","phone":"199…","state_code":"+86"}
|
|
158
|
+
* ⇒ 别再按直觉改它。
|
|
159
|
+
*/
|
|
160
|
+
export async function sendSms(site, { phone, stateCode = "+86", businessToken, type = "account_login" }) {
|
|
161
|
+
const host = keewoodOf(site)
|
|
162
|
+
const { json } = await postJSON(`${host}/v4/user/send-sms`, {
|
|
163
|
+
type,
|
|
164
|
+
phone,
|
|
165
|
+
state_code: stateCode,
|
|
166
|
+
}, { headers: { origin: site.web, "X-Captcha-Token": businessToken || "" } })
|
|
167
|
+
|
|
168
|
+
if (json?.code === CAPTCHA_CODE.OK) return { ok: true }
|
|
169
|
+
return { ok: false, message: json?.message ?? "发送短信失败" }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* ⑤ 验证码换登录态。
|
|
174
|
+
*
|
|
175
|
+
* ⚠ `device` 是必须的,而且结构是一层层试出来的:
|
|
176
|
+
* 缺 device → 「系统异常」(9999)
|
|
177
|
+
* device 不是对象 → 「系统异常」
|
|
178
|
+
* { device_id } → 「id 字段是必须的」
|
|
179
|
+
* { id, name } → 放行,返回 2168「验证码错误」
|
|
180
|
+
* ⇒ { id: 64 位 hex, name: "web" } 就够。
|
|
181
|
+
*
|
|
182
|
+
* ⚠ `auto_register` / `redirect` 一个都不能省(这是网页表单的初始值,抠出来的):
|
|
183
|
+
*
|
|
184
|
+
* { phone, state_code, code, auto_register: true,
|
|
185
|
+
* type: "phone_captcha", redirect: window.location.href }
|
|
186
|
+
*
|
|
187
|
+
* · `auto_register: true` —— 否则新号登不进来(只会报账号不存在)
|
|
188
|
+
* · `redirect` 是**当前页面的 URL**,服务端从它上面取 `?invitation=<码>`。
|
|
189
|
+
* ⇒ **邀请码是靠 redirect 传的,不是独立字段**。这就是为什么浏览器登录的
|
|
190
|
+
* URL 必须带 `?invitation=`:不带,新注册的号就没有邀请关系。
|
|
191
|
+
*/
|
|
192
|
+
export async function loginBySmsCode(
|
|
193
|
+
site,
|
|
194
|
+
{ phone, stateCode = "+86", code, device, autoRegister = true, redirect },
|
|
195
|
+
) {
|
|
196
|
+
const host = keewoodOf(site)
|
|
197
|
+
const body = {
|
|
198
|
+
type: "phone_captcha",
|
|
199
|
+
phone,
|
|
200
|
+
state_code: stateCode,
|
|
201
|
+
code,
|
|
202
|
+
auto_register: autoRegister,
|
|
203
|
+
device: device ?? { id: makeDeviceId(), name: "web" },
|
|
204
|
+
}
|
|
205
|
+
if (redirect) body.redirect = redirect
|
|
206
|
+
|
|
207
|
+
const { json, status, text } = await postJSON(`${host}/v4/user/login`, body, {
|
|
208
|
+
headers: { origin: site.web },
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
return { json, status, text }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 64 位 hex,形状与网页版 cus.device_id 一致 */
|
|
215
|
+
export function makeDeviceId() {
|
|
216
|
+
const bytes = new Uint8Array(32)
|
|
217
|
+
crypto.getRandomValues(bytes)
|
|
218
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 从登录响应里把 jwt / 组织码抠出来。
|
|
223
|
+
*
|
|
224
|
+
* ⚠ 这两个字段的真实形状**没实测过**(手上没有能收短信的号,只能验到 2168)。
|
|
225
|
+
* 所以这里做成「穷举常见键名 + 顺着对象往下找」的两层兜底,
|
|
226
|
+
* 并且把原始响应留在返回值里,出问题时日志能直接看到该怎么改。
|
|
227
|
+
*/
|
|
228
|
+
export function extractCreds(payload) {
|
|
229
|
+
const seen = new Set()
|
|
230
|
+
let jwt = null
|
|
231
|
+
let org = null
|
|
232
|
+
|
|
233
|
+
const looksJwt = (v) => typeof v === "string" && v.startsWith("eyJ") && v.split(".").length === 3
|
|
234
|
+
const looksOrg = (v) => typeof v === "string" && /^[a-z0-9]{6,32}$/i.test(v) && !looksJwt(v)
|
|
235
|
+
|
|
236
|
+
// 这些键名是服务端真实用的(实测 `login-data` 的形状),优先级高于「键名里带 org」的泛匹配 ——
|
|
237
|
+
// 后者会撞上 `organization_name: ""`、`organization_logo: null` 这类同名前缀的字段。
|
|
238
|
+
const STRONG_ORG_KEYS = [
|
|
239
|
+
"magic_organization_code",
|
|
240
|
+
"third_platform_organization_code",
|
|
241
|
+
"teamshare_organization_code",
|
|
242
|
+
"organization_code",
|
|
243
|
+
"organizationCode",
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
const walk = (node, depth) => {
|
|
247
|
+
if (!node || depth > 5 || seen.has(node)) return
|
|
248
|
+
if (typeof node !== "object") return
|
|
249
|
+
seen.add(node)
|
|
250
|
+
|
|
251
|
+
for (const [k, v] of Object.entries(node)) {
|
|
252
|
+
if (!jwt && looksJwt(v)) jwt = v
|
|
253
|
+
if (!org && STRONG_ORG_KEYS.includes(k) && looksOrg(v)) org = v
|
|
254
|
+
}
|
|
255
|
+
// 弱匹配兜底:键名带 org 且值形似组织码
|
|
256
|
+
if (!org) {
|
|
257
|
+
for (const [k, v] of Object.entries(node)) {
|
|
258
|
+
if (looksOrg(v) && /org/i.test(k)) {
|
|
259
|
+
org = v
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
for (const v of Object.values(node)) {
|
|
265
|
+
if (v && typeof v === "object") {
|
|
266
|
+
if (Array.isArray(v)) {
|
|
267
|
+
for (const item of v.slice(0, 5)) walk(item, depth + 1)
|
|
268
|
+
} else walk(v, depth + 1)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
walk(payload, 0)
|
|
273
|
+
|
|
274
|
+
return { jwt, org }
|
|
275
|
+
}
|
package/src/headless.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// 无头登录的编排:一条纯 HTTP 的链路,不开浏览器
|
|
2
|
+
//
|
|
3
|
+
// 取图 → 识图判角度 → 过验证 → 发短信 → (用户填码)→ 换登录态 → 拿组织码
|
|
4
|
+
//
|
|
5
|
+
// 每一步都实测过(见 docs/internals.md「无头登录」):
|
|
6
|
+
// · 取图/过验证在 keewood 用户中心,纯 JSON,不需要 cookie 或指纹
|
|
7
|
+
// · 角度必须「一次算准」:同一 token 试错次数有限,且高频会被拦
|
|
8
|
+
// · 识图模型是用户在 magpie 里选的那个(settings.json 的 vision)
|
|
9
|
+
//
|
|
10
|
+
// ⚠ 没有识图模型时**不在这里瞎猜** —— 猜中的概率只有 ~5.6%(容差 ±10°),
|
|
11
|
+
// 平均要试十几次,先撞上的会是「验证提交过于频繁」。那种情况走浏览器回退
|
|
12
|
+
// (见 hooks/methods.mjs 的 another method),让用户自己转那一下。
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
extractCreds,
|
|
16
|
+
generateCaptcha,
|
|
17
|
+
loginBySmsCode,
|
|
18
|
+
sendSms,
|
|
19
|
+
verifyCaptcha,
|
|
20
|
+
} from "./captcha.mjs"
|
|
21
|
+
import { decideDegFromImage, readVisionModel } from "./vision.mjs"
|
|
22
|
+
|
|
23
|
+
/** 一次登录最多换几张图;每张图只提交一次(风控不允许反复试同一个 token) */
|
|
24
|
+
const MAX_CAPTCHA_ROUNDS = 4
|
|
25
|
+
/** 两次提交之间歇一下,避开「验证提交过于频繁」 */
|
|
26
|
+
const ROUND_GAP_MS = 2500
|
|
27
|
+
|
|
28
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 过掉旋转验证,拿到 business_token。
|
|
32
|
+
*
|
|
33
|
+
* @returns {Promise<{businessToken:string, rounds:number, log:string[]}>}
|
|
34
|
+
*/
|
|
35
|
+
export async function passCaptcha(site, { mobile, stateCode = "+86" } = {}) {
|
|
36
|
+
const log = []
|
|
37
|
+
let lastReason = ""
|
|
38
|
+
|
|
39
|
+
for (let round = 1; round <= MAX_CAPTCHA_ROUNDS; round++) {
|
|
40
|
+
const cap = await generateCaptcha(site, { mobile, stateCode })
|
|
41
|
+
// 已注册的号,服务端直接给 business_token,连图都不用看
|
|
42
|
+
if (cap.registered) {
|
|
43
|
+
log.push(`第 ${round} 轮:服务端直接放行(该号已注册)`)
|
|
44
|
+
return { businessToken: cap.businessToken, rounds: round, log }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const decided = await decideDegFromImage(cap.image)
|
|
48
|
+
if (!decided.ok) {
|
|
49
|
+
// 判不出来 / 两个判官对着干 → 换一张图,不要浪费一次提交
|
|
50
|
+
lastReason = `${decided.reason}${decided.detail ? `:${decided.detail}` : ""}`
|
|
51
|
+
log.push(`第 ${round} 轮:判角度不可信(${lastReason}),换图`)
|
|
52
|
+
if (round < MAX_CAPTCHA_ROUNDS) await sleep(500)
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const res = await verifyCaptcha(site, { token: cap.token, deg: decided.deg, mobile, stateCode })
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
log.push(`第 ${round} 轮:deg=${decided.deg}(钟点 ${decided.clock?.toFixed?.(2) ?? "?"},分歧 ${decided.spread?.toFixed?.(1) ?? 0})→ 通过`)
|
|
59
|
+
return { businessToken: res.businessToken, rounds: round, log }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lastReason = res.message
|
|
63
|
+
log.push(`第 ${round} 轮:deg=${decided.deg} 没通过(${res.message})`)
|
|
64
|
+
if (round < MAX_CAPTCHA_ROUNDS) await sleep(ROUND_GAP_MS)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw Object.assign(
|
|
68
|
+
new Error(`旋转验证连试 ${MAX_CAPTCHA_ROUNDS} 张图都没过(最后一次:${lastReason})`),
|
|
69
|
+
{ captchaLog: log },
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 第 1 步:发短信。成功即代表「验证已经过了」——服务端把 business_token 当门票。
|
|
75
|
+
*/
|
|
76
|
+
export async function startSmsLogin(site, { mobile, stateCode = "+86" } = {}) {
|
|
77
|
+
if (!readVisionModel()) {
|
|
78
|
+
// 调用方应该先判断这个;走到这里说明判断被绕过了 —— 明确报出来,别静默瞎猜
|
|
79
|
+
throw Object.assign(new Error("没有配置识图模型,不能无头过旋转验证"), { code: "no-vision-model" })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { businessToken, rounds, log } = await passCaptcha(site, { mobile, stateCode })
|
|
83
|
+
const sms = await sendSms(site, { phone: mobile, stateCode, businessToken })
|
|
84
|
+
if (!sms.ok) {
|
|
85
|
+
throw Object.assign(new Error(`发短信失败:${sms.message}`), { captchaLog: log })
|
|
86
|
+
}
|
|
87
|
+
return { businessToken, rounds, log }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 第 2 步:验证码换登录态,并补齐组织码。
|
|
92
|
+
*
|
|
93
|
+
* ⚠ 登录响应里 jwt / 组织码的**确切形状没实测过**(手上没有能收短信的号,
|
|
94
|
+
* 只能验到 2168「验证码错误」)。所以:
|
|
95
|
+
* · jwt 用 extractCreds 从响应里按特征找(以 eyJ 开头的三段式)
|
|
96
|
+
* · 组织码找不着就调 `/api/v1/aggregation/login-data`(带 authorization 头,
|
|
97
|
+
* 服务端会回这个账号所在的组织列表)—— 这条路径是文档里查得到的
|
|
98
|
+
*/
|
|
99
|
+
export async function finishSmsLogin(site, { mobile, stateCode = "+86", code, device, redirect }) {
|
|
100
|
+
const { json, status, text } = await loginBySmsCode(site, {
|
|
101
|
+
phone: mobile,
|
|
102
|
+
stateCode,
|
|
103
|
+
code,
|
|
104
|
+
device,
|
|
105
|
+
autoRegister: true,
|
|
106
|
+
redirect,
|
|
107
|
+
})
|
|
108
|
+
if (!json) {
|
|
109
|
+
throw new Error(`登录接口回了非 JSON(HTTP ${status}):${String(text).slice(0, 200)}`)
|
|
110
|
+
}
|
|
111
|
+
if (json.code !== 1000) {
|
|
112
|
+
// 2168 是验证码错;其余原样透出,别把它吞成「登录失败」
|
|
113
|
+
throw Object.assign(new Error(`登录失败:${json.message ?? json.code}`), {
|
|
114
|
+
code: json.code,
|
|
115
|
+
raw: json,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { jwt, org } = extractLoginCreds(json)
|
|
120
|
+
if (!jwt) {
|
|
121
|
+
throw Object.assign(new Error("登录成功但响应里没找到 JWT —— 请把这条原始响应反馈给插件作者"), {
|
|
122
|
+
raw: json,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
// 响应里给了组织码就用它;没给就把该账号的组织**全列出来**,
|
|
126
|
+
// 由调用方逐个拿 fetchPoints 验(见 methods.mjs 的 completeSignIn)——
|
|
127
|
+
// 只取第一个是不够的:多组织账号会落到「列表第一个」而不是用户平时用的那个。
|
|
128
|
+
const orgs = org ? [org] : await fetchOrgs(site, jwt)
|
|
129
|
+
if (!orgs.length) {
|
|
130
|
+
throw Object.assign(new Error("登录成功但拿不到组织码(该账号没有任何组织?)"), { raw: json })
|
|
131
|
+
}
|
|
132
|
+
return { jwt, org: orgs[0], orgs, raw: json }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function extractLoginCreds(json) {
|
|
136
|
+
// 只认 eyJ 三段式当 JWT;组织码优先取键名带 org 的那个
|
|
137
|
+
const { jwt, org } = extractCreds(json)
|
|
138
|
+
return { jwt, org }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 这个账号属于哪些组织(按服务端返回的顺序)。
|
|
143
|
+
*
|
|
144
|
+
* ⚠ 浏览器登录能直接读到用户**当前选中**的组织(localStorage 里的
|
|
145
|
+
* `MAGIC:user:organizationCode`),无头这条路没有那个信息 —— 所以只能把候选
|
|
146
|
+
* 全拿回来,让调用方逐个验,验不过再换下一个。单组织账号没有区别;
|
|
147
|
+
* 多组织账号靠这一步才不会落错地方。
|
|
148
|
+
*/
|
|
149
|
+
export async function fetchOrgs(site, jwt) {
|
|
150
|
+
const res = await fetch(`${site.host}/api/v1/aggregation/login-data?teamshare_login_code=`, {
|
|
151
|
+
headers: {
|
|
152
|
+
authorization: `Bearer ${jwt}`,
|
|
153
|
+
"user-agent": "magpie-magicrew-auth",
|
|
154
|
+
},
|
|
155
|
+
signal: AbortSignal.timeout(20_000),
|
|
156
|
+
}).catch(() => null)
|
|
157
|
+
if (!res?.ok) return []
|
|
158
|
+
const j = await res.json().catch(() => null)
|
|
159
|
+
const list = j?.data?.auth_status ?? j?.auth_status ?? []
|
|
160
|
+
if (!Array.isArray(list)) return []
|
|
161
|
+
|
|
162
|
+
// ⚠ 字段名是 `magic_organization_code`(实测)。`organization_code` 只在**外层**存在,
|
|
163
|
+
// 而且是空串 —— 认它就会拿到 ""。每项里另有 third_platform_ / teamshare_ 两份同值码。
|
|
164
|
+
const KEYS = [
|
|
165
|
+
"magic_organization_code",
|
|
166
|
+
"third_platform_organization_code",
|
|
167
|
+
"teamshare_organization_code",
|
|
168
|
+
]
|
|
169
|
+
const out = []
|
|
170
|
+
for (const item of list) {
|
|
171
|
+
for (const key of KEYS) {
|
|
172
|
+
const v = item?.[key]
|
|
173
|
+
if (typeof v === "string" && /^[a-z0-9]{6,32}$/i.test(v) && !out.includes(v)) {
|
|
174
|
+
out.push(v)
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return out
|
|
180
|
+
}
|
package/src/hooks/methods.mjs
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, readKnownOrgs, rollInvite } from "../store.mjs"
|
|
22
22
|
import { readLocalCreds } from "../local-browser.mjs"
|
|
23
23
|
import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
|
|
24
|
+
import { readVisionModel } from "../vision.mjs"
|
|
25
|
+
import { finishSmsLogin, startSmsLogin } from "../headless.mjs"
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* 账号刚加进来(登录成功)→ 一次性把「首次 AI 工作 / 首个对话」补掉。
|
|
@@ -35,8 +37,198 @@ function kickoffFirstTask(c, jwt, org) {
|
|
|
35
37
|
.catch(() => {})
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/**
|
|
41
|
+
* 登录拿到 jwt + org 之后的统一收尾。
|
|
42
|
+
*
|
|
43
|
+
* 无头登录和浏览器登录最后都落到这里,保证「验证一次 / 建 key / 落 session /
|
|
44
|
+
* 记组织 / 滚邀请码 / 补首任务 / 取昵称」这一串只有一份实现。
|
|
45
|
+
*
|
|
46
|
+
* `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
|
|
47
|
+
* 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算 —— 多组织账号靠这个
|
|
48
|
+
* 才不会落错地方。浏览器登录只有一个候选,行为不变。
|
|
49
|
+
*/
|
|
50
|
+
async function completeSignIn(c, jwt, org, orgs = []) {
|
|
51
|
+
const candidates = [org, ...orgs.filter((o) => o && o !== org)]
|
|
52
|
+
let lastErr = null
|
|
53
|
+
let picked = null
|
|
54
|
+
|
|
55
|
+
for (const o of candidates) {
|
|
56
|
+
try {
|
|
57
|
+
// 真的验一次:读积分能过,说明 JWT + 组织码都对
|
|
58
|
+
await fetchPoints(jwt, o, c.host)
|
|
59
|
+
picked = o
|
|
60
|
+
break
|
|
61
|
+
} catch (e) {
|
|
62
|
+
lastErr = e
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!picked) throw lastErr ?? new Error("没有可用的组织码")
|
|
66
|
+
|
|
67
|
+
const key = await issueGatewayKey(jwt, picked, c.host)
|
|
68
|
+
writeSession(c.id, jwt, picked)
|
|
69
|
+
noteOrg(c.id, picked)
|
|
70
|
+
// 滚邀请码:把这个账号自己的码存成下一次用的
|
|
71
|
+
await rollInvite(c.id, jwt, picked, c.host)
|
|
72
|
+
kickoffFirstTask(c, jwt, picked)
|
|
73
|
+
const who = await fetchWho(jwt, picked, c.host).catch(() => null)
|
|
74
|
+
return {
|
|
75
|
+
type: "success",
|
|
76
|
+
access: key.apiKey,
|
|
77
|
+
refresh: key.refreshToken,
|
|
78
|
+
expires: Date.now() + 2 * 3600_000,
|
|
79
|
+
accountId: who?.name || picked,
|
|
80
|
+
uid: picked,
|
|
81
|
+
jwt,
|
|
82
|
+
org: picked,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
38
86
|
export function makeMethods(c) {
|
|
39
87
|
return [
|
|
88
|
+
{
|
|
89
|
+
// 手机号 + 短信验证码,**不开浏览器**。
|
|
90
|
+
//
|
|
91
|
+
// 卡点从来不是协议,是那张「拖动滑块使图片角度为正」的图。现在的做法:
|
|
92
|
+
// 用 magpie 设置里用户自己选的识图模型(settings.json 的 vision)
|
|
93
|
+
// 判断「主体上方向指向钟面几点」,再换算成拖动角度 —— 实测 120° 的图判出 122°,
|
|
94
|
+
// 而服务端容差 ±10°,够用。
|
|
95
|
+
//
|
|
96
|
+
// 没配识图模型时不在这里瞎猜(猜中概率只有 ~5.6%,会先撞「验证提交过于频繁」),
|
|
97
|
+
// 而是让这条方法直接报错并指路 —— 详见下面那段说明。
|
|
98
|
+
type: "oauth",
|
|
99
|
+
label: `${c.SHORT} 手机号验证码`,
|
|
100
|
+
prompts: [
|
|
101
|
+
{
|
|
102
|
+
type: "text",
|
|
103
|
+
key: "phone",
|
|
104
|
+
message: "手机号",
|
|
105
|
+
placeholder: "13800138000",
|
|
106
|
+
validate: (v) =>
|
|
107
|
+
/^1\d{10}$/.test(String(v || "").replace(/[\s-]/g, "")) ? undefined : "11 位手机号,只要数字",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
async authorize(inputs) {
|
|
111
|
+
const phone = String(inputs?.phone || "").replace(/[\s-]/g, "")
|
|
112
|
+
if (!/^1\d{10}$/.test(phone)) return { type: "failed", error: "手机号不完整" }
|
|
113
|
+
|
|
114
|
+
const vision = readVisionModel()
|
|
115
|
+
const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
|
|
116
|
+
|
|
117
|
+
if (!vision) {
|
|
118
|
+
// 没有识图模型 → 回退到浏览器:打不开就不硬撑。
|
|
119
|
+
//
|
|
120
|
+
// 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
|
|
121
|
+
// 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
|
|
122
|
+
// 等不到猜中,先被限流。所以老老实实让用户转那一下。
|
|
123
|
+
let browser = null
|
|
124
|
+
let why = ""
|
|
125
|
+
try {
|
|
126
|
+
browser = await import("../browser-login.mjs")
|
|
127
|
+
} catch (e) {
|
|
128
|
+
why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
|
|
129
|
+
noteLoginFailure(c.id, why)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const inviteCode = inviteToUse(c.id)
|
|
133
|
+
const url = inviteCode
|
|
134
|
+
? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
|
|
135
|
+
: `${c.web}/login`
|
|
136
|
+
const hostFragment = new URL(c.web).hostname
|
|
137
|
+
|
|
138
|
+
const session = browser
|
|
139
|
+
? await browser.launchLoginBrowser(url).catch((e) => {
|
|
140
|
+
why = String(e?.message ?? e).slice(0, 300)
|
|
141
|
+
noteLoginFailure(c.id, `launch: ${why}`)
|
|
142
|
+
return null
|
|
143
|
+
})
|
|
144
|
+
: null
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
url,
|
|
148
|
+
...(session ? { opened: true } : {}),
|
|
149
|
+
instructions: session
|
|
150
|
+
? `没有配置识图模型,那个「拖动滑块使图片角度为正」得你自己转一下 —— 已经用 ${session.name || "浏览器"} 打开了登录页。\n` +
|
|
151
|
+
`在里面选「手机登录」,填 ${masked},把图片转正、收到验证码后填进去;登录完成这个窗口会自动接上,不用回来点任何东西。\n` +
|
|
152
|
+
`(想全自动的话:magpie 设置 → 识图模型,选一个能看图的,下次就只需要填手机号和验证码。)`
|
|
153
|
+
: `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chromium 内核浏览器)"},已改用系统浏览器打开登录页。\n` +
|
|
154
|
+
`登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
|
|
155
|
+
method: "auto",
|
|
156
|
+
async callback() {
|
|
157
|
+
let got = null
|
|
158
|
+
if (session && browser) {
|
|
159
|
+
got = await browser.waitForCreds(session, hostFragment).catch(() => null)
|
|
160
|
+
await browser.closeSession(session).catch(() => {})
|
|
161
|
+
}
|
|
162
|
+
if (!got && !session) {
|
|
163
|
+
got = await pollClipboard({ skip: "" }).catch(() => null)
|
|
164
|
+
}
|
|
165
|
+
if (!got) {
|
|
166
|
+
return {
|
|
167
|
+
type: "failed",
|
|
168
|
+
error: session
|
|
169
|
+
? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
|
|
170
|
+
: "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
if (!got.org) throw new Error("只拿到 JWT,没有组织码")
|
|
175
|
+
return await completeSignIn(c, got.jwt, got.org)
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份。
|
|
184
|
+
// ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
|
|
185
|
+
// `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
|
|
186
|
+
// 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
|
|
187
|
+
const inviteCode = inviteToUse(c.id)
|
|
188
|
+
const loginUrl = inviteCode
|
|
189
|
+
? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
|
|
190
|
+
: `${c.web}/login`
|
|
191
|
+
|
|
192
|
+
// 发短信这一步最慢(过验证最多试 3 张图、每张问两遍模型)。
|
|
193
|
+
// 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
|
|
194
|
+
let sent
|
|
195
|
+
try {
|
|
196
|
+
sent = await startSmsLogin(c, { mobile: phone })
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
url: loginUrl,
|
|
203
|
+
instructions:
|
|
204
|
+
`验证码已发送到 ${masked}。\n` +
|
|
205
|
+
`旋转验证由识图模型(${vision})自动通过,试了 ${sent.rounds} 张图。\n` +
|
|
206
|
+
`把收到的 6 位验证码填进下面的框就行。`,
|
|
207
|
+
method: "code",
|
|
208
|
+
// ⚠ 必须是 true。这条链路**全在后台走完**,不需要任何浏览器;
|
|
209
|
+
// opened: false 会让 magpie 拿 url 去开**系统默认浏览器** —— 而那个浏览器里
|
|
210
|
+
// 往往本来就登着这个站,用户看到的是已登录界面,根本进不了「添加新号」,
|
|
211
|
+
// 还会以为插件把他登进了旧账号。这正是浏览器登录那条路修过的坑。
|
|
212
|
+
opened: true,
|
|
213
|
+
async callback(code) {
|
|
214
|
+
const c0 = String(code || "").replace(/\D/g, "")
|
|
215
|
+
if (c0.length < 4) return { type: "failed", error: "验证码是 4~8 位数字" }
|
|
216
|
+
let creds
|
|
217
|
+
try {
|
|
218
|
+
creds = await finishSmsLogin(c, { mobile: phone, code: c0, redirect: loginUrl })
|
|
219
|
+
} catch (e) {
|
|
220
|
+
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
return await completeSignIn(c, creds.jwt, creds.org, creds.orgs)
|
|
224
|
+
} catch (e) {
|
|
225
|
+
return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
|
|
40
232
|
{
|
|
41
233
|
type: "oauth",
|
|
42
234
|
label: `${c.SHORT} 浏览器登录`,
|
package/src/vision.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// 识图判角度 —— 用 magpie 里**用户自己配的那个识图模型**
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:旋转验证的答案只存在于图片里。协议(见 captcha.mjs)能把
|
|
4
|
+
// 「怎么提交」做到位,但「该提交多少度」必须有人看图。
|
|
5
|
+
//
|
|
6
|
+
// 三条实测结论决定了这里怎么写:
|
|
7
|
+
//
|
|
8
|
+
// ① **不能问「多少度」,要问「主体的上方向指向钟面几点」。**
|
|
9
|
+
// 实测:直接问角度(填空)6 次错 5 次;改成钟点(相对描述)3 轮中 2。
|
|
10
|
+
// 让模型「在脑子里把图转过来」它做不好,问「现在朝哪」它答得稳。
|
|
11
|
+
//
|
|
12
|
+
// ② **必须问两次并比分歧。**
|
|
13
|
+
// 实测的失败案例是两个判官给出 1.7 和 7.5 钟点(相差 174°)——
|
|
14
|
+
// 其中一个把方向看反了。这种情况取平均毫无意义,只能判为「不可信」重来。
|
|
15
|
+
//
|
|
16
|
+
// ③ **模型是用户在 magpie 设置里选的那个**(`settings.json` 的 `vision`),
|
|
17
|
+
// 不额外要求他配 key —— magpie 自己就监听着一个 OpenAI 兼容网关,
|
|
18
|
+
// 直接打本地端口就行。
|
|
19
|
+
//
|
|
20
|
+
// ⚠ 模型回什么形状的答案都可能(带解释、带思考过程、结尾才有数字),
|
|
21
|
+
// 所以解析一律「从后往前找最后一个合法数字」,找不到就算这次不可信。
|
|
22
|
+
|
|
23
|
+
import { readFileSync } from "node:fs"
|
|
24
|
+
import { homedir } from "node:os"
|
|
25
|
+
import { join } from "node:path"
|
|
26
|
+
|
|
27
|
+
import { CLOCK_MAX_SPREAD, clockSpread, clockToDeg } from "./captcha.mjs"
|
|
28
|
+
|
|
29
|
+
/** magpie 的配置目录;Windows 上是 ~/.config/magpie,和别的系统一致 */
|
|
30
|
+
export function magpieConfigDir() {
|
|
31
|
+
return join(homedir(), ".config", "magpie")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 用户选的识图模型。
|
|
36
|
+
* @returns {string|null} 形如 `workbuddy/deepseek-v4.1-flash`;没配 / 关掉 / 读不到 → null
|
|
37
|
+
*/
|
|
38
|
+
export function readVisionModel() {
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(join(magpieConfigDir(), "settings.json"), "utf8")
|
|
41
|
+
const j = JSON.parse(raw)
|
|
42
|
+
const v = String(j?.vision ?? "").trim()
|
|
43
|
+
if (!v || v === "off" || v === "none" || v === "0") return null
|
|
44
|
+
return v
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* magpie 自己的网关端口。
|
|
52
|
+
*
|
|
53
|
+
* 默认 3425(本机实测 `magpie-windows-amd64.exe` 就监听这个)。
|
|
54
|
+
* 用户能通过 `magpie web --addr host:port` / `magpie serve` 改,所以:
|
|
55
|
+
* · 环境变量优先(万一 magpie 把它传进插件进程)
|
|
56
|
+
* · 否则按常见端口列表逐个试 —— **只看谁能以 200 回 /v1/models**
|
|
57
|
+
*/
|
|
58
|
+
export function gatewayCandidates() {
|
|
59
|
+
const fromEnv = [process.env.MAGPIE_GATEWAY_PORT, process.env.MAGPIE_PORT]
|
|
60
|
+
.map((x) => Number(x))
|
|
61
|
+
.filter((n) => Number.isFinite(n) && n > 0)
|
|
62
|
+
const common = [3425, 3426, 8080]
|
|
63
|
+
return [...new Set([...fromEnv, ...common])]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let cachedBase = null
|
|
67
|
+
|
|
68
|
+
/** 探一个能用的网关基址;找到就记住,找不到返回 null */
|
|
69
|
+
export async function resolveGatewayBase() {
|
|
70
|
+
if (cachedBase) return cachedBase
|
|
71
|
+
for (const port of gatewayCandidates()) {
|
|
72
|
+
const base = `http://127.0.0.1:${port}/v1`
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`${base}/models`, { signal: AbortSignal.timeout(1500) })
|
|
75
|
+
if (res.ok) {
|
|
76
|
+
cachedBase = base
|
|
77
|
+
return base
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
/* 这个端口没在听,试下一个 */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* webp → png 的 data URL。
|
|
88
|
+
*
|
|
89
|
+
* 为什么要转:验证码图是 `data:image/webp`,不是每个视觉模型都吃 webp。
|
|
90
|
+
* magpie 自带的 bun 有 `Bun.Image`(实测能解 webp),在没有它的环境里就原样返回。
|
|
91
|
+
*/
|
|
92
|
+
export async function toPngDataUrl(imageDataUrl) {
|
|
93
|
+
if (!/^data:image\/webp/i.test(imageDataUrl)) return imageDataUrl
|
|
94
|
+
try {
|
|
95
|
+
if (typeof Bun === "undefined" || typeof Bun.Image !== "function") return imageDataUrl
|
|
96
|
+
const b64 = imageDataUrl.split(",")[1] || ""
|
|
97
|
+
const img = new Bun.Image(Buffer.from(b64, "base64"))
|
|
98
|
+
await img.metadata()
|
|
99
|
+
img.png()
|
|
100
|
+
const out = await img.toBuffer()
|
|
101
|
+
return "data:image/png;base64," + Buffer.from(out).toString("base64")
|
|
102
|
+
} catch {
|
|
103
|
+
return imageDataUrl
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CLOCK_PROMPT = (variant) => {
|
|
108
|
+
const frame =
|
|
109
|
+
variant === 0
|
|
110
|
+
? "想象一个钟面盖在图上:12 = 正上方,3 = 正右方,6 = 正下方,9 = 正左方。"
|
|
111
|
+
: "把这张图当成表盘:最上方是 12 点,顺时针数到 3 点在右、6 点在正下、9 点在左。"
|
|
112
|
+
return `这张图片被随机旋转过,是圆形裁切的照片,里面有一个主体。
|
|
113
|
+
${frame}
|
|
114
|
+
判断:主体自己的"上方向"(人的头顶、动物的头顶、瓶子口、相机顶部那一侧)现在指向钟面的几点?
|
|
115
|
+
要精确,允许一位小数(例如 7.3)。
|
|
116
|
+
最后一行只输出这个数字,不要写别的。`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 从一段话里取「最后出现的、落在 0…12 的数」—— 模型常在前面铺垫一堆 */
|
|
120
|
+
function lastClock(text) {
|
|
121
|
+
const nums = String(text || "").match(/\d+(?:\.\d+)?/g)
|
|
122
|
+
if (!nums) return null
|
|
123
|
+
for (let i = nums.length - 1; i >= 0; i--) {
|
|
124
|
+
const v = Number(nums[i])
|
|
125
|
+
if (Number.isFinite(v) && v >= 0 && v <= 12) return v
|
|
126
|
+
}
|
|
127
|
+
return null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function askClock(base, model, imageDataUrl, variant) {
|
|
131
|
+
const res = await fetch(`${base}/chat/completions`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "content-type": "application/json" },
|
|
134
|
+
// temperature 0 是为了同一张图两次问法之外的噪声尽量小;
|
|
135
|
+
// 两次用不同措辞(variant)而不是靠随机温度,好复现
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
model,
|
|
138
|
+
max_tokens: 400,
|
|
139
|
+
temperature: 0,
|
|
140
|
+
messages: [
|
|
141
|
+
{
|
|
142
|
+
role: "user",
|
|
143
|
+
content: [
|
|
144
|
+
{ type: "text", text: CLOCK_PROMPT(variant) },
|
|
145
|
+
{ type: "image_url", image_url: { url: imageDataUrl } },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
}),
|
|
150
|
+
signal: AbortSignal.timeout(45_000),
|
|
151
|
+
})
|
|
152
|
+
if (!res.ok) throw new Error(`识图模型回 HTTP ${res.status}`)
|
|
153
|
+
const j = await res.json()
|
|
154
|
+
const text = j?.choices?.[0]?.message?.content ?? ""
|
|
155
|
+
return { clock: lastClock(text), text: String(text).trim().slice(0, 200) }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 让识图模型判断「该顺时针转多少度」。
|
|
160
|
+
*
|
|
161
|
+
* @returns {Promise<{ok:true, deg:number, clock:number, spread:number, model:string}
|
|
162
|
+
* |{ok:false, reason:string, detail?:string}>}
|
|
163
|
+
*/
|
|
164
|
+
export async function decideDegFromImage(imageDataUrl) {
|
|
165
|
+
const model = readVisionModel()
|
|
166
|
+
if (!model) return { ok: false, reason: "no-vision-model" }
|
|
167
|
+
|
|
168
|
+
const base = await resolveGatewayBase()
|
|
169
|
+
if (!base) return { ok: false, reason: "no-gateway", detail: "连不上 magpie 自己的网关(试过 3425/3426/8080)" }
|
|
170
|
+
|
|
171
|
+
const img = await toPngDataUrl(imageDataUrl)
|
|
172
|
+
const a = await askClock(base, model, img, 0).catch((e) => ({ clock: null, text: String(e?.message ?? e) }))
|
|
173
|
+
const b = await askClock(base, model, img, 1).catch(() => ({ clock: null, text: "" }))
|
|
174
|
+
|
|
175
|
+
if (a.clock == null && b.clock == null) {
|
|
176
|
+
return { ok: false, reason: "unparsable", detail: `${a.text} / ${b.text}` }
|
|
177
|
+
}
|
|
178
|
+
// 只拿到一个也能用(另一个多半是被截断或跑题了)
|
|
179
|
+
if (a.clock == null || b.clock == null) {
|
|
180
|
+
const one = a.clock ?? b.clock
|
|
181
|
+
return { ok: true, deg: clockToDeg(one), clock: one, spread: 0, model, single: true }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const spread = clockSpread(a.clock, b.clock)
|
|
185
|
+
if (spread > CLOCK_MAX_SPREAD) {
|
|
186
|
+
// 相差超过 1.5 钟点(45°)基本就是「有一个人把方向看反了」,
|
|
187
|
+
// 这时候取平均只会得到一个两边都不对的中间值 —— 宁可换一张图重来
|
|
188
|
+
return { ok: false, reason: "disagree", detail: `${a.clock} vs ${b.clock}(相差 ${spread.toFixed(1)} 钟点)`, spread }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const clock = (a.clock + b.clock) / 2
|
|
192
|
+
return { ok: true, deg: clockToDeg(clock), clock, spread, model }
|
|
193
|
+
}
|