# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/immunotec.py : storefront sur mesure Immunotec (Vaudreuil-Dorion) — # Next.js + backend Exigo (scus-back2.immunotec.com). Le site et son API sont # derrière un challenge Cloudflare : tout passe par Scrapfly ASP. Catalogue # public = « mur produits » de la catégorie « Tous les produits » # (POST categories/product-wall, header api-key "/"). Les prix n'existent que # dans cette API (rendu client), jamais dans le HTML SSR ni en JSON-LD. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import unicodedata from ..schema import Product, parse_price from .base import BaseConnector from .scrapfly import scrapfly_get, scrapfly_post API_BASE = "https://scus-back2.immunotec.com/" WALL_PATH = "categories/product-wall" API_HEADERS = {"api-key": "/"} # valeur littérale codée dans le bundle du site # webCategoryId de « Tous les produits » (pageProps.productCategory de # /fr-CA/products) — redécouvert dynamiquement si le mur revient vide ALL_CATEGORY_ID = 2139 LOC_RE = re.compile(r"\s*(.*?)\s*", re.I | re.S) def _slugify(s: str) -> str: s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode() return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", s.lower())).strip("-") def _compact(s: str) -> str: return re.sub(r"[^a-z0-9]", "", (s or "").lower()) class ImmunotecConnector(BaseConnector): platform = "immunotec" def _wall(self, category_id: int, culture: str) -> list[dict]: body = {"countryCode": "CA", "cultureCode": culture, "categoryId": category_id, "isConsultant": False, "downlineCustomerId": 0, "isShareCart": False, "isEnrollment": False} status, content = scrapfly_post(API_BASE + WALL_PATH, body, headers=API_HEADERS) if status != 200: raise RuntimeError(f"immunotec product-wall {status}") data = json.loads(content) return ((data.get("records") or {}).get("items") or []) if data.get("status") else [] def _all_category_id(self) -> int: """Relit le webCategoryId « Tous les produits » depuis la page du mur.""" status, html = scrapfly_get(f"{self.base}/fr-CA/products") if status == 200: m = re.search(r'"slug":\s*"all".{0,400}?"customerWebCategoryId":\s*"?(\d+)', html) \ or re.search(r'"customerWebCategoryId":\s*"?(\d+)"?.{0,400}?"slug":\s*"all"', html) if m: return int(m.group(1)) return ALL_CATEGORY_ID def _product_slugs(self) -> dict[str, str]: """Slugs fr-CA du sitemap produits (slug -> URL de fiche).""" url = f"{self.base}/products-sitemap.xml" xml = "" try: r = self.session.get(url, timeout=self.timeout) if r.status_code == 200: xml = r.text except Exception: # noqa: BLE001 — Cloudflare : on escalade pass if "" not in xml: try: status, xml = scrapfly_get(url) if status != 200: xml = "" except Exception: # noqa: BLE001 — les fiches sont optionnelles xml = "" return {loc.rsplit("/", 1)[-1]: loc for loc in LOC_RE.findall(xml) if "/fr-CA/products/" in loc} @staticmethod def _match_slug(item: dict, titles: list[str], slugs: dict[str, str]) -> str | None: sku_digits = (item.get("sku") or "").lstrip("0") cands = [_slugify(t) for t in titles if t] for c in cands: if c in slugs: return slugs[c] if len(sku_digits) >= 4: for slug, loc in slugs.items(): if sku_digits in _compact(slug): return loc for c in cands: cc = _compact(c) if len(cc) < 6: continue for slug, loc in slugs.items(): sc = _compact(slug) if len(sc) >= 6 and (cc.startswith(sc) or sc.startswith(cc)): return loc return None def fetch(self) -> list[Product]: items = self._wall(ALL_CATEGORY_ID, "fr-CA") if not items: items = self._wall(self._all_category_id(), "fr-CA") if not items: return [] # titres anglais : les slugs de fiches sont dérivés des noms EN try: en_titles = {i.get("sku"): (i.get("titleDescription") or i.get("name")) for i in self._wall(ALL_CATEGORY_ID, "en-CA")} except Exception: # noqa: BLE001 — enrichissement d'URL seulement en_titles = {} slugs = self._product_slugs() wall_url = f"{self.base}/fr-CA/products" out: list[Product] = [] for it in items: sku = str(it.get("sku") or "").strip() if not sku: continue title = it.get("titleDescription") or it.get("name") or "" price = parse_price(it.get("priceRetail") or it.get("price")) url = self._match_slug(it, [title, en_titles.get(sku, "")], slugs) \ if slugs else None out.append(Product( store_id=self.store_id, external_id=sku, url=url or wall_url, title=title, description=it.get("description") or "", price=price, price_max=price, images=[it["imageUrl"]] if it.get("imageUrl") else [], product_type=it.get("itemType") or "", available=True, )) return out