magpie-marvis 0.2.3 → 0.2.5

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/CHANGELOG.md CHANGED
@@ -3,7 +3,24 @@
3
3
  > 每个版本一节,**一条一行**,说用户能感知的变化。
4
4
  > 格式与规矩见 [UPDATING.md](./UPDATING.md)。
5
5
 
6
- ## 0.2.3 — 2026-11-06
6
+ ## 0.2.5 — 2026-11-06
7
+
8
+ **修复**
9
+
10
+ - **`state` 格式与客户端不一致**:app 是 `state=${Math.random()*100}`(**小数**,如 `31.1177412`),
11
+ 我写成了 `Math.floor(Math.random()*1e9)`(整数)。抓包确认服务端收到的是 `845583851` 这种
12
+ - **`loginType` 映射错了**:渲染层 `yct={QC:"1",WX:"2",WXAPP:"2"}`,
13
+ `Ual-Access-Login-Type` 应该是 `1`/`2`,我写的是 `6`/`5`
14
+
15
+ **修复(`-109` 的最终根因)**
16
+
17
+ - **`marvis_oauth` 会被请求两次**:QQ 的 `self_redirect` + 页面自身重载,
18
+ 让同一个 code 打两遍 —— 第一次成功返回 token,第二次因为 code 已消耗而回
19
+ `-109 RC_PARAMS_INVALID`。我之前读的是**页面最终状态**,拿到的正是第二次。
20
+ 客户端主进程用 `[oauth-dedup]`("reuse first request")处理这件事;
21
+ 插件改成监听 `Network.responseReceived`,**只认第一个响应体**,抓到立刻返回
22
+ - **`authorize()` 阻塞 1.4 秒**:里面调了 `findBrowser()`(15 次注册表查询),
23
+ 违反 HOOKS 四·陷阱 1。挪进 `callback()`
7
24
 
8
25
  **修复**
9
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-marvis",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "magpie / OpenCode provider plugin for Tencent Marvis: its DeepSeek V4 Pro, Kimi K2.5, Claude Opus/Sonnet 4.6, GPT-5.4 and Hunyuan models through the Marvis model gateway — no API key to paste, sign in with QQ or WeChat.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -60,7 +60,9 @@ function oauthLogin(loginType, label, instructions) {
60
60
  label,
61
61
  async authorize() {
62
62
  const guid = deviceGuid()
63
- const browser = findBrowser()
63
+ // ⚠ 不要在这里 `findBrowser()` —— 它要查 15 次注册表(实测 1.4 秒),
64
+ // 而 `authorize()` 必须**立刻**返回(HOOKS 四·陷阱 1)。
65
+ // 浏览器查找挪进 `callback`,那时才真正需要它。
64
66
  const url = authorizeUrl(loginType, guid)
65
67
 
66
68
  return {
@@ -70,6 +72,7 @@ function oauthLogin(loginType, label, instructions) {
70
72
  method: "auto",
71
73
  instructions,
72
74
  async callback(_url, _input, signal) {
75
+ const browser = findBrowser()
73
76
  if (!browser) {
74
77
  throw new Error(
75
78
  "没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)。" +
@@ -315,52 +315,58 @@ export async function openIsolatedLogin({ browser, url, match, extraHeaders, tim
315
315
  // 导航到授权页(钩子已装好:UA / client hints / navigator.webdriver)
316
316
  await send(6, "Page.navigate", { url }).catch(() => {})
317
317
 
318
- // ⚠ 不拦截、不重放、不替换页面 —— 让浏览器**自己**走完换取请求。
319
- // 为什么:`marvis_oauth` 的换取依赖 (a) 浏览器 TLS 指纹(Node 的 undici 会被 WAF 400)、
320
- // (b) 同源 Cookie、(c) Referer。这三样只有「浏览器自己发」才齐全。
321
- // 之前用 `Fetch.fulfillRequest` 换成静态页再在页面里 fetch,会因为执行上下文
322
- // 随导航销毁而拿不到结果 —— 表现就是卡在「正在完成授权…」。
318
+ // ⚠⚠ 核心:`marvis_oauth` 会被**请求两次** —— QQ 的 `self_redirect` + 页面自身
319
+ // 的重载会让同一个 code 打两遍。第一次成功并返回 token,第二次因为 code
320
+ // 已被消耗而回 `-109 RC_PARAMS_INVALID`。
321
+ // 客户端主进程用 `[oauth-dedup]` 处理这件事("reuse first request");
322
+ // 我们这边等价的做法是:**只认第一个响应体**,绝不去读页面最终状态。
323
323
  //
324
- // 服务端换完 code 会**直接返回 token JSON**,所以只要等页面跳到回调地址、
325
- // 再把 body 读出来就行。
324
+ // 所以:监听 `Network.responseReceived` → 命中 `marvis_oauth` →
325
+ // 立刻 `Network.getResponseBody` 把正文抓下来,然后马上返回。
326
326
  let nextId = 10
327
+ /** @type {string | null} */
328
+ let firstRequestId = null
329
+ /** @type {string} */
327
330
  let hitUrl = ""
328
- let body = ""
329
331
 
330
- while (Date.now() < deadline) {
331
- if (signal?.aborted) throw new Error("登录已取消")
332
-
333
- let href = ""
332
+ ws.addEventListener("message", (event) => {
333
+ let msg
334
334
  try {
335
- const r = await send(nextId++, "Runtime.evaluate", {
336
- expression: "location.href",
337
- returnByValue: true,
338
- })
339
- href = String(r?.result?.value ?? "")
335
+ msg = JSON.parse(String(event.data))
340
336
  } catch {
341
- /* 页面正在导航 */
337
+ return
338
+ }
339
+ if (msg.method === "Network.responseReceived" && !firstRequestId) {
340
+ const { requestId, response } = msg.params ?? {}
341
+ const u = String(response?.url ?? "")
342
+ // 只认 2xx/4xx 的**文档**响应;图片/脚本不算
343
+ const type = String(response?.type ?? "")
344
+ if (u && match.test(u) && (type === "Document" || type === "Fetch" || type === "XHR")) {
345
+ firstRequestId = requestId
346
+ hitUrl = u
347
+ }
342
348
  }
349
+ })
350
+
351
+ while (Date.now() < deadline) {
352
+ if (signal?.aborted) throw new Error("登录已取消")
343
353
 
344
- if (href && match.test(href)) {
345
- hitUrl = href
346
- // 页面可能还在渲染,多读几次直到拿到非空正文
347
- for (let i = 0; i < 20; i++) {
354
+ if (firstRequestId) {
355
+ // 多试几次:responseReceived 之后 body 可能还没落盘
356
+ for (let i = 0; i < 15; i++) {
348
357
  try {
349
- const r = await send(nextId++, "Runtime.evaluate", {
350
- expression: "document.body ? document.body.innerText : ''",
351
- returnByValue: true,
352
- })
353
- body = String(r?.result?.value ?? "")
354
- if (body.trim()) break
358
+ const r = await send(nextId++, "Network.getResponseBody", { requestId: firstRequestId })
359
+ const raw = String(r?.body ?? "")
360
+ const body = r?.base64Encoded ? Buffer.from(raw, "base64").toString("utf8") : raw
361
+ if (body.trim()) return { url: hitUrl, body, browser: browser.name }
355
362
  } catch {
356
- /* 还在导航 */
363
+ /* body 还没就绪 */
357
364
  }
358
- await new Promise((r) => setTimeout(r, 400))
365
+ await new Promise((r) => setTimeout(r, 300))
359
366
  }
360
- return { url: hitUrl, body, browser: browser.name }
361
367
  }
362
368
 
363
- await new Promise((r) => setTimeout(r, 600))
369
+ await new Promise((r) => setTimeout(r, 500))
364
370
  }
365
371
 
366
372
  } finally {
@@ -226,7 +226,7 @@ export function parseLoginState(text) {
226
226
  openid: m[1],
227
227
  accessToken: m[2],
228
228
  refreshToken: m[3] || undefined,
229
- loginType: /^WX$/i.test(m[4] ?? "") ? "5" : "6",
229
+ loginType: /^WX/i.test(m[4] ?? "") ? "2" : "1",
230
230
  guid: memGuid ? memGuid[1].toLowerCase() : guid,
231
231
  expiresAt: expire ? Number(expire[1]) * 1000 : Date.now() + 30 * 24 * 3600_000,
232
232
  }
@@ -56,7 +56,9 @@ export function authorizeUrl(loginType, guid) {
56
56
  client_id: QQ_APP_ID,
57
57
  // ⚠ 必须带 ?login_type=QC,见 REDIRECT 上方说明
58
58
  redirect_uri: `${REDIRECT}?login_type=${loginType}`,
59
- state: String(Math.floor(Math.random() * 1e9)),
59
+ // ⚠ 必须与 Marvis 渲染层逐字一致:`state=${Math.random()*100}`
60
+ // 是**小数**(如 31.1177412),不是整数。服务端会拿它做校验,格式不符会被判参数无效。
61
+ state: String(Math.random() * 100),
60
62
  })
61
63
  return `https://graph.qq.com/oauth2.0/authorize?${p}`
62
64
  }
@@ -228,7 +230,9 @@ function normalize(info, accessToken, openid) {
228
230
  refreshToken: info?.refresh_token ? String(info.refresh_token) : undefined,
229
231
  nickname: info?.nick_name ? safeDecode(info.nick_name) : undefined,
230
232
  avatar: info?.head_img_url ?? undefined,
231
- loginType: "6",
233
+ // ⚠ `Ual-Access-Login-Type` 的真实取值:QC→"1"、WX/WXAPP→"2"
234
+ // (渲染层 `yct={QC:"1",WX:"2",WXAPP:"2"}`)。不是 6。
235
+ loginType: info?.login_type === "WX" || info?.login_type === "WXAPP" ? "2" : "1",
232
236
  expiresAt: expire > 1e9 ? expire * 1000 : Date.now() + 30 * 24 * 3600_000,
233
237
  }
234
238
  }