dsh-plugin-lcsc 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -89,7 +89,7 @@ Host-only bundle,无 UI 面板;`engines.dsh >= 0.2.2-rc.1 < 0.3.0`。装完
89
89
  config:
90
90
  cnRender: true # 国内站 Chrome 渲染(关掉则只有 USD + 估算)
91
91
  chromePath: '' # 本机 Chrome/Chromium 路径;留空则自动嗅探(见下)
92
- cnConcurrency: 4 # 常驻 Chrome 并发标签数 1-8
92
+ cnConcurrency: 2 # 常驻 Chrome 并发标签数 1-8(WAF 阵发,建议 ≤2)
93
93
  cnBrowserIdleTtlMs: 120000 # 空闲多久关 Chrome
94
94
  cnQueryBudgetMs: 90000 # 单次 BOM 取 CNY 时间预算;超时先返回,缓存续跑
95
95
  cnCacheTtlMs: 86400000 # JSON 缓存 24h TTL
@@ -132,6 +132,38 @@ node poc/test-improvements.mjs # 联网验收(BOM v0.6 100 片 + 人民币
132
132
 
133
133
  准确性参考(2026-11-11 实测):28 个料的 BOM 数据 28/28 取到真实 CNY,手焊总价 **¥4.0791**(Recording_card 官方口径 ¥4.0793);单料如 C1581 ¥0.0239、C7434398 ¥1.097。
134
134
 
135
+ ## 已知问题(Known Issues)
136
+
137
+ ### 1. 国内站 WAF 阵发,会成批失败
138
+
139
+ `item.szlcsc.com` 在阿里云 WAF 后面,触发后一段时间内**整批**请求都会被挑战。
140
+
141
+ - **已缓解**:单料失败自动**重试 1 次**(1.5s 退避);`cnConcurrency` 默认已降到 **2**
142
+ - **失败不缓存,成功缓存 24h**:所以重跑只补失败项,已成功的直接命中缓存
143
+ - 工具输出会写清 `失败原因:szlcsc-waf-challenge×N`,并提示**稍后重试即可、不需要改配置**
144
+ - 若仍然频繁:把 `cnConcurrency` 降到 `1`
145
+
146
+ ### 2. `product-id-not-found` 是数据缺口,重试无用
147
+
148
+ 部分料号在 EasyEDA / lceda 查不到对应的国内商品 id(常见于新料、国内专属料、非零售料)。
149
+ 这类只能看 USD 价,`hints` 里会明确标注,**不要**把它当成 Chrome 配置问题去反复调。
150
+
151
+ ### 3. Chrome 必须装在本机
152
+
153
+ 真实人民币价依赖本机 Chrome/Chromium。查找顺序:`chromePath` → `CHROME_BIN` → 自动嗅探。
154
+ 找不到时 `hints` 会给出**已尝试的路径 + 修复方法**,按提示配 `chromePath` 即可,不需要改代码。
155
+
156
+ ### 4. 手焊模式下出现 NOT-FOUND 可能只是国内站暂时不可用
157
+
158
+ 判断条件是「嘉立创 SMT 没有 **且** 国内站查不到」。国内站被 WAF 挡住时,立创有货的料也会落到
159
+ `NOT-FOUND`。隔几分钟重跑通常就恢复;`hints` 会告诉你当时的真实原因。
160
+
161
+ ### 5. 0.3.3 及更早:Chrome 适配器未初始化(0.3.4 已修)
162
+
163
+ 0.3.3 的 `index.js` 漏了 `createCnAdapter` 的 import,导致 `apply()` 里创建适配器时抛
164
+ `createCnAdapter is not defined` 并被吞掉,**人民币链路整条不生效**(静默回退到旧路径)。
165
+ 0.3.4 修复,并在 `validate.mjs` 增加了接线回归断言。
166
+
135
167
  ## 局限(无法消除,只会缓解)
136
168
 
137
169
  1. 价格和库存是**快照**,不保证下单时一致;24h 缓存只提速,不改变这一点。
package/cn.js CHANGED
@@ -66,6 +66,24 @@ const DEFAULT_USER_AGENT =
66
66
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
67
67
  '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
68
68
 
69
+ /** Ali yun WAF challenge markers: page is not the product page we asked for. */
70
+ const WAF_HINT_RE = /aliyun|acw_sc__v2|_____tmd_____|验证码|captcha|challenge-platform|error code.*?5\d\d/i
71
+
72
+ /**
73
+ * True when the rendered HTML is a WAF challenge page rather than a product
74
+ * page. A page carrying `__NEXT_DATA__` is always the real one.
75
+ * @param {unknown} html
76
+ */
77
+ export function looksLikeWafChallenge(html) {
78
+ if (typeof html !== 'string' || html === '') return false
79
+ if (html.includes('__NEXT_DATA__')) return false
80
+ return WAF_HINT_RE.test(html)
81
+ }
82
+
83
+ /** One retry for transient failures; the domestic site challenges in bursts. */
84
+ const RETRY_DELAY_MS = 1500
85
+ const RETRYABLE_FAILURES = new Set(['szlcsc-waf-challenge', 'chrome-render-empty', 'page-parse-failed'])
86
+
69
87
  /**
70
88
  * How to fix a missing Chrome, phrased so an agent can act on it directly.
71
89
  * @param {{ path?: string, source?: string, tried: string[], configPath?: string, envPath?: string }} info
@@ -299,7 +317,7 @@ export function createCnAdapter(options = {}) {
299
317
  const profileDir = path.join(cacheDir, 'chrome-profile')
300
318
  const cacheTtlMs = Number.isFinite(options.cacheTtlMs) ? options.cacheTtlMs : 24 * 60 * 60 * 1000
301
319
  const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 30000
302
- const concurrency = Math.max(1, Math.min(8, Math.floor(options.concurrency ?? 4)))
320
+ const concurrency = Math.max(1, Math.min(8, Math.floor(options.concurrency ?? 2)))
303
321
  const browserIdleTtlMs = Number.isFinite(options.browserIdleTtlMs) ? options.browserIdleTtlMs : 120000
304
322
  const cacheMaxEntries = Number.isFinite(options.cacheMaxEntries) ? Math.max(1, Math.floor(options.cacheMaxEntries)) : 800
305
323
  const legacyProfileDir = path.join(cacheDir, 'legacy-profile')
@@ -699,40 +717,56 @@ export function createCnAdapter(options = {}) {
699
717
  return cached
700
718
  }
701
719
  return withSlot(async () => {
702
- try {
703
- const pid = await resolvePid(normalized)
704
- if (pid === undefined) return { ok: false, reason: 'product-id-not-found' }
705
- const html = await renderHtml(`${ITEM_URL}${pid}.html`)
706
- if (html === '') return { ok: false, reason: 'chrome-render-empty' }
707
- const parsed = parseItemHtml(html)
708
- if (parsed === undefined || parsed.price === undefined) {
709
- return { ok: false, reason: 'page-parse-failed' }
710
- }
711
- const record = {
712
- ok: true,
713
- lcsc: normalized,
714
- product_id: pid,
715
- unit: parsed.price,
716
- currency: 'CNY',
717
- stock: parsed.stock,
718
- model: parsed.model,
719
- brand: parsed.brand,
720
- package: parsed.package,
721
- min_buy: parsed.min_buy,
722
- packing: parsed.packing,
723
- unit_name: parsed.unit,
724
- category: parsed.category,
725
- desc: parsed.desc,
726
- source: 'lcsc_cn_render',
727
- fetched_at: new Date().toISOString(),
728
- cache_age_s: 0,
729
- cached: false,
720
+ /** @type {{ ok: false, reason: string, attempts: number } | undefined} */
721
+ let failure
722
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
723
+ if (attempt > 1) await sleep(RETRY_DELAY_MS)
724
+ try {
725
+ const pid = await resolvePid(normalized)
726
+ if (pid === undefined) return { ok: false, reason: 'product-id-not-found', attempts: attempt }
727
+ const html = await renderHtml(`${ITEM_URL}${pid}.html`)
728
+ if (html === '') {
729
+ failure = { ok: false, reason: 'chrome-render-empty', attempts: attempt }
730
+ continue
731
+ }
732
+ if (looksLikeWafChallenge(html)) {
733
+ failure = { ok: false, reason: 'szlcsc-waf-challenge', attempts: attempt }
734
+ continue
735
+ }
736
+ const parsed = parseItemHtml(html)
737
+ if (parsed === undefined || parsed.price === undefined) {
738
+ failure = { ok: false, reason: 'page-parse-failed', attempts: attempt }
739
+ continue
740
+ }
741
+ const record = {
742
+ ok: true,
743
+ lcsc: normalized,
744
+ product_id: pid,
745
+ unit: parsed.price,
746
+ currency: 'CNY',
747
+ stock: parsed.stock,
748
+ model: parsed.model,
749
+ brand: parsed.brand,
750
+ package: parsed.package,
751
+ min_buy: parsed.min_buy,
752
+ packing: parsed.packing,
753
+ unit_name: parsed.unit,
754
+ category: parsed.category,
755
+ desc: parsed.desc,
756
+ source: 'lcsc_cn_render',
757
+ fetched_at: new Date().toISOString(),
758
+ cache_age_s: 0,
759
+ cached: false,
760
+ }
761
+ writeCache(normalized, record)
762
+ return record
763
+ } catch (error) {
764
+ const reason = error instanceof Error ? error.message : String(error)
765
+ failure = { ok: false, reason, attempts: attempt }
766
+ if (!RETRYABLE_FAILURES.has(reason)) return failure
730
767
  }
731
- writeCache(normalized, record)
732
- return record
733
- } catch (error) {
734
- return { ok: false, reason: error instanceof Error ? error.message : String(error) }
735
768
  }
769
+ return failure ?? { ok: false, reason: 'cn-query-failed', attempts: 2 }
736
770
  })
737
771
  }
738
772
 
package/cordis.patch.yml CHANGED
@@ -18,7 +18,7 @@
18
18
  # chromePath: 'C:\Program Files\Google\Chrome\Application\chrome.exe'
19
19
  cnCacheTtlMs: 86400000
20
20
  cnTimeoutMs: 30000
21
- cnConcurrency: 4
21
+ cnConcurrency: 2
22
22
  cnBrowserIdleTtlMs: 120000
23
23
  cnQueryBudgetMs: 90000
24
24
  cnCacheMaxEntries: 800
package/data.js CHANGED
@@ -86,7 +86,7 @@ export const DEFAULT_CONFIG = Object.freeze({
86
86
  cnCacheDir: null,
87
87
  cnCacheTtlMs: 86400000,
88
88
  cnTimeoutMs: 30000,
89
- cnConcurrency: 4,
89
+ cnConcurrency: 2,
90
90
  cnBrowserIdleTtlMs: 120000,
91
91
  cnQueryBudgetMs: 90000,
92
92
  cnCacheMaxEntries: 800,
package/index.js CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  parseBomCsvDetailed,
30
30
  pickTierPrice,
31
31
  } from './data.js'
32
+ import { createCnAdapter } from './cn.js'
32
33
 
33
34
  /** Plugin id used by the DSH loader. */
34
35
  export const name = 'lcsc-parts'
@@ -1296,18 +1297,32 @@ function cnFailureHint(results, adapter) {
1296
1297
  .map(([reason, count]) => `${reason}×${count}`)
1297
1298
  .join(',')
1298
1299
  const parts = [`国内站人民币价 ${total - failed.length}/${total} 成功,失败原因:${breakdown}。`]
1300
+ const transient = ['szlcsc-waf-challenge', 'chrome-render-empty', 'page-parse-failed'].filter((reason) => counts.has(reason))
1299
1301
  if (counts.has('chrome-not-found')) {
1300
1302
  parts.push(
1301
1303
  typeof adapter?.chrome?.hint === 'string'
1302
1304
  ? adapter.chrome.hint
1303
1305
  : '未找到 Chrome/Chromium:请配置 chromePath 或设置环境变量 CHROME_BIN 后重试。',
1304
1306
  )
1305
- } else if (adapter?.chrome?.path !== undefined) {
1306
- parts.push(`当前 Chrome:${adapter.chrome.path}(来源 ${adapter.chrome.source ?? 'auto'})。`)
1307
+ } else if (transient.length > 0) {
1308
+ parts.push(
1309
+ '以上失败属阵发问题(阿里云 WAF 限流或页面渲染为空),已自动重试 1 次;' +
1310
+ '稍后重试即可,不需要改配置。成功的料号已写入 24h 缓存,重跑只补失败项。' +
1311
+ '若频繁出现,可把 cnConcurrency 降到 1(默认 2)以减少触发。',
1312
+ )
1313
+ }
1314
+ if (counts.has('product-id-not-found')) {
1315
+ parts.push('部分料号在 EasyEDA 查不到商品 id 映射(常见于新料或国内专属料),重试无效,只能参考 USD 价。')
1316
+ }
1317
+ if (counts.has('cny-adapter-unavailable')) {
1318
+ parts.push('人民币适配器未初始化(Chrome 渲染器未挂载),请检查插件是否完整安装及宿主日志。')
1307
1319
  }
1308
1320
  if (counts.has('cn-budget-exceeded')) {
1309
1321
  parts.push('部分料号超出时间预算未查完;结果已写入 24h 缓存,再调用一次会更快补齐。')
1310
1322
  }
1323
+ if (adapter?.chrome?.path !== undefined && counts.has('chrome-not-found') === false) {
1324
+ parts.push(`当前 Chrome:${adapter.chrome.path}(来源 ${adapter.chrome.source ?? 'auto'})。`)
1325
+ }
1311
1326
  return parts.join('')
1312
1327
  }
1313
1328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-lcsc",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Requirement-driven LCSC/JLC part finder and BOM cost auditor for DeepSeek Harness agents: real CNY+USD dual pricing, basic-vs-extended library fees, DNP/hand placement, net-saving alternatives",
5
5
  "keywords": [
6
6
  "dsh-plugin",