SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
5 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%

Reapply "connecteur Immunotec branché : registre plateforme + scrapfly_post + platform du store"

This reverts commit d489330fe1fb653cda954309c9a02bba26d46238.
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent c127b5c

4 changed files +165 −5

modified data/stores.json +1 −1
@@ -66962,7 +66962,7 @@
66962 66962 "id": "immunotec.com",
66963 66963 "name": "Immunocal Derma - Acheter québécois",
66964 66964 "url": "https://www.immunotec.com",
66965 − "platform": "squarespace",
66965 + "platform": "immunotec",
66966 66966 "catalog_endpoint": "/shop?format=json",
66967 66967 "city": "",
66968 66968 "region": "",
modified fabrika/connectors/__init__.py +2 −0
@@ -7,6 +7,7 @@ from __future__ import annotations
7 7
8 8 from .base import BaseConnector
9 9 from .generic import GenericConnector
10 +from .immunotec import ImmunotecConnector
10 11 from .shopify import ShopifyConnector
11 12 from .square import SquareConnector
12 13 from .squarespace import SquarespaceConnector
@@ -16,6 +17,7 @@ from .woocommerce import WooCommerceConnector
16 17
17 18 PLATFORM_CONNECTORS: dict[str, type[BaseConnector]] = {
18 19 "shopify": ShopifyConnector,
20 + "immunotec": ImmunotecConnector, # storefront Exigo sur mesure (via Scrapfly)
19 21 "woocommerce": WooCommerceConnector,
20 22 "wordpress": WooCommerceConnector, # WordPress avec Store API active
21 23 "squarespace": SquarespaceConnector,
added fabrika/connectors/immunotec.py +140 −0
@@ -0,0 +1,140 @@
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
modified fabrika/connectors/scrapfly.py +22 −4
@@ -26,8 +26,9 @@ def available() -> bool:
26 26 return bool(os.environ.get("SCRAPFLY_API_KEY"))
27 27
28 28
29 −def scrapfly_get(url: str, render_js: bool = False, timeout: int = 150) -> tuple[int, str]:
30 − """GET via Scrapfly. Retourne (status_code_amont, contenu)."""
29 +def _scrape(url: str, *, method: str = "GET", data: str | None = None,
30 + headers: dict | None = None, render_js: bool = False,
31 + timeout: int = 150) -> tuple[int, str]:
31 32 key = os.environ.get("SCRAPFLY_API_KEY")
32 33 if not key:
33 34 raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")
@@ -39,7 +40,9 @@ def scrapfly_get(url: str, render_js: bool = False, timeout: int = 150) -> tuple
39 40 params = {"key": key, "url": url, "asp": "true", "country": "ca"}
40 41 if render_js:
41 42 params["render_js"] = "true"
42 − r = requests.get(API, params=params, timeout=timeout)
43 + for k, v in (headers or {}).items():
44 + params[f"headers[{k.lower()}]"] = v
45 + r = requests.request(method, API, params=params, data=data, timeout=timeout)
43 46 if r.status_code == 422:
44 47 # certaines cibles restreignent les pays de proxy Scrapfly (ex.
45 48 # boutiquesoha.com → us seulement) : l'erreur 422
@@ -54,7 +57,8 @@ def scrapfly_get(url: str, render_js: bool = False, timeout: int = 150) -> tuple
54 57 err.get("message", ""))
55 58 if m:
56 59 params["country"] = m.group(1).split(",")[0].strip().lower()
57 − r = requests.get(API, params=params, timeout=timeout)
60 + r = requests.request(method, API, params=params, data=data,
61 + timeout=timeout)
58 62 r.raise_for_status()
59 63 res = r.json().get("result", {})
60 64 content = res.get("content") or ""
@@ -66,6 +70,20 @@ def scrapfly_get(url: str, render_js: bool = False, timeout: int = 150) -> tuple
66 70 return int(res.get("status_code") or 0), content
67 71
68 72
73 +def scrapfly_get(url: str, render_js: bool = False, timeout: int = 150) -> tuple[int, str]:
74 + """GET via Scrapfly. Retourne (status_code_amont, contenu)."""
75 + return _scrape(url, render_js=render_js, timeout=timeout)
76 +
77 +
78 +def scrapfly_post(url: str, body: dict, headers: dict | None = None,
79 + timeout: int = 150) -> tuple[int, str]:
80 + """POST JSON via Scrapfly. Retourne (status_code_amont, contenu)."""
81 + hdrs = {"content-type": "application/json"}
82 + hdrs.update(headers or {})
83 + return _scrape(url, method="POST", data=json.dumps(body), headers=hdrs,
84 + timeout=timeout)
85 +
86 +
69 87 def scrapfly_json(url: str, render_js: bool = False) -> dict | list:
70 88 status, content = scrapfly_get(url, render_js=render_js)
71 89 if status != 200:
72 90