# -----------------------------------------------------------------------------
# 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