dsh-plugin-lcsc 0.0.0-stage → 0.3.1

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 ADDED
@@ -0,0 +1,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
+ 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
+ }