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 — 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