# ----------------------------------------------------------------------------- # Food-Ka — connecteur Avril Supermarché Santé (avril.ca) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # Magento 2 (thème Hyvä/Alpine) : les sous-catégories d'épicerie # (/fr/epicerie/.html?p=N, 24 cartes/page) sont rendues côté # serveur. Cloudflare en façade mais le GET direct passe ; repli Scrapfly # (ASP) si 403. NB : dans le balisage Avril, .price = prix courant et # .special-price = prix régulier barré (nommage inversé). # ----------------------------------------------------------------------------- from __future__ import annotations import re from bs4 import BeautifulSoup from ..schema import Product, parse_price from .base import BaseConnector BASE = "https://avril.ca" # (slug de sous-catégorie d'épicerie, catégorie canonique) CATEGORIES = [ ("fruits-legumes-biologiques", "Fruits et légumes"), ("viandes-substituts", "Viandes et volailles"), ("poissons-fruits-de-mer", "Poissons et fruits de mer"), ("produits-laitiers-oeufs", "Produits laitiers et œufs"), ("boulangerie", "Boulangerie"), ("garde-manger", "Garde-manger"), ("boissons", "Boissons"), ("pret-a-manger", "Prêt-à-manger"), ] _IMG_RE = re.compile(r"initImage\('([^']+)'\)") class AvrilConnector(BaseConnector): source_id = "avril" max_categories: int | None = None # borne pour les tests pages_per_category: int = 2 # 24 cartes/page -> ~384 produits max def _get_html(self, url: str) -> str: """GET direct d'abord ; Scrapfly (ASP) si Cloudflare bloque (403).""" try: return self.get(url).text except Exception: return self.get_scrapfly(url) def _parse_cards(self, html: str, category: str, slug: str) -> list[Product]: soup = BeautifulSoup(html, "html.parser") products: list[Product] = [] for card in soup.select("form.product-item"): link = card.select_one("a.product-item-link[href]") pid_el = card.select_one('input[name="product"][value]') if link is None or pid_el is None: continue name = link.get_text(" ", strip=True) pid = str(pid_el["value"]) if not name or not pid: continue weight_el = card.select_one(".product-item-weight") size_label = "" if weight_el is not None: size_label = weight_el.get_text(" ", strip=True).lstrip("| ").strip() brand_el = card.select_one(".product-info-brand") price_el = card.select_one(".price-container .price") # nommage Avril inversé : .special-price = prix régulier barré regular_el = card.select_one(".price-container .special-price") image = "" img = card.select_one("img.product-image-photo") if img is not None: m = _IMG_RE.search(str(img.get("x-data") or "")) if m: image = m.group(1) products.append(Product( source=self.source_id, external_id=pid, url=str(link["href"]), name=name, brand=brand_el.get_text(" ", strip=True) if brand_el else "", category=category, category_raw=f"epicerie/{slug}", size_label=size_label, price_label=price_el.get_text(" ", strip=True) if price_el else "", regular_price=(parse_price(regular_el.get_text(" ", strip=True)) if regular_el else None), on_sale=regular_el is not None, images=[image] if image.startswith("http") else [], )) return products def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() categories = CATEGORIES[: self.max_categories] for slug, category in categories: for page in range(1, self.pages_per_category + 1): url = f"{BASE}/fr/epicerie/{slug}.html" if page > 1: url += f"?p={page}" try: html = self._get_html(url) except Exception: # une page qui casse ne bloque pas le reste break new = 0 for prod in self._parse_cards(html, category, slug): if prod.uid not in seen: seen.add(prod.uid) products.append(prod) new += 1 if new == 0: # page vide ou répétée : catégorie épuisée break return products