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/data.js ADDED
@@ -0,0 +1,1810 @@
1
+ /**
2
+ * dsh-plugin-lcsc — shared data layer for the LCSC/JLC finder plugin.
3
+ *
4
+ * This module holds the HTTP/cache/normalisation layer only:
5
+ * - JLCPCB SMT parts search (`searchRaw`, `search`)
6
+ * - LCSC international product page (USD ladder prices)
7
+ * - LCSC domestic product page (CNY 1-pc price, best effort; WAF-limited)
8
+ * - EasyEDA product API (C-code -> domestic product id)
9
+ * - BOM CSV parsing and price-tier helpers
10
+ *
11
+ * The DSH tool surface lives in `index.js`.
12
+ */
13
+ /**
14
+ * The plugin description used by the DSH loader.
15
+ *
16
+ * Host-only bundle: exports `apply` + `inject` for the DSH Loader, and three
17
+ * LLM-callable tools through `ctx.tools.register`:
18
+ *
19
+ * - `lcsc_search(keyword, limit)` search candidates through JLCPCB's SMT
20
+ * parts API (no login required)
21
+ * - `lcsc_part(codes[])` exact price / stock / package lookup
22
+ * - `bom_check(rows | csv)` batch BOM verification
23
+ *
24
+ * The same module also exports pure helpers (`createLcscService`,
25
+ * `searchParts`, `getPart`, `checkBom`, `parseBomCsv`, `pickTierPrice`,
26
+ * normalizers, …) so `poc/validate.mjs` can exercise the data layer with plain
27
+ * Node, no DSH runtime required.
28
+ *
29
+ * Design constraints:
30
+ * - zero third-party runtime dependencies; global `fetch` only (Node >= 20)
31
+ * - no `@deepseek-ai/*` imports: the tool definition object is registered in
32
+ * the shape `@deepseek-ai/dsh-tools`'s `ToolsService.register()` accepts
33
+ * (see `createMcpToolDefinition` in the shipped `dsh-mcp-client`), and the
34
+ * row `config` is validated by `normalizeConfig()` instead of schemastery.
35
+ * - prices are never converted between currencies; every price carries the
36
+ * currency and the source it came from.
37
+ *
38
+ * Data sources (all public, unauthenticated):
39
+ * 1. JLCPCB SMT component search (USD, assembly pricing)
40
+ * POST https://jlcpcb.com/api/overseas-pcb-order/v1/shoppingCart/smtGood/selectSmtComponentList
41
+ * 2. LCSC international product page (USD ladder prices)
42
+ * GET https://www.lcsc.com/product-detail/<C-code>.html → __NEXT_DATA__
43
+ * 3. EasyEDA product API (identity + szlcsc id)
44
+ * GET https://easyeda.com/api/products/<C-code>/components
45
+ * 4. LCSC domestic item page (CNY 1-pc price; optional, Aliyun WAF may block)
46
+ * GET https://item.szlcsc.com/<szlcsc-id>.html → __NEXT_DATA__
47
+ */
48
+
49
+ /* -------------------------------------------------------------------------- */
50
+ /* Types (JSDoc) */
51
+ /* -------------------------------------------------------------------------- */
52
+
53
+ /**
54
+ * @typedef {object} LcscServiceOptions
55
+ * @property {number} [timeoutMs] per-request timeout, default 8000
56
+ * @property {number} [retries] retries after the first attempt, default 3
57
+ * @property {number} [retryBaseMs] exponential backoff base, default 300
58
+ * @property {number} [cacheTtlMs] in-memory cache TTL, default 600000 (10 min)
59
+ * @property {number} [cacheMaxEntries] in-memory cache entries, default 300
60
+ * @property {string} [userAgent]
61
+ * @property {typeof fetch} [fetchImpl] injectable fetch (tests)
62
+ */
63
+
64
+ /* -------------------------------------------------------------------------- */
65
+ /* Constants */
66
+ /* -------------------------------------------------------------------------- */
67
+
68
+ /** Plugin row name (also used in logs). */
69
+ export const name = 'lcsc-bom'
70
+
71
+ /** Services this plugin needs before it activates. */
72
+ export const inject = ['tools']
73
+
74
+ /** Defaults for the row `config` (see cordis.patch.yml). */
75
+ export const DEFAULT_CONFIG = Object.freeze({
76
+ timeoutMs: 8000,
77
+ retries: 3,
78
+ retryBaseMs: 300,
79
+ cacheTtlMs: 600000,
80
+ cacheMaxEntries: 300,
81
+ verbose: false,
82
+ usdToCnyFactor: 6.0,
83
+ // Domestic CNY adapter (Chrome headless render + 24h cache).
84
+ cnRender: true,
85
+ chromePath: null,
86
+ cnCacheDir: null,
87
+ cnCacheTtlMs: 86400000,
88
+ cnTimeoutMs: 30000,
89
+ cnConcurrency: 4,
90
+ cnBrowserIdleTtlMs: 120000,
91
+ cnQueryBudgetMs: 90000,
92
+ cnCacheMaxEntries: 800,
93
+ // JLCPCB extended-part feeder fee and optional engineering fees (USD).
94
+ extendedPartFeeUsd: 3.0,
95
+ setupFeeUsd: 0.0,
96
+ stencilFeeUsd: 0.0,
97
+ })
98
+
99
+ // LCSC codes are normally C + 4–7 digits, but the catalogue also carries
100
+ // long-form codes such as C9900021114 (C + 10 digits). A too-narrow bound made
101
+ // those parts invisible: normalizeJlcItem dropped them outright, so BOM rows
102
+ // referencing them looked like "no LCSC code".
103
+ const LCSC_CODE_RE = /^C\d{4,11}$/i
104
+ const LCSC_CODE_SEARCH_RE = /\bC\d{4,11}\b/gi
105
+
106
+ const JLC_SEARCH_URL =
107
+ 'https://jlcpcb.com/api/overseas-pcb-order/v1/shoppingCart/smtGood/selectSmtComponentList'
108
+ const LCSC_PRODUCT_URL = 'https://www.lcsc.com/product-detail/'
109
+ const EASYEDA_PRODUCT_URL = 'https://easyeda.com/api/products/'
110
+ const SZLCSC_ITEM_URL = 'https://item.szlcsc.com/'
111
+
112
+ const DEFAULT_USER_AGENT =
113
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
114
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
115
+
116
+ const HTML_HEADERS = {
117
+ 'User-Agent': DEFAULT_USER_AGENT,
118
+ Accept:
119
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
120
+ 'Accept-Language': 'en-US,en;q=0.9',
121
+ }
122
+
123
+ const CN_HTML_HEADERS = {
124
+ ...HTML_HEADERS,
125
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
126
+ 'sec-ch-ua': '"Chromium";v="131", "Not_A Brand";v="24"',
127
+ 'sec-ch-ua-mobile': '?0',
128
+ 'sec-ch-ua-platform': '"macOS"',
129
+ 'Sec-Fetch-Dest': 'document',
130
+ 'Sec-Fetch-Mode': 'navigate',
131
+ 'Sec-Fetch-Site': 'none',
132
+ 'Sec-Fetch-User': '?1',
133
+ Referer: 'https://www.szlcsc.com/',
134
+ }
135
+
136
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504])
137
+
138
+ const CURRENCY_NOTES = Object.freeze([
139
+ 'USD prices are quoted by the LCSC international site (lcsc.com) or by JLCPCB\'s SMT parts API; CNY prices are quoted by the LCSC domestic site (item.szlcsc.com).',
140
+ 'No FX conversion is applied and currencies are never mixed in one total.',
141
+ 'These are unofficial, unauthenticated public endpoints; treat prices and stock as indicative and confirm on lcsc.com / jlcpcb.com before ordering.',
142
+ ])
143
+
144
+ /* -------------------------------------------------------------------------- */
145
+ /* Small utilities */
146
+ /* -------------------------------------------------------------------------- */
147
+
148
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
149
+ function isRecord(value) {
150
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
151
+ }
152
+
153
+ /** @param {unknown} value @returns {number | undefined} */
154
+ function asNumber(value) {
155
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
156
+ if (typeof value === 'string' && value.trim() !== '') {
157
+ const parsed = Number(value)
158
+ return Number.isFinite(parsed) ? parsed : undefined
159
+ }
160
+ return undefined
161
+ }
162
+
163
+ /** @param {unknown} value @returns {string | undefined} */
164
+ function asString(value) {
165
+ if (typeof value !== 'string') return undefined
166
+ const trimmed = value.trim()
167
+ return trimmed === '' ? undefined : trimmed
168
+ }
169
+
170
+ /** @param {unknown} value @returns {number | undefined} */
171
+ function asInteger(value) {
172
+ const number = asNumber(value)
173
+ return number !== undefined && Number.isInteger(number) ? number : undefined
174
+ }
175
+
176
+ /**
177
+ * Remove `undefined` object members recursively so a tool result is lossless
178
+ * JSON and matches its declared output schema.
179
+ * @template T
180
+ * @param {T} value
181
+ * @returns {T}
182
+ */
183
+ function clean(value) {
184
+ if (Array.isArray(value)) return value.map((item) => clean(item))
185
+ if (isRecord(value)) {
186
+ /** @type {Record<string, unknown>} */
187
+ const out = {}
188
+ for (const [key, member] of Object.entries(value)) {
189
+ if (member === undefined) continue
190
+ out[key] = clean(member)
191
+ }
192
+ return /** @type {T} */ (out)
193
+ }
194
+ return value
195
+ }
196
+
197
+ /**
198
+ * Round a currency amount to six decimals (float noise only; no currency math).
199
+ * @param {number} value
200
+ * @returns {number}
201
+ */
202
+ function money(value) {
203
+ return Math.round(value * 1e6) / 1e6
204
+ }
205
+
206
+ /** @param {number} ms @param {AbortSignal} [signal] */
207
+ function sleep(ms, signal) {
208
+ return new Promise((resolve, reject) => {
209
+ const timer = setTimeout(() => {
210
+ signal?.removeEventListener('abort', onAbort)
211
+ resolve(undefined)
212
+ }, ms)
213
+ const onAbort = () => {
214
+ clearTimeout(timer)
215
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
216
+ }
217
+ signal?.addEventListener('abort', onAbort, { once: true })
218
+ })
219
+ }
220
+
221
+ /* -------------------------------------------------------------------------- */
222
+ /* HTTP layer: timeout, retry, JSON/text */
223
+ /* -------------------------------------------------------------------------- */
224
+
225
+ /** A meaningful non-2xx HTTP response. */
226
+ export class HttpError extends Error {
227
+ /**
228
+ * @param {number} status
229
+ * @param {string} url
230
+ * @param {string} bodySnippet
231
+ */
232
+ constructor(status, url, bodySnippet) {
233
+ super(`HTTP ${status} from ${url}${bodySnippet === '' ? '' : `: ${bodySnippet}`}`)
234
+ this.name = 'HttpError'
235
+ this.status = status
236
+ this.url = url
237
+ }
238
+ }
239
+
240
+ /** A source refused the request in a way that must degrade, not fail the tool. */
241
+ export class SourceBlockedError extends Error {
242
+ /**
243
+ * @param {string} source
244
+ * @param {string} reason
245
+ */
246
+ constructor(source, reason) {
247
+ super(`${source}: ${reason}`)
248
+ this.name = 'SourceBlockedError'
249
+ this.source = source
250
+ this.reason = reason
251
+ }
252
+ }
253
+
254
+ /**
255
+ * `fetch` with a per-attempt timeout, bounded retries with exponential
256
+ * backoff, and cooperative cancellation through the caller's signal.
257
+ * @param {string} url
258
+ * @param {object} options
259
+ * @param {string} [options.method]
260
+ * @param {Record<string, string>} [options.headers]
261
+ * @param {string} [options.body]
262
+ * @param {number} options.timeoutMs
263
+ * @param {number} options.retries
264
+ * @param {number} options.retryBaseMs
265
+ * @param {AbortSignal} [options.signal]
266
+ * @param {typeof fetch} [options.fetchImpl]
267
+ * @returns {Promise<{ text: string, status: number, url: string }>}
268
+ */
269
+ async function request(url, options) {
270
+ const {
271
+ method = 'GET',
272
+ headers = {},
273
+ body,
274
+ timeoutMs,
275
+ retries,
276
+ retryBaseMs,
277
+ signal,
278
+ fetchImpl = globalThis.fetch,
279
+ } = options
280
+ if (typeof fetchImpl !== 'function') {
281
+ throw new Error('global fetch is unavailable: run on Node >= 20 or pass fetchImpl')
282
+ }
283
+ /** @type {unknown} */
284
+ let lastError
285
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
286
+ const controller = new AbortController()
287
+ const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs)
288
+ const attemptSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal
289
+ let retryable = true
290
+ try {
291
+ const response = await fetchImpl(url, { method, headers, body, signal: attemptSignal, redirect: 'follow' })
292
+ const text = await response.text()
293
+ if (!response.ok) {
294
+ const error = new HttpError(response.status, url, text.slice(0, 200).replace(/\s+/g, ' '))
295
+ retryable = RETRYABLE_STATUS.has(response.status)
296
+ throw error
297
+ }
298
+ return { text, status: response.status, url }
299
+ } catch (error) {
300
+ lastError = error
301
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error('aborted')
302
+ const isAbort = error instanceof Error && (error.name === 'AbortError' || /aborted|timed out/i.test(error.message))
303
+ if (isAbort && controller.signal.aborted) retryable = true
304
+ if (attempt === retries || !retryable) break
305
+ await sleep(retryBaseMs * 2 ** attempt, signal)
306
+ } finally {
307
+ clearTimeout(timer)
308
+ }
309
+ }
310
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
311
+ }
312
+
313
+ /**
314
+ * JSON request helper (same retry semantics, plus JSON parse failures retried).
315
+ * @param {string} url
316
+ * @param {Omit<Parameters<typeof request>[1], 'timeoutMs' | 'retries' | 'retryBaseMs'> & {
317
+ * timeoutMs: number, retries: number, retryBaseMs: number,
318
+ * }} options
319
+ * @returns {Promise<unknown>}
320
+ */
321
+ async function requestJson(url, options) {
322
+ let lastError
323
+ for (let attempt = 0; attempt <= options.retries; attempt += 1) {
324
+ try {
325
+ const { text } = await request(url, {
326
+ ...options,
327
+ retries: 0,
328
+ timeoutMs: options.timeoutMs,
329
+ })
330
+ return JSON.parse(text)
331
+ } catch (error) {
332
+ lastError = error
333
+ const retryable =
334
+ !(error instanceof HttpError) || RETRYABLE_STATUS.has(error.status) || error.status === 404
335
+ if (attempt === options.retries || !retryable || options.signal?.aborted) break
336
+ await sleep(options.retryBaseMs * 2 ** attempt, options.signal)
337
+ }
338
+ }
339
+ throw lastError instanceof Error ? lastError : new Error(String(lastError))
340
+ }
341
+
342
+ /* -------------------------------------------------------------------------- */
343
+ /* NEXT_DATA / WAF parsing */
344
+ /* -------------------------------------------------------------------------- */
345
+
346
+ /**
347
+ * Whether an HTML body is an Aliyun WAF JS challenge (LCSC domestic site uses
348
+ * `acw_sc__v2`; the challenge page has no `__NEXT_DATA__`).
349
+ * @param {string} html
350
+ * @returns {boolean}
351
+ */
352
+ export function isWafChallenge(html) {
353
+ return (
354
+ html.includes('acw_sc__v2') ||
355
+ html.includes('aliyun_waf') ||
356
+ (html.includes('id="renderData"') && !html.includes('__NEXT_DATA__'))
357
+ )
358
+ }
359
+
360
+ /**
361
+ * Parse the Next.js `__NEXT_DATA__` JSON embedded in an HTML page.
362
+ *
363
+ * The script tag may carry extra attributes (`crossorigin`, `nonce`), so the
364
+ * regex must not assume `<script id="__NEXT_DATA__" type="application/json">`
365
+ * exactly.
366
+ * @param {string} html
367
+ * @returns {Record<string, unknown> | undefined}
368
+ */
369
+ export function parseNextData(html) {
370
+ const match = /<script id="__NEXT_DATA__" type="application\/json"[^>]*>([\s\S]*?)<\/script>/.exec(html)
371
+ if (match === null) return undefined
372
+ try {
373
+ const parsed = JSON.parse(match[1])
374
+ return isRecord(parsed) ? parsed : undefined
375
+ } catch {
376
+ return undefined
377
+ }
378
+ }
379
+
380
+ /** @param {unknown} nextData @returns {Record<string, unknown> | undefined} */
381
+ function pagePropsOf(nextData) {
382
+ if (!isRecord(nextData)) return undefined
383
+ const props = nextData.props
384
+ if (!isRecord(props)) return undefined
385
+ const pageProps = props.pageProps
386
+ return isRecord(pageProps) ? pageProps : undefined
387
+ }
388
+
389
+ /* -------------------------------------------------------------------------- */
390
+ /* Normalizers (pure, exported for offline validation) */
391
+ /* -------------------------------------------------------------------------- */
392
+
393
+ /**
394
+ * Normalize one row of the JLCPCB SMT parts search response.
395
+ * @param {unknown} raw
396
+ * @returns {Record<string, unknown> | undefined}
397
+ */
398
+ export function normalizeJlcItem(raw) {
399
+ if (!isRecord(raw)) return undefined
400
+ const code = asString(raw.componentCode)?.toUpperCase()
401
+ if (code === undefined || !LCSC_CODE_RE.test(code)) return undefined
402
+ const prices = Array.isArray(raw.componentPrices) ? raw.componentPrices : []
403
+ /** @type {{ qty: number, unit: number }[]} */
404
+ const priceUsdTiers = []
405
+ for (const tier of prices) {
406
+ if (!isRecord(tier)) continue
407
+ const qty = asInteger(tier.startNumber)
408
+ const unit = asNumber(tier.productPrice)
409
+ if (qty === undefined || unit === undefined) continue
410
+ priceUsdTiers.push({ qty, unit })
411
+ }
412
+ priceUsdTiers.sort((left, right) => left.qty - right.qty)
413
+ const attributes = Array.isArray(raw.attributes)
414
+ ? raw.attributes
415
+ .filter(isRecord)
416
+ .map((attribute) => ({
417
+ name: asString(attribute.attribute_name_en),
418
+ value: asString(attribute.attribute_value_name),
419
+ }))
420
+ .filter((attribute) => attribute.name !== undefined)
421
+ : []
422
+ return clean({
423
+ code,
424
+ model: asString(raw.componentModelEn),
425
+ brand: asString(raw.componentBrandEn),
426
+ package: asString(raw.componentSpecificationEn),
427
+ category: asString(raw.componentTypeEn),
428
+ description: asString(raw.describe) ?? asString(raw.erpComponentName) ?? asString(raw.componentName),
429
+ stock: asInteger(raw.stockCount),
430
+ libraryType: asString(raw.componentLibraryType),
431
+ preferred: raw.preferredComponentFlag === true,
432
+ alternatives: asString(raw.componentAlternativesCode),
433
+ minPurchaseNum: asInteger(raw.minPurchaseNum),
434
+ encapsulationNumber: asInteger(raw.encapsulationNumber),
435
+ priceUsdTiers,
436
+ attributes,
437
+ datasheetUrl: asString(raw.dataManualUrl) ?? asString(raw.dataManualFileAccessIdUrl),
438
+ url: asString(raw.lcscGoodsUrl) ?? `https://www.lcsc.com/product-detail/${code}.html`,
439
+ })
440
+ }
441
+
442
+ /**
443
+ * Normalize the `pageProps.webData` of `https://www.lcsc.com/product-detail/<code>.html`.
444
+ * `webData === null` with `dataIsNull: true` means the part is domestic-only.
445
+ * @param {unknown} pageProps
446
+ * @returns {{ dataIsNull: boolean, webData: Record<string, unknown> | undefined }}
447
+ */
448
+ export function normalizeLcscProduct(pageProps) {
449
+ const dataIsNull = isRecord(pageProps) ? pageProps.dataIsNull === true : false
450
+ const webData = isRecord(pageProps) && isRecord(pageProps.webData) ? pageProps.webData : undefined
451
+ return { dataIsNull, webData }
452
+ }
453
+
454
+ /**
455
+ * Extract the USD ladder from lcsc.com `productPriceList`.
456
+ * The international site quotes USD already (`usdPrice`, `currencySymbol: '$'`).
457
+ * @param {unknown} webData
458
+ * @returns {{ qty: number, unit: number }[]}
459
+ */
460
+ export function usdTiersFromLcscProduct(webData) {
461
+ if (!isRecord(webData) || !Array.isArray(webData.productPriceList)) return []
462
+ /** @type {{ qty: number, unit: number }[]} */
463
+ const tiers = []
464
+ for (const row of webData.productPriceList) {
465
+ if (!isRecord(row)) continue
466
+ const qty = asInteger(row.ladder)
467
+ const unit = asNumber(row.usdPrice) ?? asNumber(row.productPrice)
468
+ if (qty === undefined || unit === undefined) continue
469
+ tiers.push({ qty, unit })
470
+ }
471
+ tiers.sort((left, right) => left.qty - right.qty)
472
+ return tiers
473
+ }
474
+
475
+ /**
476
+ * Pick the unit price for `qty` from an ascending ladder. Below the first
477
+ * ladder the first (MOQ) price applies; above the last the last applies.
478
+ * @param {{ qty: number, unit: number }[]} tiers
479
+ * @param {number} qty
480
+ * @returns {number | undefined}
481
+ */
482
+ export function pickTierPrice(tiers, qty) {
483
+ if (!Array.isArray(tiers) || tiers.length === 0) return undefined
484
+ const sorted = tiers
485
+ .filter((tier) => Number.isFinite(tier?.qty) && Number.isFinite(tier?.unit))
486
+ .sort((left, right) => left.qty - right.qty)
487
+ if (sorted.length === 0) return undefined
488
+ let chosen = sorted[0]
489
+ for (const tier of sorted) {
490
+ if (qty >= tier.qty) chosen = tier
491
+ else break
492
+ }
493
+ return chosen.unit
494
+ }
495
+
496
+ /**
497
+ * Parse a CSV / TSV / semicolon-delimited BOM into normalized rows.
498
+ * Accepts both a header row (JLC / LCSC style, English or Chinese) and a
499
+ * headerless list of `C-code, qty` lines.
500
+ * @param {string} text
501
+ * @returns {{ line: number, code: string, qty: number, designators?: string, comment?: string, footprint?: string }[]}
502
+ */
503
+ export function parseBomCsv(text) {
504
+ return parseBomCsvDetailed(text).rows
505
+ }
506
+
507
+ /**
508
+ * Detailed variant of {@link parseBomCsv}: also reports the lines that carried
509
+ * no recognisable LCSC code, so the audit can say "7 of 20 lines were not
510
+ * costed" instead of silently dropping them.
511
+ * @param {string} text
512
+ * @returns {{
513
+ * rows: { line: number, code: string, qty: number, designators?: string, comment?: string, footprint?: string, place?: string }[],
514
+ * skipped: { line: number, raw?: string, comment?: string, designators?: string }[],
515
+ * }}
516
+ */
517
+ export function parseBomCsvDetailed(text) {
518
+ const lines = String(text)
519
+ .split(/\r?\n/)
520
+ .map((line) => line.trim())
521
+ .filter((line) => line !== '' && !line.startsWith('#'))
522
+ if (lines.length === 0) return { rows: [], skipped: [] }
523
+ const delimiter = pickDelimiter(lines[0])
524
+ const cells = lines.map((line) => splitCsvLine(line, delimiter))
525
+ const header = classifyHeader(cells[0])
526
+ const headerLooksTabular =
527
+ header.qtyIndex !== undefined ||
528
+ header.designatorsIndex !== undefined ||
529
+ header.footprintIndex !== undefined ||
530
+ header.commentIndex !== undefined
531
+ const trustedCodeColumn = header.codeIndex !== undefined
532
+ let codeIndex = header.codeIndex
533
+ let qtyIndex = header.qtyIndex
534
+ if (codeIndex === undefined && headerLooksTabular) {
535
+ // Header names were not enough: fell back to sniffing the columns by content
536
+ // instead of scanning whole rows (which picked up footprints like C0603).
537
+ codeIndex = detectCodeColumn(cells.slice(1))
538
+ if (codeIndex !== undefined && qtyIndex === undefined) {
539
+ qtyIndex = detectQuantityColumn(cells.slice(1), codeIndex)
540
+ }
541
+ }
542
+ /** @type {{ line: number, code: string, qty: number, designators?: string, comment?: string, footprint?: string, place?: string }[]} */
543
+ const rows = []
544
+ /** @type {{ line: number, raw?: string, comment?: string, designators?: string }[]} */
545
+ const skipped = []
546
+ if (codeIndex !== undefined) {
547
+ for (let index = 1; index < cells.length; index += 1) {
548
+ const row = cells[index]
549
+ const code = normalizeCode(row[codeIndex] ?? '')
550
+ const comment = optionally(header.commentIndex, row[header.commentIndex])
551
+ const designators = optionally(header.designatorsIndex, row[header.designatorsIndex])
552
+ if (code === undefined) {
553
+ skipped.push(clean({ line: index + 1, comment, designators }))
554
+ continue
555
+ }
556
+ const qty = parseQty(qtyIndex === undefined ? undefined : row[qtyIndex])
557
+ rows.push(
558
+ clean({
559
+ line: index + 1,
560
+ code,
561
+ qty,
562
+ designators,
563
+ comment,
564
+ footprint: optionally(header.footprintIndex, row[header.footprintIndex]),
565
+ place: optionally(header.placeIndex, row[header.placeIndex]),
566
+ }),
567
+ )
568
+ }
569
+ if (trustedCodeColumn || rows.length > 0) return { rows, skipped }
570
+ }
571
+ for (let index = 0; index < cells.length; index += 1) {
572
+ const row = cells[index]
573
+ const joined = row.join(' ')
574
+ const found = joined.match(LCSC_CODE_SEARCH_RE)
575
+ if (found === null) {
576
+ // index 0 is assumed to be a header when nothing matched it
577
+ if (index > 0 && joined.trim() !== '') skipped.push(clean({ line: index + 1, raw: joined.slice(0, 160) }))
578
+ continue
579
+ }
580
+ const code = normalizeCode(found[0])
581
+ if (code === undefined) continue
582
+ const rest = row.filter((cell) => !/^C\d{4,11}$/i.test(cell.trim()))
583
+ let qty = 1
584
+ let designators
585
+ for (const cell of rest) {
586
+ const integer = Number(cell)
587
+ if (Number.isInteger(integer) && integer > 0 && cell.trim() !== '') {
588
+ qty = integer
589
+ break
590
+ }
591
+ }
592
+ for (const cell of rest) {
593
+ if (cell.trim() !== '' && !Number.isFinite(Number(cell)) && !/C\d{4,11}/i.test(cell)) {
594
+ designators = cell.trim()
595
+ break
596
+ }
597
+ }
598
+ rows.push(clean({ line: index + 1, code, qty, designators }))
599
+ }
600
+ return { rows, skipped }
601
+ }
602
+
603
+ /** @param {string} line @returns {',' | ';' | '\t'} */
604
+ function pickDelimiter(line) {
605
+ const counts = { ',': 0, ';': 0, '\t': 0 }
606
+ let quoted = false
607
+ for (const character of line) {
608
+ if (character === '"') quoted = !quoted
609
+ else if (!quoted && character in counts) counts[/** @type {keyof typeof counts} */ (character)] += 1
610
+ }
611
+ /** @type {keyof typeof counts} */
612
+ let best = ','
613
+ for (const candidate of /** @type {(keyof typeof counts)[]} */ (Object.keys(counts))) {
614
+ if (counts[candidate] > counts[best]) best = candidate
615
+ }
616
+ return /** @type {',' | ';' | '\t'} */ (best)
617
+ }
618
+
619
+ /**
620
+ * @param {string} line
621
+ * @param {string} delimiter
622
+ * @returns {string[]}
623
+ */
624
+ function splitCsvLine(line, delimiter) {
625
+ /** @type {string[]} */
626
+ const cells = []
627
+ let current = ''
628
+ let quoted = false
629
+ for (let index = 0; index < line.length; index += 1) {
630
+ const character = line[index]
631
+ if (quoted) {
632
+ if (character === '"' && line[index + 1] === '"') {
633
+ current += '"'
634
+ index += 1
635
+ } else if (character === '"') quoted = false
636
+ else current += character
637
+ } else if (character === '"') quoted = true
638
+ else if (character === delimiter) {
639
+ cells.push(current.trim())
640
+ current = ''
641
+ } else current += character
642
+ }
643
+ cells.push(current.trim())
644
+ return cells
645
+ }
646
+
647
+ /**
648
+ * Locate BOM columns by header name, English or Chinese.
649
+ * @param {string[]} cells
650
+ * @returns {{
651
+ * hasCode: boolean,
652
+ * codeIndex?: number,
653
+ * qtyIndex?: number,
654
+ * designatorsIndex?: number,
655
+ * commentIndex?: number,
656
+ * footprintIndex?: number,
657
+ * placeIndex?: number,
658
+ * }}
659
+ */
660
+ function classifyHeader(cells) {
661
+ /** @type {Record<string, number>} */
662
+ const result = {}
663
+ cells.forEach((cell, index) => {
664
+ const key = cell.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, ' ').trim()
665
+ if (key === '') return
666
+ if (result.codeIndex === undefined && /(^| )(lcsc|jlc)( |$)|supplier ?part|part ?(number|no|code)|料号|编号|^c ?号$/.test(key)) result.codeIndex = index
667
+ else if (result.qtyIndex === undefined && /qty|quantity|数量|用量|个数|count/.test(key)) result.qtyIndex = index
668
+ else if (result.designatorsIndex === undefined && /designator|ref ?des|references|位号/.test(key)) result.designatorsIndex = index
669
+ else if (result.commentIndex === undefined && /comment|description|name|名称|注释|value|型号/.test(key)) result.commentIndex = index
670
+ else if (result.footprintIndex === undefined && /footprint|package|封装/.test(key)) result.footprintIndex = index
671
+ else if (result.placeIndex === undefined && /place|mount|assembly|贴装|焊接|装配|是否贴|dnp/.test(key)) result.placeIndex = index
672
+ })
673
+ return { hasCode: result.codeIndex !== undefined, ...result }
674
+ }
675
+
676
+ /**
677
+ * Pick the column that actually holds LCSC codes when the header names are
678
+ * unrecognised (the standard 立创EDA / JLCPCB BOM export uses "Supplier Part",
679
+ * which older builds did not match — the row scanner then mistook footprints
680
+ * such as `C0603` for part numbers).
681
+ *
682
+ * Scores by share of `/^C\d{4,8}$/` values, breaking ties by the number of
683
+ * distinct codes and then by the right-most column, so a footprint column
684
+ * ("C0603" repeated, few distinct) never wins over the supplier column.
685
+ * @param {string[][]} rows data rows, header excluded
686
+ * @returns {number | undefined}
687
+ */
688
+ function detectCodeColumn(rows) {
689
+ const width = rows.reduce((max, row) => Math.max(max, row.length), 0)
690
+ /** @type {{ index: number, ratio: number, distinct: number } | undefined} */
691
+ let best
692
+ for (let index = 0; index < width; index += 1) {
693
+ let matched = 0
694
+ let filled = 0
695
+ const distinct = new Set()
696
+ for (const row of rows) {
697
+ const value = (row[index] ?? '').trim()
698
+ if (value === '') continue
699
+ filled += 1
700
+ if (/^C\d{4,8}$/i.test(value)) {
701
+ matched += 1
702
+ distinct.add(value.toUpperCase())
703
+ }
704
+ }
705
+ if (filled === 0 || matched < 2) continue
706
+ const ratio = matched / filled
707
+ if (ratio < 0.6) continue
708
+ const better =
709
+ best === undefined ||
710
+ ratio > best.ratio + 1e-9 ||
711
+ (Math.abs(ratio - best.ratio) < 1e-9 &&
712
+ (distinct.size > best.distinct || (distinct.size === best.distinct && index > best.index)))
713
+ if (better) best = { index, ratio, distinct: distinct.size }
714
+ }
715
+ return best === undefined ? undefined : best.index
716
+ }
717
+
718
+ /**
719
+ * Pick the quantity column when it is not named recognisably: the right-most
720
+ * column that is almost entirely positive integers. Preferring the right-most
721
+ * match keeps `Quantity` ahead of a leading row-id column.
722
+ * @param {string[][]} rows
723
+ * @param {number} excludeIndex
724
+ * @returns {number | undefined}
725
+ */
726
+ function detectQuantityColumn(rows, excludeIndex) {
727
+ const width = rows.reduce((max, row) => Math.max(max, row.length), 0)
728
+ /** @type {{ index: number, ratio: number } | undefined} */
729
+ let best
730
+ for (let index = 0; index < width; index += 1) {
731
+ if (index === excludeIndex) continue
732
+ let positive = 0
733
+ let filled = 0
734
+ for (const row of rows) {
735
+ const value = (row[index] ?? '').trim()
736
+ if (value === '') continue
737
+ filled += 1
738
+ const number = Number(value)
739
+ if (Number.isInteger(number) && number > 0) positive += 1
740
+ }
741
+ if (filled === 0 || positive < 2) continue
742
+ const ratio = positive / filled
743
+ if (ratio < 0.8) continue
744
+ if (best === undefined || ratio > best.ratio || (ratio === best.ratio && index > best.index)) best = { index, ratio }
745
+ }
746
+ return best === undefined ? undefined : best.index
747
+ }
748
+
749
+ /** @param {unknown} value @returns {number} */
750
+ function parseQty(value) {
751
+ const number = asNumber(value)
752
+ if (number === undefined || !Number.isFinite(number) || number <= 0) return 1
753
+ return Math.floor(number)
754
+ }
755
+
756
+ /**
757
+ * @param {number | undefined} index
758
+ * @param {string | undefined} value
759
+ * @returns {string | undefined}
760
+ */
761
+ function optionally(index, value) {
762
+ if (index === undefined) return undefined
763
+ return asString(value)
764
+ }
765
+
766
+ /**
767
+ * Normalize an LCSC part code: trim, uppercase, accept a bare `668215`.
768
+ * @param {unknown} value
769
+ * @returns {string | undefined}
770
+ */
771
+ export function normalizeCode(value) {
772
+ const text = asString(value)?.toUpperCase().replace(/^C/i, 'C').replace(/\s+/g, '')
773
+ if (text === undefined) return undefined
774
+ if (LCSC_CODE_RE.test(text)) return text
775
+ if (/^\d{4,8}$/.test(text)) return `C${text}`
776
+ return undefined
777
+ }
778
+
779
+ /* -------------------------------------------------------------------------- */
780
+ /* TTL cache + in-flight de-duplication */
781
+ /* -------------------------------------------------------------------------- */
782
+
783
+ class TtlCache {
784
+ /**
785
+ * @param {{ ttlMs: number, maxEntries: number }} options
786
+ */
787
+ constructor({ ttlMs, maxEntries }) {
788
+ this.ttlMs = ttlMs
789
+ this.maxEntries = maxEntries
790
+ /** @type {Map<string, { expiresAt: number, value: unknown }>} */
791
+ this.entries = new Map()
792
+ /** @type {Map<string, Promise<unknown>>} */
793
+ this.inflight = new Map()
794
+ }
795
+
796
+ /** @param {string} key */
797
+ get(key) {
798
+ const hit = this.entries.get(key)
799
+ if (hit === undefined) return undefined
800
+ if (hit.expiresAt < Date.now()) {
801
+ this.entries.delete(key)
802
+ return undefined
803
+ }
804
+ // Refresh recency for LRU-ish eviction.
805
+ this.entries.delete(key)
806
+ this.entries.set(key, hit)
807
+ return hit.value
808
+ }
809
+
810
+ /**
811
+ * @param {string} key
812
+ * @param {unknown} value
813
+ */
814
+ set(key, value) {
815
+ this.entries.set(key, { expiresAt: Date.now() + this.ttlMs, value })
816
+ while (this.entries.size > this.maxEntries) {
817
+ const oldest = this.entries.keys().next()
818
+ if (oldest.done === true) break
819
+ this.entries.delete(oldest.value)
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Run `load` once per key even when several callers race (a BOM often lists
825
+ * the same part twice).
826
+ * @template T
827
+ * @param {string} key
828
+ * @param {() => Promise<T>} load
829
+ * @returns {Promise<T>}
830
+ */
831
+ async dedupe(key, load) {
832
+ const cached = this.get(key)
833
+ if (cached !== undefined) return /** @type {T} */ (cached)
834
+ const running = this.inflight.get(key)
835
+ if (running !== undefined) return /** @type {T} */ (running)
836
+ const promise = load()
837
+ .then((value) => {
838
+ this.set(key, value)
839
+ return value
840
+ })
841
+ .finally(() => this.inflight.delete(key))
842
+ this.inflight.set(key, promise)
843
+ return /** @type {Promise<T>} */ (promise)
844
+ }
845
+
846
+ clear() {
847
+ this.entries.clear()
848
+ this.inflight.clear()
849
+ }
850
+
851
+ get size() {
852
+ return this.entries.size
853
+ }
854
+ }
855
+
856
+ /* -------------------------------------------------------------------------- */
857
+ /* Data-layer service */
858
+ /* -------------------------------------------------------------------------- */
859
+
860
+ /**
861
+ * @typedef {object} LcscService
862
+ * @property {(keyword: string, limit?: number, options?: { signal?: AbortSignal }) => Promise<Record<string, unknown>>} search
863
+ * @property {(code: string, options?: { signal?: AbortSignal, includeCny?: boolean }) => Promise<Record<string, unknown>>} part
864
+ * @property {(input: unknown, options?: { signal?: AbortSignal, includeCny?: boolean }) => Promise<Record<string, unknown>>} checkBom
865
+ * @property {() => void} clearCache
866
+ * @property {() => number} cacheSize
867
+ */
868
+
869
+ /**
870
+ * Build the LCSC data service. No DSH dependency: usable from plain Node
871
+ * (see `poc/validate.mjs`).
872
+ * @param {LcscServiceOptions} [options]
873
+ * @returns {LcscService}
874
+ */
875
+ export function createLcscService(options = {}) {
876
+ const settings = normalizeConfig(options)
877
+ const cache = new TtlCache({ ttlMs: settings.cacheTtlMs, maxEntries: settings.cacheMaxEntries })
878
+ const requestOptions = {
879
+ timeoutMs: settings.timeoutMs,
880
+ retries: settings.retries,
881
+ retryBaseMs: settings.retryBaseMs,
882
+ fetchImpl: settings.fetchImpl,
883
+ }
884
+
885
+ /* ------------------------------ JLCPCB search ------------------------------ */
886
+
887
+ /**
888
+ * @param {string} keyword
889
+ * @param {number} pageSize
890
+ * @param {AbortSignal} [signal]
891
+ * @returns {Promise<{ total: number, items: Record<string, unknown>[] }>}
892
+ */
893
+ async function jlcSearchRaw(keyword, pageSize, signal) {
894
+ const key = `jlc:search:${pageSize}:${keyword.toLowerCase()}`
895
+ return /** @type {Promise<{ total: number, items: Record<string, unknown>[] }>} */ (
896
+ cache.dedupe(key, async () => {
897
+ const payload = await requestJson(JLC_SEARCH_URL, {
898
+ ...requestOptions,
899
+ method: 'POST',
900
+ headers: {
901
+ 'Content-Type': 'application/json',
902
+ Accept: 'application/json',
903
+ Origin: 'https://jlcpcb.com',
904
+ Referer: 'https://jlcpcb.com/',
905
+ 'User-Agent': settings.userAgent,
906
+ },
907
+ body: JSON.stringify({ keyword, currentPage: 1, pageSize }),
908
+ signal,
909
+ })
910
+ if (!isRecord(payload)) throw new Error('JLCPCB search returned a non-object JSON body')
911
+ const data = payload.data
912
+ const pageInfo = isRecord(data) && isRecord(data.componentPageInfo) ? data.componentPageInfo : undefined
913
+ if (pageInfo === undefined) throw new Error('JLCPCB search response is missing data.componentPageInfo')
914
+ const total = asInteger(pageInfo.total) ?? 0
915
+ const items = Array.isArray(pageInfo.list) ? pageInfo.list.map(normalizeJlcItem).filter(Boolean) : []
916
+ return { total, items: /** @type {Record<string, unknown>[]} */ (items) }
917
+ })
918
+ )
919
+ }
920
+
921
+ /**
922
+ * Raw paginated JLCPCB SMT search with an optional basic/extended library
923
+ * filter. Used by the requirement-driven finder, which needs a wide candidate
924
+ * pool (up to 100 per page) instead of the small `search()` preview.
925
+ * @param {string} keyword
926
+ * @param {{ pageSize?: number, currentPage?: number, libraryType?: 'base'|'expand'|'all', signal?: AbortSignal }} [options]
927
+ * @returns {Promise<{ total: number, items: Record<string, unknown>[] }>}
928
+ */
929
+ async function searchRaw(keyword, options = {}) {
930
+ const text = asString(keyword)
931
+ if (text === undefined || text === '') throw new Error('searchRaw requires a non-empty keyword string')
932
+ const pageSize = clampInteger(options.pageSize ?? 100, 1, 100)
933
+ const currentPage = clampInteger(options.currentPage ?? 1, 1, 10)
934
+ const libraryType =
935
+ options.libraryType === 'base' || options.libraryType === 'expand' ? options.libraryType : undefined
936
+ const key = `jlc:raw:${pageSize}:${currentPage}:${libraryType ?? 'all'}:${text.toLowerCase()}`
937
+ return /** @type {Promise<{ total: number, items: Record<string, unknown>[] }>} */ (
938
+ cache.dedupe(key, async () => {
939
+ /** @type {Record<string, unknown>} */
940
+ const body = { keyword: text, currentPage, pageSize }
941
+ if (libraryType !== undefined) body.componentLibraryType = libraryType
942
+ const payload = await requestJson(JLC_SEARCH_URL, {
943
+ ...requestOptions,
944
+ method: 'POST',
945
+ headers: {
946
+ 'Content-Type': 'application/json',
947
+ Accept: 'application/json',
948
+ Origin: 'https://jlcpcb.com',
949
+ Referer: 'https://jlcpcb.com/',
950
+ 'User-Agent': settings.userAgent,
951
+ },
952
+ body: JSON.stringify(body),
953
+ signal: options.signal,
954
+ })
955
+ if (!isRecord(payload)) throw new Error('JLCPCB search returned a non-object JSON body')
956
+ const data = payload.data
957
+ const pageInfo = isRecord(data) && isRecord(data.componentPageInfo) ? data.componentPageInfo : undefined
958
+ if (pageInfo === undefined) throw new Error('JLCPCB search response is missing data.componentPageInfo')
959
+ const total = asInteger(pageInfo.total) ?? 0
960
+ const items = Array.isArray(pageInfo.list) ? pageInfo.list.map(normalizeJlcItem).filter(Boolean) : []
961
+ return { total, items: /** @type {Record<string, unknown>[]} */ (items) }
962
+ })
963
+ )
964
+ }
965
+
966
+ /**
967
+ * Search candidates by part number / LCSC code / English keyword.
968
+ * @param {string} keyword
969
+ * @param {number} [limit]
970
+ * @param {{ signal?: AbortSignal }} [callOptions]
971
+ */
972
+ async function search(keyword, limit = 10, callOptions = {}) {
973
+ const text = asString(keyword)
974
+ if (text === undefined) throw new Error('lcsc_search requires a non-empty keyword string')
975
+ const size = clampInteger(limit ?? 10, 1, 50)
976
+ // Over-fetch a little: JLCPCB returns non-LCSC internal codes that we drop.
977
+ const { total, items } = await jlcSearchRaw(text, Math.min(50, size * 3), callOptions.signal)
978
+ const candidates = items.slice(0, size)
979
+ const hints = []
980
+ if (total === 0) {
981
+ hints.push(
982
+ 'No component matched. Try an exact part number (e.g. TP4054), an English keyword, or an LCSC code like C668215; Chinese-only keywords often return nothing from this API.',
983
+ )
984
+ }
985
+ if (total > 0 && candidates.length === 0) {
986
+ hints.push('Matches existed but none carried a valid LCSC code; refine the keyword.')
987
+ }
988
+ if (items.length > candidates.length) {
989
+ hints.push(`Showing ${candidates.length} of ${total} matches; raise \`limit\` for more.`)
990
+ }
991
+ const codeMatch = normalizeCode(text)
992
+ if (codeMatch !== undefined) {
993
+ hints.push(`For full stock and price data on ${codeMatch}, call lcsc_part with codes ["${codeMatch}"].`)
994
+ }
995
+ return {
996
+ keyword: text,
997
+ total,
998
+ returned: candidates.length,
999
+ candidates: candidates.map((candidate) => ({
1000
+ ...candidate,
1001
+ currency: 'USD',
1002
+ priceBasis: 'JLCPCB SMT parts API (USD ladder by quantity)',
1003
+ })),
1004
+ currencyNotes: [...CURRENCY_NOTES],
1005
+ hints,
1006
+ fetchedAt: new Date().toISOString(),
1007
+ }
1008
+ }
1009
+
1010
+ /* --------------------------- LCSC international page ---------------------- */
1011
+
1012
+ /**
1013
+ * @param {string} code
1014
+ * @param {AbortSignal} [signal]
1015
+ */
1016
+ async function lcscProductPage(code, signal) {
1017
+ const key = `lcsc.com:${code}`
1018
+ return /** @type {Promise<{ dataIsNull: boolean, webData: Record<string, unknown> | undefined } | undefined>} */ (
1019
+ cache.dedupe(key, async () => {
1020
+ const { text } = await request(`${LCSC_PRODUCT_URL}${code}.html`, {
1021
+ ...requestOptions,
1022
+ headers: { ...HTML_HEADERS, 'User-Agent': settings.userAgent },
1023
+ signal,
1024
+ })
1025
+ if (isWafChallenge(text)) throw new SourceBlockedError('lcsc.com', 'waf-challenge')
1026
+ const nextData = parseNextData(text)
1027
+ if (nextData === undefined) throw new SourceBlockedError('lcsc.com', 'no-next-data')
1028
+ const pageProps = pagePropsOf(nextData)
1029
+ if (pageProps === undefined) throw new SourceBlockedError('lcsc.com', 'no-page-props')
1030
+ return normalizeLcscProduct(pageProps)
1031
+ })
1032
+ )
1033
+ }
1034
+
1035
+ /* --------------------------------- EasyEDA -------------------------------- */
1036
+
1037
+ /**
1038
+ * @param {string} code
1039
+ * @param {AbortSignal} [signal]
1040
+ * @returns {Promise<Record<string, unknown> | undefined>}
1041
+ */
1042
+ async function easyedaProduct(code, signal) {
1043
+ const key = `easyeda:${code}`
1044
+ return /** @type {Promise<Record<string, unknown> | undefined>} */ (
1045
+ cache.dedupe(key, async () => {
1046
+ try {
1047
+ const payload = await requestJson(`${EASYEDA_PRODUCT_URL}${code}/components`, {
1048
+ ...requestOptions,
1049
+ retries: 0,
1050
+ headers: { Accept: 'application/json', 'User-Agent': settings.userAgent },
1051
+ signal,
1052
+ })
1053
+ if (!isRecord(payload) || payload.success !== true) {
1054
+ const code404 = isRecord(payload) && payload.code === 404
1055
+ if (code404) return undefined
1056
+ throw new Error(`EasyEDA API error for ${code}: ${JSON.stringify(payload).slice(0, 200)}`)
1057
+ }
1058
+ return isRecord(payload.result) ? payload.result : undefined
1059
+ } catch (error) {
1060
+ if (error instanceof HttpError && error.status === 404) return undefined
1061
+ throw error
1062
+ }
1063
+ })
1064
+ )
1065
+ }
1066
+
1067
+ /* ------------------------- LCSC domestic (CNY, optional) ------------------ */
1068
+
1069
+ /**
1070
+ * CNY 1-pc price + domestic stock from item.szlcsc.com, reached through the
1071
+ * EasyEDA product API's `szlcsc.id`. Degrades to a blocked/absent result when
1072
+ * the Aliyun WAF challenges the request; never throws for that case.
1073
+ * @param {string} code
1074
+ * @param {AbortSignal} [signal]
1075
+ */
1076
+ async function cnyQuote(code, signal) {
1077
+ const key = `szlcsc:${code}`
1078
+ return /** @type {Promise<Record<string, unknown>>} */ (
1079
+ cache.dedupe(key, async () => {
1080
+ const fallback = {}
1081
+ let easyeda
1082
+ try {
1083
+ easyeda = await easyedaProduct(code, signal)
1084
+ } catch (error) {
1085
+ return { ok: false, reason: `easyeda-unavailable: ${messageOf(error)}`, ...fallback }
1086
+ }
1087
+ if (easyeda === undefined) return { ok: false, reason: 'easyeda-not-found', ...fallback }
1088
+ const szlcsc = isRecord(easyeda.szlcsc) ? easyeda.szlcsc : undefined
1089
+ const productId = szlcsc === undefined ? undefined : asInteger(szlcsc.id)
1090
+ const edaPrice = szlcsc === undefined ? undefined : asNumber(szlcsc.price)
1091
+ const edaStock = szlcsc === undefined ? undefined : asInteger(szlcsc.stock)
1092
+ if (productId === undefined) return { ok: false, reason: 'no-szlcsc-id', ...fallback }
1093
+ let challengeSeen = false
1094
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1095
+ try {
1096
+ const { text } = await request(`${SZLCSC_ITEM_URL}${productId}.html`, {
1097
+ ...requestOptions,
1098
+ retries: 0,
1099
+ headers: { ...CN_HTML_HEADERS, 'User-Agent': settings.userAgent },
1100
+ signal,
1101
+ })
1102
+ if (isWafChallenge(text)) {
1103
+ challengeSeen = true
1104
+ await sleep(400, signal)
1105
+ continue
1106
+ }
1107
+ const nextData = parseNextData(text)
1108
+ const pageProps = pagePropsOf(nextData)
1109
+ if (pageProps === undefined) {
1110
+ challengeSeen = true
1111
+ await sleep(400, signal)
1112
+ continue
1113
+ }
1114
+ const unit = asNumber(pageProps.price)
1115
+ const webData = isRecord(pageProps.webData) ? pageProps.webData : undefined
1116
+ const productRecord = webData === undefined ? undefined : webData.productRecord
1117
+ const stock =
1118
+ (isRecord(productRecord) ? asInteger(productRecord.stockNumber) : undefined) ??
1119
+ (isRecord(webData) ? asInteger(webData.totalStockNumber) : undefined)
1120
+ const model = isRecord(productRecord) ? asString(productRecord.productModel) : undefined
1121
+ const encapsulation =
1122
+ isRecord(productRecord) ? asString(productRecord.encapsulationModel) : undefined
1123
+ const params = isRecord(webData) && Array.isArray(webData.paramList)
1124
+ ? webData.paramList.filter(isRecord).map((param) => ({
1125
+ name: asString(param.parameterName),
1126
+ value: asString(param.parameterValue) ?? asString(param.parameterDetailValue),
1127
+ })).filter((param) => param.name !== undefined)
1128
+ : []
1129
+ return clean({
1130
+ ok: true,
1131
+ currency: 'CNY',
1132
+ unit,
1133
+ easyedaUnitPrice: edaPrice,
1134
+ stock: stock ?? edaStock,
1135
+ szlcscId: productId,
1136
+ model,
1137
+ package: encapsulation,
1138
+ url: `${SZLCSC_ITEM_URL}${productId}.html`,
1139
+ params,
1140
+ note: 'CNY price is the 1-pc list price shown on the LCSC domestic site (item.szlcsc.com).',
1141
+ })
1142
+ } catch (error) {
1143
+ return {
1144
+ ok: false,
1145
+ reason: `szlcsc-unavailable: ${messageOf(error)}`,
1146
+ szlcscId: productId,
1147
+ easyedaUnitPrice: edaPrice,
1148
+ easyedaStock: edaStock,
1149
+ }
1150
+ }
1151
+ }
1152
+ return {
1153
+ ok: false,
1154
+ reason: challengeSeen ? 'szlcsc-waf-challenge' : 'szlcsc-unavailable',
1155
+ szlcscId: productId,
1156
+ easyedaUnitPrice: edaPrice,
1157
+ easyedaStock: edaStock,
1158
+ }
1159
+ })
1160
+ )
1161
+ }
1162
+
1163
+ /* ------------------------------- Part detail ------------------------------ */
1164
+
1165
+ /**
1166
+ * @param {string} code
1167
+ * @param {{ signal?: AbortSignal, includeCny?: boolean }} [callOptions]
1168
+ */
1169
+ async function part(code, callOptions = {}) {
1170
+ const normalized = normalizeCode(code)
1171
+ if (normalized === undefined) {
1172
+ return clean({
1173
+ code: String(code ?? ''),
1174
+ status: 'NOT-FOUND',
1175
+ error: `invalid LCSC code ${JSON.stringify(code)}: expected C followed by 4-8 digits (e.g. C668215)`,
1176
+ fetchedAt: new Date().toISOString(),
1177
+ })
1178
+ }
1179
+ const signal = callOptions.signal
1180
+ const includeCny = callOptions.includeCny !== false
1181
+ const [lcscResult, jlcResult, maybeCny] = await Promise.all([
1182
+ settle(lcscProductPage(normalized, signal)),
1183
+ settle(exactJlc(normalized, signal)),
1184
+ includeCny ? settle(cnyQuote(normalized, signal)) : Promise.resolve({ status: 'skipped' }),
1185
+ ])
1186
+
1187
+ /** @type {string[]} */
1188
+ const degraded = []
1189
+ const cnyState = !includeCny
1190
+ ? { ok: false, reason: 'skipped' }
1191
+ : maybeCny.status === 'rejected'
1192
+ ? { ok: false, reason: messageOf(maybeCny.reason) }
1193
+ : isRecord(maybeCny.value) && maybeCny.value.ok === true
1194
+ ? { ok: true }
1195
+ : { ok: false, reason: isRecord(maybeCny.value) ? String(maybeCny.value.reason ?? 'unavailable') : 'unavailable' }
1196
+ const lcscState = lcscResult.status === 'fulfilled'
1197
+ ? { ok: true, ...(lcscResult.value?.dataIsNull === true ? { dataIsNull: true, reason: 'domestic-only' } : {}) }
1198
+ : { ok: false, reason: messageOf(lcscResult.reason) }
1199
+ const sources = {
1200
+ 'lcsc.com': lcscState,
1201
+ jlcpcb: sourceState(jlcResult),
1202
+ 'item.szlcsc.com': cnyState,
1203
+ }
1204
+ const intl = lcscResult.status === 'fulfilled' ? lcscResult.value : undefined
1205
+ const jlc = jlcResult.status === 'fulfilled' ? jlcResult.value : undefined
1206
+ const cny = includeCny && maybeCny.status === 'fulfilled' ? maybeCny.value : undefined
1207
+
1208
+ if (lcscResult.status === 'rejected') degraded.push(`lcsc.com:${messageOf(lcscResult.reason)}`)
1209
+ if (jlcResult.status === 'rejected') degraded.push(`jlcpcb:${messageOf(jlcResult.reason)}`)
1210
+ if (includeCny && maybeCny.status === 'rejected') degraded.push(`item.szlcsc.com:${messageOf(maybeCny.reason)}`)
1211
+ if (intl !== undefined && intl.dataIsNull) degraded.push('lcsc.com:domestic-only')
1212
+ if (cny !== undefined && cny.ok === false && cny.reason !== undefined) {
1213
+ degraded.push(`item.szlcsc.com:${cny.reason}`)
1214
+ }
1215
+
1216
+ const webData = intl?.webData
1217
+ const identity = mergeIdentity(webData, jlc, cny, degraded)
1218
+
1219
+ /** @type {{ qty: number, unit: number }[]} */
1220
+ const usdTiers = webData !== undefined ? usdTiersFromLcscProduct(webData) : []
1221
+ const jlcTiers = Array.isArray(jlc?.priceUsdTiers) ? jlc.priceUsdTiers : []
1222
+ const usd = webData !== undefined && usdTiers.length > 0
1223
+ ? {
1224
+ currency: 'USD',
1225
+ source: 'lcsc.com',
1226
+ tiers: usdTiers,
1227
+ note: 'USD ladder price from the LCSC international site, by order quantity.',
1228
+ }
1229
+ : jlc !== undefined && jlcTiers.length > 0
1230
+ ? {
1231
+ currency: 'USD',
1232
+ source: 'jlcpcb',
1233
+ tiers: jlcTiers,
1234
+ note: 'USD ladder price from JLCPCB SMT parts API; used because lcsc.com had no price for this part.',
1235
+ }
1236
+ : undefined
1237
+ const cnyPrice = cny !== undefined && cny.ok === true && cny.unit !== undefined
1238
+ ? {
1239
+ currency: 'CNY',
1240
+ source: 'item.szlcsc.com',
1241
+ unit: cny.unit,
1242
+ note: 'CNY 1-pc list price from the LCSC domestic site (optional enrichment).',
1243
+ }
1244
+ : undefined
1245
+
1246
+ const intlStock = webData === undefined ? undefined : asInteger(webData.stockNumber)
1247
+ const domesticStock = cny?.ok === true ? asInteger(cny.stock) : undefined
1248
+ const jlcStock = jlc === undefined ? undefined : asInteger(jlc.stock)
1249
+ const knownStocks = [intlStock, domesticStock, jlcStock].filter((value) => value !== undefined)
1250
+ const available = knownStocks.length === 0 ? undefined : Math.min(...knownStocks)
1251
+
1252
+ const stock = {
1253
+ available,
1254
+ basis:
1255
+ knownStocks.length === 0
1256
+ ? 'unknown'
1257
+ : 'minimum of the stock counts reported by each queried source (conservative)',
1258
+ intl: intlStock === undefined ? undefined : { count: intlStock, source: 'lcsc.com' },
1259
+ domestic: domesticStock === undefined ? undefined : { count: domesticStock, source: 'item.szlcsc.com' },
1260
+ jlcpcb: jlcStock === undefined ? undefined : { count: jlcStock, source: 'jlcpcb-smt' },
1261
+ }
1262
+
1263
+ const found =
1264
+ webData !== undefined ||
1265
+ (jlc !== undefined && jlc.stock !== undefined) ||
1266
+ (cny !== undefined && cny.ok === true) ||
1267
+ identity.model !== undefined
1268
+ let status
1269
+ if (!found) status = 'NOT-FOUND'
1270
+ else if (available === 0) status = 'OUT-OF-STOCK'
1271
+ else if (available === undefined) status = 'UNKNOWN-STOCK'
1272
+ else status = 'OK'
1273
+
1274
+ return clean({
1275
+ code: normalized,
1276
+ status,
1277
+ identity,
1278
+ package: identity.package,
1279
+ stock,
1280
+ prices: {
1281
+ usd,
1282
+ cny: cnyPrice,
1283
+ easyedaReference:
1284
+ cny !== undefined && (cny.easyedaUnitPrice !== undefined || cny.easyedaStock !== undefined)
1285
+ ? {
1286
+ note: 'Reference values from the EasyEDA product API; not used for totals.',
1287
+ szlcscUnitPrice: cny.easyedaUnitPrice,
1288
+ szlcscStock: cny.easyedaStock,
1289
+ }
1290
+ : undefined,
1291
+ currencyNotes: [...CURRENCY_NOTES],
1292
+ },
1293
+ attributes: mergeAttributes(jlc, cny, webData),
1294
+ urls: {
1295
+ lcsc: `${LCSC_PRODUCT_URL}${normalized}.html`,
1296
+ szlcsc: cny?.szlcscId === undefined ? undefined : `${SZLCSC_ITEM_URL}${cny.szlcscId}.html`,
1297
+ jlcpcb: jlc?.url,
1298
+ },
1299
+ datasheetUrl: asString(jlc?.datasheetUrl) ?? (webData === undefined ? undefined : asString(webData.pdfUrl)),
1300
+ libraryType: jlc?.libraryType,
1301
+ minPurchaseNum: jlc?.minPurchaseNum,
1302
+ sources,
1303
+ degraded,
1304
+ fetchedAt: new Date().toISOString(),
1305
+ })
1306
+ }
1307
+
1308
+ /**
1309
+ * Exact JLCPCB match for one LCSC code (the search API is fuzzy).
1310
+ * @param {string} code
1311
+ * @param {AbortSignal} [signal]
1312
+ */
1313
+ async function exactJlc(code, signal) {
1314
+ const key = `jlc:exact:${code}`
1315
+ return /** @type {Promise<Record<string, unknown> | undefined>} */ (
1316
+ cache.dedupe(key, async () => {
1317
+ let payload
1318
+ try {
1319
+ payload = await jlcSearchRaw(code, 20, signal)
1320
+ } catch (error) {
1321
+ throw error
1322
+ }
1323
+ const match = payload.items.find((item) => item.code === code)
1324
+ return match === undefined ? undefined : match
1325
+ })
1326
+ )
1327
+ }
1328
+
1329
+ /* --------------------------------- BOM ----------------------------------- */
1330
+
1331
+ /**
1332
+ * Verify a BOM: array of `{ code, qty, … }` rows, or CSV / TSV BOM text.
1333
+ * @param {unknown} input
1334
+ * @param {{ signal?: AbortSignal, includeCny?: boolean, concurrency?: number }} [callOptions]
1335
+ */
1336
+ async function checkBom(input, callOptions = {}) {
1337
+ const { rows, parseError } = parseBomInput(input)
1338
+ if (parseError !== undefined) {
1339
+ return clean({
1340
+ summary: { total: 0, verified: 0, noStock: 0, notFound: 0, unknownStock: 0, failed: 0, totalQty: 0 },
1341
+ rows: [],
1342
+ errors: [parseError],
1343
+ currencyNotes: [...CURRENCY_NOTES],
1344
+ fetchedAt: new Date().toISOString(),
1345
+ })
1346
+ }
1347
+ if (rows.length === 0) {
1348
+ return clean({
1349
+ summary: { total: 0, verified: 0, noStock: 0, notFound: 0, unknownStock: 0, failed: 0, totalQty: 0 },
1350
+ rows: [],
1351
+ errors: ['no BOM rows were found; pass rows or CSV text with LCSC codes (e.g. C668215,2)'],
1352
+ currencyNotes: [...CURRENCY_NOTES],
1353
+ fetchedAt: new Date().toISOString(),
1354
+ })
1355
+ }
1356
+ const concurrency = clampInteger(callOptions.concurrency ?? 4, 1, 8)
1357
+ /** @type {Record<string, unknown>[]} */
1358
+ const outRows = new Array(rows.length)
1359
+ let cursor = 0
1360
+ /** @type {Promise<void>[]} */
1361
+ const workers = []
1362
+ for (let worker = 0; worker < Math.min(concurrency, rows.length); worker += 1) {
1363
+ workers.push(
1364
+ (async () => {
1365
+ while (true) {
1366
+ const index = cursor
1367
+ cursor += 1
1368
+ if (index >= rows.length) return
1369
+ const row = rows[index]
1370
+ try {
1371
+ const detail = await part(row.code, {
1372
+ signal: callOptions.signal,
1373
+ includeCny: callOptions.includeCny !== false,
1374
+ })
1375
+ outRows[index] = bomRow(row, detail)
1376
+ } catch (error) {
1377
+ outRows[index] = clean({
1378
+ line: row.line,
1379
+ code: row.code,
1380
+ qty: row.qty,
1381
+ designators: row.designators,
1382
+ comment: row.comment,
1383
+ status: 'CHECK-FAILED',
1384
+ messages: [`lookup failed: ${messageOf(error)}`],
1385
+ })
1386
+ }
1387
+ }
1388
+ })(),
1389
+ )
1390
+ }
1391
+ await Promise.all(workers)
1392
+
1393
+ const summary = {
1394
+ total: outRows.length,
1395
+ verified: 0,
1396
+ noStock: 0,
1397
+ notFound: 0,
1398
+ unknownStock: 0,
1399
+ failed: 0,
1400
+ totalQty: 0,
1401
+ estimatedUsd: 0,
1402
+ estimatedCny: 0,
1403
+ usdCoveredRows: 0,
1404
+ cnyCoveredRows: 0,
1405
+ currencyNotes: [...CURRENCY_NOTES],
1406
+ }
1407
+ for (const row of outRows) {
1408
+ const status = row.status
1409
+ if (status === 'verified') summary.verified += 1
1410
+ else if (status === 'NO-STOCK') summary.noStock += 1
1411
+ else if (status === 'NOT-FOUND') summary.notFound += 1
1412
+ else if (status === 'UNKNOWN-STOCK') summary.unknownStock += 1
1413
+ else summary.failed += 1
1414
+ summary.totalQty += typeof row.qty === 'number' ? row.qty : 0
1415
+ if (typeof row.subtotalUsd === 'number') {
1416
+ summary.estimatedUsd = money(summary.estimatedUsd + row.subtotalUsd)
1417
+ summary.usdCoveredRows += 1
1418
+ }
1419
+ if (typeof row.subtotalCny === 'number') {
1420
+ summary.estimatedCny = money(summary.estimatedCny + row.subtotalCny)
1421
+ summary.cnyCoveredRows += 1
1422
+ }
1423
+ }
1424
+ if (summary.usdCoveredRows === 0) summary.estimatedUsd = undefined
1425
+ if (summary.cnyCoveredRows === 0) summary.estimatedCny = undefined
1426
+ const notes = []
1427
+ if (summary.usdCoveredRows < summary.total) {
1428
+ notes.push(`${summary.usdCoveredRows}/${summary.total} rows have a USD price; the USD total covers only those rows.`)
1429
+ }
1430
+ if (summary.cnyCoveredRows < summary.total) {
1431
+ notes.push(`${summary.cnyCoveredRows}/${summary.total} rows have a CNY price; the CNY total covers only those rows.`)
1432
+ }
1433
+ return clean({
1434
+ summary,
1435
+ rows: outRows,
1436
+ notes,
1437
+ currencyNotes: [...CURRENCY_NOTES],
1438
+ fetchedAt: new Date().toISOString(),
1439
+ })
1440
+ }
1441
+
1442
+ /**
1443
+ * Normalize a BOM input into rows.
1444
+ * @param {unknown} input
1445
+ */
1446
+ function parseBomInput(input) {
1447
+ if (typeof input === 'string') {
1448
+ const rows = parseBomCsv(input)
1449
+ return { rows }
1450
+ }
1451
+ if (!Array.isArray(input)) {
1452
+ return { rows: [], parseError: 'bom_check expects an array of rows or CSV text' }
1453
+ }
1454
+ /** @type {{ line: number, code: string, qty: number, designators?: string, comment?: string, footprint?: string }[]} */
1455
+ const rows = []
1456
+ input.forEach((raw, index) => {
1457
+ /** @type {Record<string, unknown>} */
1458
+ let record = {}
1459
+ if (typeof raw === 'string') record = { code: raw }
1460
+ else if (isRecord(raw)) record = raw
1461
+ else return
1462
+ const lookup = lowerKeyed(record)
1463
+ const code = normalizeCode(
1464
+ lookup.code ?? lookup.lcsc ?? lookup.lcscpart ?? lookup.lcscpartnumber ?? lookup.partnumber ?? lookup.part ?? lookup['料号'] ?? lookup['编号'],
1465
+ )
1466
+ if (code === undefined) {
1467
+ rows.push({ line: index + 1, code: '', qty: 1, comment: asString(lookup.comment) })
1468
+ return
1469
+ }
1470
+ rows.push(
1471
+ clean({
1472
+ line: asNumber(lookup.line) !== undefined ? Number(lookup.line) : index + 1,
1473
+ code,
1474
+ qty: parseQty(lookup.qty ?? lookup.quantity ?? lookup['数量'] ?? lookup['用量'] ?? lookup.count),
1475
+ designators: asString(lookup.designators ?? lookup.designator ?? lookup.refdes ?? lookup['位号']),
1476
+ comment: asString(lookup.comment ?? lookup.description ?? lookup['名称']),
1477
+ footprint: asString(lookup.footprint ?? lookup.package ?? lookup['封装']),
1478
+ }),
1479
+ )
1480
+ })
1481
+ return { rows }
1482
+ }
1483
+
1484
+ /**
1485
+ * @param {{ line: number, code: string, qty: number, designators?: string, comment?: string, footprint?: string }} row
1486
+ * @param {Record<string, unknown>} detail
1487
+ */
1488
+ function bomRow(row, detail) {
1489
+ const qty = row.qty ?? 1
1490
+ if (row.code === '') {
1491
+ return clean({
1492
+ line: row.line,
1493
+ code: '',
1494
+ qty,
1495
+ designators: row.designators,
1496
+ comment: row.comment,
1497
+ status: 'NOT-FOUND',
1498
+ messages: ['row has no valid LCSC code'],
1499
+ })
1500
+ }
1501
+ const detailStatus = detail.status
1502
+ const usd = isRecord(detail.prices) && isRecord(detail.prices.usd) ? detail.prices.usd : undefined
1503
+ const cny = isRecord(detail.prices) && isRecord(detail.prices.cny) ? detail.prices.cny : undefined
1504
+ const tiers = usd !== undefined && Array.isArray(usd.tiers) ? usd.tiers : []
1505
+ const unitUsd = pickTierPrice(/** @type {{qty:number,unit:number}[]} */ (tiers), qty)
1506
+ const unitCny = cny === undefined ? undefined : asNumber(cny.unit)
1507
+ const messages = []
1508
+ if (detailStatus === 'NOT-FOUND') messages.push('LCSC code was not found in any queried source')
1509
+ if (detailStatus === 'OUT-OF-STOCK') messages.push('reported stock is zero')
1510
+ if (detailStatus === 'UNKNOWN-STOCK') messages.push('stock count is unknown; confirm before ordering')
1511
+ if (detailStatus === 'OK' && detail.stock?.available !== undefined && detail.stock.available < qty) {
1512
+ messages.push(`reported stock ${detail.stock.available} is below the required quantity ${qty}`)
1513
+ }
1514
+ let status = 'verified'
1515
+ if (detailStatus === 'NOT-FOUND') status = 'NOT-FOUND'
1516
+ else if (detailStatus === 'OUT-OF-STOCK') status = 'NO-STOCK'
1517
+ else if (detailStatus === 'UNKNOWN-STOCK') status = 'UNKNOWN-STOCK'
1518
+ else if (detail.stock?.available !== undefined && detail.stock.available < qty) status = 'NO-STOCK'
1519
+ return clean({
1520
+ line: row.line,
1521
+ code: row.code,
1522
+ qty,
1523
+ designators: row.designators,
1524
+ comment: row.comment,
1525
+ footprint: row.footprint,
1526
+ status,
1527
+ statusDetail: detailStatus,
1528
+ model: isRecord(detail.identity) ? detail.identity.model : undefined,
1529
+ availableStock: isRecord(detail.stock) ? detail.stock.available : undefined,
1530
+ stockBreakdown: isRecord(detail.stock) ? detail.stock : undefined,
1531
+ libraryType: detail.libraryType,
1532
+ unitUsd,
1533
+ subtotalUsd: unitUsd === undefined ? undefined : money(unitUsd * qty),
1534
+ unitCny,
1535
+ subtotalCny: unitCny === undefined ? undefined : money(unitCny * qty),
1536
+ usdSource: usd === undefined ? undefined : usd.source,
1537
+ cnySource: cny === undefined ? undefined : cny.source,
1538
+ datasheetUrl: detail.datasheetUrl,
1539
+ url: isRecord(detail.urls) ? detail.urls.lcsc : undefined,
1540
+ degraded: detail.degraded,
1541
+ messages,
1542
+ })
1543
+ }
1544
+
1545
+ return {
1546
+ search,
1547
+ searchRaw,
1548
+ part,
1549
+ cny: cnyQuote,
1550
+ checkBom,
1551
+ clearCache() {
1552
+ cache.clear()
1553
+ },
1554
+ cacheSize() {
1555
+ return cache.size
1556
+ },
1557
+ settings,
1558
+ }
1559
+ }
1560
+
1561
+ /* -------------------------------------------------------------------------- */
1562
+ /* Service helpers */
1563
+ /* -------------------------------------------------------------------------- */
1564
+
1565
+ /**
1566
+ * Validate/normalize the row `config`. Without a schemastery `Config` export
1567
+ * (zero dependencies), invalid values fall back to defaults and are reported
1568
+ * to the caller.
1569
+ * @param {unknown} raw
1570
+ * @param {{ onWarning?: (message: string) => void }} [options]
1571
+ * @returns {Required<Omit<LcscServiceOptions, 'fetchImpl'>> & { fetchImpl?: typeof fetch }}
1572
+ */
1573
+ export function normalizeConfig(raw, options = {}) {
1574
+ const warn = options.onWarning ?? (() => {})
1575
+ const source = isRecord(raw) ? raw : {}
1576
+ if (raw !== undefined && raw !== null && !isRecord(raw)) warn('config must be an object; using defaults')
1577
+ const known = new Set(Object.keys(DEFAULT_CONFIG))
1578
+ for (const key of Object.keys(source)) {
1579
+ if (key === 'fetchImpl') continue
1580
+ if (!known.has(key)) warn(`unknown config key ${JSON.stringify(key)} is ignored`)
1581
+ }
1582
+ /**
1583
+ * @param {string} key
1584
+ * @param {number} fallback
1585
+ * @param {number} min
1586
+ * @param {number} max
1587
+ * @param {boolean} [integer]
1588
+ */
1589
+ const number = (key, fallback, min, max, integer = true) => {
1590
+ const value = asNumber(source[key])
1591
+ if (source[key] === undefined) return fallback
1592
+ if (value === undefined || value < min || value > max || (integer && !Number.isInteger(value))) {
1593
+ warn(`config.${key}=${JSON.stringify(source[key])} is invalid; using ${fallback}`)
1594
+ return fallback
1595
+ }
1596
+ return value
1597
+ }
1598
+ const verbose = typeof source.verbose === 'boolean' ? source.verbose : DEFAULT_CONFIG.verbose
1599
+ const userAgent = asString(source.userAgent) ?? DEFAULT_USER_AGENT
1600
+ const fetchImpl = typeof source.fetchImpl === 'function' ? source.fetchImpl : undefined
1601
+ const factorValue = asNumber(source.usdToCnyFactor)
1602
+ let usdToCnyFactor = DEFAULT_CONFIG.usdToCnyFactor
1603
+ if (source.usdToCnyFactor !== undefined) {
1604
+ if (factorValue === undefined || factorValue < 0.5 || factorValue > 20) {
1605
+ warn(`config.usdToCnyFactor=${JSON.stringify(source.usdToCnyFactor)} is invalid; using ${DEFAULT_CONFIG.usdToCnyFactor}`)
1606
+ } else {
1607
+ usdToCnyFactor = factorValue
1608
+ }
1609
+ }
1610
+ const boolean = (key, fallback) => {
1611
+ if (source[key] === undefined) return fallback
1612
+ if (typeof source[key] === 'boolean') return source[key]
1613
+ warn(`config.${key}=${JSON.stringify(source[key])} is invalid; using ${fallback}`)
1614
+ return fallback
1615
+ }
1616
+ /** @param {string} key @param {number} fallback @param {number} min @param {number} max */
1617
+ const float = (key, fallback, min, max) => {
1618
+ const value = asNumber(source[key])
1619
+ if (source[key] === undefined) return fallback
1620
+ if (value === undefined || value < min || value > max) {
1621
+ warn(`config.${key}=${JSON.stringify(source[key])} is invalid; using ${fallback}`)
1622
+ return fallback
1623
+ }
1624
+ return value
1625
+ }
1626
+ return {
1627
+ timeoutMs: number('timeoutMs', DEFAULT_CONFIG.timeoutMs, 100, 60000),
1628
+ retries: number('retries', DEFAULT_CONFIG.retries, 0, 10),
1629
+ retryBaseMs: number('retryBaseMs', DEFAULT_CONFIG.retryBaseMs, 0, 10000),
1630
+ cacheTtlMs: number('cacheTtlMs', DEFAULT_CONFIG.cacheTtlMs, 0, 24 * 60 * 60 * 1000),
1631
+ cacheMaxEntries: number('cacheMaxEntries', DEFAULT_CONFIG.cacheMaxEntries, 1, 10000),
1632
+ verbose,
1633
+ userAgent,
1634
+ fetchImpl,
1635
+ usdToCnyFactor,
1636
+ cnRender: boolean('cnRender', DEFAULT_CONFIG.cnRender),
1637
+ chromePath: asString(source.chromePath) ?? DEFAULT_CONFIG.chromePath,
1638
+ cnCacheDir: asString(source.cnCacheDir) ?? DEFAULT_CONFIG.cnCacheDir,
1639
+ cnCacheTtlMs: number('cnCacheTtlMs', DEFAULT_CONFIG.cnCacheTtlMs, 0, 7 * 24 * 60 * 60 * 1000),
1640
+ cnTimeoutMs: number('cnTimeoutMs', DEFAULT_CONFIG.cnTimeoutMs, 5000, 120000),
1641
+ cnConcurrency: number('cnConcurrency', DEFAULT_CONFIG.cnConcurrency, 1, 8),
1642
+ cnBrowserIdleTtlMs: number('cnBrowserIdleTtlMs', DEFAULT_CONFIG.cnBrowserIdleTtlMs, 0, 30 * 60 * 1000),
1643
+ cnQueryBudgetMs: number('cnQueryBudgetMs', DEFAULT_CONFIG.cnQueryBudgetMs, 0, 10 * 60 * 1000),
1644
+ cnCacheMaxEntries: number('cnCacheMaxEntries', DEFAULT_CONFIG.cnCacheMaxEntries, 1, 100000),
1645
+ extendedPartFeeUsd: float('extendedPartFeeUsd', DEFAULT_CONFIG.extendedPartFeeUsd, 0, 100),
1646
+ setupFeeUsd: float('setupFeeUsd', DEFAULT_CONFIG.setupFeeUsd, 0, 10000),
1647
+ stencilFeeUsd: float('stencilFeeUsd', DEFAULT_CONFIG.stencilFeeUsd, 0, 10000),
1648
+ }
1649
+ }
1650
+
1651
+ /** @param {unknown} reason @returns {string} */
1652
+ function messageOf(reason) {
1653
+ if (reason instanceof Error) return reason.message
1654
+ return String(reason)
1655
+ }
1656
+
1657
+ /**
1658
+ * @template T
1659
+ * @param {Promise<T>} promise
1660
+ * @returns {Promise<PromiseSettledResult<T>>}
1661
+ */
1662
+ function settle(promise) {
1663
+ return promise.then(
1664
+ (value) => /** @type {PromiseFulfilledResult<T>} */ ({ status: 'fulfilled', value }),
1665
+ (reason) => /** @type {PromiseRejectedResult} */ ({ status: 'rejected', reason }),
1666
+ )
1667
+ }
1668
+
1669
+ /**
1670
+ * @param {{ status: 'fulfilled' | 'rejected', value?: unknown, reason?: unknown }} settled
1671
+ */
1672
+ function sourceState(settled) {
1673
+ if (settled.status === 'fulfilled') {
1674
+ return settled.value === undefined
1675
+ ? { ok: false, reason: 'not-found' }
1676
+ : { ok: true }
1677
+ }
1678
+ return { ok: false, reason: messageOf(settled.reason) }
1679
+ }
1680
+
1681
+ /**
1682
+ * @param {unknown} webData
1683
+ * @param {Record<string, unknown> | undefined} jlc
1684
+ * @param {Record<string, unknown> | undefined} cny
1685
+ * @param {string[]} degraded
1686
+ */
1687
+ function mergeIdentity(webData, jlc, cny, degraded) {
1688
+ const fromLcsc = isRecord(webData)
1689
+ ? {
1690
+ model: asString(webData.productModel),
1691
+ brand: asString(webData.brandNameEn),
1692
+ package: asString(webData.encapStandard),
1693
+ category: asString(webData.catalogName),
1694
+ description: asString(webData.productNameEn),
1695
+ minBuyNumber: asInteger(webData.minBuyNumber),
1696
+ unit: asString(webData.productUnit),
1697
+ }
1698
+ : {}
1699
+ const fromJlc = isRecord(jlc)
1700
+ ? {
1701
+ model: asString(jlc.model),
1702
+ brand: asString(jlc.brand),
1703
+ package: asString(jlc.package),
1704
+ category: asString(jlc.category),
1705
+ description: asString(jlc.description),
1706
+ libraryType: asString(jlc.libraryType),
1707
+ }
1708
+ : {}
1709
+ const fromCny = isRecord(cny) && cny.ok === true
1710
+ ? {
1711
+ model: asString(cny.model),
1712
+ package: asString(cny.package),
1713
+ }
1714
+ : {}
1715
+ const identity = clean({
1716
+ model: fromLcsc.model ?? fromJlc.model ?? fromCny.model,
1717
+ brand: fromLcsc.brand ?? fromJlc.brand,
1718
+ package: fromLcsc.package ?? fromJlc.package ?? fromCny.package,
1719
+ category: fromLcsc.category ?? fromJlc.category,
1720
+ description: fromLcsc.description ?? fromJlc.description,
1721
+ libraryType: fromJlc.libraryType,
1722
+ unit: fromLcsc.unit,
1723
+ minBuyNumber: fromLcsc.minBuyNumber,
1724
+ })
1725
+ if (identity.model === undefined) degraded.push('identity:model-missing')
1726
+ return identity
1727
+ }
1728
+
1729
+ /**
1730
+ * @param {Record<string, unknown> | undefined} jlc
1731
+ * @param {Record<string, unknown> | undefined} cny
1732
+ * @param {unknown} webData
1733
+ */
1734
+ function mergeAttributes(jlc, cny, webData) {
1735
+ const english = isRecord(jlc) && Array.isArray(jlc.attributes)
1736
+ ? jlc.attributes.filter(isRecord).map((attribute) => ({
1737
+ name: attribute.name,
1738
+ value: attribute.value,
1739
+ })).filter((attribute) => attribute.name !== undefined)
1740
+ : []
1741
+ const chinese = isRecord(cny) && Array.isArray(cny.params)
1742
+ ? cny.params.filter(isRecord).map((param) => ({ name: param.name, value: param.value })).filter((param) => param.name !== undefined)
1743
+ : []
1744
+ const keyAttributes = isRecord(webData) && Array.isArray(webData.productKeyAttributes)
1745
+ ? webData.productKeyAttributes.filter(isRecord).map((attribute) => ({
1746
+ name: asString(attribute.attributeNameEn) ?? asString(attribute.attributeName),
1747
+ value: asString(attribute.attributeValueEn) ?? asString(attribute.attributeValue),
1748
+ })).filter((attribute) => attribute.name !== undefined)
1749
+ : []
1750
+ return clean({ en: english.length > 0 ? english : keyAttributes, zh: chinese })
1751
+ }
1752
+
1753
+ /** @param {number} value @param {number} min @param {number} max */
1754
+ function clampInteger(value, min, max) {
1755
+ if (!Number.isFinite(value)) return min
1756
+ return Math.min(max, Math.max(min, Math.floor(value)))
1757
+ }
1758
+
1759
+ /**
1760
+ * @param {Record<string, unknown>} record
1761
+ * @returns {Record<string, unknown>}
1762
+ */
1763
+ function lowerKeyed(record) {
1764
+ /** @type {Record<string, unknown>} */
1765
+ const out = {}
1766
+ for (const [key, value] of Object.entries(record)) {
1767
+ out[key.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '')] = value
1768
+ out[key.toLowerCase()] = value
1769
+ }
1770
+ return out
1771
+ }
1772
+
1773
+ /* -------------------------------------------------------------------------- */
1774
+ /* Convenience standalone functions (used by validate.mjs) */
1775
+ /* -------------------------------------------------------------------------- */
1776
+
1777
+ /** Lazily created default service for the exported one-shot helpers. */
1778
+ let defaultService
1779
+
1780
+ /** @returns {LcscService} */
1781
+ function getDefaultService() {
1782
+ defaultService ??= createLcscService()
1783
+ return defaultService
1784
+ }
1785
+
1786
+ /**
1787
+ * @param {string} keyword
1788
+ * @param {number} [limit]
1789
+ * @param {{ signal?: AbortSignal, service?: LcscService }} [options]
1790
+ */
1791
+ export function searchParts(keyword, limit, options = {}) {
1792
+ return (options.service ?? getDefaultService()).search(keyword, limit, options)
1793
+ }
1794
+
1795
+ /**
1796
+ * @param {string} code
1797
+ * @param {{ signal?: AbortSignal, includeCny?: boolean, service?: LcscService }} [options]
1798
+ */
1799
+ export function getPart(code, options = {}) {
1800
+ return (options.service ?? getDefaultService()).part(code, options)
1801
+ }
1802
+
1803
+ /**
1804
+ * @param {unknown} input rows or CSV text
1805
+ * @param {{ signal?: AbortSignal, includeCny?: boolean, service?: LcscService }} [options]
1806
+ */
1807
+ export function checkBom(input, options = {}) {
1808
+ return (options.service ?? getDefaultService()).checkBom(input, options)
1809
+ }
1810
+