# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/_shopify.py : socle commun des épiceries sur Shopify # Toute boutique Shopify expose son catalogue public en JSON : # /products.json?limit=250&page=N (ou /collections/{handle}/products.json # pour cibler des rayons précis). Aucun anti-bot, requests direct suffit. # ----------------------------------------------------------------------------- from __future__ import annotations import html import re from ..schema import Product, normalize_category from .base import BaseConnector _TAG_RE = re.compile(r"<[^>]+>") def _strip_html(text: str | None, max_len: int = 500) -> str: """Retire les balises HTML d'un body_html Shopify et tronque (~500 car.).""" if not text: return "" clean = html.unescape(_TAG_RE.sub(" ", text)) clean = re.sub(r"\s+", " ", clean).strip() if len(clean) > max_len: clean = clean[:max_len].rsplit(" ", 1)[0] + "…" return clean class ShopifyConnector(BaseConnector): """Base des épiceries Shopify — sous-classes : définir source_id et domain. Par défaut on pagine /products.json (catalogue complet). Si le champ `product_type` de la boutique est vide ou inutilisable, une sous-classe peut définir `collections` : liste de (handle, catégorie canonique) — on pagine alors /collections/{handle}/products.json à la place. Une catégorie canonique vide ("") signifie : déduire du product_type. """ domain: str = "" # ex. "www.supermarchepa.com" max_pages: int = 8 # 250 produits/page collections: list[tuple[str, str]] = [] # [(handle, catégorie canonique)] # -- récupération ----------------------------------------------------------- def _page(self, url: str) -> list[dict]: """Une page de produits JSON ; liste vide si la page n'existe pas.""" resp = self.get(url, headers={"Accept": "application/json"}) data = resp.json() return data.get("products") or [] # -- mapping Shopify -> Product ---------------------------------------------- def _pick_variant(self, product: dict) -> dict: """Première variante disponible, sinon la première tout court.""" variants = product.get("variants") or [] for v in variants: if v.get("available"): return v return variants[0] if variants else {} def _to_product(self, product: dict, category: str = "") -> Product | None: pid = product.get("id") handle = product.get("handle") or "" if not pid or not handle: return None variant = self._pick_variant(product) # prix : la variante porte le prix courant ; compare_at_price est le # prix régulier quand le produit est en solde. try: price = float(variant.get("price")) if variant.get("price") else None except (TypeError, ValueError): price = None regular = None try: cap = variant.get("compare_at_price") if cap and price is not None and float(cap) > price: regular = float(cap) except (TypeError, ValueError): regular = None # format : titre de variante s'il est réel, sinon le poids en grammes size_label = "" vtitle = variant.get("title") or "" if vtitle and vtitle != "Default Title": size_label = vtitle elif variant.get("grams"): size_label = f"{variant['grams']} g" tags = [str(t) for t in product.get("tags") or [] if t] category_raw = product.get("product_type") or " ".join(tags) return Product( source=self.source_id, external_id=str(pid), url=f"https://{self.domain}/products/{handle}", name=html.unescape(product.get("title") or ""), brand=product.get("vendor") or "", category=category or normalize_category(category_raw), category_raw=category_raw, size_label=size_label, price=price, regular_price=regular, on_sale=bool(regular is not None), in_stock=(bool(variant["available"]) if variant.get("available") is not None else None), description=_strip_html(product.get("body_html")), keywords=tags, images=[img["src"] for img in product.get("images") or [] if isinstance(img, dict) and img.get("src")], ) # -- contrat ------------------------------------------------------------------ def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() # sources à paginer : catalogue complet, ou collections ciblées sources: list[tuple[str, str]] = ( [(f"https://{self.domain}/collections/{handle}/products.json", cat) for handle, cat in self.collections] or [(f"https://{self.domain}/products.json", "")]) for base_url, category in sources: for page in range(1, self.max_pages + 1): try: batch = self._page(f"{base_url}?limit=250&page={page}") except Exception: # une collection qui casse ne bloque pas le reste break if not batch: break for raw in batch: try: prod = self._to_product(raw, category) except Exception: continue if prod and prod.name and prod.uid not in seen: seen.add(prod.uid) products.append(prod) return products