lcsc.py 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. from __future__ import annotations
  2. import json
  3. import re
  4. import urllib.request
  5. from typing import Any
  6. from bom_assistant.session.models import NormalizedParams
  7. from bom_assistant.suppliers.base import SearchResult, SupplierAdapter
  8. _SEARCH_URL = "https://lcsc.com/api/products/search"
  9. _SESSION_SEED_URL = "https://lcsc.com/products/Capacitors_11.html"
  10. _UA = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
  11. class LcscAdapter(SupplierAdapter):
  12. """
  13. LCSC supplier adapter.
  14. Modes:
  15. api_token=None (default) — scraper mode: acquires CSRF session from a
  16. category page, then POSTs to lcsc.com/api/products/search.
  17. api_token=<key> — official API mode (not yet implemented, raises
  18. NotImplementedError until LCSC credentials are available).
  19. """
  20. def __init__(self, api_token: str | None = None) -> None:
  21. super().__init__(api_token)
  22. self._cookies: str | None = None
  23. self._csrf: str | None = None
  24. def search(
  25. self,
  26. category_id: int,
  27. params: NormalizedParams,
  28. filters: dict[str, Any] | None = None,
  29. page: int = 1,
  30. ) -> SearchResult:
  31. if self.api_token:
  32. return self._search_official(category_id, params, filters, page)
  33. return self._search_scraper(category_id, params, filters, page)
  34. # ------------------------------------------------------------------
  35. # Scraper mode
  36. # ------------------------------------------------------------------
  37. def _ensure_session(self) -> None:
  38. if self._csrf is not None:
  39. return
  40. req = urllib.request.Request(_SESSION_SEED_URL, headers={"User-Agent": _UA})
  41. with urllib.request.urlopen(req, timeout=15) as resp:
  42. self._cookies = resp.headers.get("Set-Cookie", "")
  43. html = resp.read().decode("utf-8", errors="replace")
  44. m = re.search(r'csrfToken["\s:=]+(["\'])([A-Za-z0-9_\-]+)\1', html)
  45. self._csrf = m.group(2) if m else ""
  46. def _search_scraper(
  47. self,
  48. category_id: int,
  49. params: NormalizedParams,
  50. filters: dict[str, Any] | None,
  51. page: int,
  52. ) -> SearchResult:
  53. try:
  54. self._ensure_session()
  55. except Exception as exc:
  56. return SearchResult(count=0, page=page, page_size=25, items=[], error=f"session: {exc}")
  57. payload: dict[str, Any] = {
  58. "current_page": page,
  59. "page_size": 25,
  60. "catalog_id": category_id,
  61. "in_stock": False,
  62. "is_RoHS": False,
  63. "show_icon": False,
  64. }
  65. if filters:
  66. payload.update(filters)
  67. data = json.dumps(payload).encode()
  68. headers: dict[str, str] = {
  69. "User-Agent": _UA,
  70. "Content-Type": "application/json",
  71. "X-CSRF-Token": self._csrf or "",
  72. "Referer": f"https://lcsc.com/category/{category_id}.html",
  73. }
  74. if self._cookies:
  75. headers["Cookie"] = self._cookies
  76. req = urllib.request.Request(_SEARCH_URL, data=data, headers=headers, method="POST")
  77. try:
  78. with urllib.request.urlopen(req, timeout=15) as resp:
  79. body = json.loads(resp.read().decode())
  80. except Exception as exc:
  81. return SearchResult(count=0, page=page, page_size=25, items=[], error=str(exc))
  82. vo = body.get("data", {}).get("productSearchResultVO", {})
  83. return SearchResult(
  84. count=vo.get("totalCount", 0),
  85. page=vo.get("currentPage", page),
  86. page_size=vo.get("pageSize", 25),
  87. items=vo.get("productList", []),
  88. )
  89. # ------------------------------------------------------------------
  90. # Official API mode (placeholder — needs LCSC API key + HMAC impl)
  91. # ------------------------------------------------------------------
  92. def _search_official(
  93. self,
  94. category_id: int,
  95. params: NormalizedParams,
  96. filters: dict[str, Any] | None,
  97. page: int,
  98. ) -> SearchResult:
  99. # TODO: HMAC-sign request → POST https://ips.lcsc.com/rest/wmsc2agent/category/product/{category_id}
  100. raise NotImplementedError(
  101. "Official LCSC API not yet implemented. "
  102. "Use LcscAdapter() without api_token to use scraper mode."
  103. )