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/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