magicrew-auth 0.3.25 → 0.3.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/CHANGELOG.md +330 -0
- package/README.md +135 -58
- package/docs/internals.md +179 -0
- package/package.json +1 -1
- package/src/captcha.mjs +146 -43
- package/src/headless.mjs +79 -29
- package/src/hooks/loader.mjs +228 -5
- package/src/hooks/methods.mjs +238 -147
- package/src/vision.mjs +83 -25
package/src/hooks/methods.mjs
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
// ② auth.methods[] —— 登录
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 四条路,按可靠性排:
|
|
4
4
|
//
|
|
5
|
-
// ①
|
|
5
|
+
// ① 手机号验证码(零浏览器):识图模型过旋转验证 → 发短信 → 用户填码。
|
|
6
|
+
// ② 邮箱验证码:同一条链,只有标识字段和两个端点不同(见下面 makeCodeMethod)。
|
|
7
|
+
// ③ 浏览器登录(默认零输入):插件自己拉起一个能被 CDP 看进去的浏览器,
|
|
6
8
|
// 用户在官方页里用任何方式登,登录成功那一刻插件把 localStorage 读走。
|
|
7
9
|
// 拉不起来就降级成「magpie 开系统浏览器 + 用户粘一行 Console」。
|
|
8
|
-
//
|
|
10
|
+
// ④ 登录态(JWT + 组织码):手填两个字段,兜底用。
|
|
9
11
|
//
|
|
10
|
-
// ⚠
|
|
12
|
+
// ⚠ 三条铁律:
|
|
11
13
|
// · authorize() 必须立刻返回 —— 真正等用户的是 callback(),死等会和 magpie 互锁
|
|
12
14
|
// · opened: true 的语义是「我自己已经开过页面了,你别再开」;
|
|
13
15
|
// 只返回 url 让它去开的,**不许**传 opened
|
|
16
|
+
// · authorize() 里失败只能抛异常,**不能** return { type: "failed" } ——
|
|
17
|
+
// magpie 把它的返回值当「登录流程对象」,会去调 `.callback()`,于是崩在一句
|
|
18
|
+
// `callback is not a function` 上(实测触发路径:callback 失败 → 用户点「重试」
|
|
19
|
+
// → 重新调 authorize → 又走到失败分支 → 崩)
|
|
20
|
+
//
|
|
21
|
+
// ⚠ 数组顺序 = magpie 登录方式列表里的顺序,所以**按站点分**:
|
|
22
|
+
// 国际站(magicrew-intl)邮箱在前 —— 那边用户大多是邮箱注册的;
|
|
23
|
+
// 国内站手机号在前。
|
|
14
24
|
|
|
15
25
|
import {
|
|
16
26
|
issueGatewayKey,
|
|
@@ -22,7 +32,7 @@ import { inviteToUse, readSession, writeSession, noteLoginFailure, noteOrg, read
|
|
|
22
32
|
import { readLocalCreds } from "../local-browser.mjs"
|
|
23
33
|
import { CLIP_HINT, clipboardText, parseCreds, pollClipboard } from "../clipboard.mjs"
|
|
24
34
|
import { readVisionModel } from "../vision.mjs"
|
|
25
|
-
import {
|
|
35
|
+
import { finishCodeLogin, startCodeLogin } from "../headless.mjs"
|
|
26
36
|
|
|
27
37
|
/**
|
|
28
38
|
* 账号刚加进来(登录成功)→ 一次性把「首次 AI 工作 / 首个对话」补掉。
|
|
@@ -46,6 +56,16 @@ function kickoffFirstTask(c, jwt, org) {
|
|
|
46
56
|
* `orgs` 是候选组织列表(无头那条路拿不到「用户当前选中的组织」,只能猜)。
|
|
47
57
|
* 按顺序用 `fetchPoints` 真验一遍,第一个验得过才算。
|
|
48
58
|
*
|
|
59
|
+
* 后两个参数只影响**卡片上显示的名字**,但选错会让同一个号在两条登录路上
|
|
60
|
+
* 算出不同的 accountId,magpie 就把同一个号加成两条(真机踩过)。三档优先级:
|
|
61
|
+
*
|
|
62
|
+
* preferredAccountId → who.name(服务端给的手机号/邮箱/昵称)→ fallbackAccountId → 组织码
|
|
63
|
+
*
|
|
64
|
+
* · 手机号验证码那条传 preferred —— 服务端只回打码号(`189****051`),
|
|
65
|
+
* 得由我们自己把它固定成同一个字符串,两条路才一致
|
|
66
|
+
* · 邮箱验证码那条传 fallback —— 邮箱不需要打码;who 成功时优先用 who,
|
|
67
|
+
* 这样和浏览器登录那条路算出来的是同一个
|
|
68
|
+
*
|
|
49
69
|
* ⚠ 别说这解决了「多组织选哪个」—— **它没解决,只是容错**:
|
|
50
70
|
* 账号所属的任何一个组织都能通过 `fetchPoints`(只要 JWT 有效),
|
|
51
71
|
* 所以「第一个验得过的」实际就是「列表第一个」。
|
|
@@ -54,7 +74,7 @@ function kickoffFirstTask(c, jwt, org) {
|
|
|
54
74
|
* 头都一样),不能拿来判断。⇒ 多组织账号在无头这条路上会落到列表第一个组织,
|
|
55
75
|
* 这是已知限制,不是 bug。
|
|
56
76
|
*/
|
|
57
|
-
async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null) {
|
|
77
|
+
async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null, fallbackAccountId = null) {
|
|
58
78
|
const candidates = [org, ...orgs.filter((o) => o && o !== org)]
|
|
59
79
|
let lastErr = null
|
|
60
80
|
let picked = null
|
|
@@ -85,10 +105,7 @@ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null)
|
|
|
85
105
|
// 真机踩过:浏览器登录拿到的是打码手机号 `189****051`(fetchWho 成功,
|
|
86
106
|
// 服务端只给打码号);无头那条路 fetchWho 挂了(438 字符的 access_token
|
|
87
107
|
// 配 Bearer 头不通),于是兜底用了组织码 `h97gkbu8bruj` ⇒ 两条并存。
|
|
88
|
-
|
|
89
|
-
// 所以优先用**调用方明确给的** accountId(无头路径里就是它自己算出来的打码手机号),
|
|
90
|
-
// `who.name` 只作为补充,组织码是最后一档 —— 它绝不该当账号名。
|
|
91
|
-
const accountId = preferredAccountId || who?.name || picked
|
|
108
|
+
const accountId = preferredAccountId || who?.name || fallbackAccountId || picked
|
|
92
109
|
return {
|
|
93
110
|
type: "success",
|
|
94
111
|
access: key.apiKey,
|
|
@@ -101,161 +118,235 @@ async function completeSignIn(c, jwt, org, orgs = [], preferredAccountId = null)
|
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// ⇒ authorize 里失败一律抛异常。
|
|
136
|
-
if (!/^1\d{10}$/.test(phone)) throw new Error("手机号不完整")
|
|
137
|
-
|
|
138
|
-
const vision = readVisionModel()
|
|
139
|
-
const masked = phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
|
|
140
|
-
|
|
141
|
-
if (!vision) {
|
|
142
|
-
// 没有识图模型 → 回退到浏览器:打不开就不硬撑。
|
|
143
|
-
//
|
|
144
|
-
// 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
|
|
145
|
-
// 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
|
|
146
|
-
// 等不到猜中,先被限流。所以老老实实让用户转那一下。
|
|
147
|
-
let browser = null
|
|
148
|
-
let why = ""
|
|
149
|
-
try {
|
|
150
|
-
browser = await import("../browser-login.mjs")
|
|
151
|
-
} catch (e) {
|
|
152
|
-
why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
|
|
153
|
-
noteLoginFailure(c.id, why)
|
|
154
|
-
}
|
|
121
|
+
/** 邮箱的粗校验。别搬 RFC 那个怪物正则 —— 服务端还会再验一次 */
|
|
122
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 「验证码登录」——手机号与邮箱共用一份实现。
|
|
126
|
+
*
|
|
127
|
+
* 两条链的协议**完全一样**:取图 → 识图判角度 → 过旋转验证 → 发码 → 用户填码 →
|
|
128
|
+
* 换登录态。差异只有三处,而且都已收敛进 captcha.mjs / headless.mjs 按 ident 分派:
|
|
129
|
+
*
|
|
130
|
+
* · 问用户要什么:11 位手机号 / 邮箱
|
|
131
|
+
* · 发码端点:`/v4/user/send-sms` / `/v4/user/send-email`
|
|
132
|
+
* · 换登录态:`/v4/user/login`(验证码字段叫 `code`)/
|
|
133
|
+
* `/v4/user/login-email`(字段叫 `captcha`)
|
|
134
|
+
*
|
|
135
|
+
* 所以这里只写一份、传 kind。哪天被复制成两份,下次改超时或改文案必然只改一份。
|
|
136
|
+
*
|
|
137
|
+
* 卡点从来不是协议,是那张「拖动滑块使图片角度为正」的图:用 magpie 设置里用户
|
|
138
|
+
* 自己选的识图模型(settings.json 的 vision)判「主体上方向指向钟面几点」,再换算
|
|
139
|
+
* 成拖动角度 —— 实测 120° 的图判出 122°,而服务端容差 ±10°,够用。
|
|
140
|
+
*
|
|
141
|
+
* 没配识图模型时**不在这里瞎猜**(猜中概率只有 ~5.6%,会先撞「验证提交过于频繁」),
|
|
142
|
+
* 而是回退到浏览器登录,让用户自己转那一下。
|
|
143
|
+
*
|
|
144
|
+
* @param {object} c 站点闭包(id / SHORT / web / host)
|
|
145
|
+
* @param {"mobile"|"email"} kind
|
|
146
|
+
*/
|
|
147
|
+
function makeCodeMethod(c, kind) {
|
|
148
|
+
const isEmail = kind === "email"
|
|
149
|
+
const what = isEmail ? "邮箱" : "手机号"
|
|
150
|
+
/** 官方登录页上那个 tab 的名字 —— 回退到浏览器时要说清楚点哪个 */
|
|
151
|
+
const tab = isEmail ? "邮箱登录(Email)" : "手机登录(Phone)"
|
|
155
152
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
153
|
+
/** 用户填的那一项 → 之后每个请求都要带的标识字段 */
|
|
154
|
+
const identOf = (inputs) =>
|
|
155
|
+
isEmail
|
|
156
|
+
? { email: String(inputs?.email || "").trim() }
|
|
157
|
+
: { mobile: String(inputs?.phone || "").replace(/[\s-]/g, ""), stateCode: "+86" }
|
|
161
158
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
159
|
+
/**
|
|
160
|
+
* 给用户看的那个标识。
|
|
161
|
+
* 手机号打码(服务端自己也是这么回的),邮箱原样 —— 邮箱没有打码这回事,
|
|
162
|
+
* 而且它正好是浏览器登录那条路上 `who.name` 会给出的字符串。
|
|
163
|
+
*/
|
|
164
|
+
const displayOf = (ident) =>
|
|
165
|
+
isEmail ? ident.email : String(ident.mobile).replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")
|
|
169
166
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
await browser.closeSession(session).catch(() => {})
|
|
185
|
-
}
|
|
186
|
-
if (!got && !session) {
|
|
187
|
-
got = await pollClipboard({ skip: "" }).catch(() => null)
|
|
188
|
-
}
|
|
189
|
-
if (!got) {
|
|
190
|
-
return {
|
|
191
|
-
type: "failed",
|
|
192
|
-
error: session
|
|
193
|
-
? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
|
|
194
|
-
: "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
try {
|
|
198
|
-
if (!got.org) throw new Error("只拿到 JWT,没有组织码")
|
|
199
|
-
return await completeSignIn(c, got.jwt, got.org)
|
|
200
|
-
} catch (e) {
|
|
201
|
-
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
202
|
-
}
|
|
203
|
-
},
|
|
167
|
+
const looksInvalid = (ident) => (isEmail ? !EMAIL_RE.test(ident.email) : !/^1\d{10}$/.test(ident.mobile))
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
type: "oauth",
|
|
171
|
+
label: `${c.SHORT} ${what}验证码`,
|
|
172
|
+
prompts: [
|
|
173
|
+
isEmail
|
|
174
|
+
? {
|
|
175
|
+
type: "text",
|
|
176
|
+
key: "email",
|
|
177
|
+
message: "邮箱",
|
|
178
|
+
placeholder: "you@example.com",
|
|
179
|
+
validate: (v) =>
|
|
180
|
+
EMAIL_RE.test(String(v || "").trim()) ? undefined : "填一个完整邮箱,例如 you@example.com",
|
|
204
181
|
}
|
|
205
|
-
|
|
182
|
+
: {
|
|
183
|
+
type: "text",
|
|
184
|
+
key: "phone",
|
|
185
|
+
message: "手机号",
|
|
186
|
+
placeholder: "13800138000",
|
|
187
|
+
validate: (v) =>
|
|
188
|
+
/^1\d{10}$/.test(String(v || "").replace(/[\s-]/g, "")) ? undefined : "11 位手机号,只要数字",
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
async authorize(inputs) {
|
|
192
|
+
const ident = identOf(inputs)
|
|
193
|
+
// ⚠ 这里是 authorize,失败只能抛异常,不能返回 { type: "failed" }(见文件头铁律)
|
|
194
|
+
if (looksInvalid(ident)) throw new Error(`${what}不完整`)
|
|
206
195
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
196
|
+
const shown = displayOf(ident)
|
|
197
|
+
|
|
198
|
+
// 邀请码两条路都要出现:登录页的 URL,以及最终注册时服务端读到的那份。
|
|
199
|
+
// ⚠ 无头这条路**没有**「打开带邀请码的页面」这一步,所以邀请码只能靠
|
|
200
|
+
// `redirect` 传 —— 网页表单的初始值里 redirect 就是 window.location.href,
|
|
201
|
+
// 服务端从它上面取 `?invitation=`。少了它,新注册的号就没有邀请关系。
|
|
202
|
+
const inviteCode = inviteToUse(c.id)
|
|
203
|
+
const loginUrl = inviteCode
|
|
204
|
+
? `${c.web}/login?invitation=${encodeURIComponent(inviteCode)}`
|
|
205
|
+
: `${c.web}/login`
|
|
215
206
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
207
|
+
/**
|
|
208
|
+
* 需要过图、但用户没配识图模型 → 回退到浏览器,让他自己转那一下。
|
|
209
|
+
*
|
|
210
|
+
* ⚠ 只有**这个号还没注册**时才该走到这里:已注册的号服务端根本不发旋转图,
|
|
211
|
+
* `startCodeLogin` 会直接发码(见 headless.mjs 里那段说明)。
|
|
212
|
+
*
|
|
213
|
+
* 为什么不在这里瞎猜角度:容差 ±10° 意味着瞎猜命中率只有 ~5.6%(20/360),
|
|
214
|
+
* 平均要试十几次,而服务端有「验证提交过于频繁」和「验证次数已超限」两道风控 ——
|
|
215
|
+
* 等不到猜中,先被限流。所以老老实实让用户转那一下。
|
|
216
|
+
*/
|
|
217
|
+
async function browserFallback() {
|
|
218
|
+
const url = loginUrl
|
|
219
|
+
const hostFragment = new URL(c.web).hostname
|
|
220
|
+
let browser = null
|
|
221
|
+
let why = ""
|
|
219
222
|
try {
|
|
220
|
-
|
|
223
|
+
browser = await import("../browser-login.mjs")
|
|
221
224
|
} catch (e) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
throw new Error(String(e?.message ?? e).slice(0, 500))
|
|
225
|
+
why = `浏览器登录模块加载失败:${String(e?.message ?? e).slice(0, 200)}`
|
|
226
|
+
noteLoginFailure(c.id, why)
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
const session = browser
|
|
230
|
+
? await browser.launchLoginBrowser(url).catch((e) => {
|
|
231
|
+
why = String(e?.message ?? e).slice(0, 300)
|
|
232
|
+
noteLoginFailure(c.id, `launch: ${why}`)
|
|
233
|
+
return null
|
|
234
|
+
})
|
|
235
|
+
: null
|
|
236
|
+
|
|
227
237
|
return {
|
|
228
|
-
url
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
238
|
+
url,
|
|
239
|
+
...(session ? { opened: true } : {}),
|
|
240
|
+
instructions: session
|
|
241
|
+
? `没有配置识图模型,那个「拖动滑块使图片角度为正」得你自己转一下 —— 已经用 ${session.name || "浏览器"} 打开了登录页。\n` +
|
|
242
|
+
`在里面选「${tab}」,填 ${shown},把图片转正、收到验证码后填进去;登录完成这个窗口会自动接上,不用回来点任何东西。\n` +
|
|
243
|
+
`(想全自动的话:magpie 设置 → 识图模型,选一个能看图的,下次就只需要填${what}和验证码。)`
|
|
244
|
+
: `没能自己拉起浏览器${why ? `(${why})` : "(本机没找到 Chromium 内核浏览器)"},已改用系统浏览器打开登录页。\n` +
|
|
245
|
+
`登录完成后,在页面里按 F12 → Console 粘这一行:\n${CLIP_HINT}`,
|
|
246
|
+
method: "auto",
|
|
247
|
+
async callback() {
|
|
248
|
+
let got = null
|
|
249
|
+
if (session && browser) {
|
|
250
|
+
got = await browser.waitForCreds(session, hostFragment).catch(() => null)
|
|
251
|
+
await browser.closeSession(session).catch(() => {})
|
|
252
|
+
}
|
|
253
|
+
if (!got && !session) {
|
|
254
|
+
got = await pollClipboard({ skip: "" }).catch(() => null)
|
|
255
|
+
}
|
|
256
|
+
if (!got) {
|
|
257
|
+
return {
|
|
258
|
+
type: "failed",
|
|
259
|
+
error: session
|
|
260
|
+
? "浏览器开着了,但 5 分钟内没在里面读到登录状态 —— 是还没登完,还是登的不是这个站点?"
|
|
261
|
+
: "5 分钟内没拿到登录状态。可以改用「登录态(JWT + 组织码)」那条备选。",
|
|
262
|
+
}
|
|
247
263
|
}
|
|
248
264
|
try {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
return await completeSignIn(c, creds.jwt, creds.org, creds.orgs, masked)
|
|
265
|
+
if (!got.org) throw new Error("只拿到 JWT,没有组织码")
|
|
266
|
+
return await completeSignIn(c, got.jwt, got.org)
|
|
252
267
|
} catch (e) {
|
|
253
|
-
return { type: "failed", error:
|
|
268
|
+
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
254
269
|
}
|
|
255
270
|
},
|
|
256
271
|
}
|
|
257
|
-
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 发码这一步最慢(要给新号过验证时最多试 4 张图、每张问两遍模型)。
|
|
275
|
+
// 但必须等它结束才能告诉用户「验证码已发出」—— 所以这里不甩到 callback 里。
|
|
276
|
+
let sent
|
|
277
|
+
try {
|
|
278
|
+
sent = await startCodeLogin(c, ident)
|
|
279
|
+
} catch (e) {
|
|
280
|
+
if (e?.code === "no-vision-model") {
|
|
281
|
+
// ⚡ 只有这一种情况才回退到浏览器:这个号**还没注册**(服务端给了旋转图),
|
|
282
|
+
// 而用户没配识图模型。已注册的号走不到这里 —— 服务端不出图。
|
|
283
|
+
return browserFallback()
|
|
284
|
+
}
|
|
285
|
+
// 其余一律抛:authorize 里 return failed 会让 magpie 崩在 `.callback()`
|
|
286
|
+
throw new Error(String(e?.message ?? e).slice(0, 500))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
url: loginUrl,
|
|
291
|
+
instructions: sent.skippedCaptcha
|
|
292
|
+
? `验证码已发送到 ${shown}。\n` +
|
|
293
|
+
`这个号服务端那边已经注册过,旋转验证直接放行 —— 这一环跳过了,识图模型这次也用不上。\n` +
|
|
294
|
+
`把收到的 6 位验证码填进下面的框就行。`
|
|
295
|
+
: `验证码已发送到 ${shown}。\n` +
|
|
296
|
+
`旋转验证由识图模型(${readVisionModel()})自动通过,试了 ${sent.rounds} 张图。\n` +
|
|
297
|
+
`把收到的 6 位验证码填进下面的框就行。`,
|
|
298
|
+
method: "code",
|
|
299
|
+
// ⚠ 必须是 true。这条链路**全在后台走完**,不需要任何浏览器;
|
|
300
|
+
// opened: false 会让 magpie 拿 url 去开**系统默认浏览器** —— 而那个浏览器里
|
|
301
|
+
// 往往本来就登着这个站,用户看到的是已登录界面,根本进不了「添加新号」,
|
|
302
|
+
// 还会以为插件把他登进了旧账号。这正是浏览器登录那条路修过的坑。
|
|
303
|
+
opened: true,
|
|
304
|
+
async callback(code) {
|
|
305
|
+
const c0 = String(code || "").replace(/\D/g, "")
|
|
306
|
+
if (c0.length < 4) return { type: "failed", error: "验证码是 4~8 位数字" }
|
|
307
|
+
let creds
|
|
308
|
+
try {
|
|
309
|
+
creds = await finishCodeLogin(c, { ...ident, code: c0, redirect: loginUrl })
|
|
310
|
+
} catch (e) {
|
|
311
|
+
return { type: "failed", error: String(e?.message ?? e).slice(0, 500) }
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
// 手机号:把打码号钉成 accountId(浏览器登录那条路拿到的也是这个字符串);
|
|
315
|
+
// 邮箱:优先让 who 决定(那才是浏览器登录会给出的一致值),who 挂了才退回邮箱
|
|
316
|
+
return await completeSignIn(
|
|
317
|
+
c,
|
|
318
|
+
creds.jwt,
|
|
319
|
+
creds.org,
|
|
320
|
+
creds.orgs,
|
|
321
|
+
isEmail ? null : shown,
|
|
322
|
+
isEmail ? ident.email : null,
|
|
323
|
+
)
|
|
324
|
+
} catch (e) {
|
|
325
|
+
return { type: "failed", error: `登录成功了,但换 key 失败:${String(e?.message ?? e).slice(0, 300)}` }
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
}
|
|
258
329
|
},
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function makeMethods(c) {
|
|
334
|
+
// 顺序 = magpie 登录方式列表里的顺序。
|
|
335
|
+
//
|
|
336
|
+
// ⚠ **邮箱验证码只有国际站有**,国内站不给。实测两件事:
|
|
337
|
+
// ① www.letsmagic.cn/login 的登录方式只有 微信 / 苹果 / 密码 / 手机 / 私有部署,
|
|
338
|
+
// **没有邮箱**(国际站 www.magicrew.ai/login 才有 `switch-to-email`);
|
|
339
|
+
// ② 同一个发信请求,国内站 `/v4/user/send-email` 回 `2000 服务异常,请联系管理员`,
|
|
340
|
+
// 国际站回 `1000 请求成功`。
|
|
341
|
+
// 国内站既然没有邮箱入口,也就不存在邮箱注册的账号 —— 把这条方法摆出来,
|
|
342
|
+
// 只会让用户填完邮箱撞一个看不懂的错。
|
|
343
|
+
const codeMethods =
|
|
344
|
+
c.id === "magicrew-intl"
|
|
345
|
+
? [makeCodeMethod(c, "email"), makeCodeMethod(c, "mobile")]
|
|
346
|
+
: [makeCodeMethod(c, "mobile")]
|
|
347
|
+
|
|
348
|
+
return [
|
|
349
|
+
...codeMethods,
|
|
259
350
|
|
|
260
351
|
{
|
|
261
352
|
type: "oauth",
|
package/src/vision.mjs
CHANGED
|
@@ -128,49 +128,107 @@ function lastClock(text) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
async function askClock(base, model, imageDataUrl, variant) {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
const body = {
|
|
132
|
+
model,
|
|
133
|
+
// ⚠ 这个上限是**给推理 token 留的**,不是给答案留的。
|
|
134
|
+
//
|
|
135
|
+
// 实测(2026-10-28,`workbuddy/deepseek-v4.1-flash`):max_tokens=400 时
|
|
136
|
+
// `finish_reason:"length"`、`reasoning_tokens:400`、**`content` 是 null** ——
|
|
137
|
+
// 推理过程把额度吃干净,答案一个字都没输出。判官拿到空串 ⇒ 判成
|
|
138
|
+
// 「不可信」⇒ 整条无头登录挂掉。给到 6000 时 reasoning 用 899 就收尾,
|
|
139
|
+
// `content` 正常回 "10"。
|
|
140
|
+
// ⇒ 别按「答案就一个数字」去估这个值;它买的是模型的思考空间。
|
|
141
|
+
max_tokens: 6000,
|
|
142
|
+
// ⚠ `reasoning_effort: "low"` —— 实测有据的省法(2026-10-29,同一模型同一批图,
|
|
143
|
+
// 每个配置各跑 6 轮):
|
|
144
|
+
//
|
|
145
|
+
// | | 出数字 | 平均耗时 | 平均 tokens | 平均 reasoning |
|
|
146
|
+
// | 不传 | 5/6 | 20.6 s | 2343 | 3581 字 |
|
|
147
|
+
// | low | 6/6 | 8.4 s | 1007 | 1646 字 |
|
|
148
|
+
//
|
|
149
|
+
// 更快、更省、而且**更稳**(不传的那一组有一次把 6000 token 全烧在思考上、
|
|
150
|
+
// `content` 交白卷)。判出来的角度两者一致。
|
|
151
|
+
//
|
|
152
|
+
// ⚠ 别指望「彻底不思考」——这个模型在网关侧关不掉,实测 12 组参数:
|
|
153
|
+
// `enable_thinking:false` / `thinking_budget:0` → 思考**更长**且 content 空;
|
|
154
|
+
// `reasoning_effort:"none"/"minimal"` → 比不传还长;
|
|
155
|
+
// `thinking:{type:"disabled"}` / `chat_template_kwargs` / `extra_body` /
|
|
156
|
+
// `reasoning:{enabled:false}` → 都只是小幅缩短,没有一个真的关掉。
|
|
157
|
+
// 要真不思考,只能换一个**非推理**的视觉模型(那由用户在 magpie 设置里选)。
|
|
158
|
+
//
|
|
159
|
+
// ⚠ 它**不是** OpenAI 规范字段:下面 send() 里有一层兜底,被 400 就退一步重发。
|
|
160
|
+
reasoning_effort: "low",
|
|
134
161
|
// temperature 0 是为了同一张图两次问法之外的噪声尽量小;
|
|
135
162
|
// 两次用不同措辞(variant)而不是靠随机温度,好复现
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
163
|
+
temperature: 0,
|
|
164
|
+
messages: [
|
|
165
|
+
{
|
|
166
|
+
role: "user",
|
|
167
|
+
content: [
|
|
168
|
+
{ type: "text", text: CLOCK_PROMPT(variant) },
|
|
169
|
+
{ type: "image_url", image_url: { url: imageDataUrl } },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const send = (b) =>
|
|
176
|
+
fetch(`${base}/chat/completions`, {
|
|
177
|
+
method: "POST",
|
|
178
|
+
headers: { "content-type": "application/json" },
|
|
179
|
+
body: JSON.stringify(b),
|
|
180
|
+
signal: AbortSignal.timeout(90_000),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
let res = await send(body)
|
|
184
|
+
if (res.status === 400 && Object.hasOwn(body, "reasoning_effort")) {
|
|
185
|
+
// 兼容兜底:识图模型是**用户自己选的**,不是我们能假设的(插件声明的模型目录里
|
|
186
|
+
// 什么都可能有,magpie 也允许自定义 provider)。有的 OpenAI 兼容端认出这个字段、
|
|
187
|
+
// 有的直接为未知字段回 400。宁可少省那几秒,也不要因为一个可选的优化把整条
|
|
188
|
+
// 无头登录打死 —— 去掉它重发一次,行为退回「不传」的那一档。
|
|
189
|
+
delete body.reasoning_effort
|
|
190
|
+
res = await send(body)
|
|
191
|
+
}
|
|
152
192
|
if (!res.ok) throw new Error(`识图模型回 HTTP ${res.status}`)
|
|
153
193
|
const j = await res.json()
|
|
154
|
-
const
|
|
155
|
-
|
|
194
|
+
const m = j?.choices?.[0]?.message ?? {}
|
|
195
|
+
const content = String(m.content ?? "").trim()
|
|
196
|
+
const reasoning = String(m.reasoning_content ?? "")
|
|
197
|
+
// ① 正常情况:答案在 content 里
|
|
198
|
+
if (content) return { clock: lastClock(content), text: content.slice(-200), from: "content" }
|
|
199
|
+
// ② 兜底:推理模型被截断、content 为空 —— 答案在 reasoning 的最后一段里
|
|
200
|
+
// (实测尾巴就是「最终决定:10。」这种形状)。只看尾巴 300 字,
|
|
201
|
+
// 别把前面推理中间提到的钟点当答案。
|
|
202
|
+
if (reasoning) {
|
|
203
|
+
const tail = reasoning.slice(-300)
|
|
204
|
+
return { clock: lastClock(tail), text: tail.slice(-200), from: "reasoning" }
|
|
205
|
+
}
|
|
206
|
+
return { clock: null, text: "", from: "empty" }
|
|
156
207
|
}
|
|
157
208
|
|
|
158
209
|
/**
|
|
159
210
|
* 让识图模型判断「该顺时针转多少度」。
|
|
160
211
|
*
|
|
212
|
+
* @param {string} imageDataUrl 验证码图的 data URL
|
|
213
|
+
* @param {{model?:string}} [opts] `model` 可覆盖设置里那个(给对比脚本 / 测试用;
|
|
214
|
+
* 正常运行不传,读用户选的)
|
|
161
215
|
* @returns {Promise<{ok:true, deg:number, clock:number, spread:number, model:string}
|
|
162
216
|
* |{ok:false, reason:string, detail?:string}>}
|
|
163
217
|
*/
|
|
164
|
-
export async function decideDegFromImage(imageDataUrl) {
|
|
165
|
-
const model = readVisionModel()
|
|
218
|
+
export async function decideDegFromImage(imageDataUrl, { model: overrideModel } = {}) {
|
|
219
|
+
const model = overrideModel || readVisionModel()
|
|
166
220
|
if (!model) return { ok: false, reason: "no-vision-model" }
|
|
167
221
|
|
|
168
222
|
const base = await resolveGatewayBase()
|
|
169
223
|
if (!base) return { ok: false, reason: "no-gateway", detail: "连不上 magpie 自己的网关(试过 3425/3426/8080)" }
|
|
170
224
|
|
|
171
225
|
const img = await toPngDataUrl(imageDataUrl)
|
|
172
|
-
|
|
173
|
-
|
|
226
|
+
// ⚠ **两个判官并行**:串行等两次就把延迟叠成两倍(实测单次 5~20 秒),
|
|
227
|
+
// 而它们互不依赖 —— 一起发,耗时等于较慢的那一次。
|
|
228
|
+
const [a, b] = await Promise.all([
|
|
229
|
+
askClock(base, model, img, 0).catch((e) => ({ clock: null, text: String(e?.message ?? e) })),
|
|
230
|
+
askClock(base, model, img, 1).catch(() => ({ clock: null, text: "" })),
|
|
231
|
+
])
|
|
174
232
|
|
|
175
233
|
if (a.clock == null && b.clock == null) {
|
|
176
234
|
return { ok: false, reason: "unparsable", detail: `${a.text} / ${b.text}` }
|