SPB Git

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%
4.3 KB · 99 lines python
Raw Blame History
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