SPB Git forge

spb/resto-ka

Public

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

52commits 1branches 0releases
11.6 MBsize
maindefault branch
19 days agolast push
Python 69.3% TypeScript 16.7% CSS 7.9% JavaScript 4.7% HTML 1.4%

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 1 mo ago (Aug 17, 2026) parent 8308099

16 changed files +422 −34

modified README.md +11 −9
@@ -6,16 +6,17 @@
6 6
7 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)
10 −![Plats avec prix](https://img.shields.io/badge/plats_avec_prix-61_330-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)
12 −![Chaînes](https://img.shields.io/badge/cha%C3%AEnes_suivies-15-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-79_159-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-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 15 ![Prix](https://img.shields.io/badge/prix-takeout_r%C3%A9els,_non_major%C3%A9s-1c5c41?style=flat-square)
15 16 ![Images](https://img.shields.io/badge/items_avec_photo-98%25-1c5c41?style=flat-square)
16 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 −![Tests](https://img.shields.io/badge/tests-41_pass-1c5c41?style=flat-square)
18 +![Géocodage](https://img.shields.io/badge/g%C3%A9olocalis%C3%A9-99%25-1c5c41?style=flat-square)
19 +![Tests](https://img.shields.io/badge/tests-45_pass-1c5c41?style=flat-square)
19 20 ![Python](https://img.shields.io/badge/python-3.14_%C2%B7_FastAPI-141814?style=flat-square)
20 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 48 - **Un prix n'est JAMAIS présenté sans son contexte** (`dine-in` / `takeout` /
48 49 `delivery`) — un menu sans `price_context`/`price_source` est rejeté à
49 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 52 répéter 76 fois la même poutine de chaîne).
52 53 - **Options & formats capturés** avec leurs suppléments (« Mini +3,70 $ ») —
53 54 ils changent le prix réel.
@@ -64,7 +65,7 @@ cd frontend && npm install && npm run build && cd ..
64 65 .venv/bin/python run.py sync # synchronise (+ géocode + dédup)
65 66 .venv/bin/python run.py serve 8115 # API + frontend sur :8115
66 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 71 ## Architecture (patron Lou·Ka)
@@ -91,7 +92,8 @@ Registre : `data/sources.json` (à consulter AVANT d'ajouter quoi que ce soit).
91 92
92 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 97 | St-Hubert | 6 | `takeout` (visé) | à faire — anti-bot Akamai, voir `data/sources.json` |
96 98
97 99 ## Déploiement
modified data/sources.json +22 −0
@@ -33,10 +33,32 @@
33 33 { "key": "d679a49f-0a25-4694-82f2-56073a8b8790", "label": "Scores (25 succ., rôtisserie)" },
34 34 { "key": "1696ecd3-d4ea-4224-9f98-9299ebcb339c", "label": "Mandy's (9 succ. QC, salades)" },
35 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 45 { "key": "e8015de3-09ac-4cd2-a18d-0820dbe356ed", "label": "(intégration retirée chez UEAT)", "status": "inactif" },
37 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 63 "id": "sthubert",
42 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 154 </div>
155 155 <div className="fmono">
156 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 161 </div>
158 162 </div>
159 163 </footer>
modified frontend/src/api.ts +7 −2
@@ -103,6 +103,7 @@ export interface Source {
103 103
104 104 export interface Stats {
105 105 restaurants: number;
106 + with_menu: number;
106 107 chains: number;
107 108 regions: number;
108 109 sources: number;
@@ -154,7 +155,10 @@ export const cuisineLabel = (c: string) => CUISINE_LABELS[c] ?? c;
154 155 export const typeLabel = (t: string) => TYPE_LABELS[t] ?? t;
155 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 162 export function registerSourceNames(sources: Source[]) {
159 163 for (const s of sources) SOURCE_NAMES[s.id] = s.name;
160 164 }
@@ -222,7 +226,8 @@ export function fetchDishes(query: DishQuery) {
222 226 export interface RestaurantQuery {
223 227 region?: string; city?: string; cuisine?: string; establishment_type?: string;
224 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 233 export function fetchRestaurants(query: RestaurantQuery = {}) {
modified frontend/src/pages/Home.tsx +14 −3
@@ -21,7 +21,7 @@ const PAGE_SIZE = 24;
21 21 const DISH_PAGE_SIZE = 20;
22 22
23 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 25 type FilterKey = typeof FILTER_KEYS[number];
26 26
27 27 function DishRow({ d }: { d: Dish }) {
@@ -92,9 +92,9 @@ export default function Home() {
92 92 fetchStats()
93 93 .then((s) => setStatChips([
94 94 { label: "restaurants", value: s.restaurants },
95 + { label: "avec menu complet", value: s.with_menu },
95 96 { label: "plats avec prix", value: s.items },
96 97 { label: "régions couvertes", value: s.regions },
97 − { label: "chaînes suivies", value: s.chains },
98 98 ]))
99 99 .catch(() => {});
100 100 }, []);
@@ -135,6 +135,8 @@ export default function Home() {
135 135 if (filters.establishment_type) activePills.push({ key: "establishment_type", label: typeLabel(filters.establishment_type) });
136 136 if (filters.diet) activePills.push({ key: "diet", label: dietLabel(filters.diet) });
137 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 140 if (filters.q) activePills.push({ key: "q", label: `« ${filters.q} »` });
139 141
140 142 const clearAll = () => { setParams(new URLSearchParams(), { replace: true }); setPage(0); };
@@ -216,9 +218,18 @@ export default function Home() {
216 218 ))}
217 219 </select>
218 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 229 <label className="f-ctl">
220 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 233 <option value="name">Nom</option>
223 234 <option value="price">Fourchette de prix</option>
224 235 <option value="recent">Récents</option>
modified frontend/src/pages/Stats.tsx +2 −2
@@ -58,8 +58,8 @@ export default function StatsPage() {
58 58 <div className="tile-k">Plats avec prix</div>
59 59 </div>
60 60 <div className="tile">
61 − <div className="tile-v">{stats.menus.toLocaleString("fr-CA")}</div>
62 − <div className="tile-k">Menus captés</div>
61 + <div className="tile-v">{(stats.with_menu ?? 0).toLocaleString("fr-CA")}</div>
62 + <div className="tile-k">Restos avec menu complet</div>
63 63 </div>
64 64 <div className="tile">
65 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 19 from .regions import strip_accents
20 20
21 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 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 29 def _name_key(name: str) -> str:
28 − """Nom normalisé : accents, ponctuation, mots vides d'enseigne."""
29 − s = strip_accents((name or "").lower())
30 + """Nom normalisé : accents, ponctuation, mots vides d'enseigne.
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 42 s = re.sub(r"\b(restaurant|resto|cafe|bistro|casse-croute|chez|le|la|les|du|de|des)\b",
31 43 " ", s)
32 44 s = re.sub(r"[^a-z0-9]+", " ", s)
modified restoka/regions.py +54 −12
@@ -210,18 +210,62 @@ _POSTAL_REGION = {
210 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)
214 −# — utilisées seulement quand ni la ville ni le code postal ne tranchent.
215 −_BBOX_REGION = [
216 − ("Montréal", 45.40, 45.71, -73.98, -73.47),
217 − ("Laval", 45.51, 45.70, -73.90, -73.60),
218 − ("Capitale-Nationale", 46.70, 47.60, -72.40, -70.30),
219 − ("Estrie", 45.00, 45.90, -72.60, -70.80),
220 − ("Outaouais", 45.35, 46.60, -77.50, -74.90),
221 − ("Saguenay–Lac-Saint-Jean", 48.00, 49.10, -72.90, -70.50),
213 +# Repli nº 2 : ancres géographiques (lat, lng) des principaux pôles de chaque
214 +# région — le point le plus proche gagne. Plus robuste que des boîtes
215 +# englobantes qui se chevauchent ; couvre les 17 régions. Utilisé seulement
216 +# quand ni la ville ni le code postal ne tranchent (POI ruraux d'OSM, etc.).
217 +_REGION_ANCHORS: list[tuple[str, float, float]] = [
218 + ("Montréal", 45.55, -73.65), ("Montréal", 45.47, -73.82),
219 + ("Laval", 45.585, -73.75),
220 + ("Montérégie", 45.50, -73.42), ("Montérégie", 45.30, -73.26),
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 269 def attach_region(city: str, postal_code: str = "",
226 270 lat: float | None = None, lng: float | None = None) -> str:
227 271 """Région administrative d'un restaurant. Retourne "" si indéterminable
@@ -235,7 +279,5 @@ def attach_region(city: str, postal_code: str = "",
235 279 if prefix in _POSTAL_REGION:
236 280 return _POSTAL_REGION[prefix]
237 281 if lat is not None and lng is not None:
238 − for name, la0, la1, lo0, lo1 in _BBOX_REGION:
239 − if la0 <= lat <= la1 and lo0 <= lng <= lo1:
240 − return name
282 + return region_from_latlng(lat, lng)
241 283 return ""
modified restoka/web.py +12 −2
@@ -103,8 +103,9 @@ def list_restaurants(
103 103 chain: str | None = None,
104 104 source: str | None = None,
105 105 q: str | None = None,
106 + has_menu: int | None = None, # 1 = seulement les restos avec menu
106 107 active: int = 1,
107 − sort: str = "name", # name | price | recent
108 + sort: str = "menu", # menu | name | price | recent
108 109 limit: int = Query(60, le=500),
109 110 offset: int = 0,
110 111 ):
@@ -134,13 +135,20 @@ def list_restaurants(
134 135 if q:
135 136 sql += " AND (name LIKE ? OR address LIKE ? OR city LIKE ? OR chain LIKE ?)"
136 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 142 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
138 143 if sort == "recent":
139 144 sql += " ORDER BY updated_at DESC"
140 145 elif sort == "price":
141 146 sql += " ORDER BY price_range='' , price_range ASC, name ASC"
142 − else:
147 + elif sort == "name":
143 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 152 sql += " LIMIT ? OFFSET ?"
145 153 args += [limit, offset]
146 154 rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
@@ -345,6 +353,8 @@ def stats():
345 353 con = db.connect()
346 354 head = dict(con.execute(
347 355 """SELECT COUNT(*) restaurants,
356 + SUM(EXISTS (SELECT 1 FROM menus m WHERE m.uid=restaurants.uid))
357 + with_menu,
348 358 COUNT(DISTINCT chain) chains,
349 359 COUNT(DISTINCT region) regions,
350 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 135 assert canon["uid"] == "ueat:1"
136 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 153 def test_succursales_jamais_fusionnees(self, con):
139 154 s1 = _resto(eid="1", name="Poulet Rouge — Beauport",
140 155 address="898 Boulevard Raymond", postal_code="G1C 5P4").finalize()
141 156