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.8 KB · 120 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur Mayrand (mayrand.ca — grossiste alimentaire)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# HubSpot CMS : chaque page de rayon (/fr/nos-produits/<rayon>) rend TOUS les5# produits côté serveur (la pagination est purement JavaScript) — un seul GET6# par rayon suffit. Cartes : .product-card-wrapper (prix, format, image, SKU).7# -----------------------------------------------------------------------------8from __future__ import annotations910import re1112from bs4 import BeautifulSoup1314from ..schema import Product, parse_price15from .base import BaseConnector1617BASE = "https://mayrand.ca"1819# (slug de rayon, catégorie canonique)20DEPARTMENTS = [21    ("fruits-et-legumes", "Fruits et légumes"),22    ("boucherie", "Viandes et volailles"),23    ("poissonnerie", "Poissons et fruits de mer"),24    ("charcuterie", "Charcuteries et fromages"),25    ("produits-laitiers", "Produits laitiers et œufs"),26    ("boulangerie", "Boulangerie"),27    ("surgele", "Surgelés"),28    ("epicerie", "Garde-manger"),29    ("boisson", "Boissons"),30]3132_PAREN_RE = re.compile(r"\(([^)]+)\)")     # « caisse (11.34kg) » -> « 11.34kg »33_SKU_RE = re.compile(r"-(\d+)/?$")         # slug produit : ...-<sku>343536class MayrandConnector(BaseConnector):37    source_id = "mayrand"3839    max_departments: int | None = None     # borne pour les tests40    max_per_department: int = 50           # ~450 produits/synchro au total4142    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.product-card-wrapper"):46            link = card.select_one("a.product_link[href]")47            if link is None:48                continue49            name = link.get_text(" ", strip=True)50            href = link["href"]51            sku_el = card.select_one(".product_id")52            sku = sku_el.get_text(strip=True) if sku_el else ""53            if not sku:54                m = _SKU_RE.search(href)55                sku = m.group(1) if m else ""56            if not name or not sku:57                continue5859            # premier bloc de prix (« unité » avant « caisse » quand les deux existent)60            price_el = card.select_one(".unit_price")61            regular = None62            price_label = ""63            if price_el is not None:64                deleted = price_el.select_one("del.price-discount")65                if deleted is not None:               # en solde : <span> prix, <del> régulier66                    regular = parse_price(deleted.get_text(" ", strip=True))67                    span = price_el.select_one("span")68                    price_label = span.get_text(" ", strip=True) if span else ""69                else:70                    price_label = price_el.get_text(" ", strip=True)7172            qty_el = card.select_one(".unit_quantity")73            qty_text = qty_el.get_text(" ", strip=True) if qty_el else ""74            m = _PAREN_RE.search(qty_text)75            size_label = m.group(1) if m else ""7677            unit_el = card.select_one(".unit-price-ref")78            img = card.select_one("img.product_image[src]")79            image = str(img["src"]) if img else ""80            brand = (card.get("data-brand") or "").strip()81            if brand.lower() == "null":82                brand = ""8384            products.append(Product(85                source=self.source_id,86                external_id=sku,87                url=f"{BASE}/fr/nos-produits/{href.lstrip('/')}",88                name=name,89                brand=brand,90                category=category,91                category_raw=slug,92                size_label=size_label,93                price_label=price_label,94                regular_price=regular,95                on_sale=card.get("data-sale") == "OnSale",96                unit_price_label=unit_el.get_text(" ", strip=True) if unit_el else "",97                details={"format": qty_text} if qty_text else {},98                images=[image] if image.startswith("http") else [],99            ))100        return products101102    def fetch(self) -> list[Product]:103        products: list[Product] = []104        seen: set[str] = set()105        departments = DEPARTMENTS[: self.max_departments]106        for slug, category in departments:107            url = f"{BASE}/fr/nos-produits/{slug}"108            try:109                html = self.get(url).text110            except Exception:   # un rayon qui casse ne bloque pas le reste111                continue112            kept = 0113            for prod in self._parse_cards(html, category, slug):114                if prod.uid in seen or kept >= self.max_per_department:115                    continue116                seen.add(prod.uid)117                products.append(prod)118                kept += 1119        return products120