dsh-plugin-lcsc 0.3.3 → 0.3.5

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/cn.js CHANGED
@@ -1,774 +1,808 @@
1
- /**
2
- * LCSC domestic (立创商城) CNY price/stock adapter.
3
- *
4
- * The domestic store sits behind an Aliyun WAF that challenges plain HTTP
5
- * requests, but a real browser executes the challenge and then serves the
6
- * product page with `<script id="__NEXT_DATA__">` containing the official CNY
7
- * price and domestic stock. This module renders `item.szlcsc.com/<productId>.html`
8
- * with the locally installed Chrome (`--headless=new --dump-dom`) and caches the
9
- * result on disk for 24 h.
10
- *
11
- * Flow: LCSC code -> EasyEDA / lceda.cn product id -> Chrome render -> parse
12
- * __NEXT_DATA__ -> cache. Every failure is returned as `{ ok: false, reason }`
13
- * so callers can fall back to the USD estimate instead of failing the batch.
14
- *
15
- * Speed: a single persistent Chrome instance is kept alive and driven over the
16
- * DevTools protocol (`--remote-debugging-pipe`), opening one tab per product.
17
- * Cold start/warm-up costs ~4-5 s (WAF challenge), after which each product is
18
- * ~1-2 s; 4-8 tabs in parallel warm a 28-part BOM in well under a minute.
19
- * Chrome is started on demand, reused across calls, and auto-closed after an
20
- * idle TTL. If the pipe/CDP path is unavailable, it falls back to the legacy
21
- * per-page `--dump-dom` spawn (slower but dependency-free).
22
- */
23
-
24
- import { spawn } from 'node:child_process'
25
- import fs from 'node:fs'
26
- import os from 'node:os'
27
- import path from 'node:path'
28
-
29
- const EASYEDA_URL = 'https://easyeda.com/api/products/'
30
- const LCEDA_URL = 'https://lceda.cn/api/products/'
31
- const ITEM_URL = 'https://item.szlcsc.com/'
32
-
33
- /**
34
- * Common Chrome/Chromium locations. Windows roots are read from the
35
- * environment so the list follows the real install (drive/ProgramFiles can be
36
- * relocated) instead of a hard-coded path.
37
- * @returns {string[]}
38
- */
39
- function chromeCandidates() {
40
- const candidates = [
41
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
42
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
43
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
44
- '/usr/bin/google-chrome',
45
- '/usr/bin/google-chrome-stable',
46
- '/usr/bin/chromium',
47
- '/usr/bin/chromium-browser',
48
- '/snap/bin/chromium',
49
- ]
50
- if (process.platform === 'win32') {
51
- const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.ProgramW6432, process.env.LOCALAPPDATA]
52
- .filter((root) => typeof root === 'string' && root !== '')
53
- const relatives = [
54
- ['Google', 'Chrome', 'Application', 'chrome.exe'],
55
- ['Chromium', 'Application', 'chrome.exe'],
56
- ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
57
- ]
58
- for (const root of roots) {
59
- for (const relative of relatives) candidates.push(path.join(root, ...relative))
60
- }
61
- }
62
- return candidates
63
- }
64
-
65
- const DEFAULT_USER_AGENT =
66
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
67
- '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
68
-
69
- /**
70
- * How to fix a missing Chrome, phrased so an agent can act on it directly.
71
- * @param {{ path?: string, source?: string, tried: string[], configPath?: string, envPath?: string }} info
72
- * @returns {string}
73
- */
74
- export function chromeHint(info) {
75
- const parts = ['未找到可用的 Chrome/Chromium,国内站人民币价与国内库存不可用(已回退为 USD 估算)。']
76
- if (info.configPath !== undefined || info.envPath !== undefined) {
77
- const missing = []
78
- if (info.configPath !== undefined) missing.push(`config.chromePath=${info.configPath}`)
79
- if (info.envPath !== undefined) missing.push(`CHROME_BIN=${info.envPath}`)
80
- parts.push(`当前指定的路径不存在:${missing.join(',')}。`)
81
- }
82
- parts.push(`已尝试 ${info.tried.length} 个常见路径${info.tried.length === 0 ? '' : `(${info.tried.slice(0, 5).join(' ; ')}${info.tried.length > 5 ? ' …' : ''})`}。`)
83
- parts.push(
84
- '请任选其一修复后重试:① 在本插件配置里加 chromePath: "<本机 Chrome 可执行文件绝对路径>"' +
85
- '(profile 的 cordis.patch.yml,条目 id 为 lcsc-parts);② 给宿主进程设置环境变量 CHROME_BIN。' +
86
- '定位方法:Windows `where chrome` 或查看 %ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe;' +
87
- 'macOS `ls /Applications | grep -i chrome`;Linux `which google-chrome chromium`。',
88
- )
89
- return parts.join('')
90
- }
91
-
92
- /**
93
- * Locate a Chrome/Chromium binary and explain where it came from.
94
- * Precedence: explicit config → CHROME_BIN → auto-discovery.
95
- * @param {string} [explicit]
96
- * @returns {{ path?: string, source?: 'config' | 'chrome_bin' | 'auto', tried: string[], configPath?: string, envPath?: string }}
97
- */
98
- export function findChromeInfo(explicit) {
99
- const configPath = typeof explicit === 'string' && explicit !== '' ? explicit : undefined
100
- if (configPath !== undefined && fs.existsSync(configPath)) return { path: configPath, source: 'config', tried: [], configPath }
101
- const envPath = typeof process.env.CHROME_BIN === 'string' && process.env.CHROME_BIN !== '' ? process.env.CHROME_BIN : undefined
102
- if (envPath !== undefined && fs.existsSync(envPath)) return { path: envPath, source: 'chrome_bin', tried: [], envPath }
103
- const tried = []
104
- for (const candidate of chromeCandidates()) {
105
- if (tried.includes(candidate)) continue
106
- tried.push(candidate)
107
- if (fs.existsSync(candidate)) return { path: candidate, source: 'auto', tried, configPath, envPath }
108
- }
109
- return { path: undefined, source: undefined, tried, configPath, envPath }
110
- }
111
-
112
- /** Locate a Chrome/Chromium binary (compatibility wrapper around findChromeInfo). */
113
- export function findChrome(explicit) {
114
- return findChromeInfo(explicit).path
115
- }
116
-
117
- /** Extract the `__NEXT_DATA__` JSON from an HTML document (or undefined). */
118
- export function extractNextData(html) {
119
- const match = String(html ?? '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)
120
- if (match === null) return undefined
121
- try {
122
- return JSON.parse(match[1])
123
- } catch {
124
- return undefined
125
- }
126
- }
127
-
128
- /** Chrome profile subdirectories that are pure cache/garbage and can be deleted freely. */
129
- const CHROME_PROFILE_JUNK = [
130
- 'Default/Cache',
131
- 'Default/Code Cache',
132
- 'Default/GPUCache',
133
- 'Default/DawnCache',
134
- 'Default/DawnGraphiteCache',
135
- 'Default/DawnWebGPUCache',
136
- 'Default/GraphiteDawnCache',
137
- 'Default/ShaderCache',
138
- 'Default/Service Worker/CacheStorage',
139
- 'Default/Service Worker/ScriptCache',
140
- 'Default/Media Cache',
141
- 'Default/Session Storage',
142
- 'Default/WebStorage',
143
- 'Crashpad',
144
- 'ShaderCache',
145
- 'GrShaderCache',
146
- 'BrowserMetrics',
147
- 'component_crx_cache',
148
- 'extensions_crx_cache',
149
- ]
150
-
151
- /**
152
- * Delete cache-only subdirectories from a Chrome profile, keeping cookies and
153
- * preferences (the WAF challenge cookie lives in the profile).
154
- * @param {string} profileDir
155
- */
156
- export function pruneChromeProfile(profileDir) {
157
- for (const relative of CHROME_PROFILE_JUNK) {
158
- try {
159
- fs.rmSync(path.join(profileDir, relative), { recursive: true, force: true })
160
- } catch {
161
- // best effort
162
- }
163
- }
164
- }
165
-
166
- /**
167
- * Prune the JSON result cache: drop entries older than `ttlMs`, then keep at
168
- * most `maxEntries` newest entries. This keeps `~/.cache/dsh-plugin-lcsc/cn`
169
- * bounded instead of accumulating one file per part ever queried.
170
- * @param {string} cacheDir
171
- * @param {{ ttlMs?: number, maxEntries?: number }} [options]
172
- * @returns {{ removed: number, kept: number }}
173
- */
174
- export function pruneJsonCache(cacheDir, options = {}) {
175
- const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : 24 * 60 * 60 * 1000
176
- const maxEntries = Number.isFinite(options.maxEntries) ? Math.max(1, Math.floor(options.maxEntries)) : 800
177
- let names
178
- try {
179
- names = fs.readdirSync(cacheDir).filter((name) => name.endsWith('.json'))
180
- } catch {
181
- return { removed: 0, kept: 0 }
182
- }
183
- const now = Date.now()
184
- /** @type {{ file: string, fetchedAt: number }[]} */
185
- const alive = []
186
- let removed = 0
187
- for (const name of names) {
188
- const file = path.join(cacheDir, name)
189
- let record
190
- try {
191
- record = JSON.parse(fs.readFileSync(file, 'utf8'))
192
- } catch {
193
- record = undefined
194
- }
195
- const fetchedAt = record === undefined ? Number.NaN : Date.parse(record.fetched_at)
196
- if (!Number.isFinite(fetchedAt) || now - fetchedAt > ttlMs) {
197
- try {
198
- fs.rmSync(file, { force: true })
199
- removed += 1
200
- } catch {
201
- // best effort
202
- }
203
- continue
204
- }
205
- alive.push({ file, fetchedAt })
206
- }
207
- if (alive.length > maxEntries) {
208
- alive.sort((left, right) => left.fetchedAt - right.fetchedAt)
209
- for (const entry of alive.slice(0, alive.length - maxEntries)) {
210
- try {
211
- fs.rmSync(entry.file, { force: true })
212
- removed += 1
213
- } catch {
214
- // best effort
215
- }
216
- }
217
- return { removed, kept: maxEntries }
218
- }
219
- return { removed, kept: alive.length }
220
- }
221
-
222
- /**
223
- * Parse a rendered `item.szlcsc.com` page into the official domestic record.
224
- * @param {string} html
225
- * @returns {{ price?: number, model?: string, brand?: string, package?: string, stock?: number, min_buy?: number, packing?: string, unit?: string, category?: string, desc?: string, product_id?: string } | undefined}
226
- */
227
- export function parseItemHtml(html) {
228
- const text = String(html ?? '')
229
- const nextData = extractNextData(text)
230
- if (nextData === undefined) {
231
- // JSON-LD fallback for pages that omit __NEXT_DATA__.
232
- const fallback = text.match(/"priceCurrency":"CNY","price":([0-9.]+)/)
233
- if (fallback === null) return undefined
234
- return { price: Number(fallback[1]) }
235
- }
236
- const pageProps = nextData?.props?.pageProps ?? {}
237
- const webData = pageProps.webData ?? {}
238
- const productRecord = webData.productRecord ?? {}
239
- const price = Number(pageProps.price)
240
- const result = {
241
- price: Number.isFinite(price) ? price : undefined,
242
- model: stringOrUndefined(productRecord.productModel),
243
- brand: stringOrUndefined(productRecord.productGradePlateName),
244
- package: stringOrUndefined(productRecord.encapsulationModel),
245
- stock: integerOrUndefined(productRecord.stockNumber),
246
- min_buy: integerOrUndefined(productRecord.minBuyNumber),
247
- packing: stringOrUndefined(productRecord.productArrange),
248
- unit: stringOrUndefined(productRecord.productUnit),
249
- category: stringOrUndefined(productRecord.productType),
250
- desc: stringOrUndefined(productRecord.productName) ?? stringOrUndefined(productRecord.remark),
251
- product_id: productRecord.productId ?? productRecord.productCode,
252
- }
253
- if (result.price === undefined) {
254
- const fallback = text.match(/"priceCurrency":"CNY","price":([0-9.]+)/)
255
- if (fallback !== null) result.price = Number(fallback[1])
256
- }
257
- return result
258
- }
259
-
260
- /** @param {unknown} value @returns {string | undefined} */
261
- function stringOrUndefined(value) {
262
- if (typeof value !== 'string') return undefined
263
- const text = value.trim()
264
- return text === '' ? undefined : text
265
- }
266
-
267
- /** @param {unknown} value @returns {number | undefined} */
268
- function integerOrUndefined(value) {
269
- const number = typeof value === 'string' ? Number(value) : value
270
- return typeof number === 'number' && Number.isInteger(number) ? number : undefined
271
- }
272
-
273
- /** @param {number} ms */
274
- function sleep(ms) {
275
- return new Promise((resolve) => setTimeout(resolve, ms))
276
- }
277
-
278
- /**
279
- * Create the domestic-CNY adapter.
280
- * @param {{
281
- * chromePath?: string,
282
- * cacheDir?: string,
283
- * cacheTtlMs?: number,
284
- * timeoutMs?: number,
285
- * concurrency?: number,
286
- * browserIdleTtlMs?: number,
287
- * verbose?: boolean,
288
- * logger?: { warn?: (message: string) => void, info?: (message: string) => void },
289
- * }} [options]
290
- */
291
- export function createCnAdapter(options = {}) {
292
- const chromeInfo = findChromeInfo(options.chromePath)
293
- const chromePath = chromeInfo.path
294
- const chromeHintText = chromePath === undefined ? chromeHint(chromeInfo) : undefined
295
- const cacheDir =
296
- typeof options.cacheDir === 'string' && options.cacheDir !== ''
297
- ? options.cacheDir
298
- : path.join(os.homedir(), '.cache', 'dsh-plugin-lcsc', 'cn')
299
- const profileDir = path.join(cacheDir, 'chrome-profile')
300
- const cacheTtlMs = Number.isFinite(options.cacheTtlMs) ? options.cacheTtlMs : 24 * 60 * 60 * 1000
301
- const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 30000
302
- const concurrency = Math.max(1, Math.min(8, Math.floor(options.concurrency ?? 4)))
303
- const browserIdleTtlMs = Number.isFinite(options.browserIdleTtlMs) ? options.browserIdleTtlMs : 120000
304
- const cacheMaxEntries = Number.isFinite(options.cacheMaxEntries) ? Math.max(1, Math.floor(options.cacheMaxEntries)) : 800
305
- const legacyProfileDir = path.join(cacheDir, 'legacy-profile')
306
- let lastJsonPruneAt = 0
307
- let queryCount = 0
308
- const logger = options.logger
309
- const memory = new Map()
310
- let active = 0
311
- /** @type {(() => void)[]} */
312
- const waiting = []
313
-
314
- /** Run `fn` under the adapter concurrency limit. */
315
- async function withSlot(fn) {
316
- while (active >= concurrency) {
317
- await new Promise((resolve) => waiting.push(resolve))
318
- }
319
- active += 1
320
- try {
321
- return await fn()
322
- } finally {
323
- active -= 1
324
- const next = waiting.shift()
325
- if (next !== undefined) next()
326
- }
327
- }
328
-
329
- /** HTTP GET JSON using the global fetch (host Node >= 20). */
330
- async function getJson(url) {
331
- const response = await fetch(url, {
332
- headers: { 'User-Agent': DEFAULT_USER_AGENT, Accept: 'application/json' },
333
- signal: AbortSignal.timeout(timeoutMs),
334
- })
335
- if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
336
- return response.json()
337
- }
338
-
339
- /* ------------------------- persistent Chrome (CDP) ----------------------- */
340
- /**
341
- * @typedef {{
342
- * child: import('node:child_process').ChildProcess,
343
- * write: NodeJS.WritableStream,
344
- * read: NodeJS.ReadableStream,
345
- * pending: Map<number, { resolve: (value: any) => void, reject: (reason: Error) => void, timer: NodeJS.Timeout }>,
346
- * nextId: number,
347
- * buffer: string,
348
- * closed: boolean,
349
- * }} BrowserState
350
- */
351
- /** @type {BrowserState | undefined} */
352
- let browser
353
- /** @type {NodeJS.Timeout | undefined} */
354
- let idleTimer
355
- let cdpBroken = false
356
- let cdpSessionErrors = 0
357
-
358
- function shutdownBrowser() {
359
- if (idleTimer !== undefined) {
360
- clearTimeout(idleTimer)
361
- idleTimer = undefined
362
- }
363
- const state = browser
364
- browser = undefined
365
- if (state === undefined) return
366
- state.closed = true
367
- for (const entry of state.pending.values()) {
368
- clearTimeout(entry.timer)
369
- entry.reject(new Error('chrome-shutdown'))
370
- }
371
- state.pending.clear()
372
- try {
373
- state.child.kill('SIGKILL')
374
- } catch {
375
- // already gone
376
- }
377
- // Keep cookies for the WAF challenge, drop HTTP/Code caches so the profile
378
- // does not silently grow.
379
- pruneChromeProfile(profileDir)
380
- }
381
-
382
- function touchIdle() {
383
- if (idleTimer !== undefined) clearTimeout(idleTimer)
384
- if (browserIdleTtlMs <= 0) return
385
- idleTimer = setTimeout(() => shutdownBrowser(), browserIdleTtlMs)
386
- idleTimer.unref?.()
387
- }
388
-
389
- /** Start Chrome once and resolve when its DevTools pipe answers. */
390
- async function startBrowser() {
391
- if (browser !== undefined && browser.closed !== true) return browser
392
- if (chromePath === undefined) throw new Error('chrome-not-found')
393
- fs.mkdirSync(profileDir, { recursive: true })
394
- // Clean caches left behind by an interrupted previous run.
395
- pruneChromeProfile(profileDir)
396
- const child = spawn(
397
- chromePath,
398
- [
399
- '--headless=new',
400
- '--disable-gpu',
401
- '--no-first-run',
402
- '--no-default-browser-check',
403
- '--disable-background-networking',
404
- '--disable-sync',
405
- '--mute-audio',
406
- '--disk-cache-size=52428800', // hard cap: 50 MiB HTTP cache
407
- '--media-cache-size=10485760', // hard cap: 10 MiB media cache
408
- '--remote-debugging-pipe',
409
- `--user-data-dir=${profileDir}`,
410
- 'about:blank',
411
- ],
412
- { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] },
413
- )
414
- const write = /** @type {NodeJS.WritableStream} */ (child.stdio[3])
415
- const read = /** @type {NodeJS.ReadableStream} */ (child.stdio[4])
416
- if (write === null || read === null) {
417
- try {
418
- child.kill('SIGKILL')
419
- } catch {
420
- // ignore
421
- }
422
- throw new Error('chrome-pipe-unavailable')
423
- }
424
- /** @type {BrowserState} */
425
- const state = { child, write, read, pending: new Map(), nextId: 1, buffer: '', closed: false }
426
- read.on('data', (chunk) => {
427
- state.buffer += chunk.toString('utf8')
428
- const parts = state.buffer.split('\u0000')
429
- state.buffer = parts.pop() ?? ''
430
- for (const part of parts) {
431
- if (part.trim() === '') continue
432
- let message
433
- try {
434
- message = JSON.parse(part)
435
- } catch {
436
- continue
437
- }
438
- if (message.id === undefined) continue
439
- const entry = state.pending.get(message.id)
440
- if (entry === undefined) continue
441
- state.pending.delete(message.id)
442
- clearTimeout(entry.timer)
443
- if (message.error !== undefined) entry.reject(new Error(JSON.stringify(message.error)))
444
- else entry.resolve(message.result ?? message)
445
- }
446
- })
447
- child.on('error', (error) => {
448
- state.closed = true
449
- if (browser === state) browser = undefined
450
- for (const entry of state.pending.values()) {
451
- clearTimeout(entry.timer)
452
- entry.reject(error instanceof Error ? error : new Error(String(error)))
453
- }
454
- state.pending.clear()
455
- })
456
- child.on('exit', () => {
457
- state.closed = true
458
- if (browser === state) browser = undefined
459
- for (const entry of state.pending.values()) {
460
- clearTimeout(entry.timer)
461
- entry.reject(new Error('chrome-exited'))
462
- }
463
- state.pending.clear()
464
- })
465
- browser = state
466
- // handshake
467
- await cdpSend(state, 'Browser.getVersion', {}, undefined, 8000)
468
- if (options.verbose === true) logger?.info?.(`[lcsc-cn] chrome started (pid ${child.pid})`)
469
- return state
470
- }
471
-
472
- /**
473
- * Send one CDP command over the pipe.
474
- * @param {BrowserState} state
475
- * @param {string} method
476
- * @param {Record<string, unknown>} [params]
477
- * @param {string} [sessionId]
478
- * @param {number} [timeout]
479
- */
480
- function cdpSend(state, method, params = {}, sessionId, timeout = 15000) {
481
- return new Promise((resolve, reject) => {
482
- if (state.closed) {
483
- reject(new Error('chrome-closed'))
484
- return
485
- }
486
- const id = state.nextId
487
- state.nextId += 1
488
- const timer = setTimeout(() => {
489
- state.pending.delete(id)
490
- reject(new Error(`cdp-timeout:${method}`))
491
- }, timeout)
492
- timer.unref?.()
493
- state.pending.set(id, { resolve, reject, timer })
494
- const payload = { id, method, params }
495
- if (sessionId !== undefined) payload.sessionId = sessionId
496
- state.write.write(`${JSON.stringify(payload)}\u0000`)
497
- })
498
- }
499
-
500
- /** Open `url` in a new tab, wait for the rendered DOM, close the tab. */
501
- async function renderViaCdp(url, timeout = timeoutMs) {
502
- const state = await startBrowser()
503
- const deadline = Date.now() + timeout
504
- let targetId
505
- let sessionId
506
- try {
507
- const created = await cdpSend(state, 'Target.createTarget', { url: 'about:blank' }, undefined, 10000)
508
- targetId = created.targetId
509
- const attached = await cdpSend(state, 'Target.attachToTarget', { targetId, flatten: true }, undefined, 10000)
510
- sessionId = attached.sessionId
511
- await cdpSend(state, 'Page.enable', {}, sessionId, 5000)
512
- await cdpSend(state, 'Runtime.enable', {}, sessionId, 5000)
513
- await cdpSend(state, 'Page.navigate', { url }, sessionId, 10000)
514
- let html = ''
515
- while (Date.now() < deadline) {
516
- await sleep(150)
517
- try {
518
- const evaluated = await cdpSend(
519
- state,
520
- 'Runtime.evaluate',
521
- { expression: 'document.documentElement.outerHTML', returnByValue: true },
522
- sessionId,
523
- 5000,
524
- )
525
- html = typeof evaluated?.result?.value === 'string' ? evaluated.result.value : ''
526
- } catch {
527
- // navigation might still be in flight; retry until the deadline
528
- }
529
- if (html.includes('__NEXT_DATA__') && /"price"\s*:/.test(html)) return html
530
- }
531
- return html
532
- } finally {
533
- if (sessionId !== undefined && state.closed !== true) {
534
- try {
535
- await cdpSend(state, 'Target.detachFromTarget', { sessionId }, undefined, 3000)
536
- } catch {
537
- // ignore
538
- }
539
- }
540
- if (targetId !== undefined && state.closed !== true) {
541
- try {
542
- await cdpSend(state, 'Target.closeTarget', { targetId }, undefined, 3000)
543
- } catch {
544
- // ignore
545
- }
546
- }
547
- touchIdle()
548
- }
549
- }
550
-
551
- /* ------------------- legacy per-page renderer (fallback) ----------------- */
552
- /** @param {string} url @param {number} timeout @returns {Promise<string>} */
553
- function renderViaDumpDom(url, timeout = timeoutMs) {
554
- if (chromePath === undefined) return Promise.resolve('')
555
- return new Promise((resolve) => {
556
- fs.mkdirSync(legacyProfileDir, { recursive: true })
557
- pruneChromeProfile(legacyProfileDir)
558
- const args = [
559
- '--headless=new',
560
- '--disable-gpu',
561
- '--no-first-run',
562
- '--no-default-browser-check',
563
- '--disable-background-networking',
564
- '--disable-sync',
565
- '--mute-audio',
566
- '--disk-cache-size=52428800',
567
- '--media-cache-size=10485760',
568
- `--user-data-dir=${legacyProfileDir}`,
569
- '--dump-dom',
570
- '--virtual-time-budget=6000',
571
- url,
572
- ]
573
- let finished = false
574
- let stdout = ''
575
- const child = spawn(chromePath, args, { stdio: ['ignore', 'pipe', 'ignore'] })
576
- const timer = setTimeout(() => {
577
- if (finished) return
578
- finished = true
579
- try {
580
- child.kill('SIGKILL')
581
- } catch {
582
- // ignore
583
- }
584
- resolve(stdout)
585
- }, timeout)
586
- child.stdout?.on('data', (chunk) => {
587
- stdout += chunk.toString()
588
- })
589
- child.on('error', () => {
590
- if (finished) return
591
- finished = true
592
- clearTimeout(timer)
593
- resolve('')
594
- })
595
- child.on('close', () => {
596
- if (finished) return
597
- finished = true
598
- clearTimeout(timer)
599
- pruneChromeProfile(legacyProfileDir)
600
- resolve(stdout)
601
- })
602
- })
603
- }
604
-
605
- /**
606
- * Render a URL to DOM: persistent CDP browser first, dump-dom fallback.
607
- * @param {string} url
608
- * @param {number} [timeout]
609
- * @returns {Promise<string>}
610
- */
611
- async function renderHtml(url, timeout = timeoutMs) {
612
- if (chromePath === undefined) return ''
613
- if (!cdpBroken && cdpSessionErrors < 3) {
614
- try {
615
- return await renderViaCdp(url, timeout)
616
- } catch (error) {
617
- cdpSessionErrors += 1
618
- if (options.verbose === true) logger?.warn?.(`[lcsc-cn] cdp render failed: ${error instanceof Error ? error.message : String(error)}`)
619
- shutdownBrowser()
620
- if (cdpSessionErrors >= 3) cdpBroken = true
621
- }
622
- }
623
- return renderViaDumpDom(url, timeout)
624
- }
625
-
626
- /**
627
- * Resolve the domestic product id for an LCSC code.
628
- * @param {string} code
629
- * @returns {Promise<string | undefined>}
630
- */
631
- async function resolvePid(code) {
632
- for (const base of [EASYEDA_URL, LCEDA_URL]) {
633
- try {
634
- const payload = await getJson(`${base}${encodeURIComponent(code)}/components`)
635
- const result = payload?.result ?? {}
636
- const pid = result?.szlcsc?.id ?? result?.lcsc?.id
637
- if (pid !== undefined && pid !== null && String(pid) !== '') return String(pid)
638
- } catch {
639
- // try the next API
640
- }
641
- }
642
- return undefined
643
- }
644
-
645
- /** @param {string} code */
646
- function cachePath(code) {
647
- return path.join(cacheDir, `${code}.json`)
648
- }
649
-
650
- /** @param {string} code */
651
- function readCache(code) {
652
- const inMemory = memory.get(code)
653
- if (inMemory !== undefined && Date.now() - inMemory.ts < cacheTtlMs) {
654
- return { ...inMemory.value, cached: true, cache_age_s: Math.round((Date.now() - inMemory.ts) / 1000) }
655
- }
656
- try {
657
- const record = JSON.parse(fs.readFileSync(cachePath(code), 'utf8'))
658
- const fetchedAt = Date.parse(record.fetched_at)
659
- if (Number.isFinite(fetchedAt) && Date.now() - fetchedAt < cacheTtlMs) {
660
- return { ...record, cached: true, cache_age_s: Math.round((Date.now() - fetchedAt) / 1000) }
661
- }
662
- } catch {
663
- // cache miss
664
- }
665
- return undefined
666
- }
667
-
668
- /** @param {string} code @param {Record<string, unknown>} record */
669
- function writeCache(code, record) {
670
- try {
671
- fs.mkdirSync(cacheDir, { recursive: true })
672
- fs.writeFileSync(cachePath(code), JSON.stringify(record, null, 1), 'utf8')
673
- } catch {
674
- // cache write is best effort
675
- }
676
- memory.set(code, { ts: Date.now(), value: record })
677
- }
678
-
679
- /**
680
- * Query one LCSC code. Never throws; returns `{ok:false, reason}` on failure.
681
- * @param {string} code
682
- * @param {{ refresh?: boolean }} [callOptions]
683
- */
684
- async function query(code, callOptions = {}) {
685
- const normalized = String(code ?? '').trim().toUpperCase()
686
- if (normalized === '') return { ok: false, reason: 'empty-code' }
687
- if (chromePath === undefined) {
688
- return { ok: false, reason: 'chrome-not-found', tried: chromeInfo.tried, hint: chromeHintText }
689
- }
690
- queryCount += 1
691
- // Prune expired JSON cache entries lazily (about once per hour / 50 hits).
692
- if (queryCount === 1 || Date.now() - lastJsonPruneAt > 60 * 60 * 1000 || queryCount % 50 === 0) {
693
- lastJsonPruneAt = Date.now()
694
- pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
695
- }
696
- const cached = callOptions.refresh === true ? undefined : readCache(normalized)
697
- if (cached !== undefined && cached.ok !== false) {
698
- touchIdle()
699
- return cached
700
- }
701
- return withSlot(async () => {
702
- try {
703
- const pid = await resolvePid(normalized)
704
- if (pid === undefined) return { ok: false, reason: 'product-id-not-found' }
705
- const html = await renderHtml(`${ITEM_URL}${pid}.html`)
706
- if (html === '') return { ok: false, reason: 'chrome-render-empty' }
707
- const parsed = parseItemHtml(html)
708
- if (parsed === undefined || parsed.price === undefined) {
709
- return { ok: false, reason: 'page-parse-failed' }
710
- }
711
- const record = {
712
- ok: true,
713
- lcsc: normalized,
714
- product_id: pid,
715
- unit: parsed.price,
716
- currency: 'CNY',
717
- stock: parsed.stock,
718
- model: parsed.model,
719
- brand: parsed.brand,
720
- package: parsed.package,
721
- min_buy: parsed.min_buy,
722
- packing: parsed.packing,
723
- unit_name: parsed.unit,
724
- category: parsed.category,
725
- desc: parsed.desc,
726
- source: 'lcsc_cn_render',
727
- fetched_at: new Date().toISOString(),
728
- cache_age_s: 0,
729
- cached: false,
730
- }
731
- writeCache(normalized, record)
732
- return record
733
- } catch (error) {
734
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
735
- }
736
- })
737
- }
738
-
739
- return {
740
- chromePath,
741
- /** Where the binary came from and what was tried; used by the tools' hints. */
742
- chrome: { ...chromeInfo, hint: chromeHintText },
743
- available: chromePath !== undefined,
744
- cacheDir,
745
- query,
746
- resolvePid,
747
- renderHtml,
748
- clearMemory() {
749
- memory.clear()
750
- },
751
- /** Remove this adapter's on-disk caches (JSON results + Chrome profiles). */
752
- clearCache() {
753
- shutdownBrowser()
754
- try {
755
- fs.rmSync(cacheDir, { recursive: true, force: true })
756
- } catch {
757
- // best effort
758
- }
759
- memory.clear()
760
- },
761
- pruneCaches() {
762
- const json = pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
763
- pruneChromeProfile(profileDir)
764
- pruneChromeProfile(legacyProfileDir)
765
- return json
766
- },
767
- dispose() {
768
- shutdownBrowser()
769
- pruneChromeProfile(profileDir)
770
- pruneChromeProfile(legacyProfileDir)
771
- pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
772
- },
773
- }
774
- }
1
+ /**
2
+ * LCSC domestic (立创商城) CNY price/stock adapter.
3
+ *
4
+ * The domestic store sits behind an Aliyun WAF that challenges plain HTTP
5
+ * requests, but a real browser executes the challenge and then serves the
6
+ * product page with `<script id="__NEXT_DATA__">` containing the official CNY
7
+ * price and domestic stock. This module renders `item.szlcsc.com/<productId>.html`
8
+ * with the locally installed Chrome (`--headless=new --dump-dom`) and caches the
9
+ * result on disk for 24 h.
10
+ *
11
+ * Flow: LCSC code -> EasyEDA / lceda.cn product id -> Chrome render -> parse
12
+ * __NEXT_DATA__ -> cache. Every failure is returned as `{ ok: false, reason }`
13
+ * so callers can fall back to the USD estimate instead of failing the batch.
14
+ *
15
+ * Speed: a single persistent Chrome instance is kept alive and driven over the
16
+ * DevTools protocol (`--remote-debugging-pipe`), opening one tab per product.
17
+ * Cold start/warm-up costs ~4-5 s (WAF challenge), after which each product is
18
+ * ~1-2 s; 4-8 tabs in parallel warm a 28-part BOM in well under a minute.
19
+ * Chrome is started on demand, reused across calls, and auto-closed after an
20
+ * idle TTL. If the pipe/CDP path is unavailable, it falls back to the legacy
21
+ * per-page `--dump-dom` spawn (slower but dependency-free).
22
+ */
23
+
24
+ import { spawn } from 'node:child_process'
25
+ import fs from 'node:fs'
26
+ import os from 'node:os'
27
+ import path from 'node:path'
28
+
29
+ const EASYEDA_URL = 'https://easyeda.com/api/products/'
30
+ const LCEDA_URL = 'https://lceda.cn/api/products/'
31
+ const ITEM_URL = 'https://item.szlcsc.com/'
32
+
33
+ /**
34
+ * Common Chrome/Chromium locations. Windows roots are read from the
35
+ * environment so the list follows the real install (drive/ProgramFiles can be
36
+ * relocated) instead of a hard-coded path.
37
+ * @returns {string[]}
38
+ */
39
+ function chromeCandidates() {
40
+ const candidates = [
41
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
42
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
43
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
44
+ '/usr/bin/google-chrome',
45
+ '/usr/bin/google-chrome-stable',
46
+ '/usr/bin/chromium',
47
+ '/usr/bin/chromium-browser',
48
+ '/snap/bin/chromium',
49
+ ]
50
+ if (process.platform === 'win32') {
51
+ const roots = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], process.env.ProgramW6432, process.env.LOCALAPPDATA]
52
+ .filter((root) => typeof root === 'string' && root !== '')
53
+ const relatives = [
54
+ ['Google', 'Chrome', 'Application', 'chrome.exe'],
55
+ ['Chromium', 'Application', 'chrome.exe'],
56
+ ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
57
+ ]
58
+ for (const root of roots) {
59
+ for (const relative of relatives) candidates.push(path.join(root, ...relative))
60
+ }
61
+ }
62
+ return candidates
63
+ }
64
+
65
+ const DEFAULT_USER_AGENT =
66
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
67
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
68
+
69
+ /** Ali yun WAF challenge markers: page is not the product page we asked for. */
70
+ const WAF_HINT_RE = /aliyun|acw_sc__v2|_____tmd_____|验证码|captcha|challenge-platform|error code.*?5\d\d/i
71
+
72
+ /**
73
+ * True when the rendered HTML is a WAF challenge page rather than a product
74
+ * page. A page carrying `__NEXT_DATA__` is always the real one.
75
+ * @param {unknown} html
76
+ */
77
+ export function looksLikeWafChallenge(html) {
78
+ if (typeof html !== 'string' || html === '') return false
79
+ if (html.includes('__NEXT_DATA__')) return false
80
+ return WAF_HINT_RE.test(html)
81
+ }
82
+
83
+ /** One retry for transient failures; the domestic site challenges in bursts. */
84
+ const RETRY_DELAY_MS = 1500
85
+ const RETRYABLE_FAILURES = new Set(['szlcsc-waf-challenge', 'chrome-render-empty', 'page-parse-failed'])
86
+
87
+ /**
88
+ * How to fix a missing Chrome, phrased so an agent can act on it directly.
89
+ * @param {{ path?: string, source?: string, tried: string[], configPath?: string, envPath?: string }} info
90
+ * @returns {string}
91
+ */
92
+ export function chromeHint(info) {
93
+ const parts = ['未找到可用的 Chrome/Chromium,国内站人民币价与国内库存不可用(已回退为 USD 估算)。']
94
+ if (info.configPath !== undefined || info.envPath !== undefined) {
95
+ const missing = []
96
+ if (info.configPath !== undefined) missing.push(`config.chromePath=${info.configPath}`)
97
+ if (info.envPath !== undefined) missing.push(`CHROME_BIN=${info.envPath}`)
98
+ parts.push(`当前指定的路径不存在:${missing.join(',')}。`)
99
+ }
100
+ parts.push(`已尝试 ${info.tried.length} 个常见路径${info.tried.length === 0 ? '' : `(${info.tried.slice(0, 5).join(' ; ')}${info.tried.length > 5 ? ' …' : ''})`}。`)
101
+ parts.push(
102
+ '请任选其一修复后重试:① 在本插件配置里加 chromePath: "<本机 Chrome 可执行文件绝对路径>"' +
103
+ '(profile 的 cordis.patch.yml,条目 id 为 lcsc-parts);② 给宿主进程设置环境变量 CHROME_BIN。' +
104
+ '定位方法:Windows `where chrome` 或查看 %ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe;' +
105
+ 'macOS `ls /Applications | grep -i chrome`;Linux `which google-chrome chromium`。',
106
+ )
107
+ return parts.join('')
108
+ }
109
+
110
+ /**
111
+ * Locate a Chrome/Chromium binary and explain where it came from.
112
+ * Precedence: explicit config → CHROME_BIN → auto-discovery.
113
+ * @param {string} [explicit]
114
+ * @returns {{ path?: string, source?: 'config' | 'chrome_bin' | 'auto', tried: string[], configPath?: string, envPath?: string }}
115
+ */
116
+ export function findChromeInfo(explicit) {
117
+ const configPath = typeof explicit === 'string' && explicit !== '' ? explicit : undefined
118
+ if (configPath !== undefined && fs.existsSync(configPath)) return { path: configPath, source: 'config', tried: [], configPath }
119
+ const envPath = typeof process.env.CHROME_BIN === 'string' && process.env.CHROME_BIN !== '' ? process.env.CHROME_BIN : undefined
120
+ if (envPath !== undefined && fs.existsSync(envPath)) return { path: envPath, source: 'chrome_bin', tried: [], envPath }
121
+ const tried = []
122
+ for (const candidate of chromeCandidates()) {
123
+ if (tried.includes(candidate)) continue
124
+ tried.push(candidate)
125
+ if (fs.existsSync(candidate)) return { path: candidate, source: 'auto', tried, configPath, envPath }
126
+ }
127
+ return { path: undefined, source: undefined, tried, configPath, envPath }
128
+ }
129
+
130
+ /** Locate a Chrome/Chromium binary (compatibility wrapper around findChromeInfo). */
131
+ export function findChrome(explicit) {
132
+ return findChromeInfo(explicit).path
133
+ }
134
+
135
+ /** Extract the `__NEXT_DATA__` JSON from an HTML document (or undefined). */
136
+ export function extractNextData(html) {
137
+ const match = String(html ?? '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)
138
+ if (match === null) return undefined
139
+ try {
140
+ return JSON.parse(match[1])
141
+ } catch {
142
+ return undefined
143
+ }
144
+ }
145
+
146
+ /** Chrome profile subdirectories that are pure cache/garbage and can be deleted freely. */
147
+ const CHROME_PROFILE_JUNK = [
148
+ 'Default/Cache',
149
+ 'Default/Code Cache',
150
+ 'Default/GPUCache',
151
+ 'Default/DawnCache',
152
+ 'Default/DawnGraphiteCache',
153
+ 'Default/DawnWebGPUCache',
154
+ 'Default/GraphiteDawnCache',
155
+ 'Default/ShaderCache',
156
+ 'Default/Service Worker/CacheStorage',
157
+ 'Default/Service Worker/ScriptCache',
158
+ 'Default/Media Cache',
159
+ 'Default/Session Storage',
160
+ 'Default/WebStorage',
161
+ 'Crashpad',
162
+ 'ShaderCache',
163
+ 'GrShaderCache',
164
+ 'BrowserMetrics',
165
+ 'component_crx_cache',
166
+ 'extensions_crx_cache',
167
+ ]
168
+
169
+ /**
170
+ * Delete cache-only subdirectories from a Chrome profile, keeping cookies and
171
+ * preferences (the WAF challenge cookie lives in the profile).
172
+ * @param {string} profileDir
173
+ */
174
+ export function pruneChromeProfile(profileDir) {
175
+ for (const relative of CHROME_PROFILE_JUNK) {
176
+ try {
177
+ fs.rmSync(path.join(profileDir, relative), { recursive: true, force: true })
178
+ } catch {
179
+ // best effort
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Prune the JSON result cache: drop entries older than `ttlMs`, then keep at
186
+ * most `maxEntries` newest entries. This keeps `~/.cache/dsh-plugin-lcsc/cn`
187
+ * bounded instead of accumulating one file per part ever queried.
188
+ * @param {string} cacheDir
189
+ * @param {{ ttlMs?: number, maxEntries?: number }} [options]
190
+ * @returns {{ removed: number, kept: number }}
191
+ */
192
+ export function pruneJsonCache(cacheDir, options = {}) {
193
+ const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : 24 * 60 * 60 * 1000
194
+ const maxEntries = Number.isFinite(options.maxEntries) ? Math.max(1, Math.floor(options.maxEntries)) : 800
195
+ let names
196
+ try {
197
+ names = fs.readdirSync(cacheDir).filter((name) => name.endsWith('.json'))
198
+ } catch {
199
+ return { removed: 0, kept: 0 }
200
+ }
201
+ const now = Date.now()
202
+ /** @type {{ file: string, fetchedAt: number }[]} */
203
+ const alive = []
204
+ let removed = 0
205
+ for (const name of names) {
206
+ const file = path.join(cacheDir, name)
207
+ let record
208
+ try {
209
+ record = JSON.parse(fs.readFileSync(file, 'utf8'))
210
+ } catch {
211
+ record = undefined
212
+ }
213
+ const fetchedAt = record === undefined ? Number.NaN : Date.parse(record.fetched_at)
214
+ if (!Number.isFinite(fetchedAt) || now - fetchedAt > ttlMs) {
215
+ try {
216
+ fs.rmSync(file, { force: true })
217
+ removed += 1
218
+ } catch {
219
+ // best effort
220
+ }
221
+ continue
222
+ }
223
+ alive.push({ file, fetchedAt })
224
+ }
225
+ if (alive.length > maxEntries) {
226
+ alive.sort((left, right) => left.fetchedAt - right.fetchedAt)
227
+ for (const entry of alive.slice(0, alive.length - maxEntries)) {
228
+ try {
229
+ fs.rmSync(entry.file, { force: true })
230
+ removed += 1
231
+ } catch {
232
+ // best effort
233
+ }
234
+ }
235
+ return { removed, kept: maxEntries }
236
+ }
237
+ return { removed, kept: alive.length }
238
+ }
239
+
240
+ /**
241
+ * Parse a rendered `item.szlcsc.com` page into the official domestic record.
242
+ * @param {string} html
243
+ * @returns {{ price?: number, model?: string, brand?: string, package?: string, stock?: number, min_buy?: number, packing?: string, unit?: string, category?: string, desc?: string, product_id?: string } | undefined}
244
+ */
245
+ export function parseItemHtml(html) {
246
+ const text = String(html ?? '')
247
+ const nextData = extractNextData(text)
248
+ if (nextData === undefined) {
249
+ // JSON-LD fallback for pages that omit __NEXT_DATA__.
250
+ const fallback = text.match(/"priceCurrency":"CNY","price":([0-9.]+)/)
251
+ if (fallback === null) return undefined
252
+ return { price: Number(fallback[1]) }
253
+ }
254
+ const pageProps = nextData?.props?.pageProps ?? {}
255
+ const webData = pageProps.webData ?? {}
256
+ const productRecord = webData.productRecord ?? {}
257
+ const price = Number(pageProps.price)
258
+ const result = {
259
+ price: Number.isFinite(price) ? price : undefined,
260
+ model: stringOrUndefined(productRecord.productModel),
261
+ brand: stringOrUndefined(productRecord.productGradePlateName),
262
+ package: stringOrUndefined(productRecord.encapsulationModel),
263
+ stock: integerOrUndefined(productRecord.stockNumber),
264
+ min_buy: integerOrUndefined(productRecord.minBuyNumber),
265
+ packing: stringOrUndefined(productRecord.productArrange),
266
+ unit: stringOrUndefined(productRecord.productUnit),
267
+ category: stringOrUndefined(productRecord.productType),
268
+ desc: stringOrUndefined(productRecord.productName) ?? stringOrUndefined(productRecord.remark),
269
+ product_id: productRecord.productId ?? productRecord.productCode,
270
+ }
271
+ if (result.price === undefined) {
272
+ const fallback = text.match(/"priceCurrency":"CNY","price":([0-9.]+)/)
273
+ if (fallback !== null) result.price = Number(fallback[1])
274
+ }
275
+ return result
276
+ }
277
+
278
+ /** @param {unknown} value @returns {string | undefined} */
279
+ function stringOrUndefined(value) {
280
+ if (typeof value !== 'string') return undefined
281
+ const text = value.trim()
282
+ return text === '' ? undefined : text
283
+ }
284
+
285
+ /** @param {unknown} value @returns {number | undefined} */
286
+ function integerOrUndefined(value) {
287
+ const number = typeof value === 'string' ? Number(value) : value
288
+ return typeof number === 'number' && Number.isInteger(number) ? number : undefined
289
+ }
290
+
291
+ /** @param {number} ms */
292
+ function sleep(ms) {
293
+ return new Promise((resolve) => setTimeout(resolve, ms))
294
+ }
295
+
296
+ /**
297
+ * Create the domestic-CNY adapter.
298
+ * @param {{
299
+ * chromePath?: string,
300
+ * cacheDir?: string,
301
+ * cacheTtlMs?: number,
302
+ * timeoutMs?: number,
303
+ * concurrency?: number,
304
+ * browserIdleTtlMs?: number,
305
+ * verbose?: boolean,
306
+ * logger?: { warn?: (message: string) => void, info?: (message: string) => void },
307
+ * }} [options]
308
+ */
309
+ export function createCnAdapter(options = {}) {
310
+ const chromeInfo = findChromeInfo(options.chromePath)
311
+ const chromePath = chromeInfo.path
312
+ const chromeHintText = chromePath === undefined ? chromeHint(chromeInfo) : undefined
313
+ const cacheDir =
314
+ typeof options.cacheDir === 'string' && options.cacheDir !== ''
315
+ ? options.cacheDir
316
+ : path.join(os.homedir(), '.cache', 'dsh-plugin-lcsc', 'cn')
317
+ const profileDir = path.join(cacheDir, 'chrome-profile')
318
+ const cacheTtlMs = Number.isFinite(options.cacheTtlMs) ? options.cacheTtlMs : 24 * 60 * 60 * 1000
319
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 30000
320
+ const concurrency = Math.max(1, Math.min(8, Math.floor(options.concurrency ?? 2)))
321
+ const browserIdleTtlMs = Number.isFinite(options.browserIdleTtlMs) ? options.browserIdleTtlMs : 120000
322
+ const cacheMaxEntries = Number.isFinite(options.cacheMaxEntries) ? Math.max(1, Math.floor(options.cacheMaxEntries)) : 800
323
+ const legacyProfileDir = path.join(cacheDir, 'legacy-profile')
324
+ let lastJsonPruneAt = 0
325
+ let queryCount = 0
326
+ const logger = options.logger
327
+ const memory = new Map()
328
+ let active = 0
329
+ /** @type {(() => void)[]} */
330
+ const waiting = []
331
+
332
+ /** Run `fn` under the adapter concurrency limit. */
333
+ async function withSlot(fn) {
334
+ while (active >= concurrency) {
335
+ await new Promise((resolve) => waiting.push(resolve))
336
+ }
337
+ active += 1
338
+ try {
339
+ return await fn()
340
+ } finally {
341
+ active -= 1
342
+ const next = waiting.shift()
343
+ if (next !== undefined) next()
344
+ }
345
+ }
346
+
347
+ /** HTTP GET JSON using the global fetch (host Node >= 20). */
348
+ async function getJson(url) {
349
+ const response = await fetch(url, {
350
+ headers: { 'User-Agent': DEFAULT_USER_AGENT, Accept: 'application/json' },
351
+ signal: AbortSignal.timeout(timeoutMs),
352
+ })
353
+ if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
354
+ return response.json()
355
+ }
356
+
357
+ /* ------------------------- persistent Chrome (CDP) ----------------------- */
358
+ /**
359
+ * @typedef {{
360
+ * child: import('node:child_process').ChildProcess,
361
+ * write: NodeJS.WritableStream,
362
+ * read: NodeJS.ReadableStream,
363
+ * pending: Map<number, { resolve: (value: any) => void, reject: (reason: Error) => void, timer: NodeJS.Timeout }>,
364
+ * nextId: number,
365
+ * buffer: string,
366
+ * closed: boolean,
367
+ * }} BrowserState
368
+ */
369
+ /** @type {BrowserState | undefined} */
370
+ let browser
371
+ /** @type {NodeJS.Timeout | undefined} */
372
+ let idleTimer
373
+ let cdpBroken = false
374
+ let cdpSessionErrors = 0
375
+
376
+ function shutdownBrowser() {
377
+ if (idleTimer !== undefined) {
378
+ clearTimeout(idleTimer)
379
+ idleTimer = undefined
380
+ }
381
+ const state = browser
382
+ browser = undefined
383
+ if (state === undefined) return
384
+ state.closed = true
385
+ for (const entry of state.pending.values()) {
386
+ clearTimeout(entry.timer)
387
+ entry.reject(new Error('chrome-shutdown'))
388
+ }
389
+ state.pending.clear()
390
+ try {
391
+ state.child.kill('SIGKILL')
392
+ } catch {
393
+ // already gone
394
+ }
395
+ // Keep cookies for the WAF challenge, drop HTTP/Code caches so the profile
396
+ // does not silently grow.
397
+ pruneChromeProfile(profileDir)
398
+ }
399
+
400
+ function touchIdle() {
401
+ if (idleTimer !== undefined) clearTimeout(idleTimer)
402
+ if (browserIdleTtlMs <= 0) return
403
+ idleTimer = setTimeout(() => shutdownBrowser(), browserIdleTtlMs)
404
+ idleTimer.unref?.()
405
+ }
406
+
407
+ /** Start Chrome once and resolve when its DevTools pipe answers. */
408
+ async function startBrowser() {
409
+ if (browser !== undefined && browser.closed !== true) return browser
410
+ if (chromePath === undefined) throw new Error('chrome-not-found')
411
+ fs.mkdirSync(profileDir, { recursive: true })
412
+ // Clean caches left behind by an interrupted previous run.
413
+ pruneChromeProfile(profileDir)
414
+ const child = spawn(
415
+ chromePath,
416
+ [
417
+ '--headless=new',
418
+ '--disable-gpu',
419
+ '--no-first-run',
420
+ '--no-default-browser-check',
421
+ '--disable-background-networking',
422
+ '--disable-sync',
423
+ '--mute-audio',
424
+ '--disk-cache-size=52428800', // hard cap: 50 MiB HTTP cache
425
+ '--media-cache-size=10485760', // hard cap: 10 MiB media cache
426
+ '--remote-debugging-pipe',
427
+ `--user-data-dir=${profileDir}`,
428
+ 'about:blank',
429
+ ],
430
+ { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] },
431
+ )
432
+ const write = /** @type {NodeJS.WritableStream} */ (child.stdio[3])
433
+ const read = /** @type {NodeJS.ReadableStream} */ (child.stdio[4])
434
+ if (write === null || read === null) {
435
+ try {
436
+ child.kill('SIGKILL')
437
+ } catch {
438
+ // ignore
439
+ }
440
+ throw new Error('chrome-pipe-unavailable')
441
+ }
442
+ /** @type {BrowserState} */
443
+ const state = { child, write, read, pending: new Map(), nextId: 1, buffer: '', closed: false }
444
+ read.on('data', (chunk) => {
445
+ state.buffer += chunk.toString('utf8')
446
+ const parts = state.buffer.split('\u0000')
447
+ state.buffer = parts.pop() ?? ''
448
+ for (const part of parts) {
449
+ if (part.trim() === '') continue
450
+ let message
451
+ try {
452
+ message = JSON.parse(part)
453
+ } catch {
454
+ continue
455
+ }
456
+ if (message.id === undefined) continue
457
+ const entry = state.pending.get(message.id)
458
+ if (entry === undefined) continue
459
+ state.pending.delete(message.id)
460
+ clearTimeout(entry.timer)
461
+ if (message.error !== undefined) entry.reject(new Error(JSON.stringify(message.error)))
462
+ else entry.resolve(message.result ?? message)
463
+ }
464
+ })
465
+ child.on('error', (error) => {
466
+ state.closed = true
467
+ if (browser === state) browser = undefined
468
+ for (const entry of state.pending.values()) {
469
+ clearTimeout(entry.timer)
470
+ entry.reject(error instanceof Error ? error : new Error(String(error)))
471
+ }
472
+ state.pending.clear()
473
+ })
474
+ child.on('exit', () => {
475
+ state.closed = true
476
+ if (browser === state) browser = undefined
477
+ for (const entry of state.pending.values()) {
478
+ clearTimeout(entry.timer)
479
+ entry.reject(new Error('chrome-exited'))
480
+ }
481
+ state.pending.clear()
482
+ })
483
+ browser = state
484
+ // handshake
485
+ await cdpSend(state, 'Browser.getVersion', {}, undefined, 8000)
486
+ if (options.verbose === true) logger?.info?.(`[lcsc-cn] chrome started (pid ${child.pid})`)
487
+ return state
488
+ }
489
+
490
+ /**
491
+ * Send one CDP command over the pipe.
492
+ * @param {BrowserState} state
493
+ * @param {string} method
494
+ * @param {Record<string, unknown>} [params]
495
+ * @param {string} [sessionId]
496
+ * @param {number} [timeout]
497
+ */
498
+ function cdpSend(state, method, params = {}, sessionId, timeout = 15000) {
499
+ return new Promise((resolve, reject) => {
500
+ if (state.closed) {
501
+ reject(new Error('chrome-closed'))
502
+ return
503
+ }
504
+ const id = state.nextId
505
+ state.nextId += 1
506
+ const timer = setTimeout(() => {
507
+ state.pending.delete(id)
508
+ reject(new Error(`cdp-timeout:${method}`))
509
+ }, timeout)
510
+ timer.unref?.()
511
+ state.pending.set(id, { resolve, reject, timer })
512
+ const payload = { id, method, params }
513
+ if (sessionId !== undefined) payload.sessionId = sessionId
514
+ state.write.write(`${JSON.stringify(payload)}\u0000`)
515
+ })
516
+ }
517
+
518
+ /** Open `url` in a new tab, wait for the rendered DOM, close the tab. */
519
+ async function renderViaCdp(url, timeout = timeoutMs) {
520
+ const state = await startBrowser()
521
+ const deadline = Date.now() + timeout
522
+ let targetId
523
+ let sessionId
524
+ try {
525
+ const created = await cdpSend(state, 'Target.createTarget', { url: 'about:blank' }, undefined, 10000)
526
+ targetId = created.targetId
527
+ const attached = await cdpSend(state, 'Target.attachToTarget', { targetId, flatten: true }, undefined, 10000)
528
+ sessionId = attached.sessionId
529
+ await cdpSend(state, 'Page.enable', {}, sessionId, 5000)
530
+ await cdpSend(state, 'Runtime.enable', {}, sessionId, 5000)
531
+ await cdpSend(state, 'Page.navigate', { url }, sessionId, 10000)
532
+ let html = ''
533
+ while (Date.now() < deadline) {
534
+ await sleep(150)
535
+ try {
536
+ const evaluated = await cdpSend(
537
+ state,
538
+ 'Runtime.evaluate',
539
+ { expression: 'document.documentElement.outerHTML', returnByValue: true },
540
+ sessionId,
541
+ 5000,
542
+ )
543
+ html = typeof evaluated?.result?.value === 'string' ? evaluated.result.value : ''
544
+ } catch {
545
+ // navigation might still be in flight; retry until the deadline
546
+ }
547
+ if (html.includes('__NEXT_DATA__') && /"price"\s*:/.test(html)) return html
548
+ }
549
+ return html
550
+ } finally {
551
+ if (sessionId !== undefined && state.closed !== true) {
552
+ try {
553
+ await cdpSend(state, 'Target.detachFromTarget', { sessionId }, undefined, 3000)
554
+ } catch {
555
+ // ignore
556
+ }
557
+ }
558
+ if (targetId !== undefined && state.closed !== true) {
559
+ try {
560
+ await cdpSend(state, 'Target.closeTarget', { targetId }, undefined, 3000)
561
+ } catch {
562
+ // ignore
563
+ }
564
+ }
565
+ touchIdle()
566
+ }
567
+ }
568
+
569
+ /* ------------------- legacy per-page renderer (fallback) ----------------- */
570
+ /** @param {string} url @param {number} timeout @returns {Promise<string>} */
571
+ function renderViaDumpDom(url, timeout = timeoutMs) {
572
+ if (chromePath === undefined) return Promise.resolve('')
573
+ return new Promise((resolve) => {
574
+ fs.mkdirSync(legacyProfileDir, { recursive: true })
575
+ pruneChromeProfile(legacyProfileDir)
576
+ const args = [
577
+ '--headless=new',
578
+ '--disable-gpu',
579
+ '--no-first-run',
580
+ '--no-default-browser-check',
581
+ '--disable-background-networking',
582
+ '--disable-sync',
583
+ '--mute-audio',
584
+ '--disk-cache-size=52428800',
585
+ '--media-cache-size=10485760',
586
+ `--user-data-dir=${legacyProfileDir}`,
587
+ '--dump-dom',
588
+ '--virtual-time-budget=6000',
589
+ url,
590
+ ]
591
+ let finished = false
592
+ let stdout = ''
593
+ const child = spawn(chromePath, args, { stdio: ['ignore', 'pipe', 'ignore'] })
594
+ const timer = setTimeout(() => {
595
+ if (finished) return
596
+ finished = true
597
+ try {
598
+ child.kill('SIGKILL')
599
+ } catch {
600
+ // ignore
601
+ }
602
+ resolve(stdout)
603
+ }, timeout)
604
+ child.stdout?.on('data', (chunk) => {
605
+ stdout += chunk.toString()
606
+ })
607
+ child.on('error', () => {
608
+ if (finished) return
609
+ finished = true
610
+ clearTimeout(timer)
611
+ resolve('')
612
+ })
613
+ child.on('close', () => {
614
+ if (finished) return
615
+ finished = true
616
+ clearTimeout(timer)
617
+ pruneChromeProfile(legacyProfileDir)
618
+ resolve(stdout)
619
+ })
620
+ })
621
+ }
622
+
623
+ /**
624
+ * Render a URL to DOM: persistent CDP browser first, dump-dom fallback.
625
+ * @param {string} url
626
+ * @param {number} [timeout]
627
+ * @returns {Promise<string>}
628
+ */
629
+ async function renderHtml(url, timeout = timeoutMs) {
630
+ if (chromePath === undefined) return ''
631
+ if (!cdpBroken && cdpSessionErrors < 3) {
632
+ try {
633
+ return await renderViaCdp(url, timeout)
634
+ } catch (error) {
635
+ cdpSessionErrors += 1
636
+ if (options.verbose === true) logger?.warn?.(`[lcsc-cn] cdp render failed: ${error instanceof Error ? error.message : String(error)}`)
637
+ shutdownBrowser()
638
+ if (cdpSessionErrors >= 3) cdpBroken = true
639
+ }
640
+ }
641
+ return renderViaDumpDom(url, timeout)
642
+ }
643
+
644
+ /**
645
+ * Resolve the domestic product id for an LCSC code.
646
+ * @param {string} code
647
+ * @returns {Promise<string | undefined>}
648
+ */
649
+ async function resolvePid(code) {
650
+ for (const base of [EASYEDA_URL, LCEDA_URL]) {
651
+ try {
652
+ const payload = await getJson(`${base}${encodeURIComponent(code)}/components`)
653
+ const result = payload?.result ?? {}
654
+ const pid = result?.szlcsc?.id ?? result?.lcsc?.id
655
+ if (pid !== undefined && pid !== null && String(pid) !== '') return String(pid)
656
+ } catch {
657
+ // try the next API
658
+ }
659
+ }
660
+ return undefined
661
+ }
662
+
663
+ /** @param {string} code */
664
+ function cachePath(code) {
665
+ return path.join(cacheDir, `${code}.json`)
666
+ }
667
+
668
+ /** @param {string} code */
669
+ function readCache(code) {
670
+ const inMemory = memory.get(code)
671
+ if (inMemory !== undefined && Date.now() - inMemory.ts < cacheTtlMs) {
672
+ return { ...inMemory.value, cached: true, cache_age_s: Math.round((Date.now() - inMemory.ts) / 1000) }
673
+ }
674
+ try {
675
+ const record = JSON.parse(fs.readFileSync(cachePath(code), 'utf8'))
676
+ const fetchedAt = Date.parse(record.fetched_at)
677
+ if (Number.isFinite(fetchedAt) && Date.now() - fetchedAt < cacheTtlMs) {
678
+ return { ...record, cached: true, cache_age_s: Math.round((Date.now() - fetchedAt) / 1000) }
679
+ }
680
+ } catch {
681
+ // cache miss
682
+ }
683
+ return undefined
684
+ }
685
+
686
+ /** @param {string} code @param {Record<string, unknown>} record */
687
+ function writeCache(code, record) {
688
+ try {
689
+ fs.mkdirSync(cacheDir, { recursive: true })
690
+ fs.writeFileSync(cachePath(code), JSON.stringify(record, null, 1), 'utf8')
691
+ } catch {
692
+ // cache write is best effort
693
+ }
694
+ memory.set(code, { ts: Date.now(), value: record })
695
+ }
696
+
697
+ /**
698
+ * Query one LCSC code. Never throws; returns `{ok:false, reason}` on failure.
699
+ * @param {string} code
700
+ * @param {{ refresh?: boolean }} [callOptions]
701
+ */
702
+ async function query(code, callOptions = {}) {
703
+ const normalized = String(code ?? '').trim().toUpperCase()
704
+ if (normalized === '') return { ok: false, reason: 'empty-code' }
705
+ if (chromePath === undefined) {
706
+ return { ok: false, reason: 'chrome-not-found', tried: chromeInfo.tried, hint: chromeHintText }
707
+ }
708
+ queryCount += 1
709
+ // Prune expired JSON cache entries lazily (about once per hour / 50 hits).
710
+ if (queryCount === 1 || Date.now() - lastJsonPruneAt > 60 * 60 * 1000 || queryCount % 50 === 0) {
711
+ lastJsonPruneAt = Date.now()
712
+ pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
713
+ }
714
+ const cached = callOptions.refresh === true ? undefined : readCache(normalized)
715
+ if (cached !== undefined && cached.ok !== false) {
716
+ touchIdle()
717
+ return cached
718
+ }
719
+ return withSlot(async () => {
720
+ /** @type {{ ok: false, reason: string, attempts: number } | undefined} */
721
+ let failure
722
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
723
+ if (attempt > 1) await sleep(RETRY_DELAY_MS)
724
+ try {
725
+ const pid = await resolvePid(normalized)
726
+ if (pid === undefined) return { ok: false, reason: 'product-id-not-found', attempts: attempt }
727
+ const html = await renderHtml(`${ITEM_URL}${pid}.html`)
728
+ if (html === '') {
729
+ failure = { ok: false, reason: 'chrome-render-empty', attempts: attempt }
730
+ continue
731
+ }
732
+ if (looksLikeWafChallenge(html)) {
733
+ failure = { ok: false, reason: 'szlcsc-waf-challenge', attempts: attempt }
734
+ continue
735
+ }
736
+ const parsed = parseItemHtml(html)
737
+ if (parsed === undefined || parsed.price === undefined) {
738
+ failure = { ok: false, reason: 'page-parse-failed', attempts: attempt }
739
+ continue
740
+ }
741
+ const record = {
742
+ ok: true,
743
+ lcsc: normalized,
744
+ product_id: pid,
745
+ unit: parsed.price,
746
+ currency: 'CNY',
747
+ stock: parsed.stock,
748
+ model: parsed.model,
749
+ brand: parsed.brand,
750
+ package: parsed.package,
751
+ min_buy: parsed.min_buy,
752
+ packing: parsed.packing,
753
+ unit_name: parsed.unit,
754
+ category: parsed.category,
755
+ desc: parsed.desc,
756
+ source: 'lcsc_cn_render',
757
+ fetched_at: new Date().toISOString(),
758
+ cache_age_s: 0,
759
+ cached: false,
760
+ }
761
+ writeCache(normalized, record)
762
+ return record
763
+ } catch (error) {
764
+ const reason = error instanceof Error ? error.message : String(error)
765
+ failure = { ok: false, reason, attempts: attempt }
766
+ if (!RETRYABLE_FAILURES.has(reason)) return failure
767
+ }
768
+ }
769
+ return failure ?? { ok: false, reason: 'cn-query-failed', attempts: 2 }
770
+ })
771
+ }
772
+
773
+ return {
774
+ chromePath,
775
+ /** Where the binary came from and what was tried; used by the tools' hints. */
776
+ chrome: { ...chromeInfo, hint: chromeHintText },
777
+ available: chromePath !== undefined,
778
+ cacheDir,
779
+ query,
780
+ resolvePid,
781
+ renderHtml,
782
+ clearMemory() {
783
+ memory.clear()
784
+ },
785
+ /** Remove this adapter's on-disk caches (JSON results + Chrome profiles). */
786
+ clearCache() {
787
+ shutdownBrowser()
788
+ try {
789
+ fs.rmSync(cacheDir, { recursive: true, force: true })
790
+ } catch {
791
+ // best effort
792
+ }
793
+ memory.clear()
794
+ },
795
+ pruneCaches() {
796
+ const json = pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
797
+ pruneChromeProfile(profileDir)
798
+ pruneChromeProfile(legacyProfileDir)
799
+ return json
800
+ },
801
+ dispose() {
802
+ shutdownBrowser()
803
+ pruneChromeProfile(profileDir)
804
+ pruneChromeProfile(legacyProfileDir)
805
+ pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
806
+ },
807
+ }
808
+ }