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.0 KB · 119 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur Marché Adonis (livraison.groupeadonis.ca)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# Boutique Instacart en marque blanche (SPA React) : rendu JavaScript via5# Scrapfly + attente des cartes produit a[data-item-card-button]. Les prix6# s'affichent sans session (adresse de livraison par défaut d'Instacart) —7# prix courant et prix d'origine exposés en texte lecteur d'écran8# (« Current price: … » / « Original Price: … »).9# Collections : /store/adonis/collections/{slug} (~20 produits rendus/page).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Product, parse_price18from .base import BaseConnector1920_ID_RE = re.compile(r"/products/(\d+)")21_CURRENT_RE = re.compile(r"Current price:\s*(.+)", re.I)22_ORIGINAL_RE = re.compile(r"Original Price:\s*(.+)", re.I)2324# collections Instacart d'Adonis : (slug, catégorie canonique)25COLLECTIONS = [26    ("n-produce-56955", "Fruits et légumes"),27    ("n-meat-seafood-98116", "Viandes et volailles"),28    ("n-dairy-eggs-49119", "Produits laitiers et œufs"),29    ("n-deli-35601", "Charcuteries et fromages"),30    ("n-bakery-91773", "Boulangerie"),31    ("n-frozen-44158", "Surgelés"),32    ("n-dry-goods-pasta-75048", "Garde-manger"),33    ("n-canned-goods-soups-12716", "Garde-manger"),34    ("n-beverages-8671", "Boissons"),35    ("n-snacks-candy-4336", "Collations et confiseries"),36    ("n-prepared-foods-84844", "Prêt-à-manger"),37]383940class AdonisConnector(BaseConnector):41    source_id = "adonis"42    request_delay = 1.04344    # -- extraction d'une collection --------------------------------------------45    def _parse(self, html: str, category: str, slug: str) -> list[Product]:46        soup = BeautifulSoup(html, "html.parser")47        products: list[Product] = []48        for card in soup.select("a[data-item-card-button][href]"):49            href = card["href"]50            m = _ID_RE.search(href)51            title = card.select_one("h3")52            if not m or title is None:53                continue54            name = title.get_text(" ", strip=True)55            if not name:56                continue57            # prix : textes lecteur d'écran, robustes face aux classes minifiées58            text = card.get_text("\n", strip=True)59            cur_m = _CURRENT_RE.search(text)60            orig_m = _ORIGINAL_RE.search(text)61            price_label = cur_m.group(1).strip() if cur_m else ""62            price = parse_price(price_label)63            regular = parse_price(orig_m.group(1).strip()) if orig_m else None64            # format / prix unitaire : bloc qui suit le titre65            size_label, unit_label = "", ""66            after = title.find_next_sibling("div")67            if after is not None:68                after_text = after.get_text(" ", strip=True)69                if "$" in after_text:70                    unit_label = after_text71                else:72                    size_label = after_text73            # produits vendus au poids : le prix affiché est par kilogramme74            if not size_label and "per kilogram" in price_label.lower():75                size_label = "1 kg"76            in_stock: bool | None = None77            low = text.lower()78            if "out of stock" in low:79                in_stock = False80            elif "in stock" in low:81                in_stock = True82            img = card.select_one('img[data-testid="item-card-image"][src]')83            products.append(Product(84                source=self.source_id,85                external_id=m.group(1),86                url=(f"https://livraison.groupeadonis.ca{href}"87                     if href.startswith("/") else href),88                name=name,89                category=category,90                category_raw=slug,91                size_label=size_label,92                price=price,93                regular_price=regular,94                price_label=price_label,95                on_sale=regular is not None,96                unit_price_label=unit_label,97                in_stock=in_stock,98                images=[img["src"]] if img is not None else [],99            ))100        return products101102    # -- contrat ---------------------------------------------------------------103    def fetch(self) -> list[Product]:104        products: list[Product] = []105        seen: set[str] = set()106        for slug, category in COLLECTIONS:107            url = f"https://livraison.groupeadonis.ca/store/adonis/collections/{slug}"108            try:109                html = self.get_scrapfly(110                    url, render_js=True,111                    wait_for_selector="a[data-item-card-button]")112            except Exception:   # une collection qui casse ne bloque pas le reste113                continue114            for prod in self._parse(html, category, slug):115                if prod.uid not in seen:116                    seen.add(prod.uid)117                    products.append(prod)118        return products119