# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/_loblaw.py : socle commun des bannières Loblaw (Maxi, Provigo) # Les sites Loblaw (Next.js) embarquent la grille produits complète dans # ', re.S) # sous-catégories standard du réseau Loblaw : (slug, code, catégorie canonique) CATEGORIES = [ ("fruits-et-legumes", 28000, "Fruits et légumes"), ("viandes", 27998, "Viandes et volailles"), ("poissons-et-fruits-de-mer", 28004, "Poissons et fruits de mer"), ("charcuteries-et-fromages", 28131, "Charcuteries et fromages"), ("produits-laitiers-et-oeufs", 28008, "Produits laitiers et œufs"), ("boulangerie", 28020, "Boulangerie"), ("surgeles", 28195, "Surgelés"), ("garde-manger", 28016, "Garde-manger"), ("boissons", 28051, "Boissons"), ] class LoblawConnector(BaseConnector): """Base des bannières Loblaw — sous-classes : définir source_id et domain.""" domain: str = "" # ex. "www.maxi.ca" pages_per_category: int = 2 # 48 produits/page ; ~860 produits/sync def _next_data(self, url: str) -> dict | None: html = self.get_scrapfly(url) m = _NEXT_RE.search(html) return json.loads(m.group(1)) if m else None def _tiles(self, data: dict) -> tuple[list[dict], bool]: """(tuiles produit, hasMore) — collecte récursive : selon la page (racine ou sous-catégorie), Loblaw embarque une ou plusieurs grilles `productTiles`, avec ou sans bloc `pagination`.""" tiles: list[dict] = [] has_more = False def walk(o): nonlocal has_more if isinstance(o, dict): if "productTiles" in o: tiles.extend(o.get("productTiles") or []) if (o.get("pagination") or {}).get("hasMore"): has_more = True return for v in o.values(): walk(v) elif isinstance(o, list): for v in o: walk(v) try: walk(data["props"]["pageProps"]["initialData"]["layout"]["sections"]) except (KeyError, TypeError): pass return tiles, has_more def _tile_to_product(self, tile: dict, category: str, category_raw: str) -> Product | None: pid = tile.get("productId") if not pid: return None pricing = tile.get("pricing") or {} price = parse_price(pricing.get("price")) was = parse_price(pricing.get("wasPrice")) images = [img.get("largeUrl") or img.get("imageUrl") for img in tile.get("productImage") or [] if isinstance(img, dict)] link = tile.get("link") or "" badges = [] deal = tile.get("deal") or {} if isinstance(deal, dict) and deal.get("name"): badges.append(str(deal["name"])) return Product( source=self.source_id, external_id=str(pid), url=f"https://{self.domain}{link}" if link.startswith("/") else link, name=tile.get("title") or "", brand=tile.get("brand") or "", category=category, category_raw=category_raw, size_label=tile.get("packageSizing") or "", price=price, regular_price=was, price_label=pricing.get("displayPrice") or "", on_sale=bool(was and price and was > price), in_stock=None, keywords=badges, details={"article": tile.get("articleNumber"), "uom": tile.get("uom")}, images=[i for i in images if i], ) def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() for slug, code, category in CATEGORIES: for page in range(1, self.pages_per_category + 1): url = f"https://{self.domain}/fr/{slug}/c/{code}?page={page}" try: data = self._next_data(url) except Exception: # une catégorie qui casse ne bloque pas le reste break if not data: break tiles, has_more = self._tiles(data) new = 0 for tile in tiles: try: prod = self._tile_to_product(tile, category, slug.replace("-", " ")) except Exception: continue if prod and prod.uid not in seen: seen.add(prod.uid) products.append(prod) new += 1 # sans bloc pagination : on s'arrête dès qu'une page ne # rapporte plus de nouveaux produits if not has_more and new == 0: break return products