# ----------------------------------------------------------------------------- # Food-Ka — connecteur T&T Supermarket (tntsupermarket.com — supermarché # asiatique, magasin de Montréal / Saint-Laurent) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # Adobe Commerce (Magento PWA) headless derrière Akamai : requêtes directes # refusées (403), mais l'API GraphQL /graphql répond via Scrapfly ASP en GET # — SANS rendu JavaScript (1 requête légère par catégorie). Prix courant et # prix régulier dans price_range.minimum_price, stock, image et url_key. # Catégories via categoryList (uid base64) ; magasin « default » (EN). # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import urllib.parse from ..schema import Product from .base import BaseConnector BASE = "https://www.tntsupermarket.com" GRAPHQL = f"{BASE}/graphql" # format en fin de nom : « Rice Cracker (180g) » -> « 180 g » _SIZE_IN_NAME_RE = re.compile( r"\(([^()]*?(\d+(?:[.,]\d+)?)\s*(kg|g|ml|l|lb|lbs|oz)\b[^()]*)\)", re.IGNORECASE) # catégories T&T : (uid GraphQL, taxonomie source, catégorie canonique) COLLECTIONS = [ ("NTAzNQ==", "Fruits & Vegetables", "Fruits et légumes"), ("NTA1MQ==", "Meat & Seafood / Beef", "Viandes et volailles"), ("NTA1Mw==", "Meat & Seafood / Fish, Shrimps, Lobsters & Crabs", "Poissons et fruits de mer"), ("NDk5MQ==", "Dairy & Frozen / Dairy & Plant-Based Milk", "Produits laitiers et œufs"), ("NDk4Nw==", "Pantry & Sauce", "Garde-manger"), ("NDk4OA==", "Snacks & Drinks", "Collations et confiseries"), ("NTE3Ng==", "T&T Bakery", "Boulangerie"), ("NDk5NQ==", "Dairy & Frozen / Frozen Meals", "Surgelés"), ] _QUERY = """query { products(filter: {category_uid: {eq: "%s"}} pageSize: %d currentPage: 1) { total_count items { sku name url_key stock_status price_range { minimum_price { final_price { value } regular_price { value } } } small_image { url } } } }""" class TntConnector(BaseConnector): source_id = "tt" request_delay = 1.0 page_size: int = 50 # produits par catégorie — 1 requête GraphQL chacune # -- une catégorie = une requête GraphQL (GET, ASP sans rendu JS) ----------- def _fetch_collection(self, uid: str) -> list[dict]: query = _QUERY % (uid, self.page_size) url = f"{GRAPHQL}?query={urllib.parse.quote(query)}" body = self.get_scrapfly( url, render_js=False, asp=True, headers={"Content-Type": "application/json"}) data = json.loads(body) return ((data.get("data") or {}).get("products") or {}).get("items") or [] def _parse_item(self, item: dict, category_raw: str, category: str) -> Product | None: name = (item.get("name") or "").strip() sku = str(item.get("sku") or "").strip() if not name or not sku: return None minimum = ((item.get("price_range") or {}).get("minimum_price")) or {} price = (minimum.get("final_price") or {}).get("value") regular = (minimum.get("regular_price") or {}).get("value") if not price or price <= 0: return None # pas de prix affiché = produit inutilisable size_label = "" m = _SIZE_IN_NAME_RE.search(name) if m: size_label = f"{m.group(2)} {m.group(3).lower()}" url_key = item.get("url_key") or "" image = ((item.get("small_image") or {}).get("url")) or "" stock = item.get("stock_status") return Product( source=self.source_id, external_id=sku, url=f"{BASE}/eng/{url_key}.html" if url_key else BASE, name=name, category=category, category_raw=category_raw, size_label=size_label, price=float(price), regular_price=(float(regular) if regular and regular > price else None), on_sale=bool(regular and regular > price), in_stock=(True if stock == "IN_STOCK" else False if stock == "OUT_OF_STOCK" else None), images=[image] if image.startswith("http") else [], ) # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Product]: products: list[Product] = [] seen: set[str] = set() for uid, category_raw, category in COLLECTIONS: try: items = self._fetch_collection(uid) except Exception: # une catégorie qui casse ne bloque pas le reste continue for item in items: prod = self._parse_item(item, category_raw, category) if prod is not None and prod.uid not in seen: seen.add(prod.uid) products.append(prod) return products