Fiche : bloc Commerces et transport (Mapbox Search Box + cache POI/OSM)
- louka/commerces.py : point de vente le plus proche de 14 grandes bannières (Costco, Walmart, Metro, IGA, Maxi, Super C, Provigo, Canadian Tire, Dollarama, SAQ, Pharmaprix, Jean Coutu, Home Depot, RONA) via l API Mapbox Search Box (jeton public kamaps, validation du nom retourné, anti « Station Métro »), cache par cellule ~1 km TTL 30 j ; station de métro et arrêt de bus les plus proches repris du cache POI du projet (repli Overpass) ; GET /api/commerces - bloc fiche : pastilles SVG monogrammes aux couleurs des bannières, nom du point de vente et distance — métro/bus en tête Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
7 changed files +381 −0
modified
.gitignore
+1 −0
@@ -31,3 +31,4 @@ data/rdl.db | |||||
| 31 | data/inondation.db | 31 | data/inondation.db | ||
| 32 | data/air.db | 32 | data/air.db | ||
| 33 | data/gaz.db | 33 | data/gaz.db | ||
| 34 | +data/commerces.db | ||||
modified
frontend/src/api.ts
+13 −0
@@ -624,3 +624,16 @@ export interface GazNearby { | |||||
| 624 | /** Stations-service à proximité et prix courants (gazquebec.ca). */ | 624 | /** Stations-service à proximité et prix courants (gazquebec.ca). */ | ||
| 625 | export const fetchGaz = (lat: number, lng: number) => | 625 | export const fetchGaz = (lat: number, lng: number) => | ||
| 626 | get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`); | 626 | get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`); | ||
| 627 | + | ||||
| 628 | +export interface CommerceItem { | ||||
| 629 | + id: string; commerce: string; nom: string; adresse: string; | ||||
| 630 | + dist_m: number; lat: number; lng: number; | ||||
| 631 | +} | ||||
| 632 | + | ||||
| 633 | +export interface CommercesNearby { | ||||
| 634 | + n: number; commerces: CommerceItem[]; transit?: CommerceItem[]; | ||||
| 635 | +} | ||||
| 636 | + | ||||
| 637 | +/** Grands commerces + métro/bus les plus proches (Mapbox / OSM). */ | ||||
| 638 | +export const fetchCommerces = (lat: number, lng: number) => | ||||
| 639 | + get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`); | ||||
added
frontend/src/components/CommercesProches.tsx
+83 −0
@@ -0,0 +1,83 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// components/CommercesProches.tsx : bloc « Commerces et transport » (fiche) | ||||
| 5 | +// Distance au point de vente le plus proche de chaque grande bannière | ||||
| 6 | +// (Costco, Metro, IGA, Walmart… via l'API Mapbox Search Box) + station de | ||||
| 7 | +// métro et arrêt de bus les plus proches. Pastilles SVG monogrammes aux | ||||
| 8 | +// couleurs des bannières (pas de logos déposés). | ||||
| 9 | +// ----------------------------------------------------------------------------- | ||||
| 10 | +import { useEffect, useState } from "react"; | ||||
| 11 | +import { CommercesNearby, fetchCommerces, fmtDist } from "../api"; | ||||
| 12 | + | ||||
| 13 | +// id -> [couleur de fond, monogramme, couleur du texte] | ||||
| 14 | +const ICONES: Record<string, [string, string, string?]> = { | ||||
| 15 | + metro_station: ["#0083C9", "M"], | ||||
| 16 | + arret_bus: ["#4E5357", "B"], | ||||
| 17 | + costco: ["#005DAA", "C"], | ||||
| 18 | + walmart: ["#0071CE", "W"], | ||||
| 19 | + metro: ["#EF3E42", "M"], | ||||
| 20 | + iga: ["#D50032", "IGA"], | ||||
| 21 | + maxi: ["#0079C1", "Mx"], | ||||
| 22 | + superc: ["#E4002B", "SC"], | ||||
| 23 | + provigo: ["#DA291C", "P"], | ||||
| 24 | + canadiantire: ["#D6001C", "CT"], | ||||
| 25 | + dollarama: ["#00B140", "D", "#FFDD00"], | ||||
| 26 | + saq: ["#892034", "SAQ"], | ||||
| 27 | + pharmaprix: ["#E11B22", "Ph"], | ||||
| 28 | + jeancoutu: ["#003DA5", "JC"], | ||||
| 29 | + homedepot: ["#F96302", "HD"], | ||||
| 30 | + rona: ["#1B4298", "R"], | ||||
| 31 | +}; | ||||
| 32 | + | ||||
| 33 | +function Pastille({ id }: { id: string }) { | ||||
| 34 | + const [bg, mono, fg] = ICONES[id] ?? ["#777", "•"]; | ||||
| 35 | + const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14; | ||||
| 36 | + return ( | ||||
| 37 | + <svg className="cm-ico" viewBox="0 0 28 28" width="28" height="28" | ||||
| 38 | + aria-hidden="true"> | ||||
| 39 | + {id === "metro_station" || id === "arret_bus" | ||||
| 40 | + ? <circle cx="14" cy="14" r="13" fill={bg} /> | ||||
| 41 | + : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />} | ||||
| 42 | + <text x="14" y="14" textAnchor="middle" dominantBaseline="central" | ||||
| 43 | + fontSize={fs} fontWeight="800" fontFamily="inherit" | ||||
| 44 | + fill={fg ?? "#fff"}>{mono}</text> | ||||
| 45 | + </svg> | ||||
| 46 | + ); | ||||
| 47 | +} | ||||
| 48 | + | ||||
| 49 | +export default function CommercesProches({ lat, lng }: | ||||
| 50 | + { lat: number | null; lng: number | null }) { | ||||
| 51 | + const [d, setD] = useState<CommercesNearby | null>(null); | ||||
| 52 | + useEffect(() => { | ||||
| 53 | + setD(null); | ||||
| 54 | + if (lat == null || lng == null) return; | ||||
| 55 | + fetchCommerces(lat, lng).then(setD).catch(() => setD(null)); | ||||
| 56 | + }, [lat, lng]); | ||||
| 57 | + if (lat == null || lng == null || !d) return null; | ||||
| 58 | + const tous = [...(d.transit ?? []), ...(d.commerces ?? [])]; | ||||
| 59 | + if (tous.length === 0) return null; | ||||
| 60 | + | ||||
| 61 | + return ( | ||||
| 62 | + <section className="f-bloc f-commerces" id="commerces"> | ||||
| 63 | + <h2>Commerces et transport</h2> | ||||
| 64 | + <ul className="cm-grille"> | ||||
| 65 | + {tous.map((c) => ( | ||||
| 66 | + <li key={c.id} className="cm-item" | ||||
| 67 | + title={c.adresse || undefined}> | ||||
| 68 | + <Pastille id={c.id} /> | ||||
| 69 | + <span className="cm-txt"> | ||||
| 70 | + <span className="cm-nom">{c.commerce}</span> | ||||
| 71 | + <span className="cm-poi">{c.nom}</span> | ||||
| 72 | + </span> | ||||
| 73 | + <span className="cm-dist">{fmtDist(c.dist_m)}</span> | ||||
| 74 | + </li> | ||||
| 75 | + ))} | ||||
| 76 | + </ul> | ||||
| 77 | + <p className="fine"> | ||||
| 78 | + Point de vente le plus proche de chaque bannière — distances à vol | ||||
| 79 | + d'oiseau (recherche Mapbox ; métro et bus : OpenStreetMap). | ||||
| 80 | + </p> | ||||
| 81 | + </section> | ||||
| 82 | + ); | ||||
| 83 | +} | ||||
modified
frontend/src/pages/Listing.tsx
+3 −0
@@ -21,6 +21,7 @@ import RegistreLoyers from "../components/RegistreLoyers"; | |||||
| 21 | import RisqueInondation from "../components/RisqueInondation"; | 21 | import RisqueInondation from "../components/RisqueInondation"; | ||
| 22 | import QualiteAir from "../components/QualiteAir"; | 22 | import QualiteAir from "../components/QualiteAir"; | ||
| 23 | import EssenceProche from "../components/EssenceProche"; | 23 | import EssenceProche from "../components/EssenceProche"; | ||
| 24 | +import CommercesProches from "../components/CommercesProches"; | ||||
| 24 | import { IcoAlert, IcoDoc } from "../components/Icons"; | 25 | import { IcoAlert, IcoDoc } from "../components/Icons"; | ||
| 25 | import KaScoresBlock from "../components/KaScoresBlock"; | 26 | import KaScoresBlock from "../components/KaScoresBlock"; | ||
| 26 | import { markSeen } from "../search/seen"; | 27 | import { markSeen } from "../search/seen"; | ||
@@ -481,6 +482,8 @@ export default function ListingPage() { | |||||
| 481 | 482 | ||||
| 482 | <QualiteAir lat={l.lat} lng={l.lng} /> | 483 | <QualiteAir lat={l.lat} lng={l.lng} /> | ||
| 483 | 484 | ||||
| 485 | + <CommercesProches lat={l.lat} lng={l.lng} /> | ||||
| 486 | + | ||||
| 484 | <EssenceProche lat={l.lat} lng={l.lng} /> | 487 | <EssenceProche lat={l.lat} lng={l.lng} /> | ||
| 485 | 488 | ||||
| 486 | {l.kascores && <KaScoresBlock ks={l.kascores} />} | 489 | {l.kascores && <KaScoresBlock ks={l.kascores} />} | ||
modified
frontend/src/styles.css
+16 −0
@@ -1956,3 +1956,19 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | |||||
| 1956 | border-radius: 999px; background: #e7f4ea; color: #1e6b34; | 1956 | border-radius: 999px; background: #e7f4ea; color: #1e6b34; | ||
| 1957 | font-size: 10.5px; font-weight: 700; } | 1957 | font-size: 10.5px; font-weight: 700; } | ||
| 1958 | .gaz-adr { display: block; font-size: 11px; color: var(--ink-3); } | 1958 | .gaz-adr { display: block; font-size: 11px; color: var(--ink-3); } | ||
| 1959 | + | ||||
| 1960 | + | ||||
| 1961 | +/* ---- Commerces et transport (fiche) ---- */ | ||||
| 1962 | +.cm-grille { list-style: none; margin: 6px 0 0; padding: 0; | ||||
| 1963 | + display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; } | ||||
| 1964 | +@media (max-width: 560px) { .cm-grille { grid-template-columns: 1fr; } } | ||||
| 1965 | +.cm-item { display: flex; align-items: center; gap: 9px; padding: 5px 0; | ||||
| 1966 | + border-bottom: 1px solid var(--line, #eeece7); min-width: 0; } | ||||
| 1967 | +.cm-ico { flex: 0 0 28px; } | ||||
| 1968 | +.cm-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; } | ||||
| 1969 | +.cm-nom { font-size: 13px; font-weight: 600; color: var(--ink, #222); | ||||
| 1970 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | ||||
| 1971 | +.cm-poi { font-size: 11px; color: var(--ink-3, #8a877f); | ||||
| 1972 | + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | ||||
| 1973 | +.cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700; | ||||
| 1974 | + color: var(--ink-2, #4c4a45); white-space: nowrap; } | ||||
added
louka/commerces.py
+258 −0
@@ -0,0 +1,258 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# commerces.py : grands commerces à proximité — API Mapbox Search Box | ||||
| 5 | +# | ||||
| 6 | +# Pour chaque grande bannière (Costco, Metro, IGA, Walmart…), on interroge | ||||
| 7 | +# l'API Search Box de Mapbox (jeton PUBLIC pk.… lu dans | ||||
| 8 | +# frontend/src/kamaps/config.ts — source de vérité du projet) avec la | ||||
| 9 | +# position de l'annonce en `proximity`, et on retient le point de vente le | ||||
| 10 | +# plus proche. Cache par cellule d'environ 1 km (data/commerces.db, | ||||
| 11 | +# TTL 30 jours) : les fiches d'un même secteur ne recoûtent rien. | ||||
| 12 | +# ----------------------------------------------------------------------------- | ||||
| 13 | +from __future__ import annotations | ||||
| 14 | + | ||||
| 15 | +import json | ||||
| 16 | +import math | ||||
| 17 | +import re | ||||
| 18 | +import sqlite3 | ||||
| 19 | +import time | ||||
| 20 | +import urllib.parse | ||||
| 21 | +import urllib.request | ||||
| 22 | +from concurrent.futures import ThreadPoolExecutor | ||||
| 23 | +from pathlib import Path | ||||
| 24 | + | ||||
| 25 | +ROOT = Path(__file__).resolve().parent.parent | ||||
| 26 | +DB_PATH = ROOT / "data" / "commerces.db" | ||||
| 27 | +UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)" | ||||
| 28 | +TTL = 30 * 86400 | ||||
| 29 | +API = "https://api.mapbox.com/search/searchbox/v1/forward" | ||||
| 30 | + | ||||
| 31 | +# id, libellé, requête Mapbox, mot-clé de validation (le nom du POI doit le | ||||
| 32 | +# contenir, sans accents ni casse — écarte « Station Métro », « Super Qualité »…) | ||||
| 33 | +BRANDS = [ | ||||
| 34 | + ("costco", "Costco", "Costco Wholesale", "costco"), | ||||
| 35 | + ("walmart", "Walmart", "Walmart Supercentre", "walmart"), | ||||
| 36 | + ("metro", "Metro", "Metro", "metro"), | ||||
| 37 | + ("iga", "IGA", "IGA", "iga"), | ||||
| 38 | + ("maxi", "Maxi", "Maxi", "maxi"), | ||||
| 39 | + ("superc", "Super C", "Super C", "super c"), | ||||
| 40 | + ("provigo", "Provigo", "Provigo", "provigo"), | ||||
| 41 | + ("canadiantire", "Canadian Tire", "Canadian Tire", "canadian tire"), | ||||
| 42 | + ("dollarama", "Dollarama", "Dollarama", "dollarama"), | ||||
| 43 | + ("saq", "SAQ", "SAQ", "saq"), | ||||
| 44 | + ("pharmaprix", "Pharmaprix", "Pharmaprix", "pharmaprix"), | ||||
| 45 | + ("jeancoutu", "Jean Coutu", "Jean Coutu pharmacie", "jean coutu"), | ||||
| 46 | + ("homedepot", "Home Depot", "Home Depot", "home depot"), | ||||
| 47 | + ("rona", "RONA", "RONA", "rona"), | ||||
| 48 | +] | ||||
| 49 | + | ||||
| 50 | +_BAN = ("station", "stationnement") | ||||
| 51 | + | ||||
| 52 | + | ||||
| 53 | +def _norm(s: str) -> str: | ||||
| 54 | + import unicodedata | ||||
| 55 | + s = unicodedata.normalize("NFD", s or "") | ||||
| 56 | + return "".join(c for c in s if unicodedata.category(c) != "Mn").lower() | ||||
| 57 | + | ||||
| 58 | +_token_cache: list[str] = [] | ||||
| 59 | + | ||||
| 60 | + | ||||
| 61 | +def _token() -> str: | ||||
| 62 | + if not _token_cache: | ||||
| 63 | + cfg = (ROOT / "frontend" / "src" / "kamaps" / "config.ts").read_text() | ||||
| 64 | + m = re.search(r'"(pk\.[A-Za-z0-9._-]+)"', cfg) | ||||
| 65 | + if not m: | ||||
| 66 | + raise RuntimeError("jeton Mapbox introuvable (kamaps/config.ts)") | ||||
| 67 | + _token_cache.append(m.group(1)) | ||||
| 68 | + return _token_cache[0] | ||||
| 69 | + | ||||
| 70 | + | ||||
| 71 | +def _connect() -> sqlite3.Connection: | ||||
| 72 | + DB_PATH.parent.mkdir(parents=True, exist_ok=True) | ||||
| 73 | + con = sqlite3.connect(DB_PATH, timeout=15) | ||||
| 74 | + con.row_factory = sqlite3.Row | ||||
| 75 | + con.execute("""CREATE TABLE IF NOT EXISTS commerces_cache ( | ||||
| 76 | + cellule TEXT, brand TEXT, nom TEXT, adresse TEXT, | ||||
| 77 | + lat REAL, lng REAL, fetched_at REAL, | ||||
| 78 | + PRIMARY KEY (cellule, brand))""") | ||||
| 79 | + return con | ||||
| 80 | + | ||||
| 81 | + | ||||
| 82 | +def _dist_m(lat1, lng1, lat2, lng2) -> float: | ||||
| 83 | + dlat = math.radians(lat2 - lat1) | ||||
| 84 | + dlng = math.radians(lng2 - lng1) | ||||
| 85 | + a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) | ||||
| 86 | + * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2) | ||||
| 87 | + return 6371000 * 2 * math.asin(math.sqrt(a)) | ||||
| 88 | + | ||||
| 89 | + | ||||
| 90 | +def _fetch_brand(brand_q: str, lat: float, lng: float, | ||||
| 91 | + match: str = "") -> dict | None: | ||||
| 92 | + params = urllib.parse.urlencode({ | ||||
| 93 | + "q": brand_q, "proximity": f"{lng},{lat}", "limit": 5, | ||||
| 94 | + "types": "poi", "language": "fr", "country": "CA", | ||||
| 95 | + "access_token": _token()}) | ||||
| 96 | + req = urllib.request.Request(f"{API}?{params}", | ||||
| 97 | + headers={"User-Agent": UA}) | ||||
| 98 | + try: | ||||
| 99 | + with urllib.request.urlopen(req, timeout=12) as r: | ||||
| 100 | + feats = json.load(r).get("features") or [] | ||||
| 101 | + except Exception: | ||||
| 102 | + return None | ||||
| 103 | + for f in feats: | ||||
| 104 | + p = f.get("properties") or {} | ||||
| 105 | + nom = _norm(p.get("name") or "") | ||||
| 106 | + if match and match not in nom: | ||||
| 107 | + continue | ||||
| 108 | + if any(b in nom for b in _BAN): | ||||
| 109 | + continue | ||||
| 110 | + lng2, lat2 = f["geometry"]["coordinates"][:2] | ||||
| 111 | + return {"nom": p.get("name") or brand_q, | ||||
| 112 | + "adresse": p.get("full_address") | ||||
| 113 | + or p.get("place_formatted") or "", | ||||
| 114 | + "lat": lat2, "lng": lng2} | ||||
| 115 | + return None | ||||
| 116 | + | ||||
| 117 | + | ||||
| 118 | +OVERPASS = ["https://overpass.kumi.systems/api/interpreter", | ||||
| 119 | + "https://overpass-api.de/api/interpreter"] | ||||
| 120 | + | ||||
| 121 | + | ||||
| 122 | +def _fetch_transit(lat: float, lng: float) -> list[tuple[str, dict]]: | ||||
| 123 | + """Station de métro et arrêt de bus les plus proches (OpenStreetMap).""" | ||||
| 124 | + q = f"""[out:json][timeout:20]; | ||||
| 125 | +( | ||||
| 126 | + node["railway"="station"]["station"="subway"](around:3000,{lat},{lng}); | ||||
| 127 | + node["highway"="bus_stop"](around:1000,{lat},{lng}); | ||||
| 128 | +); | ||||
| 129 | +out body;""" | ||||
| 130 | + data = None | ||||
| 131 | + for url in OVERPASS: | ||||
| 132 | + try: | ||||
| 133 | + req = urllib.request.Request( | ||||
| 134 | + url, data=urllib.parse.urlencode({"data": q}).encode(), | ||||
| 135 | + headers={"User-Agent": UA}) | ||||
| 136 | + with urllib.request.urlopen(req, timeout=25) as r: | ||||
| 137 | + data = json.load(r) | ||||
| 138 | + break | ||||
| 139 | + except Exception: | ||||
| 140 | + continue | ||||
| 141 | + if not data: | ||||
| 142 | + return [] | ||||
| 143 | + best: dict[str, tuple[float, dict]] = {} | ||||
| 144 | + for el in data.get("elements", []): | ||||
| 145 | + tags = el.get("tags") or {} | ||||
| 146 | + kind = ("metro_station" if tags.get("railway") == "station" | ||||
| 147 | + else "arret_bus") | ||||
| 148 | + d = _dist_m(lat, lng, el["lat"], el["lon"]) | ||||
| 149 | + if kind not in best or d < best[kind][0]: | ||||
| 150 | + best[kind] = (d, {"nom": tags.get("name") | ||||
| 151 | + or ("Station de métro" if kind == "metro_station" | ||||
| 152 | + else "Arrêt de bus"), | ||||
| 153 | + "adresse": "", "lat": el["lat"], | ||||
| 154 | + "lng": el["lon"]}) | ||||
| 155 | + return [(k, v[1]) for k, v in best.items()] | ||||
| 156 | + | ||||
| 157 | + | ||||
| 158 | +TRANSIT = [("metro_station", "Station de métro"), ("arret_bus", "Arrêt de bus")] | ||||
| 159 | + | ||||
| 160 | +_POI_CAT = {"metro": "metro_station", "bus": "arret_bus"} | ||||
| 161 | + | ||||
| 162 | + | ||||
| 163 | +def _transit_from_poi(lat: float, lng: float) -> list[tuple[str, dict]]: | ||||
| 164 | + """Métro/bus depuis le cache POI du projet (louka.db, déjà calculé par | ||||
| 165 | + immeuble) — la fiche interroge avec les mêmes coordonnées que poi.py.""" | ||||
| 166 | + db_main = ROOT / "data" / next( | ||||
| 167 | + (n for n in ("louka.db", "immoka.db", "immo.db") | ||||
| 168 | + if (ROOT / "data" / n).exists()), "louka.db") | ||||
| 169 | + if not db_main.exists(): | ||||
| 170 | + return [] | ||||
| 171 | + try: | ||||
| 172 | + con = sqlite3.connect(f"file:{db_main}?mode=ro", uri=True) | ||||
| 173 | + con.row_factory = sqlite3.Row | ||||
| 174 | + d = 300 / 111320.0 | ||||
| 175 | + row = con.execute( | ||||
| 176 | + "SELECT pois, lat, lng FROM poi_cache WHERE lat BETWEEN ? AND ? " | ||||
| 177 | + "AND lng BETWEEN ? AND ? ORDER BY (lat-?)*(lat-?)+(lng-?)*(lng-?) " | ||||
| 178 | + "LIMIT 1", (lat - d, lat + d, lng - d, lng + d, | ||||
| 179 | + lat, lat, lng, lng)).fetchone() | ||||
| 180 | + con.close() | ||||
| 181 | + except sqlite3.Error: | ||||
| 182 | + return [] | ||||
| 183 | + if row is None: | ||||
| 184 | + return [] | ||||
| 185 | + out = [] | ||||
| 186 | + for e in json.loads(row["pois"] or "[]"): | ||||
| 187 | + k = _POI_CAT.get(e.get("cat")) | ||||
| 188 | + if k: | ||||
| 189 | + out.append((k, {"nom": e.get("name") or "", "adresse": "", | ||||
| 190 | + "lat": lat, "lng": lng, | ||||
| 191 | + "_dist": e.get("dist_m")})) | ||||
| 192 | + return out | ||||
| 193 | + | ||||
| 194 | + | ||||
| 195 | +def nearby(lat: float, lng: float) -> dict: | ||||
| 196 | + """Grand commerce le plus proche par bannière (cache ~1 km, TTL 30 j).""" | ||||
| 197 | + cell = f"{round(lat, 2)},{round(lng, 2)}" | ||||
| 198 | + con = _connect() | ||||
| 199 | + now = time.time() | ||||
| 200 | + cached = {r["brand"]: r for r in con.execute( | ||||
| 201 | + "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?", | ||||
| 202 | + (cell, now - TTL))} | ||||
| 203 | + manquants = [(bid, q, m) for bid, _, q, m in BRANDS | ||||
| 204 | + if bid not in cached] | ||||
| 205 | + transit_manquant = any(k not in cached for k, _ in TRANSIT) | ||||
| 206 | + if manquants or transit_manquant: | ||||
| 207 | + res: list[tuple[str, dict | None]] = [] | ||||
| 208 | + if manquants: | ||||
| 209 | + with ThreadPoolExecutor(max_workers=6) as ex: | ||||
| 210 | + res = list(ex.map( | ||||
| 211 | + lambda b: (b[0], _fetch_brand(b[1], lat, lng, b[2])), | ||||
| 212 | + manquants)) | ||||
| 213 | + if transit_manquant: | ||||
| 214 | + tr = _transit_from_poi(lat, lng) or _fetch_transit(lat, lng) | ||||
| 215 | + res.extend(tr) | ||||
| 216 | + with con: | ||||
| 217 | + for bid, hit in res: | ||||
| 218 | + if hit is None: | ||||
| 219 | + continue | ||||
| 220 | + con.execute( | ||||
| 221 | + "INSERT OR REPLACE INTO commerces_cache VALUES " | ||||
| 222 | + "(?,?,?,?,?,?,?)", | ||||
| 223 | + (cell, bid, hit["nom"], | ||||
| 224 | + hit.get("adresse") or (str(hit["_dist"]) | ||||
| 225 | + if hit.get("_dist") is not None | ||||
| 226 | + else ""), | ||||
| 227 | + hit["lat"], hit["lng"], now)) | ||||
| 228 | + cached = {r["brand"]: r for r in con.execute( | ||||
| 229 | + "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?", | ||||
| 230 | + (cell, now - TTL))} | ||||
| 231 | + con.close() | ||||
| 232 | + | ||||
| 233 | + items = [] | ||||
| 234 | + transit = [] | ||||
| 235 | + for bid, label in TRANSIT: | ||||
| 236 | + r = cached.get(bid) | ||||
| 237 | + if r is not None: | ||||
| 238 | + # distance : celle du cache POI si disponible (adresse numérique) | ||||
| 239 | + d = (float(r["adresse"]) if (r["adresse"] or "").replace( | ||||
| 240 | + ".", "").isdigit() else _dist_m(lat, lng, r["lat"], r["lng"])) | ||||
| 241 | + if d <= 5000: | ||||
| 242 | + transit.append({"id": bid, "commerce": label, | ||||
| 243 | + "nom": r["nom"], "adresse": "", | ||||
| 244 | + "dist_m": round(d), | ||||
| 245 | + "lat": r["lat"], "lng": r["lng"]}) | ||||
| 246 | + for bid, label, _q, _m in BRANDS: | ||||
| 247 | + r = cached.get(bid) | ||||
| 248 | + if r is None: | ||||
| 249 | + continue | ||||
| 250 | + d = _dist_m(lat, lng, r["lat"], r["lng"]) | ||||
| 251 | + if d > 40000: # au-delà de 40 km : non pertinent | ||||
| 252 | + continue | ||||
| 253 | + items.append({"id": bid, "commerce": label, "nom": r["nom"], | ||||
| 254 | + "adresse": r["adresse"], "dist_m": round(d), | ||||
| 255 | + "lat": r["lat"], "lng": r["lng"]}) | ||||
| 256 | + items.sort(key=lambda x: x["dist_m"]) | ||||
| 257 | + transit.sort(key=lambda x: x["dist_m"]) | ||||
| 258 | + return {"n": len(items), "commerces": items, "transit": transit} | ||||
modified
louka/web.py
+7 −0
@@ -542,6 +542,13 @@ def fairvalue_detail(uid: str): | |||||
| 542 | return d | 542 | return d | ||
| 543 | 543 | ||||
| 544 | 544 | ||||
| 545 | +@app.get("/api/commerces") | ||||
| 546 | +def commerces_at(lat: float, lng: float): | ||||
| 547 | + """Grands commerces + métro/bus les plus proches (Mapbox / OSM).""" | ||||
| 548 | + from . import commerces | ||||
| 549 | + return commerces.nearby(lat, lng) | ||||
| 550 | + | ||||
| 551 | + | ||||
| 545 | @app.get("/api/air") | 552 | @app.get("/api/air") | ||
| 546 | def air_at(lat: float, lng: float): | 553 | def air_at(lat: float, lng: float): | ||
| 547 | """Qualité de l'air : station RSQAQ la plus proche (MELCCFP).""" | 554 | """Qualité de l'air : station RSQAQ la plus proche (MELCCFP).""" | ||
| 548 | 555 | ||||