# ----------------------------------------------------------------------------- # Food-Ka — connecteur Mayrand (mayrand.ca — grossiste alimentaire) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # HubSpot CMS : chaque page de rayon (/fr/nos-produits/) rend TOUS les # produits côté serveur (la pagination est purement JavaScript) — un seul GET # par rayon suffit. Cartes : .product-card-wrapper (prix, format, image, SKU). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Product, parse_price from .base import BaseConnector BASE = "https://mayrand.ca" # (slug de rayon, catégorie canonique) DEPARTMENTS = [ ("fruits-et-legumes", "Fruits et légumes"), ("boucherie", "Viandes et volailles"), ("poissonnerie", "Poissons et fruits de mer"), ("charcuterie", "Charcuteries et fromages"), ("produits-laitiers", "Produits laitiers et œufs"), ("boulangerie", "Boulangerie"), ("surgele", "Surgelés"), ("epicerie", "Garde-manger"), ("boisson", "Boissons"), ] _PAREN_RE = re.compile(r"\(([^)]+)\)") # « caisse (11.34kg) » -> « 11.34kg » _SKU_RE = re.compile(r"-(\d+)/?$") # slug produit : ...- class MayrandConnector(BaseConnector): source_id = "mayrand" max_departments: int | None = None # borne pour les tests max_per_department: int = 50 # ~450 produits/synchro au total 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.product-card-wrapper"): link = card.select_one("a.product_link[href]") if link is None: continue name = link.get_text(" ", strip=True) href = link["href"] sku_el = card.select_one(".product_id") sku = sku_el.get_text(strip=True) if sku_el else "" if not sku: m = _SKU_RE.search(href) sku = m.group(1) if m else "" if not name or not sku: continue # premier bloc de prix (« unité » avant « caisse » quand les deux existent) price_el = card.select_one(".unit_price") regular = None price_label = "" if price_el is not None: deleted = price_el.select_one("del.price-discount") if deleted is not None: # en solde : prix, régulier regular = parse_price(deleted.get_text(" ", strip=True)) span = price_el.select_one("span") price_label = span.get_text(" ", strip=True) if span else "" else: price_label = price_el.get_text(" ", strip=True) qty_el = card.select_one(".unit_quantity") qty_text = qty_el.get_text(" ", strip=True) if qty_el else "" m = _PAREN_RE.search(qty_text) size_label = m.group(1) if m else "" unit_el = card.select_one(".unit-price-ref") img = card.select_one("img.product_image[src]") image = str(img["src"]) if img else "" brand = (card.get("data-brand") or "").strip() if brand.lower() == "null": brand = "" products.append(Product( source=self.source_id, external_id=sku, url=f"{BASE}/fr/nos-produits/{href.lstrip('/')}", name=name, brand=brand, category=category, category_raw=slug, size_label=size_label, price_label=price_label, regular_price=regular, on_sale=card.get("data-sale") == "OnSale", unit_price_label=unit_el.get_text(" ", strip=True) if unit_el else "", details={"format": qty_text} if qty_text else {}, images=[image] if image.startswith("http") else [], )) return products def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() departments = DEPARTMENTS[: self.max_departments] for slug, category in departments: url = f"{BASE}/fr/nos-produits/{slug}" try: html = self.get(url).text except Exception: # un rayon qui casse ne bloque pas le reste continue kept = 0 for prod in self._parse_cards(html, category, slug): if prod.uid in seen or kept >= self.max_per_department: continue seen.add(prod.uid) products.append(prod) kept += 1 return products