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 Aubut (aubut.ca — grossiste cash & carry, Montréal)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# Site maison (Vue.js) : les grilles de catégories (/produits/<cat>?p=N,5# 32 tuiles/page) embarquent un composant <app-product-ecommerce> dont6# l'attribut :product contient TOUT le produit en JSON (prix, format, solde,7# inventaire, image) — parsing trivial et fiable.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import json1213from bs4 import BeautifulSoup1415from ..schema import Product16from .base import BaseConnector1718BASE = "https://www.aubut.ca"1920# (slug de catégorie, catégorie canonique)21CATEGORIES = [22 ("fruits-et-legumes-9", "Fruits et légumes"),23 ("viandes-poissons-frais-8", "Viandes et volailles"),24 ("charcuteries-7", "Charcuteries et fromages"),25 ("produits-laitiers-6", "Produits laitiers et œufs"),26 ("boulangerie-5", "Boulangerie"),27 ("surgele-4", "Surgelés"),28 ("epicerie-1", "Garde-manger"),29 ("boissons-2", "Boissons"),30]313233def _price(value) -> float | None:34 """« 31.99 » (chaîne du JSON Aubut) -> float, None si vide/zéro."""35 try:36 price = float(value)37 except (TypeError, ValueError):38 return None39 return price if price > 0 else None404142class AubutConnector(BaseConnector):43 source_id = "aubut"4445 max_categories: int | None = None # borne pour les tests46 pages_per_category: int = 2 # 32 tuiles/page -> ~512 produits max4748 def _parse_tiles(self, html: str, category: str, slug: str) -> list[Product]:49 soup = BeautifulSoup(html, "html.parser")50 products: list[Product] = []51 for tag in soup.select("app-product-ecommerce[\\:product]"):52 try:53 data = json.loads(tag[":product"])54 except (ValueError, KeyError):55 continue56 name = (data.get("title") or "").strip()57 pid = str(data.get("product_id") or data.get("id") or "")58 if not name or not pid:59 continue6061 # prix « unité » ; *_special > 0 = prix en solde62 regular = _price(data.get("unite_price"))63 special = _price(data.get("unite_price_special"))64 price = special if special is not None else regular65 if special is None:66 regular = None # pas de solde -> pas de prix régulier distinct6768 image = data.get("picture_thumb_path") or ""69 in_stock = None70 if str(data.get("has_inventaire") or "") in ("0", "1"):71 in_stock = data.get("has_inventaire") == "1"7273 products.append(Product(74 source=self.source_id,75 external_id=pid,76 url=data.get("detail") or f"{BASE}/produits/details/{data.get('slug', '')}-{pid}",77 name=name,78 brand=(data.get("brand_title") or "").strip(),79 category=category,80 category_raw=slug,81 size_label=(data.get("unite_format") or "").strip(),82 price=price,83 regular_price=regular,84 on_sale=special is not None,85 unit_price_label=(data.get("price_100") or "").strip(),86 in_stock=in_stock,87 images=[image] if image.startswith("http") else [],88 ))89 return products9091 def fetch(self) -> list[Product]:92 products: list[Product] = []93 seen: set[str] = set()94 categories = CATEGORIES[: self.max_categories]95 for slug, category in categories:96 for page in range(1, self.pages_per_category + 1):97 url = f"{BASE}/produits/{slug}"98 if page > 1:99 url += f"?p={page}"100 try:101 html = self.get(url).text102 except Exception: # une page qui casse ne bloque pas le reste103 break104 new = 0105 for prod in self._parse_tiles(html, category, slug):106 if prod.uid not in seen:107 seen.add(prod.uid)108 products.append(prod)109 new += 1110 if new == 0: # page vide ou répétée : catégorie épuisée111 break112 return products113