magpie-marvis 0.1.9 → 0.2.0
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 +12 -1
- package/package.json +1 -1
- package/src/marvis/browser.mjs +103 -27
package/CHANGELOG.md
CHANGED
|
@@ -3,7 +3,18 @@
|
|
|
3
3
|
> 每个版本一节,**一条一行**,说用户能感知的变化。
|
|
4
4
|
> 格式与规矩见 [UPDATING.md](./UPDATING.md)。
|
|
5
5
|
|
|
6
|
-
## 0.
|
|
6
|
+
## 0.2.0 — 2026-11-06
|
|
7
|
+
|
|
8
|
+
**修复(逆向客户端主进程后对齐)**
|
|
9
|
+
|
|
10
|
+
- **换取请求改在页面上下文里发**:`yybadaccess` 的 WAF 做 TLS 指纹校验,
|
|
11
|
+
Node 的 undici(OpenSSL)和 Chromium(BoringSSL)指纹不同 ——
|
|
12
|
+
带头的请求从 Node 发出去一律 **400 空响应**。
|
|
13
|
+
- **补 `Referer`**:客户端重放时会把原始请求头整份复制(`reqHeaders` / `cookies` / `referer`),
|
|
14
|
+
缺 referer 服务端判来源不合法。
|
|
15
|
+
- **去掉 `Ual-Access-Businessid`**:实测它会让 WAF 直接 400(这是我这轮踩的最大坑——
|
|
16
|
+
加头反而更糟,不加头时至少还能拿到 `-109` JSON)。
|
|
17
|
+
- 浏览器先开 `about:blank`、装好钩子再 `Page.navigate` —— 否则首次请求漏过去,拦截永远不触发。
|
|
7
18
|
|
|
8
19
|
**修复(这是 `-109` 的真正根因)**
|
|
9
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magpie-marvis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
@@ -198,11 +198,12 @@ async function getJson(url, timeoutMs = 3000) {
|
|
|
198
198
|
* @param {{name:string,path:string}} o.browser findBrowser() 的返回值
|
|
199
199
|
* @param {string} o.url 登录页
|
|
200
200
|
* @param {RegExp} o.match 回调地址的判据
|
|
201
|
+
* @param {Record<string,string>} [o.extraHeaders] 注入到**每个请求**的头(MarvisExt 等)
|
|
201
202
|
* @param {number} [o.timeoutMs] 默认 5 分钟
|
|
202
203
|
* @param {AbortSignal} [o.signal]
|
|
203
204
|
* @returns {Promise<{url:string, body:string, browser:string}>} 回调页面的地址与正文
|
|
204
205
|
*/
|
|
205
|
-
export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 60_000, signal }) {
|
|
206
|
+
export async function openIsolatedLogin({ browser, url, match, extraHeaders, timeoutMs = 5 * 60_000, signal }) {
|
|
206
207
|
if (!browser?.path) throw new Error("没有找到可用的 Chromium 内核浏览器(Chrome / Edge / 360 / QQ 浏览器都行)")
|
|
207
208
|
|
|
208
209
|
const profile = mkdtempSync(join(tmpdir(), "magpie-marvis-login-"))
|
|
@@ -223,7 +224,9 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
|
|
|
223
224
|
"--exclude-switches=enable-automation",
|
|
224
225
|
`--user-agent=${CHROME_UA}`,
|
|
225
226
|
"--lang=zh-CN",
|
|
226
|
-
|
|
227
|
+
// ⚠ 必须先开 about:blank:钩子(Fetch.enable / setExtraHTTPHeaders)要在
|
|
228
|
+
// 导航**之前**装好,否则首次请求漏过去,拦截永远不触发。
|
|
229
|
+
"about:blank",
|
|
227
230
|
],
|
|
228
231
|
{ stdio: "ignore", windowsHide: false },
|
|
229
232
|
)
|
|
@@ -295,20 +298,38 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
|
|
|
295
298
|
acceptLanguage: "zh-CN,zh;q=0.9",
|
|
296
299
|
platform: "Win32",
|
|
297
300
|
}).catch(() => {})
|
|
301
|
+
// ⚠⚠ 关键:服务端对 `yybadaccess.3g.qq.com` 的**每个**请求都校验
|
|
302
|
+
// `Ual-Access-MarvisExt`(uskey 设备指纹)。没有它就返回
|
|
303
|
+
// 「请使用 Marvis 客户端」—— 这正是浏览器登录一直失败的原因。
|
|
304
|
+
// Marvis 的 BrowserWindow 用 `session.webRequest.onBeforeSendHeaders` 注入,
|
|
305
|
+
// 这里用 CDP 的 `Network.setExtraHTTPHeaders` 达到同样效果。
|
|
306
|
+
if (extraHeaders && Object.keys(extraHeaders).length > 0) {
|
|
307
|
+
await send(5, "Network.setExtraHTTPHeaders", { headers: extraHeaders }).catch(() => {})
|
|
308
|
+
}
|
|
309
|
+
|
|
298
310
|
// 顺手抹掉 navigator.webdriver(比 --disable-blink-features 更干净,不带警告条)
|
|
299
311
|
await send(3, "Page.addScriptToEvaluateOnNewDocument", {
|
|
300
312
|
source: "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})",
|
|
301
313
|
}).catch(() => {})
|
|
302
314
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
315
|
+
// ⚠⚠ 核心:**在跳转发生前**拦下回调,然后在**页面上下文里**发换取请求。
|
|
316
|
+
//
|
|
317
|
+
// 为什么不能从 Node 发:`yybadaccess` 的 WAF 做 **TLS 指纹**校验 ——
|
|
318
|
+
// Node 的 undici(OpenSSL)和 Chromium(BoringSSL)指纹不同,
|
|
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
|
+
|
|
328
|
+
let nextId = 10
|
|
306
329
|
/** @type {string | null} */
|
|
307
|
-
let
|
|
330
|
+
let pausedId = null
|
|
308
331
|
/** @type {string} */
|
|
309
|
-
let
|
|
310
|
-
/** @type {{url:string, body:string} | null} */
|
|
311
|
-
let hit = null
|
|
332
|
+
let pausedUrl = ""
|
|
312
333
|
|
|
313
334
|
ws.addEventListener("message", (event) => {
|
|
314
335
|
let msg
|
|
@@ -317,36 +338,91 @@ export async function openIsolatedLogin({ browser, url, match, timeoutMs = 5 * 6
|
|
|
317
338
|
} catch {
|
|
318
339
|
return
|
|
319
340
|
}
|
|
320
|
-
if (msg.method === "
|
|
321
|
-
const { requestId,
|
|
322
|
-
const u = String(
|
|
341
|
+
if (msg.method === "Fetch.requestPaused" && !pausedId) {
|
|
342
|
+
const { requestId, request } = msg.params ?? {}
|
|
343
|
+
const u = String(request?.url ?? "")
|
|
323
344
|
if (u && match.test(u)) {
|
|
324
|
-
|
|
325
|
-
|
|
345
|
+
pausedId = requestId
|
|
346
|
+
pausedUrl = u
|
|
326
347
|
}
|
|
327
348
|
}
|
|
328
349
|
})
|
|
329
350
|
|
|
330
|
-
let nextId = 10
|
|
331
351
|
while (Date.now() < deadline) {
|
|
332
352
|
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
|
+
}
|
|
333
359
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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) });
|
|
343
399
|
}
|
|
344
|
-
|
|
400
|
+
})()`
|
|
345
401
|
|
|
346
|
-
|
|
402
|
+
/** @type {any} */
|
|
403
|
+
let out = null
|
|
404
|
+
for (let i = 0; i < 20; i++) {
|
|
405
|
+
try {
|
|
406
|
+
const r = await send(nextId++, "Runtime.evaluate", {
|
|
407
|
+
expression: expr,
|
|
408
|
+
awaitPromise: true,
|
|
409
|
+
returnByValue: true,
|
|
410
|
+
})
|
|
411
|
+
const raw = r?.result?.value
|
|
412
|
+
if (raw) {
|
|
413
|
+
out = JSON.parse(String(raw))
|
|
414
|
+
break
|
|
415
|
+
}
|
|
416
|
+
} catch {
|
|
417
|
+
/* 页面还在导航 */
|
|
418
|
+
}
|
|
419
|
+
await new Promise((r) => setTimeout(r, 500))
|
|
347
420
|
}
|
|
348
421
|
|
|
349
|
-
throw new Error("
|
|
422
|
+
if (!out) throw new Error("换取登录态超时:页面里没能完成请求。请重新走一遍登录。")
|
|
423
|
+
if (out.__error) throw new Error(`页面内换取失败:${out.__error}`)
|
|
424
|
+
|
|
425
|
+
return { url: exchangeUrl, body: String(out.__body ?? ""), browser: browser.name }
|
|
350
426
|
} finally {
|
|
351
427
|
try {
|
|
352
428
|
ws.close()
|