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 Maturin (maturin.ca — produits québécois en ligne)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# Site maison rendu côté serveur : /categorie/<cat>?page=N (24 cartes/page,5# défilement infini côté client = simple paramètre page). Cartes6# .masonry-product : titre, producteur (marque), prix « À partir de X$ / fmt »,7# image /image/crop/....8# -----------------------------------------------------------------------------9from __future__ import annotations1011import re1213from bs4 import BeautifulSoup1415from ..schema import Product, parse_price16from .base import BaseConnector1718BASE = "https://maturin.ca"1920# (slug de catégorie, catégorie canonique)21CATEGORIES = [22 ("fruits-et-legumes", "Fruits et légumes"),23 ("boucherie", "Viandes et volailles"),24 ("poissonnerie", "Poissons et fruits de mer"),25 ("produits-laitiers", "Produits laitiers et œufs"),26 ("boulangerie-et-desserts", "Boulangerie"),27 ("garde-manger", "Garde-manger"),28 ("breuvages", "Boissons"),29 ("mets-cuisines", "Prêt-à-manger"),30]3132# « À partir de 7.60$ / 350g » -> (prix, format)33_PRICE_FMT_RE = re.compile(r"(\d+(?:[.,]\d{1,2})?\s*\$)(?:\s*/\s*(\S+))?")343536class MaturinConnector(BaseConnector):37 source_id = "maturin"3839 max_categories: int | None = None # borne pour les tests40 pages_per_category: int = 2 # 24 cartes/page -> ~384 produits max4142 def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]:43 soup = BeautifulSoup(html, "html.parser")44 products: list[Product] = []45 for card in soup.select("div.masonry-product[data-product]"):46 pid = str(card.get("data-product") or "")47 title = card.select_one(".product-title a[href]")48 if not pid or title is None:49 continue50 name = title.get_text(" ", strip=True)51 if not name:52 continue53 brand_el = card.select_one(".product-subtitle a")5455 price_label, size_label = "", ""56 price_el = card.select_one(".product-price span")57 if price_el is not None:58 text = price_el.get_text(" ", strip=True)59 m = _PRICE_FMT_RE.search(text)60 if m:61 price_label = m.group(1)62 size_label = m.group(2) or ""6364 img = card.select_one("img.product-image")65 src = str((img.get("data-src") or img.get("src") or "")) if img else ""66 if src.startswith("/") and "none.png" not in src:67 image = f"{BASE}{src}"68 else:69 image = src if src.startswith("http") else ""7071 href = str(title["href"])72 products.append(Product(73 source=self.source_id,74 external_id=pid,75 url=f"{BASE}{href}" if href.startswith("/") else href,76 name=name,77 brand=brand_el.get_text(" ", strip=True) if brand_el else "",78 category=category,79 category_raw=slug,80 size_label=size_label,81 price=parse_price(price_label),82 price_label=price_label,83 images=[image] if image else [],84 ))85 return products8687 def fetch(self) -> list[Product]:88 products: list[Product] = []89 seen: set[str] = set()90 categories = CATEGORIES[: self.max_categories]91 for slug, category in categories:92 for page in range(1, self.pages_per_category + 1):93 url = f"{BASE}/categorie/{slug}"94 if page > 1:95 url += f"?url=categorie/{slug}&page={page}"96 try:97 html = self.get(url).text98 except Exception: # une page qui casse ne bloque pas le reste99 break100 new = 0101 for prod in self._parse_cards(html, category, slug):102 if prod.uid not in seen:103 seen.add(prod.uid)104 products.append(prod)105 new += 1106 if new == 0: # page vide ou répétée : catégorie épuisée107 break108 return products109