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