dsh-plugin-lcsc 0.3.2 → 0.3.3

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,707 +1,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
- const CHROME_CANDIDATES = [
34
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
35
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
36
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
37
- '/usr/bin/google-chrome',
38
- '/usr/bin/chromium',
39
- '/usr/bin/chromium-browser',
40
- ]
41
-
42
- const DEFAULT_USER_AGENT =
43
- 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
44
- '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
45
-
46
- /** Locate a Chrome/Chromium binary. `explicit` wins (e.g. config.chromePath). */
47
- export function findChrome(explicit) {
48
- if (typeof explicit === 'string' && explicit !== '' && fs.existsSync(explicit)) return explicit
49
- const env = process.env.CHROME_BIN
50
- if (typeof env === 'string' && env !== '' && fs.existsSync(env)) return env
51
- for (const candidate of CHROME_CANDIDATES) {
52
- if (fs.existsSync(candidate)) return candidate
53
- }
54
- return undefined
55
- }
56
-
57
- /** Extract the `__NEXT_DATA__` JSON from an HTML document (or undefined). */
58
- export function extractNextData(html) {
59
- const match = String(html ?? '').match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/)
60
- if (match === null) return undefined
61
- try {
62
- return JSON.parse(match[1])
63
- } catch {
64
- return undefined
65
- }
66
- }
67
-
68
- /** Chrome profile subdirectories that are pure cache/garbage and can be deleted freely. */
69
- const CHROME_PROFILE_JUNK = [
70
- 'Default/Cache',
71
- 'Default/Code Cache',
72
- 'Default/GPUCache',
73
- 'Default/DawnCache',
74
- 'Default/DawnGraphiteCache',
75
- 'Default/DawnWebGPUCache',
76
- 'Default/GraphiteDawnCache',
77
- 'Default/ShaderCache',
78
- 'Default/Service Worker/CacheStorage',
79
- 'Default/Service Worker/ScriptCache',
80
- 'Default/Media Cache',
81
- 'Default/Session Storage',
82
- 'Default/WebStorage',
83
- 'Crashpad',
84
- 'ShaderCache',
85
- 'GrShaderCache',
86
- 'BrowserMetrics',
87
- 'component_crx_cache',
88
- 'extensions_crx_cache',
89
- ]
90
-
91
- /**
92
- * Delete cache-only subdirectories from a Chrome profile, keeping cookies and
93
- * preferences (the WAF challenge cookie lives in the profile).
94
- * @param {string} profileDir
95
- */
96
- export function pruneChromeProfile(profileDir) {
97
- for (const relative of CHROME_PROFILE_JUNK) {
98
- try {
99
- fs.rmSync(path.join(profileDir, relative), { recursive: true, force: true })
100
- } catch {
101
- // best effort
102
- }
103
- }
104
- }
105
-
106
- /**
107
- * Prune the JSON result cache: drop entries older than `ttlMs`, then keep at
108
- * most `maxEntries` newest entries. This keeps `~/.cache/dsh-plugin-lcsc/cn`
109
- * bounded instead of accumulating one file per part ever queried.
110
- * @param {string} cacheDir
111
- * @param {{ ttlMs?: number, maxEntries?: number }} [options]
112
- * @returns {{ removed: number, kept: number }}
113
- */
114
- export function pruneJsonCache(cacheDir, options = {}) {
115
- const ttlMs = Number.isFinite(options.ttlMs) ? options.ttlMs : 24 * 60 * 60 * 1000
116
- const maxEntries = Number.isFinite(options.maxEntries) ? Math.max(1, Math.floor(options.maxEntries)) : 800
117
- let names
118
- try {
119
- names = fs.readdirSync(cacheDir).filter((name) => name.endsWith('.json'))
120
- } catch {
121
- return { removed: 0, kept: 0 }
122
- }
123
- const now = Date.now()
124
- /** @type {{ file: string, fetchedAt: number }[]} */
125
- const alive = []
126
- let removed = 0
127
- for (const name of names) {
128
- const file = path.join(cacheDir, name)
129
- let record
130
- try {
131
- record = JSON.parse(fs.readFileSync(file, 'utf8'))
132
- } catch {
133
- record = undefined
134
- }
135
- const fetchedAt = record === undefined ? Number.NaN : Date.parse(record.fetched_at)
136
- if (!Number.isFinite(fetchedAt) || now - fetchedAt > ttlMs) {
137
- try {
138
- fs.rmSync(file, { force: true })
139
- removed += 1
140
- } catch {
141
- // best effort
142
- }
143
- continue
144
- }
145
- alive.push({ file, fetchedAt })
146
- }
147
- if (alive.length > maxEntries) {
148
- alive.sort((left, right) => left.fetchedAt - right.fetchedAt)
149
- for (const entry of alive.slice(0, alive.length - maxEntries)) {
150
- try {
151
- fs.rmSync(entry.file, { force: true })
152
- removed += 1
153
- } catch {
154
- // best effort
155
- }
156
- }
157
- return { removed, kept: maxEntries }
158
- }
159
- return { removed, kept: alive.length }
160
- }
161
-
162
- /**
163
- * Parse a rendered `item.szlcsc.com` page into the official domestic record.
164
- * @param {string} html
165
- * @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}
166
- */
167
- export function parseItemHtml(html) {
168
- const text = String(html ?? '')
169
- const nextData = extractNextData(text)
170
- if (nextData === undefined) {
171
- // JSON-LD fallback for pages that omit __NEXT_DATA__.
172
- const fallback = text.match(/"priceCurrency":"CNY","price":([0-9.]+)/)
173
- if (fallback === null) return undefined
174
- return { price: Number(fallback[1]) }
175
- }
176
- const pageProps = nextData?.props?.pageProps ?? {}
177
- const webData = pageProps.webData ?? {}
178
- const productRecord = webData.productRecord ?? {}
179
- const price = Number(pageProps.price)
180
- const result = {
181
- price: Number.isFinite(price) ? price : undefined,
182
- model: stringOrUndefined(productRecord.productModel),
183
- brand: stringOrUndefined(productRecord.productGradePlateName),
184
- package: stringOrUndefined(productRecord.encapsulationModel),
185
- stock: integerOrUndefined(productRecord.stockNumber),
186
- min_buy: integerOrUndefined(productRecord.minBuyNumber),
187
- packing: stringOrUndefined(productRecord.productArrange),
188
- unit: stringOrUndefined(productRecord.productUnit),
189
- category: stringOrUndefined(productRecord.productType),
190
- desc: stringOrUndefined(productRecord.productName) ?? stringOrUndefined(productRecord.remark),
191
- product_id: productRecord.productId ?? productRecord.productCode,
192
- }
193
- if (result.price === undefined) {
194
- const fallback = text.match(/"priceCurrency":"CNY","price":([0-9.]+)/)
195
- if (fallback !== null) result.price = Number(fallback[1])
196
- }
197
- return result
198
- }
199
-
200
- /** @param {unknown} value @returns {string | undefined} */
201
- function stringOrUndefined(value) {
202
- if (typeof value !== 'string') return undefined
203
- const text = value.trim()
204
- return text === '' ? undefined : text
205
- }
206
-
207
- /** @param {unknown} value @returns {number | undefined} */
208
- function integerOrUndefined(value) {
209
- const number = typeof value === 'string' ? Number(value) : value
210
- return typeof number === 'number' && Number.isInteger(number) ? number : undefined
211
- }
212
-
213
- /** @param {number} ms */
214
- function sleep(ms) {
215
- return new Promise((resolve) => setTimeout(resolve, ms))
216
- }
217
-
218
- /**
219
- * Create the domestic-CNY adapter.
220
- * @param {{
221
- * chromePath?: string,
222
- * cacheDir?: string,
223
- * cacheTtlMs?: number,
224
- * timeoutMs?: number,
225
- * concurrency?: number,
226
- * browserIdleTtlMs?: number,
227
- * verbose?: boolean,
228
- * logger?: { warn?: (message: string) => void, info?: (message: string) => void },
229
- * }} [options]
230
- */
231
- export function createCnAdapter(options = {}) {
232
- const chromePath = findChrome(options.chromePath)
233
- const cacheDir =
234
- typeof options.cacheDir === 'string' && options.cacheDir !== ''
235
- ? options.cacheDir
236
- : path.join(os.homedir(), '.cache', 'dsh-plugin-lcsc', 'cn')
237
- const profileDir = path.join(cacheDir, 'chrome-profile')
238
- const cacheTtlMs = Number.isFinite(options.cacheTtlMs) ? options.cacheTtlMs : 24 * 60 * 60 * 1000
239
- const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 30000
240
- const concurrency = Math.max(1, Math.min(8, Math.floor(options.concurrency ?? 4)))
241
- const browserIdleTtlMs = Number.isFinite(options.browserIdleTtlMs) ? options.browserIdleTtlMs : 120000
242
- const cacheMaxEntries = Number.isFinite(options.cacheMaxEntries) ? Math.max(1, Math.floor(options.cacheMaxEntries)) : 800
243
- const legacyProfileDir = path.join(cacheDir, 'legacy-profile')
244
- let lastJsonPruneAt = 0
245
- let queryCount = 0
246
- const logger = options.logger
247
- const memory = new Map()
248
- let active = 0
249
- /** @type {(() => void)[]} */
250
- const waiting = []
251
-
252
- /** Run `fn` under the adapter concurrency limit. */
253
- async function withSlot(fn) {
254
- while (active >= concurrency) {
255
- await new Promise((resolve) => waiting.push(resolve))
256
- }
257
- active += 1
258
- try {
259
- return await fn()
260
- } finally {
261
- active -= 1
262
- const next = waiting.shift()
263
- if (next !== undefined) next()
264
- }
265
- }
266
-
267
- /** HTTP GET JSON using the global fetch (host Node >= 20). */
268
- async function getJson(url) {
269
- const response = await fetch(url, {
270
- headers: { 'User-Agent': DEFAULT_USER_AGENT, Accept: 'application/json' },
271
- signal: AbortSignal.timeout(timeoutMs),
272
- })
273
- if (!response.ok) throw new Error(`HTTP ${response.status} for ${url}`)
274
- return response.json()
275
- }
276
-
277
- /* ------------------------- persistent Chrome (CDP) ----------------------- */
278
- /**
279
- * @typedef {{
280
- * child: import('node:child_process').ChildProcess,
281
- * write: NodeJS.WritableStream,
282
- * read: NodeJS.ReadableStream,
283
- * pending: Map<number, { resolve: (value: any) => void, reject: (reason: Error) => void, timer: NodeJS.Timeout }>,
284
- * nextId: number,
285
- * buffer: string,
286
- * closed: boolean,
287
- * }} BrowserState
288
- */
289
- /** @type {BrowserState | undefined} */
290
- let browser
291
- /** @type {NodeJS.Timeout | undefined} */
292
- let idleTimer
293
- let cdpBroken = false
294
- let cdpSessionErrors = 0
295
-
296
- function shutdownBrowser() {
297
- if (idleTimer !== undefined) {
298
- clearTimeout(idleTimer)
299
- idleTimer = undefined
300
- }
301
- const state = browser
302
- browser = undefined
303
- if (state === undefined) return
304
- state.closed = true
305
- for (const entry of state.pending.values()) {
306
- clearTimeout(entry.timer)
307
- entry.reject(new Error('chrome-shutdown'))
308
- }
309
- state.pending.clear()
310
- try {
311
- state.child.kill('SIGKILL')
312
- } catch {
313
- // already gone
314
- }
315
- // Keep cookies for the WAF challenge, drop HTTP/Code caches so the profile
316
- // does not silently grow.
317
- pruneChromeProfile(profileDir)
318
- }
319
-
320
- function touchIdle() {
321
- if (idleTimer !== undefined) clearTimeout(idleTimer)
322
- if (browserIdleTtlMs <= 0) return
323
- idleTimer = setTimeout(() => shutdownBrowser(), browserIdleTtlMs)
324
- idleTimer.unref?.()
325
- }
326
-
327
- /** Start Chrome once and resolve when its DevTools pipe answers. */
328
- async function startBrowser() {
329
- if (browser !== undefined && browser.closed !== true) return browser
330
- if (chromePath === undefined) throw new Error('chrome-not-found')
331
- fs.mkdirSync(profileDir, { recursive: true })
332
- // Clean caches left behind by an interrupted previous run.
333
- pruneChromeProfile(profileDir)
334
- const child = spawn(
335
- chromePath,
336
- [
337
- '--headless=new',
338
- '--disable-gpu',
339
- '--no-first-run',
340
- '--no-default-browser-check',
341
- '--disable-background-networking',
342
- '--disable-sync',
343
- '--mute-audio',
344
- '--disk-cache-size=52428800', // hard cap: 50 MiB HTTP cache
345
- '--media-cache-size=10485760', // hard cap: 10 MiB media cache
346
- '--remote-debugging-pipe',
347
- `--user-data-dir=${profileDir}`,
348
- 'about:blank',
349
- ],
350
- { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] },
351
- )
352
- const write = /** @type {NodeJS.WritableStream} */ (child.stdio[3])
353
- const read = /** @type {NodeJS.ReadableStream} */ (child.stdio[4])
354
- if (write === null || read === null) {
355
- try {
356
- child.kill('SIGKILL')
357
- } catch {
358
- // ignore
359
- }
360
- throw new Error('chrome-pipe-unavailable')
361
- }
362
- /** @type {BrowserState} */
363
- const state = { child, write, read, pending: new Map(), nextId: 1, buffer: '', closed: false }
364
- read.on('data', (chunk) => {
365
- state.buffer += chunk.toString('utf8')
366
- const parts = state.buffer.split('\u0000')
367
- state.buffer = parts.pop() ?? ''
368
- for (const part of parts) {
369
- if (part.trim() === '') continue
370
- let message
371
- try {
372
- message = JSON.parse(part)
373
- } catch {
374
- continue
375
- }
376
- if (message.id === undefined) continue
377
- const entry = state.pending.get(message.id)
378
- if (entry === undefined) continue
379
- state.pending.delete(message.id)
380
- clearTimeout(entry.timer)
381
- if (message.error !== undefined) entry.reject(new Error(JSON.stringify(message.error)))
382
- else entry.resolve(message.result ?? message)
383
- }
384
- })
385
- child.on('error', (error) => {
386
- state.closed = true
387
- if (browser === state) browser = undefined
388
- for (const entry of state.pending.values()) {
389
- clearTimeout(entry.timer)
390
- entry.reject(error instanceof Error ? error : new Error(String(error)))
391
- }
392
- state.pending.clear()
393
- })
394
- child.on('exit', () => {
395
- state.closed = true
396
- if (browser === state) browser = undefined
397
- for (const entry of state.pending.values()) {
398
- clearTimeout(entry.timer)
399
- entry.reject(new Error('chrome-exited'))
400
- }
401
- state.pending.clear()
402
- })
403
- browser = state
404
- // handshake
405
- await cdpSend(state, 'Browser.getVersion', {}, undefined, 8000)
406
- if (options.verbose === true) logger?.info?.(`[lcsc-cn] chrome started (pid ${child.pid})`)
407
- return state
408
- }
409
-
410
- /**
411
- * Send one CDP command over the pipe.
412
- * @param {BrowserState} state
413
- * @param {string} method
414
- * @param {Record<string, unknown>} [params]
415
- * @param {string} [sessionId]
416
- * @param {number} [timeout]
417
- */
418
- function cdpSend(state, method, params = {}, sessionId, timeout = 15000) {
419
- return new Promise((resolve, reject) => {
420
- if (state.closed) {
421
- reject(new Error('chrome-closed'))
422
- return
423
- }
424
- const id = state.nextId
425
- state.nextId += 1
426
- const timer = setTimeout(() => {
427
- state.pending.delete(id)
428
- reject(new Error(`cdp-timeout:${method}`))
429
- }, timeout)
430
- timer.unref?.()
431
- state.pending.set(id, { resolve, reject, timer })
432
- const payload = { id, method, params }
433
- if (sessionId !== undefined) payload.sessionId = sessionId
434
- state.write.write(`${JSON.stringify(payload)}\u0000`)
435
- })
436
- }
437
-
438
- /** Open `url` in a new tab, wait for the rendered DOM, close the tab. */
439
- async function renderViaCdp(url, timeout = timeoutMs) {
440
- const state = await startBrowser()
441
- const deadline = Date.now() + timeout
442
- let targetId
443
- let sessionId
444
- try {
445
- const created = await cdpSend(state, 'Target.createTarget', { url: 'about:blank' }, undefined, 10000)
446
- targetId = created.targetId
447
- const attached = await cdpSend(state, 'Target.attachToTarget', { targetId, flatten: true }, undefined, 10000)
448
- sessionId = attached.sessionId
449
- await cdpSend(state, 'Page.enable', {}, sessionId, 5000)
450
- await cdpSend(state, 'Runtime.enable', {}, sessionId, 5000)
451
- await cdpSend(state, 'Page.navigate', { url }, sessionId, 10000)
452
- let html = ''
453
- while (Date.now() < deadline) {
454
- await sleep(150)
455
- try {
456
- const evaluated = await cdpSend(
457
- state,
458
- 'Runtime.evaluate',
459
- { expression: 'document.documentElement.outerHTML', returnByValue: true },
460
- sessionId,
461
- 5000,
462
- )
463
- html = typeof evaluated?.result?.value === 'string' ? evaluated.result.value : ''
464
- } catch {
465
- // navigation might still be in flight; retry until the deadline
466
- }
467
- if (html.includes('__NEXT_DATA__') && /"price"\s*:/.test(html)) return html
468
- }
469
- return html
470
- } finally {
471
- if (sessionId !== undefined && state.closed !== true) {
472
- try {
473
- await cdpSend(state, 'Target.detachFromTarget', { sessionId }, undefined, 3000)
474
- } catch {
475
- // ignore
476
- }
477
- }
478
- if (targetId !== undefined && state.closed !== true) {
479
- try {
480
- await cdpSend(state, 'Target.closeTarget', { targetId }, undefined, 3000)
481
- } catch {
482
- // ignore
483
- }
484
- }
485
- touchIdle()
486
- }
487
- }
488
-
489
- /* ------------------- legacy per-page renderer (fallback) ----------------- */
490
- /** @param {string} url @param {number} timeout @returns {Promise<string>} */
491
- function renderViaDumpDom(url, timeout = timeoutMs) {
492
- if (chromePath === undefined) return Promise.resolve('')
493
- return new Promise((resolve) => {
494
- fs.mkdirSync(legacyProfileDir, { recursive: true })
495
- pruneChromeProfile(legacyProfileDir)
496
- const args = [
497
- '--headless=new',
498
- '--disable-gpu',
499
- '--no-first-run',
500
- '--no-default-browser-check',
501
- '--disable-background-networking',
502
- '--disable-sync',
503
- '--mute-audio',
504
- '--disk-cache-size=52428800',
505
- '--media-cache-size=10485760',
506
- `--user-data-dir=${legacyProfileDir}`,
507
- '--dump-dom',
508
- '--virtual-time-budget=6000',
509
- url,
510
- ]
511
- let finished = false
512
- let stdout = ''
513
- const child = spawn(chromePath, args, { stdio: ['ignore', 'pipe', 'ignore'] })
514
- const timer = setTimeout(() => {
515
- if (finished) return
516
- finished = true
517
- try {
518
- child.kill('SIGKILL')
519
- } catch {
520
- // ignore
521
- }
522
- resolve(stdout)
523
- }, timeout)
524
- child.stdout?.on('data', (chunk) => {
525
- stdout += chunk.toString()
526
- })
527
- child.on('error', () => {
528
- if (finished) return
529
- finished = true
530
- clearTimeout(timer)
531
- resolve('')
532
- })
533
- child.on('close', () => {
534
- if (finished) return
535
- finished = true
536
- clearTimeout(timer)
537
- pruneChromeProfile(legacyProfileDir)
538
- resolve(stdout)
539
- })
540
- })
541
- }
542
-
543
- /**
544
- * Render a URL to DOM: persistent CDP browser first, dump-dom fallback.
545
- * @param {string} url
546
- * @param {number} [timeout]
547
- * @returns {Promise<string>}
548
- */
549
- async function renderHtml(url, timeout = timeoutMs) {
550
- if (chromePath === undefined) return ''
551
- if (!cdpBroken && cdpSessionErrors < 3) {
552
- try {
553
- return await renderViaCdp(url, timeout)
554
- } catch (error) {
555
- cdpSessionErrors += 1
556
- if (options.verbose === true) logger?.warn?.(`[lcsc-cn] cdp render failed: ${error instanceof Error ? error.message : String(error)}`)
557
- shutdownBrowser()
558
- if (cdpSessionErrors >= 3) cdpBroken = true
559
- }
560
- }
561
- return renderViaDumpDom(url, timeout)
562
- }
563
-
564
- /**
565
- * Resolve the domestic product id for an LCSC code.
566
- * @param {string} code
567
- * @returns {Promise<string | undefined>}
568
- */
569
- async function resolvePid(code) {
570
- for (const base of [EASYEDA_URL, LCEDA_URL]) {
571
- try {
572
- const payload = await getJson(`${base}${encodeURIComponent(code)}/components`)
573
- const result = payload?.result ?? {}
574
- const pid = result?.szlcsc?.id ?? result?.lcsc?.id
575
- if (pid !== undefined && pid !== null && String(pid) !== '') return String(pid)
576
- } catch {
577
- // try the next API
578
- }
579
- }
580
- return undefined
581
- }
582
-
583
- /** @param {string} code */
584
- function cachePath(code) {
585
- return path.join(cacheDir, `${code}.json`)
586
- }
587
-
588
- /** @param {string} code */
589
- function readCache(code) {
590
- const inMemory = memory.get(code)
591
- if (inMemory !== undefined && Date.now() - inMemory.ts < cacheTtlMs) {
592
- return { ...inMemory.value, cached: true, cache_age_s: Math.round((Date.now() - inMemory.ts) / 1000) }
593
- }
594
- try {
595
- const record = JSON.parse(fs.readFileSync(cachePath(code), 'utf8'))
596
- const fetchedAt = Date.parse(record.fetched_at)
597
- if (Number.isFinite(fetchedAt) && Date.now() - fetchedAt < cacheTtlMs) {
598
- return { ...record, cached: true, cache_age_s: Math.round((Date.now() - fetchedAt) / 1000) }
599
- }
600
- } catch {
601
- // cache miss
602
- }
603
- return undefined
604
- }
605
-
606
- /** @param {string} code @param {Record<string, unknown>} record */
607
- function writeCache(code, record) {
608
- try {
609
- fs.mkdirSync(cacheDir, { recursive: true })
610
- fs.writeFileSync(cachePath(code), JSON.stringify(record, null, 1), 'utf8')
611
- } catch {
612
- // cache write is best effort
613
- }
614
- memory.set(code, { ts: Date.now(), value: record })
615
- }
616
-
617
- /**
618
- * Query one LCSC code. Never throws; returns `{ok:false, reason}` on failure.
619
- * @param {string} code
620
- * @param {{ refresh?: boolean }} [callOptions]
621
- */
622
- async function query(code, callOptions = {}) {
623
- const normalized = String(code ?? '').trim().toUpperCase()
624
- if (normalized === '') return { ok: false, reason: 'empty-code' }
625
- if (chromePath === undefined) return { ok: false, reason: 'chrome-not-found' }
626
- queryCount += 1
627
- // Prune expired JSON cache entries lazily (about once per hour / 50 hits).
628
- if (queryCount === 1 || Date.now() - lastJsonPruneAt > 60 * 60 * 1000 || queryCount % 50 === 0) {
629
- lastJsonPruneAt = Date.now()
630
- pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
631
- }
632
- const cached = callOptions.refresh === true ? undefined : readCache(normalized)
633
- if (cached !== undefined && cached.ok !== false) {
634
- touchIdle()
635
- return cached
636
- }
637
- return withSlot(async () => {
638
- try {
639
- const pid = await resolvePid(normalized)
640
- if (pid === undefined) return { ok: false, reason: 'product-id-not-found' }
641
- const html = await renderHtml(`${ITEM_URL}${pid}.html`)
642
- if (html === '') return { ok: false, reason: 'chrome-render-empty' }
643
- const parsed = parseItemHtml(html)
644
- if (parsed === undefined || parsed.price === undefined) {
645
- return { ok: false, reason: 'page-parse-failed' }
646
- }
647
- const record = {
648
- ok: true,
649
- lcsc: normalized,
650
- product_id: pid,
651
- unit: parsed.price,
652
- currency: 'CNY',
653
- stock: parsed.stock,
654
- model: parsed.model,
655
- brand: parsed.brand,
656
- package: parsed.package,
657
- min_buy: parsed.min_buy,
658
- packing: parsed.packing,
659
- unit_name: parsed.unit,
660
- category: parsed.category,
661
- desc: parsed.desc,
662
- source: 'lcsc_cn_render',
663
- fetched_at: new Date().toISOString(),
664
- cache_age_s: 0,
665
- cached: false,
666
- }
667
- writeCache(normalized, record)
668
- return record
669
- } catch (error) {
670
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
671
- }
672
- })
673
- }
674
-
675
- return {
676
- chromePath,
677
- cacheDir,
678
- query,
679
- resolvePid,
680
- renderHtml,
681
- clearMemory() {
682
- memory.clear()
683
- },
684
- /** Remove this adapter's on-disk caches (JSON results + Chrome profiles). */
685
- clearCache() {
686
- shutdownBrowser()
687
- try {
688
- fs.rmSync(cacheDir, { recursive: true, force: true })
689
- } catch {
690
- // best effort
691
- }
692
- memory.clear()
693
- },
694
- pruneCaches() {
695
- const json = pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
696
- pruneChromeProfile(profileDir)
697
- pruneChromeProfile(legacyProfileDir)
698
- return json
699
- },
700
- dispose() {
701
- shutdownBrowser()
702
- pruneChromeProfile(profileDir)
703
- pruneChromeProfile(legacyProfileDir)
704
- pruneJsonCache(cacheDir, { ttlMs: cacheTtlMs, maxEntries: cacheMaxEntries })
705
- },
706
- }
707
- }
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
+ }