SPB Git

spb/house-ka

Public

feat(commerces): bannieres de proximite adaptees a chaque province

Paniers par province fondes sur la presence reelle des chaines (Loblaw n.1
national, Sobeys n.2 dominant Atlantique, Metro Ontario, Pattison/Save-On-Foods
~180 magasins Ouest+Yukon, Co-op ~300 magasins SK, monopoles d alcool
provinciaux LCBO/BC Liquor/Liquor Mart/NB Liquor/NSLC, Kent Irving Atlantique,
Dominion et Colemans a Terre-Neuve, London Drugs BC/AB).

- commerces.py : BRAND_DEFS + PROVINCE_BRANDS + inference lat/lng de repli
- /api/commerces?region=<province> ; fiche passe l.region
- NearbyPlaces : ~30 pastilles monogrammes aux couleurs des bannieres
Simon-Pierre Boucher committed (Aug 27, 2026) parent 852c914 Browse files

5 changed files +196 −51

modified frontend/src/api.ts +4 −2
@@ -379,5 +379,7 @@ export interface CommercesNearby {
379 n: number; commerces: CommerceItem[]; transit?: CommerceItem[]; 379 n: number; commerces: CommerceItem[]; transit?: CommerceItem[];
380 } 380 }
381 381
382−export const fetchCommerces = (lat: number, lng: number) => 382+export const fetchCommerces = (lat: number, lng: number, region?: string) =>
383− get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`); 383+ get<CommercesNearby>(
384+ `/api/commerces?lat=${lat}&lng=${lng}` +
385+ (region ? `&region=${encodeURIComponent(region)}` : ""));
modified frontend/src/components/NearbyPlaces.tsx +42 −12
@@ -15,20 +15,50 @@ const ICONS: Record<string, [string, string, string?]> = {
15 rem_station: ["#84BD00", "R"], 15 rem_station: ["#84BD00", "R"],
16 arret_bus: ["#4E5357", "B"], 16 arret_bus: ["#4E5357", "B"],
17 gare_train: ["#6E5B3F", "T"], 17 gare_train: ["#6E5B3F", "T"],
18+ // national
18 costco: ["#005DAA", "C"], 19 costco: ["#005DAA", "C"],
19 walmart: ["#0071CE", "W"], 20 walmart: ["#0071CE", "W"],
20− metro: ["#EF3E42", "M"],
21− iga: ["#D50032", "IGA"],
22− maxi: ["#0079C1", "Mx"],
23− superc: ["#E4002B", "SC"],
24− provigo: ["#DA291C", "P"],
25 canadiantire: ["#D6001C", "CT"], 21 canadiantire: ["#D6001C", "CT"],
26 dollarama: ["#00B140", "D", "#FFDD00"], 22 dollarama: ["#00B140", "D", "#FFDD00"],
27− saq: ["#892034", "SAQ"],
28− pharmaprix: ["#E11B22", "Ph"],
29− jeancoutu: ["#003DA5", "JC"],
30 homedepot: ["#F96302", "HD"], 23 homedepot: ["#F96302", "HD"],
24+ homehardware: ["#DA291C", "HH"],
25+ shoppers: ["#E51937", "SDM"],
26+ gianttiger: ["#FFD200", "GT", "#D6001C"],
27+ // grocery — Loblaw banners
28+ loblaws: ["#E4002B", "L"],
29+ superstore: ["#D22630", "RCS"],
30+ atlanticsuperstore: ["#D22630", "AS"],
31+ nofrills: ["#FFDD00", "NF", "#111111"],
32+ zehrs: ["#C8102E", "Z"],
33+ dominion: ["#C8102E", "Do"],
34+ independent: ["#C8102E", "YIG"],
35+ // grocery — Empire/Sobeys banners
36+ sobeys: ["#00563F", "S"],
37+ safeway: ["#ED1C24", "SW"],
38+ foodland: ["#00794A", "FL"],
39+ freshco: ["#54B948", "FC"],
40+ // grocery — Metro (Ontario)
41+ metro: ["#EF3E42", "M"],
42+ foodbasics: ["#EE3124", "FB"],
43+ // grocery — western / co-ops / regional
44+ saveon: ["#00543D", "SOF"],
45+ coop: ["#00843D", "CO"],
46+ colemans: ["#0067B1", "Cm"],
47+ iga: ["#D50032", "IGA"],
48+ // provincial liquor boards
49+ lcbo: ["#00532A", "LC"],
50+ beerstore: ["#1E4620", "TBS"],
51+ bcliquor: ["#003E51", "BCL"],
52+ liquormart: ["#004F9F", "LM"],
53+ nbliquor: ["#00539F", "NBL"],
54+ nslc: ["#0B4F32", "NSL"],
55+ // regional pharmacies
56+ londondrugs: ["#004F9F", "LD"],
57+ rexall: ["#003087", "Rx"],
58+ lawtons: ["#00539F", "Lw"],
59+ // hardware
31 rona: ["#1B4298", "R"], 60 rona: ["#1B4298", "R"],
61+ kent: ["#006A4D", "K"],
32 }; 62 };
33 63
34 function Chip({ id }: { id: string }) { 64 function Chip({ id }: { id: string }) {
@@ -47,14 +77,14 @@ function Chip({ id }: { id: string }) {
47 ); 77 );
48 } 78 }
49 79
50−export default function NearbyPlaces({ lat, lng }: 80+export default function NearbyPlaces({ lat, lng, region }:
51− { lat: number | null; lng: number | null }) { 81+ { lat: number | null; lng: number | null; region?: string }) {
52 const [d, setD] = useState<CommercesNearby | null>(null); 82 const [d, setD] = useState<CommercesNearby | null>(null);
53 useEffect(() => { 83 useEffect(() => {
54 setD(null); 84 setD(null);
55 if (lat == null || lng == null) return; 85 if (lat == null || lng == null) return;
56− fetchCommerces(lat, lng).then(setD).catch(() => setD(null)); 86+ fetchCommerces(lat, lng, region).then(setD).catch(() => setD(null));
57− }, [lat, lng]); 87+ }, [lat, lng, region]);
58 if (lat == null || lng == null || !d) return null; 88 if (lat == null || lng == null || !d) return null;
59 const all = [...(d.transit ?? []), ...(d.commerces ?? [])]; 89 const all = [...(d.transit ?? []), ...(d.commerces ?? [])];
60 if (all.length === 0) return null; 90 if (all.length === 0) return null;
modified frontend/src/pages/Listing.tsx +4 −2
@@ -425,8 +425,10 @@ export default function ListingPage() {
425 {/* financing: real mortgage rates + Canadian calculator */} 425 {/* financing: real mortgage rates + Canadian calculator */}
426 {isSale && <Financing price={l.price} />} 426 {isSale && <Financing price={l.price} />}
427 427
428− {/* nearby amenities: full width, AFTER the property info (correct mobile order) */} 428+ {/* nearby amenities: full width, AFTER the property info (correct mobile order) —
429− <NearbyPlaces lat={l.lat} lng={l.lng} /> 429+ the banner basket is per-province (Save-On-Foods in BC, Sobeys in the
430+ Atlantic, Co-op in SK, LCBO in ON…) */}
431+ <NearbyPlaces lat={l.lat} lng={l.lng} region={l.region} />
430 432
431 <div className="fine f-foot"> 433 <div className="fine f-foot">
432 Prices and availability are those displayed by the source — every 434 Prices and availability are those displayed by the source — every
modified immoka/commerces.py +142 −32
@@ -1,14 +1,23 @@
1 # ----------------------------------------------------------------------------- 1 # -----------------------------------------------------------------------------
2−# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) 2+# House-Ka — Agrégateur de maisons à vendre (Canada hors Québec)
3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4−# commerces.py : grands commerces à proximité — API Mapbox Search Box 4+# commerces.py : grands commerces à proximité — API Mapbox Search Box,
5+# bannières ADAPTÉES À CHAQUE PROVINCE.
5 # 6 #
6−# Pour chaque grande bannière (Costco, Metro, IGA, Walmart…), on interroge 7+# Pour chaque grande bannière du panier provincial, on interroge l'API
7−# l'API Search Box de Mapbox (jeton PUBLIC pk.… lu dans 8+# Search Box de Mapbox (jeton PUBLIC pk.… lu dans
8−# frontend/src/kamaps/config.ts — source de vérité du projet) avec la 9+# frontend/src/kamaps/config.ts) avec la position de l'annonce en
9−# position de l'annonce en `proximity`, et on retient le point de vente le 10+# `proximity`, et on retient le point de vente le plus proche. Cache par
10−# plus proche. Cache par cellule d'environ 1 km (data/commerces.db, 11+# cellule d'environ 1 km (data/commerces.db, TTL 30 jours).
11−# TTL 30 jours) : les fiches d'un même secteur ne recoûtent rien. 12+#
13+# Paniers par province (2026-08) — fondés sur la présence réelle des
14+# chaînes : Loblaw n°1 national (Superstore/No Frills, Zehrs ON, Dominion NL),
15+# Sobeys n°2 (dominant en Atlantique : Sobeys/Foodland/Lawtons), Metro n°3
16+# (Ontario+Québec, Food Basics), Pattison/Save-On-Foods ~180 magasins dans
17+# l'Ouest + Yukon, Co-op (FCL) ~300 magasins en Saskatchewan, monopoles
18+# d'alcool provinciaux (LCBO, BC Liquor, Liquor Mart MB, NB Liquor, NSLC),
19+# Kent (Irving) en quincaillerie atlantique, London Drugs (BC/AB),
20+# Colemans (chaîne terre-neuvienne).
12 # ----------------------------------------------------------------------------- 21 # -----------------------------------------------------------------------------
13 from __future__ import annotations 22 from __future__ import annotations
14 23
@@ -28,26 +37,126 @@ UA = "HouseKaBot/1.0 (+https://www.house-ka.com; contact@spboucher.ai)"
28 TTL = 30 * 86400 37 TTL = 30 * 86400
29 API = "https://api.mapbox.com/search/searchbox/v1/forward" 38 API = "https://api.mapbox.com/search/searchbox/v1/forward"
30 39
31−# id, libellé, requête Mapbox, mot-clé de validation (le nom du POI doit le 40+# 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é »…) 41+# contenir, sans accents ni casse ; écarte « Station Métro », « Super Qualité »…)
33−BRANDS = [ 42+BRAND_DEFS = {
34− ("costco", "Costco", "Costco Wholesale", "costco"), 43+ # nationaux
35− ("walmart", "Walmart", "Walmart Supercentre", "walmart"), 44+ "costco": ("Costco", "Costco Wholesale", "costco"),
36− ("metro", "Metro", "Metro", "metro"), 45+ "walmart": ("Walmart", "Walmart Supercentre", "walmart"),
37− ("iga", "IGA", "IGA", "iga"), 46+ "canadiantire": ("Canadian Tire", "Canadian Tire", "canadian tire"),
38− ("maxi", "Maxi", "Maxi", "maxi"), 47+ "dollarama": ("Dollarama", "Dollarama", "dollarama"),
39− ("superc", "Super C", "Super C", "super c"), 48+ "homedepot": ("Home Depot", "Home Depot", "home depot"),
40− ("provigo", "Provigo", "Provigo", "provigo"), 49+ "homehardware": ("Home Hardware", "Home Hardware", "home hardware"),
41− ("canadiantire", "Canadian Tire", "Canadian Tire", "canadian tire"), 50+ "shoppers": ("Shoppers Drug Mart", "Shoppers Drug Mart", "shoppers"),
42− ("dollarama", "Dollarama", "Dollarama", "dollarama"), 51+ "gianttiger": ("Giant Tiger", "Giant Tiger", "giant tiger"),
43− ("saq", "SAQ", "SAQ", "saq"), 52+ # épicerie — Loblaw
44− ("pharmaprix", "Pharmaprix", "Pharmaprix", "pharmaprix"), 53+ "loblaws": ("Loblaws", "Loblaws", "loblaws"),
45− ("jeancoutu", "Jean Coutu", "Jean Coutu pharmacie", "jean coutu"), 54+ "superstore": ("Real Canadian Superstore", "Real Canadian Superstore", "superstore"),
46− ("homedepot", "Home Depot", "Home Depot", "home depot"), 55+ "atlanticsuperstore": ("Atlantic Superstore", "Atlantic Superstore", "superstore"),
47− ("rona", "RONA", "RONA", "rona"), 56+ "nofrills": ("No Frills", "No Frills", "no frills"),
48−] 57+ "zehrs": ("Zehrs", "Zehrs", "zehrs"),
49− 58+ "dominion": ("Dominion", "Dominion grocery", "dominion"),
50−_BAN = ("station", "stationnement") 59+ "independent": ("Your Independent Grocer", "Your Independent Grocer", "independent"),
60+ # épicerie — Empire/Sobeys
61+ "sobeys": ("Sobeys", "Sobeys", "sobeys"),
62+ "safeway": ("Safeway", "Safeway", "safeway"),
63+ "foodland": ("Foodland", "Foodland", "foodland"),
64+ "freshco": ("FreshCo", "FreshCo", "freshco"),
65+ # épicerie — Metro (Ontario)
66+ "metro": ("Metro", "Metro grocery", "metro"),
67+ "foodbasics": ("Food Basics", "Food Basics", "food basics"),
68+ # épicerie — Ouest / coopératives / régionales
69+ "saveon": ("Save-On-Foods", "Save-On-Foods", "save-on"),
70+ "coop": ("Co-op", "Co-op Food Store", "co-op"),
71+ "colemans": ("Colemans", "Colemans grocery", "colemans"),
72+ "iga": ("IGA", "IGA", "iga"),
73+ # alcool (monopoles/sociétés provinciales)
74+ "lcbo": ("LCBO", "LCBO", "lcbo"),
75+ "beerstore": ("The Beer Store", "The Beer Store", "beer store"),
76+ "bcliquor": ("BC Liquor", "BC Liquor Store", "liquor"),
77+ "liquormart": ("Liquor Mart", "Manitoba Liquor Mart", "liquor mart"),
78+ "nbliquor": ("NB Liquor", "NB Liquor Alcool NB", "liquor"),
79+ "nslc": ("NSLC", "NSLC", "nslc"),
80+ # pharmacies régionales
81+ "londondrugs": ("London Drugs", "London Drugs", "london drugs"),
82+ "rexall": ("Rexall", "Rexall", "rexall"),
83+ "lawtons": ("Lawtons", "Lawtons Drugs", "lawtons"),
84+ # quincaillerie
85+ "rona": ("RONA", "RONA", "rona"),
86+ "kent": ("Kent", "Kent Building Supplies", "kent"),
87+}
88+
89+_NATIONAL = ["costco", "walmart", "canadiantire", "dollarama", "homedepot",
90+ "shoppers"]
91+
92+# panier par province — les bannières LES PLUS PRÉSENTES dans chaque marché
93+PROVINCE_BRANDS = {
94+ "British Columbia": _NATIONAL + ["saveon", "superstore", "safeway",
95+ "nofrills", "iga", "londondrugs",
96+ "bcliquor", "homehardware"],
97+ "Alberta": _NATIONAL + ["superstore", "safeway", "saveon", "sobeys",
98+ "nofrills", "coop", "rexall", "homehardware"],
99+ "Saskatchewan": _NATIONAL + ["coop", "superstore", "sobeys", "safeway",
100+ "nofrills", "gianttiger", "homehardware"],
101+ "Manitoba": _NATIONAL + ["superstore", "coop", "sobeys", "safeway",
102+ "nofrills", "liquormart", "gianttiger",
103+ "homehardware"],
104+ "Ontario": _NATIONAL + ["loblaws", "nofrills", "foodbasics", "metro",
105+ "sobeys", "freshco", "zehrs", "lcbo", "beerstore",
106+ "rexall", "rona"],
107+ "New Brunswick": _NATIONAL + ["sobeys", "atlanticsuperstore", "foodland",
108+ "nofrills", "nbliquor", "kent", "lawtons",
109+ "gianttiger"],
110+ "Nova Scotia": _NATIONAL + ["sobeys", "atlanticsuperstore", "foodland",
111+ "nofrills", "nslc", "kent", "lawtons",
112+ "gianttiger"],
113+ "Prince Edward Island": _NATIONAL + ["sobeys", "atlanticsuperstore",
114+ "foodland", "kent", "lawtons",
115+ "gianttiger"],
116+ "Newfoundland and Labrador": _NATIONAL + ["sobeys", "dominion", "colemans",
117+ "foodland", "kent", "lawtons"],
118+ "Yukon": _NATIONAL + ["saveon", "superstore", "independent",
119+ "homehardware"],
120+ "Northwest Territories": _NATIONAL + ["independent", "coop",
121+ "homehardware"],
122+ "Nunavut": ["canadiantire", "independent", "coop", "homehardware"],
123+}
124+_DEFAULT_PROVINCE = "Ontario"
125+
126+# frontières longitudinales approximatives (repli quand la région est absente)
127+def _infer_province(lat: float, lng: float) -> str:
128+ if lat >= 60:
129+ if lng < -124:
130+ return "Yukon"
131+ return "Northwest Territories" if lng < -102 else "Nunavut"
132+ if lng < -120:
133+ return "British Columbia"
134+ if lng < -110:
135+ return "Alberta"
136+ if lng < -101.4:
137+ return "Saskatchewan"
138+ if lng < -95.15:
139+ return "Manitoba"
140+ if lng < -74.3:
141+ return "Ontario"
142+ if -64.5 <= lng <= -61.9 and 45.9 <= lat <= 47.1:
143+ return "Prince Edward Island"
144+ if lng >= -59.5 or lat >= 50.5:
145+ return "Newfoundland and Labrador"
146+ if lat < 46.05 or lng > -64.4:
147+ return "Nova Scotia"
148+ return "New Brunswick"
149+
150+
151+def _brands_for(region: str | None, lat: float, lng: float):
152+ prov = (region or "").strip()
153+ if prov not in PROVINCE_BRANDS:
154+ prov = _infer_province(lat, lng)
155+ ids = PROVINCE_BRANDS.get(prov, PROVINCE_BRANDS[_DEFAULT_PROVINCE])
156+ return [(bid, *BRAND_DEFS[bid]) for bid in ids if bid in BRAND_DEFS]
157+
158+_BAN = ("station", "stationnement", "kentucky", "kentville",
159+ "co-operators", "cooperators")
51 160
52 161
53 def _norm(s: str) -> str: 162 def _norm(s: str) -> str:
@@ -227,15 +336,16 @@ def _transit_from_poi(lat: float, lng: float) -> list[tuple[str, dict]]:
227 return out 336 return out
228 337
229 338
230−def nearby(lat: float, lng: float) -> dict: 339+def nearby(lat: float, lng: float, region: str | None = None) -> dict:
231− """Grand commerce le plus proche par bannière (cache ~1 km, TTL 30 j).""" 340+ """Grand commerce le plus proche par bannière PROVINCIALE (cache ~1 km)."""
341+ brands = _brands_for(region, lat, lng)
232 cell = f"{round(lat, 2)},{round(lng, 2)}" 342 cell = f"{round(lat, 2)},{round(lng, 2)}"
233 con = _connect() 343 con = _connect()
234 now = time.time() 344 now = time.time()
235 cached = {r["brand"]: r for r in con.execute( 345 cached = {r["brand"]: r for r in con.execute(
236 "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?", 346 "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
237 (cell, now - TTL))} 347 (cell, now - TTL))}
238− manquants = [(bid, q, m) for bid, _, q, m in BRANDS 348+ manquants = [(bid, q, m) for bid, _, q, m in brands
239 if bid not in cached] 349 if bid not in cached]
240 transit_manquant = any(k not in cached for k, _ in TRANSIT) 350 transit_manquant = any(k not in cached for k, _ in TRANSIT)
241 if manquants or transit_manquant: 351 if manquants or transit_manquant:
@@ -279,7 +389,7 @@ def nearby(lat: float, lng: float) -> dict:
279 "nom": r["nom"], "adresse": "", 389 "nom": r["nom"], "adresse": "",
280 "dist_m": round(d), 390 "dist_m": round(d),
281 "lat": r["lat"], "lng": r["lng"]}) 391 "lat": r["lat"], "lng": r["lng"]})
282− for bid, label, _q, _m in BRANDS: 392+ for bid, label, _q, _m in brands:
283 r = cached.get(bid) 393 r = cached.get(bid)
284 if r is None: 394 if r is None:
285 continue 395 continue
modified immoka/web.py +4 −3
@@ -175,10 +175,11 @@ def list_listings(
175 175
176 176
177 @app.get("/api/commerces") 177 @app.get("/api/commerces")
178−def commerces_at(lat: float, lng: float): 178+def commerces_at(lat: float, lng: float, region: str | None = None):
179− """Grands commerces + métro/bus les plus proches (Mapbox / OSM).""" 179+ """Grands commerces (bannières PAR PROVINCE) + transit les plus proches
180+ (Mapbox / OSM). `region` = province de l'annonce ; sinon inférée du point."""
180 from . import commerces 181 from . import commerces
181− return commerces.nearby(lat, lng) 182+ return commerces.nearby(lat, lng, region=region)
182 183
183 184
184 @app.get("/api/listings/{uid}") 185 @app.get("/api/listings/{uid}")
185 186
Menu

Theme