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 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