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