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 Épiceries LOCO (epicerie-loco-en-ligne.square.site —3# épicerie écologique zéro déchet, Montréal)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# Square Online (SPA Vue) : aucun prix dans le HTML statique ni d'API catalogue6# publique — les grilles /shop/<slug>/<id> sont rendues côté client. Stratégie :7# Scrapfly render_js sur une sélection de catégories alimentaires (?limit=120,8# une page suffit : ~176 produits alimentaires au total), cartes9# div.product-group (titre p.w-product-title, prix « $7.30 »).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Product18from .base import BaseConnector1920BASE = "https://epicerie-loco-en-ligne.square.site"2122# /product/<slug>/<id>?cp=true&category_id=… -> id (numérique ou uid Square)23_ID_RE = re.compile(r"/product/[^/]+/([^/?#]+)")24_PRICE_RE = re.compile(r"\$\s*(\d{1,4}(?:[.,]\d{1,2})?)")2526# catégories Square Online de LOCO : (slug/id, taxonomie source, catégorie canonique)27COLLECTIONS = [28 ("Fruit/26", "Fruits", "Fruits et légumes"),29 ("Legume/27", "Légumes", "Fruits et légumes"),30 ("produits-laitiers-et-oeufs/29", "Produits laitiers et oeufs",31 "Produits laitiers et œufs"),32 ("riz-grains-et-legumineuses/16", "Riz, graines et légumineuses",33 "Garde-manger"),34 ("Cereales-et-granola/18", "Céréales, granola et flocons d'avoine",35 "Garde-manger"),36 ("Colllations/31", "Collations", "Collations et confiseries"),37 ("Breuvage/24", "Breuvages", "Boissons"),38 ("noix/39", "Noix", "Collations et confiseries"),39 ("tartinade-et-beurre-de-noix/22", "Tartinades et beurres de noix",40 "Garde-manger"),41 ("Pret-a-manger/25", "Prêt-à-manger", "Prêt-à-manger"),42]434445class LocoConnector(BaseConnector):46 source_id = "loco"47 request_delay = 1.04849 page_limit: int = 120 # produits par grille (?limit=…) — 1 requête/catégorie5051 # -- extraction d'une grille de catégorie -----------------------------------52 def _parse(self, html: str, category_raw: str, category: str) -> list[Product]:53 soup = BeautifulSoup(html, "html.parser")54 products: list[Product] = []55 for card in soup.select("div.product-group"):56 link = card.select_one('a[href^="/product/"]')57 if link is None:58 continue59 m = _ID_RE.search(link.get("href", ""))60 if m is None:61 continue62 title = card.select_one("p.w-product-title")63 name = title.get_text(" ", strip=True) if title else ""64 if not name:65 img_alt = card.select_one("img[alt]")66 name = str(img_alt["alt"]).strip() if img_alt else ""67 if not name:68 continue69 # prix « $7.30 » ou plage de formats vrac « $4.55 - $6.55 »70 # (prix du plus petit format) ; un solde affiche un prix barré71 # dans un élément <s>/<del>.72 price_el = card.select_one(".product-price__wrapper") or card73 price_label = price_el.get_text(" ", strip=True)74 amounts = [float(v.replace(",", "."))75 for v in _PRICE_RE.findall(price_label)]76 if not amounts:77 continue # carte sans prix affiché : on l'écarte78 price = min(amounts)79 struck = price_el.select_one("s, del")80 regular = (max(amounts)81 if struck is not None and max(amounts) > price else None)8283 low = card.get_text(" ", strip=True).lower()84 in_stock: bool | None = None85 if "épuisé" in low or "sold out" in low:86 in_stock = False8788 img = card.select_one("img[src]")89 image = str(img["src"]) if img else ""90 href = str(link["href"]).split("?", 1)[0]9192 products.append(Product(93 source=self.source_id,94 external_id=m.group(1),95 url=f"{BASE}{href}",96 name=name,97 category=category,98 category_raw=category_raw,99 price=price,100 regular_price=regular,101 price_label=price_label,102 on_sale=regular is not None,103 in_stock=in_stock,104 images=[image] if image.startswith("http") else [],105 ))106 return products107108 # -- contrat ---------------------------------------------------------------109 def fetch(self) -> list[Product]:110 products: list[Product] = []111 seen: set[str] = set()112 for slug, category_raw, category in COLLECTIONS:113 url = (f"{BASE}/shop/{slug}?page=1&limit={self.page_limit}"114 "&sort_by=category_order&sort_order=asc")115 try:116 html = self.get_scrapfly(117 url, render_js=True,118 wait_for_selector="a.product-image__link")119 except Exception: # une catégorie qui casse ne bloque pas le reste120 continue121 for prod in self._parse(html, category_raw, category):122 if prod.uid not in seen:123 seen.add(prod.uid)124 products.append(prod)125 return products126