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