@dougen/dsh-deepseek-usage 0.9.2 → 0.10.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 +8 -8
- package/lib/client.js +83 -18
- package/lib/index.js +267 -487
- package/lib/pricing.js +89 -146
- package/package.json +2 -3
- package/lib/pricing-remote.js +0 -428
- package/lib/pricing-store.js +0 -253
package/lib/pricing-remote.js
DELETED
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-deepseek-usage — 官方"模型 & 价格"页抓取与解析(lib/pricing.js 的远端数据源)。
|
|
3
|
-
*
|
|
4
|
-
* 数据源: https://api-docs.deepseek.com/zh-cn/quick_start/pricing/
|
|
5
|
-
* 该页为静态站点、服务端直出 HTML:无需登录、无需浏览器执行 JS。
|
|
6
|
-
* 官方调整价格/改版后页面随之更新,插件每日首次访问或点击卡片时重拉即可跟随,
|
|
7
|
-
* 无需随官方改价更新内置价格表。
|
|
8
|
-
*
|
|
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 # 机器可读输出
|
|
40
|
-
*/
|
|
41
|
-
import { pathToFileURL } from "node:url";
|
|
42
|
-
|
|
43
|
-
/** 官方价格页(中文,人民币计价)。 */
|
|
44
|
-
const DEFAULT_URL = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
|
|
45
|
-
/** 可选环境变量覆盖(供镜像/代理场景)。 */
|
|
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)";
|
|
48
|
-
|
|
49
|
-
const ENTITY_MAP = {
|
|
50
|
-
' ': ' ', '&': '&', '<': '<', '>': '>',
|
|
51
|
-
'"': '"', ''': "'", ''': "'",
|
|
52
|
-
};
|
|
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
|
-
|
|
61
|
-
/** 官方价格页 URL(可用环境变量 DEEPSEEK_USAGE_PRICE_URL 覆盖)。 */
|
|
62
|
-
export function officialPriceUrl() {
|
|
63
|
-
const v = process.env[URL_ENV];
|
|
64
|
-
return typeof v === "string" && v.trim() !== "" ? v.trim() : DEFAULT_URL;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** 常见 HTML 实体解码(含数字实体)。 */
|
|
68
|
-
function decodeEntities(s) {
|
|
69
|
-
return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, e) => {
|
|
70
|
-
if (e[0] === '#') {
|
|
71
|
-
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
72
|
-
if (Number.isFinite(code)) {
|
|
73
|
-
try { return String.fromCodePoint(code); } catch {}
|
|
74
|
-
}
|
|
75
|
-
return m;
|
|
76
|
-
}
|
|
77
|
-
return Object.prototype.hasOwnProperty.call(ENTITY_MAP, e.toLowerCase()) ? ENTITY_MAP[e.toLowerCase()] : m;
|
|
78
|
-
});
|
|
79
|
-
}
|
|
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
|
-
|
|
90
|
-
/** 把 HTML 拍平成"行主序纯文本":去脚本/样式/标签,连续空白收敛为单个空格。 */
|
|
91
|
-
function flattenHtml(html) {
|
|
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;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 把一行拆成"末尾连续的价格单元格 + 其余标签单元格"。
|
|
123
|
-
* colspan 会展开(合并单元格的价格视为各列同价)。
|
|
124
|
-
*/
|
|
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) {
|
|
262
|
-
const flat = flattenHtml(html);
|
|
263
|
-
|
|
264
|
-
// 定位价格表区域:模型细节标题之后、并发限制行之前(排除导航/脚注噪音)。
|
|
265
|
-
const start = flat.indexOf('模型细节');
|
|
266
|
-
const end = start === -1 ? -1 : flat.indexOf('并发限制', start);
|
|
267
|
-
const region = start !== -1 && end > start ? flat.slice(start, end) : flat;
|
|
268
|
-
|
|
269
|
-
// 模型 id:区域内按首次出现顺序收集(表头在前,脚注/重复被去重)。
|
|
270
|
-
const ids = [];
|
|
271
|
-
for (const m of region.matchAll(/deepseek-[a-z0-9-]+/g)) {
|
|
272
|
-
if (!ids.includes(m[0])) ids.push(m[0]);
|
|
273
|
-
}
|
|
274
|
-
if (ids.length === 0) return null;
|
|
275
|
-
|
|
276
|
-
// 结构化 token 游走:metric 标签 →(空闲/高峰)× 紧随其后的价格数字。
|
|
277
|
-
// 注意排除"输出长度"这类前缀相同的非价格行(0.5.x 曾在此处误判)。
|
|
278
|
-
const TOKEN = /缓存命中|缓存未命中|空闲时段|高峰时段|输出(?!\s*长度)(?=\s)|(\d+(?:\.\d+)?)\s*元/g;
|
|
279
|
-
const slots = {
|
|
280
|
-
input: { idle: [], peak: [] },
|
|
281
|
-
cacheRead: { idle: [], peak: [] },
|
|
282
|
-
output: { idle: [], peak: [] },
|
|
283
|
-
};
|
|
284
|
-
let metric = null;
|
|
285
|
-
let tier = null;
|
|
286
|
-
for (const m of region.matchAll(TOKEN)) {
|
|
287
|
-
const t = m[0];
|
|
288
|
-
if (t === '缓存命中') { metric = 'cacheRead'; tier = null; continue; }
|
|
289
|
-
if (t === '缓存未命中') { metric = 'input'; tier = null; continue; }
|
|
290
|
-
if (t === '输出') { metric = 'output'; tier = null; continue; }
|
|
291
|
-
if (t === '空闲时段') { tier = 'idle'; continue; }
|
|
292
|
-
if (t === '高峰时段') { tier = 'peak'; continue; }
|
|
293
|
-
// 仅当处于某 (metric, tier) 上下文时才收数字,否则忽略(如并发数、上下文长度)。
|
|
294
|
-
if (m[1] !== undefined && metric && tier) {
|
|
295
|
-
const n = Number(m[1]);
|
|
296
|
-
if (Number.isFinite(n) && n > 0) slots[metric][tier].push(n);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const n = ids.length;
|
|
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
|
-
}
|
|
306
|
-
|
|
307
|
-
const models = buildModels(ids, pairs);
|
|
308
|
-
return numbersLookSane(models) ? models : null;
|
|
309
|
-
}
|
|
310
|
-
|
|
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';
|
|
323
|
-
}
|
|
324
|
-
if (!models) return null;
|
|
325
|
-
|
|
326
|
-
// 页面脚注若仍写"周一至周五",记入 meta(峰谷窗口本身以内置配置为准;
|
|
327
|
-
// 脚注位于价格表之后,因此扫整页文本)。
|
|
328
|
-
return {
|
|
329
|
-
models,
|
|
330
|
-
meta: { source: officialPriceUrl(), weekdayPeakOnly: /周一至周五/.test(flat), strategy },
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* 拉取官方价格页原始 HTML。
|
|
336
|
-
* @throws 网络失败 / HTTP 非 2xx
|
|
337
|
-
*/
|
|
338
|
-
export async function fetchPricingHtml(opts = {}) {
|
|
339
|
-
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 12000;
|
|
340
|
-
const url = officialPriceUrl();
|
|
341
|
-
const res = await fetch(url, {
|
|
342
|
-
headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml' },
|
|
343
|
-
redirect: 'follow',
|
|
344
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
345
|
-
});
|
|
346
|
-
if (!res.ok) throw new Error(`pricing page HTTP ${res.status}`);
|
|
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);
|
|
357
|
-
const parsed = parseOfficialPrices(html);
|
|
358
|
-
if (!parsed) throw new Error('pricing page parse failed (official page layout changed?)');
|
|
359
|
-
parsed.meta.fetchedAt = Date.now();
|
|
360
|
-
return parsed;
|
|
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-store.js
DELETED
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-deepseek-usage — 本地价格配置表(磁盘文件,lib/pricing.js 的数据源之一)。
|
|
3
|
-
*
|
|
4
|
-
* 为什么有这一层:价格同步状态原来只活在进程内存里,dsh 一重启就没了,于是重启后
|
|
5
|
-
* 的第一张卡片只能用代码内置的价格表(表现为"价格未同步(用内置表)")。现在把
|
|
6
|
-
* "模型 → 档单价"从代码里抽出来存成配置文件:
|
|
7
|
-
*
|
|
8
|
-
* 启动 → 读本文件(上次同步到的官方价 / 用户手改的价)→ 卡片立刻有价
|
|
9
|
-
* 每天/点击 → 拉官方价格页 → 合并回写本文件(官方条目覆盖同名项,用户自加条目保留)
|
|
10
|
-
*
|
|
11
|
-
* 路径解析优先级:
|
|
12
|
-
* 1. 环境变量 DEEPSEEK_USAGE_PRICE_FILE(显式指定,测试/多实例用)
|
|
13
|
-
* 2. <DSH_HOME|~/.dsh>/dsh-deepseek-usage/pricing.json
|
|
14
|
-
*
|
|
15
|
-
* 文件格式(JSON;允许**整行** `//` 注释,方便手改):
|
|
16
|
-
* {
|
|
17
|
-
* "version": 1,
|
|
18
|
-
* "autoUpdate": true, // false = 手动模式:不再联网同步,直接用本表
|
|
19
|
-
* "updatedAt": 1789090582880, // 上次写入时间(ms;也接受 ISO 字符串)
|
|
20
|
-
* "updatedFrom": "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/",
|
|
21
|
-
* "strategy": "table", // 官方页解析策略(table=结构化表格 / flat=文本兜底 / seed=内置快照)
|
|
22
|
-
* "aliases": { "deepseek-v4-flash": "deepseek-flash" },
|
|
23
|
-
* "models": {
|
|
24
|
-
* "deepseek-flash": { "input": [2, 1], "cacheRead": [0.04, 0.02], "cacheWrite": 0, "output": [8, 4] }
|
|
25
|
-
* }
|
|
26
|
-
* }
|
|
27
|
-
* 单价单位:元 / 百万 tokens;input / cacheRead / output 均为 [高峰, 空闲] 两档
|
|
28
|
-
* (cacheWrite 目前官方不计费,恒 0)。也接受"整个文件就是模型映射"的简写形式:
|
|
29
|
-
* { "deepseek-flash": { "input": [2, 1], ... } }
|
|
30
|
-
*
|
|
31
|
-
* 写入策略:读-改-写 + 原子替换(先写同目录 .tmp 再 rename),避免半截文件。
|
|
32
|
-
* 本文件不做任何网络访问,也不含运行时状态(解析 → 返回,交给调用方注入)。
|
|
33
|
-
*/
|
|
34
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
35
|
-
import { homedir } from "node:os";
|
|
36
|
-
import { dirname, join } from "node:path";
|
|
37
|
-
|
|
38
|
-
/** 价格表文件格式版本(结构不兼容时递增)。 */
|
|
39
|
-
export const PRICE_FILE_VERSION = 1;
|
|
40
|
-
/** 价格表路径覆盖(便于测试与多实例)。 */
|
|
41
|
-
const FILE_ENV = "DEEPSEEK_USAGE_PRICE_FILE";
|
|
42
|
-
/** DSH 家目录覆盖(与 dsh-home-paths 的约定一致)。 */
|
|
43
|
-
const HOME_ENV = "DSH_HOME";
|
|
44
|
-
/** 插件在 DSH 家目录下的子目录 / 文件名。 */
|
|
45
|
-
const DIR_NAME = "dsh-deepseek-usage";
|
|
46
|
-
const FILE_NAME = "pricing.json";
|
|
47
|
-
|
|
48
|
-
/** 写进文件的格式说明(JSON 无注释,只能占一个字段)。 */
|
|
49
|
-
const FILE_COMMENT =
|
|
50
|
-
"dsh-deepseek-usage 价格表:input/cacheRead/output 为 [高峰, 空闲] 两档单价,单位 元/百万 tokens;"
|
|
51
|
-
+ "autoUpdate=true 时插件每天首次使用(或点击卡片)会拉取官方价格页并回写本文件——"
|
|
52
|
-
+ "官方页列出的模型覆盖同名条目,你自己加的条目保留;autoUpdate=false 表示不再联网同步,完全以本表为准。"
|
|
53
|
-
+ "允许整行 // 注释。";
|
|
54
|
-
|
|
55
|
-
/** 当前生效的价格表路径(env > DSH_HOME/dsh-deepseek-usage/pricing.json > ~/.dsh/...)。 */
|
|
56
|
-
export function defaultPriceFilePath() {
|
|
57
|
-
const fromEnv = process.env[FILE_ENV];
|
|
58
|
-
if (typeof fromEnv === "string" && fromEnv.trim() !== "") return fromEnv.trim();
|
|
59
|
-
const home = process.env[HOME_ENV];
|
|
60
|
-
const base = typeof home === "string" && home.trim() !== "" ? home.trim() : join(homedir(), ".dsh");
|
|
61
|
-
return join(base, DIR_NAME, FILE_NAME);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** 只剥掉"整行 //"注释:URL 里的 `//` 在行中间,不会被误伤。 */
|
|
65
|
-
function stripLineComments(text) {
|
|
66
|
-
return text
|
|
67
|
-
.split(/\r?\n/)
|
|
68
|
-
.map((line) => (/^\s*\/\//.test(line) ? "" : line))
|
|
69
|
-
.join("\n");
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** [高峰, 空闲] 二元组校验。 */
|
|
73
|
-
const okPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => Number.isFinite(n) && n >= 0);
|
|
74
|
-
|
|
75
|
-
/** 时间容忍数字(ms)/ ISO 字符串。 */
|
|
76
|
-
function toMs(value) {
|
|
77
|
-
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
78
|
-
if (typeof value === "string") {
|
|
79
|
-
const n = Number(value);
|
|
80
|
-
if (Number.isFinite(n) && n > 0) return n;
|
|
81
|
-
const t = Date.parse(value);
|
|
82
|
-
if (Number.isFinite(t)) return t;
|
|
83
|
-
}
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** 清洗模型映射:非法条目丢弃并记入 dropped(缺失的档位回退到 input 档)。 */
|
|
88
|
-
function cleanModels(raw) {
|
|
89
|
-
const models = {};
|
|
90
|
-
const dropped = [];
|
|
91
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { models, dropped };
|
|
92
|
-
for (const [id, entry] of Object.entries(raw)) {
|
|
93
|
-
if (typeof id !== "string" || id === "" || !entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
94
|
-
dropped.push(String(id));
|
|
95
|
-
continue;
|
|
96
|
-
}
|
|
97
|
-
const input = okPair(entry.input) ? entry.input.slice() : null;
|
|
98
|
-
if (!input) {
|
|
99
|
-
dropped.push(id);
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
const cacheWrite = Number(entry.cacheWrite);
|
|
103
|
-
models[id] = {
|
|
104
|
-
input,
|
|
105
|
-
cacheRead: okPair(entry.cacheRead) ? entry.cacheRead.slice() : input.slice(),
|
|
106
|
-
cacheWrite: Number.isFinite(cacheWrite) && cacheWrite >= 0 ? cacheWrite : 0,
|
|
107
|
-
output: okPair(entry.output) ? entry.output.slice() : input.slice(),
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
return { models, dropped };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** 清洗别名映射(旧名 → 现名)。 */
|
|
114
|
-
function cleanAliases(raw) {
|
|
115
|
-
const aliases = {};
|
|
116
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return aliases;
|
|
117
|
-
for (const [from, to] of Object.entries(raw)) {
|
|
118
|
-
if (typeof from === "string" && from !== "" && typeof to === "string" && to !== "") aliases[from] = to;
|
|
119
|
-
}
|
|
120
|
-
return aliases;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** 按键名排序(让文件 diff 稳定、便于人读)。 */
|
|
124
|
-
function sorted(obj) {
|
|
125
|
-
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)));
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* 解析价格表 JSON 文本。
|
|
130
|
-
* @throws 根节点/模型条目完全不合法时抛错(调用方据此判定"文件损坏")。
|
|
131
|
-
* @returns {{ doc: { version, autoUpdate, updatedAt, updatedFrom, strategy, aliases, models }, dropped: string[] }}
|
|
132
|
-
*/
|
|
133
|
-
export function parsePriceTable(text) {
|
|
134
|
-
const raw = JSON.parse(stripLineComments(String(text)));
|
|
135
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("价格表根节点必须是对象");
|
|
136
|
-
|
|
137
|
-
// 显式结构(带 models/autoUpdate/... 字段)与简写结构(整个文件就是模型映射)都支持。
|
|
138
|
-
const explicit = ["models", "aliases", "autoUpdate", "updatedAt", "updatedFrom", "strategy", "version"].some(
|
|
139
|
-
(k) => raw[k] !== undefined,
|
|
140
|
-
);
|
|
141
|
-
const { models, dropped } = cleanModels(explicit ? raw.models : raw);
|
|
142
|
-
if (Object.keys(models).length === 0) {
|
|
143
|
-
throw new Error("价格表里没有可用模型条目(每项至少要有 input: [高峰, 空闲])");
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
doc: {
|
|
147
|
-
version: Number.isFinite(Number(raw.version)) ? Number(raw.version) : PRICE_FILE_VERSION,
|
|
148
|
-
autoUpdate: raw.autoUpdate !== false,
|
|
149
|
-
updatedAt: toMs(raw.updatedAt),
|
|
150
|
-
updatedFrom: typeof raw.updatedFrom === "string" && raw.updatedFrom !== "" ? raw.updatedFrom : null,
|
|
151
|
-
strategy: typeof raw.strategy === "string" && raw.strategy !== "" ? raw.strategy : null,
|
|
152
|
-
aliases: explicit ? sorted(cleanAliases(raw.aliases)) : {},
|
|
153
|
-
models: sorted(models),
|
|
154
|
-
},
|
|
155
|
-
dropped,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* 读取价格表文件。
|
|
161
|
-
* @returns {{ path: string, exists: boolean, error: string|null, table: object|null, warnings: string[] }}
|
|
162
|
-
* exists=false 表示文件不存在(首次运行,error 为 null);table=null 且 error 非空表示文件损坏。
|
|
163
|
-
*/
|
|
164
|
-
export function loadPriceTable(options = {}) {
|
|
165
|
-
const path = options.path ?? defaultPriceFilePath();
|
|
166
|
-
let text;
|
|
167
|
-
try {
|
|
168
|
-
text = readFileSync(path, "utf8");
|
|
169
|
-
} catch (error) {
|
|
170
|
-
const code = error && typeof error === "object" ? error.code : void 0;
|
|
171
|
-
return {
|
|
172
|
-
path,
|
|
173
|
-
exists: false,
|
|
174
|
-
error: code === "ENOENT" ? null : error instanceof Error ? error.message : String(error),
|
|
175
|
-
table: null,
|
|
176
|
-
warnings: [],
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
const { doc, dropped } = parsePriceTable(text);
|
|
181
|
-
return {
|
|
182
|
-
path,
|
|
183
|
-
exists: true,
|
|
184
|
-
error: null,
|
|
185
|
-
table: doc,
|
|
186
|
-
warnings: dropped.length > 0 ? [`忽略 ${dropped.length} 个无法解析的模型条目:${dropped.join(", ")}`] : [],
|
|
187
|
-
};
|
|
188
|
-
} catch (error) {
|
|
189
|
-
return {
|
|
190
|
-
path,
|
|
191
|
-
exists: true,
|
|
192
|
-
error: error instanceof Error ? error.message : String(error),
|
|
193
|
-
table: null,
|
|
194
|
-
warnings: [],
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** 文件状态(用于"手改价格表后免重启生效"的变更探测)。 */
|
|
200
|
-
export function priceTableStat(options = {}) {
|
|
201
|
-
const path = options.path ?? defaultPriceFilePath();
|
|
202
|
-
try {
|
|
203
|
-
const st = statSync(path);
|
|
204
|
-
return { path, exists: true, mtimeMs: st.mtimeMs, size: st.size };
|
|
205
|
-
} catch {
|
|
206
|
-
return { path, exists: false, mtimeMs: 0, size: 0 };
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* 合并写回价格表:patch.models / patch.aliases 覆盖同名项,其余条目(含用户自加模型)
|
|
212
|
-
* 保留;未给出的字段沿用磁盘上的旧值。写盘为"临时文件 + rename"原子替换。
|
|
213
|
-
* @returns {{ path: string, doc: object }}
|
|
214
|
-
*/
|
|
215
|
-
export function savePriceTable(patch = {}, options = {}) {
|
|
216
|
-
const path = options.path ?? defaultPriceFilePath();
|
|
217
|
-
const base = loadPriceTable({ path }).table ?? {
|
|
218
|
-
version: PRICE_FILE_VERSION,
|
|
219
|
-
autoUpdate: true,
|
|
220
|
-
updatedAt: null,
|
|
221
|
-
updatedFrom: null,
|
|
222
|
-
strategy: null,
|
|
223
|
-
aliases: {},
|
|
224
|
-
models: {},
|
|
225
|
-
};
|
|
226
|
-
const { models } = cleanModels({ ...base.models, ...(patch.models ?? {}) });
|
|
227
|
-
const aliases = cleanAliases({ ...base.aliases, ...(patch.aliases ?? {}) });
|
|
228
|
-
const doc = {
|
|
229
|
-
$comment: FILE_COMMENT,
|
|
230
|
-
version: PRICE_FILE_VERSION,
|
|
231
|
-
autoUpdate: patch.autoUpdate ?? base.autoUpdate ?? true,
|
|
232
|
-
updatedAt: patch.updatedAt ?? Date.now(),
|
|
233
|
-
updatedFrom: patch.updatedFrom ?? base.updatedFrom ?? null,
|
|
234
|
-
strategy: patch.strategy ?? base.strategy ?? null,
|
|
235
|
-
aliases: sorted(aliases),
|
|
236
|
-
models: sorted(models),
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
240
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
241
|
-
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
|
|
242
|
-
try {
|
|
243
|
-
renameSync(tmp, path);
|
|
244
|
-
} catch (error) {
|
|
245
|
-
try {
|
|
246
|
-
rmSync(tmp, { force: true });
|
|
247
|
-
} catch {
|
|
248
|
-
/* 清理失败不影响报错 */
|
|
249
|
-
}
|
|
250
|
-
throw error;
|
|
251
|
-
}
|
|
252
|
-
return { path, doc };
|
|
253
|
-
}
|