# ----------------------------------------------------------------------------- # Food-Ka — connecteur Maturin (maturin.ca — produits québécois en ligne) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # Site maison rendu côté serveur : /categorie/?page=N (24 cartes/page, # défilement infini côté client = simple paramètre page). Cartes # .masonry-product : titre, producteur (marque), prix « À partir de X$ / fmt », # image /image/crop/.... # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Product, parse_price from .base import BaseConnector BASE = "https://maturin.ca" # (slug de catégorie, catégorie canonique) CATEGORIES = [ ("fruits-et-legumes", "Fruits et légumes"), ("boucherie", "Viandes et volailles"), ("poissonnerie", "Poissons et fruits de mer"), ("produits-laitiers", "Produits laitiers et œufs"), ("boulangerie-et-desserts", "Boulangerie"), ("garde-manger", "Garde-manger"), ("breuvages", "Boissons"), ("mets-cuisines", "Prêt-à-manger"), ] # « À partir de 7.60$ / 350g » -> (prix, format) _PRICE_FMT_RE = re.compile(r"(\d+(?:[.,]\d{1,2})?\s*\$)(?:\s*/\s*(\S+))?") class MaturinConnector(BaseConnector): source_id = "maturin" max_categories: int | None = None # borne pour les tests pages_per_category: int = 2 # 24 cartes/page -> ~384 produits max def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]: soup = BeautifulSoup(html, "html.parser") products: list[Product] = [] for card in soup.select("div.masonry-product[data-product]"): pid = str(card.get("data-product") or "") title = card.select_one(".product-title a[href]") if not pid or title is None: continue name = title.get_text(" ", strip=True) if not name: continue brand_el = card.select_one(".product-subtitle a") price_label, size_label = "", "" price_el = card.select_one(".product-price span") if price_el is not None: text = price_el.get_text(" ", strip=True) m = _PRICE_FMT_RE.search(text) if m: price_label = m.group(1) size_label = m.group(2) or "" img = card.select_one("img.product-image") src = str((img.get("data-src") or img.get("src") or "")) if img else "" if src.startswith("/") and "none.png" not in src: image = f"{BASE}{src}" else: image = src if src.startswith("http") else "" href = str(title["href"]) products.append(Product( source=self.source_id, external_id=pid, url=f"{BASE}{href}" if href.startswith("/") else href, name=name, brand=brand_el.get_text(" ", strip=True) if brand_el else "", category=category, category_raw=slug, size_label=size_label, price=parse_price(price_label), price_label=price_label, images=[image] if image else [], )) return products def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() categories = CATEGORIES[: self.max_categories] for slug, category in categories: for page in range(1, self.pages_per_category + 1): url = f"{BASE}/categorie/{slug}" if page > 1: url += f"?url=categorie/{slug}&page={page}" try: html = self.get(url).text except Exception: # une page qui casse ne bloque pas le reste break new = 0 for prod in self._parse_cards(html, category, slug): if prod.uid not in seen: seen.add(prod.uid) products.append(prod) new += 1 if new == 0: # page vide ou répétée : catégorie épuisée break return products