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>
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 | − | |
| 10 | − | |
| 11 | − | |
| 12 | − | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 13 | 14 | |
| 14 | 15 |  |
| 15 | 16 |  |
| 16 | 17 |  |
| 17 | − | |
| 18 | − | |
| 18 | + | |
| 19 | + | |
| 19 | 20 |  |
| 20 | 21 |  |
| 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 | |