SPB Git

spb/resto-ka

Public

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

Vague 2 : inspections MAPAQ, horaires structurés, contact site-resto, Yelp, fix Overpass 406

- MAPAQ (Données Québec, CC-BY 4.0) : module restoka/inspections.py — import
  des 2 994 condamnations (listecondamnation.csv) dans la table liée
  inspections, croisement CONSERVATEUR avec les restaurants (nom+lieu avec
  CP ou civique exigé ; adresse+nom hors tokens de ville — jamais la mauvaise
  succursale d une chaîne, jamais deux voisins d un centre commercial) :
  288 condamnations croisées sur 204 restos. details.mapaq + endpoint
  GET /api/restaurants/{uid}/inspections + compteurs /api/stats.
- Horaires : restoka/hours.py — parseur opening_hours OSM (plages de jours,
  double service, off, 24/7, PH ignoré ; l exotique reste en brut) + JSON-LD
  schema.org. Câblé dans le connecteur OSM et backfillé : 2 656/2 939 tags
  bruts structurés par jour (90,4 %).
- site-resto : extraction téléphone (tel:/JSON-LD/regex, E.164, garde
  anti-annuaire de chaîne), horaires JSON-LD et lien de réservation
  (OpenTable, Libro, Resto Loco…) -> COALESCE conservateur sur la fiche OSM
  (db.enrich_contact) + details.reservation_url (pending_enrichment).
- Yelp Fusion : connecteur d enrichissement complet (rating, review_count,
  price, categories, phone -> details.yelp ; croisement par téléphone puis
  /businesses/matches, GPS <300 m si ambigu, budget 450 req/run). Livré en
  SkipSource « clé requise » (YELP_API_KEY absente) — nouveau patron
  SkipSource dans base.py + .env.example.
- Overpass 406 : overpass-api.de refuse les UA déguisés en navigateur mais
  accepte un UA de bot honnête — UA dédié OVERPASS_UA (vérifié live, 200).
- Colonne restaurants.details (migration auto, jamais touchée par
  sync_source), registre data/sources.json à 6 sources, /api/stats expose
  inspections/inspections_matched/restaurants_with_inspections.
- Tests : 64 au vert (51 existants + 13 nouveaux : hours, inspections,
  extraction contact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 18, 2026) parent 21c54c2 Browse files

15 changed files +1,215 −11

added .env.example +28 −0
@@ -0,0 +1,28 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: .env.example
4+# Desc: Resto·Ka — gabarit des secrets locaux. Copier vers `.env` (jamais
5+# commité, voir .gitignore) et remplir les clés.
6+# ==============================================================================
7+
8+# Scrapfly — rendu JS + bypass anti-bot (plateformes de livraison, §10/§15)
9+SCRAPFLY_KEY=
10+
11+# Anthropic — structuration des menus maison par Claude (restoka/menullm.py)
12+ANTHROPIC_API_KEY=
13+
14+# Firecrawl — rendu JS de secours pour les sites de restos SPA (site-resto)
15+FIRECRAWL_API_KEY=
16+
17+# Yelp Fusion — enrichissement avis/notes (connecteur yelp, gratuit 500 req/j)
18+# Créer une app : https://www.yelp.com/developers — sans clé, la source est
19+# simplement sautée (SkipSource « clé requise »).
20+YELP_API_KEY=
21+
22+# Budget de requêtes Yelp par passage (quota quotidien 500)
23+# YELP_BUDGET=450
24+
25+# SSO Groupe KA (« Se connecter avec KA ») + sessions
26+KA_SSO_SECRET=
27+SESSION_SECRET=
28+RESTOKA_BASE_URL=https://www.resto-ka.com
modified data/sources.json +27 −1
@@ -256,6 +256,32 @@
256 "acces_legal": "Site de commande de la chaîne elle-même (prix réels). À industrialiser prudemment (CLAUDE.md §15).", 256 "acces_legal": "Site de commande de la chaîne elle-même (prix réels). À industrialiser prudemment (CLAUDE.md §15).",
257 "cadence": "hebdomadaire (visé)", 257 "cadence": "hebdomadaire (visé)",
258 "status": "à faire" 258 "status": "à faire"
259+ },
260+ {
261+ "id": "mapaq",
262+ "name": "MAPAQ — Inspections alimentaires (condamnations)",
263+ "url": "https://www.donneesquebec.ca/recherche/dataset/condamnations-des-etablissements-alimentaires-et-condamnations-concernant-le-bien-etre-des-anim",
264+ "platform": "donnees-quebec",
265+ "connector": "inspections (module d'enrichissement, pas de fiches)",
266+ "tier": 5,
267+ "price_context": "aucun",
268+ "extraction": "CSV ouvert listecondamnation.csv (Données Québec) -> table liée `inspections` + croisement CONSERVATEUR avec les restaurants (nom normalisé + ville/code postal, ou code postal + civique + similarité de nom). Résumé conformité dans details.mapaq, endpoint GET /api/restaurants/{uid}/inspections.",
269+ "acces_legal": "Données ouvertes du gouvernement du Québec, licence CC-BY 4.0 — attribution MAPAQ/Données Québec affichée avec les inspections.",
270+ "cadence": "hebdomadaire (guard 6 jours dans ingest.enrich)",
271+ "status": "actif"
272+ },
273+ {
274+ "id": "yelp",
275+ "name": "Yelp Fusion (avis et notes)",
276+ "url": "https://www.yelp.com/developers",
277+ "platform": "yelp",
278+ "connector": "yelp",
279+ "tier": 5,
280+ "price_context": "aucun",
281+ "extraction": "API officielle Yelp Fusion (gratuit, 500 req/jour) : rating, review_count, price, categories, phone -> details.yelp. Croisement conservateur par téléphone (/businesses/search/phone, GPS <300 m si ambigu) puis nom+adresse+GPS (/businesses/matches). N'émet aucune fiche.",
282+ "acces_legal": "API officielle avec clé, Display Requirements Yelp : attribution affichée avec la note.",
283+ "cadence": "hebdomadaire, re-vérification 30 jours",
284+ "status": "clé requise (YELP_API_KEY absente de .env — connecteur livré, SkipSource)"
259 } 285 }
260 ] 286 ]
261−}
\ No newline at end of file
287+}
modified restoka/connectors/base.py +8 −0
@@ -23,6 +23,14 @@ FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
23 SCRAPFLY_API = "https://api.scrapfly.io/scrape" 23 SCRAPFLY_API = "https://api.scrapfly.io/scrape"
24 24
25 25
26+class SkipSource(RuntimeError):
27+ """Levée par un connecteur qui ne peut pas tourner (clé API manquante…).
28+
29+ Le pipeline (ingest.run) consigne le motif et passe à la source suivante
30+ SANS marquer d'échec dans sync_log — ce n'est pas une erreur, la source
31+ est simplement en attente (ex. Yelp Fusion sans YELP_API_KEY)."""
32+
33+
26 class BaseConnector: 34 class BaseConnector:
27 """Un connecteur = un adaptateur propre à une source (ou une plateforme). 35 """Un connecteur = un adaptateur propre à une source (ou une plateforme).
28 36
modified restoka/connectors/osm.py +15 −1
@@ -21,6 +21,7 @@ from __future__ import annotations
21 21
22 import sys 22 import sys
23 23
24+from ..hours import parse_opening_hours
24 from ..normalize import classify_cuisines 25 from ..normalize import classify_cuisines
25 from ..schema import Restaurant 26 from ..schema import Restaurant
26 from .base import BaseConnector 27 from .base import BaseConnector
@@ -34,6 +35,13 @@ MIRRORS = [
34 "https://overpass.private.coffee/api/interpreter", 35 "https://overpass.private.coffee/api/interpreter",
35 "https://overpass.osm.jp/api/interpreter", 36 "https://overpass.osm.jp/api/interpreter",
36 ] 37 ]
38+
39+# User-Agent DÉDIÉ aux miroirs Overpass. Vérifié le 2026-08-18 :
40+# overpass-api.de répond 406 Not Acceptable à tout UA « déguisé en
41+# navigateur » (Mozilla/… Chrome/… — y compris notre UA global qui préfixe
42+# RestoKaBot d'une chaîne Chrome) et à python-requests, mais accepte un UA
43+# de bot honnête et identifiable. On envoie donc le nom nu du bot ici.
44+OVERPASS_UA = "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)"
37 # NB : overpass.osm.ch RETIRÉ des miroirs — c'est un extrait SUISSE seulement : 45 # NB : overpass.osm.ch RETIRÉ des miroirs — c'est un extrait SUISSE seulement :
38 # il répond 200 avec 0 élément pour l'aire Québec, ce qui empoisonnait les 46 # il répond 200 avec 0 élément pour l'aire Québec, ce qui empoisonnait les
39 # runs (incident des runs #2/#3 : des types entiers « réussis » à 0 POI). 47 # runs (incident des runs #2/#3 : des types entiers « réussis » à 0 POI).
@@ -135,7 +143,8 @@ class OsmConnector(BaseConnector):
135 last_exc: Exception | None = None 143 last_exc: Exception | None = None
136 for mirror in MIRRORS: 144 for mirror in MIRRORS:
137 try: 145 try:
138− resp = self.post(mirror, data={"data": query}) 146+ resp = self.post(mirror, data={"data": query},
147+ headers={"User-Agent": OVERPASS_UA})
139 data = resp.json() 148 data = resp.json()
140 # Overpass peut répondre 200 avec des résultats TRONQUÉS et un 149 # Overpass peut répondre 200 avec des résultats TRONQUÉS et un
141 # « remark » (timeout/mémoire) — traiter comme un échec de 150 # « remark » (timeout/mémoire) — traiter comme un échec de
@@ -213,6 +222,11 @@ class OsmConnector(BaseConnector):
213 hours = {} 222 hours = {}
214 if tags.get("opening_hours"): 223 if tags.get("opening_hours"):
215 hours["osm"] = tags["opening_hours"] 224 hours["osm"] = tags["opening_hours"]
225+ # horaires STRUCTURÉS par jour quand la syntaxe est courante
226+ # (Mo-Fr 11:00-22:00…) ; les cas exotiques restent en brut
227+ parsed = parse_opening_hours(tags["opening_hours"])
228+ if parsed:
229+ hours.update(parsed)
216 osm_id = f"{el.get('type')}/{el.get('id')}" 230 osm_id = f"{el.get('type')}/{el.get('id')}"
217 return Restaurant( 231 return Restaurant(
218 source=self.source_id, 232 source=self.source_id,
modified restoka/connectors/siteresto.py +110 −7
@@ -34,6 +34,8 @@ import sys
34 import urllib.parse 34 import urllib.parse
35 from pathlib import Path 35 from pathlib import Path
36 36
37+from ..hours import parse_jsonld_hours
38+from ..normalize import normalize_phone
37 from ..schema import Restaurant 39 from ..schema import Restaurant
38 from .base import BaseConnector 40 from .base import BaseConnector
39 41
@@ -49,6 +51,13 @@ REFRESH_DAYS = 30 # re-crawl des menus captés (cadence §14)
49 # verrou d'écriture du fichier des intégrations UEAT découvertes (workers //) 51 # verrou d'écriture du fichier des intégrations UEAT découvertes (workers //)
50 _UEAT_FILE_LOCK = __import__("threading").Lock() 52 _UEAT_FILE_LOCK = __import__("threading").Lock()
51 53
54+# plateformes de réservation reconnues (lien « réserver » sur le site du resto)
55+_RESERVATION_DOMAINS = (
56+ "opentable.com", "opentable.ca", "libroreserve.com", "restoloco.com",
57+ "restoloco.ca", "resy.com", "sevenrooms.com", "bookenda.com",
58+ "zenchef.com", "guestonline.io", "tablein.com",
59+)
60+
52 _UEAT_RE = re.compile( 61 _UEAT_RE = re.compile(
53 r"(?:order\.ueat\.io/integration/|order-online\.ai.{0,200}?integration/)" 62 r"(?:order\.ueat\.io/integration/|order-online\.ai.{0,200}?integration/)"
54 r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.I) 63 r"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", re.I)
@@ -144,6 +153,66 @@ class SiteRestoConnector(BaseConnector):
144 pages.append(full) 153 pages.append(full)
145 return pages[:2], pdfs[:2] 154 return pages[:2], pdfs[:2]
146 155
156+ # -- extraction contact/horaires/réservation (page d'accueil) ---------------------
157+ @staticmethod
158+ def _extract_contact(html: str, base_url: str) -> dict:
159+ """{"phone", "hours", "reservation_url"} extraits du HTML d'accueil.
160+
161+ - téléphone : lien tel: d'abord (fiable), sinon regex nord-américaine
162+ sur le texte visible -> E.164 (+1XXXXXXXXXX) ;
163+ - réservation : premier lien vers une plateforme connue (OpenTable,
164+ Libro, Resto Loco…) ;
165+ - horaires : JSON-LD schema.org (LocalBusiness/Restaurant,
166+ openingHoursSpecification ou openingHours) -> structuré par jour.
167+ """
168+ from bs4 import BeautifulSoup
169+ out = {"phone": "", "hours": None, "reservation_url": ""}
170+ if not html:
171+ return out
172+ soup = BeautifulSoup(html, "html.parser")
173+ tel_numbers: list[str] = []
174+ for a in soup.find_all("a", href=True):
175+ href = a["href"].strip()
176+ if href.lower().startswith("tel:"):
177+ p = normalize_phone(href[4:])
178+ if p and p not in tel_numbers:
179+ tel_numbers.append(p)
180+ if not out["reservation_url"] and href.startswith("http"):
181+ dom = _domain(href)
182+ if any(dom == d or dom.endswith("." + d)
183+ for d in _RESERVATION_DOMAINS):
184+ out["reservation_url"] = href
185+ # >3 numéros distincts = page « trouver une succursale » d'une chaîne :
186+ # impossible d'attribuer LE bon numéro à CE resto — on s'abstient
187+ if 1 <= len(tel_numbers) <= 3:
188+ out["phone"] = tel_numbers[0]
189+ # horaires structurés via JSON-LD (LocalBusiness / Restaurant)
190+ for script in soup.find_all("script", type="application/ld+json"):
191+ try:
192+ data = json.loads(script.string or "")
193+ except (ValueError, TypeError):
194+ continue
195+ nodes = data if isinstance(data, list) else [data]
196+ more = []
197+ for n in nodes:
198+ if isinstance(n, dict) and isinstance(n.get("@graph"), list):
199+ more.extend(n["@graph"])
200+ for node in nodes + more:
201+ if not isinstance(node, dict):
202+ continue
203+ if not out["hours"]:
204+ out["hours"] = parse_jsonld_hours(node)
205+ if not out["phone"]:
206+ out["phone"] = normalize_phone(node.get("telephone"))
207+ if out["hours"] and out["phone"]:
208+ break
209+ if not out["phone"]: # secours : regex sur le texte visible
210+ text = SiteRestoConnector._html_text(html)[:20_000]
211+ m = re.search(r"(?:t[eé]l|phone|appelez|r[eé]servation)[^\n]{0,40}?"
212+ r"(\(?\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4})", text, re.I)
213+ out["phone"] = normalize_phone(m.group(1) if m else "")
214+ return out
215+
147 # -- traitement d'un resto ------------------------------------------------------- 216 # -- traitement d'un resto -------------------------------------------------------
148 def _discover_ueat(self, html: str, final_url: str) -> str | None: 217 def _discover_ueat(self, html: str, final_url: str) -> str | None:
149 """GUID d'intégration UEAT si le site embarque la plateforme.""" 218 """GUID d'intégration UEAT si le site embarque la plateforme."""
@@ -179,14 +248,17 @@ class SiteRestoConnector(BaseConnector):
179 f" ({guid}) -> ueat-discovered.json") 248 f" ({guid}) -> ueat-discovered.json")
180 249
181 def _crawl_menu(self, website: str, firecrawl_budget: list[int]) -> dict | None: 250 def _crawl_menu(self, website: str, firecrawl_budget: list[int]) -> dict | None:
182− """Retourne {"sections": [...], "url": <page>} ou None.""" 251+ """Retourne {"sections": [...], "url": <page>, "contact": {...}},
252+ {"ueat": guid, "contact": ...} ou {"contact": ...} (pas de menu mais
253+ téléphone/horaires/réservation captés) ou None."""
183 from .. import menullm 254 from .. import menullm
184 home = self._get_html(website) 255 home = self._get_html(website)
185 if not home: 256 if not home:
186 return None 257 return None
258+ contact = self._extract_contact(home, website)
187 guid = self._discover_ueat(home, website) 259 guid = self._discover_ueat(home, website)
188 if guid: 260 if guid:
189− return {"ueat": guid} 261+ return {"ueat": guid, "contact": contact}
190 pages, pdfs = self._menu_links(home, website) 262 pages, pdfs = self._menu_links(home, website)
191 # 1) pages HTML de menu 263 # 1) pages HTML de menu
192 for page_url in pages: 264 for page_url in pages:
@@ -203,20 +275,25 @@ class SiteRestoConnector(BaseConnector):
203 if len(_PRICE_HINT_RE.findall(text)) >= 3: 275 if len(_PRICE_HINT_RE.findall(text)) >= 3:
204 menu = menullm.menu_from_text(text) 276 menu = menullm.menu_from_text(text)
205 if menu: 277 if menu:
206− return {"sections": menu["sections"], "url": page_url} 278+ return {"sections": menu["sections"], "url": page_url,
279+ "contact": contact}
207 # 2) PDF de menu 280 # 2) PDF de menu
208 for pdf_url in pdfs: 281 for pdf_url in pdfs:
209 pdf = self._get_pdf(pdf_url) 282 pdf = self._get_pdf(pdf_url)
210 if pdf[:5] == b"%PDF-": 283 if pdf[:5] == b"%PDF-":
211 menu = menullm.menu_from_pdf(pdf) 284 menu = menullm.menu_from_pdf(pdf)
212 if menu: 285 if menu:
213− return {"sections": menu["sections"], "url": pdf_url} 286+ return {"sections": menu["sections"], "url": pdf_url,
287+ "contact": contact}
214 # 3) la page d'accueil elle-même contient parfois le menu 288 # 3) la page d'accueil elle-même contient parfois le menu
215 text = self._html_text(home) 289 text = self._html_text(home)
216 if len(_PRICE_HINT_RE.findall(text)) >= 8: 290 if len(_PRICE_HINT_RE.findall(text)) >= 8:
217 menu = menullm.menu_from_text(text) 291 menu = menullm.menu_from_text(text)
218 if menu: 292 if menu:
219− return {"sections": menu["sections"], "url": website} 293+ return {"sections": menu["sections"], "url": website,
294+ "contact": contact}
295+ if any(v for v in contact.values()):
296+ return {"contact": contact} # pas de menu, mais du contact
220 return None 297 return None
221 298
222 # -- reconstruction des fiches déjà captées ------------------------------------------ 299 # -- reconstruction des fiches déjà captées ------------------------------------------
@@ -270,8 +347,11 @@ class SiteRestoConnector(BaseConnector):
270 347
271 firecrawl_budget = [MAX_FIRECRAWL_PER_RUN 348 firecrawl_budget = [MAX_FIRECRAWL_PER_RUN
272 if os.environ.get("FIRECRAWL_API_KEY") else 0] 349 if os.environ.get("FIRECRAWL_API_KEY") else 0]
273− stats = {"crawled": 0, "menus": 0, "ueat": 0, "vides": 0} 350+ stats = {"crawled": 0, "menus": 0, "ueat": 0, "vides": 0, "contacts": 0}
274 now = time.time() 351 now = time.time()
352+ # enrichissements à appliquer APRÈS sync_source (la fiche site-resto
353+ # n'existe en base qu'après l'upsert) — consommé par ingest.run
354+ self.pending_enrichment: list[tuple[str, dict]] = []
275 355
276 # 1re passe (fil principal) : réémission depuis le cache + sélection 356 # 1re passe (fil principal) : réémission depuis le cache + sélection
277 # des sites à crawler (budget) — aucune E/S réseau ici. 357 # des sites à crawler (budget) — aucune E/S réseau ici.
@@ -327,13 +407,27 @@ class SiteRestoConnector(BaseConnector):
327 results = pool.map(_crawl_one, to_crawl) 407 results = pool.map(_crawl_one, to_crawl)
328 for row, domain, website, result in results: 408 for row, domain, website, result in results:
329 stats["crawled"] += 1 409 stats["crawled"] += 1
410+ # téléphone / horaires / lien de réservation captés sur le
411+ # site : COALESCE conservateur sur la fiche OSM d'origine
412+ # (fil principal seulement — les workers ne touchent pas la BD)
413+ contact = (result or {}).get("contact") or {}
414+ if any(v for v in contact.values()):
415+ from .. import db as _db
416+ _db.enrich_contact(
417+ con, row["uid"], phone=contact.get("phone") or "",
418+ hours=contact.get("hours"),
419+ details={"reservation_url":
420+ contact.get("reservation_url")}
421+ if contact.get("reservation_url") else None)
422+ con.commit()
423+ stats["contacts"] += 1
330 if result and result.get("ueat"): 424 if result and result.get("ueat"):
331 self._record_ueat(result["ueat"], row["name"], domain) 425 self._record_ueat(result["ueat"], row["name"], domain)
332 self._set_status(domain, {"status": "ueat", 426 self._set_status(domain, {"status": "ueat",
333 "guid": result["ueat"]}) 427 "guid": result["ueat"]})
334 stats["ueat"] += 1 428 stats["ueat"] += 1
335 continue 429 continue
336− if not result: 430+ if not result or not result.get("sections"):
337 self._set_status(domain, {"status": "no_menu"}) 431 self._set_status(domain, {"status": "no_menu"})
338 stats["vides"] += 1 432 stats["vides"] += 1
339 continue 433 continue
@@ -352,6 +446,15 @@ class SiteRestoConnector(BaseConnector):
352 resto.cuisines = [] # reclassées avec le texte du menu 446 resto.cuisines = [] # reclassées avec le texte du menu
353 resto.price_range = "" 447 resto.price_range = ""
354 resto.dietary_options = [] 448 resto.dietary_options = []
449+ # contact capté sur le site : complète la fiche émise (COALESCE)
450+ if contact.get("phone") and not (resto.phone or "").strip():
451+ resto.phone = contact["phone"]
452+ if contact.get("hours"):
453+ resto.hours = {**contact["hours"], **(resto.hours or {})}
454+ if contact.get("reservation_url"):
455+ self.pending_enrichment.append(
456+ (resto.uid, {"details": {"reservation_url":
457+ contact["reservation_url"]}}))
355 out[resto.uid] = resto 458 out[resto.uid] = resto
356 self._set_status(domain, {"status": "menu", "url": result["url"], 459 self._set_status(domain, {"status": "menu", "url": result["url"],
357 "captured_at": captured_at, 460 "captured_at": captured_at,
added restoka/connectors/yelp.py +172 −0
@@ -0,0 +1,172 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: restoka/connectors/yelp.py
4+# Desc: Connecteur d'ENRICHISSEMENT Yelp Fusion (avis/notes) — gratuit,
5+# 500 requêtes/jour. N'émet AUCUNE fiche restaurant : il complète les
6+# fiches existantes avec rating, review_count, price, categories et
7+# phone dans details.yelp (colonne d'enrichissement, jamais écrasée
8+# par les re-crawls des sources).
9+#
10+# Croisement CONSERVATEUR (jamais fusionner deux établissements) :
11+# 1. par TÉLÉPHONE (/businesses/search/phone) — signal fort ; si
12+# plusieurs résultats, on exige le GPS le plus proche (<300 m) ;
13+# 2. sinon par NOM+ADRESSE+GPS (/businesses/matches) — l'endpoint
14+# d'appariement officiel de Yelp, seuil par défaut (conservateur).
15+#
16+# Clé requise : YELP_API_KEY dans .env (https://www.yelp.com/developers
17+# — app gratuite). Sans clé, le connecteur lève SkipSource et le
18+# pipeline passe son tour SANS marquer d'échec (statut « clé requise »
19+# dans data/sources.json).
20+# Base légale : API officielle Yelp Fusion, conditions Display
21+# Requirements (attribution Yelp affichée avec la note sur la fiche).
22+# ==============================================================================
23+from __future__ import annotations
24+
25+import datetime
26+import json
27+import math
28+import os
29+import sys
30+import time
31+
32+from ..normalize import normalize_phone
33+from ..schema import Restaurant
34+from .base import BaseConnector, SkipSource
35+
36+API = "https://api.yelp.com/v3"
37+REFRESH_DAYS = 30 # re-vérification des notes captées
38+MAX_REQUESTS_PER_RUN = int(os.environ.get("YELP_BUDGET", "450")) # quota 500/j
39+MAX_PHONE_DISTANCE_M = 300 # ambiguïté téléphone : GPS requis
40+
41+
42+def _haversine_m(lat1, lng1, lat2, lng2) -> float:
43+ r = 6371000.0
44+ p1, p2 = math.radians(lat1), math.radians(lat2)
45+ dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
46+ a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
47+ return 2 * r * math.asin(math.sqrt(a))
48+
49+
50+class YelpConnector(BaseConnector):
51+ source_id = "yelp"
52+ request_delay = 0.35 # politesse API (limite 5 QPS)
53+ timeout = 20
54+ use_detail_cache = False
55+ enrichment_only = True # n'émet aucune fiche (ingest.run)
56+
57+ def _api(self, path: str, params: dict) -> dict:
58+ resp = self.get(f"{API}{path}", params=params,
59+ headers={"Authorization": f"Bearer {self._key}"})
60+ return resp.json()
61+
62+ def _payload(self, biz: dict, matched_by: str) -> dict:
63+ return {
64+ "id": biz.get("id"),
65+ "url": (biz.get("url") or "").split("?")[0],
66+ "name": biz.get("name"),
67+ "rating": biz.get("rating"),
68+ "review_count": biz.get("review_count"),
69+ "price": biz.get("price"),
70+ "categories": [c.get("title") for c in biz.get("categories") or []],
71+ "phone": biz.get("phone"),
72+ "matched_by": matched_by,
73+ "fetched_at": datetime.datetime.now(datetime.timezone.utc)
74+ .strftime("%Y-%m-%dT%H:%M:%SZ"),
75+ }
76+
77+ def _match_by_phone(self, phone: str, lat, lng) -> dict | None:
78+ data = self._api("/businesses/search/phone", {"phone": phone})
79+ businesses = data.get("businesses") or []
80+ if not businesses:
81+ return None
82+ if len(businesses) == 1:
83+ return self._payload(businesses[0], "telephone")
84+ if lat is None or lng is None:
85+ return None # plusieurs candidats, pas de GPS : on passe
86+ best, best_d = None, None
87+ for b in businesses:
88+ c = b.get("coordinates") or {}
89+ if c.get("latitude") is None or c.get("longitude") is None:
90+ continue
91+ d = _haversine_m(lat, lng, c["latitude"], c["longitude"])
92+ if best_d is None or d < best_d:
93+ best, best_d = b, d
94+ if best is not None and best_d is not None and best_d <= MAX_PHONE_DISTANCE_M:
95+ return self._payload(best, "telephone+gps")
96+ return None
97+
98+ def _match_by_name(self, row) -> dict | None:
99+ if not (row["name"] and row["address"] and row["city"]
100+ and row["lat"] is not None and row["lng"] is not None):
101+ return None
102+ data = self._api("/businesses/matches", {
103+ "name": row["name"][:64], "address1": row["address"][:64],
104+ "city": row["city"][:64], "state": "QC", "country": "CA",
105+ "latitude": row["lat"], "longitude": row["lng"], "limit": 1,
106+ })
107+ businesses = data.get("businesses") or []
108+ if not businesses:
109+ return None
110+ biz = businesses[0]
111+ # /matches ne retourne pas rating/review_count : détail requis
112+ detail = self._api(f"/businesses/{biz['id']}", {})
113+ return self._payload(detail or biz, "nom+gps")
114+
115+ def fetch(self) -> list[Restaurant]:
116+ self._key = os.environ.get("YELP_API_KEY", "").strip()
117+ if not self._key:
118+ raise SkipSource("YELP_API_KEY manquant (.env) — connecteur prêt, "
119+ "clé gratuite : https://www.yelp.com/developers")
120+ from .. import db
121+ con = db.connect()
122+ now = time.time()
123+ stale = now - REFRESH_DAYS * 86400
124+ budget = MAX_REQUESTS_PER_RUN
125+ enriched = skipped = 0
126+ rows = con.execute(
127+ "SELECT uid, name, address, city, lat, lng, phone, details"
128+ " FROM restaurants WHERE active=1 AND dup_of IS NULL"
129+ " ORDER BY phone<>'' DESC, updated_at DESC").fetchall()
130+ for row in rows:
131+ if budget <= 0:
132+ break
133+ try:
134+ details = json.loads(row["details"] or "{}")
135+ except ValueError:
136+ details = {}
137+ yelp = details.get("yelp") or {}
138+ if yelp.get("fetched_at"):
139+ try:
140+ ts = datetime.datetime.strptime(
141+ yelp["fetched_at"], "%Y-%m-%dT%H:%M:%SZ") \
142+ .replace(tzinfo=datetime.timezone.utc).timestamp()
143+ if ts > stale:
144+ continue # déjà frais (<30 j)
145+ except ValueError:
146+ pass
147+ payload = None
148+ try:
149+ phone = normalize_phone(row["phone"])
150+ if phone:
151+ budget -= 1
152+ payload = self._match_by_phone(phone, row["lat"], row["lng"])
153+ if payload is None:
154+ budget -= 2 # /matches + détail
155+ payload = self._match_by_name(row)
156+ except Exception as exc: # un resto raté ne bloque pas
157+ skipped += 1
158+ print(f"[resto-ka] yelp: {row['uid']} erreur: {exc}",
159+ file=sys.stderr)
160+ continue
161+ if payload:
162+ db.merge_details(con, row["uid"], {"yelp": payload})
163+ con.commit()
164+ enriched += 1
165+ con.commit()
166+ con.close()
167+ self.enriched_count = enriched
168+ self.enrich_message = (f"{enriched} resto(s) enrichis (avis/notes), "
169+ f"budget restant {max(budget, 0)} req")
170+ print(f"[resto-ka] yelp: {self.enrich_message}"
171+ + (f", {skipped} erreurs" if skipped else ""))
172+ return []
modified restoka/db.py +78 −0
@@ -157,6 +157,30 @@ CREATE TABLE IF NOT EXISTS favorites (
157 ts REAL, 157 ts REAL,
158 PRIMARY KEY (user_id, uid) 158 PRIMARY KEY (user_id, uid)
159 ); 159 );
160+
161+-- Inspections alimentaires MAPAQ (« Condamnations des établissements
162+-- alimentaires », Données Québec, licence CC-BY 4.0). Table LIÉE : le
163+-- croisement conservateur avec restaurants remplit `uid` (sinon NULL).
164+CREATE TABLE IF NOT EXISTS inspections (
165+ id INTEGER PRIMARY KEY AUTOINCREMENT,
166+ row_hash TEXT UNIQUE, -- anti-doublon au ré-import
167+ exploitant TEXT, -- Nom_exploitant (entité légale)
168+ etablissement TEXT, -- Raison_sociale (nom commercial)
169+ description TEXT, -- Description_infraction
170+ adresse TEXT, -- Adresse_lieu_infraction (brute)
171+ postal_code TEXT, -- extrait de l'adresse (A1A1A1)
172+ type_etablissement TEXT, -- Type_etablissement MAPAQ
173+ categorie TEXT, -- regroupement (RESTAURATION, LAIT…)
174+ date_infraction TEXT, -- ISO-8601 (date seulement)
175+ date_jugement TEXT,
176+ date_publication TEXT,
177+ montant_amende REAL,
178+ loi TEXT,
179+ motif TEXT, -- SOC_NOM_ARTCL_INFRC (INSALUBRITE…)
180+ uid TEXT, -- restaurants.uid si croisement réussi
181+ matched_by TEXT -- nom+ville | adresse+nom (traçabilité)
182+);
183+CREATE INDEX IF NOT EXISTS idx_inspections_uid ON inspections(uid);
160 """ 184 """
161 185
162 186
@@ -165,6 +189,9 @@ CREATE TABLE IF NOT EXISTS favorites (
165 _MIGRATIONS = { 189 _MIGRATIONS = {
166 "restaurants": { 190 "restaurants": {
167 "images": "TEXT", 191 "images": "TEXT",
192+ # enrichissements hors connecteurs (réservation, Yelp, MAPAQ…) —
193+ # JAMAIS touché par sync_source : survit aux re-crawls des sources
194+ "details": "TEXT",
168 }, 195 },
169 } 196 }
170 197
@@ -426,6 +453,57 @@ def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
426 con.commit() 453 con.commit()
427 454
428 455
456+# ---------------------------------------------------------------------------
457+# Enrichissements hors connecteurs (colonne `details` + COALESCE conservateur)
458+# ---------------------------------------------------------------------------
459+
460+def merge_details(con: sqlite3.Connection, uid: str, patch: dict) -> bool:
461+ """Fusionne `patch` dans la colonne JSON `details` du resto (additif :
462+ les clés du patch écrasent seulement leurs propres clés). Retourne False
463+ si le resto n'existe pas (encore)."""
464+ row = con.execute("SELECT details FROM restaurants WHERE uid=?",
465+ (uid,)).fetchone()
466+ if row is None:
467+ return False
468+ try:
469+ details = json.loads(row["details"] or "{}")
470+ except ValueError:
471+ details = {}
472+ details.update({k: v for k, v in patch.items() if v not in (None, "", {})})
473+ con.execute("UPDATE restaurants SET details=? WHERE uid=?",
474+ (json.dumps(details, ensure_ascii=False), uid))
475+ return True
476+
477+
478+def enrich_contact(con: sqlite3.Connection, uid: str, phone: str = "",
479+ hours: dict | None = None, details: dict | None = None) -> None:
480+ """Enrichissement CONSERVATEUR d'une fiche : ne remplit que les trous.
481+
482+ - phone : seulement si la colonne est vide (COALESCE) ;
483+ - hours : fusion — n'ajoute que les clés absentes (le connecteur d'origine
484+ garde la priorité) ;
485+ - details : fusion via merge_details (reservation_url, yelp, mapaq…).
486+ """
487+ row = con.execute("SELECT phone, hours FROM restaurants WHERE uid=?",
488+ (uid,)).fetchone()
489+ if row is None:
490+ return
491+ if phone and not (row["phone"] or "").strip():
492+ con.execute("UPDATE restaurants SET phone=? WHERE uid=?", (phone, uid))
493+ if hours:
494+ try:
495+ cur = json.loads(row["hours"] or "{}")
496+ except ValueError:
497+ cur = {}
498+ add = {k: v for k, v in hours.items() if k not in cur}
499+ if add:
500+ cur.update(add)
501+ con.execute("UPDATE restaurants SET hours=? WHERE uid=?",
502+ (json.dumps(cur, ensure_ascii=False), uid))
503+ if details:
504+ merge_details(con, uid, details)
505+
506+
429 # --------------------------------------------------------------------------- 507 # ---------------------------------------------------------------------------
430 # Cache des pages détail (« détail si nouveau/modifié ») 508 # Cache des pages détail (« détail si nouveau/modifié »)
431 # --------------------------------------------------------------------------- 509 # ---------------------------------------------------------------------------
added restoka/hours.py +167 −0
@@ -0,0 +1,167 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: restoka/hours.py
4+# Desc: Parseur d'horaires — convertit la syntaxe OSM `opening_hours`
5+# (« Mo-Fr 11:00-22:00; Sa-Su 10:00-23:00 ») et les horaires JSON-LD
6+# schema.org (openingHoursSpecification) en horaires STRUCTURÉS par
7+# jour : {"mon": [["11:00","22:00"]], ...}. Les syntaxes exotiques
8+# (sunrise, mois, conditions) ne sont PAS devinées : on retourne None
9+# et le tag brut reste dans hours["osm"] (jamais de perte).
10+# ==============================================================================
11+from __future__ import annotations
12+
13+import re
14+
15+__all__ = ["parse_opening_hours", "parse_jsonld_hours", "DAYS"]
16+
17+DAYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
18+
19+_OSM_DAY = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
20+
21+# schema.org : "Monday", "https://schema.org/Monday", "Mo"…
22+_SCHEMA_DAY = {
23+ "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
24+ "friday": 4, "saturday": 5, "sunday": 6,
25+ "mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6,
26+}
27+
28+_TIME_RANGE_RE = re.compile(r"^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$")
29+# marqueurs de syntaxe OSM avancée que l'on refuse de deviner (le brut suffit)
30+_EXOTIC_RE = re.compile(
31+ r"sunrise|sunset|dawn|dusk|week\s|\bweek\b|easter|\[|\"|\|\||"
32+ r"\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b", re.I)
33+
34+
35+def _expand_days(spec: str) -> list[int] | None:
36+ """« Mo-Fr », « Sa,Su », « Mo,We-Fr » -> indices 0-6 ; None si inconnu."""
37+ out: list[int] = []
38+ for part in spec.split(","):
39+ part = part.strip().lower()
40+ if not part:
41+ continue
42+ if part in ("ph", "sh"): # jours fériés/vacances : ignorés
43+ continue
44+ m = re.match(r"^([a-z]{2})\s*-\s*([a-z]{2})$", part)
45+ if m:
46+ a, b = _OSM_DAY.get(m.group(1)), _OSM_DAY.get(m.group(2))
47+ if a is None or b is None:
48+ return None
49+ i = a
50+ while True:
51+ if i not in out:
52+ out.append(i)
53+ if i == b:
54+ break
55+ i = (i + 1) % 7 # Sa-Mo enroule la semaine
56+ elif part in _OSM_DAY:
57+ if _OSM_DAY[part] not in out:
58+ out.append(_OSM_DAY[part])
59+ else:
60+ return None
61+ return out
62+
63+
64+def _parse_times(spec: str) -> list[list[str]] | None:
65+ """« 11:00-14:00,17:00-22:00 » -> [["11:00","14:00"],["17:00","22:00"]]."""
66+ if spec.strip().lower() in ("off", "closed"):
67+ return []
68+ out: list[list[str]] = []
69+ for chunk in spec.split(","):
70+ m = _TIME_RANGE_RE.match(chunk.strip())
71+ if not m:
72+ return None
73+ h1, m1, h2, m2 = (int(g) for g in m.groups())
74+ if h1 > 24 or h2 > 48 or m1 > 59 or m2 > 59:
75+ return None
76+ # « 17:00-01:00 » (ferme après minuit) est conservé tel quel
77+ out.append([f"{h1:02d}:{m1:02d}", f"{h2 % 24:02d}:{m2:02d}"
78+ if h2 > 24 else f"{h2:02d}:{m2:02d}"])
79+ return out
80+
81+
82+def parse_opening_hours(raw: str | None) -> dict[str, list[list[str]]] | None:
83+ """Tag OSM `opening_hours` -> {"mon": [["11:00","22:00"]], ...} ou None.
84+
85+ Gère les syntaxes courantes : plages de jours (Mo-Fr), listes (Sa,Su),
86+ plusieurs plages horaires (11:00-14:00,17:00-22:00), « off », « 24/7 »,
87+ PH/SH ignorés. Tout le reste (sunrise, mois, semaines, conditions) est
88+ refusé — le tag brut reste disponible dans hours["osm"].
89+ """
90+ if not raw or not isinstance(raw, str):
91+ return None
92+ raw = raw.strip()
93+ if raw == "24/7":
94+ return {d: [["00:00", "24:00"]] for d in DAYS}
95+ if _EXOTIC_RE.search(raw):
96+ return None
97+ out: dict[str, list[list[str]]] = {}
98+ for rule in raw.split(";"):
99+ rule = rule.strip().rstrip(",")
100+ if not rule:
101+ continue
102+ # séparer le spécificateur de jours du spécificateur d'heures
103+ m = re.match(r"^([A-Za-z ,\-]+?)\s+(.+)$", rule)
104+ if m and re.search(r"[a-zA-Z]", m.group(1)):
105+ days_spec, times_spec = m.group(1), m.group(2)
106+ elif re.match(r"^\d", rule): # heures seules -> tous les jours
107+ days_spec, times_spec = "Mo-Su", rule
108+ elif rule.strip().lower() in ("ph off", "sh off", "ph closed"):
109+ continue
110+ else:
111+ return None
112+ # « PH off » collé à une règle de jours (ex. « PH 10:00-… ») : ignorer
113+ if days_spec.strip().lower() in ("ph", "sh"):
114+ continue
115+ days = _expand_days(days_spec)
116+ times = _parse_times(times_spec)
117+ if days is None or times is None:
118+ return None # syntaxe non couverte : brut
119+ for d in days:
120+ out[DAYS[d]] = times
121+ return out or None
122+
123+
124+def parse_jsonld_hours(node: dict) -> dict[str, list[list[str]]] | None:
125+ """Horaires d'un nœud JSON-LD schema.org (LocalBusiness/Restaurant).
126+
127+ Gère `openingHoursSpecification` (liste de {dayOfWeek, opens, closes})
128+ et `openingHours` (chaînes façon OSM « Mo-Fr 11:00-22:00 »).
129+ """
130+ spec = node.get("openingHoursSpecification")
131+ if isinstance(spec, dict):
132+ spec = [spec]
133+ if isinstance(spec, list) and spec:
134+ out: dict[str, list[list[str]]] = {}
135+ for entry in spec:
136+ if not isinstance(entry, dict):
137+ continue
138+ days = entry.get("dayOfWeek") or []
139+ if isinstance(days, str):
140+ days = [days]
141+ opens = str(entry.get("opens") or "")[:5]
142+ closes = str(entry.get("closes") or "")[:5]
143+ if not re.match(r"^\d{1,2}:\d{2}$", opens) or \
144+ not re.match(r"^\d{1,2}:\d{2}$", closes):
145+ continue
146+ rng = [f"{int(opens.split(':')[0]):02d}:{opens.split(':')[1]}",
147+ f"{int(closes.split(':')[0]):02d}:{closes.split(':')[1]}"]
148+ for d in days:
149+ key = str(d).rsplit("/", 1)[-1].strip().lower()
150+ idx = _SCHEMA_DAY.get(key)
151+ if idx is None:
152+ continue
153+ out.setdefault(DAYS[idx], []).append(rng)
154+ if out:
155+ return out
156+ oh = node.get("openingHours")
157+ if isinstance(oh, str):
158+ oh = [oh]
159+ if isinstance(oh, list) and oh:
160+ merged: dict[str, list[list[str]]] = {}
161+ for chunk in oh:
162+ parsed = parse_opening_hours(str(chunk))
163+ if parsed:
164+ merged.update(parsed)
165+ if merged:
166+ return merged
167+ return None
modified restoka/ingest.py +29 −0
@@ -14,6 +14,7 @@ import traceback
14 14
15 from . import db 15 from . import db
16 from .connectors import CONNECTORS 16 from .connectors import CONNECTORS
17+from .connectors.base import SkipSource
17 18
18 19
19 def run(sources: list[str] | None = None) -> list[dict]: 20 def run(sources: list[str] | None = None) -> list[dict]:
@@ -31,6 +32,19 @@ def run(sources: list[str] | None = None) -> list[dict]:
31 try: 32 try:
32 connector = cls() 33 connector = cls()
33 restos = connector.fetch() 34 restos = connector.fetch()
35+ # connecteur d'ENRICHISSEMENT (ex. Yelp) : complète les fiches
36+ # existantes sans en émettre — pas de diff, journal dédié
37+ if getattr(connector, "enrichment_only", False):
38+ n = getattr(connector, "enriched_count", 0)
39+ msg = getattr(connector, "enrich_message", "enrichissement")
40+ con.execute(
41+ "INSERT INTO sync_log (source, ts, found, added, updated,"
42+ " removed, ok, message) VALUES (?,?,?,0,?,0,1,?)",
43+ (sid, time.time(), n, n, msg))
44+ con.commit()
45+ results.append({"source": sid, "enriched": n,
46+ "seconds": round(time.time() - t0, 1)})
47+ continue
34 finalized, dropped = [], 0 48 finalized, dropped = [], 0
35 for r in restos: 49 for r in restos:
36 try: 50 try:
@@ -43,6 +57,13 @@ def run(sources: list[str] | None = None) -> list[dict]:
43 stats = db.sync_source(con, sid, finalized, 57 stats = db.sync_source(con, sid, finalized,
44 partial=getattr(connector, "partial_run", 58 partial=getattr(connector, "partial_run",
45 False)) 59 False))
60+ # enrichissements différés (ex. site-resto : reservation_url de la
61+ # fiche site-resto, qui n'existe en base qu'après l'upsert)
62+ for uid, patch in getattr(connector, "pending_enrichment", []) or []:
63+ db.enrich_contact(con, uid, phone=patch.get("phone", ""),
64+ hours=patch.get("hours"),
65+ details=patch.get("details"))
66+ con.commit()
46 stats["seconds"] = round(time.time() - t0, 1) 67 stats["seconds"] = round(time.time() - t0, 1)
47 if dropped: 68 if dropped:
48 stats["dropped"] = dropped 69 stats["dropped"] = dropped
@@ -50,6 +71,9 @@ def run(sources: list[str] | None = None) -> list[dict]:
50 print(f"[resto-ka] ⚠ ALERTE {sid} : {stats['alert']}") 71 print(f"[resto-ka] ⚠ ALERTE {sid} : {stats['alert']}")
51 print(f"[resto-ka] {stats}") 72 print(f"[resto-ka] {stats}")
52 results.append(stats) 73 results.append(stats)
74+ except SkipSource as exc: # source en attente (clé API manquante…)
75+ print(f"[resto-ka] {sid} sauté : {exc}")
76+ results.append({"source": sid, "skipped": str(exc)})
53 except Exception as exc: # robustesse : une source ne bloque pas les autres 77 except Exception as exc: # robustesse : une source ne bloque pas les autres
54 db.log_failure(con, sid, f"{exc}") 78 db.log_failure(con, sid, f"{exc}")
55 traceback.print_exc() 79 traceback.print_exc()
@@ -75,6 +99,11 @@ def enrich() -> None:
75 print(f"[resto-ka] dedup: {stats}") 99 print(f"[resto-ka] dedup: {stats}")
76 except Exception as exc: 100 except Exception as exc:
77 print(f"[resto-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr) 101 print(f"[resto-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr)
102+ try: # inspections MAPAQ (condamnations) — cadence hebdo, croisement
103+ from . import inspections
104+ inspections.sync()
105+ except Exception as exc:
106+ print(f"[resto-ka] mapaq: erreur non bloquante: {exc}", file=sys.stderr)
78 107
79 108
80 def watch(interval_seconds: int = 7 * 86400 // 7) -> None: 109 def watch(interval_seconds: int = 7 * 86400 // 7) -> None:
added restoka/inspections.py +284 −0
@@ -0,0 +1,284 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: restoka/inspections.py
4+# Desc: Connecteur MAPAQ — « Condamnations des établissements alimentaires »
5+# (Données Québec, jeu 515374ee…, CSV listecondamnation.csv, licence
6+# CC-BY 4.0). Inspections alimentaires condamnées : établissement,
7+# adresse, dates, amende, motif (INSALUBRITE…). Alimente la table
8+# LIÉE `inspections` + croisement CONSERVATEUR avec les restaurants
9+# (jamais de fusion hasardeuse : nom normalisé + ville/code postal,
10+# ou code postal + civique + similarité de nom).
11+#
12+# Ce n'est PAS une source de fiches restaurant : c'est un
13+# enrichissement conformité/qualité appelé par ingest.enrich()
14+# (cadence hebdomadaire, guard via sync_log source='mapaq').
15+# ==============================================================================
16+from __future__ import annotations
17+
18+import csv
19+import hashlib
20+import io
21+import json
22+import re
23+import sys
24+import time
25+
26+import requests
27+
28+from .normalize import parse_price, strip_accents
29+
30+SOURCE_ID = "mapaq"
31+CSV_URL = ("https://www.donneesquebec.ca/recherche/dataset/"
32+ "515374ee-ce34-464f-9875-7d1af3fa9b2a/resource/"
33+ "40105615-3abf-414b-bcba-182e8f2c5eb2/download/listecondamnation.csv")
34+USER_AGENT = "RestoKaBot/1.0 (+https://www.resto-ka.com/bot; contact@spboucher.ai)"
35+REFRESH_DAYS = 6 # au plus une fois par cycle hebdo du watcher
36+
37+_POSTAL_RE = re.compile(r"\b([A-Z]\d[A-Z])\s?(\d[A-Z]\d)\b")
38+_CIVIC_RE = re.compile(r"^\s*(\d+)")
39+# entité légale « à numéro » (9277-4876 QUEBEC INC.) : inutilisable pour le nom
40+_NUMBERED_CO_RE = re.compile(r"^\d{4}-\d{4}\s+quebec", re.I)
41+_LEGAL_RE = re.compile(
42+ r"\b(inc|ltee|ltd|enr|senc|sec|s\.e\.n\.c|s\.a|cie|co|corp|corporation)\b\.?",
43+ re.I)
44+_STOPWORDS = {"le", "la", "les", "l", "du", "de", "des", "d", "au", "aux",
45+ "et", "un", "une", "chez", "restaurant", "resto", "cafe", "bar",
46+ "groupe", "gestion", "quebec", "canada"}
47+
48+
49+def norm_name(name: str) -> str:
50+ """Nom commercial normalisé pour le croisement : accents, ponctuation,
51+ suffixes légaux et espaces réduits."""
52+ s = strip_accents((name or "").lower())
53+ s = _LEGAL_RE.sub(" ", s)
54+ s = re.sub(r"[^a-z0-9]+", " ", s)
55+ return re.sub(r"\s+", " ", s).strip()
56+
57+
58+def core_name(norm: str) -> str:
59+ """Cœur du nom : tokens significatifs sans mots génériques (« Restaurant
60+ Chez Mamy » et « Chez Mamy » -> « mamy »)."""
61+ return " ".join(t for t in norm.split() if t not in _STOPWORDS)
62+
63+
64+def _tokens(norm: str) -> set[str]:
65+ return {t for t in norm.split() if len(t) >= 3 and t not in _STOPWORDS}
66+
67+
68+def _name_similar(a: str, b: str, exclude: frozenset | set = frozenset()) -> bool:
69+ """Similarité conservatrice : contenance mutuelle ou ≥1 token significatif
70+ partagé. `exclude` = tokens de l'adresse/ville : un nom qui ne partage que
71+ le nom de sa VILLE (« Les Moulins La Fayette St-Hyacinthe » vs « Sushi
72+ Taxi — St-Hyacinthe », voisins de la même adresse) n'est PAS similaire."""
73+ if not a or not b:
74+ return False
75+ ta = _tokens(a) - set(exclude)
76+ if not ta: # rien de distinctif hors adresse/ville
77+ return False
78+ if a == b or a in b or b in a:
79+ return True
80+ return bool(ta & _tokens(b))
81+
82+
83+def _iso_date(raw: str) -> str:
84+ """« 05/21/2025 00:00:00 » (MM/DD/YYYY) -> « 2025-05-21 »."""
85+ m = re.match(r"^(\d{2})/(\d{2})/(\d{4})", (raw or "").strip())
86+ if not m:
87+ return ""
88+ mm, dd, yyyy = m.groups()
89+ return f"{yyyy}-{mm}-{dd}"
90+
91+
92+def _download() -> list[dict]:
93+ resp = requests.get(CSV_URL, headers={"User-Agent": USER_AGENT}, timeout=120)
94+ resp.raise_for_status()
95+ text = None
96+ for enc in ("utf-8-sig", "latin-1"):
97+ try:
98+ text = resp.content.decode(enc)
99+ break
100+ except UnicodeDecodeError:
101+ continue
102+ if text is None:
103+ raise RuntimeError("encodage CSV MAPAQ inconnu")
104+ return list(csv.DictReader(io.StringIO(text)))
105+
106+
107+def _row_hash(rec: dict) -> str:
108+ blob = "|".join((rec.get("Nom_exploitant") or "",
109+ rec.get("Description_infraction") or "",
110+ rec.get("Adresse_lieu_infraction") or "",
111+ rec.get("Date_infraction") or "",
112+ rec.get("Date_jugement") or "",
113+ rec.get("Amende") or ""))
114+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
115+
116+
117+def import_rows(con, records: list[dict]) -> int:
118+ """Insère les condamnations (anti-doublon par row_hash). Retourne le nb
119+ de nouvelles lignes."""
120+ added = 0
121+ for rec in records:
122+ h = _row_hash(rec)
123+ adresse = (rec.get("Adresse_lieu_infraction") or "").strip()
124+ m = _POSTAL_RE.search(adresse.upper())
125+ postal = f"{m.group(1)}{m.group(2)}" if m else ""
126+ cur = con.execute(
127+ """INSERT OR IGNORE INTO inspections
128+ (row_hash, exploitant, etablissement, description, adresse,
129+ postal_code, type_etablissement, categorie, date_infraction,
130+ date_jugement, date_publication, montant_amende, loi, motif)
131+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
132+ (h,
133+ (rec.get("Nom_exploitant") or "").strip(),
134+ (rec.get("Raison_sociale") or "").strip(),
135+ (rec.get("Description_infraction") or "").strip(),
136+ adresse, postal,
137+ (rec.get("Type_etablissement") or "").strip(),
138+ (rec.get("SOC_DESC_REGRP_TYP_ENTT") or "").strip(),
139+ _iso_date(rec.get("Date_infraction")),
140+ _iso_date(rec.get("Date_jugement")),
141+ _iso_date(rec.get("Date_publication")),
142+ parse_price(rec.get("Amende")),
143+ (rec.get("SOC_NOM_LOI") or "").strip(),
144+ (rec.get("SOC_NOM_ARTCL_INFRC") or "").strip()))
145+ added += cur.rowcount
146+ con.commit()
147+ return added
148+
149+
150+def match(con) -> dict:
151+ """Croisement CONSERVATEUR inspections <-> restaurants (uid canonique).
152+
153+ Règle A « nom+lieu » : nom commercial normalisé IDENTIQUE au nom du resto
154+ ET (code postal identique OU ville du resto contenue dans l'adresse
155+ d'infraction ET numéro civique identique). Le civique est exigé quand on
156+ n'a que la ville : les CHAÎNES ont plusieurs succursales par ville et une
157+ condamnation ne doit JAMAIS être épinglée sur la mauvaise succursale.
158+ Règle B « adresse+nom » : code postal identique ET numéro civique
159+ identique ET similarité de nom (contenance ou token partagé).
160+ """
161+ restos = con.execute(
162+ "SELECT uid, name, chain, city, postal_code, address FROM restaurants"
163+ " WHERE active=1 AND dup_of IS NULL").fetchall()
164+ by_name: dict[str, list] = {}
165+ by_postal: dict[str, list] = {}
166+ for r in restos:
167+ info = {
168+ "uid": r["uid"],
169+ "nname": norm_name(r["name"]),
170+ "nchain": norm_name(r["chain"] or ""),
171+ "ncity": strip_accents((r["city"] or "").lower()).strip(),
172+ "postal": (r["postal_code"] or "").replace(" ", "").upper(),
173+ }
174+ mm = _CIVIC_RE.match(r["address"] or "")
175+ info["civic"] = mm.group(1) if mm else ""
176+ if info["nname"]:
177+ by_name.setdefault(info["nname"], []).append(info)
178+ core = core_name(info["nname"])
179+ if core and core != info["nname"]:
180+ by_name.setdefault(core, []).append(info)
181+ if info["postal"]:
182+ by_postal.setdefault(info["postal"], []).append(info)
183+
184+ matched = 0
185+ for row in con.execute(
186+ "SELECT id, exploitant, etablissement, adresse, postal_code"
187+ " FROM inspections WHERE uid IS NULL").fetchall():
188+ names = []
189+ if row["etablissement"]:
190+ names.append(norm_name(row["etablissement"]))
191+ expl = row["exploitant"] or ""
192+ if expl and not _NUMBERED_CO_RE.match(strip_accents(expl.lower())):
193+ names.append(norm_name(expl))
194+ # variantes du nom : normalisé complet + cœur sans mots génériques
195+ names = [n for n in names if n]
196+ names += [c for c in (core_name(n) for n in names)
197+ if c and c not in names]
198+ nadresse = strip_accents((row["adresse"] or "").lower())
199+ civic_m = _CIVIC_RE.match(row["adresse"] or "")
200+ civic = civic_m.group(1) if civic_m else ""
201+ postal = (row["postal_code"] or "").upper()
202+
203+ hit, how = None, ""
204+ # Règle A : nom exact + ville/code postal
205+ for n in names:
206+ for info in by_name.get(n, []):
207+ same_place = ((postal and info["postal"] == postal)
208+ or (info["ncity"] and len(info["ncity"]) >= 4
209+ and info["ncity"] in nadresse
210+ and civic and info["civic"] == civic))
211+ if same_place:
212+ hit, how = info["uid"], "nom+lieu"
213+ break
214+ if hit:
215+ break
216+ # Règle B : code postal + civique + similarité de nom (hors tokens
217+ # d'adresse/ville — les voisins d'un même centre commercial partagent
218+ # CP + civique, seul un nom distinctif partagé fait foi)
219+ if hit is None and postal and civic:
220+ addr_tokens = set(re.sub(r"[^a-z0-9]+", " ", nadresse).split())
221+ for info in by_postal.get(postal, []):
222+ if info["civic"] != civic:
223+ continue
224+ if any(_name_similar(n, info["nname"], addr_tokens)
225+ or (info["nchain"]
226+ and _name_similar(n, info["nchain"], addr_tokens))
227+ for n in names):
228+ hit, how = info["uid"], "adresse+nom"
229+ break
230+ if hit:
231+ con.execute("UPDATE inspections SET uid=?, matched_by=? WHERE id=?",
232+ (hit, how, row["id"]))
233+ matched += 1
234+ con.commit()
235+
236+ # résumé conformité par resto -> details.mapaq (affichage fiche)
237+ from . import db as _db
238+ for agg in con.execute(
239+ "SELECT uid, COUNT(*) n, SUM(COALESCE(montant_amende,0)) total,"
240+ " MAX(date_jugement) derniere FROM inspections"
241+ " WHERE uid IS NOT NULL GROUP BY uid"):
242+ _db.merge_details(con, agg["uid"], {"mapaq": {
243+ "condamnations": agg["n"],
244+ "total_amendes": round(agg["total"] or 0, 2),
245+ "derniere_condamnation": agg["derniere"],
246+ }})
247+ con.commit()
248+ total_linked = con.execute(
249+ "SELECT COUNT(*) c FROM inspections WHERE uid IS NOT NULL").fetchone()["c"]
250+ return {"matched_new": matched, "matched_total": total_linked}
251+
252+
253+def sync(con=None, force: bool = False) -> dict | None:
254+ """Télécharge le CSV MAPAQ, importe et croise. Cadence hebdo (guard)."""
255+ from . import db
256+ own = con is None
257+ if own:
258+ con = db.connect()
259+ try:
260+ last = con.execute(
261+ "SELECT MAX(ts) ts FROM sync_log WHERE source=? AND ok=1",
262+ (SOURCE_ID,)).fetchone()["ts"]
263+ if not force and last and time.time() - last < REFRESH_DAYS * 86400:
264+ return None # déjà à jour cette semaine
265+ records = _download()
266+ added = import_rows(con, records)
267+ m = match(con)
268+ stats = {"rows": len(records), "new_rows": added, **m}
269+ con.execute(
270+ "INSERT INTO sync_log (source, ts, found, added, updated, removed,"
271+ " ok, message, stats) VALUES (?,?,?,?,0,0,1,?,?)",
272+ (SOURCE_ID, time.time(), len(records), added,
273+ f"condamnations importées, {m['matched_total']} croisées",
274+ json.dumps(stats, ensure_ascii=False)))
275+ con.commit()
276+ print(f"[resto-ka] mapaq: {stats}")
277+ return stats
278+ except Exception as exc:
279+ db.log_failure(con, SOURCE_ID, str(exc))
280+ print(f"[resto-ka] mapaq: erreur non bloquante: {exc}", file=sys.stderr)
281+ return {"error": str(exc)}
282+ finally:
283+ if own:
284+ con.close()
modified restoka/normalize.py +19 −1
@@ -17,7 +17,7 @@ from .regions import strip_accents
17 __all__ = [ 17 __all__ = [
18 "strip_accents", "clean_name", "clean_address", "parse_price", 18 "strip_accents", "clean_name", "clean_address", "parse_price",
19 "classify_cuisines", "classify_establishment", "infer_dietary", 19 "classify_cuisines", "classify_establishment", "infer_dietary",
20− "price_range_from_menu", "CUISINES", "PRICE_CONTEXTS", 20+ "price_range_from_menu", "normalize_phone", "CUISINES", "PRICE_CONTEXTS",
21 ] 21 ]
22 22
23 # Taxonomie des cuisines (CLAUDE.md §6.1) — multi-valué. 23 # Taxonomie des cuisines (CLAUDE.md §6.1) — multi-valué.
@@ -60,6 +60,24 @@ def clean_address(address: str) -> str:
60 return s 60 return s
61 61
62 62
63+_PHONE_RE = re.compile(
64+ r"(?:\+?1[\s.\-]?)?\(?([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-]?(\d{4})(?!\d)")
65+
66+
67+def normalize_phone(text: str | None) -> str:
68+ """Extrait et normalise un numéro nord-américain en E.164 (« +14185551234 »).
69+
70+ Retourne "" si aucun numéro à 10 chiffres plausible n'est trouvé.
71+ Accepte les formats courants : (418) 555-1234, 418.555.1234, tel:+1418…
72+ """
73+ if not text:
74+ return ""
75+ m = _PHONE_RE.search(str(text))
76+ if not m:
77+ return ""
78+ return "+1" + "".join(m.groups())
79+
80+
63 _PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?") 81 _PRICE_RE = re.compile(r"(\d{1,4}(?:[ ]\d{3})*(?:[.,]\d{1,2})?)\s*\$?")
64 82
65 83
modified restoka/web.py +38 −1
@@ -48,6 +48,10 @@ def _row_to_dict(row) -> dict:
48 for col in ("cuisines", "services", "dietary_options", "languages", "images"): 48 for col in ("cuisines", "services", "dietary_options", "languages", "images"):
49 d[col] = json.loads(d.get(col) or "[]") 49 d[col] = json.loads(d.get(col) or "[]")
50 d["hours"] = json.loads(d.get("hours") or "{}") 50 d["hours"] = json.loads(d.get("hours") or "{}")
51+ try: # enrichissements (reservation_url, yelp, mapaq…) — voir db.merge_details
52+ d["details"] = json.loads(d.get("details") or "{}")
53+ except (ValueError, TypeError):
54+ d["details"] = {}
51 if d.get("dup_sources"): 55 if d.get("dup_sources"):
52 try: 56 try:
53 d["dup_sources"] = json.loads(d["dup_sources"]) 57 d["dup_sources"] = json.loads(d["dup_sources"])
@@ -250,6 +254,28 @@ def price_history(uid: str):
250 return {"uid": uid, "prices": rows} 254 return {"uid": uid, "prices": rows}
251 255
252 256
257+@app.get("/api/restaurants/{uid:path}/inspections")
258+def restaurant_inspections(uid: str):
259+ """Condamnations MAPAQ croisées avec ce restaurant (inspections
260+ alimentaires — Données Québec, licence CC-BY 4.0). Croisement
261+ conservateur : voir restoka/inspections.py."""
262+ con = db.connect()
263+ if con.execute("SELECT 1 FROM restaurants WHERE uid=?",
264+ (uid,)).fetchone() is None:
265+ con.close()
266+ raise HTTPException(404, "Restaurant introuvable")
267+ rows = [dict(r) for r in con.execute(
268+ "SELECT etablissement, exploitant, description, adresse,"
269+ " date_infraction, date_jugement, montant_amende, motif, matched_by"
270+ " FROM inspections WHERE uid=? ORDER BY date_jugement DESC", (uid,))]
271+ con.close()
272+ return {"uid": uid, "total": len(rows),
273+ "total_amendes": round(sum(r["montant_amende"] or 0 for r in rows), 2),
274+ "source": "MAPAQ — Condamnations des établissements alimentaires "
275+ "(Données Québec, CC-BY 4.0)",
276+ "inspections": rows}
277+
278+
253 @app.get("/api/restaurants/{uid:path}") 279 @app.get("/api/restaurants/{uid:path}")
254 def get_restaurant(uid: str): 280 def get_restaurant(uid: str):
255 con = db.connect() 281 con = db.connect()
@@ -421,8 +447,19 @@ def stats():
421 log = [dict(r) for r in con.execute( 447 log = [dict(r) for r in con.execute(
422 "SELECT source, ts, found, added, updated, removed, ok, message" 448 "SELECT source, ts, found, added, updated, removed, ok, message"
423 " FROM sync_log ORDER BY ts DESC LIMIT 20")] 449 " FROM sync_log ORDER BY ts DESC LIMIT 20")]
450+ insp = dict(con.execute(
451+ """SELECT COUNT(*) inspections,
452+ SUM(uid IS NOT NULL) inspections_matched,
453+ COUNT(DISTINCT uid) restaurants_with_inspections
454+ FROM inspections""").fetchone())
424 con.close() 455 con.close()
425− return {**head, **m, "by_region": by_region, "by_context": by_context, 456+ try: # nb de sources au registre (data/sources.json), actives ou en attente
457+ registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
458+ registered = len(registry)
459+ except (ValueError, OSError, KeyError):
460+ registered = None
461+ return {**head, **m, **insp, "sources_registry": registered,
462+ "by_region": by_region, "by_context": by_context,
426 "recent_syncs": log} 463 "recent_syncs": log}
427 464
428 465
added tests/test_hours.py +60 −0
@@ -0,0 +1,60 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: tests/test_hours.py
4+# Desc: Parseur d'horaires — syntaxes OSM courantes structurées par jour,
5+# cas exotiques refusés (le brut reste dans hours["osm"]), horaires
6+# JSON-LD schema.org (openingHoursSpecification / openingHours).
7+# ==============================================================================
8+from restoka.hours import parse_jsonld_hours, parse_opening_hours
9+
10+
11+def test_plages_de_jours_simples():
12+ h = parse_opening_hours("Mo-Fr 11:00-22:00; Sa-Su 10:00-23:00")
13+ assert h["mon"] == [["11:00", "22:00"]]
14+ assert h["fri"] == [["11:00", "22:00"]]
15+ assert h["sat"] == [["10:00", "23:00"]]
16+ assert h["sun"] == [["10:00", "23:00"]]
17+
18+
19+def test_double_service_et_jour_off():
20+ h = parse_opening_hours("Tu-Sa 11:30-14:00,17:00-22:00; Su-Mo off")
21+ assert h["tue"] == [["11:30", "14:00"], ["17:00", "22:00"]]
22+ assert h["sun"] == [] and h["mon"] == []
23+
24+
25+def test_24_7_et_liste_de_jours():
26+ assert parse_opening_hours("24/7")["wed"] == [["00:00", "24:00"]]
27+ h = parse_opening_hours("Mo,We,Fr 09:00-17:00")
28+ assert set(h) == {"mon", "wed", "fri"}
29+
30+
31+def test_heures_seules_et_enroulement_semaine():
32+ assert parse_opening_hours("08:00-16:00")["sun"] == [["08:00", "16:00"]]
33+ h = parse_opening_hours("Sa-Mo 10:00-15:00") # enroule Sa, Su, Mo
34+ assert set(h) == {"sat", "sun", "mon"}
35+
36+
37+def test_ph_ignore_et_exotique_refuse():
38+ h = parse_opening_hours("Mo-Fr 11:00-22:00; PH off")
39+ assert h["mon"] == [["11:00", "22:00"]] and "ph" not in h
40+ assert parse_opening_hours("Mo-Fr sunrise-sunset") is None
41+ assert parse_opening_hours("Jun-Aug Mo-Su 10:00-22:00") is None
42+ assert parse_opening_hours("") is None
43+ assert parse_opening_hours(None) is None
44+
45+
46+def test_jsonld_opening_hours_specification():
47+ node = {"@type": "Restaurant", "openingHoursSpecification": [
48+ {"dayOfWeek": ["Monday", "Tuesday"], "opens": "11:00", "closes": "22:00"},
49+ {"dayOfWeek": "https://schema.org/Sunday", "opens": "10:00",
50+ "closes": "15:00"},
51+ ]}
52+ h = parse_jsonld_hours(node)
53+ assert h["mon"] == [["11:00", "22:00"]]
54+ assert h["sun"] == [["10:00", "15:00"]]
55+
56+
57+def test_jsonld_opening_hours_chaines():
58+ h = parse_jsonld_hours({"openingHours": ["Mo-Fr 11:00-22:00"]})
59+ assert h["thu"] == [["11:00", "22:00"]]
60+ assert parse_jsonld_hours({}) is None
added tests/test_inspections.py +119 −0
@@ -0,0 +1,119 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: tests/test_inspections.py
4+# Desc: Connecteur MAPAQ (condamnations) — import CSV HORS LIGNE, parsing
5+# des dates/amendes/code postal, croisement CONSERVATEUR avec les
6+# restaurants (nom+lieu, adresse+nom, jamais de fusion hasardeuse),
7+# anti-doublon au ré-import, endpoint et details.mapaq.
8+# ==============================================================================
9+import json
10+
11+from restoka import inspections
12+from restoka.schema import Restaurant
13+from restoka import db as rdb
14+
15+
16+RECORDS = [
17+ { # croisement par nom exact + ville contenue dans l'adresse
18+ "Nom_exploitant": "9277-4876 QUEBEC INC.",
19+ "Raison_sociale": "RESTAURANT CHEZ MAMY",
20+ "Description_infraction": "LIEUX NON PROPRES.",
21+ "Adresse_lieu_infraction": "1999 SAINTE-FAMILLE SAGUENAY, (QC) G7X4X5",
22+ "Type_etablissement": "CASSE-CROUTE",
23+ "Date_infraction": "11/27/2025 00:00:00",
24+ "Date_jugement": "05/19/2026 00:00:00",
25+ "Date_publication": "05/22/2026 00:00:00",
26+ "Amende": "500 $",
27+ "SOC_NOM_LOI": "Loi sur les produits alimentaires",
28+ "SOC_NOM_ARTCL_INFRC": "INSALUBRITE",
29+ "SOC_DESC_REGRP_TYP_ENTT": "RESTAURATION",
30+ },
31+ { # croisement par code postal + civique + similarité de nom
32+ "Nom_exploitant": "GESTION PIZZA BELLA INC.",
33+ "Raison_sociale": "PIZZA BELLA",
34+ "Description_infraction": "TEMPERATURE INADEQUATE.",
35+ "Adresse_lieu_infraction": "300 RUE PRINCIPALE QUEBEC, (QC) G1K 3Y2",
36+ "Type_etablissement": "RESTAURANT",
37+ "Date_infraction": "01/15/2026 00:00:00",
38+ "Date_jugement": "03/10/2026 00:00:00",
39+ "Date_publication": "03/12/2026 00:00:00",
40+ "Amende": "1 250 $",
41+ "SOC_NOM_LOI": "Loi sur les produits alimentaires",
42+ "SOC_NOM_ARTCL_INFRC": "TEMPERATURE",
43+ "SOC_DESC_REGRP_TYP_ENTT": "RESTAURATION",
44+ },
45+ { # AUCUN croisement : même nom qu'un resto mais autre ville/CP
46+ "Nom_exploitant": "FERME LAITIERE X INC.",
47+ "Raison_sociale": "CHEZ MAMY",
48+ "Description_infraction": "EQUIPEMENTS NON PROPRES.",
49+ "Adresse_lieu_infraction": "714 RANG OUEST SAINT-JEAN-DE-DIEU, (QC) G0L3M0",
50+ "Type_etablissement": "FERME LAITIERE",
51+ "Date_infraction": "05/21/2025 00:00:00",
52+ "Date_jugement": "04/14/2026 00:00:00",
53+ "Date_publication": "04/17/2026 00:00:00",
54+ "Amende": "2 000 $",
55+ "SOC_NOM_LOI": "Loi sur les produits alimentaires",
56+ "SOC_NOM_ARTCL_INFRC": "INSALUBRITE",
57+ "SOC_DESC_REGRP_TYP_ENTT": "LAIT",
58+ },
59+]
60+
61+
62+def _seed_restaurants(con):
63+ restos = [
64+ Restaurant(source="osm", external_id="node/1", name="Chez Mamy",
65+ address="1999 Rue Sainte-Famille", city="Saguenay",
66+ postal_code="G7X 4X5", lat=48.42, lng=-71.06),
67+ Restaurant(source="osm", external_id="node/2", name="Bella Pizza Resto",
68+ address="300 Rue Principale", city="Québec",
69+ postal_code="G1K 3Y2", lat=46.81, lng=-71.21),
70+ # piège : même nom que la condamnation #1 mais dans une AUTRE ville
71+ Restaurant(source="osm", external_id="node/3", name="Chez Mamy",
72+ address="12 Rue Untel", city="Gatineau",
73+ postal_code="J8X 1A1", lat=45.47, lng=-75.70),
74+ ]
75+ rdb.sync_source(con, "osm", [r.finalize() for r in restos])
76+
77+
78+def test_import_croisement_et_antidoublon(con):
79+ _seed_restaurants(con)
80+ added = inspections.import_rows(con, RECORDS)
81+ assert added == 3
82+ # parsing : date ISO, amende numérique, code postal extrait
83+ row = con.execute("SELECT * FROM inspections WHERE etablissement="
84+ "'RESTAURANT CHEZ MAMY'").fetchone()
85+ assert row["date_jugement"] == "2026-05-19"
86+ assert row["montant_amende"] == 500.0
87+ assert row["postal_code"] == "G7X4X5"
88+
89+ stats = inspections.match(con)
90+ assert stats["matched_total"] == 2
91+ # #1 : nom+lieu -> le Chez Mamy de SAGUENAY, jamais celui de Gatineau
92+ assert con.execute("SELECT uid, matched_by FROM inspections WHERE "
93+ "etablissement='RESTAURANT CHEZ MAMY'").fetchone()["uid"] \
94+ == "osm:node/1"
95+ # #2 : adresse+nom (CP + civique + token « pizza/bella » partagé)
96+ m2 = con.execute("SELECT uid, matched_by FROM inspections WHERE "
97+ "etablissement='PIZZA BELLA'").fetchone()
98+ assert m2["uid"] == "osm:node/2" and m2["matched_by"] == "adresse+nom"
99+ # #3 : la ferme laitière homonyme ne matche RIEN (ville et CP différents)
100+ assert con.execute("SELECT uid FROM inspections WHERE "
101+ "exploitant='FERME LAITIERE X INC.'").fetchone()["uid"] is None
102+
103+ # résumé conformité écrit dans details.mapaq du resto croisé
104+ details = json.loads(con.execute(
105+ "SELECT details FROM restaurants WHERE uid='osm:node/1'")
106+ .fetchone()["details"])
107+ assert details["mapaq"]["condamnations"] == 1
108+ assert details["mapaq"]["total_amendes"] == 500.0
109+
110+ # ré-import : aucun doublon
111+ assert inspections.import_rows(con, RECORDS) == 0
112+ assert con.execute("SELECT COUNT(*) c FROM inspections").fetchone()["c"] == 3
113+
114+
115+def test_norm_et_core_name():
116+ n = inspections.norm_name("RESTAURANT CHEZ MAMY INC.")
117+ assert n == "restaurant chez mamy"
118+ assert inspections.core_name(n) == "mamy"
119+ assert inspections.norm_name("Pâtisserie L'Éclair Ltée") == "patisserie l eclair"
added tests/test_siteresto_contact.py +61 −0
@@ -0,0 +1,61 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: tests/test_siteresto_contact.py
4+# Desc: Extraction téléphone / horaires / lien de réservation depuis la page
5+# d'accueil d'un site de resto (HORS LIGNE) : lien tel:, JSON-LD
6+# LocalBusiness (openingHoursSpecification), plateformes de
7+# réservation reconnues, regex de secours, normalisation E.164.
8+# ==============================================================================
9+from restoka.connectors.siteresto import SiteRestoConnector
10+from restoka.normalize import normalize_phone
11+
12+HTML = """
13+<html><head>
14+<script type="application/ld+json">
15+{"@context":"https://schema.org","@type":"Restaurant","name":"Chez Test",
16+ "telephone":"+1-418-555-1234",
17+ "openingHoursSpecification":[
18+ {"@type":"OpeningHoursSpecification","dayOfWeek":["Monday","Tuesday"],
19+ "opens":"11:00","closes":"22:00"}]}
20+</script>
21+</head><body>
22+<a href="tel:+14185551234">Appelez-nous</a>
23+<a href="https://www.opentable.ca/r/chez-test-quebec">Réserver</a>
24+<a href="/menu">Menu</a>
25+</body></html>
26+"""
27+
28+HTML_REGEX_ONLY = """
29+<html><body><p>Réservation : (514) 555-9876</p>
30+<a href="https://widgets.libroreserve.com/QC012345">R&eacute;server une table</a>
31+</body></html>
32+"""
33+
34+
35+def test_extraction_complete():
36+ c = SiteRestoConnector._extract_contact(HTML, "https://chez-test.ca")
37+ assert c["phone"] == "+14185551234"
38+ assert c["reservation_url"] == "https://www.opentable.ca/r/chez-test-quebec"
39+ assert c["hours"]["mon"] == [["11:00", "22:00"]]
40+ assert c["hours"]["tue"] == [["11:00", "22:00"]]
41+
42+
43+def test_extraction_regex_et_libro():
44+ c = SiteRestoConnector._extract_contact(HTML_REGEX_ONLY,
45+ "https://exemple.ca")
46+ assert c["phone"] == "+15145559876"
47+ assert "libroreserve.com" in c["reservation_url"]
48+ assert c["hours"] is None
49+
50+
51+def test_extraction_vide():
52+ c = SiteRestoConnector._extract_contact("", "https://exemple.ca")
53+ assert c == {"phone": "", "hours": None, "reservation_url": ""}
54+
55+
56+def test_normalize_phone():
57+ assert normalize_phone("tel:418.555.1234") == "+14185551234"
58+ assert normalize_phone("(514) 555-9876") == "+15145559876"
59+ assert normalize_phone("+1 450 555 0000") == "+14505550000"
60+ assert normalize_phone("1234") == ""
61+ assert normalize_phone(None) == ""
62
Menu

Theme