# ----------------------------------------------------------------------------- # Food-Ka — connecteur Marché Adonis (livraison.groupeadonis.ca) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # Boutique Instacart en marque blanche (SPA React) : rendu JavaScript via # Scrapfly + attente des cartes produit a[data-item-card-button]. Les prix # s'affichent sans session (adresse de livraison par défaut d'Instacart) — # prix courant et prix d'origine exposés en texte lecteur d'écran # (« Current price: … » / « Original Price: … »). # Collections : /store/adonis/collections/{slug} (~20 produits rendus/page). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Product, parse_price from .base import BaseConnector _ID_RE = re.compile(r"/products/(\d+)") _CURRENT_RE = re.compile(r"Current price:\s*(.+)", re.I) _ORIGINAL_RE = re.compile(r"Original Price:\s*(.+)", re.I) # collections Instacart d'Adonis : (slug, catégorie canonique) COLLECTIONS = [ ("n-produce-56955", "Fruits et légumes"), ("n-meat-seafood-98116", "Viandes et volailles"), ("n-dairy-eggs-49119", "Produits laitiers et œufs"), ("n-deli-35601", "Charcuteries et fromages"), ("n-bakery-91773", "Boulangerie"), ("n-frozen-44158", "Surgelés"), ("n-dry-goods-pasta-75048", "Garde-manger"), ("n-canned-goods-soups-12716", "Garde-manger"), ("n-beverages-8671", "Boissons"), ("n-snacks-candy-4336", "Collations et confiseries"), ("n-prepared-foods-84844", "Prêt-à-manger"), ] class AdonisConnector(BaseConnector): source_id = "adonis" request_delay = 1.0 # -- extraction d'une collection -------------------------------------------- def _parse(self, html: str, category: str, slug: str) -> list[Product]: soup = BeautifulSoup(html, "html.parser") products: list[Product] = [] for card in soup.select("a[data-item-card-button][href]"): href = card["href"] m = _ID_RE.search(href) title = card.select_one("h3") if not m or title is None: continue name = title.get_text(" ", strip=True) if not name: continue # prix : textes lecteur d'écran, robustes face aux classes minifiées text = card.get_text("\n", strip=True) cur_m = _CURRENT_RE.search(text) orig_m = _ORIGINAL_RE.search(text) price_label = cur_m.group(1).strip() if cur_m else "" price = parse_price(price_label) regular = parse_price(orig_m.group(1).strip()) if orig_m else None # format / prix unitaire : bloc qui suit le titre size_label, unit_label = "", "" after = title.find_next_sibling("div") if after is not None: after_text = after.get_text(" ", strip=True) if "$" in after_text: unit_label = after_text else: size_label = after_text # produits vendus au poids : le prix affiché est par kilogramme if not size_label and "per kilogram" in price_label.lower(): size_label = "1 kg" in_stock: bool | None = None low = text.lower() if "out of stock" in low: in_stock = False elif "in stock" in low: in_stock = True img = card.select_one('img[data-testid="item-card-image"][src]') products.append(Product( source=self.source_id, external_id=m.group(1), url=(f"https://livraison.groupeadonis.ca{href}" if href.startswith("/") else href), name=name, category=category, category_raw=slug, size_label=size_label, price=price, regular_price=regular, price_label=price_label, on_sale=regular is not None, unit_price_label=unit_label, in_stock=in_stock, images=[img["src"]] if img is not None else [], )) return products # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() for slug, category in COLLECTIONS: url = f"https://livraison.groupeadonis.ca/store/adonis/collections/{slug}" try: html = self.get_scrapfly( url, render_js=True, wait_for_selector="a[data-item-card-button]") except Exception: # une collection qui casse ne bloque pas le reste continue for prod in self._parse(html, category, slug): if prod.uid not in seen: seen.add(prod.uid) products.append(prod) return products