dsh-deepseek-web-login 0.6.29 → 0.6.31
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 +96 -0
- package/lib/index.js +130 -34
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
本项目大致遵循语义化版本;日期为本地时间。
|
|
4
4
|
|
|
5
|
+
## 0.6.31 — 2026-10-02
|
|
6
|
+
|
|
7
|
+
**修复:新建一个窗口、只发一句话,网页端会多出一个会话(那个是"生成标题"用的脚手架)。**
|
|
8
|
+
|
|
9
|
+
现场(用户原话):「刚在 dsh 中新建一个窗口聊天,就发了一句话,网页版直接俩窗口」。
|
|
10
|
+
`feed-decisions.jsonl` 里两条对得整整齐齐:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
12:55:54 new-session sess=04c63135 ← 对话那条
|
|
14
|
+
12:56:00 no-parts sess=de6a8d4f ← 网页端多出来的那个
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`de6a8d4f` 正是用户在网页端看到的那条(标题请求的 prompt 直接显示在里面)。
|
|
18
|
+
|
|
19
|
+
**根因**:`session-title` 这类内部请求**必须有一条自己的网页端会话**才能调 completion
|
|
20
|
+
(0.6.26 为了修「`n/n` 分叉」特意把它们和对话分开,见 `requestSlotKey` 的注释)。
|
|
21
|
+
当时的注释写的是「代价是内部请求自己占一个会话,**它不出现在对话里**」——
|
|
22
|
+
**判断漏了一半**:它确实不在*对话*里,但它**出现在侧边栏里**。
|
|
23
|
+
|
|
24
|
+
而它**从来不会被删**:用户设的是 `sessionCleanup: keep`,而清理器的 `schedule()` 在 keep 下
|
|
25
|
+
第一行就 `return` —— 于是这条会话**连 ledger 都没有**(从没安排过删除),永远留在网页端。
|
|
26
|
+
|
|
27
|
+
**改法**:把「内部请求的会话」与「用户的对话会话」在**收尾时彻底分开**。
|
|
28
|
+
|
|
29
|
+
- 收尾时按 `promptParts` 有没有传来分(与 `requestSlotKey`、adapter 的 `chatLike` 同一处判据):
|
|
30
|
+
**不带** = 内部请求 ⇒ 会话是**脚手架**,一律走新的 `onDiscardSession` 通道丢掉,
|
|
31
|
+
**不进用户的清理队列**。
|
|
32
|
+
- ⚠️ **复用来的会话也要丢** —— 它不在收尾逻辑的 `owned` 集合里(那一轮没调 `createSession`),
|
|
33
|
+
这正是最容易漏掉的一半。
|
|
34
|
+
- 清理器新增 `discard(auth, sessionId)`:**不受 `keep` / `manualOnly` 影响** ——
|
|
35
|
+
那两道开关的语义是「别删**我的对话**」,而脚手架会话不是用户的对话。
|
|
36
|
+
- ⚠️ `discard` **只删那一个**,**不 drain 队列**:`manualOnly`(链式模式)下队列里攒的是
|
|
37
|
+
**用户的**会话,借这次机会顺手删掉就变成"用户没点按钮却被删了"(0.6.1x 修过的那个 bug)。
|
|
38
|
+
- ⚠️ 仍然受 `deleteWebSessions === false`("一个都不许删"总闸)约束 —— 那个开关不能破。
|
|
39
|
+
所以**关掉总闸时这类会话仍会留在网页端**,这是已知代价。
|
|
40
|
+
|
|
41
|
+
**顺带把一条"注意事项"升级成硬约束:测试 fixture 模拟真实 chat 时必须传 `promptParts`。**
|
|
42
|
+
改了收尾判据之后,4 个用例文件成批变红 —— 全是拿裸 `params`(不带 `promptParts`)去测
|
|
43
|
+
"chat 会话的轮换 / 回收 / 归属"的。这不是 bug,是夹具没按生产链路的形态构造
|
|
44
|
+
(adapter 只给 `chatLike` 传 `promptParts`)。已给 6 处夹具补上,并在文件里写清为什么。
|
|
45
|
+
|
|
46
|
+
**新增 6 条用例**(`check-internal-session-discard.mjs`):内部请求走「丢掉」通道且不进用户队列 /
|
|
47
|
+
复用的会话也要丢 / 对话请求**不**走丢弃通道(防过度清理)/ `keep` 与 `manualOnly` 下都必须删 /
|
|
48
|
+
`discard` 不许顺手清空用户的队列。**三处变异确认变红**:关掉内部请求分支(2 红)、
|
|
49
|
+
把 `discard` 改成 drain 队列(2 红)、以及重建后的产物断言。
|
|
50
|
+
|
|
51
|
+
> ⚠️ 又记一条**产物断言的坑**:打包器除了会去掉单语句的花括号,还会把 `undefined` 改写成 `void 0`
|
|
52
|
+
> —— 断言里写 `=== undefined` 必然假红(这次踩了)。
|
|
53
|
+
|
|
54
|
+
## 0.6.30 — 2026-10-02
|
|
55
|
+
|
|
56
|
+
**补齐请求头:把浏览器**自动加**的那批指纹头收回来,并修掉一个过时的写死版本号。**
|
|
57
|
+
|
|
58
|
+
起因是调研"能不能不打开浏览器也像真浏览器发消息"(报告见工作区 `浏览器指纹伪装可行性调研.md`)。
|
|
59
|
+
调研的结论之一是:**在改 TLS 之前,头部这一层就有零成本的差距可以补**。
|
|
60
|
+
|
|
61
|
+
对着**真实登录捕获**(`accounts/acc_9be659e4.json`,2026-10-02T03:42Z)核对出两处:
|
|
62
|
+
|
|
63
|
+
1. **收头的规则只认 `x-*`,于是浏览器自动加的那批全被丢掉**:
|
|
64
|
+
`sec-ch-ua` / `sec-ch-ua-mobile` / `sec-ch-ua-platform`、
|
|
65
|
+
`sec-fetch-dest` / `sec-fetch-mode` / `sec-fetch-site`、`priority`、`accept`。
|
|
66
|
+
这批是"是不是真浏览器"最表层的信号 —— 真实 Chrome/Edge 发 fetch 请求时一定会带,
|
|
67
|
+
而我们一条都没发。现在收进来,并且**保留浏览器给头的顺序**(顺序本身就是指纹)。
|
|
68
|
+
|
|
69
|
+
2. **`x-client-version` 写死 `2.0.0`,而真实捕获是 `2.5.0`** —— 落后两个小版本,而且
|
|
70
|
+
**没有任何机制会发现它过时**。真实值一直走的是抓取那条路(所以线上没暴露),
|
|
71
|
+
但抓取失败时就会发这个陈旧值。现在:抓来的真值永远优先,写死的只做兜底并标注要定期校准。
|
|
72
|
+
|
|
73
|
+
**顺带(同一批证据的两件事):**
|
|
74
|
+
|
|
75
|
+
- `pickExtraHeaders` 的规则原先在 `browser-login.ts`(CDP 路径)和 `login.ts`(旧 webRequest 路径)
|
|
76
|
+
**各写了一遍** —— 两份一旦漂移,"同一账号换个登录方式指纹就不一样",且不会有任何报错。
|
|
77
|
+
现在只有一处,两边都调它。
|
|
78
|
+
- `captureDefect()` 增加一条判据:**抓到了头、但里面没有 `x-device-id`** 也算捕获缺陷。
|
|
79
|
+
依据:真实捕获里 `x-device-id` 是数美设备指纹(UUID),而上游对"缺少浏览器设备指纹"
|
|
80
|
+
会直接返回 `biz_code=11 / RISK_DEVICE_DETECTED` —— 缺了它不是"信息少一点",是**会被风控判定**。
|
|
81
|
+
⚠️ 只覆盖"有头但缺这一项";`extraHeaders` 整个为空的手工粘 token 路径**故意不碰**(既有设计)。
|
|
82
|
+
|
|
83
|
+
**刻意不做的一件事:不收 `accept-encoding`。**
|
|
84
|
+
它是浏览器的解压能力声明,服务端可能据此回 `zstd`,而 Node 侧的 `fetch`(undici)不保证能解 ——
|
|
85
|
+
一旦解不开,SSE 会**静默变成乱码**(不是报错,是内容全错)。这是"宁可少一个头,也不要静默坏掉"的取舍,
|
|
86
|
+
代码里写了理由,用例里也钉住了(防止有人"顺手补上")。
|
|
87
|
+
|
|
88
|
+
⚠️ **一处保留的偏差**:真实浏览器**不发** `x-app-version`(7 个 `x-*` 里没有它),我们仍在发。
|
|
89
|
+
保留是为了不改变既有行为,代码里标了注释 —— 想更贴近浏览器,删掉那一行即可。
|
|
90
|
+
|
|
91
|
+
**新增 11 条用例**(`check-request-headers.mjs`,此前 `pickExtraHeaders` 只有 1 条、`buildDsHeaders` 零覆盖):
|
|
92
|
+
该收的收进来 / 不该收的别收(逐请求头 + `accept-encoding`)/ 顺序保留 / 抓来的真值赢过兜底 /
|
|
93
|
+
逐请求头必须用当前值(旧 token 旧 cookie 不许漏出去)。
|
|
94
|
+
四处变异确认变红:白名单改回 `x-`(3 条红)、兜底覆盖抓取值(1 条红)、关掉 `x-device-id` 判据(1 条红)、
|
|
95
|
+
以及重建后的一处产物断言。
|
|
96
|
+
|
|
97
|
+
> ⚠️ 写用例时踩了一次**虚断言**:第一版把"抓来的值"和兜底都写成 `2.5.0`,于是
|
|
98
|
+
> "兜底覆盖抓取值"这个变异体照样通过(两者无法区分)。改用 `88.8.8` 之后才真的能打红。
|
|
99
|
+
> 这正是"先自问这条用例在改之前是不是也绿"那条铁律的又一次实证。
|
|
100
|
+
|
|
5
101
|
## 0.6.29 — 2026-10-02
|
|
6
102
|
|
|
7
103
|
**修复:登录态过期(或账号被限流)之后,重新登录会"又新建一个网页端会话"、整段历史全量重发。**
|
package/lib/index.js
CHANGED
|
@@ -616,13 +616,21 @@ function accountsFootprint() {
|
|
|
616
616
|
* 2026-09-21 的现场:`acc_2df7cf2f` 正是 cookie 与 extraHeaders 双空,
|
|
617
617
|
* 且它 `capturedAt` 刷新后 19 秒就撞了 `code 9 / invalid ref file id`。
|
|
618
618
|
* ⚠️ 但这只是**时间相关**,不是已证实的因果 —— 所以这里只留痕,不据此阻断。
|
|
619
|
+
*
|
|
620
|
+
* 2026-10-02 补一条:**抓到了头、但里面没有 `x-device-id`** 也算缺陷。
|
|
621
|
+
* 依据:真实捕获(同日 03:42Z)里 `x-device-id` 是一个 UUID(数美设备指纹),
|
|
622
|
+
* 且上游对"缺少浏览器设备指纹"会直接返回 `biz_code=11 / RISK_DEVICE_DETECTED`。
|
|
623
|
+
* 所以缺了它就不是"信息少一点",而是**会被风控判定**。
|
|
624
|
+
* ⚠️ 只覆盖"有头但缺这一项";`extraHeaders` 整个为空的手工粘 token 路径**故意不碰**
|
|
625
|
+
* (那条路本来就没有任何浏览器头,是既有设计,另议)。
|
|
619
626
|
*/
|
|
620
627
|
function captureDefect(auth) {
|
|
621
628
|
if (!String(auth.token ?? "").trim()) return void 0;
|
|
622
629
|
const hasCookie = !!String(auth.cookie ?? "").trim();
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
return "本次捕获只拿到 token(cookie 与请求头都为空)—— 若之后出现图片引用被拒(code 9)之类的异常,优先怀疑这份凭证";
|
|
630
|
+
const headers = auth.extraHeaders ?? {};
|
|
631
|
+
const hasHeaders = Object.keys(headers).length > 0;
|
|
632
|
+
if (!hasCookie && !hasHeaders) return "本次捕获只拿到 token(cookie 与请求头都为空)—— 若之后出现图片引用被拒(code 9)之类的异常,优先怀疑这份凭证";
|
|
633
|
+
if (hasHeaders && !String(headers["x-device-id"] ?? "").trim()) return "本次捕获的请求头里没有 x-device-id(数美设备指纹)—— 上游对\"缺少浏览器设备指纹\"会直接判 RISK_DEVICE_DETECTED(biz_code=11),建议重新登录一次把这份凭证补全";
|
|
626
634
|
}
|
|
627
635
|
/** 当前生效的登录凭证(没有选择账号 → undefined)。 */
|
|
628
636
|
function readAuth() {
|
|
@@ -3642,29 +3650,40 @@ const DEFAULT_WASM_URL = "https://fe-static.deepseek.com/chat/static/sha3_wasm_b
|
|
|
3642
3650
|
/** 浏览器 UA 兜底(捕获失败时使用)。 */
|
|
3643
3651
|
const FALLBACK_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
3644
3652
|
/**
|
|
3653
|
+
* 网页端 `x-client-version` 的兜底值(**抓取失败时**才用)。
|
|
3654
|
+
*
|
|
3655
|
+
* 🔴 这个值会随网页端发版过时,而且**没有任何自动机制会发现它过时** ——
|
|
3656
|
+
* 旧实现写死 `2.0.0`,到 2026-10-02 已经落后到 `2.5.0`(真实捕获值)。
|
|
3657
|
+
* 所以:① 优先永远用抓来的真值(`auth.extraHeaders`);
|
|
3658
|
+
* ② 这里只保证"万一没有真值时,不至于差得离谱";③ 每次核对捕获时顺手校准一次。
|
|
3659
|
+
*/
|
|
3660
|
+
const FALLBACK_CLIENT_VERSION = "2.5.0";
|
|
3661
|
+
/**
|
|
3645
3662
|
* 组装一次网页端请求的头。
|
|
3646
|
-
*
|
|
3647
|
-
*
|
|
3648
|
-
*
|
|
3663
|
+
*
|
|
3664
|
+
* 顺序与优先级(2026-10-02 重排,理由见下):
|
|
3665
|
+
* ① 先铺**登录时从真实浏览器抓来的指纹头**(`auth.extraHeaders`)——
|
|
3666
|
+
* 头的**顺序本身就是指纹**,浏览器给什么顺序就用什么顺序;
|
|
3667
|
+
* 而且它的值比我们写死的兜底更可信(`x-client-version` 随网页端版本走,写死必然过时)。
|
|
3668
|
+
* ② 再补**兜底**,且只补浏览器没给的(写死值绝不能盖掉抓来的真值)。
|
|
3669
|
+
* ③ 最后放**逐请求现算**的头(token / cookie / hif / pow / content-type / origin / referer)——
|
|
3670
|
+
* 这些必须用**当前**登录态,复用快照里的旧值会出错。
|
|
3649
3671
|
*/
|
|
3650
3672
|
function buildDsHeaders(auth, referer) {
|
|
3651
|
-
const headers = {
|
|
3652
|
-
|
|
3673
|
+
const headers = { ...auth.extraHeaders ?? {} };
|
|
3674
|
+
const defaults = {
|
|
3653
3675
|
accept: "application/json, text/plain, */*",
|
|
3654
3676
|
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
|
|
3655
|
-
"content-type": "application/json",
|
|
3656
|
-
origin: DS_BASE,
|
|
3657
|
-
referer: referer || `https://chat.deepseek.com/`,
|
|
3658
3677
|
"x-client-platform": "web",
|
|
3659
|
-
"x-client-version":
|
|
3660
|
-
"x-app-version":
|
|
3661
|
-
...auth.extraHeaders ?? {}
|
|
3678
|
+
"x-client-version": FALLBACK_CLIENT_VERSION,
|
|
3679
|
+
"x-app-version": FALLBACK_CLIENT_VERSION
|
|
3662
3680
|
};
|
|
3663
|
-
|
|
3681
|
+
for (const [key, value] of Object.entries(defaults)) if (!headers[key]) headers[key] = value;
|
|
3682
|
+
headers["user-agent"] = auth.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
|
|
3664
3683
|
headers["content-type"] = "application/json";
|
|
3665
3684
|
headers.origin = DS_BASE;
|
|
3666
3685
|
headers.referer = referer || `https://chat.deepseek.com/`;
|
|
3667
|
-
headers
|
|
3686
|
+
headers.authorization = `Bearer ${auth.token}`;
|
|
3668
3687
|
headers["x-deepseek-harness"] = "deepseek-harness (+https://github.com/deepseek-ai/deepseek-harness); provider=deepseek-web";
|
|
3669
3688
|
delete headers["x-ds-pow-response"];
|
|
3670
3689
|
if (auth.cookie) headers.cookie = auth.cookie;
|
|
@@ -4482,6 +4501,30 @@ function createSessionCleaner(options = {}) {
|
|
|
4482
4501
|
sessionId: item.sessionId
|
|
4483
4502
|
});
|
|
4484
4503
|
}
|
|
4504
|
+
/**
|
|
4505
|
+
* 立刻丢掉**一个我们自己的脚手架会话**(内部请求用的那个),**不受清理策略影响**。
|
|
4506
|
+
*
|
|
4507
|
+
* 为什么要单独一条路:`keep`(用户选的「不删」)与 `manualOnly`(链式模式下攒着等手动清)
|
|
4508
|
+
* 都让 `schedule()` 变成空操作 —— 那对**用户的对话**是对的,对**我们自己建的会话**是错的。
|
|
4509
|
+
* 内部请求(`session-title` / 压缩之类)必须有一条会话才能调 completion,而那条会话
|
|
4510
|
+
* 既不是用户建的、用户也不需要它 —— 留在网页端就是**凭空多出来的一个会话**。
|
|
4511
|
+
*
|
|
4512
|
+
* 🔴 2026-10-02 用户现场:「刚在 dsh 中新建一个窗口聊天,就发了一句话,网页版直接俩窗口」。
|
|
4513
|
+
* `feed-decisions.jsonl` 里对上了:12:55:54 `new-session`(对话,`04c63135`)+
|
|
4514
|
+
* 12:56:00 `no-parts`(内部请求,`de6a8d4f`)—— 后者正是网页端多出来的那一个,
|
|
4515
|
+
* 而且它**连 ledger 都没有**(keep 模式下 `schedule()` 直接 return,从没安排过删除)。
|
|
4516
|
+
*
|
|
4517
|
+
* ⚠️ 只删**这一个**,**不去 drain 队列**:`manualOnly` 下队列里攒的是用户的会话,
|
|
4518
|
+
* 借这次机会顺手把它们删掉,就变成"用户没点按钮却被删了"(0.6.1x 修过的那个 bug)。
|
|
4519
|
+
* ⚠️ 仍然受 `deleteWebSessions === false`(总闸)约束 —— 那个开关的语义是"一个都不许删",
|
|
4520
|
+
* 接线在 adapter.ts。所以**关掉总闸时这类会话仍会留在网页端**,这是已知代价。
|
|
4521
|
+
*/
|
|
4522
|
+
function discard(auth, sessionId) {
|
|
4523
|
+
return deleteChunk([{
|
|
4524
|
+
auth,
|
|
4525
|
+
sessionId
|
|
4526
|
+
}]);
|
|
4527
|
+
}
|
|
4485
4528
|
function configure(next) {
|
|
4486
4529
|
const modeChanged = next.mode !== void 0 && next.mode !== policy.mode;
|
|
4487
4530
|
if (next.mode !== void 0) policy.mode = next.mode;
|
|
@@ -4506,6 +4549,7 @@ function createSessionCleaner(options = {}) {
|
|
|
4506
4549
|
}
|
|
4507
4550
|
return {
|
|
4508
4551
|
schedule,
|
|
4552
|
+
discard,
|
|
4509
4553
|
flush,
|
|
4510
4554
|
pendingCount: () => queue.length,
|
|
4511
4555
|
policy: () => ({ ...policy }),
|
|
@@ -4530,6 +4574,13 @@ function scheduleDeleteSession(auth, sessionId) {
|
|
|
4530
4574
|
defaultCleaner.schedule(auth, sessionId);
|
|
4531
4575
|
}
|
|
4532
4576
|
/**
|
|
4577
|
+
* 丢掉一个**脚手架会话**(内部请求用的那个)—— 没有注入清理器时的兜底路径。
|
|
4578
|
+
* 语义与 `SessionCleaner.discard` 完全一致(不受 keep 影响)。
|
|
4579
|
+
*/
|
|
4580
|
+
function discardSession(auth, sessionId) {
|
|
4581
|
+
defaultCleaner.discard(auth, sessionId);
|
|
4582
|
+
}
|
|
4583
|
+
/**
|
|
4533
4584
|
* 「立即清理」用:把**当前在用的**网页端会话连同投喂链一起退掉,返回被退的会话 id。
|
|
4534
4585
|
*
|
|
4535
4586
|
* 为什么要有它:链式模式下不自动清理(会话就是链的载体),但用户总得有个"我现在就要
|
|
@@ -5728,7 +5779,14 @@ async function* streamWebCompletion(auth, params, transport = defaultTransport)
|
|
|
5728
5779
|
finalized = true;
|
|
5729
5780
|
const roundOk = complete || sawTerminal;
|
|
5730
5781
|
lastRetireReason = void 0;
|
|
5731
|
-
|
|
5782
|
+
if (params.promptParts === void 0) {
|
|
5783
|
+
const scaffolding = new Set(owned);
|
|
5784
|
+
if (sessionId) scaffolding.add(sessionId);
|
|
5785
|
+
for (const id of scaffolding) {
|
|
5786
|
+
retireSession(id);
|
|
5787
|
+
params.onDiscardSession?.(id);
|
|
5788
|
+
}
|
|
5789
|
+
} else for (const id of owned) {
|
|
5732
5790
|
if (limit > 0 && keepIds.has(id) && !poisoned) continue;
|
|
5733
5791
|
if (id === sessionId && roundOk && !poisoned && limit > 0) continue;
|
|
5734
5792
|
lastRetireReason = id !== sessionId ? "extra-session" : poisoned ? "poisoned" : !roundOk ? "not-finished" : "rotated";
|
|
@@ -6899,6 +6957,10 @@ function createAdapter(deps) {
|
|
|
6899
6957
|
onDeleteSession: deps.config.deleteWebSessions === false ? void 0 : (sessionId) => {
|
|
6900
6958
|
if (deps.sessionCleaner) deps.sessionCleaner.schedule(auth, sessionId);
|
|
6901
6959
|
else scheduleDeleteSession(auth, sessionId);
|
|
6960
|
+
},
|
|
6961
|
+
onDiscardSession: deps.config.deleteWebSessions === false ? void 0 : (sessionId) => {
|
|
6962
|
+
if (deps.sessionCleaner) deps.sessionCleaner.discard(auth, sessionId);
|
|
6963
|
+
else discardSession(auth, sessionId);
|
|
6902
6964
|
}
|
|
6903
6965
|
})) {
|
|
6904
6966
|
if (event.kind === "thinking" || event.kind === "text") roundUsage.outputChars += event.text.length;
|
|
@@ -7499,19 +7561,62 @@ function buildCookieHeader(cookies) {
|
|
|
7499
7561
|
return (cookies ?? []).filter((cookie) => cookie && typeof cookie.name === "string" && String(cookie.domain ?? "").includes("deepseek")).map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
7500
7562
|
}
|
|
7501
7563
|
/**
|
|
7502
|
-
*
|
|
7503
|
-
*
|
|
7564
|
+
* 浏览器**指纹头**的名字判据 —— 只有"浏览器行为的一部分、且可跨请求复用"的才收。
|
|
7565
|
+
*
|
|
7566
|
+
* 这个判据**只能有一处**:原先它在 `browser-login.ts`(CDP 路径)和 `login.ts`
|
|
7567
|
+
* (旧的 webRequest 路径)各写了一遍,收的还只有 `x-*`。两份一旦漂移,
|
|
7568
|
+
* "同一账号用不同路径登录、指纹就不一样",而且不会有任何报错。
|
|
7569
|
+
*/
|
|
7570
|
+
const FINGERPRINT_HEADER_RE = /^(?:x-|sec-ch-ua|sec-fetch-)/;
|
|
7571
|
+
const FINGERPRINT_HEADER_EXACT = /* @__PURE__ */ new Set([
|
|
7572
|
+
"priority",
|
|
7573
|
+
"accept",
|
|
7574
|
+
"accept-language"
|
|
7575
|
+
]);
|
|
7576
|
+
/**
|
|
7577
|
+
* 逐请求**现算**的头 —— 收进快照就是 bug:下一轮用旧值会把当前登录态盖掉。
|
|
7578
|
+
*
|
|
7579
|
+
* ⚠️ `accept-encoding` 是**故意**收在外面的,理由不是"不该收"而是"收了会坏":
|
|
7580
|
+
* 它是浏览器的解压能力声明,服务端可能据此回 `zstd`,而 Node 侧的 `fetch`(undici)
|
|
7581
|
+
* 不保证能解 —— 一旦解不开,SSE 会**静默变成乱码**(不是报错,是内容全错)。
|
|
7582
|
+
* 宁可少一个头,也不要静默坏掉。将来若要收,必须先验证整条解压链。
|
|
7583
|
+
*/
|
|
7584
|
+
const PER_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
7585
|
+
"x-ds-pow-response",
|
|
7586
|
+
"x-hif-dliq",
|
|
7587
|
+
"x-hif-leim",
|
|
7588
|
+
"authorization",
|
|
7589
|
+
"cookie",
|
|
7590
|
+
"content-type",
|
|
7591
|
+
"content-length",
|
|
7592
|
+
"host",
|
|
7593
|
+
"connection",
|
|
7594
|
+
"transfer-encoding",
|
|
7595
|
+
"accept-encoding",
|
|
7596
|
+
"origin",
|
|
7597
|
+
"referer",
|
|
7598
|
+
"user-agent"
|
|
7599
|
+
]);
|
|
7600
|
+
/**
|
|
7601
|
+
* 从浏览器请求头里挑出可复用的**指纹头**(纯函数,便于单测)。
|
|
7602
|
+
*
|
|
7603
|
+
* 为什么重要(2026-10-02 核对真实捕获):
|
|
7604
|
+
* 旧规则只留 `x-*`,于是浏览器**自动加**的那一批全被丢掉 ——
|
|
7605
|
+
* `sec-ch-ua` / `sec-ch-ua-mobile` / `sec-ch-ua-platform`、`sec-fetch-dest/mode/site`、
|
|
7606
|
+
* `priority`。这些是"是不是真浏览器"最表层、最容易被看到的一批信号,
|
|
7607
|
+
* 而丢掉它们的代价是零成本就能避免的。
|
|
7608
|
+
*
|
|
7609
|
+
* ⚠️ 顺序必须保留:**头的顺序本身就是指纹**。`Object.entries` 按插入顺序遍历,
|
|
7610
|
+
* 而调用方是按浏览器给的顺序建这个对象的 ⇒ 收出来的 `out` 就是浏览器的相对顺序。
|
|
7504
7611
|
*/
|
|
7505
7612
|
function pickExtraHeaders(headers) {
|
|
7506
7613
|
const out = {};
|
|
7507
7614
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
7508
7615
|
const lower = key.toLowerCase();
|
|
7509
|
-
if (
|
|
7510
|
-
if (lower
|
|
7616
|
+
if (PER_REQUEST_HEADERS.has(lower)) continue;
|
|
7617
|
+
if (!FINGERPRINT_HEADER_RE.test(lower) && !FINGERPRINT_HEADER_EXACT.has(lower)) continue;
|
|
7511
7618
|
out[lower] = String(value);
|
|
7512
7619
|
}
|
|
7513
|
-
const acceptLanguage = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === "accept-language");
|
|
7514
|
-
if (acceptLanguage) out["accept-language"] = String(acceptLanguage[1]);
|
|
7515
7620
|
return out;
|
|
7516
7621
|
}
|
|
7517
7622
|
/** 极简 CDP 客户端:只需 send + 事件监听。 */
|
|
@@ -8577,16 +8682,7 @@ function hookHeaders(ses, buffer, onRewrite) {
|
|
|
8577
8682
|
if (cleanLower["cookie"]) buffer.cookie = cleanLower["cookie"];
|
|
8578
8683
|
if (cleanLower["x-hif-dliq"]) buffer.hifDliq = cleanLower["x-hif-dliq"];
|
|
8579
8684
|
if (cleanLower["x-hif-leim"]) buffer.hifLeim = cleanLower["x-hif-leim"];
|
|
8580
|
-
if (!buffer.extraHeaders["x-client-version"])
|
|
8581
|
-
const snapshot = {};
|
|
8582
|
-
for (const [key, value] of Object.entries(cleanLower)) {
|
|
8583
|
-
if (!/^x-/.test(key)) continue;
|
|
8584
|
-
if (key === "x-ds-pow-response" || key === "x-hif-dliq" || key === "x-hif-leim") continue;
|
|
8585
|
-
snapshot[key] = value;
|
|
8586
|
-
}
|
|
8587
|
-
if (cleanLower["accept-language"]) snapshot["accept-language"] = cleanLower["accept-language"];
|
|
8588
|
-
buffer.extraHeaders = snapshot;
|
|
8589
|
-
}
|
|
8685
|
+
if (!buffer.extraHeaders["x-client-version"]) buffer.extraHeaders = pickExtraHeaders(cleanLower);
|
|
8590
8686
|
}
|
|
8591
8687
|
callback({ requestHeaders: headers });
|
|
8592
8688
|
});
|
|
@@ -10259,7 +10355,7 @@ async function checkForUpdate(current, fetchImpl) {
|
|
|
10259
10355
|
* 兜底常量与 package.json 的一致性由 `tests/check-smoke.mjs` 守着,不会漂。
|
|
10260
10356
|
*/
|
|
10261
10357
|
/** 与 package.json 保持一致的兜底版本(由测试保证不会漂)。 */
|
|
10262
|
-
const FALLBACK_VERSION = "0.6.
|
|
10358
|
+
const FALLBACK_VERSION = "0.6.31";
|
|
10263
10359
|
let cached;
|
|
10264
10360
|
/** 本插件版本(如 `0.1.26`)。 */
|
|
10265
10361
|
function pluginVersion() {
|