SPB Git

spb/food-ka Public

Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com

Python 57.7% TypeScript 24.9% CSS 16.7% HTML 0.6%
5.6 KB · 129 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur Walmart Canada (walmart.ca, rayon épicerie)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# Site Next.js derrière Akamai/PerimeterX (curl direct → HTTP 418) : on lit la5# grille produits embarquée dans <script id="__NEXT_DATA__"> via Scrapfly ASP6# (pas de rendu JavaScript nécessaire — le JSON est côté serveur).7# URLs de rayon : /fr/browse/epicerie/{slug}/10019_{id} — seul l'id compte,8# le slug est décoratif ; pagination par ?page=N (55 produits/page).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import json13import re1415from ..schema import Product, parse_price16from .base import BaseConnector1718_NEXT_RE = re.compile(19    r'<script id="__NEXT_DATA__" type="application/json"[^>]*>(.*?)</script>',20    re.S)21# format dans le nom du produit : "567 g", "2 L", "12 x 355 ml"…22_SIZE_IN_NAME = re.compile(23    r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b",24    re.IGNORECASE)2526# rayons épicerie Walmart : (slug décoratif, id de catégorie, catégorie canonique)27CATEGORIES = [28    ("fruits-et-legumes-frais", "6000194327370", "Fruits et légumes"),29    ("charcuteries-et-repas-prepares", "6000194327356", "Charcuteries et fromages"),30    ("produits-laitiers-oeufs", "6000194327369", "Produits laitiers et œufs"),31    ("pains-et-boulangerie", "6000194327359", "Boulangerie"),32    ("aliments-surgeles", "6000194326337", "Surgelés"),33    ("garde-manger", "6000194326346", "Garde-manger"),34    ("boissons", "6000194326336", "Boissons"),35    ("collations-bonbons", "6000194328523", "Collations et confiseries"),36]373839class WalmartConnector(BaseConnector):40    source_id = "walmart"41    pages_per_category = 2   # 55 produits/page → ~880 produits/sync4243    # -- récupération ----------------------------------------------------------44    def _items(self, url: str) -> list[dict]:45        """Tuiles produit d'une page de rayon (JSON __NEXT_DATA__)."""46        html = self.get_scrapfly(url)47        m = _NEXT_RE.search(html)48        if not m:49            return []50        data = json.loads(m.group(1))51        try:52            sr = data["props"]["pageProps"]["initialData"]["searchResult"]53        except (KeyError, TypeError):54            return []55        items: list[dict] = []56        for stack in sr.get("itemStacks") or []:57            items.extend(stack.get("items") or [])58        return items5960    # -- transformation --------------------------------------------------------61    def _item_to_product(self, item: dict, category: str) -> Product | None:62        if item.get("__typename") not in (None, "Product"):63            return None64        pid = item.get("usItemId") or item.get("id")65        name = item.get("name") or ""66        if not pid or not name:67            return None68        info = item.get("priceInfo") or {}69        # `price` est le prix courant numérique ; linePrice le libellé affiché70        price = parse_price(item.get("price"))71        if price is None:72            price = parse_price(info.get("linePrice"))73        was = parse_price(info.get("wasPrice"))74        link = item.get("canonicalUrl") or ""75        image = (item.get("imageInfo") or {}).get("thumbnailUrl") \76            or item.get("image") or ""77        cat_path = [p.get("name") for p in78                    ((item.get("category") or {}).get("path") or [])79                    if isinstance(p, dict) and p.get("name")]80        size_m = _SIZE_IN_NAME.search(name)81        out_of_stock = item.get("isOutOfStock")82        return Product(83            source=self.source_id,84            external_id=str(pid),85            url=f"https://www.walmart.ca{link}" if link.startswith("/") else link,86            name=name,87            brand=item.get("brand") or "",88            category=category,89            category_raw=" > ".join(cat_path),90            size_label=size_m.group(1) if size_m else "",91            price=price,92            regular_price=was,93            price_label=info.get("linePriceDisplay") or info.get("linePrice") or "",94            on_sale=bool(was and price and was > price),95            in_stock=(not out_of_stock) if out_of_stock is not None else None,96            unit_price_label=info.get("unitPrice") or "",97            details={"sales_unit": item.get("salesUnitType"),98                     "seller": item.get("sellerName")},99            images=[image] if image else [],100        )101102    # -- contrat ---------------------------------------------------------------103    def fetch(self) -> list[Product]:104        products: list[Product] = []105        seen: set[str] = set()106        for slug, cat_id, category in CATEGORIES:107            for page in range(1, self.pages_per_category + 1):108                url = (f"https://www.walmart.ca/fr/browse/epicerie/"109                       f"{slug}/10019_{cat_id}?page={page}")110                try:111                    items = self._items(url)112                except Exception:   # un rayon qui casse ne bloque pas le reste113                    break114                if not items:115                    break116                new = 0117                for item in items:118                    try:119                        prod = self._item_to_product(item, category)120                    except Exception:121                        continue122                    if prod and prod.uid not in seen:123                        seen.add(prod.uid)124                        products.append(prod)125                        new += 1126                if new == 0:   # page sans nouveauté = fin du rayon127                    break128        return products129