@dougen/dsh-deepseek-usage 0.5.0 → 0.6.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/README.md CHANGED
@@ -10,7 +10,9 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
10
10
 
11
11
  - **Balance**: `¥ 110.00` large display (symbol follows account currency: `¥` CNY / `$` USD)
12
12
  - **Unit pricing**: `→|1.5 ⚡0.05 |→4.5` (input / input cache / output, bottom-right of the card; switches with the active model and peak/off-peak window — **updates instantly when you switch models**)
13
- - **Auto price sync**: unit prices are re-fetched from the official DeepSeek pricing page on first use each day and when you click the card — DeepSeek price changes no longer require a plugin update
13
+ - **Auto price sync**: unit prices are re-fetched from the official DeepSeek pricing page on first use each day and when you click the card — DeepSeek price changes no longer require a plugin update. The page is parsed **structurally (HTML table cells)**, not by matching surrounding text, so official layout/rewording changes no longer break it; a plain-text strategy remains as a fallback
14
+ - **Rename-proof model mapping**: when DeepSeek renames a model (e.g. `deepseek-v4-flash` → `deepseek-flash`) the old id is charged at the current model's price, and the card notes `Priced as deepseek-flash` instead of silently showing an outdated built-in price
15
+ - **Visible sync state**: if the official page cannot be fetched/parsed, the card says prices are not synced (hover for the error) instead of quietly showing stale built-in numbers
14
16
  - **Peak indicator**: top-right dot, orange during peak hours (Mon–Fri 9:00–12:00, 14:00–18:00 Beijing Time), green otherwise
15
17
  - **Auto refresh**: every 5 minutes; click the card to refresh instantly
16
18
  - **i18n**: UI copy follows the DSH locale (Chinese / English)
@@ -18,7 +20,9 @@ DeepSeek **余额侧边栏插件**(DeepSeek Harness Web UI):在 DSH **网
18
20
 
19
21
  - **余额**:`¥ 110.00` 大字显示(符号随账号币种自动切换:人民币 `¥` / 美元 `$`)
20
22
  - **档单价**:`→|1.5 ⚡0.05 |→4.5`(输入 / 输入缓存 / 输出,位于卡片右下角;随当前启用模型与峰谷时段自动切换,**切换模型时立即同步**)
21
- - **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价;官方调价无需更新插件
23
+ - **单价自动同步**:每日首次使用或点击卡片时,自动从官方价格页拉取最新档单价;官方调价无需更新插件。页面解析采用**结构化表格解析**(直接读 HTML 表格单元格),不再依赖"正文里某段文字长什么样",官方改版/改文案不会失效;另保留纯文本兜底策略
24
+ - **抗改名映射**:官方给模型改名(如 `deepseek-v4-flash` → `deepseek-flash`)时,旧名按现名价格计费,卡片会注明 `按 deepseek-flash 计价`,不会静默显示内置旧价
25
+ - **同步状态可见**:官方页抓取/解析失败时,卡片会提示"价格未同步"(悬停可看具体错误),而不是悄悄显示内置旧价
22
26
  - **峰谷指示灯**:卡片右上角,高峰(周一至周五 9:00–12:00、14:00–18:00 北京时间)橙色,空闲绿色
23
27
  - **自动刷新**:每 5 分钟一次;点击卡片任意位置立即刷新
24
28
  - **多语言**:界面文案跟随 DSH 语言(中文 / English)
@@ -49,12 +53,12 @@ Restart the web app (`dsh web`), open http://127.0.0.1:3080 , and the usage card
49
53
  | Data | Source |
50
54
  |---|---|
51
55
  | Balance | Official DeepSeek API `GET /user/balance` (uses `DEEPSEEK_API_KEY` configured in DSH) |
52
- | Unit pricing / peak window | Auto-synced from the official pricing page (fetched on first daily use and on card click: [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)); built-in table (official snapshot) as offline fallback |
56
+ | Unit pricing / peak window | Auto-synced from the official pricing page (fetched on first daily use and on card click: [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)); parsed from the HTML **table cells** (text fallback available); built-in table (current official snapshot) as offline fallback |
53
57
 
54
58
  | 数据 | 来源 |
55
59
  |---|---|
56
60
  | 余额 | DeepSeek 官方 API `GET /user/balance`(使用 DSH 中已配置的 `DEEPSEEK_API_KEY`) |
57
- | 档单价 / 峰谷时段 | 自动从官方价格页同步(每日首次使用或点击卡片时拉取 [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/));内置价格表仅作离线兜底 |
61
+ | 档单价 / 峰谷时段 | 自动从官方价格页同步(每日首次使用或点击卡片时拉取 [api-docs.deepseek.com/zh-cn/quick_start/pricing](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/));解析**官方页 HTML 表格单元格**(另有纯文本兜底策略);内置价格表仅作离线兜底 |
58
62
 
59
63
  Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before use.
60
64
 
@@ -73,8 +77,40 @@ Make sure `DEEPSEEK_API_KEY` is configured in DSH (Model settings page) before u
73
77
  **Price differs from the official one? / 单价与官方价格不一致?**
74
78
 
75
79
  - The unit price switches with the **active model** and the **peak/off-peak window**; prices are re-synced from the official pricing page daily (first use) or on card click. If the active model is not on the official page nor in the built-in table, `deepseek-chat` pricing is shown with a notice.
80
+ - Hover/text cues at the bottom-left of the card tell you where the number comes from:
81
+ - *Prices not synced (built-in table)* — the official page could not be fetched/parsed; click the card to retry (hover the text for the exact error).
82
+ - *Priced as `deepseek-flash`* — the active model id was renamed by DeepSeek; the current model's price is used.
83
+ - *Priced as built-in `…`* — the model is not listed on the official page; a built-in entry is used.
76
84
 
77
85
  - 单价按**当前启用模型**与**峰谷时段**自动切换;单价每日(首次使用)或点击卡片时从官方价格页同步。若当前模型不在官方价格页也不在内置表,会按 `deepseek-chat` 档展示并给出提示。
86
+ - 卡片左下角的提示文字说明数字来自哪里:
87
+ - *价格未同步(用内置表)*:官方页抓取/解析失败,点击卡片可重试(悬停看具体错误)。
88
+ - *按 `deepseek-flash` 计价*:官方给该模型改名了,已按现名价格计费。
89
+ - *按内置 `…` 档计价*:该模型不在官方价格页,用了内置表。
90
+
91
+ ## Development / 开发与自检
92
+
93
+ ```sh
94
+ npm test # 解析 + 定价单测(node --test)
95
+ npm run check:pricing # 联网抓取官方页并打印解析结果;失败时退出码非 0
96
+ ```
97
+
98
+ 官方页再次改版时(自检失败 / 卡片提示"价格未同步"):
99
+
100
+ ```sh
101
+ # 1. 存档新页面(解析失败也会存下来)并看解析结果
102
+ node lib/pricing-remote.js --check --save page.html
103
+
104
+ # 2. 离线复现、迭代解析策略(结构化策略通常无需改动,只有官方改用非表格
105
+ # 结构时才需要动 parseFlatPrices())
106
+ node lib/pricing-remote.js --check --html page.html --json
107
+
108
+ # 3. 把新页面存为 test/fixtures/official-pricing-zh.html,跑 npm test 防回归
109
+ ```
110
+
111
+ > DSH 沙箱内 `node --test test/` 可能因禁止子进程管道而报 `spawn EPERM`,此时用
112
+ > `node --test --test-isolation=none "test/*.test.mjs"`(Node ≥ 22.8),或逐个跑
113
+ > `node test/parse.test.mjs`、`node test/pricing.test.mjs`、`node test/client-layout.test.mjs`。
78
114
 
79
115
  ## Uninstall / 卸载
80
116
 
package/lib/client.js CHANGED
@@ -28,6 +28,10 @@ window.__ModuleLoader__.load({
28
28
  "price.output": "输出",
29
29
  "peak.peak": "高峰时段",
30
30
  "peak.off": "空闲时段",
31
+ "price.stale": "价格未同步(用内置表)",
32
+ "price.lastGood": "同步失败,仍用上次价格",
33
+ "price.alias": "按 {model} 计价",
34
+ "price.builtin": "按内置 {model} 档计价",
31
35
  };
32
36
  const EN = {
33
37
  "price.input": "Input",
@@ -35,6 +39,10 @@ window.__ModuleLoader__.load({
35
39
  "price.output": "Output",
36
40
  "peak.peak": "Peak hours",
37
41
  "peak.off": "Off-peak hours",
42
+ "price.stale": "Prices not synced (built-in table)",
43
+ "price.lastGood": "Sync failed; using last prices",
44
+ "price.alias": "Priced as {model}",
45
+ "price.builtin": "Priced as built-in {model}",
38
46
  };
39
47
 
40
48
  // ---- helpers ---------------------------------------------------
@@ -158,8 +166,26 @@ window.__ModuleLoader__.load({
158
166
 
159
167
  const balance = data && data.balance ? data.balance : null;
160
168
  const tier = data && data.tier ? data.tier : null;
169
+ const pricing = data && data.pricing ? data.pricing : null;
161
170
  const symbol = balance && balance.currency === "USD" ? "$" : "¥";
162
171
  const errs = (Array.isArray(data && data.errors) ? data.errors : []).concat(error ? [error] : []);
172
+ // 价格来源提示:模型改名按现名计价 / 价格取自内置表(同步失败或该模型不在官方页)
173
+ // 时明示,避免"官方已调价但卡片静静显示内置旧价"。
174
+ const hints = [];
175
+ if (pricing && typeof pricing === "object") {
176
+ const used = pricing.used ? String(pricing.used) : "";
177
+ if (pricing.via === "remote-alias" || pricing.via === "remote-family") {
178
+ hints.push(t("price.alias").replace("{model}", used));
179
+ } else if (pricing.source === "seed" && pricing.via !== "fallback") {
180
+ hints.push(pricing.syncOk === false
181
+ ? t("price.stale")
182
+ : t("price.builtin").replace("{model}", used));
183
+ } else if (pricing.syncOk === false && pricing.syncedAt) {
184
+ // 本次刷新失败,但仍在用上次成功同步到的官方价格
185
+ hints.push(t("price.lastGood"));
186
+ }
187
+ }
188
+ const hintTitle = pricing && typeof pricing.syncError === "string" ? pricing.syncError : "";
163
189
  const dot = {
164
190
  width: 8,
165
191
  height: 8,
@@ -168,9 +194,10 @@ window.__ModuleLoader__.load({
168
194
  background: data && data.peak ? C.peak : C.idle,
169
195
  boxShadow: "0 0 4px rgba(0,0,0,.3)",
170
196
  };
171
- const peakTitle = data && data.peak
172
- ? t("peak.peak") + (data.peakHours ? " " + data.peakHours : "")
173
- : t("peak.off");
197
+ // 指示灯 hover 提示只显示当前处于高峰/空闲时段,不拼时段明细(太长);
198
+ // 价格未同步/按现名计价等提示附在末尾(窄卡片下唯一可见的提示位置)。
199
+ const peakTitle = (data && data.peak ? t("peak.peak") : t("peak.off"))
200
+ + (hints.length ? " · " + hints.join(" · ") : "");
174
201
 
175
202
  if (!wide) {
176
203
  return h("div", {
@@ -232,13 +259,22 @@ window.__ModuleLoader__.load({
232
259
  style: { position: "absolute", top: 8, right: 9, ...dot },
233
260
  title: peakTitle,
234
261
  }),
235
- // 当前档单价:卡片右下角
262
+ // 底部行:左侧价格来源提示(有则显示,过长省略、hover 看详情)+ 右下角当前档单价
263
+ // 注意:容器用 flex-end 让单价恒靠右;提示靠 marginRight:auto 顶到左侧,
264
+ // 不能用 space-between——没有提示时单子元素会被推到最左边。
236
265
  h("span", {
237
- style: { position: "absolute", right: 9, bottom: 4, display: "inline-flex", alignItems: "center", gap: 5 },
266
+ style: { position: "absolute", left: 9, right: 9, bottom: 4, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 6 },
238
267
  },
239
- Price(h(IconIn, null), t("price.input"), tier && tier.input),
240
- Price(h(IconCache, null), t("price.cache"), tier && tier.cacheRead),
241
- Price(h(IconOut, null), t("price.output"), tier && tier.output)),
268
+ hints.length
269
+ ? h("span", {
270
+ style: { color: C.sub, fontSize: 10, minWidth: 0, marginRight: "auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
271
+ title: hintTitle || hints.join(" · "),
272
+ }, hints.join(" · "))
273
+ : null,
274
+ h("span", { style: { display: "inline-flex", alignItems: "center", gap: 5, flex: "none" } },
275
+ Price(h(IconIn, null), t("price.input"), tier && tier.input),
276
+ Price(h(IconCache, null), t("price.cache"), tier && tier.cacheRead),
277
+ Price(h(IconOut, null), t("price.output"), tier && tier.output))),
242
278
  errs.length
243
279
  ? h("div", { style: { color: C.err, fontSize: 10, whiteSpace: "pre-wrap" } }, errs.join(" | "))
244
280
  : null);
package/lib/index.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * ok: true,
11
11
  * balance: { isAvailable, total, currency, granted, toppedUp }, // 官方余额接口(API Key)
12
12
  * tier: { input, cacheRead, cacheWrite, output, model, known }, // 当前模型当前档单价
13
+ * pricing: { source, via, requested, used, syncOk, syncedAt, ... }, // 价格来源/同步状态
13
14
  * peak, peakHours, // 峰谷时段(北京时间)
14
15
  * updatedAt, errors
15
16
  * }
@@ -19,11 +20,14 @@
19
20
  *
20
21
  * 单价数据源(0.5.0 起):
21
22
  * - 默认从 DeepSeek 官方"模型 & 价格"页自动同步(lib/pricing-remote.js):
22
- * 每天首次访问该路由时后台拉取一次;请求带 refresh=1(点击卡片)时立即
23
- * 重拉并等待结果,便于官方刚调价后手动跟新。
24
- * - 拉取/解析失败不打扰用户:记录日志并继续使用上次成功数据或内置种子表
25
- * (lib/pricing.js)。峰谷时段(周一至周五 9–12、14–18,北京时间)见
26
- * lib/pricing.js。官方调价后只需页面更新,无需更新本插件。
23
+ * 每天首次成功同步一次,失败按 10 分钟退避重试;请求带 refresh=1(点击卡片)
24
+ * 时立即重拉并等待结果,便于官方刚调价后手动跟新。
25
+ * - 解析优先走"结构化表格"策略(对官方改版排版不敏感),失败再退回文本策略;
26
+ * 两者都失败才使用内置种子表(lib/pricing.js)。
27
+ * - 拉取/解析失败不打扰用户:记录日志、保留上次成功数据或内置表,并通过
28
+ * pricing.syncOk / pricing.source 让界面提示"当前价格未同步",不静默展示旧价。
29
+ * - 官方改名的旧模型 id(如 deepseek-v4-flash)按 MODEL_ALIASES 映射到现名计价。
30
+ * 峰谷时段(周一至周五 9–12、14–18,北京时间)见 lib/pricing.js。
27
31
  *
28
32
  * 数据源:余额来自 DeepSeek 官方 GET /user/balance(API Key 经凭证服务解析,
29
33
  * 与 llm-deepseek 同一引用,不出本机)。本插件仅支持 DeepSeek 官方平台余额显示。
@@ -38,6 +42,7 @@ import {
38
42
  PEAK_HOURS_TEXT,
39
43
  PEAK_HOURS_TEXT_EN,
40
44
  isPeak,
45
+ remotePriceMeta,
41
46
  setRemotePrices,
42
47
  unitAt,
43
48
  } from "./pricing.js";
@@ -58,6 +63,8 @@ const TIMEOUT_MS = 15000;
58
63
  const REMOTE_TIMEOUT_MS = 12000;
59
64
  /** 手动(点击)重拉的最小间隔,避免连点打爆官方文档站。 */
60
65
  const FORCED_GAP_MS = 30 * 1000;
66
+ /** 自动同步失败后的重试间隔(成功后当天不再自动重试)。 */
67
+ const RETRY_GAP_MS = 10 * 60 * 1000;
61
68
 
62
69
  const JSON_HEADERS = {
63
70
  "content-type": "application/json; charset=utf-8",
@@ -80,10 +87,13 @@ const MESSAGES = {
80
87
  },
81
88
  };
82
89
 
83
- // ---- 官方价格自动同步状态(进程内缓存;每日首次访问拉取一次,点击可强制) ----
90
+ // ---- 官方价格自动同步状态(进程内缓存;每日首次成功一次,失败按间隔重试) ----
84
91
  let remoteInFlight = null; // 进行中的拉取 promise(合并并发)
85
- let remoteDay = ""; // 已尝试拉取的北京日期键(成功/失败都记,离线时避免反复重试)
92
+ let remoteDay = ""; // 上次"自动同步成功"的北京日期键
93
+ let lastAutoAt = 0; // 上次自动尝试时刻(失败后按 RETRY_GAP_MS 退避)
86
94
  let lastForcedAt = 0; // 上次手动重拉时刻
95
+ /** 同步状态(透出到界面:同步失败时让用户看到"当前是内置/旧价",而不是静默展示)。 */
96
+ let remoteStatus = { ok: false, at: 0, error: null, models: 0, strategy: null };
87
97
 
88
98
  /** 北京时间日期键(yyyy-mm-dd)。 */
89
99
  function beijingDayKey(ms) {
@@ -94,27 +104,44 @@ function beijingDayKey(ms) {
94
104
  }
95
105
 
96
106
  /**
97
- * 保证"今天"的价格已尝试同步;force(点击卡片 refresh=1)时立即重拉并等待。
98
- * 拉取成功 → pricing.setRemotePrices() 注入;失败 → 记日志,继续用内置/上次数据。
107
+ * 保证官方价格已同步:每天首次自动拉取一次,失败则按 RETRY_GAP_MS 退避重试;
108
+ * force(点击卡片 refresh=1)时立即重拉并等待。
109
+ * 拉取成功 → pricing.setRemotePrices() 注入;失败 → 记日志并记录状态,
110
+ * 继续用内置/上次数据,同时把失败状态透出到界面。
99
111
  */
100
112
  async function refreshRemote(ctx, force) {
101
113
  const now = Date.now();
102
- if (!force && remoteDay === beijingDayKey(now)) return; // 今天已拉取过
103
- if (force && now - lastForcedAt < FORCED_GAP_MS) return; // 防连点
114
+ if (!force) {
115
+ if (remoteDay === beijingDayKey(now)) return; // 今天已成功同步过
116
+ if (now - lastAutoAt < RETRY_GAP_MS) return; // 上次失败,退避中
117
+ }
118
+ if (force && now - lastForcedAt < FORCED_GAP_MS) return; // 防连点
104
119
  if (force) lastForcedAt = now;
105
120
  if (remoteInFlight) {
106
121
  if (force) { try { await remoteInFlight; } catch { /* 已吞错 */ } }
107
122
  return;
108
123
  }
124
+ lastAutoAt = now;
109
125
  const task = (async () => {
110
126
  try {
111
127
  const { models, meta } = await fetchOfficialPrices({ timeoutMs: REMOTE_TIMEOUT_MS });
112
- setRemotePrices(models);
113
- ctx.logger.info(`dsh-deepseek-usage: 官方价格已同步(${Object.keys(models).length} 个模型,来源 ${meta.source})`);
128
+ setRemotePrices(models, meta);
129
+ remoteDay = beijingDayKey(Date.now());
130
+ remoteStatus = {
131
+ ok: true,
132
+ at: meta.fetchedAt || Date.now(),
133
+ error: null,
134
+ models: Object.keys(models).length,
135
+ strategy: meta.strategy || null,
136
+ };
137
+ ctx.logger.info(
138
+ `dsh-deepseek-usage: 官方价格已同步(${Object.keys(models).length} 个模型,策略 ${meta.strategy},来源 ${meta.source})`,
139
+ );
114
140
  } catch (error) {
115
- ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用内置/上次价格: ${error instanceof Error ? error.message : String(error)}`);
141
+ const message = error instanceof Error ? error.message : String(error);
142
+ remoteStatus = { ...remoteStatus, ok: false, error: message };
143
+ ctx.logger.warn(`dsh-deepseek-usage: 官方价格同步失败,沿用内置/上次价格: ${message}`);
116
144
  } finally {
117
- remoteDay = beijingDayKey(Date.now()); // 无论成败,当天不再自动重试(可点击手动刷新)
118
145
  remoteInFlight = null;
119
146
  }
120
147
  })();
@@ -249,6 +276,20 @@ function apply(ctx) {
249
276
  model: unit.model,
250
277
  known: unit.known,
251
278
  },
279
+ // 价格来源与同步状态:让"官方页改版/网络失败 → 正在用内置旧价"可见,
280
+ // 而不是静默按过期价格展示。
281
+ pricing: {
282
+ source: unit.source, // remote=官方页同步 / seed=内置兜底
283
+ via: unit.via, // remote | remote-alias | remote-family | alias | seed | fallback
284
+ requested: unit.requested, // 当前启用的模型 id
285
+ used: unit.model, // 实际取价使用的模型 id
286
+ syncOk: remoteStatus.ok,
287
+ syncedAt: remoteStatus.at || null,
288
+ strategy: remoteStatus.strategy, // table=结构化表格 / flat=文本兜底
289
+ modelCount: remoteStatus.models,
290
+ syncError: remoteStatus.error,
291
+ remoteMeta: remotePriceMeta(),
292
+ },
252
293
  updatedAt: now,
253
294
  errors,
254
295
  });
@@ -1,36 +1,67 @@
1
1
  /**
2
2
  * dsh-deepseek-usage — 官方"模型 & 价格"页抓取与解析(lib/pricing.js 的远端数据源)。
3
3
  *
4
- * 从 DeepSeek 官方价格页拉取当前生效价目:
5
- * https://api-docs.deepseek.com/zh-cn/quick_start/pricing/
6
- *
7
- * 该页为静态站点、服务端直出 HTML:无需登录、无需浏览器执行 JS,价格以
8
- * "元 / 百万 tokens"按模型列出(输入缓存命中 / 未命中 / 输出 × 空闲 / 高峰)。
9
- * 官方调整价格后页面随之更新,插件每日首次访问或点击卡片时重拉即可跟随,
4
+ * 数据源: https://api-docs.deepseek.com/zh-cn/quick_start/pricing/
5
+ * 该页为静态站点、服务端直出 HTML:无需登录、无需浏览器执行 JS。
6
+ * 官方调整价格/改版后页面随之更新,插件每日首次访问或点击卡片时重拉即可跟随,
10
7
  * 无需随官方改价更新内置价格表。
11
8
  *
12
- * 本文件不含运行时状态:fetchOfficialPrices() 取 HTML,
13
- * parseOfficialPrices() 解析为 { models, meta };"每日一次"的节流与注入由
14
- * lib/index.js 负责,解析结果经 pricing.setRemotePrices() 注入。
15
- * 解析基于"行主序文本顺序 + 标签紧跟价格"的宽容规则(对表格排布方式不敏感);
16
- * 任何异常(无模型列、维度缺失、数值不合理)一律返回 null,由调用方回退内置表。
9
+ * ---------------------------------------------------------------------------
10
+ * 解析策略(0.6.0 起按优先级依次尝试,任一成功即返回)
11
+ *
12
+ * 1. 结构化表格解析 parseTablePrices() —— 只依赖"表格 + `N元` 价格单元格"这一
13
+ * 语义结构:
14
+ * · 模型列由表头行(单元格文本就是一个模型 id)确定,列序即价格列序;
15
+ * · 每行末尾连续的 `N元` 单元格就是各模型该行的价格;
16
+ * · 行内标签给出维度(缓存命中 / 缓存未命中 / 输出)与时段(空闲 / 高峰)。
17
+ * 因此对页面导航、脚注、"输出长度"这类同名前缀文本、行序、rowspan/colspan
18
+ * 合并不敏感——官方 2026-09 改版(模型改名 deepseek-v4-flash → deepseek-flash、
19
+ * 表头结构变化)即属于这类"排版变化",策略 1 不受影响。
20
+ *
21
+ * 2. 行主序纯文本兜底 parseFlatPrices() —— 0.5.x 的启发式(去标签后按
22
+ * "标签紧跟价格"顺序对齐)。仅在策略 1 失败时使用,用于表格被拆散、
23
+ * 价格被渲染成非表格结构等少数情况。
24
+ *
25
+ * 两种策略共用同一套数值校验:三个维度 × 空闲/高峰 都必须"恰好每模型一格"、
26
+ * 价格 > 0、高峰 ≥ 空闲、高峰/空闲 比值 ≤ 4。任一不满足即判该策略失败,
27
+ * 全部失败则返回 null,由调用方(lib/index.js → lib/pricing.js)回退内置表,
28
+ * 并把"未同步"状态透出到界面,不静默展示旧价。
29
+ * ---------------------------------------------------------------------------
30
+ *
31
+ * 本文件不含运行时状态:fetchOfficialPrices() 取 HTML,parseOfficialPrices()
32
+ * 解析为 { models, meta };"每日一次"的节流与注入由 lib/index.js 负责,
33
+ * 解析结果经 pricing.setRemotePrices() 注入。
34
+ *
35
+ * 自检(页面再次改版时可先用它离线定位):
36
+ * node lib/pricing-remote.js --check # 联网拉取并打印解析结果
37
+ * node lib/pricing-remote.js --check --save page.html # 顺带存档原始 HTML
38
+ * node lib/pricing-remote.js --check --html page.html # 用本地存档 HTML 解析
39
+ * node lib/pricing-remote.js --check --json # 机器可读输出
17
40
  */
41
+ import { pathToFileURL } from "node:url";
18
42
 
19
43
  /** 官方价格页(中文,人民币计价)。 */
20
- const DEFAULT_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
44
+ const DEFAULT_URL = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
21
45
  /** 可选环境变量覆盖(供镜像/代理场景)。 */
22
- const URL_ENV = 'DEEPSEEK_USAGE_PRICE_URL';
23
- const USER_AGENT = 'Mozilla/5.0 (compatible; dsh-deepseek-usage/0.5; official pricing auto-sync)';
46
+ const URL_ENV = "DEEPSEEK_USAGE_PRICE_URL";
47
+ const USER_AGENT = "Mozilla/5.0 (compatible; dsh-deepseek-usage/0.6; official pricing auto-sync)";
24
48
 
25
49
  const ENTITY_MAP = {
26
50
  '&nbsp;': ' ', '&amp;': '&', '&lt;': '<', '&gt;': '>',
27
51
  '&quot;': '"', '&#39;': "'", '&apos;': "'",
28
52
  };
29
53
 
54
+ /** 合法模型 id(官方页当前只列 deepseek-* 系列)。 */
55
+ const MODEL_ID = /^deepseek-[a-z0-9][a-z0-9.-]*$/;
56
+ /** 价格单元格:`1元` / `4.5 元` / `0.02元(3)`(容忍脚注与星号)。 */
57
+ const PRICE_CELL = /^(\d+(?:\.\d+)?)\s*元\s*(?:[((]\d+[))]|\*)*$/u;
58
+
59
+ const METRICS = ["input", "cacheRead", "output"];
60
+
30
61
  /** 官方价格页 URL(可用环境变量 DEEPSEEK_USAGE_PRICE_URL 覆盖)。 */
31
62
  export function officialPriceUrl() {
32
63
  const v = process.env[URL_ENV];
33
- return typeof v === 'string' && v.trim() !== '' ? v.trim() : DEFAULT_URL;
64
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : DEFAULT_URL;
34
65
  }
35
66
 
36
67
  /** 常见 HTML 实体解码(含数字实体)。 */
@@ -47,21 +78,187 @@ function decodeEntities(s) {
47
78
  });
48
79
  }
49
80
 
81
+ /** 去标签 + 解码实体 + 空白收敛,得到单元格纯文本(`<br>` 视作空格)。 */
82
+ function cellText(fragment) {
83
+ return decodeEntities(
84
+ String(fragment)
85
+ .replace(/<br\s*\/?>/gi, ' ')
86
+ .replace(/<[^>]*>/g, ' '),
87
+ ).replace(/\s+/g, ' ').trim();
88
+ }
89
+
50
90
  /** 把 HTML 拍平成"行主序纯文本":去脚本/样式/标签,连续空白收敛为单个空格。 */
51
91
  function flattenHtml(html) {
52
- return decodeEntities(html)
53
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
54
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
55
- .replace(/<[^>]+>/g, ' ')
56
- .replace(/\s+/g, ' ')
57
- .trim();
92
+ return decodeEntities(
93
+ String(html)
94
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
95
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
96
+ .replace(/<br\s*\/?>/gi, ' ')
97
+ .replace(/<[^>]+>/g, ' '),
98
+ ).replace(/\s+/g, ' ').trim();
99
+ }
100
+
101
+ /** 去脚注标记(`(1)` / `(2)`),得到干净的单元格文本。 */
102
+ function withoutFootnotes(s) {
103
+ return s.replace(/[((]\d+[))]/g, '').trim();
104
+ }
105
+
106
+ /** 从一行文本判断计费维度。 */
107
+ function metricOf(text) {
108
+ if (/缓存\s*命中/.test(text)) return 'cacheRead';
109
+ if (/缓存\s*未命中|输入/.test(text)) return 'input';
110
+ if (/输出/.test(text)) return 'output';
111
+ return null;
112
+ }
113
+
114
+ /** 从一行文本判断峰谷时段。 */
115
+ function tierOf(text) {
116
+ if (/空闲时段|非高峰|off[\s-]?peak/i.test(text)) return 'idle';
117
+ if (/高峰时段|peak/i.test(text)) return 'peak';
118
+ return null;
58
119
  }
59
120
 
60
121
  /**
61
- * 解析官方价格页 HTML。
62
- * @returns {{ models: Record<string, {input:number[],cacheRead:number[],cacheWrite:0,output:number[]}>, meta:{source:string,weekdayPeakOnly:boolean} } | null}
122
+ * 把一行拆成"末尾连续的价格单元格 + 其余标签单元格"。
123
+ * colspan 会展开(合并单元格的价格视为各列同价)。
63
124
  */
64
- export function parseOfficialPrices(html) {
125
+ function splitPriceRow(cells) {
126
+ const prices = [];
127
+ let i = cells.length - 1;
128
+ for (; i >= 0; i -= 1) {
129
+ const m = PRICE_CELL.exec(cells[i].text);
130
+ if (!m) break;
131
+ const n = Number(m[1]);
132
+ for (let k = 0; k < cells[i].colspan; k += 1) prices.push(n);
133
+ }
134
+ prices.reverse();
135
+ return { prices, labels: cells.slice(0, i + 1) };
136
+ }
137
+
138
+ /** 解析一张 `<table>` 的所有行 → 单元格数组(含 colspan)。 */
139
+ function tableRows(tableHtml) {
140
+ const rows = [];
141
+ const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
142
+ let row;
143
+ while ((row = rowRe.exec(tableHtml)) !== null) {
144
+ const cells = [];
145
+ const cellRe = /<(td|th)\b([^>]*)>([\s\S]*?)<\/\1>/gi;
146
+ let cell;
147
+ while ((cell = cellRe.exec(row[1])) !== null) {
148
+ const attrs = cell[2] || '';
149
+ const span = /colspan\s*=\s*"?(\d+)/i.exec(attrs);
150
+ cells.push({
151
+ text: cellText(cell[3]),
152
+ colspan: span ? Math.max(1, Number(span[1])) : 1,
153
+ });
154
+ }
155
+ if (cells.length > 0) rows.push(cells);
156
+ }
157
+ return rows;
158
+ }
159
+
160
+ /** 组装模型表。ids 顺序即各价格数组的下标顺序。 */
161
+ function buildModels(ids, pricePairs) {
162
+ const models = {};
163
+ ids.forEach((id, i) => {
164
+ models[id] = {
165
+ input: [pricePairs.input.peak[i], pricePairs.input.idle[i]],
166
+ cacheRead: [pricePairs.cacheRead.peak[i], pricePairs.cacheRead.idle[i]],
167
+ cacheWrite: 0,
168
+ output: [pricePairs.output.peak[i], pricePairs.output.idle[i]],
169
+ };
170
+ });
171
+ return models;
172
+ }
173
+
174
+ /**
175
+ * 保守数值校验:价格 > 0,高峰 ≥ 空闲,且高峰/空闲 比值不超合理范围
176
+ * (当前官档为 ×2,留一倍余量)。
177
+ */
178
+ function numbersLookSane(models) {
179
+ for (const e of Object.values(models)) {
180
+ for (const key of METRICS) {
181
+ const [peak, idle] = e[key];
182
+ if (!(peak > 0 && idle > 0 && peak >= idle && peak / idle <= 4)) return false;
183
+ }
184
+ }
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * 策略 1:结构化表格解析。
190
+ * @returns {Record<string, object> | null}
191
+ */
192
+ export function parseTablePrices(html) {
193
+ const tables = String(html).match(/<table\b[\s\S]*?<\/table>/gi);
194
+ if (!tables) return null;
195
+
196
+ for (const table of tables) {
197
+ const rows = tableRows(table);
198
+
199
+ // 表头行:含至少一个"单元格文本就是模型 id"的单元格。
200
+ let ids = null;
201
+ for (const row of rows) {
202
+ const found = [];
203
+ for (const c of row) {
204
+ const t = withoutFootnotes(c.text);
205
+ if (MODEL_ID.test(t) && !found.includes(t)) found.push(t);
206
+ }
207
+ if (found.length > 0) { ids = found; break; }
208
+ }
209
+ if (!ids) continue;
210
+ const n = ids.length;
211
+
212
+ // 逐行收集价格:维度 × 时段 → 各模型价格。
213
+ const slots = {
214
+ input: { idle: [], peak: [], flat: null },
215
+ cacheRead: { idle: [], peak: [], flat: null },
216
+ output: { idle: [], peak: [], flat: null },
217
+ };
218
+ let lastMetric = null;
219
+ for (const row of rows) {
220
+ const { prices, labels } = splitPriceRow(row);
221
+ if (prices.length === 0) continue;
222
+ const labelText = labels.map((c) => c.text).join(' ');
223
+ const metric = metricOf(labelText) || lastMetric;
224
+ if (!metric || !slots[metric]) continue;
225
+ lastMetric = metric;
226
+ if (prices.length !== n) continue; // 与模型列数不符的行直接跳过(会被完整性校验拦下)
227
+ const tier = tierOf(labelText);
228
+ if (tier) {
229
+ if (slots[metric][tier].length === 0) slots[metric][tier] = prices;
230
+ } else if (slots[metric].flat === null) {
231
+ slots[metric].flat = prices; // 单一时段(官方取消峰谷)时,同一价同时用于高峰/空闲
232
+ }
233
+ }
234
+
235
+ const pairs = {};
236
+ let complete = true;
237
+ for (const metric of METRICS) {
238
+ const s = slots[metric];
239
+ if (s.idle.length === n && s.peak.length === n) {
240
+ pairs[metric] = { peak: s.peak, idle: s.idle };
241
+ } else if (s.flat && s.flat.length === n) {
242
+ pairs[metric] = { peak: s.flat, idle: s.flat };
243
+ } else {
244
+ complete = false;
245
+ break;
246
+ }
247
+ }
248
+ if (!complete) continue;
249
+
250
+ const models = buildModels(ids, pairs);
251
+ if (!numbersLookSane(models)) continue;
252
+ return models;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * 策略 2:行主序纯文本兜底(0.5.x 启发式)。
259
+ * @returns {Record<string, object> | null}
260
+ */
261
+ export function parseFlatPrices(html) {
65
262
  const flat = flattenHtml(html);
66
263
 
67
264
  // 定位价格表区域:模型细节标题之后、并发限制行之前(排除导航/脚注噪音)。
@@ -77,8 +274,8 @@ export function parseOfficialPrices(html) {
77
274
  if (ids.length === 0) return null;
78
275
 
79
276
  // 结构化 token 游走:metric 标签 →(空闲/高峰)× 紧随其后的价格数字。
80
- // 对表格排布不敏感:无论"整行按档位分组"还是"逐模型交替",按序分配都对齐模型列。
81
- const TOKEN = /缓存命中|缓存未命中|空闲时段|高峰时段|输出(?=\s)|(\d+(?:\.\d+)?)\s*元/g;
277
+ // 注意排除"输出长度"这类前缀相同的非价格行(0.5.x 曾在此处误判)。
278
+ const TOKEN = /缓存命中|缓存未命中|空闲时段|高峰时段|输出(?!\s*长度)(?=\s)|(\d+(?:\.\d+)?)\s*元/g;
82
279
  const slots = {
83
280
  input: { idle: [], peak: [] },
84
281
  cacheRead: { idle: [], peak: [] },
@@ -100,46 +297,45 @@ export function parseOfficialPrices(html) {
100
297
  }
101
298
  }
102
299
 
103
- // 校验:三个维度都要有"恰好每模型一格"的空闲与高峰价。
104
300
  const n = ids.length;
105
- const complete = (slot) => slot.length === n;
106
- const ok = ['input', 'cacheRead', 'output'].every(
107
- (k) => complete(slots[k].idle) && complete(slots[k].peak),
108
- );
109
- if (!ok) return null;
301
+ const pairs = {};
302
+ for (const key of METRICS) {
303
+ if (slots[key].idle.length !== n || slots[key].peak.length !== n) return null;
304
+ pairs[key] = { peak: slots[key].peak, idle: slots[key].idle };
305
+ }
110
306
 
111
- const models = {};
112
- ids.forEach((id, i) => {
113
- models[id] = {
114
- input: [slots.input.peak[i], slots.input.idle[i]],
115
- cacheRead: [slots.cacheRead.peak[i], slots.cacheRead.idle[i]],
116
- cacheWrite: 0,
117
- output: [slots.output.peak[i], slots.output.idle[i]],
118
- };
119
- });
307
+ const models = buildModels(ids, pairs);
308
+ return numbersLookSane(models) ? models : null;
309
+ }
120
310
 
121
- // 保守数值校验:价格 > 0,高峰 ≥ 空闲,且高峰/空闲比值不超合理范围(当前官档为 ×2)。
122
- for (const e of Object.values(models)) {
123
- for (const key of ['input', 'cacheRead', 'output']) {
124
- const [peak, idle] = e[key];
125
- if (!(peak > 0 && idle > 0 && peak >= idle && peak / idle <= 4)) return null;
126
- }
311
+ /**
312
+ * 解析官方价格页 HTML(先结构化表格,再文本兜底)。
313
+ * @returns {{ models: Record<string, {input:number[],cacheRead:number[],cacheWrite:0,output:number[]}>, meta:{source:string,weekdayPeakOnly:boolean,strategy:'table'|'flat'} } | null}
314
+ */
315
+ export function parseOfficialPrices(html) {
316
+ const flat = flattenHtml(html);
317
+
318
+ let models = parseTablePrices(html);
319
+ let strategy = 'table';
320
+ if (!models) {
321
+ models = parseFlatPrices(html);
322
+ strategy = 'flat';
127
323
  }
324
+ if (!models) return null;
128
325
 
129
326
  // 页面脚注若仍写"周一至周五",记入 meta(峰谷窗口本身以内置配置为准;
130
327
  // 脚注位于价格表之后,因此扫整页文本)。
131
328
  return {
132
329
  models,
133
- meta: { source: officialPriceUrl(), weekdayPeakOnly: /周一至周五/.test(flat) },
330
+ meta: { source: officialPriceUrl(), weekdayPeakOnly: /周一至周五/.test(flat), strategy },
134
331
  };
135
332
  }
136
333
 
137
334
  /**
138
- * 拉取并解析官方价格页。
139
- * @returns {{ models, meta: {source, weekdayPeakOnly, fetchedAt} }}
140
- * @throws 网络失败 / HTTP 非 2xx / 解析失败(无可用的完整价格表)
335
+ * 拉取官方价格页原始 HTML。
336
+ * @throws 网络失败 / HTTP 非 2xx
141
337
  */
142
- export async function fetchOfficialPrices(opts = {}) {
338
+ export async function fetchPricingHtml(opts = {}) {
143
339
  const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 12000;
144
340
  const url = officialPriceUrl();
145
341
  const res = await fetch(url, {
@@ -148,9 +344,85 @@ export async function fetchOfficialPrices(opts = {}) {
148
344
  signal: AbortSignal.timeout(timeoutMs),
149
345
  });
150
346
  if (!res.ok) throw new Error(`pricing page HTTP ${res.status}`);
151
- const html = await res.text();
347
+ return res.text();
348
+ }
349
+
350
+ /**
351
+ * 拉取并解析官方价格页。
352
+ * @returns {{ models, meta: {source, weekdayPeakOnly, strategy, fetchedAt} }}
353
+ * @throws 网络失败 / HTTP 非 2xx / 解析失败(无可用的完整价格表)
354
+ */
355
+ export async function fetchOfficialPrices(opts = {}) {
356
+ const html = await fetchPricingHtml(opts);
152
357
  const parsed = parseOfficialPrices(html);
153
358
  if (!parsed) throw new Error('pricing page parse failed (official page layout changed?)');
154
359
  parsed.meta.fetchedAt = Date.now();
155
360
  return parsed;
156
361
  }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // 自检 CLI:node lib/pricing-remote.js --check [--html <文件>] [--save <文件>] [--json]
365
+ // ---------------------------------------------------------------------------
366
+ const CLI_ARGS = new Set(['--check', '-c', '--json', '--html', '--save', '--help', '-h']);
367
+
368
+ function isDirectRun() {
369
+ const arg = process.argv[1];
370
+ if (!arg) return false;
371
+ try { return import.meta.url === pathToFileURL(arg).href; } catch { return false; }
372
+ }
373
+
374
+ function formatTable(models) {
375
+ const lines = [];
376
+ for (const [id, e] of Object.entries(models)) {
377
+ lines.push(
378
+ `${id.padEnd(32)} 输入 ${e.input[0]}/${e.input[1]} 缓存 ${e.cacheRead[0]}/${e.cacheRead[1]} 输出 ${e.output[0]}/${e.output[1]} (元/百万tokens,高峰/空闲)`,
379
+ );
380
+ }
381
+ return lines.join('\n');
382
+ }
383
+
384
+ async function cli() {
385
+ const argv = process.argv.slice(2);
386
+ const usage = '用法: node lib/pricing-remote.js --check [--html <文件>] [--save <文件>] [--json]';
387
+ if (argv.includes('--help') || argv.includes('-h') || !argv.some((a) => CLI_ARGS.has(a))) {
388
+ console.log(usage);
389
+ return 0;
390
+ }
391
+ const htmlFlag = argv.indexOf('--html');
392
+ const saveFlag = argv.indexOf('--save');
393
+ const asJson = argv.includes('--json');
394
+ const { readFileSync, writeFileSync } = await import('node:fs');
395
+ try {
396
+ let html;
397
+ if (htmlFlag !== -1 && argv[htmlFlag + 1]) {
398
+ html = readFileSync(argv[htmlFlag + 1], 'utf8');
399
+ } else {
400
+ html = await fetchPricingHtml({ timeoutMs: 15000 });
401
+ }
402
+ // 先存档再解析:即使解析失败,原始页面也已留下,便于离线迭代解析策略。
403
+ if (saveFlag !== -1 && argv[saveFlag + 1]) {
404
+ writeFileSync(argv[saveFlag + 1], html);
405
+ if (!asJson) console.log(`已存档原始 HTML: ${argv[saveFlag + 1]}`);
406
+ }
407
+ const parsed = parseOfficialPrices(html);
408
+ if (!parsed) throw new Error('解析失败(策略 table / flat 均未通过校验,官方页可能改版)');
409
+ parsed.meta.fetchedAt = Date.now();
410
+ if (asJson) {
411
+ console.log(JSON.stringify(parsed, null, 2));
412
+ } else {
413
+ console.log(`来源: ${parsed.meta.source}`);
414
+ console.log(`解析策略: ${parsed.meta.strategy} 模型数: ${Object.keys(parsed.models).length}`);
415
+ console.log(formatTable(parsed.models));
416
+ console.log('OK: 价格表解析正常');
417
+ }
418
+ return 0;
419
+ } catch (error) {
420
+ console.error(`FAIL: ${error instanceof Error ? error.message : String(error)}`);
421
+ console.error('提示:用 --save <文件> 存档页面、再用 --html <文件> 离线复现,然后调整本文件的解析策略。');
422
+ return 1;
423
+ }
424
+ }
425
+
426
+ if (isDirectRun()) {
427
+ cli().then((code) => { process.exitCode = code; });
428
+ }
package/lib/pricing.js CHANGED
@@ -6,10 +6,15 @@
6
6
  * 访问/点击卡片时刷新),解析结果经 setRemotePrices() 注入本模块,作为 LIVE
7
7
  * 层按模型覆盖内置种子——官方调价只需页面更新,无需再改本文件。
8
8
  * - 兜底:下方 PRICE_TABLE 内置种子表——DeepSeek 官方峰谷定价快照
9
- * (2026-08-17,与当时官方价格页一致),仅当官方页抓取/解析失败时生效;
10
- * deepseek-chat / deepseek-reasoner 为历史档(官方页已不单列),未知模型
11
- * 回退 chat 档,并通过 unitAt().known 与 isKnownModel() 让调用方提示用户,
12
- * 而不是静默算错。
9
+ * (2026-09-10,与当时官方价格页一致),仅当官方页抓取/解析失败时生效。
10
+ *
11
+ * 模型改名(0.6.0 起):官方会把模型改名并把旧名保留为别名(如
12
+ * deepseek-v4-flash / deepseek-v4-flash-vision-exp → deepseek-flash)。旧名若
13
+ * 落到内置表就会长期显示过期价且无从察觉,因此:
14
+ * · MODEL_ALIASES 维护"旧名 → 现名"的显式映射;
15
+ * · 远端表查不到时先试别名,再试"同家族唯一匹配"(flash / pro / chat /
16
+ * reasoner),命中即按现名计价;
17
+ * · 两条路径都会在 unitAt() 的 via 字段里标出来源,供界面提示,避免静默算错。
13
18
  *
14
19
  * 峰谷(官方口径):高峰时段为北京时间周一至周五 9:00–12:00、14:00–18:00,
15
20
  * 其余(含整个周末)为空闲时段;缓存写暂不计费(cacheWrite = 0)。
@@ -27,20 +32,32 @@ export const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
27
32
  /**
28
33
  * 内置种子表:input(缓存未命中)/ cacheRead(缓存命中)/ output 均为
29
34
  * [高峰, 空闲] 二元组;cacheWrite 为常量 0。
35
+ * 仅作离线兜底,正常情况会被官方价格页同步结果覆盖。
30
36
  */
31
37
  const PRICE_TABLE = {
32
- 'deepseek-v4-flash': { input: [3.0, 1.5], cacheRead: [0.1, 0.05], cacheWrite: 0, output: [9.0, 4.5] },
33
- 'deepseek-v4-flash-vision-exp': { input: [3.0, 1.5], cacheRead: [0.1, 0.05], cacheWrite: 0, output: [9.0, 4.5] },
34
- 'deepseek-v4-pro': { input: [9.0, 4.5], cacheRead: [0.3, 0.15], cacheWrite: 0, output: [27.0, 13.5] },
38
+ 'deepseek-flash': { input: [2, 1], cacheRead: [0.04, 0.02], cacheWrite: 0, output: [8, 4] },
39
+ 'deepseek-v4-pro': { input: [9, 4.5], cacheRead: [0.3, 0.15], cacheWrite: 0, output: [27, 13.5] },
35
40
  'deepseek-chat': { input: [2, 2], cacheRead: [0.5, 0.5], cacheWrite: 0, output: [8, 8] },
36
41
  'deepseek-reasoner': { input: [4, 4], cacheRead: [1, 1], cacheWrite: 0, output: [16, 16] },
37
42
  };
38
43
 
44
+ /**
45
+ * 旧模型名 → 现模型名(官方改名后旧名仍可调用,且按现模型价格计费)。
46
+ * 见官方价格页脚注:deepseek-v4-flash / deepseek-v4-flash-vision-exp 由
47
+ * DeepSeek-V4.1-Flash 提供服务,按 Flash 价格计费。
48
+ */
49
+ export const MODEL_ALIASES = {
50
+ 'deepseek-v4-flash': 'deepseek-flash',
51
+ 'deepseek-v4-flash-vision-exp': 'deepseek-flash',
52
+ };
53
+
39
54
  /** 远端官方价格(setRemotePrices 注入,按模型覆盖种子表;key 与种子一致)。 */
40
55
  let remoteTable = null;
56
+ /** 远端同步元信息(解析策略 / 抓取时间 / 来源),供界面展示同步状态。 */
57
+ let remoteMeta = null;
41
58
 
42
59
  /** 默认模型(当 agentDefaultModel 不可用时的展示默认)。 */
43
- export const DEFAULT_MODEL = 'deepseek-v4-flash';
60
+ export const DEFAULT_MODEL = 'deepseek-flash';
44
61
 
45
62
  /** 高峰时段窗口(北京时间,含起点不含终点)。 */
46
63
  export const PEAK_WINDOWS = [[9, 12], [14, 18]];
@@ -68,9 +85,10 @@ export function isPeak(ms) {
68
85
  }
69
86
 
70
87
  /** 注入远端官方价格(parseOfficialPrices 的结果);传 null/空表即清除回种子。 */
71
- export function setRemotePrices(table) {
88
+ export function setRemotePrices(table, meta) {
72
89
  if (!table || typeof table !== 'object') {
73
90
  remoteTable = null;
91
+ remoteMeta = null;
74
92
  return;
75
93
  }
76
94
  const clean = {};
@@ -87,38 +105,84 @@ export function setRemotePrices(table) {
87
105
  };
88
106
  }
89
107
  remoteTable = Object.keys(clean).length > 0 ? clean : null;
108
+ remoteMeta = remoteTable && meta && typeof meta === 'object' ? { ...meta } : null;
90
109
  }
91
110
 
92
- /** 模型是否在价格表中(种子表或远端表)。 */
93
- export function isKnownModel(model) {
94
- if (typeof model !== 'string' || model === '') return false;
95
- if (Object.prototype.hasOwnProperty.call(PRICE_TABLE, model)) return true;
96
- return !!remoteTable && Object.prototype.hasOwnProperty.call(remoteTable, model);
111
+ /** 远端同步元信息(未同步/同步失败时为 null)。 */
112
+ export function remotePriceMeta() {
113
+ return remoteMeta ? { ...remoteMeta } : null;
97
114
  }
98
115
 
99
- /** 取某模型价格条目:远端 > 种子 > chat 档。 */
100
- function entryFor(model) {
101
- if (typeof model === 'string') {
102
- if (remoteTable && Object.prototype.hasOwnProperty.call(remoteTable, model)) return remoteTable[model];
103
- if (Object.prototype.hasOwnProperty.call(PRICE_TABLE, model)) return PRICE_TABLE[model];
116
+ /** 远端价格表是否可用。 */
117
+ export function hasRemotePrices() {
118
+ return remoteTable !== null;
119
+ }
120
+
121
+ /** 模型家族名(用于官方改名后的兜底匹配)。 */
122
+ function familyOf(id) {
123
+ const m = /(flash|pro|chat|reasoner)/.exec(id);
124
+ return m ? m[1] : null;
125
+ }
126
+
127
+ const has = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key);
128
+
129
+ /** 在远端表里找模型:精确 → 别名 → 同家族唯一匹配。 */
130
+ function remoteLookup(id) {
131
+ if (!remoteTable || typeof id !== 'string' || id === '') return null;
132
+ if (has(remoteTable, id)) return { id, via: 'remote' };
133
+ const alias = MODEL_ALIASES[id];
134
+ if (alias && has(remoteTable, alias)) return { id: alias, via: 'remote-alias' };
135
+ const fam = familyOf(id);
136
+ if (fam) {
137
+ const hits = Object.keys(remoteTable).filter((k) => familyOf(k) === fam);
138
+ // 仅在家族唯一时匹配,避免把 flash 档算成 pro 档。
139
+ if (hits.length === 1) return { id: hits[0], via: 'remote-family' };
104
140
  }
105
- return PRICE_TABLE['deepseek-chat'];
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * 解析模型的计价条目。
146
+ * @returns {{ price: object, id: string, via: 'remote'|'remote-alias'|'remote-family'|'alias'|'seed'|'fallback', known: boolean }}
147
+ */
148
+ function lookup(model) {
149
+ const hit = remoteLookup(model);
150
+ if (hit) return { price: remoteTable[hit.id], id: hit.id, via: hit.via, known: true };
151
+
152
+ // 远端不可用(尚未同步/同步失败)或该模型确实不在官方页 → 内置表兜底。
153
+ const alias = typeof model === 'string' ? MODEL_ALIASES[model] : undefined;
154
+ if (alias && has(PRICE_TABLE, alias)) {
155
+ return { price: PRICE_TABLE[alias], id: alias, via: 'alias', known: true };
156
+ }
157
+ if (has(PRICE_TABLE, model)) {
158
+ return { price: PRICE_TABLE[model], id: model, via: 'seed', known: true };
159
+ }
160
+ return { price: PRICE_TABLE['deepseek-chat'], id: 'deepseek-chat', via: 'fallback', known: false };
161
+ }
162
+
163
+ /** 模型是否能解析出价格(远端精确/别名/家族,或内置表/别名)。 */
164
+ export function isKnownModel(model) {
165
+ return lookup(model).known;
106
166
  }
107
167
 
108
168
  /**
109
169
  * 某模型在某一时刻的单位价格(元/百万 tokens)。
110
170
  * 未知模型回退 chat 档;known=false 供调用方提示,避免静默算错。
171
+ * via 说明价格来源,供界面区分"官方页同步"与"内置兜底"。
111
172
  */
112
173
  export function unitAt(model, ms) {
113
- const known = isKnownModel(model);
114
- const p = entryFor(model);
174
+ const found = lookup(model);
175
+ const p = found.price;
115
176
  const tier = isPeak(ms) ? 0 : 1;
116
177
  return {
117
178
  input: p.input[tier],
118
179
  cacheRead: p.cacheRead[tier],
119
180
  cacheWrite: p.cacheWrite,
120
181
  output: p.output[tier],
121
- model: known ? model : 'deepseek-chat',
122
- known,
182
+ model: found.id,
183
+ requested: typeof model === 'string' ? model : '',
184
+ known: found.known,
185
+ via: found.via,
186
+ source: found.via.startsWith('remote') ? 'remote' : 'seed',
123
187
  };
124
188
  }
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@dougen/dsh-deepseek-usage",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "DeepSeek 余额侧边栏插件:余额(官方 API)、当前档单价与峰谷时段指示灯,支持中/英文界面。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "scripts": {
8
+ "test": "node --test test/",
9
+ "check:pricing": "node lib/pricing-remote.js --check"
10
+ },
7
11
  "files": [
8
12
  "lib",
9
13
  "cordis.patch.yml",