# ============================================================================== # Author: Simon-Pierre Boucher # File: restoka/connectors/ueat.py # Desc: Connecteur TEMPLATISÉ pour UEAT (foodtech de Québec — plateforme de # commande en ligne marque blanche, très répandue au QC). Couvre tous # les restos listés dans data/sources.json -> source « ueat » -> # « integrations » (un GUID d'intégration par marque/resto). # # Mode d'extraction : API GraphQL interne (https://api.ueat.io/graphql), # la même que consomme le widget de commande officiel — données # structurées, aucun rendu navigateur requis. Auth anonyme : # en-têtes `x-ueatapikey: ` + `x-ueatculture: fr`. # # Contexte de prix produit : TAKEOUT (prix réels de la commande en # ligne du resto lui-même, non majorés — source de première partie, # voir CLAUDE.md §9 Palier 1 et §15). # # Couverture par intégration : # - restaurant { name url logo } -> identité de la marque # - franchises(modeType: TAKEOUT) -> UNE FICHE PAR ADRESSE # (succursales reliées par `chain`, jamais fusionnées — §12.1) # - menu { categories } + items(categoryId) -> sections & plats # (sous-catégories via menu(parentCategoryId), récursif) # - variations d'item -> options « Format » # - item.question (1er niveau) -> groupes d'options # (garnitures, choix de base…) — mis en cache par marque, refait # seulement quand le menu change (detail_cache). # # Limite documentée : le menu est capté au niveau de la MARQUE (menu # par défaut du siège). Les écarts locaux de prix entre succursales # d'une même bannière ne sont pas captés (menus standardisés §9 P6). # ============================================================================== from __future__ import annotations import datetime import hashlib import json import sys from pathlib import Path from ..normalize import infer_item_tags from ..schema import Restaurant from .base import BaseConnector API = "https://api.ueat.io/graphql" ORDER_URL = "https://order.ueat.io/integration/{guid}/fr" SOURCES_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "sources.json" # Nombre maximal de niveaux de sous-catégories suivis (garde-fou récursion). MAX_DEPTH = 3 # Nombre maximal d'items dont on va chercher les groupes d'options (par marque, # par sync) — garde-fou volume ; le cache évite de refaire les items connus. MAX_QUESTION_FETCH = 400 _Q_FRANCHISES = """ query getFranchises($orderType: OrderTypeEnum!) { restaurant { name url logo } franchises(modeType: $orderType) { companyProfileId code name currency countryCode phoneNumber address { civicNumber streetName postalCode city province latitude longitude } } } """ _Q_CATEGORIES = """ query menuCategories { menu { categories { id name description numberOfMenuItems hasSubCategory image(size: 1024) } } } """ _Q_SUBMENU = """ query menu($parentCategoryId: Int!) { menu(parentCategoryId: $parentCategoryId) { categories { id name description numberOfMenuItems hasSubCategory image(size: 1024) } items { id name description subtitle image regularPrice unitPrice hasQuestions isOutOfStock isTemporaryDisabled labels { name } variations { id name description unitPrice regularPrice variations { id name unitPrice regularPrice } } } } } """ _Q_ITEMS = """ query items($categoryId: Int!) { items(categoryId: $categoryId) { id name description subtitle image regularPrice unitPrice hasQuestions isOutOfStock isTemporaryDisabled labels { name } variations { id name description unitPrice regularPrice variations { id name unitPrice regularPrice } } } } """ _Q_QUESTION = """ query questions($menuItemId: Int!) { item(id: $menuItemId) { id name question { questionText selectionType minimum maximum choices { name unitPrice isNotAdded } } } } """ def _price(val) -> float | None: if isinstance(val, (int, float)) and val > 0: return round(float(val), 2) return None class UeatConnector(BaseConnector): source_id = "ueat" request_delay = 0.35 # -- transport -------------------------------------------------------------- def _gql(self, api_key: str, query: str, variables: dict | None = None, operation: str | None = None) -> dict: """Une requête GraphQL UEAT ; lève sur toute erreur GraphQL.""" body: dict = {"query": query} if variables: body["variables"] = variables if operation: body["operationName"] = operation resp = self.post(API, json=body, headers={ "x-ueatapikey": api_key, "x-ueatculture": "fr"}) data = resp.json() if data.get("errors"): raise RuntimeError(data["errors"][0].get("message", "erreur GraphQL")) return data.get("data") or {} # -- menu --------------------------------------------------------------------- def _item_options(self, api_key: str, item: dict, base: float) -> list[dict]: """Groupes d'options d'un item : variations inline + 1er niveau de « questions » (les niveaux suivants dépendent des réponses — non captés). `base` = prix affiché de l'item (celui du menu) : les deltas des variations sont calculés par rapport à LUI, jamais par rapport à un unitPrice nul (sinon on affiche des « +10,60 $ » qui sont en fait le prix absolu du format de base).""" options: list[dict] = [] variations = item.get("variations") or [] if variations: choices = [] for v in variations: vp = _price(v.get("unitPrice")) choices.append({"name": (v.get("name") or "").strip(), "price_delta": (round(vp - base, 2) if vp is not None else 0.0)}) if choices: options.append({"group": "Format", "required": True, "choices": choices}) if item.get("hasQuestions"): try: data = self._gql(api_key, _Q_QUESTION, {"menuItemId": item["id"]}, "questions") q = (data.get("item") or {}).get("question") except Exception: q = None # une question inaccessible ne bloque pas l'item if q and q.get("choices"): choices = [ {"name": (c.get("name") or "").strip(), "price_delta": _price(c.get("unitPrice")) or 0.0} for c in q["choices"] if not c.get("isNotAdded") ] # si la « question » ne fait que répéter les variations # (choix de format), ne pas dupliquer le groupe Format var_names = {(v.get("name") or "").strip().lower() for v in variations} q_names = {c["name"].lower() for c in choices} if choices and (not var_names or q_names != var_names): options.append({ "group": (q.get("questionText") or "Options").strip(), "required": (q.get("minimum") or 0) >= 1, "choices": choices, }) return options def _to_menu_item(self, api_key: str, item: dict, budget: list[int]) -> dict | None: name = (item.get("name") or "").strip() if not name: return None price = _price(item.get("unitPrice")) variations = item.get("variations") or [] if price is None and variations: vprices = [_price(v.get("unitPrice")) for v in variations] vprices = [p for p in vprices if p is not None] price = min(vprices) if vprices else None labels = [l.get("name") or "" for l in item.get("labels") or []] out = { "id": str(item.get("id") or ""), "name": name, "description": (item.get("description") or "").strip(), "price": price, "currency": "CAD", "tags": infer_item_tags(name, item.get("description") or "", labels), } if item.get("image"): out["image"] = item["image"] if item.get("isOutOfStock"): out["tags"].append("rupture") can_fetch_q = budget[0] < MAX_QUESTION_FETCH if item.get("hasQuestions") and can_fetch_q: budget[0] += 1 elif item.get("hasQuestions"): item = dict(item, hasQuestions=False) # budget épuisé : variations seulement options = self._item_options(api_key, item, price or 0.0) if options: out["options"] = options return out def _walk_category(self, api_key: str, cat: dict, budget: list[int], depth: int = 0) -> list[dict]: """Retourne les sections (aplaties « Parent — Enfant ») d'une catégorie.""" cat_name = (cat.get("name") or "").strip() sections: list[dict] = [] if cat.get("hasSubCategory") and depth < MAX_DEPTH: data = self._gql(api_key, _Q_SUBMENU, {"parentCategoryId": cat["id"]}, "menu") sub = data.get("menu") or {} items = [self._to_menu_item(api_key, it, budget) for it in sub.get("items") or []] items = [it for it in items if it] if items: sec = {"name": cat_name, "items": items} if cat.get("image"): sec["image"] = cat["image"] sections.append(sec) for child in sub.get("categories") or []: child_secs = self._walk_category(api_key, child, budget, depth + 1) for cs in child_secs: cs["name"] = f"{cat_name} — {cs['name']}" sections.extend(child_secs) else: data = self._gql(api_key, _Q_ITEMS, {"categoryId": cat["id"]}, "items") items = [self._to_menu_item(api_key, it, budget) for it in data.get("items") or []] items = [it for it in items if it] if items: sec = {"name": cat_name, "items": items} if cat.get("image"): sec["image"] = cat["image"] sections.append(sec) return sections def _fetch_menu_sections(self, api_key: str) -> list[dict]: data = self._gql(api_key, _Q_CATEGORIES, operation="menuCategories") categories = ((data.get("menu") or {}).get("categories")) or [] budget = [0] # compteur partagé de requêtes « questions » sections: list[dict] = [] for cat in categories: sections.extend(self._walk_category(api_key, cat, budget)) return sections # -- fetch ---------------------------------------------------------------------- def _integrations(self) -> list[dict]: registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8")) out: list[dict] = [] for src in registry.get("sources", []): if src.get("id") == self.source_id: out.extend(src.get("integrations") or []) # intégrations découvertes automatiquement par le connecteur site-resto # (sites de restos qui embarquent le widget UEAT) — voir siteresto.py discovered = SOURCES_PATH.parent / "ueat-discovered.json" if discovered.exists(): try: extra = json.loads(discovered.read_text(encoding="utf-8")) known = {i.get("key") for i in out} out.extend(i for i in extra.get("integrations", []) if i.get("key") not in known) except ValueError: pass return out def fetch(self) -> list[Restaurant]: out: dict[str, Restaurant] = {} for integ in self._integrations(): guid = integ.get("key") or "" if not guid or integ.get("status") == "inactif": continue try: self._fetch_integration(guid, out) except Exception as exc: # une marque ne bloque pas les autres print(f"[resto-ka] ueat: intégration {integ.get('label') or guid} " f"ignorée : {exc}", file=sys.stderr) return list(out.values()) def _fetch_integration(self, guid: str, out: dict[str, Restaurant]) -> None: data = self._gql(guid, _Q_FRANCHISES, {"orderType": "TAKEOUT"}, "getFranchises") brand = data.get("restaurant") or {} brand_name = (brand.get("name") or "").strip() franchises = [f for f in data.get("franchises") or [] if ((f.get("address") or {}).get("province") or "") .strip().lower() in ("québec", "quebec", "qc")] if not brand_name or not franchises: return # menu de marque, avec cache : recapturé seulement si l'empreinte des # catégories (ids + nb d'items) a changé — sinon rejoué depuis le cache cats = self._gql(guid, _Q_CATEGORIES, operation="menuCategories") cat_list = ((cats.get("menu") or {}).get("categories")) or [] # « v2 » = version du format de payload (images) : bust le cache v1 cache_key = "v2-" + hashlib.sha256(json.dumps( [(c.get("id"), c.get("numberOfMenuItems")) for c in cat_list], sort_keys=True).encode()).hexdigest() payload = self.detail(guid, cache_key, lambda: {"sections": self._fetch_menu_sections(guid)}) sections = payload.get("sections") or [] captured_at = datetime.datetime.now(datetime.timezone.utc) \ .strftime("%Y-%m-%dT%H:%M:%SZ") is_chain = len(franchises) > 1 for fr in franchises: addr = fr.get("address") or {} civic = (addr.get("civicNumber") or "").strip() street = (addr.get("streetName") or "").strip() profile_id = fr.get("companyProfileId") if profile_id is None: continue menu = { "currency": fr.get("currency") or "CAD", "price_context": "takeout", # commande en ligne du resto : "price_source": "ueat", # prix réels, non majorés (§9 P1) "captured_at": captured_at, "sections": sections, } if sections else None resto = Restaurant( source=self.source_id, external_id=f"{guid}:{profile_id}", name=(fr.get("name") or brand_name).strip() if is_chain else brand_name, url=ORDER_URL.format(guid=guid), chain=brand_name if is_chain else None, address=f"{civic} {street}".strip(), city=(addr.get("city") or "").strip(), postal_code=(addr.get("postalCode") or "").strip(), # GPS de première partie (API) : prime sur le géocodage # Nominatim (l'UPDATE fait COALESCE(:lat, lat) — un null ne # peut pas écraser des coordonnées déjà géocodées) lat=addr.get("latitude"), lng=addr.get("longitude"), phone=(fr.get("phoneNumber") or "").strip(), website=(brand.get("url") or "").strip(), services=["takeout"], languages=["fr", "en"], images=[u for u in ([brand.get("logo") or ""] + [s.get("image") or "" for s in sections][:4]) if u], menu=menu, ) # préfixer le nom de la succursale par la marque si absent if is_chain and brand_name.lower() not in resto.name.lower(): resto.name = f"{brand_name} — {resto.name}" out.setdefault(resto.uid, resto)