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%
5.2 KB · 114 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/_metro.py : socle commun Metro / Super C (même plateforme)5#   Tuiles produit rendues côté serveur (data-product-code) mais site derrière6#   un anti-bot agressif -> Scrapfly (ASP). Pagination : « <allée>-page-N ».7# -----------------------------------------------------------------------------8from __future__ import annotations910import re1112from bs4 import BeautifulSoup1314from ..schema import Product, parse_price15from .base import BaseConnector1617_PER_KG_RE = re.compile(r"(\d+(?:,\d+)?)\s*\$\s*/\s*kg")1819# (slug d'allée, catégorie canonique)20AISLES = [21    ("fruits-et-legumes", "Fruits et légumes"),22    ("viandes-et-volailles", "Viandes et volailles"),23    ("poissons-et-fruits-de-mer", "Poissons et fruits de mer"),24    ("produits-laitiers-et-oeufs", "Produits laitiers et œufs"),25    ("pains-et-patisseries", "Boulangerie"),26    ("produits-surgeles", "Surgelés"),27    ("garde-manger", "Garde-manger"),28    ("boissons", "Boissons"),29    ("collations", "Collations et confiseries"),30]313233class MetroPlatformConnector(BaseConnector):34    """Base Metro/Super C — sous-classes : définir source_id et domain."""3536    domain: str = ""              # ex. "www.metro.ca"37    aisle_prefix: str = "/epicerie-en-ligne/allees"   # superc : "/allees"38    pages_per_aisle: int = 2      # ~45 produits/page3940    def _parse_tiles(self, html: str, category: str) -> list[Product]:41        soup = BeautifulSoup(html, "html.parser")42        products: list[Product] = []43        for tile in soup.select("[data-product-code]"):44            code = tile.get("data-product-code")45            name = tile.get("data-product-name") or ""46            if not code or not name:47                continue48            link_el = tile.select_one("a.product-details-link[href]")49            link = link_el["href"] if link_el else ""50            img_el = tile.select_one(".pt__visual img[src]")51            image = (img_el["src"] if img_el else "") or ""52            brand_el = tile.select_one(".head__brand")53            size_el = tile.select_one(".head__unit-details")54            price_el = tile.select_one(".price-update")55            before_el = tile.select_one(".pricing__before-price")56            unit_el = tile.select_one(".pricing__secondary-price")57            price_label = price_el.get_text(" ", strip=True) if price_el else ""58            unit_label = unit_el.get_text(" ", strip=True) if unit_el else ""59            before_txt = before_el.get_text(" ", strip=True) if before_el else ""60            price = None61            regular = parse_price(before_txt) if before_txt else None62            # Produit au poids : le prix affiché est une estimation à l'unité63            # mais le « prix régulier » est au kg — on compare tout au kg.64            if "kg" in before_txt:65                m_cur = _PER_KG_RE.search(unit_label)66                m_reg = _PER_KG_RE.search(before_txt)67                if m_cur:68                    price = float(m_cur.group(1).replace(",", "."))69                    price_label = f"{m_cur.group(1)} $ / kg"70                    regular = (float(m_reg.group(1).replace(",", "."))71                               if m_reg else None)72                else:   # pas de prix au kg comparable : ne rien inventer73                    regular = None74            on_sale = bool(tile.select_one(".pi-price-promo, .promo-price")) or regular is not None75            products.append(Product(76                source=self.source_id,77                external_id=str(code),78                url=(f"https://{self.domain}{link}" if link.startswith("/") else link),79                name=name,80                brand=brand_el.get_text(strip=True) if brand_el else "",81                category=category,82                category_raw=tile.get("data-product-category") or "",83                size_label=size_el.get_text(" ", strip=True) if size_el else "",84                price=price,85                price_label=price_label,86                regular_price=regular,87                on_sale=on_sale,88                unit_price_label=unit_label,89                images=[image] if image.startswith("http") else [],90            ))91        return products9293    def fetch(self) -> list[Product]:94        products: list[Product] = []95        seen: set[str] = set()96        for slug, category in AISLES:97            for page in range(1, self.pages_per_aisle + 1):98                suffix = slug if page == 1 else f"{slug}-page-{page}"99                url = f"https://{self.domain}{self.aisle_prefix}/{suffix}"100                try:101                    html = self.get_scrapfly(url)102                except Exception:   # une allée qui casse ne bloque pas le reste103                    break104                batch = self._parse_tiles(html, category)105                new = 0106                for prod in batch:107                    if prod.uid not in seen:108                        seen.add(prod.uid)109                        products.append(prod)110                        new += 1111                if new == 0:   # page vide ou répétée : allée épuisée112                    break113        return products114