product_lookup.py 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. #!/usr/bin/env python3
  2. """
  3. Ground-truth helper: fetch a real LCSC product's actual category by part number.
  4. Scrapes https://www.lcsc.com/product-detail/{part}.html, which embeds a plain
  5. JSON blob in <script id="__NEXT_DATA__">. The relevant fields live at
  6. props.pageProps.webData: `catalogId`/`catalogName` (leaf category — matches
  7. the `id`/`name` used in categories.json) and `parentCatalogId`/`parentCatalogName`.
  8. Usage (library):
  9. from bom_assistant.suppliers.lcsc.product_lookup import lookup_product
  10. info = lookup_product("C437643")
  11. # ProductInfo(part="C437643", category_id=1140, category_name="Aluminum Electrolytic Capacitors",
  12. # parent_category_name="Capacitors", product_name="CAP ALUM 330uF ±20% 63V TH")
  13. """
  14. from __future__ import annotations
  15. import json
  16. import random
  17. import re
  18. import time
  19. import urllib.request
  20. from dataclasses import dataclass
  21. _DETAIL_URL = "https://www.lcsc.com/product-detail/{part}.html"
  22. _UA = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
  23. _GRACE = 1.5
  24. _JITTER = 0.5
  25. _RETRY_WAIT = 5.0
  26. _NEXT_DATA_RE = re.compile(
  27. r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.DOTALL
  28. )
  29. @dataclass
  30. class ProductInfo:
  31. part: str
  32. category_id: int
  33. category_name: str
  34. parent_category_name: str
  35. product_name: str
  36. def _fetch(url: str) -> str | None:
  37. req = urllib.request.Request(
  38. url, headers={"User-Agent": _UA, "Accept-Language": "en-US,en;q=0.9"}
  39. )
  40. for attempt in range(2):
  41. try:
  42. with urllib.request.urlopen(req, timeout=15) as resp:
  43. return resp.read().decode("utf-8", errors="replace")
  44. except Exception as exc:
  45. if attempt == 0:
  46. print(f" warn: {exc} — retrying in {_RETRY_WAIT}s")
  47. time.sleep(_RETRY_WAIT)
  48. else:
  49. return None
  50. return None
  51. def sleep_between_requests() -> None:
  52. time.sleep(max(0.5, _GRACE + random.uniform(-_JITTER, _JITTER)))
  53. def lookup_product(part: str) -> ProductInfo | None:
  54. """Fetch the real LCSC category for a part number. Returns None on 404/parse failure."""
  55. html = _fetch(_DETAIL_URL.format(part=part))
  56. if html is None:
  57. return None
  58. m = _NEXT_DATA_RE.search(html)
  59. if not m:
  60. return None
  61. try:
  62. data = json.loads(m.group(1))
  63. web_data = data["props"]["pageProps"].get("webData")
  64. except (json.JSONDecodeError, KeyError):
  65. return None
  66. if not web_data or not web_data.get("catalogId"):
  67. return None
  68. return ProductInfo(
  69. part=part,
  70. category_id=web_data["catalogId"],
  71. category_name=web_data.get("catalogName", ""),
  72. parent_category_name=web_data.get("parentCatalogName", ""),
  73. product_name=web_data.get("productNameEn", ""),
  74. )
  75. if __name__ == "__main__":
  76. import sys
  77. for p in sys.argv[1:]:
  78. info = lookup_product(p)
  79. print(f"{p}: {info}")
  80. sleep_between_requests()