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.8 KB · 119 lines python
Raw Blame History
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