# ----------------------------------------------------------------------------- # Food-Ka — connecteur Épiceries LOCO (epicerie-loco-en-ligne.square.site — # épicerie écologique zéro déchet, Montréal) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # Square Online (SPA Vue) : aucun prix dans le HTML statique ni d'API catalogue # publique — les grilles /shop// sont rendues côté client. Stratégie : # Scrapfly render_js sur une sélection de catégories alimentaires (?limit=120, # une page suffit : ~176 produits alimentaires au total), cartes # div.product-group (titre p.w-product-title, prix « $7.30 »). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Product from .base import BaseConnector BASE = "https://epicerie-loco-en-ligne.square.site" # /product//?cp=true&category_id=… -> id (numérique ou uid Square) _ID_RE = re.compile(r"/product/[^/]+/([^/?#]+)") _PRICE_RE = re.compile(r"\$\s*(\d{1,4}(?:[.,]\d{1,2})?)") # catégories Square Online de LOCO : (slug/id, taxonomie source, catégorie canonique) COLLECTIONS = [ ("Fruit/26", "Fruits", "Fruits et légumes"), ("Legume/27", "Légumes", "Fruits et légumes"), ("produits-laitiers-et-oeufs/29", "Produits laitiers et oeufs", "Produits laitiers et œufs"), ("riz-grains-et-legumineuses/16", "Riz, graines et légumineuses", "Garde-manger"), ("Cereales-et-granola/18", "Céréales, granola et flocons d'avoine", "Garde-manger"), ("Colllations/31", "Collations", "Collations et confiseries"), ("Breuvage/24", "Breuvages", "Boissons"), ("noix/39", "Noix", "Collations et confiseries"), ("tartinade-et-beurre-de-noix/22", "Tartinades et beurres de noix", "Garde-manger"), ("Pret-a-manger/25", "Prêt-à-manger", "Prêt-à-manger"), ] class LocoConnector(BaseConnector): source_id = "loco" request_delay = 1.0 page_limit: int = 120 # produits par grille (?limit=…) — 1 requête/catégorie # -- extraction d'une grille de catégorie ----------------------------------- def _parse(self, html: str, category_raw: str, category: str) -> list[Product]: soup = BeautifulSoup(html, "html.parser") products: list[Product] = [] for card in soup.select("div.product-group"): link = card.select_one('a[href^="/product/"]') if link is None: continue m = _ID_RE.search(link.get("href", "")) if m is None: continue title = card.select_one("p.w-product-title") name = title.get_text(" ", strip=True) if title else "" if not name: img_alt = card.select_one("img[alt]") name = str(img_alt["alt"]).strip() if img_alt else "" if not name: continue # prix « $7.30 » ou plage de formats vrac « $4.55 - $6.55 » # (prix du plus petit format) ; un solde affiche un prix barré # dans un élément /. price_el = card.select_one(".product-price__wrapper") or card price_label = price_el.get_text(" ", strip=True) amounts = [float(v.replace(",", ".")) for v in _PRICE_RE.findall(price_label)] if not amounts: continue # carte sans prix affiché : on l'écarte price = min(amounts) struck = price_el.select_one("s, del") regular = (max(amounts) if struck is not None and max(amounts) > price else None) low = card.get_text(" ", strip=True).lower() in_stock: bool | None = None if "épuisé" in low or "sold out" in low: in_stock = False img = card.select_one("img[src]") image = str(img["src"]) if img else "" href = str(link["href"]).split("?", 1)[0] products.append(Product( source=self.source_id, external_id=m.group(1), url=f"{BASE}{href}", name=name, category=category, category_raw=category_raw, price=price, regular_price=regular, price_label=price_label, on_sale=regular is not None, in_stock=in_stock, images=[image] if image.startswith("http") else [], )) return products # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() for slug, category_raw, category in COLLECTIONS: url = (f"{BASE}/shop/{slug}?page=1&limit={self.page_limit}" "&sort_by=category_order&sort_order=asc") try: html = self.get_scrapfly( url, render_js=True, wait_for_selector="a.product-image__link") except Exception: # une catégorie qui casse ne bloque pas le reste continue for prod in self._parse(html, category_raw, category): if prod.uid not in seen: seen.add(prod.uid) products.append(prod) return products