SPB Git

spb/resto-ka

Public

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

v2 — le plus grand agrégateur de restos du Québec : découverte OSM + 10 bannières UEAT

- Connecteur de découverte OpenStreetMap (Overpass, ODbL, attribution) :
  13 531 établissements nommés (restos, fast-foods, cafés, bars, crèmeries,
  boulangeries) → 14 055 fiches actives, 17/17 régions couvertes.
- +10 intégrations UEAT (Boustan 65, Ben & Florentine 54, NO.900 32,
  Sushi Taxi 30, Cochon Dingue, Salvatoré, Belle Province, Madisons, Freddy)
  → 29 intégrations, 738 restos avec menu complet, 79 159 plats avec prix.
- Déduplication chaîne↔découverte (suffixe de succursale retranché, OSM
  toujours derrière la fiche à menu) : 215 doublons masqués.
- Régions : repli par ancres géographiques couvrant les 17 régions.
- UI : filtre « Avec menu complet / Fiche seulement », tri « Menus d'abord »
  par défaut, tuile restos avec menu, attribution OSM au pied de page.
- 45 tests (fixtures Overpass + UEAT réelles hors ligne).

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

16 changed files +422 −34

modified README.md +11 −9
@@ -6,16 +6,17 @@
6 6
7 # Resto·Ka — [resto-ka.com](https://www.resto-ka.com) 7 # Resto·Ka — [resto-ka.com](https://www.resto-ka.com)
8 8
9−![Restaurants](https://img.shields.io/badge/restaurants-542-141814?labelColor=d9f26b&style=for-the-badge) 9+![Restaurants](https://img.shields.io/badge/restaurants-14_055-141814?labelColor=d9f26b&style=for-the-badge)
10−![Plats avec prix](https://img.shields.io/badge/plats_avec_prix-61_330-141814?labelColor=d9f26b&style=for-the-badge) 10+![Plats avec prix](https://img.shields.io/badge/plats_avec_prix-79_159-141814?labelColor=d9f26b&style=for-the-badge)
11−![Régions](https://img.shields.io/badge/r%C3%A9gions-14%2F17-141814?labelColor=d9f26b&style=for-the-badge) 11+![Régions](https://img.shields.io/badge/r%C3%A9gions-17%2F17-141814?labelColor=d9f26b&style=for-the-badge)
12−![Chaînes](https://img.shields.io/badge/cha%C3%AEnes_suivies-15-141814?labelColor=d9f26b&style=for-the-badge) 12+![Chaînes](https://img.shields.io/badge/cha%C3%AEnes_suivies-24-141814?labelColor=d9f26b&style=for-the-badge)
13+![Menus complets](https://img.shields.io/badge/menus_complets-738_restos-141814?labelColor=d9f26b&style=for-the-badge)
13 14
14 ![Prix](https://img.shields.io/badge/prix-takeout_r%C3%A9els,_non_major%C3%A9s-1c5c41?style=flat-square) 15 ![Prix](https://img.shields.io/badge/prix-takeout_r%C3%A9els,_non_major%C3%A9s-1c5c41?style=flat-square)
15 ![Images](https://img.shields.io/badge/items_avec_photo-98%25-1c5c41?style=flat-square) 16 ![Images](https://img.shields.io/badge/items_avec_photo-98%25-1c5c41?style=flat-square)
16 ![Options](https://img.shields.io/badge/items_avec_options-69%25-1c5c41?style=flat-square) 17 ![Options](https://img.shields.io/badge/items_avec_options-69%25-1c5c41?style=flat-square)
17−![Géocodage](https://img.shields.io/badge/g%C3%A9ocod%C3%A9-96%25-1c5c41?style=flat-square) 18+![Géocodage](https://img.shields.io/badge/g%C3%A9olocalis%C3%A9-99%25-1c5c41?style=flat-square)
18−![Tests](https://img.shields.io/badge/tests-41_pass-1c5c41?style=flat-square) 19+![Tests](https://img.shields.io/badge/tests-45_pass-1c5c41?style=flat-square)
19 ![Python](https://img.shields.io/badge/python-3.14_%C2%B7_FastAPI-141814?style=flat-square) 20 ![Python](https://img.shields.io/badge/python-3.14_%C2%B7_FastAPI-141814?style=flat-square)
20 ![React](https://img.shields.io/badge/react-18_%C2%B7_Vite_%C2%B7_TS-141814?style=flat-square) 21 ![React](https://img.shields.io/badge/react-18_%C2%B7_Vite_%C2%B7_TS-141814?style=flat-square)
21 22
@@ -47,7 +48,7 @@ JetBrains Mono, papier-encre-lime, bordures encre + ombres décalées).
47 - **Un prix n'est JAMAIS présenté sans son contexte** (`dine-in` / `takeout` / 48 - **Un prix n'est JAMAIS présenté sans son contexte** (`dine-in` / `takeout` /
48 `delivery`) — un menu sans `price_context`/`price_source` est rejeté à 49 `delivery`) — un menu sans `price_context`/`price_source` est rejeté à
49 l'ingestion, et un menu salle n'est jamais écrasé par un menu livraison. 50 l'ingestion, et un menu salle n'est jamais écrasé par un menu livraison.
50−- **Recherche par plat** dans les 61 330 items (groupée par marque pour ne pas 51+- **Recherche par plat** dans les 79 159 items (groupée par marque pour ne pas
51 répéter 76 fois la même poutine de chaîne). 52 répéter 76 fois la même poutine de chaîne).
52 - **Options & formats capturés** avec leurs suppléments (« Mini +3,70 $ ») — 53 - **Options & formats capturés** avec leurs suppléments (« Mini +3,70 $ ») —
53 ils changent le prix réel. 54 ils changent le prix réel.
@@ -64,7 +65,7 @@ cd frontend && npm install && npm run build && cd ..
64 .venv/bin/python run.py sync # synchronise (+ géocode + dédup) 65 .venv/bin/python run.py sync # synchronise (+ géocode + dédup)
65 .venv/bin/python run.py serve 8115 # API + frontend sur :8115 66 .venv/bin/python run.py serve 8115 # API + frontend sur :8115
66 .venv/bin/python run.py watch 168 # boucle hebdomadaire 67 .venv/bin/python run.py watch 168 # boucle hebdomadaire
67−.venv/bin/python -m pytest tests/ # 41 tests (fixtures réelles hors ligne) 68+.venv/bin/python -m pytest tests/ # 45 tests (fixtures réelles hors ligne)
68 ``` 69 ```
69 70
70 ## Architecture (patron Lou·Ka) 71 ## Architecture (patron Lou·Ka)
@@ -91,7 +92,8 @@ Registre : `data/sources.json` (à consulter AVANT d'ajouter quoi que ce soit).
91 92
92 | Source | Palier | Contexte de prix | Couverture | 93 | Source | Palier | Contexte de prix | Couverture |
93 |--------|--------|------------------|------------| 94 |--------|--------|------------------|------------|
94−| **UEAT** (commande en ligne, API GraphQL anonyme) | 1 | `takeout` (prix réels) | 19 intégrations : Sushi Shop (141), Poulet Rouge (82), Valentine (76), Normandin (44), Chocolats Favoris (41), Allô mon Coco (34), Thaïzone (26), Scores (25), Chez Ashton (23), Pacini (20), Tutti Frutti, Mandy's, Panda Boba-T, Ryu Sushi, Impérial, Pizza Mia, Station W, Le Super Qualité, Château Frontenac | 95+| **OpenStreetMap** (découverte, API Overpass) | 4 | — (fiches sans menu) | **13 500+ établissements** : tous les restos, fast-foods, cafés, bars, crèmeries et boulangeries nommés du Québec — 17/17 régions. Données © contributeurs OSM (ODbL). |
96+| **UEAT** (commande en ligne, API GraphQL anonyme) | 1 | `takeout` (prix réels) | 29 intégrations : Sushi Shop (141), Poulet Rouge (82), Valentine (76), Normandin (44), Chocolats Favoris (41), Allô mon Coco (34), Thaïzone (26), Scores (25), Chez Ashton (23), Pacini (20), Boustan (65), Ben & Florentine (54), Pizzéria NO.900 (32), Sushi Taxi (30), Tutti Frutti, Mandy's, Cochon Dingue, Panda Boba-T, Salvatoré, Ryu Sushi, Impérial, La Belle Province, Madisons, Pizza Mia, Station W, Freddy, Le Super Qualité, Château Frontenac |
95 | St-Hubert | 6 | `takeout` (visé) | à faire — anti-bot Akamai, voir `data/sources.json` | 97 | St-Hubert | 6 | `takeout` (visé) | à faire — anti-bot Akamai, voir `data/sources.json` |
96 98
97 ## Déploiement 99 ## Déploiement
modified data/sources.json +22 −0
@@ -33,10 +33,32 @@
33 { "key": "d679a49f-0a25-4694-82f2-56073a8b8790", "label": "Scores (25 succ., rôtisserie)" }, 33 { "key": "d679a49f-0a25-4694-82f2-56073a8b8790", "label": "Scores (25 succ., rôtisserie)" },
34 { "key": "1696ecd3-d4ea-4224-9f98-9299ebcb339c", "label": "Mandy's (9 succ. QC, salades)" }, 34 { "key": "1696ecd3-d4ea-4224-9f98-9299ebcb339c", "label": "Mandy's (9 succ. QC, salades)" },
35 { "key": "dce3e009-8038-47bc-8d5e-f336458e573f", "label": "Sushi Shop (141 succ. QC)" }, 35 { "key": "dce3e009-8038-47bc-8d5e-f336458e573f", "label": "Sushi Shop (141 succ. QC)" },
36+ { "key": "a0dac11c-e43c-4ae2-a37c-22d8542872e0", "label": "Ben & Florentine (54 succ. QC, déjeuner)" },
37+ { "key": "f0ac233f-df24-47a8-9e3a-a64520738ff2", "label": "Boustan (65 succ. QC, libanais)" },
38+ { "key": "45c52d27-bf8c-4995-9c4e-a0a2754101fb", "label": "Pizzéria NO.900 (32 succ. QC)" },
39+ { "key": "e3ca524e-57bb-4d8e-ad2f-53a35731c802", "label": "Sushi Taxi (30 succ. QC)" },
40+ { "key": "031978fa-09a0-493b-86d9-65aca8b3bef0", "label": "Cochon Dingue (7 succ., Québec)" },
41+ { "key": "d6a20802-625c-4970-920b-1b82c6867e9d", "label": "Salvatoré (3 succ. QC, pizza)" },
42+ { "key": "a23e2d27-6871-4d2f-be29-610426c251a2", "label": "La Belle Province — Montréal (2 succ.)" },
43+ { "key": "48c08050-9f8a-4ab9-a8a2-d58c9602df56", "label": "Madisons (2 succ. QC)" },
44+ { "key": "fd9eb8cb-4536-4bb6-bb19-6fb3be67808f", "label": "Freddy Pizzeria (Sherbrooke)" },
36 { "key": "e8015de3-09ac-4cd2-a18d-0820dbe356ed", "label": "(intégration retirée chez UEAT)", "status": "inactif" }, 45 { "key": "e8015de3-09ac-4cd2-a18d-0820dbe356ed", "label": "(intégration retirée chez UEAT)", "status": "inactif" },
37 { "key": "dfde098b-ee0f-46a3-b1ad-09ec2acb54b9", "label": "(intégration retirée chez UEAT)", "status": "inactif" } 46 { "key": "dfde098b-ee0f-46a3-b1ad-09ec2acb54b9", "label": "(intégration retirée chez UEAT)", "status": "inactif" }
38 ] 47 ]
39 }, 48 },
49+ {
50+ "id": "osm",
51+ "name": "OpenStreetMap (découverte)",
52+ "url": "https://www.openstreetmap.org",
53+ "platform": "overpass",
54+ "connector": "osm",
55+ "tier": 4,
56+ "price_context": null,
57+ "extraction": "API Overpass (aire administrative Québec), nœuds + chemins : restaurant, fast_food, cafe, bar, pub, ice_cream, food_court, bakery, pastry — nom, adresse, GPS, cuisine, horaires, contact. Pas de menus (couche d'exhaustivité, les connecteurs de menus s'y greffent par déduplication).",
58+ "acces_legal": "Données © contributeurs OpenStreetMap, licence ODbL — attribution affichée (pages Sources et pied de page). API Overpass publique, requêtes espacées, miroirs en rotation.",
59+ "cadence": "mensuel",
60+ "status": "actif"
61+ },
40 { 62 {
41 "id": "sthubert", 63 "id": "sthubert",
42 "name": "St-Hubert (commande en ligne)", 64 "name": "St-Hubert (commande en ligne)",
modified docs/screenshots/accueil.png +0 −0

Binary file not shown.

modified docs/screenshots/plats.png +0 −0

Binary file not shown.

modified docs/screenshots/stats.png +0 −0

Binary file not shown.

modified frontend/src/App.tsx +4 −0
@@ -154,6 +154,10 @@ function Footer() {
154 </div> 154 </div>
155 <div className="fmono"> 155 <div className="fmono">
156 © {new Date().getFullYear()} Groupe KA — Resto-Ka 156 © {new Date().getFullYear()} Groupe KA — Resto-Ka
157+ {" · "}
158+ Données de découverte ©{" "}
159+ <a href="https://www.openstreetmap.org/copyright" target="_blank"
160+ rel="noopener noreferrer">contributeurs OpenStreetMap</a> (ODbL)
157 </div> 161 </div>
158 </div> 162 </div>
159 </footer> 163 </footer>
modified frontend/src/api.ts +7 −2
@@ -103,6 +103,7 @@ export interface Source {
103 103
104 export interface Stats { 104 export interface Stats {
105 restaurants: number; 105 restaurants: number;
106+ with_menu: number;
106 chains: number; 107 chains: number;
107 regions: number; 108 regions: number;
108 sources: number; 109 sources: number;
@@ -154,7 +155,10 @@ export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;
154 export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t; 155 export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;
155 export const dietLabel = (d: string) => DIET_LABELS[d] ?? d; 156 export const dietLabel = (d: string) => DIET_LABELS[d] ?? d;
156 157
157−const SOURCE_NAMES: Record<string, string> = { ueat: "UEAT (commande en ligne)" }; 158+const SOURCE_NAMES: Record<string, string> = {
159+ ueat: "UEAT (commande en ligne)",
160+ osm: "OpenStreetMap",
161+};
158 export function registerSourceNames(sources: Source[]) { 162 export function registerSourceNames(sources: Source[]) {
159 for (const s of sources) SOURCE_NAMES[s.id] = s.name; 163 for (const s of sources) SOURCE_NAMES[s.id] = s.name;
160 } 164 }
@@ -222,7 +226,8 @@ export function fetchDishes(query: DishQuery) {
222 export interface RestaurantQuery { 226 export interface RestaurantQuery {
223 region?: string; city?: string; cuisine?: string; establishment_type?: string; 227 region?: string; city?: string; cuisine?: string; establishment_type?: string;
224 diet?: string; service?: string; price_range?: string; chain?: string; 228 diet?: string; service?: string; price_range?: string; chain?: string;
225− source?: string; q?: string; sort?: string; limit?: number; offset?: number; 229+ source?: string; q?: string; has_menu?: string; sort?: string;
230+ limit?: number; offset?: number;
226 } 231 }
227 232
228 export function fetchRestaurants(query: RestaurantQuery = {}) { 233 export function fetchRestaurants(query: RestaurantQuery = {}) {
modified frontend/src/pages/Home.tsx +14 −3
@@ -21,7 +21,7 @@ const PAGE_SIZE = 24;
21 const DISH_PAGE_SIZE = 20; 21 const DISH_PAGE_SIZE = 20;
22 22
23 const FILTER_KEYS = ["q", "region", "city", "cuisine", "establishment_type", 23 const FILTER_KEYS = ["q", "region", "city", "cuisine", "establishment_type",
24− "diet", "price_range", "sort", "mode"] as const; 24+ "diet", "price_range", "sort", "mode", "has_menu"] as const;
25 type FilterKey = typeof FILTER_KEYS[number]; 25 type FilterKey = typeof FILTER_KEYS[number];
26 26
27 function DishRow({ d }: { d: Dish }) { 27 function DishRow({ d }: { d: Dish }) {
@@ -92,9 +92,9 @@ export default function Home() {
92 fetchStats() 92 fetchStats()
93 .then((s) => setStatChips([ 93 .then((s) => setStatChips([
94 { label: "restaurants", value: s.restaurants }, 94 { label: "restaurants", value: s.restaurants },
95+ { label: "avec menu complet", value: s.with_menu },
95 { label: "plats avec prix", value: s.items }, 96 { label: "plats avec prix", value: s.items },
96 { label: "régions couvertes", value: s.regions }, 97 { label: "régions couvertes", value: s.regions },
97− { label: "chaînes suivies", value: s.chains },
98 ])) 98 ]))
99 .catch(() => {}); 99 .catch(() => {});
100 }, []); 100 }, []);
@@ -135,6 +135,8 @@ export default function Home() {
135 if (filters.establishment_type) activePills.push({ key: "establishment_type", label: typeLabel(filters.establishment_type) }); 135 if (filters.establishment_type) activePills.push({ key: "establishment_type", label: typeLabel(filters.establishment_type) });
136 if (filters.diet) activePills.push({ key: "diet", label: dietLabel(filters.diet) }); 136 if (filters.diet) activePills.push({ key: "diet", label: dietLabel(filters.diet) });
137 if (filters.price_range) activePills.push({ key: "price_range", label: filters.price_range }); 137 if (filters.price_range) activePills.push({ key: "price_range", label: filters.price_range });
138+ if (filters.has_menu === "1") activePills.push({ key: "has_menu", label: "Avec menu complet" });
139+ if (filters.has_menu === "0") activePills.push({ key: "has_menu", label: "Fiche seulement" });
138 if (filters.q) activePills.push({ key: "q", label: `« ${filters.q} »` }); 140 if (filters.q) activePills.push({ key: "q", label: `« ${filters.q} »` });
139 141
140 const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); }; 142 const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };
@@ -216,9 +218,18 @@ export default function Home() {
216 ))} 218 ))}
217 </select> 219 </select>
218 </label> 220 </label>
221+ <label className="f-ctl">
222+ <span>Menu</span>
223+ <select value={filters.has_menu ?? ""} onChange={(e) => setFilter("has_menu", e.target.value)}>
224+ <option value="">Tous</option>
225+ <option value="1">Avec menu complet</option>
226+ <option value="0">Fiche seulement</option>
227+ </select>
228+ </label>
219 <label className="f-ctl"> 229 <label className="f-ctl">
220 <span>Tri</span> 230 <span>Tri</span>
221− <select value={filters.sort ?? "name"} onChange={(e) => setFilter("sort", e.target.value)}> 231+ <select value={filters.sort ?? "menu"} onChange={(e) => setFilter("sort", e.target.value)}>
232+ <option value="menu">Menus d'abord</option>
222 <option value="name">Nom</option> 233 <option value="name">Nom</option>
223 <option value="price">Fourchette de prix</option> 234 <option value="price">Fourchette de prix</option>
224 <option value="recent">Récents</option> 235 <option value="recent">Récents</option>
modified frontend/src/pages/Stats.tsx +2 −2
@@ -58,8 +58,8 @@ export default function StatsPage() {
58 <div className="tile-k">Plats avec prix</div> 58 <div className="tile-k">Plats avec prix</div>
59 </div> 59 </div>
60 <div className="tile"> 60 <div className="tile">
61− <div className="tile-v">{stats.menus.toLocaleString("fr-CA")}</div> 61+ <div className="tile-v">{(stats.with_menu ?? 0).toLocaleString("fr-CA")}</div>
62− <div className="tile-k">Menus captés</div> 62+ <div className="tile-k">Restos avec menu complet</div>
63 </div> 63 </div>
64 <div className="tile"> 64 <div className="tile">
65 <div className="tile-v">{stats.regions}</div> 65 <div className="tile-v">{stats.regions}</div>
added restoka/connectors/osm.py +180 −0
@@ -0,0 +1,180 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: restoka/connectors/osm.py
4+# Desc: Connecteur de DÉCOUVERTE OpenStreetMap (Palier 4) — TOUS les
5+# établissements où l'on mange au Québec : restaurants, fast-foods,
6+# cafés, bars/pubs, crèmeries, foires alimentaires, boulangeries-
7+# pâtisseries. C'est la couche « exhaustivité » de Resto·Ka : des
8+# milliers de fiches (nom, adresse, GPS, cuisine, horaires, contact),
9+# SANS menu — les connecteurs de menus (UEAT…) viennent s'y greffer
10+# par déduplication.
11+#
12+# Mode d'extraction : API Overpass (miroirs publics, requêtes par type
13+# d'établissement, aire administrative « Québec » 3600061549).
14+# Base légale : données © contributeurs OpenStreetMap, licence ODbL —
15+# attribution affichée sur le site (pages Sources et pied de page).
16+# Politesse : 1 requête par type, timeout large, miroirs en rotation.
17+#
18+# Contexte de prix produit : AUCUN (pas de menu — fiche de découverte).
19+# ==============================================================================
20+from __future__ import annotations
21+
22+import sys
23+
24+from ..normalize import classify_cuisines
25+from ..schema import Restaurant
26+from .base import BaseConnector
27+
28+# Aire Overpass de la province de Québec (relation OSM 61549 + 3600000000)
29+QC_AREA = 3600061549
30+
31+MIRRORS = [
32+ "https://overpass.kumi.systems/api/interpreter",
33+ "https://overpass-api.de/api/interpreter",
34+ "https://overpass.osm.ch/api/interpreter",
35+]
36+
37+# (clé, valeur, type d'établissement Resto·Ka)
38+TARGETS = [
39+ ("amenity", "restaurant", "restaurant"),
40+ ("amenity", "fast_food", "fast-food"),
41+ ("amenity", "cafe", "cafe"),
42+ ("amenity", "bar", "bar"),
43+ ("amenity", "pub", "bar"),
44+ ("amenity", "ice_cream", "cafe"),
45+ ("amenity", "food_court", "fast-food"),
46+ ("shop", "bakery", "boulangerie-patisserie"),
47+ ("shop", "pastry", "boulangerie-patisserie"),
48+]
49+
50+# cuisine=* OSM -> taxonomie Resto·Ka (§6.1) ; multi-valué « a;b;c »
51+_CUISINE_MAP = {
52+ "pizza": "pizza", "italian": "italien", "burger": "burgers",
53+ "chicken": "poulet", "sushi": "sushi-japonais", "japanese": "sushi-japonais",
54+ "chinese": "chinois", "thai": "thai", "vietnamese": "vietnamien",
55+ "korean": "coreen", "indian": "indien", "lebanese": "libanais-moyen-orient",
56+ "kebab": "libanais-moyen-orient", "mexican": "mexicain", "greek": "grec",
57+ "mediterranean": "mediterraneen", "seafood": "fruits-de-mer",
58+ "fish_and_chips": "fruits-de-mer", "french": "francais",
59+ "breakfast": "dejeuner-brunch", "brunch": "dejeuner-brunch",
60+ "crepe": "dejeuner-brunch", "coffee_shop": "cafe-dessert",
61+ "ice_cream": "cafe-dessert", "dessert": "cafe-dessert",
62+ "donut": "cafe-dessert", "bubble_tea": "cafe-dessert",
63+ "vegan": "vegetarien-vegan", "vegetarian": "vegetarien-vegan",
64+ "barbecue": "bbq-grillades", "steak_house": "bbq-grillades",
65+ "grill": "bbq-grillades", "poutine": "quebecois", "regional": "quebecois",
66+ "canadian": "quebecois", "sandwich": "fast-food", "friture": "quebecois",
67+ "asian": "chinois", "ramen": "sushi-japonais", "poke": "sushi-japonais",
68+ "shawarma": "libanais-moyen-orient", "falafel": "libanais-moyen-orient",
69+ "tapas": "mediterraneen", "portuguese": "mediterraneen",
70+ "spanish": "mediterraneen", "american": "burgers", "diner": "dejeuner-brunch",
71+}
72+
73+_DIET_TAGS = {
74+ "diet:vegan": "vegan", "diet:vegetarian": "vegetarien",
75+ "diet:gluten_free": "sans-gluten", "diet:halal": "halal",
76+ "diet:kosher": "casher",
77+}
78+
79+
80+class OsmConnector(BaseConnector):
81+ source_id = "osm"
82+ request_delay = 3.0 # politesse Overpass : requêtes lourdes
83+ timeout = 240
84+ use_detail_cache = False # une seule passe, pas de pages détail
85+
86+ def _overpass(self, key: str, value: str) -> list[dict]:
87+ """Une requête Overpass (nœuds + centres de chemins), miroirs en rotation."""
88+ query = (
89+ f'[out:json][timeout:180];'
90+ f'(node["{key}"="{value}"](area:{QC_AREA});'
91+ f'way["{key}"="{value}"](area:{QC_AREA}););'
92+ f'out tags center;'
93+ )
94+ last_exc: Exception | None = None
95+ for mirror in MIRRORS:
96+ try:
97+ resp = self.post(mirror, data={"data": query})
98+ return resp.json().get("elements") or []
99+ except Exception as exc: # miroir surchargé -> suivant
100+ last_exc = exc
101+ print(f"[resto-ka] osm: {mirror} a échoué ({exc}), miroir suivant",
102+ file=sys.stderr)
103+ raise RuntimeError(f"tous les miroirs Overpass ont échoué : {last_exc}")
104+
105+ # cuisine par défaut quand ni le tag cuisine ni le nom ne classent le POI
106+ _DEFAULT_CUISINE = {
107+ "ice_cream": "cafe-dessert", "cafe": "cafe-dessert",
108+ "bakery": "cafe-dessert", "pastry": "cafe-dessert",
109+ "fast_food": "fast-food", "food_court": "fast-food",
110+ }
111+
112+ @classmethod
113+ def _cuisines(cls, tags: dict, name: str, value: str) -> list[str]:
114+ vals = [v.strip().lower() for v in (tags.get("cuisine") or "").split(";") if v.strip()]
115+ out: list[str] = []
116+ for v in vals:
117+ mapped = _CUISINE_MAP.get(v)
118+ if mapped and mapped not in out:
119+ out.append(mapped)
120+ if not out:
121+ out = classify_cuisines(name)
122+ if out == ["autre"] and value in cls._DEFAULT_CUISINE:
123+ out = [cls._DEFAULT_CUISINE[value]]
124+ return out[:4]
125+
126+ def _to_restaurant(self, el: dict, etype: str,
127+ value: str = "") -> Restaurant | None:
128+ tags = el.get("tags") or {}
129+ name = (tags.get("name") or "").strip()
130+ if not name:
131+ return None # un POI sans nom est inutilisable
132+ lat = el.get("lat") or (el.get("center") or {}).get("lat")
133+ lng = el.get("lon") or (el.get("center") or {}).get("lon")
134+ civic = (tags.get("addr:housenumber") or "").strip()
135+ street = (tags.get("addr:street") or "").strip()
136+ services = []
137+ if tags.get("takeaway") in ("yes", "only"):
138+ services.append("takeout")
139+ if tags.get("delivery") == "yes":
140+ services.append("delivery")
141+ if tags.get("takeaway") != "only":
142+ services.append("salle")
143+ hours = {}
144+ if tags.get("opening_hours"):
145+ hours["osm"] = tags["opening_hours"]
146+ osm_id = f"{el.get('type')}/{el.get('id')}"
147+ return Restaurant(
148+ source=self.source_id,
149+ external_id=osm_id,
150+ name=name,
151+ url=f"https://www.openstreetmap.org/{osm_id}",
152+ cuisines=self._cuisines(tags, name, value),
153+ establishment_type=etype,
154+ address=f"{civic} {street}".strip(),
155+ city=(tags.get("addr:city") or "").strip(),
156+ postal_code=(tags.get("addr:postcode") or "").strip(),
157+ lat=lat, lng=lng,
158+ phone=(tags.get("phone") or tags.get("contact:phone") or "").strip(),
159+ website=(tags.get("website") or tags.get("contact:website") or "").strip(),
160+ hours=hours,
161+ services=services,
162+ dietary_options=[d for t, d in _DIET_TAGS.items()
163+ if tags.get(t) in ("yes", "only")],
164+ languages=["fr"],
165+ menu=None, # découverte : pas de menu ici
166+ )
167+
168+ def fetch(self) -> list[Restaurant]:
169+ out: dict[str, Restaurant] = {}
170+ for key, value, etype in TARGETS:
171+ elements = self._overpass(key, value)
172+ kept = 0
173+ for el in elements:
174+ resto = self._to_restaurant(el, etype, value)
175+ if resto is not None and resto.uid not in out:
176+ out[resto.uid] = resto
177+ kept += 1
178+ print(f"[resto-ka] osm: {key}={value} -> {len(elements)} POI, "
179+ f"{kept} fiches nommées")
180+ return list(out.values())
modified restoka/dedup.py +16 −4
@@ -19,14 +19,26 @@ from . import db
19 from .regions import strip_accents 19 from .regions import strip_accents
20 20
21 # Ordre de préférence des sources pour le choix de la fiche canonique : 21 # Ordre de préférence des sources pour le choix de la fiche canonique :
22−# sources de première partie (prix réels) avant les plateformes de livraison. 22+# sources de première partie (prix réels) avant les plateformes de livraison,
23+# la découverte (osm) toujours en dernier — sa fiche s'efface derrière celle
24+# qui porte un menu.
23 SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow", 25 SOURCE_PRIORITY = ["ueat", "square", "toast", "gloriafood", "chownow",
24− "site-resto", "ubereats", "doordash", "skip"] 26+ "site-resto", "ubereats", "doordash", "skip", "osm"]
25 27
26 28
27 def _name_key(name: str) -> str: 29 def _name_key(name: str) -> str:
28− """Nom normalisé : accents, ponctuation, mots vides d'enseigne.""" 30+ """Nom normalisé : accents, ponctuation, mots vides d'enseigne.
29− s = strip_accents((name or "").lower()) 31+
32+ Le suffixe de succursale (« Poulet Rouge — Beauport », « Ashton - 1e
33+ avenue ») est retranché : l'identité géographique (coordonnées/postal)
34+ distingue déjà les succursales, et les sources de découverte (OSM) nomment
35+ l'enseigne sans suffixe."""
36+ s = (name or "")
37+ for sep in (" — ", " – ", " - "):
38+ if sep in s:
39+ s = s.split(sep, 1)[0]
40+ break
41+ s = strip_accents(s.lower())
30 s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b", 42 s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b",
31 " ", s) 43 " ", s)
32 s = re.sub(r"[^a-z0-9]+", " ", s) 44 s = re.sub(r"[^a-z0-9]+", " ", s)
modified restoka/regions.py +54 −12
@@ -210,18 +210,62 @@ _POSTAL_REGION = {
210 "J5W": "Lanaudière", "J6E": "Lanaudière", 210 "J5W": "Lanaudière", "J6E": "Lanaudière",
211 } 211 }
212 212
213−# Repli nº 2 : boîtes englobantes grossières (lat_min, lat_max, lng_min, lng_max) 213+# Repli nº 2 : ancres géographiques (lat, lng) des principaux pôles de chaque
214−# — utilisées seulement quand ni la ville ni le code postal ne tranchent. 214+# région — le point le plus proche gagne. Plus robuste que des boîtes
215−_BBOX_REGION = [ 215+# englobantes qui se chevauchent ; couvre les 17 régions. Utilisé seulement
216− ("Montréal", 45.40, 45.71, -73.98, -73.47), 216+# quand ni la ville ni le code postal ne tranchent (POI ruraux d'OSM, etc.).
217− ("Laval", 45.51, 45.70, -73.90, -73.60), 217+_REGION_ANCHORS: list[tuple[str, float, float]] = [
218− ("Capitale-Nationale", 46.70, 47.60, -72.40, -70.30), 218+ ("Montréal", 45.55, -73.65), ("Montréal", 45.47, -73.82),
219− ("Estrie", 45.00, 45.90, -72.60, -70.80), 219+ ("Laval", 45.585, -73.75),
220− ("Outaouais", 45.35, 46.60, -77.50, -74.90), 220+ ("Montérégie", 45.50, -73.42), ("Montérégie", 45.30, -73.26),
221− ("Saguenay–Lac-Saint-Jean", 48.00, 49.10, -72.90, -70.50), 221+ ("Montérégie", 45.40, -74.03), ("Montérégie", 45.05, -74.13),
222+ ("Montérégie", 45.63, -72.95), ("Montérégie", 46.04, -73.11),
223+ ("Montérégie", 45.40, -72.73),
224+ ("Lanaudière", 45.75, -73.45), ("Lanaudière", 46.02, -73.44),
225+ ("Lanaudière", 46.35, -73.87), ("Lanaudière", 46.02, -73.78),
226+ ("Laurentides", 45.78, -74.00), ("Laurentides", 45.88, -74.17),
227+ ("Laurentides", 46.12, -74.60), ("Laurentides", 46.55, -75.50),
228+ ("Laurentides", 45.65, -74.34),
229+ ("Outaouais", 45.48, -75.70), ("Outaouais", 45.65, -76.24),
230+ ("Outaouais", 46.38, -75.98), ("Outaouais", 45.88, -77.10),
231+ ("Abitibi-Témiscamingue", 48.24, -79.03), ("Abitibi-Témiscamingue", 48.10, -77.78),
232+ ("Abitibi-Témiscamingue", 48.57, -78.12), ("Abitibi-Témiscamingue", 47.34, -79.44),
233+ ("Estrie", 45.40, -71.90), ("Estrie", 45.27, -72.15),
234+ ("Estrie", 45.60, -71.00), ("Estrie", 45.03, -72.10), ("Estrie", 45.77, -71.62),
235+ ("Centre-du-Québec", 45.88, -72.48), ("Centre-du-Québec", 46.06, -71.96),
236+ ("Centre-du-Québec", 46.22, -72.62),
237+ ("Mauricie", 46.35, -72.55), ("Mauricie", 46.57, -72.75),
238+ ("Mauricie", 47.44, -72.78), ("Mauricie", 46.28, -72.94),
239+ ("Capitale-Nationale", 46.81, -71.22), ("Capitale-Nationale", 46.94, -71.00),
240+ ("Capitale-Nationale", 46.68, -71.75), ("Capitale-Nationale", 47.44, -70.50),
241+ ("Capitale-Nationale", 46.86, -71.90),
242+ ("Chaudière-Appalaches", 46.74, -71.28), ("Chaudière-Appalaches", 46.12, -70.67),
243+ ("Chaudière-Appalaches", 46.05, -71.30), ("Chaudière-Appalaches", 46.98, -70.55),
244+ ("Chaudière-Appalaches", 46.55, -70.88),
245+ ("Saguenay–Lac-Saint-Jean", 48.43, -71.07), ("Saguenay–Lac-Saint-Jean", 48.55, -71.65),
246+ ("Saguenay–Lac-Saint-Jean", 48.51, -72.23), ("Saguenay–Lac-Saint-Jean", 48.88, -72.23),
247+ ("Bas-Saint-Laurent", 48.45, -68.53), ("Bas-Saint-Laurent", 47.83, -69.53),
248+ ("Bas-Saint-Laurent", 48.85, -67.53), ("Bas-Saint-Laurent", 47.37, -70.03),
249+ ("Gaspésie–Îles-de-la-Madeleine", 48.83, -64.48), ("Gaspésie–Îles-de-la-Madeleine", 48.40, -64.68),
250+ ("Gaspésie–Îles-de-la-Madeleine", 48.10, -66.10), ("Gaspésie–Îles-de-la-Madeleine", 49.13, -66.50),
251+ ("Gaspésie–Îles-de-la-Madeleine", 47.38, -61.87),
252+ ("Côte-Nord", 49.22, -68.15), ("Côte-Nord", 50.22, -66.38),
253+ ("Côte-Nord", 48.15, -69.72), ("Côte-Nord", 50.28, -63.60),
254+ ("Nord-du-Québec", 49.92, -74.37), ("Nord-du-Québec", 49.78, -77.63),
255+ ("Nord-du-Québec", 53.79, -73.67), ("Nord-du-Québec", 58.10, -68.40),
222 ] 256 ]
223 257
224 258
259+def region_from_latlng(lat: float, lng: float) -> str:
260+ """Région la plus proche par ancre (approximatif — dernier recours)."""
261+ best, best_d2 = "", float("inf")
262+ for name, alat, alng in _REGION_ANCHORS:
263+ d2 = (lat - alat) ** 2 + ((lng - alng) * 0.7) ** 2 # ~pondération lng
264+ if d2 < best_d2:
265+ best, best_d2 = name, d2
266+ return best
267+
268+
225 def attach_region(city: str, postal_code: str = "", 269 def attach_region(city: str, postal_code: str = "",
226 lat: float | None = None, lng: float | None = None) -> str: 270 lat: float | None = None, lng: float | None = None) -> str:
227 """Région administrative d'un restaurant. Retourne "" si indéterminable 271 """Région administrative d'un restaurant. Retourne "" si indéterminable
@@ -235,7 +279,5 @@ def attach_region(city: str, postal_code: str = "",
235 if prefix in _POSTAL_REGION: 279 if prefix in _POSTAL_REGION:
236 return _POSTAL_REGION[prefix] 280 return _POSTAL_REGION[prefix]
237 if lat is not None and lng is not None: 281 if lat is not None and lng is not None:
238− for name, la0, la1, lo0, lo1 in _BBOX_REGION: 282+ return region_from_latlng(lat, lng)
239− if la0 <= lat <= la1 and lo0 <= lng <= lo1:
240− return name
241 return "" 283 return ""
modified restoka/web.py +12 −2
@@ -103,8 +103,9 @@ def list_restaurants(
103 chain: str | None = None, 103 chain: str | None = None,
104 source: str | None = None, 104 source: str | None = None,
105 q: str | None = None, 105 q: str | None = None,
106+ has_menu: int | None = None, # 1 = seulement les restos avec menu
106 active: int = 1, 107 active: int = 1,
107− sort: str = "name", # name | price | recent 108+ sort: str = "menu", # menu | name | price | recent
108 limit: int = Query(60, le=500), 109 limit: int = Query(60, le=500),
109 offset: int = 0, 110 offset: int = 0,
110 ): 111 ):
@@ -134,13 +135,20 @@ def list_restaurants(
134 if q: 135 if q:
135 sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)" 136 sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"
136 args += [f"%{q}%"] * 4 137 args += [f"%{q}%"] * 4
138+ if has_menu == 1:
139+ sql += " AND EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"
140+ elif has_menu == 0:
141+ sql += " AND NOT EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid)"
137 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] 142 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
138 if sort == "recent": 143 if sort == "recent":
139 sql += " ORDER BY updated_at DESC" 144 sql += " ORDER BY updated_at DESC"
140 elif sort == "price": 145 elif sort == "price":
141 sql += " ORDER BY price_range='' , price_range ASC, name ASC" 146 sql += " ORDER BY price_range='' , price_range ASC, name ASC"
142− else: 147+ elif sort == "name":
143 sql += " ORDER BY name COLLATE NOCASE ASC" 148 sql += " ORDER BY name COLLATE NOCASE ASC"
149+ else: # défaut « menu » : les restos avec menu complet d'abord
150+ sql += (" ORDER BY NOT EXISTS (SELECT 1 FROM menus m"
151+ " WHERE m.uid=restaurants.uid), name COLLATE NOCASE ASC")
144 sql += " LIMIT ? OFFSET ?" 152 sql += " LIMIT ? OFFSET ?"
145 args += [limit, offset] 153 args += [limit, offset]
146 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()] 154 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
@@ -345,6 +353,8 @@ def stats():
345 con = db.connect() 353 con = db.connect()
346 head = dict(con.execute( 354 head = dict(con.execute(
347 """SELECT COUNT(*) restaurants, 355 """SELECT COUNT(*) restaurants,
356+ SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))
357+ with_menu,
348 COUNT(DISTINCT chain) chains, 358 COUNT(DISTINCT chain) chains,
349 COUNT(DISTINCT region) regions, 359 COUNT(DISTINCT region) regions,
350 COUNT(DISTINCT source) sources 360 COUNT(DISTINCT source) sources
added tests/fixtures/osm/elements.json +31 −0
@@ -0,0 +1,31 @@
1+{
2+ "_comment": "Author: Simon-Pierre Boucher <contact@spboucher.ai> — Fixture réelle (extrait) captée le 2026-08-17 sur l'API Overpass (aire Québec), tronquée à 3 éléments représentatifs (nœud complet, chemin avec center, nœud sans nom).",
3+ "elements": [
4+ {
5+ "type": "node", "id": 1234567,
6+ "lat": 46.8033889, "lon": -71.2258143,
7+ "tags": {
8+ "amenity": "restaurant", "name": "Chez Rioux & Pettigrew",
9+ "addr:housenumber": "160", "addr:street": "Quai Saint-André",
10+ "addr:city": "Québec", "addr:postcode": "G1K 3Y2",
11+ "cuisine": "regional;seafood", "opening_hours": "Tu-Sa 17:30-22:00",
12+ "phone": "+1 418 694 4448", "website": "https://chezriouxetpettigrew.com",
13+ "diet:vegetarian": "yes", "takeaway": "no"
14+ }
15+ },
16+ {
17+ "type": "way", "id": 7654321,
18+ "center": { "lat": 45.5236, "lon": -73.5820 },
19+ "tags": {
20+ "amenity": "restaurant", "name": "Le Gros Jambon",
21+ "addr:street": "Rue Notre-Dame Ouest", "addr:housenumber": "286",
22+ "cuisine": "diner;poutine", "takeaway": "yes", "delivery": "yes"
23+ }
24+ },
25+ {
26+ "type": "node", "id": 999,
27+ "lat": 45.5, "lon": -73.5,
28+ "tags": { "amenity": "restaurant" }
29+ }
30+ ]
31+}
added tests/test_osm.py +54 −0
@@ -0,0 +1,54 @@
1+# ==============================================================================
2+# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3+# File: tests/test_osm.py
4+# Desc: Régression du connecteur de découverte OpenStreetMap — rejeu HORS
5+# LIGNE d'une fixture Overpass réelle : normalisation nœud/chemin,
6+# POI sans nom rejeté, cuisines mappées, région par ville et par
7+# ancre lat/lng, services takeaway/delivery.
8+# ==============================================================================
9+import json
10+from pathlib import Path
11+
12+import pytest
13+
14+from restoka.connectors.osm import OsmConnector
15+
16+FIXTURE = json.loads(
17+ (Path(__file__).parent / "fixtures" / "osm" / "elements.json")
18+ .read_text(encoding="utf-8"))
19+
20+
21+class OfflineOsm(OsmConnector):
22+ def _overpass(self, key, value):
23+ return FIXTURE["elements"] if (key, value) == ("amenity", "restaurant") else []
24+
25+
26+@pytest.fixture(scope="module")
27+def restos():
28+ return [r.finalize() for r in OfflineOsm().fetch()]
29+
30+
31+def test_poi_sans_nom_rejete(restos):
32+ assert len(restos) == 2 # le 3e élément n'a pas de nom
33+
34+
35+def test_noeud_complet(restos):
36+ r = next(x for x in restos if x.name == "Chez Rioux & Pettigrew")
37+ assert r.uid == "osm:node/1234567"
38+ assert r.address == "160 Quai Saint-André"
39+ assert r.region == "Capitale-Nationale" # via addr:city
40+ assert r.postal_code == "G1K 3Y2"
41+ assert "quebecois" in r.cuisines and "fruits-de-mer" in r.cuisines
42+ assert r.hours["osm"] == "Tu-Sa 17:30-22:00"
43+ assert "vegetarien" in r.dietary_options
44+ assert "salle" in r.services and "takeout" not in r.services
45+ assert r.menu is None # découverte : pas de menu
46+
47+
48+def test_chemin_avec_centre(restos):
49+ r = next(x for x in restos if x.name == "Le Gros Jambon")
50+ assert r.uid == "osm:way/7654321"
51+ assert r.lat == pytest.approx(45.5236)
52+ assert r.region == "Montréal" # via ancre lat/lng (pas de ville)
53+ assert "quebecois" in r.cuisines
54+ assert "takeout" in r.services and "delivery" in r.services
modified tests/test_schema_db.py +15 −0
@@ -135,6 +135,21 @@ class TestDedup:
135 assert canon["uid"] == "ueat:1" 135 assert canon["uid"] == "ueat:1"
136 assert json.loads(canon["dup_sources"]) == ["ubereats"] 136 assert json.loads(canon["dup_sources"]) == ["ubereats"]
137 137
138+ def test_decouverte_derriere_le_menu(self, con):
139+ """Une fiche OSM (découverte, sans menu) au même endroit qu'une
140+ succursale UEAT nommée « Marque — Succursale » doit s'effacer
141+ derrière la fiche qui porte le menu."""
142+ u = _resto(source="ueat", eid="g:9", name="Ashton — 1e avenue",
143+ lat=46.8123, lng=-71.2340).finalize()
144+ o = _resto(source="osm", eid="node/42", name="Ashton",
145+ lat=46.8123, lng=-71.2340, menu=False).finalize()
146+ db.sync_source(con, "ueat", [u])
147+ db.sync_source(con, "osm", [o])
148+ stats = dedup.run()
149+ assert stats["merged"] == 1
150+ row = con.execute("SELECT dup_of FROM restaurants WHERE uid='osm:node/42'").fetchone()
151+ assert row["dup_of"] == "ueat:g:9"
152+
138 def test_succursales_jamais_fusionnees(self, con): 153 def test_succursales_jamais_fusionnees(self, con):
139 s1 = _resto(eid="1", name="Poulet Rouge — Beauport", 154 s1 = _resto(eid="1", name="Poulet Rouge — Beauport",
140 address="898 Boulevard Raymond", postal_code="G1C 5P4").finalize() 155 address="898 Boulevard Raymond", postal_code="G1C 5P4").finalize()
141 156
Menu

Theme