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 Costco Canada (costco.ca, épicerie livrée)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# Nouveau catalogue Next.js (App Router) derrière Akamai : les tuiles produit5# sont montées côté client — rendu JavaScript via Scrapfly + attente du6# sélecteur [data-testid^="ProductTile"]. Chaque page feuille (ex. crackers)7# affiche une grille de tuiles avec prix ; les pages « rayon » (ex. snacks)8# ne contiennent que des vignettes de sous-catégories, on cible donc les9# feuilles directement. Numéro d'article Costco = id dans l'URL produit.10# ⚠️ Le frais (produce, dairy, meat…) est « Warehouse Only » : aucun prix en11# ligne (« Warehouse pricing may vary ») — on ne couvre que l'épicerie livrée12# (Costco Grocery, non périssable) et on ignore les tuiles sans prix.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import re1718from bs4 import BeautifulSoup1920from ..schema import Product, parse_price21from .base import BaseConnector2223# format dans le nom : "567 g", "2 × 425 g", "40 x 28 g", "1.36 kg"…24_SIZE_IN_NAME = re.compile(25 r"((?:\d+\s*[x×]\s*)?\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b",26 re.IGNORECASE)2728# pages feuilles (grille produits) de l'épicerie livrée : (slug, catégorie)29CATEGORIES = [30 ("canned-food", "Garde-manger"),31 ("cereal", "Garde-manger"),32 ("pasta", "Garde-manger"),33 ("oils-vinegars", "Garde-manger"),34 ("soup", "Garde-manger"),35 ("spreads-butters", "Garde-manger"),36 ("baking-packaged-food", "Garde-manger"),37 ("soda-soft-drinks", "Boissons"),38 ("juice", "Boissons"),39 ("ground-coffee", "Boissons"),40 ("tea", "Boissons"),41 ("crackers", "Collations et confiseries"),42 ("chocolates", "Collations et confiseries"),43 ("candy", "Collations et confiseries"),44 ("fruit-nuts", "Collations et confiseries"),45 ("organic", "Bio et santé"),46 ("laundry-detergent", "Maison et entretien"),47]484950class CostcoConnector(BaseConnector):51 source_id = "costco"52 request_delay = 1.05354 # -- extraction d'une grille -----------------------------------------------55 def _parse(self, html: str, category: str, slug: str) -> list[Product]:56 soup = BeautifulSoup(html, "html.parser")57 products: list[Product] = []58 for tile in soup.select('div[data-testid^="ProductTile_"]'):59 pid = tile.get("data-testid", "").split("_", 1)[-1]60 link = tile.select_one('a[data-testid="Link"][href]')61 if not pid or link is None:62 continue63 name = link.get_text(" ", strip=True)64 if not name:65 continue66 price_el = tile.select_one('[data-testid^="Text_Price"]')67 price = parse_price(price_el.get_text(" ", strip=True)) if price_el else None68 if price is None: # tuile « Warehouse Only » : pas de prix en ligne69 continue70 img = tile.select_one('[data-testid^="ProductImage"] img[src]')71 size_m = _SIZE_IN_NAME.search(name)72 text = tile.get_text(" ", strip=True)73 in_stock: bool | None = None74 if "Out of Stock" in text:75 in_stock = False76 elif "In Stock" in text or "Add to Cart" in text:77 in_stock = True78 href = link["href"]79 products.append(Product(80 source=self.source_id,81 external_id=str(pid),82 url=href if href.startswith("http") else f"https://www.costco.ca{href}",83 name=name,84 category=category,85 category_raw=slug.replace("-", " "),86 size_label=size_m.group(1) if size_m else "",87 price=price,88 in_stock=in_stock,89 details={"item_number": pid},90 images=[img["src"]] if img is not None else [],91 ))92 return products9394 # -- contrat ---------------------------------------------------------------95 def fetch(self) -> list[Product]:96 products: list[Product] = []97 seen: set[str] = set()98 for slug, category in CATEGORIES:99 url = f"https://www.costco.ca/{slug}.html"100 try:101 html = self.get_scrapfly(102 url, render_js=True,103 wait_for_selector='[data-testid^="ProductTile"]')104 except Exception: # une catégorie qui casse ne bloque pas le reste105 continue106 for prod in self._parse(html, category, slug):107 if prod.uid not in seen:108 seen.add(prod.uid)109 products.append(prod)110 return products111