SPB Git forge

spb/resto-ka

Public

Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%
16.1 KB · 362 lines python
Raw Blame History
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