lcsc.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. from __future__ import annotations
  2. import json
  3. import urllib.request
  4. from typing import Any
  5. from bom_assistant.session.models import NormalizedParams
  6. from bom_assistant.suppliers.base import SearchResult, SupplierAdapter
  7. _FACET_URL = "https://wmsc.lcsc.com/ftps/wm/product/query/param/group"
  8. _SEARCH_URL = "https://wmsc.lcsc.com/ftps/wm/product/query/list"
  9. _UA = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
  10. _PAGE_SIZE = 25
  11. _FILTER_DEFAULTS: dict[str, Any] = {
  12. "keyword": "",
  13. "brandIdList": [],
  14. "encapValueList": [],
  15. "isStock": False,
  16. "isOtherSuppliers": False,
  17. "isAsianBrand": False,
  18. "isDeals": False,
  19. "isEnvironment": False,
  20. "paramNameValueMap": {},
  21. }
  22. def _build_filter_payload(category_id: int, filters: dict[str, Any] | None) -> dict[str, Any]:
  23. payload = dict(_FILTER_DEFAULTS)
  24. payload["catalogIdList"] = [category_id]
  25. if filters:
  26. payload.update(filters)
  27. return payload
  28. def _post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
  29. data = json.dumps(payload).encode()
  30. headers = {
  31. "User-Agent": _UA,
  32. "Accept": "application/json, text/plain, */*",
  33. "Content-Type": "application/json;charset=utf-8",
  34. "Origin": "https://www.lcsc.com",
  35. "Referer": "https://www.lcsc.com/",
  36. }
  37. req = urllib.request.Request(url, data=data, headers=headers, method="POST")
  38. with urllib.request.urlopen(req, timeout=15) as resp:
  39. return json.loads(resp.read().decode())
  40. class LcscAdapter(SupplierAdapter):
  41. """
  42. LCSC supplier adapter.
  43. Modes:
  44. api_token=None (default) — talks to LCSC's real internal JSON API
  45. (wmsc.lcsc.com/ftps/wm/product/query/*), reverse-engineered from live
  46. browser traffic. No CSRF/session dance needed — plain POST with
  47. User-Agent/Origin/Referer is sufficient.
  48. api_token=<key> — official API mode (not yet implemented, raises
  49. NotImplementedError until LCSC credentials are available).
  50. """
  51. def search(
  52. self,
  53. category_id: int,
  54. params: NormalizedParams,
  55. filters: dict[str, Any] | None = None,
  56. page: int = 1,
  57. ) -> SearchResult:
  58. if self.api_token:
  59. return self._search_official(category_id, params, filters, page)
  60. return self._search_live(category_id, filters, page)
  61. def query_facets(
  62. self,
  63. category_id: int,
  64. filters: dict[str, Any] | None = None,
  65. ) -> dict[str, Any]:
  66. """
  67. Returns LCSC's own facet groups for this category (+ any already-applied
  68. filters): {"Package": [...], "Manufacturer": [...], "Packaging": [...],
  69. "paramNameValueMap": {...}}, or {"error": "..."} on failure.
  70. """
  71. payload = _build_filter_payload(category_id, filters)
  72. try:
  73. body = _post(_FACET_URL, payload)
  74. except Exception as exc:
  75. return {"error": str(exc)}
  76. if not body.get("ok"):
  77. return {"error": body.get("msg") or "facet query failed"}
  78. return body.get("result", {})
  79. # ------------------------------------------------------------------
  80. # Live API mode
  81. # ------------------------------------------------------------------
  82. def _search_live(
  83. self,
  84. category_id: int,
  85. filters: dict[str, Any] | None,
  86. page: int,
  87. ) -> SearchResult:
  88. payload = _build_filter_payload(category_id, filters)
  89. payload["currentPage"] = page
  90. payload["pageSize"] = _PAGE_SIZE
  91. try:
  92. body = _post(_SEARCH_URL, payload)
  93. except Exception as exc:
  94. return SearchResult(count=0, page=page, page_size=_PAGE_SIZE, items=[], error=str(exc))
  95. if not body.get("ok"):
  96. error = body.get("msg") or "search failed"
  97. return SearchResult(count=0, page=page, page_size=_PAGE_SIZE, items=[], error=error)
  98. result = body.get("result", {})
  99. return SearchResult(
  100. count=result.get("totalRow", 0),
  101. page=result.get("currPage", page),
  102. page_size=result.get("pageRow", _PAGE_SIZE),
  103. items=result.get("dataList", []),
  104. )
  105. # ------------------------------------------------------------------
  106. # Official API mode (placeholder — needs LCSC API key + HMAC impl)
  107. # ------------------------------------------------------------------
  108. def _search_official(
  109. self,
  110. category_id: int,
  111. params: NormalizedParams,
  112. filters: dict[str, Any] | None,
  113. page: int,
  114. ) -> SearchResult:
  115. raise NotImplementedError(
  116. "Official LCSC API not yet implemented. "
  117. "Use LcscAdapter() without api_token to use the live API mode."
  118. )