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.3 KB · 109 lines python
Raw Blame History
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