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%
4.7 KB · 111 lines python
Raw Blame History
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