|
|
@@ -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."
|
|
|
)
|