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%

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

This reverts commit 33cd1ba5c387fb5692ea56c69a7aeab66dd35b12.
Simon-Pierre Boucher committed 1 mo ago (Aug 24, 2026) parent b3a6a11

4 changed files +5 −165

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": "immunotec",
66965 + "platform": "squarespace",
66966 66966 "catalog_endpoint": "/shop?format=json",
66967 66967 "city": "",
66968 66968 "region": "",
modified fabrika/connectors/__init__.py +0 −2
@@ -7,7 +7,6 @@ from __future__ import annotations
7 7
8 8 from .base import BaseConnector
9 9 from .generic import GenericConnector
10 −from .immunotec import ImmunotecConnector
11 10 from .shopify import ShopifyConnector
12 11 from .square import SquareConnector
13 12 from .squarespace import SquarespaceConnector
@@ -17,7 +16,6 @@ from .woocommerce import WooCommerceConnector
17 16
18 17 PLATFORM_CONNECTORS: dict[str, type[BaseConnector]] = {
19 18 "shopify": ShopifyConnector,
20 − "immunotec": ImmunotecConnector, # storefront Exigo sur mesure (via Scrapfly)
21 19 "woocommerce": WooCommerceConnector,
22 20 "wordpress": WooCommerceConnector, # WordPress avec Store API active
23 21 "squarespace": SquarespaceConnector,
deleted fabrika/connectors/immunotec.py +0 −140
@@ -1,140 +0,0 @@
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 +4 −22
@@ -26,9 +26,8 @@ def available() -> bool:
26 26 return bool(os.environ.get("SCRAPFLY_API_KEY"))
27 27
28 28
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]:
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)."""
32 31 key = os.environ.get("SCRAPFLY_API_KEY")
33 32 if not key:
34 33 raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")
@@ -40,9 +39,7 @@ def _scrape(url: str, *, method: str = "GET", data: str | None = None,
40 39 params = {"key": key, "url": url, "asp": "true", "country": "ca"}
41 40 if render_js:
42 41 params["render_js"] = "true"
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)
42 + r = requests.get(API, params=params, timeout=timeout)
46 43 if r.status_code == 422:
47 44 # certaines cibles restreignent les pays de proxy Scrapfly (ex.
48 45 # boutiquesoha.com → us seulement) : l'erreur 422
@@ -57,8 +54,7 @@ def _scrape(url: str, *, method: str = "GET", data: str | None = None,
57 54 err.get("message", ""))
58 55 if m:
59 56 params["country"] = m.group(1).split(",")[0].strip().lower()
60 − r = requests.request(method, API, params=params, data=data,
61 − timeout=timeout)
57 + r = requests.get(API, params=params, timeout=timeout)
62 58 r.raise_for_status()
63 59 res = r.json().get("result", {})
64 60 content = res.get("content") or ""
@@ -70,20 +66,6 @@ def _scrape(url: str, *, method: str = "GET", data: str | None = None,
70 66 return int(res.get("status_code") or 0), content
71 67
72 68
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 −
87 69 def scrapfly_json(url: str, render_js: bool = False) -> dict | list:
88 70 status, content = scrapfly_get(url, render_js=render_js)
89 71 if status != 200:
90 72