# ----------------------------------------------------------------------------- # Food-Ka — connecteur Costco Canada (costco.ca, épicerie livrée) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # Nouveau catalogue Next.js (App Router) derrière Akamai : les tuiles produit # sont montées côté client — rendu JavaScript via Scrapfly + attente du # sélecteur [data-testid^="ProductTile"]. Chaque page feuille (ex. crackers) # affiche une grille de tuiles avec prix ; les pages « rayon » (ex. snacks) # ne contiennent que des vignettes de sous-catégories, on cible donc les # feuilles directement. Numéro d'article Costco = id dans l'URL produit. # ⚠️ Le frais (produce, dairy, meat…) est « Warehouse Only » : aucun prix en # ligne (« Warehouse pricing may vary ») — on ne couvre que l'épicerie livrée # (Costco Grocery, non périssable) et on ignore les tuiles sans prix. # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Product, parse_price from .base import BaseConnector # format dans le nom : "567 g", "2 × 425 g", "40 x 28 g", "1.36 kg"… _SIZE_IN_NAME = re.compile( r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b", re.IGNORECASE) # pages feuilles (grille produits) de l'épicerie livrée : (slug, catégorie) CATEGORIES = [ ("canned-food", "Garde-manger"), ("cereal", "Garde-manger"), ("pasta", "Garde-manger"), ("oils-vinegars", "Garde-manger"), ("soup", "Garde-manger"), ("spreads-butters", "Garde-manger"), ("baking-packaged-food", "Garde-manger"), ("soda-soft-drinks", "Boissons"), ("juice", "Boissons"), ("ground-coffee", "Boissons"), ("tea", "Boissons"), ("crackers", "Collations et confiseries"), ("chocolates", "Collations et confiseries"), ("candy", "Collations et confiseries"), ("fruit-nuts", "Collations et confiseries"), ("organic", "Bio et santé"), ("laundry-detergent", "Maison et entretien"), ] class CostcoConnector(BaseConnector): source_id = "costco" request_delay = 1.0 # -- extraction d'une grille ----------------------------------------------- def _parse(self, html: str, category: str, slug: str) -> list[Product]: soup = BeautifulSoup(html, "html.parser") products: list[Product] = [] for tile in soup.select('div[data-testid^="ProductTile_"]'): pid = tile.get("data-testid", "").split("_", 1)[-1] link = tile.select_one('a[data-testid="Link"][href]') if not pid or link is None: continue name = link.get_text(" ", strip=True) if not name: continue price_el = tile.select_one('[data-testid^="Text_Price"]') price = parse_price(price_el.get_text(" ", strip=True)) if price_el else None if price is None: # tuile « Warehouse Only » : pas de prix en ligne continue img = tile.select_one('[data-testid^="ProductImage"] img[src]') size_m = _SIZE_IN_NAME.search(name) text = tile.get_text(" ", strip=True) in_stock: bool | None = None if "Out of Stock" in text: in_stock = False elif "In Stock" in text or "Add to Cart" in text: in_stock = True href = link["href"] products.append(Product( source=self.source_id, external_id=str(pid), url=href if href.startswith("http") else f"https://www.costco.ca{href}", name=name, category=category, category_raw=slug.replace("-", " "), size_label=size_m.group(1) if size_m else "", price=price, in_stock=in_stock, details={"item_number": pid}, images=[img["src"]] if img is not None else [], )) return products # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() for slug, category in CATEGORIES: url = f"https://www.costco.ca/{slug}.html" try: html = self.get_scrapfly( url, render_js=True, wait_for_selector='[data-testid^="ProductTile"]') except Exception: # une catégorie qui casse ne bloque pas le reste continue for prod in self._parse(html, category, slug): if prod.uid not in seen: seen.add(prod.uid) products.append(prod) return products