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%
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