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%
5.3 KB · 126 lines python
Raw Blame History
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