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