dsh-plugin-lcsc 0.3.4 → 0.3.6

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