magicrew-auth 0.3.15 → 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 +137 -0
- package/CHANGELOG.md +126 -0
- package/CHECKLIST.md +110 -0
- package/README.md +5 -0
- package/docs/internals.md +23 -0
- package/package.json +3 -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
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# AGENTS.md —— 给 AI 执行者的入口
|
|
2
|
+
|
|
3
|
+
> 人类请看 [README](./README.md)。这份只回答两件事:**你现在该读哪一份**、
|
|
4
|
+
> **忘了哪一条会静默出错**。
|
|
5
|
+
>
|
|
6
|
+
> 结构照搬 [magpie-plugin-scaffold](../magpie-plugin-scaffold/AGENTS.md) 的入口式写法;
|
|
7
|
+
> 下面第二节是本仓库自己的坑,不是通用套话。
|
|
8
|
+
|
|
9
|
+
---
|
|
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
|
+
|
|
32
|
+
## 一、先定位你在哪一步
|
|
33
|
+
|
|
34
|
+
| 你在做什么 | 必读(按顺序) | 做完的判据 |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| **改协议 / 接口调用** | [docs/internals.md](./docs/internals.md) 的「三、接口清单」「十二、流式」「十六、上下文窗口」 | 改动在两站(`magicrew` 与 `magicrew-intl`)都验过 |
|
|
37
|
+
| **改登录链路** | internals 的「二、三层凭证」「十一、浏览器登录怎么做到全自动」 | 三条登录路径(本机浏览器 / 浏览器登录 / 手填 JWT)都还能登 |
|
|
38
|
+
| **改 key / 续期** | internals 的「十三、签新 key 会吊销旧 key」「十四、三层凭据的有效期与保活」 | 没引入第二份 refresh_token(会和 `src/keys.mjs` 分叉) |
|
|
39
|
+
| **改模型目录** | internals 的「十六、上下文窗口必须查库」+ HOOKS 十一之二(`free`) | 上下文来自查库;`free` 来自厂商声明,不从 `rate` 反推 |
|
|
40
|
+
| **改签到 / 首任务** | internals 的 checkin 小节(一次性闸门、五种状态) | 不漏发、不重发、失败方向是「宁可晚一轮」 |
|
|
41
|
+
| **代码写完,准备交付** | [CHECKLIST.md](./CHECKLIST.md) 的 **A 表** | A 表全勾 + `node scripts/check.mjs` 退出 0 |
|
|
42
|
+
| **要发版本** | **[UPDATING.md](https://github.com/MYCF711/magpie-plugin-scaffold/blob/main/UPDATING.md)**(脚手架的,七步) | CHECKLIST B 表全勾 + 四条验证 |
|
|
43
|
+
| **用户说「更新了没变化」** | 本文件坑 12(git 源只提示、要手动点更新) | `magpie plugin list` 里的 commit 真的变了 |
|
|
44
|
+
|
|
45
|
+
**最容易漏的是「要发版本」这一行。** `git push` 只是七步里的第 4 步 ——
|
|
46
|
+
本仓库真踩过:0.3.14 只 push,结果 GitHub 右侧「暂无发布版本」、npm 停在 0.3.8。
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 二、本仓库的坑
|
|
51
|
+
|
|
52
|
+
| # | 症状(出问题时你看到什么) | 判据 | 出处 |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| 1 | 改好了国内站,国际站用户报同样的错 | **两站是同一个 Go 服务、逐字符同构**,改一处就要两站都验;`SITES.cn` / `SITES.intl` 只是 host 不同 | internals 一 |
|
|
55
|
+
| 2 | 国际站 `/v1/chat/completions` 回 `HTTP 500` + 22 字节裸文本(不是 JSON) | 国际站网关**强制**要 `organization-code` 头,缺了就是这个 500 | internals 一 / 五 |
|
|
56
|
+
| 3 | 换了 key 之后老 key 立刻失效、用户被要求重登 | **签新 key 会吊销旧 key**,别在热路径上调签发;续期走 `src/keys.mjs` 的唯一出口 | internals 十三 |
|
|
57
|
+
| 4 | 刷新 token 之后两处 refresh_token 不一致、用户莫名掉登录 | 只允许**一个**出口持有 refresh_token;`src/hooks/refresh.mjs` 绕过出口就会分叉 | internals 二 / 十四 |
|
|
58
|
+
| 5 | agent 说「上下文放不下」,而实际模型能吃更长 | 上下文窗口**必须查 magpie 的模型库**,不能填保守值(早前一刀切 256K,实际 1M) | internals 十六 |
|
|
59
|
+
| 6 | 流式回复拿到了,但 `usage` 永远是空、额度不扣 | 流式请求必须补 `stream_options.include_usage`,否则上游不回 usage | internals 十二 |
|
|
60
|
+
| 7 | 模型被标成免费、Free first 一直派到它 | `free` 只认厂商声明(`input_points === 0`),**不能从 `rate === 0` 反推**;价格缺失 = 不列,不猜 | internals 十六 · HOOKS 十一之二 |
|
|
61
|
+
| 8 | 新号邀请奖励不到账 | 国际站判据是**有没有话题**,不是有没有项目 —— 项目打开工作台就会自动建 | internals checkin 小节 |
|
|
62
|
+
| 9 | 一个号天天烧一次模型积分 | 首任务是**每账号一辈子一次**(`isAutoChatSettled`),别改回按日期;失败才重试,最多 3 次 | internals checkin 小节 |
|
|
63
|
+
| 10 | 话题接口抖一下,就白花一次积分去补消息 | 查不到判据时**这次不发**、不落标记、不计数 —— 消息发出去花钱且不可撤 | `src/tasks.mjs` 的 `autoFirstChat` |
|
|
64
|
+
| 11 | Windows 上点登录,浏览器窗口拉不起来 | 浏览器可执行文件名**随版本变**(`msedge.exe` / Chrome 各版本目录不同),要逐个试候选,别写死一个名字 | internals 十一 |
|
|
65
|
+
| 12 | 用户点「检查更新」显示有新提交,点了更新却没变化 | 本仓库是 **git 源**:magpie 比默认分支 HEAD 的 commit,只提示不自动更新,要用户点那一行的「更新」;更新后 `magpie plugin list` 的 commit 应变化 | 脚手架 UPDATING 一 |
|
|
66
|
+
| 13 | 想让卡片标题 / 「源码」按钮对,去改了 magpie 的缓存 | 插件对 magpie 目录**只有读的份**;唯一载体是 `registry-entry.json`(提交给 `registry.json`) | internals 二 · README |
|
|
67
|
+
|
|
68
|
+
补充一条最容易误信的旧说法:
|
|
69
|
+
|
|
70
|
+
| 症状 | 判据 | 出处 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| 以为 checkin 会「顺带续 key」 | **从来没有**。`src/hooks/checkin.mjs` → `src/tasks.mjs` 全链路没 import 过 `ensureKey`;保活由 magpie 按 `refreshLead` 主动调 `auth.refresh` | internals 十四 · CHANGELOG 0.x |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 三、命令
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# 合规检查(脚手架里的脚本,直接指到本仓库)
|
|
80
|
+
node ../magpie-plugin-scaffold/scripts/check.mjs .
|
|
81
|
+
|
|
82
|
+
# 两站都要能跑:真实登录态在 ~/.cache/magpie/magicrew-session-<siteId>.json
|
|
83
|
+
# siteId = magicrew(国内) / magicrew-intl(国际)
|
|
84
|
+
# 探针脚本必须放进仓库目录用 magpie 自带的 bun 跑(node 连不上国际站域名):
|
|
85
|
+
~/.cache/magpie/bun/1.4.4/magpie-bun.exe ./_probe/xxx.mjs
|
|
86
|
+
|
|
87
|
+
# 打包面预演
|
|
88
|
+
npm pack --dry-run
|
|
89
|
+
|
|
90
|
+
# 发布(包内 name 是 Magicrew,npm 包名是 magicrew-auth)
|
|
91
|
+
npm run publish:npm
|
|
92
|
+
|
|
93
|
+
# Release:本仓库没有 .github/workflows,必须手工建
|
|
94
|
+
git tag v0.3.16 && git push origin v0.3.16
|
|
95
|
+
# 然后 POST /repos/MYCF711/magpie-magicrew-auth/releases
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 四、两个交付门
|
|
101
|
+
|
|
102
|
+
**门 1 · 代码交付**
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
□ node ../magpie-plugin-scaffold/scripts/check.mjs . 退出码 = 0
|
|
106
|
+
□ CHECKLIST.md 的 A 表全勾
|
|
107
|
+
□ 改动涉及的站点都跑过(国内 / 国际,别只验一个)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**门 2 · 发布**
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
□ CHECKLIST.md 的 B 表全勾(七步)
|
|
114
|
+
□ 四条验证:npm view magicrew-auth version / tarball 内容 / magpie plugin list / Release 在
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
⚠ 没过门 2 不许说「已发布」。
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## 五、文档地图
|
|
122
|
+
|
|
123
|
+
| 文件 | 管什么 | 什么时候读 |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| [README](./README.md) | 用户看的门面:功能、模型、安装 | 改用户可见行为时 |
|
|
126
|
+
| [docs/internals.md](./docs/internals.md) | 16 章实测:两站同构、三层凭证、接口清单、协议要点 | 改代码前按章查 |
|
|
127
|
+
| [CHANGELOG.md](./CHANGELOG.md) | 每版一节 | 写新版本时 |
|
|
128
|
+
| [CHECKLIST.md](./CHECKLIST.md) | 两张勾选表 | 两个交付门 |
|
|
129
|
+
| 脚手架 [AGENTS.md](../magpie-plugin-scaffold/AGENTS.md) | 通用坑(九项清单、三条铁律、check 能查什么) | 第一次接触这个插件族时 |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 六、这份文件的维护规矩
|
|
134
|
+
|
|
135
|
+
1. 任何一次「不报错、要几小时才找到」的坑,收工前在第二节加一行(症状 → 判据 → 出处)。
|
|
136
|
+
2. 第二节只放一句话判据,论证留在 `docs/internals.md`。
|
|
137
|
+
3. 判据要能当场验证(命令、字段、界面现象),写不成判据的是感想。
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,131 @@
|
|
|
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
|
+
|
|
113
|
+
## 0.3.16
|
|
114
|
+
|
|
115
|
+
**加了给 AI 执行者的入口 —— 因为「发布」这一步真的被漏过一次**
|
|
116
|
+
|
|
117
|
+
不是功能改动。这个仓库 0.3.14 发布时只 `git push`,没发 npm、没打 tag、没建 Release,
|
|
118
|
+
结果 GitHub 右侧一直挂着「暂无发布版本」、npm 停在 0.3.8。根因不是不知道有发布流程,
|
|
119
|
+
是它没进阅读路径。
|
|
120
|
+
|
|
121
|
+
- **新增 `AGENTS.md`** —— 按「你在哪一步」给阅读顺序(改协议 / 改登录 / 改 key /
|
|
122
|
+
改模型 / 改签到 / 交付 / 发版),外加**本仓库 13 条会静默出错的坑**,
|
|
123
|
+
每条带「症状 → 判据 → 出处章节」。坑都来自 `docs/internals.md` 的实测记录
|
|
124
|
+
- **新增 `CHECKLIST.md`** —— A 表代码交付前 14 项(含「两站都验过」「上下文来自查库」
|
|
125
|
+
「流式带 include_usage」「话题查不到就不发」),B 表发版前七步带命令与判据
|
|
126
|
+
- [README](./README.md) —— 顶部加三行指路:改代码 / 发版前先看这两份;发布是七步,
|
|
127
|
+
`git push` 只是第 4 步
|
|
128
|
+
|
|
3
129
|
## 0.3.15
|
|
4
130
|
|
|
5
131
|
**修掉「网页已经登好、magpie 里一直转圈等」—— 弹窗永远拉不起来**
|
package/CHECKLIST.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# CHECKLIST —— 两张表,两个交付门
|
|
2
|
+
|
|
3
|
+
> 给 `magpie-magicrew-auth`。`scripts/check.mjs`(在脚手架仓库里)只查**能自动查的那部分**;
|
|
4
|
+
> 下面这些查不到,但漏了照样出事,而且通常**不报错**。
|
|
5
|
+
>
|
|
6
|
+
> 逐条勾,勾不上就是还没好。表格里的「判据」都能当场验。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## A 表 · 代码交付前
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
□ 1. config.models 有内容
|
|
14
|
+
判据:插件装上后模型列表不是空的(未登录时也要有静态表)
|
|
15
|
+
出处:README
|
|
16
|
+
|
|
17
|
+
□ 2. authorize() 立刻返回
|
|
18
|
+
判据:登录时没有长时间转圈
|
|
19
|
+
出处:docs/internals.md 二
|
|
20
|
+
|
|
21
|
+
□ 3. 两站都验过
|
|
22
|
+
判据:国内(magicrew)与国际(magicrew-intl)各跑一次目标路径,都通
|
|
23
|
+
出处:internals 一 —— 同一个 Go 服务,一边过不代表另一边过
|
|
24
|
+
|
|
25
|
+
□ 4. 国际站请求带 organization-code
|
|
26
|
+
判据:/v1/chat/completions 不是 HTTP 500 + 22 字节裸文本
|
|
27
|
+
出处:internals 一 / 五
|
|
28
|
+
|
|
29
|
+
□ 5. 上下文窗口来自查库,不是保守常数
|
|
30
|
+
判据:模型条目的 limit.context 与实际能吃的长度对得上(早前一刀切 256K 是错的)
|
|
31
|
+
出处:internals 十六
|
|
32
|
+
|
|
33
|
+
□ 6. 流式请求带 stream_options.include_usage
|
|
34
|
+
判据:流式回复里有 usage,额度真的扣了
|
|
35
|
+
出处:internals 十二
|
|
36
|
+
|
|
37
|
+
□ 7. 模型 free 来自厂商声明,不是从 rate 反推
|
|
38
|
+
判据:input_points === 0 才 free;缺失就不列,不猜
|
|
39
|
+
出处:internals 十六 · HOOKS 十一之二
|
|
40
|
+
|
|
41
|
+
□ 8. 只有一处持有 refresh_token
|
|
42
|
+
判据:`src/hooks/refresh.mjs` 没有绕过 `src/keys.mjs`;没有第二次签发 key 的调用
|
|
43
|
+
出处:internals 十三 / 十四
|
|
44
|
+
|
|
45
|
+
□ 9. 首任务仍是一次性闸门
|
|
46
|
+
判据:isAutoChatSettled 还在用;失败最多 3 次;不落日期格式
|
|
47
|
+
出处:internals checkin 小节
|
|
48
|
+
|
|
49
|
+
□ 10. 话题查不到时「这次不发」
|
|
50
|
+
判据:fetchProjectTopics 抛错时不写标记、不计数、不发送
|
|
51
|
+
出处:src/tasks.mjs 的 autoFirstChat
|
|
52
|
+
|
|
53
|
+
□ 11. 登录三条路径都还能登
|
|
54
|
+
判据:本机浏览器接上 / 浏览器登录 / 手填 JWT,各走一次
|
|
55
|
+
出处:internals 十一
|
|
56
|
+
|
|
57
|
+
□ 12. 浏览器可执行文件是逐个试候选,不是写死一个名字
|
|
58
|
+
判据:Windows 上点登录能起浏览器(文件名随浏览器版本变)
|
|
59
|
+
出处:internals 十一
|
|
60
|
+
|
|
61
|
+
□ 13. 没有写 magpie 的市场缓存
|
|
62
|
+
判据:src 里搜不到 plugin-market.json
|
|
63
|
+
出处:CHANGELOG 0.1.9 那段事故
|
|
64
|
+
|
|
65
|
+
□ 14. node ../magpie-plugin-scaffold/scripts/check.mjs . 退出码 = 0
|
|
66
|
+
判据:末尾「失败 0」
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## B 表 · 发版前(七步,一步不能少)
|
|
72
|
+
|
|
73
|
+
> ⚠ **`git push` 只是第 4 步。** 本仓库真踩过:0.3.14 只 push,
|
|
74
|
+
> 结果 GitHub 右侧一直写着「暂无发布版本」、npm 停在 0.3.8。
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
□ 第 0 步 · 写 CHANGELOG
|
|
78
|
+
判据:顶部是这个版本的一节,一条一行,说用户能感知的变化
|
|
79
|
+
|
|
80
|
+
□ 第 1 步 · bump version
|
|
81
|
+
命令:npm version patch --no-git-tag-version
|
|
82
|
+
判据:package.json 的 version 变了(同版本重打会命中 bun 缓存)
|
|
83
|
+
|
|
84
|
+
□ 第 2 步 · 打包面预演
|
|
85
|
+
命令:npm pack --dry-run
|
|
86
|
+
判据:清单里有 README.md / CHANGELOG.md / docs / AGENTS.md / CHECKLIST.md
|
|
87
|
+
|
|
88
|
+
□ 第 3 步 · 提交
|
|
89
|
+
|
|
90
|
+
□ 第 4 步 · 推到默认分支
|
|
91
|
+
命令:git push origin main
|
|
92
|
+
判据:git 源装的人靠这里检测 —— 推别的分支不算
|
|
93
|
+
|
|
94
|
+
□ 第 5 步 · npm publish
|
|
95
|
+
命令:npm run publish:npm ← 包内 name 是 Magicrew,npm 包名是 magicrew-auth
|
|
96
|
+
判据:返回 + magicrew-auth@<版本>,且 package.json 的 name 已还原成 Magicrew
|
|
97
|
+
|
|
98
|
+
□ 第 6 步 · tag + Release
|
|
99
|
+
命令:git tag v<version> && git push origin v<version>
|
|
100
|
+
本仓库**没有** .github/workflows ⇒ Release 必须手工建:
|
|
101
|
+
POST /repos/MYCF711/magpie-magicrew-auth/releases
|
|
102
|
+
{ tag_name, target_commitish:"main", name, body:<CHANGELOG 该版本段>, draft:false }
|
|
103
|
+
判据:tag 与 version 一致;Release 不是 draft
|
|
104
|
+
|
|
105
|
+
□ 第 7 步 · 四条验证
|
|
106
|
+
① npm view magicrew-auth version → 新版本(登记可能延迟 1-2 分钟)
|
|
107
|
+
② 下载 dist.tarball 解包看内容 → 要看到新代码,不只看版本号
|
|
108
|
+
③ magpie plugin list → commit 变了(git 源看 commit 不看 version)
|
|
109
|
+
④ GET /releases/tags/v<version> → 200 且 draft=false
|
|
110
|
+
```
|
package/README.md
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
> 上面那排是 2026-10-27 从两站 `GET /api/v1/flows/models` 拉下来核对的。清单会随订阅档位和平台上下架变 —— 所以插件里**没有一张写死的模型清单**,账户能选什么就显示什么。
|
|
16
16
|
|
|
17
|
+
> **改代码 / 发版本之前**:先看 [AGENTS.md](./AGENTS.md) —— 按「你在哪一步」给阅读顺序,
|
|
18
|
+
> 外加本仓库 13 条**会静默出错**的坑(症状 → 判据 → 出处)。交付与发版逐条走
|
|
19
|
+
> [CHECKLIST.md](./CHECKLIST.md) 的两张表。
|
|
20
|
+
> 发布是**七步**,`git push` 只是第 4 步。
|
|
21
|
+
|
|
17
22
|
---
|
|
18
23
|
|
|
19
24
|
## 功能
|
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",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
"index.mjs",
|
|
13
13
|
"src",
|
|
14
14
|
"README.md",
|
|
15
|
+
"AGENTS.md",
|
|
16
|
+
"CHECKLIST.md",
|
|
15
17
|
"CHANGELOG.md",
|
|
16
18
|
"NOTICE.md",
|
|
17
19
|
"docs"
|
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
|
+
}
|