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.9 KB · 131 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Food-Ka — connecteur T&T Supermarket (tntsupermarket.com — supermarché3# asiatique, magasin de Montréal / Saint-Laurent)4# Auteur : Simon-Pierre Boucher — contact@spboucher.ai5# Adobe Commerce (Magento PWA) headless derrière Akamai : requêtes directes6# refusées (403), mais l'API GraphQL /graphql répond via Scrapfly ASP en GET7# — SANS rendu JavaScript (1 requête légère par catégorie). Prix courant et8# prix régulier dans price_range.minimum_price, stock, image et url_key.9# Catégories via categoryList (uid base64) ; magasin « default » (EN).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import json14import re15import urllib.parse1617from ..schema import Product18from .base import BaseConnector1920BASE = "https://www.tntsupermarket.com"21GRAPHQL = f"{BASE}/graphql"2223# format en fin de nom : « Rice Cracker (180g) » -> « 180 g »24_SIZE_IN_NAME_RE = re.compile(25    r"\(([^()]*?(\d+(?:[.,]\d+)?)\s*(kg|g|ml|l|lb|lbs|oz)\b[^()]*)\)",26    re.IGNORECASE)2728# catégories T&T : (uid GraphQL, taxonomie source, catégorie canonique)29COLLECTIONS = [30    ("NTAzNQ==", "Fruits & Vegetables", "Fruits et légumes"),31    ("NTA1MQ==", "Meat & Seafood / Beef", "Viandes et volailles"),32    ("NTA1Mw==", "Meat & Seafood / Fish, Shrimps, Lobsters & Crabs",33     "Poissons et fruits de mer"),34    ("NDk5MQ==", "Dairy & Frozen / Dairy & Plant-Based Milk",35     "Produits laitiers et œufs"),36    ("NDk4Nw==", "Pantry & Sauce", "Garde-manger"),37    ("NDk4OA==", "Snacks & Drinks", "Collations et confiseries"),38    ("NTE3Ng==", "T&T Bakery", "Boulangerie"),39    ("NDk5NQ==", "Dairy & Frozen / Frozen Meals", "Surgelés"),40]4142_QUERY = """query {43  products(filter: {category_uid: {eq: "%s"}} pageSize: %d currentPage: 1) {44    total_count45    items {46      sku47      name48      url_key49      stock_status50      price_range {51        minimum_price {52          final_price { value }53          regular_price { value }54        }55      }56      small_image { url }57    }58  }59}"""606162class TntConnector(BaseConnector):63    source_id = "tt"64    request_delay = 1.06566    page_size: int = 50       # produits par catégorie — 1 requête GraphQL chacune6768    # -- une catégorie = une requête GraphQL (GET, ASP sans rendu JS) -----------69    def _fetch_collection(self, uid: str) -> list[dict]:70        query = _QUERY % (uid, self.page_size)71        url = f"{GRAPHQL}?query={urllib.parse.quote(query)}"72        body = self.get_scrapfly(73            url, render_js=False, asp=True,74            headers={"Content-Type": "application/json"})75        data = json.loads(body)76        return ((data.get("data") or {}).get("products") or {}).get("items") or []7778    def _parse_item(self, item: dict, category_raw: str,79                    category: str) -> Product | None:80        name = (item.get("name") or "").strip()81        sku = str(item.get("sku") or "").strip()82        if not name or not sku:83            return None84        minimum = ((item.get("price_range") or {}).get("minimum_price")) or {}85        price = (minimum.get("final_price") or {}).get("value")86        regular = (minimum.get("regular_price") or {}).get("value")87        if not price or price <= 0:88            return None            # pas de prix affiché = produit inutilisable8990        size_label = ""91        m = _SIZE_IN_NAME_RE.search(name)92        if m:93            size_label = f"{m.group(2)} {m.group(3).lower()}"9495        url_key = item.get("url_key") or ""96        image = ((item.get("small_image") or {}).get("url")) or ""97        stock = item.get("stock_status")9899        return Product(100            source=self.source_id,101            external_id=sku,102            url=f"{BASE}/eng/{url_key}.html" if url_key else BASE,103            name=name,104            category=category,105            category_raw=category_raw,106            size_label=size_label,107            price=float(price),108            regular_price=(float(regular)109                           if regular and regular > price else None),110            on_sale=bool(regular and regular > price),111            in_stock=(True if stock == "IN_STOCK"112                      else False if stock == "OUT_OF_STOCK" else None),113            images=[image] if image.startswith("http") else [],114        )115116    # -- contrat ---------------------------------------------------------------117    def fetch(self) -> list[Product]:118        products: list[Product] = []119        seen: set[str] = set()120        for uid, category_raw, category in COLLECTIONS:121            try:122                items = self._fetch_collection(uid)123            except Exception:   # une catégorie qui casse ne bloque pas le reste124                continue125            for item in items:126                prod = self._parse_item(item, category_raw, category)127                if prod is not None and prod.uid not in seen:128                    seen.add(prod.uid)129                    products.append(prod)130        return products131