dsh-deepseek-web-login 0.6.6 → 0.6.8

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
@@ -2,6 +2,78 @@
2
2
 
3
3
  本项目遵循大致语义化版本;日期为本地时间。
4
4
 
5
+ ## 0.6.8 — 2026-09-30
6
+
7
+ **修「凭证被服务端作废之后不自动换号、只能手动切」+ 把 AUTH 做成条件式可重试。**
8
+
9
+ 现场(用户截图 + 本机台账原文):
10
+
11
+ ```
12
+ 15:49:43 acc_de90f63b {ok:false, code:"AUTH", ms:7} ← 7ms=本地拦下,请求根本没发出去
13
+ 15:49:45 同上(会话标题) {ok:false, code:"AUTH", ms:5}
14
+ 15:49:58 ← 用户手动切到 acc_ec650e63,之后 12 次全部成功
15
+ ```
16
+
17
+ 界面显示「本轮运行失败 · API 密钥无效」,任务停在那里 —— 用户的期待是**它自己换号接下去**。
18
+
19
+ 两个根因:
20
+
21
+ 1. `auth`(凭证作废)**不在** `retryableCodes` 里 ⇒ 重试器一次都不重试。而换号检查点是
22
+ **请求前**检查点:没有下一次请求,就永远等不到它。所以"能换号"这件事在 AUTH 上从来没发生。
23
+ 2. 「AUTH 一票制标记」被 0.2.0 改成"先只读复核探活、确认失效才标记"是对的(防端点级误判),
24
+ 但**标记写完之后的下一步没人接** —— 之前只有"限流/封禁"两条路会触发换号。
25
+
26
+ 改法(让"能不能换号"成为 AUTH 唯一的开关):
27
+
28
+ - `canFailover` 增加第三种 `kind`:`'auth'`。判据抽成纯函数
29
+ `hasFailoverCandidate`(自动换号开着 + 没在切 + 账号库里还有**别的**可用号);
30
+ AUTH 刻意**不过**限流那条"窗口 + 冷却" —— 死号是永久性坏状态,等多久都不会自己好。
31
+ - 三个 AUTH 抛出点(HTTP 401/403、信封 40003/40001、"已知失效就不发请求"的拦截)
32
+ 都按它给退避:**能换号 ⇒ 5 秒**(留出复核探活那 0.9~3.5 秒)**重发一次,检查点换号接上**;
33
+ **不能换号 ⇒ 600 秒**(> `maxDelayMs`)⇒ 重试器直接放弃。
34
+ ⚠️ 这就是"条件式可重试":AUTH 进 `retryableCodes` 是为了让那条 5 秒有路可走,
35
+ **"不能换号就别重试"靠的是退避值**,不是码 —— 所以两档常量各自有用例守,只改一边必红。
36
+ - 默认没开自动换号的用户**行为完全不变**(走 600 秒那档,与加这个功能之前一致)。
37
+ - 「已知失效就不发请求」那条错误的文案跟着分流:能换号时说"正在自动换到另一个账号重试",
38
+ 不能换号时才引导去「切换」/「重登」/「校验全部」。
39
+
40
+ ⚠️ 另一个同源问题(不在本版代码里):官方桌面端装在**新环境**时,旧环境的
41
+ `~/.dsh/web-login/gate.json` 不会跟着过来 ⇒ `autoSwitchMinutes` 落回默认 **0(关闭)**
42
+ ⇒ 整条自动换号链不工作。若你也是从旧环境迁过来的,请检查
43
+ 「设置 → DeepSeek 网页登录 → 防风控 → 自动换号」是不是被重置成关闭了。
44
+
45
+ 用例 `tests/check-auth-failover.mjs`(14 项):两档退避跨在 `maxDelayMs` 两侧 + 纯判据六种边界
46
+ + 三个抛出点的接线守卫。反向验证:把 GIVEUP 退避改小、把宿主签名里的 `'auth'` 删掉,
47
+ 两个变异体都变红。
48
+
49
+ ## 0.6.7 — 2026-09-30
50
+
51
+ **修「点切换像没反应、报错在列表底部看不见」+「探活超时被当成登录态失效」。**
52
+
53
+ 现场(用户截图):点一个**正常**账号的「切换」,界面上像什么都没发生;过一会儿列表底部才冒出
54
+ 一行红字 —— `切换失败:该账号登录态校验未通过(The operation was aborted due to timeout),
55
+ 请重新登录后再切换`。用户的原话是"这样一点也不明显"。
56
+
57
+ 两件事叠在一起:
58
+
59
+ - **失败分类又漏了一处**。0.6.6 给探活分了「授权类 / 网络类」,但**切换前那次探活没跟上**:
60
+ 不管哪一类失败都拦下来、都建议"重新登录"。而那次真因只是超时(网络抖了一下),凭证完全可用。
61
+ - **反馈位置不对**。切换的反馈只写卡片底部那一条消息,而用户的视线还停在他点的那颗按钮上;
62
+ 探活最长 20 秒,这段时间里按钮毫无变化 ⇒ 体感就是"点了没反应"。
63
+
64
+ 改法:
65
+
66
+ 1. **切换前探活按类型分流**(新纯函数 `switchGateFromProbe`,有专门用例守):
67
+ - 授权失效 ⇒ 拦下,说清「这个号的登录态已失效,需要重新登录一次才能切换」;
68
+ - 网络类(超时/断网) ⇒ **放行**,只提示「已切换,但没能校验(网络问题)—— 登录态未必失效」;
69
+ - 探活通过 ⇒ 直接切。
70
+ 与 `staleAuthRecord` 同一取舍:**网络问题不该冒充授权结论**。
71
+ (判据本身还是纯函数,宿主真的会调用它 —— 用例里连这条也守着,防"判据写好了没人调用"。)
72
+ 2. **交互路径的探活超时 20s → 10s**(`SWITCH_PROBE_TIMEOUT_MS`)。后台定时探活仍是 20s。
73
+ 3. **反馈落在那一行**:新增行内提示条(`.dsw-rowmsg`),切换的结果直接写在账号行里;
74
+ 按钮自身进入「切换中…」禁用态。列表重建后按账号 id 把结果写回新行(顺序反了会被冲掉)。
75
+ 4. **「重登」同样落到行内** —— 它是同一类"慢操作 + 只在底部说话"的问题。
76
+
5
77
  ## 0.6.6 — 2026-09-29
6
78
 
7
79
  **修「网络一抖,整库账号被标成需要重新登录,点重登还得重敲密码」。**
package/README.md CHANGED
@@ -400,12 +400,22 @@ cipher 列表哈希与 Chrome 逐字节一致 —— 且**零新依赖**(不
400
400
  对话记录存在 DSH 本地、每一轮请求都把**整段历史完整重发**、网页端不留任何会话。
401
401
  账号只是"通行证 + 额度归属",换它不影响对话内容。(真正会"失忆"的是在 DSH 里开一个新会话。)
402
402
 
403
- > ⚠️ **为什么没有「自动换号」**:账号库只提供**手动**切换,刻意不做"检测到限流就自动换一个号继续发"。
404
- > 真人不会在几分钟内换一个账号接着发消息 —— 那是极强的机器行为特征,与本文档里
405
- > 传输层指纹、随机间隔、会话清理这些"降低机器可识别性"的努力直接冲突。
403
+ > ⚠️ **关于「自动换号」(默认关闭,滑块在「设置 → DeepSeek 网页登录 → 防风控」)**:
404
+ > 它会按你设的间隔(分钟,0 = 关闭)换到下一个可用账号,**0.6.8 起还有一条"失效即换"**:
405
+ > 某个账号的凭证被服务端判为失效(`AUTH` / `40003`)时,只要库里还有别的可用账号,
406
+ > 就会自动换过去并把这一轮接着跑完 —— 不需要你再手动点「切换」。
407
+ >
408
+ > 但请想清楚再打开它:真人不会在几分钟内换一个账号接着发消息 —— 那是极强的机器行为特征,
409
+ > 与本文档里传输层指纹、随机间隔、会话清理这些"降低机器可识别性"的努力直接冲突。
406
410
  > 另外同一服务商会把多账号关联起来(同设备 / 同 IP / 同指纹 / 相近行为),
407
411
  > 一旦被判定为"同一人的多开小号",处置通常比单账号超频更重。
408
- > **账号库的目标是"在自己的多个正常账号之间切换更省事",不是"靠轮换把限流绕过去"。**
412
+ > **账号库的目标是"在自己的多个正常账号之间切换更省事",不是"靠轮换把限流绕过去"** ——
413
+ > 所以它默认是关的,间隔也别给太短(半小时起步比较稳)。
414
+ >
415
+ > ⚠️ 这个设置存在 `~/.dsh/web-login/gate.json`。**换到新环境/重装 DSH 时它不会跟着迁过去**
416
+ > (账号库也不会 —— 用「导出备份 / 导入备份」搬),重启后会回到"关闭",
417
+ > 表现为"以前会自己换号,现在不换了"。
418
+ >
409
419
  > 导出的备份文件里是可完整登录的凭证,等同于账号本身 —— 别分享、别提交到仓库。
410
420
 
411
421
  **「登录新账号」和「退出」的区别**(很容易搞混,但后果完全不同):
@@ -518,7 +528,8 @@ CI(`.github/workflows/ci.yml`)在每次推送到 `main` 与每个 PR 上跑
518
528
  | 现象 | 处理 |
519
529
  |---|---|
520
530
  | 面板「未登录」但登录窗口里已登录 | 点「从已登录窗口恢复」;或看 `loginProgress.lastError` |
521
- | `AUTH` / `40003 Authorization Failed` | 登录态过期 → 重新登录或恢复 |
531
+ | `AUTH` / `40003 Authorization Failed` | 这个账号的凭证被服务端判失效了。**0.6.8 起**:自动换号开着、且库里还有别的可用账号时,会**自动换过去把这一轮接着跑完**(不再需要你手动切号;日志里会写「将自动换到另一个可用账号重试」)。否则请重新登录这个号,或点它那一行的「切换」换一个能用的号 |
532
+ | 以前会自己换号,现在**不换了**(尤其是刚换过 DSH 环境 / 重装过之后) | 自动换号的设置存在 `~/.dsh/web-login/gate.json`,**换环境不会跟着迁过去** ⇒ 静默回到默认的「关闭」,表现为"以前会自己换号,现在不换了"。去「设置 → DeepSeek 网页登录 → 防风控」把「自动换号」重新拉起来即可(拉动一次就会写回该文件) |
522
533
  | `MISSING_CREDENTIAL` | 凭证文件不存在(`~/.dsh/web-login/`) |
523
534
  | `EMPTY_RESPONSE` | 可能触发频控或长上下文截断,属于默认可重试码 |
524
535
  | `RATE_LIMIT` | 免费额度频控,稍后重试 |
package/lib/client.js CHANGED
@@ -409,6 +409,17 @@ border:1px solid var(--bd);border-left:3px solid var(--fg3);white-space:pre-wrap
409
409
  overflow:auto;font-size:12px;line-height:1.6;color:var(--fg)}
410
410
  .dsw-msg.err{border-left-color:var(--err);color:color-mix(in srgb,var(--err) 78%,var(--fg))}
411
411
  .dsw-msg.ok{border-left-color:var(--ok)}
412
+ .dsw-msg.warn{border-left-color:var(--warn);color:color-mix(in srgb,var(--warn) 80%,var(--fg))}
413
+ /* 行内结果条:由**某一行上的按钮**触发的操作(切换 / 重登),结果必须留在那一行。
414
+ 为什么(2026-09-30 用户反馈「点切换没反应,报错在下面显示,一点也不明显」):
415
+ 反馈原先只写卡片底部那条消息,用户点完按钮视线还在原处 —— 列表一长,等于没反馈。 */
416
+ .dsw-rowmsg{margin-top:6px;padding:6px 10px;border-radius:var(--r-sm);background:var(--bg2);
417
+ border-left:3px solid var(--fg3);color:var(--fg2);font-size:12px;line-height:1.55;
418
+ white-space:pre-wrap;word-break:break-word}
419
+ .dsw-rowmsg:empty{display:none}
420
+ .dsw-rowmsg.err{border-left-color:var(--err);color:color-mix(in srgb,var(--err) 78%,var(--fg))}
421
+ .dsw-rowmsg.ok{border-left-color:var(--ok)}
422
+ .dsw-rowmsg.warn{border-left-color:var(--warn);color:color-mix(in srgb,var(--warn) 80%,var(--fg))}
412
423
  .dsw-models{list-style:none;margin:0;padding:0}
413
424
  .dsw-models li{padding:10px 0;border-top:1px solid var(--bd)}
414
425
  .dsw-models li:first-child{border-top:none;padding-top:2px}
@@ -901,34 +912,47 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
901
912
  const reloginAccount = (id, title) => {
902
913
  (async () => {
903
914
  accountsMsg.textContent = `正在校验「${title}」并准备登录窗口……`;
915
+ paintRowMsg(id, "正在校验登录态并准备登录窗口……", "info");
904
916
  try {
905
917
  const prep = await api("/login/relogin", {
906
918
  method: "POST",
907
919
  body: JSON.stringify({ id })
908
920
  });
909
921
  if (prep?.alreadyValid) {
910
- accountsMsg.textContent = String(prep.hint ?? "这条账号校验通过,不需要重新登录");
922
+ const text = String(prep.hint ?? "这条账号校验通过,不需要重新登录");
923
+ accountsMsg.textContent = text;
924
+ paintRowMsg(id, `✅ ${text}`, "ok");
911
925
  await loadAccounts();
912
926
  return;
913
927
  }
914
928
  if (prep?.ok === false) {
915
- accountsMsg.textContent = String(prep?.error ?? "未知原因");
929
+ const text = String(prep?.error ?? "未知原因");
930
+ accountsMsg.textContent = text;
931
+ paintRowMsg(id, `⚠️ ${text}`, "warn");
916
932
  return;
917
933
  }
918
934
  boostUntil = Date.now() + 3e5;
919
- accountsMsg.textContent = String(prep?.hint ?? "登录窗口已打开:如果浏览器里还留着这个账号的登录态会立刻复用,否则在里面重新登录一次……");
935
+ const opened = String(prep?.hint ?? "登录窗口已打开:如果浏览器里还留着这个账号的登录态会立刻复用,否则在里面重新登录一次……");
936
+ accountsMsg.textContent = opened;
937
+ paintRowMsg(id, opened, "info");
920
938
  const result = await api("/login/browser", {
921
939
  method: "POST",
922
940
  body: "{}"
923
941
  });
924
942
  if (result?.started === false) {
925
- accountsMsg.textContent = `打开登录窗口失败:${result?.reason ?? "未知原因"}(可改用「手动粘贴 Token」)`;
943
+ const text = `打开登录窗口失败:${result?.reason ?? "未知原因"}(可改用「手动粘贴 Token」)`;
944
+ accountsMsg.textContent = text;
945
+ paintRowMsg(id, `❌ ${text}`, "err");
926
946
  return;
927
947
  }
928
- accountsMsg.textContent = result?.relogin ? `「${title}」的凭证已原地更新(同一条记录、当前账号未变),旧的失败标记也清掉了。` : result?.added ? `「${title}」已加入账号库 —— 当前使用的账号没有改变。` : "已捕获并保存凭证。";
948
+ const done = result?.relogin ? `「${title}」的凭证已原地更新(同一条记录、当前账号未变),旧的失败标记也清掉了。` : result?.added ? `「${title}」已加入账号库 —— 当前使用的账号没有改变。` : "已捕获并保存凭证。";
949
+ accountsMsg.textContent = done;
929
950
  await loadAccounts();
951
+ paintRowMsg(id, `✅ ${done}`, "ok");
930
952
  } catch (error) {
931
- accountsMsg.textContent = `重新登录失败:${error?.message ?? error}`;
953
+ const message = `重新登录失败:${error?.message ?? error}`;
954
+ accountsMsg.textContent = message;
955
+ paintRowMsg(id, `❌ ${message}`, "err");
932
956
  } finally {
933
957
  await refresh(false).catch(() => void 0);
934
958
  }
@@ -954,6 +978,9 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
954
978
  main.append(el("div", "dsw-account-meta", meta.join(" · ")));
955
979
  if (item.lastVerifyError) main.append(el("div", "dsw-account-fix", `⚠️ ${relTime(item.lastVerifyError.at)}校验失败(登录态已失效):${item.lastVerifyError.message}`));
956
980
  else if (item.lastCheckError) main.append(el("div", "dsw-account-fix", `⚠️ ${relTime(item.lastCheckError.at)}未能校验(网络问题,账号未必失效):${item.lastCheckError.message}`));
981
+ const rowMsg = el("div", "dsw-rowmsg");
982
+ rowMsg.dataset.rowMsg = String(item.id);
983
+ main.append(rowMsg);
957
984
  row.append(main);
958
985
  const actions = el("div", "dsw-account-actions");
959
986
  if (item.lastVerifyError || item.lastCheckError) {
@@ -965,7 +992,7 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
965
992
  if (item.isActive) actions.append(el("span", "dsw-hint", "使用中"));
966
993
  else {
967
994
  const useBtn = el("button", "dsw-btn ghost dsw-preset", "切换");
968
- useBtn.addEventListener("click", () => void switchToAccount(item.id));
995
+ useBtn.addEventListener("click", () => void switchToAccount(item.id, useBtn));
969
996
  actions.append(useBtn);
970
997
  }
971
998
  const renameBtn = el("button", "dsw-btn ghost dsw-preset", "备注");
@@ -1167,7 +1194,26 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
1167
1194
  };
1168
1195
  refreshAccountsBtn.addEventListener("click", () => void refreshAccountStates());
1169
1196
  newGroupBtn.addEventListener("click", () => promptNewGroup());
1170
- const switchToAccount = async (id) => {
1197
+ /**
1198
+ * 把结果写进**某一行的行内提示条**(按账号 id 找回节点 —— 列表重建过也能写回新的那条)。
1199
+ *
1200
+ * 为什么要有它(2026-09-30 用户反馈「点切换没反应,报错在下面显示,一点也不明显」):
1201
+ * 切换的反馈原先只写卡片底部那一条,而用户的视线还停在他点的那颗按钮上 ——
1202
+ * 列表一长,那条消息在屏幕外,体感就是"点了没反应"。所以结果必须在**那一行**里出现。
1203
+ */
1204
+ const paintRowMsg = (id, text, kind) => {
1205
+ const node = accountsList.querySelector(`.dsw-rowmsg[data-row-msg="${id}"]`);
1206
+ if (!node) return;
1207
+ node.className = kind === "info" ? "dsw-rowmsg" : `dsw-rowmsg ${kind}`;
1208
+ node.textContent = text;
1209
+ };
1210
+ const switchToAccount = async (id, btn) => {
1211
+ const originalLabel = btn?.textContent ?? "";
1212
+ if (btn) {
1213
+ btn.disabled = true;
1214
+ btn.textContent = "切换中…";
1215
+ }
1216
+ paintRowMsg(id, "正在校验登录态并切换……", "info");
1171
1217
  accountsMsg.textContent = "切换中……";
1172
1218
  try {
1173
1219
  const result = await api("/accounts/switch", {
@@ -1175,15 +1221,30 @@ background:var(--bg2);white-space:pre-wrap;font-size:12px}
1175
1221
  body: JSON.stringify({ id })
1176
1222
  });
1177
1223
  if (!result?.ok) {
1178
- accountsMsg.textContent = `切换失败:${result?.error ?? "未知原因"}`;
1224
+ const message = `切换失败:${result?.error ?? "未知原因"}`;
1225
+ paintRowMsg(id, `❌ ${message}`, "err");
1226
+ accountsMsg.textContent = message;
1179
1227
  return;
1180
1228
  }
1181
- accountsMsg.textContent = "已切换(下一次请求生效)。正在刷新登录状态……";
1182
1229
  await loadAccounts();
1183
1230
  await refresh(false);
1184
- accountsMsg.textContent = "已切换(下一次请求生效)";
1231
+ if (result?.warning) {
1232
+ const text = `⚠️ ${String(result.warning)}`;
1233
+ paintRowMsg(id, text, "warn");
1234
+ accountsMsg.textContent = text;
1235
+ } else {
1236
+ paintRowMsg(id, "✅ 已切换(下一次请求生效)", "ok");
1237
+ accountsMsg.textContent = "已切换(下一次请求生效)";
1238
+ }
1185
1239
  } catch (error) {
1186
- accountsMsg.textContent = `切换失败:${error?.message ?? error}`;
1240
+ const message = `切换失败:${error?.message ?? error}`;
1241
+ paintRowMsg(id, `❌ ${message}`, "err");
1242
+ accountsMsg.textContent = message;
1243
+ } finally {
1244
+ if (btn) {
1245
+ btn.disabled = false;
1246
+ btn.textContent = originalLabel;
1247
+ }
1187
1248
  }
1188
1249
  };
1189
1250
  const removeAccountById = async (id) => {