spb/fabri-ka Public
Agrégateur de produits québécois — www.fabri-ka.com
HTML 57.9%
Python 18.6%
TypeScript 15.6%
CSS 7.8%
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/shopify.py : connecteur générique Shopify — catalogue complet5# via l'endpoint public /products.json (paginé, 250 produits/page).6# -----------------------------------------------------------------------------7from __future__ import annotations89import threading10import time1112from ..schema import Product, parse_price13from .base import BaseConnector1415# Shopify applique une limite de débit PAR IP CLIENTE partagée entre toutes les16# boutiques derrière son CDN : on sérialise donc les requêtes Shopify17# globalement (toutes boutiques confondues) + retry sur 429.18_GLOBAL_LOCK = threading.Lock()19_MIN_INTERVAL = 0.720_last_shopify_req = [0.0]212223class ShopifyConnector(BaseConnector):24 platform = "shopify"2526 def _get_json(self, url: str) -> dict:27 """GET JSON via curl (l'empreinte TLS de python-requests déclenche le28 429 de Shopify sous volume ; curl passe). Throttle global + retry."""29 import json as _json30 import subprocess31 last_err = None32 for attempt in range(4):33 with _GLOBAL_LOCK:34 wait = _MIN_INTERVAL - (time.time() - _last_shopify_req[0])35 if wait > 0:36 time.sleep(wait)37 _last_shopify_req[0] = time.time()38 p = subprocess.run(39 ["curl", "-sS", "--compressed", "--max-time", str(self.timeout),40 "-A", self.session.headers["User-Agent"].split(" FabriKaBot")[0],41 "-w", "\n%{http_code}", url],42 capture_output=True, text=True, errors="replace")43 body, _, code = p.stdout.rpartition("\n")44 if code == "200":45 try:46 return _json.loads(body)47 except _json.JSONDecodeError as exc:48 last_err = exc49 elif code == "429":50 time.sleep(5 * (attempt + 1))51 last_err = RuntimeError("429 Too Many Requests")52 else:53 last_err = RuntimeError(f"HTTP {code or 'error'} {p.stderr[:120]}")54 time.sleep(2)55 # dernier recours : Scrapfly (anti-bot) si configuré56 from . import scrapfly57 if scrapfly.available():58 try:59 return scrapfly.scrapfly_json(url)60 except Exception as exc:61 last_err = exc62 raise RuntimeError(f"{url}: {last_err}")6364 def fetch(self) -> list[Product]:65 out: list[Product] = []66 page = 167 while page <= self.max_pages:68 data = self._get_json(f"{self.base}/products.json?limit=250&page={page}")69 items = data.get("products", [])70 if not items:71 break72 for it in items:73 variants = it.get("variants") or []74 prices = [parse_price(v.get("price")) for v in variants]75 prices = [p for p in prices if p]76 compare = [parse_price(v.get("compare_at_price")) for v in variants]77 compare = [c for c in compare if c]78 available = any(v.get("available", True) for v in variants) if variants else None79 out.append(Product(80 store_id=self.store_id,81 external_id=str(it["id"]),82 url=f"{self.base}/products/{it.get('handle','')}",83 title=it.get("title", ""),84 description=it.get("body_html", "") or "",85 price=min(prices) if prices else None,86 price_max=max(prices) if prices else None,87 compare_at_price=max(compare) if compare else None,88 images=[im.get("src", "") for im in (it.get("images") or [])],89 product_type=it.get("product_type", "") or "",90 tags=(it.get("tags") if isinstance(it.get("tags"), list)91 else [t.strip() for t in (it.get("tags") or "").split(",") if t.strip()]),92 vendor=it.get("vendor", "") or "",93 available=available,94 ))95 if len(items) < 250:96 break97 page += 198 return out99