Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: restoka/connectors/ueat.py4# Desc: Connecteur TEMPLATISÉ pour UEAT (foodtech de Québec — plateforme de5# commande en ligne marque blanche, très répandue au QC). Couvre tous6# les restos listés dans data/sources.json -> source « ueat » ->7# « integrations » (un GUID d'intégration par marque/resto).8#9# Mode d'extraction : API GraphQL interne (https://api.ueat.io/graphql),10# la même que consomme le widget de commande officiel — données11# structurées, aucun rendu navigateur requis. Auth anonyme :12# en-têtes `x-ueatapikey: <GUID d'intégration>` + `x-ueatculture: fr`.13#14# Contexte de prix produit : TAKEOUT (prix réels de la commande en15# ligne du resto lui-même, non majorés — source de première partie,16# voir CLAUDE.md §9 Palier 1 et §15).17#18# Couverture par intégration :19# - restaurant { name url logo } -> identité de la marque20# - franchises(modeType: TAKEOUT) -> UNE FICHE PAR ADRESSE21# (succursales reliées par `chain`, jamais fusionnées — §12.1)22# - menu { categories } + items(categoryId) -> sections & plats23# (sous-catégories via menu(parentCategoryId), récursif)24# - variations d'item -> options « Format »25# - item.question (1er niveau) -> groupes d'options26# (garnitures, choix de base…) — mis en cache par marque, refait27# seulement quand le menu change (detail_cache).28#29# Limite documentée : le menu est capté au niveau de la MARQUE (menu30# par défaut du siège). Les écarts locaux de prix entre succursales31# d'une même bannière ne sont pas captés (menus standardisés §9 P6).32# ==============================================================================33from __future__ import annotations3435import datetime36import hashlib37import json38import sys39from pathlib import Path4041from ..normalize import infer_item_tags42from ..schema import Restaurant43from .base import BaseConnector4445API = "https://api.ueat.io/graphql"46ORDER_URL = "https://order.ueat.io/integration/{guid}/fr"47SOURCES_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "sources.json"4849# Nombre maximal de niveaux de sous-catégories suivis (garde-fou récursion).50MAX_DEPTH = 351# Nombre maximal d'items dont on va chercher les groupes d'options (par marque,52# par sync) — garde-fou volume ; le cache évite de refaire les items connus.53MAX_QUESTION_FETCH = 4005455_Q_FRANCHISES = """56query getFranchises($orderType: OrderTypeEnum!) {57 restaurant { name url logo }58 franchises(modeType: $orderType) {59 companyProfileId code name currency countryCode phoneNumber60 address { civicNumber streetName postalCode city province61 latitude longitude }62 }63}64"""6566_Q_CATEGORIES = """67query menuCategories {68 menu { categories { id name description numberOfMenuItems hasSubCategory69 image(size: 1024) } }70}71"""7273_Q_SUBMENU = """74query menu($parentCategoryId: Int!) {75 menu(parentCategoryId: $parentCategoryId) {76 categories { id name description numberOfMenuItems hasSubCategory77 image(size: 1024) }78 items { id name description subtitle image regularPrice unitPrice79 hasQuestions isOutOfStock isTemporaryDisabled80 labels { name }81 variations { id name description unitPrice regularPrice82 variations { id name unitPrice regularPrice } } }83 }84}85"""8687_Q_ITEMS = """88query items($categoryId: Int!) {89 items(categoryId: $categoryId) {90 id name description subtitle image regularPrice unitPrice hasQuestions91 isOutOfStock isTemporaryDisabled92 labels { name }93 variations { id name description unitPrice regularPrice94 variations { id name unitPrice regularPrice } }95 }96}97"""9899_Q_QUESTION = """100query questions($menuItemId: Int!) {101 item(id: $menuItemId) {102 id name103 question {104 questionText selectionType minimum maximum105 choices { name unitPrice isNotAdded }106 }107 }108}109"""110111112def _price(val) -> float | None:113 if isinstance(val, (int, float)) and val > 0:114 return round(float(val), 2)115 return None116117118class UeatConnector(BaseConnector):119 source_id = "ueat"120 request_delay = 0.35121122 # -- transport --------------------------------------------------------------123 def _gql(self, api_key: str, query: str, variables: dict | None = None,124 operation: str | None = None) -> dict:125 """Une requête GraphQL UEAT ; lève sur toute erreur GraphQL."""126 body: dict = {"query": query}127 if variables:128 body["variables"] = variables129 if operation:130 body["operationName"] = operation131 resp = self.post(API, json=body, headers={132 "x-ueatapikey": api_key, "x-ueatculture": "fr"})133 data = resp.json()134 if data.get("errors"):135 raise RuntimeError(data["errors"][0].get("message", "erreur GraphQL"))136 return data.get("data") or {}137138 # -- menu ---------------------------------------------------------------------139 def _item_options(self, api_key: str, item: dict,140 base: float) -> list[dict]:141 """Groupes d'options d'un item : variations inline + 1er niveau de142 « questions » (les niveaux suivants dépendent des réponses — non captés).143144 `base` = prix affiché de l'item (celui du menu) : les deltas des145 variations sont calculés par rapport à LUI, jamais par rapport à un146 unitPrice nul (sinon on affiche des « +10,60 $ » qui sont en fait le147 prix absolu du format de base)."""148 options: list[dict] = []149 variations = item.get("variations") or []150 if variations:151 choices = []152 for v in variations:153 vp = _price(v.get("unitPrice"))154 choices.append({"name": (v.get("name") or "").strip(),155 "price_delta": (round(vp - base, 2)156 if vp is not None else 0.0)})157 if choices:158 options.append({"group": "Format", "required": True,159 "choices": choices})160 if item.get("hasQuestions"):161 try:162 data = self._gql(api_key, _Q_QUESTION,163 {"menuItemId": item["id"]}, "questions")164 q = (data.get("item") or {}).get("question")165 except Exception:166 q = None # une question inaccessible ne bloque pas l'item167 if q and q.get("choices"):168 choices = [169 {"name": (c.get("name") or "").strip(),170 "price_delta": _price(c.get("unitPrice")) or 0.0}171 for c in q["choices"] if not c.get("isNotAdded")172 ]173 # si la « question » ne fait que répéter les variations174 # (choix de format), ne pas dupliquer le groupe Format175 var_names = {(v.get("name") or "").strip().lower()176 for v in variations}177 q_names = {c["name"].lower() for c in choices}178 if choices and (not var_names or q_names != var_names):179 options.append({180 "group": (q.get("questionText") or "Options").strip(),181 "required": (q.get("minimum") or 0) >= 1,182 "choices": choices,183 })184 return options185186 def _to_menu_item(self, api_key: str, item: dict,187 budget: list[int]) -> dict | None:188 name = (item.get("name") or "").strip()189 if not name:190 return None191 price = _price(item.get("unitPrice"))192 variations = item.get("variations") or []193 if price is None and variations:194 vprices = [_price(v.get("unitPrice")) for v in variations]195 vprices = [p for p in vprices if p is not None]196 price = min(vprices) if vprices else None197 labels = [l.get("name") or "" for l in item.get("labels") or []]198 out = {199 "id": str(item.get("id") or ""),200 "name": name,201 "description": (item.get("description") or "").strip(),202 "price": price,203 "currency": "CAD",204 "tags": infer_item_tags(name, item.get("description") or "", labels),205 }206 if item.get("image"):207 out["image"] = item["image"]208 if item.get("isOutOfStock"):209 out["tags"].append("rupture")210 can_fetch_q = budget[0] < MAX_QUESTION_FETCH211 if item.get("hasQuestions") and can_fetch_q:212 budget[0] += 1213 elif item.get("hasQuestions"):214 item = dict(item, hasQuestions=False) # budget épuisé : variations seulement215 options = self._item_options(api_key, item, price or 0.0)216 if options:217 out["options"] = options218 return out219220 def _walk_category(self, api_key: str, cat: dict, budget: list[int],221 depth: int = 0) -> list[dict]:222 """Retourne les sections (aplaties « Parent — Enfant ») d'une catégorie."""223 cat_name = (cat.get("name") or "").strip()224 sections: list[dict] = []225 if cat.get("hasSubCategory") and depth < MAX_DEPTH:226 data = self._gql(api_key, _Q_SUBMENU,227 {"parentCategoryId": cat["id"]}, "menu")228 sub = data.get("menu") or {}229 items = [self._to_menu_item(api_key, it, budget)230 for it in sub.get("items") or []]231 items = [it for it in items if it]232 if items:233 sec = {"name": cat_name, "items": items}234 if cat.get("image"):235 sec["image"] = cat["image"]236 sections.append(sec)237 for child in sub.get("categories") or []:238 child_secs = self._walk_category(api_key, child, budget, depth + 1)239 for cs in child_secs:240 cs["name"] = f"{cat_name} — {cs['name']}"241 sections.extend(child_secs)242 else:243 data = self._gql(api_key, _Q_ITEMS, {"categoryId": cat["id"]}, "items")244 items = [self._to_menu_item(api_key, it, budget)245 for it in data.get("items") or []]246 items = [it for it in items if it]247 if items:248 sec = {"name": cat_name, "items": items}249 if cat.get("image"):250 sec["image"] = cat["image"]251 sections.append(sec)252 return sections253254 def _fetch_menu_sections(self, api_key: str) -> list[dict]:255 data = self._gql(api_key, _Q_CATEGORIES, operation="menuCategories")256 categories = ((data.get("menu") or {}).get("categories")) or []257 budget = [0] # compteur partagé de requêtes « questions »258 sections: list[dict] = []259 for cat in categories:260 sections.extend(self._walk_category(api_key, cat, budget))261 return sections262263 # -- fetch ----------------------------------------------------------------------264 def _integrations(self) -> list[dict]:265 registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))266 out: list[dict] = []267 for src in registry.get("sources", []):268 if src.get("id") == self.source_id:269 out.extend(src.get("integrations") or [])270 # intégrations découvertes automatiquement par le connecteur site-resto271 # (sites de restos qui embarquent le widget UEAT) — voir siteresto.py272 discovered = SOURCES_PATH.parent / "ueat-discovered.json"273 if discovered.exists():274 try:275 extra = json.loads(discovered.read_text(encoding="utf-8"))276 known = {i.get("key") for i in out}277 out.extend(i for i in extra.get("integrations", [])278 if i.get("key") not in known)279 except ValueError:280 pass281 return out282283 def fetch(self) -> list[Restaurant]:284 out: dict[str, Restaurant] = {}285 for integ in self._integrations():286 guid = integ.get("key") or ""287 if not guid or integ.get("status") == "inactif":288 continue289 try:290 self._fetch_integration(guid, out)291 except Exception as exc: # une marque ne bloque pas les autres292 print(f"[resto-ka] ueat: intégration {integ.get('label') or guid} "293 f"ignorée : {exc}", file=sys.stderr)294 return list(out.values())295296 def _fetch_integration(self, guid: str, out: dict[str, Restaurant]) -> None:297 data = self._gql(guid, _Q_FRANCHISES, {"orderType": "TAKEOUT"},298 "getFranchises")299 brand = data.get("restaurant") or {}300 brand_name = (brand.get("name") or "").strip()301 franchises = [f for f in data.get("franchises") or []302 if ((f.get("address") or {}).get("province") or "")303 .strip().lower() in ("québec", "quebec", "qc")]304 if not brand_name or not franchises:305 return306307 # menu de marque, avec cache : recapturé seulement si l'empreinte des308 # catégories (ids + nb d'items) a changé — sinon rejoué depuis le cache309 cats = self._gql(guid, _Q_CATEGORIES, operation="menuCategories")310 cat_list = ((cats.get("menu") or {}).get("categories")) or []311 # « v2 » = version du format de payload (images) : bust le cache v1312 cache_key = "v2-" + hashlib.sha256(json.dumps(313 [(c.get("id"), c.get("numberOfMenuItems")) for c in cat_list],314 sort_keys=True).encode()).hexdigest()315 payload = self.detail(guid, cache_key,316 lambda: {"sections": self._fetch_menu_sections(guid)})317 sections = payload.get("sections") or []318 captured_at = datetime.datetime.now(datetime.timezone.utc) \319 .strftime("%Y-%m-%dT%H:%M:%SZ")320321 is_chain = len(franchises) > 1322 for fr in franchises:323 addr = fr.get("address") or {}324 civic = (addr.get("civicNumber") or "").strip()325 street = (addr.get("streetName") or "").strip()326 profile_id = fr.get("companyProfileId")327 if profile_id is None:328 continue329 menu = {330 "currency": fr.get("currency") or "CAD",331 "price_context": "takeout", # commande en ligne du resto :332 "price_source": "ueat", # prix réels, non majorés (§9 P1)333 "captured_at": captured_at,334 "sections": sections,335 } if sections else None336 resto = Restaurant(337 source=self.source_id,338 external_id=f"{guid}:{profile_id}",339 name=(fr.get("name") or brand_name).strip()340 if is_chain else brand_name,341 url=ORDER_URL.format(guid=guid),342 chain=brand_name if is_chain else None,343 address=f"{civic} {street}".strip(),344 city=(addr.get("city") or "").strip(),345 postal_code=(addr.get("postalCode") or "").strip(),346 # GPS de première partie (API) : prime sur le géocodage347 # Nominatim (l'UPDATE fait COALESCE(:lat, lat) — un null ne348 # peut pas écraser des coordonnées déjà géocodées)349 lat=addr.get("latitude"), lng=addr.get("longitude"),350 phone=(fr.get("phoneNumber") or "").strip(),351 website=(brand.get("url") or "").strip(),352 services=["takeout"],353 languages=["fr", "en"],354 images=[u for u in ([brand.get("logo") or ""]355 + [s.get("image") or "" for s in sections][:4]) if u],356 menu=menu,357 )358 # préfixer le nom de la succursale par la marque si absent359 if is_chain and brand_name.lower() not in resto.name.lower():360 resto.name = f"{brand_name} — {resto.name}"361 out.setdefault(resto.uid, resto)362