SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%

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 27 days ago (Aug 27, 2026) parent 852c914

5 changed files +196 −51

modified frontend/src/api.ts +4 −2
@@ -379,5 +379,7 @@ export interface CommercesNearby {
379 379 n: number; commerces: CommerceItem[]; transit?: CommerceItem[];
380 380 }
381 381
382 export const fetchCommerces = (lat: number, lng: number) =>
383 get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`);
382 +export const fetchCommerces = (lat: number, lng: number, region?: string) =>
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 15 rem_station: ["#84BD00", "R"],
16 16 arret_bus: ["#4E5357", "B"],
17 17 gare_train: ["#6E5B3F", "T"],
18 + // national
18 19 costco: ["#005DAA", "C"],
19 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 21 canadiantire: ["#D6001C", "CT"],
26 22 dollarama: ["#00B140", "D", "#FFDD00"],
27 saq: ["#892034", "SAQ"],
28 pharmaprix: ["#E11B22", "Ph"],
29 jeancoutu: ["#003DA5", "JC"],
30 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 60 rona: ["#1B4298", "R"],
61 + kent: ["#006A4D", "K"],
32 62 };
33 63
34 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 }:
51 { lat: number | null; lng: number | null }) {
80 +export default function NearbyPlaces({ lat, lng, region }:
81 + { lat: number | null; lng: number | null; region?: string }) {
52 82 const [d, setD] = useState<CommercesNearby | null>(null);
53 83 useEffect(() => {
54 84 setD(null);
55 85 if (lat == null || lng == null) return;
56 fetchCommerces(lat, lng).then(setD).catch(() => setD(null));
57 }, [lat, lng]);
86 + fetchCommerces(lat, lng, region).then(setD).catch(() => setD(null));
87 + }, [lat, lng, region]);
58 88 if (lat == null || lng == null || !d) return null;
59 89 const all = [...(d.transit ?? []), ...(d.commerces ?? [])];
60 90 if (all.length === 0) return null;
modified frontend/src/pages/Listing.tsx +4 −2
@@ -425,8 +425,10 @@ export default function ListingPage() {
425 425 {/* financing: real mortgage rates + Canadian calculator */}
426 426 {isSale && <Financing price={l.price} />}
427 427
428 {/* nearby amenities: full width, AFTER the property info (correct mobile order) */}
429 <NearbyPlaces lat={l.lat} lng={l.lng} />
428 + {/* nearby amenities: full width, AFTER the property info (correct mobile order) —
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 433 <div className="fine f-foot">
432 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 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 # 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.
7 +# Pour chaque grande bannière du panier provincial, on interroge l'API
8 +# Search Box de Mapbox (jeton PUBLIC pk.… lu dans
9 +# frontend/src/kamaps/config.ts) avec la position de l'annonce en
10 +# `proximity`, et on retient le point de vente le plus proche. Cache par
11 +# cellule d'environ 1 km (data/commerces.db, TTL 30 jours).
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 22 from __future__ import annotations
14 23
@@ -28,26 +37,126 @@ UA = "HouseKaBot/1.0 (+https://www.house-ka.com; contact@spboucher.ai)"
28 37 TTL = 30 * 86400
29 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
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")
40 +# id -> (libellé, requête Mapbox, mot-clé de validation — le nom du POI doit le
41 +# contenir, sans accents ni casse ; écarte « Station Métro », « Super Qualité »…)
42 +BRAND_DEFS = {
43 + # nationaux
44 + "costco": ("Costco", "Costco Wholesale", "costco"),
45 + "walmart": ("Walmart", "Walmart Supercentre", "walmart"),
46 + "canadiantire": ("Canadian Tire", "Canadian Tire", "canadian tire"),
47 + "dollarama": ("Dollarama", "Dollarama", "dollarama"),
48 + "homedepot": ("Home Depot", "Home Depot", "home depot"),
49 + "homehardware": ("Home Hardware", "Home Hardware", "home hardware"),
50 + "shoppers": ("Shoppers Drug Mart", "Shoppers Drug Mart", "shoppers"),
51 + "gianttiger": ("Giant Tiger", "Giant Tiger", "giant tiger"),
52 + # épicerie — Loblaw
53 + "loblaws": ("Loblaws", "Loblaws", "loblaws"),
54 + "superstore": ("Real Canadian Superstore", "Real Canadian Superstore", "superstore"),
55 + "atlanticsuperstore": ("Atlantic Superstore", "Atlantic Superstore", "superstore"),
56 + "nofrills": ("No Frills", "No Frills", "no frills"),
57 + "zehrs": ("Zehrs", "Zehrs", "zehrs"),
58 + "dominion": ("Dominion", "Dominion grocery", "dominion"),
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 162 def _norm(s: str) -> str:
@@ -227,15 +336,16 @@ def _transit_from_poi(lat: float, lng: float) -> list[tuple[str, dict]]:
227 336 return out
228 337
229 338
230 def nearby(lat: float, lng: float) -> dict:
231 """Grand commerce le plus proche par bannière (cache ~1 km, TTL 30 j)."""
339 +def nearby(lat: float, lng: float, region: str | None = None) -> dict:
340 + """Grand commerce le plus proche par bannière PROVINCIALE (cache ~1 km)."""
341 + brands = _brands_for(region, lat, lng)
232 342 cell = f"{round(lat, 2)},{round(lng, 2)}"
233 343 con = _connect()
234 344 now = time.time()
235 345 cached = {r["brand"]: r for r in con.execute(
236 346 "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
237 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 349 if bid not in cached]
240 350 transit_manquant = any(k not in cached for k, _ in TRANSIT)
241 351 if manquants or transit_manquant:
@@ -279,7 +389,7 @@ def nearby(lat: float, lng: float) -> dict:
279 389 "nom": r["nom"], "adresse": "",
280 390 "dist_m": round(d),
281 391 "lat": r["lat"], "lng": r["lng"]})
282 for bid, label, _q, _m in BRANDS:
392 + for bid, label, _q, _m in brands:
283 393 r = cached.get(bid)
284 394 if r is None:
285 395 continue
modified immoka/web.py +4 −3
@@ -175,10 +175,11 @@ def list_listings(
175 175
176 176
177 177 @app.get("/api/commerces")
178 def commerces_at(lat: float, lng: float):
179 """Grands commerces + métro/bus les plus proches (Mapbox / OSM)."""
178 +def commerces_at(lat: float, lng: float, region: str | None = None):
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 181 from . import commerces
181 return commerces.nearby(lat, lng)
182 + return commerces.nearby(lat, lng, region=region)
182 183
183 184
184 185 @app.get("/api/listings/{uid}")
185 186