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%
8.5 KB · 205 lines python
Raw Blame History
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File:   restoka/connectors/chownow.py4# Desc:   Connecteur templatisé ChowNow (Palier 1 — plateforme de commande en5#         ligne des restos eux-mêmes -> PRIX RÉELS, contexte `takeout`).6#         API JSON publique et non authentifiée :7#           GET api.chownow.com/api/restaurant/<id>       (fiche : nom,8#               adresse, téléphone, GPS « lat,lng », cuisines, site web)9#           GET api.chownow.com/api/restaurant/<id>/menu  (menu_categories[]10#               .items[] {name, description, price, size} + graphe complet11#               des modificateurs -> options §5.2)12#         Une intégration = un id de resto ChowNow, consigné dans13#         data/sources.json (source « chownow » -> integrations[]) et/ou14#         découvert automatiquement par le connecteur site-resto15#         (data/chownow-discovered.json). CONNECTEUR DORMANT tant qu'aucune16#         intégration n'est connue (SkipSource — pas un échec).17#         Seuls les restos au Québec (state == QC) sont émis.18#19#         Mode d'extraction : API directe (2 requêtes/resto), politesse 0.6 s.20#         Base légale §15 : API ouverte de la plateforme du resto (source de21#         première partie), extraction minimale, lien source conservé.22# ==============================================================================23from __future__ import annotations2425import json26import sys27from pathlib import Path2829from ..schema import Restaurant30from .base import BaseConnector, SkipSource3132API = "https://api.chownow.com/api/restaurant"33SOURCES_PATH = Path(__file__).resolve().parents[2] / "data" / "sources.json"343536def parse_geo(geo: str) -> tuple[float | None, float | None]:37    """« 45.5017,-73.5673 » -> (lat, lng)."""38    try:39        lat_s, lng_s = (geo or "").split(",", 1)40        return float(lat_s), float(lng_s)41    except (ValueError, AttributeError):42        return None, None434445def build_options(item: dict, mod_cats: dict[str, dict],46                  mods: dict[str, dict]) -> list[dict]:47    """Options §5.2 depuis le graphe de modificateurs ChowNow (item ->48    modifier_categories -> modifiers {name, price})."""49    options: list[dict] = []50    for cat_id in item.get("modifier_categories") or []:51        cat = mod_cats.get(str(cat_id))52        if not cat:53            continue54        choices = []55        for mod_id in cat.get("modifiers") or []:56            mod = mods.get(str(mod_id))57            if not mod or not (mod.get("name") or "").strip():58                continue59            price = mod.get("price")60            choices.append({61                "name": mod["name"].strip(),62                "price_delta": round(float(price), 2)63                if isinstance(price, (int, float)) else 0.0,64            })65        if choices:66            options.append({67                "group": (cat.get("name") or "").strip() or "Options",68                "required": bool(cat.get("min_qty")),69                "choices": choices,70            })71    return options727374def build_menu(menu_payload: dict, captured_at: str) -> dict | None:75    """Menu standard Resto·Ka (CLAUDE.md §5.2) depuis menu_categories."""76    mod_cats = {str(c.get("id")): c77                for c in menu_payload.get("modifier_categories") or []}78    mods = {str(m.get("id")): m for m in menu_payload.get("modifiers") or []}79    sections_out: list[dict] = []80    seen: set[str] = set()81    for cat in menu_payload.get("menu_categories") or []:82        name = (cat.get("name") or "").strip()83        if not name or name in seen:84            continue85        items = []86        for it in cat.get("items") or []:87            title = (it.get("name") or "").strip()88            if not title:89                continue90            size = (it.get("size") or "").strip()91            if size and size.lower() not in ("regular", "régulier"):92                title = f"{title} ({size})"93            price = it.get("price")94            entry = {95                "name": title,96                "description": (it.get("description") or "").strip(),97                "price": round(float(price), 2)98                if isinstance(price, (int, float)) and price > 0 else None,99            }100            options = build_options(it, mod_cats, mods)101            if options:102                entry["options"] = options103            items.append(entry)104        if items:105            seen.add(name)106            sections_out.append({"name": name, "items": items})107    if not sections_out:108        return None109    return {110        "price_context": "takeout",111        "price_source": "chownow",112        "currency": "CAD",113        "captured_at": captured_at,114        "sections": sections_out,115    }116117118class ChowNowConnector(BaseConnector):119    source_id = "chownow"120    request_delay = 0.8121    timeout = 30122    use_detail_cache = False       # 2 requêtes API légères par resto123124    def _integrations(self) -> list[dict]:125        registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))126        out: list[dict] = []127        for src in registry.get("sources", []):128            if src.get("id") == self.source_id:129                out.extend(src.get("integrations") or [])130        # ids découverts automatiquement par site-resto (widget ChowNow131        # détecté sur le site d'un resto) — voir siteresto.py132        discovered = SOURCES_PATH.parent / "chownow-discovered.json"133        if discovered.exists():134            try:135                extra = json.loads(discovered.read_text(encoding="utf-8"))136                known = {str(i.get("key")) for i in out}137                out.extend(i for i in extra.get("integrations", [])138                           if str(i.get("key")) not in known)139            except ValueError:140                pass141        return out142143    def _now(self) -> str:144        import datetime145        return datetime.datetime.now(datetime.timezone.utc) \146            .strftime("%Y-%m-%dT%H:%M:%SZ")147148    def _fetch_restaurant(self, cn_id: str) -> Restaurant | None:149        info = self.get(f"{API}/{cn_id}",150                        headers={"Accept": "application/json"}).json()151        addr = info.get("address") or {}152        state = (addr.get("state") or "").strip().upper()153        if state and state != "QC":154            print(f"[resto-ka] chownow: {cn_id} hors Québec ({state}) — ignoré",155                  file=sys.stderr)156            return None157        name = (info.get("name") or "").strip()158        if not name:159            return None160        lat, lng = parse_geo(addr.get("geo") or "")161        menu_payload = self.get(f"{API}/{cn_id}/menu",162                                headers={"Accept": "application/json"}).json()163        menu = build_menu(menu_payload or {}, self._now())164        services = ["takeout"]165        if (info.get("fulfillment") or {}).get("delivery") \166                or info.get("has_low_cost_delivery"):167            services.append("delivery")168        return Restaurant(169            source=self.source_id,170            external_id=str(cn_id),171            name=name,172            url=f"https://direct.chownow.com/order/{cn_id}/locations",173            address=(addr.get("street_address1") or "").strip(),174            city=(addr.get("city") or "").strip(),175            postal_code=(addr.get("zip") or "").strip(),176            lat=lat, lng=lng,177            phone=(info.get("phone") or "").strip(),178            website=(info.get("website_url") or "").strip(),179            services=services,180            menu=menu,181        )182183    def fetch(self) -> list[Restaurant]:184        integrations = [i for i in self._integrations()185                        if i.get("key") and i.get("status") != "inactif"]186        if not integrations:187            raise SkipSource(188                "aucune intégration ChowNow connue (data/sources.json et "189                "data/chownow-discovered.json vides) — connecteur dormant, "190                "en attente des découvertes du connecteur site-resto")191        out: list[Restaurant] = []192        for integ in integrations:193            cn_id = str(integ["key"])194            try:195                resto = self._fetch_restaurant(cn_id)196            except Exception as exc:   # un resto ne bloque pas les autres197                print(f"[resto-ka] chownow: intégration {cn_id} ignorée : "198                      f"{exc}", file=sys.stderr)199                continue200            if resto is not None:201                out.append(resto)202        print(f"[resto-ka] chownow: {len(integrations)} intégration(s) -> "203              f"{len(out)} resto(s) QC avec menu takeout")204        return out205