| 1 |
|
−# ----------------------------------------------------------------------------- |
| 2 |
|
−# Fabri-Ka — Agrégateur de produits québécois |
| 3 |
|
−# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 |
|
−# connectors/immunotec.py : storefront sur mesure Immunotec (Vaudreuil-Dorion) — |
| 5 |
|
−# Next.js + backend Exigo (scus-back2.immunotec.com). Le site et son API sont |
| 6 |
|
−# derrière un challenge Cloudflare : tout passe par Scrapfly ASP. Catalogue |
| 7 |
|
−# public = « mur produits » de la catégorie « Tous les produits » |
| 8 |
|
−# (POST categories/product-wall, header api-key "/"). Les prix n'existent que |
| 9 |
|
−# dans cette API (rendu client), jamais dans le HTML SSR ni en JSON-LD. |
| 10 |
|
−# ----------------------------------------------------------------------------- |
| 11 |
|
−from __future__ import annotations |
| 12 |
|
− |
| 13 |
|
−import json |
| 14 |
|
−import re |
| 15 |
|
−import unicodedata |
| 16 |
|
− |
| 17 |
|
−from ..schema import Product, parse_price |
| 18 |
|
−from .base import BaseConnector |
| 19 |
|
−from .scrapfly import scrapfly_get, scrapfly_post |
| 20 |
|
− |
| 21 |
|
−API_BASE = "https://scus-back2.immunotec.com/" |
| 22 |
|
−WALL_PATH = "categories/product-wall" |
| 23 |
|
−API_HEADERS = {"api-key": "/"} # valeur littérale codée dans le bundle du site |
| 24 |
|
−# webCategoryId de « Tous les produits » (pageProps.productCategory de |
| 25 |
|
−# /fr-CA/products) — redécouvert dynamiquement si le mur revient vide |
| 26 |
|
−ALL_CATEGORY_ID = 2139 |
| 27 |
|
−LOC_RE = re.compile(r"<loc>\s*(.*?)\s*</loc>", re.I | re.S) |
| 28 |
|
− |
| 29 |
|
− |
| 30 |
|
−def _slugify(s: str) -> str: |
| 31 |
|
− s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode() |
| 32 |
|
− return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s.lower())).strip("-") |
| 33 |
|
− |
| 34 |
|
− |
| 35 |
|
−def _compact(s: str) -> str: |
| 36 |
|
− return re.sub(r"[^a-z0-9]", "", (s or "").lower()) |
| 37 |
|
− |
| 38 |
|
− |
| 39 |
|
−class ImmunotecConnector(BaseConnector): |
| 40 |
|
− platform = "immunotec" |
| 41 |
|
− |
| 42 |
|
− def _wall(self, category_id: int, culture: str) -> list[dict]: |
| 43 |
|
− body = {"countryCode": "CA", "cultureCode": culture, |
| 44 |
|
− "categoryId": category_id, "isConsultant": False, |
| 45 |
|
− "downlineCustomerId": 0, "isShareCart": False, |
| 46 |
|
− "isEnrollment": False} |
| 47 |
|
− status, content = scrapfly_post(API_BASE + WALL_PATH, body, |
| 48 |
|
− headers=API_HEADERS) |
| 49 |
|
− if status != 200: |
| 50 |
|
− raise RuntimeError(f"immunotec product-wall {status}") |
| 51 |
|
− data = json.loads(content) |
| 52 |
|
− return ((data.get("records") or {}).get("items") or []) if data.get("status") else [] |
| 53 |
|
− |
| 54 |
|
− def _all_category_id(self) -> int: |
| 55 |
|
− """Relit le webCategoryId « Tous les produits » depuis la page du mur.""" |
| 56 |
|
− status, html = scrapfly_get(f"{self.base}/fr-CA/products") |
| 57 |
|
− if status == 200: |
| 58 |
|
− m = re.search(r'"slug":\s*"all".{0,400}?"customerWebCategoryId":\s*"?(\d+)', html) \ |
| 59 |
|
− or re.search(r'"customerWebCategoryId":\s*"?(\d+)"?.{0,400}?"slug":\s*"all"', html) |
| 60 |
|
− if m: |
| 61 |
|
− return int(m.group(1)) |
| 62 |
|
− return ALL_CATEGORY_ID |
| 63 |
|
− |
| 64 |
|
− def _product_slugs(self) -> dict[str, str]: |
| 65 |
|
− """Slugs fr-CA du sitemap produits (slug -> URL de fiche).""" |
| 66 |
|
− url = f"{self.base}/products-sitemap.xml" |
| 67 |
|
− xml = "" |
| 68 |
|
− try: |
| 69 |
|
− r = self.session.get(url, timeout=self.timeout) |
| 70 |
|
− if r.status_code == 200: |
| 71 |
|
− xml = r.text |
| 72 |
|
− except Exception: # noqa: BLE001 — Cloudflare : on escalade |
| 73 |
|
− pass |
| 74 |
|
− if "<loc>" not in xml: |
| 75 |
|
− try: |
| 76 |
|
− status, xml = scrapfly_get(url) |
| 77 |
|
− if status != 200: |
| 78 |
|
− xml = "" |
| 79 |
|
− except Exception: # noqa: BLE001 — les fiches sont optionnelles |
| 80 |
|
− xml = "" |
| 81 |
|
− return {loc.rsplit("/", 1)[-1]: loc |
| 82 |
|
− for loc in LOC_RE.findall(xml) if "/fr-CA/products/" in loc} |
| 83 |
|
− |
| 84 |
|
− @staticmethod |
| 85 |
|
− def _match_slug(item: dict, titles: list[str], slugs: dict[str, str]) -> str | None: |
| 86 |
|
− sku_digits = (item.get("sku") or "").lstrip("0") |
| 87 |
|
− cands = [_slugify(t) for t in titles if t] |
| 88 |
|
− for c in cands: |
| 89 |
|
− if c in slugs: |
| 90 |
|
− return slugs[c] |
| 91 |
|
− if len(sku_digits) >= 4: |
| 92 |
|
− for slug, loc in slugs.items(): |
| 93 |
|
− if sku_digits in _compact(slug): |
| 94 |
|
− return loc |
| 95 |
|
− for c in cands: |
| 96 |
|
− cc = _compact(c) |
| 97 |
|
− if len(cc) < 6: |
| 98 |
|
− continue |
| 99 |
|
− for slug, loc in slugs.items(): |
| 100 |
|
− sc = _compact(slug) |
| 101 |
|
− if len(sc) >= 6 and (cc.startswith(sc) or sc.startswith(cc)): |
| 102 |
|
− return loc |
| 103 |
|
− return None |
| 104 |
|
− |
| 105 |
|
− def fetch(self) -> list[Product]: |
| 106 |
|
− items = self._wall(ALL_CATEGORY_ID, "fr-CA") |
| 107 |
|
− if not items: |
| 108 |
|
− items = self._wall(self._all_category_id(), "fr-CA") |
| 109 |
|
− if not items: |
| 110 |
|
− return [] |
| 111 |
|
− # titres anglais : les slugs de fiches sont dérivés des noms EN |
| 112 |
|
− try: |
| 113 |
|
− en_titles = {i.get("sku"): (i.get("titleDescription") or i.get("name")) |
| 114 |
|
− for i in self._wall(ALL_CATEGORY_ID, "en-CA")} |
| 115 |
|
− except Exception: # noqa: BLE001 — enrichissement d'URL seulement |
| 116 |
|
− en_titles = {} |
| 117 |
|
− slugs = self._product_slugs() |
| 118 |
|
− wall_url = f"{self.base}/fr-CA/products" |
| 119 |
|
− out: list[Product] = [] |
| 120 |
|
− for it in items: |
| 121 |
|
− sku = str(it.get("sku") or "").strip() |
| 122 |
|
− if not sku: |
| 123 |
|
− continue |
| 124 |
|
− title = it.get("titleDescription") or it.get("name") or "" |
| 125 |
|
− price = parse_price(it.get("priceRetail") or it.get("price")) |
| 126 |
|
− url = self._match_slug(it, [title, en_titles.get(sku, "")], slugs) \ |
| 127 |
|
− if slugs else None |
| 128 |
|
− out.append(Product( |
| 129 |
|
− store_id=self.store_id, |
| 130 |
|
− external_id=sku, |
| 131 |
|
− url=url or wall_url, |
| 132 |
|
− title=title, |
| 133 |
|
− description=it.get("description") or "", |
| 134 |
|
− price=price, |
| 135 |
|
− price_max=price, |
| 136 |
|
− images=[it["imageUrl"]] if it.get("imageUrl") else [], |
| 137 |
|
− product_type=it.get("itemType") or "", |
| 138 |
|
− available=True, |
| 139 |
|
− )) |
| 140 |
|
− return out |