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%
7.1 KB · 155 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur IGA (Sobeys Québec — plateforme Voilà, voila.ca)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# iga.net délègue l'épicerie en ligne à voila.ca. Deux voies :5#   1. (principale) API REST publique des promotions — JSON complet avec prix,6#      prix promo, format et catégorie, pagination par curseur, sans anti-bot.7#   2. (complément, optionnelle) recherche de la SPA rendue via Scrapfly8#      (render_js) pour les produits hors promotion.9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Product, normalize_category, parse_price17from .base import BaseConnector1819PLP_URL = "https://voila.ca/api/product-listing-pages/v1/pages/promotions"20# regionId public observé (région de livraison Québec) — requis par l'API21REGION_ID = "dd7143ad-a16a-4c1d-8dee-e3c7e48dc511"2223# (terme de recherche, catégorie canonique) — voie complémentaire (rendu JS)24SEARCHES = [25    ("fruits", "Fruits et légumes"),26    ("viande", "Viandes et volailles"),27    ("fromage", "Charcuteries et fromages"),28    ("pain", "Boulangerie"),29    ("surgele", "Surgelés"),30]3132_ID_RE = re.compile(r"/([0-9]+[A-Z]{2})/?$")33_SIZE_IN_TITLE = re.compile(34    r"(\d+(?:[.,]\d+)?\s*(?:kg|g|ml|l|lb|oz|un))\b", re.IGNORECASE)353637class IgaConnector(BaseConnector):38    source_id = "iga"39    request_delay = 1.040    max_api_pages = 12           # 50 produits/page (promotions en cours)41    use_search = False           # recherches Scrapfly complémentaires (coûteux)4243    # -- voie 1 : API REST des promotions --------------------------------------44    def _fetch_promotions(self) -> list[Product]:45        products: list[Product] = []46        token: str | None = None47        for _ in range(self.max_api_pages):48            params = {"regionId": REGION_ID, "maxPageSize": 50,49                      "maxProductsToDecorate": 50}50            if token:51                params["pageToken"] = token52            data = self.get(PLP_URL, params=params,53                            headers={"Accept": "application/json",54                                     "Accept-Language": "fr-CA,fr;q=0.9"}).json()55            decorated = list(data.get("decoratedProducts") or [])56            for group in data.get("productGroups") or []:57                decorated.extend(group.get("decoratedProducts") or [])58            for p in decorated:59                rid = p.get("retailerProductId")60                name = p.get("name") or ""61                if not rid or not name:62                    continue63                price = (p.get("price") or {}).get("amount")64                promo = (p.get("promoPrice") or {}).get("amount")65                current, regular = (promo, price) if promo else (price, None)66                image = p.get("image") or {}67                srcset = image.get("bopSrcset") or ""68                img_url = srcset.split(" ")[0] if srcset else ""69                cat_path = p.get("categoryPath") or []70                unit = ((p.get("unitPrice") or {}).get("price") or {}).get("amount")71                products.append(Product(72                    source=self.source_id,73                    external_id=str(rid),74                    url=f"https://voila.ca/products/{rid}",75                    name=name,76                    brand=p.get("brand") or "",77                    category=normalize_category(cat_path[0] if cat_path else ""),78                    category_raw=" / ".join(cat_path),79                    size_label=p.get("packSizeDescription") or "",80                    price=float(current) if current else None,81                    regular_price=float(regular) if regular else None,82                    on_sale=promo is not None,83                    in_stock=p.get("available"),84                    keywords=[a.get("label") for a in p.get("iconAttributes") or []85                              if isinstance(a, dict) and a.get("label")],86                    images=[img_url] if img_url.startswith("http") else [],87                ))88            token = (data.get("metadata") or {}).get("nextPageToken")89            if not token or not decorated:90                break91        return products9293    # -- voie 2 : recherche SPA rendue (complément) -----------------------------94    def _parse_search(self, html: str, category: str, term: str) -> list[Product]:95        soup = BeautifulSoup(html, "html.parser")96        products: list[Product] = []97        for link in soup.select('[data-test="fop-product-link"][href]'):98            href = link["href"]99            m = _ID_RE.search(href)100            if not m:101                continue102            tile = link103            for _ in range(6):104                if tile.parent is None:105                    break106                tile = tile.parent107                if tile.select_one('[data-test="fop-price"]'):108                    break109            title_el = tile.select_one('[data-test="fop-title"]') or link110            price_el = tile.select_one('[data-test="fop-price"]')111            orig_el = tile.select_one('[data-test="fop-original-price"]')112            unit_el = tile.select_one('[data-test="fop-price-per-unit"]')113            img = tile.select_one("img[src]")114            name = title_el.get_text(" ", strip=True)115            if not name:116                continue117            size_m = _SIZE_IN_TITLE.search(name)118            regular = parse_price(orig_el.get_text(" ", strip=True)) if orig_el else None119            products.append(Product(120                source=self.source_id,121                external_id=m.group(1),122                url=f"https://voila.ca{href}" if href.startswith("/") else href,123                name=name,124                category=category,125                category_raw=term,126                size_label=size_m.group(1) if size_m else "",127                price=parse_price(price_el.get_text(" ", strip=True)) if price_el else None,128                regular_price=regular,129                on_sale=regular is not None,130                unit_price_label=(unit_el.get_text(" ", strip=True).strip("()")131                                  if unit_el else ""),132                images=([img["src"]] if img is not None133                        and str(img.get("src", "")).startswith("http") else []),134            ))135        return products136137    def fetch(self) -> list[Product]:138        products = self._fetch_promotions()139        seen = {p.uid for p in products}140        if self.use_search:141            for term, category in SEARCHES:142                url = f"https://voila.ca/products?q={term.replace(' ', '+')}"143                try:144                    html = self.get_scrapfly(145                        url, render_js=True,146                        headers={"Accept-Language": "fr-CA,fr;q=0.9"},147                        wait_for_selector='[data-test="fop-product-link"]')148                except Exception:149                    continue150                for prod in self._parse_search(html, category, term):151                    if prod.uid not in seen:152                        seen.add(prod.uid)153                        products.append(prod)154        return products155