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%
4.7 KB · 118 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur Avril Supermarché Santé (avril.ca)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# Magento 2 (thème Hyvä/Alpine) : les sous-catégories d'épicerie5# (/fr/epicerie/<sous-cat>.html?p=N, 24 cartes/page) sont rendues côté6# serveur. Cloudflare en façade mais le GET direct passe ; repli Scrapfly7# (ASP) si 403. NB : dans le balisage Avril, .price = prix courant et8# .special-price = prix régulier barré (nommage inversé).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import re1314from bs4 import BeautifulSoup1516from ..schema import Product, parse_price17from .base import BaseConnector1819BASE = "https://avril.ca"2021# (slug de sous-catégorie d'épicerie, catégorie canonique)22CATEGORIES = [23    ("fruits-legumes-biologiques", "Fruits et légumes"),24    ("viandes-substituts", "Viandes et volailles"),25    ("poissons-fruits-de-mer", "Poissons et fruits de mer"),26    ("produits-laitiers-oeufs", "Produits laitiers et œufs"),27    ("boulangerie", "Boulangerie"),28    ("garde-manger", "Garde-manger"),29    ("boissons", "Boissons"),30    ("pret-a-manger", "Prêt-à-manger"),31]3233_IMG_RE = re.compile(r"initImage\('([^']+)'\)")343536class AvrilConnector(BaseConnector):37    source_id = "avril"3839    max_categories: int | None = None    # borne pour les tests40    pages_per_category: int = 2          # 24 cartes/page -> ~384 produits max4142    def _get_html(self, url: str) -> str:43        """GET direct d'abord ; Scrapfly (ASP) si Cloudflare bloque (403)."""44        try:45            return self.get(url).text46        except Exception:47            return self.get_scrapfly(url)4849    def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]:50        soup = BeautifulSoup(html, "html.parser")51        products: list[Product] = []52        for card in soup.select("form.product-item"):53            link = card.select_one("a.product-item-link[href]")54            pid_el = card.select_one('input[name="product"][value]')55            if link is None or pid_el is None:56                continue57            name = link.get_text(" ", strip=True)58            pid = str(pid_el["value"])59            if not name or not pid:60                continue6162            weight_el = card.select_one(".product-item-weight")63            size_label = ""64            if weight_el is not None:65                size_label = weight_el.get_text(" ", strip=True).lstrip("| ").strip()6667            brand_el = card.select_one(".product-info-brand")68            price_el = card.select_one(".price-container .price")69            # nommage Avril inversé : .special-price = prix régulier barré70            regular_el = card.select_one(".price-container .special-price")7172            image = ""73            img = card.select_one("img.product-image-photo")74            if img is not None:75                m = _IMG_RE.search(str(img.get("x-data") or ""))76                if m:77                    image = m.group(1)7879            products.append(Product(80                source=self.source_id,81                external_id=pid,82                url=str(link["href"]),83                name=name,84                brand=brand_el.get_text(" ", strip=True) if brand_el else "",85                category=category,86                category_raw=f"epicerie/{slug}",87                size_label=size_label,88                price_label=price_el.get_text(" ", strip=True) if price_el else "",89                regular_price=(parse_price(regular_el.get_text(" ", strip=True))90                               if regular_el else None),91                on_sale=regular_el is not None,92                images=[image] if image.startswith("http") else [],93            ))94        return products9596    def fetch(self) -> list[Product]:97        products: list[Product] = []98        seen: set[str] = set()99        categories = CATEGORIES[: self.max_categories]100        for slug, category in categories:101            for page in range(1, self.pages_per_category + 1):102                url = f"{BASE}/fr/epicerie/{slug}.html"103                if page > 1:104                    url += f"?p={page}"105                try:106                    html = self._get_html(url)107                except Exception:   # une page qui casse ne bloque pas le reste108                    break109                new = 0110                for prod in self._parse_cards(html, category, slug):111                    if prod.uid not in seen:112                        seen.add(prod.uid)113                        products.append(prod)114                        new += 1115                if new == 0:        # page vide ou répétée : catégorie épuisée116                    break117        return products118