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 Marché Tau (marchestau.com — supermarchés santé)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# k-eCommerce : les grilles de catégories sont chargées en AJAX (aucun prix5# dans le HTML), mais les FICHES produit sont rendues côté serveur6# (« 16,99$ CAD », fil d'Ariane, marque, image). Stratégie : sitemap7# kSitemap-1.xml (~14 000 URLs), filtrage des slugs produit (suffixe de8# format « -227gr »), échantillon réparti sur tout le catalogue, puis une9# requête par fiche (throttling de BaseConnector).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import re1415from bs4 import BeautifulSoup1617from ..schema import Product, normalize_category, parse_price18from .base import BaseConnector1920BASE = "https://marchestau.com"21SITEMAP = f"{BASE}/kSitemap-1.xml"2223_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")24# slug produit : se termine par un format, ex. « -227gr », « -1.36l », « -60un »25_SLUG_SIZE_RE = re.compile(26 r"-(\d+(?:[.,]\d+)?)\s*(gr|g|kg|ml|l|lb|oz|un)$", re.IGNORECASE)27_CODE_RE = re.compile(r"(\d{6,})")282930class TauConnector(BaseConnector):31 source_id = "tau"3233 max_products: int = 180 # une requête par fiche : rester raisonnable3435 def _product_urls(self) -> list[str]:36 """URLs de fiches produit, échantillonnées uniformément (tout l'alphabet)."""37 xml = self.get(SITEMAP).text38 urls = [u for u in _LOC_RE.findall(xml)39 if _SLUG_SIZE_RE.search(u.rsplit("/", 1)[-1])]40 if len(urls) <= self.max_products:41 return urls42 step = len(urls) / self.max_products43 return [urls[int(i * step)] for i in range(self.max_products)]4445 def _parse_product(self, html: str, url: str) -> Product | None:46 soup = BeautifulSoup(html, "html.parser")47 h1 = soup.select_one(".product-detail h1") or soup.select_one("h1")48 if h1 is None:49 return None50 name = h1.get_text(" ", strip=True)51 if not name:52 return None5354 code_el = soup.select_one(".product-details-code")55 code = ""56 if code_el is not None:57 m = _CODE_RE.search(code_el.get_text())58 code = m.group(1) if m else ""59 slug = url.rstrip("/").rsplit("/", 1)[-1]60 external_id = code or slug6162 # prix courant + prix régulier barré (solde)63 price_el = soup.select_one(".price-current")64 regular_el = soup.select_one(65 ".price-regular, .price-before, .single-price-display del, "66 ".single-price-display s")67 unit_el = soup.select_one(".price-per-unit small")6869 # fil d'Ariane : Accueil > Catalogue > <cat> > <sous-cat> > <produit>70 crumbs = [li.get_text(" ", strip=True)71 for li in soup.select("ul.breadcrumb li a")]72 crumbs = [c for c in crumbs73 if c and c not in ("Page d'accueil", "Catalogue")]74 category_raw = " / ".join(crumbs)7576 brand_el = soup.select_one(".product-brand a")77 img = soup.select_one("#product-detail-gallery-main-img[src]")78 image = str(img["src"]) if img else ""7980 # format depuis le slug : « mures-noires-227gr » -> « 227 g »81 size_label = ""82 m = _SLUG_SIZE_RE.search(slug)83 if m:84 unit = m.group(2).lower()85 size_label = f"{m.group(1)} {'g' if unit == 'gr' else unit}"8687 desc_el = soup.select_one(".product-details-desc")88 return Product(89 source=self.source_id,90 external_id=external_id,91 url=url,92 name=name,93 brand=brand_el.get_text(" ", strip=True) if brand_el else "",94 category=normalize_category(category_raw),95 category_raw=category_raw,96 size_label=size_label,97 price_label=price_el.get_text(" ", strip=True) if price_el else "",98 regular_price=(parse_price(regular_el.get_text(" ", strip=True))99 if regular_el else None),100 on_sale=regular_el is not None,101 unit_price_label=unit_el.get_text(" ", strip=True) if unit_el else "",102 description=desc_el.get_text(" ", strip=True) if desc_el else "",103 images=[image] if image.startswith("http") else [],104 )105106 def fetch(self) -> list[Product]:107 products: list[Product] = []108 seen: set[str] = set()109 for url in self._product_urls():110 try:111 html = self.get(url).text112 except Exception: # une fiche qui casse ne bloque pas le reste113 continue114 prod = self._parse_product(html, url)115 if prod is not None and prod.uid not in seen:116 seen.add(prod.uid)117 products.append(prod)118 return products119