magpie-marvis 0.2.1 → 0.2.3
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 +13 -1
- package/package.json +1 -1
- package/src/marvis/browser.mjs +37 -97
- package/src/marvis/login.mjs +10 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,19 @@
|
|
|
3
3
|
> 每个版本一节,**一条一行**,说用户能感知的变化。
|
|
4
4
|
> 格式与规矩见 [UPDATING.md](./UPDATING.md)。
|
|
5
5
|
|
|
6
|
-
## 0.2.
|
|
6
|
+
## 0.2.3 — 2026-11-06
|
|
7
|
+
|
|
8
|
+
**修复**
|
|
9
|
+
|
|
10
|
+
- **响应解析对格式不敏感**:浏览器渲染 `application/json` 会加缩进/包装,
|
|
11
|
+
原来只看 `startsWith("{")` 会漏判。改成「抠第一个 `{` 到最后一个 `}`」再解析
|
|
12
|
+
|
|
13
|
+
**修复**
|
|
14
|
+
|
|
15
|
+
- **授权页卡在「正在完成授权…」,magpie 加不进号**:上一版用 `Fetch.fulfillRequest`
|
|
16
|
+
把回调换成静态页再在页面里 `fetch` 换取 —— 但执行上下文会随导航销毁,`Runtime.evaluate`
|
|
17
|
+
拿不到结果。改成**完全不拦截**:让浏览器自己走完换取(TLS 指纹 / Cookie / Referer 天然齐全),
|
|
18
|
+
只轮询 `location.href` 等它跳到回调地址,再读 `document.body.innerText`
|
|
7
19
|
|
|
8
20
|
**变更**
|
|
9
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-marvis",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
package/src/marvis/browser.mjs
CHANGED
|
@@ -312,117 +312,57 @@ export async function openIsolatedLogin({ browser, url, match, extraHeaders, tim
|
|
|
312
312
|
source: "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})",
|
|
313
313
|
}).catch(() => {})
|
|
314
314
|
|
|
315
|
-
//
|
|
315
|
+
// 导航到授权页(钩子已装好:UA / client hints / navigator.webdriver)
|
|
316
|
+
await send(6, "Page.navigate", { url }).catch(() => {})
|
|
317
|
+
|
|
318
|
+
// ⚠ 不拦截、不重放、不替换页面 —— 让浏览器**自己**走完换取请求。
|
|
319
|
+
// 为什么:`marvis_oauth` 的换取依赖 (a) 浏览器 TLS 指纹(Node 的 undici 会被 WAF 400)、
|
|
320
|
+
// (b) 同源 Cookie、(c) Referer。这三样只有「浏览器自己发」才齐全。
|
|
321
|
+
// 之前用 `Fetch.fulfillRequest` 换成静态页再在页面里 fetch,会因为执行上下文
|
|
322
|
+
// 随导航销毁而拿不到结果 —— 表现就是卡在「正在完成授权…」。
|
|
316
323
|
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// 带 `Ual-Access-MarvisExt` 的请求从 Node 发出去一律 **400 空响应**。
|
|
320
|
-
// 在页面里 `fetch` 出去 = Chromium 的 TLS 栈 + 同源 Cookie,服务端才认。
|
|
321
|
-
await send(6, "Fetch.enable", {
|
|
322
|
-
patterns: [{ urlPattern: "*marvis_client_login/marvis_oauth*", requestStage: "Request" }],
|
|
323
|
-
}).catch(() => {})
|
|
324
|
-
|
|
325
|
-
// 钩子装好,现在才导航到授权页
|
|
326
|
-
await send(7, "Page.navigate", { url }).catch(() => {})
|
|
327
|
-
|
|
324
|
+
// 服务端换完 code 会**直接返回 token JSON**,所以只要等页面跳到回调地址、
|
|
325
|
+
// 再把 body 读出来就行。
|
|
328
326
|
let nextId = 10
|
|
329
|
-
|
|
330
|
-
let
|
|
331
|
-
/** @type {string} */
|
|
332
|
-
let pausedUrl = ""
|
|
333
|
-
|
|
334
|
-
ws.addEventListener("message", (event) => {
|
|
335
|
-
let msg
|
|
336
|
-
try {
|
|
337
|
-
msg = JSON.parse(String(event.data))
|
|
338
|
-
} catch {
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
if (msg.method === "Fetch.requestPaused" && !pausedId) {
|
|
342
|
-
const { requestId, request } = msg.params ?? {}
|
|
343
|
-
const u = String(request?.url ?? "")
|
|
344
|
-
if (u && match.test(u)) {
|
|
345
|
-
pausedId = requestId
|
|
346
|
-
pausedUrl = u
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
})
|
|
327
|
+
let hitUrl = ""
|
|
328
|
+
let body = ""
|
|
350
329
|
|
|
351
330
|
while (Date.now() < deadline) {
|
|
352
331
|
if (signal?.aborted) throw new Error("登录已取消")
|
|
353
|
-
if (pausedId) break
|
|
354
|
-
await new Promise((r) => setTimeout(r, 400))
|
|
355
|
-
}
|
|
356
|
-
if (!pausedId) {
|
|
357
|
-
throw new Error("登录超时:没有等到授权回调。请在打开的窗口里完成 QQ / 微信 授权。")
|
|
358
|
-
}
|
|
359
332
|
|
|
360
|
-
|
|
361
|
-
await send(nextId++, "Fetch.fulfillRequest", {
|
|
362
|
-
requestId: pausedId,
|
|
363
|
-
responseCode: 200,
|
|
364
|
-
responseHeaders: [{ name: "content-type", value: "text/html; charset=utf-8" }],
|
|
365
|
-
body: Buffer.from(
|
|
366
|
-
'<!doctype html><meta charset="utf-8"><title>Marvis</title>' +
|
|
367
|
-
'<body style="font:16px/1.6 system-ui;padding:48px;color:#111">' +
|
|
368
|
-
"<h2>正在完成授权…</h2><p>回到 magpie 即可。</p></body>",
|
|
369
|
-
"utf8",
|
|
370
|
-
).toString("base64"),
|
|
371
|
-
}).catch(() => {})
|
|
372
|
-
|
|
373
|
-
// 在页面上下文里发换取请求(Chromium 的 TLS 指纹 + 同源 Cookie + 注入的头)
|
|
374
|
-
//
|
|
375
|
-
// ⚠ 还要补 `Referer`:客户端主进程重放时会把**原始请求头**整份复制过去
|
|
376
|
-
// (字符串表里的 `reqHeaders` / `cookies` / `referer` / `ALL_REQ_HEADERS`),
|
|
377
|
-
// 其中 referer 是 QQ 授权页本身的地址。缺它服务端会判来源不合法。
|
|
378
|
-
// fetch 的 Referer 属于禁止头,只能走 CDP 注入。
|
|
379
|
-
try {
|
|
380
|
-
const origin = new URL(pausedUrl).origin
|
|
381
|
-
await send(nextId++, "Network.setExtraHTTPHeaders", {
|
|
382
|
-
headers: { Referer: `${origin}/oauth2.0/show?which=Login&display=pc` },
|
|
383
|
-
})
|
|
384
|
-
} catch {
|
|
385
|
-
/* 注入失败不致命,继续试 */
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const exchangeUrl = pausedUrl
|
|
389
|
-
const expr = `(async () => {
|
|
390
|
-
try {
|
|
391
|
-
const r = await fetch(${JSON.stringify(exchangeUrl)}, {
|
|
392
|
-
method: "GET",
|
|
393
|
-
credentials: "include",
|
|
394
|
-
headers: { "Accept": "application/json" },
|
|
395
|
-
});
|
|
396
|
-
return JSON.stringify({ __status: r.status, __body: await r.text() });
|
|
397
|
-
} catch (e) {
|
|
398
|
-
return JSON.stringify({ __error: String(e && e.message || e) });
|
|
399
|
-
}
|
|
400
|
-
})()`
|
|
401
|
-
|
|
402
|
-
/** @type {any} */
|
|
403
|
-
let out = null
|
|
404
|
-
for (let i = 0; i < 20; i++) {
|
|
333
|
+
let href = ""
|
|
405
334
|
try {
|
|
406
335
|
const r = await send(nextId++, "Runtime.evaluate", {
|
|
407
|
-
expression:
|
|
408
|
-
awaitPromise: true,
|
|
336
|
+
expression: "location.href",
|
|
409
337
|
returnByValue: true,
|
|
410
338
|
})
|
|
411
|
-
|
|
412
|
-
if (raw) {
|
|
413
|
-
out = JSON.parse(String(raw))
|
|
414
|
-
break
|
|
415
|
-
}
|
|
339
|
+
href = String(r?.result?.value ?? "")
|
|
416
340
|
} catch {
|
|
417
|
-
/*
|
|
341
|
+
/* 页面正在导航 */
|
|
418
342
|
}
|
|
419
|
-
await new Promise((r) => setTimeout(r, 500))
|
|
420
|
-
}
|
|
421
343
|
|
|
422
|
-
|
|
423
|
-
|
|
344
|
+
if (href && match.test(href)) {
|
|
345
|
+
hitUrl = href
|
|
346
|
+
// 页面可能还在渲染,多读几次直到拿到非空正文
|
|
347
|
+
for (let i = 0; i < 20; i++) {
|
|
348
|
+
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
|
|
355
|
+
} catch {
|
|
356
|
+
/* 还在导航 */
|
|
357
|
+
}
|
|
358
|
+
await new Promise((r) => setTimeout(r, 400))
|
|
359
|
+
}
|
|
360
|
+
return { url: hitUrl, body, browser: browser.name }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
await new Promise((r) => setTimeout(r, 600))
|
|
364
|
+
}
|
|
424
365
|
|
|
425
|
-
return { url: exchangeUrl, body: String(out.__body ?? ""), browser: browser.name }
|
|
426
366
|
} finally {
|
|
427
367
|
try {
|
|
428
368
|
ws.close()
|
package/src/marvis/login.mjs
CHANGED
|
@@ -176,12 +176,19 @@ export async function exchangeCode(code, loginType, _guid, state) {
|
|
|
176
176
|
export async function parseCallback(callbackUrl, body, opts = {}) {
|
|
177
177
|
const text = String(body ?? "").trim()
|
|
178
178
|
|
|
179
|
-
//
|
|
180
|
-
|
|
179
|
+
// 主路径:正文就是 JSON。
|
|
180
|
+
// ⚠ 不能只看 `startsWith("{")` —— 浏览器渲染 `application/json` 时会加缩进/包装,
|
|
181
|
+
// 也可能前后带空白。改成「抠第一个 `{` 到最后一个 `}`」再解析。
|
|
182
|
+
const jsonText = (() => {
|
|
183
|
+
const a = text.indexOf("{")
|
|
184
|
+
const b = text.lastIndexOf("}")
|
|
185
|
+
return a >= 0 && b > a ? text.slice(a, b + 1) : ""
|
|
186
|
+
})()
|
|
187
|
+
if (jsonText) {
|
|
181
188
|
/** @type {any} */
|
|
182
189
|
let body0
|
|
183
190
|
try {
|
|
184
|
-
body0 = JSON.parse(
|
|
191
|
+
body0 = JSON.parse(jsonText)
|
|
185
192
|
} catch {
|
|
186
193
|
body0 = null
|
|
187
194
|
}
|