dsh-plugin-lcsc 0.3.4 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/data.js CHANGED
@@ -1,1816 +1,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
- })
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
+ })
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
+