Forráskód Böngészése

feat: wire LcscAdapter to real LCSC facet + search API

Old scraper endpoint (lcsc.com/api/products/search) was marked Done
but never verified live -- returns Nuxt SSR shell, not JSON. Replaced
with real internal API (wmsc.lcsc.com/ftps/wm/product/query/*) found
via live traffic capture. Drops unnecessary CSRF/session handling,
adds query_facets() for filter-chip data. Verified category resolver
output plugs in directly, no id remapping needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
n2749 1 hete
szülő
commit
ad1b9b0db5
1 módosított fájl, 79 hozzáadás és 61 törlés
  1. 79 61
      bom_assistant/suppliers/lcsc/lcsc.py

+ 79 - 61
bom_assistant/suppliers/lcsc/lcsc.py

@@ -1,16 +1,50 @@
 from __future__ import annotations
 
 import json
-import re
 import urllib.request
 from typing import Any
 
 from bom_assistant.session.models import NormalizedParams
 from bom_assistant.suppliers.base import SearchResult, SupplierAdapter
 
-_SEARCH_URL = "https://lcsc.com/api/products/search"
-_SESSION_SEED_URL = "https://lcsc.com/products/Capacitors_11.html"
+_FACET_URL = "https://wmsc.lcsc.com/ftps/wm/product/query/param/group"
+_SEARCH_URL = "https://wmsc.lcsc.com/ftps/wm/product/query/list"
 _UA = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
+_PAGE_SIZE = 25
+
+_FILTER_DEFAULTS: dict[str, Any] = {
+    "keyword": "",
+    "brandIdList": [],
+    "encapValueList": [],
+    "isStock": False,
+    "isOtherSuppliers": False,
+    "isAsianBrand": False,
+    "isDeals": False,
+    "isEnvironment": False,
+    "paramNameValueMap": {},
+}
+
+
+def _build_filter_payload(category_id: int, filters: dict[str, Any] | None) -> dict[str, Any]:
+    payload = dict(_FILTER_DEFAULTS)
+    payload["catalogIdList"] = [category_id]
+    if filters:
+        payload.update(filters)
+    return payload
+
+
+def _post(url: str, payload: dict[str, Any]) -> dict[str, Any]:
+    data = json.dumps(payload).encode()
+    headers = {
+        "User-Agent": _UA,
+        "Accept": "application/json, text/plain, */*",
+        "Content-Type": "application/json;charset=utf-8",
+        "Origin": "https://www.lcsc.com",
+        "Referer": "https://www.lcsc.com/",
+    }
+    req = urllib.request.Request(url, data=data, headers=headers, method="POST")
+    with urllib.request.urlopen(req, timeout=15) as resp:
+        return json.loads(resp.read().decode())
 
 
 class LcscAdapter(SupplierAdapter):
@@ -18,17 +52,14 @@ class LcscAdapter(SupplierAdapter):
     LCSC supplier adapter.
 
     Modes:
-      api_token=None (default) — scraper mode: acquires CSRF session from a
-        category page, then POSTs to lcsc.com/api/products/search.
+      api_token=None (default) — talks to LCSC's real internal JSON API
+        (wmsc.lcsc.com/ftps/wm/product/query/*), reverse-engineered from live
+        browser traffic. No CSRF/session dance needed — plain POST with
+        User-Agent/Origin/Referer is sufficient.
       api_token=<key>          — official API mode (not yet implemented, raises
         NotImplementedError until LCSC credentials are available).
     """
 
-    def __init__(self, api_token: str | None = None) -> None:
-        super().__init__(api_token)
-        self._cookies: str | None = None
-        self._csrf: str | None = None
-
     def search(
         self,
         category_id: int,
@@ -38,68 +69,56 @@ class LcscAdapter(SupplierAdapter):
     ) -> SearchResult:
         if self.api_token:
             return self._search_official(category_id, params, filters, page)
-        return self._search_scraper(category_id, params, filters, page)
+        return self._search_live(category_id, filters, page)
+
+    def query_facets(
+        self,
+        category_id: int,
+        filters: dict[str, Any] | None = None,
+    ) -> dict[str, Any]:
+        """
+        Returns LCSC's own facet groups for this category (+ any already-applied
+        filters): {"Package": [...], "Manufacturer": [...], "Packaging": [...],
+        "paramNameValueMap": {...}}, or {"error": "..."} on failure.
+        """
+        payload = _build_filter_payload(category_id, filters)
+        try:
+            body = _post(_FACET_URL, payload)
+        except Exception as exc:
+            return {"error": str(exc)}
+        if not body.get("ok"):
+            return {"error": body.get("msg") or "facet query failed"}
+        return body.get("result", {})
 
     # ------------------------------------------------------------------
-    # Scraper mode
+    # Live API mode
     # ------------------------------------------------------------------
 
-    def _ensure_session(self) -> None:
-        if self._csrf is not None:
-            return
-        req = urllib.request.Request(_SESSION_SEED_URL, headers={"User-Agent": _UA})
-        with urllib.request.urlopen(req, timeout=15) as resp:
-            self._cookies = resp.headers.get("Set-Cookie", "")
-            html = resp.read().decode("utf-8", errors="replace")
-        m = re.search(r'csrfToken["\s:=]+(["\'])([A-Za-z0-9_\-]+)\1', html)
-        self._csrf = m.group(2) if m else ""
-
-    def _search_scraper(
+    def _search_live(
         self,
         category_id: int,
-        params: NormalizedParams,
         filters: dict[str, Any] | None,
         page: int,
     ) -> SearchResult:
+        payload = _build_filter_payload(category_id, filters)
+        payload["currentPage"] = page
+        payload["pageSize"] = _PAGE_SIZE
+
         try:
-            self._ensure_session()
-        except Exception as exc:
-            return SearchResult(count=0, page=page, page_size=25, items=[], error=f"session: {exc}")
-
-        payload: dict[str, Any] = {
-            "current_page": page,
-            "page_size": 25,
-            "catalog_id": category_id,
-            "in_stock": False,
-            "is_RoHS": False,
-            "show_icon": False,
-        }
-        if filters:
-            payload.update(filters)
-
-        data = json.dumps(payload).encode()
-        headers: dict[str, str] = {
-            "User-Agent": _UA,
-            "Content-Type": "application/json",
-            "X-CSRF-Token": self._csrf or "",
-            "Referer": f"https://lcsc.com/category/{category_id}.html",
-        }
-        if self._cookies:
-            headers["Cookie"] = self._cookies
-
-        req = urllib.request.Request(_SEARCH_URL, data=data, headers=headers, method="POST")
-        try:
-            with urllib.request.urlopen(req, timeout=15) as resp:
-                body = json.loads(resp.read().decode())
+            body = _post(_SEARCH_URL, payload)
         except Exception as exc:
-            return SearchResult(count=0, page=page, page_size=25, items=[], error=str(exc))
+            return SearchResult(count=0, page=page, page_size=_PAGE_SIZE, items=[], error=str(exc))
+
+        if not body.get("ok"):
+            error = body.get("msg") or "search failed"
+            return SearchResult(count=0, page=page, page_size=_PAGE_SIZE, items=[], error=error)
 
-        vo = body.get("data", {}).get("productSearchResultVO", {})
+        result = body.get("result", {})
         return SearchResult(
-            count=vo.get("totalCount", 0),
-            page=vo.get("currentPage", page),
-            page_size=vo.get("pageSize", 25),
-            items=vo.get("productList", []),
+            count=result.get("totalRow", 0),
+            page=result.get("currPage", page),
+            page_size=result.get("pageRow", _PAGE_SIZE),
+            items=result.get("dataList", []),
         )
 
     # ------------------------------------------------------------------
@@ -113,8 +132,7 @@ class LcscAdapter(SupplierAdapter):
         filters: dict[str, Any] | None,
         page: int,
     ) -> SearchResult:
-        # TODO: HMAC-sign request → POST https://ips.lcsc.com/rest/wmsc2agent/category/product/{category_id}
         raise NotImplementedError(
             "Official LCSC API not yet implemented. "
-            "Use LcscAdapter() without api_token to use scraper mode."
+            "Use LcscAdapter() without api_token to use the live API mode."
         )