dsh-usage-badge 0.1.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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * dsh-usage-badge — DeepSeek's published price list.
3
+ *
4
+ * The official pricing page is Docusaurus server-rendered HTML, so it can be read
5
+ * without a browser: one `<table>` holds the model columns and the price rows, and
6
+ * the footnotes carry the policy that the price table alone does not express
7
+ * (peak windows, their time zone, and that holidays are off-peak all day).
8
+ *
9
+ * Parsing a page we do not control is inherently brittle — the shape is asserted
10
+ * and a mismatch raises a descriptive error rather than silently returning wrong
11
+ * prices. `test/fixtures/` keeps the captured pages this parser is verified
12
+ * against, so a change in the page shows up as a failing test.
13
+ *
14
+ * @module dsh-usage-badge/official-pricing
15
+ */
16
+
17
+ /** The two published pages; the Chinese one quotes CNY and the English one USD. */
18
+ export const OFFICIAL_SOURCES = {
19
+ 'zh-cn': {
20
+ url: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing',
21
+ currency: 'cny',
22
+ label: '中文页(人民币)',
23
+ },
24
+ en: {
25
+ url: 'https://api-docs.deepseek.com/quick_start/pricing',
26
+ currency: 'usd',
27
+ label: 'English page (USD)',
28
+ },
29
+ }
30
+
31
+ /** Fetch timeout, so a hung docs site cannot hold a request open. */
32
+ const FETCH_TIMEOUT_MS = 15000
33
+
34
+ const decodeEntities = (text) =>
35
+ text
36
+ .replace(/&nbsp;/g, ' ')
37
+ .replace(/&amp;/g, '&')
38
+ .replace(/&lt;/g, '<')
39
+ .replace(/&gt;/g, '>')
40
+ .replace(/&quot;/g, '"')
41
+ .replace(/&#x27;|&#39;/g, "'")
42
+
43
+ const stripTags = (html) => decodeEntities(html.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim()
44
+
45
+ /** One `<tr>`'s cells, in document order. */
46
+ function parseRows(tableHtml) {
47
+ return tableHtml
48
+ .split(/<tr[^>]*>/i)
49
+ .slice(1)
50
+ .map((row) =>
51
+ row
52
+ .split(/<\/tr>/i)[0]
53
+ .split(/<td[^>]*>/i)
54
+ .slice(1)
55
+ .map((cell) => ({
56
+ html: cell.split(/<\/td>/i)[0],
57
+ text: stripTags(cell.split(/<\/td>/i)[0]),
58
+ }))
59
+ .filter((cell) => cell.text !== '' || cell.html !== ''),
60
+ )
61
+ }
62
+
63
+ const isPeriod = (text) => /空闲时段|高峰时段|off-?peak|^peak$/i.test(text)
64
+ const periodOf = (text) => (/空闲时段|off-?peak/i.test(text) ? 'offPeak' : 'peak')
65
+
66
+ /** Which token bucket a price row names, or null when the row is not a price row. */
67
+ function priceKindOf(text) {
68
+ if (/缓存命中|cache\s*hit/i.test(text)) return 'cacheRead'
69
+ if (/缓存未命中|cache\s*miss/i.test(text)) return 'input'
70
+ if (/输出|output/i.test(text)) return 'output'
71
+ return null
72
+ }
73
+
74
+ /** A price cell like `0.02元` or `$0.003` → its number and the currency it names. */
75
+ function parseAmount(text) {
76
+ const match = /(\d+(?:\.\d+)?)/.exec(text)
77
+ if (!match) return null
78
+ const currency = /元|¥|¥|人民币/.test(text) ? 'cny' : /\$|USD/i.test(text) ? 'usd' : null
79
+ return { value: Number(match[1]), currency }
80
+ }
81
+
82
+ /**
83
+ * Shift published peak windows into the host's local hours.
84
+ *
85
+ * The Chinese page quotes 北京时间 and the English page quotes UTC for the same
86
+ * windows, so the printed hours are only meaningful together with the zone. Since
87
+ * the pricing config expresses `peakRanges` in local hours, the windows are
88
+ * converted here and both forms are reported, letting the UI show "official
89
+ * 01:00–04:00 UTC (≈ local 09:00–12:00)" instead of hiding the conversion.
90
+ */
91
+ function toLocalRanges(ranges, sourceOffsetMinutes, localOffsetMinutes) {
92
+ const delta = localOffsetMinutes - sourceOffsetMinutes
93
+ const round = (value) => Math.round(value * 100) / 100
94
+ const out = []
95
+ for (const [start, end] of ranges) {
96
+ const startMinutes = start * 60 + delta
97
+ const endMinutes = end * 60 + delta
98
+ if (startMinutes >= 0 && endMinutes <= 1440) {
99
+ out.push([round(startMinutes / 60), round(endMinutes / 60)])
100
+ continue
101
+ }
102
+ // The shifted window crosses midnight, so it becomes two ranges.
103
+ const wrap = (minutes) => ((minutes % 1440) + 1440) % 1440
104
+ out.push([round(wrap(startMinutes) / 60), 24])
105
+ out.push([0, round(wrap(endMinutes) / 60)])
106
+ }
107
+ return out.sort((a, b) => a[0] - b[0])
108
+ }
109
+
110
+ /**
111
+ * Parse one captured or freshly fetched pricing page.
112
+ *
113
+ * @param {string} html - the page body.
114
+ * @param {{url?:string, now?:number, localOffsetMinutes?:number}} [options]
115
+ * @returns {object} the parsed prices and policy.
116
+ * @throws {Error} when the page no longer has the expected shape.
117
+ */
118
+ export function parseOfficialPricing(html, options = {}) {
119
+ const url = options.url ?? OFFICIAL_SOURCES['zh-cn'].url
120
+ const now = options.now ?? Date.now()
121
+ const localOffsetMinutes = options.localOffsetMinutes ?? -new Date(now).getTimezoneOffset()
122
+
123
+ const tables = html.match(/<table[^>]*>[\s\S]*?<\/table>/gi) ?? []
124
+ const table = tables.find((candidate) => /价格|PRICING/i.test(stripTags(candidate)))
125
+ if (!table) throw new Error('the pricing table was not found on the page (layout changed?)')
126
+
127
+ const rows = parseRows(table)
128
+ const modelRow = rows.find((cells) => /^(模型|MODEL)$/i.test(cells[0]?.text ?? ''))
129
+ if (!modelRow) throw new Error('the model header row was not found in the pricing table (layout changed?)')
130
+
131
+ // The first model may carry a `(1)` marker naming its legacy aliases.
132
+ const models = []
133
+ const footnotedModels = new Map()
134
+ for (const [index, cell] of modelRow.slice(1).entries()) {
135
+ const marker = /\((\d+)\)/.exec(cell.text)
136
+ models.push({ model: cell.text.replace(/\(\d+\)/g, '').trim(), peak: {}, offPeak: {}, cacheWritePerMillion: 0 })
137
+ if (marker) footnotedModels.set(Number(marker[1]), index)
138
+ }
139
+ if (models.length === 0 || models.some((entry) => !entry.model)) {
140
+ throw new Error('the pricing table listed no model columns (layout changed?)')
141
+ }
142
+
143
+ let currency = null
144
+ let kind = null
145
+ let priceRows = 0
146
+ for (const cells of rows) {
147
+ // The period cell is located rather than assumed: the first price row carries
148
+ // an extra leading `rowspan` cell ("价格"/"PRICING"), while the continuation
149
+ // rows below it start straight at the period. Everything after the period cell
150
+ // is a per-model value, and the cell just before it names the token bucket
151
+ // when this row opens a new one.
152
+ const periodIndex = cells.findIndex((cell) => isPeriod(cell.text))
153
+ if (periodIndex === -1) continue
154
+ if (periodIndex >= 1) {
155
+ const named = priceKindOf(cells[periodIndex - 1].text)
156
+ if (named) kind = named
157
+ }
158
+ if (!kind) continue
159
+ const values = cells.slice(periodIndex + 1)
160
+ if (values.length < models.length) {
161
+ throw new Error(
162
+ `a price row listed ${values.length} value(s) for ${models.length} model(s) (layout changed?)`,
163
+ )
164
+ }
165
+ for (const [index, model] of models.entries()) {
166
+ const amount = parseAmount(values[index].text)
167
+ if (!amount) continue
168
+ model[periodOf(cells[periodIndex].text)][kind] = amount.value
169
+ if (amount.currency) currency = amount.currency
170
+ }
171
+ priceRows++
172
+ }
173
+
174
+ if (priceRows === 0) throw new Error('no price rows could be read from the pricing table (layout changed?)')
175
+
176
+ for (const model of models) {
177
+ for (const period of ['peak', 'offPeak']) {
178
+ for (const key of ['input', 'cacheRead', 'cacheWrite', 'output']) {
179
+ if (model[period][key] === undefined) model[period][key] = 0
180
+ }
181
+ }
182
+ }
183
+
184
+ // ── policy footnotes ───────────────────────────────────────────────────────
185
+ const footnotes = new Map()
186
+ for (const match of html.matchAll(/<p>\s*\((\d+)\)([\s\S]*?)<\/p>/gi)) {
187
+ footnotes.set(Number(match[1]), match[2])
188
+ }
189
+ const policyHtml = [...footnotes.entries()].map(([, body]) => body).find((body) => /高峰时段|Peak hours/i.test(body))
190
+ if (!policyHtml) throw new Error('the peak-hours footnote was not found on the page (layout changed?)')
191
+ const policyText = stripTags(policyHtml)
192
+
193
+ const ranges = []
194
+ for (const match of policyText.matchAll(/(\d{1,2}):(\d{2})\s*[-–—~至]\s*(\d{1,2}):(\d{2})/g)) {
195
+ ranges.push([Number(match[1]) + Number(match[2]) / 60, Number(match[3]) + Number(match[4]) / 60])
196
+ }
197
+ if (ranges.length === 0) throw new Error('no peak-hour windows could be read from the footnote (layout changed?)')
198
+
199
+ const sourceZone = /北京时间/.test(policyText) ? { name: 'Asia/Shanghai', offsetMinutes: 480 } : /UTC/.test(policyText) ? { name: 'UTC', offsetMinutes: 0 } : null
200
+
201
+ // Footnote (1) names the retired model ids that are still served and billed at
202
+ // the marked model's price, so they get their own override rows.
203
+ const [markedFootnote, markedIndex] = footnotedModels.entries().next().value ?? []
204
+ const aliases = []
205
+ if (markedFootnote !== undefined) {
206
+ const footnoteHtml = footnotes.get(markedFootnote) ?? ''
207
+ if (/legacy|旧模型名|已下线/i.test(stripTags(footnoteHtml))) {
208
+ for (const match of footnoteHtml.matchAll(/<code>([^<]+)<\/code>/g)) {
209
+ const name = decodeEntities(match[1]).trim()
210
+ if (name && !models.some((model) => model.model === name)) aliases.push(name)
211
+ }
212
+ }
213
+ }
214
+ const aliasTarget = models[markedIndex ?? 0].model
215
+
216
+ return {
217
+ ok: true,
218
+ url,
219
+ fetchedAt: now,
220
+ currency: currency ?? OFFICIAL_SOURCES['zh-cn'].currency,
221
+ models,
222
+ aliases: { target: aliasTarget, names: aliases },
223
+ policy: {
224
+ text: policyText,
225
+ peakRangesSource: ranges,
226
+ sourceZone,
227
+ // Converted into the host's local hours, which is what `timeOfUse.peakRanges` means.
228
+ peakRangesLocal: toLocalRanges(ranges, sourceZone?.offsetMinutes ?? localOffsetMinutes, localOffsetMinutes),
229
+ localOffsetMinutes,
230
+ offPeakIsHalfOfPeak: /一半|half of the peak/i.test(policyText),
231
+ weekendsOffPeak: /周末|weekends/i.test(policyText),
232
+ holidaysOffPeak: /法定节假日|public holidays/i.test(policyText),
233
+ },
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Fetch and parse a published pricing page.
239
+ *
240
+ * @param {{source?:'zh-cn'|'en', url?:string, now?:number, fetchImpl?:Function}} [options]
241
+ * @returns {Promise<object>} the parsed result.
242
+ */
243
+ export async function fetchOfficialPricing(options = {}) {
244
+ const source = OFFICIAL_SOURCES[options.source] ?? OFFICIAL_SOURCES['zh-cn']
245
+ const url = options.url ?? source.url
246
+ const doFetch = options.fetchImpl ?? fetch
247
+ const response = await doFetch(url, {
248
+ headers: { 'user-agent': 'dsh-usage-badge', accept: 'text/html' },
249
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
250
+ })
251
+ if (!response.ok) throw new Error(`the pricing page answered HTTP ${response.status}`)
252
+ const html = await response.text()
253
+ return parseOfficialPricing(html, { url, now: options.now })
254
+ }
255
+
256
+ /**
257
+ * Build the pricing document that applying an official price list produces.
258
+ *
259
+ * The row price is the **off-peak** rate and the peak multiplier is 2, which is
260
+ * the same shape the desktop shell already used and follows the page's own rule
261
+ * that off-peak is half of peak. Applying also switches the holiday calendar on,
262
+ * because the published peak windows are defined as excluding Chinese public
263
+ * holidays — without it a weekday holiday would be billed at peak.
264
+ *
265
+ * Unknown keys in the existing document survive, so hand-written rows elsewhere
266
+ * are not lost.
267
+ *
268
+ * @param {object} current - the raw user pricing document.
269
+ * @param {object} parsed - a {@link parseOfficialPricing} result.
270
+ * @returns {{document:object, applied:{models:string[], aliases:string[], peakRanges:number[][]}}}
271
+ */
272
+ export function applyOfficialPricing(current, parsed) {
273
+ const next = current && typeof current === 'object' ? { ...current } : {}
274
+ const overrides = { ...(next.overrides ?? {}) }
275
+ const applied = []
276
+
277
+ const rowFor = (base) => ({
278
+ inputPerMillion: base.offPeak.input,
279
+ cacheReadPerMillion: base.offPeak.cacheRead,
280
+ cacheWritePerMillion: base.offPeak.cacheWrite ?? 0,
281
+ outputPerMillion: base.offPeak.output,
282
+ currency: parsed.currency,
283
+ multiplier: 1,
284
+ })
285
+
286
+ for (const entry of parsed.models) {
287
+ overrides[entry.model] = { ...(overrides[entry.model] ?? {}), ...rowFor(entry) }
288
+ applied.push(entry.model)
289
+ }
290
+
291
+ // Retired ids the page says are still served and billed at the same price.
292
+ const aliasNames = []
293
+ const target = parsed.models.find((entry) => entry.model === parsed.aliases.target) ?? parsed.models[0]
294
+ for (const name of parsed.aliases.names ?? []) {
295
+ overrides[name] = { ...(overrides[name] ?? {}), ...rowFor(target) }
296
+ aliasNames.push(name)
297
+ }
298
+
299
+ next.overrides = overrides
300
+ next.timeOfUse = {
301
+ ...(next.timeOfUse ?? {}),
302
+ enabled: true,
303
+ days: 'weekday',
304
+ peakMultiplier: 2,
305
+ valleyMultiplier: 1,
306
+ peakRanges: parsed.policy.peakRangesLocal,
307
+ }
308
+ if (parsed.policy.holidaysOffPeak) {
309
+ next.holidays = { ...(next.holidays ?? {}), source: 'cn' }
310
+ }
311
+
312
+ return {
313
+ document: next,
314
+ applied: { models: applied, aliases: aliasNames, peakRanges: parsed.policy.peakRangesLocal },
315
+ }
316
+ }