SPB Git

spb/house-ka

Public

House-Ka v1 — English homes-for-sale aggregator for Canada outside Québec (fork of Immo-Ka)

- Backend: RealtyPress/CREA DDF connectors only (15 ON sources), Canada-wide
  bbox, English canonical property types, relaxed publication rule
  (price+city), English SEO (/property, /for-sale, /type), QC-only modules
  removed (Hydro-Québec, BDZI, RSQAQ, gaz, RDL, quartier, Vrai-Prix,
  movers/inspectors, PDF sheets, QC connectors).
- Frontend: new English skin — pine/cream palette, Fraunces serif display,
  custom footer, Ka Maps themed house-ka, mortgage engine UI in English.
- Data: 202 190 Ontario listings imported from immo-ka (pause-ontario moved
  here), types derived from DDF details, IMMOKA_RP_DETAIL_LIMIT=1200.
Simon-Pierre Boucher committed (Aug 27, 2026) Browse files

91 changed files +20,001 −0

91 changed files
Unified Split
added .claude/settings.local.json +5 −0
@@ -0,0 +1,5 @@
1+{
2+ "enabledMcpjsonServers": [
3+ "cluster"
4+ ]
5+}
added .env.example +27 −0
@@ -0,0 +1,27 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — variables d'environnement (modèle ; copier vers .env, NE PAS COMMITTER .env)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# -----------------------------------------------------------------------------
5+
6+# --- Général ------------------------------------------------------------------
7+IMMOKA_BASE_URL=https://www.immo-ka.com
8+
9+# --- Connecteurs immobiliers (scraping) ----------------------------------------
10+FIRECRAWL_API_KEY=
11+SCRAPFLY_KEY= # anti-bot — aussi utilisé par les providers de taux (dernier recours)
12+HQ_CAPTCHA_KEY=
13+HQ_CAPTCHA_PROVIDER=
14+
15+# --- Compte KA (SSO groupe-ka.com) ---------------------------------------------
16+KA_SSO_SECRET=
17+KA_HUB_URL=
18+AUTH_SECRET=
19+
20+# --- Vrai-Prix -----------------------------------------------------------------
21+VRAIPRIX_DB=
22+
23+# --- Moteur hypothécaire (immoka/mortgage) ---------------------------------------
24+# Aucun taux n'est jamais inventé : ces variables ne règlent que la collecte.
25+IMMOKA_MORTGAGE_INTERVAL_MIN=180 # fréquence de collecte (minutes) — watch/maybe_run
26+IMMOKA_MORTGAGE_RETRIES=3 # tentatives par provider avant échec
27+IMMOKA_MORTGAGE_BACKOFF=5 # backoff exponentiel de base (secondes)
added .gitignore +11 −0
@@ -0,0 +1,11 @@
1+.venv/
2+__pycache__/
3+*.pyc
4+data/*.db
5+data/*.db-shm
6+data/*.db-wal
7+frontend/node_modules/
8+frontend/dist/
9+frontend/tsconfig.tsbuildinfo
10+.env
11+.DS_Store
added README.md +67 −0
@@ -0,0 +1,67 @@
1+# House-Ka
2+
3+**www.house-ka.com** — Homes-for-sale aggregator for Canada **outside Québec**,
4+Ontario first. A Groupe KA service, sister site of
5+[Immo-Ka](https://www.immo-ka.com) (Québec).
6+
7+House-Ka continuously aggregates homes publicly listed by Canadian real-estate
8+brokerages and teams whose sites run the **RealtyPress** WordPress plugin on
9+the **CREA DDF** feed. Each site exposes its board's full inventory; a single
10+generic connector (`immoka/connectors/realtypress.py`) covers them all, and
11+cross-site duplicates are masked by DDF number (`external_id = ddf<id>`).
12+
13+## Architecture
14+
15+Forked from Immo-Ka on 2026-08-27 (the Ontario expansion paused there moved
16+here). The Python package keeps its historical name `immoka`.
17+
18+- **Backend** — FastAPI + SQLite (`data/immoka.db`), same pipeline as Immo-Ka:
19+ connectors → `ingest` → dedup → `quality` (relaxed publication rule: price +
20+ city; type/description enrich over time via detail passes) → API.
21+- **Frontend** — React/Vite, **English**, pine/cream/serif skin (deliberately
22+ different from Immo-Ka's cherry). Routes: `/`, `/property/{uid}[/{slug}]`,
23+ `/for-sale/{city}[/{type}]`, `/type/{type}`, `/rates`, `/agencies`, `/stats`.
24+ Map = Ka Maps (`@groupe-ka/ka-maps`, expected at `../../ka-maps`).
25+- **SEO** — `immoka/seo.py` renders server-side HTML (meta, JSON-LD, sitemaps)
26+ in English.
27+- **Mortgage engine** — shared with Immo-Ka (`immoka/mortgage/`), national
28+ Canadian rates. See `docs/mortgage-engine.md`.
29+- **Removed vs Immo-Ka** — everything Québec-only: Hydro-Québec estimates,
30+ BDZI flood, RSQAQ air, gazquebec, rental registry, quartier (census),
31+ Vrai-Prix, movers/inspectors directories, PDF listing sheets, all QC
32+ connectors.
33+
34+## Canonical property types (English)
35+
36+`House, Condo, Townhouse, Semi-detached, Duplex, Triplex, Multi-family,
37+Cottage, Mobile home, Land, Farm, Commercial, Parking` —
38+see `immoka/normalize.py`. DDF list cards carry no type: most listings get
39+their type when their detail page is fetched (`IMMOKA_RP_DETAIL_LIMIT` per
40+source per sync).
41+
42+## Run
43+
44+```bash
45+python run.py sync [source ...] # sync listings
46+python run.py watch [minutes] # sync loop (default 60 min)
47+python run.py serve [port] # API + frontend (default 8098)
48+python run.py list # registered connectors
49+python run.py geocode [n] # geocode listings missing coordinates
50+python run.py mortgage-sync # collect mortgage rates
51+```
52+
53+`.env`: `IMMOKA_BASE_URL=https://www.house-ka.com`,
54+`IMMOKA_RP_DETAIL_LIMIT=<n>` (detail pages fetched per source per sync).
55+
56+## Deployment
57+
58+M4M64b, `~/apps/house-ka`, PM2 (`house-ka-web` :8098, `house-ka-sync`,
59+`house-ka-ngrok` → www.house-ka.com). Remote-first: the repo on the node is
60+the source of truth, `origin` = spbgit (`gitsrv:house-ka.git`).
61+
62+## Adding sources (rest of Canada)
63+
64+RealtyPress sites exist across Canada. Census & instructions:
65+`docs/ontario-agencies.md` (method transposes to any province). Add the site
66+to `data/ontario_agencies.json` + an entry in `data/sources.json`, then
67+`python run.py sync <id>`. The coordinate guard covers all of Canada.
added data/ontario_agencies.json +96 −0
@@ -0,0 +1,96 @@
1+[
2+ {
3+ "id": "rp_ag_revelrealty",
4+ "name": "Revel Realty (Niagara & provincial)",
5+ "site": "https://revelrealty.ca",
6+ "archive": "listings",
7+ "max_pages": 1300,
8+ "note": "Recensement ON 2026-08-27 : ~110 145 fiches — pool DDF quasi provincial. Archive /listings/ (⚠ /listing/ = carousel 8 cartes), 108 cartes/page avec posts_per_page=100. RealtyPress/DDF."
9+ },
10+ {
11+ "id": "rp_ag_codygroup",
12+ "name": "The Cody Group (London/ITSO)",
13+ "site": "https://codygroup.ca",
14+ "archive": "all-regional-listings",
15+ "max_pages": 700,
16+ "note": "Recensement ON 2026-08-27 : ~58 062 fiches (London + ITSO élargi). Archive /all-regional-listings/, fiches sous le même chemin. RealtyPress/DDF."
17+ },
18+ {
19+ "id": "rp_ag_suttonottawa",
20+ "name": "Sutton Group — Ottawa Realty",
21+ "site": "https://suttonottawa.ca",
22+ "note": "Recensement ON 2026-08-27 : ~10 080 fiches (OREB+). RealtyPress/DDF."
23+ },
24+ {
25+ "id": "rp_ag_helensteam",
26+ "name": "Helen's Team (Kitchener-Waterloo)",
27+ "site": "https://helensteam.ca",
28+ "note": "Recensement ON 2026-08-27 : ~9 910 fiches (Kitchener-Waterloo). RealtyPress/DDF."
29+ },
30+ {
31+ "id": "rp_ag_greybruce",
32+ "name": "Grey Bruce Real Estate",
33+ "site": "https://greybrucerealestate.ca",
34+ "note": "Recensement ON 2026-08-27 : ~8 268 fiches (Grey-Bruce/Georgian Bay). RealtyPress/DDF. Même feed que collaborativerealestate.ca (fallback)."
35+ },
36+ {
37+ "id": "rp_ag_remaxfinest",
38+ "name": "RE/MAX Finest Realty (Kingston)",
39+ "site": "https://remaxfinestrealty.com",
40+ "note": "Recensement ON 2026-08-27 : ~8 217 fiches (Kingston). RealtyPress/DDF."
41+ },
42+ {
43+ "id": "rp_ag_riouxbaker",
44+ "name": "Rioux Baker Real Estate Team (Collingwood)",
45+ "site": "https://riouxbakerteam.com",
46+ "note": "Recensement ON 2026-08-27 : ~7 716 fiches (Collingwood/South Georgian Bay). RealtyPress/DDF."
47+ },
48+ {
49+ "id": "rp_ag_countyguys",
50+ "name": "The County Guys (Prince Edward County)",
51+ "site": "https://thecountyguys.com",
52+ "note": "Recensement ON 2026-08-27 : ~6 878 fiches (Prince Edward County/Quinte). RealtyPress/DDF."
53+ },
54+ {
55+ "id": "rp_ag_labrosse",
56+ "name": "Labrosse Real Estate (Ottawa/Orléans)",
57+ "site": "https://labrosserealestate.com",
58+ "note": "Recensement ON 2026-08-27 : ~6 873 fiches (Ottawa/Orléans, équipe FRANCOPHONE). RealtyPress/DDF."
59+ },
60+ {
61+ "id": "rp_ag_ryanpattinson",
62+ "name": "Ryan Pattinson (Pembroke/Renfrew)",
63+ "site": "https://ryanpattinson.com",
64+ "note": "Recensement ON 2026-08-27 : ~6 601 fiches (Pembroke/vallée de l'Outaouais ON). RealtyPress/DDF."
65+ },
66+ {
67+ "id": "rp_ag_grapevine",
68+ "name": "Grapevine (Ottawa)",
69+ "site": "https://grapevine.ca",
70+ "note": "Recensement ON 2026-08-27 : ~6 550 fiches (Ottawa). RealtyPress/DDF. Suivre les redirections www/apex."
71+ },
72+ {
73+ "id": "rp_ag_rlpheartland",
74+ "name": "Royal LePage Heartland Realty",
75+ "site": "https://rlpheartland.ca",
76+ "note": "Recensement ON 2026-08-27 : ~5 577 fiches (Midwestern Ontario — Huron/Perth). RealtyPress/DDF."
77+ },
78+ {
79+ "id": "rp_ag_signaturenorth",
80+ "name": "Signature North Realty (Thunder Bay)",
81+ "site": "https://signaturenorthrealty.ca",
82+ "note": "Recensement ON 2026-08-27 : ~1 023 fiches (Thunder Bay). RealtyPress/DDF."
83+ },
84+ {
85+ "id": "rp_ag_saultstemarie",
86+ "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
87+ "site": "https://saultstemarierealestate.com",
88+ "note": "Recensement ON 2026-08-27 : ~864 fiches (Sault Ste. Marie). RealtyPress/DDF."
89+ },
90+ {
91+ "id": "rp_ag_cbnorthbay",
92+ "name": "Coldwell Banker Peter Minogue (North Bay)",
93+ "site": "https://cbnorthbay.com",
94+ "note": "Recensement ON 2026-08-27 : ~307 fiches (North Bay). RealtyPress/DDF."
95+ }
96+]
added data/sources.json +169 −0
@@ -0,0 +1,169 @@
1+{
2+ "sources": [
3+ {
4+ "id": "rp_ag_revelrealty",
5+ "name": "Revel Realty (Niagara & provincial)",
6+ "url": "https://revelrealty.ca",
7+ "listing_url": "https://revelrealty.ca/listings/",
8+ "coverage": "Niagara + pool DDF quasi provincial — ~110 000 fiches",
9+ "connector": "realtypress",
10+ "status": "actif",
11+ "type": "agence",
12+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
13+ },
14+ {
15+ "id": "rp_ag_codygroup",
16+ "name": "The Cody Group (London/ITSO)",
17+ "url": "https://codygroup.ca",
18+ "listing_url": "https://codygroup.ca/all-regional-listings/",
19+ "coverage": "London + ITSO élargi — ~58 000 fiches DDF",
20+ "connector": "realtypress",
21+ "status": "actif",
22+ "type": "agence",
23+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
24+ },
25+ {
26+ "id": "rp_ag_suttonottawa",
27+ "name": "Sutton Group — Ottawa Realty",
28+ "url": "https://suttonottawa.ca",
29+ "listing_url": "https://suttonottawa.ca/listing/",
30+ "coverage": "Ottawa et l'Est ontarien (board OREB) — ~10 000 fiches DDF",
31+ "connector": "realtypress",
32+ "status": "actif",
33+ "type": "agence",
34+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
35+ },
36+ {
37+ "id": "rp_ag_helensteam",
38+ "name": "Helen's Team (Kitchener-Waterloo)",
39+ "url": "https://helensteam.ca",
40+ "listing_url": "https://helensteam.ca/listing/",
41+ "coverage": "Kitchener-Waterloo et région (ITSO) — ~9 900 fiches DDF",
42+ "connector": "realtypress",
43+ "status": "actif",
44+ "type": "agence",
45+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
46+ },
47+ {
48+ "id": "rp_ag_greybruce",
49+ "name": "Grey Bruce Real Estate",
50+ "url": "https://greybrucerealestate.ca",
51+ "listing_url": "https://greybrucerealestate.ca/listing/",
52+ "coverage": "Grey-Bruce / Georgian Bay (ITSO) — ~8 300 fiches DDF",
53+ "connector": "realtypress",
54+ "status": "actif",
55+ "type": "agence",
56+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
57+ },
58+ {
59+ "id": "rp_ag_remaxfinest",
60+ "name": "RE/MAX Finest Realty (Kingston)",
61+ "url": "https://remaxfinestrealty.com",
62+ "listing_url": "https://remaxfinestrealty.com/listing/",
63+ "coverage": "Kingston et région (KAREA) — ~8 200 fiches DDF",
64+ "connector": "realtypress",
65+ "status": "actif",
66+ "type": "agence",
67+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
68+ },
69+ {
70+ "id": "rp_ag_riouxbaker",
71+ "name": "Rioux Baker Real Estate Team (Collingwood)",
72+ "url": "https://riouxbakerteam.com",
73+ "listing_url": "https://riouxbakerteam.com/listing/",
74+ "coverage": "Collingwood / South Georgian Bay — ~7 700 fiches DDF",
75+ "connector": "realtypress",
76+ "status": "actif",
77+ "type": "agence",
78+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
79+ },
80+ {
81+ "id": "rp_ag_countyguys",
82+ "name": "The County Guys (Prince Edward County)",
83+ "url": "https://thecountyguys.com",
84+ "listing_url": "https://thecountyguys.com/listing/",
85+ "coverage": "Prince Edward County / Quinte — ~6 900 fiches DDF",
86+ "connector": "realtypress",
87+ "status": "actif",
88+ "type": "agence",
89+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
90+ },
91+ {
92+ "id": "rp_ag_labrosse",
93+ "name": "Labrosse Real Estate (Ottawa/Orléans)",
94+ "url": "https://labrosserealestate.com",
95+ "listing_url": "https://labrosserealestate.com/listing/",
96+ "coverage": "Ottawa / Orléans (équipe francophone, OREB) — ~6 900 fiches DDF",
97+ "connector": "realtypress",
98+ "status": "actif",
99+ "type": "agence",
100+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
101+ },
102+ {
103+ "id": "rp_ag_ryanpattinson",
104+ "name": "Ryan Pattinson (Pembroke/Renfrew)",
105+ "url": "https://ryanpattinson.com",
106+ "listing_url": "https://ryanpattinson.com/listing/",
107+ "coverage": "Pembroke / vallée de l'Outaouais ontarienne — ~6 600 fiches DDF",
108+ "connector": "realtypress",
109+ "status": "actif",
110+ "type": "agence",
111+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
112+ },
113+ {
114+ "id": "rp_ag_grapevine",
115+ "name": "Grapevine (Ottawa)",
116+ "url": "https://grapevine.ca",
117+ "listing_url": "https://grapevine.ca/listing/",
118+ "coverage": "Ottawa (OREB) — ~6 600 fiches DDF",
119+ "connector": "realtypress",
120+ "status": "actif",
121+ "type": "agence",
122+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
123+ },
124+ {
125+ "id": "rp_ag_rlpheartland",
126+ "name": "Royal LePage Heartland Realty",
127+ "url": "https://rlpheartland.ca",
128+ "listing_url": "https://rlpheartland.ca/listing/",
129+ "coverage": "Midwestern Ontario (Huron-Perth) — ~5 600 fiches DDF",
130+ "connector": "realtypress",
131+ "status": "actif",
132+ "type": "agence",
133+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
134+ },
135+ {
136+ "id": "rp_ag_signaturenorth",
137+ "name": "Signature North Realty (Thunder Bay)",
138+ "url": "https://signaturenorthrealty.ca",
139+ "listing_url": "https://signaturenorthrealty.ca/listing/",
140+ "coverage": "Thunder Bay — ~1 000 fiches DDF",
141+ "connector": "realtypress",
142+ "status": "actif",
143+ "type": "agence",
144+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
145+ },
146+ {
147+ "id": "rp_ag_saultstemarie",
148+ "name": "Sault Ste. Marie Real Estate (Century 21 Choice)",
149+ "url": "https://saultstemarierealestate.com",
150+ "listing_url": "https://saultstemarierealestate.com/listing/",
151+ "coverage": "Sault Ste. Marie — ~860 fiches DDF",
152+ "connector": "realtypress",
153+ "status": "actif",
154+ "type": "agence",
155+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
156+ },
157+ {
158+ "id": "rp_ag_cbnorthbay",
159+ "name": "Coldwell Banker Peter Minogue (North Bay)",
160+ "url": "https://cbnorthbay.com",
161+ "listing_url": "https://cbnorthbay.com/listing/",
162+ "coverage": "North Bay / Nipissing — ~300 fiches DDF",
163+ "connector": "realtypress",
164+ "status": "actif",
165+ "type": "agence",
166+ "note": "House-Ka — generic RealtyPress connector (registry data/ontario_agencies.json). WordPress RealtyPress plugin on the CREA DDF feed. external_id ddf<id>: cross-site dedup by MIN(uid)."
167+ }
168+ ]
169+}
\ No newline at end of file
added docs/mortgage-engine.md +163 −0
@@ -0,0 +1,163 @@
1+# Moteur hypothécaire Immo-Ka (Mortgage Intelligence Engine)
2+
3+Moteur natif de collecte, d'historisation et de calcul des taux hypothécaires
4+canadiens, intégré au backend FastAPI d'Immo-Ka. **Aucun taux n'est jamais
5+inventé, codé en dur ni estimé** : tout taux affiché provient d'une page
6+officielle d'une institution financière, avec provenance (URL source) et
7+fraîcheur (horodatage de collecte).
8+
9+## Architecture
10+
11+```
12+immoka/mortgage/
13+├── providers/ # 1 connecteur indépendant par institution (12)
14+│ ├── base.py # RateProvider : fetch() → produits normalisés
15+│ └── README.md # comment ajouter une banque
16+├── validate.py # garde-fous anti-aberration (419 % ≠ 4,19 %…)
17+├── store.py # SQLite annexe data/mortgage.db — historisation
18+├── scheduler.py # orchestration : retries, backoff, santé, isolation
19+├── calc.py # mathématiques hypothécaires canadiennes
20+├── cmhc.py # assurance prêt (SCHL) + taxe de vente QC
21+└── api.py # routes /api/mortgage/* (montées dans web.py)
22+```
23+
24+Le flux : `provider.fetch()` → `validate_batch()` → `store.record_observations()`.
25+Les providers ne touchent jamais la base ; le calculateur ne touche jamais les
26+scrapers — seule l'API interne les relie.
27+
28+## Institutions couvertes (12 connecteurs)
29+
30+| slug | Institution | Type de source |
31+|---|---|---|
32+| `bank_of_canada` | Banque du Canada | Valet API (JSON officiel) — taux de référence |
33+| `bmo` | BMO | JSON embarqué |
34+| `cibc` | CIBC | JSON |
35+| `desjardins` | Desjardins | HTML |
36+| `eq_bank` | Banque EQ | HTML |
37+| `first_national` | First National | HTML |
38+| `mcap` | MCAP | HTML (taux préférentiel) |
39+| `national_bank` | Banque Nationale | HTML |
40+| `rbc` | RBC | JSON |
41+| `scotiabank` | Banque Scotia | JSON (posted + promos) |
42+| `tangerine` | Tangerine | JSON |
43+| `td` | TD Canada Trust | JSON |
44+
45+Chaque produit est normalisé par `RateProvider.make_product()` :
46+`provider, institution, product_name, rate_type (fixed|variable|other),
47+term_months, kind (posted|special), rate, apr, insured_status
48+(insured|insurable|uninsured|unknown), purpose (purchase|renewal|refinance|unknown),
49+amortization_max_years, conditions, source_url, confidence, raw`.
50+
51+Les taux **préférentiels/prime** sont stockés en `rate_type="other"` +
52+`purpose="unknown"` : ils ne peuvent jamais contaminer un classement
53+« meilleur taux d'achat ».
54+
55+## Validation (validate.py)
56+
57+Rejette avant enregistrement :
58+- taux hors bornes plausibles (0,5 %–24 %) — attrape `4.19 → 419` ;
59+- champs requis manquants, enums invalides, termes hors 3–120 mois ;
60+- APR incohérent (APR < taux − 0,02 pt) ou aberrant ;
61+- doublons exacts dans un même lot (silencieusement dédupliqués).
62+
63+Un lot partiellement invalide n'est pas jeté : les produits sains sont
64+enregistrés, les problèmes journalisés.
65+
66+## Historisation (store.py — data/mortgage.db, WAL)
67+
68+- `rate_observations` : périodes de validité (`valid_from`/`valid_to`,
69+ `is_current`). Taux inchangé → simple mise à jour de `last_checked` ;
70+ taux changé → clôture de la période + nouvelle ligne. Un saut > 2,5 pts en
71+ < 48 h est rejeté **sans écraser** la donnée existante (garde anti-aberration
72+ au niveau BD).
73+- `provider_runs` : journal de chaque collecte (statut, durée, produits,
74+ changements, rejets) → santé OK / WARNING (> 24 h) / ERROR.
75+- `product_key` : sha1 tronqué de
76+ `provider|rate_type|term|kind|insured|purpose|name` — identité stable d'un
77+ produit à travers le temps.
78+
79+En cas de panne d'une source, **les derniers taux valides restent servis**,
80+avec leur âge affiché (mention « stale » au-delà de 24 h).
81+
82+## Collecte (scheduler.py)
83+
84+- `run_provider(slug)` : retries (défaut 3) avec backoff exponentiel ;
85+ une exception d'un provider n'affecte jamais les autres.
86+- `run()` : séquentiel et poli (`request_delay` par provider — jamais de
87+ martèlement des sites bancaires).
88+- `watch(min)` : boucle autonome ; `maybe_run()` est appelé depuis la boucle
89+ d'ingestion existante (**process PM2 `immo-ka-sync`**) et ne collecte que si
90+ la dernière passe date de plus de `IMMOKA_MORTGAGE_INTERVAL_MIN` minutes
91+ (défaut 180).
92+
93+CLI :
94+
95+```bash
96+python run.py mortgage-sync [slug…] # collecte (toutes ou certaines banques)
97+python run.py mortgage-watch [min] # boucle autonome
98+python run.py mortgage-status # santé des providers
99+```
100+
101+Variables d'environnement (voir `.env.example`) :
102+`IMMOKA_MORTGAGE_INTERVAL_MIN`, `IMMOKA_MORTGAGE_RETRIES`,
103+`IMMOKA_MORTGAGE_BACKOFF`, `SCRAPFLY_KEY` (anti-bot, dernier recours).
104+
105+## Calculateur canadien (calc.py + cmhc.py)
106+
107+- **Composition semestrielle** pour les taux fixes (norme légale canadienne) :
108+ taux périodique = `(1 + r/2)^(2/f) − 1`. Valeur étalon vérifiée par test :
109+ 100 000 $ à 6 % sur 25 ans = **639,81 $/mois** (≠ 644,30 $ en composition
110+ mensuelle américaine — testé aussi, pour prouver qu'on n'utilise pas la
111+ mauvaise formule). Taux variables : composition mensuelle.
112+- 6 fréquences : mensuelle, bimensuelle, aux 2 semaines, hebdomadaire,
113+ accélérée aux 2 semaines (mensualité ÷ 2), accélérée hebdo (÷ 4).
114+- **Test de résistance** fédéral : qualification à `max(taux + 2, 5,25 %)`.
115+- **SCHL** (cmhc.py) : mise de fonds légale minimale (5 % / 10 % / 20 %),
116+ primes par tranche RPV (0,60 % → 4,00 %), surprime +0,20 % amortissement
117+ 30 ans (premier acheteur), plafond assurable 1,5 M$, **TVQ 9,975 % sur la
118+ prime payable comptant** (spécificité québécoise) — la prime s'ajoute au
119+ prêt, la taxe non.
120+- Tableau d'amortissement, résumé de terme (solde au renouvellement),
121+ scénarios de renouvellement (+0/+1/+2/+3 pts), ratios ABD/ATD informatifs,
122+ inverses (prêt max pour un versement, taux requis).
123+
124+## API interne (`/api/mortgage/*`)
125+
126+| Route | Rôle |
127+|---|---|
128+| `GET /rates` | taux courants filtrables (type, terme, kind, provider…) |
129+| `GET /rates/best` | meilleur taux comparable + classement par institution |
130+| `GET /rates/history` | périodes de validité (historique réel, jamais extrapolé) |
131+| `GET /providers` | santé des sources (OK/WARNING/ERROR, âge, produits) |
132+| `GET /market` | vue marché (meilleur/médiane/variations 7-30 j) |
133+| `GET /intelligence` | market + taux préférentiels (page /taux-hypothecaires) |
134+| `POST /calculate` | calcul complet (SCHL, stress, terme, renouvellement…) |
135+| `POST /affordability` | capacité d'emprunt (ABD/ATD + stress test) |
136+
137+Règle absolue : **jamais de comparaison de produits incomparables** — affiché
138+vs offre spéciale, assuré vs non assuré — sans l'indiquer. Le comparateur ne
139+garde qu'un produit comparable par institution (l'offre spéciale prime).
140+
141+## Frontend
142+
143+- **Fiche propriété** (`Financement.tsx`, section « Financer cette propriété »,
144+ ventes seulement) : prix prérempli, mise de fonds $/% synchronisée,
145+ versement + taux utilisé avec provenance/fraîcheur, SCHL détaillée,
146+ coût réel mensuel (+ taxes municipales/scolaires de la fiche), stress test,
147+ renouvellement, comparateur banques, historique SVG, amortissement.
148+- **Page `/taux-hypothecaires`** (`Taux.tsx`) : vue marché cliquable,
149+ comparateur par institution (nature + fraîcheur + source officielle),
150+ historique, santé des sources. Référencée (seo.py + sitemap).
151+
152+## Tests
153+
154+```bash
155+PYTHONPATH=. .venv/bin/python -P -m unittest discover -s tests
156+```
157+
158+61 tests : `test_mortgage_calc.py` (valeurs étalons, fréquences accélérées,
159+inverses, stress), `test_mortgage_cmhc.py` (primes, TVQ, éligibilité),
160+`test_mortgage_validate.py` (anti-aberration), `test_mortgage_store.py`
161+(historisation, garde 2,5 pts, meilleur taux), `test_mortgage_providers.py`
162+(chaque parseur sur fixtures HTML/JSON committées dans
163+`tests/fixtures/mortgage/` — aucun réseau).
added docs/ontario-agencies.md +87 −0
@@ -0,0 +1,87 @@
1+# Extension Ontario — liste des agences & sous-agents connectables
2+
3+Recensement du 2026-08-27 (~150 sites vérifiés live par curl : plateforme, rendu, volume, code HTTP).
4+Doctrine immo-ka : connecteurs par site d'agence/équipe (éviter les portails durs type realtor.ca).
5+Constat structurel ON : contrairement au Québec, un site d'agence expose souvent l'**IDX/DDF complet de son board** (TRREB, OREB, RAHB, ITSO, LSTAR, WECAR, KAREA…) → quelques connecteurs bien choisis ≈ toute la province. Dédupliquer par **MLS#**.
6+
7+## Priorité 1 — RealtyPress (plugin WordPress branché CREA DDF)
8+
9+Signature : `wp-content/plugins/realtypress-premium`, archive `/listing/` ou `/listings/`, SSR paginé `?paged=N`, MLS# dans l'URL de détail. **Un connecteur générique = tous ces sites.** Aucun anti-bot (200 en curl nu).
10+
11+| Site | Zone | Volume vérifié |
12+|---|---|---|
13+| revelrealty.ca | Niagara (multi-bureaux) | **110 171** — pool DDF quasi complet ⭐ |
14+| codygroup.ca | London | **58 074** ⭐ |
15+| suttonottawa.ca | Ottawa | 10 080 (OREB+) |
16+| helensteam.ca | Kitchener-Waterloo | 9 910 |
17+| greybrucerealestate.ca | Grey-Bruce/Georgian Bay | 8 268 |
18+| collaborativerealestate.ca | Blue Mountains | 8 268 (même feed que Grey-Bruce → fallback) |
19+| remaxfinestrealty.com | Kingston | 8 217 |
20+| riouxbakerteam.com | Collingwood | 7 716 |
21+| thecountyguys.com | Prince Edward County | 6 878 |
22+| labrosserealestate.com | Ottawa/Orléans (**francophone**) | 6 873 |
23+| ryanpattinson.com | Pembroke/Renfrew | 6 601 |
24+| grapevine.ca | Ottawa | 6 550 |
25+| rlpheartland.ca | Midwestern Ontario | 5 577 |
26+| pennyblake.com / dynamickingston.com / thehintonteam.com | Kingston | 1 760 / 1 756 / 1 666 |
27+| signaturenorthrealty.ca | Thunder Bay | 1 023 |
28+| saultstemarierealestate.com | Sault Ste Marie | 864 |
29+| soldsmart.ca | Cornwall (CDREB) | 682 |
30+| 401homes.ca | corridor 401 | 662 |
31+| tcrealty.ca | Thunder Bay | 564 |
32+| cbnorthbay.com | North Bay | 307 |
33+| thebrollygroup.ca / teamkate.ca | Brantford | 231+ / régional |
34+| boldtrealty.ca | St. Catharines | bureau |
35+| Autres confirmés | — | thewillsteam.ca, dyerrealty.ca, muskoka-realestate.ca, barriehome.net, morrishometeam.com, dalebryant.ca, kenpipher.ca, greatermuskoka.ca, claimpostrealty.com, paulrushforth.com, liamswords.com |
36+
37+→ Couverture : Niagara, London, Ottawa, KW, Kingston, Georgian Bay, PEC, Nord — **quasi toute la province avec un seul parseur**. Démo publique : demo.realtypress.ca.
38+
39+## Priorité 2 — myRealPage « recip.html » (réciprocité TRREB complète)
40+
41+SSR paginé `?_pg=N` (~12 annonces/page, prix dans le HTML), hôte central `idx.myrealpage.com`. Un connecteur = toute la grappe.
42+
43+- goldenhouserealty.com, remaxpluscity.com, foresthillcentral.com, baystreetcondos.ca, topgan.ca (Toronto — TRREB complet)
44+- jarrodarmstrong.com (condos TRREB), condominiums.ca (IDX condos GTA), yourmarkhamrealestate.ca (76 prix SSR)
45+- dottedline.ca (Tillsonburg, SSR 112) ; variantes JS : detailsrealty.ca, chellteam.com, maguireteam.ca, powerofsaleplus.ca
46+
47+## Priorité 3 — Sierra Interactive (SSR, API JSON connue)
48+
49+- robgolfi.com — RE/MAX Escarpment : **2 678 (RAHB complet)** + 924 Oakville ⭐
50+- weknowottawa.com (Hamre, Ottawa 634+), feelyrealestate.com (Ottawa), niagarahomes.com (McGarr, 474 St. Catharines), teamgoran.com (Windsor/Chatham), viewbrantfordhomes.com, goodmanors.ca (Sudbury), anuraghomes.ca (KW)
51+
52+## Priorité 4 — AgentLocator (plateforme ontarienne, SSR + JSON `TotalIDX/TotalVOW` embarqué)
53+
54+- daverealty.ca (Cambridge, 1 272 IDX ITSO), ateamlondon.ca (London LSTAR), kitchenerwaterloo-realestate.com, stjeanrealty.com (Hamilton RAHB)
55+- Grappe Durham/York : miragerealestate.ca (103 prix), lighthouserealtygroup.ca (96), shawnlepp.com, buyselllove.ca, itsanna.ca, realtorsunnyg.com, teamarora.com
56+- jancsiks.com (Kawartha), pairofkings.ca (Dufferin) ; JS-only : seguinrealtyltd.com (**franco Hawkesbury**), agentinottawa.com
57+
58+## Priorité 5 — autres grappes mutualisées
59+
60+- **Plateforme Windsor commune** (custom SSR, URLs `/ville-properties` identiques) : buckinghamrealty.ca (**4 943 = WECAR complet + Chatham-Kent**), deerbrookproperty.com, nkrealestate.ca — 1 connecteur = 3 sites, zone mal desservie ailleurs ⭐
61+- **Real Estate Webmasters (REW)** : danplowman.com (Whitby, 529 prix SSR, TRREB large) ⭐, londonontariorealestate.com (Team Forster ~2 000 LSTAR), gordwaites.com + rlpmuskoka.com (Muskoka, cap 500), sudburyrealestate.ca
62+- **EZ Media** (vendeur régional, WP SSR) : performancerealty.ca (RLP Ottawa ~700 agents), remaxdeltahometeam.com (**franco Embrun/Prescott-Russell**), brockvillesutton.com, briangraham.ca (North Bay)
63+- **InCom** (JS — API centrale `/mapsearchapp/search?json=true` à reverser une fois) : chestnutpark.com, foresthill.com, dhesirealestate.ca, royallepagepremiumone.com, jasonyuteam.com, cbadvantage.ca, niagarapropertygroup.ca, suttongroupinnovative.com, kwhomegrouprealty.ca, brockvillehomes.com
64+- **Luxury Presence** (MLS TRREB dans les assets) : mcdadi.com (350+), goodalemillerteam.com, ppreteam.com, harveykalles.com, psrbrokerage.com, muskokacottagelistings.com, muskokacottagesforsale.com, lakelandsrealestate.ca
65+- **SoldPress / Team Marshall** (WP SSR, 1 parseur = 4 domaines) : teammarshall.ca, findingyourmuskoka.ca, findingyourparrysound.ca, findingyourmagnetawan.ca
66+- **c21.ca corporate** (API avec `company_uuid` — 1 connecteur = tous les bureaux C21 Canada) : heritagehouseniagara.c21.ca, c21firstcanadian.c21.ca, c21bluesky.com…
67+
68+## Sites custom individuels (SSR, un connecteur dédié chacun si ROI)
69+
70+- bushrealtysystems.com (Hamilton — moteur de recherche CREA board complet, `/search/listing/CREA/<mls>`)
71+- remaxquinte.com (Belleville, ColdFusion `listings.cfm`), thegrimeteam.com (Orangeville, ColdFusion, board interrogeable par params URL)
72+- exitrealtymatrix.com (Ottawa/Embrun franco-friendly, Onjax), therealtyfirm.ca (London/Woodstock, RealtyNinja SSR), m1wellington.com (Guelph, IDX Broker)
73+- trilliumwest.com (Guelph, 58 MLS refs), woolcott.ca (Waterdown DDF ~100+), peggyhill.com (Barrie, #1 RE/MAX Canada), tarteam.com (Markham, 31 prix), stacyvermeire.com (Cobourg, Ubertor SSR), troyausten.ca (Haliburton), homesincambridge.com (~108)
74+- JS-only à reverser si besoin : rightathomerealty.com (plus gros courtier indépendant du Canada — XHR probable Repliers/AMPRE), mls-sarnia.com + windsorrealestate.com (`__NEXT_DATA__`), phinneyrealestate.com, heyray.ca, elevatelondon.ca, housesforsaleottawa.ca, ngroup.ca (Kingston), kbbrokerage.ca, kawarthalife.com
75+
76+## Bloqués (403 Cloudflare — Scrapfly requis, faible priorité)
77+
78+teamrealty.ca, royalcity.com, royallepagetriland.com, wollerealty.com, remaxrecentre.ca, rlpbinder.ca, royallepagebrantrealty.com, viewhomes.ca, royallepagethunderbay.com, kormendytrott.com, rlpburloak.ca, remax-gc.ca, teamprestige.ca, estaterealty.ca, mullingroup.ca, royallepagequest.ca, faristeam.ca (429). Plusieurs partagent le même vendor (template RLP) → potentiel méga-connecteur via Scrapfly.
79+
80+## Plan de couverture minimal suggéré
81+
82+1. Connecteur **RealtyPress générique** + revelrealty/codygroup en sources primaires (≈ pool DDF provincial), sites régionaux en secours/dédup
83+2. Connecteur **myRealPage recip** (TRREB/GTA complet)
84+3. Connecteur **Sierra** (robgolfi = RAHB ; weknowottawa = OREB)
85+4. Connecteur **AgentLocator** (Durham/York + ITSO/LSTAR)
86+5. Connecteur **Windsor mutualisé** (WECAR) + **REW** (danplowman/Team Forster)
87+→ ~6-7 parseurs pour une couverture provinciale quasi complète, boards dédupliqués par MLS#.
added frontend/index.html +31 −0
@@ -0,0 +1,31 @@
1+<!doctype html>
2+<!-- ---------------------------------------------------------------------------
3+ House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
4+ Author: Simon-Pierre Boucher — contact@spboucher.ai
5+---------------------------------------------------------------------------- -->
6+<html lang="en-CA">
7+ <head>
8+ <meta charset="UTF-8" />
9+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10+ <title>House-Ka — A Groupe KA service</title>
11+ <meta name="description" content="House-Ka, a Groupe KA service, aggregates homes for sale listed by Canadian real-estate brokerages on the CREA DDF feed — Ontario first, the rest of Canada next. Always up to date." />
12+ <meta name="theme-color" content="#14201a" />
13+ <meta name="mobile-web-app-capable" content="yes" />
14+ <meta name="apple-mobile-web-app-capable" content="yes" />
15+ <meta name="apple-mobile-web-app-title" content="House-Ka" />
16+ <link rel="preconnect" href="https://fonts.googleapis.com" />
17+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
19+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
20+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
21+ <meta property="og:image" content="https://www.house-ka.com/og.png" />
22+ <meta property="og:image:width" content="1200" />
23+ <meta property="og:image:height" content="630" />
24+ <meta name="twitter:card" content="summary_large_image" />
25+ <meta name="twitter:image" content="https://www.house-ka.com/og.png" />
26+ </head>
27+ <body>
28+ <div id="root"></div>
29+ <script type="module" src="/src/main.tsx"></script>
30+ </body>
31+</html>
added frontend/package-lock.json +1874 −0
@@ -0,0 +1,1874 @@
1+{
2+ "name": "house-ka-frontend",
3+ "version": "1.0.0",
4+ "lockfileVersion": 3,
5+ "requires": true,
6+ "packages": {
7+ "": {
8+ "name": "house-ka-frontend",
9+ "version": "1.0.0",
10+ "dependencies": {
11+ "@groupe-ka/ka-maps": "file:../../ka-maps",
12+ "mapbox-gl": "^3.28.1",
13+ "react": "^18.3.1",
14+ "react-dom": "^18.3.1",
15+ "react-router-dom": "^6.26.0"
16+ },
17+ "devDependencies": {
18+ "@types/react": "^18.3.3",
19+ "@types/react-dom": "^18.3.0",
20+ "@vitejs/plugin-react": "^4.3.1",
21+ "typescript": "^5.5.4",
22+ "vite": "^5.4.0"
23+ }
24+ },
25+ "../../ka-maps": {
26+ "name": "@groupe-ka/ka-maps",
27+ "version": "0.2.0",
28+ "license": "UNLICENSED",
29+ "dependencies": {
30+ "@types/geojson": "^7946.0.16",
31+ "mapbox-gl": "^3.28.1"
32+ },
33+ "devDependencies": {
34+ "@types/react": "^18.3.3",
35+ "typescript": "^5.8.0",
36+ "vitest": "^3.0.0"
37+ },
38+ "peerDependencies": {
39+ "react": ">=18"
40+ },
41+ "peerDependenciesMeta": {
42+ "react": {
43+ "optional": true
44+ }
45+ }
46+ },
47+ "node_modules/@babel/code-frame": {
48+ "version": "7.29.7",
49+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
50+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
51+ "dev": true,
52+ "license": "MIT",
53+ "dependencies": {
54+ "@babel/helper-validator-identifier": "^7.29.7",
55+ "js-tokens": "^4.0.0",
56+ "picocolors": "^1.1.1"
57+ },
58+ "engines": {
59+ "node": ">=6.9.0"
60+ }
61+ },
62+ "node_modules/@babel/compat-data": {
63+ "version": "7.29.7",
64+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
65+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
66+ "dev": true,
67+ "license": "MIT",
68+ "engines": {
69+ "node": ">=6.9.0"
70+ }
71+ },
72+ "node_modules/@babel/core": {
73+ "version": "7.29.7",
74+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
75+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
76+ "dev": true,
77+ "license": "MIT",
78+ "dependencies": {
79+ "@babel/code-frame": "^7.29.7",
80+ "@babel/generator": "^7.29.7",
81+ "@babel/helper-compilation-targets": "^7.29.7",
82+ "@babel/helper-module-transforms": "^7.29.7",
83+ "@babel/helpers": "^7.29.7",
84+ "@babel/parser": "^7.29.7",
85+ "@babel/template": "^7.29.7",
86+ "@babel/traverse": "^7.29.7",
87+ "@babel/types": "^7.29.7",
88+ "@jridgewell/remapping": "^2.3.5",
89+ "convert-source-map": "^2.0.0",
90+ "debug": "^4.1.0",
91+ "gensync": "^1.0.0-beta.2",
92+ "json5": "^2.2.3",
93+ "semver": "^6.3.1"
94+ },
95+ "engines": {
96+ "node": ">=6.9.0"
97+ },
98+ "funding": {
99+ "type": "opencollective",
100+ "url": "https://opencollective.com/babel"
101+ }
102+ },
103+ "node_modules/@babel/generator": {
104+ "version": "7.29.8",
105+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
106+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
107+ "dev": true,
108+ "license": "MIT",
109+ "dependencies": {
110+ "@babel/parser": "^7.29.8",
111+ "@babel/types": "^7.29.8",
112+ "@jridgewell/gen-mapping": "^0.3.12",
113+ "@jridgewell/trace-mapping": "^0.3.28",
114+ "jsesc": "^3.0.2"
115+ },
116+ "engines": {
117+ "node": ">=6.9.0"
118+ }
119+ },
120+ "node_modules/@babel/helper-compilation-targets": {
121+ "version": "7.29.7",
122+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
123+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
124+ "dev": true,
125+ "license": "MIT",
126+ "dependencies": {
127+ "@babel/compat-data": "^7.29.7",
128+ "@babel/helper-validator-option": "^7.29.7",
129+ "browserslist": "^4.24.0",
130+ "lru-cache": "^5.1.1",
131+ "semver": "^6.3.1"
132+ },
133+ "engines": {
134+ "node": ">=6.9.0"
135+ }
136+ },
137+ "node_modules/@babel/helper-globals": {
138+ "version": "7.29.7",
139+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
140+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
141+ "dev": true,
142+ "license": "MIT",
143+ "engines": {
144+ "node": ">=6.9.0"
145+ }
146+ },
147+ "node_modules/@babel/helper-module-imports": {
148+ "version": "7.29.7",
149+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
150+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
151+ "dev": true,
152+ "license": "MIT",
153+ "dependencies": {
154+ "@babel/traverse": "^7.29.7",
155+ "@babel/types": "^7.29.7"
156+ },
157+ "engines": {
158+ "node": ">=6.9.0"
159+ }
160+ },
161+ "node_modules/@babel/helper-module-transforms": {
162+ "version": "7.29.7",
163+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
164+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
165+ "dev": true,
166+ "license": "MIT",
167+ "dependencies": {
168+ "@babel/helper-module-imports": "^7.29.7",
169+ "@babel/helper-validator-identifier": "^7.29.7",
170+ "@babel/traverse": "^7.29.7"
171+ },
172+ "engines": {
173+ "node": ">=6.9.0"
174+ },
175+ "peerDependencies": {
176+ "@babel/core": "^7.0.0"
177+ }
178+ },
179+ "node_modules/@babel/helper-plugin-utils": {
180+ "version": "7.29.7",
181+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
182+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
183+ "dev": true,
184+ "license": "MIT",
185+ "engines": {
186+ "node": ">=6.9.0"
187+ }
188+ },
189+ "node_modules/@babel/helper-string-parser": {
190+ "version": "7.29.7",
191+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
192+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
193+ "dev": true,
194+ "license": "MIT",
195+ "engines": {
196+ "node": ">=6.9.0"
197+ }
198+ },
199+ "node_modules/@babel/helper-validator-identifier": {
200+ "version": "7.29.7",
201+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
202+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
203+ "dev": true,
204+ "license": "MIT",
205+ "engines": {
206+ "node": ">=6.9.0"
207+ }
208+ },
209+ "node_modules/@babel/helper-validator-option": {
210+ "version": "7.29.7",
211+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
212+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
213+ "dev": true,
214+ "license": "MIT",
215+ "engines": {
216+ "node": ">=6.9.0"
217+ }
218+ },
219+ "node_modules/@babel/helpers": {
220+ "version": "7.29.7",
221+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
222+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
223+ "dev": true,
224+ "license": "MIT",
225+ "dependencies": {
226+ "@babel/template": "^7.29.7",
227+ "@babel/types": "^7.29.7"
228+ },
229+ "engines": {
230+ "node": ">=6.9.0"
231+ }
232+ },
233+ "node_modules/@babel/parser": {
234+ "version": "7.29.8",
235+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
236+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
237+ "dev": true,
238+ "license": "MIT",
239+ "dependencies": {
240+ "@babel/types": "^7.29.8"
241+ },
242+ "bin": {
243+ "parser": "bin/babel-parser.js"
244+ },
245+ "engines": {
246+ "node": ">=6.0.0"
247+ }
248+ },
249+ "node_modules/@babel/plugin-transform-react-jsx-self": {
250+ "version": "7.29.7",
251+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
252+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
253+ "dev": true,
254+ "license": "MIT",
255+ "dependencies": {
256+ "@babel/helper-plugin-utils": "^7.29.7"
257+ },
258+ "engines": {
259+ "node": ">=6.9.0"
260+ },
261+ "peerDependencies": {
262+ "@babel/core": "^7.0.0-0"
263+ }
264+ },
265+ "node_modules/@babel/plugin-transform-react-jsx-source": {
266+ "version": "7.29.7",
267+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
268+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
269+ "dev": true,
270+ "license": "MIT",
271+ "dependencies": {
272+ "@babel/helper-plugin-utils": "^7.29.7"
273+ },
274+ "engines": {
275+ "node": ">=6.9.0"
276+ },
277+ "peerDependencies": {
278+ "@babel/core": "^7.0.0-0"
279+ }
280+ },
281+ "node_modules/@babel/template": {
282+ "version": "7.29.7",
283+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
284+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
285+ "dev": true,
286+ "license": "MIT",
287+ "dependencies": {
288+ "@babel/code-frame": "^7.29.7",
289+ "@babel/parser": "^7.29.7",
290+ "@babel/types": "^7.29.7"
291+ },
292+ "engines": {
293+ "node": ">=6.9.0"
294+ }
295+ },
296+ "node_modules/@babel/traverse": {
297+ "version": "7.29.8",
298+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
299+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
300+ "dev": true,
301+ "license": "MIT",
302+ "dependencies": {
303+ "@babel/code-frame": "^7.29.7",
304+ "@babel/generator": "^7.29.8",
305+ "@babel/helper-globals": "^7.29.7",
306+ "@babel/parser": "^7.29.8",
307+ "@babel/template": "^7.29.7",
308+ "@babel/types": "^7.29.8",
309+ "debug": "^4.3.1"
310+ },
311+ "engines": {
312+ "node": ">=6.9.0"
313+ }
314+ },
315+ "node_modules/@babel/types": {
316+ "version": "7.29.8",
317+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
318+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
319+ "dev": true,
320+ "license": "MIT",
321+ "dependencies": {
322+ "@babel/helper-string-parser": "^7.29.7",
323+ "@babel/helper-validator-identifier": "^7.29.7"
324+ },
325+ "engines": {
326+ "node": ">=6.9.0"
327+ }
328+ },
329+ "node_modules/@esbuild/aix-ppc64": {
330+ "version": "0.21.5",
331+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
332+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
333+ "cpu": [
334+ "ppc64"
335+ ],
336+ "dev": true,
337+ "license": "MIT",
338+ "optional": true,
339+ "os": [
340+ "aix"
341+ ],
342+ "engines": {
343+ "node": ">=12"
344+ }
345+ },
346+ "node_modules/@esbuild/android-arm": {
347+ "version": "0.21.5",
348+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
349+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
350+ "cpu": [
351+ "arm"
352+ ],
353+ "dev": true,
354+ "license": "MIT",
355+ "optional": true,
356+ "os": [
357+ "android"
358+ ],
359+ "engines": {
360+ "node": ">=12"
361+ }
362+ },
363+ "node_modules/@esbuild/android-arm64": {
364+ "version": "0.21.5",
365+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
366+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
367+ "cpu": [
368+ "arm64"
369+ ],
370+ "dev": true,
371+ "license": "MIT",
372+ "optional": true,
373+ "os": [
374+ "android"
375+ ],
376+ "engines": {
377+ "node": ">=12"
378+ }
379+ },
380+ "node_modules/@esbuild/android-x64": {
381+ "version": "0.21.5",
382+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
383+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
384+ "cpu": [
385+ "x64"
386+ ],
387+ "dev": true,
388+ "license": "MIT",
389+ "optional": true,
390+ "os": [
391+ "android"
392+ ],
393+ "engines": {
394+ "node": ">=12"
395+ }
396+ },
397+ "node_modules/@esbuild/darwin-arm64": {
398+ "version": "0.21.5",
399+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
400+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
401+ "cpu": [
402+ "arm64"
403+ ],
404+ "dev": true,
405+ "license": "MIT",
406+ "optional": true,
407+ "os": [
408+ "darwin"
409+ ],
410+ "engines": {
411+ "node": ">=12"
412+ }
413+ },
414+ "node_modules/@esbuild/darwin-x64": {
415+ "version": "0.21.5",
416+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
417+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
418+ "cpu": [
419+ "x64"
420+ ],
421+ "dev": true,
422+ "license": "MIT",
423+ "optional": true,
424+ "os": [
425+ "darwin"
426+ ],
427+ "engines": {
428+ "node": ">=12"
429+ }
430+ },
431+ "node_modules/@esbuild/freebsd-arm64": {
432+ "version": "0.21.5",
433+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
434+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
435+ "cpu": [
436+ "arm64"
437+ ],
438+ "dev": true,
439+ "license": "MIT",
440+ "optional": true,
441+ "os": [
442+ "freebsd"
443+ ],
444+ "engines": {
445+ "node": ">=12"
446+ }
447+ },
448+ "node_modules/@esbuild/freebsd-x64": {
449+ "version": "0.21.5",
450+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
451+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
452+ "cpu": [
453+ "x64"
454+ ],
455+ "dev": true,
456+ "license": "MIT",
457+ "optional": true,
458+ "os": [
459+ "freebsd"
460+ ],
461+ "engines": {
462+ "node": ">=12"
463+ }
464+ },
465+ "node_modules/@esbuild/linux-arm": {
466+ "version": "0.21.5",
467+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
468+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
469+ "cpu": [
470+ "arm"
471+ ],
472+ "dev": true,
473+ "license": "MIT",
474+ "optional": true,
475+ "os": [
476+ "linux"
477+ ],
478+ "engines": {
479+ "node": ">=12"
480+ }
481+ },
482+ "node_modules/@esbuild/linux-arm64": {
483+ "version": "0.21.5",
484+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
485+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
486+ "cpu": [
487+ "arm64"
488+ ],
489+ "dev": true,
490+ "license": "MIT",
491+ "optional": true,
492+ "os": [
493+ "linux"
494+ ],
495+ "engines": {
496+ "node": ">=12"
497+ }
498+ },
499+ "node_modules/@esbuild/linux-ia32": {
500+ "version": "0.21.5",
501+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
502+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
503+ "cpu": [
504+ "ia32"
505+ ],
506+ "dev": true,
507+ "license": "MIT",
508+ "optional": true,
509+ "os": [
510+ "linux"
511+ ],
512+ "engines": {
513+ "node": ">=12"
514+ }
515+ },
516+ "node_modules/@esbuild/linux-loong64": {
517+ "version": "0.21.5",
518+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
519+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
520+ "cpu": [
521+ "loong64"
522+ ],
523+ "dev": true,
524+ "license": "MIT",
525+ "optional": true,
526+ "os": [
527+ "linux"
528+ ],
529+ "engines": {
530+ "node": ">=12"
531+ }
532+ },
533+ "node_modules/@esbuild/linux-mips64el": {
534+ "version": "0.21.5",
535+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
536+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
537+ "cpu": [
538+ "mips64el"
539+ ],
540+ "dev": true,
541+ "license": "MIT",
542+ "optional": true,
543+ "os": [
544+ "linux"
545+ ],
546+ "engines": {
547+ "node": ">=12"
548+ }
549+ },
550+ "node_modules/@esbuild/linux-ppc64": {
551+ "version": "0.21.5",
552+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
553+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
554+ "cpu": [
555+ "ppc64"
556+ ],
557+ "dev": true,
558+ "license": "MIT",
559+ "optional": true,
560+ "os": [
561+ "linux"
562+ ],
563+ "engines": {
564+ "node": ">=12"
565+ }
566+ },
567+ "node_modules/@esbuild/linux-riscv64": {
568+ "version": "0.21.5",
569+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
570+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
571+ "cpu": [
572+ "riscv64"
573+ ],
574+ "dev": true,
575+ "license": "MIT",
576+ "optional": true,
577+ "os": [
578+ "linux"
579+ ],
580+ "engines": {
581+ "node": ">=12"
582+ }
583+ },
584+ "node_modules/@esbuild/linux-s390x": {
585+ "version": "0.21.5",
586+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
587+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
588+ "cpu": [
589+ "s390x"
590+ ],
591+ "dev": true,
592+ "license": "MIT",
593+ "optional": true,
594+ "os": [
595+ "linux"
596+ ],
597+ "engines": {
598+ "node": ">=12"
599+ }
600+ },
601+ "node_modules/@esbuild/linux-x64": {
602+ "version": "0.21.5",
603+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
604+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
605+ "cpu": [
606+ "x64"
607+ ],
608+ "dev": true,
609+ "license": "MIT",
610+ "optional": true,
611+ "os": [
612+ "linux"
613+ ],
614+ "engines": {
615+ "node": ">=12"
616+ }
617+ },
618+ "node_modules/@esbuild/netbsd-x64": {
619+ "version": "0.21.5",
620+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
621+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
622+ "cpu": [
623+ "x64"
624+ ],
625+ "dev": true,
626+ "license": "MIT",
627+ "optional": true,
628+ "os": [
629+ "netbsd"
630+ ],
631+ "engines": {
632+ "node": ">=12"
633+ }
634+ },
635+ "node_modules/@esbuild/openbsd-x64": {
636+ "version": "0.21.5",
637+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
638+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
639+ "cpu": [
640+ "x64"
641+ ],
642+ "dev": true,
643+ "license": "MIT",
644+ "optional": true,
645+ "os": [
646+ "openbsd"
647+ ],
648+ "engines": {
649+ "node": ">=12"
650+ }
651+ },
652+ "node_modules/@esbuild/sunos-x64": {
653+ "version": "0.21.5",
654+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
655+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
656+ "cpu": [
657+ "x64"
658+ ],
659+ "dev": true,
660+ "license": "MIT",
661+ "optional": true,
662+ "os": [
663+ "sunos"
664+ ],
665+ "engines": {
666+ "node": ">=12"
667+ }
668+ },
669+ "node_modules/@esbuild/win32-arm64": {
670+ "version": "0.21.5",
671+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
672+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
673+ "cpu": [
674+ "arm64"
675+ ],
676+ "dev": true,
677+ "license": "MIT",
678+ "optional": true,
679+ "os": [
680+ "win32"
681+ ],
682+ "engines": {
683+ "node": ">=12"
684+ }
685+ },
686+ "node_modules/@esbuild/win32-ia32": {
687+ "version": "0.21.5",
688+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
689+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
690+ "cpu": [
691+ "ia32"
692+ ],
693+ "dev": true,
694+ "license": "MIT",
695+ "optional": true,
696+ "os": [
697+ "win32"
698+ ],
699+ "engines": {
700+ "node": ">=12"
701+ }
702+ },
703+ "node_modules/@esbuild/win32-x64": {
704+ "version": "0.21.5",
705+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
706+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
707+ "cpu": [
708+ "x64"
709+ ],
710+ "dev": true,
711+ "license": "MIT",
712+ "optional": true,
713+ "os": [
714+ "win32"
715+ ],
716+ "engines": {
717+ "node": ">=12"
718+ }
719+ },
720+ "node_modules/@groupe-ka/ka-maps": {
721+ "resolved": "../../ka-maps",
722+ "link": true
723+ },
724+ "node_modules/@jridgewell/gen-mapping": {
725+ "version": "0.3.13",
726+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
727+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
728+ "dev": true,
729+ "license": "MIT",
730+ "dependencies": {
731+ "@jridgewell/sourcemap-codec": "^1.5.0",
732+ "@jridgewell/trace-mapping": "^0.3.24"
733+ }
734+ },
735+ "node_modules/@jridgewell/remapping": {
736+ "version": "2.3.5",
737+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
738+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
739+ "dev": true,
740+ "license": "MIT",
741+ "dependencies": {
742+ "@jridgewell/gen-mapping": "^0.3.5",
743+ "@jridgewell/trace-mapping": "^0.3.24"
744+ }
745+ },
746+ "node_modules/@jridgewell/resolve-uri": {
747+ "version": "3.1.2",
748+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
749+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
750+ "dev": true,
751+ "license": "MIT",
752+ "engines": {
753+ "node": ">=6.0.0"
754+ }
755+ },
756+ "node_modules/@jridgewell/sourcemap-codec": {
757+ "version": "1.5.5",
758+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
759+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
760+ "dev": true,
761+ "license": "MIT"
762+ },
763+ "node_modules/@jridgewell/trace-mapping": {
764+ "version": "0.3.31",
765+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
766+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
767+ "dev": true,
768+ "license": "MIT",
769+ "dependencies": {
770+ "@jridgewell/resolve-uri": "^3.1.0",
771+ "@jridgewell/sourcemap-codec": "^1.4.14"
772+ }
773+ },
774+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
775+ "version": "1.5.1",
776+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
777+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
778+ "cpu": [
779+ "x64"
780+ ],
781+ "dev": true,
782+ "libc": [
783+ "glibc"
784+ ],
785+ "license": "MIT",
786+ "optional": true,
787+ "os": [
788+ "linux"
789+ ],
790+ "engines": {
791+ "node": "^22.20 || ^24.12 || >=25"
792+ }
793+ },
794+ "node_modules/@remix-run/router": {
795+ "version": "1.23.3",
796+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
797+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
798+ "license": "MIT",
799+ "engines": {
800+ "node": ">=14.0.0"
801+ }
802+ },
803+ "node_modules/@rolldown/pluginutils": {
804+ "version": "1.0.0-beta.27",
805+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
806+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
807+ "dev": true,
808+ "license": "MIT"
809+ },
810+ "node_modules/@rollup/rollup-android-arm-eabi": {
811+ "version": "4.62.4",
812+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
813+ "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
814+ "cpu": [
815+ "arm"
816+ ],
817+ "dev": true,
818+ "license": "MIT",
819+ "optional": true,
820+ "os": [
821+ "android"
822+ ]
823+ },
824+ "node_modules/@rollup/rollup-android-arm64": {
825+ "version": "4.62.4",
826+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
827+ "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
828+ "cpu": [
829+ "arm64"
830+ ],
831+ "dev": true,
832+ "license": "MIT",
833+ "optional": true,
834+ "os": [
835+ "android"
836+ ]
837+ },
838+ "node_modules/@rollup/rollup-darwin-arm64": {
839+ "version": "4.62.4",
840+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
841+ "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
842+ "cpu": [
843+ "arm64"
844+ ],
845+ "dev": true,
846+ "license": "MIT",
847+ "optional": true,
848+ "os": [
849+ "darwin"
850+ ]
851+ },
852+ "node_modules/@rollup/rollup-darwin-x64": {
853+ "version": "4.62.4",
854+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
855+ "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
856+ "cpu": [
857+ "x64"
858+ ],
859+ "dev": true,
860+ "license": "MIT",
861+ "optional": true,
862+ "os": [
863+ "darwin"
864+ ]
865+ },
866+ "node_modules/@rollup/rollup-freebsd-arm64": {
867+ "version": "4.62.4",
868+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
869+ "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
870+ "cpu": [
871+ "arm64"
872+ ],
873+ "dev": true,
874+ "license": "MIT",
875+ "optional": true,
876+ "os": [
877+ "freebsd"
878+ ]
879+ },
880+ "node_modules/@rollup/rollup-freebsd-x64": {
881+ "version": "4.62.4",
882+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
883+ "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
884+ "cpu": [
885+ "x64"
886+ ],
887+ "dev": true,
888+ "license": "MIT",
889+ "optional": true,
890+ "os": [
891+ "freebsd"
892+ ]
893+ },
894+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
895+ "version": "4.62.4",
896+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
897+ "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
898+ "cpu": [
899+ "arm"
900+ ],
901+ "dev": true,
902+ "libc": [
903+ "glibc"
904+ ],
905+ "license": "MIT",
906+ "optional": true,
907+ "os": [
908+ "linux"
909+ ]
910+ },
911+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
912+ "version": "4.62.4",
913+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
914+ "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
915+ "cpu": [
916+ "arm"
917+ ],
918+ "dev": true,
919+ "libc": [
920+ "musl"
921+ ],
922+ "license": "MIT",
923+ "optional": true,
924+ "os": [
925+ "linux"
926+ ]
927+ },
928+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
929+ "version": "4.62.4",
930+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
931+ "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
932+ "cpu": [
933+ "arm64"
934+ ],
935+ "dev": true,
936+ "libc": [
937+ "glibc"
938+ ],
939+ "license": "MIT",
940+ "optional": true,
941+ "os": [
942+ "linux"
943+ ]
944+ },
945+ "node_modules/@rollup/rollup-linux-arm64-musl": {
946+ "version": "4.62.4",
947+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
948+ "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
949+ "cpu": [
950+ "arm64"
951+ ],
952+ "dev": true,
953+ "libc": [
954+ "musl"
955+ ],
956+ "license": "MIT",
957+ "optional": true,
958+ "os": [
959+ "linux"
960+ ]
961+ },
962+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
963+ "version": "4.62.4",
964+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
965+ "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
966+ "cpu": [
967+ "loong64"
968+ ],
969+ "dev": true,
970+ "libc": [
971+ "glibc"
972+ ],
973+ "license": "MIT",
974+ "optional": true,
975+ "os": [
976+ "linux"
977+ ]
978+ },
979+ "node_modules/@rollup/rollup-linux-loong64-musl": {
980+ "version": "4.62.4",
981+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
982+ "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
983+ "cpu": [
984+ "loong64"
985+ ],
986+ "dev": true,
987+ "libc": [
988+ "musl"
989+ ],
990+ "license": "MIT",
991+ "optional": true,
992+ "os": [
993+ "linux"
994+ ]
995+ },
996+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
997+ "version": "4.62.4",
998+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
999+ "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
1000+ "cpu": [
1001+ "ppc64"
1002+ ],
1003+ "dev": true,
1004+ "libc": [
1005+ "glibc"
1006+ ],
1007+ "license": "MIT",
1008+ "optional": true,
1009+ "os": [
1010+ "linux"
1011+ ]
1012+ },
1013+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
1014+ "version": "4.62.4",
1015+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
1016+ "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
1017+ "cpu": [
1018+ "ppc64"
1019+ ],
1020+ "dev": true,
1021+ "libc": [
1022+ "musl"
1023+ ],
1024+ "license": "MIT",
1025+ "optional": true,
1026+ "os": [
1027+ "linux"
1028+ ]
1029+ },
1030+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1031+ "version": "4.62.4",
1032+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1033+ "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1034+ "cpu": [
1035+ "riscv64"
1036+ ],
1037+ "dev": true,
1038+ "libc": [
1039+ "glibc"
1040+ ],
1041+ "license": "MIT",
1042+ "optional": true,
1043+ "os": [
1044+ "linux"
1045+ ]
1046+ },
1047+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
1048+ "version": "4.62.4",
1049+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1050+ "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1051+ "cpu": [
1052+ "riscv64"
1053+ ],
1054+ "dev": true,
1055+ "libc": [
1056+ "musl"
1057+ ],
1058+ "license": "MIT",
1059+ "optional": true,
1060+ "os": [
1061+ "linux"
1062+ ]
1063+ },
1064+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
1065+ "version": "4.62.4",
1066+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1067+ "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1068+ "cpu": [
1069+ "s390x"
1070+ ],
1071+ "dev": true,
1072+ "libc": [
1073+ "glibc"
1074+ ],
1075+ "license": "MIT",
1076+ "optional": true,
1077+ "os": [
1078+ "linux"
1079+ ]
1080+ },
1081+ "node_modules/@rollup/rollup-linux-x64-gnu": {
1082+ "version": "4.62.4",
1083+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1084+ "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1085+ "cpu": [
1086+ "x64"
1087+ ],
1088+ "dev": true,
1089+ "libc": [
1090+ "glibc"
1091+ ],
1092+ "license": "MIT",
1093+ "optional": true,
1094+ "os": [
1095+ "linux"
1096+ ]
1097+ },
1098+ "node_modules/@rollup/rollup-linux-x64-musl": {
1099+ "version": "4.62.4",
1100+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1101+ "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1102+ "cpu": [
1103+ "x64"
1104+ ],
1105+ "dev": true,
1106+ "libc": [
1107+ "musl"
1108+ ],
1109+ "license": "MIT",
1110+ "optional": true,
1111+ "os": [
1112+ "linux"
1113+ ]
1114+ },
1115+ "node_modules/@rollup/rollup-openbsd-x64": {
1116+ "version": "4.62.4",
1117+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1118+ "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1119+ "cpu": [
1120+ "x64"
1121+ ],
1122+ "dev": true,
1123+ "license": "MIT",
1124+ "optional": true,
1125+ "os": [
1126+ "openbsd"
1127+ ]
1128+ },
1129+ "node_modules/@rollup/rollup-openharmony-arm64": {
1130+ "version": "4.62.4",
1131+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1132+ "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1133+ "cpu": [
1134+ "arm64"
1135+ ],
1136+ "dev": true,
1137+ "license": "MIT",
1138+ "optional": true,
1139+ "os": [
1140+ "openharmony"
1141+ ]
1142+ },
1143+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
1144+ "version": "4.62.4",
1145+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1146+ "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1147+ "cpu": [
1148+ "arm64"
1149+ ],
1150+ "dev": true,
1151+ "license": "MIT",
1152+ "optional": true,
1153+ "os": [
1154+ "win32"
1155+ ]
1156+ },
1157+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
1158+ "version": "4.62.4",
1159+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1160+ "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1161+ "cpu": [
1162+ "ia32"
1163+ ],
1164+ "dev": true,
1165+ "license": "MIT",
1166+ "optional": true,
1167+ "os": [
1168+ "win32"
1169+ ]
1170+ },
1171+ "node_modules/@rollup/rollup-win32-x64-gnu": {
1172+ "version": "4.62.4",
1173+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1174+ "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1175+ "cpu": [
1176+ "x64"
1177+ ],
1178+ "dev": true,
1179+ "license": "MIT",
1180+ "optional": true,
1181+ "os": [
1182+ "win32"
1183+ ]
1184+ },
1185+ "node_modules/@rollup/rollup-win32-x64-msvc": {
1186+ "version": "4.62.4",
1187+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1188+ "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1189+ "cpu": [
1190+ "x64"
1191+ ],
1192+ "dev": true,
1193+ "license": "MIT",
1194+ "optional": true,
1195+ "os": [
1196+ "win32"
1197+ ]
1198+ },
1199+ "node_modules/@types/babel__core": {
1200+ "version": "7.20.5",
1201+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1202+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1203+ "dev": true,
1204+ "license": "MIT",
1205+ "dependencies": {
1206+ "@babel/parser": "^7.20.7",
1207+ "@babel/types": "^7.20.7",
1208+ "@types/babel__generator": "*",
1209+ "@types/babel__template": "*",
1210+ "@types/babel__traverse": "*"
1211+ }
1212+ },
1213+ "node_modules/@types/babel__generator": {
1214+ "version": "7.27.0",
1215+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1216+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1217+ "dev": true,
1218+ "license": "MIT",
1219+ "dependencies": {
1220+ "@babel/types": "^7.0.0"
1221+ }
1222+ },
1223+ "node_modules/@types/babel__template": {
1224+ "version": "7.4.4",
1225+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1226+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1227+ "dev": true,
1228+ "license": "MIT",
1229+ "dependencies": {
1230+ "@babel/parser": "^7.1.0",
1231+ "@babel/types": "^7.0.0"
1232+ }
1233+ },
1234+ "node_modules/@types/babel__traverse": {
1235+ "version": "7.28.0",
1236+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1237+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1238+ "dev": true,
1239+ "license": "MIT",
1240+ "dependencies": {
1241+ "@babel/types": "^7.28.2"
1242+ }
1243+ },
1244+ "node_modules/@types/estree": {
1245+ "version": "1.0.9",
1246+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1247+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1248+ "dev": true,
1249+ "license": "MIT"
1250+ },
1251+ "node_modules/@types/prop-types": {
1252+ "version": "15.7.15",
1253+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1254+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1255+ "dev": true,
1256+ "license": "MIT"
1257+ },
1258+ "node_modules/@types/react": {
1259+ "version": "18.3.31",
1260+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1261+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1262+ "dev": true,
1263+ "license": "MIT",
1264+ "dependencies": {
1265+ "@types/prop-types": "*",
1266+ "csstype": "^3.2.2"
1267+ }
1268+ },
1269+ "node_modules/@types/react-dom": {
1270+ "version": "18.3.7",
1271+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1272+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1273+ "dev": true,
1274+ "license": "MIT",
1275+ "peerDependencies": {
1276+ "@types/react": "^18.0.0"
1277+ }
1278+ },
1279+ "node_modules/@vitejs/plugin-react": {
1280+ "version": "4.7.0",
1281+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1282+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1283+ "dev": true,
1284+ "license": "MIT",
1285+ "dependencies": {
1286+ "@babel/core": "^7.28.0",
1287+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1288+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1289+ "@rolldown/pluginutils": "1.0.0-beta.27",
1290+ "@types/babel__core": "^7.20.5",
1291+ "react-refresh": "^0.17.0"
1292+ },
1293+ "engines": {
1294+ "node": "^14.18.0 || >=16.0.0"
1295+ },
1296+ "peerDependencies": {
1297+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1298+ }
1299+ },
1300+ "node_modules/baseline-browser-mapping": {
1301+ "version": "2.11.13",
1302+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
1303+ "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
1304+ "dev": true,
1305+ "license": "Apache-2.0",
1306+ "bin": {
1307+ "baseline-browser-mapping": "dist/cli.cjs"
1308+ },
1309+ "engines": {
1310+ "node": ">=6.0.0"
1311+ }
1312+ },
1313+ "node_modules/browserslist": {
1314+ "version": "4.28.8",
1315+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1316+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1317+ "dev": true,
1318+ "funding": [
1319+ {
1320+ "type": "opencollective",
1321+ "url": "https://opencollective.com/browserslist"
1322+ },
1323+ {
1324+ "type": "tidelift",
1325+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1326+ },
1327+ {
1328+ "type": "github",
1329+ "url": "https://github.com/sponsors/ai"
1330+ }
1331+ ],
1332+ "license": "MIT",
1333+ "dependencies": {
1334+ "baseline-browser-mapping": "^2.11.12",
1335+ "caniuse-lite": "^1.0.30001809",
1336+ "electron-to-chromium": "^1.5.402",
1337+ "node-releases": "^2.0.53",
1338+ "update-browserslist-db": "^1.3.0"
1339+ },
1340+ "bin": {
1341+ "browserslist": "cli.js"
1342+ },
1343+ "engines": {
1344+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1345+ }
1346+ },
1347+ "node_modules/caniuse-lite": {
1348+ "version": "1.0.30001809",
1349+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1350+ "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1351+ "dev": true,
1352+ "funding": [
1353+ {
1354+ "type": "opencollective",
1355+ "url": "https://opencollective.com/browserslist"
1356+ },
1357+ {
1358+ "type": "tidelift",
1359+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1360+ },
1361+ {
1362+ "type": "github",
1363+ "url": "https://github.com/sponsors/ai"
1364+ }
1365+ ],
1366+ "license": "CC-BY-4.0"
1367+ },
1368+ "node_modules/convert-source-map": {
1369+ "version": "2.0.0",
1370+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1371+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1372+ "dev": true,
1373+ "license": "MIT"
1374+ },
1375+ "node_modules/csstype": {
1376+ "version": "3.2.3",
1377+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1378+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1379+ "dev": true,
1380+ "license": "MIT"
1381+ },
1382+ "node_modules/debug": {
1383+ "version": "4.4.3",
1384+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1385+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1386+ "dev": true,
1387+ "license": "MIT",
1388+ "dependencies": {
1389+ "ms": "^2.1.3"
1390+ },
1391+ "engines": {
1392+ "node": ">=6.0"
1393+ },
1394+ "peerDependenciesMeta": {
1395+ "supports-color": {
1396+ "optional": true
1397+ }
1398+ }
1399+ },
1400+ "node_modules/electron-to-chromium": {
1401+ "version": "1.5.405",
1402+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
1403+ "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
1404+ "dev": true,
1405+ "license": "ISC"
1406+ },
1407+ "node_modules/esbuild": {
1408+ "version": "0.21.5",
1409+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1410+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1411+ "dev": true,
1412+ "hasInstallScript": true,
1413+ "license": "MIT",
1414+ "bin": {
1415+ "esbuild": "bin/esbuild"
1416+ },
1417+ "engines": {
1418+ "node": ">=12"
1419+ },
1420+ "optionalDependencies": {
1421+ "@esbuild/aix-ppc64": "0.21.5",
1422+ "@esbuild/android-arm": "0.21.5",
1423+ "@esbuild/android-arm64": "0.21.5",
1424+ "@esbuild/android-x64": "0.21.5",
1425+ "@esbuild/darwin-arm64": "0.21.5",
1426+ "@esbuild/darwin-x64": "0.21.5",
1427+ "@esbuild/freebsd-arm64": "0.21.5",
1428+ "@esbuild/freebsd-x64": "0.21.5",
1429+ "@esbuild/linux-arm": "0.21.5",
1430+ "@esbuild/linux-arm64": "0.21.5",
1431+ "@esbuild/linux-ia32": "0.21.5",
1432+ "@esbuild/linux-loong64": "0.21.5",
1433+ "@esbuild/linux-mips64el": "0.21.5",
1434+ "@esbuild/linux-ppc64": "0.21.5",
1435+ "@esbuild/linux-riscv64": "0.21.5",
1436+ "@esbuild/linux-s390x": "0.21.5",
1437+ "@esbuild/linux-x64": "0.21.5",
1438+ "@esbuild/netbsd-x64": "0.21.5",
1439+ "@esbuild/openbsd-x64": "0.21.5",
1440+ "@esbuild/sunos-x64": "0.21.5",
1441+ "@esbuild/win32-arm64": "0.21.5",
1442+ "@esbuild/win32-ia32": "0.21.5",
1443+ "@esbuild/win32-x64": "0.21.5"
1444+ }
1445+ },
1446+ "node_modules/escalade": {
1447+ "version": "3.2.0",
1448+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1449+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1450+ "dev": true,
1451+ "license": "MIT",
1452+ "engines": {
1453+ "node": ">=6"
1454+ }
1455+ },
1456+ "node_modules/fsevents": {
1457+ "version": "2.3.3",
1458+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1459+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1460+ "dev": true,
1461+ "hasInstallScript": true,
1462+ "license": "MIT",
1463+ "optional": true,
1464+ "os": [
1465+ "darwin"
1466+ ],
1467+ "engines": {
1468+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1469+ }
1470+ },
1471+ "node_modules/gensync": {
1472+ "version": "1.0.0-beta.2",
1473+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1474+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1475+ "dev": true,
1476+ "license": "MIT",
1477+ "engines": {
1478+ "node": ">=6.9.0"
1479+ }
1480+ },
1481+ "node_modules/js-tokens": {
1482+ "version": "4.0.0",
1483+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1484+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1485+ "license": "MIT"
1486+ },
1487+ "node_modules/jsesc": {
1488+ "version": "3.1.0",
1489+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1490+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1491+ "dev": true,
1492+ "license": "MIT",
1493+ "bin": {
1494+ "jsesc": "bin/jsesc"
1495+ },
1496+ "engines": {
1497+ "node": ">=6"
1498+ }
1499+ },
1500+ "node_modules/json5": {
1501+ "version": "2.2.3",
1502+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1503+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1504+ "dev": true,
1505+ "license": "MIT",
1506+ "bin": {
1507+ "json5": "lib/cli.js"
1508+ },
1509+ "engines": {
1510+ "node": ">=6"
1511+ }
1512+ },
1513+ "node_modules/loose-envify": {
1514+ "version": "1.4.0",
1515+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1516+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1517+ "license": "MIT",
1518+ "dependencies": {
1519+ "js-tokens": "^3.0.0 || ^4.0.0"
1520+ },
1521+ "bin": {
1522+ "loose-envify": "cli.js"
1523+ }
1524+ },
1525+ "node_modules/lru-cache": {
1526+ "version": "5.1.1",
1527+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1528+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1529+ "dev": true,
1530+ "license": "ISC",
1531+ "dependencies": {
1532+ "yallist": "^3.0.2"
1533+ }
1534+ },
1535+ "node_modules/mapbox-gl": {
1536+ "version": "3.28.1",
1537+ "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.28.1.tgz",
1538+ "integrity": "sha512-f8bCHFzZ51bKig7rnD7e08aoFLOV3MNFZduspZ4lgOgiaNVp9sw4NSWcgo3IWTyekYKKXjiICD2BP2o4DiYfxw==",
1539+ "license": "SEE LICENSE IN LICENSE.txt",
1540+ "workspaces": [
1541+ "src/style-spec",
1542+ "plugins/mapbox-gl-pmtiles-provider",
1543+ "test/bundlers/*",
1544+ "test/build/typings"
1545+ ]
1546+ },
1547+ "node_modules/ms": {
1548+ "version": "2.1.3",
1549+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1550+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1551+ "dev": true,
1552+ "license": "MIT"
1553+ },
1554+ "node_modules/nanoid": {
1555+ "version": "3.3.18",
1556+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1557+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1558+ "dev": true,
1559+ "funding": [
1560+ {
1561+ "type": "github",
1562+ "url": "https://github.com/sponsors/ai"
1563+ }
1564+ ],
1565+ "license": "MIT",
1566+ "bin": {
1567+ "nanoid": "bin/nanoid.cjs"
1568+ },
1569+ "engines": {
1570+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1571+ }
1572+ },
1573+ "node_modules/node-releases": {
1574+ "version": "2.0.53",
1575+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1576+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1577+ "dev": true,
1578+ "license": "MIT",
1579+ "engines": {
1580+ "node": ">=18"
1581+ }
1582+ },
1583+ "node_modules/picocolors": {
1584+ "version": "1.1.1",
1585+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1586+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1587+ "dev": true,
1588+ "license": "ISC"
1589+ },
1590+ "node_modules/postcss": {
1591+ "version": "8.5.26",
1592+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1593+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1594+ "dev": true,
1595+ "funding": [
1596+ {
1597+ "type": "opencollective",
1598+ "url": "https://opencollective.com/postcss/"
1599+ },
1600+ {
1601+ "type": "tidelift",
1602+ "url": "https://tidelift.com/funding/github/npm/postcss"
1603+ },
1604+ {
1605+ "type": "github",
1606+ "url": "https://github.com/sponsors/ai"
1607+ }
1608+ ],
1609+ "license": "MIT",
1610+ "dependencies": {
1611+ "nanoid": "^3.3.17",
1612+ "picocolors": "^1.1.1",
1613+ "source-map-js": "^1.2.1"
1614+ },
1615+ "engines": {
1616+ "node": "^10 || ^12 || >=14"
1617+ }
1618+ },
1619+ "node_modules/react": {
1620+ "version": "18.3.1",
1621+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1622+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1623+ "license": "MIT",
1624+ "dependencies": {
1625+ "loose-envify": "^1.1.0"
1626+ },
1627+ "engines": {
1628+ "node": ">=0.10.0"
1629+ }
1630+ },
1631+ "node_modules/react-dom": {
1632+ "version": "18.3.1",
1633+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1634+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1635+ "license": "MIT",
1636+ "dependencies": {
1637+ "loose-envify": "^1.1.0",
1638+ "scheduler": "^0.23.2"
1639+ },
1640+ "peerDependencies": {
1641+ "react": "^18.3.1"
1642+ }
1643+ },
1644+ "node_modules/react-refresh": {
1645+ "version": "0.17.0",
1646+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1647+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1648+ "dev": true,
1649+ "license": "MIT",
1650+ "engines": {
1651+ "node": ">=0.10.0"
1652+ }
1653+ },
1654+ "node_modules/react-router": {
1655+ "version": "6.30.4",
1656+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1657+ "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1658+ "license": "MIT",
1659+ "dependencies": {
1660+ "@remix-run/router": "1.23.3"
1661+ },
1662+ "engines": {
1663+ "node": ">=14.0.0"
1664+ },
1665+ "peerDependencies": {
1666+ "react": ">=16.8"
1667+ }
1668+ },
1669+ "node_modules/react-router-dom": {
1670+ "version": "6.30.4",
1671+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1672+ "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1673+ "license": "MIT",
1674+ "dependencies": {
1675+ "@remix-run/router": "1.23.3",
1676+ "react-router": "6.30.4"
1677+ },
1678+ "engines": {
1679+ "node": ">=14.0.0"
1680+ },
1681+ "peerDependencies": {
1682+ "react": ">=16.8",
1683+ "react-dom": ">=16.8"
1684+ }
1685+ },
1686+ "node_modules/rollup": {
1687+ "version": "4.62.4",
1688+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1689+ "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1690+ "dev": true,
1691+ "license": "MIT",
1692+ "dependencies": {
1693+ "@types/estree": "1.0.9"
1694+ },
1695+ "bin": {
1696+ "rollup": "dist/bin/rollup"
1697+ },
1698+ "engines": {
1699+ "node": ">=18.0.0",
1700+ "npm": ">=8.0.0"
1701+ },
1702+ "optionalDependencies": {
1703+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1704+ "@rollup/rollup-android-arm-eabi": "4.62.4",
1705+ "@rollup/rollup-android-arm64": "4.62.4",
1706+ "@rollup/rollup-darwin-arm64": "4.62.4",
1707+ "@rollup/rollup-darwin-x64": "4.62.4",
1708+ "@rollup/rollup-freebsd-arm64": "4.62.4",
1709+ "@rollup/rollup-freebsd-x64": "4.62.4",
1710+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
1711+ "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
1712+ "@rollup/rollup-linux-arm64-gnu": "4.62.4",
1713+ "@rollup/rollup-linux-arm64-musl": "4.62.4",
1714+ "@rollup/rollup-linux-loong64-gnu": "4.62.4",
1715+ "@rollup/rollup-linux-loong64-musl": "4.62.4",
1716+ "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
1717+ "@rollup/rollup-linux-ppc64-musl": "4.62.4",
1718+ "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
1719+ "@rollup/rollup-linux-riscv64-musl": "4.62.4",
1720+ "@rollup/rollup-linux-s390x-gnu": "4.62.4",
1721+ "@rollup/rollup-linux-x64-gnu": "4.62.4",
1722+ "@rollup/rollup-linux-x64-musl": "4.62.4",
1723+ "@rollup/rollup-openbsd-x64": "4.62.4",
1724+ "@rollup/rollup-openharmony-arm64": "4.62.4",
1725+ "@rollup/rollup-win32-arm64-msvc": "4.62.4",
1726+ "@rollup/rollup-win32-ia32-msvc": "4.62.4",
1727+ "@rollup/rollup-win32-x64-gnu": "4.62.4",
1728+ "@rollup/rollup-win32-x64-msvc": "4.62.4",
1729+ "fsevents": "~2.3.2"
1730+ }
1731+ },
1732+ "node_modules/scheduler": {
1733+ "version": "0.23.2",
1734+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
1735+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
1736+ "license": "MIT",
1737+ "dependencies": {
1738+ "loose-envify": "^1.1.0"
1739+ }
1740+ },
1741+ "node_modules/semver": {
1742+ "version": "6.3.1",
1743+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
1744+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
1745+ "dev": true,
1746+ "license": "ISC",
1747+ "bin": {
1748+ "semver": "bin/semver.js"
1749+ }
1750+ },
1751+ "node_modules/source-map-js": {
1752+ "version": "1.2.1",
1753+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
1754+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
1755+ "dev": true,
1756+ "license": "BSD-3-Clause",
1757+ "engines": {
1758+ "node": ">=0.10.0"
1759+ }
1760+ },
1761+ "node_modules/typescript": {
1762+ "version": "5.9.3",
1763+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1764+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1765+ "dev": true,
1766+ "license": "Apache-2.0",
1767+ "bin": {
1768+ "tsc": "bin/tsc",
1769+ "tsserver": "bin/tsserver"
1770+ },
1771+ "engines": {
1772+ "node": ">=14.17"
1773+ }
1774+ },
1775+ "node_modules/update-browserslist-db": {
1776+ "version": "1.3.1",
1777+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
1778+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
1779+ "dev": true,
1780+ "funding": [
1781+ {
1782+ "type": "opencollective",
1783+ "url": "https://opencollective.com/browserslist"
1784+ },
1785+ {
1786+ "type": "tidelift",
1787+ "url": "https://tidelift.com/funding/github/npm/browserslist"
1788+ },
1789+ {
1790+ "type": "github",
1791+ "url": "https://github.com/sponsors/ai"
1792+ }
1793+ ],
1794+ "license": "MIT",
1795+ "dependencies": {
1796+ "escalade": "^3.2.0",
1797+ "picocolors": "^1.1.1"
1798+ },
1799+ "bin": {
1800+ "update-browserslist-db": "cli.js"
1801+ },
1802+ "peerDependencies": {
1803+ "browserslist": ">= 4.21.0"
1804+ }
1805+ },
1806+ "node_modules/vite": {
1807+ "version": "5.4.21",
1808+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
1809+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1810+ "dev": true,
1811+ "license": "MIT",
1812+ "dependencies": {
1813+ "esbuild": "^0.21.3",
1814+ "postcss": "^8.4.43",
1815+ "rollup": "^4.20.0"
1816+ },
1817+ "bin": {
1818+ "vite": "bin/vite.js"
1819+ },
1820+ "engines": {
1821+ "node": "^18.0.0 || >=20.0.0"
1822+ },
1823+ "funding": {
1824+ "url": "https://github.com/vitejs/vite?sponsor=1"
1825+ },
1826+ "optionalDependencies": {
1827+ "fsevents": "~2.3.3"
1828+ },
1829+ "peerDependencies": {
1830+ "@types/node": "^18.0.0 || >=20.0.0",
1831+ "less": "*",
1832+ "lightningcss": "^1.21.0",
1833+ "sass": "*",
1834+ "sass-embedded": "*",
1835+ "stylus": "*",
1836+ "sugarss": "*",
1837+ "terser": "^5.4.0"
1838+ },
1839+ "peerDependenciesMeta": {
1840+ "@types/node": {
1841+ "optional": true
1842+ },
1843+ "less": {
1844+ "optional": true
1845+ },
1846+ "lightningcss": {
1847+ "optional": true
1848+ },
1849+ "sass": {
1850+ "optional": true
1851+ },
1852+ "sass-embedded": {
1853+ "optional": true
1854+ },
1855+ "stylus": {
1856+ "optional": true
1857+ },
1858+ "sugarss": {
1859+ "optional": true
1860+ },
1861+ "terser": {
1862+ "optional": true
1863+ }
1864+ }
1865+ },
1866+ "node_modules/yallist": {
1867+ "version": "3.1.1",
1868+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
1869+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
1870+ "dev": true,
1871+ "license": "ISC"
1872+ }
1873+ }
1874+}
added frontend/package.json +26 −0
@@ -0,0 +1,26 @@
1+{
2+ "name": "house-ka-frontend",
3+ "author": "Simon-Pierre Boucher <contact@spboucher.ai>",
4+ "private": true,
5+ "version": "1.0.0",
6+ "type": "module",
7+ "scripts": {
8+ "dev": "vite",
9+ "build": "tsc -b && vite build",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "@groupe-ka/ka-maps": "file:../../ka-maps",
14+ "mapbox-gl": "^3.28.1",
15+ "react": "^18.3.1",
16+ "react-dom": "^18.3.1",
17+ "react-router-dom": "^6.26.0"
18+ },
19+ "devDependencies": {
20+ "@types/react": "^18.3.3",
21+ "@types/react-dom": "^18.3.0",
22+ "@vitejs/plugin-react": "^4.3.1",
23+ "typescript": "^5.5.4",
24+ "vite": "^5.4.0"
25+ }
26+}
\ No newline at end of file
added frontend/public/apple-touch-icon.png +0 −0

Binary file not shown.

added frontend/public/favicon.svg +5 −0
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2+ <rect x="2" y="2" width="60" height="60" rx="14" fill="#14201a"/>
3+ <path d="M14 30 32 14l18 16" fill="none" stroke="#0f6b4f" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
4+ <text x="32" y="52" text-anchor="middle" font-family="Georgia, 'Times New Roman', serif" font-weight="700" font-size="30" fill="#faf7f0">H</text>
5+</svg>
added frontend/public/og.png +0 −0

Binary file not shown.

added frontend/scripts/check-order.mjs +41 −0
@@ -0,0 +1,41 @@
1+// Validation ordre des sections — fiche Immo-Ka (ordre DOM = ordre visuel)
2+import { chromium, devices } from "playwright";
3+
4+const BASE = process.env.BASE || "http://localhost:18096";
5+const UID = process.argv[2];
6+const URL = `${BASE}/property/${encodeURIComponent(UID)}`;
7+const SEL = [".f-galerie", ".f-hero", ".f-desc", "#caracteristiques", "#pieces", "#inclusions", "#carte", ".quartier"];
8+
9+async function check(name, ctxOpts) {
10+ const browser = await chromium.launch();
11+ const ctx = await browser.newContext(ctxOpts);
12+ const page = await ctx.newPage();
13+ await page.goto(URL, { waitUntil: "networkidle" });
14+ await page.waitForSelector(".f-galerie", { timeout: 15000 });
15+ await page.waitForTimeout(1200);
16+ const data = await page.evaluate((sel) => {
17+ const out = [];
18+ for (const s of sel) {
19+ const el = document.querySelector(s);
20+ if (!el) { out.push({ s, missing: true }); continue; }
21+ const r = el.getBoundingClientRect();
22+ out.push({ s, hidden: r.height === 0 && r.width === 0, top: Math.round(r.top + window.scrollY), left: Math.round(r.left), order: getComputedStyle(el).order });
23+ }
24+ return { out, scrollY: window.scrollY };
25+ }, SEL);
26+ console.log(`\n=== ${name} === scrollY: ${data.scrollY}`);
27+ for (const b of data.out)
28+ console.log(b.missing ? `${b.s.padEnd(18)} (non rendue)` : b.hidden ? `${b.s.padEnd(18)} (vide/masquée)` :
29+ `${b.s.padEnd(18)} top=${String(b.top).padStart(6)} left=${String(b.left).padStart(4)} order=${b.order}`);
30+ await browser.close();
31+ return data;
32+}
33+
34+const mob = await check("iPhone 14 (mobile)", { ...devices["iPhone 14"] });
35+await check("Desktop 1440px", { viewport: { width: 1440, height: 900 } });
36+const vis = mob.out.filter(b => !b.missing && !b.hidden);
37+const sorted = vis.every((b, i) => i === 0 || b.top >= vis[i - 1].top);
38+const ok = sorted && mob.scrollY === 0 && vis[0].s === ".f-galerie" && vis.every(b => b.order === "0");
39+console.log(`\nMOBILE: ordre ${sorted ? "CROISSANT ✓" : "DÉSORDONNÉ ✗"} · scrollY=${mob.scrollY} · 1re=${vis[0].s} · sans order=${vis.every(b => b.order === "0")}`);
40+console.log(ok ? "VALIDATION OK" : "VALIDATION ÉCHEC");
41+process.exit(ok ? 0 : 1);
added frontend/src/App.tsx +238 −0
@@ -0,0 +1,238 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// App.tsx : global layout (header + live ticker + footer) and routing.
5+// -----------------------------------------------------------------------------
6+import { Ico } from "./components/Icons";
7+import { useEffect, useState } from "react";
8+import { NavLink, Route, Routes, useLocation } from "react-router-dom";
9+import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
10+import AgenciesPage from "./pages/Agencies";
11+import ContactPage from "./pages/Contact";
12+import Home from "./pages/Home";
13+import { PrivacyPage, TermsPage } from "./pages/Legal";
14+import ListingPage from "./pages/Listing";
15+import RatesPage from "./pages/Rates";
16+import StatsPage from "./pages/Stats";
17+
18+function Ticker() {
19+ const [items, setItems] = useState<string[]>([]);
20+
21+ useEffect(() => {
22+ Promise.all([fetchStats(), fetchFacets(), fetchSources()])
23+ .then(([stats, facets, src]) => {
24+ registerSourceNames(src.sources);
25+ const parts: string[] = [`${stats.total.toLocaleString("en-CA")} homes for sale`];
26+ if (stats.cities) parts.push(`${stats.cities.toLocaleString("en-CA")} cities & towns`);
27+ if (stats.avg_price != null)
28+ parts.push(`Average price $${Math.round(stats.avg_price).toLocaleString("en-CA")}`);
29+ for (const s of facets.sources.slice(0, 12))
30+ parts.push(`${sourceName(s.source)} · ${s.n.toLocaleString("en-CA")}`);
31+ parts.push("Continuously updated");
32+ setItems(parts);
33+ })
34+ .catch(() => setItems(["House-Ka — homes for sale across Canada"]));
35+ }, []);
36+
37+ if (items.length === 0) return null;
38+ return (
39+ <div className="ticker" aria-hidden="true">
40+ <div className="ticker-track">
41+ {[...items, ...items].map((t, i) => (
42+ <span key={i}>{t}</span>
43+ ))}
44+ </div>
45+ </div>
46+ );
47+}
48+
49+const NAV_LINKS = [
50+ { to: "/", label: "Homes", icon: "home", end: true },
51+ { to: "/?view=map", label: "Map", icon: "map", end: false, force: true },
52+ { to: "/rates", label: "Mortgage rates", icon: "trendup", end: false },
53+ { to: "/stats", label: "Stats", icon: "chart", end: false },
54+ { to: "/agencies", label: "Brokerages", icon: "building", end: false },
55+ { to: "/contact", label: "Contact", icon: "arrow", end: false },
56+];
57+
58+function Header() {
59+ const [open, setOpen] = useState(false);
60+ const location = useLocation();
61+
62+ useEffect(() => { setOpen(false); }, [location]);
63+ useEffect(() => {
64+ document.documentElement.classList.toggle("ka-scroll-lock", open);
65+ return () => { document.documentElement.classList.remove("ka-scroll-lock"); };
66+ }, [open]);
67+
68+ return (
69+ <>
70+ <header className="header">
71+ <div className="container header-inner">
72+ <NavLink to="/" className="brand" aria-label="House-Ka — home">
73+ House<span className="ka">Ka</span>
74+ </NavLink>
75+ <span className="brand-tag">A Groupe KA service</span>
76+ <nav className="nav" aria-label="Main navigation">
77+ <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
78+ Homes
79+ </NavLink>
80+ <NavLink to="/rates" className={({ isActive }) => (isActive ? "active" : "")}>
81+ Rates
82+ </NavLink>
83+ <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
84+ Stats
85+ </NavLink>
86+ <NavLink to="/agencies" className={({ isActive }) => (isActive ? "active" : "")}>
87+ Brokerages
88+ </NavLink>
89+ <NavLink to="/contact" className={({ isActive }) => (isActive ? "active" : "")}>
90+ Contact
91+ </NavLink>
92+ </nav>
93+ <button
94+ className={`menu-btn ${open ? "open" : ""}`}
95+ aria-expanded={open}
96+ aria-label={open ? "Close the menu" : "Open the menu"}
97+ onClick={() => setOpen(!open)}
98+ >
99+ <span /><span /><span />
100+ </button>
101+ </div>
102+
103+ <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Mobile menu">
104+ {/* fixed ✕ of the panel: visible regardless of scroll; the header
105+ burger is hidden while the panel is open */}
106+ <button type="button" className="mm-close" aria-label="Close the menu"
107+ onClick={() => setOpen(false)}>✕</button>
108+ {NAV_LINKS.map((l, i) => (
109+ <NavLink
110+ key={l.to}
111+ to={l.to}
112+ end={l.end}
113+ style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
114+ className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}
115+ onClick={() => setOpen(false)}
116+ >
117+ <span className="mm-ico" aria-hidden="true"><Ico name={l.icon} size={19} /></span>
118+ {l.label}
119+ <span className="mm-arrow" aria-hidden="true"><Ico name="arrow" size={15} /></span>
120+ </NavLink>
121+ ))}
122+ <div className="mm-foot">
123+ <p>
124+ Independent aggregator — continuously updated, every listing links
125+ back to the brokerage's original page.
126+ </p>
127+ </div>
128+ </div>
129+ </header>
130+ {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
131+ <Ticker />
132+ </>
133+ );
134+}
135+
136+/** Bottom navigation bar (mobile) — floating pill detached from the edges,
137+ accent underline below the active tab. */
138+function MobileTabBar() {
139+ const location = useLocation();
140+ const isMap = new URLSearchParams(location.search).get("view") === "map";
141+ const tabs = [
142+ { to: "/", label: "Discover", icon: <Ico name="search" size={20} stroke={1.6} />, on: location.pathname === "/" && !isMap },
143+ { to: "/?view=map", label: "Map", icon: <Ico name="map" size={20} stroke={1.6} />, on: location.pathname === "/" && isMap },
144+ { to: "/rates", label: "Rates", icon: <Ico name="trendup" size={20} stroke={1.6} />, on: location.pathname.startsWith("/rates") },
145+ { to: "/stats", label: "Stats", icon: <Ico name="chart" size={20} stroke={1.6} />, on: location.pathname.startsWith("/stats") },
146+ ];
147+ return (
148+ <nav className="tabbar" aria-label="Mobile navigation">
149+ {tabs.map((t) => (
150+ <NavLink key={t.label} to={t.to} className={() => (t.on ? "active" : "")}>
151+ {t.icon}
152+ {t.label}
153+ </NavLink>
154+ ))}
155+ </nav>
156+ );
157+}
158+
159+/** House-Ka footer — ink panel, Groupe KA credit + sister sites. */
160+function Footer() {
161+ const year = new Date().getFullYear();
162+ const sisters = [
163+ { name: "Groupe·Ka", url: "https://www.groupe-ka.com", note: "the Groupe KA portal" },
164+ { name: "Immo·Ka", url: "https://www.immo-ka.com", note: "homes for sale in Québec" },
165+ { name: "Lou·Ka", url: "https://www.lou-ka.com", note: "rentals in Québec" },
166+ { name: "Vrai·Prix", url: "https://www.vrai-prix.com", note: "Québec market-value estimates" },
167+ ];
168+ return (
169+ <footer className="hk-footer" id="contact">
170+ <div className="container">
171+ <div className="hk-foot-brand">House<span className="ka">Ka</span></div>
172+ <p className="hk-foot-desc">
173+ House-Ka continuously aggregates homes for sale publicly listed by
174+ Canadian real-estate brokerages and teams on the CREA DDF feed —
175+ starting with Ontario and growing across the rest of Canada. Every
176+ listing links back to the brokerage's original page. House-Ka is a
177+ service of <b>Groupe KA</b>.
178+ </p>
179+ <p className="hk-foot-notice">
180+ House-Ka is an independent aggregator: it is not a brokerage, does not
181+ represent buyers or sellers, and is not affiliated with the sources it
182+ indexes. Prices and availability are those displayed by each source.
183+ </p>
184+ <ul className="hk-foot-sites">
185+ {sisters.map((s) => (
186+ <li key={s.url}>
187+ <a href={s.url} target="_blank" rel="noopener noreferrer">
188+ {s.name}
189+ </a>
190+ <span>{s.note}</span>
191+ </li>
192+ ))}
193+ </ul>
194+ <div className="hk-foot-legal">
195+ <a href="/terms">Terms of use</a>
196+ <a href="/privacy">Privacy</a>
197+ <a href="/contact">Contact</a>
198+ <span>© {year} Groupe-Ka</span>
199+ </div>
200+ </div>
201+ </footer>
202+ );
203+}
204+
205+export default function App() {
206+ return (
207+ <>
208+ <Header />
209+ <main>
210+ <Routes>
211+ <Route path="/" element={<Home />} />
212+ <Route path="/property/:uid" element={<ListingPage />} />
213+ {/* canonical SEO URL: /property/{uid}/{slug} (server 301) — without
214+ this route any shared/direct link would land on the 404 page */}
215+ <Route path="/property/:uid/:slug" element={<ListingPage />} />
216+ <Route path="/stats" element={<StatsPage />} />
217+ <Route path="/rates" element={<RatesPage />} />
218+ <Route path="/agencies" element={<AgenciesPage />} />
219+ <Route path="/contact" element={<ContactPage />} />
220+ <Route path="/terms" element={<TermsPage />} />
221+ <Route path="/privacy" element={<PrivacyPage />} />
222+ <Route
223+ path="*"
224+ element={
225+ <div className="notice container">
226+ <div className="big">🧭</div>
227+ <h2>Page not found</h2>
228+ <p>The requested link does not exist.</p>
229+ </div>
230+ }
231+ />
232+ </Routes>
233+ </main>
234+ <MobileTabBar />
235+ <Footer />
236+ </>
237+ );
238+}
added frontend/src/api.ts +383 −0
@@ -0,0 +1,383 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// api.ts : types + robust API client (timeout, typed errors)
5+// -----------------------------------------------------------------------------
6+
7+export interface Room {
8+ nom?: string;
9+ niveau?: string;
10+ dimensions?: string;
11+ revetement?: string;
12+}
13+
14+/** details: free-form dictionary of DDF fields (label → value), with the
15+ * special keys `pieces` (rooms) and `photo_captions`. */
16+export interface ListingDetails {
17+ pieces?: Room[];
18+ price_from?: boolean;
19+ [key: string]: unknown;
20+}
21+
22+export interface Listing {
23+ uid: string;
24+ source: string;
25+ external_id: string;
26+ url: string;
27+ title: string;
28+ address: string;
29+ sector: string;
30+ city: string;
31+ region: string;
32+ property_type: string;
33+ price: number | null;
34+ price_label: string;
35+ bedrooms: number | null;
36+ bathrooms: number | null;
37+ powder_rooms: number | null;
38+ area_sqft: number | null;
39+ lot_sqft: number | null;
40+ year_built: number | null;
41+ mls: string;
42+ status: string;
43+ broker_name: string;
44+ broker_phone: string;
45+ description: string;
46+ features: string[];
47+ details: ListingDetails;
48+ images: string[];
49+ lat: number | null;
50+ lng: number | null;
51+ price_history?: { ts: number; price: number | null }[];
52+ duplicates?: DuplicateListing[]; // other publications of the same property
53+ poi?: Poi[]; // nearby amenities (listing page only)
54+ first_seen?: number;
55+ last_seen?: number;
56+ updated_at?: number;
57+ active?: number;
58+ days_on_market?: number;
59+}
60+
61+export interface DuplicateListing {
62+ uid: string;
63+ source: string;
64+ url: string;
65+ broker_name: string;
66+ agency: string;
67+ price_label: string;
68+}
69+
70+export interface Poi { cat: string; name: string; dist_m: number }
71+
72+export interface Facets {
73+ cities: string[];
74+ sectors: string[];
75+ property_types: string[];
76+ sources: { source: string; n: number }[];
77+}
78+
79+export interface Source {
80+ id: string;
81+ name: string;
82+ url: string;
83+ listing_url?: string;
84+ coverage?: string;
85+ type?: string;
86+ connector?: string | null;
87+ status: string;
88+ active_listings: number;
89+ last_sync: number | null;
90+}
91+
92+export interface Stats {
93+ total: number;
94+ sources: number;
95+ cities: number;
96+ avg_price: number | null;
97+ min_price: number | null;
98+ max_price: number | null;
99+ recent_syncs?: {
100+ source: string; ts: number; found: number; added: number;
101+ updated: number; removed: number; ok: number; message: string;
102+ }[];
103+ qualite?: Quality;
104+}
105+
106+/** Data quality (completeness, quarantine, anomalies) — immoka/quality.py */
107+export interface Quality {
108+ actives: number;
109+ publiees: number;
110+ quarantaine: number;
111+ completude_moyenne: number | null;
112+ anomalies: Record<string, number>;
113+ par_source: {
114+ source: string; n: number; publiees: number;
115+ completude: number | null; anomalies: number;
116+ }[];
117+}
118+
119+// --- Source names (pretty labels) --------------------------------------------
120+const SOURCE_NAMES: Record<string, string> = {};
121+export function registerSourceNames(sources: Source[]) {
122+ for (const s of sources) SOURCE_NAMES[s.id] = s.name;
123+}
124+export function sourceName(id: string): string {
125+ if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];
126+ // readable fallback for generated RealtyPress sources (rp_ag_xxx)
127+ const base = id.replace(/^rp_ag_/, "").replace(/^rp_/, "");
128+ return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
129+}
130+
131+async function get<T>(path: string): Promise<T> {
132+ const ctrl = new AbortController();
133+ const timer = setTimeout(() => ctrl.abort(), 25000);
134+ try {
135+ const res = await fetch(path, { signal: ctrl.signal });
136+ if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
137+ return (await res.json()) as T;
138+ } finally {
139+ clearTimeout(timer);
140+ }
141+}
142+
143+export interface ListingFilters {
144+ city?: string;
145+ sector?: string;
146+ region?: string;
147+ property_type?: string;
148+ source?: string;
149+ price_min?: string;
150+ price_max?: string;
151+ bedrooms_min?: string;
152+ bathrooms_min?: string;
153+ area_min?: string;
154+ q?: string;
155+ sort?: string; // price_asc | price_desc | recent
156+}
157+
158+export function listingParams(f: ListingFilters): URLSearchParams {
159+ const params = new URLSearchParams();
160+ for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
161+ return params;
162+}
163+
164+export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {
165+ const params = listingParams(f);
166+ params.set("limit", String(limit));
167+ params.set("offset", String(offset));
168+ return get<{ total: number; count: number; listings: Listing[] }>(
169+ `/api/listings?${params}`);
170+}
171+
172+export const fetchListing = (uid: string) =>
173+ get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
174+export const fetchFacets = (city?: string) =>
175+ get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);
176+export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
177+export const fetchStats = () => get<Stats>("/api/stats");
178+
179+export interface SubAgency { name: string; count: number; sources: string[] }
180+export interface Franchise {
181+ franchise: string;
182+ total: number;
183+ sub_agencies: number;
184+ agencies: SubAgency[];
185+}
186+export const fetchAgencies = () =>
187+ get<{ franchises: Franchise[] }>("/api/agencies");
188+
189+// --- Formatting ---------------------------------------------------------------
190+export const fmtPrice = (p: number | null, label?: string) =>
191+ p != null
192+ ? "$" + p.toLocaleString("en-CA", { maximumFractionDigits: 0 })
193+ : label || "Price on request";
194+
195+export const fmtArea = (a: number | null): string | null =>
196+ a != null ? `${Math.round(a).toLocaleString("en-CA")} sq ft` : null;
197+
198+export const fmtDate = (ts: number): string =>
199+ new Date(ts * 1000).toLocaleDateString("en-CA", {
200+ day: "numeric", month: "long", year: "numeric",
201+ });
202+
203+/** 250 -> "250 m", 1240 -> "1.2 km" */
204+export const fmtDist = (m: number): string =>
205+ m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1)} km`;
206+
207+// -----------------------------------------------------------------------------
208+// Mortgage rates (immoka/mortgage) — real rates observed at the banks
209+// -----------------------------------------------------------------------------
210+export interface MortgageRate {
211+ provider: string;
212+ institution: string;
213+ product_key: string;
214+ product_name: string | null;
215+ rate_type: "fixed" | "variable" | "adjustable" | "other";
216+ term_months: number;
217+ kind: "posted" | "special";
218+ rate: number;
219+ apr: number | null;
220+ insured_status: "insured" | "insurable" | "uninsured" | "unknown";
221+ purpose: string;
222+ conditions: string | null;
223+ source_url: string | null;
224+ last_checked: number;
225+ age_minutes: number;
226+ stale: boolean;
227+}
228+
229+export interface MortgageBest extends MortgageRate {
230+ median_rate: number | null;
231+ institutions_count: number;
232+ per_institution: MortgageRate[];
233+}
234+
235+export interface MortgageMarket {
236+ rate_type: string;
237+ term_months: number;
238+ best: number;
239+ best_provider: string;
240+ best_institution: string;
241+ best_kind: string;
242+ median: number | null;
243+ spread: number | null;
244+ institutions_count: number;
245+ var_7d: number | null;
246+ var_30d: number | null;
247+ var_90d: number | null;
248+ lowest_6m: number | null;
249+}
250+
251+export interface MortgageIntelligence {
252+ products: MortgageMarket[];
253+ prime_rates: { institution: string; rate: number; product_name: string;
254+ age_minutes: number }[];
255+}
256+
257+export interface MortgageHistoryRow {
258+ provider: string; institution: string; product_name: string | null;
259+ kind: string; rate: number; insured_status: string;
260+ valid_from: number; valid_to: number | null; last_checked: number;
261+ source_url: string | null;
262+}
263+
264+export interface MortgageProviderHealth {
265+ provider: string; institution: string; source_url: string | null;
266+ level: "OK" | "WARNING" | "ERROR"; status: string | null;
267+ age_minutes: number; current_products: number; last_data_at: number | null;
268+}
269+
270+export interface MortgageRateSource {
271+ provider: string; institution: string; product_name: string | null;
272+ kind: string; rate: number; apr?: number | null; insured_status?: string;
273+ source_url: string | null; last_checked?: number;
274+ age_minutes: number; stale: boolean;
275+}
276+
277+export interface MortgageInsurance {
278+ required: boolean; eligible: boolean; premium: number; premium_rate: number;
279+ loan_before: number; total_mortgage: number; qc_tax: number;
280+ ltv: number | null; issues: string[];
281+}
282+
283+export interface MortgageCalc {
284+ inputs: {
285+ price: number; down_payment: number; down_payment_pct: number;
286+ rate: number; rate_type: string; term_months: number;
287+ amortization_years: number; frequency: string; compounding: string;
288+ };
289+ insurance: MortgageInsurance;
290+ principal: number;
291+ payment: number;
292+ payment_monthly_equivalent: number;
293+ qualifying: { rate: number; payment: number; note: string };
294+ term: {
295+ payment: number; frequency: string; payments_per_year: number;
296+ payments_in_term: number; annual_cost: number; principal_paid: number;
297+ interest_paid: number; balance_end_of_term: number; paid_off: boolean;
298+ };
299+ stress: { bump: number; rate: number; payment: number }[];
300+ renewal: {
301+ balance_at_renewal: number; remaining_amortization_years: number;
302+ scenarios: { bump: number; rate: number; payment: number }[];
303+ };
304+ payoff_years: number;
305+ rate_source: MortgageRateSource | null;
306+ annual: { year: number; payment: number; interest: number;
307+ principal: number; balance: number }[];
308+ ratios?: { gds: number | null; tds: number | null;
309+ gds_ok: boolean | null; tds_ok: boolean | null };
310+}
311+
312+export interface MortgageCalcInput {
313+ price: number;
314+ down_payment?: number;
315+ down_payment_pct?: number;
316+ amortization_years?: number;
317+ term_months?: number;
318+ frequency?: string;
319+ rate_type?: "fixed" | "variable";
320+ rate?: number;
321+ income?: number;
322+ property_tax_monthly?: number;
323+ heating_monthly?: number;
324+ condo_fees_monthly?: number;
325+ other_debts_monthly?: number;
326+}
327+
328+async function post<T>(path: string, body: unknown): Promise<T> {
329+ const ctrl = new AbortController();
330+ const timer = setTimeout(() => ctrl.abort(), 25000);
331+ try {
332+ const res = await fetch(path, {
333+ method: "POST",
334+ headers: { "Content-Type": "application/json" },
335+ body: JSON.stringify(body),
336+ signal: ctrl.signal,
337+ });
338+ if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
339+ return (await res.json()) as T;
340+ } finally {
341+ clearTimeout(timer);
342+ }
343+}
344+
345+export const fetchMortgageIntelligence = () =>
346+ get<MortgageIntelligence>("/api/mortgage/intelligence");
347+
348+export const fetchMortgageBest = (rateType: string, termMonths: number) =>
349+ get<MortgageBest>(
350+ `/api/mortgage/rates/best?rate_type=${rateType}&term_months=${termMonths}`);
351+
352+export const fetchMortgageHistory = (
353+ rateType: string, termMonths: number, days = 365, kind?: string,
354+) =>
355+ get<{ count: number; days: number; history: MortgageHistoryRow[] }>(
356+ `/api/mortgage/rates/history?rate_type=${rateType}` +
357+ `&term_months=${termMonths}&days=${days}${kind ? `&kind=${kind}` : ""}`);
358+
359+export const fetchMortgageProviders = () =>
360+ get<{ providers: MortgageProviderHealth[]; registered: string[] }>(
361+ "/api/mortgage/providers");
362+
363+export const calculateMortgage = (input: MortgageCalcInput) =>
364+ post<MortgageCalc>("/api/mortgage/calculate", input);
365+
366+/** 4.19 -> "4.19%" */
367+export const fmtRate = (r: number | null | undefined): string =>
368+ r == null ? "—" : `${r.toFixed(2)}%`;
369+
370+// -----------------------------------------------------------------------------
371+// Nearby places (Mapbox Search Box + OSM) — listing page block
372+// -----------------------------------------------------------------------------
373+export interface CommerceItem {
374+ id: string; commerce: string; nom: string; adresse: string;
375+ dist_m: number; lat: number; lng: number;
376+}
377+
378+export interface CommercesNearby {
379+ n: number; commerces: CommerceItem[]; transit?: CommerceItem[];
380+}
381+
382+export const fetchCommerces = (lat: number, lng: number) =>
383+ get<CommercesNearby>(`/api/commerces?lat=${lat}&lng=${lng}`);
added frontend/src/components/AmenityIco.tsx +112 −0
@@ -0,0 +1,112 @@
1+// -----------------------------------------------------------------------------
2+// Groupe KA — composant partagé (lou-ka / immo-ka)
3+// components/AmenityIco.tsx : icône contextuelle d'une commodité ou inclusion.
4+// Fini l'icône unique répétée dix fois : chaque libellé est associé par
5+// mots-clés (accents neutralisés) à l'un des ~30 pictos maison — trait 1.8,
6+// 24×24, currentColor. Repli : ✓ (confirmé) ou étoile (mention libre).
7+// -----------------------------------------------------------------------------
8+import { ReactNode } from "react";
9+
10+const ICONS: Record<string, ReactNode> = {
11+ heat: (<><rect x="4" y="9" width="16" height="9" rx="2" /><path d="M8 9v9M12 9v9M16 9v9M7.6 3.5c0 1.6 1.2 1.6 1.2 3.2M11.4 3.5c0 1.6 1.2 1.6 1.2 3.2M15.2 3.5c0 1.6 1.2 1.6 1.2 3.2" /></>),
12+ bolt: <path d="M13 2.5 4.5 13.5H11l-1.5 8 8.5-11H11.5l1.5-8z" />,
13+ droplet: (<><path d="M12 3.5S6.2 9.8 6.2 13.6a5.8 5.8 0 0 0 11.6 0C17.8 9.8 12 3.5 12 3.5z" /><path d="M9.4 14a2.7 2.7 0 0 0 2.1 2.6" /></>),
14+ wifi: <path d="M3.5 9.5a13 13 0 0 1 17 0M6.5 13a8.5 8.5 0 0 1 11 0M9.5 16.4a4.4 4.4 0 0 1 5 0M12 19.6h.01" />,
15+ dishwasher: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><path d="M4.5 8.5h15M8 12.5V17M12 12.5V17M16 12.5V17M7.5 5.7h.01M10.5 5.7h.01" /></>),
16+ washer: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><circle cx="12" cy="14" r="4.2" /><path d="M4.5 7.5h15M16.4 5.2h.01M9.2 14c1 .8 1.9.8 2.8 0s1.9-.8 2.8 0" /></>),
17+ fridge: (<><rect x="6.5" y="2.5" width="11" height="19" rx="2" /><path d="M6.5 9.5h11M9.5 5.5v2M9.5 12.5V16" /></>),
18+ snow: <path d="M12 2.5v19M3.8 7.25l16.4 9.5M20.2 7.25 3.8 16.75" />,
19+ elevator: (<><rect x="4.5" y="3" width="15" height="18" rx="2" /><path d="M12 3v18M7 11l1.5-1.8L10 11M14 13l1.5 1.8L17 13" /></>),
20+ balcony: <path d="M4 11.5h16M5 11.5V20M19 11.5V20M9.5 11.5V20M14.5 11.5V20M4 20h16M7 11.5V6.5a5 5 0 0 1 10 0v5" />,
21+ pool: <path d="M9 4.5v9M13.5 4.5v9M9 7.5h4.5M3 16.5c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 20c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />,
22+ gym: <path d="M6.7 6.7v10.6M17.3 6.7v10.6M3.5 9.2v5.6M20.5 9.2v5.6M6.7 12h10.6" />,
23+ laundry: <path d="M4 9.5h16l-1.8 10a1.8 1.8 0 0 1-1.8 1.5H7.6a1.8 1.8 0 0 1-1.8-1.5zM8 9.5 12 3l4 6.5M9.3 13.5v3.5M12 13.5v3.5M14.7 13.5v3.5" />,
24+ box: <path d="M3.5 8 12 3.5 20.5 8v8L12 20.5 3.5 16zM3.5 8 12 12.5 20.5 8M12 12.5v8" />,
25+ parking: (<><rect x="3.5" y="3.5" width="17" height="17" rx="3.5" /><path d="M9.5 16.5v-9H13a2.9 2.9 0 0 1 0 5.8H9.5" /></>),
26+ garage: <path d="M3.5 20V9.5L12 4l8.5 5.5V20M7 20v-6.5h10V20M7 16.8h10" />,
27+ sofa: <path d="M5.5 11V8.8A2.8 2.8 0 0 1 8.3 6h7.4a2.8 2.8 0 0 1 2.8 2.8V11M3.5 13.7a2.2 2.2 0 0 1 4.4 0v1h8.2v-1a2.2 2.2 0 0 1 4.4 0V17.5h-17zM5.5 17.5v1.8M18.5 17.5v1.8" />,
28+ nosmoke: <path d="M4 4l16 16M14.5 13H4v3h6.5M17.5 13H20v3h-1" />,
29+ paw: (<><circle cx="8.2" cy="7.8" r="1.7" /><circle cx="15.8" cy="7.8" r="1.7" /><circle cx="4.8" cy="12" r="1.6" /><circle cx="19.2" cy="12" r="1.6" /><path d="M12 11c2.8 0 5.2 2.3 5.2 4.8 0 1.8-1.4 3-3.2 3-.8 0-1.3-.3-2-.3s-1.2.3-2 .3c-1.8 0-3.2-1.2-3.2-3C6.8 13.3 9.2 11 12 11z" /></>),
30+ fire: (<><path d="M4 4.5h16M5.5 4.5V19.5M18.5 4.5V19.5M4 19.5h16" /><path d="M12 8.5c-1.8 2-3 3.4-3 5.3a3 3 0 0 0 6 0c0-1.9-1.2-3.3-3-5.3z" /></>),
31+ tree: <path d="M12 3 7.2 9.5h2.4L5.5 15.5h13L14.4 9.5h2.4zM12 15.5V21" />,
32+ spa: <path d="M8 3.5c0 1.7-1.4 2-1.4 3.7S8 9.5 8 11M12.5 3.5c0 1.7-1.4 2-1.4 3.7s1.4 2.3 1.4 3.8M17 3.5c0 1.7-1.4 2-1.4 3.7S17 9.5 17 11M3.5 15.5c1.4 1.3 2.8 1.3 4.2 0s2.9-1.3 4.3 0 2.8 1.3 4.2 0 2.9-1.3 4.3 0M3.5 19.5c1.4 1.3 2.8 1.3 4.2 0s2.9-1.3 4.3 0 2.8 1.3 4.2 0 2.9-1.3 4.3 0" />,
33+ shield: (<><path d="M12 3 5 6v5.5c0 4.4 3 7.6 7 9.5 4-1.9 7-5.1 7-9.5V6z" /><path d="m9 11.5 2.2 2.2L15.5 9" /></>),
34+ eye: (<><path d="M2.5 12S6.3 5.8 12 5.8 21.5 12 21.5 12 17.7 18.2 12 18.2 2.5 12 2.5 12z" /><circle cx="12" cy="12" r="2.8" /></>),
35+ waves: <path d="M3 9c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 14c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0M3 19c1.5 1.3 3 1.3 4.5 0s3-1.3 4.5 0 3 1.3 4.5 0 3-1.3 4.5 0" />,
36+ calendar: (<><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8.5 3v4M15.5 3v4M8 14h.01M12 14h.01M16 14h.01" /></>),
37+ ruler: <path d="M3.5 16.2 16.2 3.5l4.3 4.3L7.8 20.5zM7.8 15.9l1.8 1.8M10.6 13.1l1.8 1.8M13.4 10.3l1.8 1.8M16.2 7.5 18 9.3" />,
38+ bed: <path d="M3.5 18V6.5M3.5 13.5h17V18M3.5 10h6.5v3.5M20.5 13.5v-1a3 3 0 0 0-3-3H10" />,
39+ bath: <path d="M4 12.5h16v1.7a4.8 4.8 0 0 1-4.8 4.8H8.8A4.8 4.8 0 0 1 4 14.2zM6 12.5V6a2.5 2.5 0 0 1 4.6-1.3M7 19l-1 1.8M17 19l1 1.8" />,
40+ people: (<><circle cx="9" cy="8" r="3.2" /><path d="M3.5 20.5c0-3.1 2.4-5.2 5.5-5.2s5.5 2.1 5.5 5.2M15.5 5.4a3.2 3.2 0 0 1 0 5.2M20.5 20.5c0-2.7-1.8-4.6-4.3-5.1" /></>),
41+ stairs: <path d="M3.5 20.5h4.2v-4.2h4.2v-4.2h4.2V7.9h4.4" />,
42+ door: (<><rect x="6" y="3" width="12" height="18" rx="1.5" /><path d="M14.8 12h.01M4 21h16" /></>),
43+ home: <path d="m3.5 11 8.5-7 8.5 7M6 9.5V20h12V9.5" />,
44+ sun: (<><circle cx="12" cy="12" r="4" /><path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M18.7 5.3l-1.8 1.8M7.1 16.9l-1.8 1.8" /></>),
45+ check: <path d="m4.5 12.5 5.3 5.3L19.5 6.5" />,
46+ spark: <path d="M12 3.5 13.9 10l6.6 2-6.6 2L12 20.5 10.1 14l-6.6-2 6.6-2z" />,
47+};
48+
49+/** [motif (sur libellé minuscule sans accents), clé d'icône] — ordre = priorité */
50+const RULES: [RegExp, string][] = [
51+ [/lave-?vaisselle/, "dishwasher"],
52+ [/laveuse|secheuse|lessive/, "washer"],
53+ [/buanderie/, "laundry"],
54+ [/electromenager|frigo|refrigerateur|cuisiniere|four incl|poele/, "fridge"],
55+ [/climatis|air clim|thermopompe|echangeur d.air|\ba\/?c\b/, "snow"],
56+ [/chauff/, "heat"],
57+ [/eau chaude/, "droplet"],
58+ [/electric|eclair|hydro/, "bolt"],
59+ [/internet|wi-?fi|cablodistribution|\bcable\b|fibre/, "wifi"],
60+ [/ascenseur/, "elevator"],
61+ [/balcon|terrasse|patio|loggia|veranda/, "balcony"],
62+ [/piscine/, "pool"],
63+ [/gym|salle d.entrainement|exercice/, "gym"],
64+ [/rangement|locker|entreposage|walk-?in|penderie/, "box"],
65+ [/garage/, "garage"],
66+ [/stationnement|parking|abri d.auto/, "parking"],
67+ [/meuble/, "sofa"],
68+ [/non-?fumeur|sans fumee/, "nosmoke"],
69+ [/animau|chat|chien|\bpet\b/, "paw"],
70+ [/foyer|cheminee|poele a bois/, "fire"],
71+ [/spa\b|jacuzzi|sauna|bain tourbillon/, "spa"],
72+ [/securite|surveill|camera|alarme|interphone|concierge|portier/, "shield"],
73+ [/\bvue\b|panoram/, "eye"],
74+ [/bord de l.eau|acces au lac|\blac\b|riviere|plage|navigable/, "waves"],
75+ [/cour|jardin|arbre|boise|verdure|gazon|amenagement paysager/, "tree"],
76+ [/ensoleill|luminosite|lumineux|solarium/, "sun"],
77+ [/dispo|libre |libre$/, "calendar"],
78+ [/pi2|pieds carres|superficie|\bm2\b/, "ruler"],
79+ [/chambre/, "bed"],
80+ [/salle[s]? de bain|salle[s]? d.eau|douche|\bsdb\b/, "bath"],
81+ [/occupant|personne|colocataire/, "people"],
82+ [/etage|niveau|escalier|mezzanine/, "stairs"],
83+ [/studio|loft|(^|\s)\d ?1\/2|½/, "door"],
84+ [/maison|plain-?pied|unifamiliale/, "home"],
85+ [/egout|septique|fosse/, "waves"],
86+ [/aqueduc|approvisionnement en eau|puits/, "droplet"],
87+ [/construction|neuve|fondation|toiture|revetement|renov/, "home"],
88+ [/commerce|zonage|usage/, "box"],
89+];
90+
91+/** minuscules + accents neutralisés, pour un appariement robuste */
92+const norm = (s: string) =>
93+ s.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
94+
95+export function amenityKey(label: string, fallback = "check"): string {
96+ const n = norm(label);
97+ for (const [re, key] of RULES) if (re.test(n)) return key;
98+ return fallback;
99+}
100+
101+export default function AmenityIco({ label, size = 16, fallback = "check" }:
102+ { label: string; size?: number; fallback?: string }) {
103+ return (
104+ <svg
105+ width={size} height={size} viewBox="0 0 24 24" aria-hidden="true"
106+ fill="none" stroke="currentColor" strokeWidth={1.8}
107+ strokeLinecap="round" strokeLinejoin="round"
108+ >
109+ {ICONS[amenityKey(label, fallback)] ?? ICONS.check}
110+ </svg>
111+ );
112+}
added frontend/src/components/Financing.tsx +369 −0
@@ -0,0 +1,369 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// components/Financing.tsx : “Finance this home” (listing page) —
5+// Canadian mortgage calculator plugged into the REAL observed rates
6+// (immoka/mortgage). Semi-annual compounding for fixed rates, CMHC shown
7+// separately, stress test, per-bank comparator, rate history. Every rate
8+// shows its provenance and freshness.
9+// -----------------------------------------------------------------------------
10+import { useEffect, useMemo, useRef, useState } from "react";
11+import { Link } from "react-router-dom";
12+import {
13+ MortgageBest, MortgageCalc, calculateMortgage, fetchMortgageBest,
14+ fmtPrice, fmtRate,
15+} from "../api";
16+import RateHistory from "./RateHistory";
17+
18+const FREQS: [string, string][] = [
19+ ["monthly", "Monthly"],
20+ ["semimonthly", "Semi-monthly (24/yr)"],
21+ ["biweekly", "Bi-weekly"],
22+ ["accelerated-biweekly", "Accelerated bi-weekly"],
23+ ["weekly", "Weekly"],
24+ ["accelerated-weekly", "Accelerated weekly"],
25+];
26+const TERMS: [number, string][] = [
27+ [12, "1 year"], [24, "2 years"], [36, "3 years"], [48, "4 years"],
28+ [60, "5 years"], [84, "7 years"], [120, "10 years"],
29+];
30+const KIND_EN: Record<string, string> = {
31+ posted: "posted rate", special: "special offer",
32+};
33+const INSURED_EN: Record<string, string> = {
34+ insured: "insured", insurable: "insurable", uninsured: "uninsured",
35+ unknown: "",
36+};
37+
38+const nf = (n: number) => n.toLocaleString("en-CA", { maximumFractionDigits: 0 });
39+const money = (n: number | null | undefined) =>
40+ n == null ? "—" : "$" + n.toLocaleString("en-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
41+
42+/** Readable freshness of a rate observation. */
43+function freshness(ageMinutes: number): string {
44+ if (ageMinutes < 60) return `${ageMinutes} min ago`;
45+ if (ageMinutes < 48 * 60) return `${Math.round(ageMinutes / 60)} h ago`;
46+ return `${Math.round(ageMinutes / 1440)} d ago`;
47+}
48+
49+export default function Financing({ price: askingPrice }:
50+ { price: number | null }) {
51+ const [price, setPrice] = useState<number>(askingPrice ?? 0);
52+ const [down, setDown] = useState<number>(Math.round((askingPrice ?? 0) * 0.2));
53+ const [amort, setAmort] = useState(25);
54+ const [term, setTerm] = useState(60);
55+ const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");
56+ const [freq, setFreq] = useState("monthly");
57+ const [res, setRes] = useState<MortgageCalc | null>(null);
58+ const [err, setErr] = useState<string | null>(null);
59+ const [best, setBest] = useState<MortgageBest | null>(null);
60+ const timer = useRef<number>();
61+
62+ const downPct = price > 0 ? (down / price) * 100 : 0;
63+ const setDownPct = (pct: number) =>
64+ setDown(Math.round((price * Math.min(99, Math.max(0, pct))) / 100));
65+
66+ // debounced recompute: the rates come from the engine, never the browser
67+ useEffect(() => {
68+ if (!price || price <= 0 || down < 0 || down >= price) { setRes(null); return; }
69+ window.clearTimeout(timer.current);
70+ timer.current = window.setTimeout(() => {
71+ calculateMortgage({
72+ price, down_payment: down, amortization_years: amort,
73+ term_months: term, frequency: freq, rate_type: rateType,
74+ })
75+ .then((r) => { setRes(r); setErr(null); })
76+ .catch(() => setErr("Rates momentarily unavailable — try again later."));
77+ }, 350);
78+ return () => window.clearTimeout(timer.current);
79+ }, [price, down, amort, term, rateType, freq]);
80+
81+ // per-bank comparator (same type/term as the scenario)
82+ useEffect(() => {
83+ setBest(null);
84+ fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));
85+ }, [rateType, term]);
86+
87+ const monthlyCost = useMemo(() => {
88+ if (!res) return null;
89+ const parts: { k: string; v: number }[] = [
90+ { k: "Mortgage payment (monthly equivalent)", v: res.payment_monthly_equivalent },
91+ ];
92+ return { parts, total: parts.reduce((s, p) => s + p.v, 0) };
93+ }, [res]);
94+
95+ if (askingPrice == null || askingPrice <= 0) return null;
96+ const src = res?.rate_source ?? null;
97+ const ins = res?.insurance;
98+
99+ return (
100+ <section className="f-bloc f-mtg" id="financing">
101+ <h2>Finance this home</h2>
102+ <p className="mtg-intro">
103+ Simulation using the <b>real rates published by Canadian banks</b>,
104+ collected continuously by House-Ka — semi-annual compounding (the
105+ Canadian standard) for fixed rates.{" "}
106+ <Link to="/rates">See all rates ↗</Link>
107+ </p>
108+
109+ <div className="mtg-form">
110+ <label>
111+ <span>Price</span>
112+ <input type="number" inputMode="numeric" min={1} value={price || ""}
113+ onChange={(e) => setPrice(Number(e.target.value) || 0)} />
114+ </label>
115+ <label>
116+ <span>Down payment ($)</span>
117+ <input type="number" inputMode="numeric" min={0} value={down || ""}
118+ onChange={(e) => setDown(Number(e.target.value) || 0)} />
119+ </label>
120+ <label>
121+ <span>Down payment (%)</span>
122+ <input type="number" inputMode="decimal" min={0} max={99} step={1}
123+ value={downPct ? Math.round(downPct * 10) / 10 : ""}
124+ onChange={(e) => setDownPct(Number(e.target.value) || 0)} />
125+ </label>
126+ <label>
127+ <span>Amortization</span>
128+ <select value={amort} onChange={(e) => setAmort(Number(e.target.value))}>
129+ {[10, 15, 20, 25, 30].map((a) => <option key={a} value={a}>{a} years</option>)}
130+ </select>
131+ </label>
132+ <label>
133+ <span>Term</span>
134+ <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>
135+ {TERMS.map(([m, l]) => <option key={m} value={m}>{l}</option>)}
136+ </select>
137+ </label>
138+ <label>
139+ <span>Rate type</span>
140+ <select value={rateType}
141+ onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>
142+ <option value="fixed">Fixed</option>
143+ <option value="variable">Variable</option>
144+ </select>
145+ </label>
146+ <label>
147+ <span>Frequency</span>
148+ <select value={freq} onChange={(e) => setFreq(e.target.value)}>
149+ {FREQS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
150+ </select>
151+ </label>
152+ </div>
153+
154+ {err && <p className="mtg-err">{err}</p>}
155+
156+ {res && (
157+ <>
158+ <div className="mtg-resultat">
159+ <div className="mtg-kpi">
160+ <span className="mtg-kpi-k">Payment</span>
161+ <span className="mtg-kpi-v">{money(res.payment)}</span>
162+ <span className="mtg-kpi-sub">
163+ {FREQS.find(([v]) => v === freq)?.[1].toLowerCase()}
164+ {freq !== "monthly" && ` · equiv. ${money(res.payment_monthly_equivalent)}/month`}
165+ </span>
166+ </div>
167+ <div className="mtg-kpi">
168+ <span className="mtg-kpi-k">Rate used</span>
169+ <span className="mtg-kpi-v">{fmtRate(res.inputs.rate)}</span>
170+ {src && (
171+ <span className="mtg-kpi-sub">
172+ {src.institution} — {src.product_name}{" "}
173+ ({KIND_EN[src.kind] ?? src.kind}
174+ {INSURED_EN[src.insured_status ?? "unknown"]
175+ ? `, ${INSURED_EN[src.insured_status ?? "unknown"]}` : ""})
176+ </span>
177+ )}
178+ </div>
179+ <div className="mtg-kpi">
180+ <span className="mtg-kpi-k">Mortgage</span>
181+ <span className="mtg-kpi-v">{fmtPrice(res.principal)}</span>
182+ <span className="mtg-kpi-sub">
183+ down payment {fmtPrice(res.inputs.down_payment)} ({res.inputs.down_payment_pct.toLocaleString("en-CA")}%)
184+ </span>
185+ </div>
186+ <div className="mtg-kpi">
187+ <span className="mtg-kpi-k">Stress test</span>
188+ <span className="mtg-kpi-v">{money(res.qualifying.payment)}</span>
189+ <span className="mtg-kpi-sub">qualifying at {fmtRate(res.qualifying.rate)}</span>
190+ </div>
191+ </div>
192+
193+ {src && (
194+ <p className="mtg-source fine">
195+ Rate observed at <b>{src.institution}</b> {freshness(src.age_minutes)}
196+ {src.stale && " ⚠ data older than 24 h"} ·{" "}
197+ {src.source_url && (
198+ <a href={src.source_url} target="_blank" rel="noopener noreferrer">
199+ official source ↗
200+ </a>
201+ )}
202+ </p>
203+ )}
204+
205+ {ins && ins.required && (
206+ <div className={`mtg-schl ${ins.eligible ? "" : "mtg-schl-no"}`}>
207+ <b>Mortgage default insurance (CMHC)</b>
208+ {ins.eligible ? (
209+ <ul>
210+ <li>Premium: <b>{fmtPrice(ins.premium)}</b> ({ins.premium_rate.toLocaleString("en-CA")}% of the loan, added to the mortgage)</li>
211+ <li>Loan-to-value ratio: {ins.ltv?.toLocaleString("en-CA")}%</li>
212+ <li>Provincial sales tax on the premium may apply and is due at closing.</li>
213+ </ul>
214+ ) : null}
215+ {ins.issues.map((i, k) => <p className="mtg-issue" key={k}>⚠ {i}</p>)}
216+ </div>
217+ )}
218+
219+ {monthlyCost && (
220+ <details className="mtg-detail">
221+ <summary>Estimated real monthly cost</summary>
222+ <div className="dtable">
223+ {monthlyCost.parts.map((p) => (
224+ <div className="drow" key={p.k}><span>{p.k}</span><b>{money(p.v)}</b></div>
225+ ))}
226+ <div className="drow mtg-total"><span>Estimated total</span><b>{money(monthlyCost.total)}</b></div>
227+ </div>
228+ <p className="fine">
229+ Property taxes, heating, electricity, home insurance and condo
230+ fees are extra.
231+ </p>
232+ </details>
233+ )}
234+
235+ <details className="mtg-detail">
236+ <summary>What if rates rise? (stress test)</summary>
237+ <div className="dtable">
238+ {res.stress.map((s) => (
239+ <div className="drow" key={s.bump}>
240+ <span>{s.bump === 0 ? "Current rate" : `+${s.bump} point${s.bump > 1 ? "s" : ""}`} — {fmtRate(s.rate)}</span>
241+ <b>{money(s.payment)}</b>
242+ </div>
243+ ))}
244+ </div>
245+ <p className="fine">{res.qualifying.note}</p>
246+ </details>
247+
248+ <details className="mtg-detail">
249+ <summary>At renewal ({TERMS.find(([m]) => m === term)?.[1]})</summary>
250+ <p className="fine">
251+ Balance remaining at maturity: <b>{fmtPrice(res.renewal.balance_at_renewal)}</b>{" "}
252+ (remaining amortization {res.renewal.remaining_amortization_years} years).
253+ Interest paid during the term: {fmtPrice(res.term.interest_paid)}.
254+ </p>
255+ <div className="dtable">
256+ {res.renewal.scenarios.map((s) => (
257+ <div className="drow" key={s.bump}>
258+ <span>Renewed at {fmtRate(s.rate)} ({s.bump >= 0 ? "+" : ""}{s.bump} pt)</span>
259+ <b>{money(s.payment)}</b>
260+ </div>
261+ ))}
262+ </div>
263+ </details>
264+
265+ {best && best.per_institution.length > 1 && (
266+ <details className="mtg-detail">
267+ <summary>Compare the banks ({best.institutions_count} institutions)</summary>
268+ <div className="rooms-wrap">
269+ <table className="rooms mtg-comp">
270+ <thead>
271+ <tr><th>Institution</th><th>Rate</th><th>Kind</th><th>Payment</th><th>Freshness</th></tr>
272+ </thead>
273+ <tbody>
274+ {best.per_institution.map((r) => (
275+ <tr key={r.provider}>
276+ <td>
277+ {r.source_url
278+ ? <a href={r.source_url} target="_blank" rel="noopener noreferrer">{r.institution}</a>
279+ : r.institution}
280+ </td>
281+ <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (APR ${fmtRate(r.apr)})` : ""}</td>
282+ <td>
283+ {KIND_EN[r.kind]}
284+ {INSURED_EN[r.insured_status] ? ` · ${INSURED_EN[r.insured_status]}` : ""}
285+ </td>
286+ <td>{res.principal > 0 ? money(estimatePayment(res, r.rate)) : "—"}</td>
287+ <td className={r.stale ? "mtg-stale" : ""}>{freshness(r.age_minutes)}</td>
288+ </tr>
289+ ))}
290+ </tbody>
291+ </table>
292+ </div>
293+ <p className="fine">
294+ Comparable products only (same type, same term) — a posted rate
295+ and a special offer are not the same thing, hence the Kind
296+ column. Payments estimated on your scenario.
297+ </p>
298+ </details>
299+ )}
300+
301+ <details className="mtg-detail">
302+ <summary>Rate history ({rateType} {TERMS.find(([m]) => m === term)?.[1]})</summary>
303+ <RateHistory rateType={rateType} termMonths={term} />
304+ </details>
305+
306+ <details className="mtg-detail">
307+ <summary>Year-by-year amortization</summary>
308+ <div className="rooms-wrap">
309+ <table className="rooms">
310+ <thead>
311+ <tr><th>Year</th><th>Interest</th><th>Principal</th><th>Balance</th></tr>
312+ </thead>
313+ <tbody>
314+ {res.annual.map((a) => (
315+ <tr key={a.year}>
316+ <td>{a.year}</td>
317+ <td>${nf(a.interest)}</td>
318+ <td>${nf(a.principal)}</td>
319+ <td>${nf(a.balance)}</td>
320+ </tr>
321+ ))}
322+ </tbody>
323+ </table>
324+ </div>
325+ {res.payoff_years < res.inputs.amortization_years && (
326+ <p className="fine">
327+ With the accelerated frequency chosen, the loan is paid off in{" "}
328+ <b>{res.payoff_years} years</b> instead of {res.inputs.amortization_years}.
329+ </p>
330+ )}
331+ </details>
332+ </>
333+ )}
334+
335+ <p className="fine">
336+ Indicative tool only — neither a financing offer nor a pre-approval.
337+ The rates shown are those published by the institutions (source and
338+ freshness indicated); confirm with the bank or a mortgage broker.
339+ </p>
340+ </section>
341+ );
342+}
343+
344+/** Estimated payment at another bank's rate, same scenario (frontend
345+ * approximation via the annuity factor — the scenario's official numbers
346+ * always come from the engine). */
347+function estimatePayment(res: MortgageCalc, rate: number): number {
348+ const { amortization_years, frequency, compounding } = res.inputs;
349+ const f = ({ monthly: 12, semimonthly: 24, biweekly: 26,
350+ "accelerated-biweekly": 26, weekly: 52, "accelerated-weekly": 52 } as
351+ Record<string, number>)[frequency] ?? 12;
352+ const per = (pct: number, k: number) =>
353+ compounding === "monthly"
354+ ? Math.pow(1 + pct / 100 / 12, 12 / k) - 1
355+ : Math.pow(1 + pct / 100 / 2, 2 / k) - 1;
356+ const pay = (pct: number) => {
357+ if (frequency.startsWith("accelerated")) {
358+ const m = pay0(pct, 12);
359+ return Math.round((m / (frequency === "accelerated-biweekly" ? 2 : 4)) * 100) / 100;
360+ }
361+ return pay0(pct, f);
362+ };
363+ const pay0 = (pct: number, k: number) => {
364+ const i = per(pct, k);
365+ const n = Math.round(amortization_years * k);
366+ return Math.round(((res.principal * i) / (1 - Math.pow(1 + i, -n))) * 100) / 100;
367+ };
368+ return pay(rate);
369+}
added frontend/src/components/Icons.tsx +188 −0
@@ -0,0 +1,188 @@
1+// -----------------------------------------------------------------------------
2+// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/Icons.tsx : iconographie MAISON — traits 1,7 px sur grille 24,
5+// dessinée pour Immo-Ka (zéro emoji, zéro pack générique). Chaque icône est
6+// un tracé « stroke » net qui hérite de la couleur du texte (currentColor).
7+// -----------------------------------------------------------------------------
8+import { ReactNode } from "react";
9+
10+const P: Record<string, ReactNode> = {
11+ // --- navigation -----------------------------------------------------------
12+ home: (<>
13+ <path d="M3.5 10.6 12 3.4l8.5 7.2" />
14+ <path d="M5.6 9.4V20h12.8V9.4" />
15+ <path d="M10 20v-5.6h4V20" />
16+ </>),
17+ map: (<>
18+ <path d="M3.2 6.4 9 4.2l6 2.2 5.8-2.2v13.4L15 19.8l-6-2.2-5.8 2.2z" />
19+ <path d="M9 4.2v13.4M15 6.4v13.4" />
20+ </>),
21+ chart: (<>
22+ <path d="M4 20h16" />
23+ <path d="M7.2 20v-5.6M12 20V8.8M16.8 20V12" />
24+ </>),
25+ building: (<>
26+ <path d="M5 20V5.6A1.6 1.6 0 0 1 6.6 4h6.2a1.6 1.6 0 0 1 1.6 1.6V20" />
27+ <path d="M14.4 9.4h3.4A1.6 1.6 0 0 1 19.4 11v9" />
28+ <path d="M3.2 20h17.6" />
29+ <path d="M8 8h2.4M8 11.6h2.4M8 15.2h2.4M16.4 13h.9M16.4 16.2h.9" />
30+ </>),
31+
32+ // --- specs propriété --------------------------------------------------------
33+ bed: (<>
34+ <path d="M3.4 6.8V18.6" />
35+ <path d="M3.4 15.4h17.2v3.2" />
36+ <path d="M3.4 12.2h6.2v3.2" />
37+ <circle cx="6.5" cy="9.9" r="1.35" />
38+ <path d="M11.4 12.2h5.8a3.4 3.4 0 0 1 3.4 3.2" />
39+ </>),
40+ bath: (<>
41+ <path d="M3.6 12.4h16.8v1.4a5.2 5.2 0 0 1-5.2 5.2H8.8a5.2 5.2 0 0 1-5.2-5.2z" />
42+ <path d="M5.8 12.4V5.9a2.1 2.1 0 0 1 4-1" />
43+ <path d="m7 19.4-1 2.1M17 19.4l1 2.1" />
44+ </>),
45+ drop: (<>
46+ <path d="M12 3.6s6 6.4 6 10.6a6 6 0 1 1-12 0C6 10 12 3.6 12 3.6z" />
47+ <path d="M9.4 14.2a2.7 2.7 0 0 0 2 2.6" />
48+ </>),
49+ area: (<>
50+ <path d="M4.4 19.6 19.6 4.4" />
51+ <path d="M4.4 14v5.6H10" />
52+ <path d="M19.6 10V4.4H14" />
53+ </>),
54+ land: (<>
55+ <path d="M4.4 9.6v9.8M9.5 9.6v9.8M14.5 9.6v9.8M19.6 9.6v9.8" />
56+ <path d="M3 12.6h18M3 16.4h18" />
57+ <path d="M4.4 9.6 12 5.2l7.6 4.4" />
58+ </>),
59+ calendar: (<>
60+ <rect x="4" y="5.6" width="16" height="14.8" rx="2" />
61+ <path d="M4 10.2h16M8.2 3.4v4M15.8 3.4v4" />
62+ </>),
63+ tag: (<>
64+ <path d="m12.9 3.6 7.5 7.5a1.8 1.8 0 0 1 0 2.5l-6.8 6.8a1.8 1.8 0 0 1-2.5 0L3.6 12.9V3.6z" />
65+ <circle cx="8" cy="8" r="1.5" />
66+ </>),
67+ camera: (<>
68+ <rect x="3.4" y="7" width="17.2" height="13" rx="2" />
69+ <path d="M8.6 7 10 4.4h4L15.4 7" />
70+ <circle cx="12" cy="13.2" r="3.4" />
71+ </>),
72+
73+ // --- actions / états ----------------------------------------------------------
74+ search: (<>
75+ <circle cx="10.6" cy="10.6" r="6.2" />
76+ <path d="m15.3 15.3 5.3 5.3" />
77+ </>),
78+ arrow: (<>
79+ <path d="M4 12h15.2" />
80+ <path d="m13.8 6.4 5.4 5.6-5.4 5.6" />
81+ </>),
82+ check: (<path d="m5 12.8 4.3 4.4L19 7.4" />),
83+ alert: (<>
84+ <path d="M12 4.2 2.9 19.4h18.2z" />
85+ <path d="M12 10.2v4.4" />
86+ <path d="M12 17.4v.05" />
87+ </>),
88+ phone: (<path d="M5.2 4h3.6L10.4 8.4 8.3 10.1a12.5 12.5 0 0 0 5.6 5.6l1.7-2.1 4.4 1.6v3.6a1.8 1.8 0 0 1-1.9 1.8A16.4 16.4 0 0 1 3.4 5.9 1.8 1.8 0 0 1 5.2 4z" />),
89+ trendup: (<>
90+ <path d="m3.6 17.4 6-6.4 4 3.6 6.8-7.8" />
91+ <path d="M15.6 6.8h4.8v4.8" />
92+ </>),
93+ trenddown: (<>
94+ <path d="m3.6 6.8 6 6.4 4-3.6 6.8 7.8" />
95+ <path d="M15.6 17.4h4.8v-4.8" />
96+ </>),
97+ external: (<>
98+ <path d="M14.4 4h5.6v5.6" />
99+ <path d="M20 4 11.4 12.6" />
100+ <path d="M18.6 13.6V18a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V7.4a2 2 0 0 1 2-2h4.4" />
101+ </>),
102+ pin: (<>
103+ <path d="M12 21.2S5.4 15.7 5.4 10.8a6.6 6.6 0 0 1 13.2 0c0 4.9-6.6 10.4-6.6 10.4z" />
104+ <circle cx="12" cy="10.6" r="2.3" />
105+ </>),
106+ download: (<>
107+ <path d="M12 3.6v11.2" />
108+ <path d="m7.2 10.4 4.8 4.8 4.8-4.8" />
109+ <path d="M4.4 19.2h15.2" />
110+ </>),
111+
112+ // --- quartier ---------------------------------------------------------------------
113+ leaf: (<>
114+ <path d="M5 19.2C5 9.6 12 5 20 4.2c0 9-4.2 15-13.2 15z" />
115+ <path d="M5 19.2C7 14 11 10 15 8" />
116+ </>),
117+ thermo: (<>
118+ <path d="M10.4 4.2a1.9 1.9 0 0 1 3.8 0v8.8a4.2 4.2 0 1 1-3.8 0z" />
119+ <path d="M12.3 8.4v7" />
120+ </>),
121+ sun: (<>
122+ <circle cx="12" cy="12" r="4.2" />
123+ <path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.8 5.8l1.4 1.4M16.8 16.8l1.4 1.4M18.2 5.8l-1.4 1.4M7.2 16.8l-1.4 1.4" />
124+ </>),
125+ shield: (<>
126+ <path d="M12 3.4 5.2 5.9v5.9c0 4.6 3 7.6 6.8 8.8 3.8-1.2 6.8-4.2 6.8-8.8V5.9z" />
127+ <path d="m9.2 12 2 2 3.8-4" />
128+ </>),
129+ cart: (<>
130+ <circle cx="9.6" cy="19.6" r="1.35" />
131+ <circle cx="17" cy="19.6" r="1.35" />
132+ <path d="M3.4 4.4h2.2l2.5 10.8h9.6l2.7-7.8H7" />
133+ </>),
134+ bus: (<>
135+ <rect x="4.6" y="3.8" width="14.8" height="13.4" rx="2.4" />
136+ <path d="M4.6 10.4h14.8" />
137+ <path d="M7.6 20.2v-3M16.4 20.2v-3" />
138+ <path d="M8.3 14h.05M15.7 14h.05" />
139+ </>),
140+ tree: (<>
141+ <path d="M12 3.4 6.8 11.4h2.8L5.4 17.8h13.2l-4.2-6.4h2.8z" />
142+ <path d="M12 17.8v3.4" />
143+ </>),
144+ school: (<>
145+ <path d="m12 4.2 9.8 4.4L12 13 2.2 8.6z" />
146+ <path d="M6.6 10.8v5c0 1.6 2.4 3 5.4 3s5.4-1.4 5.4-3v-5" />
147+ <path d="M21.8 8.6v5.4" />
148+ </>),
149+ health: (<>
150+ <circle cx="12" cy="12" r="8.4" />
151+ <path d="M12 8.4v7.2M8.4 12h7.2" />
152+ </>),
153+ pill: (<>
154+ <rect x="3.2" y="8.6" width="17.6" height="6.8" rx="3.4" transform="rotate(-33 12 12)" />
155+ <path d="M12 8.6v6.8" transform="rotate(-33 12 12)" />
156+ </>),
157+ people: (<>
158+ <circle cx="9" cy="8" r="3.2" />
159+ <path d="M3.6 20a5.4 5.4 0 0 1 10.8 0" />
160+ <path d="M15.4 5.4a3.2 3.2 0 0 1 0 5.9M17 14.8a5.4 5.4 0 0 1 3.4 5.2" />
161+ </>),
162+};
163+
164+export type IconName = keyof typeof P;
165+
166+export function Ico({ name, size = 18, className = "", stroke = 1.7 }:
167+ { name: IconName | string; size?: number; className?: string; stroke?: number }) {
168+ const paths = P[name as IconName];
169+ if (!paths) return null;
170+ return (
171+ <svg
172+ className={`ico ${className}`}
173+ width={size} height={size} viewBox="0 0 24 24"
174+ fill="none" stroke="currentColor" strokeWidth={stroke}
175+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"
176+ >
177+ {paths}
178+ </svg>
179+ );
180+}
181+
182+/** Version « chaîne HTML » pour les popups MapLibre (hors React). */
183+export function icoHTML(name: IconName, size = 30): string {
184+ const d: Record<string, string> = {
185+ home: '<path d="M3.5 10.6 12 3.4l8.5 7.2"/><path d="M5.6 9.4V20h12.8V9.4"/><path d="M10 20v-5.6h4V20"/>',
186+ };
187+ return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${d[name] ?? d.home}</svg>`;
188+}
added frontend/src/components/ListingCard.tsx +57 −0
@@ -0,0 +1,57 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// components/ListingCard.tsx : property card (results grid)
5+// -----------------------------------------------------------------------------
6+import { Link } from "react-router-dom";
7+import { Listing, fmtArea, fmtPrice, sourceName } from "../api";
8+import { Ico } from "./Icons";
9+import PropertyImg from "./PropertyImg";
10+
11+export default function ListingCard({ l }: { l: Listing }) {
12+ // light thumbnail when the connector provides one (mobile/cellular),
13+ // otherwise the full-size cover photo
14+ const img = (typeof l.details?.cover_thumb === "string" && l.details.cover_thumb)
15+ || (l.images && l.images.length > 0 ? l.images[0] : null);
16+ const rent = l.details?.transaction === "location";
17+ const meta: { ico: string; txt: string }[] = [];
18+ if (l.bedrooms != null) meta.push({ ico: "bed", txt: `${l.bedrooms} bed` });
19+ if (l.bathrooms != null) meta.push({ ico: "bath", txt: `${l.bathrooms} bath` });
20+ const area = fmtArea(l.area_sqft);
21+ if (area) meta.push({ ico: "area", txt: area });
22+
23+ return (
24+ <Link to={`/property/${encodeURIComponent(l.uid)}`} className="card">
25+ <div className="card-img">
26+ <PropertyImg src={img} alt={l.title || l.address} type={l.property_type} />
27+ {l.property_type && <span className="badge type">{l.property_type}</span>}
28+ {l.images.length > 1 && (
29+ <span className="badge right"><Ico name="camera" size={12} /> {l.images.length}</span>
30+ )}
31+ </div>
32+ <div className="card-body">
33+ <div className="card-price">
34+ {fmtPrice(l.price, l.price_label)}
35+ {rent && <span className="per-month"> /month</span>}
36+ </div>
37+ <div className="card-title">{l.address || l.title}</div>
38+ <div className="card-meta">
39+ {l.sector && <span>{l.sector}</span>}
40+ {l.sector && l.city && <span className="sep" />}
41+ {l.city && <span>{l.city}</span>}
42+ </div>
43+ {meta.length > 0 && (
44+ <div className="card-specs">
45+ {meta.map((m) => (
46+ <span key={m.ico}><Ico name={m.ico} size={13} /> {m.txt}</span>
47+ ))}
48+ </div>
49+ )}
50+ <div className="card-foot">
51+ <span className="source-tag">{sourceName(l.source)}</span>
52+ {l.mls && <span className="avail">MLS® {l.mls}</span>}
53+ </div>
54+ </div>
55+ </Link>
56+ );
57+}
added frontend/src/components/MapView.tsx +321 −0
@@ -0,0 +1,321 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (House-Ka integration)
5+// components/MapView.tsx : the House-Ka MAP MODE (Ka Map System v2) — same
6+// architecture as Lou-Ka/Immo-Ka Maps, themed pine/cream/ink.
7+// · viewport takeover (KaMapShell): mobile edge-to-edge + 3-notch results
8+// bottom sheet, desktop resizable list|map split with near-fullscreen map;
9+// · viewport-driven data: /api/listings.geojson?bbox=…
10+// (Ka Maps adapter — cancellable requests, never a request storm);
11+// · unified toolbar (zoom, 3D, draw, locate me), contextual "Search this
12+// area", drawn area clipped CLIENT-SIDE (setClipPolygon) with the
13+// "N homes in this area" CTA;
14+// · contextual preview card v2: photo, price, swipe/chevrons between
15+// neighbouring properties.
16+// -----------------------------------------------------------------------------
17+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18+import { useNavigate } from "react-router-dom";
19+import "mapbox-gl/dist/mapbox-gl.css";
20+import "@groupe-ka/ka-maps/styles.css";
21+import type { KaMap, MapProperty } from "@groupe-ka/ka-maps";
22+import { cameraFromParams, cameraToParams } from "@groupe-ka/ka-maps";
23+import {
24+ KaBrandBadge,
25+ KaDrawAreaMode,
26+ KaMapShell,
27+ KaMapToolbar,
28+ KaMapView,
29+ KaPropertyPreview,
30+ KaToolbar3D,
31+ KaToolbarDraw,
32+ KaToolbarGroup,
33+ KaToolbarLocate,
34+ KaToolbarZoom,
35+ LoadingIndicator,
36+ SearchAreaControl,
37+ useKaMap,
38+ useKaShell,
39+} from "@groupe-ka/ka-maps/react";
40+import { Listing, ListingFilters, sourceName } from "../api";
41+import ListingCard from "./ListingCard";
42+import { houseKaMapTheme } from "../kamaps/theme";
43+import { houseKaMapAdapter } from "../kamaps/adapter";
44+import { MAPBOX_TOKEN } from "../kamaps/config";
45+
46+// Ontario first: open on Toronto (shared URLs override via ?lat&lng&zoom)
47+const DEFAULT_CENTER = { lat: 43.68, lng: -79.4 };
48+
49+/** Preview card — Ka Map System ka-prevcard structure, House-Ka content
50+ * (the adapter already carries photo/price/traits: no extra fetch). */
51+function PreviewCard({ p }: { p: MapProperty }) {
52+ const navigate = useNavigate();
53+ const extra = (p.extra ?? {}) as {
54+ title?: string | null; priceLabel?: string | null;
55+ source?: string;
56+ };
57+ const price = p.price != null
58+ ? "$" + p.price.toLocaleString("en-CA", { maximumFractionDigits: 0 })
59+ : extra.priceLabel || "Price on request";
60+ const meta = [
61+ p.propertyType,
62+ p.bedrooms != null ? `${p.bedrooms} bed` : "",
63+ p.bathrooms != null ? `${p.bathrooms} bath` : "",
64+ extra.source ? sourceName(extra.source) : "",
65+ ].filter(Boolean).join(" · ");
66+ const fiche = p.originalUrl ?? "/";
67+ return (
68+ <>
69+ <div className="ka-prevcard-media">
70+ {p.thumbnailUrl ? (
71+ <img
72+ src={p.thumbnailUrl} alt="" loading="lazy"
73+ onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
74+ />
75+ ) : (
76+ <div className="ka-prevcard-noimg" aria-hidden="true">⌂</div>
77+ )}
78+ </div>
79+ <div className="ka-prevcard-body">
80+ <div className="ka-prevcard-price">{price}</div>
81+ <div className="ka-prevcard-addr">{p.address ?? extra.title ?? ""}</div>
82+ <div className="ka-prevcard-meta">{meta}</div>
83+ <div className="ka-prevcard-actions">
84+ <a
85+ className="ka-prevcard-cta"
86+ href={fiche}
87+ onClick={(e) => { e.preventDefault(); navigate(fiche); }}
88+ >
89+ See the listing →
90+ </a>
91+ </div>
92+ </div>
93+ </>
94+ );
95+}
96+
97+/** Bridge: exposes the KaMap engine to the parent component (outside canvas). */
98+function EngineBridge({ onEngine }: { onEngine: (m: KaMap | null) => void }) {
99+ const map = useKaMap();
100+ useEffect(() => {
101+ onEngine(map);
102+ return () => onEngine(null);
103+ }, [map, onEngine]);
104+ return null;
105+}
106+
107+/** Mobile: selecting a marker collapses the sheet to mini. */
108+function SheetAutoCollapse({ selectedUid }: { selectedUid: string | null }) {
109+ const shell = useKaShell();
110+ const shellRef = useRef(shell);
111+ shellRef.current = shell;
112+ useEffect(() => {
113+ const s = shellRef.current;
114+ if (selectedUid && s?.isMobile) s.setSheet("mini");
115+ }, [selectedUid]);
116+ return null;
117+}
118+
119+export interface MapViewProps {
120+ filters: ListingFilters;
121+ /** Current list page (12 listings) — the shell's results pane. */
122+ listings: Listing[] | null;
123+ total: number;
124+ page: number;
125+ totalPages: number;
126+ onPage: (p: number) => void;
127+ sort: string;
128+ onSort: (s: string) => void;
129+ onExit?: () => void;
130+ onOpenFilters?: () => void;
131+ filtersCount?: number;
132+}
133+
134+export default function MapView({
135+ filters, listings, total, page, totalPages, onPage, sort, onSort,
136+ onExit, onOpenFilters, filtersCount = 0,
137+}: MapViewProps) {
138+ // initial camera: shared URL (?lat&lng&zoom) otherwise Toronto
139+ const initialCamera = useMemo(() => {
140+ const cam = cameraFromParams(new URLSearchParams(window.location.search));
141+ return cam ?? { ...DEFAULT_CENTER, zoom: 10 };
142+ }, []);
143+
144+ const [selectedUid, setSelectedUid] = useState<string | null>(null);
145+ const [mapCount, setMapCount] = useState<number | null>(null);
146+ const [hasZone, setHasZone] = useState(false);
147+ const [isMobile, setIsMobile] = useState(
148+ () => window.matchMedia("(max-width: 780px)").matches);
149+ const engineRef = useRef<KaMap | null>(null);
150+ const listRef = useRef<HTMLDivElement | null>(null);
151+
152+ useEffect(() => {
153+ const mq = window.matchMedia("(max-width: 780px)");
154+ const update = () => setIsMobile(mq.matches);
155+ mq.addEventListener("change", update);
156+ return () => mq.removeEventListener("change", update);
157+ }, []);
158+
159+ // camera → URL (replaceState: no router re-render)
160+ const onMoveEnd = useCallback((center: { lat: number; lng: number }, zoom: number) => {
161+ const url = new URL(window.location.href);
162+ url.search = cameraToParams({ ...center, zoom }, url.searchParams).toString();
163+ window.history.replaceState(null, "", url);
164+ }, []);
165+
166+ const mapFilters = useMemo(
167+ () => Object.fromEntries(Object.entries(filters).filter(([, v]) => v)),
168+ [filters],
169+ );
170+
171+ const handleEngine = useCallback((m: KaMap | null) => {
172+ engineRef.current = m;
173+ }, []);
174+
175+ // map selection: preview card + list card scrolled into view
176+ const onSelect = useCallback((p: MapProperty | null) => {
177+ const uid = p?.id ?? null;
178+ setSelectedUid(uid);
179+ if (!uid) return;
180+ const card = listRef.current?.querySelector<HTMLElement>(
181+ `[data-uid="${CSS.escape(uid)}"]`);
182+ card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
183+ }, []);
184+
185+ // drawn area: CLIENT-SIDE clip of the displayed set (the House-Ka API does
186+ // not filter by polygon) — the CTA count comes from the data event.
187+ const onDraw = useCallback((polygon: [number, number][] | null, drawing: boolean) => {
188+ if (drawing) return;
189+ setHasZone(polygon !== null);
190+ engineRef.current?.setClipPolygon(polygon);
191+ }, []);
192+
193+ const listHeader = (
194+ <div className="ms2-head">
195+ <div className="ms2-count" role="status" aria-live="polite">
196+ <b>{total.toLocaleString("en-CA")}</b>
197+ {" "}home{total > 1 ? "s" : ""}
198+ {mapCount != null && hasZone && (
199+ <span className="ms2-zone">{mapCount.toLocaleString("en-CA")} in the area</span>
200+ )}
201+ </div>
202+ <label className="ms2-sort">
203+ <select
204+ value={sort}
205+ onChange={(e) => onSort(e.target.value)}
206+ aria-label="Sort the results"
207+ >
208+ <option value="recent">Newest</option>
209+ <option value="price_asc">Price: low to high</option>
210+ <option value="price_desc">Price: high to low</option>
211+ </select>
212+ </label>
213+ </div>
214+ );
215+
216+ const listPane = (
217+ <div className="ms2-list" aria-label="List results" ref={listRef}>
218+ {(listings ?? []).map((l) => (
219+ <div
220+ key={l.uid}
221+ data-uid={l.uid}
222+ className={`map-card${selectedUid === l.uid ? " map-card-sel" : ""}`}
223+ onMouseEnter={() => engineRef.current?.setHovered(l.uid, "app")}
224+ onMouseLeave={() => engineRef.current?.setHovered(null, "app")}
225+ >
226+ <ListingCard l={l} />
227+ </div>
228+ ))}
229+ {listings !== null && listings.length === 0 && (
230+ <div className="ms-empty" role="status">
231+ <h3>No home matches</h3>
232+ <p>Try widening your criteria.</p>
233+ </div>
234+ )}
235+ {totalPages > 1 && (
236+ <nav className="pager ms2-pager" aria-label="Pagination">
237+ <button className="pager-btn" onClick={() => {
238+ onPage(page - 1);
239+ listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
240+ }} disabled={page <= 1}>‹ Prev</button>
241+ <span className="pager-info">Page {page} / {totalPages}</span>
242+ <button className="pager-btn" onClick={() => {
243+ onPage(page + 1);
244+ listRef.current?.parentElement?.scrollTo({ top: 0, behavior: "smooth" });
245+ }} disabled={page >= totalPages}>Next ›</button>
246+ </nav>
247+ )}
248+ </div>
249+ );
250+
251+ return (
252+ <KaMapShell
253+ className="ms2"
254+ brand={<span className="ms2-brand"><b>House·Ka</b><span>Map</span></span>}
255+ onExit={onExit}
256+ exitLabel="List"
257+ storageKey="houseka-map-split"
258+ listHeader={listHeader}
259+ list={listPane}
260+ topExtras={
261+ onOpenFilters ? (
262+ <button className="ka-top-btn" onClick={onOpenFilters}>
263+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
264+ <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
265+ </svg>
266+ Filters
267+ {filtersCount > 0 && <span className="ka-top-badge">{filtersCount}</span>}
268+ </button>
269+ ) : null
270+ }
271+ >
272+ <KaMapView
273+ theme={houseKaMapTheme}
274+ adapter={houseKaMapAdapter}
275+ mapboxToken={MAPBOX_TOKEN}
276+ filters={mapFilters}
277+ center={initialCamera}
278+ zoom={initialCamera.zoom}
279+ pitch={50}
280+ // Realistic rendering: full-colour Standard + 3D landmarks — same
281+ // settings as Lou-Ka Maps.
282+ basemap={{ theme: "default", showLandmarks: true }}
283+ // valueClamp: caps each ASKING PRICE's contribution to the cluster
284+ // bubble (a $20M mansion doesn't skew the average)
285+ cluster={{ maxZoom: 15, valueClamp: [100_000, 3_000_000], valueMinCount: 10 }}
286+ searchMode="manual"
287+ navControl={false}
288+ onMoveEnd={onMoveEnd}
289+ onSelect={onSelect}
290+ onData={(count) => setMapCount(count)}
291+ onDraw={onDraw}
292+ >
293+ <EngineBridge onEngine={handleEngine} />
294+ <SheetAutoCollapse selectedUid={selectedUid} />
295+ <KaBrandBadge />
296+
297+ {/* THE control cluster — zoom (desktop), 3D, draw, locate */}
298+ <KaMapToolbar>
299+ {!isMobile && (
300+ <KaToolbarGroup><KaToolbarZoom /></KaToolbarGroup>
301+ )}
302+ <KaToolbarGroup>
303+ <KaToolbar3D />
304+ <KaToolbarDraw />
305+ <KaToolbarLocate />
306+ </KaToolbarGroup>
307+ </KaMapToolbar>
308+
309+ {/* draw mode: temporary banner + "N homes" CTA */}
310+ <KaDrawAreaMode
311+ formatCount={(n) => `${n.toLocaleString("en-CA")} home${n > 1 ? "s" : ""}`}
312+ onClear={() => engineRef.current?.setClipPolygon(null)}
313+ />
314+
315+ <SearchAreaControl />
316+ <LoadingIndicator label="Updating the homes…" />
317+ <KaPropertyPreview render={(p) => <PreviewCard p={p} />} />
318+ </KaMapView>
319+ </KaMapShell>
320+ );
321+}
added frontend/src/components/NearbyPlaces.tsx +84 −0
@@ -0,0 +1,84 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// components/NearbyPlaces.tsx : “Shops and transit” block (listing page)
5+// Distance to the closest location of each big banner (Costco, Walmart,
6+// Canadian Tire… via the Mapbox Search Box API) + nearest transit stops.
7+// SVG monogram chips in the banners' colours (no registered logos).
8+// -----------------------------------------------------------------------------
9+import { useEffect, useState } from "react";
10+import { CommercesNearby, fetchCommerces, fmtDist } from "../api";
11+
12+// id -> [background colour, monogram, text colour]
13+const ICONS: Record<string, [string, string, string?]> = {
14+ metro_station: ["#0083C9", "M"],
15+ rem_station: ["#84BD00", "R"],
16+ arret_bus: ["#4E5357", "B"],
17+ gare_train: ["#6E5B3F", "T"],
18+ costco: ["#005DAA", "C"],
19+ 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"],
26+ dollarama: ["#00B140", "D", "#FFDD00"],
27+ saq: ["#892034", "SAQ"],
28+ pharmaprix: ["#E11B22", "Ph"],
29+ jeancoutu: ["#003DA5", "JC"],
30+ homedepot: ["#F96302", "HD"],
31+ rona: ["#1B4298", "R"],
32+};
33+
34+function Chip({ id }: { id: string }) {
35+ const [bg, mono, fg] = ICONS[id] ?? ["#777", "•"];
36+ const fs = mono.length >= 3 ? 9 : mono.length === 2 ? 11 : 14;
37+ return (
38+ <svg className="cm-ico" viewBox="0 0 28 28" width="28" height="28"
39+ aria-hidden="true">
40+ {["metro_station", "rem_station", "arret_bus", "gare_train"].includes(id)
41+ ? <circle cx="14" cy="14" r="13" fill={bg} />
42+ : <rect x="1" y="1" width="26" height="26" rx="7" fill={bg} />}
43+ <text x="14" y="14" textAnchor="middle" dominantBaseline="central"
44+ fontSize={fs} fontWeight="800" fontFamily="inherit"
45+ fill={fg ?? "#fff"}>{mono}</text>
46+ </svg>
47+ );
48+}
49+
50+export default function NearbyPlaces({ lat, lng }:
51+ { lat: number | null; lng: number | null }) {
52+ const [d, setD] = useState<CommercesNearby | null>(null);
53+ useEffect(() => {
54+ setD(null);
55+ if (lat == null || lng == null) return;
56+ fetchCommerces(lat, lng).then(setD).catch(() => setD(null));
57+ }, [lat, lng]);
58+ if (lat == null || lng == null || !d) return null;
59+ const all = [...(d.transit ?? []), ...(d.commerces ?? [])];
60+ if (all.length === 0) return null;
61+
62+ return (
63+ <section className="f-bloc f-commerces" id="nearby">
64+ <h2>Shops and transit</h2>
65+ <ul className="cm-grille">
66+ {all.map((c) => (
67+ <li key={c.id} className="cm-item"
68+ title={c.adresse || undefined}>
69+ <Chip id={c.id} />
70+ <span className="cm-txt">
71+ <span className="cm-nom">{c.commerce}</span>
72+ <span className="cm-poi">{c.nom}</span>
73+ </span>
74+ <span className="cm-dist">{fmtDist(c.dist_m)}</span>
75+ </li>
76+ ))}
77+ </ul>
78+ <p className="fine">
79+ Closest location of each banner — straight-line distances (Mapbox
80+ search; transit: OpenStreetMap).
81+ </p>
82+ </section>
83+ );
84+}
added frontend/src/components/PropertyImg.tsx +45 −0
@@ -0,0 +1,45 @@
1+// -----------------------------------------------------------------------------
2+// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/PropertyImg.tsx : image de propriété robuste
5+// · <TypeFallback/> : image de secours élégante PAR TYPE DE BIEN aux couleurs
6+// Immo-Ka — jamais d'icône d'image cassée ni de vide gris.
7+// · <PropertyImg/> : <img> avec repli automatique si l'URL ne charge pas.
8+// -----------------------------------------------------------------------------
9+import { useState } from "react";
10+import { Ico } from "./Icons";
11+
12+const TYPE_ICONS: Record<string, string> = {
13+ "Maison": "home", "Maison mobile": "home", "Maison de ville": "home",
14+ "Jumelé": "home", "Chalet": "tree", "Condo": "building",
15+ "Duplex": "building", "Triplex": "building", "Multiplex": "building",
16+ "Terrain": "land", "Fermette/Agricole": "leaf", "Commercial": "cart",
17+};
18+
19+/** Image de secours par type de bien (gradient cerise + icône + libellé). */
20+export function TypeFallback({ type, label = true }: { type?: string; label?: boolean }) {
21+ const ico = TYPE_ICONS[type ?? ""] ?? "home";
22+ return (
23+ <div className="type-fallback" aria-label={type || "Propriété"}>
24+ <Ico name={ico} size={40} />
25+ {label && <span>{type || "Photos à venir"}</span>}
26+ </div>
27+ );
28+}
29+
30+/** <img> qui bascule sur l'image de secours du type si le chargement échoue. */
31+export default function PropertyImg({
32+ src, alt, type, eager = false,
33+}: { src?: string | null; alt: string; type?: string; eager?: boolean }) {
34+ const [broken, setBroken] = useState(false);
35+ if (!src || broken) return <TypeFallback type={type} />;
36+ return (
37+ <img
38+ src={src}
39+ alt={alt}
40+ loading={eager ? "eager" : "lazy"}
41+ decoding="async"
42+ onError={() => setBroken(true)}
43+ />
44+ );
45+}
added frontend/src/components/PropertyMap.tsx +69 −0
@@ -0,0 +1,69 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (House-Ka integration)
5+// components/PropertyMap.tsx : 3D mini-map of the listing page — same
6+// component as Lou-Ka (KaSpotlightMap): tight camera on the address, realistic
7+// Mapbox Standard basemap, the listing's building highlighted in House-Ka
8+// PINE. Lazy-loaded from Listing.tsx.
9+// -----------------------------------------------------------------------------
10+import { useMemo } from "react";
11+import "mapbox-gl/dist/mapbox-gl.css";
12+import "@groupe-ka/ka-maps/styles.css";
13+import type { MapProperty } from "@groupe-ka/ka-maps";
14+import { KaBrandBadge, KaSpotlightMap } from "@groupe-ka/ka-maps/react";
15+import { houseKaMapTheme } from "../kamaps/theme";
16+import { MAPBOX_TOKEN } from "../kamaps/config";
17+
18+/** House-Ka pine signal — same language as the brand accent. */
19+export const BUILDING_PINE = "#0f6b4f";
20+
21+export interface PropertyMapProps {
22+ uid: string;
23+ lat: number;
24+ lng: number;
25+ price?: number | null;
26+ propertyType?: string;
27+ address?: string;
28+ city?: string;
29+ image?: string | null;
30+ deal?: boolean;
31+}
32+
33+export default function PropertyMap({
34+ uid, lat, lng, price, propertyType, address, city, image, deal,
35+}: PropertyMapProps) {
36+ const property = useMemo<MapProperty>(() => ({
37+ id: uid,
38+ appSource: "house-ka",
39+ latitude: lat,
40+ longitude: lng,
41+ kind: "listing",
42+ listingType: "sale",
43+ price: price ?? undefined,
44+ propertyType: propertyType || undefined,
45+ address: address || undefined,
46+ city: city || undefined,
47+ thumbnailUrl: image ?? undefined,
48+ highlight: deal ?? false,
49+ }), [uid, lat, lng, price, propertyType, address, city, image, deal]);
50+
51+ return (
52+ <div
53+ className="lmap3d" role="img"
54+ aria-label={`3D map — ${address || "property"}, the listing's building in green`}
55+ >
56+ <KaSpotlightMap
57+ theme={houseKaMapTheme}
58+ mapboxToken={MAPBOX_TOKEN}
59+ property={property}
60+ buildingColor={BUILDING_PINE}
61+ >
62+ <KaBrandBadge />
63+ </KaSpotlightMap>
64+ <span className="lmap3d-legende" aria-hidden="true">
65+ <i /> The listing's building
66+ </span>
67+ </div>
68+ );
69+}
added frontend/src/components/RateHistory.tsx +101 −0
@@ -0,0 +1,101 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// components/RateHistory.tsx : SVG chart of the best observed rate over time
5+// (validity periods valid_from/valid_to rebuilt as a step curve — a rate
6+// stays in force until it changes).
7+// -----------------------------------------------------------------------------
8+import { useEffect, useMemo, useState } from "react";
9+import { MortgageHistoryRow, fetchMortgageHistory, fmtRate } from "../api";
10+
11+/** Best rate (all institutions) at every instant: for each period boundary,
12+ * the min of the rates whose period covers that instant. */
13+function bestCurve(rows: MortgageHistoryRow[], now: number) {
14+ const stamps = new Set<number>();
15+ for (const r of rows) {
16+ stamps.add(r.valid_from);
17+ if (r.valid_to != null) stamps.add(r.valid_to);
18+ }
19+ stamps.add(now);
20+ const ts = [...stamps].sort((a, b) => a - b);
21+ const pts: { t: number; rate: number }[] = [];
22+ for (const t of ts) {
23+ let best: number | null = null;
24+ for (const r of rows) {
25+ if (r.valid_from <= t && (r.valid_to == null || r.valid_to > t))
26+ best = best == null ? r.rate : Math.min(best, r.rate);
27+ }
28+ if (best != null) pts.push({ t, rate: best });
29+ }
30+ return pts;
31+}
32+
33+export default function RateHistory({ rateType, termMonths, days = 365 }:
34+ { rateType: string; termMonths: number; days?: number }) {
35+ const [rows, setRows] = useState<MortgageHistoryRow[] | null>(null);
36+
37+ useEffect(() => {
38+ setRows(null);
39+ fetchMortgageHistory(rateType, termMonths, days, "special")
40+ .then((r) => setRows(r.history))
41+ .catch(() => setRows([]));
42+ }, [rateType, termMonths, days]);
43+
44+ const now = Math.floor(Date.now() / 1000);
45+ const pts = useMemo(() => bestCurve(rows ?? [], now), [rows, now]);
46+
47+ if (rows == null) return <div className="fine">Loading the history…</div>;
48+ if (pts.length === 0)
49+ return <div className="fine">No history for this product yet.</div>;
50+
51+ const W = 640, H = 180, PAD = { l: 44, r: 10, t: 10, b: 22 };
52+ const t0 = pts[0].t, t1 = now;
53+ const rates = pts.map((p) => p.rate);
54+ const rMin = Math.floor(Math.min(...rates) * 10) / 10 - 0.1;
55+ const rMax = Math.ceil(Math.max(...rates) * 10) / 10 + 0.1;
56+ const x = (t: number) =>
57+ PAD.l + ((t - t0) / Math.max(1, t1 - t0)) * (W - PAD.l - PAD.r);
58+ const y = (r: number) =>
59+ PAD.t + (1 - (r - rMin) / Math.max(0.01, rMax - rMin)) * (H - PAD.t - PAD.b);
60+
61+ // step curve: the rate holds until the next change
62+ let d = `M ${x(pts[0].t).toFixed(1)} ${y(pts[0].rate).toFixed(1)}`;
63+ for (let i = 1; i < pts.length; i++) {
64+ d += ` H ${x(pts[i].t).toFixed(1)} V ${y(pts[i].rate).toFixed(1)}`;
65+ }
66+ d += ` H ${x(t1).toFixed(1)}`;
67+
68+ const yTicks: number[] = [];
69+ for (let r = Math.ceil(rMin * 4) / 4; r <= rMax + 1e-9; r += 0.25)
70+ yTicks.push(Math.round(r * 100) / 100);
71+ const fmtD = (t: number) =>
72+ new Date(t * 1000).toLocaleDateString("en-CA", { month: "short", day: "numeric" });
73+ const last = pts[pts.length - 1];
74+
75+ return (
76+ <div className="mtg-chart">
77+ <svg viewBox={`0 0 ${W} ${H}`} role="img"
78+ aria-label={`Best ${rateType} ${termMonths}-month rate over time`}>
79+ {yTicks.map((r) => (
80+ <g key={r}>
81+ <line x1={PAD.l} x2={W - PAD.r} y1={y(r)} y2={y(r)} className="mtg-grid" />
82+ <text x={PAD.l - 6} y={y(r) + 3} className="mtg-tick" textAnchor="end">
83+ {r.toFixed(2)}
84+ </text>
85+ </g>
86+ ))}
87+ <text x={x(t0)} y={H - 6} className="mtg-tick">{fmtD(t0)}</text>
88+ <text x={W - PAD.r} y={H - 6} className="mtg-tick" textAnchor="end">
89+ today
90+ </text>
91+ <path d={d} className="mtg-line" />
92+ <circle cx={x(t1)} cy={y(last.rate)} r={3.5} className="mtg-dot" />
93+ </svg>
94+ <div className="fine">
95+ Best “special offer” rate observed across all institutions —
96+ currently <b>{fmtRate(last.rate)}</b>. The history builds up as the
97+ collections run (no data is extrapolated).
98+ </div>
99+ </div>
100+ );
101+}
added frontend/src/ka/ecosystem.json +206 −0
@@ -0,0 +1,206 @@
1+{
2+ "org": {
3+ "name": "Groupe KA",
4+ "legalName": "Groupe KA — Simon-Pierre Boucher",
5+ "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.",
6+ "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.",
7+ "copyrightHolder": "Groupe KA — Simon-Pierre Boucher"
8+ },
9+ "hub": {
10+ "url": "https://www.groupe-ka.com",
11+ "loginPath": "/connexion",
12+ "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."
13+ },
14+ "contacts": [
15+ {
16+ "email": "contact@groupe-ka.com",
17+ "role": "Projets, partenariats & données"
18+ },
19+ {
20+ "email": "info@groupe-ka.com",
21+ "role": "Médias & questions générales"
22+ },
23+ {
24+ "email": "admin@groupe-ka.com",
25+ "role": "Légal, vie privée & Loi 25"
26+ }
27+ ],
28+ "legal": [
29+ {
30+ "label": "Conditions d'utilisation",
31+ "href": "https://www.groupe-ka.com/conditions"
32+ },
33+ {
34+ "label": "Politique de confidentialité",
35+ "href": "https://www.groupe-ka.com/confidentialite"
36+ },
37+ {
38+ "label": "Protection des renseignements personnels (Loi 25)",
39+ "href": "https://www.groupe-ka.com/loi-25"
40+ },
41+ {
42+ "label": "Transparence des robots d'indexation",
43+ "href": "https://www.groupe-ka.com/bots"
44+ }
45+ ],
46+ "sites": [
47+ {
48+ "id": "groupe-ka",
49+ "wordmark": "Groupe KA",
50+ "domain": "www.groupe-ka.com",
51+ "accent": "#d9f26b",
52+ "accentSoft": "#f0f9d2",
53+ "accentDeep": "#123f2e",
54+ "onAccent": "#141814",
55+ "tagline": "Le portail de l'écosystème ·Ka"
56+ },
57+ {
58+ "id": "trouve-ka",
59+ "wordmark": "Trouve·Ka",
60+ "domain": "www.trouve-ka.com",
61+ "accent": "#1c7ed6",
62+ "accentSoft": "#e7f2fd",
63+ "accentDeep": "#14508f",
64+ "onAccent": "#ffffff",
65+ "tagline": "Le moteur de recherche du web québécois"
66+ },
67+ {
68+ "id": "lou-ka",
69+ "wordmark": "Lou·Ka",
70+ "domain": "www.lou-ka.com",
71+ "accent": "#ff6a00",
72+ "accentSoft": "#fff1e6",
73+ "accentDeep": "#cc5500",
74+ "onAccent": "#ffffff",
75+ "tagline": "Tous les logements à louer"
76+ },
77+ {
78+ "id": "immo-ka",
79+ "wordmark": "Immo·Ka",
80+ "domain": "www.immo-ka.com",
81+ "accent": "#e23744",
82+ "accentSoft": "#fbe0e2",
83+ "accentDeep": "#a8232e",
84+ "onAccent": "#ffffff",
85+ "tagline": "Toutes les propriétés à vendre"
86+ },
87+ {
88+ "id": "vrai-prix",
89+ "wordmark": "Vrai-Prix",
90+ "domain": "www.vrai-prix.com",
91+ "accent": "#ff5148",
92+ "accentSoft": "#ffe3e0",
93+ "accentDeep": "#9e2a25",
94+ "onAccent": "#ffffff",
95+ "tagline": "La valeur réelle de chaque propriété"
96+ },
97+ {
98+ "id": "auto-ka",
99+ "wordmark": "Auto·Ka",
100+ "domain": "www.auto-ka.com",
101+ "accent": "#ff5a2a",
102+ "accentSoft": "#ffe8de",
103+ "accentDeep": "#cc3f16",
104+ "onAccent": "#ffffff",
105+ "tagline": "Les voitures usagées du Québec"
106+ },
107+ {
108+ "id": "fabri-ka",
109+ "wordmark": "Fabri·Ka",
110+ "domain": "www.fabri-ka.com",
111+ "accent": "#c4532e",
112+ "accentSoft": "#f7e3da",
113+ "accentDeep": "#a94525",
114+ "onAccent": "#ffffff",
115+ "tagline": "Les produits fabriqués au Québec"
116+ },
117+ {
118+ "id": "food-ka",
119+ "wordmark": "Food·Ka",
120+ "domain": "www.food-ka.com",
121+ "accent": "#1f9d55",
122+ "accentSoft": "#e2f5ea",
123+ "accentDeep": "#157a40",
124+ "onAccent": "#ffffff",
125+ "tagline": "Les prix d'épicerie, suivis à la source"
126+ },
127+ {
128+ "id": "resto-ka",
129+ "wordmark": "Resto·Ka",
130+ "domain": "www.resto-ka.com",
131+ "accent": "#f08c00",
132+ "accentSoft": "#fdeed7",
133+ "accentDeep": "#b96a00",
134+ "onAccent": "#141814",
135+ "tagline": "Chaque resto, chaque plat, chaque prix"
136+ },
137+ {
138+ "id": "sorti-ka",
139+ "wordmark": "Sorti·Ka",
140+ "domain": "www.sorti-ka.com",
141+ "accent": "#d6336c",
142+ "accentSoft": "#fbe0eb",
143+ "accentDeep": "#a12551",
144+ "onAccent": "#ffffff",
145+ "tagline": "Toutes les sorties, dans les 17 régions"
146+ },
147+ {
148+ "id": "crea-ka",
149+ "wordmark": "Créa·Ka",
150+ "domain": "www.crea-ka.com",
151+ "accent": "#7048e8",
152+ "accentSoft": "#ece5fc",
153+ "accentDeep": "#5433b8",
154+ "onAccent": "#ffffff",
155+ "tagline": "Les créateurs d'ici, tous leurs liens"
156+ },
157+ {
158+ "id": "api-ka",
159+ "wordmark": "API·Ka",
160+ "domain": "www.api-ka.com",
161+ "accent": "#3b5bdb",
162+ "accentSoft": "#e4eafb",
163+ "accentDeep": "#2b44a8",
164+ "onAccent": "#ffffff",
165+ "tagline": "La donnée de l'écosystème, par API"
166+ },
167+ {
168+ "id": "job-ka",
169+ "wordmark": "Job·Ka",
170+ "domain": "www.job-ka.com",
171+ "accent": "#0c8599",
172+ "accentSoft": "#def0f4",
173+ "accentDeep": "#095c6b",
174+ "onAccent": "#ffffff",
175+ "tagline": "Tous les emplois des employeurs québécois"
176+ },
177+ {
178+ "id": "ka-stats",
179+ "wordmark": "Ka·Stats",
180+ "domain": "www.ka-stats.com",
181+ "accent": "#095797",
182+ "accentSoft": "#e2edf6",
183+ "accentDeep": "#063a63",
184+ "onAccent": "#ffffff",
185+ "tagline": "L'explorateur de statistiques du Québec"
186+ }
187+ ],
188+ "extraFooterLinks": [
189+ {
190+ "label": "ValoPlex",
191+ "href": "https://www.valoplex.com"
192+ },
193+ {
194+ "label": "Ka2",
195+ "href": "https://www.ka2.bot"
196+ },
197+ {
198+ "label": "Ka4",
199+ "href": "https://www.ka4.bot"
200+ },
201+ {
202+ "label": "Ka6",
203+ "href": "https://www.ka6.bot"
204+ }
205+ ]
206+}
\ No newline at end of file
added frontend/src/ka/tokens.css +319 −0
@@ -0,0 +1,319 @@
1+/* -----------------------------------------------------------------------------
2+ Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3+ Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4+ Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5+ de composants partagés par les 12 plateformes. Chaque site importe ce
6+ fichier PUIS surcharge uniquement son accent (voir accents.css).
7+
8+ · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9+ · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10+ · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11+ surlignés d'accent inclinés (néo-brutalisme raffiné)
12+ · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13+ · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14+----------------------------------------------------------------------------- */
15+
16+:root {
17+ /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18+ --paper: #f5f3ee;
19+ --surface: #ffffff;
20+ --surface-2: #faf9f5;
21+ --ink: #141814;
22+ --ink-2: #4d5551;
23+ --ink-3: #8b928c;
24+ --line: rgba(20, 24, 20, 0.14);
25+ --line-strong: rgba(20, 24, 20, 0.85);
26+ --green: #1c5c41;
27+ --green-deep: #123f2e;
28+ --amber: #e8a33d;
29+ --amber-soft: #fdf3e2;
30+ --danger: #b3423a;
31+ --danger-soft: #fbe9e7;
32+
33+ /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34+ --accent: #d9f26b; /* défaut : lime Groupe KA */
35+ --accent-soft: #f0f9d2;
36+ --accent-deep: #123f2e;
37+ --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38+
39+ /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40+ --lime: var(--accent);
41+ --lime-soft: var(--accent-soft);
42+
43+ /* ---- Géométrie sharp ---- */
44+ --r-card: 10px;
45+ --r-ctl: 6px;
46+ --r-pill: 999px;
47+
48+ /* ---- Ombres décalées — la signature du groupe ---- */
49+ --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50+ --shadow-off: 6px 6px 0 var(--ink);
51+ --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52+ --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53+
54+ /* ---- Typographie ---- */
55+ --font-display: "Space Grotesk", system-ui, sans-serif;
56+ --font-body: "Inter", system-ui, sans-serif;
57+ --font-mono: "JetBrains Mono", ui-monospace, monospace;
58+
59+ /* Échelle fluide (mobile-first, clamp) */
60+ --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61+ --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62+ --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63+ --fs-body: 15px;
64+ --fs-small: 13px;
65+
66+ /* Espacements */
67+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68+ --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69+
70+ /* Cible tactile minimale */
71+ --touch: 44px;
72+
73+ /* ---- Échelle z-index COMMUNE (obligatoire — aucune valeur arbitraire) ----
74+ grain de film body::before = 9999 (pointer-events:none, toujours au-dessus,
75+ purement visuel). Tout composant interactif se place sous 9999 : */
76+ --z-content: 1; /* contenu positionné ordinaire */
77+ --z-sticky: 300; /* éléments sticky de contenu (sous-nav…) */
78+ --z-header: 500; /* header du site */
79+ --z-bottombar: 600; /* tab bar / barre d'action basse */
80+ --z-dropdown: 700; /* menus déroulants (au-dessus des barres) */
81+ --z-overlay: 800; /* voile sous modale/panneau */
82+ --z-modal: 900; /* modales, panneaux de filtres, galeries */
83+ --z-toast: 950; /* notifications */
84+
85+ /* ---- Hauteur de viewport DYNAMIQUE (barre du navigateur mobile qui se
86+ replie au scroll) : toujours var(--vh100), JAMAIS 100vh en dur. ---- */
87+ --vh100: 100vh;
88+}
89+@supports (height: 100dvh) { :root { --vh100: 100dvh; } }
90+
91+/* ---- Socle ---- */
92+* { box-sizing: border-box; }
93+/* overflow-x: clip sur html ET body — sur WebKit iOS, clip posé sur body seul
94+ ne bloque PAS le défilement latéral du viewport (quirk de propagation) ;
95+ clip (≠ hidden) ne casse pas position:sticky. */
96+html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; overflow-x: clip; }
97+body {
98+ margin: 0;
99+ background: var(--paper);
100+ color: var(--ink);
101+ font-family: var(--font-body);
102+ font-size: var(--fs-body);
103+ line-height: 1.55;
104+ -webkit-font-smoothing: antialiased;
105+ padding-bottom: env(safe-area-inset-bottom);
106+ overflow-x: clip; /* jamais de débordement horizontal */
107+}
108+img, svg, video { max-width: 100%; height: auto; display: block; }
109+h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
110+h1 { font-size: var(--fs-h1); line-height: 1.06; }
111+h2 { font-size: var(--fs-h2); line-height: 1.15; }
112+h3 { font-size: var(--fs-h3); line-height: 1.25; }
113+a { color: inherit; }
114+::selection { background: var(--accent); color: var(--on-accent); }
115+:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
116+
117+/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
118+ relative seulement), sinon les utilitaires z-* sont écrasés. */
119+body::before {
120+ content: "";
121+ position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
122+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
123+}
124+body > * { position: relative; }
125+
126+/* ---- Conteneur commun ---- */
127+.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
128+@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
129+
130+/* ---- Micro-typographie signature ---- */
131+.kicker {
132+ display: inline-flex; align-items: center; gap: 10px;
133+ font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
134+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
135+}
136+.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
137+.klabel {
138+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
139+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
140+}
141+.hl {
142+ display: inline-block; background: var(--accent); color: var(--on-accent);
143+ border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
144+}
145+.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
146+
147+/* ---- Boutons (cible ≥ 44 px) ---- */
148+.btn {
149+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
150+ min-height: var(--touch); padding: 10px 18px;
151+ font-family: var(--font-display); font-weight: 700; font-size: 14px;
152+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
153+ background: var(--surface); color: var(--ink);
154+ cursor: pointer; text-decoration: none;
155+ transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
156+}
157+.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
158+.btn:active { transform: translate(0, 0); box-shadow: none; }
159+.btn:disabled { opacity: 0.45; pointer-events: none; }
160+.btn-primary { background: var(--ink); color: var(--accent); }
161+.btn-primary:hover { background: var(--accent-deep); }
162+.btn-accent { background: var(--accent); color: var(--on-accent); }
163+.btn-ghost { background: transparent; border-color: var(--line); }
164+.btn-ghost:hover { border-color: var(--ink); }
165+
166+/* ---- Cartes ---- */
167+.card {
168+ background: var(--surface); border: 1.5px solid var(--ink);
169+ border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
170+ overflow: hidden;
171+}
172+.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
173+.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
174+
175+/* ---- Chips / badges ---- */
176+.chip {
177+ display: inline-flex; align-items: center; gap: 6px;
178+ padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
179+ text-transform: uppercase; letter-spacing: 0.06em;
180+ border: 1.5px solid var(--ink); border-radius: var(--r-pill);
181+ background: var(--surface); color: var(--ink);
182+}
183+.chip-accent { background: var(--accent); color: var(--on-accent); }
184+.chip-soft { background: var(--accent-soft); border-color: var(--line); }
185+
186+/* ---- Champs ---- */
187+.input, .select, .textarea {
188+ width: 100%; min-height: var(--touch); padding: 10px 14px;
189+ font: inherit; color: var(--ink); background: var(--surface);
190+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
191+}
192+.input:focus, .select:focus, .textarea:focus {
193+ outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
194+}
195+.input::placeholder { color: var(--ink-3); }
196+
197+/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
198+.gk-badge {
199+ display: inline-flex; align-items: center; gap: 7px;
200+ min-height: 30px; padding: 3px 10px 4px;
201+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
202+ letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
203+ border: 1.5px solid var(--ink); border-radius: var(--r-pill);
204+ background: var(--surface); color: var(--ink-2); white-space: nowrap;
205+}
206+.gk-badge b {
207+ font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
208+ text-transform: none; color: var(--ink);
209+}
210+.gk-badge b .ka {
211+ display: inline-block; background: var(--ink); color: var(--accent);
212+ border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
213+}
214+.gk-badge:hover .ka { transform: rotate(0); }
215+.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
216+.gk-badge--dark b { color: var(--paper); }
217+
218+/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
219+.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
220+.ka-footer a { text-decoration: none; }
221+.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
222+.ka-footer .wordmark .ka { color: var(--accent); }
223+.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
224+.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
225+.ka-footer .notice b { color: var(--paper); }
226+.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
227+.ka-footer .sites a { color: rgba(245,243,238,0.65); }
228+.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
229+.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
230+@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
231+.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
232+.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
233+.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
234+.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
235+.ka-footer .legal a { color: inherit; }
236+.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
237+
238+/* ---- Tableaux → cartes empilées sous 768 px ---- */
239+.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
240+@media (max-width: 767px) {
241+ .tbl-stack thead { display: none; }
242+ .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
243+ .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
244+ .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
245+}
246+
247+/* ---- Utilitaires responsive ---- */
248+.only-mobile { display: initial; } .only-desktop { display: none; }
249+@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
250+
251+/* =============================================================================
252+ SOCLE MOBILE OBLIGATOIRE (2026-08-19) — règles communes aux 13 sites.
253+ RÈGLES pour tout composant futur :
254+ · TAP, jamais :hover, pour ouvrir un menu (le survol n'existe pas au doigt) ;
255+ le :hover ne sert qu'aux effets décoratifs, sous @media (hover: hover).
256+ · var(--vh100) (dvh) et JAMAIS 100vh pour tout élément calé sur le viewport.
257+ · position:fixed exige qu'AUCUN ancêtre n'ait transform/filter/perspective/
258+ will-change/backdrop-filter — sinon monter l'élément à la racine (body).
259+ · Barre basse fixe = .ka-bottombar + classe has-bottombar sur <body>.
260+ · Menu/panneau ouvert = .ka-scroll-lock sur <html> (scroll arrière-plan gelé).
261+ · z-index : uniquement l'échelle --z-* ci-dessus.
262+ · Zones tactiles ≥ var(--touch) (44 px), champs ≥ 16 px (zoom iOS).
263+ ========================================================================== */
264+
265+/* Anti-zoom iOS : au doigt, aucun champ sous 16 px (le focus d'un champ <16 px
266+ déclenche un zoom automatique de page sur Safari iOS). !important assumé :
267+ c'est un filet d'accessibilité, un champ plus petit casse le zoom quoi
268+ qu'il arrive — ne s'applique qu'aux écrans tactiles. */
269+@media (hover: none) and (pointer: coarse) {
270+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
271+ select, textarea { font-size: max(16px, 1em) !important; }
272+}
273+
274+/* Zone tactile du badge Groupe KA étendue à ≥44 px au doigt, sans changer
275+ son rendu (débord de la zone cliquable via pseudo-élément). */
276+@media (pointer: coarse) {
277+ .gk-badge { position: relative; }
278+ .gk-badge::after { content: ""; position: absolute; inset: -8px; }
279+}
280+
281+/* Hauteurs viewport dynamiques */
282+.h-viewport { height: 100vh; height: 100dvh; }
283+.min-h-viewport { min-height: 100vh; min-height: 100dvh; }
284+
285+/* Barre fixe basse fiable : safe-area iPhone incluse, immunisée contre les
286+ transforms accidentels, et compensation d'espace via body.has-bottombar. */
287+.ka-bottombar {
288+ position: fixed; left: 0; right: 0; bottom: 0;
289+ z-index: var(--z-bottombar);
290+ padding-bottom: env(safe-area-inset-bottom, 0px);
291+ background: var(--surface);
292+ border-top: 1.5px solid var(--ink);
293+ transform: none !important; filter: none !important;
294+}
295+body.has-bottombar { padding-bottom: calc(var(--bottombar-h, 64px) + env(safe-area-inset-bottom, 0px)); }
296+
297+/* Verrou de scroll d'arrière-plan (menu mobile, modale, panneau de filtres
298+ ouverts) : ajouter/retirer .ka-scroll-lock sur <html>. */
299+html.ka-scroll-lock, html.ka-scroll-lock body { overflow: hidden !important; overscroll-behavior: none; }
300+
301+/* Menu déroulant de référence : au-dessus de tout contenu (cartes Mapbox
302+ incluses), défilement interne avec élan, jamais plus haut que l'écran. */
303+.ka-menu {
304+ position: absolute; z-index: var(--z-dropdown);
305+ min-width: 180px;
306+ max-height: min(60vh, 420px); max-height: min(60dvh, 420px);
307+ overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
308+ background: var(--surface); border: 1.5px solid var(--ink);
309+ border-radius: var(--r-card); box-shadow: var(--shadow-off-mid);
310+}
311+.ka-menu a, .ka-menu button, .ka-menu label, .ka-menu [role="menuitem"], .ka-menu [role="option"] {
312+ display: flex; align-items: center; min-height: var(--touch);
313+ padding: 10px 14px; width: 100%; text-decoration: none;
314+}
315+/* Voile plein écran sous une modale / un panneau */
316+.ka-overlay {
317+ position: fixed; inset: 0; z-index: var(--z-overlay);
318+ background: rgba(20, 24, 20, 0.45);
319+}
added frontend/src/kamaps/adapter.ts +96 −0
@@ -0,0 +1,96 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (House-Ka integration)
5+// kamaps/adapter.ts : House-Ka → MapProperty adapter.
6+// -----------------------------------------------------------------------------
7+import type {
8+ BoundsQuery,
9+ BoundsQueryResult,
10+ KaDataAdapter,
11+ MapProperty,
12+} from "@groupe-ka/ka-maps";
13+import { bboxToString } from "@groupe-ka/ka-maps";
14+import type { ListingFilters } from "../api";
15+
16+interface HouseKaFeatureProps {
17+ uid: string;
18+ title: string | null;
19+ address: string | null;
20+ price: number | null;
21+ price_label: string | null;
22+ property_type: string | null;
23+ bedrooms: number | null;
24+ bathrooms: number | null;
25+ source: string;
26+ city: string | null;
27+ sector: string | null;
28+ image: string | null;
29+}
30+
31+interface HouseKaFC {
32+ type: "FeatureCollection";
33+ features: {
34+ geometry: { coordinates: [number, number] };
35+ properties: HouseKaFeatureProps;
36+ }[];
37+ totalGeocoded?: number;
38+ totalMatching?: number;
39+}
40+
41+function toMapProperty(
42+ coords: [number, number],
43+ p: HouseKaFeatureProps,
44+): MapProperty {
45+ return {
46+ id: p.uid,
47+ appSource: "house-ka",
48+ longitude: coords[0],
49+ latitude: coords[1],
50+ kind: "listing",
51+ listingType: "sale",
52+ price: p.price ?? undefined,
53+ propertyType: p.property_type ?? undefined,
54+ bedrooms: p.bedrooms ?? undefined,
55+ bathrooms: p.bathrooms ?? undefined,
56+ address: p.address ?? p.title ?? undefined,
57+ city: p.city ?? undefined,
58+ region: p.sector ?? undefined,
59+ thumbnailUrl: p.image ?? undefined,
60+ originalUrl: `/property/${encodeURIComponent(p.uid)}`,
61+ highlight: false,
62+ extra: {
63+ title: p.title,
64+ priceLabel: p.price_label,
65+ source: p.source,
66+ },
67+ };
68+}
69+
70+/** Viewport→data adapter for the House-Ka map. */
71+export const houseKaMapAdapter: KaDataAdapter = {
72+ id: "house-ka-listings",
73+ appSource: "house-ka",
74+ async fetchInBounds(query: BoundsQuery): Promise<BoundsQueryResult> {
75+ const params = new URLSearchParams();
76+ const filters = (query.filters ?? {}) as Partial<ListingFilters>;
77+ for (const [k, v] of Object.entries(filters)) {
78+ if (v && k !== "sort") params.set(k, String(v));
79+ }
80+ params.set("bbox", bboxToString(query.bbox));
81+ params.set("limit", "3000");
82+
83+ const res = await fetch(`/api/listings.geojson?${params}`, {
84+ signal: query.signal,
85+ });
86+ if (!res.ok) throw new Error(`Map: API ${res.status}`);
87+ const data = (await res.json()) as HouseKaFC;
88+
89+ return {
90+ properties: data.features.map((f) =>
91+ toMapProperty(f.geometry.coordinates, f.properties),
92+ ),
93+ totalCount: data.totalMatching,
94+ };
95+ },
96+};
added frontend/src/kamaps/config.ts +11 −0
@@ -0,0 +1,11 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (Immo-Ka integration)
5+// kamaps/config.ts : jeton PUBLIC Mapbox (pk.…), surchargable au build via
6+// VITE_MAPBOX_TOKEN ; restrictions d'URL gérées côté tableau de bord Mapbox.
7+// -----------------------------------------------------------------------------
8+
9+export const MAPBOX_TOKEN: string =
10+ (import.meta.env.VITE_MAPBOX_TOKEN as string | undefined) ??
11+ "pk.eyJ1Ijoic3Bib3VjaGVyIiwiYSI6ImNtc3Fyb3k4djAwOTgyenB3dWt6NHBjc2kifQ.poqLf0ADy3lIh28O-pFI2Q";
added frontend/src/kamaps/theme.ts +49 −0
@@ -0,0 +1,49 @@
1+// -----------------------------------------------------------------------------
2+// Author: Simon-Pierre Boucher
3+// Contact: contact@spboucher.ai
4+// Project: Groupe Ka / Ka Maps (House-Ka integration)
5+// kamaps/theme.ts : House-Ka Maps theme — pine green / cream / ink identity.
6+// Premium price pills: cream with ink text; the PINE #0f6b4f is reserved for
7+// brand moments (selection, controls, highlights).
8+// -----------------------------------------------------------------------------
9+import type { KaMapTheme } from "@groupe-ka/ka-maps";
10+
11+const INK = "#14201a";
12+const PINE = "#0f6b4f";
13+const CREAM = "#faf7f0";
14+const WHITE = "#ffffff";
15+
16+export const houseKaMapTheme: KaMapTheme = {
17+ id: "house-ka",
18+ productName: "House-Ka Maps",
19+ accent: PINE,
20+ onAccent: WHITE,
21+ fontFamily: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
22+ markers: {
23+ // Asking price: cream pill, ink text — readable without crushing the map.
24+ // Selection/hover: full ink, the absolute anchor.
25+ sale: {
26+ background: CREAM,
27+ text: INK,
28+ halo: "rgba(20, 32, 26, 0.30)",
29+ selectedBackground: INK,
30+ selectedText: WHITE,
31+ },
32+ // Highlighted listings: pine.
33+ highlight: {
34+ background: PINE,
35+ text: WHITE,
36+ halo: "rgba(255, 255, 255, 0.55)",
37+ selectedBackground: INK,
38+ selectedText: WHITE,
39+ },
40+ },
41+ cluster: {
42+ background: CREAM,
43+ text: INK,
44+ border: PINE,
45+ valueText: "#0a4a37",
46+ valueHalo: "rgba(250, 247, 240, 0.92)",
47+ },
48+ supportsDark: false,
49+};
added frontend/src/main.tsx +20 −0
@@ -0,0 +1,20 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// main.tsx : React entry point — Groupe KA tokens (ka/tokens.css) BEFORE the
5+// local CSS.
6+// -----------------------------------------------------------------------------
7+import React from "react";
8+import ReactDOM from "react-dom/client";
9+import { BrowserRouter } from "react-router-dom";
10+import App from "./App";
11+import "./ka/tokens.css";
12+import "./styles.css";
13+
14+ReactDOM.createRoot(document.getElementById("root")!).render(
15+ <React.StrictMode>
16+ <BrowserRouter>
17+ <App />
18+ </BrowserRouter>
19+ </React.StrictMode>
20+);
added frontend/src/pages/Agencies.tsx +91 −0
@@ -0,0 +1,91 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// pages/Agencies.tsx : brokerage registry — banner → brokerage (office)
5+// -----------------------------------------------------------------------------
6+import { useEffect, useState } from "react";
7+import { Link } from "react-router-dom";
8+import { Franchise, fetchAgencies } from "../api";
9+
10+export default function AgenciesPage() {
11+ const [data, setData] = useState<Franchise[] | null>(null);
12+ const [error, setError] = useState<string | null>(null);
13+ const [open, setOpen] = useState<Record<string, boolean>>({});
14+
15+ useEffect(() => {
16+ document.title = "Covered brokerages | House-Ka";
17+ fetchAgencies().then((r) => setData(r.franchises)).catch((e) => setError(String(e)));
18+ }, []);
19+
20+ const totalProps = (data ?? []).reduce((s, f) => s + f.total, 0);
21+ const totalSub = (data ?? []).reduce((s, f) => s + f.sub_agencies, 0);
22+
23+ return (
24+ <div className="container sources">
25+ <span className="kicker">Registry — brokerages & offices</span>
26+ <h1>Sources by brokerage</h1>
27+ <p className="sub">
28+ Every covered brokerage or team publishes its board's full inventory
29+ through the CREA DDF feed. Each listing is synced by a dedicated
30+ connector and deduplicated by DDF number — the same property published
31+ on several sites is only counted once. Click a source to see its homes.
32+ </p>
33+
34+ {error && <div className="notice"> {error}</div>}
35+ {!data && !error && <div className="notice">Loading…</div>}
36+
37+ {data && (
38+ <>
39+ <p className="stats-foot" style={{ marginTop: 0 }}>
40+ <b>{totalProps.toLocaleString("en-CA")}</b> homes ·{" "}
41+ <b>{data.length}</b> banners ·{" "}
42+ <b>{totalSub.toLocaleString("en-CA")}</b> offices
43+ </p>
44+
45+ <div className="franchise-list">
46+ {data.map((f) => {
47+ const isOpen = open[f.franchise] ?? false;
48+ return (
49+ <div className="franchise" key={f.franchise}>
50+ <button
51+ className="franchise-head"
52+ onClick={() => setOpen({ ...open, [f.franchise]: !isOpen })}
53+ aria-expanded={isOpen}
54+ >
55+ <span className="fh-caret">{isOpen ? "▾" : "▸"}</span>
56+ <span className="fh-name">{f.franchise}</span>
57+ <span className="fh-sub">
58+ {f.sub_agencies} office{f.sub_agencies > 1 ? "s" : ""}
59+ </span>
60+ <span className="fh-count">{f.total.toLocaleString("en-CA")}</span>
61+ </button>
62+
63+ {isOpen && (
64+ <ul className="subagency-list">
65+ {f.agencies.map((a) => (
66+ <li key={a.name}>
67+ <span className="sa-name">{a.name}</span>
68+ <Link
69+ className="count-pill"
70+ to={`/?source=${encodeURIComponent(a.sources[0])}`}
71+ >
72+ {a.count.toLocaleString("en-CA")}
73+ </Link>
74+ </li>
75+ ))}
76+ </ul>
77+ )}
78+ </div>
79+ );
80+ })}
81+ </div>
82+
83+ <p className="stats-foot">
84+ Sites sharing the same CREA DDF pool also serve as backup sources,
85+ deduplicated by DDF number — no double counting.
86+ </p>
87+ </>
88+ )}
89+ </div>
90+ );
91+}
added frontend/src/pages/Contact.tsx +76 −0
@@ -0,0 +1,76 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// A Groupe-Ka application — contact@groupe-ka.com
4+// pages/Contact.tsx : shared Groupe KA contact page (English).
5+// -----------------------------------------------------------------------------
6+
7+const CONTACTS = [
8+ { role: "Projects and data", email: "contact@groupe-ka.com" },
9+ { role: "Media", email: "info@groupe-ka.com" },
10+ { role: "Legal and privacy", email: "admin@groupe-ka.com" },
11+];
12+
13+const SITES = [
14+ { wordmark: "Groupe·Ka", domain: "www.groupe-ka.com", tagline: "the Groupe KA portal" },
15+ { wordmark: "Immo·Ka", domain: "www.immo-ka.com", tagline: "homes for sale in Québec" },
16+ { wordmark: "Lou·Ka", domain: "www.lou-ka.com", tagline: "rentals in Québec" },
17+ { wordmark: "Vrai·Prix", domain: "www.vrai-prix.com", tagline: "Québec market-value estimates" },
18+ { wordmark: "Auto·Ka", domain: "www.auto-ka.com", tagline: "used cars" },
19+ { wordmark: "Job·Ka", domain: "www.job-ka.com", tagline: "job listings" },
20+];
21+
22+export default function ContactPage() {
23+ return (
24+ <div className="container contact">
25+ <span className="kicker">Contact</span>
26+ <h1>
27+ Write to <span className="hl">Groupe KA</span>
28+ </h1>
29+ <p className="lede">
30+ House-Ka is a service of <b>Groupe-Ka</b> — an ecosystem of data
31+ aggregators. All the platforms share the same contact channels.
32+ </p>
33+
34+ <div className="contact-cards">
35+ {CONTACTS.map((c) => (
36+ <a key={c.email} className="contact-card" href={`mailto:${c.email}`}>
37+ <span className="contact-role">{c.role}</span>
38+ <span className="contact-mail">{c.email}</span>
39+ </a>
40+ ))}
41+ </div>
42+
43+ <p className="contact-disclaimer">
44+ <b>House-Ka is an independent aggregator — it is not a brokerage and is
45+ not affiliated with the sources it indexes.</b> Every listing links back
46+ to the original page of the brokerage: for a specific property, contact
47+ the listing agent shown on the page directly.
48+ </p>
49+
50+ <div className="contact-hub">
51+ <a
52+ className="btn btn-primary"
53+ href="https://www.groupe-ka.com"
54+ target="_blank"
55+ rel="noopener noreferrer"
56+ >
57+ Visit the groupe-ka.com portal ↗
58+ </a>
59+ </div>
60+
61+ <section className="contact-sites">
62+ <h2>The ·Ka ecosystem</h2>
63+ <ul>
64+ {SITES.map((s) => (
65+ <li key={s.domain}>
66+ <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
67+ <b>{s.wordmark}</b>
68+ <span>{s.tagline}</span>
69+ </a>
70+ </li>
71+ ))}
72+ </ul>
73+ </section>
74+ </div>
75+ );
76+}
added frontend/src/pages/Home.tsx +518 −0
@@ -0,0 +1,518 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// pages/Home.tsx : home — hero, live stats, advanced filters, grid + map
5+// -----------------------------------------------------------------------------
6+import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react";
7+import { useSearchParams } from "react-router-dom";
8+import {
9+ Facets, Listing, ListingFilters, Stats,
10+ fetchFacets, fetchListings, fetchSources, fetchStats,
11+ registerSourceNames, sourceName,
12+} from "../api";
13+import ListingCard from "../components/ListingCard";
14+import { Ico } from "../components/Icons";
15+
16+const MapView = lazy(() => import("../components/MapView"));
17+
18+const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];
19+const AREA_STEPS = [800, 1000, 1500, 2000, 3000];
20+const PAGE = 12;
21+
22+const fmtK = (n: number) =>
23+ n >= 1_000_000 ? `$${n / 1_000_000}M` : `$${Math.round(n / 1000)}k`;
24+
25+/** Animated hero counter (~0.9 s, cubic easing) — the feel of a real engine
26+ indexing. Respects prefers-reduced-motion (direct value). */
27+function useCountUp(target: number | null | undefined, ms = 900): string | null {
28+ const [v, setV] = useState<number | null>(null);
29+ useEffect(() => {
30+ if (target == null) return;
31+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { setV(target); return; }
32+ let raf = 0;
33+ const t0 = performance.now();
34+ const step = (t: number) => {
35+ const p = Math.min(1, (t - t0) / ms);
36+ setV(Math.round(target * (1 - Math.pow(1 - p, 3))));
37+ if (p < 1) raf = requestAnimationFrame(step);
38+ };
39+ raf = requestAnimationFrame(step);
40+ return () => cancelAnimationFrame(raf);
41+ }, [target, ms]);
42+ return v == null ? null : v.toLocaleString("en-CA");
43+}
44+
45+/** live "X s ago" — re-rendered every second while the ts exists. */
46+function useAgo(ts: number | null): string | null {
47+ const [, tick] = useState(0);
48+ useEffect(() => {
49+ if (ts == null) return;
50+ const id = setInterval(() => tick((x) => x + 1), 1000);
51+ return () => clearInterval(id);
52+ }, [ts]);
53+ if (ts == null) return null;
54+ const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));
55+ if (s < 90) return `${s} s ago`;
56+ if (s < 5400) return `${Math.round(s / 60)} min ago`;
57+ return `${Math.round(s / 3600)} h ago`;
58+}
59+
60+// pagination window: 1 … (p-1) p (p+1) … N
61+function pageNumbers(p: number, n: number): (number | "…")[] {
62+ if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1);
63+ const out: (number | "…")[] = [1];
64+ const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1);
65+ if (lo > 2) out.push("…");
66+ for (let i = lo; i <= hi; i++) out.push(i);
67+ if (hi < n - 1) out.push("…");
68+ out.push(n);
69+ return out;
70+}
71+
72+export default function Home() {
73+ const [listings, setListings] = useState<Listing[] | null>(null);
74+ const [total, setTotal] = useState(0);
75+ const [params, setParams] = useSearchParams();
76+ const [page, setPage] = useState(Number(params.get("page")) || 1); // 12/page, in the URL
77+ const [facets, setFacets] = useState<Facets | null>(null);
78+ const [sectors, setSectors] = useState<string[]>([]);
79+ const [stats, setStats] = useState<Stats | null>(null);
80+ const [error, setError] = useState<string | null>(null);
81+ const [q, setQ] = useState(params.get("q") ?? "");
82+ const [city, setCity] = useState(params.get("city") ?? "");
83+ const [sector, setSector] = useState(params.get("sector") ?? "");
84+ const [ptype, setPtype] = useState(params.get("property_type") ?? "");
85+ const [source, setSource] = useState(params.get("source") ?? "");
86+ const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
87+ const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
88+ const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");
89+ const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");
90+ const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");
91+ const [sort, setSort] = useState(params.get("sort") ?? "recent");
92+
93+ const [sheetOpen, setSheetOpen] = useState(false);
94+ const [advOpen, setAdvOpen] = useState(false);
95+
96+ // Filter bottom-sheet: background scroll lock + Escape to close
97+ // (same rules as the header mobile menu — see App.tsx).
98+ useEffect(() => {
99+ if (!sheetOpen) return;
100+ document.body.style.overflow = "hidden";
101+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); };
102+ window.addEventListener("keydown", onKey);
103+ return () => {
104+ document.body.style.overflow = "";
105+ window.removeEventListener("keydown", onKey);
106+ };
107+ }, [sheetOpen]);
108+ const activeFilters = [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin].filter(Boolean).length;
109+ const advCount = [bedsMin, bathsMin, areaMin, source, sector].filter(Boolean).length;
110+
111+ const [view, setView] = useState<"list" | "map">(params.get("view") === "map" ? "map" : "list");
112+ useEffect(() => { setView(params.get("view") === "map" ? "map" : "list"); }, [params]);
113+
114+ // map mode (Ka Map System v2): body class — the page's filter sheet becomes
115+ // a modal ABOVE the full-viewport shell.
116+ useEffect(() => {
117+ document.body.classList.toggle("ka-map-mode", view === "map");
118+ return () => document.body.classList.remove("ka-map-mode");
119+ }, [view]);
120+
121+ const filters: ListingFilters = useMemo(() => ({
122+ q, city, sector, region: "", property_type: ptype, source,
123+ price_min: priceMin, price_max: priceMax,
124+ bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort,
125+ }), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);
126+
127+ useEffect(() => {
128+ fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
129+ fetchFacets().then(setFacets).catch(() => {});
130+ fetchStats().then(setStats).catch(() => {});
131+ }, []);
132+
133+ useEffect(() => {
134+ fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));
135+ }, [city]);
136+
137+ // back to page 1 whenever the filters change
138+ const firstRender = useRef(true);
139+ useEffect(() => {
140+ if (firstRender.current) { firstRender.current = false; return; }
141+ setPage(1);
142+ }, [filters]);
143+
144+ // sync filters + page + sort + view into the URL → going back from a
145+ // listing returns to the SAME page/filters.
146+ useEffect(() => {
147+ const p = new URLSearchParams();
148+ const set = (k: string, v: string) => { if (v) p.set(k, v); };
149+ set("q", q); set("city", city); set("sector", sector);
150+ set("property_type", ptype); set("source", source);
151+ set("price_min", priceMin); set("price_max", priceMax);
152+ set("bedrooms_min", bedsMin); set("bathrooms_min", bathsMin);
153+ set("area_min", areaMin);
154+ if (sort && sort !== "recent") p.set("sort", sort);
155+ if (view === "map") p.set("view", "map");
156+ if (page > 1) p.set("page", String(page));
157+ setParams(p, { replace: true });
158+ }, [filters, page, view, q, city, sector, ptype, source, priceMin, priceMax,
159+ bedsMin, bathsMin, areaMin, sort, setParams]);
160+
161+ // load the current page (12 listings) — replaces the grid
162+ useEffect(() => {
163+ let cancelled = false;
164+ setListings(null); setError(null);
165+ fetchListings(filters, PAGE, (page - 1) * PAGE)
166+ .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } })
167+ .catch((e) => !cancelled && setError(String(e)));
168+ return () => { cancelled = true; };
169+ }, [filters, page]);
170+
171+ const totalPages = Math.max(1, Math.ceil(total / PAGE));
172+ const gotoPage = (p: number) => {
173+ setPage(Math.min(Math.max(1, p), totalPages));
174+ if (view === "map") return; // the map-mode pane manages its own scrolling
175+ document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" });
176+ };
177+
178+ const resetAll = () => {
179+ setQ(""); setCity(""); setSector(""); setPtype(""); setSource("");
180+ setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");
181+ };
182+
183+ // live hero data — real connector syncs (recent_syncs)
184+ const totalLive = useCountUp(stats?.total);
185+ const lastSync = useMemo(() => {
186+ const ss = stats?.recent_syncs ?? [];
187+ if (!ss.length) return null;
188+ const ts = Math.max(...ss.map((s) => s.ts));
189+ return ts > 1e12 ? Math.round(ts / 1000) : ts;
190+ }, [stats]);
191+ const syncAgo = useAgo(lastSync);
192+ const newToday = useMemo(() => {
193+ const ss = stats?.recent_syncs ?? [];
194+ const now = Date.now() / 1000;
195+ return ss
196+ .filter((s) => (s.ts > 1e12 ? s.ts / 1000 : s.ts) > now - 86400)
197+ .reduce((n, s) => n + (s.added || 0), 0);
198+ }, [stats]);
199+
200+ const pills: { label: string; clear: () => void }[] = [];
201+ if (q) pills.push({ label: `“${q}”`, clear: () => setQ("") });
202+ if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });
203+ if (sector) pills.push({ label: sector, clear: () => setSector("") });
204+ if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });
205+ if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });
206+ if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });
207+ if (bedsMin) pills.push({ label: `${bedsMin}+ bed`, clear: () => setBedsMin("") });
208+ if (bathsMin) pills.push({ label: `${bathsMin}+ bath`, clear: () => setBathsMin("") });
209+ if (areaMin) pills.push({ label: `≥ ${areaMin} sq ft`, clear: () => setAreaMin("") });
210+ if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
211+
212+ const topTypes = (facets?.property_types ?? []).slice(0, 7);
213+
214+ return (
215+ <div className="container">
216+ <section className="hero">
217+ <div className="hero-wrap">
218+ <div className="hero-main">
219+ <span className="kicker">Aggregator — Canadian brokerages, Ontario first</span>
220+ <h1 className="hero-display" aria-label="Every home for sale. One place.">
221+ <span className="hd-l1" aria-hidden="true">Every home</span>
222+ <span className="hd-l2" aria-hidden="true">for sale.</span>
223+ <span className="hd-l4" aria-hidden="true">One <em className="signal">place</em>.</span>
224+ </h1>
225+ <p className="lede">
226+ Homes listed by Canadian real-estate brokerages and teams on the
227+ CREA DDF feed — aggregated continuously, full photos and details,
228+ direct link to the original listing. Ontario today, the rest of
229+ Canada next.
230+ </p>
231+ <div className="live-line" aria-label="Live data">
232+ <span className="live-flag"><span className="live-dot" /> live</span>
233+ {syncAgo && <span className="live-item">synced {syncAgo}</span>}
234+ {newToday > 0 && (
235+ <span className="live-item"><b>+{newToday.toLocaleString("en-CA")}</b> today</span>
236+ )}
237+ {stats && stats.sources > 0 && (
238+ <span className="live-item"><b>{stats.sources}</b> sources</span>
239+ )}
240+ </div>
241+ </div>
242+ <aside className="hero-data" aria-label="The market in numbers">
243+ <div className="hd-row">
244+ <b>{totalLive ?? "—"}</b><span>homes indexed</span>
245+ </div>
246+ {stats && stats.cities != null && (
247+ <div className="hd-row">
248+ <b>{stats.cities.toLocaleString("en-CA")}</b><span>cities & towns</span>
249+ </div>
250+ )}
251+ {stats?.avg_price != null && (
252+ <div className="hd-row">
253+ <b>${Math.round(stats.avg_price).toLocaleString("en-CA")}</b><span>average price</span>
254+ </div>
255+ )}
256+ {stats?.max_price != null && (
257+ <div className="hd-row">
258+ <b>{fmtK(stats.max_price)}</b><span>highest price</span>
259+ </div>
260+ )}
261+ </aside>
262+ </div>
263+ </section>
264+
265+ {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}
266+ <section className={`search-zone ${sheetOpen ? "open" : ""}`} aria-label="Search and filters">
267+ <div className="sheet-handle" aria-hidden="true" />
268+ <div className="sheet-head">
269+ <span>Refine your search</span>
270+ <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Close the filters">✕</button>
271+ </div>
272+
273+ {/* — search, the heart of the product: one large underlined field — */}
274+ <div className="q-big">
275+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
276+ <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
277+ </svg>
278+ <input id="f-q" placeholder="Where do you want to live?" value={q}
279+ onChange={(e) => setQ(e.target.value)}
280+ aria-label="Search — address, city or MLS number" />
281+ {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Clear the search">✕</button>}
282+ </div>
283+
284+ {/* — inline criteria, separated by hairlines (no boxes) — */}
285+ <div className="crit-line">
286+ <label className="crit">
287+ <span>City</span>
288+ <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
289+ <option value="">All</option>
290+ {(facets?.cities ?? []).map((c) => <option key={c} value={c}>{c}</option>)}
291+ </select>
292+ </label>
293+ <label className="crit">
294+ <span>Type</span>
295+ <select value={ptype} onChange={(e) => setPtype(e.target.value)}>
296+ <option value="">All</option>
297+ {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}
298+ </select>
299+ </label>
300+ <div className="crit">
301+ <span>Price</span>
302+ <div className="range-pair">
303+ <select aria-label="Minimum price" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>
304+ <option value="">Min</option>
305+ {PRICE_STEPS.map((p) => (
306+ <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>
307+ ))}
308+ </select>
309+ <span className="range-sep">—</span>
310+ <select aria-label="Maximum price" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>
311+ <option value="">Max</option>
312+ {PRICE_STEPS.map((p) => (
313+ <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>
314+ ))}
315+ </select>
316+ </div>
317+ </div>
318+ <button className={`crit-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
319+ All criteria
320+ {advCount > 0 && <span className="crit-badge">{advCount}</span>}
321+ <span className={`f-chev${advOpen ? " up" : ""}`} aria-hidden="true" />
322+ </button>
323+ </div>
324+
325+ {(advOpen || sheetOpen) && (
326+ <div className="f-adv">
327+ <div className="f-group">
328+ <label>Neighbourhood / area</label>
329+ <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>
330+ <option value="">All</option>
331+ {sectors.map((s) => <option key={s} value={s}>{s}</option>)}
332+ </select>
333+ </div>
334+ <div className="f-group">
335+ <label>Bedrooms (min.)</label>
336+ <div className="seg" role="group">
337+ <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Any</button>
338+ {["1", "2", "3", "4", "5"].map((n) => (
339+ <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>
340+ ))}
341+ </div>
342+ </div>
343+ <div className="f-group">
344+ <label>Bathrooms (min.)</label>
345+ <div className="seg" role="group">
346+ <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Any</button>
347+ {["1", "2", "3"].map((n) => (
348+ <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>
349+ ))}
350+ </div>
351+ </div>
352+ <div className="f-group">
353+ <label>Minimum living area</label>
354+ <div className="seg" role="group">
355+ <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Any</button>
356+ {AREA_STEPS.map((a) => (
357+ <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>
358+ ))}
359+ </div>
360+ </div>
361+ <div className="f-group">
362+ <label>Source</label>
363+ <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
364+ <option value="">All</option>
365+ {(facets?.sources ?? []).map((s) => (
366+ <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>
367+ ))}
368+ </select>
369+ </div>
370+ <div className="f-group f-group-end">
371+ <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
372+ Reset everything{activeFilters > 0 ? ` (${activeFilters})` : ""}
373+ </button>
374+ </div>
375+ </div>
376+ )}
377+
378+ <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
379+ See {listings ? `the ${total.toLocaleString("en-CA")} homes` : "the results"}
380+ </button>
381+ </section>
382+
383+ {/* mobile: criteria summary — opens the sheet (search built in, no FAB) */}
384+ <button className="crit-summary" onClick={() => setSheetOpen(true)}>
385+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
386+ <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
387+ </svg>
388+ <span className="cs-txt">
389+ {pills.length > 0
390+ ? pills.slice(0, 3).map((p) => p.label).join(" · ") + (pills.length > 3 ? ` · +${pills.length - 3}` : "")
391+ : "City, type, price, bedrooms…"}
392+ </span>
393+ <span className="f-chev" aria-hidden="true" />
394+ </button>
395+
396+ <div className="chips" role="group" aria-label="Quick filters">
397+ {topTypes.map((t) => (
398+ <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>
399+ {t}
400+ </button>
401+ ))}
402+ </div>
403+
404+ {pills.length > 0 && (
405+ <div className="pills" aria-label="Active filters">
406+ {pills.map((p) => (
407+ <button key={p.label} className="pill" onClick={p.clear} aria-label={`Remove the filter ${p.label}`}>
408+ {p.label} <span className="pill-x">✕</span>
409+ </button>
410+ ))}
411+ <button className="pill pill-clear" onClick={resetAll}>Clear all</button>
412+ </div>
413+ )}
414+
415+ <div className="results-bar" id="results-top">
416+ <h2 className="rb-count">
417+ {listings
418+ ? <><b>{total.toLocaleString("en-CA")}</b> home{total > 1 ? "s" : ""}</>
419+ : "Homes"}
420+ </h2>
421+ <div className="rb-tools">
422+ <label className="rb-sort">
423+ <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
424+ <option value="recent">Newest</option>
425+ <option value="price_asc">Price: low to high</option>
426+ <option value="price_desc">Price: high to low</option>
427+ </select>
428+ </label>
429+ <div className="rb-tabs" role="tablist" aria-label="Display mode">
430+ <button role="tab" aria-selected={view === "list"} className={`rb-tab ${view === "list" ? "on" : ""}`} onClick={() => setView("list")}>
431+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
432+ <path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01" />
433+ </svg>
434+ List
435+ </button>
436+ <button role="tab" aria-selected={view === "map"} className={`rb-tab ${view === "map" ? "on" : ""}`} onClick={() => setView("map")}>
437+ <Ico name="map" size={13} /> Map
438+ </button>
439+ </div>
440+ <button className="rb-filters" onClick={() => setSheetOpen(true)}>
441+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
442+ <path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />
443+ </svg>
444+ Filters
445+ {activeFilters > 0 && <span className="rb-badge">{activeFilters}</span>}
446+ </button>
447+ </div>
448+ </div>
449+
450+ {error && (
451+ <div className="notice">
452+ <div className="big"><Ico name="alert" size={40} /></div>
453+ <h2>Could not load the listings</h2>
454+ <p>{error}</p>
455+ <button className="btn btn-primary" onClick={() => window.location.reload()}>Try again</button>
456+ </div>
457+ )}
458+
459+ {!error && view === "list" && listings === null && (
460+ <div className="grid grid-edito" aria-busy="true">
461+ {Array.from({ length: 8 }).map((_, i) => (
462+ <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>
463+ ))}
464+ </div>
465+ )}
466+
467+ {!error && view === "list" && listings !== null && listings.length === 0 && (
468+ <div className="notice">
469+ <div className="big"><Ico name="search" size={40} /></div>
470+ <h2>No home matches</h2>
471+ <p>Try widening your criteria
472+ {activeFilters > 0 && <> — or <button className="link-btn" onClick={resetAll}>remove the {activeFilters} active filters</button></>}.</p>
473+ </div>
474+ )}
475+
476+ {!error && view === "map" && (
477+ <div className="view-pane" key="map">
478+ <Suspense fallback={<div className="ka-shell-fallback">Loading the map…</div>}>
479+ <MapView
480+ filters={filters}
481+ listings={listings}
482+ total={total}
483+ page={page}
484+ totalPages={totalPages}
485+ onPage={gotoPage}
486+ sort={sort}
487+ onSort={setSort}
488+ onExit={() => setView("list")}
489+ onOpenFilters={() => setSheetOpen(true)}
490+ filtersCount={activeFilters}
491+ />
492+ </Suspense>
493+ </div>
494+ )}
495+
496+ {!error && view === "list" && listings !== null && listings.length > 0 && (
497+ <div className="view-pane">
498+ <div className="grid grid-edito">
499+ {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
500+ </div>
501+ {totalPages > 1 && (
502+ <nav className="pager" aria-label="Pagination">
503+ <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Prev</button>
504+ {pageNumbers(page, totalPages).map((p, i) =>
505+ p === "…"
506+ ? <span key={`e${i}`} className="pager-gap">…</span>
507+ : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`}
508+ onClick={() => gotoPage(p as number)}>{p}</button>
509+ )}
510+ <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Next ›</button>
511+ <span className="pager-info">Page {page} / {totalPages}</span>
512+ </nav>
513+ )}
514+ </div>
515+ )}
516+ </div>
517+ );
518+}
added frontend/src/pages/Legal.tsx +114 −0
@@ -0,0 +1,114 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// pages/Legal.tsx : Terms of use + Privacy policy (PIPEDA)
5+// -----------------------------------------------------------------------------
6+import { useEffect } from "react";
7+
8+function LegalShell({ title, children }: { title: string; children: React.ReactNode }) {
9+ useEffect(() => { document.title = `${title} | House-Ka`; window.scrollTo(0, 0); }, [title]);
10+ return (
11+ <div className="container legal">
12+ <span className="kicker">Legal</span>
13+ <h1>{title}</h1>
14+ {children}
15+ </div>
16+ );
17+}
18+
19+export function TermsPage() {
20+ return (
21+ <LegalShell title="Terms of use">
22+ <p className="legal-date">Last updated: August 27, 2026</p>
23+
24+ <h2>1. What House-Ka is</h2>
25+ <p>
26+ House-Ka (www.house-ka.com) is an <b>independent aggregator</b> of
27+ homes publicly listed for sale by Canadian real-estate brokerages and
28+ teams, operated by Groupe-Ka. House-Ka is <b>not a real-estate
29+ brokerage</b>, does not represent buyers or sellers, provides no
30+ brokerage services, and is not affiliated with, endorsed by, or
31+ sponsored by any of the sources it indexes.
32+ </p>
33+
34+ <h2>2. Nature of the information</h2>
35+ <p>
36+ Listings, prices, photos, descriptions and availability are those
37+ publicly displayed by each source at the time of synchronization. They
38+ may be incomplete, outdated or inaccurate. Every listing links back to
39+ the source's original page, which alone is authoritative. Market
40+ statistics and mortgage figures are provided for information only and
41+ constitute neither professional advice nor a financing offer.
42+ </p>
43+
44+ <h2>3. Permitted use</h2>
45+ <p>
46+ The site is offered for personal, non-commercial use. Automated
47+ scraping of House-Ka, republishing of its aggregated database, or any
48+ use that disrupts the service is prohibited. Trademarks and photos
49+ remain the property of their respective owners.
50+ </p>
51+
52+ <h2>4. Liability</h2>
53+ <p>
54+ The service is provided “as is”, without warranty of any kind.
55+ Groupe-Ka cannot be held liable for decisions made on the basis of the
56+ information displayed, for source errors, or for service
57+ interruptions. For any transaction, verify the information with the
58+ listing brokerage and the appropriate professionals.
59+ </p>
60+
61+ <h2>5. Removal requests</h2>
62+ <p>
63+ A brokerage or rights holder who wishes a listing or a source to be
64+ removed can write to <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a> —
65+ requests are honoured promptly.
66+ </p>
67+
68+ <h2>6. Contact</h2>
69+ <p>
70+ Groupe-Ka — <a href="mailto:contact@groupe-ka.com">contact@groupe-ka.com</a>.
71+ </p>
72+ </LegalShell>
73+ );
74+}
75+
76+export function PrivacyPage() {
77+ return (
78+ <LegalShell title="Privacy policy">
79+ <p className="legal-date">Last updated: August 27, 2026</p>
80+
81+ <h2>1. What we collect</h2>
82+ <p>
83+ House-Ka can be browsed without an account and without providing any
84+ personal information. Our servers keep standard technical logs
85+ (IP address, pages requested, user agent) for security and capacity
86+ purposes, retained for a limited time.
87+ </p>
88+
89+ <h2>2. What we do not do</h2>
90+ <p>
91+ No sale or sharing of personal information, no advertising trackers,
92+ no profiling. Third-party map tiles (Mapbox) and listing photos are
93+ loaded from their providers, which may see your IP address as with any
94+ website.
95+ </p>
96+
97+ <h2>3. Listing data</h2>
98+ <p>
99+ The property information displayed comes from listings publicly
100+ published by brokerages. Agent names and business phone numbers shown
101+ on listings are professional contact details published by the source.
102+ Removal requests: <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>.
103+ </p>
104+
105+ <h2>4. Your rights (PIPEDA)</h2>
106+ <p>
107+ In accordance with the Personal Information Protection and Electronic
108+ Documents Act, you may ask what information we hold about you, request
109+ a correction or deletion, by writing to{" "}
110+ <a href="mailto:admin@groupe-ka.com">admin@groupe-ka.com</a>.
111+ </p>
112+ </LegalShell>
113+ );
114+}
added frontend/src/pages/Listing.tsx +444 −0
@@ -0,0 +1,444 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// pages/Listing.tsx : full listing page
5+// gallery + lightbox · specs · details (DDF fields) · rooms ·
6+// features · description · price history · agent · mini-map · financing
7+// -----------------------------------------------------------------------------
8+import { Suspense, lazy, useEffect, useRef, useState } from "react";
9+import { Link, useParams } from "react-router-dom";
10+import {
11+ Listing, Room, fetchListing, fetchSources, fmtArea, fmtDate, fmtPrice,
12+ registerSourceNames, sourceName,
13+} from "../api";
14+
15+const PropertyMap = lazy(() => import("../components/PropertyMap"));
16+import Financing from "../components/Financing";
17+import NearbyPlaces from "../components/NearbyPlaces";
18+import { Ico } from "../components/Icons";
19+import AmenityIco from "../components/AmenityIco";
20+import { TypeFallback } from "../components/PropertyImg";
21+
22+// --- Lightbox: pinch to zoom + pan + swipe between photos ---------------------
23+function ZoomImg({ src, onSwipe }: { src: string; onSwipe: (dir: 1 | -1) => void }) {
24+ const [t, setT] = useState({ scale: 1, x: 0, y: 0 });
25+ const pointers = useRef(new Map<number, { x: number; y: number }>());
26+ const start = useRef({ scale: 1, x: 0, y: 0, dist: 0, cx: 0, cy: 0, t: 0 });
27+ const lastTap = useRef(0);
28+
29+ // reset when the photo changes
30+ useEffect(() => { setT({ scale: 1, x: 0, y: 0 }); }, [src]);
31+
32+ const dist = () => {
33+ const p = [...pointers.current.values()];
34+ return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
35+ };
36+ const center = () => {
37+ const p = [...pointers.current.values()];
38+ return p.length < 2
39+ ? p[0] ?? { x: 0, y: 0 }
40+ : { x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 };
41+ };
42+
43+ const onDown = (e: React.PointerEvent) => {
44+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
45+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
46+ const c = center();
47+ start.current = { scale: t.scale, x: t.x, y: t.y, dist: dist(), cx: c.x, cy: c.y, t: Date.now() };
48+ };
49+ const onMove = (e: React.PointerEvent) => {
50+ if (!pointers.current.has(e.pointerId)) return;
51+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
52+ const s = start.current;
53+ if (pointers.current.size >= 2 && s.dist > 0) {
54+ // pinch: zoom around the two-finger midpoint
55+ const scale = Math.min(4, Math.max(1, (dist() / s.dist) * s.scale));
56+ const c = center();
57+ setT({ scale, x: s.x + (c.x - s.cx), y: s.y + (c.y - s.cy) });
58+ } else if (pointers.current.size === 1 && t.scale > 1) {
59+ // pan once zoomed
60+ const p = pointers.current.get(e.pointerId)!;
61+ setT({ scale: t.scale, x: s.x + (p.x - s.cx), y: s.y + (p.y - s.cy) });
62+ }
63+ };
64+ const onUp = (e: React.PointerEvent) => {
65+ const p = pointers.current.get(e.pointerId);
66+ pointers.current.delete(e.pointerId);
67+ const s = start.current;
68+ if (pointers.current.size === 0 && p) {
69+ const dx = p.x - s.cx, dy = p.y - s.cy, dt = Date.now() - s.t;
70+ if (t.scale <= 1.05 && Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) {
71+ onSwipe(dx < 0 ? 1 : -1); // swipe → next photo
72+ } else if (dt < 260 && Math.abs(dx) < 8 && Math.abs(dy) < 8) {
73+ const now = Date.now();
74+ if (now - lastTap.current < 320) // double-tap: ×2.4 zoom
75+ setT(t.scale > 1 ? { scale: 1, x: 0, y: 0 } : { scale: 2.4, x: 0, y: 0 });
76+ lastTap.current = now;
77+ }
78+ if (t.scale <= 1.02) setT({ scale: 1, x: 0, y: 0 });
79+ }
80+ };
81+
82+ return (
83+ <img
84+ src={src} alt="" draggable={false}
85+ style={{
86+ transform: `translate(${t.x}px, ${t.y}px) scale(${t.scale})`,
87+ transition: pointers.current.size ? "none" : "transform 0.15s ease",
88+ touchAction: "none", cursor: t.scale > 1 ? "grab" : "zoom-out",
89+ }}
90+ onClick={(e) => e.stopPropagation()}
91+ onPointerDown={onDown} onPointerMove={onMove}
92+ onPointerUp={onUp} onPointerCancel={onUp}
93+ />
94+ );
95+}
96+
97+// --- Gallery: native swipe (scroll-snap) + thumbnails + fullscreen ------------
98+function Gallery({ images, captions, title, type }:
99+ { images: string[]; captions?: string[]; title: string; type?: string }) {
100+ const [idx, setIdx] = useState(0);
101+ const [zoom, setZoom] = useState(false);
102+ const [dead, setDead] = useState<Set<string>>(new Set());
103+ const track = useRef<HTMLDivElement>(null);
104+
105+ // images that fail to load: removed from the gallery on the fly (never a
106+ // broken-image icon); captions kept aligned
107+ const alive = images
108+ .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))
109+ .filter(({ u }) => !dead.has(u));
110+ const markDead = (u: string) => setDead((d) => new Set(d).add(u));
111+
112+ const onScroll = () => {
113+ const el = track.current;
114+ if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
115+ };
116+ const goto = (i: number) =>
117+ track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
118+
119+ useEffect(() => {
120+ if (!zoom) return;
121+ const onKey = (e: KeyboardEvent) => {
122+ if (e.key === "Escape") setZoom(false);
123+ if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));
124+ if (e.key === "ArrowRight") setIdx((i) => Math.min(alive.length - 1, i + 1));
125+ };
126+ window.addEventListener("keydown", onKey);
127+ // freeze the background while fullscreen (mobile)
128+ document.body.style.overflow = "hidden";
129+ return () => {
130+ window.removeEventListener("keydown", onKey);
131+ document.body.style.overflow = "";
132+ };
133+ }, [zoom, alive.length]);
134+
135+ if (alive.length === 0)
136+ return <div className="carousel"><div className="carousel-empty"><TypeFallback type={type} /></div></div>;
137+
138+ const cur = Math.min(idx, alive.length - 1);
139+ const swipe = (dir: 1 | -1) =>
140+ setIdx((i) => Math.min(alive.length - 1, Math.max(0, i + dir)));
141+
142+ return (
143+ <>
144+ <div className="carousel">
145+ <div className="carousel-track" ref={track} onScroll={onScroll}>
146+ {alive.map(({ u }, i) => (
147+ <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"
148+ alt={`${title} — photo ${i + 1} of ${alive.length}`}
149+ onError={() => markDead(u)} onClick={() => setZoom(true)} />
150+ ))}
151+ </div>
152+ {alive[cur]?.cap && <span className="carousel-caption">{alive[cur].cap}</span>}
153+ <span className="carousel-count" aria-live="polite">{cur + 1}/{alive.length}</span>
154+ {cur > 0 && <button className="carousel-nav prev" aria-label="Previous photo" onClick={() => goto(cur - 1)}>‹</button>}
155+ {cur < alive.length - 1 && <button className="carousel-nav next" aria-label="Next photo" onClick={() => goto(cur + 1)}>›</button>}
156+ </div>
157+ {alive.length > 1 && (
158+ <div className="thumbs">
159+ {alive.map(({ u }, i) => (
160+ <button key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
161+ <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />
162+ </button>
163+ ))}
164+ </div>
165+ )}
166+ {zoom && (
167+ <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Enlarged photo">
168+ <button className="lb-close" aria-label="Close" onClick={() => setZoom(false)}>✕</button>
169+ {cur > 0 && <button className="lb-nav prev" aria-label="Previous" onClick={(e) => { e.stopPropagation(); setIdx(cur - 1); }}>‹</button>}
170+ <ZoomImg src={alive[cur].u} onSwipe={swipe} />
171+ {cur < alive.length - 1 && <button className="lb-nav next" aria-label="Next" onClick={(e) => { e.stopPropagation(); setIdx(cur + 1); }}>›</button>}
172+ <span className="lb-count">
173+ {alive[cur]?.cap ? `${alive[cur].cap} · ` : ""}{cur + 1} / {alive.length}
174+ </span>
175+ </div>
176+ )}
177+ </>
178+ );
179+}
180+
181+// technical `details` keys never shown in “Details”
182+const DETAIL_HIDDEN = new Set([
183+ "pieces", "price_from", "cover_thumb", "photo_captions", "img_audited",
184+ "needs_image_review", "postal_code", "region", "transaction",
185+ "prix_pi2", "prix_m2", "listing_origin_url",
186+]);
187+
188+// icon for each “spec” tile (Icons.tsx)
189+const SPEC_ICONS: Record<string, string> = {
190+ "Type": "home", "Bedrooms": "bed", "Bathrooms": "bath",
191+ "Half baths": "drop", "Living area": "area", "Lot": "land",
192+ "Year built": "calendar", "MLS®": "tag",
193+};
194+
195+export default function ListingPage() {
196+ const { uid } = useParams<{ uid: string }>();
197+ const [l, setL] = useState<Listing | null>(null);
198+ const [error, setError] = useState<string | null>(null);
199+ // re-render when the source names arrive (Title Case fallback otherwise)
200+ const [, setSrcTick] = useState(0);
201+
202+ useEffect(() => {
203+ fetchSources().then((r) => { registerSourceNames(r.sources); setSrcTick(1); }).catch(() => {});
204+ if (!uid) return;
205+ setL(null); setError(null);
206+ fetchListing(uid).then(setL).catch((e) => setError(String(e)));
207+ window.scrollTo(0, 0);
208+ }, [uid]);
209+
210+ if (error)
211+ return (
212+ <div className="notice container">
213+ <div className="big"><Ico name="alert" size={44} /></div>
214+ <h2>Listing not found</h2>
215+ <p>{error}</p>
216+ <Link className="btn btn-primary" to="/">Back to homes</Link>
217+ </div>
218+ );
219+
220+ if (!l)
221+ return (
222+ <div className="container detail">
223+ <div className="fiche" aria-busy="true">
224+ <div className="skel"><div className="sk-img" /></div>
225+ <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
226+ </div>
227+ </div>
228+ );
229+
230+ const specs: { k: string; v: string }[] = [];
231+ if (l.property_type) specs.push({ k: "Type", v: l.property_type });
232+ if (l.bedrooms != null) specs.push({ k: "Bedrooms", v: String(l.bedrooms) });
233+ if (l.bathrooms != null) specs.push({ k: "Bathrooms", v: String(l.bathrooms) });
234+ if (l.powder_rooms != null) specs.push({ k: "Half baths", v: String(l.powder_rooms) });
235+ if (l.area_sqft != null) specs.push({ k: "Living area", v: fmtArea(l.area_sqft)! });
236+ if (l.lot_sqft != null) specs.push({ k: "Lot", v: fmtArea(l.lot_sqft)! });
237+ if (l.year_built != null) specs.push({ k: "Year built", v: String(l.year_built) });
238+ if (l.mls) specs.push({ k: "MLS®", v: l.mls });
239+
240+ const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];
241+ const detEntries = Object.entries(l.details ?? {})
242+ .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());
243+
244+ const isSale = l.details?.transaction !== "location";
245+
246+ const hist = (l.price_history ?? []).filter((h) => h.price != null);
247+ const drop = hist.length >= 2 && hist[0].price !== hist[1].price
248+ ? { from: hist[1].price!, to: hist[0].price! } : null;
249+ const updated = l.updated_at ? fmtDate(l.updated_at) : null;
250+
251+ return (
252+ <div className="container detail">
253+ <nav className="crumbs" aria-label="Breadcrumb">
254+ <Link to="/">Homes</Link> ›
255+ {l.city && <span>{l.city}</span>} ›
256+ <span>{l.address || l.title}</span>
257+ </nav>
258+
259+ <div className="fiche">
260+ {/* -------- left column: gallery, price/summary, description, ---------
261+ -------- details, rooms — DOM order = visual order ---------------- */}
262+ <div className="f-col">
263+ <section className="f-bloc f-galerie" aria-label="Photos">
264+ <Gallery
265+ images={l.images ?? []}
266+ captions={Array.isArray(l.details?.photo_captions)
267+ ? (l.details!.photo_captions as string[]) : undefined}
268+ title={l.address || l.title}
269+ type={l.property_type}
270+ />
271+ </section>
272+
273+ <section className="f-bloc f-hero">
274+ <div className="price-kicker">
275+ {isSale ? "Asking price" : "Monthly rent"}
276+ </div>
277+ <div className="price-row">
278+ <div className="price">
279+ {fmtPrice(l.price, l.price_label)}
280+ {!isSale && <span className="per-month"> /month</span>}
281+ </div>
282+ {l.property_type && (
283+ <span className="type-chip">
284+ <Ico name={SPEC_ICONS["Type"]} size={13} /> {l.property_type}
285+ {!isSale ? " · for rent" : ""}
286+ {l.details?.price_from ? " · starting at" : ""}
287+ </span>
288+ )}
289+ </div>
290+ {l.price != null && l.area_sqft != null && l.area_sqft > 200 && (
291+ <div className="price-sub">${Math.round(l.price / l.area_sqft).toLocaleString("en-CA")} / sq ft of living area</div>
292+ )}
293+ <h1>{l.address || l.title}</h1>
294+ <div className="loc"><Ico name="pin" size={13} /> {[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>
295+
296+ <div className="spec-list">
297+ {specs.map((s) => (
298+ <div className="spec-row" key={s.k}>
299+ <span className="spec-badge"><Ico name={SPEC_ICONS[s.k] ?? "tag"} size={15} /></span>
300+ <span className="spec-k">{s.k}</span>
301+ <b className="spec-v">{s.v}</b>
302+ </div>
303+ ))}
304+ </div>
305+
306+ {drop && (
307+ <div className={`prix-histo ${drop.to < drop.from ? "down" : ""}`}>
308+ <Ico name={drop.to < drop.from ? "trenddown" : "trendup"} size={16} /> Price changed from {fmtPrice(drop.from)} to <b>{fmtPrice(drop.to)}</b>
309+ </div>
310+ )}
311+
312+ {(l.broker_name || l.broker_phone) && (
313+ <div className="broker">
314+ <div className="broker-k">Listing agent</div>
315+ {l.broker_name && <div className="broker-name">{l.broker_name}</div>}
316+ {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}><Ico name="phone" size={14} /> {l.broker_phone}</a>}
317+ </div>
318+ )}
319+
320+ <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
321+ See the listing at {sourceName(l.source)} <Ico name="external" size={15} />
322+ </a>
323+ <div className="fine">
324+ Aggregated by House-Ka — {sourceName(l.source)}{updated ? ` · synced on ${updated}` : ""}.
325+ </div>
326+ </section>
327+
328+ {l.duplicates && l.duplicates.length > 0 && (
329+ <section className="f-bloc" id="publications">
330+ <h2>Also published on</h2>
331+ <p className="dups-note">
332+ This property was found on {l.duplicates.length}{" "}
333+ other site{l.duplicates.length > 1 ? "s" : ""} —
334+ House-Ka shows the most complete version.
335+ </p>
336+ <div className="dups-list">
337+ {l.duplicates.map((d) => (
338+ <a key={d.uid} className="dup-item" href={d.url} target="_blank" rel="noopener noreferrer">
339+ <span className="dup-src">{sourceName(d.source)}</span>
340+ {(d.broker_name || d.agency) && (
341+ <span className="dup-broker">{d.broker_name || d.agency}</span>
342+ )}
343+ <span className="dup-go">See the listing <Ico name="external" size={13} /></span>
344+ </a>
345+ ))}
346+ </div>
347+ </section>
348+ )}
349+
350+ {l.description && (
351+ <section className="f-bloc f-desc" id="description">
352+ <h2>Description</h2>
353+ <p className="desc-text">{l.description}</p>
354+ </section>
355+ )}
356+
357+ {detEntries.length > 0 && (
358+ <section className="f-bloc" id="details">
359+ <h2>Details</h2>
360+ <div className="dtable">
361+ {detEntries.map(([k, v]) => (
362+ <div className="drow" key={k}>
363+ <span>{k}</span>
364+ {/^https?:\/\//.test(String(v))
365+ ? <b><a href={String(v)} target="_blank" rel="noopener noreferrer">Open ↗</a></b>
366+ : <b>{String(v)}</b>}
367+ </div>
368+ ))}
369+ </div>
370+ </section>
371+ )}
372+
373+ {rooms.length > 0 && (
374+ <section className="f-bloc" id="rooms">
375+ <h2>Rooms</h2>
376+ <div className="rooms-wrap">
377+ <table className="rooms">
378+ <thead><tr><th>Room</th><th>Level</th><th>Dimensions</th><th>Flooring</th></tr></thead>
379+ <tbody>
380+ {rooms.map((r, i) => (
381+ <tr key={i}>
382+ <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>
383+ <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>
384+ </tr>
385+ ))}
386+ </tbody>
387+ </table>
388+ </div>
389+ </section>
390+ )}
391+
392+ </div>
393+
394+ {/* -------- right column (desktop): features, map --------------------- */}
395+ <div className="f-col">
396+ {l.features && l.features.length > 0 && (
397+ <section className="f-bloc" id="features">
398+ <h2>Features</h2>
399+ <div className="amenity-grid">
400+ {l.features.map((f, i) => (
401+ <span className="amenity-it" key={i}>
402+ <span className="am-ico"><AmenityIco label={f} /></span>
403+ <span className="am-txt">{f}</span>
404+ </span>
405+ ))}
406+ </div>
407+ </section>
408+ )}
409+
410+ {l.lat != null && l.lng != null && (
411+ <section className="f-bloc" id="map">
412+ <h2>Location</h2>
413+ <Suspense fallback={<div className="lmap3d lmap3d-skel map-loading">Loading the map…</div>}>
414+ <PropertyMap
415+ uid={l.uid} lat={l.lat} lng={l.lng} price={l.price}
416+ propertyType={l.property_type} address={l.address || l.title}
417+ city={l.city} image={l.images?.[0]}
418+ />
419+ </Suspense>
420+ </section>
421+ )}
422+ </div>
423+ </div>
424+
425+ {/* financing: real mortgage rates + Canadian calculator */}
426+ {isSale && <Financing price={l.price} />}
427+
428+ {/* nearby amenities: full width, AFTER the property info (correct mobile order) */}
429+ <NearbyPlaces lat={l.lat} lng={l.lng} />
430+
431+ <div className="fine f-foot">
432+ Prices and availability are those displayed by the source — every
433+ listing links back to the brokerage's original page.
434+ </div>
435+
436+ <div className="cta-sticky">
437+ <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>
438+ <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
439+ See at {sourceName(l.source)} ↗
440+ </a>
441+ </div>
442+ </div>
443+ );
444+}
added frontend/src/pages/Rates.tsx +192 −0
@@ -0,0 +1,192 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// pages/Rates.tsx : /rates — Mortgage Intelligence.
5+// Market view (best/median/variations), per-bank comparator with rate kind
6+// (posted vs special) and freshness, history, prime rates, source health.
7+// No invented rates: everything comes from the institutions' official
8+// pages, with provenance.
9+// -----------------------------------------------------------------------------
10+import { useEffect, useState } from "react";
11+import {
12+ MortgageBest, MortgageIntelligence, MortgageProviderHealth,
13+ fetchMortgageBest, fetchMortgageIntelligence, fetchMortgageProviders,
14+ fmtRate,
15+} from "../api";
16+import RateHistory from "../components/RateHistory";
17+
18+const TERMS: [number, string][] = [
19+ [12, "1 year"], [24, "2 years"], [36, "3 years"], [48, "4 years"],
20+ [60, "5 years"], [84, "7 years"], [120, "10 years"],
21+];
22+const KIND_EN: Record<string, string> = { posted: "posted", special: "special offer" };
23+const INSURED_EN: Record<string, string> = {
24+ insured: "insured", insurable: "insurable", uninsured: "uninsured", unknown: "",
25+};
26+
27+const termLabel = (m: number) => TERMS.find(([t]) => t === m)?.[1] ?? `${m} months`;
28+const freshness = (min: number) =>
29+ min < 60 ? `${min} min ago` : min < 48 * 60
30+ ? `${Math.round(min / 60)} h ago` : `${Math.round(min / 1440)} d ago`;
31+const varTxt = (v: number | null) =>
32+ v == null ? "—" : v === 0 ? "stable"
33+ : `${v > 0 ? "▲ +" : "▼ "}${v.toFixed(2)} pt`;
34+
35+export default function RatesPage() {
36+ const [intel, setIntel] = useState<MortgageIntelligence | null>(null);
37+ const [rateType, setRateType] = useState<"fixed" | "variable">("fixed");
38+ const [term, setTerm] = useState(60);
39+ const [best, setBest] = useState<MortgageBest | null>(null);
40+ const [health, setHealth] = useState<MortgageProviderHealth[]>([]);
41+
42+ useEffect(() => {
43+ document.title = "Mortgage rates in Canada — live comparator | House-Ka";
44+ fetchMortgageIntelligence().then(setIntel).catch(() => setIntel(null));
45+ fetchMortgageProviders().then((r) => setHealth(r.providers)).catch(() => {});
46+ }, []);
47+
48+ useEffect(() => {
49+ setBest(null);
50+ fetchMortgageBest(rateType, term).then(setBest).catch(() => setBest(null));
51+ }, [rateType, term]);
52+
53+ return (
54+ <div className="container taux-page">
55+ <header className="taux-head">
56+ <h1>Live mortgage rates</h1>
57+ <p>
58+ The rates actually published by the big Canadian institutions
59+ (banks, Desjardins, virtual lenders and monolines), collected
60+ continuously by House-Ka. Every rate shows its <b>kind</b> (posted or
61+ special offer), its <b>official source</b> and its <b>freshness</b> —
62+ never an invented rate, never a stale one without a warning.
63+ </p>
64+ </header>
65+
66+ {intel && intel.products.length > 0 && (
67+ <section className="f-bloc">
68+ <h2>Market overview</h2>
69+ <div className="taux-grid">
70+ {intel.products.map((p) => (
71+ <button
72+ key={`${p.rate_type}-${p.term_months}`}
73+ className={`taux-card ${p.rate_type === rateType && p.term_months === term ? "on" : ""}`}
74+ onClick={() => { setRateType(p.rate_type as "fixed" | "variable"); setTerm(p.term_months); }}
75+ >
76+ <span className="taux-card-k">
77+ {p.rate_type === "fixed" ? "Fixed" : "Variable"} {termLabel(p.term_months)}
78+ </span>
79+ <span className="taux-card-v">{fmtRate(p.best)}</span>
80+ <span className="taux-card-sub">{p.best_institution}</span>
81+ <span className="taux-card-sub">
82+ median {fmtRate(p.median)} · 30 d: {varTxt(p.var_30d)}
83+ </span>
84+ </button>
85+ ))}
86+ </div>
87+ {intel.prime_rates.length > 0 && (
88+ <p className="fine">
89+ Prime rates:{" "}
90+ {intel.prime_rates.map((p, i) => (
91+ <span key={i}>
92+ {i > 0 && " · "}
93+ {p.institution} <b>{fmtRate(p.rate)}</b>
94+ </span>
95+ ))}
96+ </p>
97+ )}
98+ </section>
99+ )}
100+
101+ <section className="f-bloc">
102+ <h2>Compare the institutions</h2>
103+ <div className="taux-filtres">
104+ <label>
105+ <span>Type</span>
106+ <select value={rateType}
107+ onChange={(e) => setRateType(e.target.value as "fixed" | "variable")}>
108+ <option value="fixed">Fixed</option>
109+ <option value="variable">Variable</option>
110+ </select>
111+ </label>
112+ <label>
113+ <span>Term</span>
114+ <select value={term} onChange={(e) => setTerm(Number(e.target.value))}>
115+ {TERMS.map(([m, l]) => <option key={m} value={m}>{l}</option>)}
116+ </select>
117+ </label>
118+ </div>
119+
120+ {best == null ? (
121+ <p className="fine">No current rate for this product — try another term.</p>
122+ ) : (
123+ <>
124+ <div className="rooms-wrap">
125+ <table className="rooms mtg-comp">
126+ <thead>
127+ <tr><th>Institution</th><th>Product</th><th>Rate</th><th>Kind</th><th>Freshness</th><th>Source</th></tr>
128+ </thead>
129+ <tbody>
130+ {best.per_institution.map((r, i) => (
131+ <tr key={r.provider} className={i === 0 ? "taux-best" : ""}>
132+ <td>{r.institution}{i === 0 && <span className="taux-badge">best</span>}</td>
133+ <td>{r.product_name}</td>
134+ <td><b>{fmtRate(r.rate)}</b>{r.apr != null ? ` (APR ${fmtRate(r.apr)})` : ""}</td>
135+ <td>
136+ {KIND_EN[r.kind] ?? r.kind}
137+ {INSURED_EN[r.insured_status] ? ` · ${INSURED_EN[r.insured_status]}` : ""}
138+ </td>
139+ <td className={r.stale ? "mtg-stale" : ""}>{freshness(r.age_minutes)}</td>
140+ <td>
141+ {r.source_url && (
142+ <a href={r.source_url} target="_blank" rel="noopener noreferrer">official ↗</a>
143+ )}
144+ </td>
145+ </tr>
146+ ))}
147+ </tbody>
148+ </table>
149+ </div>
150+ <p className="fine">
151+ One comparable product per institution (the special offer wins
152+ over the posted rate). Incomparable products — insured vs
153+ uninsured, posted vs special — are never mixed in the same
154+ ranking without saying so.
155+ </p>
156+ </>
157+ )}
158+ </section>
159+
160+ <section className="f-bloc">
161+ <h2>Trend — {rateType} {termLabel(term)}</h2>
162+ <RateHistory rateType={rateType} termMonths={term} />
163+ </section>
164+
165+ {health.length > 0 && (
166+ <section className="f-bloc">
167+ <h2>Source freshness</h2>
168+ <div className="taux-sante">
169+ {health.map((h) => (
170+ <span key={h.provider}
171+ className={`taux-src taux-src-${h.level.toLowerCase()}`}
172+ title={`${h.current_products} current product(s) — last collection ${freshness(h.age_minutes)}`}>
173+ {h.institution}
174+ </span>
175+ ))}
176+ </div>
177+ <p className="fine">
178+ Green: recent successful collection · yellow: data kept but aging ·
179+ red: source in error. When a collection fails, the last valid rates
180+ stay displayed with their date.
181+ </p>
182+ </section>
183+ )}
184+
185+ <p className="fine">
186+ Indicative information only, without guarantee — actual conditions
187+ depend on your file. House-Ka is neither a lender nor a mortgage
188+ broker.
189+ </p>
190+ </div>
191+ );
192+}
added frontend/src/pages/Stats.tsx +139 −0
@@ -0,0 +1,139 @@
1+// -----------------------------------------------------------------------------
2+// House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+// Author: Simon-Pierre Boucher — contact@spboucher.ai
4+// pages/Stats.tsx : market & platform statistics — totals, per-source volumes,
5+// data quality (completeness, quarantine), recent syncs.
6+// -----------------------------------------------------------------------------
7+import { useEffect, useState } from "react";
8+import {
9+ Stats, fetchSources, fetchStats, registerSourceNames, sourceName,
10+} from "../api";
11+
12+const n = (v: number | null | undefined) =>
13+ v == null ? "—" : Math.round(v).toLocaleString("en-CA");
14+
15+export default function StatsPage() {
16+ const [stats, setStats] = useState<Stats | null>(null);
17+ const [error, setError] = useState<string | null>(null);
18+
19+ useEffect(() => {
20+ document.title = "Canadian housing market statistics | House-Ka";
21+ fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
22+ fetchStats().then(setStats).catch((e) => setError(String(e)));
23+ }, []);
24+
25+ if (error) return <div className="notice container"><h2>Could not load the stats</h2><p>{error}</p></div>;
26+ if (!stats) return <div className="notice container">Loading…</div>;
27+
28+ const q = stats.qualite;
29+ const syncs = (stats.recent_syncs ?? []).slice(0, 20);
30+
31+ return (
32+ <div className="container stats-page">
33+ <span className="kicker">Live data</span>
34+ <h1>Platform statistics</h1>
35+
36+ <div className="mtg-resultat" style={{ marginTop: 18 }}>
37+ <div className="mtg-kpi">
38+ <span className="mtg-kpi-k">Homes for sale</span>
39+ <span className="mtg-kpi-v">{n(stats.total)}</span>
40+ <span className="mtg-kpi-sub">published, deduplicated</span>
41+ </div>
42+ <div className="mtg-kpi">
43+ <span className="mtg-kpi-k">Cities & towns</span>
44+ <span className="mtg-kpi-v">{n(stats.cities)}</span>
45+ </div>
46+ <div className="mtg-kpi">
47+ <span className="mtg-kpi-k">Average price</span>
48+ <span className="mtg-kpi-v">${n(stats.avg_price)}</span>
49+ <span className="mtg-kpi-sub">from ${n(stats.min_price)} to ${n(stats.max_price)}</span>
50+ </div>
51+ <div className="mtg-kpi">
52+ <span className="mtg-kpi-k">Sources</span>
53+ <span className="mtg-kpi-v">{n(stats.sources)}</span>
54+ <span className="mtg-kpi-sub">CREA DDF brokerage sites</span>
55+ </div>
56+ </div>
57+
58+ {q && (
59+ <section className="f-bloc">
60+ <h2>Data quality</h2>
61+ <p className="sub">
62+ Every listing gets a completeness score (photos, description,
63+ specs, coordinates). Listings without a plausible price or a known
64+ city are quarantined until the next enrichment pass completes them.
65+ </p>
66+ <div className="mtg-resultat">
67+ <div className="mtg-kpi">
68+ <span className="mtg-kpi-k">Active listings</span>
69+ <span className="mtg-kpi-v">{n(q.actives)}</span>
70+ </div>
71+ <div className="mtg-kpi">
72+ <span className="mtg-kpi-k">Published</span>
73+ <span className="mtg-kpi-v">{n(q.publiees)}</span>
74+ </div>
75+ <div className="mtg-kpi">
76+ <span className="mtg-kpi-k">In quarantine</span>
77+ <span className="mtg-kpi-v">{n(q.quarantaine)}</span>
78+ </div>
79+ <div className="mtg-kpi">
80+ <span className="mtg-kpi-k">Avg. completeness</span>
81+ <span className="mtg-kpi-v">{q.completude_moyenne ?? "—"}</span>
82+ <span className="mtg-kpi-sub">score /100</span>
83+ </div>
84+ </div>
85+
86+ {q.par_source.length > 0 && (
87+ <div className="rooms-wrap">
88+ <table className="rooms">
89+ <thead>
90+ <tr><th>Source</th><th>Listings</th><th>Published</th><th>Completeness</th><th>Flags</th></tr>
91+ </thead>
92+ <tbody>
93+ {q.par_source.map((s) => (
94+ <tr key={s.source}>
95+ <td>{sourceName(s.source)}</td>
96+ <td>{n(s.n)}</td>
97+ <td>{n(s.publiees)}</td>
98+ <td>{s.completude ?? "—"}</td>
99+ <td>{n(s.anomalies)}</td>
100+ </tr>
101+ ))}
102+ </tbody>
103+ </table>
104+ </div>
105+ )}
106+ </section>
107+ )}
108+
109+ {syncs.length > 0 && (
110+ <section className="f-bloc">
111+ <h2>Recent syncs</h2>
112+ <div className="rooms-wrap">
113+ <table className="rooms">
114+ <thead>
115+ <tr><th>Source</th><th>When</th><th>Found</th><th>Added</th><th>Updated</th><th>Removed</th></tr>
116+ </thead>
117+ <tbody>
118+ {syncs.map((s, i) => (
119+ <tr key={i} className={s.ok ? "" : "mtg-stale"}>
120+ <td>{sourceName(s.source)}</td>
121+ <td>{new Date((s.ts > 1e12 ? s.ts : s.ts * 1000)).toLocaleString("en-CA")}</td>
122+ <td>{n(s.found)}</td>
123+ <td>{n(s.added)}</td>
124+ <td>{n(s.updated)}</td>
125+ <td>{n(s.removed)}</td>
126+ </tr>
127+ ))}
128+ </tbody>
129+ </table>
130+ </div>
131+ <p className="fine">
132+ The connectors re-sync on their own, around the clock. A failed
133+ sync never wipes data — the last valid state is kept.
134+ </p>
135+ </section>
136+ )}
137+ </div>
138+ );
139+}
added frontend/src/styles.css +1827 −0
@@ -0,0 +1,1827 @@
1+/* -----------------------------------------------------------------------------
2+ House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+ Author: Simon-Pierre Boucher — contact@spboucher.ai
4+ styles.css : House-Ka business classes on top of the shared Groupe KA design
5+ system (ka/tokens.css, imported BEFORE this file in main.tsx).
6+ House-Ka overrides MORE than the accent: pine/cream palette + serif
7+ display type (Fraunces) — a deliberately different skin from Immo-Ka.
8+----------------------------------------------------------------------------- */
9+:root {
10+ /* ---- House-Ka PINE accent + warm cream paper + serif display ---- */
11+ --accent: #0f6b4f;
12+ --accent-soft: #dcefe6;
13+ --accent-deep: #0a4a37;
14+ --on-accent: #ffffff;
15+ --paper: #faf7f0;
16+ --ink: #14201a;
17+ --font-display: "Fraunces", "Iowan Old Style", Georgia, serif;
18+ /* legacy aliases — all the business CSS below uses --lime */
19+ --lime: var(--accent);
20+ --lime-soft: var(--accent-soft);
21+}
22+/* Serif display: Fraunces is wider than Space Grotesk — soften the tracking */
23+h1, h2, h3, h4, .brand, .price, .card-price { letter-spacing: -0.015em; }
24+
25+/* Ancre sous le header collant (spécifique Immo-Ka) */
26+html { scroll-padding-top: 76px; }
27+h1, h2, h3, h4 { margin: 0; }
28+a { text-decoration: none; }
29+button { font-family: inherit; }
30+
31+.mono { font-family: var(--font-mono); }
32+.kicker {
33+ font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
34+ text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
35+ display: inline-flex; align-items: center; gap: 8px;
36+}
37+.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
38+
39+.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
40+@media (max-width: 640px) { .container { padding: 0 16px; } }
41+
42+/* ================= Header ================= */
43+.header {
44+ position: sticky; top: 0; z-index: var(--z-header, 500);
45+ background: var(--paper);
46+ border-bottom: 2px solid var(--ink);
47+}
48+/* Menu mobile ouvert : le panneau vit DANS le header — on monte tout le header
49+ au niveau modale pour qu il passe au-dessus du voile (--z-overlay). */
50+.header:has(.mobile-menu.open) { z-index: var(--z-modal, 900); }
51+.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
52+.brand { font-family: var(--font-display); font-weight: 700; font-size: 26px; letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; }
53+.brand .ka { background: var(--ink); color: var(--lime); padding: 2px 7px 4px; border-radius: 6px; margin-left: 3px; transform: rotate(-2deg); transition: transform 0.2s ease; }
54+.brand:hover .ka { transform: rotate(0deg); }
55+.brand-tag { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; margin-left: 12px; }
56+@media (max-width: 860px) { .brand-tag { display: none; } }
57+.nav { margin-left: auto; display: flex; gap: 4px; }
58+.nav a { padding: 9px 16px; border-radius: 999px; font-weight: 600; font-size: 14px; color: var(--ink-2); border: 1.5px solid transparent; transition: all 0.15s ease; min-height: 40px; display: inline-flex; align-items: center; }
59+.nav a:hover { border-color: var(--ink); color: var(--ink); }
60+.nav a.active { background: var(--ink); color: var(--lime); }
61+
62+/* ================= Ticker ================= */
63+.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(15, 107, 79, 0.25); }
64+.ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; }
65+.ticker span { padding: 0 26px; position: relative; }
66+.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
67+@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
68+@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } * { transition-duration: 0.01ms !important; } }
69+
70+/* ================= Hero ================= */
71+.hero { position: relative; padding: 64px 0 26px; }
72+@media (max-width: 640px) { .hero { padding: 36px 0 14px; } }
73+/* halo cerise + trame de points en fond — décoratif, sous le texte */
74+.hero::before {
75+ content: ""; position: absolute; z-index: -1; top: -60px; right: -120px;
76+ width: 580px; height: 460px; pointer-events: none;
77+ background: radial-gradient(closest-side, rgba(15, 107, 79, 0.12), transparent 72%);
78+}
79+.hero::after {
80+ content: ""; position: absolute; z-index: -1; inset: -20px -40px 0 0; pointer-events: none;
81+ background-image: radial-gradient(rgba(20, 24, 20, 0.18) 1.2px, transparent 1.7px);
82+ background-size: 22px 22px;
83+ -webkit-mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.9));
84+ mask-image: linear-gradient(112deg, transparent 58%, rgba(0, 0, 0, 0.9));
85+}
86+.hero h1 { font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 14px; }
87+.hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); }
88+.hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
89+.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; }
90+
91+@media (max-width: 640px) {
92+ .hero::before { width: 320px; height: 260px; top: -30px; right: -90px; }
93+}
94+
95+.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
96+.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.12); transition: transform 0.13s ease, box-shadow 0.13s ease; }
97+@media (hover: hover) { .stat-chip:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.2); } }
98+.stat-chip b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
99+.stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; }
100+@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(15, 107, 79, 0.4); } }
101+
102+/* ================= Filter bar ================= */
103+.filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: 14px; box-shadow: var(--shadow-off-soft); padding: 14px; margin: 34px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
104+.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
105+.f-search { flex: 1 1 240px; min-width: 0; display: flex; align-items: center; gap: 9px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface-2); padding: 0 14px; min-height: 52px; color: var(--ink-2); transition: box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease; }
106+.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); color: var(--accent-deep); }
107+.f-search input { border: none; background: none; outline: none; flex: 1; min-width: 0; font-size: 15px; color: var(--ink); font-family: inherit; }
108+.f-search input::placeholder { color: var(--ink-3); }
109+.f-clear { border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; width: 22px; height: 22px; font-size: 10px; cursor: pointer; flex: none; display: grid; place-items: center; transition: background 0.12s ease, color 0.12s ease; }
110+.f-clear:hover { background: var(--ink); color: #fff; }
111+.f-ctl { flex: 0 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 2px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); padding: 7px 12px 6px; min-height: 52px; cursor: pointer; transition: box-shadow 0.15s ease, background 0.15s ease; }
112+@media (hover: hover) { .f-ctl:hover { background: var(--surface-2); } }
113+.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
114+.f-ctl > span { font-family: var(--font-mono); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
115+.f-ctl select { border: none; background: transparent; outline: none; font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 16px; min-width: 0; max-width: 180px; text-overflow: ellipsis; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right center; }
116+.range-pair { display: flex; align-items: center; gap: 4px; }
117+.range-pair select { max-width: 92px; }
118+.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
119+.f-more { flex: none; align-self: stretch; display: inline-flex; align-items: center; gap: 8px; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease; }
120+.f-more:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); background: var(--surface); }
121+.f-more:active { transform: none; box-shadow: none; }
122+.f-more.on { background: var(--ink); border-color: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 var(--lime); }
123+.f-more.on:hover { transform: none; }
124+.f-more-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: var(--on-accent); font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; }
125+.f-chev { width: 8px; height: 8px; flex: none; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg); margin-top: -4px; transition: transform 0.18s ease, margin 0.18s ease; }
126+.f-chev.up { transform: rotate(-135deg); margin-top: 4px; }
127+.f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 18px 26px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 16px; min-width: 0; animation: adv-in 0.22s cubic-bezier(0.2, 0.9, 0.3, 1); }
128+@keyframes adv-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
129+.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
130+.f-group > label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
131+.f-group-end { justify-content: flex-end; }
132+.f-group .btn:disabled { opacity: 0.4; cursor: default; }
133+.f-native { border: 1.5px solid var(--line); background: var(--surface-2); border-radius: var(--r-ctl); padding: 10px 30px 10px 12px; font-size: 14px; color: var(--ink); outline: none; font-family: inherit; min-height: 42px; width: 100%; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
134+.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
135+.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
136+.seg button { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink-2); padding: 8px 13px; font-family: var(--font-display); font-weight: 600; font-size: 13px; cursor: pointer; margin-left: -1.5px; white-space: nowrap; min-height: 40px; transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; }
137+.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
138+.seg button:last-child { border-radius: 0 8px 8px 0; }
139+.seg button:hover { background: var(--lime-soft); color: var(--ink); }
140+.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; box-shadow: inset 0 -2.5px 0 var(--lime); }
141+
142+/* pastilles de filtres actifs — survol = intention de retrait (danger) */
143+.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 2px; }
144+.pills .pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); text-transform: none; letter-spacing: normal; cursor: pointer; transition: all 0.13s ease; box-shadow: 2px 2px 0 var(--lime-soft); animation: pill-in 0.18s ease; }
145+@keyframes pill-in { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: none; } }
146+.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); box-shadow: none; }
147+.pills .pill:hover .pill-x { opacity: 1; }
148+.pill-x { font-size: 10px; opacity: 0.6; }
149+.pills .pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); box-shadow: none; }
150+.pills .pill-clear:hover { background: var(--danger); color: #fff; }
151+.link-btn { background: none; border: none; padding: 0; color: var(--green); font: inherit; text-decoration: underline; cursor: pointer; }
152+
153+.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; background: var(--surface); color: var(--ink); transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease, color 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; text-decoration: none; }
154+.btn:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
155+.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
156+.btn:disabled { opacity: 0.45; pointer-events: none; }
157+.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); }
158+.btn-primary:hover { background: var(--green-deep); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); }
159+.btn-ghost { background: transparent; color: var(--ink); }
160+.btn-ghost:hover { background: var(--lime); color: #fff; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.2); }
161+
162+/* ================= Chips ================= */
163+.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
164+.chips::-webkit-scrollbar { display: none; }
165+.chip { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 9px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 42px; transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.13s ease, color 0.13s ease; }
166+.chip:hover { transform: translate(-1px, -1px); box-shadow: var(--shadow-off-mid); }
167+.chip:active { transform: none; box-shadow: none; }
168+.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 var(--lime); }
169+
170+/* ================= Results ================= */
171+.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; }
172+.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
173+.results-head > span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
174+.results-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; }
175+.sort-ctl select { border: 1.5px solid var(--ink); background: var(--surface); border-radius: var(--r-ctl); padding: 8px 28px 8px 12px; min-height: 36px; font-family: var(--font-mono); font-size: 12px; color: var(--ink); cursor: pointer; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23141814'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; transition: box-shadow 0.13s ease; }
176+.sort-ctl select:hover { box-shadow: 2px 2px 0 rgba(26, 18, 20, 0.18); }
177+.sort-ctl select:focus { outline: none; box-shadow: 3px 3px 0 var(--lime); }
178+.view-toggle { display: inline-flex; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); box-shadow: var(--shadow-flat); }
179+.view-toggle button { display: inline-flex; align-items: center; gap: 6px; border: 0; background: transparent; padding: 8px 14px; min-height: 36px; cursor: pointer; font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--ink-2); transition: background 0.12s ease, color 0.12s ease; }
180+.view-toggle button:hover { background: var(--surface-2); color: var(--ink); }
181+.view-toggle button + button { border-left: 1.5px solid var(--ink); }
182+.view-toggle button.on { background: var(--ink); color: var(--lime); box-shadow: inset 0 -2.5px 0 var(--lime); }
183+
184+.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 22px; padding-bottom: 20px; }
185+@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; } }
186+.more-wrap { display: flex; justify-content: center; padding: 8px 0 70px; }
187+
188+.card { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow-flat); transition: transform 0.16s ease, box-shadow 0.16s ease; }
189+.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
190+.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
191+.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
192+.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
193+.card:hover .card-img img { transform: scale(1.05); }
194+.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
195+.badge { position: absolute; top: 12px; left: 12px; border-radius: 6px; padding: 4px 10px; font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; letter-spacing: 0.04em; background: rgba(255, 255, 255, 0.95); color: var(--ink); border: 1px solid var(--ink); }
196+.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
197+.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
198+.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
199+.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
200+.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
201+.card-meta { color: var(--ink-3); font-size: 12.5px; display: flex; gap: 7px; flex-wrap: wrap; align-items: center; font-family: var(--font-mono); }
202+.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
203+.card-specs { display: flex; gap: 12px; font-size: 12.5px; color: var(--ink-2); font-family: var(--font-mono); }
204+.card-foot { margin-top: auto; padding-top: 11px; border-top: 1.5px dashed var(--line); display: flex; justify-content: space-between; align-items: center; gap: 8px; }
205+.source-tag { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--green-deep); background: var(--lime-soft); border: 1px solid var(--green); border-radius: 4px; padding: 3px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 62%; }
206+.avail { font-size: 11px; color: var(--ink-3); font-weight: 500; text-align: right; font-family: var(--font-mono); }
207+
208+/* ================= Skeletons ================= */
209+@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
210+.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
211+.skel .sk-img, .skel .sk-line { background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%); background-size: 800px 100%; animation: shimmer 1.4s infinite linear; }
212+.skel .sk-img { aspect-ratio: 16/10.5; }
213+.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
214+.skel .sk-line.short { width: 45%; }
215+
216+/* ================= Empty / error ================= */
217+.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
218+.notice .big { font-size: 44px; margin-bottom: 10px; }
219+.notice h2 { text-transform: uppercase; }
220+
221+/* ================= Detail page ================= */
222+.detail { padding: 30px 0 90px; }
223+.crumbs { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
224+.crumbs a { border-bottom: 1.5px solid transparent; }
225+.crumbs a:hover { color: var(--green); border-color: var(--green); }
226+
227+/* Ordre DOM = ordre visuel sur TOUS les breakpoints (standard Groupe Ka
228+ « Ordre des sections — pages détail »). Interdit : `order` / `column-reverse`
229+ / `display:contents` pour réordonner — un bloc sans `order` retombe à
230+ order:0 et passe devant la galerie sur mobile (bug corrigé sur Lou-Ka).
231+ Mobile : colonnes empilées ; desktop : grid 2 colonnes (fiche | annexes). */
232+.fiche { display: flex; flex-direction: column; gap: 22px; }
233+.f-col { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
234+@media (min-width: 900px) {
235+ .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
236+ .f-col { gap: 26px; }
237+ .f-col:last-child { position: sticky; top: 88px; }
238+}
239+.f-bloc { min-width: 0; }
240+.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 12px; border-left: 4px solid var(--lime); padding-left: 10px; }
241+.f-bloc:empty { display: none; }
242+
243+/* galerie */
244+.carousel { position: relative; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); background: var(--surface-2); box-shadow: var(--shadow-off-soft); }
245+.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; }
246+.carousel-track::-webkit-scrollbar { display: none; }
247+.carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; }
248+.carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; }
249+.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(26, 18, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
250+.carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; }
251+.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
252+@media (max-width: 640px) { .carousel-nav { display: none; } }
253+.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; }
254+.thumbs button { border: 2px solid var(--line); border-radius: 8px; overflow: hidden; padding: 0; cursor: pointer; aspect-ratio: 4/3; background: #eceae3; transition: border-color 0.12s ease, transform 0.12s ease; }
255+.thumbs button:hover { transform: translateY(-2px); }
256+.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
257+.thumbs img { width: 100%; height: 100%; object-fit: cover; }
258+@media (max-width: 640px) { .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; } .thumbs::-webkit-scrollbar { display: none; } .thumbs button { flex: 0 0 84px; } }
259+
260+/* lightbox */
261+.lightbox { position: fixed; inset: 0; background: rgba(16, 18, 16, 0.94); z-index: 100; display: flex; align-items: center; justify-content: center; cursor: zoom-out; padding: max(16px, env(safe-area-inset-top)) 16px; }
262+.lightbox img { max-width: 94vw; max-height: 86vh; border-radius: 6px; border: 2px solid var(--lime); object-fit: contain; }
263+.lb-close { position: absolute; top: 18px; right: 22px; background: none; border: none; color: #fff; font-size: 30px; cursor: pointer; }
264+.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--lime); font-size: 52px; cursor: pointer; padding: 0 22px; user-select: none; }
265+.lb-nav.prev { left: 6px; } .lb-nav.next { right: 6px; }
266+.lb-count { position: absolute; bottom: 24px; color: #c9ccc2; font-family: var(--font-mono); font-size: 13px; }
267+
268+/* hero panneau (colonne droite) */
269+.f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; }
270+.price-kicker { font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--green); }
271+.price-kicker::before { content: ""; display: inline-block; width: 16px; height: 2px; background: var(--lime); vertical-align: 3px; margin-right: 7px; }
272+.price-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
273+.f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; }
274+.type-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 11px; background: var(--lime-soft); color: var(--green-deep); border: 1px solid rgba(179, 32, 43, 0.25); border-radius: 999px; font-size: 12px; font-weight: 600; }
275+.price-sub { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
276+.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 10px; }
277+.f-hero .loc { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; display: flex; align-items: center; gap: 5px; }
278+.f-hero .loc .ico { color: var(--lime); }
279+
280+/* liste de specs — fiche technique premium */
281+.spec-list { margin: 18px 0 4px; border-top: 1.5px solid var(--line-strong); }
282+.spec-row { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 11px; padding: 8.5px 0; border-bottom: 1px dashed var(--line); }
283+.spec-row:last-child { border-bottom: 1.5px solid var(--line-strong); }
284+.spec-badge { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 9px; background: var(--lime-soft); color: var(--green-deep); }
285+.spec-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-2); }
286+.spec-v { font-family: var(--font-display); font-size: 15.5px; font-weight: 700; text-align: right; }
287+.deal-badge { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 4px; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; border: 1.5px solid var(--line-strong); }
288+.deal-ok { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
289+
290+.specs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin: 18px 0; }
291+.spec { border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 9px 11px; background: var(--surface-2); }
292+.spec b { display: block; font-family: var(--font-display); font-size: 16px; }
293+.spec span { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
294+
295+.broker { margin: 16px 0; padding: 14px; border: 1.5px dashed var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); }
296+.broker-k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); }
297+.broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; }
298+.broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
299+
300+.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); transition: all 0.14s ease; margin-top: 6px; }
301+.cta:hover { background: var(--lime); color: #fff; }
302+.cta:active { transform: translate(2px, 2px); box-shadow: none; }
303+.f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; }
304+
305+.desc-text { color: var(--ink-2); font-size: 14.5px; white-space: pre-line; line-height: 1.6; margin: 0; }
306+
307+/* tableau caractéristiques */
308+.dtable { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 22px; }
309+.drow { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
310+.drow span { color: var(--ink-3); } .drow b { text-align: right; font-family: var(--font-display); }
311+
312+/* tableau pièces */
313+.rooms-wrap { overflow-x: auto; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); }
314+table.rooms { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 460px; }
315+table.rooms th { text-align: left; background: var(--ink); color: var(--paper); padding: 9px 12px; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
316+table.rooms td { padding: 8px 12px; border-bottom: 1px solid var(--line); }
317+table.rooms tr:last-child td { border-bottom: none; }
318+table.rooms tr:nth-child(even) td { background: var(--surface-2); }
319+
320+.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; }
321+.amenity { background: var(--lime-soft); color: var(--green-deep); font-size: 12px; font-weight: 600; border: 1px solid var(--green); border-radius: 999px; padding: 5px 12px; }
322+
323+.prix-histo { margin-top: 14px; padding: 10px 14px; border-radius: var(--r-card); border: 1.5px solid var(--line-strong); font-size: 13.5px; background: var(--surface); }
324+.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
325+
326+.mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; }
327+.mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; }
328+
329+.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(247, 241, 239, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
330+.cta-sticky .cta { flex: 1; margin: 0; }
331+.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
332+@media (min-width: 900px) { .cta-sticky { display: none; } }
333+@media (max-width: 899px) { .detail { padding-bottom: 84px; } }
334+.f-foot { margin-top: 26px; }
335+.fine { font-size: 12px; color: var(--ink-3); }
336+
337+/* Box « Aussi publiée sur… » : autres publications de la même propriété */
338+.dups-note { font-size: 13px; color: var(--ink-3); margin: 4px 0 10px; }
339+.dups-list { display: flex; flex-direction: column; gap: 8px; }
340+.dup-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 11px 13px; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); transition: all 0.14s ease; min-height: 44px; }
341+.dup-item:hover { border-color: var(--ink); box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.18); }
342+.dup-src { font-family: var(--font-display); font-weight: 700; font-size: 14px; }
343+.dup-broker { font-size: 12.5px; color: var(--ink-3); }
344+.dup-go { margin-left: auto; font-size: 12.5px; font-weight: 600; color: var(--green-deep); border-bottom: 1.5px solid var(--lime); white-space: nowrap; }
345+.dup-go .ico { vertical-align: -2px; }
346+
347+/* ================= Sources / Agences ================= */
348+.sources { padding: 44px 0 90px; }
349+.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
350+.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 720px; }
351+.src-wrap { overflow-x: auto; border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); background: var(--surface); }
352+.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 760px; }
353+.src-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); padding: 14px 18px; border-bottom: 1.5px solid var(--ink); background: var(--surface-2); }
354+.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
355+.src-table tr:last-child td { border-bottom: none; }
356+.src-table tr:hover td { background: var(--surface-2); }
357+.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
358+.pill.ok { background: var(--lime); color: #fff; border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
359+.pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; }
360+.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); }
361+
362+/* ================= Stats ================= */
363+.stats-page { padding: 44px 0 90px; }
364+.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
365+.stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; }
366+.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
367+.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); }
368+.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; }
369+.tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; }
370+.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
371+.hero-tile .tile-k { color: rgba(15, 107, 79, 0.7); }
372+.viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; min-width: 0; max-width: 100%; overflow-x: auto; }
373+/* mobile : une table large ne doit JAMAIS déborder la page (grid blowout) —
374+ chaque bloc de la page Stats défile horizontalement en interne au besoin */
375+.stats-page section, .stats-page > * { min-width: 0; max-width: 100%; }
376+@media (max-width: 640px) { .stats-page .viz-card { padding: 18px 14px 14px; } }
377+.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
378+.viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; }
379+.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
380+@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
381+.hbars { display: flex; flex-direction: column; gap: 7px; }
382+.hbar-row { display: grid; grid-template-columns: minmax(96px, 180px) 1fr auto; gap: 12px; align-items: center; min-height: 26px; }
383+.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
384+.hbar-label a { border-bottom: 1.5px solid var(--lime); }
385+.hbar-label a:hover { color: var(--green-deep); }
386+.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
387+.hbar-fill { display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; min-width: 2px; transition: background 0.12s ease; }
388+.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
389+.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
390+.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
391+.viz-table { margin-top: 4px; }
392+.viz-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
393+.viz-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
394+.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
395+.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
396+
397+/* ---- Iconographie maison (Icons.tsx) ---- */
398+.ico { vertical-align: -3px; flex-shrink: 0; }
399+.card-specs span { display: inline-flex; align-items: center; gap: 5px; }
400+.card-specs .ico { color: var(--green); }
401+.badge.right .ico { vertical-align: -2px; }
402+.noimg .ico, .carousel-empty .ico { color: var(--ink-3); }
403+.notice .big .ico { color: var(--lime); }
404+.amenity .ico { color: var(--green); margin-right: 2px; }
405+.q-badge .ico { vertical-align: -3px; margin-right: 2px; }
406+.q-bar-label { display: inline-flex; align-items: center; gap: 7px; }
407+.q-bar-label .ico { color: var(--green); }
408+.mm-ico .ico { vertical-align: -4px; }
409+.mv-noimg svg { color: var(--ink-3); }
410+.broker-tel .ico { vertical-align: -2.5px; }
411+.cta .ico { vertical-align: -2.5px; margin-left: 3px; }
412+.prix-histo .ico { vertical-align: -3.5px; }
413+.spec { position: relative; }
414+.spec-ico { position: absolute; top: 10px; right: 10px; color: var(--lime); opacity: 0.85; }
415+
416+/* ---- Survalorisation vs Vrai-Prix (jauges divergentes) ---- */
417+.vpg-axis { display: grid; grid-template-columns: 220px 1fr 74px 90px; gap: 10px; margin-bottom: 2px; }
418+.vpg-axis > div { grid-column: 2; display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
419+.vpg-row { display: grid; grid-template-columns: 220px 1fr 74px 90px; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px dashed var(--line); }
420+.vpg-row:last-of-type { border-bottom: none; }
421+.vpg-name { font-size: 13.5px; font-weight: 600; }
422+.vpg-name em { display: block; font-style: normal; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); }
423+.vpg-track { position: relative; height: 18px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
424+.vpg-zero { position: absolute; left: 50%; top: 0; bottom: 0; width: 1.5px; background: var(--line-strong); opacity: 0.5; }
425+.vpg-band { position: absolute; top: 3px; bottom: 3px; background: rgba(26, 18, 20, 0.14); border-radius: 999px; }
426+.vpg-median { position: absolute; top: 1px; bottom: 1px; width: 4px; margin-left: -2px; border-radius: 2px; }
427+.vpg-median.vp-sur { background: var(--lime); }
428+.vpg-median.vp-juste { background: #d9a942; }
429+.vpg-median.vp-sous { background: #4c8b4f; }
430+.vpg-value { font-family: var(--font-display); font-weight: 700; font-size: 15px; text-align: right; }
431+.vpg-value.vp-sur { color: var(--green); }
432+.vpg-value.vp-juste { color: #a97b1e; }
433+.vpg-value.vp-sous { color: #3c7440; }
434+.vpg-split { display: flex; height: 10px; border-radius: 999px; overflow: hidden; border: 1px solid var(--line); }
435+.vps-sous { background: #7fb283; }
436+.vps-juste { background: #d9cfc7; }
437+.vps-sur { background: var(--lime); }
438+.vpg-sep { border-top: 1.5px solid var(--line-strong); margin: 4px 0; }
439+.vpg-legend { display: flex; gap: 16px; margin-top: 10px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); flex-wrap: wrap; }
440+.vpg-legend i { display: inline-block; width: 14px; height: 9px; border-radius: 999px; margin-right: 5px; vertical-align: -1px; }
441+@media (max-width: 780px) { .vpg-row { grid-template-columns: 1fr 64px; } .vpg-track { grid-column: 1 / -1; } .vpg-split { grid-column: 1 / -1; } .vpg-axis { display: none; } }
442+
443+/* ================= Carte (Ka Maps — framework partagé Groupe Ka) =========
444+ Jetons du design system appliqués au chrome de la carte, mêmes réglages
445+ que Lou-Ka Maps. */
446+.mapview .ka-map, .lmap3d .ka-map {
447+ --ka-accent: var(--accent);
448+ --ka-on-accent: var(--on-accent);
449+ --ka-surface: var(--surface);
450+ --ka-ink: var(--ink);
451+ --ka-line: var(--line-strong);
452+ --ka-radius: var(--r-ctl);
453+ --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14);
454+ --ka-font: var(--font-body);
455+}
456+.mapview { position: relative; overflow: hidden; }
457+.mapview .ka-map { position: absolute; inset: 0; }
458+
459+/* mini-carte 3D de la fiche (Emplacement) — bâtiment de l'annonce en cerise */
460+.lmap3d {
461+ position: relative; height: 340px; border: 1.5px solid var(--ink);
462+ border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
463+ overflow: hidden; background: var(--surface-2);
464+}
465+.lmap3d-skel {
466+ background: linear-gradient(90deg, #eeece5 25%, #f7f5ef 50%, #eeece5 75%);
467+ background-size: 800px 100%; animation: shimmer 1.4s infinite linear;
468+}
469+.lmap3d-legende {
470+ position: absolute; left: 10px; bottom: 10px; z-index: 5;
471+ display: inline-flex; align-items: center; gap: 6px;
472+ padding: 4px 10px; border: 1.5px solid var(--ink);
473+ border-radius: 999px; background: var(--surface);
474+ font-size: 10.5px; font-weight: 500; color: var(--ink-2);
475+ pointer-events: none;
476+}
477+.lmap3d-legende i {
478+ width: 10px; height: 10px; border-radius: 3px;
479+ background: var(--accent); border: 1px solid rgba(26, 18, 20, 0.25);
480+}
481+@media (max-width: 780px) { .lmap3d { height: 280px; } }
482+
483+/* ================= Carte ================= */
484+.map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
485+.map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
486+.map-list .card { margin: 0; flex: 0 0 auto; }
487+/* sync liste ↔ carte : fiche sélectionnée depuis une pastille de la carte */
488+.map-card { border-radius: var(--r-card); }
489+.map-card-sel .card { border-color: var(--accent); box-shadow: 4px 4px 0 var(--accent-soft), 0 0 0 2px var(--accent); }
490+.mapwrap { position: relative; width: 100%; height: 100%; }
491+.mapview { width: 100%; height: 100%; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); overflow: hidden; background: var(--surface-2); }
492+.mv-brand { position: absolute; top: 10px; left: 10px; z-index: 3; display: flex; align-items: center; gap: 6px; padding: 5px 11px; background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: 999px; box-shadow: var(--shadow-off-soft); font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; }
493+.mv-brand b { color: var(--ink); }
494+.mv-brand-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lime); box-shadow: 0 0 0 3px var(--lime-soft); }
495+.mv-legend { position: absolute; bottom: 26px; left: 10px; z-index: 3; display: flex; flex-direction: column; gap: 4px; padding: 8px 11px; background: rgba(255, 255, 255, 0.92); border: 1px solid var(--line); border-radius: var(--r-ctl); font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-2); pointer-events: none; }
496+.mv-legend span { display: flex; align-items: center; gap: 7px; }
497+.mv-lg-pill { display: inline-block; width: 22px; height: 12px; border-radius: 999px; background: #fff; border: 1.5px solid rgba(26, 18, 20, 0.35); }
498+.mv-lg-deal { background: var(--lime); border-color: var(--green); }
499+.mv-credit { position: absolute; bottom: 8px; right: 8px; z-index: 3; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.04em; color: var(--ink-2); pointer-events: none; }
500+.mv-credit b { color: var(--ink); }
501+.mini-map .mv-brand, .mini-map .mv-legend { display: none; }
502+.mini-map .mapwrap { height: 100%; }
503+
504+/* ---- carte propriété (fiche) : marqueur pulsant + anneaux piéton ---- */
505+.pm-wrap { position: relative; width: 100%; height: 100%; }
506+.pm-marker { display: flex; flex-direction: column; align-items: center; }
507+.pm-pill { padding: 5px 13px; background: #fff; border: 1.5px solid var(--line-strong); border-radius: 999px; font-family: var(--font-display); font-weight: 700; font-size: 14px; color: var(--ink); box-shadow: var(--shadow-off-soft); white-space: nowrap; margin-bottom: 4px; }
508+.pm-pill-deal { background: var(--lime); border-color: var(--green); color: #fff; }
509+.pm-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--lime); border: 2.5px solid #fff; box-shadow: 0 1px 4px rgba(26, 18, 20, 0.4); }
510+.pm-pulse { position: absolute; bottom: -8px; width: 30px; height: 30px; border-radius: 50%; background: var(--lime); opacity: 0.35; animation: pm-pulse 2s ease-out infinite; }
511+@keyframes pm-pulse { 0% { transform: scale(0.5); opacity: 0.45; } 80% { transform: scale(1.9); opacity: 0; } 100% { opacity: 0; } }
512+.pm-rings-legend { position: absolute; bottom: 8px; left: 8px; z-index: 3; display: flex; gap: 12px; padding: 4px 10px; background: rgba(255, 255, 255, 0.9); border-radius: 999px; font-family: var(--font-mono); font-size: 10px; color: var(--green); pointer-events: none; }
513+.mini-map .pm-wrap .mv-credit { display: block; }
514+.map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
515+@media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } }
516+.maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); }
517+.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(26, 18, 20, 0.8); }
518+.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
519+.mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); }
520+.mv-pop-body { padding: 10px 12px 12px; }
521+.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
522+.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
523+.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
524+.mv-pop-src { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 2px; }
525+.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: #fff; border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
526+.mv-pop-cta:hover { background: var(--green-deep); }
527+.mv-pop-deal { display: inline-block; margin-left: 6px; padding: 2px 8px; vertical-align: 3px; background: var(--lime); color: #fff; border-radius: 999px; font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.05em; }
528+.maplibregl-ctrl-attrib { font-size: 10px; }
529+
530+/* ================= Menu mobile ================= */
531+.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
532+.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(26, 18, 20,0.15); }
533+.menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; }
534+.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
535+.menu-btn.open span:nth-child(2) { opacity: 0; }
536+.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
537+/* KA Nav v2 (2026-08-25) : panneau PLEIN ECRAN fixed inset:0 - visible peu
538+ importe le scroll (l'ancien dropdown absolute 480px clippait/se coupait). */
539+.mobile-menu {
540+ display: none; position: fixed; inset: 0;
541+ background: var(--paper);
542+ padding: 76px 16px calc(24px + env(safe-area-inset-bottom));
543+ overflow-y: auto; overscroll-behavior: contain;
544+}
545+.mobile-menu.open { display: block; animation: mm-in 0.16s ease; }
546+@keyframes mm-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
547+/* burger masque a l'ouverture : la fermeture passe par le X fixe du panneau */
548+.header:has(.mobile-menu.open) .menu-btn { visibility: hidden; }
549+/* bouton de fermeture du panneau : fixe en haut a droite du viewport */
550+.mm-close {
551+ position: fixed; top: 10px; right: 14px; z-index: 2;
552+ width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
553+ border: 1.5px solid var(--ink); border-radius: 999px;
554+ background: var(--ink); color: var(--paper);
555+ font-size: 17px; font-weight: 700; line-height: 1; cursor: pointer; padding: 0;
556+}
557+/* verrou du scroll d'arriere-plan quand le menu est ouvert (CSS pur) */
558+html:has(.mobile-menu.open) { overflow: hidden; }
559+.mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; }
560+.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
561+.mm-link.active { color: var(--green); }
562+.mm-ico { width: 26px; text-align: center; font-size: 17px; }
563+.mm-arrow { margin-left: auto; opacity: 0.25; }
564+.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
565+.mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
566+.mm-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); }
567+@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } }
568+
569+/* ================= Footer =================
570+ Remplacé par le footer commun Groupe KA (ka/KaFooter.tsx — styles .ka-footer
571+ dans ka/tokens.css). */
572+
573+/* ================= Mobile : feuille de filtres + FAB ================= */
574+.sheet-head { display: none; }
575+.sheet-handle { display: none; }
576+.sheet-apply { display: none; }
577+.sheet-backdrop { display: none; }
578+.fab { display: none; }
579+.fab-badge { display: inline-grid; place-items: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px; background: var(--lime); color: #fff; font-size: 11.5px; font-weight: 700; font-variant-numeric: tabular-nums; margin-left: 2px; }
580+@media (max-width: 640px) {
581+ .header-inner { height: 56px; }
582+ .brand { font-size: 21px; }
583+ .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
584+ .hero p.lede { font-size: 15px; }
585+ .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
586+ .stat-row::-webkit-scrollbar { display: none; }
587+ .stat-chip { flex: 0 0 auto; white-space: nowrap; }
588+ .filterbar { display: none; }
589+ .filterbar.open .f-primary { flex-direction: column; }
590+ .filterbar.open .f-ctl select { max-width: none; width: 100%; }
591+ .filterbar.open .f-more { display: none; }
592+ .filterbar.open { display: flex; flex-direction: column; gap: 12px; position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900); margin: 0; border-radius: 20px 20px 0 0; border-width: 2px 0 0 0; max-height: 82dvh; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 10px 18px calc(18px + env(safe-area-inset-bottom)); box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35); animation: sheet-up 0.24s cubic-bezier(0.2, 0.9, 0.3, 1); }
593+ @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
594+ .filterbar.open .sheet-handle { display: block; flex: none; width: 44px; height: 5px; border-radius: 999px; background: var(--line); margin: 0 auto 2px; }
595+ .filterbar.open .sheet-head { display: flex; justify-content: space-between; align-items: center; font-family: var(--font-display); font-weight: 700; font-size: 17px; text-transform: uppercase; position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px; border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; }
596+ .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; }
597+ .filterbar.open .sheet-apply { display: flex; width: 100%; min-height: 50px; position: sticky; bottom: 0; z-index: 1; box-shadow: 0 -14px 18px -12px rgba(16, 18, 16, 0.4), 5px 5px 0 var(--lime); }
598+ .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); }
599+ .fab { display: flex; align-items: center; gap: 8px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(15, 107, 79, 0.55); }
600+ .fab:active { transform: translateX(-50%) scale(0.97); }
601+ .results-head { margin-top: 20px; }
602+ .chips { margin-right: -16px; padding-right: 16px; }
603+ .notice { padding: 48px 16px; }
604+}
605+@media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } }
606+
607+/* --- Sources : bannières → sous-agences -------------------------------- */
608+.franchise-list { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; }
609+.franchise { border: 2px solid var(--ink); background: var(--surface); box-shadow: var(--shadow-off-soft); }
610+.franchise-head {
611+ width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px;
612+ background: none; border: 0; cursor: pointer; font-family: var(--font-display);
613+ font-size: 16px; font-weight: 600; color: var(--ink); text-align: left;
614+}
615+.franchise-head:hover { background: var(--surface-2); }
616+.fh-caret { color: var(--green); width: 14px; }
617+.fh-name { flex: 1; }
618+.fh-sub { font-size: 12px; font-weight: 500; color: var(--ink-3); font-family: var(--font-mono); }
619+.fh-count {
620+ font-family: var(--font-mono); font-weight: 700; background: var(--ink);
621+ color: var(--lime); padding: 3px 10px; min-width: 64px; text-align: right;
622+}
623+.subagency-list { list-style: none; margin: 0; padding: 0 16px 10px 40px; border-top: 1px solid var(--line); }
624+.subagency-list li {
625+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
626+ padding: 8px 0; border-bottom: 1px dotted var(--line); font-size: 14px;
627+}
628+.subagency-list li:last-child { border-bottom: 0; }
629+.sa-name { color: var(--ink-2); }
630+
631+/* --- Pagination (accueil) ---------------------------------------------- */
632+.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
633+ gap: 6px; margin: 32px 0 8px; }
634+.pager-btn {
635+ font-family: var(--font-mono); font-size: 14px; min-width: 42px; min-height: 42px; padding: 8px 12px;
636+ border: 2px solid var(--ink); border-radius: 8px; background: var(--surface); color: var(--ink);
637+ cursor: pointer; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.14);
638+ transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
639+}
640+.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.2); }
641+.pager-btn:active:not(:disabled) { transform: none; box-shadow: none; }
642+.pager-btn.on { background: var(--ink); color: var(--lime); font-weight: 700; box-shadow: 3px 3px 0 var(--lime); }
643+.pager-btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; }
644+.pager-gap { padding: 0 4px; color: var(--ink-3); }
645+.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px;
646+ color: var(--ink-3); font-family: var(--font-mono); }
647+
648+
649+
650+/* --- Le quartier (census / proximité / chaleur / crime) — porté de Lou-Ka --- */
651+/* --- Section « Le quartier » (fiche) --------------------------------------- */
652+.quartier { margin-top: 34px; }
653+.quartier h2 { font-size: 24px; letter-spacing: -0.02em; }
654+.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; }
655+.q-grid {
656+ display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px;
657+}
658+.q-grid { grid-template-columns: repeat(2, 1fr); }
659+.q-cell {
660+ background: var(--surface); border: 1.5px solid var(--line-strong);
661+ border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat);
662+}
663+.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; }
664+.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3);
665+ text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; }
666+.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; }
667+.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; }
668+.q-bar-label { flex: 0 0 150px; color: var(--ink-2); }
669+.q-bar-label { flex-basis: 120px; font-size: 12px; }
670+.q-bar-track {
671+ flex: 1; height: 10px; background: var(--surface-2);
672+ border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden;
673+}
674+.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; }
675+.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono);
676+ font-size: 11.5px; font-weight: 700; }
677+.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
678+.q-badge {
679+ display: inline-flex; align-items: center; gap: 6px;
680+ border: 1.5px solid var(--line-strong); border-radius: 999px;
681+ padding: 7px 13px; font-size: 12.5px; background: var(--surface);
682+}
683+.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
684+.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; }
685+.quartier .fine { margin-top: 10px; }
686+.q-cell { padding: 9px 11px; }
687+.q-val { font-size: 16px; }
688+.q-bar { gap: 8px; font-size: 12px; }
689+.q-bar-track { height: 8px; }
690+.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; }
691+
692+/* --- Vrai-Prix : estimation de valeur ---------------------------------- */
693+.vraiprix { border: 2px solid var(--ink); background: var(--surface-2);
694+ box-shadow: var(--shadow-off-soft); padding: 14px; margin: 14px 0; }
695+.vp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
696+.vp-logo { font-family: var(--font-display); font-weight: 700; color: var(--green-deep);
697+ letter-spacing: -.3px; }
698+.vp-conf { font-family: var(--font-mono); font-size: 11px; padding: 2px 7px; border: 1px solid var(--ink); }
699+.vp-conf-A { background: var(--lime); color: #fff; } .vp-conf-B { background: var(--lime-soft); }
700+.vp-conf-C { background: var(--amber-soft); } .vp-conf-D { background: #f0d9d9; }
701+.vp-k { font-size: 11px; color: var(--ink-3); text-transform: uppercase;
702+ letter-spacing: .5px; margin-top: 8px; }
703+.vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700;
704+ letter-spacing: -1px; color: var(--ink); }
705+.vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); }
706+/* jauge Vrai-Prix : fourchette P10–P90 + marqueurs estimation / prix demandé */
707+.vp-gauge { margin-top: 10px; }
708+.vp-gauge-track { position: relative; height: 14px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line-strong); overflow: hidden; }
709+.vp-gauge-band { position: absolute; inset: 3px 6%; background: linear-gradient(90deg, var(--lime-soft), rgba(15, 107, 79, 0.35), var(--lime-soft)); border-radius: 999px; }
710+.vp-gauge-est { position: absolute; top: 0; bottom: 0; width: 4px; margin-left: -2px; background: var(--lime); border-radius: 2px; }
711+.vp-gauge-ask { position: absolute; top: 2px; bottom: 2px; width: 4px; margin-left: -2px; background: var(--ink); border-radius: 2px; }
712+.vp-gauge-ends { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); margin-top: 4px; }
713+.vp-gauge-legend { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 10px; color: var(--ink-2); margin-top: 5px; }
714+.vp-dot-est, .vp-dot-ask { display: inline-block; width: 9px; height: 9px; border-radius: 3px; margin-right: 4px; vertical-align: -1px; }
715+.vp-dot-est { background: var(--lime); }
716+.vp-dot-ask { background: var(--ink); }
717+.vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); }
718+.vp-over { background: #f6e2e2; color: #8a2b2b; }
719+.vp-under { background: #e2f0e6; color: #1c5c41; }
720+.vp-fair { background: var(--surface); color: var(--ink-2); }
721+.vp-link { display: block; text-align: center; margin-top: 10px; font-weight: 700;
722+ font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff;
723+ text-decoration: none; }
724+.vp-link:hover { background: var(--green); }
725+
726+/* =============================================================================
727+ Harmonisation Groupe KA — header (badge + compte KA ID), pages Profil,
728+ Contact et légales. Le socle vient de ka/tokens.css.
729+============================================================================= */
730+
731+/* ---- Header : badge « Un service Groupe KA » + zone compte ---- */
732+.header-inner .gk-badge { flex: none; }
733+@media (max-width: 900px) { .header-inner .gk-badge { display: none; } }
734+.header-acct { display: flex; align-items: center; gap: 10px; flex: none; }
735+.ka-auth { display: flex; align-items: center; gap: 10px; }
736+.ka-login {
737+ display: inline-flex; align-items: center; gap: 6px; min-height: 40px;
738+ padding: 8px 15px; border: 1.5px solid var(--ink); border-radius: 999px;
739+ background: var(--surface); color: var(--ink); cursor: pointer;
740+ font-family: var(--font-display); font-weight: 600; font-size: 13.5px;
741+ box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15);
742+ transition: transform 0.13s ease, box-shadow 0.13s ease, background 0.15s ease;
743+}
744+.ka-login b { background: var(--ink); color: var(--accent); border-radius: 5px; padding: 0 6px 1px; transform: rotate(-2deg); transition: transform 0.15s ease; }
745+.ka-login:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 rgba(20, 24, 20, 0.2); }
746+.ka-login:hover b { transform: rotate(0); }
747+.ka-login:active { transform: translate(2px, 2px); box-shadow: none; }
748+.ka-signup { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; }
749+.ka-signup:hover { color: var(--accent-deep); }
750+@media (max-width: 1100px) { .header-acct .ka-signup { display: none; } }
751+.ka-acct {
752+ display: inline-flex; align-items: center; gap: 8px; min-height: 40px;
753+ padding: 6px 14px; border: 1.5px solid var(--ink); border-radius: 999px;
754+ background: var(--surface); font-family: var(--font-display);
755+ font-weight: 600; font-size: 13.5px; color: var(--ink);
756+ max-width: 160px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
757+}
758+.ka-acct:hover, .ka-acct.active { background: var(--ink); color: var(--accent); }
759+.ka-acct-pic { width: 24px; height: 24px; border-radius: 50%; flex: none; border: 1px solid var(--line); }
760+@media (max-width: 760px) {
761+ .header-acct { margin-left: auto; }
762+ .menu-btn { margin-left: 0; }
763+ .ka-login { min-height: 44px; }
764+}
765+@media (max-width: 380px) { .header-acct { display: none; } } /* → menu mobile */
766+
767+/* ---- Menu mobile : compte + badge groupe ---- */
768+.mm-auth { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 15px 10px; border-bottom: 1.5px dashed var(--line); }
769+.mm-auth .ka-login { min-height: 44px; }
770+.mm-foot p { margin: 10px 0 0; }
771+
772+/* ---- Générique : lede (Profil / Contact) ---- */
773+.lede { color: var(--ink-2); font-size: 15.5px; max-width: 640px; }
774+
775+/* ================= Page Profil (KA ID) ================= */
776+.profil { padding: 44px 0 90px; }
777+.profil h1 { font-size: clamp(28px, 4.6vw, 44px); margin: 10px 0 24px; letter-spacing: -0.03em; }
778+.profil .lede { margin-bottom: 22px; }
779+.profil .notice { padding: 60px 0; }
780+
781+/* Carte de membre — encre + cerise */
782+.pc {
783+ position: relative; overflow: hidden; max-width: 460px;
784+ background: var(--ink); color: var(--paper);
785+ border: 1.5px solid var(--ink); border-radius: var(--r-card);
786+ box-shadow: var(--shadow-off-soft); padding: 22px 26px 0;
787+}
788+.pc-watermark {
789+ position: absolute; right: -18px; bottom: 14px; font-family: var(--font-display);
790+ font-weight: 700; font-size: 150px; line-height: 1; letter-spacing: -0.06em;
791+ color: rgba(245, 243, 238, 0.05); pointer-events: none; user-select: none;
792+}
793+.pc-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
794+.pc-brand { font-family: var(--font-display); font-weight: 700; font-size: 24px; letter-spacing: -0.03em; }
795+.pc-ka { display: inline-block; background: var(--accent); color: var(--on-accent); border-radius: 6px; padding: 0 7px 2px; margin-left: 3px; transform: rotate(-2deg); }
796+.pc-label { font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.16em; color: rgba(245, 243, 238, 0.5); }
797+.pc-id-block { margin: 26px 0 22px; display: flex; flex-direction: column; gap: 4px; }
798+.pc-id-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.2em; color: var(--accent); }
799+.pc-id { font-family: var(--font-mono); font-weight: 700; font-size: clamp(17px, 4.4vw, 22px); letter-spacing: 0.04em; word-break: break-all; }
800+.pc-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 14px; padding-bottom: 18px; }
801+.pc-holder { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
802+.pc-holder-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
803+.pc-role-badge { align-self: flex-start; font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; background: var(--accent); color: var(--on-accent); border-radius: 999px; padding: 2px 9px; }
804+.pc-holder-since { font-family: var(--font-mono); font-size: 10.5px; color: rgba(245, 243, 238, 0.5); letter-spacing: 0.06em; }
805+.pc-avatar { width: 52px; height: 52px; border-radius: 50%; border: 2px solid var(--accent); flex: none; object-fit: cover; }
806+.pc-strip { display: flex; align-items: flex-end; gap: 5px; margin: 0 -26px; padding: 9px 18px; background: rgba(15, 107, 79, 0.14); border-top: 1px solid rgba(15, 107, 79, 0.4); overflow: hidden; }
807+.pc-strip i { display: block; width: 3px; border-radius: 1px; background: var(--accent); opacity: 0.75; }
808+.pc-strip i:nth-child(3n) { height: 14px; opacity: 0.4; }
809+.pc-strip i:nth-child(3n+1) { height: 9px; }
810+.pc-strip i:nth-child(3n+2) { height: 17px; opacity: 0.95; }
811+.pc-copy { margin-top: 16px; }
812+.pc-copy.ok { background: var(--ink); color: var(--accent); }
813+
814+.profil-grid {
815+ display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
816+ gap: 12px; margin-top: 28px; max-width: 720px;
817+}
818+.pg-item { background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
819+.pg-label { font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3); }
820+.pg-value { font-weight: 600; font-size: 14.5px; word-break: break-word; }
821+.pg-value.mono { font-family: var(--font-mono); color: var(--accent-deep); }
822+.profil-note { max-width: 640px; color: var(--ink-2); font-size: 13.5px; margin-top: 22px; }
823+.profil-note a { text-decoration: underline; text-underline-offset: 3px; }
824+.profil-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 26px; }
825+
826+/* Profil hub Groupe KA (lecture seule) */
827+.hub-profile { margin-top: 34px; padding-top: 24px; border-top: 1.5px solid var(--line-strong); max-width: 720px; display: flex; flex-direction: column; gap: 14px; }
828+.hub-profile h3 { font-size: 18px; }
829+.hub-profile .hub-bio { color: var(--ink-2); font-size: 14px; margin: 0; max-width: 560px; }
830+.hub-profile .pub-meta { display: flex; gap: 8px; flex-wrap: wrap; }
831+.hub-profile .stat-chip { display: inline-flex; align-items: center; gap: 6px; }
832+.hub-profile a.stat-chip:hover { background: var(--accent-soft); }
833+.hub-hint { color: var(--ink-3); font-size: 12.5px; margin: 0; }
834+
835+/* ================= Pages légales ================= */
836+.legal { padding: 48px 0 80px; max-width: 780px; }
837+.legal h1 { font-size: clamp(28px, 4.6vw, 44px); margin: 8px 0 6px; }
838+.legal .legal-meta { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; margin: 0 0 26px; }
839+.legal h2 { font-size: 20px; margin: 30px 0 8px; border-left: 4px solid var(--accent); padding-left: 10px; }
840+.legal p, .legal li { color: var(--ink-2); font-size: 14.5px; }
841+.legal ul { padding-left: 20px; display: flex; flex-direction: column; gap: 6px; }
842+.legal a { text-decoration: underline; text-underline-offset: 3px; }
843+.legal b { color: var(--ink); }
844+
845+/* ================= Page Contact (écosystème Groupe KA) ================= */
846+.contact { padding: 48px 0 90px; }
847+.contact h1 { font-size: clamp(30px, 5vw, 52px); margin: 12px 0 14px; text-transform: uppercase; letter-spacing: -0.03em; }
848+.contact-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 28px 0 6px; max-width: 900px; }
849+.contact-card {
850+ display: flex; flex-direction: column; gap: 8px; min-height: var(--touch, 44px);
851+ background: var(--surface); border: 1.5px solid var(--ink);
852+ border-radius: var(--r-card); padding: 18px 20px;
853+ box-shadow: var(--shadow-off-soft);
854+ transition: transform 0.15s ease, box-shadow 0.15s ease;
855+}
856+.contact-card:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
857+.contact-role { font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--green); }
858+.contact-mail { font-family: var(--font-display); font-weight: 700; font-size: clamp(14px, 2vw, 17px); word-break: break-all; border-bottom: 2px solid var(--accent); align-self: flex-start; }
859+.contact-disclaimer { max-width: 720px; margin: 24px 0 0; padding: 14px 16px; border-left: 3px solid var(--accent); background: var(--accent-soft); color: var(--ink-2); font-size: 13.5px; }
860+.contact-disclaimer b { color: var(--ink); }
861+.contact-hub { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 26px; }
862+.contact-sites { margin-top: 46px; }
863+.contact-sites h2 { font-size: 21px; text-transform: uppercase; margin-bottom: 14px; }
864+.contact-sites ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
865+.contact-sites a { display: flex; flex-direction: column; gap: 3px; min-height: var(--touch, 44px); background: var(--surface); border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 12px 14px; transition: transform 0.14s ease, box-shadow 0.14s ease; }
866+.contact-sites a:hover { transform: translate(-2px, -2px); box-shadow: 4px 4px 0 var(--ink); }
867+.contact-sites b { font-family: var(--font-display); font-size: 15px; }
868+.contact-sites span { font-size: 12px; color: var(--ink-3); }
869+@media (max-width: 480px) {
870+ .header-inner { gap: 10px; }
871+ .ka-login { font-size: 12px; padding: 6px 10px; gap: 4px; min-height: 44px; }
872+}
873+
874+/* Bulle KA Agent : sur mobile, remontée au-dessus de la barre CTA collante des
875+ fiches (sinon elle chevauche le bouton « Voir chez … »). */
876+@media (max-width: 899px) {
877+ .kaa-btn {
878+ bottom: calc(88px + env(safe-area-inset-bottom, 0px)) !important;
879+ }
880+}
881+
882+/* Footer commun KA : zones tactiles ≥ 44 px pour les petits liens (Ka2, Ka4…)
883+ — padding compensé par une marge négative : aucun changement visuel. */
884+.ka-footer .sites a, .ka-footer .legal a, .ka-footer .contacts a {
885+ display: inline-block; padding: 12px 8px; margin: -12px -8px;
886+}
887+
888+/* ================= Qualité des annonces / images ================= */
889+/* Image de secours par TYPE DE BIEN (jamais d'icône d'image cassée) */
890+.type-fallback {
891+ display: flex; flex-direction: column; align-items: center; justify-content: center;
892+ gap: 9px; width: 100%; height: 100%; min-height: 120px;
893+ background:
894+ radial-gradient(circle at 78% 18%, rgba(15, 107, 79, 0.14), transparent 52%),
895+ repeating-linear-gradient(45deg, #f3ece9 0 14px, #f7f1ef 14px 28px);
896+ color: var(--accent-deep);
897+}
898+.type-fallback .ico { opacity: 0.75; }
899+.type-fallback span {
900+ font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
901+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
902+}
903+.carousel-empty .type-fallback { aspect-ratio: 16/11; }
904+
905+/* légende de photo (étiquette de pièce de la source) */
906+.carousel-caption {
907+ position: absolute; left: 12px; bottom: 12px; z-index: 2; max-width: 60%;
908+ background: rgba(26, 18, 20, 0.82); color: #f5f3ee;
909+ font-family: var(--font-mono); font-size: 11px; font-weight: 600;
910+ padding: 4px 10px; border-radius: 999px;
911+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
912+}
913+
914+/* mention loyer mensuel (transaction = location) */
915+.per-month { font-size: 0.55em; font-weight: 600; color: var(--ink-3); letter-spacing: 0; }
916+
917+/* lightbox : l'image zoomable ne doit pas déclencher le scroll de la page */
918+.lightbox img { user-select: none; -webkit-user-drag: none; }
919+
920+/* ---- Stats : qualité des données ---- */
921+.q-table-wrap { overflow-x: auto; }
922+.q-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 520px; }
923+.q-table th { text-align: left; font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); padding: 6px 10px; border-bottom: 1.5px solid var(--ink); }
924+.q-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
925+.q-table .num { text-align: right; font-family: var(--font-mono); }
926+.q-meter { display: inline-block; width: 74px; height: 9px; background: var(--surface-2); border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; vertical-align: -1px; margin-right: 7px; }
927+.q-meter i { display: block; height: 100%; background: var(--green); }
928+
929+
930+/* ---- Criminalité détaillée SPVM (bloc quartier) ---- */
931+.q-crime { width: 100%; }
932+.q-crime-cats { list-style: none; margin: 8px 0 0; padding: 0; }
933+.q-crime-cats li { display: flex; align-items: center; gap: 8px;
934+ padding: 3px 0; font-size: 12.5px; }
935+.q-crime-nom { flex: 0 0 46%; color: var(--ink-2, #555); overflow: hidden;
936+ text-overflow: ellipsis; white-space: nowrap; }
937+.q-crime-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8);
938+ border-radius: 5px; overflow: hidden; }
939+.q-crime-barre i { display: block; height: 100%; border-radius: 5px;
940+ background: var(--accent, #0f6b4f); opacity: 0.55; }
941+.q-crime-n { flex: 0 0 52px; text-align: right; font-weight: 600;
942+ color: var(--ink, #222); white-space: nowrap; }
943+.q-crime-n small { font-weight: 400; color: var(--ink-3, #888); }
944+.q-crime-src { margin-top: 8px; }
945+
946+
947+/* ---- Risque d'inondation (fiche) — BDZI gouv. du Québec ---- */
948+.f-zi .zi-head { display: flex; align-items: center; gap: 10px; }
949+.zi-badge { display: inline-block; padding: 3px 10px; border-radius: 999px;
950+ font-size: 12.5px; font-weight: 700; }
951+.zi-eleve { background: #fde8e8; color: #a12622; }
952+.zi-modere { background: #fdf3e0; color: #8a5a00; }
953+.zi-present { background: #fdf3e0; color: #8a5a00; }
954+.zi-ok { background: #e7f4ea; color: #1e6b34; }
955+.zi-nc { background: var(--surface-2, #f0ede8); color: var(--ink-3, #888); }
956+.zi-liste { margin: 8px 0 0; padding: 0; list-style: none; font-size: 13px; }
957+.zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); }
958+
959+
960+/* ---- tuiles KPI + tableau compacts (partagés air/essence) ---- */
961+.rdl-kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 12px; }
962+.rdl-kpi { flex: 1 1 90px; min-width: 90px; background: var(--surface-2, #f7f5f1);
963+ border-radius: 10px; padding: 10px 12px; }
964+.rdl-kpi-v { display: block; font-size: 20px; font-weight: 700;
965+ letter-spacing: -0.02em; color: var(--ink, #222); }
966+.rdl-kpi-l { display: block; font-size: 11px; line-height: 1.35;
967+ color: var(--ink-3, #888); margin-top: 2px; }
968+.rdl-table { width: 100%; border-collapse: collapse; font-size: 13px; }
969+.rdl-table td { padding: 5px 8px 5px 0; border-top: 1px solid var(--line, #e6e4df);
970+ vertical-align: top; }
971+.rdl-table .rdl-addr { max-width: 46%; }
972+.rdl-table .rdl-prix { font-weight: 600; white-space: nowrap; }
973+.rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2, #555); }
974+.rdl-table .rdl-dist { color: var(--ink-3, #888); white-space: nowrap; text-align: right; }
975+.rdl-cap { caption-side: top; text-align: left; font-size: 13px; font-weight: 700;
976+ color: var(--ink, #222); padding: 6px 0; }
977+
978+/* ---- Qualité de l'air (RSQAQ) + Essence à proximité ---- */
979+.air-liste { list-style: none; margin: 10px 0 0; padding: 0; }
980+.air-liste li { display: flex; align-items: center; gap: 8px;
981+ padding: 4px 0; font-size: 12.5px; }
982+.air-nom { flex: 0 0 44%; color: var(--ink-2); overflow: hidden;
983+ text-overflow: ellipsis; white-space: nowrap; }
984+.air-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8);
985+ border-radius: 5px; overflow: hidden; }
986+.air-barre i { display: block; height: 100%; border-radius: 5px;
987+ background: #4d9e64; }
988+.air-barre i.air-sur { background: #c96a1f; }
989+.air-val { flex: 0 0 34%; text-align: right; font-weight: 600;
990+ color: var(--ink); white-space: nowrap; overflow: hidden;
991+ text-overflow: ellipsis; }
992+.air-val small { font-weight: 400; color: var(--ink-3); }
993+.air-val .air-ref { display: block; font-size: 10px; }
994+.gaz-head th { text-align: left; font-size: 11px; color: var(--ink-3);
995+ font-weight: 600; padding: 2px 8px 2px 0; }
996+.gaz-best { display: inline-block; margin-left: 6px; padding: 1px 7px;
997+ border-radius: 999px; background: #e7f4ea; color: #1e6b34;
998+ font-size: 10.5px; font-weight: 700; }
999+.gaz-adr { display: block; font-size: 11px; color: var(--ink-3); }
1000+
1001+
1002+/* ---- Commerces et transport (fiche) ---- */
1003+.cm-grille { list-style: none; margin: 6px 0 0; padding: 0;
1004+ display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; }
1005+@media (max-width: 560px) { .cm-grille { grid-template-columns: 1fr; } }
1006+.cm-item { display: flex; align-items: center; gap: 9px; padding: 5px 0;
1007+ border-bottom: 1px solid var(--line, #eeece7); min-width: 0; }
1008+.cm-ico { flex: 0 0 28px; }
1009+.cm-txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
1010+.cm-nom { font-size: 13px; font-weight: 600; color: var(--ink, #222);
1011+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1012+.cm-poi { font-size: 11px; color: var(--ink-3, #8a877f);
1013+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1014+.cm-dist { flex: 0 0 auto; font-size: 13px; font-weight: 700;
1015+ color: var(--ink-2, #4c4a45); white-space: nowrap; }
1016+
1017+
1018+/* ---- Coût d'électricité (Hydro-Québec) ---- */
1019+.hydro-intro { margin: 2px 0 12px; color: var(--ink-2, #4c4a45); }
1020+.hydro-btn { appearance: none; border: 0; border-radius: 10px; cursor: pointer;
1021+ background: var(--accent, #2b7a3b); color: #fff; font-weight: 700;
1022+ font-size: 14px; padding: 11px 18px; }
1023+.hydro-btn:disabled { opacity: 0.6; cursor: progress; }
1024+.hydro-montant { font-size: 26px; font-weight: 800; letter-spacing: -0.02em;
1025+ color: var(--navy, #1c1b18); display: flex; align-items: baseline;
1026+ gap: 10px; flex-wrap: wrap; }
1027+.hydro-montant small { font-size: 14px; font-weight: 500; color: var(--ink-3); }
1028+.hydro-an { font-size: 14px; font-weight: 600; color: var(--ink-2, #4c4a45); }
1029+.hydro-vide { color: var(--ink-3, #8a877f); }
1030+
1031+
1032+/* ============================================================================
1033+ PASSE DE DESIGN — « éditorial sharp » appliqué à toute la fiche
1034+ Blocs = cartes encre (bordure 1,5 px + ombre décalée douce), titres à
1035+ marqueur accent + filet, tuiles KPI blanches à ombre dure, tableaux zébrés,
1036+ barres en dégradé, pastilles unifiées (mono, point d'état, liseré).
1037+ Aucune propriété `order` (règle Groupe Ka : ordre DOM = ordre visuel).
1038+ ========================================================================== */
1039+
1040+/* --- Blocs de la fiche : cartes posées sur le papier --- */
1041+.f-bloc:not(.f-galerie):not(.f-hero),
1042+.quartier {
1043+ background: var(--surface);
1044+ border: 1.5px solid var(--ink);
1045+ border-radius: var(--r-card);
1046+ padding: 18px 20px 16px;
1047+ box-shadow: var(--shadow-off-soft);
1048+}
1049+@media (max-width: 640px) {
1050+ .f-bloc:not(.f-galerie):not(.f-hero),
1051+ .quartier { padding: 15px 15px 13px; }
1052+}
1053+/* blocs pleine largeur sous la grille : respiration entre les cartes */
1054+.detail > .f-bloc { margin-top: 22px; }
1055+
1056+/* --- Titres de section : carré accent + filet éditorial --- */
1057+.f-bloc h2, .quartier h2 {
1058+ display: flex; align-items: center; gap: 10px;
1059+ font-size: 19px; letter-spacing: -0.015em; margin-bottom: 14px;
1060+ border-left: 0; padding-left: 0;
1061+}
1062+.f-bloc h2::before, .quartier h2::before {
1063+ content: ""; flex: 0 0 9px; width: 9px; height: 9px;
1064+ background: var(--accent); border: 1.5px solid var(--ink);
1065+ box-shadow: 2px 2px 0 var(--ink);
1066+}
1067+.f-bloc h2::after, .quartier h2::after {
1068+ content: ""; flex: 1 1 auto; height: 1.5px; min-width: 20px;
1069+ background: var(--line);
1070+}
1071+
1072+/* --- Tuiles KPI : blanches, bord encre, ombre dure accent --- */
1073+.rdl-kpi {
1074+ background: var(--surface);
1075+ border: 1.5px solid var(--ink);
1076+ border-radius: var(--r-ctl);
1077+ box-shadow: 3px 3px 0 var(--accent-soft);
1078+}
1079+.rdl-kpi-v { font-family: var(--font-display); }
1080+.q-cell { box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); border-radius: var(--r-ctl); }
1081+.q-val { font-size: 20px; }
1082+
1083+/* --- Tableaux : en-têtes mono, zébrure, survol accent --- */
1084+.rdl-table td { padding: 7px 8px 7px 0; }
1085+.rdl-table tbody tr:nth-child(even) td,
1086+.rdl-table > tr:nth-child(even) td { background: var(--surface-2); }
1087+.rdl-table tr:hover td { background: var(--accent-soft); }
1088+.gaz-head th {
1089+ font-family: var(--font-mono); font-size: 10px; font-weight: 700;
1090+ text-transform: uppercase; letter-spacing: 0.08em;
1091+ border-bottom: 1.5px solid var(--ink);
1092+}
1093+.rdl-cap { font-family: var(--font-display); }
1094+table.rooms tbody tr:hover td { background: var(--accent-soft); }
1095+
1096+/* --- Barres : pistes bordées + remplissage en dégradé --- */
1097+.q-bar-track, .q-crime-barre, .air-barre {
1098+ background: var(--surface-2);
1099+ border: 1px solid var(--line);
1100+ height: 12px;
1101+}
1102+.q-bar-fill {
1103+ background: linear-gradient(90deg, var(--accent), var(--accent-deep));
1104+}
1105+.q-crime-barre i {
1106+ background: linear-gradient(90deg, #f0808a, var(--accent));
1107+ opacity: 0.9;
1108+}
1109+.air-barre i { background: linear-gradient(90deg, #6fbc85, #2e7d4a); }
1110+.air-barre i.air-sur { background: linear-gradient(90deg, #e08a48, #c96a1f); }
1111+
1112+/* --- Pastilles d'état unifiées (zi-badge & cie) : mono + point d'état --- */
1113+.zi-badge {
1114+ display: inline-flex; align-items: center; gap: 6px;
1115+ font-family: var(--font-mono); font-size: 10.5px; font-weight: 700;
1116+ text-transform: uppercase; letter-spacing: 0.06em;
1117+ padding: 4px 10px; border: 1.5px solid currentColor;
1118+}
1119+.zi-badge::before {
1120+ content: ""; width: 6px; height: 6px; border-radius: 50%;
1121+ background: currentColor;
1122+}
1123+.gaz-best { border: 1px solid currentColor; }
1124+
1125+/* --- Listes riches (zones d'inondation) : mini-cartes à liseré accent --- */
1126+.zi-liste { display: flex; flex-direction: column; gap: 8px; }
1127+.zi-liste li {
1128+ border: 1px solid var(--line); border-left: 3px solid var(--accent);
1129+ border-top: 1px solid var(--line);
1130+ border-radius: var(--r-ctl); padding: 9px 12px;
1131+ background: var(--surface-2);
1132+}
1133+
1134+/* --- Inclusions : icône et libellé alignés sur une seule ligne --- */
1135+.amenity { display: inline-flex; align-items: center; gap: 6px; }
1136+
1137+/* --- Notes de bas de bloc : filet pointillé --- */
1138+.f-bloc .fine, .quartier .fine {
1139+ margin-top: 12px; padding-top: 9px;
1140+ border-top: 1px dashed var(--line);
1141+}
1142+
1143+/* --- CTA secondaire : téléchargement de la fiche PDF --- */
1144+.cta-pdf {
1145+ background: var(--surface); color: var(--ink);
1146+ box-shadow: 4px 4px 0 rgba(26, 18, 20, 0.12);
1147+ margin-top: 10px;
1148+}
1149+.cta-pdf:hover { background: var(--ink); color: var(--paper); }
1150+
1151+/* ================= Financement (fiche) + page Taux hypothécaires ========= */
1152+.f-mtg .mtg-intro { color: var(--ink-2); font-size: 14px; margin: 4px 0 14px; }
1153+.f-mtg .mtg-intro a { color: var(--lime); font-weight: 600; }
1154+
1155+.mtg-form {
1156+ display: grid; gap: 10px;
1157+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
1158+}
1159+.mtg-form label { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
1160+.mtg-form label > span {
1161+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1162+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
1163+}
1164+.mtg-form input, .mtg-form select {
1165+ border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
1166+ padding: 9px 10px; font-size: 14.5px; font-family: inherit; color: var(--ink);
1167+ min-height: 42px; min-width: 0;
1168+}
1169+.mtg-form input:focus, .mtg-form select:focus {
1170+ outline: none; box-shadow: 3px 3px 0 var(--lime);
1171+}
1172+
1173+.mtg-err { color: var(--accent-deep); font-size: 13.5px; margin: 12px 0 0; }
1174+
1175+.mtg-resultat {
1176+ display: grid; gap: 10px; margin-top: 16px;
1177+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1178+}
1179+.mtg-kpi {
1180+ border: 1.5px solid var(--line-strong); border-radius: var(--r-card);
1181+ background: var(--surface-2); padding: 12px 14px;
1182+ display: flex; flex-direction: column; gap: 2px; min-width: 0;
1183+}
1184+.mtg-kpi-k {
1185+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1186+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
1187+}
1188+.mtg-kpi-v {
1189+ font-family: var(--font-display); font-weight: 700; font-size: 22px;
1190+ letter-spacing: -0.02em;
1191+}
1192+.mtg-kpi-sub { font-size: 12px; color: var(--ink-3); }
1193+.mtg-kpi:first-child { background: var(--ink); border-color: var(--ink); }
1194+.mtg-kpi:first-child .mtg-kpi-v { color: var(--lime); }
1195+.mtg-kpi:first-child .mtg-kpi-k, .mtg-kpi:first-child .mtg-kpi-sub { color: rgba(255,255,255,0.75); }
1196+
1197+.mtg-source { margin-top: 10px; }
1198+
1199+.mtg-schl {
1200+ margin-top: 14px; border: 1.5px dashed var(--line-strong);
1201+ border-radius: var(--r-card); padding: 12px 14px; font-size: 13.5px;
1202+ background: var(--surface);
1203+}
1204+.mtg-schl ul { margin: 8px 0 0; padding-left: 18px; display: grid; gap: 3px; }
1205+.mtg-schl-no { border-color: var(--accent-deep); }
1206+.mtg-issue { margin: 8px 0 0; color: var(--accent-deep); font-size: 13px; }
1207+
1208+.mtg-detail {
1209+ margin-top: 10px; border: 1.5px solid var(--line-strong);
1210+ border-radius: var(--r-card); background: var(--surface);
1211+}
1212+.mtg-detail > summary {
1213+ cursor: pointer; padding: 11px 14px; font-weight: 700; font-size: 14px;
1214+ list-style: none; display: flex; align-items: center; gap: 8px;
1215+}
1216+.mtg-detail > summary::before { content: "+"; font-family: var(--font-mono); color: var(--lime); font-weight: 700; }
1217+.mtg-detail[open] > summary::before { content: "−"; }
1218+.mtg-detail > summary::-webkit-details-marker { display: none; }
1219+.mtg-detail > *:not(summary) { margin: 0 14px 12px; }
1220+.mtg-detail .fine { border-top: none; padding-top: 0; }
1221+.mtg-total { border-top: 2px solid var(--ink); }
1222+.mtg-comp td, .mtg-comp th { white-space: nowrap; }
1223+.mtg-stale { color: var(--accent-deep); }
1224+
1225+.mtg-chart svg { width: 100%; height: auto; display: block; }
1226+.mtg-grid { stroke: var(--line); stroke-width: 1; }
1227+.mtg-tick { font-family: var(--font-mono); font-size: 10px; fill: var(--ink-3); }
1228+.mtg-line { fill: none; stroke: var(--lime); stroke-width: 2.5; stroke-linejoin: round; }
1229+.mtg-dot { fill: var(--ink); stroke: var(--lime); stroke-width: 2.5; }
1230+
1231+/* ---- page /taux-hypothecaires ---- */
1232+.taux-page { padding-bottom: 40px; }
1233+.taux-head { margin: 28px 0 6px; max-width: 760px; }
1234+.taux-head h1 { font-family: var(--font-display); font-size: clamp(28px, 5vw, 40px); letter-spacing: -0.03em; }
1235+.taux-head p { color: var(--ink-2); font-size: 15px; margin: 12px 0 0; }
1236+.taux-page > .f-bloc { margin-top: 22px; }
1237+
1238+.taux-grid {
1239+ display: grid; gap: 10px;
1240+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
1241+}
1242+.taux-card {
1243+ border: 1.5px solid var(--ink); border-radius: var(--r-card);
1244+ background: var(--surface); padding: 12px 14px; text-align: left;
1245+ cursor: pointer; display: flex; flex-direction: column; gap: 2px;
1246+ transition: box-shadow 0.15s ease; font-family: inherit; min-width: 0;
1247+}
1248+.taux-card:hover { box-shadow: 3px 3px 0 var(--lime); }
1249+.taux-card.on { background: var(--ink); color: var(--paper); }
1250+.taux-card.on .taux-card-v { color: var(--lime); }
1251+.taux-card-k {
1252+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1253+ text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-3);
1254+}
1255+.taux-card.on .taux-card-k, .taux-card.on .taux-card-sub { color: rgba(255,255,255,0.7); }
1256+.taux-card-v { font-family: var(--font-display); font-weight: 700; font-size: 24px; }
1257+.taux-card-sub { font-size: 11.5px; color: var(--ink-3); }
1258+
1259+.taux-filtres { display: flex; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
1260+.taux-filtres label { display: flex; flex-direction: column; gap: 3px; }
1261+.taux-filtres label > span {
1262+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1263+ text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
1264+}
1265+.taux-filtres select {
1266+ border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface);
1267+ padding: 9px 12px; font-size: 14.5px; font-family: inherit; min-height: 42px;
1268+}
1269+.taux-best td { background: var(--lime-soft); }
1270+.taux-badge {
1271+ font-family: var(--font-mono); font-size: 9px; font-weight: 700;
1272+ text-transform: uppercase; letter-spacing: 0.1em; background: var(--ink);
1273+ color: var(--lime); border-radius: 999px; padding: 2px 8px; margin-left: 8px;
1274+}
1275+.taux-sante { display: flex; flex-wrap: wrap; gap: 8px; }
1276+.taux-src {
1277+ border: 1.5px solid var(--line-strong); border-radius: 999px;
1278+ padding: 6px 12px; font-size: 12.5px; font-weight: 600;
1279+ display: inline-flex; align-items: center; gap: 7px;
1280+}
1281+.taux-src::before { content: ""; width: 8px; height: 8px; border-radius: 50%; }
1282+.taux-src-ok::before { background: var(--green, #2f9e44); }
1283+.taux-src-warning::before { background: #e8a000; }
1284+.taux-src-error::before { background: var(--accent-deep); }
1285+
1286+/* =============================================================================
1287+ REFONTE ÉDITORIALE v2 (2026-08-25) — accueil « produit premium »
1288+ Personnalité Immo-Ka : architecturale et financière — composition
1289+ typographique majuscule plein/outline décalée, colonne de données mono
1290+ façon terminal, cerise en couleur de SIGNAL (jamais en aplat décoratif).
1291+ Primitives locales : rayons 0/6/10/18, durées 150/240/350 ms.
1292+ Couche posée en FIN de fichier : elle a le dernier mot sur la cascade.
1293+============================================================================= */
1294+:root {
1295+ --r-0: 0px; --r-1: 6px; --r-2: 10px; --r-3: 18px;
1296+ --dur-1: 150ms; --dur-2: 240ms; --dur-3: 350ms;
1297+ --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
1298+ --hairline: rgba(20, 24, 20, 0.14);
1299+ --hairline-strong: rgba(20, 24, 20, 0.6);
1300+}
1301+
1302+/* ---- Héro : composition typographique + colonne de données ---- */
1303+.hero-wrap { display: grid; grid-template-columns: 1fr auto; gap: 24px 56px; align-items: end; }
1304+.hero h1.hero-display {
1305+ display: flex; flex-direction: column;
1306+ font-size: clamp(40px, 6.8vw, 92px); line-height: 0.96;
1307+ text-transform: uppercase; letter-spacing: -0.04em;
1308+ max-width: none; margin-top: 16px;
1309+}
1310+.hero-display .hd-l1, .hero-display .hd-l2,
1311+.hero-display .hd-l3, .hero-display .hd-l4 { display: block; }
1312+.hero-display .hd-l2 { color: transparent; -webkit-text-stroke: 2.5px var(--ink); }
1313+.hero-display .hd-l3 { margin-left: clamp(24px, 6vw, 110px); }
1314+.hero-display .hd-l4 {
1315+ font-size: 0.5em; letter-spacing: -0.02em; margin-top: 0.35em;
1316+ margin-left: clamp(2px, 1vw, 8px);
1317+}
1318+.hero-display .signal {
1319+ font-style: normal; color: var(--lime); position: relative;
1320+ border-bottom: 4px solid var(--lime); padding-bottom: 1px;
1321+}
1322+.hero p.lede { font-size: 16px; max-width: 560px; }
1323+.hero::before { opacity: 0.7; }
1324+.hero::after { opacity: 0.5; }
1325+
1326+/* colonne « terminal » : les chiffres du marché comme élément graphique */
1327+.hero-data {
1328+ display: flex; flex-direction: column; gap: 18px;
1329+ border-left: 1px solid var(--hairline-strong); padding-left: 28px;
1330+ min-width: 190px;
1331+}
1332+.hd-row b {
1333+ display: block; font-family: var(--font-display); font-weight: 700;
1334+ font-size: clamp(20px, 2vw, 27px); letter-spacing: -0.03em; color: var(--ink);
1335+ font-variant-numeric: tabular-nums; line-height: 1.1;
1336+}
1337+.hd-row span {
1338+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1339+ text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
1340+}
1341+@media (max-width: 900px) {
1342+ .hero-wrap { grid-template-columns: 1fr; align-items: start; }
1343+ .hero-data {
1344+ flex-direction: row; flex-wrap: wrap; gap: 16px 28px;
1345+ border-left: 0; border-top: 1px solid var(--hairline);
1346+ padding: 16px 0 0; min-width: 0; margin-top: 6px;
1347+ }
1348+}
1349+
1350+/* ---- Ligne de données vivantes (mono + filets) ---- */
1351+.live-line {
1352+ display: flex; flex-wrap: wrap; align-items: baseline;
1353+ margin-top: 30px; font-family: var(--font-mono); font-size: 12px;
1354+ color: var(--ink-2); letter-spacing: 0.02em; row-gap: 10px;
1355+}
1356+.live-flag {
1357+ display: inline-flex; align-items: center; gap: 7px;
1358+ font-weight: 700; font-size: 10.5px; letter-spacing: 0.18em;
1359+ text-transform: uppercase; color: var(--accent-deep);
1360+ padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline-strong);
1361+}
1362+.live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--lime); animation: pulse 2.4s ease infinite; }
1363+.live-item { padding-right: 14px; margin-right: 14px; border-right: 1px solid var(--hairline); }
1364+.live-item:last-child { border-right: 0; padding-right: 0; margin-right: 0; }
1365+.live-item b { color: var(--ink); font-weight: 700; font-variant-numeric: tabular-nums; }
1366+@media (max-width: 640px) {
1367+ .live-line { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
1368+ white-space: nowrap; margin-right: -16px; padding-right: 16px; }
1369+ .live-line::-webkit-scrollbar { display: none; }
1370+}
1371+
1372+/* ---- Recherche au cœur : grand champ souligné + critères en filets ---- */
1373+.search-zone { margin: 42px 0 0; }
1374+.q-big {
1375+ display: flex; align-items: center; gap: 14px;
1376+ border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
1377+ background: transparent; padding: 4px 2px 14px; color: var(--ink-3);
1378+ transition: border-color var(--dur-2) var(--ease-out), color var(--dur-2) ease;
1379+}
1380+.q-big:focus-within { border-color: var(--lime); color: var(--accent-deep); }
1381+.q-big svg { flex: none; }
1382+.q-big input {
1383+ flex: 1; min-width: 0; border: 0; background: none; outline: none; padding: 0;
1384+ font-family: var(--font-display); font-weight: 600;
1385+ font-size: clamp(19px, 2.8vw, 28px); letter-spacing: -0.02em; color: var(--ink);
1386+}
1387+.q-big input::placeholder { color: var(--ink-3); font-weight: 500; }
1388+.crit-line { display: flex; align-items: stretch; flex-wrap: wrap; }
1389+.crit {
1390+ display: flex; flex-direction: column; justify-content: center; gap: 1px;
1391+ padding: 12px 26px 12px 0; margin-right: 26px;
1392+ border-right: 1px solid var(--hairline); min-width: 0; cursor: pointer;
1393+}
1394+.crit > span {
1395+ font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
1396+ text-transform: uppercase; letter-spacing: 0.14em; color: var(--ink-3);
1397+}
1398+.crit select {
1399+ border: 0; background: transparent; outline: none; cursor: pointer;
1400+ font-family: var(--font-display); font-weight: 700; font-size: 15.5px;
1401+ color: var(--ink); appearance: none; -webkit-appearance: none;
1402+ padding: 2px 18px 2px 0; min-width: 0; max-width: 190px; text-overflow: ellipsis;
1403+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%238b928c'/%3E%3C/svg%3E");
1404+ background-repeat: no-repeat; background-position: right center;
1405+}
1406+.crit:hover select { color: var(--accent-deep); }
1407+.crit .range-pair select { max-width: 92px; }
1408+.crit-more {
1409+ display: inline-flex; align-items: center; gap: 9px; align-self: center;
1410+ border: 0; background: none; padding: 12px 0; cursor: pointer;
1411+ font-family: var(--font-display); font-weight: 700; font-size: 14.5px; color: var(--ink);
1412+ transition: color var(--dur-1) ease;
1413+}
1414+.crit-more:hover, .crit-more.on { color: var(--accent-deep); }
1415+.crit-badge, .rb-badge {
1416+ display: inline-grid; place-items: center; min-width: 19px; height: 19px; padding: 0 5px;
1417+ border-radius: 999px; background: var(--lime); color: #fff;
1418+ font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums;
1419+}
1420+.search-zone .f-adv { border-top: 1px solid var(--hairline); margin-top: 2px; padding-top: 18px; }
1421+.search-zone .seg button {
1422+ border: 1px solid var(--hairline-strong); background: transparent; color: var(--ink-2);
1423+ min-height: 38px; font-weight: 600; margin-left: -1px;
1424+}
1425+.search-zone .seg button:first-child { border-radius: var(--r-1) 0 0 var(--r-1); }
1426+.search-zone .seg button:last-child { border-radius: 0 var(--r-1) var(--r-1) 0; }
1427+.search-zone .seg button:hover { background: var(--lime-soft); color: var(--accent-deep); }
1428+.search-zone .seg button.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
1429+.search-zone .f-native { border: 1px solid var(--hairline-strong); background: transparent; }
1430+
1431+/* résumé mobile des critères — remplace le FAB (voir media 640 plus bas) */
1432+.crit-summary { display: none; }
1433+
1434+/* ---- Barre de résultats sticky : N propriétés · tri · Liste|Carte ---- */
1435+.results-bar {
1436+ position: sticky; top: 64px; z-index: var(--z-sticky, 300);
1437+ display: flex; align-items: center; gap: 16px;
1438+ margin: 30px 0 22px; padding: 12px 0 11px;
1439+ border-bottom: 1px solid var(--hairline-strong);
1440+ background: color-mix(in srgb, var(--paper) 86%, transparent);
1441+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
1442+}
1443+@supports not (background: color-mix(in srgb, red 50%, blue)) {
1444+ .results-bar { background: rgba(245, 243, 238, 0.92); }
1445+}
1446+.rb-count { margin: 0; font-size: clamp(16px, 2vw, 20px); letter-spacing: -0.02em; color: var(--ink); text-transform: none; }
1447+.rb-count b { font-variant-numeric: tabular-nums; }
1448+.rb-tools { margin-left: auto; display: flex; align-items: center; gap: 20px; }
1449+.rb-sort select {
1450+ border: 0; background: transparent; outline: none; cursor: pointer;
1451+ font-family: var(--font-mono); font-size: 11.5px; font-weight: 700;
1452+ text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-2);
1453+ appearance: none; -webkit-appearance: none; padding: 8px 16px 8px 0;
1454+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='9' height='5'%3E%3Cpath d='M0 0l4.5 5L9 0z' fill='%238b928c'/%3E%3C/svg%3E");
1455+ background-repeat: no-repeat; background-position: right center;
1456+}
1457+.rb-sort select:hover { color: var(--ink); }
1458+.rb-tabs { display: flex; gap: 18px; }
1459+.rb-tab {
1460+ position: relative; display: inline-flex; align-items: center; gap: 6px;
1461+ border: 0; background: none; padding: 8px 0; cursor: pointer;
1462+ font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink-3);
1463+ transition: color var(--dur-1) ease;
1464+}
1465+.rb-tab:hover, .rb-tab.on { color: var(--ink); }
1466+.rb-tab::after {
1467+ content: ""; position: absolute; left: 0; right: 0; bottom: -12px; height: 2.5px;
1468+ background: var(--lime); transform: scaleX(0); transform-origin: left;
1469+ transition: transform var(--dur-2) var(--ease-out);
1470+}
1471+.rb-tab.on::after { transform: scaleX(1); }
1472+.rb-filters {
1473+ display: inline-flex; align-items: center; gap: 7px;
1474+ border: 0; background: none; padding: 8px 0; cursor: pointer;
1475+ font-family: var(--font-display); font-weight: 700; font-size: 13.5px; color: var(--ink);
1476+}
1477+.rb-filters:hover { color: var(--accent-deep); }
1478+@media (min-width: 641px) { .rb-filters { display: none; } }
1479+
1480+/* ---- Transition douce liste ↔ carte ---- */
1481+.view-pane { animation: pane-in var(--dur-2) var(--ease-out); }
1482+@keyframes pane-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
1483+
1484+/* ---- Chips rapides : plus légères ---- */
1485+.chip {
1486+ border: 1px solid var(--hairline-strong); background: transparent;
1487+ min-height: 38px; padding: 7px 15px; font-size: 12.5px; box-shadow: none;
1488+}
1489+.chip:hover { transform: none; box-shadow: none; background: var(--lime-soft); color: var(--accent-deep); }
1490+.chip.on { background: var(--ink); border-color: var(--ink); color: var(--paper); box-shadow: none; }
1491+.chips { margin: 20px 0 0; }
1492+
1493+/* ---- Pastilles actives : discrètes, survol = retrait ---- */
1494+.pills { margin: 14px 0 0; }
1495+.pills .pill { border: 1px solid var(--hairline-strong); background: transparent; box-shadow: none; padding: 5px 12px; font-size: 12px; }
1496+.pills .pill:hover { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
1497+
1498+/* ---- Fiches éditoriales : l'image domine, plus de boîte ---- */
1499+.card, .card:hover { background: transparent; border: 0; border-radius: 0; box-shadow: none; transform: none; }
1500+.card-img { border-radius: var(--r-2); aspect-ratio: 4/3; }
1501+.card-img img { transition: transform var(--dur-3) var(--ease-out); }
1502+@media (hover: hover) {
1503+ .card:hover .card-img img { transform: scale(1.025); }
1504+ .card:hover .card-price { color: var(--accent-deep); }
1505+}
1506+.card:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; border-radius: var(--r-2); }
1507+.card-body { padding: 13px 2px 0; gap: 3px; }
1508+.card-price { font-size: 22px; letter-spacing: -0.03em; color: var(--ink); transition: color var(--dur-1) ease; }
1509+.card-title { font-size: 14px; font-weight: 500; color: var(--ink-2); }
1510+.card-meta { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
1511+.card-meta .sep { width: 3px; height: 3px; border: 0; border-radius: 50%; background: var(--lime); transform: none; }
1512+.card-specs { font-size: 11.5px; color: var(--ink-2); }
1513+.card-foot { border-top: 0; padding-top: 8px; }
1514+.source-tag { background: none; border: 0; border-radius: 0; padding: 0; color: var(--ink-3); font-size: 10px; letter-spacing: 0.08em; max-width: 62%; }
1515+.avail { font-size: 11px; color: var(--ink-3); }
1516+.badge { border: 0; border-radius: 4px; background: rgba(20, 24, 20, 0.78); color: var(--paper);
1517+ backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
1518+ font-size: 9.5px; padding: 4px 9px; letter-spacing: 0.08em; }
1519+.badge.type { background: var(--lime); color: #fff; }
1520+.badge.right { background: rgba(20, 24, 20, 0.55); }
1521+/* la liste latérale de la vue carte garde ses fiches synchronisées lisibles */
1522+.map-card { border-radius: var(--r-2); }
1523+.map-card-sel { outline: 2px solid var(--lime); outline-offset: 3px; border-radius: var(--r-2); }
1524+
1525+/* ---- Grille à rythme éditorial (accueil) ---- */
1526+.grid.grid-edito { grid-template-columns: repeat(3, 1fr); gap: 36px 26px; align-items: start; }
1527+@media (max-width: 980px) {
1528+ .grid.grid-edito { grid-template-columns: 1fr 1fr; gap: 30px 20px; }
1529+}
1530+@media (max-width: 640px) {
1531+ .grid.grid-edito { grid-template-columns: 1fr; gap: 32px; }
1532+}
1533+@media (prefers-reduced-motion: no-preference) {
1534+ .grid-edito > * { animation: rise 0.5s var(--ease-out) backwards; }
1535+ .grid-edito > *:nth-child(3n+2) { animation-delay: 60ms; }
1536+ .grid-edito > *:nth-child(3n) { animation-delay: 120ms; }
1537+}
1538+@keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
1539+
1540+/* ---- Squelettes sans boîte ---- */
1541+.skel { border: 0; border-radius: 0; background: transparent; }
1542+.skel .sk-img { aspect-ratio: 4/3; border-radius: var(--r-2); }
1543+.skel .sk-line { margin: 12px 2px; }
1544+
1545+/* ---- Pagination éditoriale : chiffres nus, page courante soulignée ---- */
1546+.pager { border-top: 1px solid var(--hairline); padding-top: 16px; margin-top: 26px; }
1547+.pager-info { font-family: var(--font-mono); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); }
1548+.pager-btn {
1549+ min-width: 34px; min-height: 38px; height: auto; padding: 0 10px; border: 0; border-radius: var(--r-1);
1550+ background: none; color: var(--ink-2); box-shadow: none;
1551+ font-family: var(--font-display); font-size: 13.5px; font-weight: 600; position: relative;
1552+}
1553+.pager-btn:hover:not(:disabled):not(.on) { background: var(--lime-soft); color: var(--accent-deep); transform: none; box-shadow: none; }
1554+.pager-btn.on { background: none; border: 0; color: var(--ink); font-weight: 700; box-shadow: none; }
1555+.pager-btn.on::after { content: ""; position: absolute; left: 9px; right: 9px; bottom: 3px; height: 2.5px; background: var(--lime); }
1556+.pager-btn:disabled { box-shadow: none; }
1557+
1558+/* ---- Mobile : recherche condensée + feuille de critères premium ---- */
1559+@media (max-width: 640px) {
1560+ .hero h1.hero-display { font-size: clamp(38px, 11vw, 52px); }
1561+ .results-bar { top: 64px; gap: 10px; flex-wrap: wrap; }
1562+ .rb-tools { gap: 14px; }
1563+ .search-zone { margin: 30px 0 0; }
1564+ .crit-line, .search-zone .f-adv, .search-zone .sheet-apply { display: none; }
1565+ .crit-summary {
1566+ display: flex; align-items: center; gap: 10px; width: 100%;
1567+ border: 0; border-bottom: 1px solid var(--hairline); background: none;
1568+ padding: 14px 2px; margin: 0; cursor: pointer; text-align: left;
1569+ font-family: var(--font-mono); font-size: 12px; color: var(--ink-2);
1570+ }
1571+ .crit-summary svg { flex: none; color: var(--ink); }
1572+ .crit-summary .cs-txt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1573+ .crit-summary .f-chev { margin: 0 2px 0 0; }
1574+
1575+ .search-zone.open {
1576+ display: flex; flex-direction: column; gap: 12px;
1577+ position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-modal, 900);
1578+ margin: 0; border-radius: var(--r-3) var(--r-3) 0 0; background: var(--surface);
1579+ border: 0;
1580+ max-height: 86dvh; overflow-y: auto; -webkit-overflow-scrolling: touch;
1581+ padding: 10px 18px calc(16px + env(safe-area-inset-bottom));
1582+ box-shadow: 0 -18px 50px rgba(20, 24, 20, 0.3);
1583+ animation: sheet-up 0.26s var(--ease-out);
1584+ }
1585+ .search-zone.open .sheet-handle {
1586+ display: block; flex: none; width: 40px; height: 4.5px;
1587+ border-radius: 999px; background: var(--line); margin: 0 auto;
1588+ }
1589+ .search-zone.open .sheet-head {
1590+ display: flex; justify-content: space-between; align-items: center;
1591+ font-family: var(--font-display); font-weight: 700; font-size: 17px;
1592+ letter-spacing: -0.01em; color: var(--ink); text-transform: none;
1593+ position: sticky; top: -10px; background: var(--surface); padding: 6px 0 8px;
1594+ border-bottom: 1px solid var(--hairline); z-index: 1;
1595+ }
1596+ .search-zone.open .crit-summary { display: none; }
1597+ .search-zone.open .q-big { padding: 2px 0 10px; }
1598+ .search-zone.open .q-big input { font-size: 17px; }
1599+ .search-zone.open .crit-line { display: flex; flex-direction: column; }
1600+ .search-zone.open .crit { border-right: 0; border-bottom: 1px solid var(--hairline); margin: 0; padding: 10px 0; }
1601+ .search-zone.open .crit select { max-width: none; width: 100%; font-size: 16px; }
1602+ .search-zone.open .crit .range-pair select { width: auto; flex: 1; }
1603+ .search-zone.open .crit-more { display: none; }
1604+ .search-zone.open .f-adv { display: grid; border-top: 0; padding-top: 4px; margin-top: 0; }
1605+ .search-zone.open .sheet-apply {
1606+ display: flex; position: sticky; bottom: 0; z-index: 1; width: 100%; min-height: 50px;
1607+ box-shadow: 0 -14px 18px -12px rgba(20, 24, 20, 0.35);
1608+ }
1609+}
1610+
1611+/* ---- Navigation mobile flottante (pilule détachée du bord) ---- */
1612+.tabbar { display: none; }
1613+@media (max-width: 760px) {
1614+ .tabbar {
1615+ display: grid; grid-template-columns: repeat(4, 1fr);
1616+ position: fixed; left: 12px; right: 12px;
1617+ bottom: calc(10px + env(safe-area-inset-bottom));
1618+ z-index: var(--z-bottombar, 600);
1619+ border: 1px solid var(--hairline-strong); border-radius: var(--r-3);
1620+ background: rgba(250, 249, 245, 0.88);
1621+ backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
1622+ box-shadow: 0 14px 34px rgba(20, 24, 20, 0.16);
1623+ padding: 6px;
1624+ }
1625+ .tabbar a {
1626+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1627+ gap: 2px; padding: 5px 2px; min-height: 48px; border-radius: 12px;
1628+ font-family: var(--font-display); font-size: 10px; font-weight: 600;
1629+ color: var(--ink-3); text-decoration: none; transition: color var(--dur-1) ease;
1630+ }
1631+ .tabbar a svg { display: block; }
1632+ .tabbar a.active { color: var(--ink); }
1633+ .tabbar a.active::after { content: ""; width: 16px; height: 2.5px; border-radius: 2px; background: var(--lime); }
1634+ body { padding-bottom: calc(80px + env(safe-area-inset-bottom)); }
1635+ .ka-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); }
1636+
1637+ /* widget KA Agent : dégagé de la tabbar (style injecté après le bundle →
1638+ spécificité doublée pour gagner) */
1639+ .kaa-btn.kaa-btn { bottom: calc(86px + env(safe-area-inset-bottom, 0px)); right: 14px; }
1640+ .kaa-panel.kaa-panel:not(.kaa-full) { bottom: calc(152px + env(safe-area-inset-bottom, 0px)); }
1641+}
1642+
1643+/* ---- Header : jamais de débordement à droite ----
1644+ La nav (8 entrées) + connexion dépassaient le conteneur même à 1440px
1645+ (masqué par overflow-x: clip). Resserrage, badge Groupe KA masqué quand
1646+ l'espace manque, burger sous 1200px. ---- */
1647+.nav a { white-space: nowrap; padding: 8px 11px; font-size: 13px; }
1648+@media (max-width: 1400px) { .header .gk-badge { display: none; } }
1649+@media (max-width: 1200px) {
1650+ .nav { display: none; }
1651+ .menu-btn { display: flex; }
1652+}
1653+
1654+/* header pleine largeur : la nav dense s'aligne aux bords de l'écran au lieu
1655+ de déborder du conteneur 1240px sur les grands écrans */
1656+.header .header-inner { max-width: none; }
1657+
1658+/* ---- Fiche propriété : DA v2 — sections en filets, icônes contextuelles ---- */
1659+.f-bloc h2 { border-left: 0; padding-left: 0; display: flex; align-items: center; gap: 10px; }
1660+.f-bloc h2::before { content: ""; width: 20px; height: 3px; border-radius: 2px; background: var(--lime); flex: none; }
1661+/* le panneau héros perd sa boîte : la typographie structure */
1662+.f-hero { background: transparent; border: 0; box-shadow: none; padding: 0; }
1663+.f-hero .price { font-size: clamp(36px, 7vw, 54px); letter-spacing: -0.04em; }
1664+/* inclusions : grille d'items en filets — chaque inclusion a SON icône */
1665+.amenity-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 0 28px; margin-bottom: 8px; }
1666+.amenity-it {
1667+ display: flex; align-items: center; gap: 11px; padding: 7px 0;
1668+ border-bottom: 1px solid var(--hairline); font-size: 13.5px; color: var(--ink); min-height: 46px;
1669+}
1670+.am-ico {
1671+ display: inline-grid; place-items: center; width: 32px; height: 32px;
1672+ border-radius: 10px; background: var(--lime-soft); color: var(--green-deep); flex: none;
1673+}
1674+.am-txt { min-width: 0; }
1675+/* caractéristiques : clés en micro-mono */
1676+.dtable { gap: 0 28px; }
1677+.drow { padding: 9px 0; }
1678+.drow span { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); align-self: center; }
1679+/* galerie : cadre allégé */
1680+.carousel { border: 1px solid var(--hairline-strong); box-shadow: none; border-radius: var(--r-2); }
1681+.thumbs button { border-radius: 8px; }
1682+/* CTA sticky : sur la fiche, il prend le bas d'écran — la tabbar s'efface */
1683+body:has(.cta-sticky) .tabbar { display: none; }
1684+.cta-sticky { z-index: var(--z-dropdown, 700); border-top: 1px solid var(--hairline-strong); background: rgba(250, 249, 245, 0.92); }
1685+@media (max-width: 760px) {
1686+ body:has(.cta-sticky) { padding-bottom: 0; }
1687+}
1688+
1689+/* colonnes de la fiche : les sections respirent sans boîte — les modules
1690+ pleine largeur (.detail > .f-bloc : financement, quartier…) restent en cartes */
1691+.fiche > .f-col > .f-bloc:not(.f-galerie) {
1692+ background: transparent; border: 0; border-radius: 0; padding: 0; box-shadow: none;
1693+}
1694+@media (max-width: 640px) {
1695+ .fiche > .f-col > .f-bloc:not(.f-galerie) { padding: 0; }
1696+}
1697+/* tiret accent des titres : sans bordure ni ombre héritées de la couche fiche */
1698+.f-bloc h2::before, .quartier h2::before { border: 0; box-shadow: none; }
1699+
1700+/* =============================================================================
1701+ KA MAP SYSTEM v2 — MODE CARTE IMMO-KA (shell plein viewport)
1702+ Jetons du design system appliqués au shell ; le sélecteur .ka-map du
1703+ framework pose ses défauts directement sur le canevas — re-déclarer les
1704+ jetons dessus, sinon l'accent retombe au noir générique dans la carte.
1705+============================================================================= */
1706+.ka-shell,
1707+.ka-shell .ka-map {
1708+ --ka-accent: var(--accent);
1709+ --ka-on-accent: var(--on-accent, #fff);
1710+ --ka-surface: var(--surface);
1711+ --ka-ink: var(--ink);
1712+ --ka-line: var(--line-strong);
1713+ --ka-radius: var(--r-ctl);
1714+ --ka-shadow: 0 8px 24px rgba(26, 18, 20, 0.14);
1715+ --ka-font: var(--font-body);
1716+}
1717+.ka-shell-fallback {
1718+ position: fixed; inset: 0; z-index: 640;
1719+ display: grid; place-items: center;
1720+ background: var(--surface); color: var(--ink-3);
1721+ font-family: var(--font-mono, inherit); font-size: 13px;
1722+}
1723+.ms2-brand { display: inline-flex; align-items: baseline; gap: 7px; font-family: var(--font-display); min-width: 0; }
1724+.ms2-brand b { font-size: 15px; letter-spacing: -0.01em; }
1725+.ms2-brand span {
1726+ font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.14em;
1727+ color: var(--ink-3); font-weight: 600;
1728+}
1729+.ka-top-btn {
1730+ display: inline-flex; align-items: center; gap: 6px;
1731+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1732+ background: var(--surface); color: var(--ink);
1733+ font-family: var(--font-display); font-weight: 600; font-size: 12.5px;
1734+ padding: 8px 13px; cursor: pointer; min-height: 38px; white-space: nowrap;
1735+}
1736+.ka-top-btn:hover { background: var(--ink); color: #fff; }
1737+.ka-top-badge {
1738+ min-width: 17px; height: 17px; border-radius: 999px;
1739+ background: var(--accent); color: var(--on-accent, #fff);
1740+ font-size: 10.5px; font-weight: 700; line-height: 17px;
1741+ text-align: center; padding: 0 4px;
1742+}
1743+.ms2-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; min-width: 0; }
1744+.ms2-count { font-size: 13px; color: var(--ink-2); min-width: 0; }
1745+.ms2-count b { color: var(--ink); font-size: 15px; }
1746+.ms2-zone {
1747+ margin-left: 8px; font-size: 11px; color: var(--accent-deep, var(--accent));
1748+ border: 1px dashed var(--accent); border-radius: 999px; padding: 2px 8px;
1749+}
1750+.ms2-sort select {
1751+ border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
1752+ background: var(--surface); padding: 6px 8px; font-size: 12.5px;
1753+ font-weight: 600; color: var(--ink); max-width: 168px;
1754+}
1755+.ms2-list { display: flex; flex-direction: column; gap: 14px; position: relative; }
1756+.ms2-list .map-card { flex: 0 0 auto; }
1757+.ka-sheet .ms2-list { gap: 12px; padding-bottom: 10px; }
1758+.ms2-pager { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 6px 0 4px; }
1759+
1760+/* mode carte : le widget Ka Agent s'efface (le sheet occupe son coin) */
1761+body.ka-map-mode .kaa-btn, body.ka-map-mode .kaa-hello { display: none !important; }
1762+
1763+/* filtres de la page AU-DESSUS du mode carte — desktop : modale centrée */
1764+@media (min-width: 781px) {
1765+ body.ka-map-mode .sheet-backdrop {
1766+ display: block; position: fixed; inset: 0;
1767+ z-index: var(--z-overlay, 800); background: rgba(26, 18, 20, 0.45);
1768+ }
1769+ body.ka-map-mode .search-zone { display: none; }
1770+ body.ka-map-mode .search-zone.open {
1771+ display: block; position: fixed; top: 7dvh; left: 50%;
1772+ transform: translateX(-50%); width: min(760px, 94vw);
1773+ max-height: 84dvh; overflow-y: auto; z-index: var(--z-modal, 900);
1774+ background: var(--surface); border: 1.5px solid var(--ink);
1775+ border-radius: var(--r-card); box-shadow: var(--shadow-off);
1776+ padding: 16px 22px 20px; margin: 0;
1777+ }
1778+ body.ka-map-mode .search-zone.open .sheet-head {
1779+ display: flex; align-items: center; justify-content: space-between;
1780+ font-weight: 700; font-size: 14px; margin-bottom: 10px;
1781+ }
1782+ body.ka-map-mode .search-zone.open .sheet-close {
1783+ display: grid; place-items: center; width: 34px; height: 34px;
1784+ border: 1.5px solid var(--ink); border-radius: 50%;
1785+ background: var(--surface); cursor: pointer; font-size: 14px;
1786+ }
1787+}
1788+
1789+/* --- KA ID v2 : badge « Recommandé pour vous » (parcimonieux, serveur) --- */
1790+.badge.ka-reco { top: auto; bottom: 12px; background: var(--ink); color: var(--accent); }
1791+
1792+/* ================= House-Ka footer (replaces the shared KaFooter) ========== */
1793+.hk-footer {
1794+ margin-top: 72px; background: var(--ink); color: rgba(250, 247, 240, 0.78);
1795+ padding: 52px 0 40px; position: relative; overflow: hidden;
1796+}
1797+.hk-footer::before {
1798+ content: ""; position: absolute; inset: 0; pointer-events: none;
1799+ background:
1800+ radial-gradient(circle at 78% 18%, rgba(15, 107, 79, 0.35), transparent 52%),
1801+ radial-gradient(circle at 8% 90%, rgba(15, 107, 79, 0.18), transparent 45%);
1802+}
1803+.hk-footer .container { position: relative; }
1804+.hk-foot-brand {
1805+ font-family: var(--font-display); font-weight: 700; font-size: 30px;
1806+ letter-spacing: -0.02em; color: var(--paper); display: inline-flex; align-items: center;
1807+}
1808+.hk-foot-brand .ka {
1809+ background: var(--accent); color: #fff; padding: 2px 8px 4px;
1810+ border-radius: 7px; margin-left: 4px; transform: rotate(-2deg);
1811+}
1812+.hk-foot-desc { max-width: 640px; margin: 16px 0 10px; line-height: 1.65; font-size: 14.5px; }
1813+.hk-foot-desc b { color: var(--paper); }
1814+.hk-foot-notice { max-width: 640px; margin: 0 0 24px; font-size: 12.5px; line-height: 1.6; color: rgba(250, 247, 240, 0.55); }
1815+.hk-foot-sites { list-style: none; margin: 0 0 26px; padding: 0; display: flex; flex-wrap: wrap; gap: 10px 26px; }
1816+.hk-foot-sites li { display: flex; align-items: baseline; gap: 8px; font-size: 13px; }
1817+.hk-foot-sites a { color: var(--paper); font-family: var(--font-display); font-weight: 600; font-size: 15px; }
1818+.hk-foot-sites a:hover { color: var(--accent-soft); }
1819+.hk-foot-sites span { color: rgba(250, 247, 240, 0.5); }
1820+.hk-foot-legal { display: flex; flex-wrap: wrap; gap: 8px 22px; padding-top: 18px; border-top: 1px solid rgba(250, 247, 240, 0.14); font-size: 12.5px; }
1821+.hk-foot-legal a { color: rgba(250, 247, 240, 0.72); }
1822+.hk-foot-legal a:hover { color: var(--paper); }
1823+.hk-foot-legal span { margin-left: auto; color: rgba(250, 247, 240, 0.45); }
1824+@media (max-width: 780px) {
1825+ .hk-footer { padding-bottom: calc(56px + env(safe-area-inset-bottom)); }
1826+}
1827+.legal .legal-date { color: var(--ink-3); font-family: var(--font-mono); font-size: 12px; margin: 0 0 26px; }
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
added frontend/tsconfig.json +21 −0
@@ -0,0 +1,21 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2020",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+ "moduleResolution": "bundler",
9+ "allowImportingTsExtensions": true,
10+ "resolveJsonModule": true,
11+ "isolatedModules": true,
12+ "noEmit": true,
13+ "jsx": "react-jsx",
14+ "strict": true,
15+ "noUnusedLocals": false,
16+ "noUnusedParameters": false,
17+ "noFallthroughCasesInSwitch": true
18+ },
19+ "include": ["src"],
20+ "exclude": ["src/kamaps", "src/pages/Category.tsx"]
21+}
added frontend/vite.config.ts +15 −0
@@ -0,0 +1,15 @@
1+// -----------------------------------------------------------------------------
2+// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// vite.config.ts : configuration Vite (proxy API en dev, build vers dist/)
5+// -----------------------------------------------------------------------------
6+import { defineConfig } from "vite";
7+import react from "@vitejs/plugin-react";
8+
9+export default defineConfig({
10+ plugins: [react()],
11+ server: {
12+ proxy: { "/api": "http://localhost:8090" },
13+ },
14+ build: { outDir: "dist", chunkSizeWarningLimit: 1200 },
15+});
added immoka/__init__.py +9 −0
@@ -0,0 +1,9 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# -----------------------------------------------------------------------------
5+"""Immo-Ka : un connecteur par agence de courtage immobilier, un schéma unique,
6+un diff engine — toutes les propriétés à vendre du Québec, à jour, à un seul
7+endroit. Architecture jumelle de Lou-Ka (location)."""
8+
9+__version__ = "0.1.0"
added immoka/auth.py +306 −0
@@ -0,0 +1,306 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Une application du Groupe-Ka — contact@groupe-ka.com
4+# auth.py : connexion Google (OAuth 2.0 / OpenID Connect) + sessions signées
5+#
6+# Identité PARTAGÉE Groupe-Ka : Lou-Ka (location) et Immo-Ka (vente) utilisent
7+# le MÊME client OAuth Google (GOOGLE_CLIENT_ID) — chaque app enregistre son
8+# URL de rappel dans la console Google. L'utilisateur est identifié par le
9+# `sub` Google (stable pour un compte, identique dans les deux apps) : le
10+# profil (courriel, nom, photo) est donc le même des deux côtés, et Google
11+# saute l'écran de consentement à la 2e app (SSO de fait).
12+#
13+# URLs de rappel à autoriser dans Google Cloud Console (Identifiants →
14+# ID client OAuth « Groupe-Ka ») :
15+# https://www.immo-ka.com/api/auth/google/callback
16+# https://www.lou-ka.com/api/auth/google/callback
17+# http://localhost:8090/api/auth/google/callback (développement)
18+#
19+# Session : JWT HS256 maison (hmac/base64, aucune dépendance) signé avec
20+# AUTH_SECRET (PARTAGER le même secret entre les deux apps si on veut que les
21+# jetons soient mutuellement vérifiables), cookie httponly 30 jours.
22+# -----------------------------------------------------------------------------
23+from __future__ import annotations
24+
25+import base64
26+import hashlib
27+import hmac
28+import json
29+import os
30+import time
31+import urllib.parse
32+
33+import requests
34+from fastapi import APIRouter, Request
35+from fastapi.responses import JSONResponse, RedirectResponse
36+
37+from . import db
38+from .hubprofile import fetch_hub_profile, to_epoch
39+
40+router = APIRouter(prefix="/api/auth")
41+
42+GOOGLE_AUTH = "https://accounts.google.com/o/oauth2/v2/auth"
43+GOOGLE_TOKEN = "https://oauth2.googleapis.com/token"
44+GOOGLE_USERINFO = "https://openidconnect.googleapis.com/v1/userinfo"
45+
46+CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
47+CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
48+# base publique de CETTE app (l'URL de rappel en découle)
49+BASE_URL = os.environ.get("IMMOKA_BASE_URL", "http://localhost:8090").rstrip("/")
50+REDIRECT_URI = f"{BASE_URL}/api/auth/google/callback"
51+SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256(
52+ (CLIENT_SECRET or "immoka-dev").encode()).hexdigest()
53+COOKIE = "groupeka_session"
54+SESSION_DAYS = 30
55+
56+
57+# -- JWT HS256 minimal (aucune dépendance) -----------------------------------
58+def _b64(d: bytes) -> str:
59+ return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
60+
61+
62+def _unb64(s: str) -> bytes:
63+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
64+
65+
66+def jwt_encode(payload: dict) -> str:
67+ head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
68+ body = _b64(json.dumps(payload, separators=(",", ":")).encode())
69+ sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
70+ return f"{head}.{body}.{sig}"
71+
72+
73+def jwt_decode(token: str) -> dict | None:
74+ try:
75+ head, body, sig = token.split(".")
76+ good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
77+ if not hmac.compare_digest(sig, good):
78+ return None
79+ payload = json.loads(_unb64(body))
80+ if payload.get("exp", 0) < time.time():
81+ return None
82+ return payload
83+ except Exception:
84+ return None
85+
86+
87+# -- table users --------------------------------------------------------------
88+def _ensure_table(con) -> None:
89+ con.execute("""CREATE TABLE IF NOT EXISTS users (
90+ sub TEXT PRIMARY KEY, -- identifiant Google (stable, partagé Lou-Ka/Immo-Ka)
91+ email TEXT,
92+ name TEXT,
93+ picture TEXT,
94+ created REAL,
95+ last_login REAL
96+ )""")
97+
98+
99+def _upsert_user(info: dict) -> dict:
100+ con = db.connect()
101+ try:
102+ _ensure_table(con)
103+ now = time.time()
104+ con.execute(
105+ "INSERT INTO users (sub, email, name, picture, created, last_login)"
106+ " VALUES (?,?,?,?,?,?)"
107+ " ON CONFLICT(sub) DO UPDATE SET email=excluded.email,"
108+ " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login",
109+ (info["sub"], info.get("email", ""), info.get("name", ""),
110+ info.get("picture", ""), now, now))
111+ con.commit()
112+ finally:
113+ con.close()
114+ return {"sub": info["sub"], "email": info.get("email", ""),
115+ "name": info.get("name", ""), "picture": info.get("picture", "")}
116+
117+
118+def current_user(request: Request) -> dict | None:
119+ payload = jwt_decode(request.cookies.get(COOKIE, ""))
120+ return payload.get("user") if payload else None
121+
122+
123+# -- routes --------------------------------------------------------------------
124+@router.get("/google/login")
125+def google_login(next: str = "/"):
126+ """Redirige vers l'écran de connexion Google (OpenID Connect)."""
127+ if not CLIENT_ID:
128+ return JSONResponse({"error": "GOOGLE_CLIENT_ID manquant (voir .env)"}, status_code=503)
129+ state = jwt_encode({"next": next[:200], "exp": time.time() + 600})
130+ params = {
131+ "client_id": CLIENT_ID,
132+ "redirect_uri": REDIRECT_URI,
133+ "response_type": "code",
134+ "scope": "openid email profile",
135+ "state": state,
136+ "access_type": "online",
137+ "prompt": "select_account",
138+ }
139+ return RedirectResponse(f"{GOOGLE_AUTH}?{urllib.parse.urlencode(params)}")
140+
141+
142+@router.get("/google/callback")
143+def google_callback(code: str = "", state: str = "", error: str = ""):
144+ """Échange le code contre le profil Google, crée la session (cookie signé)."""
145+ st = jwt_decode(state) or {}
146+ dest = st.get("next") or "/"
147+ if error or not code or not st:
148+ return RedirectResponse(f"/?auth=echec")
149+ try:
150+ tok = requests.post(GOOGLE_TOKEN, data={
151+ "code": code, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET,
152+ "redirect_uri": REDIRECT_URI, "grant_type": "authorization_code",
153+ }, timeout=15).json()
154+ info = requests.get(GOOGLE_USERINFO, headers={
155+ "Authorization": f"Bearer {tok['access_token']}"}, timeout=15).json()
156+ if not info.get("sub"):
157+ raise ValueError("profil Google sans sub")
158+ except Exception:
159+ return RedirectResponse("/?auth=echec")
160+ user = _upsert_user(info)
161+ session = jwt_encode({"user": user, "iss": "groupe-ka",
162+ "exp": time.time() + SESSION_DAYS * 86400})
163+ resp = RedirectResponse(dest)
164+ resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,
165+ samesite="lax", secure=BASE_URL.startswith("https"), path="/")
166+ return resp
167+
168+
169+@router.get("/me")
170+def me(request: Request):
171+ """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si
172+ la connexion (Google ou KA ID) est configurée (le frontend masque le bouton
173+ sinon). Quand le compte est relié au hub Groupe KA, le profil du hub
174+ (bio, ville, emploi, entreprise, âge, site, réseaux, statut) ENRICHIT la
175+ réponse — le hub est la source de vérité, l'édition se fait sur
176+ groupe-ka.com/compte."""
177+ user = current_user(request)
178+ if user is None:
179+ return {"user": None, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)}
180+
181+ out = dict(user)
182+ # ancienneté locale (table users) — « Membre depuis »
183+ con = db.connect()
184+ try:
185+ _ensure_table(con)
186+ row = con.execute(
187+ "SELECT created, last_login FROM users WHERE sub=?",
188+ (user.get("sub", ""),)).fetchone()
189+ finally:
190+ con.close()
191+ if row is not None:
192+ out["created_at"] = row[0]
193+ out["last_login"] = row[1]
194+ ka_id = user.get("ka_id") or ""
195+ out["ka_id"] = ka_id
196+ out["provider"] = "ka-id" if ka_id else "google"
197+ out["profile_source"] = "local"
198+
199+ # Le HUB Groupe KA est la source de vérité du profil : s'il connaît ce
200+ # KA ID, ses champs REMPLACENT les champs locaux. Hub injoignable ou 404
201+ # (vieux compte non relié) -> réponse locale inchangée.
202+ hub = fetch_hub_profile(ka_id) if ka_id else None
203+ if hub is not None:
204+ hub_name = (hub.get("name") or "").strip()
205+ out.update({
206+ "name": hub_name or out.get("name", ""),
207+ "bio": hub.get("bio") or "",
208+ "city": hub.get("city") or "",
209+ "phone": hub.get("phone") or "",
210+ "website": hub.get("website") or "",
211+ "socials": hub.get("socials") or {},
212+ "public": bool(hub.get("public")),
213+ "role_label": hub.get("role_label") or "",
214+ "job_title": hub.get("job_title") or "",
215+ "company": hub.get("company") or "",
216+ "age": hub.get("age"),
217+ "public_url": hub.get("public_url") or "",
218+ "created_at": to_epoch(hub.get("created_at"))
219+ or out.get("created_at"),
220+ "profile_source": "groupe-ka",
221+ })
222+ if hub.get("picture"):
223+ out["picture"] = hub["picture"]
224+ return {"user": out, "enabled": bool(CLIENT_ID or KA_SSO_SECRET)}
225+
226+
227+@router.post("/logout")
228+def logout():
229+ resp = JSONResponse({"ok": True})
230+ resp.delete_cookie(COOKIE, path="/")
231+ return resp
232+
233+
234+# -- KA ID (hub d'identité du groupe — groupe-ka.com) --------------------------
235+# « Se connecter avec KA ID » : Immo-Ka délègue la connexion au hub
236+# (qui offre Google OU courriel/mot de passe). Retour avec un JWT HS256
237+# signé du secret partagé KA_SSO_SECRET ; le profil (ka_id, courriel, nom,
238+# photo) est LE MÊME sur toutes les plateformes du groupe.
239+# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel), AUTH_SECRET.
240+
241+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
242+KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")
243+
244+
245+def _ka_verify(token: str) -> dict | None:
246+ """Vérifie un JWT HS256 émis par le hub KA (stdlib seulement)."""
247+ if not KA_SSO_SECRET:
248+ return None
249+ try:
250+ head, body, sig = token.split(".")
251+ good = _b64(hmac.new(KA_SSO_SECRET.encode(),
252+ f"{head}.{body}".encode(), hashlib.sha256).digest())
253+ if not hmac.compare_digest(sig, good):
254+ return None
255+ if json.loads(_unb64(head)).get("alg") != "HS256":
256+ return None
257+ claims = json.loads(_unb64(body))
258+ if claims.get("iss") != KA_HUB_URL:
259+ return None
260+ if claims.get("aud") != "immo-ka":
261+ return None
262+ if claims.get("exp", 0) < time.time():
263+ return None
264+ return claims
265+ except Exception:
266+ return None
267+
268+
269+@router.get("/ka/login")
270+def ka_login(next: str = "/"):
271+ """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""
272+ if not KA_SSO_SECRET:
273+ return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},
274+ status_code=503)
275+ state = jwt_encode({"next": next[:200], "exp": time.time() + 600})
276+ params = {
277+ "client_id": "immo-ka",
278+ "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",
279+ "state": state,
280+ }
281+ return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")
282+
283+
284+@router.get("/ka/callback")
285+def ka_callback(ka_token: str = "", state: str = ""):
286+ """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""
287+ st = jwt_decode(state) or {}
288+ dest = st.get("next") or "/"
289+ claims = _ka_verify(ka_token) if ka_token else None
290+ if not st or claims is None:
291+ return RedirectResponse("/?auth=echec")
292+ # clé stable = KA-ID du groupe (créé par le hub, identique partout)
293+ ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")
294+ user = _upsert_user({
295+ "sub": ka_id,
296+ "email": claims.get("email", ""),
297+ "name": claims.get("name", ""),
298+ "picture": claims.get("picture") or "",
299+ })
300+ user["ka_id"] = ka_id
301+ session = jwt_encode({"user": user, "iss": "groupe-ka",
302+ "exp": time.time() + SESSION_DAYS * 86400})
303+ resp = RedirectResponse(dest)
304+ resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,
305+ samesite="lax", secure=BASE_URL.startswith("https"), path="/")
306+ return resp
added immoka/commerces.py +294 −0
@@ -0,0 +1,294 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (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 = "HouseKaBot/1.0 (+https://www.house-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"),
159+ ("rem_station", "Station REM"),
160+ ("arret_bus", "Arrêt de bus"),
161+ ("gare_train", "Gare de train")]
162+
163+_DB_GENRE = {"metro": "metro_station", "rem": "rem_station",
164+ "bus": "arret_bus", "train": "gare_train"}
165+
166+
167+def _transit_from_db(lat: float, lng: float) -> list[tuple[str, dict]]:
168+ """Arrêts/stations depuis data/transit.db (extrait OpenStreetMap
169+ pré-calculé : ~37 000 arrêts de bus, métro, REM, gares du Québec)."""
170+ db = ROOT / "data" / "transit.db"
171+ if not db.exists():
172+ return []
173+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
174+ con.row_factory = sqlite3.Row
175+ out = []
176+ for genre, rayon in (("metro", 6000), ("rem", 6000), ("bus", 1500),
177+ ("train", 8000)):
178+ d = rayon / 111320.0
179+ rows = con.execute(
180+ "SELECT nom, lat, lng FROM arrets WHERE genre=? AND lat BETWEEN "
181+ "? AND ? AND lng BETWEEN ? AND ?",
182+ (genre, lat - d, lat + d, lng - d, lng + d)).fetchall()
183+ best = None
184+ for r in rows:
185+ dd = _dist_m(lat, lng, r["lat"], r["lng"])
186+ if dd <= rayon and (best is None or dd < best[0]):
187+ best = (dd, r)
188+ if best:
189+ out.append((_DB_GENRE[genre],
190+ {"nom": best[1]["nom"] or "", "adresse": "",
191+ "lat": best[1]["lat"], "lng": best[1]["lng"]}))
192+ con.close()
193+ return out
194+
195+_POI_CAT = {"metro": "metro_station", "bus": "arret_bus"}
196+
197+
198+def _transit_from_poi(lat: float, lng: float) -> list[tuple[str, dict]]:
199+ """Métro/bus depuis le cache POI du projet (louka.db, déjà calculé par
200+ immeuble) — la fiche interroge avec les mêmes coordonnées que poi.py."""
201+ db_main = ROOT / "data" / next(
202+ (n for n in ("louka.db", "immoka.db", "immo.db")
203+ if (ROOT / "data" / n).exists()), "louka.db")
204+ if not db_main.exists():
205+ return []
206+ try:
207+ con = sqlite3.connect(f"file:{db_main}?mode=ro", uri=True)
208+ con.row_factory = sqlite3.Row
209+ d = 300 / 111320.0
210+ row = con.execute(
211+ "SELECT pois, lat, lng FROM poi_cache WHERE lat BETWEEN ? AND ? "
212+ "AND lng BETWEEN ? AND ? ORDER BY (lat-?)*(lat-?)+(lng-?)*(lng-?) "
213+ "LIMIT 1", (lat - d, lat + d, lng - d, lng + d,
214+ lat, lat, lng, lng)).fetchone()
215+ con.close()
216+ except sqlite3.Error:
217+ return []
218+ if row is None:
219+ return []
220+ out = []
221+ for e in json.loads(row["pois"] or "[]"):
222+ k = _POI_CAT.get(e.get("cat"))
223+ if k:
224+ out.append((k, {"nom": e.get("name") or "", "adresse": "",
225+ "lat": lat, "lng": lng,
226+ "_dist": e.get("dist_m")}))
227+ return out
228+
229+
230+def nearby(lat: float, lng: float) -> dict:
231+ """Grand commerce le plus proche par bannière (cache ~1 km, TTL 30 j)."""
232+ cell = f"{round(lat, 2)},{round(lng, 2)}"
233+ con = _connect()
234+ now = time.time()
235+ cached = {r["brand"]: r for r in con.execute(
236+ "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
237+ (cell, now - TTL))}
238+ manquants = [(bid, q, m) for bid, _, q, m in BRANDS
239+ if bid not in cached]
240+ transit_manquant = any(k not in cached for k, _ in TRANSIT)
241+ if manquants or transit_manquant:
242+ res: list[tuple[str, dict | None]] = []
243+ if manquants:
244+ with ThreadPoolExecutor(max_workers=6) as ex:
245+ res = list(ex.map(
246+ lambda b: (b[0], _fetch_brand(b[1], lat, lng, b[2])),
247+ manquants))
248+ if transit_manquant:
249+ tr = (_transit_from_db(lat, lng)
250+ or _transit_from_poi(lat, lng) or _fetch_transit(lat, lng))
251+ res.extend(tr)
252+ with con:
253+ for bid, hit in res:
254+ if hit is None:
255+ continue
256+ con.execute(
257+ "INSERT OR REPLACE INTO commerces_cache VALUES "
258+ "(?,?,?,?,?,?,?)",
259+ (cell, bid, hit["nom"],
260+ hit.get("adresse") or (str(hit["_dist"])
261+ if hit.get("_dist") is not None
262+ else ""),
263+ hit["lat"], hit["lng"], now))
264+ cached = {r["brand"]: r for r in con.execute(
265+ "SELECT * FROM commerces_cache WHERE cellule=? AND fetched_at>?",
266+ (cell, now - TTL))}
267+ con.close()
268+
269+ items = []
270+ transit = []
271+ for bid, label in TRANSIT:
272+ r = cached.get(bid)
273+ if r is not None:
274+ # distance : celle du cache POI si disponible (adresse numérique)
275+ d = (float(r["adresse"]) if (r["adresse"] or "").replace(
276+ ".", "").isdigit() else _dist_m(lat, lng, r["lat"], r["lng"]))
277+ if d <= 5000:
278+ transit.append({"id": bid, "commerce": label,
279+ "nom": r["nom"], "adresse": "",
280+ "dist_m": round(d),
281+ "lat": r["lat"], "lng": r["lng"]})
282+ for bid, label, _q, _m in BRANDS:
283+ r = cached.get(bid)
284+ if r is None:
285+ continue
286+ d = _dist_m(lat, lng, r["lat"], r["lng"])
287+ if d > 40000: # au-delà de 40 km : non pertinent
288+ continue
289+ items.append({"id": bid, "commerce": label, "nom": r["nom"],
290+ "adresse": r["adresse"], "dist_m": round(d),
291+ "lat": r["lat"], "lng": r["lng"]})
292+ items.sort(key=lambda x: x["dist_m"])
293+ transit.sort(key=lambda x: x["dist_m"])
294+ return {"n": len(items), "commerces": items, "transit": transit}
added immoka/connectors/__init__.py +30 −0
@@ -0,0 +1,30 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/__init__.py : registre AUTO-DÉCOUVRANT des connecteurs
5+# Tout module de ce paquet contenant une sous-classe de BaseConnector avec un
6+# source_id non vide est enregistré automatiquement — aucun fichier partagé
7+# à modifier pour ajouter un connecteur (1 connecteur = 1 agence).
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import importlib
12+import pkgutil
13+import sys
14+
15+from .base import BaseConnector
16+
17+CONNECTORS: dict[str, type[BaseConnector]] = {}
18+
19+for _mod in pkgutil.iter_modules(__path__):
20+ if _mod.name in ("base", "__init__"):
21+ continue
22+ try:
23+ module = importlib.import_module(f"{__name__}.{_mod.name}")
24+ except Exception as exc: # un connecteur cassé ne bloque pas les autres
25+ print(f"[immo-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr)
26+ continue
27+ for obj in vars(module).values():
28+ if (isinstance(obj, type) and issubclass(obj, BaseConnector)
29+ and obj is not BaseConnector and getattr(obj, "source_id", "")):
30+ CONNECTORS[obj.source_id] = obj
added immoka/connectors/_detailutil.py +219 −0
@@ -0,0 +1,219 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/_detailutil.py : utilitaires partagés d'enrichissement « page détail »
5+# Mutualise ce que chaque connecteur d'agence répète pour capter TOUTES les
6+# infos de la fiche source (comme remax_quebec.py) : description JSON-LD,
7+# coordonnées, aplatissement HTML, application au PropertyListing.
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import html as _html
12+import json
13+import re
14+
15+from ..schema import PropertyListing
16+
17+_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)
18+_COORD_RE = re.compile(r'(?:google\.[^"\']*?[?&](?:q|query|ll|center)=|maps/@)'
19+ r'(-?\d{1,2}\.\d+)[ ,%+A-Za-z]+?(-?\d{2,3}\.\d+)')
20+_LD_PROP_TYPES = {"RealEstateListing", "Residence", "SingleFamilyResidence",
21+ "House", "Apartment", "Product", "Offer", "Place", "Accommodation"}
22+
23+
24+def ld_nodes(html: str):
25+ """Itère les objets JSON-LD (aplatis depuis @graph)."""
26+ for block in _LD_RE.findall(html):
27+ try:
28+ # strict=False : tolère les \n/\t bruts dans les chaînes (Yoamo…)
29+ data = json.loads(block, strict=False)
30+ except ValueError:
31+ continue
32+ graph = data.get("@graph", [data]) if isinstance(data, dict) else data
33+ for node in (graph if isinstance(graph, list) else [graph]):
34+ if isinstance(node, dict):
35+ yield node
36+
37+
38+def ld_description(html: str) -> str:
39+ """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""
40+ best = ""
41+ for n in ld_nodes(html):
42+ t = n.get("@type")
43+ types = t if isinstance(t, list) else [t]
44+ if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):
45+ d = _html.unescape(str(n["description"])).strip()
46+ if len(d) > len(best):
47+ best = d
48+ return best
49+
50+
51+def gmaps_coords(html: str) -> tuple[float, float] | None:
52+ m = _COORD_RE.search(html)
53+ if not m:
54+ return None
55+ try:
56+ lat, lng = float(m.group(1)), float(m.group(2))
57+ except ValueError:
58+ return None
59+ if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
60+ return lat, lng
61+ return None
62+
63+
64+def flatten(html: str) -> str:
65+ """HTML -> texte « valeur | libellé » pour extraire les tableaux Centris."""
66+ t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))
67+ t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)
68+ return re.sub(r"[ \t]+", " ", t)
69+
70+
71+# libellés Centris standard cherchés dans « valeur | libellé » OU « libellé | valeur »
72+_LABELS = [
73+ "Type de propriété", "Genre de propriété", "Style de bâtiment",
74+ "Année de construction", "Superficie habitable", "Superficie du terrain",
75+ "Superficie du bâtiment (au sol)", "Nombre de pièces", "Nombre d'unités",
76+ "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage",
77+ "Énergie pour le chauffage", "Type de fenestration", "Fenêtres", "Toiture",
78+ "Revêtement", "Sous-sol", "Piscine", "Zonage", "Système d'égouts",
79+ "Approvisionnement en eau", "Déménagement", "Taxes municipales",
80+ "Taxes scolaires", "Évaluation municipale (terrain)",
81+ "Évaluation municipale (bâtiment)", "Cuisine",
82+]
83+
84+
85+# en-têtes de section des fiches (jamais des valeurs valides)
86+_SECTION_HEADERS = {
87+ "bâtiment et intérieur", "particularités du bâtiment",
88+ "particularités du terrain", "particularités du site", "caractéristiques",
89+ "caractéristiques de la propriété", "détails financiers", "taxes et coûts",
90+ "taxes\xa0et\xa0coûts", "détails des pièces", "détails des rénovations",
91+ "inclusions et exclusions", "dimensions", "addenda", "frais mensuels",
92+ "évaluations", "équipement disponible", "dans les environs",
93+ "niveau", "revêtement", "détails", "taxes", "total", "pièce", "couvre-sol",
94+}
95+_LABELS_LOWER = {lb.lower() for lb in _LABELS}
96+
97+
98+def _plausible(label: str, val: str) -> bool:
99+ """Garde-fou : rejette les valeurs qui sont d'autres libellés/en-têtes de
100+ section (l'aplatissement « | » rend les deux ordres ambigus) et exige un
101+ format minimal pour les champs monétaires/numériques."""
102+ low = val.lower().strip(" :")
103+ if not val or len(val) > 55 or low == label.lower():
104+ return False
105+ if low in _LABELS_LOWER or low in _SECTION_HEADERS:
106+ return False
107+ if label.startswith(("Taxes", "Évaluation")) and "$" not in val:
108+ return False
109+ if label == "Année de construction" and not re.search(r"\b(1[6-9]|20)\d{2}\b", val):
110+ return False
111+ if label.startswith("Superficie") and not re.search(r"\d", val):
112+ return False
113+ return True
114+
115+
116+def centris_details(text: str) -> dict:
117+ """Extrait les caractéristiques Centris d'un texte aplati.
118+
119+ Les fiches Centris modernes rendent « Libellé | Valeur » (le libellé
120+ précède la valeur) — c'est l'ordre essayé en premier ; l'ancien ordre
121+ « Valeur | Libellé » reste en repli. Un garde-fou (_plausible) évite de
122+ capter l'en-tête de section ou le libellé voisin comme valeur."""
123+ out: dict = {}
124+ for label in _LABELS:
125+ lab = re.escape(label)
126+ for m in (re.search(lab + r"\s*(?:\(\d{4}\))?\s*\|\s*([^|]{1,55})", text),
127+ re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)):
128+ if m:
129+ val = re.sub(r"\s+", " ", m.group(1)).strip(" |")
130+ if _plausible(label, val):
131+ out[label] = val
132+ break
133+ return out
134+
135+
136+_INT_RE = re.compile(r"\d+")
137+
138+
139+def _int(v):
140+ if v is None:
141+ return None
142+ if isinstance(v, (int, float)):
143+ return int(v) or None
144+ m = _INT_RE.search(str(v))
145+ return int(m.group()) if m else None
146+
147+
148+def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):
149+ """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.
150+
151+ - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.
152+ - `key` : versionne le cache (changer pour forcer un rafraîchissement).
153+ - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer
154+ connector.get_rendered pour les sites derrière Firecrawl/anti-bot.
155+ """
156+ if limit <= 0:
157+ return
158+ from .. import db
159+ fetch_html = fetch_html or (lambda u: connector.get(u).text)
160+ con = db.connect()
161+ budget = limit
162+ try:
163+ for lst in listings:
164+ cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)
165+ if cached is None:
166+ stale = db.get_stale_detail(con, connector.source_id, lst.external_id)
167+ if budget <= 0:
168+ # budget épuisé : payload périmé (ancienne clé) plutôt que
169+ # rien — la fiche garde ses photos/détails en attendant
170+ # son re-parse à un prochain cycle
171+ if stale:
172+ apply_detail(lst, stale)
173+ continue
174+ try:
175+ cached = parse_fn(fetch_html(lst.url))
176+ except Exception:
177+ cached = {}
178+ if not cached and stale:
179+ # échec transitoire (challenge, timeout) : on garde l'ancien
180+ # payload et on ne l'écrase pas — nouvel essai au prochain cycle
181+ apply_detail(lst, stale)
182+ budget -= 1
183+ continue
184+ db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)
185+ budget -= 1
186+ apply_detail(lst, cached)
187+ finally:
188+ con.close()
189+
190+
191+def apply_detail(lst: PropertyListing, d: dict) -> None:
192+ """Applique un payload détail au PropertyListing sans écraser les valeurs déjà
193+ présentes (sauf images : on garde la plus grande galerie)."""
194+ if not d:
195+ return
196+ imgs = d.get("images")
197+ if imgs and len(imgs) > len(lst.images):
198+ lst.images = imgs
199+ if d.get("features"):
200+ # fusionne en dédoublonnant
201+ seen = {f.lower() for f in lst.features}
202+ for f in d["features"]:
203+ if f.lower() not in seen:
204+ lst.features.append(f)
205+ seen.add(f.lower())
206+ if d.get("details"):
207+ lst.details.update(d["details"])
208+ if d.get("broker_name"):
209+ lst.broker_name = d["broker_name"]
210+ # description : on garde la plus riche (la fiche détail bat le résumé liste)
211+ if d.get("description") and len(d["description"]) > len(lst.description or ""):
212+ lst.description = d["description"]
213+ for f in ("price_label", "address", "city", "sector", "property_type"):
214+ if d.get(f) and not getattr(lst, f, ""):
215+ setattr(lst, f, d[f])
216+ for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built",
217+ "area_sqft", "lot_sqft", "lat", "lng", "broker_phone", "price"):
218+ if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):
219+ setattr(lst, f, d[f])
added immoka/connectors/_resilient.py +318 −0
@@ -0,0 +1,318 @@
1+# =============================================================================
2+# Groupe KA — connecteurs : chaîne de fetch anti-bot RÉSILIENTE (commune)
3+# Auteur : Simon-Pierre Boucher <contact@spboucher.ai>
4+# Fichier : connectors/_resilient.py
5+# -----------------------------------------------------------------------------
6+# But : rendre les connecteurs durables dans le temps. Quand un site jusque-là
7+# ouvert déploie un anti-bot (Cloudflare / Akamai / Incapsula / PerimeterX) ou
8+# renvoie 403/429/503, la requête directe N'ÉCHOUE PLUS silencieusement : elle
9+# ESCALADE automatiquement à travers une chaîne de secours :
10+#
11+# 1. Direct — la session du connecteur (curl_cffi impersonate si
12+# dispo, sinon requests) : rapide et gratuit.
13+# 2. Oxylabs (résid.) — proxy résidentiel Canada (-cc-CA) : IP propre.
14+# 3. Scrapfly (ASP) — bypass anti-bot géré + rendu JS optionnel.
15+# 4. Bright Data — Web Unlocker : déblocage premium, dernier recours.
16+#
17+# Le premier backend qui renvoie un 200 non vide gagne. Si TOUS échouent, on
18+# renvoie la dernière réponse (avec son code d'erreur) pour que le connecteur
19+# journalise l'échec comme avant — aucun changement de comportement en cas
20+# d'échec total, aucun blocage silencieux.
21+#
22+# Conception :
23+# - Aucun effet de bord à l'import ; toute brique non configurée est sautée.
24+# - Les clés sont lues de os.environ, avec repli sur le .env de l'app puis
25+# ~/.claude/.env, et acceptent les deux noms Scrapfly (SCRAPFLY_KEY /
26+# SCRAPFLY_API_KEY). => aucune modif de .env nécessaire.
27+# - `_ResilientResponse` imite requests.Response (.text/.content/.status_code/
28+# .url/.headers/.json()/.ok/.raise_for_status()) : les connecteurs existants
29+# continuent de fonctionner sans modification.
30+# - Coupe-circuit par hôte : après plusieurs escalades totalement infructueuses
31+# sur un même hôte, on saute l'escalade payante pendant un temps de repos
32+# (évite de brûler du quota Scrapfly/Bright Data sur une source morte).
33+# =============================================================================
34+from __future__ import annotations
35+
36+import json as _json
37+import os
38+import time
39+from pathlib import Path
40+from urllib.parse import quote, urlsplit
41+
42+import requests
43+
44+# -- curl_cffi est OPTIONNEL (meilleur fingerprint TLS s'il est présent) ------
45+try: # pragma: no cover
46+ from curl_cffi import requests as _cffi # type: ignore
47+ _HAS_CFFI = True
48+except Exception: # noqa: BLE001
49+ _cffi = None
50+ _HAS_CFFI = False
51+
52+# Codes HTTP typiques d'un blocage anti-bot (≠ 401/404/410/500 « métier » :
53+# 401 = auth manquante, 403/429 = bot bloqué, 5xx CF = challenge/edge).
54+BLOCK_STATUS = {403, 429, 503, 520, 521, 522, 523, 524, 526, 1020}
55+
56+# Marqueurs de page-challenge (Cloudflare/Akamai/Incapsula/PerimeterX/DataDome).
57+_CHALLENGE_MARKERS = (
58+ "just a moment", "cf-browser-verification", "cf-challenge",
59+ "attention required", "access denied", "request unsuccessful",
60+ "px-captcha", "perimeterx", "incapsula", "_incapsula_", "datadome",
61+ "captcha-delivery", "please enable javascript and cookies",
62+ "checking your browser", "ddos protection by",
63+)
64+
65+_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
66+ "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
67+
68+# Coupe-circuit en mémoire : hôte -> (timestamp_jusquà, échecs_consécutifs)
69+_COOLDOWN: dict[str, tuple[float, int]] = {}
70+_COOLDOWN_HITS = 3 # nb d'échecs totaux avant repos
71+_COOLDOWN_SECONDS = 900.0 # 15 min de repos pour un hôte « mort »
72+
73+# -- chargement paresseux des secrets ----------------------------------------
74+_ENV_CACHE: dict[str, str] | None = None
75+
76+
77+def _load_env_files() -> dict[str, str]:
78+ """Parse les .env candidats une seule fois (repli si os.environ vide)."""
79+ global _ENV_CACHE
80+ if _ENV_CACHE is not None:
81+ return _ENV_CACHE
82+ out: dict[str, str] = {}
83+ candidates = []
84+ # .env de l'app (remonte quelques niveaux depuis ce module)
85+ here = Path(__file__).resolve()
86+ for up in range(2, 6):
87+ try:
88+ candidates.append(here.parents[up] / ".env")
89+ except IndexError:
90+ break
91+ candidates.append(Path.home() / ".claude" / ".env")
92+ for path in candidates:
93+ try:
94+ if not path.is_file():
95+ continue
96+ for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
97+ line = line.strip()
98+ if not line or line.startswith("#") or "=" not in line:
99+ continue
100+ k, _, v = line.partition("=")
101+ k, v = k.strip(), v.strip().strip('"').strip("'")
102+ # ne pas écraser une valeur déjà trouvée (priorité app > global)
103+ if k and k not in out:
104+ out[k] = v
105+ except Exception: # noqa: BLE001
106+ continue
107+ _ENV_CACHE = out
108+ return out
109+
110+
111+def _secret(*names: str) -> str | None:
112+ """Cherche une clé dans os.environ puis dans les .env (par ordre de noms)."""
113+ for n in names:
114+ v = os.environ.get(n)
115+ if v:
116+ return v
117+ env = _load_env_files()
118+ for n in names:
119+ v = env.get(n)
120+ if v:
121+ return v
122+ return None
123+
124+
125+# -- réponse compatible requests.Response ------------------------------------
126+class _ResilientResponse:
127+ """Imite le minimum utile d'une requests.Response pour les connecteurs."""
128+
129+ def __init__(self, url: str, status_code: int, text: str,
130+ headers: dict | None = None, via: str = "direct") -> None:
131+ self.url = url
132+ self.status_code = int(status_code or 0)
133+ self._text = text or ""
134+ self.headers = headers or {}
135+ self.encoding = "utf-8"
136+ self.via = via # backend gagnant (diagnostic)
137+
138+ @property
139+ def text(self) -> str:
140+ return self._text
141+
142+ @property
143+ def content(self) -> bytes:
144+ return self._text.encode("utf-8", errors="ignore")
145+
146+ @property
147+ def ok(self) -> bool:
148+ return 200 <= self.status_code < 400
149+
150+ def json(self, **kw):
151+ return _json.loads(self._text)
152+
153+ def raise_for_status(self):
154+ if 400 <= self.status_code < 600:
155+ raise requests.HTTPError(
156+ f"{self.status_code} via {self.via} pour {self.url}",
157+ response=self) # type: ignore[arg-type]
158+ return None
159+
160+ def __repr__(self) -> str: # pragma: no cover
161+ return f"<_ResilientResponse [{self.status_code}] via {self.via}>"
162+
163+
164+# -- détection de blocage -----------------------------------------------------
165+def is_blocked(resp) -> bool:
166+ """True si la réponse ressemble à un blocage anti-bot (≠ erreur métier)."""
167+ if resp is None:
168+ return True
169+ code = getattr(resp, "status_code", 0) or 0
170+ if code in BLOCK_STATUS:
171+ return True
172+ # 200 mais page-challenge servie
173+ if code == 200:
174+ try:
175+ body = (resp.text or "")[:4000].lower()
176+ except Exception: # noqa: BLE001
177+ return False
178+ server = str(resp.headers.get("Server", "")).lower() if getattr(resp, "headers", None) else ""
179+ if any(m in body for m in _CHALLENGE_MARKERS):
180+ return True
181+ if "cloudflare" in server and ("captcha" in body or "challenge" in body):
182+ return True
183+ return False
184+
185+
186+def _host(url: str) -> str:
187+ try:
188+ return urlsplit(url).netloc.lower()
189+ except Exception: # noqa: BLE001
190+ return url
191+
192+
193+def _cooling(host: str) -> bool:
194+ until, _ = _COOLDOWN.get(host, (0.0, 0))
195+ return time.time() < until
196+
197+
198+def _note_failure(host: str) -> None:
199+ until, hits = _COOLDOWN.get(host, (0.0, 0))
200+ hits += 1
201+ if hits >= _COOLDOWN_HITS:
202+ _COOLDOWN[host] = (time.time() + _COOLDOWN_SECONDS, 0)
203+ else:
204+ _COOLDOWN[host] = (until, hits)
205+
206+
207+def _note_success(host: str) -> None:
208+ _COOLDOWN.pop(host, None)
209+
210+
211+# -- backends d'escalade ------------------------------------------------------
212+def _try_oxylabs(url: str, timeout: int, country: str,
213+ headers: dict | None) -> _ResilientResponse | None:
214+ endpoint = _secret("OXYLABS_PROXY") # pr.oxylabs.io:7777
215+ user = _secret("OXYLABS_PROXY_USER") # customer-... (sans -cc-XX)
216+ pwd = _secret("OXYLABS_PROXY_PASS")
217+ if not (endpoint and user and pwd):
218+ return None
219+ cc = (country or "ca").upper()
220+ puser = f"{user}-cc-{cc}"
221+ proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"
222+ proxies = {"http": proxy, "https": proxy}
223+ hdrs = {"User-Agent": _UA}
224+ if headers:
225+ hdrs.update(headers)
226+ try:
227+ r = requests.get(url, proxies=proxies, headers=hdrs, timeout=timeout,
228+ verify=False) # noqa: S501 (proxy MITM du CA Oxylabs)
229+ return _ResilientResponse(url, r.status_code, r.text,
230+ dict(r.headers), via="oxylabs")
231+ except Exception: # noqa: BLE001
232+ return None
233+
234+
235+def _try_scrapfly(url: str, timeout: int, country: str, render_js: bool,
236+ headers: dict | None) -> _ResilientResponse | None:
237+ key = _secret("SCRAPFLY_KEY", "SCRAPFLY_API_KEY")
238+ if not key:
239+ return None
240+ params = {"key": key, "url": url, "country": country or "ca",
241+ "asp": "true", "proxy_pool": "public_residential_pool"}
242+ if render_js:
243+ params["render_js"] = "true"
244+ if headers:
245+ for k, v in headers.items():
246+ params[f"headers[{k}]"] = v
247+ try:
248+ r = requests.get("https://api.scrapfly.io/scrape", params=params,
249+ timeout=max(timeout, 180))
250+ result = (r.json() or {}).get("result") or {}
251+ return _ResilientResponse(
252+ url, result.get("status_code") or 0, result.get("content") or "",
253+ (result.get("response_headers") or {}), via="scrapfly")
254+ except Exception: # noqa: BLE001
255+ return None
256+
257+
258+def _try_brightdata(url: str, timeout: int,
259+ headers: dict | None) -> _ResilientResponse | None:
260+ key = _secret("BRIGHTDATA_API_KEY")
261+ zone = _secret("BRIGHTDATA_ZONE") or "web_unlocker1"
262+ if not key:
263+ return None
264+ try:
265+ r = requests.post(
266+ "https://api.brightdata.com/request",
267+ headers={"Authorization": f"Bearer {key}",
268+ "Content-Type": "application/json"},
269+ json={"zone": zone, "url": url, "format": "raw"},
270+ timeout=max(timeout, 120))
271+ return _ResilientResponse(url, r.status_code, r.text,
272+ dict(r.headers), via="brightdata")
273+ except Exception: # noqa: BLE001
274+ return None
275+
276+
277+# -- API publique -------------------------------------------------------------
278+def escalate(url: str, *, timeout: int = 30, country: str = "ca",
279+ render_js: bool = False, headers: dict | None = None,
280+ original=None):
281+ """Tente la chaîne de secours et renvoie la meilleure réponse.
282+
283+ Renvoie un `_ResilientResponse` 200 dès qu'un backend réussit ; sinon la
284+ dernière réponse tentée (ou `original`) pour préserver le comportement
285+ d'échec du connecteur. Respecte le coupe-circuit par hôte.
286+ """
287+ host = _host(url)
288+ if _cooling(host):
289+ return original # source au repos : on ne brûle pas de quota payant
290+
291+ last = original
292+ for backend in (
293+ lambda: _try_oxylabs(url, timeout, country, headers),
294+ lambda: _try_scrapfly(url, timeout, country, render_js, headers),
295+ lambda: _try_brightdata(url, timeout, headers),
296+ ):
297+ resp = backend()
298+ if resp is None:
299+ continue
300+ last = resp
301+ if resp.status_code == 200 and resp.text and not is_blocked(resp):
302+ _note_success(host)
303+ return resp
304+ time.sleep(0.4)
305+
306+ _note_failure(host)
307+ return last if last is not None else original
308+
309+
310+def escalate_if_blocked(resp, url: str, *, timeout: int = 30,
311+ country: str = "ca", render_js: bool = False,
312+ headers: dict | None = None):
313+ """Renvoie `resp` s'il est bon ; sinon lance l'escalade anti-bot."""
314+ if not is_blocked(resp):
315+ return resp
316+ better = escalate(url, timeout=timeout, country=country,
317+ render_js=render_js, headers=headers, original=resp)
318+ return better if better is not None else resp
added immoka/connectors/base.py +244 −0
@@ -0,0 +1,244 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/base.py : classe de base des connecteurs + backends de fetch
5+# (requests direct, ou Firecrawl pour les sites JavaScript)
6+# -----------------------------------------------------------------------------
7+from __future__ import annotations
8+
9+import json
10+import os
11+import time
12+
13+import requests
14+
15+from ..schema import PropertyListing
16+
17+USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
18+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
19+ "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)")
20+
21+FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
22+SCRAPFLY_API = "https://api.scrapfly.io/scrape"
23+
24+
25+class BaseConnector:
26+ """Un connecteur = un adaptateur propre à un site d'agence de courtage.
27+
28+ Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
29+ la liste complète des propriétés actuellement affichées sur le site.
30+ Le pipeline (ingest.py) s'occupe du diff avec la base de données.
31+ """
32+
33+ source_id: str = ""
34+ request_delay: float = 0.6 # politesse entre requêtes
35+ timeout: int = 30
36+ use_detail_cache: bool = True # cache BD des pages détail
37+
38+ def __init__(self) -> None:
39+ self.session = requests.Session()
40+ self.session.headers["User-Agent"] = USER_AGENT
41+ self._last_request = 0.0
42+ self._detail_con = None
43+
44+ # -- backends -------------------------------------------------------------
45+ def get(self, url: str, **kw) -> requests.Response:
46+ """GET direct avec throttling poli."""
47+ wait = self.request_delay - (time.time() - self._last_request)
48+ if wait > 0:
49+ time.sleep(wait)
50+ resp = self.session.get(url, timeout=self.timeout, **kw)
51+ self._last_request = time.time()
52+ resp.raise_for_status()
53+ return resp
54+
55+ def post(self, url: str, **kw) -> requests.Response:
56+ """POST direct avec throttling poli (APIs de recherche internes)."""
57+ wait = self.request_delay - (time.time() - self._last_request)
58+ if wait > 0:
59+ time.sleep(wait)
60+ resp = self.session.post(url, timeout=self.timeout, **kw)
61+ self._last_request = time.time()
62+ resp.raise_for_status()
63+ return resp
64+
65+ def get_rendered(self, url: str, wait_for: int = 0,
66+ proxy: str | None = None) -> str:
67+ """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
68+
69+ Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
70+ À utiliser pour les sites SPA ou derrière Cloudflare.
71+ `proxy="stealth"` franchit les challenges anti-bot (Cloudflare, etc.).
72+ """
73+ key = os.environ.get("FIRECRAWL_API_KEY")
74+ if not key:
75+ raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
76+ payload: dict = {"url": url, "formats": ["html"], "timeout": 90000}
77+ if wait_for:
78+ payload["waitFor"] = wait_for
79+ if proxy:
80+ payload["proxy"] = proxy
81+ resp = requests.post(
82+ FIRECRAWL_API,
83+ json=payload,
84+ headers={"Authorization": f"Bearer {key}"},
85+ timeout=150,
86+ )
87+ resp.raise_for_status()
88+ data = resp.json()
89+ return (data.get("data") or {}).get("html", "")
90+
91+ def scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
92+ rendering_wait: int = 0, country: str = "ca",
93+ wait_for_selector: str | None = None,
94+ js_scenario: list | str | None = None,
95+ proxy_pool: str | None = None, headers: dict | None = None,
96+ method: str = "GET", body: str | None = None) -> dict:
97+ """Appel Scrapfly complet — retourne le dict `result` (content, status_code…).
98+
99+ - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (encodée base64)
100+ pour charger les listes virtualisées (BoldTrail/kvCORE, etc.).
101+ - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif).
102+ - `headers`/`method`/`body` : pour REJOUER une API JSON interne via ASP.
103+ """
104+ import base64
105+ key = os.environ.get("SCRAPFLY_KEY")
106+ if not key:
107+ raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
108+ params: dict = {"key": key, "url": url, "country": country}
109+ if asp:
110+ params["asp"] = "true"
111+ if render_js:
112+ params["render_js"] = "true"
113+ if rendering_wait:
114+ params["rendering_wait"] = rendering_wait
115+ if wait_for_selector:
116+ params["wait_for_selector"] = wait_for_selector
117+ if proxy_pool:
118+ params["proxy_pool"] = proxy_pool
119+ if js_scenario is not None:
120+ js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario)
121+ params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode()
122+ if headers:
123+ for k, v in headers.items():
124+ params[f"headers[{k}]"] = v
125+ wait = self.request_delay - (time.time() - self._last_request)
126+ if wait > 0:
127+ time.sleep(wait)
128+ if method.upper() == "POST":
129+ resp = requests.post(SCRAPFLY_API, params=params,
130+ data=(body or ""), timeout=180)
131+ else:
132+ resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
133+ self._last_request = time.time()
134+ try:
135+ return resp.json().get("result") or {}
136+ except ValueError:
137+ return {}
138+
139+ def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True,
140+ rendering_wait: int = 0, country: str = "ca",
141+ wait_for_selector: str | None = None,
142+ js_scenario: list | str | None = None,
143+ proxy_pool: str | None = None) -> str:
144+ """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne
145+ le HTML (result.content) ou "" en cas d'échec ASP."""
146+ return self.scrapfly(url, render_js=render_js, asp=asp,
147+ rendering_wait=rendering_wait, country=country,
148+ wait_for_selector=wait_for_selector,
149+ js_scenario=js_scenario, proxy_pool=proxy_pool
150+ ).get("content") or ""
151+
152+ def detail(self, external_id: str, key: str, fetch_fn) -> dict:
153+ """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
154+ la propriété est nouvelle ou si sa clé (hash du contenu liste) a changé.
155+
156+ Permet d'extraire les champs riches (description, courtier, photos…)
157+ sans revisiter chaque page détail à chaque synchronisation.
158+ `fetch_fn` doit retourner un dict JSON-sérialisable.
159+ """
160+ if not self.use_detail_cache:
161+ return fetch_fn() or {}
162+ from .. import db
163+ if self._detail_con is None:
164+ self._detail_con = db.connect()
165+ cached = db.get_cached_detail(self._detail_con, self.source_id,
166+ str(external_id), key)
167+ if cached is not None:
168+ return cached
169+ payload = fetch_fn() or {}
170+ db.put_cached_detail(self._detail_con, self.source_id,
171+ str(external_id), key, payload)
172+ return payload
173+
174+ # -- contrat --------------------------------------------------------------
175+ def fetch(self) -> list[PropertyListing]:
176+ raise NotImplementedError
177+
178+
179+# =============================================================================
180+# Résilience anti-bot (Groupe KA) — auto-escalade de get() sans toucher au corps.
181+# Ajouté par l'orchestrateur KA : enrobe BaseConnector.get pour qu'un blocage
182+# anti-bot (403/429/503/challenge) ou une coupure réseau déclenche la chaîne
183+# de secours (Oxylabs résidentiel -> Scrapfly ASP -> Bright Data). Voir
184+# connectors/_resilient.py. Idempotent (marqueur _KA_RESILIENT_WRAPPED).
185+# =============================================================================
186+if not getattr(BaseConnector, "_KA_RESILIENT_WRAPPED", False):
187+ import requests as _ka_requests # noqa: E402
188+ from . import _resilient as _kar # noqa: E402
189+
190+ _ka_orig_get = BaseConnector.get
191+
192+ def _ka_full_url(url, kw):
193+ try:
194+ return _ka_requests.Request("GET", url,
195+ params=kw.get("params")).prepare().url
196+ except Exception: # noqa: BLE001
197+ return url
198+
199+ def _ka_resilient_get(self, url, **kw):
200+ timeout = getattr(self, "timeout", 30)
201+ headers = kw.get("headers")
202+ try:
203+ return _ka_orig_get(self, url, **kw)
204+ except _ka_requests.HTTPError as exc:
205+ r = getattr(exc, "response", None)
206+ if r is not None and _kar.is_blocked(r):
207+ target = getattr(r, "url", None) or _ka_full_url(url, kw)
208+ better = _kar.escalate_if_blocked(
209+ r, target, timeout=timeout, headers=headers)
210+ if better is not None and getattr(better, "status_code", 0) == 200:
211+ return better
212+ raise
213+ except (_ka_requests.ConnectionError, _ka_requests.Timeout):
214+ better = _kar.escalate(_ka_full_url(url, kw),
215+ timeout=timeout, headers=headers)
216+ if better is not None and getattr(better, "status_code", 0) == 200:
217+ return better
218+ raise
219+
220+ def _ka_get_resilient(self, url, *, render_js=False, country="ca", **kw):
221+ """Fetch anti-bot explicite : force la chaîne de secours au besoin.
222+
223+ Comme get() mais tente d'abord le direct puis escalade même sur 200-
224+ challenge, avec rendu JS optionnel. Renvoie une réponse compatible
225+ requests (.text/.content/.status_code/.json()...).
226+ """
227+ timeout = getattr(self, "timeout", 30)
228+ headers = kw.get("headers")
229+ try:
230+ resp = _ka_orig_get(self, url, **kw)
231+ except _ka_requests.HTTPError as exc:
232+ resp = getattr(exc, "response", None)
233+ except (_ka_requests.ConnectionError, _ka_requests.Timeout):
234+ resp = None
235+ target = _ka_full_url(url, kw)
236+ if resp is not None and getattr(resp, "url", None):
237+ target = resp.url
238+ return _kar.escalate_if_blocked(resp, target, timeout=timeout,
239+ country=country, render_js=render_js,
240+ headers=headers)
241+
242+ BaseConnector.get = _ka_resilient_get
243+ BaseConnector.get_resilient = _ka_get_resilient
244+ BaseConnector._KA_RESILIENT_WRAPPED = True
added immoka/connectors/jsonld.py +51 −0
@@ -0,0 +1,51 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/jsonld.py : utilitaires partagés d'extraction schema.org (JSON-LD)
5+# Beaucoup de sites d'agences (Sutton, et d'autres CMS immobiliers) publient
6+# la liste des propriétés en JSON-LD `ItemList` — données propres et stables.
7+# -----------------------------------------------------------------------------
8+from __future__ import annotations
9+
10+import json
11+import re
12+
13+_LD_RE = re.compile(r'<script[^>]+type="application/ld\+json"[^>]*>(.*?)</script>',
14+ re.S | re.I)
15+
16+
17+def iter_ld(html: str):
18+ """Itère les objets JSON-LD d'une page (chaque bloc, aplati depuis @graph)."""
19+ for block in _LD_RE.findall(html):
20+ block = block.strip()
21+ if not block:
22+ continue
23+ try:
24+ data = json.loads(block)
25+ except ValueError:
26+ continue
27+ nodes = data.get("@graph", [data]) if isinstance(data, dict) else data
28+ for node in (nodes if isinstance(nodes, list) else [nodes]):
29+ if isinstance(node, dict):
30+ yield node
31+
32+
33+def item_list_elements(html: str) -> list[dict]:
34+ """Retourne les `itemListElement` (schema.org ItemList) trouvés dans la page."""
35+ out: list[dict] = []
36+ for node in iter_ld(html):
37+ elems = node.get("itemListElement")
38+ if isinstance(elems, list):
39+ out.extend(e for e in elems if isinstance(e, dict))
40+ # certaines pages imbriquent l'ItemList sous mainEntity
41+ me = node.get("mainEntity")
42+ if isinstance(me, dict) and isinstance(me.get("itemListElement"), list):
43+ out.extend(e for e in me["itemListElement"] if isinstance(e, dict))
44+ return out
45+
46+
47+def sqm_to_sqft(value) -> float | None:
48+ try:
49+ return round(float(value) * 10.7639)
50+ except (TypeError, ValueError):
51+ return None
added immoka/connectors/realtypress.py +300 −0
@@ -0,0 +1,300 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (Québec + Ontario)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# connectors/realtypress.py : connecteur GÉNÉRIQUE RealtyPress (Ontario)
5+# RealtyPress = plugin WordPress branché sur le flux CREA DDF ; ~35 sites
6+# d'agences/équipes ontariennes confirmés (recensement 2026-08-27, voir
7+# docs/ontario-agences-connecteurs.md). Chaque site expose l'IDX/DDF complet
8+# de son board (OREB, ITSO, KAREA…) en HTML server-rendered, sans anti-bot :
9+# un seul parseur couvre quasi toute la province.
10+#
11+# - liste : archive /listing/page/N/?posts_per_page=100 (100 cartes/page ;
12+# ⚠ ?posts_per_page directement sur /listing = 301/vide) ; cartes
13+# class="rps-property-result" (ruban For sale/For rent, prix, adresse,
14+# ville, caractéristiques) ;
15+# - fiche : mur CREA « I Accept The Terms » contourné par le cookie
16+# `disclaimer=accepted` ; tableaux <strong>Label</strong>/valeur (MLS®
17+# Number, Property Type, Bedrooms…), description « … (id:NNNNN) »,
18+# lat/lng JSON-LD, photos ddfcdn.realtor.ca ;
19+# - external_id = ddf<id> (préfixe : jamais de collision avec les n° Centris
20+# QC) ; sources avec infixe _ag_ → la dédup par external_id masque les
21+# doublons inter-sites (le même bien DDF publié sur plusieurs sites).
22+# Sites générés depuis data/ontario_agencies.json (un source_id par site).
23+# -----------------------------------------------------------------------------
24+from __future__ import annotations
25+
26+import html as _html
27+import json
28+import os
29+import re
30+import urllib.parse
31+from pathlib import Path
32+
33+from .base import BaseConnector
34+from . import _detailutil as du
35+from ..schema import PropertyListing
36+
37+REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "ontario_agencies.json"
38+DETAIL_LIMIT = int(os.environ.get("IMMOKA_RP_DETAIL_LIMIT",
39+ os.environ.get("IMMOKA_DETAIL_LIMIT", "150")))
40+
41+_CARD_RE = re.compile(r'<div class="rps-property-result">')
42+# fiche = 1er lien de la carte finissant par -<id DDF>/ ; le chemin varie selon
43+# le site (/listing/, /listings/, /all-regional-listings/…)
44+_LINK_RE = re.compile(r'href="(https?://[^"]+?-(\d{6,10})/?)"')
45+_RIBBON_RE = re.compile(r'rps-ribbon[^>]*>\s*([^<]+?)\s*<')
46+_PRICE_RE = re.compile(r'rps-price[^>]*>\s*\$\s*([\d,]+)')
47+_H4_RE = re.compile(r"<h4>\s*(.*?)\s*</h4>", re.S)
48+# avec ou sans <strong> selon le thème du site
49+_CITY_RE = re.compile(r'city-province-postalcode[^>]*>\s*(?:<strong>\s*)?([^<]+?)\s*<', re.S)
50+_FEAT_RE = re.compile(r'rps-result-feature-label[^>]*>\s*([^<]+?)\s*<')
51+_CARD_BROKER_RE = re.compile(r'text-muted[^>]*>\s*<small>\s*([^<]+?)\s*(?:<br|</small>)', re.S)
52+_DDFIMG_RE = re.compile(r'https://ddfcdn\.realtor\.ca/[^")\'\s\\]+')
53+_ROW_RE = re.compile(r"<td[^>]*>\s*<strong>([^<]{2,45})</strong>\s*</td>\s*"
54+ r"<td[^>]*>(.*?)</td>", re.S)
55+_DESC_RE = re.compile(r'<!--\s*Description\s*-->\s*<p[^>]*>(.*?)</p>', re.S)
56+_DESC_RE2 = re.compile(r'<p itemprop="description"[^>]*>(.*?)</p>', re.S)
57+# ville depuis <title> « 3383 Romeo Street, Greater Sudbury (Valley East), Ontario … »
58+_TITLE_CITY_RE = re.compile(r",\s*([^,<>|]{2,60}),\s*Ontario\b")
59+_PRICING_RE = re.compile(r'rps-pricing[^>]*>\s*\$\s*([\d,]+)')
60+_ID_TAIL_RE = re.compile(r"\s*\(id:\d{4,9}\)\s*$")
61+_TAG_RE = re.compile(r"<[^>]+>")
62+_NUM_RE = re.compile(r"[\d,]+(?:\.\d+)?")
63+_YEAR_RE = re.compile(r"\b(1[6-9]\d{2}|20\d{2})\b")
64+# territoire couvert (Canada au complet) — même boîte que schema.finalize()
65+_BBOX = (41.6, 83.2, -141.1, -52.5)
66+
67+
68+def _num(s: str) -> float | None:
69+ m = _NUM_RE.search(s or "")
70+ if not m:
71+ return None
72+ try:
73+ return float(m.group(0).replace(",", ""))
74+ except ValueError:
75+ return None
76+
77+
78+class _RealtyPress(BaseConnector):
79+ """Connecteur générique de site RealtyPress (voir data/ontario_agencies.json)."""
80+
81+ agency_name = ""
82+ site_url = ""
83+ archive = "listing" # chemin de l'archive (revelrealty: "listings",
84+ # codygroup: "all-regional-listings")
85+ max_pages = 150 # 100 cartes/page → jusqu'à 15 000 fiches par site
86+ request_delay = 0.6
87+
88+ def fetch(self) -> list[PropertyListing]:
89+ # mur CREA des fiches détail : le cookie suffit (posé pour tout domaine,
90+ # les redirections www/apex restent couvertes)
91+ self.session.cookies.set("disclaimer", "accepted")
92+ by_id: dict[str, PropertyListing] = {}
93+ base = self.site_url.rstrip("/")
94+ dry = 0
95+ for page in range(1, self.max_pages + 1):
96+ url = f"{base}/{self.archive}/page/{page}/?posts_per_page=100"
97+ try:
98+ body = self.get(url).text
99+ except Exception:
100+ break
101+ cards = self._cards(body)
102+ if not cards:
103+ break
104+ before = len(by_id)
105+ for card in cards:
106+ self._parse_card(card, by_id)
107+ dry = dry + 1 if len(by_id) == before else 0
108+ if dry >= 2:
109+ break
110+ listings = list(by_id.values())
111+ du.enrich(self, listings, DETAIL_LIMIT, parse_rp_detail, key="v1")
112+ for lst in listings:
113+ # n° MLS du board (fiche détail) — utile à la dédup inter-plateformes
114+ if not lst.mls and lst.details.get("MLS® Number"):
115+ lst.mls = str(lst.details["MLS® Number"])
116+ if not lst.title:
117+ lst.title = ", ".join(filter(None, (lst.address, lst.city))) \
118+ or "Propriété à vendre"
119+ return listings
120+
121+ def _cards(self, body: str) -> list[str]:
122+ marks = list(_CARD_RE.finditer(body))
123+ return [body[m.start():(marks[i + 1].start() if i + 1 < len(marks)
124+ else m.start() + 6000)]
125+ for i, m in enumerate(marks)]
126+
127+ def _parse_card(self, card: str, by_id: dict) -> None:
128+ ml = _LINK_RE.search(card)
129+ if not ml:
130+ return
131+ url, ddf = ml.group(1), ml.group(2)
132+ eid = f"ddf{ddf}"
133+ if eid in by_id:
134+ return
135+ mr = _RIBBON_RE.search(card)
136+ ribbon = (mr.group(1) if mr else "").strip().lower()
137+ if "rent" in ribbon or "lease" in ribbon:
138+ return # locations : hors périmètre
139+ lst = PropertyListing(source=self.source_id, external_id=eid, url=url,
140+ region="Ontario", agency=self.agency_name,
141+ broker_name=self.agency_name)
142+ ma = _H4_RE.search(card)
143+ if ma:
144+ lst.address = _html.unescape(_TAG_RE.sub(" ", ma.group(1))).strip()
145+ mc = _CITY_RE.search(card)
146+ if mc:
147+ city = _html.unescape(mc.group(1)).strip().rstrip(",")
148+ city = re.sub(r",?\s*Ontario\b.*$", "", city, flags=re.I)
149+ lst.city = city.split("(")[0].strip()
150+ mp = _PRICE_RE.search(card)
151+ if mp:
152+ lst.price = _num(mp.group(1))
153+ lst.price_label = f"{mp.group(1)} $"
154+ for feat in _FEAT_RE.findall(card):
155+ f = _html.unescape(feat).strip()
156+ low = f.lower()
157+ n = _num(f)
158+ if not n:
159+ continue
160+ if "bedroom" in low:
161+ lst.bedrooms = int(n)
162+ elif "bathroom" in low:
163+ lst.bathrooms = int(n)
164+ elif "sqft" in low or "sq ft" in low or "ft" in low:
165+ lst.area_sqft = n # plage « 1,100 - 1,500 ft² » : borne basse
166+ mbk = _CARD_BROKER_RE.search(card)
167+ if mbk:
168+ lst.broker_name = _html.unescape(mbk.group(1)).strip()[:120]
169+ mi = _DDFIMG_RE.search(card)
170+ if mi:
171+ lst.images = [mi.group(0)]
172+ by_id[lst.external_id] = lst
173+
174+
175+def parse_rp_detail(html: str) -> dict:
176+ """Fiche RealtyPress : tableaux DDF, description, GPS, galerie, courtier."""
177+ out: dict = {}
178+ details: dict = {}
179+
180+ for lab, val in _ROW_RE.findall(html):
181+ label = _html.unescape(lab).strip().rstrip(":")
182+ value = re.sub(r"\s+", " ", _html.unescape(_TAG_RE.sub(" ", val))).strip()
183+ if label and value and len(value) <= 300:
184+ details.setdefault(label, value)
185+
186+ def dv(*labels: str) -> str:
187+ for lb in labels:
188+ if details.get(lb):
189+ return details[lb]
190+ return ""
191+
192+ b = _num(dv("Bedrooms Total", "Bedrooms", "Bedrooms Above Ground"))
193+ if b is not None and 0 < b <= 30:
194+ out["bedrooms"] = int(b)
195+ b = _num(dv("Bathroom Total", "Bathrooms"))
196+ if b is not None and 0 < b <= 30:
197+ out["bathrooms"] = int(b)
198+ b = _num(dv("Half Bath Total"))
199+ if b is not None and 0 < b <= 10:
200+ out["powder_rooms"] = int(b)
201+ my = _YEAR_RE.search(dv("Constructed Date", "Construction Year", "Age"))
202+ if my:
203+ out["year_built"] = int(my.group(1))
204+ si = dv("Size Interior")
205+ if si and "sqft" in si.lower().replace(" ", ""):
206+ a = _num(si) # « 7,901 Sqft » / « 1200 - 1399 sqft »
207+ if a and a >= 100:
208+ out["area_sqft"] = a
209+ pt = dv("Property Type", "Building Type", "Type")
210+ if pt:
211+ out["property_type"] = pt # anglais DDF — normalisé par finalize()
212+ sec = dv("Neigbourhood", "Neighbourhood", "Community Name")
213+ if sec:
214+ out["sector"] = sec
215+
216+ mp = _PRICING_RE.search(html)
217+ if mp:
218+ out["price"] = _num(mp.group(1))
219+ out["price_label"] = f"{mp.group(1)} $"
220+
221+ md = _DESC_RE.search(html) or _DESC_RE2.search(html)
222+ if md:
223+ desc = _html.unescape(_TAG_RE.sub(" ", md.group(1)))
224+ desc = re.sub(r"\s+", " ", desc).strip()
225+ out["description"] = _ID_TAIL_RE.sub("", desc)[:6000]
226+
227+ mt = re.search(r"<title>(.*?)</title>", html, re.S)
228+ if mt:
229+ mc = _TITLE_CITY_RE.search(_html.unescape(mt.group(1)))
230+ if mc:
231+ # « Greater Sudbury (Valley East) » : le secteur part dans sector
232+ city = mc.group(1).split("(")[0].strip()
233+ if city and not any(c.isdigit() for c in city):
234+ out["city"] = city
235+ msec = re.search(r"\(([^)]{2,45})\)", mc.group(1))
236+ if msec and "sector" not in out:
237+ out["sector"] = msec.group(1).strip()
238+
239+ for n in du.ld_nodes(html):
240+ t = n.get("@type")
241+ types = set(t if isinstance(t, list) else [t])
242+ geo = n.get("geo") or {}
243+ if isinstance(geo, dict) and "lat" not in out:
244+ try:
245+ lat, lng = float(geo["latitude"]), float(geo["longitude"])
246+ if _BBOX[0] <= lat <= _BBOX[1] and _BBOX[2] <= lng <= _BBOX[3]:
247+ out["lat"], out["lng"] = lat, lng
248+ except (KeyError, TypeError, ValueError):
249+ pass
250+ if types & {"RealEstateAgent", "Organization"}:
251+ name = str(n.get("name") or "").strip()
252+ if name and "broker_name" not in out:
253+ out["broker_name"] = name[:120]
254+ tel = str(n.get("telephone") or "").strip()
255+ if tel and "broker_phone" not in out:
256+ out["broker_phone"] = tel[:40]
257+ if "lat" not in out:
258+ m = re.search(r'"latitude"\s*:\s*"?(-?\d{1,2}\.\d{3,})"?\s*,\s*'
259+ r'"longitude"\s*:\s*"?(-?\d{2,3}\.\d{3,})"?', html)
260+ if m:
261+ lat, lng = float(m.group(1)), float(m.group(2))
262+ if _BBOX[0] <= lat <= _BBOX[1] and _BBOX[2] <= lng <= _BBOX[3]:
263+ out["lat"], out["lng"] = lat, lng
264+
265+ gal = [u for u in dict.fromkeys(_DDFIMG_RE.findall(html))
266+ if "/listings/" in u.lower()]
267+ if gal:
268+ out["images"] = gal[:60]
269+
270+ if details:
271+ out["details"] = details
272+ return out
273+
274+
275+def _load() -> list[dict]:
276+ try:
277+ return json.loads(REGISTRY.read_text(encoding="utf-8"))
278+ except Exception:
279+ return []
280+
281+
282+# House-Ka : les connecteurs RealtyPress sont le cœur du site — toujours
283+# enregistrés (pas de gate IMMOKA_ONTARIO, contrairement à Immo-Ka).
284+
285+# Génère une classe par site du registre.
286+for _ag in _load():
287+ if not all(_ag.get(k) for k in ("id", "site")):
288+ continue
289+ _sid = _ag["id"]
290+ globals()[f"REALTYPRESS_{_sid.upper()}"] = type(
291+ "RealtyPress" + "".join(p.title() for p in _sid.split("_")),
292+ (_RealtyPress,),
293+ {
294+ "source_id": _sid,
295+ "site_url": _ag["site"],
296+ "agency_name": _ag.get("name", _sid),
297+ "archive": _ag.get("archive", "listing"),
298+ "max_pages": int(_ag.get("max_pages", 150)),
299+ },
300+ )
added immoka/db.py +575 −0
@@ -0,0 +1,575 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# db.py : persistance SQLite — upsert avec détection de changements,
5+# cycle de vie avec délai de grâce (2 syncs), détection de dérive,
6+# historique de prix, cache des pages détail.
7+# -----------------------------------------------------------------------------
8+from __future__ import annotations
9+
10+import json
11+import sqlite3
12+import statistics
13+import time
14+from pathlib import Path
15+
16+from .schema import PropertyListing
17+
18+DB_PATH = Path(__file__).resolve().parent.parent / "data" / "immoka.db"
19+
20+# Nombre d'exécutions consécutives où une propriété doit être absente de la
21+# source avant d'être désactivée (délai de grâce contre les ratés ponctuels).
22+MISS_GRACE = 2
23+
24+# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique
25+# (médiane >= DRIFT_MIN_BASE annonces), on alerte et on suspend les retraits.
26+DRIFT_RATIO = 0.25
27+DRIFT_MIN_BASE = 8
28+DRIFT_HISTORY = 5
29+
30+_SCHEMA = """
31+CREATE TABLE IF NOT EXISTS listings (
32+ uid TEXT PRIMARY KEY,
33+ source TEXT NOT NULL,
34+ external_id TEXT NOT NULL,
35+ url TEXT,
36+ title TEXT,
37+ address TEXT,
38+ sector TEXT,
39+ city TEXT,
40+ region TEXT,
41+ property_type TEXT,
42+ price REAL,
43+ price_label TEXT,
44+ bedrooms INTEGER,
45+ bathrooms INTEGER,
46+ powder_rooms INTEGER,
47+ area_sqft REAL,
48+ lot_sqft REAL,
49+ year_built INTEGER,
50+ mls TEXT,
51+ status TEXT DEFAULT 'a-vendre',
52+ broker_name TEXT,
53+ broker_phone TEXT,
54+ agency TEXT,
55+ description TEXT,
56+ features TEXT, -- JSON (liste de textes source)
57+ details TEXT, -- JSON (champs structurés)
58+ images TEXT, -- JSON
59+ lat REAL,
60+ lng REAL,
61+ geocode_failed INTEGER DEFAULT 0,
62+ content_hash TEXT,
63+ first_seen REAL,
64+ last_seen REAL,
65+ updated_at REAL,
66+ miss_count INTEGER DEFAULT 0,
67+ active INTEGER DEFAULT 1,
68+ dup_hidden INTEGER DEFAULT 0 -- 1 = doublon de sous-agence masqué (dédup Centris)
69+);
70+CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);
71+CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city);
72+CREATE INDEX IF NOT EXISTS idx_listings_type ON listings(property_type);
73+CREATE INDEX IF NOT EXISTS idx_listings_active ON listings(active);
74+CREATE INDEX IF NOT EXISTS idx_listings_extid ON listings(external_id);
75+
76+CREATE TABLE IF NOT EXISTS sync_log (
77+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78+ source TEXT,
79+ ts REAL,
80+ found INTEGER,
81+ added INTEGER,
82+ updated INTEGER,
83+ removed INTEGER,
84+ ok INTEGER,
85+ message TEXT,
86+ stats TEXT -- JSON : taux de champs null, missed, alerte…
87+);
88+
89+CREATE TABLE IF NOT EXISTS detail_cache (
90+ source TEXT NOT NULL,
91+ external_id TEXT NOT NULL,
92+ key TEXT, -- hash du contenu « liste » de l'annonce
93+ payload TEXT, -- JSON opaque propre au connecteur
94+ fetched_at REAL,
95+ PRIMARY KEY (source, external_id)
96+);
97+
98+CREATE TABLE IF NOT EXISTS price_log (
99+ uid TEXT NOT NULL,
100+ ts REAL NOT NULL,
101+ price REAL -- prix observé (baisses/hausses de prix demandé)
102+);
103+CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
104+
105+CREATE TABLE IF NOT EXISTS geocode_cache (
106+ address TEXT PRIMARY KEY,
107+ lat REAL,
108+ lng REAL,
109+ provider TEXT,
110+ failed INTEGER DEFAULT 0,
111+ ts REAL,
112+ muni TEXT -- municipalité officielle (clés « ville:… »)
113+);
114+
115+CREATE TABLE IF NOT EXISTS poi_cache (
116+ coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m)
117+ lat REAL,
118+ lng REAL,
119+ pois TEXT, -- JSON : [{cat, name, dist_m}] (plus proche/catégorie)
120+ ts REAL
121+);
122+"""
123+
124+
125+_SCHEMA_READY = False # le schéma/migration ne s'exécute qu'UNE fois par process
126+
127+
128+def _init_schema(con: sqlite3.Connection) -> None:
129+ """Création de schéma + migration — coûteuse (write-lock). À faire une seule
130+ fois par process : l'exécuter à chaque connexion sérialisait les requêtes du
131+ web sur un verrou d'écriture (deadlock/starvation sous charge)."""
132+ con.executescript(_SCHEMA)
133+ cols = {r["name"] for r in con.execute("PRAGMA table_info(listings)")}
134+ if "agency" not in cols:
135+ con.execute("ALTER TABLE listings ADD COLUMN agency TEXT")
136+ if "dup_hidden" not in cols:
137+ con.execute("ALTER TABLE listings ADD COLUMN dup_hidden INTEGER DEFAULT 0")
138+ if "dup_of" not in cols: # uid de la fiche visible au profit de laquelle
139+ con.execute("ALTER TABLE listings ADD COLUMN dup_of TEXT") # celle-ci est masquée
140+ if "dauid" not in cols: # aire de diffusion 2021 (stats de quartier)
141+ con.execute("ALTER TABLE listings ADD COLUMN dauid TEXT")
142+ if "vraiprix" not in cols: # estimation Vrai-Prix (JSON) + lien analyse
143+ con.execute("ALTER TABLE listings ADD COLUMN vraiprix TEXT")
144+ gcols = {r["name"] for r in con.execute("PRAGMA table_info(geocode_cache)")}
145+ if "muni" not in gcols: # municipalité officielle (entrées « ville:… »)
146+ con.execute("ALTER TABLE geocode_cache ADD COLUMN muni TEXT")
147+ con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)")
148+ con.execute("CREATE INDEX IF NOT EXISTS idx_listings_dupof ON listings(dup_of)")
149+ con.execute("CREATE INDEX IF NOT EXISTS idx_listings_geo ON listings(lat, lng)")
150+ con.commit()
151+
152+
153+def refresh_dedup(con: sqlite3.Connection) -> int:
154+ """Pré-calcule la déduplication de la famille sous-agences (`*_ag_*`) dans la
155+ colonne `dup_hidden`, pour que la lecture soit instantanée (« AND dup_hidden=0 »)
156+ au lieu d'un sous-select corrélé par ligne (300 s sur 75 k lignes).
157+
158+ Règle (identique à l'ancienne clause) : une fiche de sous-agence est masquée
159+ si une fiche de plus haute priorité — même n° Centris (external_id), active —
160+ existe : le flux central/agence principale d'abord, sinon la sous-agence au
161+ plus petit uid. Les fiches non sous-agence ne sont jamais masquées par
162+ cette règle. Une 3e passe (dedup_by_address) masque ensuite les doublons
163+ INTER-SOURCES sans n° Centris commun (même adresse + type + prix ±1 %).
164+ Retourne le nombre total de fiches masquées."""
165+ AG = "source LIKE '%\\_ag\\_%' ESCAPE '\\'"
166+ NOTAG = "source NOT LIKE '%\\_ag\\_%' ESCAPE '\\'"
167+ con.execute("UPDATE listings SET dup_hidden=0, dup_of=NULL")
168+ # 1) masquer les sous-agences dont le n° Centris est porté par une fiche
169+ # canonique (non sous-agence) active — semi-jointure, rapide.
170+ # dup_of = la fiche canonique (pour la box « Aussi publiée sur… »).
171+ con.execute(
172+ f"UPDATE listings SET dup_hidden=1,"
173+ f" dup_of=(SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
174+ f" AND d.external_id=listings.external_id AND d.{NOTAG})"
175+ f" WHERE active=1 AND {AG}"
176+ f" AND external_id IN (SELECT external_id FROM listings"
177+ f" WHERE active=1 AND {NOTAG})")
178+ # 2) parmi les sous-agences restantes (sans canonique), ne garder que le plus
179+ # petit uid par n° Centris.
180+ con.execute(
181+ f"UPDATE listings SET dup_hidden=1,"
182+ f" dup_of=(SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
183+ f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)"
184+ f" WHERE active=1 AND {AG} AND dup_hidden=0"
185+ f" AND uid > (SELECT MIN(d.uid) FROM listings d WHERE d.active=1"
186+ f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)")
187+ # 3) dédup INTER-SOURCES par adresse : la même propriété publiée sur deux
188+ # plateformes (ex. courtier + Kijiji) sans n° Centris commun. Règle
189+ # conservatrice : même adresse normalisée (civique + rue + ville) + même
190+ # type + prix identique à ±1 % → on garde la fiche la plus autoritaire.
191+ try:
192+ n_addr = dedup_by_address(con)
193+ if n_addr:
194+ print(f"[immo-ka] dédup adresse: {n_addr} doublon(s) inter-sources masqué(s)")
195+ except Exception:
196+ import traceback
197+ traceback.print_exc()
198+ n = con.execute("SELECT COUNT(*) c FROM listings WHERE dup_hidden=1").fetchone()["c"]
199+ con.commit()
200+ return n
201+
202+
203+# petites annonces généralistes (republication d'annonces d'ailleurs) : moins
204+# autoritaires que la source primaire (courtier / FSBO première main).
205+# fb_marketplace : fiches anonymes/republication — jamais préférées à un courtier.
206+_PETITES_ANNONCES = {"kijiji", "lespac", "fb_marketplace"}
207+
208+
209+def _source_rank(source: str) -> int:
210+ """Autorité d'une source pour la dédup d'adresse :
211+ 0 = source primaire (bannière/agence/FSBO), 1 = sous-agence (_ag_),
212+ 2 = petites annonces (republication probable)."""
213+ if source in _PETITES_ANNONCES:
214+ return 2
215+ if "_ag_" in source:
216+ return 1
217+ return 0
218+
219+
220+def dedup_by_address(con: sqlite3.Connection) -> int:
221+ """Passe de déduplication conservatrice par ADRESSE (inter-sources).
222+
223+ Clé : n° civique(s) + mots significatifs de la rue + ville normalisée +
224+ type canonique + n° d'app/unité (vide s'il n'y en a pas — deux unités
225+ différentes d'un même immeuble ne partagent JAMAIS la même clé, et une
226+ adresse sans unité ne s'apparie pas à une adresse avec unité). Dans une
227+ clé, seules les fiches au prix identique à ±1 % sont considérées comme
228+ doublons ; si une même source apparaît deux fois dans le groupe (probables
229+ unités jumelles d'un projet neuf), le groupe ENTIER est ignoré. On garde
230+ la fiche la plus autoritaire (source primaire > sous-agence _ag_ >
231+ petites annonces), puis à autorité égale celle qui a un COURTIER/agence
232+ (jamais une fiche anonyme devant un courtier), puis le plus petit uid.
233+ Retourne le nb masqué."""
234+ import re as _re
235+ from .vraiprix_local import _addr_parts, _norm, _muni_norm, _APP_RE
236+ groups: dict[tuple, list] = {}
237+ for r in con.execute(
238+ "SELECT uid, source, address, city, price, property_type,"
239+ " broker_name, agency"
240+ " FROM listings WHERE active=1 AND dup_hidden=0 AND address<>''"
241+ " AND city<>'' AND price IS NOT NULL AND property_type<>''"):
242+ civs, words = _addr_parts(r["address"])
243+ if not civs or not words:
244+ continue
245+ a = _norm(r["address"]).split(",")[0]
246+ mapt = _re.search(_APP_RE, a)
247+ apt = ""
248+ if mapt:
249+ toks = _re.findall(r"[a-z0-9]+", mapt.group(0))
250+ # dernier token = le n° d'unité (le 1er est le mot-clé app/unité/#)
251+ apt = toks[-1] if toks else ""
252+ key = ("-".join(civs), " ".join(sorted(set(words))),
253+ _muni_norm(r["city"]), _norm(r["property_type"]), apt)
254+ anonyme = 0 if (r["broker_name"] or r["agency"]) else 1
255+ groups.setdefault(key, []).append(
256+ (r["price"], _source_rank(r["source"]), anonyme, r["uid"], r["source"]))
257+ hidden = 0
258+ for rows in groups.values():
259+ if len(rows) < 2:
260+ continue
261+ rows.sort() # par prix croissant
262+ cluster: list = []
263+ for row in rows:
264+ if cluster and row[0] > cluster[0][0] * 1.01:
265+ hidden += _mask_cluster(con, cluster)
266+ cluster = []
267+ cluster.append(row)
268+ hidden += _mask_cluster(con, cluster)
269+ return hidden
270+
271+
272+def _mask_cluster(con: sqlite3.Connection, cluster: list) -> int:
273+ """Masque les doublons d'un groupe (même clé d'adresse, prix ±1 %), puis
274+ FUSIONNE en « golden record » : les champs vides de la fiche conservée sont
275+ complétés depuis les doublons masqués (description plus longue, galerie plus
276+ riche, superficies, année, GPS, téléphone) — le meilleur des deux sources."""
277+ if len(cluster) < 2:
278+ return 0
279+ sources = [c[4] for c in cluster]
280+ if len(set(sources)) != len(sources):
281+ return 0 # même source en double = probables unités distinctes : prudence
282+ keep = min(cluster, key=lambda c: (c[1], c[2], c[3])) # (autorité, anonyme, uid)
283+ n = 0
284+ donors = []
285+ for c in cluster:
286+ if c[3] != keep[3]:
287+ con.execute("UPDATE listings SET dup_hidden=1, dup_of=? WHERE uid=?",
288+ (keep[3], c[3]))
289+ donors.append(c[3])
290+ n += 1
291+ try:
292+ _merge_golden(con, keep[3], donors)
293+ except Exception:
294+ pass # la fusion est un bonus — ne jamais casser la dédup
295+ return n
296+
297+
298+_MERGE_NUM_FIELDS = ("bedrooms", "bathrooms", "powder_rooms", "area_sqft",
299+ "lot_sqft", "year_built")
300+
301+
302+def _merge_golden(con: sqlite3.Connection, keep_uid: str, donor_uids: list[str]) -> None:
303+ """Complète les champs vides de `keep_uid` depuis ses doublons masqués."""
304+ if not donor_uids:
305+ return
306+ cols = ("uid, description, images, bedrooms, bathrooms, powder_rooms,"
307+ " area_sqft, lot_sqft, year_built, lat, lng, broker_phone")
308+ keep = con.execute(f"SELECT {cols} FROM listings WHERE uid=?",
309+ (keep_uid,)).fetchone()
310+ if keep is None:
311+ return
312+ sets, args = [], []
313+ kimgs = len(json.loads(keep["images"] or "[]"))
314+ kdesc = len(keep["description"] or "")
315+ best_imgs, best_desc = None, None
316+ donor_rows = con.execute(
317+ f"SELECT {cols} FROM listings WHERE uid IN "
318+ f"({','.join('?' * len(donor_uids))})", donor_uids).fetchall()
319+ merged_num: dict = {}
320+ for d in donor_rows:
321+ di = json.loads(d["images"] or "[]")
322+ if len(di) > max(kimgs, len(json.loads(best_imgs or "[]"))):
323+ best_imgs = d["images"]
324+ dd = d["description"] or ""
325+ if len(dd) > max(kdesc, 80, len(best_desc or "")):
326+ best_desc = dd
327+ for f in _MERGE_NUM_FIELDS:
328+ if keep[f] is None and merged_num.get(f) is None and d[f] is not None:
329+ merged_num[f] = d[f]
330+ # GPS : toujours la PAIRE du même donneur (jamais lat et lng mélangés)
331+ if (keep["lat"] is None and "lat" not in merged_num
332+ and d["lat"] is not None and d["lng"] is not None):
333+ merged_num["lat"], merged_num["lng"] = d["lat"], d["lng"]
334+ if not keep["broker_phone"] and d["broker_phone"] and "broker_phone" not in merged_num:
335+ merged_num["broker_phone"] = d["broker_phone"]
336+ if best_imgs is not None:
337+ sets.append("images=?"); args.append(best_imgs)
338+ if best_desc is not None and kdesc < 80:
339+ sets.append("description=?"); args.append(best_desc)
340+ for f, v in merged_num.items():
341+ sets.append(f"{f}=?"); args.append(v)
342+ if sets:
343+ args.append(keep_uid)
344+ con.execute(f"UPDATE listings SET {', '.join(sets)} WHERE uid=?", args)
345+
346+
347+def connect() -> sqlite3.Connection:
348+ global _SCHEMA_READY
349+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
350+ con = sqlite3.connect(DB_PATH, timeout=60)
351+ con.row_factory = sqlite3.Row
352+ # WAL + busy_timeout : accès concurrents (watcher + web) sans « db is locked ».
353+ con.execute("PRAGMA journal_mode=WAL")
354+ # 120 s : couvre les longues transactions (refresh_dedup ~70 s) sans que les
355+ # autres écrivains (géocodage, vraiprix) ne lèvent « database is locked ».
356+ con.execute("PRAGMA busy_timeout=120000")
357+ con.execute("PRAGMA synchronous=NORMAL")
358+ if not _SCHEMA_READY:
359+ _init_schema(con)
360+ _SCHEMA_READY = True
361+ return con
362+
363+
364+# ---------------------------------------------------------------------------
365+# Synchronisation d'une source
366+# ---------------------------------------------------------------------------
367+
368+def _drift_alert(con: sqlite3.Connection, source: str, found: int,
369+ null_price_rate: float) -> str | None:
370+ """Détecte une dérive du connecteur (chute du volume ou des prix extraits)."""
371+ hist = con.execute(
372+ "SELECT found, stats FROM sync_log WHERE source=? AND ok=1"
373+ " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()
374+ if len(hist) < 3:
375+ return None
376+ med_found = statistics.median(r["found"] for r in hist)
377+ if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:
378+ return (f"dérive: {found} propriété(s) trouvée(s) contre une médiane de "
379+ f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")
380+ if found >= DRIFT_MIN_BASE and null_price_rate >= 0.8:
381+ rates = []
382+ for r in hist:
383+ try:
384+ rates.append(json.loads(r["stats"] or "{}")["null_price_rate"])
385+ except (KeyError, ValueError, TypeError):
386+ continue
387+ if rates and statistics.median(rates) <= 0.3:
388+ return (f"dérive: {null_price_rate:.0%} des propriétés sans prix "
389+ f"(habituellement {statistics.median(rates):.0%}) — "
390+ "le format de la source a probablement changé")
391+ return None
392+
393+
394+def sync_source(con: sqlite3.Connection, source: str,
395+ listings: list[PropertyListing]) -> dict:
396+ """Synchronise les propriétés d'une source.
397+
398+ - nouvelle propriété -> insertion
399+ - propriété modifiée -> mise à jour (comparaison de content_hash)
400+ - propriété disparue -> miss_count += 1, puis active=0 après MISS_GRACE
401+ exécutions consécutives (vendue ou retirée)
402+ - dérive détectée -> alerte consignée, retraits suspendus
403+ """
404+ now = time.time()
405+ added = updated = 0
406+ seen_uids = set()
407+
408+ n = len(listings)
409+ null_price = sum(1 for l in listings if l.price is None)
410+ null_addr = sum(1 for l in listings if not l.address)
411+ null_price_rate = round(null_price / n, 3) if n else 0.0
412+
413+ alert = _drift_alert(con, source, n, null_price_rate)
414+
415+ for lst in listings:
416+ seen_uids.add(lst.uid)
417+ h = lst.content_hash()
418+ row = con.execute("SELECT content_hash, price FROM listings WHERE uid=?",
419+ (lst.uid,)).fetchone()
420+ params = dict(
421+ uid=lst.uid, source=lst.source, external_id=lst.external_id,
422+ url=lst.url, title=lst.title, address=lst.address,
423+ sector=lst.sector, city=lst.city, region=lst.region,
424+ property_type=lst.property_type, price=lst.price,
425+ price_label=lst.price_label, bedrooms=lst.bedrooms,
426+ bathrooms=lst.bathrooms, powder_rooms=lst.powder_rooms,
427+ area_sqft=lst.area_sqft, lot_sqft=lst.lot_sqft,
428+ year_built=lst.year_built, mls=lst.mls, status=lst.status,
429+ broker_name=lst.broker_name, broker_phone=lst.broker_phone,
430+ agency=lst.agency,
431+ description=lst.description,
432+ features=json.dumps(lst.features, ensure_ascii=False),
433+ details=json.dumps(lst.details, ensure_ascii=False),
434+ images=json.dumps(lst.images, ensure_ascii=False),
435+ lat=lst.lat, lng=lst.lng, content_hash=h, now=now,
436+ )
437+ if row is None:
438+ con.execute(
439+ """INSERT INTO listings (uid, source, external_id, url, title,
440+ address, sector, city, region, property_type, price,
441+ price_label, bedrooms, bathrooms, powder_rooms, area_sqft,
442+ lot_sqft, year_built, mls, status, broker_name, broker_phone,
443+ agency, description, features, details, images, lat, lng,
444+ content_hash, first_seen, last_seen, updated_at,
445+ miss_count, active)
446+ VALUES (:uid,:source,:external_id,:url,:title,:address,
447+ :sector,:city,:region,:property_type,:price,:price_label,
448+ :bedrooms,:bathrooms,:powder_rooms,:area_sqft,:lot_sqft,
449+ :year_built,:mls,:status,:broker_name,:broker_phone,
450+ :agency,:description,:features,:details,:images,:lat,:lng,
451+ :content_hash,:now,:now,:now,0,1)""", params)
452+ if lst.price is not None:
453+ con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
454+ (lst.uid, now, lst.price))
455+ added += 1
456+ elif row["content_hash"] != h:
457+ # COALESCE : ne jamais écraser par null des coordonnées géocodées ni
458+ # les année/superficies remplies par enrichissement (rôle d'évaluation,
459+ # fiche détail) quand la source liste ne les fournit pas
460+ con.execute(
461+ """UPDATE listings SET url=:url, title=:title,
462+ address=:address, sector=:sector, city=:city, region=:region,
463+ property_type=:property_type, price=:price,
464+ price_label=:price_label, bedrooms=:bedrooms,
465+ bathrooms=:bathrooms, powder_rooms=:powder_rooms,
466+ area_sqft=COALESCE(:area_sqft, area_sqft),
467+ lot_sqft=COALESCE(:lot_sqft, lot_sqft),
468+ year_built=COALESCE(:year_built, year_built),
469+ mls=:mls, status=:status,
470+ broker_name=:broker_name, broker_phone=:broker_phone,
471+ agency=:agency, description=:description, features=:features,
472+ details=:details, images=:images,
473+ lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
474+ content_hash=:content_hash, last_seen=:now,
475+ updated_at=:now, miss_count=0, active=1
476+ WHERE uid=:uid""", params)
477+ if lst.price != row["price"]: # baisse/hausse de prix -> historique
478+ con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
479+ (lst.uid, now, lst.price))
480+ updated += 1
481+ else:
482+ con.execute(
483+ "UPDATE listings SET last_seen=?, miss_count=0, active=1 WHERE uid=?",
484+ (now, lst.uid))
485+
486+ # Propriétés de cette source qui n'apparaissent plus : délai de grâce,
487+ # puis désactivation (vendue/retirée). Suspendu si dérive détectée.
488+ removed = missed = 0
489+ if not alert:
490+ for r in con.execute(
491+ "SELECT uid, miss_count FROM listings WHERE source=? AND active=1",
492+ (source,)).fetchall():
493+ if r["uid"] in seen_uids:
494+ continue
495+ missed += 1
496+ if r["miss_count"] + 1 >= MISS_GRACE:
497+ con.execute(
498+ "UPDATE listings SET active=0, miss_count=?, updated_at=?"
499+ " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"]))
500+ removed += 1
501+ else:
502+ con.execute("UPDATE listings SET miss_count=miss_count+1 WHERE uid=?",
503+ (r["uid"],))
504+
505+ stats = {
506+ "null_price_rate": null_price_rate,
507+ "null_address_rate": round(null_addr / n, 3) if n else 0.0,
508+ "missed": missed,
509+ }
510+ if alert:
511+ stats["alert"] = alert
512+ con.execute(
513+ "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"
514+ " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",
515+ (source, now, n, added, updated, removed, alert or "ok",
516+ json.dumps(stats, ensure_ascii=False)))
517+ con.commit()
518+ out = {"source": source, "found": n, "added": added,
519+ "updated": updated, "removed": removed}
520+ if alert:
521+ out["alert"] = alert
522+ return out
523+
524+
525+def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
526+ con.execute(
527+ "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
528+ " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
529+ con.commit()
530+
531+
532+# ---------------------------------------------------------------------------
533+# Cache des pages détail (« détail si nouveau/modifié »)
534+# ---------------------------------------------------------------------------
535+
536+def get_cached_detail(con: sqlite3.Connection, source: str,
537+ external_id: str, key: str) -> dict | None:
538+ """Payload détail mis en cache si la clé (hash liste) n'a pas changé."""
539+ row = con.execute(
540+ "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",
541+ (source, external_id)).fetchone()
542+ if row and row["key"] == key and row["payload"]:
543+ try:
544+ return json.loads(row["payload"])
545+ except ValueError:
546+ return None
547+ return None
548+
549+
550+def get_stale_detail(con: sqlite3.Connection, source: str,
551+ external_id: str) -> dict | None:
552+ """Payload détail SANS vérifier la clé — repli « périmé plutôt que rien »
553+ quand le budget de re-fetch d'un cycle est épuisé (ex. bump de version de
554+ clé) : la fiche garde photos/détails existants en attendant son re-parse."""
555+ row = con.execute(
556+ "SELECT payload FROM detail_cache WHERE source=? AND external_id=?",
557+ (source, external_id)).fetchone()
558+ if row and row["payload"]:
559+ try:
560+ return json.loads(row["payload"])
561+ except ValueError:
562+ return None
563+ return None
564+
565+
566+def put_cached_detail(con: sqlite3.Connection, source: str,
567+ external_id: str, key: str, payload: dict) -> None:
568+ con.execute(
569+ "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"
570+ " VALUES (?,?,?,?,?)"
571+ " ON CONFLICT(source, external_id) DO UPDATE SET"
572+ " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",
573+ (source, external_id, key, json.dumps(payload, ensure_ascii=False),
574+ time.time()))
575+ con.commit()
added immoka/favorites.py +75 −0
@@ -0,0 +1,75 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# favorites.py : routes API des favoris (♥) — session Groupe KA requise.
5+# AUCUN stockage local : lecture et écriture passent par le hub Groupe KA
6+# (magasin central « Mon univers Ka »), voir immoka/hubfav.py.
7+# GET /api/favorites -> {ids:[item_id…], items:[…]} (depuis le hub)
8+# POST /api/favorites/toggle {on:bool, item:{item_id,title,…}} -> {ok,on}
9+# 401 si pas de session, ou si le compte n'est pas relié au hub (pas de
10+# ka_id) — le frontend redirige alors vers /api/auth/ka/login.
11+# -----------------------------------------------------------------------------
12+from __future__ import annotations
13+
14+from fastapi import APIRouter, Request
15+from fastapi.responses import JSONResponse
16+
17+from .auth import current_user
18+from .hubfav import hub_list, hub_toggle, invalidate
19+
20+router = APIRouter(prefix="/api/favorites")
21+
22+# champs acceptés d'un item de favori (et longueur maximale)
23+_ITEM_FIELDS = {"item_id": 120, "title": 200, "subtitle": 200,
24+ "price_label": 60, "image_url": 500, "url": 500}
25+
26+
27+def _ka_id(request: Request) -> str | None:
28+ """KA-ID de la session, ou None (pas de session / compte non relié)."""
29+ user = current_user(request)
30+ if not user:
31+ return None
32+ ka_id = str(user.get("ka_id") or "")
33+ return ka_id if ka_id.startswith("ka-") else None
34+
35+
36+@router.get("")
37+def list_favorites(request: Request):
38+ """Favoris immo-ka du membre connecté, lus du hub Groupe KA."""
39+ ka_id = _ka_id(request)
40+ if not ka_id:
41+ return JSONResponse({"error": "non connecté"}, status_code=401)
42+ items = hub_list(ka_id)
43+ return {"ids": [it.get("item_id") for it in items if it.get("item_id")],
44+ "items": items}
45+
46+
47+@router.post("/toggle")
48+async def toggle_favorite(request: Request):
49+ """Ajoute (on=true) ou retire (on=false) un favori — poussé au hub."""
50+ ka_id = _ka_id(request)
51+ if not ka_id:
52+ return JSONResponse({"error": "non connecté"}, status_code=401)
53+ try:
54+ body = await request.json()
55+ assert isinstance(body, dict)
56+ except Exception:
57+ return JSONResponse({"error": "corps JSON attendu"}, status_code=400)
58+ on = bool(body.get("on"))
59+ raw = body.get("item") or {}
60+ item = {k: str(raw.get(k) or "")[:n] for k, n in _ITEM_FIELDS.items()}
61+ if not item["item_id"]:
62+ return JSONResponse({"error": "item.item_id requis"}, status_code=400)
63+ ok = hub_toggle(ka_id, "add" if on else "remove", item)
64+ # signal fort du moteur de préférences KA ID (features lues de la BD)
65+ from . import db as _db, kaid as _kaid
66+ con = _db.connect()
67+ row = con.execute("SELECT * FROM listings WHERE uid=?",
68+ (item["item_id"],)).fetchone()
69+ con.close()
70+ from .web import _kaid_features as _feats
71+ _kaid.track({"ka_id": ka_id}, "favorite" if on else "unfavorite",
72+ entity_type="property", entity_id=item["item_id"],
73+ features=_feats(dict(row)) if row else None)
74+ invalidate(ka_id) # le prochain GET relit l'état frais du hub
75+ return {"ok": ok, "on": on}
added immoka/geocode.py +654 −0
@@ -0,0 +1,654 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# geocode.py : géocodage des adresses via Nominatim (OpenStreetMap)
5+# - cache persistant (table geocode_cache) : une adresse n'est géocodée
6+# qu'une seule fois, et par immeuble (adresse normalisée), pas par annonce
7+# - politesse : 1 requête/seconde max, User-Agent identifiable
8+# - validation : les coordonnées doivent tomber dans la région attendue
9+# (bounding box par ville), sinon flag geocode_failed — jamais de
10+# coordonnées bidon
11+# - rigueur : chaque résultat est validé contre le CENTROÏDE de la ville
12+# annoncée (médiane des fiches déjà géocodées, sinon Nominatim, en cache) —
13+# à plus de VILLE_RADIUS_KM du centroïde, le candidat est rejeté. Corrige
14+# la classe de bug « rue homonyme dans une autre ville » (ex. rue des
15+# Perdrix de Jonquière géocodée à Saint-Augustin).
16+# -----------------------------------------------------------------------------
17+from __future__ import annotations
18+
19+import json
20+import math
21+import sqlite3
22+import statistics
23+
24+import re
25+import time
26+
27+import requests
28+
29+from . import db
30+from .normalize import strip_accents
31+
32+NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
33+# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre
34+# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…)
35+AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
36+ "Adresse_Geocodage/GeocodeServer/findAddressCandidates")
37+AQ_MIN_SCORE = 75
38+# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête
39+# User-Agent contient des accents (latin-1)
40+USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"
41+REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s
42+REQUEST_DELAY_AQ = 0.12 # Adresses Québec (MERN) : pas de limite stricte
43+RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours
44+
45+# Bounding boxes (lat_min, lat_max, lng_min, lng_max)
46+_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin
47+# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord
48+# et la Gaspésie (expansion provinciale)
49+_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00)
50+# territoire complet Groupe Ka : Québec + Ontario (Windsor -83°, Kenora -94.5°)
51+# — même boîte que schema.finalize()
52+_BBOX_KA = (41.60, 63.00, -95.50, -56.00)
53+
54+_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures",
55+ "l'ancienne-lorette", "ancienne-lorette"}
56+
57+# Validation « rigoureuse » : distance max entre un résultat de géocodage et une
58+# référence de la ville annoncée. 40 km tolère les grandes municipalités
59+# fusionnées (Saguenay, Gatineau, La Tuque…) tout en rejetant les rues
60+# homonymes d'une autre région (Saint-Augustin ↔ Jonquière = ~190 km).
61+# ⚠ Le Québec compte de nombreuses municipalités HOMONYMES (deux L'Ange-Gardien,
62+# deux Saint-Donat, deux Sainte-Félicité…) : la référence n'est donc jamais un
63+# centroïde unique mais un ENSEMBLE de grappes de fiches (chaque homonyme garde
64+# la sienne) + le point Nominatim de la ville.
65+VILLE_RADIUS_KM = 40.0
66+CLUSTER_MIN = 3 # une grappe de fiches devient une référence de confiance dès n >= 3
67+
68+
69+def _bbox_for(city: str) -> tuple[float, float, float, float]:
70+ key = strip_accents((city or "").strip().lower())
71+ return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_KA
72+
73+
74+def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool:
75+ return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3]
76+
77+
78+def _dist_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
79+ """Distance haversine en kilomètres."""
80+ p1, p2 = math.radians(lat1), math.radians(lat2)
81+ a = (math.sin((p2 - p1) / 2) ** 2
82+ + math.cos(p1) * math.cos(p2) * math.sin(math.radians(lng2 - lng1) / 2) ** 2)
83+ return 2 * 6371.0 * math.asin(math.sqrt(a))
84+
85+
86+def norm_key(address: str) -> str:
87+ """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.)."""
88+ s = strip_accents((address or "").lower())
89+ s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s)
90+ s = re.sub(r"[^a-z0-9]+", " ", s)
91+ return re.sub(r"\s+", " ", s).strip()
92+
93+
94+class Geocoder:
95+ def __init__(self, con) -> None:
96+ self.con = con
97+ self.session = requests.Session()
98+ self.session.headers["User-Agent"] = USER_AGENT
99+ self._last = 0.0
100+ self._villes: dict[str, dict | None] = {} # norm(ville) -> {lat,lng,muni}
101+ self._med: dict[str, list[tuple]] | None = None # norm(ville) -> grappes (lat,lng,n)
102+ self.ville_api_calls = 0 # requêtes Nominatim « ville »
103+
104+ @staticmethod
105+ def _clean(address: str) -> str:
106+ """Nettoyages qui aident Nominatim sur les adresses québécoises."""
107+ s = address.strip()
108+ # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique
109+ s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s)
110+ # « 6275 et 6375 boulevard X » -> premier civique
111+ s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s)
112+ # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu
113+ s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I)
114+ # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau
115+ s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I)
116+ # « Vanier (Québec) » -> « Vanier, Québec »
117+ s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I)
118+ # abréviations cardinales : « Rue Salaberry O » -> « Ouest »
119+ s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s)
120+ s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s)
121+ return s
122+
123+ def _build_query(self, address: str, city: str) -> str:
124+ q = self._clean(address)
125+ key = strip_accents(q.lower())
126+ if city and strip_accents(city.lower()) not in key:
127+ q += f", {city}"
128+ if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower():
129+ q += ", Québec"
130+ return q + ", Canada"
131+
132+ def _query_nominatim(self, params: dict) -> tuple[float, float] | None:
133+ """Une requête Nominatim, throttlée."""
134+ wait = REQUEST_DELAY - (time.time() - self._last)
135+ if wait > 0:
136+ time.sleep(wait)
137+ try:
138+ resp = self.session.get(NOMINATIM_URL, params={
139+ "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params,
140+ }, timeout=20)
141+ self._last = time.time()
142+ resp.raise_for_status()
143+ hits = resp.json()
144+ except Exception:
145+ self._last = time.time()
146+ return None
147+ if not hits:
148+ return None
149+ try:
150+ return float(hits[0]["lat"]), float(hits[0]["lon"])
151+ except (KeyError, ValueError):
152+ return None
153+
154+ def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None:
155+ """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).
156+
157+ Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE
158+ pour éviter les correspondances approximatives sur une autre rue.
159+ """
160+ premier = self._clean(address).split(",")[0].strip()
161+ ville = (city or "Québec").strip()
162+ wait = REQUEST_DELAY_AQ - (time.time() - self._last)
163+ if wait > 0:
164+ time.sleep(wait)
165+ try:
166+ resp = self.session.get(AQ_URL, params={
167+ "SingleLine": f"{premier}, {ville}",
168+ "f": "json", "outSR": 4326, "maxLocations": 1,
169+ }, timeout=20)
170+ self._last = time.time()
171+ resp.raise_for_status()
172+ cands = resp.json().get("candidates") or []
173+ except Exception:
174+ self._last = time.time()
175+ return None
176+ if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE:
177+ return None
178+ loc = cands[0].get("location") or {}
179+ try:
180+ return float(loc["y"]), float(loc["x"])
181+ except (KeyError, ValueError):
182+ return None
183+
184+ def _attempts(self, address: str, city: str, muni: str | None = None) -> list[dict]:
185+ """Stratégies de requête, de la plus précise à la moins précise.
186+
187+ 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province)
188+ 2) idem avec la municipalité OFFICIELLE (« Saguenay ») quand la ville
189+ annoncée est un secteur (« Jonquière (Lac-Kénogami) ») inconnu des API
190+ 3) recherche libre complète
191+ 4) structurée rue seule -> centroïde de rue (repli acceptable pour la
192+ carte quand le numéro civique est absent d'OpenStreetMap)
193+ """
194+ premier = self._clean(address).split(",")[0].strip()
195+ ville = (city or "").strip()
196+ if not ville:
197+ k = strip_accents(address.lower())
198+ ville = "Lévis" if "levis" in k else "Québec"
199+ villes = [ville]
200+ # premier mot de la ville (« Jonquiere Lac Kenogami » -> « Jonquiere »)
201+ if " " in ville:
202+ villes.append(ville.split(" ")[0])
203+ if muni and norm_key(muni) not in {norm_key(v) for v in villes}:
204+ villes.append(muni)
205+
206+ tries: list[dict] = []
207+ m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier)
208+ for v in villes:
209+ commun = {"city": v, "state": "Québec", "country": "Canada"}
210+ if m:
211+ tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun})
212+ tries.append({"q": self._build_query(address, city)})
213+ for v in villes:
214+ commun = {"city": v, "state": "Québec", "country": "Canada"}
215+ if m:
216+ tries.append({"street": m.group(2), **commun})
217+ return tries
218+
219+ # ----- références de ville (validation rigoureuse) -----------------------
220+ def _clusters(self) -> dict[str, list[tuple]]:
221+ """Grappes de coordonnées par ville, depuis les fiches déjà géocodées
222+ (regroupement glouton, rayon VILLE_RADIUS_KM). Chaque grappe :
223+ (lat médiane, lng médiane, n). Les municipalités homonymes produisent
224+ naturellement une grappe chacune — aucune n'écrase l'autre."""
225+ if self._med is None:
226+ acc: dict[str, list] = {}
227+ for r in self.con.execute(
228+ """SELECT city, lat, lng FROM listings
229+ WHERE active=1 AND lat IS NOT NULL AND city<>''"""):
230+ acc.setdefault(norm_key(r["city"]), []).append((r["lat"], r["lng"]))
231+ out: dict[str, list[tuple]] = {}
232+ for k, pts in acc.items():
233+ clusters: list[list] = [] # [ [lat_c, lng_c, [points]] ]
234+ for lat, lng in pts:
235+ for cl in clusters:
236+ if _dist_km(lat, lng, cl[0], cl[1]) <= VILLE_RADIUS_KM:
237+ cl[2].append((lat, lng))
238+ cl[0] = statistics.median(p[0] for p in cl[2])
239+ cl[1] = statistics.median(p[1] for p in cl[2])
240+ break
241+ else:
242+ clusters.append([lat, lng, [(lat, lng)]])
243+ out[k] = [(cl[0], cl[1], len(cl[2])) for cl in clusters]
244+ self._med = out
245+ return self._med
246+
247+ def resolve_ville(self, city: str, allow_api: bool = True) -> dict | None:
248+ """Ville -> {lat, lng, muni} (muni = municipalité officielle, ex.
249+ « Jonquière (Lac-Kénogami) » -> Saguenay). Cache : geocode_cache,
250+ clé « ville:<norm> ». None si irrésoluble."""
251+ k = norm_key(city or "")
252+ if not k:
253+ return None
254+ if k in self._villes:
255+ return self._villes[k]
256+ key = "ville:" + k
257+ row = self.con.execute(
258+ "SELECT lat, lng, muni, failed, ts FROM geocode_cache WHERE address=?",
259+ (key,)).fetchone()
260+ if row is not None:
261+ if not row["failed"]:
262+ v = {"lat": row["lat"], "lng": row["lng"], "muni": row["muni"]}
263+ self._villes[k] = v
264+ return v
265+ if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
266+ self._villes[k] = None
267+ return None
268+ if not allow_api:
269+ return None # pas de mémoire : on réessaiera quand l'API sera permise
270+ v = None
271+ # priorité au Québec ; à défaut, l'Ontario (extension immo-ka)
272+ for prov in ("Québec", "Ontario"):
273+ self.ville_api_calls += 1
274+ wait = REQUEST_DELAY - (time.time() - self._last)
275+ if wait > 0:
276+ time.sleep(wait)
277+ try:
278+ resp = self.session.get(NOMINATIM_URL, params={
279+ "q": f"{city}, {prov}, Canada", "format": "jsonv2", "limit": 1,
280+ "countrycodes": "ca", "addressdetails": 1}, timeout=20)
281+ self._last = time.time()
282+ resp.raise_for_status()
283+ hits = resp.json()
284+ except Exception:
285+ self._last = time.time()
286+ return None # erreur réseau : ne pas cacher un échec définitif
287+ if hits:
288+ try:
289+ lat, lng = float(hits[0]["lat"]), float(hits[0]["lon"])
290+ if _in_bbox(lat, lng, _BBOX_KA):
291+ a = hits[0].get("address") or {}
292+ muni = (a.get("city") or a.get("town") or a.get("village")
293+ or a.get("municipality"))
294+ v = {"lat": lat, "lng": lng, "muni": muni}
295+ except (KeyError, ValueError, TypeError):
296+ v = None
297+ if v:
298+ break
299+ self.con.execute(
300+ "INSERT INTO geocode_cache (address, lat, lng, muni, provider, failed, ts)"
301+ " VALUES (?,?,?,?,?,?,?) ON CONFLICT(address) DO UPDATE SET"
302+ " lat=excluded.lat, lng=excluded.lng, muni=excluded.muni,"
303+ " provider=excluded.provider, failed=excluded.failed, ts=excluded.ts",
304+ (key, v["lat"] if v else None, v["lng"] if v else None,
305+ v["muni"] if v else None, "ville_nominatim", 0 if v else 1, time.time()))
306+ self.con.commit()
307+ self._villes[k] = v
308+ return v
309+
310+ def city_refs(self, city: str, allow_api: bool = True) -> list[tuple[float, float]]:
311+ """Points de référence d'une ville : grappes de fiches dignes de
312+ confiance (n >= CLUSTER_MIN — une par municipalité homonyme) + point
313+ Nominatim de la ville. Liste vide = ville inconnue (-> bbox seule)."""
314+ k = norm_key(city or "")
315+ if not k:
316+ return []
317+ refs = [(cl[0], cl[1]) for cl in self._clusters().get(k, [])
318+ if cl[2] >= CLUSTER_MIN]
319+ v = self.resolve_ville(city, allow_api=allow_api)
320+ if v:
321+ refs.append((v["lat"], v["lng"]))
322+ return refs
323+
324+ def coords_ok(self, c: tuple[float, float] | None, city: str,
325+ refs: list[tuple[float, float]] | None = None) -> bool:
326+ """Valide un candidat : dans la bbox ET à <= VILLE_RADIUS_KM d'au moins
327+ une référence de la ville annoncée (quand elle en a)."""
328+ if c is None or not _in_bbox(*c, _bbox_for(city)):
329+ return False
330+ if refs is None:
331+ refs = self.city_refs(city)
332+ return not refs or any(_dist_km(c[0], c[1], r[0], r[1]) <= VILLE_RADIUS_KM
333+ for r in refs)
334+
335+ def resolve(self, address: str, city: str) -> tuple[float, float] | None:
336+ """Adresse -> (lat, lng), via cache puis AQ/Nominatim. None si introuvable.
337+
338+ Rigueur : chaque candidat doit être cohérent avec la ville annoncée
339+ (centroïde <= VILLE_RADIUS_KM). Si la ville annoncée est un secteur
340+ inconnu d'Adresses Québec, on réessaie avec la municipalité officielle.
341+ """
342+ key = norm_key(address)
343+ if not key or len(key) < 6:
344+ return None
345+ row = self.con.execute(
346+ "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?",
347+ (key,)).fetchone()
348+ if row is not None:
349+ if not row["failed"]:
350+ return (row["lat"], row["lng"])
351+ if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER:
352+ return None # échec récent : ne pas marteler l'API
353+
354+ refs = self.city_refs(city)
355+ coords = None
356+ # Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de
357+ # limite stricte. Nominatim (1 req/s) seulement en repli.
358+ provider = "adresses_quebec"
359+ c = self._query_adresses_quebec(address, city)
360+ if self.coords_ok(c, city, refs):
361+ coords = c
362+ muni = None
363+ if coords is None:
364+ # ville annoncée = secteur (« Jonquière (Lac-Kénogami) ») ? AQ ne
365+ # connaît que les municipalités officielles -> retenter avec elle.
366+ v = self.resolve_ville(city)
367+ muni = (v or {}).get("muni")
368+ if muni and norm_key(muni) != norm_key(city or ""):
369+ c = self._query_adresses_quebec(address, muni)
370+ if self.coords_ok(c, city, refs):
371+ coords, provider = c, "adresses_quebec_muni"
372+ if coords is None:
373+ provider = "nominatim"
374+ for params in self._attempts(address, city, muni):
375+ c = self._query_nominatim(params)
376+ if self.coords_ok(c, city, refs):
377+ coords = c
378+ break
379+ ok = coords is not None
380+ self.con.execute(
381+ "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)"
382+ " VALUES (?,?,?,?,?,?)"
383+ " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat,"
384+ " lng=excluded.lng, provider=excluded.provider,"
385+ " failed=excluded.failed, ts=excluded.ts",
386+ (key, coords[0] if ok else None, coords[1] if ok else None,
387+ provider, 0 if ok else 1, time.time()))
388+ self.con.commit()
389+ return coords if ok else None
390+
391+
392+def run(limit: int | None = None) -> dict:
393+ """Géocode les annonces actives sans coordonnées, par adresse unique.
394+
395+ `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de
396+ cache sont gratuits et toujours appliqués).
397+ """
398+ con = db.connect()
399+ geo = Geocoder(con)
400+ rows = con.execute(
401+ """SELECT uid, address, city FROM listings
402+ WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
403+ AND dup_hidden=0
404+ ORDER BY address""").fetchall()
405+
406+ # regrouper par immeuble (adresse normalisée)
407+ groupes: dict[str, list] = {}
408+ for r in rows:
409+ groupes.setdefault(norm_key(r["address"]), []).append(r)
410+
411+ done = failed = requests_made = 0
412+ for key, members in groupes.items():
413+ if not key:
414+ continue
415+ cached = con.execute(
416+ "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone()
417+ if cached is None:
418+ if limit is not None and requests_made >= limit:
419+ continue
420+ requests_made += 1
421+ try:
422+ coords = geo.resolve(members[0]["address"], members[0]["city"])
423+ if coords:
424+ for r in members:
425+ con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
426+ (coords[0], coords[1], r["uid"]))
427+ done += len(members)
428+ else:
429+ # introuvable/hors zone : flag ; jamais de coordonnées bidon
430+ for r in members:
431+ con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?",
432+ (r["uid"],))
433+ failed += len(members)
434+ con.commit()
435+ except sqlite3.OperationalError:
436+ # verrou transitoire (watcher/refresh_dedup) : on saute cette adresse,
437+ # elle sera reprise au prochain passage — jamais crasher tout le run.
438+ try:
439+ con.rollback()
440+ except sqlite3.Error:
441+ pass
442+ time.sleep(1.0)
443+ continue
444+
445+ con.close()
446+ stats = {"geocoded": done, "failed": failed,
447+ "unique_addresses": len(groupes), "api_requests": requests_made}
448+ print(f"[immo-ka] geocode {stats}")
449+ return stats
450+
451+
452+AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/"
453+ "Adresse_Geocodage/GeocodeServer/geocodeAddresses")
454+BATCH_SIZE = 200
455+RESCUE_MAX = 150 # rattrapages resolve() max par passe (Nominatim = 1 req/s)
456+
457+
458+def strip_bad_source_coords(con, listings) -> int:
459+ """Garde d'ingestion : annule les lat/lng fournis par un connecteur quand
460+ ils contredisent la ville annoncée (> VILLE_RADIUS_KM du centroïde connu).
461+ Cache seulement (médianes + villes déjà résolues) : aucun appel réseau,
462+ la synchronisation reste rapide. Retourne le nombre de paires annulées."""
463+ geo = Geocoder(con)
464+ n = 0
465+ for lst in listings:
466+ if lst.lat is None or lst.lng is None or not (lst.city or "").strip():
467+ continue
468+ refs = geo.city_refs(lst.city, allow_api=False)
469+ if refs and not geo.coords_ok((lst.lat, lst.lng), lst.city, refs):
470+ lst.lat = lst.lng = None
471+ n += 1
472+ return n
473+
474+
475+def run_batch(limit: int | None = None) -> dict:
476+ """Géocodage EN LOT via Adresses Québec (`geocodeAddresses`, jusqu'à 1000
477+ adresses/requête) — ~35 requêtes pour tout le parc au lieu de dizaines de
478+ milliers. Beaucoup plus rapide que le mode 1-par-1 (et que Nominatim)."""
479+ con = db.connect()
480+ geo = Geocoder(con) # pour réutiliser _clean / bbox
481+ rows = con.execute(
482+ """SELECT uid, address, city FROM listings
483+ WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0
484+ AND dup_hidden=0 AND COALESCE(region,'')<>'Ontario'
485+ ORDER BY address""").fetchall()
486+ # 1 entrée par immeuble (adresse normalisée) ; on saute les échecs en cache
487+ uniq: dict[str, dict] = {}
488+ for r in rows:
489+ k = norm_key(r["address"])
490+ if not k or len(k) < 6:
491+ continue
492+ u = uniq.setdefault(k, {"address": r["address"], "city": r["city"],
493+ "members": []})
494+ u["members"].append(r["uid"])
495+ pending = []
496+ for k, u in uniq.items():
497+ c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?",
498+ (k,)).fetchone()
499+ if c is not None and not c["failed"]: # déjà résolu : appliquer direct
500+ for uid in u["members"]:
501+ con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
502+ (c["lat"], c["lng"], uid))
503+ continue
504+ if c is not None and c["failed"]:
505+ continue
506+ pending.append((k, u))
507+ con.commit()
508+ if limit is not None:
509+ pending = pending[:limit]
510+
511+ session = requests.Session()
512+ session.headers["User-Agent"] = USER_AGENT
513+ done = failed = 0
514+ rescue: list[tuple[str, dict]] = [] # échecs du lot -> resolve() individuel
515+ for i in range(0, len(pending), BATCH_SIZE):
516+ chunk = pending[i:i + BATCH_SIZE]
517+ records = {"records": [
518+ {"attributes": {"OBJECTID": j,
519+ "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, "
520+ f"{(u['city'] or 'Québec').strip()}"}}
521+ for j, (_k, u) in enumerate(chunk)]}
522+ try:
523+ # POST obligatoire : le param `addresses` (JSON de N records) est trop
524+ # long pour une URL GET dès quelques dizaines d'adresses.
525+ resp = session.post(AQ_BATCH_URL, data={
526+ "addresses": json.dumps(records, ensure_ascii=False),
527+ "f": "json", "outSR": 4326}, timeout=90)
528+ locs = resp.json().get("locations", [])
529+ except Exception as e:
530+ print(f"[immo-ka] geocode-batch lot {i//BATCH_SIZE} ERREUR: {str(e)[:80]}")
531+ time.sleep(1.0)
532+ continue
533+ by_id = {l["attributes"].get("ResultID"): l for l in locs}
534+ for j, (k, u) in enumerate(chunk):
535+ loc = by_id.get(j)
536+ coords = None
537+ if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE:
538+ lc = loc.get("location") or {}
539+ try:
540+ cand = (float(lc["y"]), float(lc["x"]))
541+ # bbox + cohérence avec le centroïde de la ville annoncée
542+ if geo.coords_ok(cand, u["city"]):
543+ coords = cand
544+ except (KeyError, ValueError, TypeError):
545+ coords = None
546+ try:
547+ if coords:
548+ for uid in u["members"]:
549+ con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
550+ (coords[0], coords[1], uid))
551+ con.execute(
552+ "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)"
553+ " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET"
554+ " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider,"
555+ " failed=0, ts=excluded.ts",
556+ (k, coords[0], coords[1], "adresses_quebec_batch", time.time()))
557+ done += len(u["members"])
558+ con.commit()
559+ else:
560+ # pas de verdict tout de suite : resolve() réessaiera avec la
561+ # municipalité officielle + Nominatim (borné par RESCUE_MAX)
562+ rescue.append((k, u))
563+ except sqlite3.OperationalError:
564+ try: con.rollback()
565+ except sqlite3.Error: pass
566+ time.sleep(1.0)
567+ print(f"[immo-ka] geocode-batch {i+len(chunk)}/{len(pending)} "
568+ f"(résolues {done}, à rattraper {len(rescue)})")
569+
570+ # Rattrapage 1-par-1 des échecs du lot : variantes de municipalité (AQ) puis
571+ # Nominatim, validées par centroïde. resolve() écrit lui-même le verdict au
572+ # cache (succès ou échec 30 j) ; au-delà de RESCUE_MAX, on laisse les fiches
573+ # intactes (ni cache ni flag) -> reprises aux prochains passages.
574+ rescued = 0
575+ for k, u in rescue[:RESCUE_MAX]:
576+ try:
577+ coords = geo.resolve(u["address"], u["city"])
578+ if coords:
579+ for uid in u["members"]:
580+ con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?",
581+ (coords[0], coords[1], uid))
582+ done += len(u["members"])
583+ rescued += len(u["members"])
584+ else:
585+ for uid in u["members"]:
586+ con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", (uid,))
587+ failed += len(u["members"])
588+ con.commit()
589+ except sqlite3.OperationalError:
590+ try: con.rollback()
591+ except sqlite3.Error: pass
592+ time.sleep(1.0)
593+ con.close()
594+ stats = {"geocoded": done, "rescued": rescued, "failed": failed,
595+ "deferred": max(0, len(rescue) - RESCUE_MAX),
596+ "batches": (len(pending)+BATCH_SIZE-1)//BATCH_SIZE}
597+ print(f"[immo-ka] geocode-batch {stats}")
598+ return stats
599+
600+
601+def run_audit(nominatim_budget: int | None = None, apply: bool = True) -> dict:
602+ """Audit de cohérence géographique des fiches DÉJÀ géocodées.
603+
604+ Détecte les coordonnées à plus de VILLE_RADIUS_KM de TOUTE référence de
605+ leur ville — grappes de fiches (une par municipalité homonyme) + point
606+ Nominatim — puis les remet en file de géocodage : lat/lng annulés,
607+ geocode_failed remis à 0, entrée de cache purgée. Une fiche n'est JAMAIS
608+ flaguée sans référence indépendante confirmant l'incohérence (les
609+ homonymes et villes irrésolues sont épargnés). `nominatim_budget` borne
610+ les résolutions de villes inconnues (1 req/s) ; None = illimité.
611+ `apply=False` = rapport seul.
612+ """
613+ con = db.connect()
614+ geo = Geocoder(con)
615+ rows = con.execute(
616+ """SELECT uid, address, city, lat, lng FROM listings
617+ WHERE active=1 AND lat IS NOT NULL AND city<>''""").fetchall()
618+ flagged, inconnues, reportees = [], 0, 0
619+ for r in rows:
620+ k = norm_key(r["city"])
621+ clusters = [(cl[0], cl[1]) for cl in geo._clusters().get(k, [])
622+ if cl[2] >= CLUSTER_MIN]
623+ dists = [_dist_km(r["lat"], r["lng"], c[0], c[1]) for c in clusters]
624+ if dists and min(dists) <= VILLE_RADIUS_KM:
625+ continue # cohérente avec une grappe de sa ville
626+ budget_ok = nominatim_budget is None or geo.ville_api_calls < nominatim_budget
627+ v = geo.resolve_ville(r["city"], allow_api=budget_ok)
628+ if v is not None:
629+ d = _dist_km(r["lat"], r["lng"], v["lat"], v["lng"])
630+ if d <= VILLE_RADIUS_KM:
631+ continue # cohérente avec le point Nominatim de la ville
632+ flagged.append((r, min(dists + [d])))
633+ elif not clusters:
634+ inconnues += 1 # aucune référence : on ne flague pas
635+ elif not budget_ok:
636+ reportees += 1 # ville pas encore résolue : prochaine passe
637+ else:
638+ flagged.append((r, min(dists))) # ville irrésoluble, grappes loin
639+ if apply:
640+ for r, d in flagged:
641+ con.execute("DELETE FROM geocode_cache WHERE address=?",
642+ (norm_key(r["address"]),))
643+ con.execute("""UPDATE listings SET lat=NULL, lng=NULL,
644+ geocode_failed=0 WHERE uid=?""", (r["uid"],))
645+ con.commit()
646+ for r, d in flagged[:20]:
647+ print(f"[immo-ka] audit: {r['uid']} « {r['address']}, {r['city']} » "
648+ f"à {d:.0f} km de sa ville")
649+ con.close()
650+ stats = {"verifiees": len(rows), "incoherentes": len(flagged),
651+ "villes_inconnues": inconnues, "reportees": reportees,
652+ "corrigees": apply}
653+ print(f"[immo-ka] geocode-audit {stats}")
654+ return stats
added immoka/hubfav.py +100 −0
@@ -0,0 +1,100 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# hubfav.py : favoris unifiés « Mon univers Ka » — Immo·Ka n'a AUCUN stockage
5+# local de favoris : le hub Groupe KA (groupe-ka.com) est le magasin central.
6+# Chaque ♥ est poussé au hub (POST signé, SYNCHRONE : c'est l'action
7+# utilisateur) et la liste est relue du hub (GET signé, cache mémoire 30 s
8+# par ka_id, [] sur toute erreur). sig = HMAC-SHA256(KA_SSO_SECRET,
9+# "immo-ka.<ka_id>.<ts>") hex — même secret que le SSO (ts ±5 min).
10+# Config .env : KA_SSO_SECRET, KA_HUB_URL (optionnel).
11+# -----------------------------------------------------------------------------
12+from __future__ import annotations
13+
14+import hashlib
15+import hmac
16+import os
17+import threading
18+import time
19+
20+import requests
21+
22+CLIENT_ID = "immo-ka"
23+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
24+LIST_TTL = 30 # secondes — cache mémoire de hub_list par ka_id
25+
26+_cache: dict[str, tuple[float, list]] = {}
27+_lock = threading.Lock()
28+
29+
30+def _sig(ka_id: str, ts: int) -> str | None:
31+ secret = os.environ.get("KA_SSO_SECRET")
32+ if not secret:
33+ return None
34+ return hmac.new(secret.encode(),
35+ f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
36+ hashlib.sha256).hexdigest()
37+
38+
39+def hub_toggle(ka_id: str, action: str, item: dict) -> bool:
40+ """Pousse un ♥ au hub (action « add » ou « remove ») — SYNCHRONE.
41+
42+ C'est l'action utilisateur : on attend la réponse du hub (timeout 6 s)
43+ pour que le GET qui suit reflète l'état réel. True si le hub a accepté.
44+ """
45+ if not ka_id or not str(ka_id).startswith("ka-"):
46+ return False # compte legacy non relié au hub
47+ ts = int(time.time())
48+ sig = _sig(ka_id, ts)
49+ if not sig:
50+ return False
51+ try:
52+ r = requests.post(f"{KA_HUB_URL}/api/sso/favorites", timeout=6, json={
53+ "client_id": CLIENT_ID, "ka_id": ka_id,
54+ "ts": str(ts), "sig": sig,
55+ "action": action, "item": item,
56+ })
57+ return r.status_code == 200
58+ except Exception:
59+ return False
60+
61+
62+def hub_list(ka_id: str) -> list:
63+ """Favoris immo-ka du membre, lus du hub (GET signé, timeout 5 s).
64+
65+ Cache mémoire 30 s par ka_id ; [] sur toute erreur (réseau, 401, 5xx…)
66+ — l'erreur n'est PAS mise en cache pour réessayer au prochain appel.
67+ """
68+ if not ka_id:
69+ return []
70+ now = time.time()
71+ with _lock:
72+ hit = _cache.get(ka_id)
73+ if hit and now - hit[0] < LIST_TTL:
74+ return hit[1]
75+ ts = int(now)
76+ sig = _sig(ka_id, ts)
77+ if not sig:
78+ return []
79+ try:
80+ r = requests.get(
81+ f"{KA_HUB_URL}/api/sso/favorites",
82+ params={"client_id": CLIENT_ID, "ka_id": ka_id,
83+ "ts": ts, "sig": sig},
84+ timeout=5)
85+ if r.status_code != 200:
86+ return []
87+ favs = r.json().get("favorites")
88+ if not isinstance(favs, list):
89+ return []
90+ except Exception:
91+ return []
92+ with _lock:
93+ _cache[ka_id] = (now, favs)
94+ return favs
95+
96+
97+def invalidate(ka_id: str) -> None:
98+ """Invalide le cache de hub_list après un toggle (état frais au prochain GET)."""
99+ with _lock:
100+ _cache.pop(ka_id, None)
added immoka/hubprofile.py +79 −0
@@ -0,0 +1,79 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# hubprofile.py : profil membre lu depuis le HUB Groupe KA (groupe-ka.com)
5+# Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise,
6+# site web, réseaux sociaux, photo, statut, profil public) — l'édition se
7+# fait sur groupe-ka.com/compte, Immo·Ka ne fait qu'afficher.
8+# GET {hub}/api/sso/profile?client_id=immo-ka&ka_id=…&ts=…&sig=…
9+# avec sig = HMAC-SHA256(KA_SSO_SECRET, "immo-ka.<ka_id>.<ts>") en hex
10+# (même secret que le SSO). Cache mémoire 60 s ; None sur toute erreur
11+# (réseau, 401, 404 : vieux compte non relié) -> l'appelant retombe sur
12+# les données locales.
13+# -----------------------------------------------------------------------------
14+from __future__ import annotations
15+
16+import hashlib
17+import hmac
18+import os
19+import threading
20+import time
21+from datetime import datetime, timezone
22+
23+import requests
24+
25+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
26+CLIENT_ID = "immo-ka"
27+CACHE_TTL = 60 # secondes
28+TIMEOUT = 5 # secondes
29+
30+_cache: dict[str, tuple[float, dict | None]] = {}
31+_lock = threading.Lock()
32+
33+
34+def fetch_hub_profile(ka_id: str) -> dict | None:
35+ """Profil du membre au hub Groupe KA, ou None (inconnu ou injoignable)."""
36+ secret = os.environ.get("KA_SSO_SECRET")
37+ if not secret or not ka_id:
38+ return None
39+ now = time.time()
40+ with _lock:
41+ hit = _cache.get(ka_id)
42+ if hit and now - hit[0] < CACHE_TTL:
43+ return hit[1]
44+ data: dict | None = None
45+ try:
46+ ts = int(now)
47+ sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
48+ hashlib.sha256).hexdigest()
49+ r = requests.get(
50+ f"{KA_HUB_URL}/api/sso/profile",
51+ params={"client_id": CLIENT_ID, "ka_id": ka_id,
52+ "ts": ts, "sig": sig},
53+ timeout=TIMEOUT)
54+ if r.status_code == 200:
55+ data = r.json()
56+ if not isinstance(data, dict):
57+ return None
58+ elif r.status_code != 404:
59+ return None # erreur transitoire (401, 5xx…) : pas de cache
60+ except Exception:
61+ return None # réseau/JSON : pas de cache
62+ with _lock:
63+ _cache[ka_id] = (now, data) # 200 -> data ; 404 -> None (négatif)
64+ return data
65+
66+
67+def to_epoch(v) -> float | None:
68+ """created_at du hub (epoch OU chaîne ISO) -> epoch secondes, sinon None."""
69+ if isinstance(v, (int, float)):
70+ return float(v)
71+ if isinstance(v, str) and v:
72+ try:
73+ dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
74+ if dt.tzinfo is None: # chaîne naïve du hub = UTC
75+ dt = dt.replace(tzinfo=timezone.utc)
76+ return dt.timestamp()
77+ except ValueError:
78+ return None
79+ return None
added immoka/imgaudit.py +293 −0
@@ -0,0 +1,293 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# imgaudit.py : qualité des images d'annonces.
5+#
6+# 1) clean_gallery(urls) — nettoyage STATIQUE (sans réseau), appliqué par
7+# PropertyListing.finalize() : URLs invalides, placeholders connus des
8+# portails (« photo à venir », logos), doublons (y compris la même photo
9+# en deux tailles).
10+# 2) run_batch(...) — audit RÉSEAU budgété des photos de couverture : lien
11+# mort (4xx/5xx/timeout), image minuscule/pixellisée (dimensions décodées
12+# de l'en-tête JPEG/PNG/WebP/GIF), fichier corrompu. Résultats en cache
13+# (table image_audit, TTL 30 jours). Une couverture morte est retirée et
14+# la première image VALIDE de la galerie est promue ; une annonce sans
15+# aucune image valide est marquée (details.needs_image_review) — le
16+# frontend affiche alors l'image de secours par type de bien.
17+# -----------------------------------------------------------------------------
18+from __future__ import annotations
19+
20+import json
21+import re
22+import sqlite3
23+import struct
24+import time
25+from concurrent.futures import ThreadPoolExecutor
26+
27+import requests
28+
29+AUDIT_TTL = 30 * 86400 # re-vérification d'une URL après 30 jours
30+MIN_WIDTH, MIN_HEIGHT = 250, 160 # sous ces dimensions : miniature inutilisable
31+MIN_BYTES = 3_000 # fichier suspicieusement petit (icône/placeholder)
32+TIMEOUT = 10
33+UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
34+ "(KHTML, like Gecko) Chrome/126 Safari/537.36 ImmoKaBot/1.0")
35+
36+# motifs de placeholders/logos des portails (jamais une photo de propriété).
37+# ⚠ default/defaut/logo sont ANCRÉS sur le nom de fichier : un segment de
38+# chemin comme Cloudinary « t_default_size/ » n'est PAS un placeholder
39+# (bug payé : toutes les galeries Ubee vidées par « default[-_.] »).
40+_PLACEHOLDER_RE = re.compile(
41+ r"placeholder|no[-_]?photo|nophoto|photo[-_]?a[-_]?venir|coming[-_]?soon|"
42+ r"missing|awaiting|shadow_listing|image[-_]?indisponible|no[-_]?image|"
43+ r"/(?:default|defaut|logo)[^/]*\.(?:jpe?g|png|webp|gif|svg)(?:\?|$)", re.I)
44+
45+_SIZE_VARIANT_RE = re.compile(r"-(?:sm|md|lg|xl|thumb|small|medium|large)(?=\.\w+$)")
46+
47+# handlers dynamiques : la photo est identifiée par la QUERYSTRING (propId/seq,
48+# id…), pas par le chemin — ex. yoamo.immo/ALSPicture.axd?propId=…&seq=N,
49+# mediaserver.centris.ca/media.ashx?id=… On ne retire que les params de taille.
50+_DYNAMIC_EXT = (".axd", ".ashx", ".php", ".aspx", ".cfm")
51+_SIZE_PARAM_RE = re.compile(r"&(?:w|h|width|height|size|sm|scale|quality)=[^&]*", re.I)
52+
53+
54+def _canon(url: str) -> str:
55+ """Clé de déduplication : ignore la variante de taille et la querystring
56+ de redimensionnement pour attraper la même photo en deux formats."""
57+ path, _, query = url.partition("?")
58+ if query and path.lower().endswith(_DYNAMIC_EXT):
59+ params = _SIZE_PARAM_RE.sub("", "&" + query.replace("&amp;", "&")).lstrip("&")
60+ return f"{path}?{params}".lower()
61+ return _SIZE_VARIANT_RE.sub("", path).lower()
62+
63+
64+def clean_gallery(urls: list[str]) -> list[str]:
65+ """Nettoyage statique d'une galerie : URLs http(s) uniquement, placeholders
66+ retirés, doublons (même photo, autre taille) dédupliqués, ordre préservé."""
67+ out: list[str] = []
68+ seen: set[str] = set()
69+ for u in urls or []:
70+ if not isinstance(u, str):
71+ continue
72+ u = u.strip()
73+ if not u.lower().startswith(("http://", "https://")):
74+ continue
75+ if _PLACEHOLDER_RE.search(u):
76+ continue
77+ key = _canon(u)
78+ if key in seen:
79+ continue
80+ seen.add(key)
81+ out.append(u)
82+ return out
83+
84+
85+# ---------------------------------------------------------------------------
86+# Décodage des dimensions depuis les premiers octets (sans télécharger tout)
87+# ---------------------------------------------------------------------------
88+
89+def image_size(data: bytes) -> tuple[int, int] | None:
90+ """(largeur, hauteur) depuis l'en-tête PNG/GIF/WebP/JPEG, None si indécodable."""
91+ if len(data) < 26:
92+ return None
93+ if data[:8] == b"\x89PNG\r\n\x1a\n":
94+ w, h = struct.unpack(">II", data[16:24])
95+ return w, h
96+ if data[:6] in (b"GIF87a", b"GIF89a"):
97+ w, h = struct.unpack("<HH", data[6:10])
98+ return w, h
99+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
100+ if data[12:16] == b"VP8 " and len(data) >= 30:
101+ w, h = struct.unpack("<HH", data[26:30])
102+ return w & 0x3FFF, h & 0x3FFF
103+ if data[12:16] == b"VP8L" and len(data) >= 25:
104+ bits = struct.unpack("<I", data[21:25])[0]
105+ return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
106+ if data[12:16] == b"VP8X" and len(data) >= 30:
107+ w = int.from_bytes(data[24:27], "little") + 1
108+ h = int.from_bytes(data[27:30], "little") + 1
109+ return w, h
110+ if data[:2] == b"\xff\xd8": # JPEG : chercher le SOF
111+ i = 2
112+ while i + 9 < len(data):
113+ if data[i] != 0xFF:
114+ i += 1
115+ continue
116+ marker = data[i + 1]
117+ if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
118+ 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
119+ h, w = struct.unpack(">HH", data[i + 5:i + 9])
120+ return w, h
121+ seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
122+ i += 2 + seg_len
123+ return None
124+
125+
126+def check_url(url: str) -> dict:
127+ """Vérifie une URL d'image : {ok, status, width, height, bytes, reason}.
128+
129+ ⚠ Verdict « morte » UNIQUEMENT sur preuve solide (404/410) : un 403/429 est
130+ presque toujours du rate-limiting ou de l'anti-hotlink du CDN (bug payé :
131+ ~6 000 galeries DuProprio retirées à tort). En cas de doute on garde
132+ l'image — le repli onError du frontend couvre les rares vraies mortes."""
133+ try:
134+ r = requests.get(url, headers={"User-Agent": UA, "Range": "bytes=0-65535"},
135+ timeout=TIMEOUT, stream=True)
136+ status = r.status_code
137+ if status in (404, 410):
138+ return {"ok": 0, "status": status, "reason": "http"}
139+ if status >= 400:
140+ return {"ok": 1, "status": status, "reason": "non_verifiable"}
141+ data = next(r.iter_content(65536), b"") or b""
142+ r.close()
143+ total = int((r.headers.get("Content-Range") or "/0").split("/")[-1] or 0) \
144+ or int(r.headers.get("Content-Length") or 0) or len(data)
145+ size = image_size(data)
146+ if size is None:
147+ ctype = (r.headers.get("Content-Type") or "").lower()
148+ if "image" not in ctype:
149+ return {"ok": 0, "status": status, "bytes": total, "reason": "format"}
150+ # image valide mais en-tête non décodé (format exotique) : on tolère
151+ return {"ok": 1, "status": status, "bytes": total}
152+ w, h = size
153+ if w < MIN_WIDTH or h < MIN_HEIGHT:
154+ return {"ok": 0, "status": status, "width": w, "height": h,
155+ "bytes": total, "reason": "minuscule"}
156+ if total and total < MIN_BYTES:
157+ return {"ok": 0, "status": status, "width": w, "height": h,
158+ "bytes": total, "reason": "poids_suspect"}
159+ return {"ok": 1, "status": status, "width": w, "height": h, "bytes": total}
160+ except requests.RequestException:
161+ # réseau/timeout : non concluant — ne jamais retirer sur un doute
162+ return {"ok": 1, "status": 0, "reason": "non_verifiable"}
163+
164+
165+# ---------------------------------------------------------------------------
166+# Audit budgété des couvertures (+ galeries courtes) avec cache BD
167+# ---------------------------------------------------------------------------
168+
169+def _ensure_table(con: sqlite3.Connection) -> None:
170+ con.execute("""CREATE TABLE IF NOT EXISTS image_audit (
171+ url TEXT PRIMARY KEY,
172+ ok INTEGER,
173+ status INTEGER,
174+ width INTEGER,
175+ height INTEGER,
176+ bytes INTEGER,
177+ reason TEXT,
178+ checked_at REAL
179+ )""")
180+ con.commit()
181+
182+
183+def _cached(con: sqlite3.Connection, url: str) -> dict | None:
184+ r = con.execute("SELECT ok, reason, checked_at FROM image_audit WHERE url=?",
185+ (url,)).fetchone()
186+ if r and time.time() - (r["checked_at"] or 0) < AUDIT_TTL:
187+ return {"ok": r["ok"], "reason": r["reason"]}
188+ return None
189+
190+
191+def _store(con: sqlite3.Connection, url: str, res: dict) -> None:
192+ con.execute(
193+ "INSERT INTO image_audit (url, ok, status, width, height, bytes, reason,"
194+ " checked_at) VALUES (?,?,?,?,?,?,?,?)"
195+ " ON CONFLICT(url) DO UPDATE SET ok=excluded.ok, status=excluded.status,"
196+ " width=excluded.width, height=excluded.height, bytes=excluded.bytes,"
197+ " reason=excluded.reason, checked_at=excluded.checked_at",
198+ (url, res.get("ok"), res.get("status"), res.get("width"),
199+ res.get("height"), res.get("bytes"), res.get("reason"), time.time()))
200+
201+
202+def audit_listing(con: sqlite3.Connection, uid: str, images: list[str],
203+ details: dict, pool: ThreadPoolExecutor) -> tuple[list[str], dict, int]:
204+ """Vérifie la couverture (et remonte la 1re image valide en tête).
205+
206+ Vérifie au plus les 4 premières images ; les mortes sont retirées de la
207+ galerie. Retourne (nouvelle galerie, details, nb de vérifications réseau)."""
208+ checked = 0
209+ good_idx = None
210+ dead: set[int] = set()
211+ for i, url in enumerate(images[:4]):
212+ res = _cached(con, url)
213+ if res is None:
214+ res = check_url(url)
215+ _store(con, url, res)
216+ checked += 1
217+ if res.get("ok"):
218+ good_idx = i
219+ break
220+ dead.add(i)
221+ new_images = [u for i, u in enumerate(images) if i not in dead]
222+ details = dict(details)
223+ if good_idx is None and images:
224+ # aucune image valide parmi les premières : re-vérification demandée,
225+ # le frontend applique l'image de secours par type de bien
226+ details["needs_image_review"] = True
227+ else:
228+ details.pop("needs_image_review", None)
229+ return new_images, details, checked
230+
231+
232+def run_batch(limit: int = 2000, workers: int = 8) -> dict:
233+ """Audit réseau budgété : les annonces publiées jamais auditées d'abord.
234+
235+ Appelé après chaque synchronisation (ingest.watch) ; relancer avec un gros
236+ `limit` pour un rattrapage complet. Idempotent grâce au cache par URL."""
237+ from . import db
238+ con = db.connect()
239+ _ensure_table(con)
240+ rows = con.execute(
241+ "SELECT uid, images, details FROM listings"
242+ " WHERE active=1 AND dup_hidden=0 AND images IS NOT NULL AND images!='[]'"
243+ " AND json_extract(COALESCE(details,'{}'), '$.img_audited') IS NULL"
244+ " LIMIT ?", (limit,)).fetchall()
245+ checked = removed = flagged = 0
246+
247+ def _work(row):
248+ images = json.loads(row["images"] or "[]")
249+ details = json.loads(row["details"] or "{}")
250+ # les URLs sont vérifiées séquentiellement par annonce ; le parallélisme
251+ # est au niveau des annonces (une connexion BD par worker serait fragile,
252+ # donc le réseau seul est parallèle : cache lu/écrit dans le fil principal)
253+ return row["uid"], images, details
254+
255+ with ThreadPoolExecutor(max_workers=workers) as pool:
256+ futures = []
257+ for row in rows:
258+ uid, images, details = _work(row)
259+ futures.append((uid, images, details))
260+ # traitement principal (cache BD dans ce fil, réseau via check_url —
261+ # parallélisé par lots d'URLs de couverture inconnues)
262+ unknown = []
263+ for uid, images, details in futures:
264+ for u in images[:4]:
265+ if _cached(con, u) is None:
266+ unknown.append(u)
267+ unknown = list(dict.fromkeys(unknown))
268+ # commits par tranches : ne JAMAIS tenir le verrou d'écriture pendant
269+ # tout l'audit (des dizaines de minutes) — les syncs/le web écrivent aussi
270+ for url, res in zip(unknown, pool.map(check_url, unknown)):
271+ _store(con, url, res)
272+ checked += 1
273+ if checked % 400 == 0:
274+ con.commit()
275+ con.commit()
276+ done = 0
277+ for uid, images, details in futures:
278+ new_images, new_details, _ = audit_listing(con, uid, images, details,
279+ pool)
280+ new_details["img_audited"] = int(time.time())
281+ if new_details.get("needs_image_review"):
282+ flagged += 1
283+ removed += len(images) - len(new_images)
284+ con.execute("UPDATE listings SET images=?, details=? WHERE uid=?",
285+ (json.dumps(new_images, ensure_ascii=False),
286+ json.dumps(new_details, ensure_ascii=False), uid))
287+ done += 1
288+ if done % 400 == 0:
289+ con.commit()
290+ con.commit()
291+ con.close()
292+ return {"annonces": len(rows), "urls_verifiees": checked,
293+ "images_retirees": removed, "sans_image_valide": flagged}
added immoka/ingest.py +119 −0
@@ -0,0 +1,119 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# ingest.py : pipeline d'ingestion — exécute les connecteurs et synchronise
5+# la base (ajouts / mises à jour / retraits) = contenu toujours à jour
6+# -----------------------------------------------------------------------------
7+from __future__ import annotations
8+
9+import sys
10+import time
11+import traceback
12+
13+from . import db
14+from .connectors import CONNECTORS
15+
16+
17+def run(sources: list[str] | None = None) -> list[dict]:
18+ """Exécute l'ingestion pour toutes les sources (ou celles demandées)."""
19+ con = db.connect()
20+ results = []
21+ targets = sources or list(CONNECTORS.keys())
22+ for sid in targets:
23+ cls = CONNECTORS.get(sid)
24+ if cls is None:
25+ print(f"[immo-ka] connecteur inconnu : {sid}", file=sys.stderr)
26+ continue
27+ t0 = time.time()
28+ print(f"[immo-ka] sync {sid} ...")
29+ try:
30+ listings = cls().fetch()
31+ finalized, dropped = [], 0
32+ for lst in listings:
33+ try:
34+ finalized.append(lst.finalize())
35+ except Exception: # une annonce malformée ne bloque pas la source
36+ dropped += 1
37+ try:
38+ # garde géographique : coordonnées fournies par la source mais
39+ # incompatibles avec la ville annoncée -> annulées (le géocodeur
40+ # rigoureux prendra le relais). Cache seulement, aucun réseau.
41+ from . import geocode
42+ bad = geocode.strip_bad_source_coords(con, finalized)
43+ if bad:
44+ print(f"[immo-ka] {bad} coordonnée(s) source incohérente(s) rejetée(s)")
45+ except Exception:
46+ traceback.print_exc()
47+ stats = db.sync_source(con, sid, finalized)
48+ stats["seconds"] = round(time.time() - t0, 1)
49+ if dropped:
50+ stats["dropped"] = dropped
51+ if stats.get("alert"):
52+ print(f"[immo-ka] ⚠ ALERTE {sid} : {stats['alert']}")
53+ print(f"[immo-ka] {stats}")
54+ results.append(stats)
55+ except Exception as exc: # robustesse : une source ne bloque pas les autres
56+ db.log_failure(con, sid, f"{exc}")
57+ traceback.print_exc()
58+ results.append({"source": sid, "error": str(exc)})
59+ # recalcule la déduplication (pré-calculée pour des lectures instantanées)
60+ try:
61+ hidden = db.refresh_dedup(con)
62+ print(f"[immo-ka] dédup: {hidden} doublon(s) masqué(s) "
63+ "(sous-agences Centris + adresse inter-sources)")
64+ except Exception:
65+ traceback.print_exc()
66+ # contrôle qualité : score de complétude, cohérence immobilière, seuil de
67+ # publication (quarantaine) et champs dérivés (prix/pi², transaction)
68+ try:
69+ from . import quality
70+ q = quality.refresh(con)
71+ print(f"[immo-ka] qualité: {q['publiees']} publiée(s), "
72+ f"{q['quarantaine']} en quarantaine, "
73+ f"{q['recalculees']} recalculée(s)")
74+ except Exception:
75+ traceback.print_exc()
76+ # statistiques du planificateur SQLite : sans elles, les requêtes bbox
77+ # de la carte (Ka Maps) n'utilisent pas idx_listings_geo
78+ try:
79+ con.execute("PRAGMA optimize")
80+ except Exception:
81+ pass
82+ con.close()
83+ return results
84+
85+
86+def watch(interval_seconds: int = 3600) -> None:
87+ """Boucle de synchronisation périodique (équivalent webhook, via PM2/cron).
88+
89+ Après chaque synchronisation, l'enrichissement continu de la carte
90+ (Ka Maps) : géocodage EN LOT des nouvelles adresses (Adresses Québec),
91+ puis appariement Vrai-Prix local (estimations + coordonnées du rôle)
92+ quand data/vraiprix.db (ou VRAIPRIX_DB) est disponible.
93+ """
94+ while True:
95+ run()
96+ try: # nouvelles adresses → coordonnées (cache : quasi gratuit ensuite)
97+ from . import geocode
98+ # audit de cohérence d'abord : les fiches mal localisées (rue
99+ # homonyme, geo source erroné) sont remises en file, puis le lot
100+ # les re-géocode rigoureusement (budget Nominatim borné par passe)
101+ geocode.run_audit(nominatim_budget=50)
102+ geocode.run_batch()
103+ except Exception as exc:
104+ print(f"[immo-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr)
105+ try: # audit budgété des images (liens morts, minuscules, corrompues)
106+ from . import imgaudit
107+ ia = imgaudit.run_batch(limit=2000)
108+ print(f"[immo-ka] images: {ia['urls_verifiees']} URL vérifiée(s), "
109+ f"{ia['images_retirees']} retirée(s), "
110+ f"{ia['sans_image_valide']} annonce(s) sans image valide")
111+ except Exception as exc:
112+ print(f"[immo-ka] imgaudit: erreur non bloquante: {exc}", file=sys.stderr)
113+ try: # taux hypothécaires — collecte espacée (IMMOKA_MORTGAGE_INTERVAL_MIN)
114+ from .mortgage import scheduler as mortgage_scheduler
115+ mortgage_scheduler.maybe_run()
116+ except Exception as exc:
117+ print(f"[immo-ka] mortgage: erreur non bloquante: {exc}", file=sys.stderr)
118+ print(f"[immo-ka] prochaine synchronisation dans {interval_seconds // 60} min")
119+ time.sleep(interval_seconds)
added immoka/kaid.py +449 −0
@@ -0,0 +1,449 @@
1+# -----------------------------------------------------------------------------
2+# Groupe KA — kaid.py : client KA ID v2 (personnalisation) pour les satellites.
3+# SOURCE CANONIQUE : ka-ui.git/kaid/kaid.py — copié dans le paquet backend de
4+# chaque app (louka/, jobka/, sortika/, …) par sync-kaid.sh. Ne pas diverger :
5+# corriger ICI puis redistribuer.
6+#
7+# Rôle : relier l'app au feature store du hub (groupe-ka.com) —
8+# · track() journal d'interactions (serveur, fil d'exécution dédié)
9+# · fetch_prefs() profil de préférences appris (cache 90 s, fail-open)
10+# · rerank() reclassement personnalisé APRÈS la pertinence de base
11+# · build_router() routes /api/kaid/* (événements client, masquage,
12+# recherches sauvegardées)
13+#
14+# Contrat s2s (identique à hubfav/hubprofile) : HMAC-SHA256 du secret SSO
15+# partagé — sig = HMAC(KA_SSO_SECRET, f"{CLIENT_ID}.{ka_id}.{ts}").
16+# Config .env : KA_SSO_SECRET (déjà présent), KA_HUB_URL (optionnel).
17+#
18+# Principes : la connexion n'est JAMAIS requise ; sans profil ou à la moindre
19+# erreur réseau → classement de base inchangé (fail-open). La personnalisation
20+# ne remplace pas la pertinence : elle reclasse (blend) et n'écrase jamais
21+# l'intention de la session (les dimensions explicitement filtrées par la
22+# requête courante sont ignorées dans le score).
23+# -----------------------------------------------------------------------------
24+from __future__ import annotations
25+
26+import hashlib
27+import hmac
28+import json
29+import os
30+import threading
31+import time
32+
33+import requests
34+from fastapi import APIRouter, HTTPException, Request
35+from pydantic import BaseModel
36+
37+KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
38+CLIENT_ID = os.environ.get("KA_CLIENT_ID", "") # fixé par init() dans web.py
39+
40+PREFS_TTL = 90 # secondes de cache du profil
41+TIMEOUT = 5 # secondes par appel hub
42+LOCATION_DIMS = {"city", "region", "sector", "quartier", "ville",
43+ "location", "neighborhood"}
44+LANGUAGE_DIMS = {"language", "langue"}
45+PRICE_DIMS = {"price", "rent", "salary", "salary_year", "price_min"}
46+
47+# Événements acceptés depuis le navigateur (le reste vient du serveur).
48+CLIENT_EVENT_TYPES = {
49+ "click", "impression", "detail_dwell", "scroll_depth", "return_visit",
50+ "share", "compare", "external_click", "map_open", "map_marker_click",
51+ "alert_open",
52+}
53+
54+_prefs_cache: dict[str, tuple[float, dict | None]] = {}
55+_seen_searches: dict[str, float] = {} # anti-doublon des recherches (120 s)
56+_lock = threading.Lock()
57+
58+
59+def init(client_id: str) -> None:
60+ """À appeler une fois au démarrage de l'app (web.py)."""
61+ global CLIENT_ID
62+ CLIENT_ID = client_id
63+
64+
65+def _sig(ka_id: str, ts: int) -> str | None:
66+ secret = os.environ.get("KA_SSO_SECRET")
67+ if not secret or not CLIENT_ID:
68+ return None
69+ return hmac.new(secret.encode(),
70+ f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
71+ hashlib.sha256).hexdigest()
72+
73+
74+def _signed_params(ka_id: str) -> dict | None:
75+ ts = int(time.time())
76+ sig = _sig(ka_id, ts)
77+ if not sig:
78+ return None
79+ return {"client_id": CLIENT_ID, "ka_id": ka_id, "ts": str(ts), "sig": sig}
80+
81+
82+def _ka_id_of(user) -> str | None:
83+ """Extrait un ka_id exploitable d'un dict utilisateur (ou None)."""
84+ if not user:
85+ return None
86+ ka = (user.get("ka_id") or "").strip() if isinstance(user, dict) else ""
87+ return ka if ka.startswith("ka-") else None
88+
89+
90+# ---------------------------------------------------------------- événements
91+
92+def _post_events(ka_id: str, events: list[dict]) -> None:
93+ p = _signed_params(ka_id)
94+ if not p:
95+ return
96+ try:
97+ requests.post(f"{KA_HUB_URL}/api/sso/events", timeout=TIMEOUT,
98+ json={**p, "events": events})
99+ except Exception:
100+ pass # best-effort : jamais bloquant, jamais fatal
101+
102+
103+def track(user, etype: str, *, entity_type: str | None = None,
104+ entity_id: str | None = None, query: str | None = None,
105+ filters: dict | None = None, position: int | None = None,
106+ features: dict | None = None, dwell_ms: int | None = None,
107+ session_id: str | None = None) -> None:
108+ """Journalise un événement au hub (fil dédié, zéro latence ajoutée).
109+ No-op si l'utilisateur n'est pas connecté via KA ID."""
110+ ka_id = _ka_id_of(user)
111+ if not ka_id:
112+ return
113+ if etype == "search":
114+ # anti-rafale : la même recherche (mêmes filtres) < 120 s n'est
115+ # journalisée qu'une fois — une SPA relance l'API à chaque frappe.
116+ key = ka_id + "|" + hashlib.sha1(
117+ json.dumps([query, filters], sort_keys=True, default=str).encode()
118+ ).hexdigest()
119+ now = time.time()
120+ with _lock:
121+ if now - _seen_searches.get(key, 0) < 120:
122+ return
123+ _seen_searches[key] = now
124+ if len(_seen_searches) > 2000:
125+ cutoff = now - 300
126+ for k in [k for k, t in _seen_searches.items() if t < cutoff]:
127+ del _seen_searches[k]
128+ ev: dict = {"type": etype}
129+ if entity_type: ev["entity_type"] = entity_type
130+ if entity_id: ev["entity_id"] = str(entity_id)
131+ if query: ev["query"] = str(query)[:200]
132+ if filters: ev["filters"] = filters
133+ if position is not None: ev["position"] = int(position)
134+ if features: ev["features"] = features
135+ if dwell_ms is not None: ev["dwell_ms"] = int(dwell_ms)
136+ if session_id: ev["session_id"] = str(session_id)[:60]
137+ threading.Thread(target=_post_events, args=(ka_id, [ev]), daemon=True).start()
138+
139+
140+# ------------------------------------------------------------------ profil
141+
142+def fetch_prefs(ka_id: str | None) -> dict | None:
143+ """Profil de personnalisation du membre (cache 90 s). None si non
144+ connecté, non configuré ou hub injoignable — l'appelant retombe alors
145+ sur le classement de base."""
146+ if not ka_id or not str(ka_id).startswith("ka-"):
147+ return None
148+ now = time.time()
149+ with _lock:
150+ hit = _prefs_cache.get(ka_id)
151+ if hit and now - hit[0] < PREFS_TTL:
152+ return hit[1]
153+ data: dict | None = None
154+ p = _signed_params(ka_id)
155+ if p:
156+ try:
157+ r = requests.get(f"{KA_HUB_URL}/api/sso/prefs", params=p,
158+ timeout=TIMEOUT)
159+ if r.status_code == 200:
160+ data = r.json()
161+ except Exception:
162+ data = None
163+ with _lock:
164+ _prefs_cache[ka_id] = (now, data)
165+ if len(_prefs_cache) > 500:
166+ for k in list(_prefs_cache)[:100]:
167+ del _prefs_cache[k]
168+ return data
169+
170+
171+def invalidate_prefs(ka_id: str | None) -> None:
172+ if not ka_id:
173+ return
174+ with _lock:
175+ _prefs_cache.pop(ka_id, None)
176+
177+
178+# ---------------------------------------------------------------- reranking
179+
180+def _norm(v) -> str:
181+ return str(v).strip().lower()
182+
183+
184+def personal_score(feats: dict, app_profile: dict, global_profile: dict,
185+ active_dims: set[str]) -> tuple[float | None, list[str]]:
186+ """Score personnel [0,1] d'une annonce, ou None si le profil ne couvre
187+ aucune de ses caractéristiques. `active_dims` = dimensions explicitement
188+ filtrées par la requête courante (intention de session > long terme)."""
189+ dims = app_profile.get("dims") or {}
190+ ranges = app_profile.get("ranges") or {}
191+ gl = (global_profile or {}).get("location") or {}
192+ num = 0.0
193+ den = 0.0
194+ reasons: list[str] = []
195+ for dim, val in (feats or {}).items():
196+ if val is None or dim in active_dims:
197+ continue
198+ if isinstance(val, bool):
199+ val = str(val)
200+ if isinstance(val, (int, float)):
201+ r = ranges.get(dim)
202+ if r and r.get("n", 0) >= 5:
203+ p25, p75 = r["p25"], r["p75"]
204+ iqr = max(p75 - p25, abs(r.get("p50", 0)) * 0.1, 1.0)
205+ if p25 <= val <= p75:
206+ aff = 1.0
207+ elif p25 - 1.5 * iqr <= val <= p75 + 1.5 * iqr:
208+ aff = 0.3
209+ else:
210+ aff = -0.4
211+ # poids réduit : une plage numérique seule (prix…) ne doit
212+ # jamais suffire à personnaliser (0.6 < seuil den 0.8) —
213+ # sinon tout item au « bon prix » score 1.0 et noie les
214+ # correspondances réelles (ville, marque, type).
215+ w = 0.6
216+ num += w * aff
217+ den += w
218+ if aff == 1.0:
219+ reasons.append("MATCH_PRICE_RANGE" if dim in PRICE_DIMS
220+ else f"MATCH_{dim.upper()}_RANGE")
221+ continue
222+ vals = val if isinstance(val, (list, tuple)) else [val]
223+ vals = [_norm(v) for v in vals if v not in (None, "")]
224+ if not vals:
225+ continue
226+ d = dims.get(dim)
227+ if d:
228+ vv = d.get("values") or {}
229+ affs = [vv[v] for v in vals if v in vv]
230+ if affs:
231+ aff = max(affs)
232+ w = float(d.get("conf") or 0.5)
233+ num += w * aff
234+ den += w
235+ if aff >= 0.6:
236+ reasons.append("MATCH_LOCATION" if dim in LOCATION_DIMS
237+ else f"MATCH_{dim.upper()}")
238+ if dim in LOCATION_DIMS:
239+ gv = gl.get("values") or {}
240+ affs = [gv[v] for v in vals if v in gv]
241+ if affs and max(affs) > 0:
242+ w = 0.6 * float(gl.get("conf") or 0.3)
243+ num += w * max(affs)
244+ den += w
245+ if max(affs) >= 0.6 and "MATCH_LOCATION" not in reasons:
246+ reasons.append("MATCH_LOCATION")
247+ if dim in LANGUAGE_DIMS:
248+ glang = (global_profile or {}).get("language") or {}
249+ gv = glang.get("values") or {}
250+ affs = [gv[v] for v in vals if v in gv]
251+ if affs and max(affs) > 0:
252+ w = 0.4 * float(glang.get("conf") or 0.3)
253+ num += w * max(affs)
254+ den += w
255+ if den < 0.8:
256+ return None, []
257+ score = (num / den + 1.0) / 2.0
258+ return max(0.0, min(1.0, score)), reasons[:4]
259+
260+
261+def rerank(items: list, user, *, features_of, uid_of=None,
262+ active_dims: set[str] | None = None, blend: float = 0.35,
263+ badge: float = 0.62, max_considered: int = 300,
264+ reco_key: str = "ka_reco") -> tuple[list, bool]:
265+ """Reclassement personnalisé APRÈS la pertinence de base.
266+ · items : liste (dicts) déjà triée par la pertinence de base
267+ · features_of : item -> dict de caractéristiques {dim: valeur}
268+ · uid_of : item -> identifiant canonique (défaut : item["uid"])
269+ · active_dims : dimensions filtrées par la requête (ignorées du score)
270+ Retourne (items, personnalisé?). Les annonces masquées (« Pas pour moi »)
271+ sont retirées. Annote item[reco_key] = {score, reasons} quand le score
272+ personnel est net (badge « Recommandé pour vous » — parcimonieux)."""
273+ if uid_of is None:
274+ uid_of = lambda it: (it.get("uid") if isinstance(it, dict) else None)
275+ ka_id = _ka_id_of(user)
276+ if not ka_id or not items:
277+ return items, False
278+ prefs = fetch_prefs(ka_id)
279+ if not prefs:
280+ return items, False
281+ hidden = set(prefs.get("hidden") or [])
282+ if hidden:
283+ items = [it for it in items if str(uid_of(it)) not in hidden]
284+ if not prefs.get("personalization"):
285+ return items, False
286+ profile = prefs.get("profile") or {}
287+ app_p = profile.get("app")
288+ if not app_p or not items:
289+ return items, False
290+
291+ head = items[:max_considered]
292+ tail = items[max_considered:]
293+ n = len(head)
294+ active = active_dims or set()
295+ # signaux collaboratifs du hub : co-favoris (item-item) et
296+ # recommandations du modèle de matrix factorization (ALS, batch quotidien)
297+ similar = {str(s) for s in (app_p.get("similar") or [])}
298+ mf = {str(s) for s in (app_p.get("mf") or [])}
299+ scored = []
300+ badged = 0
301+ for i, it in enumerate(head):
302+ base = 1.0 - i / max(n, 1)
303+ try:
304+ p, reasons = personal_score(features_of(it) or {}, app_p,
305+ profile.get("global") or {}, active)
306+ except Exception:
307+ p, reasons = None, []
308+ uid = str(uid_of(it))
309+ if similar and uid in similar:
310+ p = min(1.0, (p if p is not None else 0.55) + 0.25)
311+ reasons = (["SIMILAR_USERS"] + reasons)[:4]
312+ elif mf and uid in mf:
313+ p = min(1.0, (p if p is not None else 0.55) + 0.25)
314+ reasons = (["COLLABORATIVE_MODEL"] + reasons)[:4]
315+ if p is None:
316+ final = (1.0 - blend) * base + blend * 0.5
317+ else:
318+ final = (1.0 - blend) * base + blend * p
319+ if p >= badge and reasons and badged < max(2, n // 8) \
320+ and isinstance(it, dict):
321+ it[reco_key] = {"score": round(p, 2), "reasons": reasons}
322+ badged += 1
323+ scored.append((final, i, it))
324+ scored.sort(key=lambda t: (-t[0], t[1])) # stable : départage par rang
325+ return [it for _, _, it in scored] + tail, True
326+
327+
328+# ------------------------------------------------------- proxys hub (s2s)
329+
330+def _hub_post(ka_id: str, path: str, payload: dict) -> dict:
331+ p = _signed_params(ka_id)
332+ if not p:
333+ raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
334+ try:
335+ r = requests.post(f"{KA_HUB_URL}{path}", timeout=TIMEOUT,
336+ json={**p, **payload})
337+ return r.json() if r.status_code == 200 else {"error": r.status_code}
338+ except Exception:
339+ raise HTTPException(502, "hub KA injoignable")
340+
341+
342+def _hub_get(ka_id: str, path: str) -> dict:
343+ p = _signed_params(ka_id)
344+ if not p:
345+ raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
346+ try:
347+ r = requests.get(f"{KA_HUB_URL}{path}", params=p, timeout=TIMEOUT)
348+ return r.json() if r.status_code == 200 else {"error": r.status_code}
349+ except Exception:
350+ raise HTTPException(502, "hub KA injoignable")
351+
352+
353+# ------------------------------------------------------------------ routeur
354+
355+class _EventsIn(BaseModel):
356+ events: list[dict]
357+
358+
359+class _HideIn(BaseModel):
360+ item_id: str
361+ on: bool = True
362+ features: dict | None = None
363+
364+
365+class _SearchIn(BaseModel):
366+ action: str = "add" # add | remove | alert | touch
367+ id: int | None = None
368+ label: str | None = None
369+ query: str | None = None
370+ filters: dict | None = None
371+ location: str | None = None
372+ url: str | None = None
373+ alert: bool = False
374+ frequency: str | None = None
375+
376+
377+def build_router(get_user) -> APIRouter:
378+ """Routes /api/kaid/* de l'app. `get_user(request)` = current_user de
379+ l'app (dict avec ka_id, ou None)."""
380+ router = APIRouter(prefix="/api/kaid")
381+
382+ def _require_ka(request: Request) -> tuple[dict, str]:
383+ user = get_user(request)
384+ ka_id = _ka_id_of(user)
385+ if not ka_id:
386+ raise HTTPException(401, "connexion KA ID requise")
387+ return user, ka_id
388+
389+ @router.get("/status")
390+ def status(request: Request):
391+ user = get_user(request)
392+ ka_id = _ka_id_of(user)
393+ if not ka_id:
394+ return {"connected": False}
395+ prefs = fetch_prefs(ka_id)
396+ return {
397+ "connected": True,
398+ "personalization": bool(prefs and prefs.get("personalization")),
399+ "monka_url": f"{KA_HUB_URL}/mon-ka",
400+ }
401+
402+ @router.post("/events")
403+ def client_events(request: Request, body: _EventsIn):
404+ user = get_user(request)
405+ ka_id = _ka_id_of(user)
406+ if not ka_id:
407+ return {"ok": True, "stored": 0}
408+ events = []
409+ for e in body.events[:20]:
410+ if e.get("type") in CLIENT_EVENT_TYPES:
411+ events.append({k: e[k] for k in
412+ ("type", "entity_type", "entity_id", "query",
413+ "filters", "position", "features", "dwell_ms",
414+ "session_id") if k in e})
415+ if events:
416+ threading.Thread(target=_post_events, args=(ka_id, events),
417+ daemon=True).start()
418+ return {"ok": True, "stored": len(events)}
419+
420+ @router.post("/hide")
421+ def hide(request: Request, body: _HideIn):
422+ _, ka_id = _require_ka(request)
423+ out = _hub_post(ka_id, "/api/sso/hide", {
424+ "item_id": body.item_id, "on": body.on,
425+ "features": body.features,
426+ })
427+ invalidate_prefs(ka_id)
428+ return out
429+
430+ @router.get("/saved-searches")
431+ def saved_list(request: Request):
432+ _, ka_id = _require_ka(request)
433+ return _hub_get(ka_id, "/api/sso/saved-searches")
434+
435+ @router.post("/saved-searches")
436+ def saved_post(request: Request, body: _SearchIn):
437+ _, ka_id = _require_ka(request)
438+ search: dict = {k: v for k, v in {
439+ "id": body.id, "label": body.label, "query": body.query,
440+ "filters": body.filters, "location": body.location,
441+ "url": body.url, "alert": body.alert,
442+ "frequency": body.frequency,
443+ }.items() if v is not None}
444+ out = _hub_post(ka_id, "/api/sso/saved-searches",
445+ {"action": body.action, "search": search})
446+ invalidate_prefs(ka_id)
447+ return out
448+
449+ return router
added immoka/kapdf.py +1267 −0
@@ -0,0 +1,1267 @@
1+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2+# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3
3+# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit
4+# les rapports estampillés Groupe-KA. 5 modes fixes :
5+# complet — toutes les sections (KPI, jauges, séries + stats, multi-
6+# séries, empilées, distributions, répartitions, géo,
7+# heatmap horaire, tableaux, records)
8+# synthese — couverture + KPI + records (2-3 pages)
9+# tendances — KPI + toutes les séries temporelles + stats de séries
10+# repartitions — breakdowns, distributions, géo, activité horaire
11+# donnees — tous les tableaux en version longue (400 lignes max)
12+# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par
13+# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/
14+# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport
15+# suit une spec {"title": str, "blocks": [{"key": "series:ajouts",
16+# "render": "bar"}, …]} et respecte l'ordre demandé.
17+# Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque.
18+# Usage :
19+# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename
20+# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00",
21+# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json,
22+# mode="complet").build()
23+# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise",
24+# spec={"title": "Mon rapport", "blocks": [...]}).build()
25+# Dépendance : pip install fpdf2 (aucune autre)
26+from __future__ import annotations
27+
28+import math
29+from datetime import datetime
30+from zoneinfo import ZoneInfo
31+
32+from fpdf import FPDF
33+
34+INK = (20, 24, 20)
35+INK2 = (77, 85, 81)
36+INK3 = (139, 146, 140)
37+PAPER = (245, 243, 238)
38+SURFACE2 = (250, 249, 245)
39+GREEN = (28, 92, 65)
40+DANGER = (179, 66, 58)
41+WHITE = (255, 255, 255)
42+
43+REPORT_MODES = {
44+ "complet": "Rapport complet",
45+ "synthese": "Synthèse exécutive",
46+ "tendances": "Tendances & évolution",
47+ "repartitions": "Répartitions & géographie",
48+ "donnees": "Données détaillées",
49+}
50+# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes)
51+CUSTOM_MODE = "personnalise"
52+CUSTOM_LABEL = "Rapport personnalisé"
53+
54+# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ;
55+# « table » est toujours offert : toute donnée a un équivalent tableau)
56+RENDER_LABELS = {
57+ "line": "Courbe", "area": "Aire", "bar": "Barres verticales",
58+ "bars": "Barres horizontales", "donut": "Anneau",
59+ "lines": "Multi-courbes", "stacked": "Barres empilées",
60+ "histogram": "Histogramme", "heatmap": "Heatmap",
61+ "cards": "Cartes", "gauges": "Jauges", "table": "Tableau",
62+}
63+SECTION_LABELS = {
64+ "kpis": "Indicateurs", "gauges": "Taux & couvertures",
65+ "series": "Évolution", "multiseries": "Comparaisons",
66+ "stacked": "Compositions", "breakdowns": "Répartitions",
67+ "distributions": "Distributions", "geo": "Géographie",
68+ "heatmap": "Calendrier", "hourly": "Activité horaire",
69+ "tables": "Tableaux", "records": "Records",
70+}
71+
72+
73+def catalog(dash: dict) -> list[dict]:
74+ """v3 — blocs composables d'un dashboard : ce que le constructeur de
75+ rapports personnalisés peut inclure, avec les rendus compatibles.
76+ key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard."""
77+ out: list[dict] = []
78+
79+ def add(key, title, renders, default=None, count=None):
80+ b = {"key": key, "section": key.split(":")[0], "title": title,
81+ "renders": renders, "default_render": default or renders[0]}
82+ if count is not None:
83+ b["count"] = count
84+ out.append(b)
85+
86+ if dash.get("kpis"):
87+ add("kpis", "Indicateurs clés (KPI)", ["cards", "table"],
88+ count=len(dash["kpis"]))
89+ gs = [g for g in (dash.get("gauges") or [])
90+ if isinstance(g.get("value"), (int, float)) and g.get("max")]
91+ if gs:
92+ add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"],
93+ count=len(gs))
94+ for s in dash.get("series") or []:
95+ if len(s.get("points") or []) < 2:
96+ continue
97+ kind = s.get("kind") or "line"
98+ default = kind if kind in ("line", "area", "bar") else "line"
99+ add(f"series:{s.get('id')}", s.get("title", ""),
100+ ["line", "area", "bar", "table"], default,
101+ len(s.get("points") or []))
102+ for ms in dash.get("multiseries") or []:
103+ if not (ms.get("series") or []):
104+ continue
105+ add(f"multiseries:{ms.get('id')}", ms.get("title", ""),
106+ ["lines", "table"], count=len(ms["series"]))
107+ for st in dash.get("stacked") or []:
108+ if not (st.get("points") or []):
109+ continue
110+ add(f"stacked:{st.get('id')}", st.get("title", ""),
111+ ["stacked", "table"], count=len(st.get("keys") or []))
112+ for b in dash.get("breakdowns") or []:
113+ if not (b.get("items") or []):
114+ continue
115+ default = "donut" if b.get("kind") == "donut" else "bars"
116+ add(f"breakdowns:{b.get('id')}", b.get("title", ""),
117+ ["donut", "bars", "table"], default, len(b["items"]))
118+ for d in dash.get("distributions") or []:
119+ if not (d.get("bins") or []):
120+ continue
121+ add(f"distributions:{d.get('id')}", d.get("title", ""),
122+ ["histogram", "table"], count=len(d["bins"]))
123+ geo = dash.get("geo") or {}
124+ if geo.get("items"):
125+ add("geo", geo.get("title", "Répartition géographique"),
126+ ["bars", "table"], count=len(geo["items"]))
127+ hm = dash.get("heatmap") or {}
128+ if hm.get("cells"):
129+ add("heatmap", hm.get("title", "Calendrier d'activité"),
130+ ["heatmap", "table"])
131+ hr = dash.get("hourly") or {}
132+ if hr.get("cells"):
133+ add("hourly", hr.get("title", "Activité par jour et heure"),
134+ ["heatmap", "table"])
135+ for t in dash.get("tables") or []:
136+ if not (t.get("rows") or []):
137+ continue
138+ add(f"tables:{t.get('id')}", t.get("title", ""), ["table"],
139+ count=len(t["rows"]))
140+ if dash.get("records"):
141+ add("records", "Records & faits marquants", ["cards", "table"],
142+ count=len(dash["records"]))
143+ return out
144+
145+EMAILS = [
146+ ("contact@groupe-ka.com", "Projets, partenariats & données"),
147+ ("info@groupe-ka.com", "Médias & questions générales"),
148+ ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"),
149+]
150+DISCLAIMER = (
151+ "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons "
152+ "rien et ne sommes partie à aucune transaction. Données lues à la source, "
153+ "rien d'inventé, tout est traçable."
154+)
155+
156+
157+def _hex(c: str) -> tuple[int, int, int]:
158+ c = c.lstrip("#")
159+ return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
160+
161+
162+def _fr(n) -> str:
163+ if isinstance(n, float) and not n.is_integer():
164+ return f"{n:,.2f}".replace(",", " ").replace(".", ",")
165+ return f"{int(n):,}".replace(",", " ")
166+
167+
168+_SUBST = {
169+ "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-",
170+ "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"',
171+ "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ",
172+ "×": "x", "·": ".", "σ": "sigma", "Δ": "delta",
173+}
174+
175+
176+def _latin1(s: str) -> str:
177+ for k, v in _SUBST.items():
178+ s = s.replace(k, v)
179+ return s.encode("latin-1", "replace").decode("latin-1")
180+
181+
182+class _PDF(FPDF):
183+ """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture).
184+ Les polices core sont latin-1 : normalize_text sanitise en amont."""
185+
186+ def normalize_text(self, text):
187+ return super().normalize_text(_latin1(text))
188+
189+ def __init__(self, brand: str, accent: tuple, period_label: str):
190+ super().__init__(orientation="P", unit="mm", format="A4")
191+ self.brand = brand
192+ self.accent = accent
193+ self.period_label = period_label
194+ self.cover_mode = False
195+ self.set_margins(18, 20, 18)
196+ self.set_auto_page_break(True, margin=22)
197+
198+ def header(self):
199+ if self.cover_mode or self.page_no() == 1:
200+ return
201+ self.set_font("helvetica", "B", 8.5)
202+ self.set_text_color(*INK)
203+ self.set_xy(18, 9)
204+ self.cell(0, 5, f"Groupe KA · {self.brand}")
205+ self.set_font("helvetica", "", 8)
206+ self.set_text_color(*INK3)
207+ self.set_xy(18, 9)
208+ self.cell(0, 5, "Rapport statistique", align="R")
209+ self.set_draw_color(*INK)
210+ self.set_line_width(0.5)
211+ self.line(18, 15.5, 192, 15.5)
212+ self.set_y(20)
213+
214+ def footer(self):
215+ # page 1 = couverture (le flag cover_mode est déjà retombé quand
216+ # add_page() clôt la page 1 → tester aussi le numéro de page)
217+ if self.cover_mode or self.page_no() == 1:
218+ return
219+ self.set_y(-15)
220+ self.set_draw_color(*INK3)
221+ self.set_line_width(0.2)
222+ self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5)
223+ self.set_font("helvetica", "", 7.5)
224+ self.set_text_color(*INK3)
225+ year = datetime.now(ZoneInfo("America/Toronto")).year
226+ self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}")
227+ self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R")
228+
229+
230+class GroupeKAReport:
231+ def __init__(self, site: dict, dashboard: dict, mode: str = "complet",
232+ spec: dict | None = None):
233+ self.site = site
234+ self.d = dashboard
235+ self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet"
236+ self.spec = spec or {}
237+ self.accent = _hex(site.get("accent", "#d9f26b"))
238+ period = dashboard.get("period", {}) or {}
239+ self.period_label = period.get("label") or "toute la période"
240+ self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label)
241+ self.toc: list[tuple[str, int]] = []
242+
243+ @property
244+ def mode_label(self) -> str:
245+ if self.mode == CUSTOM_MODE:
246+ t = str(self.spec.get("title") or "").strip()
247+ return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL
248+ return REPORT_MODES[self.mode]
249+
250+ # ---------- primitives ----------
251+ def _card(self, x, y, w, h, fill=WHITE):
252+ p = self.pdf
253+ p.set_draw_color(*INK)
254+ p.set_line_width(0.45)
255+ p.set_fill_color(*fill)
256+ p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2)
257+
258+ def _shade(self, i, n=8):
259+ shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10]
260+ f = shades[i % len(shades)]
261+ return tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3))
262+
263+ def _kicker(self, text):
264+ p = self.pdf
265+ p.set_font("helvetica", "B", 8)
266+ p.set_text_color(*GREEN)
267+ p.set_draw_color(*GREEN)
268+ p.set_line_width(0.6)
269+ y = p.get_y() + 2
270+ p.line(p.l_margin, y, p.l_margin + 7, y)
271+ p.set_xy(p.l_margin + 9, y - 2.5)
272+ p.cell(0, 5, text.upper())
273+ p.ln(8)
274+
275+ def _section_title(self, title):
276+ if self.pdf.get_y() > 240:
277+ self.pdf.add_page()
278+ self._kicker("Groupe KA · " + self.site.get("wordmark", ""))
279+ self.pdf.set_font("helvetica", "B", 15)
280+ self.pdf.set_text_color(*INK)
281+ self.pdf.set_x(self.pdf.l_margin)
282+ self.pdf.cell(0, 8, title)
283+ self.toc.append((title, self.pdf.page_no()))
284+ self.pdf.ln(11)
285+
286+ def _chart_title(self, title):
287+ p = self.pdf
288+ p.set_font("helvetica", "B", 10)
289+ p.set_text_color(*INK)
290+ p.set_x(p.l_margin)
291+ p.cell(0, 6, title)
292+ p.ln(7)
293+
294+ # ---------- pages ----------
295+ def _cover(self):
296+ p = self.pdf
297+ p.cover_mode = True
298+ p.set_auto_page_break(False)
299+ p.add_page()
300+ p.set_fill_color(*PAPER)
301+ p.rect(0, 0, 210, 297, style="F")
302+ p.set_draw_color(*INK)
303+ p.set_line_width(1.0)
304+ p.rect(10, 10, 190, 277)
305+ p.set_font("helvetica", "B", 10)
306+ p.set_text_color(*GREEN)
307+ p.set_xy(24, 34)
308+ p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE")
309+ wm = self.site.get("wordmark", "")
310+ left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None)
311+ p.set_xy(24, 70)
312+ p.set_font("helvetica", "B", 40)
313+ p.set_text_color(*INK)
314+ p.cell(p.get_string_width(left) + 2, 20, left)
315+ if boxed:
316+ bw = p.get_string_width(boxed) + 12
317+ x = p.get_x() + 2
318+ p.set_fill_color(*INK)
319+ p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3)
320+ p.set_text_color(*self.accent)
321+ p.set_xy(x + 6, 70)
322+ p.cell(bw - 12, 18, boxed)
323+ p.set_xy(24, 100)
324+ p.set_font("helvetica", "", 13)
325+ p.set_text_color(*INK2)
326+ p.multi_cell(150, 7, f"{self.mode_label} — {wm}")
327+ now = datetime.now(ZoneInfo("America/Toronto"))
328+ per = self.d.get("period", {}) or {}
329+ p.set_xy(24, 125)
330+ p.set_font("helvetica", "", 10.5)
331+ rows = [
332+ ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")),
333+ ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"),
334+ ("Plateforme", "https://" + self.site.get("domain", "")),
335+ ("Type de rapport", self.mode_label),
336+ ]
337+ y = 128
338+ for k, v in rows:
339+ p.set_xy(24, y)
340+ p.set_text_color(*INK3)
341+ p.cell(40, 6, k)
342+ p.set_text_color(*INK)
343+ p.set_font("helvetica", "B", 10.5)
344+ p.cell(0, 6, str(v))
345+ p.set_font("helvetica", "", 10.5)
346+ y += 8
347+ p.set_fill_color(*INK)
348+ p.rect(10, 262, 190, 25, style="F")
349+ p.set_xy(24, 270)
350+ p.set_font("helvetica", "B", 12)
351+ p.set_text_color(*WHITE)
352+ p.cell(60, 8, "par Groupe ")
353+ p.set_text_color(*self.accent)
354+ p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270)
355+ p.cell(20, 8, "KA")
356+ p.set_font("helvetica", "B", 10)
357+ p.set_xy(24, 270)
358+ p.set_text_color(*self.accent)
359+ p.cell(162, 8, "groupe-ka.com", align="R")
360+ p.set_auto_page_break(True, margin=22)
361+ p.cover_mode = False
362+
363+ def _kpis(self):
364+ kpis = self.d.get("kpis") or []
365+ if not kpis:
366+ return
367+ self._section_title("Synthèse des indicateurs")
368+ p = self.pdf
369+ cols, gw, gh, gap = 3, 56, 26, 3
370+ x0, y = p.l_margin, p.get_y()
371+ for i, k in enumerate(kpis[:12]):
372+ x = x0 + (i % cols) * (gw + gap)
373+ if i and i % cols == 0:
374+ y += gh + gap
375+ if y > 250:
376+ p.add_page(); y = p.get_y()
377+ self._card(x, y, gw, gh)
378+ p.set_xy(x + 4, y + 4)
379+ p.set_font("helvetica", "B", 14)
380+ p.set_text_color(*INK)
381+ val = k.get("value")
382+ p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else ""))
383+ p.set_xy(x + 4, y + 12)
384+ p.set_font("helvetica", "", 7.6)
385+ p.set_text_color(*INK2)
386+ p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70])
387+ if k.get("delta_pct") is not None:
388+ up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up"
389+ p.set_xy(x + 4, y + gh - 6.5)
390+ p.set_font("helvetica", "B", 8)
391+ p.set_text_color(*(GREEN if up else DANGER))
392+ arrow = "+" if k["delta_pct"] >= 0 else ""
393+ dv = round(float(k["delta_pct"]), 1)
394+ dv = int(dv) if float(dv).is_integer() else dv
395+ p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(dv).replace('.', ',')} % vs période préc.")
396+ p.set_y(y + gh + 8)
397+
398+ def _gauges(self):
399+ gs = self.d.get("gauges") or []
400+ gs = [g for g in gs if isinstance(g.get("value"), (int, float)) and g.get("max")]
401+ if not gs:
402+ return
403+ self._section_title("Taux & couvertures")
404+ p = self.pdf
405+ cols, gw, gh, gap = 3, 56, 34, 3
406+ x0, y = p.l_margin, p.get_y()
407+ for i, g in enumerate(gs[:9]):
408+ x = x0 + (i % cols) * (gw + gap)
409+ if i and i % cols == 0:
410+ y += gh + gap
411+ if y > 240:
412+ p.add_page(); y = p.get_y()
413+ self._card(x, y, gw, gh)
414+ frac = max(0.0, min(1.0, g["value"] / g["max"]))
415+ cx, cy, r = x + gw / 2, y + 20, 14
416+ # arc de fond + arc de valeur (demi-cercle en petits segments)
417+ for pass_col, pass_frac, lw in (((225, 223, 217), 1.0, 2.6), (self.accent, frac, 2.6)):
418+ p.set_draw_color(*pass_col)
419+ p.set_line_width(lw)
420+ steps = max(2, int(60 * pass_frac))
421+ last = None
422+ for st in range(steps + 1):
423+ a = math.pi + math.pi * pass_frac * st / steps
424+ pt = (cx + r * math.cos(a), cy + r * math.sin(a))
425+ if last:
426+ p.line(last[0], last[1], pt[0], pt[1])
427+ last = pt
428+ p.set_font("helvetica", "B", 11)
429+ p.set_text_color(*INK)
430+ p.set_xy(x + 4, cy - 5)
431+ p.cell(gw - 8, 6, f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", align="C")
432+ p.set_font("helvetica", "", 6.6)
433+ p.set_text_color(*INK3)
434+ p.set_xy(x + 4, cy + 1.5)
435+ p.cell(gw - 8, 4, f"{frac * 100:.0f} % de {_fr(g['max'])}", align="C")
436+ p.set_xy(x + 3, y + gh - 7)
437+ p.set_font("helvetica", "", 7)
438+ p.set_text_color(*INK2)
439+ p.multi_cell(gw - 6, 3.3, str(g.get("label", ""))[:60], align="C")
440+ p.set_y(y + gh + 8)
441+
442+ def _serie_stats_row(self, s):
443+ """Ligne min/max/moyenne/médiane sous un graphique de série."""
444+ p = self.pdf
445+ vs = [pt["v"] for pt in (s.get("points") or []) if isinstance(pt.get("v"), (int, float))]
446+ if len(vs) < 2:
447+ return
448+ sv = sorted(vs)
449+ mean = sum(vs) / len(vs)
450+ med = sv[len(sv) // 2]
451+ sd = math.sqrt(sum((v - mean) ** 2 for v in vs) / len(vs))
452+ p.set_font("helvetica", "", 6.8)
453+ p.set_text_color(*INK3)
454+ p.cell(0, 4, f"min {_fr(sv[0])} · max {_fr(sv[-1])} · moyenne {_fr(round(mean, 2))} · médiane {_fr(med)} · écart-type {_fr(round(sd, 2))}")
455+ p.ln(5.5)
456+
457+ def _line_chart(self, s, with_stats=False):
458+ p = self.pdf
459+ pts = s.get("points") or []
460+ if len(pts) < 2:
461+ return
462+ if s.get("kind") == "bar":
463+ self._vbars(s)
464+ return
465+ if p.get_y() > 200:
466+ p.add_page()
467+ self._chart_title(s.get("title", ""))
468+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
469+ self._card(x0, y0, w, h, fill=WHITE)
470+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
471+ vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])]
472+ vmax = max(vals) or 1
473+ vmin = min(0, min(vals))
474+ rng = (vmax - vmin) or 1
475+ p.set_font("helvetica", "", 6.3)
476+ p.set_text_color(*INK3)
477+ p.set_draw_color(200, 200, 195)
478+ p.set_line_width(0.15)
479+ for g in range(5):
480+ gy = cy + ch - ch * g / 4
481+ p.line(cx, gy, cx + cw, gy)
482+ p.set_xy(x0 + 1, gy - 1.6)
483+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
484+
485+ def xy(i, n, v):
486+ return (cx + cw * (i / (n - 1)), cy + ch - ch * ((v - vmin) / rng))
487+
488+ # aire sous la courbe (kind=area) : petits trapèzes accent pâle
489+ if s.get("kind") == "area":
490+ fill = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * 0.18) for j in range(3))
491+ p.set_fill_color(*fill)
492+ p.set_draw_color(*fill)
493+ n = len(pts)
494+ for i in range(n - 1):
495+ x1, y1 = xy(i, n, pts[i]["v"])
496+ x2, y2 = xy(i + 1, n, pts[i + 1]["v"])
497+ p.polygon([(x1, y1), (x2, y2), (x2, cy + ch), (x1, cy + ch)], style="DF")
498+
499+ def draw(series, color, width, dash=None):
500+ n = len(series)
501+ p.set_draw_color(*color)
502+ p.set_line_width(width)
503+ if dash:
504+ p.set_dash_pattern(dash=1.2, gap=1.2)
505+ last = None
506+ for i, pt in enumerate(series):
507+ px, py = xy(i, n, pt["v"])
508+ if last:
509+ p.line(last[0], last[1], px, py)
510+ last = (px, py)
511+ p.set_dash_pattern()
512+
513+ if s.get("compare"):
514+ draw(s["compare"], INK3, 0.35, dash=True)
515+ draw(pts, self.accent, 0.7)
516+ p.set_text_color(*INK3)
517+ for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)):
518+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
519+ p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C")
520+ p.set_y(y0 + h + 4)
521+ if s.get("compare"):
522+ p.set_font("helvetica", "", 6.8)
523+ p.set_text_color(*INK3)
524+ p.cell(0, 4, "— période courante (accent) · ---- période comparée")
525+ p.ln(5.5)
526+ if with_stats:
527+ self._serie_stats_row(s)
528+ p.ln(1.5)
529+
530+ def _vbars(self, s):
531+ """Barres verticales : série kind=bar ou distribution (bins)."""
532+ p = self.pdf
533+ pts = s.get("points") or [{"t": b.get("label"), "v": b.get("value")} for b in (s.get("bins") or [])]
534+ pts = [pt for pt in pts if isinstance(pt.get("v"), (int, float))]
535+ if not pts:
536+ return
537+ if p.get_y() > 205:
538+ p.add_page()
539+ self._chart_title(s.get("title", ""))
540+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 48
541+ self._card(x0, y0, w, h, fill=WHITE)
542+ cx, cy, cw, ch = x0 + 12, y0 + 5, w - 20, h - 14
543+ vmax = max(pt["v"] for pt in pts) or 1
544+ p.set_font("helvetica", "", 6.3)
545+ p.set_text_color(*INK3)
546+ p.set_draw_color(200, 200, 195)
547+ p.set_line_width(0.15)
548+ for g in range(5):
549+ gy = cy + ch - ch * g / 4
550+ p.line(cx, gy, cx + cw, gy)
551+ p.set_xy(x0 + 1, gy - 1.6)
552+ p.cell(10, 3, _fr(vmax * g / 4), align="R")
553+ n = len(pts)
554+ bw = max(0.8, cw / n - 0.6)
555+ p.set_fill_color(*self.accent)
556+ p.set_draw_color(*INK)
557+ p.set_line_width(0.15)
558+ for i, pt in enumerate(pts):
559+ bh = ch * (pt["v"] / vmax)
560+ p.rect(cx + cw * i / n + 0.3, cy + ch - bh, bw, max(bh, 0.4 if pt["v"] > 0 else 0), style="DF")
561+ p.set_text_color(*INK3)
562+ for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
563+ p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
564+ p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
565+ p.set_y(y0 + h + 5)
566+
567+ def _multiline(self, ms):
568+ """Multi-séries (≤4) : accent plein / encre fin / accent pointillé /
569+ gris pointillé — l'identité passe par le motif, pas la couleur seule."""
570+ p = self.pdf
571+ series = [s for s in (ms.get("series") or []) if len(s.get("points") or []) > 1][:4]
572+ if not series:
573+ return
574+ if p.get_y() > 195:
575+ p.add_page()
576+ self._chart_title(ms.get("title", ""))
577+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
578+ self._card(x0, y0, w, h, fill=WHITE)
579+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
580+ vals = [pt["v"] for s in series for pt in s["points"]]
581+ vmax = max(vals) or 1
582+ vmin = min(0, min(vals))
583+ rng = (vmax - vmin) or 1
584+ p.set_font("helvetica", "", 6.3)
585+ p.set_text_color(*INK3)
586+ p.set_draw_color(200, 200, 195)
587+ p.set_line_width(0.15)
588+ for g in range(5):
589+ gy = cy + ch - ch * g / 4
590+ p.line(cx, gy, cx + cw, gy)
591+ p.set_xy(x0 + 1, gy - 1.6)
592+ p.cell(10, 3, _fr(vmin + rng * g / 4), align="R")
593+ styles = [
594+ (self.accent, 0.7, None),
595+ (INK, 0.45, None),
596+ (self.accent, 0.55, True),
597+ (INK3, 0.5, True),
598+ ]
599+ for si, s in enumerate(series):
600+ col, lw, dash = styles[si]
601+ p.set_draw_color(*col)
602+ p.set_line_width(lw)
603+ if dash:
604+ p.set_dash_pattern(dash=1.4, gap=1.2)
605+ n = len(s["points"])
606+ last = None
607+ for i, pt in enumerate(s["points"]):
608+ px = cx + cw * (i / (n - 1))
609+ py = cy + ch - ch * ((pt["v"] - vmin) / rng)
610+ if last:
611+ p.line(last[0], last[1], px, py)
612+ last = (px, py)
613+ p.set_dash_pattern()
614+ ref = series[0]["points"]
615+ p.set_text_color(*INK3)
616+ for frac, idx in ((0, 0), (0.5, len(ref) // 2), (1, -1)):
617+ p.set_xy(cx + cw * frac - 9, cy + ch + 1.5)
618+ p.cell(18, 3, str(ref[idx].get("t", ""))[:10], align="C")
619+ p.set_y(y0 + h + 4)
620+ p.set_font("helvetica", "", 6.8)
621+ p.set_text_color(*INK3)
622+ marks = ["—", "—", "----", "----"]
623+ leg = " · ".join(f"{marks[i]} {s.get('label', '')}" for i, s in enumerate(series))
624+ p.cell(0, 4, leg[:120])
625+ p.ln(6)
626+
627+ def _stacked(self, st):
628+ p = self.pdf
629+ keys = (st.get("keys") or [])[:6]
630+ pts = st.get("points") or []
631+ if not keys or not pts:
632+ return
633+ if p.get_y() > 195:
634+ p.add_page()
635+ self._chart_title(st.get("title", ""))
636+ x0, y0, w, h = p.l_margin, p.get_y(), 174, 52
637+ self._card(x0, y0, w, h, fill=WHITE)
638+ cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16
639+ totals = [sum(v or 0 for v in pt.get("values", [])[: len(keys)]) for pt in pts]
640+ vmax = max(totals) or 1
641+ p.set_font("helvetica", "", 6.3)
642+ p.set_text_color(*INK3)
643+ p.set_draw_color(200, 200, 195)
644+ p.set_line_width(0.15)
645+ for g in range(5):
646+ gy = cy + ch - ch * g / 4
647+ p.line(cx, gy, cx + cw, gy)
648+ p.set_xy(x0 + 1, gy - 1.6)
649+ p.cell(10, 3, _fr(vmax * g / 4), align="R")
650+ n = len(pts)
651+ bw = max(0.8, cw / n - 0.6)
652+ p.set_draw_color(*WHITE)
653+ p.set_line_width(0.12)
654+ for i, pt in enumerate(pts):
655+ yacc = cy + ch
656+ for j, k in enumerate(keys):
657+ v = (pt.get("values") or [0] * len(keys))[j] if j < len(pt.get("values", [])) else 0
658+ if not v:
659+ continue
660+ bh = ch * (v / vmax)
661+ yacc -= bh
662+ p.set_fill_color(*self._shade(j))
663+ p.rect(cx + cw * i / n + 0.3, yacc, bw, max(bh - 0.15, 0.3), style="DF")
664+ p.set_text_color(*INK3)
665+ for frac, idx in ((0, 0), (0.5, n // 2), (1, -1)):
666+ p.set_xy(cx + cw * frac - 10, cy + ch + 1.5)
667+ p.cell(20, 3, str(pts[idx].get("t", ""))[:12], align="C")
668+ p.set_y(y0 + h + 4)
669+ # légende
670+ p.set_font("helvetica", "", 6.8)
671+ lx = p.l_margin
672+ for j, k in enumerate(keys):
673+ p.set_fill_color(*self._shade(j))
674+ p.set_draw_color(*INK)
675+ p.set_line_width(0.2)
676+ p.rect(lx, p.get_y() + 0.6, 3, 3, style="DF")
677+ p.set_xy(lx + 4, p.get_y())
678+ p.set_text_color(*INK2)
679+ txt = str(k)[:22]
680+ p.cell(p.get_string_width(txt) + 3, 4, txt)
681+ lx = p.get_x() + 3
682+ if lx > 165:
683+ break
684+ p.ln(7)
685+
686+ def _bars(self, title, items, unit=""):
687+ p = self.pdf
688+ items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12]
689+ if not items:
690+ return
691+ need = 10 + len(items) * 7
692+ if p.get_y() + need > 265:
693+ p.add_page()
694+ self._chart_title(title)
695+ p.ln(1)
696+ vmax = max(it["value"] for it in items) or 1
697+ for it in items:
698+ y = p.get_y()
699+ p.set_font("helvetica", "", 7.6)
700+ p.set_text_color(*INK)
701+ p.set_x(p.l_margin)
702+ p.cell(46, 5, str(it["label"])[:34])
703+ bw = 86 * (it["value"] / vmax)
704+ p.set_fill_color(*self.accent)
705+ p.set_draw_color(*INK)
706+ p.set_line_width(0.25)
707+ p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF")
708+ p.set_xy(p.l_margin + 136, y)
709+ p.set_font("helvetica", "B", 7.6)
710+ p.cell(24, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R")
711+ if it.get("delta_pct") is not None:
712+ up = it["delta_pct"] >= 0
713+ p.set_font("helvetica", "B", 6.6)
714+ p.set_text_color(*(GREEN if up else DANGER))
715+ p.cell(14, 5, f"{'+' if up else ''}{str(round(it['delta_pct'], 1)).replace('.', ',')} %", align="R")
716+ p.ln(6.4)
717+ p.ln(3)
718+
719+ def _donut(self, b):
720+ p = self.pdf
721+ items = [it for it in (b.get("items") or []) if it.get("value")][:8]
722+ total = sum(it["value"] for it in items)
723+ if not items or not total:
724+ return
725+ if p.get_y() > 210:
726+ p.add_page()
727+ self._chart_title(b.get("title", ""))
728+ p.ln(1)
729+ cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20
730+ start = -90.0
731+ for i, it in enumerate(items):
732+ frac = it["value"] / total
733+ col = self._shade(i)
734+ steps = max(2, int(72 * frac))
735+ p.set_fill_color(*col)
736+ p.set_draw_color(*col)
737+ for st in range(steps):
738+ a0 = math.radians(start + 360 * frac * st / steps)
739+ a1 = math.radians(start + 360 * frac * (st + 1) / steps)
740+ p.polygon(
741+ [(cx, cy),
742+ (cx + r * math.cos(a0), cy + r * math.sin(a0)),
743+ (cx + r * math.cos(a1), cy + r * math.sin(a1))],
744+ style="DF",
745+ )
746+ start += 360 * frac
747+ p.set_fill_color(*WHITE)
748+ p.set_draw_color(*INK)
749+ p.set_line_width(0.4)
750+ p.ellipse(cx - 11, cy - 11, 22, 22, style="DF")
751+ p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D")
752+ ly = cy - 22
753+ for i, it in enumerate(items):
754+ col = self._shade(i)
755+ p.set_fill_color(*col)
756+ p.set_draw_color(*INK)
757+ p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF")
758+ p.set_xy(p.l_margin + 66, ly)
759+ p.set_font("helvetica", "", 7.6)
760+ p.set_text_color(*INK)
761+ pct = 100 * it["value"] / total
762+ p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ","))
763+ ly += 5.6
764+ p.set_y(max(cy + r, ly) + 6)
765+
766+ def _hourly(self):
767+ hh = self.d.get("hourly") or {}
768+ cells = hh.get("cells") or []
769+ if not cells:
770+ return
771+ p = self.pdf
772+ if p.get_y() > 190:
773+ p.add_page()
774+ self._chart_title(hh.get("title", "Activité par jour et heure"))
775+ x0, y0 = p.l_margin, p.get_y()
776+ cw, chh, lx, ly = 6.4, 6.4, 12, 5
777+ vmax = max((c.get("value") or 0) for c in cells) or 1
778+ grid = {(c.get("dow"), c.get("hour")): c.get("value") or 0 for c in cells}
779+ dows = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]
780+ p.set_font("helvetica", "", 5.8)
781+ p.set_text_color(*INK3)
782+ for h in (0, 6, 12, 18, 23):
783+ p.set_xy(x0 + lx + h * cw, y0)
784+ p.cell(cw, 3, f"{h}h", align="C")
785+ for d in range(7):
786+ p.set_xy(x0, y0 + ly + d * chh + 1.5)
787+ p.cell(lx - 1, 3, dows[d], align="R")
788+ for h in range(24):
789+ v = grid.get((d, h), 0)
790+ f = 0.1 + 0.9 * (v / vmax) if v else 0.0
791+ col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) if v else (235, 233, 228)
792+ p.set_fill_color(*col)
793+ p.set_draw_color(215, 213, 207)
794+ p.set_line_width(0.1)
795+ p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF")
796+ p.set_y(y0 + ly + 7 * chh + 5)
797+
798+ def _calheat(self, hm):
799+ """v3 — calendrier de chaleur 26 semaines (équivalent PDF du
800+ CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours."""
801+ from datetime import date as _date, timedelta as _td
802+ cells = hm.get("cells") or []
803+ vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")}
804+ if not vals:
805+ return
806+ p = self.pdf
807+ if p.get_y() > 215:
808+ p.add_page()
809+ self._chart_title(hm.get("title", "Calendrier d'activité"))
810+ try:
811+ end = _date.fromisoformat(max(vals))
812+ except ValueError:
813+ return
814+ weeks = 26
815+ start = end - _td(days=weeks * 7 - 1)
816+ start -= _td(days=start.weekday()) # lundi
817+ vmax = max(vals.values()) or 1
818+ x0, y0 = p.l_margin, p.get_y()
819+ cw, lx, ly = 6.3, 10, 4
820+ dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"]
821+ p.set_font("helvetica", "", 5.8)
822+ p.set_text_color(*INK3)
823+ for d in range(7):
824+ if dows[d]:
825+ p.set_xy(x0, y0 + ly + d * cw + 1.2)
826+ p.cell(lx - 1, 3, dows[d], align="R")
827+ for w in range(weeks):
828+ monday = start + _td(days=7 * w)
829+ if monday.day <= 7: # étiquette de mois à la 1re semaine du mois
830+ p.set_xy(x0 + lx + w * cw, y0)
831+ p.cell(cw * 4, 3, monday.strftime("%m"))
832+ for d in range(7):
833+ day = monday + _td(days=d)
834+ v = vals.get(day.isoformat(), 0)
835+ f = 0.15 + 0.85 * (v / vmax) if v else 0.0
836+ col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f)
837+ for j in range(3)) if v else (235, 233, 228))
838+ p.set_fill_color(*col)
839+ p.set_draw_color(215, 213, 207)
840+ p.set_line_width(0.1)
841+ p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5,
842+ style="DF")
843+ p.set_y(y0 + ly + 7 * cw + 5)
844+
845+ # ---------- v3 : conversions bloc → tableau ----------
846+ @staticmethod
847+ def _serie_as_table(s):
848+ unit = s.get("unit") or "Valeur"
849+ cols = ["Date", unit.capitalize()]
850+ cmp_ = s.get("compare") or []
851+ if cmp_:
852+ cols.append("Période comparée")
853+ rows = []
854+ for i, pt in enumerate(s.get("points") or []):
855+ row = [str(pt.get("t", "")), pt.get("v", "")]
856+ if cmp_:
857+ row.append(cmp_[i]["v"] if i < len(cmp_) else "")
858+ rows.append(row)
859+ return {"id": s.get("id"), "title": s.get("title", ""),
860+ "columns": cols, "rows": rows}
861+
862+ @staticmethod
863+ def _multi_as_table(ms):
864+ labels = [s.get("label", "") for s in (ms.get("series") or [])][:4]
865+ by_t: dict[str, dict] = {}
866+ for s in (ms.get("series") or [])[:4]:
867+ for pt in s.get("points") or []:
868+ by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v")
869+ rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels]
870+ for t in sorted(by_t)]
871+ return {"id": ms.get("id"), "title": ms.get("title", ""),
872+ "columns": ["Date"] + labels, "rows": rows}
873+
874+ @staticmethod
875+ def _stacked_as_table(st):
876+ keys = (st.get("keys") or [])[:6]
877+ rows = []
878+ for pt in st.get("points") or []:
879+ vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0
880+ for j in range(len(keys))]
881+ rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)])
882+ return {"id": st.get("id"), "title": st.get("title", ""),
883+ "columns": ["Date"] + list(keys) + ["Total"], "rows": rows}
884+
885+ @staticmethod
886+ def _items_as_table(id_, title, items, label_col="Libellé"):
887+ items = items or []
888+ with_delta = any(it.get("delta_pct") is not None for it in items)
889+ cols = [label_col, "Valeur"] + (["delta %"] if with_delta else [])
890+ rows = []
891+ for it in items:
892+ row = [str(it.get("label", "")), it.get("value", "")]
893+ if with_delta:
894+ d = it.get("delta_pct")
895+ row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %")
896+ rows.append(row)
897+ return {"id": id_, "title": title, "columns": cols, "rows": rows}
898+
899+ def _kpis_as_table(self):
900+ rows = []
901+ for k in self.d.get("kpis") or []:
902+ v = k.get("value")
903+ val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \
904+ ((" " + k["unit"]) if k.get("unit") else "")
905+ d = k.get("delta_pct")
906+ rows.append([str(k.get("label", "")), val,
907+ "" if d is None else f"{'+' if d >= 0 else ''}{d} %"])
908+ return {"id": "kpis", "title": "Indicateurs clés",
909+ "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows}
910+
911+ def _gauges_as_table(self):
912+ rows = [[str(g.get("label", "")),
913+ f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}",
914+ _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"]
915+ for g in self.d.get("gauges") or []
916+ if isinstance(g.get("value"), (int, float)) and g.get("max")]
917+ return {"id": "gauges", "title": "Taux & couvertures",
918+ "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows}
919+
920+ def _records_as_table(self):
921+ rows = [[str(r.get("label", "")), str(r.get("value", "")),
922+ str(r.get("date", "") or "")]
923+ for r in self.d.get("records") or []]
924+ return {"id": "records", "title": "Records & faits marquants",
925+ "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows}
926+
927+ @staticmethod
928+ def _heatmap_as_table(hm, title):
929+ cells = sorted((hm.get("cells") or []),
930+ key=lambda c: -(c.get("value") or 0))[:40]
931+ return {"id": "heatmap", "title": title + " — jours les plus chargés",
932+ "columns": ["Date", "Valeur"],
933+ "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]}
934+
935+ @staticmethod
936+ def _hourly_as_table(hr, title):
937+ days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi",
938+ "Dimanche"]
939+ cells = sorted((hr.get("cells") or []),
940+ key=lambda c: -(c.get("value") or 0))[:40]
941+ return {"id": "hourly", "title": title + " — créneaux les plus actifs",
942+ "columns": ["Jour", "Heure", "Valeur"],
943+ "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?",
944+ f"{c.get('hour', '?')} h", c.get("value") or 0]
945+ for c in cells]}
946+
947+ # ---------- v3 : rendu d'un bloc du rapport personnalisé ----------
948+ def _find(self, coll: str, id_: str):
949+ for it in self.d.get(coll) or []:
950+ if str(it.get("id")) == id_:
951+ return it
952+ return None
953+
954+ def _toc_mark(self, title: str):
955+ """Blocs graphiques du mode personnalisé : entrée de sommaire sans
956+ _section_title (le graphique porte déjà son titre)."""
957+ if self.pdf.get_y() > 235:
958+ self.pdf.add_page()
959+ self.toc.append((title, self.pdf.page_no()))
960+
961+ def _render_block(self, key: str, render: str):
962+ section, _, id_ = key.partition(":")
963+ if section == "kpis":
964+ self._table(self._kpis_as_table()) if render == "table" else self._kpis()
965+ elif section == "gauges":
966+ self._table(self._gauges_as_table()) if render == "table" else self._gauges()
967+ elif section == "records":
968+ self._table(self._records_as_table()) if render == "table" else self._records()
969+ elif section == "series":
970+ s = self._find("series", id_)
971+ if not s:
972+ return
973+ if render == "table":
974+ self._table(self._serie_as_table(s), max_rows=400)
975+ else:
976+ s2 = dict(s)
977+ if render in ("line", "area", "bar"):
978+ s2["kind"] = render
979+ self._toc_mark(s2.get("title", ""))
980+ if s2.get("kind") == "bar":
981+ self._vbars(s2)
982+ else:
983+ self._line_chart(s2, with_stats=True)
984+ elif section == "multiseries":
985+ ms = self._find("multiseries", id_)
986+ if not ms:
987+ return
988+ if render == "table":
989+ self._table(self._multi_as_table(ms), max_rows=400)
990+ else:
991+ self._toc_mark(ms.get("title", ""))
992+ self._multiline(ms)
993+ elif section == "stacked":
994+ st = self._find("stacked", id_)
995+ if not st:
996+ return
997+ if render == "table":
998+ self._table(self._stacked_as_table(st), max_rows=400)
999+ else:
1000+ self._toc_mark(st.get("title", ""))
1001+ self._stacked(st)
1002+ elif section == "breakdowns":
1003+ b = self._find("breakdowns", id_)
1004+ if not b:
1005+ return
1006+ if render == "table":
1007+ self._table(self._items_as_table(id_, b.get("title", ""),
1008+ b.get("items")), max_rows=400)
1009+ else:
1010+ self._toc_mark(b.get("title", ""))
1011+ if render == "donut":
1012+ self._donut(b)
1013+ else:
1014+ self._bars(b.get("title", ""), b.get("items"))
1015+ elif section == "distributions":
1016+ d = self._find("distributions", id_)
1017+ if not d:
1018+ return
1019+ if render == "table":
1020+ bins = [{"label": bn.get("label"), "value": bn.get("value")}
1021+ for bn in d.get("bins") or []]
1022+ self._table(self._items_as_table(id_, d.get("title", ""), bins,
1023+ label_col="Tranche"))
1024+ else:
1025+ self._toc_mark(d.get("title", ""))
1026+ self._vbars(d)
1027+ elif section == "geo":
1028+ geo = self.d.get("geo") or {}
1029+ if not geo.get("items"):
1030+ return
1031+ title = geo.get("title", "Répartition géographique")
1032+ if render == "table":
1033+ self._table(self._items_as_table("geo", title, geo["items"],
1034+ label_col="Zone"), max_rows=400)
1035+ else:
1036+ self._toc_mark(title)
1037+ self._bars(title, geo["items"])
1038+ elif section == "heatmap":
1039+ hm = self.d.get("heatmap") or {}
1040+ if not hm.get("cells"):
1041+ return
1042+ title = hm.get("title", "Calendrier d'activité")
1043+ if render == "table":
1044+ self._table(self._heatmap_as_table(hm, title))
1045+ else:
1046+ self._toc_mark(title)
1047+ self._calheat(hm)
1048+ elif section == "hourly":
1049+ hr = self.d.get("hourly") or {}
1050+ if not hr.get("cells"):
1051+ return
1052+ title = hr.get("title", "Activité par jour et heure")
1053+ if render == "table":
1054+ self._table(self._hourly_as_table(hr, title))
1055+ else:
1056+ self._toc_mark(title)
1057+ self._hourly()
1058+ elif section == "tables":
1059+ t = self._find("tables", id_)
1060+ if t:
1061+ self._table(t, max_rows=400)
1062+
1063+ def _table(self, t, max_rows=200):
1064+ p = self.pdf
1065+ cols = t.get("columns") or []
1066+ rows = t.get("rows") or []
1067+ if not cols or not rows:
1068+ return
1069+ self._section_title(t.get("title", "Tableau"))
1070+ w = 174 / len(cols)
1071+ def head():
1072+ p.set_font("helvetica", "B", 7.6)
1073+ p.set_fill_color(*INK)
1074+ p.set_text_color(*WHITE)
1075+ for c in cols:
1076+ p.cell(w, 6, " " + str(c)[:30], fill=True)
1077+ p.ln(6)
1078+ head()
1079+ p.set_text_color(*INK)
1080+ for i, row in enumerate(rows[:max_rows]):
1081+ if p.get_y() > 262:
1082+ p.add_page()
1083+ head()
1084+ p.set_text_color(*INK)
1085+ p.set_font("helvetica", "", 7.4)
1086+ p.set_fill_color(*(SURFACE2 if i % 2 else WHITE))
1087+ for cell in row:
1088+ txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell)
1089+ p.cell(w, 5.4, " " + txt[:34], fill=True)
1090+ p.ln(5.4)
1091+ if len(rows) > max_rows:
1092+ p.set_font("helvetica", "", 7)
1093+ p.set_text_color(*INK3)
1094+ p.cell(0, 5, f"… {len(rows) - max_rows} lignes supplémentaires non imprimées")
1095+ p.ln(6)
1096+
1097+ def _records(self):
1098+ recs = self.d.get("records") or []
1099+ if not recs:
1100+ return
1101+ self._section_title("Records & faits marquants")
1102+ p = self.pdf
1103+ for r in recs[:14]:
1104+ if p.get_y() > 258:
1105+ p.add_page()
1106+ y = p.get_y()
1107+ self._card(p.l_margin, y, 174, 11, fill=SURFACE2)
1108+ p.set_xy(p.l_margin + 4, y + 2)
1109+ p.set_font("helvetica", "", 8.6)
1110+ p.set_text_color(*INK2)
1111+ p.cell(96, 7, str(r.get("label", ""))[:70])
1112+ p.set_font("helvetica", "B", 9)
1113+ p.set_text_color(*INK)
1114+ p.cell(52, 7, str(r.get("value", ""))[:36], align="R")
1115+ p.set_font("helvetica", "", 7.6)
1116+ p.set_text_color(*INK3)
1117+ p.cell(20, 7, str(r.get("date", "") or ""), align="R")
1118+ p.set_y(y + 13.5)
1119+ p.ln(4)
1120+
1121+ def _final_page(self):
1122+ p = self.pdf
1123+ p.add_page()
1124+ self._kicker("Groupe KA · contact")
1125+ p.set_font("helvetica", "B", 15)
1126+ p.set_text_color(*INK)
1127+ p.cell(0, 8, "Coordonnées du Groupe KA")
1128+ p.ln(12)
1129+ for email, role in EMAILS:
1130+ p.set_font("helvetica", "B", 10.5)
1131+ p.set_text_color(*INK)
1132+ p.cell(0, 6, email)
1133+ p.ln(5.5)
1134+ p.set_font("helvetica", "", 8.6)
1135+ p.set_text_color(*INK3)
1136+ p.cell(0, 5, role)
1137+ p.ln(8)
1138+ p.ln(2)
1139+ p.set_font("helvetica", "B", 10)
1140+ p.set_text_color(*GREEN)
1141+ p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka")
1142+ p.ln(10)
1143+ p.set_draw_color(*self.accent)
1144+ p.set_line_width(0.8)
1145+ p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y())
1146+ p.ln(4)
1147+ p.set_font("helvetica", "", 8.6)
1148+ p.set_text_color(*INK2)
1149+ p.multi_cell(160, 4.6, DISCLAIMER)
1150+ p.ln(4)
1151+ p.set_font("helvetica", "", 7.6)
1152+ p.set_text_color(*INK3)
1153+ p.multi_cell(
1154+ 160, 4.2,
1155+ "Mentions : rapport généré automatiquement à partir des données réelles de la "
1156+ "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique "
1157+ "de confidentialité et protection des renseignements personnels (Loi 25) : "
1158+ "groupe-ka.com/conditions · /confidentialite · /loi-25.",
1159+ )
1160+
1161+ # ---------- groupes de sections ----------
1162+ def _all_series(self, with_stats=True):
1163+ for s in self.d.get("series") or []:
1164+ self._line_chart(s, with_stats=with_stats)
1165+ for ms in self.d.get("multiseries") or []:
1166+ self._multiline(ms)
1167+ for st in self.d.get("stacked") or []:
1168+ self._stacked(st)
1169+
1170+ def _all_breakdowns(self):
1171+ for b in self.d.get("breakdowns") or []:
1172+ if b.get("kind") == "donut":
1173+ self._donut(b)
1174+ else:
1175+ self._bars(b.get("title", ""), b.get("items"))
1176+ for dist in self.d.get("distributions") or []:
1177+ self._vbars(dist)
1178+ geo = self.d.get("geo")
1179+ if geo:
1180+ self._bars(geo.get("title", "Répartition géographique"), geo.get("items"))
1181+ self._hourly()
1182+
1183+ def build(self) -> bytes:
1184+ p = self.pdf
1185+ p.alias_nb_pages()
1186+ self._cover()
1187+ with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE)
1188+ toc_page_no = None
1189+ if self.mode == "synthese":
1190+ p.add_page()
1191+ self._kpis()
1192+ self._gauges()
1193+ self._records()
1194+ self._final_page()
1195+ elif self.mode == "tendances":
1196+ p.add_page()
1197+ self._kpis()
1198+ self._section_title("Évolution & tendances")
1199+ self._all_series(with_stats=True)
1200+ self._records()
1201+ self._final_page()
1202+ elif self.mode == "repartitions":
1203+ p.add_page()
1204+ self._section_title("Répartitions, distributions & géographie")
1205+ self._all_breakdowns()
1206+ self._final_page()
1207+ elif self.mode == "donnees":
1208+ p.add_page()
1209+ toc_page_no = p.page_no()
1210+ for t in self.d.get("tables") or []:
1211+ self._table(t, max_rows=400)
1212+ self._final_page()
1213+ elif self.mode == CUSTOM_MODE:
1214+ p.add_page()
1215+ toc_page_no = p.page_no()
1216+ p.add_page()
1217+ known = {b["key"]: b for b in catalog(self.d)}
1218+ for blk in self.spec.get("blocks") or []:
1219+ key = str(blk.get("key", ""))
1220+ b = known.get(key)
1221+ if not b:
1222+ continue
1223+ render = str(blk.get("render") or "")
1224+ if render not in b["renders"]:
1225+ render = b["default_render"]
1226+ self._render_block(key, render)
1227+ self._final_page()
1228+ else: # complet
1229+ p.add_page()
1230+ toc_page_no = p.page_no()
1231+ p.add_page()
1232+ self._kpis()
1233+ self._gauges()
1234+ if (self.d.get("series") or self.d.get("multiseries") or self.d.get("stacked")):
1235+ self._section_title("Évolution & tendances")
1236+ self._all_series(with_stats=True)
1237+ if (self.d.get("breakdowns") or self.d.get("distributions") or self.d.get("geo") or self.d.get("hourly")):
1238+ self._section_title("Répartitions, distributions & géographie")
1239+ self._all_breakdowns()
1240+ for t in self.d.get("tables") or []:
1241+ self._table(t)
1242+ self._records()
1243+ self._final_page()
1244+ # sommaire écrit sur la page réservée
1245+ if toc_page_no is not None:
1246+ last_page = p.page
1247+ p.page = toc_page_no
1248+ p.set_y(22)
1249+ p.set_font("helvetica", "B", 15)
1250+ p.set_text_color(*INK)
1251+ p.cell(0, 8, "Sommaire")
1252+ p.ln(12)
1253+ p.set_font("helvetica", "", 9.5)
1254+ for title, page_no in self.toc:
1255+ p.set_text_color(*INK)
1256+ p.cell(140, 6.5, title[:80])
1257+ p.set_text_color(*INK3)
1258+ p.cell(0, 6.5, str(page_no), align="R")
1259+ p.ln(6.5)
1260+ p.page = last_page
1261+ return bytes(p.output())
1262+
1263+
1264+def filename(platform_id: str, period: str, mode: str = "complet") -> str:
1265+ today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d")
1266+ suffix = "" if mode in ("", "complet") else f"_{mode}"
1267+ return f"groupe-ka_{platform_id}_stats_{period}{suffix}_{today}.pdf"
added immoka/mortgage/__init__.py +7 −0
@@ -0,0 +1,7 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/ : moteur hypothécaire canadien — collecte des taux publiés par les
5+# institutions financières, historisation, calculs (composition
6+# semestrielle canadienne, SCHL, stress test, abordabilité).
7+# -----------------------------------------------------------------------------
added immoka/mortgage/api.py +373 −0
@@ -0,0 +1,373 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/api.py : routes /api/mortgage/* — taux courants, meilleur taux,
5+# historique, santé des providers, calculateur canadien, abordabilité.
6+# Lecture seule sur mortgage.db (le scheduler écrit) ; cache applicatif
7+# 5 min sur les lectures ; jamais d'internals de scraping exposés (le champ
8+# raw est retiré au niveau du store).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import time
13+
14+from fastapi import APIRouter, Body, HTTPException, Query
15+
16+from . import calc, cmhc, store
17+
18+router = APIRouter(prefix="/api/mortgage", tags=["mortgage"])
19+
20+CACHE_TTL_S = 300
21+_cache: dict[str, tuple[float, object]] = {}
22+
23+RATE_TYPES = {"fixed", "variable", "adjustable", "other"}
24+KINDS = {"posted", "special"}
25+INSURED = {"insured", "insurable", "uninsured", "unknown"}
26+
27+
28+def _cached(key: str, builder):
29+ hit = _cache.get(key)
30+ now = time.time()
31+ if hit and now - hit[0] < CACHE_TTL_S:
32+ return hit[1]
33+ value = builder()
34+ _cache[key] = (now, value)
35+ if len(_cache) > 512: # borne mémoire : purge des entrées expirées
36+ for k in [k for k, (t, _) in _cache.items() if now - t >= CACHE_TTL_S]:
37+ _cache.pop(k, None)
38+ return value
39+
40+
41+def _check(value, allowed: set, label: str):
42+ if value is not None and value not in allowed:
43+ raise HTTPException(422, f"{label} invalide : {value}")
44+ return value
45+
46+
47+@router.get("/rates")
48+def rates(rate_type: str | None = None, term_months: int | None = None,
49+ kind: str | None = None, insured_status: str | None = None,
50+ purpose: str | None = Query("purchase"),
51+ provider: str | None = None):
52+ """Taux courants (dernière donnée valide par produit), filtrables."""
53+ _check(rate_type, RATE_TYPES, "rate_type")
54+ _check(kind, KINDS, "kind")
55+ _check(insured_status, INSURED, "insured_status")
56+ if purpose in ("", "all"):
57+ purpose = None
58+ key = f"rates|{rate_type}|{term_months}|{kind}|{insured_status}|{purpose}|{provider}"
59+
60+ def build():
61+ con = store.connect()
62+ rows = store.current_rates(
63+ con, rate_type=rate_type, term_months=term_months, kind=kind,
64+ insured_status=insured_status, purpose=purpose, provider=provider)
65+ con.close()
66+ return {"count": len(rows), "rates": rows,
67+ "stale_hours_threshold": store.STALE_H}
68+ return _cached(key, build)
69+
70+
71+@router.get("/rates/best")
72+def rates_best(rate_type: str = "fixed", term_months: int = 60,
73+ insured_status: str | None = None, purpose: str = "purchase"):
74+ """Meilleur taux courant par produit comparable + comparateur par banque."""
75+ _check(rate_type, RATE_TYPES, "rate_type")
76+ _check(insured_status, INSURED, "insured_status")
77+ key = f"best|{rate_type}|{term_months}|{insured_status}|{purpose}"
78+
79+ def build():
80+ con = store.connect()
81+ best = store.best_rate(con, rate_type=rate_type,
82+ term_months=term_months,
83+ insured_status=insured_status, purpose=purpose)
84+ con.close()
85+ if best is None:
86+ raise HTTPException(404, "Aucun taux courant pour ces critères")
87+ return best
88+ return _cached(key, build)
89+
90+
91+@router.get("/rates/history")
92+def rates_history(rate_type: str = "fixed", term_months: int = 60,
93+ provider: str | None = None, kind: str | None = None,
94+ days: int = 365):
95+ """Périodes de validité par produit — reconstruit « le taux X à date ». """
96+ _check(rate_type, RATE_TYPES, "rate_type")
97+ _check(kind, KINDS, "kind")
98+ days = max(1, min(days, 730))
99+ key = f"hist|{rate_type}|{term_months}|{provider}|{kind}|{days}"
100+
101+ def build():
102+ con = store.connect()
103+ rows = store.history(con, rate_type, term_months,
104+ provider=provider, kind=kind, days=days)
105+ con.close()
106+ return {"count": len(rows), "days": days, "history": rows}
107+ return _cached(key, build)
108+
109+
110+@router.get("/providers")
111+def providers():
112+ """Santé des connecteurs de taux (OK / WARNING / ERROR + fraîcheur)."""
113+ def build():
114+ from .providers import PROVIDERS
115+ con = store.connect()
116+ health = store.provider_health(con)
117+ con.close()
118+ names = {slug: cls.institution for slug, cls in PROVIDERS.items()}
119+ urls = {slug: cls.source_url for slug, cls in PROVIDERS.items()}
120+ out = []
121+ for h in health:
122+ out.append({
123+ "provider": h["provider"],
124+ "institution": names.get(h["provider"], h["provider"]),
125+ "source_url": urls.get(h["provider"]),
126+ "level": h["level"],
127+ "status": h["status"],
128+ "age_minutes": h["age_minutes"],
129+ "current_products": h["current_products"],
130+ "last_data_at": h["last_data_at"],
131+ })
132+ return {"providers": out,
133+ "registered": sorted(names),
134+ "stale_hours_threshold": store.STALE_H}
135+ return _cached("providers", build)
136+
137+
138+@router.get("/market")
139+def market(rate_type: str = "fixed", term_months: int = 60):
140+ """Métriques Mortgage Intelligence pour un produit donné."""
141+ _check(rate_type, RATE_TYPES, "rate_type")
142+
143+ def build():
144+ con = store.connect()
145+ stats = store.market_stats(con, rate_type=rate_type,
146+ term_months=term_months)
147+ con.close()
148+ if stats is None:
149+ raise HTTPException(404, "Aucun taux courant pour ces critères")
150+ return stats
151+ return _cached(f"market|{rate_type}|{term_months}", build)
152+
153+
154+@router.get("/intelligence")
155+def intelligence():
156+ """Vue d'ensemble du marché : les produits phares en un appel."""
157+ def build():
158+ con = store.connect()
159+ combos = [("fixed", 12), ("fixed", 36), ("fixed", 48),
160+ ("fixed", 60), ("fixed", 120), ("variable", 60)]
161+ grid = []
162+ for rtype, term in combos:
163+ s = store.market_stats(con, rate_type=rtype, term_months=term)
164+ if s:
165+ grid.append(s)
166+ prime_rows = store.current_rates(con, rate_type="other")
167+ con.close()
168+ prime = [{"institution": r["institution"], "rate": r["rate"],
169+ "product_name": r["product_name"],
170+ "age_minutes": r["age_minutes"]}
171+ for r in prime_rows if "préférentiel" in
172+ (r["product_name"] or "").lower() or "prime" in
173+ (r["product_name"] or "").lower()]
174+ return {"products": grid, "prime_rates": prime}
175+ return _cached("intelligence", build)
176+
177+
178+# ---------------------------------------------------------------------------
179+# Calculateur
180+# ---------------------------------------------------------------------------
181+
182+def _pick_rate(rate_type: str, term_months: int,
183+ insured_status: str | None) -> dict | None:
184+ con = store.connect()
185+ best = store.best_rate(con, rate_type=rate_type, term_months=term_months,
186+ insured_status=insured_status)
187+ con.close()
188+ return best
189+
190+
191+def _validate_scenario(price: float, down: float, amort: int, term: int,
192+ frequency: str) -> None:
193+ if price <= 0 or price > 100_000_000:
194+ raise HTTPException(422, "Prix invalide")
195+ if down < 0 or down >= price:
196+ raise HTTPException(422, "Mise de fonds invalide")
197+ if amort not in calc.AMORTIZATIONS_YEARS and not (5 <= amort <= 30):
198+ raise HTTPException(422, "Amortissement invalide (5–30 ans)")
199+ if not 3 <= term <= 120:
200+ raise HTTPException(422, "Terme invalide (3–120 mois)")
201+ if frequency not in calc.FREQUENCIES:
202+ raise HTTPException(422, f"Fréquence invalide : {frequency}")
203+
204+
205+@router.post("/calculate")
206+def calculate(body: dict = Body(...)):
207+ """Calcul hypothécaire canadien complet pour un scénario.
208+
209+ Entrées : price, down_payment (ou down_payment_pct), amortization_years,
210+ term_months, frequency, rate_type, rate (sinon meilleur taux observé),
211+ insured_status?, include_schedule?, income?, other_debts_monthly?,
212+ property_tax_monthly?, heating_monthly?, condo_fees_monthly?.
213+ """
214+ try:
215+ price = float(body.get("price") or 0)
216+ if body.get("down_payment") is not None:
217+ down = float(body["down_payment"])
218+ else:
219+ down = price * float(body.get("down_payment_pct") or 20) / 100
220+ amort = int(body.get("amortization_years") or 25)
221+ term = int(body.get("term_months") or 60)
222+ frequency = str(body.get("frequency") or "monthly")
223+ rate_type = str(body.get("rate_type") or "fixed")
224+ except (TypeError, ValueError):
225+ raise HTTPException(422, "Paramètres numériques invalides")
226+ _check(rate_type, {"fixed", "variable"}, "rate_type")
227+ _validate_scenario(price, down, amort, term, frequency)
228+
229+ quote = cmhc.insurance_quote(price, down, amort)
230+ rate_source = None
231+ rate = body.get("rate")
232+ if rate is None:
233+ insured = "insured" if quote["required"] and quote["eligible"] else None
234+ best = _pick_rate(rate_type, term, insured)
235+ if best is None:
236+ raise HTTPException(
237+ 503, "Aucun taux courant disponible — réessayez plus tard")
238+ rate = best["rate"]
239+ rate_source = {k: best[k] for k in
240+ ("provider", "institution", "product_name", "kind",
241+ "rate", "apr", "insured_status", "source_url",
242+ "last_checked", "age_minutes", "stale")}
243+ rate = float(rate)
244+ if not 0 < rate <= 25:
245+ raise HTTPException(422, "Taux invalide")
246+ if quote["required"] and not quote["eligible"]:
247+ principal = price - down # non assurable : calcul quand même, signalé
248+ else:
249+ principal = quote["total_mortgage"] if quote["required"] else price - down
250+
251+ compounding = "semi-annual" if rate_type == "fixed" else "monthly"
252+ pay = calc.payment(principal, rate, amort, frequency, compounding)
253+ monthly_eq = calc.payment(principal, rate, amort, "monthly", compounding)
254+ q_rate = calc.qualifying_rate(rate)
255+ q_pay = calc.payment(principal, q_rate, amort, frequency, compounding)
256+ out = {
257+ "inputs": {"price": price, "down_payment": round(down, 2),
258+ "down_payment_pct": round(down / price * 100, 2),
259+ "rate": rate, "rate_type": rate_type,
260+ "term_months": term, "amortization_years": amort,
261+ "frequency": frequency, "compounding": compounding},
262+ "insurance": quote,
263+ "principal": round(principal, 2),
264+ "payment": pay,
265+ "payment_monthly_equivalent": monthly_eq,
266+ "qualifying": {"rate": q_rate, "payment": q_pay,
267+ "note": "Test de résistance : max(taux + 2, 5,25 %)"},
268+ "term": calc.term_summary(principal, rate, amort, term,
269+ frequency, compounding),
270+ "stress": calc.stress_scenarios(principal, rate, amort,
271+ frequency, compounding),
272+ "renewal": calc.renewal_scenarios(principal, rate, amort, term,
273+ frequency, compounding),
274+ "payoff_years": calc.payoff_years(principal, rate, amort,
275+ frequency, compounding),
276+ "rate_source": rate_source,
277+ }
278+ rows = calc.schedule(principal, rate, amort, frequency, compounding)
279+ out["annual"] = calc.annual_rollup(rows, frequency)
280+ if body.get("include_schedule"):
281+ out["schedule"] = rows
282+ income = body.get("income")
283+ if income:
284+ out["ratios"] = calc.gds_tds(
285+ float(income), monthly_eq,
286+ float(body.get("property_tax_monthly") or 0),
287+ float(body.get("heating_monthly") or 0),
288+ float(body.get("condo_fees_monthly") or 0),
289+ float(body.get("other_debts_monthly") or 0))
290+ return out
291+
292+
293+@router.post("/affordability")
294+def affordability(body: dict = Body(...)):
295+ """Capacité d'achat : prix max selon un versement cible OU selon les
296+ revenus (ABD/ATD au taux de qualification) ; taux requis pour un
297+ versement cible vs meilleur taux observé."""
298+ try:
299+ amort = int(body.get("amortization_years") or 25)
300+ term = int(body.get("term_months") or 60)
301+ frequency = str(body.get("frequency") or "monthly")
302+ rate_type = str(body.get("rate_type") or "fixed")
303+ down = float(body.get("down_payment") or 0)
304+ except (TypeError, ValueError):
305+ raise HTTPException(422, "Paramètres numériques invalides")
306+ _check(rate_type, {"fixed", "variable"}, "rate_type")
307+ if frequency not in calc.FREQUENCIES:
308+ raise HTTPException(422, f"Fréquence invalide : {frequency}")
309+
310+ rate = body.get("rate")
311+ rate_source = None
312+ if rate is None:
313+ best = _pick_rate(rate_type, term, None)
314+ if best is None:
315+ raise HTTPException(
316+ 503, "Aucun taux courant disponible — réessayez plus tard")
317+ rate = best["rate"]
318+ rate_source = {k: best[k] for k in
319+ ("provider", "institution", "product_name", "kind",
320+ "rate", "source_url", "age_minutes", "stale")}
321+ rate = float(rate)
322+ if not 0 < rate <= 25:
323+ raise HTTPException(422, "Taux invalide")
324+ compounding = "semi-annual" if rate_type == "fixed" else "monthly"
325+ q_rate = calc.qualifying_rate(rate)
326+ out: dict = {"rate": rate, "qualifying_rate": q_rate,
327+ "rate_source": rate_source}
328+
329+ target = body.get("target_payment_monthly")
330+ if target:
331+ target = float(target)
332+ loan = calc.max_loan(target, rate, amort, "monthly", compounding)
333+ loan_q = calc.max_loan(target, q_rate, amort, "monthly", compounding)
334+ out["from_payment"] = {
335+ "target_payment_monthly": target,
336+ "max_loan": loan, "max_price": round(loan + down, 2),
337+ "max_loan_stress_tested": loan_q,
338+ "max_price_stress_tested": round(loan_q + down, 2),
339+ }
340+ income = body.get("income")
341+ if income:
342+ income = float(income)
343+ gds_room = income / 12 * 0.39 \
344+ - float(body.get("property_tax_monthly") or 0) \
345+ - float(body.get("heating_monthly") or 0) \
346+ - float(body.get("condo_fees_monthly") or 0) * 0.5
347+ tds_room = income / 12 * 0.44 \
348+ - float(body.get("property_tax_monthly") or 0) \
349+ - float(body.get("heating_monthly") or 0) \
350+ - float(body.get("condo_fees_monthly") or 0) * 0.5 \
351+ - float(body.get("other_debts_monthly") or 0)
352+ room = max(0.0, min(gds_room, tds_room))
353+ loan_q = calc.max_loan(room, q_rate, amort, "monthly", compounding)
354+ out["from_income"] = {
355+ "income": income, "max_payment_monthly": round(room, 2),
356+ "max_loan_stress_tested": loan_q,
357+ "max_price_estimate": round(loan_q + down, 2),
358+ "note": ("Indicatif seulement (ABD 39 % / ATD 44 % au taux de "
359+ "qualification) — ne constitue pas une préapprobation."),
360+ }
361+ principal = body.get("principal")
362+ target_rate_payment = body.get("required_rate_for_payment")
363+ if principal and target_rate_payment:
364+ req = calc.required_rate(float(principal), float(target_rate_payment),
365+ amort, "monthly", compounding)
366+ out["required_rate"] = {
367+ "principal": float(principal),
368+ "target_payment_monthly": float(target_rate_payment),
369+ "rate": req,
370+ "achievable_now": req is not None and req >= rate,
371+ "best_observed": rate,
372+ }
373+ return out
added immoka/mortgage/calc.py +257 −0
@@ -0,0 +1,257 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/calc.py : mathématiques hypothécaires canadiennes.
5+# Taux fixes : intérêt composé SEMESTRIELLEMENT, non à l'avance (Loi sur
6+# l'intérêt, art. 6) — jamais la formule américaine (composition mensuelle).
7+# Taux variables : composition mensuelle (convention majoritaire des
8+# prêteurs canadiens).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import math
13+
14+# Fréquences de paiement : nombre de versements par année.
15+FREQUENCIES: dict[str, int] = {
16+ "monthly": 12,
17+ "semimonthly": 24,
18+ "biweekly": 26,
19+ "accelerated-biweekly": 26,
20+ "weekly": 52,
21+ "accelerated-weekly": 52,
22+}
23+
24+# Taux de qualification minimal (test de résistance B-20 / ligne directrice
25+# du BSIF) : max(taux contractuel + 2 points, plancher).
26+STRESS_TEST_FLOOR = 5.25
27+STRESS_TEST_BUFFER = 2.0
28+
29+AMORTIZATIONS_YEARS = [10, 15, 20, 25, 30]
30+TERMS_MONTHS = [12, 24, 36, 48, 60, 84, 120]
31+
32+
33+def periodic_rate(annual_pct: float, frequency: str = "monthly",
34+ compounding: str = "semi-annual") -> float:
35+ """Taux périodique équivalent au taux nominal annuel `annual_pct` (%).
36+
37+ compounding="semi-annual" : convention canadienne des prêts fixes —
38+ i = (1 + r/2)^(2/f) − 1. "monthly" : prêts variables — i = (1+r/12)^(12/f) − 1.
39+ """
40+ if annual_pct < 0:
41+ raise ValueError("taux négatif")
42+ f = FREQUENCIES[frequency]
43+ r = annual_pct / 100.0
44+ if compounding == "monthly":
45+ return (1.0 + r / 12.0) ** (12.0 / f) - 1.0
46+ return (1.0 + r / 2.0) ** (2.0 / f) - 1.0
47+
48+
49+def payment(principal: float, annual_pct: float, amort_years: float,
50+ frequency: str = "monthly",
51+ compounding: str = "semi-annual") -> float:
52+ """Versement périodique (arrondi au cent).
53+
54+ Fréquences accélérées : convention canadienne — le versement mensuel
55+ divisé par 2 (aux deux semaines) ou par 4 (hebdomadaire), ce qui raccourcit
56+ l'amortissement réel.
57+ """
58+ if principal <= 0:
59+ return 0.0
60+ if frequency in ("accelerated-biweekly", "accelerated-weekly"):
61+ m = payment(principal, annual_pct, amort_years, "monthly", compounding)
62+ return round(m / (2 if frequency == "accelerated-biweekly" else 4), 2)
63+ i = periodic_rate(annual_pct, frequency, compounding)
64+ n = round(amort_years * FREQUENCIES[frequency])
65+ if i == 0:
66+ return round(principal / n, 2)
67+ return round(principal * i / (1.0 - (1.0 + i) ** -n), 2)
68+
69+
70+def schedule(principal: float, annual_pct: float, amort_years: float,
71+ frequency: str = "monthly", compounding: str = "semi-annual",
72+ pay_amount: float | None = None,
73+ max_periods: int | None = None) -> list[dict]:
74+ """Tableau d'amortissement complet : [{n, payment, interest, principal,
75+ balance}]. Le dernier versement est ajusté au solde exact. Les fréquences
76+ accélérées s'éteignent avant l'amortissement contractuel (comportement
77+ attendu). `max_periods` borne la simulation (ex. durée du terme)."""
78+ if principal <= 0:
79+ return []
80+ i = periodic_rate(annual_pct, frequency, compounding)
81+ pmt = pay_amount if pay_amount is not None else payment(
82+ principal, annual_pct, amort_years, frequency, compounding)
83+ if pmt <= 0:
84+ return []
85+ hard_cap = round(amort_years * FREQUENCIES[frequency]) + FREQUENCIES[frequency]
86+ if frequency.startswith("accelerated"):
87+ hard_cap = round(40 * FREQUENCIES[frequency]) # s'éteint plus tôt
88+ limit = min(max_periods, hard_cap) if max_periods else hard_cap
89+ rows: list[dict] = []
90+ bal = round(principal, 2)
91+ n = 0
92+ while bal > 0.005 and n < limit:
93+ n += 1
94+ interest = round(bal * i, 2)
95+ cap = round(pmt - interest, 2)
96+ if cap <= 0 and n > 1:
97+ break # paiement insuffisant : ne jamais boucler à l'infini
98+ if cap >= bal: # dernier versement ajusté
99+ cap = bal
100+ row_pay = round(cap + interest, 2)
101+ else:
102+ row_pay = pmt
103+ bal = round(bal - cap, 2)
104+ rows.append({"n": n, "payment": row_pay, "interest": interest,
105+ "principal": cap, "balance": bal})
106+ return rows
107+
108+
109+def annual_rollup(rows: list[dict], frequency: str = "monthly") -> list[dict]:
110+ """Agrège un tableau d'amortissement par année de prêt."""
111+ f = FREQUENCIES[frequency]
112+ out: list[dict] = []
113+ for r in rows:
114+ year = (r["n"] - 1) // f + 1
115+ if not out or out[-1]["year"] != year:
116+ out.append({"year": year, "payment": 0.0, "interest": 0.0,
117+ "principal": 0.0, "balance": r["balance"]})
118+ acc = out[-1]
119+ acc["payment"] = round(acc["payment"] + r["payment"], 2)
120+ acc["interest"] = round(acc["interest"] + r["interest"], 2)
121+ acc["principal"] = round(acc["principal"] + r["principal"], 2)
122+ acc["balance"] = r["balance"]
123+ return out
124+
125+
126+def term_summary(principal: float, annual_pct: float, amort_years: float,
127+ term_months: int, frequency: str = "monthly",
128+ compounding: str = "semi-annual") -> dict:
129+ """Bilan du terme : versement, nombre de versements, capital payé,
130+ intérêts payés, solde à l'échéance du terme."""
131+ f = FREQUENCIES[frequency]
132+ n_term = round(f * term_months / 12)
133+ rows = schedule(principal, annual_pct, amort_years, frequency,
134+ compounding, max_periods=n_term)
135+ pmt = payment(principal, annual_pct, amort_years, frequency, compounding)
136+ interest = round(sum(r["interest"] for r in rows), 2)
137+ cap = round(sum(r["principal"] for r in rows), 2)
138+ balance = rows[-1]["balance"] if rows else round(principal, 2)
139+ payments_per_year = f
140+ return {
141+ "payment": pmt,
142+ "frequency": frequency,
143+ "payments_per_year": payments_per_year,
144+ "payments_in_term": len(rows),
145+ "annual_cost": round(pmt * payments_per_year, 2),
146+ "principal_paid": cap,
147+ "interest_paid": interest,
148+ "balance_end_of_term": balance,
149+ "paid_off": balance <= 0.005,
150+ }
151+
152+
153+def payoff_years(principal: float, annual_pct: float, amort_years: float,
154+ frequency: str, compounding: str = "semi-annual") -> float:
155+ """Durée réelle d'extinction (années) — utile pour les fréquences
156+ accélérées qui raccourcissent l'amortissement."""
157+ rows = schedule(principal, annual_pct, amort_years, frequency, compounding)
158+ if not rows or rows[-1]["balance"] > 0.005:
159+ return float(amort_years)
160+ return round(len(rows) / FREQUENCIES[frequency], 2)
161+
162+
163+def max_loan(target_payment: float, annual_pct: float, amort_years: float,
164+ frequency: str = "monthly",
165+ compounding: str = "semi-annual") -> float:
166+ """Prêt maximal finançable avec un versement donné (calcul inverse)."""
167+ if target_payment <= 0:
168+ return 0.0
169+ if frequency in ("accelerated-biweekly", "accelerated-weekly"):
170+ # équivalent : versement mensuel = paiement × 2 ou × 4
171+ mult = 2 if frequency == "accelerated-biweekly" else 4
172+ return max_loan(target_payment * mult, annual_pct, amort_years,
173+ "monthly", compounding)
174+ i = periodic_rate(annual_pct, frequency, compounding)
175+ n = round(amort_years * FREQUENCIES[frequency])
176+ if i == 0:
177+ return round(target_payment * n, 2)
178+ return round(target_payment * (1.0 - (1.0 + i) ** -n) / i, 2)
179+
180+
181+def required_rate(principal: float, target_payment: float, amort_years: float,
182+ frequency: str = "monthly",
183+ compounding: str = "semi-annual") -> float | None:
184+ """Taux annuel (%) tel que le versement du prêt = `target_payment`.
185+ Bisection sur [0, 25]. None si même 0 % ne suffit pas."""
186+ if principal <= 0 or target_payment <= 0:
187+ return None
188+ if payment(principal, 0.0, amort_years, frequency, compounding) > target_payment:
189+ return None
190+ lo, hi = 0.0, 25.0
191+ if payment(principal, hi, amort_years, frequency, compounding) < target_payment:
192+ return hi
193+ for _ in range(60):
194+ mid = (lo + hi) / 2
195+ if payment(principal, mid, amort_years, frequency, compounding) > target_payment:
196+ hi = mid
197+ else:
198+ lo = mid
199+ return round(lo, 2)
200+
201+
202+def qualifying_rate(contract_pct: float) -> float:
203+ """Taux de qualification du test de résistance canadien."""
204+ return round(max(contract_pct + STRESS_TEST_BUFFER, STRESS_TEST_FLOOR), 2)
205+
206+
207+def stress_scenarios(principal: float, annual_pct: float, amort_years: float,
208+ frequency: str = "monthly",
209+ compounding: str = "semi-annual",
210+ bumps: tuple = (0.0, 1.0, 2.0, 3.0)) -> list[dict]:
211+ """« Et si les taux montent ? » — versement à +0/+1/+2/+3 points."""
212+ return [{
213+ "bump": b,
214+ "rate": round(annual_pct + b, 2),
215+ "payment": payment(principal, annual_pct + b, amort_years,
216+ frequency, compounding),
217+ } for b in bumps]
218+
219+
220+def renewal_scenarios(principal: float, annual_pct: float, amort_years: float,
221+ term_months: int, frequency: str = "monthly",
222+ compounding: str = "semi-annual",
223+ bumps: tuple = (-1.0, 0.0, 1.0, 2.0)) -> dict:
224+ """Scénario de renouvellement : solde restant à la fin du terme, puis
225+ versement recalculé sur l'amortissement résiduel à divers taux."""
226+ summary = term_summary(principal, annual_pct, amort_years, term_months,
227+ frequency, compounding)
228+ balance = summary["balance_end_of_term"]
229+ remaining_years = max(amort_years - term_months / 12.0, 1.0)
230+ rows = []
231+ for b in bumps:
232+ r = round(annual_pct + b, 2)
233+ if r <= 0 or balance <= 0:
234+ continue
235+ rows.append({"bump": b, "rate": r,
236+ "payment": payment(balance, r, remaining_years,
237+ frequency, compounding)})
238+ return {"balance_at_renewal": balance,
239+ "remaining_amortization_years": round(remaining_years, 1),
240+ "scenarios": rows}
241+
242+
243+def gds_tds(gross_annual_income: float, mortgage_payment_monthly: float,
244+ property_tax_monthly: float = 0.0, heating_monthly: float = 0.0,
245+ condo_fees_monthly: float = 0.0,
246+ other_debts_monthly: float = 0.0) -> dict:
247+ """Ratios ABD/ATD (GDS/TDS). Convention : 50 % des frais de copropriété.
248+ Seuils usuels assurés SCHL : ABD ≤ 39 %, ATD ≤ 44 %. Informatif seulement."""
249+ if gross_annual_income <= 0:
250+ return {"gds": None, "tds": None, "gds_ok": None, "tds_ok": None}
251+ monthly_income = gross_annual_income / 12.0
252+ housing = (mortgage_payment_monthly + property_tax_monthly +
253+ heating_monthly + 0.5 * condo_fees_monthly)
254+ gds = round(100.0 * housing / monthly_income, 1)
255+ tds = round(100.0 * (housing + other_debts_monthly) / monthly_income, 1)
256+ return {"gds": gds, "tds": tds, "gds_ok": gds <= 39.0, "tds_ok": tds <= 44.0,
257+ "gds_limit": 39.0, "tds_limit": 44.0}
added immoka/mortgage/cmhc.py +123 −0
@@ -0,0 +1,123 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/cmhc.py : assurance prêt hypothécaire (SCHL / Sagen / Canada
5+# Guaranty). Règles et barèmes isolés ici pour être mis à jour facilement.
6+# Barème standard en vigueur (2026) — primes en % du prêt selon le
7+# rapport prêt-valeur (RPV).
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+# (RPV maximal, prime en % du prêt)
12+PREMIUM_TABLE: list[tuple[float, float]] = [
13+ (0.65, 0.0060),
14+ (0.75, 0.0170),
15+ (0.80, 0.0240),
16+ (0.85, 0.0280),
17+ (0.90, 0.0310),
18+ (0.95, 0.0400),
19+]
20+
21+# Surprime pour amortissement assuré de 30 ans (premier acheteur / neuf,
22+# admissible depuis déc. 2024).
23+SURCHARGE_30Y = 0.0020
24+
25+# Prix maximal admissible à l'assurance (porté de 1 M$ à 1,5 M$ en déc. 2024).
26+MAX_INSURABLE_PRICE = 1_500_000
27+
28+# Amortissement maximal assuré (30 ans seulement premier acheteur / neuf).
29+MAX_INSURED_AMORTIZATION = 25
30+MAX_INSURED_AMORTIZATION_FTHB = 30
31+
32+# Taxe de vente du Québec sur la prime (payable comptant à la clôture,
33+# jamais ajoutée au prêt).
34+QC_TAX_ON_PREMIUM = 0.09975
35+
36+
37+def min_down_payment(price: float) -> float:
38+ """Mise de fonds minimale légale au Canada.
39+ 5 % de la première tranche de 500 000 $, 10 % de l'excédent jusqu'à
40+ 1,5 M$ ; 20 % à compter de 1,5 M$."""
41+ if price <= 0:
42+ return 0.0
43+ if price >= MAX_INSURABLE_PRICE:
44+ return round(0.20 * price, 2)
45+ return round(0.05 * min(price, 500_000) + 0.10 * max(0.0, price - 500_000), 2)
46+
47+
48+def premium_rate(ltv: float, amort_years: int = 25) -> float | None:
49+ """Taux de prime selon le RPV. None si RPV > 95 % (non assurable)."""
50+ if ltv <= 0:
51+ return 0.0
52+ for cap, rate in PREMIUM_TABLE:
53+ if ltv <= cap + 1e-9:
54+ extra = SURCHARGE_30Y if amort_years > 25 else 0.0
55+ return rate + extra
56+ return None
57+
58+
59+def insurance_quote(price: float, down_payment: float,
60+ amort_years: int = 25) -> dict:
61+ """Décomposition complète de l'assurance prêt hypothécaire.
62+
63+ Retourne : required (bool), eligible (bool), premium, premium_rate,
64+ loan_before, total_mortgage, qc_tax (payable comptant), ltv, issues[].
65+ Ne devine rien : si le scénario est inadmissible, le dit explicitement.
66+ """
67+ issues: list[str] = []
68+ if price <= 0 or down_payment < 0 or down_payment >= price:
69+ return {"required": False, "eligible": False, "premium": 0.0,
70+ "premium_rate": 0.0, "loan_before": max(price - down_payment, 0.0),
71+ "total_mortgage": max(price - down_payment, 0.0),
72+ "qc_tax": 0.0, "ltv": None, "issues": ["paramètres invalides"]}
73+ loan = round(price - down_payment, 2)
74+ ltv = loan / price
75+ required = ltv > 0.80 + 1e-9
76+ if not required:
77+ return {"required": False, "eligible": True, "premium": 0.0,
78+ "premium_rate": 0.0, "loan_before": loan,
79+ "total_mortgage": loan, "qc_tax": 0.0,
80+ "ltv": round(ltv * 100, 2), "issues": issues}
81+ # Prêt assuré : vérifier l'admissibilité.
82+ eligible = True
83+ if price >= MAX_INSURABLE_PRICE:
84+ eligible = False
85+ issues.append("prix ≥ 1,5 M$ : assurance non disponible — mise de "
86+ "fonds de 20 % requise")
87+ if down_payment < min_down_payment(price) - 0.01:
88+ eligible = False
89+ issues.append("mise de fonds sous le minimum légal "
90+ f"({min_down_payment(price):,.0f} $)".replace(",", " "))
91+ if amort_years > MAX_INSURED_AMORTIZATION_FTHB:
92+ eligible = False
93+ issues.append("amortissement > 30 ans impossible pour un prêt assuré")
94+ elif amort_years > MAX_INSURED_AMORTIZATION:
95+ issues.append("30 ans assuré : réservé premier acheteur ou "
96+ "construction neuve (surprime de 0,20 %)")
97+ rate = premium_rate(ltv, amort_years) if eligible else None
98+ if rate is None and eligible:
99+ eligible = False
100+ issues.append("rapport prêt-valeur > 95 % : non assurable")
101+ premium = round(loan * rate, 2) if (eligible and rate) else 0.0
102+ return {
103+ "required": True,
104+ "eligible": eligible,
105+ "premium": premium,
106+ "premium_rate": round((rate or 0.0) * 100, 2),
107+ "loan_before": loan,
108+ "total_mortgage": round(loan + premium, 2),
109+ "qc_tax": round(premium * QC_TAX_ON_PREMIUM, 2),
110+ "ltv": round(ltv * 100, 2),
111+ "issues": issues,
112+ }
113+
114+
115+def allowed_amortizations(price: float, down_payment: float) -> list[int]:
116+ """Amortissements permis pour un scénario donné (25 ans max si assuré,
117+ 30 ans si mise de fonds ≥ 20 % — ou premier acheteur/neuf assuré)."""
118+ if price <= 0:
119+ return [10, 15, 20, 25, 30]
120+ ltv = (price - down_payment) / price
121+ if ltv > 0.80:
122+ return [10, 15, 20, 25, 30] # 30 = cas particulier (signalé par issues)
123+ return [10, 15, 20, 25, 30]
added immoka/mortgage/providers/README.md +85 −0
@@ -0,0 +1,85 @@
1+# Providers de taux hypothécaires
2+
3+Un connecteur **indépendant** par institution. Auto-découverte : tout module du
4+dossier définissant une sous-classe de `RateProvider` avec un `provider_id`
5+non vide est enregistré dans `PROVIDERS` automatiquement (aucun registre à
6+éditer). La panne d'un provider n'affecte jamais les autres.
7+
8+**Règle absolue : aucun taux inventé.** Un produit non confirmé sur la page
9+officielle est simplement omis — jamais deviné, jamais de valeur par défaut.
10+
11+## Ajouter une institution (8 étapes)
12+
13+1. **Créer `<slug>.py`** dans ce dossier, avec une sous-classe de
14+ `RateProvider` :
15+
16+ ```python
17+ from .base import RateProvider
18+
19+ class MaBanque(RateProvider):
20+ provider_id = "ma_banque" # slug stable (clé BD)
21+ institution = "Ma Banque" # nom d'affichage fr-CA
22+ source_url = "https://mabanque.ca/taux-hypothecaires"
23+
24+ def fetch(self) -> list[dict]:
25+ html = self.get(self.source_url).text # ou .json()
26+ return self.parse(html)
27+
28+ def parse(self, html: str) -> list[dict]:
29+ ... # → [self.make_product(...), ...]
30+ ```
31+
32+ Séparer `fetch()` (réseau) de `parse()` (pur) : les tests appellent
33+ `parse()` sur des fixtures, sans réseau.
34+
35+2. **Backend réseau** : `self.get(url)` (requests + politesse `request_delay`)
36+ d'abord ; `self.get_scrapfly(url, render_js=True)` **en dernier recours
37+ seulement** si le site bloque (403/JS requis).
38+
39+3. **Normaliser** chaque produit via `self.make_product(...)` :
40+ - `rate_type` : `fixed` / `variable` — ou **`other` pour tout taux
41+ préférentiel/prime/référence** (avec `purpose="unknown"`), afin qu'il ne
42+ tombe jamais dans un classement « meilleur taux d'achat » ;
43+ - `kind` : `posted` (affiché) ou `special` (offre spéciale) — ne jamais
44+ confondre ;
45+ - `insured_status` : `insured` / `insurable` / `uninsured`, ou `unknown`
46+ si la page ne le précise pas — ne pas deviner ;
47+ - `product_name` en français, explicite (ex. « Fixe fermé 5 ans ») ;
48+ - `apr` (TAP) seulement s'il est publié.
49+
50+4. **Aucune écriture BD** dans le provider : le scheduler valide
51+ (`validate_batch`) puis enregistre (`store.record_observations`). Ne pas
52+ filtrer soi-même les aberrations — la validation s'en charge et journalise.
53+
54+5. **Fixture** : sauvegarder la réponse réelle (HTML/JSON) dans
55+ `tests/fixtures/mortgage/<slug>.<ext>` (anonymisée si besoin, taille
56+ raisonnable — garder le bloc utile).
57+
58+6. **Test** : ajouter le slug dans `EXPECTED` de
59+ `tests/test_mortgage_providers.py` (fixture + nombre exact de produits) ;
60+ le test générique vérifie déjà validation propre, `source_url`,
61+ `institution` et la règle « préférentiel → other/unknown ». Ajouter un
62+ test ciblé sur 1–2 valeurs connues de la fixture.
63+
64+7. **Exécuter** :
65+
66+ ```bash
67+ PYTHONPATH=. .venv/bin/python -P -m unittest tests.test_mortgage_providers
68+ .venv/bin/python run.py mortgage-sync ma_banque # collecte réelle
69+ .venv/bin/python run.py mortgage-status # santé
70+ ```
71+
72+8. **Vérifier en BD/API** : `GET /api/mortgage/rates?provider=ma_banque` —
73+ provenance (`source_url`), fraîcheur et nature correctes. C'est tout :
74+ ni web.py, ni le scheduler, ni le frontend n'ont besoin d'être modifiés.
75+
76+## Pièges connus
77+
78+- `4.19 % → 419` : toujours vérifier l'échelle ; la validation rejette
79+ > 24 %, mais un « 41,9 » passerait — parser au bon endroit.
80+- Pages avec plusieurs onglets (assuré/non assuré) : étiqueter
81+ `insured_status` correctement plutôt que de tout mélanger.
82+- Taux « ouverts » vs « fermés » : les distinguer dans `product_name`
83+ (ex. BNC publie « Fixe ouvert 1 an » à 9,65 % — ce n'est pas une erreur).
84+- Ne jamais soumettre de formulaire ni simuler une demande de prêt : pages
85+ publiques de taux uniquement.
added immoka/mortgage/providers/__init__.py +25 −0
@@ -0,0 +1,25 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/ : un connecteur indépendant par institution financière.
5+# Auto-découverte : tout module du dossier définissant une sous-classe de
6+# RateProvider avec provider_id non vide est enregistré automatiquement
7+# (même mécanique que immoka/connectors/).
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import importlib
12+import pkgutil
13+
14+from .base import RateProvider
15+
16+PROVIDERS: dict[str, type[RateProvider]] = {}
17+
18+for _mod in pkgutil.iter_modules(__path__):
19+ if _mod.name.startswith("_") or _mod.name == "base":
20+ continue
21+ module = importlib.import_module(f".{_mod.name}", __name__)
22+ for obj in vars(module).values():
23+ if (isinstance(obj, type) and issubclass(obj, RateProvider)
24+ and obj is not RateProvider and obj.provider_id):
25+ PROVIDERS[obj.provider_id] = obj
added immoka/mortgage/providers/bank_of_canada.py +77 −0
@@ -0,0 +1,77 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/bank_of_canada.py : Banque du Canada — API Valet
5+# (officielle, publique, JSON). Séries de référence : taux hypothécaire
6+# conventionnel affiché 1/3/5 ans, taux directeur, rendement obligataire
7+# 5 ans. purpose="unknown" : séries de RÉFÉRENCE, exclues du comparateur
8+# de prêteurs (la BdC ne prête pas aux particuliers).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+from .base import RateProvider
13+
14+SERIES_URL = ("https://www.bankofcanada.ca/valet/observations/"
15+ "V80691333,V80691334,V80691335,V39079,BD.CDN.5YR.DQ.YLD/"
16+ "json?recent=10")
17+
18+# série -> (terme mois, nom de produit)
19+MORTGAGE_SERIES = {
20+ "V80691333": (12, "Taux hypothécaire conventionnel affiché — 1 an"),
21+ "V80691334": (36, "Taux hypothécaire conventionnel affiché — 3 ans"),
22+ "V80691335": (60, "Taux hypothécaire conventionnel affiché — 5 ans"),
23+}
24+CONTEXT_SERIES = {
25+ "V39079": "Taux cible du financement à un jour",
26+ "BD.CDN.5YR.DQ.YLD": "Rendement obligataire 5 ans — Gouvernement du Canada",
27+}
28+
29+
30+class BankOfCanadaProvider(RateProvider):
31+ provider_id = "bank_of_canada"
32+ institution = "Banque du Canada"
33+ source_url = "https://www.bankofcanada.ca/rates/interest-rates/"
34+ request_delay = 0.5
35+
36+ def fetch(self) -> list[dict]:
37+ return self.parse(self.get(SERIES_URL).text)
38+
39+ def parse(self, payload: str) -> list[dict]:
40+ import json
41+ data = json.loads(payload)
42+ observations = data.get("observations") or []
43+ # Séries à fréquences mélangées : garder la DERNIÈRE valeur par série.
44+ latest: dict[str, tuple[str, float]] = {}
45+ for obs in observations:
46+ d = obs.get("d", "")
47+ for sid, cell in obs.items():
48+ if sid == "d" or not isinstance(cell, dict):
49+ continue
50+ v = cell.get("v")
51+ if v in (None, ""):
52+ continue
53+ try:
54+ latest[sid] = (d, float(v))
55+ except ValueError:
56+ continue
57+ out: list[dict] = []
58+ for sid, (term, name) in MORTGAGE_SERIES.items():
59+ if sid not in latest:
60+ continue
61+ d, v = latest[sid]
62+ out.append(self.make_product(
63+ rate=v, rate_type="fixed", term_months=term, kind="posted",
64+ product_name=name, purpose="unknown",
65+ conditions=f"Série Valet {sid} — observation du {d} "
66+ "(moyenne hebdomadaire des taux affichés)",
67+ raw={"series": sid, "date": d, "value": v}))
68+ for sid, name in CONTEXT_SERIES.items():
69+ if sid not in latest:
70+ continue
71+ d, v = latest[sid]
72+ out.append(self.make_product(
73+ rate=v, rate_type="other", term_months=12, kind="posted",
74+ product_name=name, purpose="unknown",
75+ conditions=f"Série Valet {sid} — observation du {d}",
76+ raw={"series": sid, "date": d, "value": v}))
77+ return out
added immoka/mortgage/providers/base.py +133 −0
@@ -0,0 +1,133 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/base.py : classe de base des connecteurs de taux.
5+# Contrat : fetch() retourne la liste des produits NORMALISÉS de
6+# l'institution (dicts au format de make_product). Aucune écriture BD ici —
7+# le scheduler valide puis enregistre. Jamais de taux inventé : un produit
8+# non confirmé est simplement omis.
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import os
13+import re
14+import time
15+
16+import requests
17+
18+USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
19+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
20+ "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)")
21+
22+SCRAPFLY_API = "https://api.scrapfly.io/scrape"
23+
24+
25+class RateProvider:
26+ provider_id: str = "" # slug (rbc, td, desjardins…)
27+ institution: str = "" # nom d'affichage
28+ source_url: str = "" # page officielle des taux (lien public)
29+ request_delay: float = 1.0 # politesse
30+ timeout: int = 30
31+
32+ def __init__(self) -> None:
33+ self.session = requests.Session()
34+ self.session.headers["User-Agent"] = USER_AGENT
35+ self._last_request = 0.0
36+
37+ # -- backends -------------------------------------------------------------
38+ def get(self, url: str, **kw) -> requests.Response:
39+ wait = self.request_delay - (time.time() - self._last_request)
40+ if wait > 0:
41+ time.sleep(wait)
42+ resp = self.session.get(url, timeout=self.timeout, **kw)
43+ self._last_request = time.time()
44+ resp.raise_for_status()
45+ return resp
46+
47+ def get_scrapfly(self, url: str, render_js: bool = False,
48+ asp: bool = True, country: str = "ca") -> str:
49+ """HTML via Scrapfly (anti-bot) — dernier recours seulement."""
50+ key = os.environ.get("SCRAPFLY_KEY") or os.environ.get("SCRAPFLY_API_KEY")
51+ if not key:
52+ raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)")
53+ params: dict = {"key": key, "url": url, "country": country}
54+ if asp:
55+ params["asp"] = "true"
56+ if render_js:
57+ params["render_js"] = "true"
58+ wait = self.request_delay - (time.time() - self._last_request)
59+ if wait > 0:
60+ time.sleep(wait)
61+ resp = requests.get(SCRAPFLY_API, params=params, timeout=180)
62+ self._last_request = time.time()
63+ try:
64+ return (resp.json().get("result") or {}).get("content") or ""
65+ except ValueError:
66+ return ""
67+
68+ # -- normalisation --------------------------------------------------------
69+ def make_product(self, *, rate: float, rate_type: str, term_months: int,
70+ kind: str, product_name: str | None = None,
71+ apr: float | None = None,
72+ insured_status: str = "unknown",
73+ purpose: str = "purchase",
74+ amortization_max_years: int | None = None,
75+ conditions: str | None = None,
76+ source_url: str | None = None,
77+ confidence: float = 1.0,
78+ raw=None) -> dict:
79+ """Observation normalisée commune à tous les providers."""
80+ return {
81+ "provider": self.provider_id,
82+ "institution": self.institution,
83+ "product_name": product_name,
84+ "rate_type": rate_type,
85+ "term_months": int(term_months),
86+ "kind": kind,
87+ "rate": round(float(rate), 4),
88+ "apr": round(float(apr), 4) if apr is not None else None,
89+ "insured_status": insured_status,
90+ "purpose": purpose,
91+ "amortization_max_years": amortization_max_years,
92+ "conditions": conditions,
93+ "source_url": source_url or self.source_url,
94+ "confidence": confidence,
95+ "raw": raw,
96+ }
97+
98+ # -- contrat --------------------------------------------------------------
99+ def fetch(self) -> list[dict]:
100+ raise NotImplementedError
101+
102+ # Point d'entrée pour les tests avec fixtures : si le provider parse un
103+ # payload texte, il expose parse(payload) et fetch() = parse(download()).
104+ def parse(self, payload: str) -> list[dict]: # pragma: no cover
105+ raise NotImplementedError
106+
107+
108+def parse_rate(text: str) -> float | None:
109+ """Extrait un taux en % d'un texte : '4,19 %', '4.19%', ' 4.19 '.
110+ Retourne None si introuvable — jamais de valeur devinée."""
111+ if text is None:
112+ return None
113+ m = re.search(r"(\d{1,2}(?:[.,]\d{1,4})?)\s*%?", str(text).strip())
114+ if not m:
115+ return None
116+ try:
117+ return float(m.group(1).replace(",", "."))
118+ except ValueError:
119+ return None
120+
121+
122+def term_to_months(text: str) -> int | None:
123+ """'5 ans', '5 year', '5-year', '6 months', '6 mois' → mois."""
124+ if not text:
125+ return None
126+ t = str(text).lower()
127+ m = re.search(r"(\d{1,3})\s*(?:-|\s)?\s*(an|ans|année|year|yr)", t)
128+ if m:
129+ return int(m.group(1)) * 12
130+ m = re.search(r"(\d{1,3})\s*(?:-|\s)?\s*(mois|month)", t)
131+ if m:
132+ return int(m.group(1))
133+ return None
added immoka/mortgage/providers/bmo.py +135 −0
@@ -0,0 +1,135 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/bmo.py : BMO — 3 JSON publics public-data (spéciaux,
5+# grille affichée EPM, prime). ⚠️ bmo.com filtre l'empreinte TLS (Akamai) :
6+# cascade requests → curl_cffi (si dispo) → Scrapfly asp. Les clés Over25
7+# sont les variantes amortissement > 25 ans ; 18YearOpen et 12Variable*
8+# sont des reliquats hérités (valeurs figées) et sont ignorés.
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import json
13+
14+from .base import RateProvider
15+
16+URLS = {
17+ "ca-mortgages-special-rates":
18+ "https://www.bmo.com/public-data/api/v2.0/bmo-ca-mortgages-rates.json",
19+ "epm-mortgage":
20+ "https://www.bmo.com/public-data/api/epm/v1.0/bmo-epm-mortgage.json",
21+ "epm-prime":
22+ "https://www.bmo.com/public-data/api/epm/v1.0/bmo-epm-prime.json",
23+}
24+PAGE_URL = "https://www.bmo.com/en-ca/main/personal/mortgages/mortgage-rates/"
25+
26+# clé spéciale -> (rate_type, terme mois, nom, clé APR, amort max, insured)
27+SPECIAL_MAP: dict[str, tuple] = {
28+ "fixed3YearClosedSpecial":
29+ ("fixed", 36, "Fixe fermé 3 ans (offre spéciale)",
30+ "fixed3YearClosedSpecialApr", 25, "unknown"),
31+ "fixed3YearClosedSpecialOver25":
32+ ("fixed", 36, "Fixe fermé 3 ans (offre spéciale, amort. >25 ans)",
33+ "fixed3YearClosedSpecialOver25Apr", 30, "uninsured"),
34+ "smartFixed5YearClosedSpecial":
35+ ("fixed", 60, "Smart Fixed fermé 5 ans (offre spéciale)",
36+ "smartFixed5YearClosedSpecialApr", 25, "uninsured"),
37+ "smartFixed5YearClosedHighRatioSpecial":
38+ ("fixed", 60, "Smart Fixed fermé 5 ans (offre spéciale, ratio élevé)",
39+ "smartFixed5YearClosedHighRatioSpecialApr", 25, "insured"),
40+ "variable5YearClosedSpecial":
41+ ("variable", 60, "Variable fermé 5 ans (offre spéciale)",
42+ "variable5YearClosedSpecialApr", 25, "unknown"),
43+ "variable5YearClosedSpecialOver25":
44+ ("variable", 60, "Variable fermé 5 ans (offre spéciale, amort. >25 ans)",
45+ "variable5YearClosedSpecialOver25Apr", 30, "uninsured"),
46+}
47+
48+EPM_SKIP = {"18YearOpen", "12VariableLimited", "12VariableOpen"}
49+
50+
51+def _num(v) -> float | None:
52+ try:
53+ return float(str(v).strip())
54+ except (TypeError, ValueError):
55+ return None
56+
57+
58+class BmoProvider(RateProvider):
59+ provider_id = "bmo"
60+ institution = "BMO Banque de Montréal"
61+ source_url = PAGE_URL
62+ request_delay = 1.2
63+
64+ def _get_json_text(self, url: str) -> str:
65+ try:
66+ return self.get(url).text
67+ except Exception: # noqa: BLE001 — TLS Akamai : on escalade
68+ pass
69+ try:
70+ from curl_cffi import requests as curl_requests
71+ resp = curl_requests.get(url, impersonate="chrome",
72+ timeout=self.timeout)
73+ resp.raise_for_status()
74+ return resp.text
75+ except ImportError:
76+ pass
77+ return self.get_scrapfly(url, render_js=False, asp=True)
78+
79+ def fetch(self) -> list[dict]:
80+ docs: dict[str, dict] = {}
81+ for key, url in URLS.items():
82+ text = self._get_json_text(url)
83+ docs[key] = json.loads(text)
84+ return self.parse_docs(docs)
85+
86+ def parse(self, payload: str) -> list[dict]:
87+ """Pour les tests fixtures : payload = JSON {clé: {url, payload}}."""
88+ data = json.loads(payload)
89+ docs: dict[str, dict] = {}
90+ for key, entry in data.items():
91+ doc = entry.get("payload") if isinstance(entry, dict) and \
92+ "payload" in entry else entry
93+ if isinstance(doc, str):
94+ doc = json.loads(doc)
95+ docs[key] = doc
96+ return self.parse_docs(docs)
97+
98+ def parse_docs(self, docs: dict[str, dict]) -> list[dict]:
99+ out: list[dict] = []
100+ specials = docs.get("ca-mortgages-special-rates") or {}
101+ for key, (rtype, term, name, apr_key, amort, insured) in \
102+ SPECIAL_MAP.items():
103+ rate = _num(specials.get(key))
104+ if rate is None or rate <= 0:
105+ continue
106+ out.append(self.make_product(
107+ rate=rate, rate_type=rtype, term_months=term, kind="special",
108+ product_name=name, apr=_num(specials.get(apr_key)),
109+ insured_status=insured, amortization_max_years=amort,
110+ raw={"key": key, "value": specials.get(key)}))
111+ epm = docs.get("epm-mortgage") or {}
112+ grid = epm.get("mortgageRates") or epm
113+ for rtype_key, rtype, prefix in (("fixed", "fixed", "Fixe"),
114+ ("variable", "variable", "Variable")):
115+ for key, cell in (grid.get(rtype_key) or {}).items():
116+ if key in EPM_SKIP or not isinstance(cell, dict):
117+ continue
118+ rate = _num(cell.get("value"))
119+ term = _num(cell.get("term_months"))
120+ if rate is None or rate <= 0 or not term:
121+ continue
122+ label = cell.get("fr") or cell.get("en") or key
123+ out.append(self.make_product(
124+ rate=rate, rate_type=rtype, term_months=int(term),
125+ kind="posted", product_name=f"{prefix} {label}",
126+ raw={"key": key, "cell": cell}))
127+ prime = docs.get("epm-prime") or {}
128+ prime_rate = _num((prime.get("caPrimeRate") or {}).get("value"))
129+ if prime_rate and prime_rate > 0:
130+ out.append(self.make_product(
131+ rate=prime_rate, rate_type="other", term_months=12,
132+ kind="posted", product_name="Taux préférentiel BMO",
133+ purpose="unknown",
134+ raw={"caPrimeRate": prime.get("caPrimeRate")}))
135+ return out
added immoka/mortgage/providers/cibc.py +144 −0
@@ -0,0 +1,144 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/cibc.py : CIBC — pseudo-JSON JS productRatesLegacy
5+# (blocs `var CODE = {...}` avec lignes [terme, ?, colId, valeur, …]).
6+# colId 1 = affiché, 18 = spécial, 2 = APR du spécial ; sentinelle
7+# -99.999999991 et spécial 0.00 = non publié ; colId 34 = colonne
8+# d'identité inconnue, jamais devinée. MICRO/MICROVAR ne portent que
9+# l'offre spéciale (leur « affiché » duplique FRCM).
10+# -----------------------------------------------------------------------------
11+from __future__ import annotations
12+
13+import re
14+
15+from .base import RateProvider
16+
17+BASE_URL = ("https://www.cibconline.cibc.com/ebm-pno/api/v1/json/"
18+ "productRatesLegacy?lobId={lob}&sourceProductCode={codes}")
19+LOB5_CODES = "FRCM,FOM,CCM,5YRVARCLO,MICRO,MICROVAR,VROM"
20+PAGE_URL = "https://www.cibc.com/en/interest-rates/mortgage-rates.html"
21+
22+COL_POSTED, COL_APR, COL_SPECIAL = 1, 2, 18
23+SENTINEL = -99.0
24+
25+# code -> (rate_type, libellé de base, spécial seulement, libellé du spécial)
26+PRODUCTS: dict[str, tuple] = {
27+ "FRCM": ("fixed", "Fixe fermé", False, "offre spéciale"),
28+ "FOM": ("fixed", "Fixe ouvert", False, "offre spéciale"),
29+ "CCM": ("fixed", "Fermé convertible", False, "offre spéciale"),
30+ "5YRVARCLO": ("variable", "Variable Flex fermé", False, "offre spéciale"),
31+ "VROM": ("variable", "Variable ouvert", False, "offre spéciale"),
32+ # MICRO/MICROVAR : l'offre mise en avant sur la page des taux — libellé
33+ # distinct pour ne pas entrer en collision avec le spécial FRCM/5YRVARCLO.
34+ "MICRO": ("fixed", "Fixe fermé", True, "offre annoncée"),
35+ "MICROVAR": ("variable", "Variable Flex fermé", True, "offre annoncée"),
36+}
37+
38+BLOCK_RX = re.compile(r"var\s+(\w+)\s*=\s*\{(.*?)\]\s*\}", re.S)
39+ROW_RX = re.compile(r"\[([^\[\]]*)\]")
40+TERM_RX = re.compile(r"^(\d{1,3})_.*_(Year|Years|Month|Months)_T$")
41+
42+
43+def _cells(row: str) -> list:
44+ out = []
45+ for cell in row.split(","):
46+ c = cell.strip().strip("'\"")
47+ out.append(None if c == "null" else c)
48+ return out
49+
50+
51+def _term_months(token) -> int | None:
52+ m = TERM_RX.match(str(token or ""))
53+ if not m:
54+ return None
55+ n = int(m.group(1))
56+ return n * 12 if m.group(2).startswith("Year") else n
57+
58+
59+def _num(v) -> float | None:
60+ try:
61+ return float(str(v).strip())
62+ except (TypeError, ValueError):
63+ return None
64+
65+
66+def _label(months: int) -> str:
67+ if months < 12:
68+ return f"{months} mois"
69+ years = months // 12
70+ return f"{years} an" if years == 1 else f"{years} ans"
71+
72+
73+class CibcProvider(RateProvider):
74+ provider_id = "cibc"
75+ institution = "CIBC"
76+ source_url = PAGE_URL
77+ request_delay = 1.2
78+
79+ def fetch(self) -> list[dict]:
80+ text = self.get(BASE_URL.format(lob=5, codes=LOB5_CODES)).text
81+ try:
82+ text += "\n" + self.get(BASE_URL.format(lob=1, codes="PRIME")).text
83+ except Exception: # noqa: BLE001 — prime facultatif
84+ pass
85+ return self.parse(text)
86+
87+ def parse(self, payload: str) -> list[dict]:
88+ out: list[dict] = []
89+ for var_name, body in BLOCK_RX.findall(payload):
90+ name = var_name[1:] if var_name[:1] == "p" and \
91+ var_name[1:2].isdigit() else var_name
92+ if name == "PRIME":
93+ out.extend(self._parse_prime(body))
94+ continue
95+ if name not in PRODUCTS:
96+ continue
97+ rtype, base_label, special_only, special_label = PRODUCTS[name]
98+ # (terme -> {colId: valeur})
99+ grid: dict[int, dict[int, float]] = {}
100+ for row in ROW_RX.findall(body):
101+ cells = _cells(row)
102+ if len(cells) < 4:
103+ continue
104+ term = _term_months(cells[0])
105+ col = _num(cells[2])
106+ val = _num(cells[3])
107+ if term is None or col is None or val is None:
108+ continue
109+ if val < SENTINEL + 1 or val <= 0:
110+ continue # sentinelle -99.999999991 ou 0.00 : non publié
111+ grid.setdefault(term, {})[int(col)] = val
112+ for term, cols in sorted(grid.items()):
113+ posted = cols.get(COL_POSTED)
114+ special = cols.get(COL_SPECIAL)
115+ if posted and not special_only:
116+ out.append(self.make_product(
117+ rate=posted, rate_type=rtype, term_months=term,
118+ kind="posted",
119+ product_name=f"{base_label} {_label(term)}",
120+ conditions="Base = taux préférentiel CIBC"
121+ if rtype == "variable" and name == "5YRVARCLO"
122+ else None,
123+ raw={"code": name, "cols": cols}))
124+ if special:
125+ out.append(self.make_product(
126+ rate=special, rate_type=rtype, term_months=term,
127+ kind="special", apr=cols.get(COL_APR),
128+ product_name=f"{base_label} {_label(term)} "
129+ f"({special_label})",
130+ raw={"code": name, "cols": cols}))
131+ return out
132+
133+ def _parse_prime(self, body: str) -> list[dict]:
134+ for row in ROW_RX.findall(body):
135+ cells = _cells(row)
136+ if len(cells) < 4:
137+ continue
138+ rate = _num(cells[3])
139+ if rate and rate > 0:
140+ return [self.make_product(
141+ rate=rate, rate_type="other", term_months=12,
142+ kind="posted", product_name="Taux préférentiel CIBC",
143+ purpose="unknown", raw={"code": "PRIME", "value": rate})]
144+ return []
added immoka/mortgage/providers/desjardins.py +83 −0
@@ -0,0 +1,83 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/desjardins.py : Desjardins — JSON server-rendered
5+# window.dcomProducts sur la page des taux. Clé = cfPath COMPLET (le même
6+# id existe dans les familles « taux-hypothecaires » (affichés) et
7+# « taux-hypothecaires-promotionnels » (spéciaux) avec des valeurs
8+# différentes — ne jamais confondre).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import json
13+import re
14+
15+from .base import RateProvider
16+
17+PAGE_URL = "https://www.desjardins.com/fr/hypotheque/taux-hypothecaires.html"
18+
19+# id Desjardins -> (rate_type, terme mois, nom, purpose)
20+ID_MAP: dict[str, tuple] = {
21+ "6000_6M": ("fixed", 6, "Fixe fermé 6 mois"),
22+ "6000_1A": ("fixed", 12, "Fixe fermé 1 an"),
23+ "6000_2A": ("fixed", 24, "Fixe fermé 2 ans"),
24+ "6000_3A": ("fixed", 36, "Fixe fermé 3 ans"),
25+ "6000_4A": ("fixed", 48, "Fixe fermé 4 ans"),
26+ "6000_5A": ("fixed", 60, "Fixe fermé 5 ans"),
27+ "6000_6A": ("fixed", 72, "Fixe fermé 6 ans"),
28+ "6000_7A": ("fixed", 84, "Fixe fermé 7 ans"),
29+ "6000_10A": ("fixed", 120, "Fixe fermé 10 ans"),
30+ "6000_6MO": ("fixed", 6, "Fixe ouvert 6 mois"),
31+ "6000_1AO": ("fixed", 12, "Fixe ouvert 1 an"),
32+ "6004_5AR": ("variable", 60, "Variable réduit 5 ans"),
33+ "tvp": ("variable", 60, "Variable protégé 5 ans"),
34+ "tvred": ("variable", 60, "Variable réduit"),
35+ "tvreg": ("variable", 60, "Variable régulier"),
36+ "tra": ("fixed", 12, "Révisable annuellement"),
37+}
38+PRIME_ID = "tpcad"
39+
40+
41+class DesjardinsProvider(RateProvider):
42+ provider_id = "desjardins"
43+ institution = "Desjardins"
44+ source_url = PAGE_URL
45+ request_delay = 1.5
46+
47+ def fetch(self) -> list[dict]:
48+ return self.parse(self.get(self.source_url).text)
49+
50+ def parse(self, payload: str) -> list[dict]:
51+ m = re.search(r"window\.dcomProducts\s*=\s*(\{.*?\});", payload, re.S)
52+ if not m:
53+ raise ValueError("dcomProducts introuvable (structure changée)")
54+ products = json.loads(m.group(1))
55+ out: list[dict] = []
56+ for cf_path, cell in products.items():
57+ if "/hypotheque/" not in cf_path or not isinstance(cell, dict):
58+ continue
59+ pid = cell.get("id") or ""
60+ try:
61+ rate = float(cell.get("rate"))
62+ except (TypeError, ValueError):
63+ continue
64+ if rate <= 0:
65+ continue
66+ promo = "taux-hypothecaires-promotionnels" in cf_path
67+ if pid == PRIME_ID:
68+ out.append(self.make_product(
69+ rate=rate, rate_type="other", term_months=12,
70+ kind="posted", product_name="Taux préférentiel Desjardins",
71+ purpose="unknown", raw={"cfPath": cf_path, "rate": rate}))
72+ continue
73+ if pid not in ID_MAP:
74+ continue # id inconnu : jamais deviné
75+ rtype, term, label = ID_MAP[pid]
76+ out.append(self.make_product(
77+ rate=rate, rate_type=rtype, term_months=term,
78+ kind="special" if promo else "posted",
79+ product_name=label + (" (promotion)" if promo else ""),
80+ conditions="Taux promotionnel Desjardins" if promo
81+ else "Taux affiché Desjardins",
82+ raw={"cfPath": cf_path, "id": pid, "rate": cell.get("rate")}))
83+ return out
added immoka/mortgage/providers/eq_bank.py +94 −0
@@ -0,0 +1,94 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/eq_bank.py : Banque EQ / Équitable — dictionnaire JSON
5+# global embarqué dans le payload Next.js de la page des taux (marqueur
6+# \"rates\":{ échappé). Clés CMS stables ; les clés « Adjustable » sont des
7+# ÉCARTS vs prime (jamais interprétées comme des taux).
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import json
12+
13+from .base import RateProvider
14+
15+# clé CMS -> (rate_type, terme mois, kind, nom, clé APR éventuelle)
16+KEY_MAP: dict[str, tuple] = {
17+ "mortgage-rate-fixed-5-year": ("fixed", 60, "special",
18+ "Fixe 5 ans (vitrine EQ)",
19+ "mortgage-rate-fixed-5-year-APR"),
20+ "mortgage-rate-variable-5-year": ("variable", 60, "special",
21+ "Variable 5 ans (vitrine EQ)",
22+ "mortgage-rate-variable-5-year-APR"),
23+ "es-fixed-12-month": ("fixed", 12, "special", "Evolution Suite — fixe 1 an", None),
24+ "es-fixed-24-month": ("fixed", 24, "special", "Evolution Suite — fixe 2 ans", None),
25+ "es-fixed-36-month": ("fixed", 36, "special", "Evolution Suite — fixe 3 ans", None),
26+ "es-fixed-48-month": ("fixed", 48, "special", "Evolution Suite — fixe 4 ans", None),
27+ "es-fixed-60-month": ("fixed", 60, "special", "Evolution Suite — fixe 5 ans", None),
28+ "Standard-Mortgage-Rate-1-Year-Fixed": ("fixed", 12, "posted", "Fixe affiché 1 an", None),
29+ "Standard-Mortgage-Rate-2-Year-Fixed": ("fixed", 24, "posted", "Fixe affiché 2 ans", None),
30+ "Standard-Mortgage-Rate-3-Year-Fixed": ("fixed", 36, "posted", "Fixe affiché 3 ans", None),
31+ "Standard-Mortgage-Rate-4-Year-Fixed": ("fixed", 48, "posted", "Fixe affiché 4 ans", None),
32+ "Standard-Mortgage-Rate-5-Year-Fixed": ("fixed", 60, "posted", "Fixe affiché 5 ans", None),
33+}
34+PRIME_KEY = "equitable-prime-rate"
35+
36+
37+def extract_rates_blob(html: str) -> dict:
38+ """Isole le dictionnaire {clé: {name, rate}} du payload Next.js.
39+ Le JSON est échappé dans le HTML (\\\" -> \")."""
40+ marker = '\\"rates\\":{'
41+ i = html.find(marker)
42+ if i < 0:
43+ marker = '"rates":{'
44+ i = html.find(marker)
45+ if i < 0:
46+ raise ValueError("marqueur rates introuvable (structure changée)")
47+ seg = html[i:i + 400_000].replace('\\"', '"')
48+ start = seg.find("{")
49+ depth = 0
50+ for j, ch in enumerate(seg[start:], start):
51+ if ch == "{":
52+ depth += 1
53+ elif ch == "}":
54+ depth -= 1
55+ if depth == 0:
56+ return json.loads(seg[start:j + 1])
57+ raise ValueError("JSON rates non équilibré (structure changée)")
58+
59+
60+class EqBankProvider(RateProvider):
61+ provider_id = "eq_bank"
62+ institution = "Banque EQ"
63+ source_url = "https://www.eqbank.ca/residential/mortgage-rates"
64+ request_delay = 1.0
65+
66+ def fetch(self) -> list[dict]:
67+ return self.parse(self.get(self.source_url).text)
68+
69+ def parse(self, payload: str) -> list[dict]:
70+ rates = extract_rates_blob(payload)
71+
72+ def val(key: str) -> float | None:
73+ cell = rates.get(key)
74+ if isinstance(cell, dict) and isinstance(cell.get("rate"), (int, float)):
75+ return float(cell["rate"])
76+ return None
77+
78+ out: list[dict] = []
79+ for key, (rtype, term, kind, name, apr_key) in KEY_MAP.items():
80+ rate = val(key)
81+ if rate is None:
82+ continue # clé absente : produit omis, jamais deviné
83+ apr = val(apr_key) if apr_key else None
84+ out.append(self.make_product(
85+ rate=rate, rate_type=rtype, term_months=term, kind=kind,
86+ product_name=name, apr=apr,
87+ raw={"key": key, "rate": rate, "apr": apr}))
88+ prime = val(PRIME_KEY)
89+ if prime is not None:
90+ out.append(self.make_product(
91+ rate=prime, rate_type="other", term_months=12, kind="posted",
92+ product_name="Taux préférentiel Banque Équitable",
93+ purpose="unknown", raw={"key": PRIME_KEY, "rate": prime}))
94+ return out
added immoka/mortgage/providers/first_national.py +97 −0
@@ -0,0 +1,97 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/first_national.py : First National — table HTML statique
5+# (server-side, aucun anti-bot). Taux fixes fermés par catégorie
6+# assuré/assurable(LTV)/conventionnel, ARM « Prime - X% », prime maison.
7+# -----------------------------------------------------------------------------
8+from __future__ import annotations
9+
10+import re
11+
12+from bs4 import BeautifulSoup
13+
14+from .base import RateProvider, parse_rate, term_to_months
15+
16+
17+def _insured_status(category: str) -> str:
18+ c = category.lower()
19+ if c.startswith("insured"):
20+ return "insured"
21+ if c.startswith("insurable"):
22+ return "insurable"
23+ if c.startswith("conventional"):
24+ return "uninsured"
25+ return "unknown"
26+
27+
28+class FirstNationalProvider(RateProvider):
29+ provider_id = "first_national"
30+ institution = "First National"
31+ source_url = "https://www.firstnational.ca/residential/mortgage-rates"
32+ request_delay = 1.0
33+
34+ def fetch(self) -> list[dict]:
35+ return self.parse(self.get(self.source_url).text)
36+
37+ def parse(self, payload: str) -> list[dict]:
38+ soup = BeautifulSoup(payload, "html.parser")
39+ out: list[dict] = []
40+ prime = None
41+ m = re.search(r"First National Prime Rate:\s*(?:</?\w+[^>]*>\s*)*([\d.]+)",
42+ payload)
43+ if m:
44+ prime = float(m.group(1))
45+ # -- taux fixes fermés (table avec aria-label par terme) --------------
46+ for h3 in soup.find_all("h3"):
47+ title = h3.get_text(" ", strip=True)
48+ table = h3.find_next("table")
49+ if table is None:
50+ continue
51+ if title.lower().startswith("fixed rate mortgages"):
52+ for tr in table.select("tbody tr"):
53+ th = tr.find("th")
54+ if th is None:
55+ continue
56+ category = re.sub(r"\s+", " ", th.get_text(" ", strip=True))
57+ status = _insured_status(category)
58+ for td in tr.find_all("td"):
59+ term = term_to_months(td.get("aria-label") or "")
60+ rate = parse_rate(td.get_text(strip=True))
61+ if term is None or rate is None:
62+ continue # cellule N/A : produit non offert
63+ out.append(self.make_product(
64+ rate=rate, rate_type="fixed", term_months=term,
65+ kind="posted",
66+ product_name=f"Fixe fermé — {category}",
67+ insured_status=status, conditions=category,
68+ raw={"category": category,
69+ "cell": td.get_text(strip=True)}))
70+ elif title.lower().startswith("adjustable rate"):
71+ if prime is None:
72+ continue # sans prime confirmée, ne rien deviner
73+ for tr in table.select("tbody tr"):
74+ th = tr.find("th")
75+ td = tr.find("td")
76+ if th is None or td is None:
77+ continue
78+ category = re.sub(r"\s+", " ", th.get_text(" ", strip=True))
79+ cell = td.get_text(" ", strip=True)
80+ dm = re.search(r"Prime\s*([+-])\s*([\d.]+)\s*%", cell)
81+ if not dm:
82+ continue
83+ delta = float(dm.group(2)) * (1 if dm.group(1) == "+" else -1)
84+ out.append(self.make_product(
85+ rate=round(prime + delta, 2), rate_type="adjustable",
86+ term_months=60, kind="special",
87+ product_name=f"ARM 5 ans — {category}",
88+ insured_status=_insured_status(category),
89+ conditions=f"{cell} (prime First National {prime} %)",
90+ raw={"category": category, "cell": cell,
91+ "prime": prime, "delta": delta}))
92+ if prime is not None:
93+ out.append(self.make_product(
94+ rate=prime, rate_type="other", term_months=12, kind="posted",
95+ product_name="Taux préférentiel First National",
96+ purpose="unknown", raw={"prime": prime}))
97+ return out
added immoka/mortgage/providers/mcap.py +40 −0
@@ -0,0 +1,40 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/mcap.py : MCAP — distribution 100 % courtiers, aucune
5+# table publique de taux par terme (vérifié 2026-08). Seul signal public :
6+# le taux préférentiel MCAP (+ date d'effet). Collecté comme référence,
7+# exclu du comparateur de prêteurs (rate_type="other", purpose="unknown").
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import re
12+
13+from .base import RateProvider
14+
15+
16+class McapProvider(RateProvider):
17+ provider_id = "mcap"
18+ institution = "MCAP"
19+ source_url = ("https://www.mcap.com/residential-mortgages/advice/"
20+ "mortgage-rates-canada")
21+ request_delay = 1.0
22+
23+ def fetch(self) -> list[dict]:
24+ return self.parse(self.get(self.source_url).text)
25+
26+ def parse(self, payload: str) -> list[dict]:
27+ m = re.search(
28+ r'prime-rate-module.*?round-number-box[^>]*>\s*([\d.]+)\s*%',
29+ payload, re.S)
30+ if not m:
31+ return []
32+ prime = float(m.group(1))
33+ dm = re.search(r'Effective\s+([A-Z][a-z]+ \d{1,2},? \d{4})', payload)
34+ conditions = ("Taux préférentiel MCAP"
35+ + (f" — en vigueur le {dm.group(1)}" if dm else ""))
36+ return [self.make_product(
37+ rate=prime, rate_type="other", term_months=12, kind="posted",
38+ product_name="Taux préférentiel MCAP", purpose="unknown",
39+ conditions=conditions,
40+ raw={"prime": prime, "effective": dm.group(1) if dm else None})]
added immoka/mortgage/providers/national_bank.py +131 −0
@@ -0,0 +1,131 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/national_bank.py : Banque Nationale — JSON double-échappé
5+# setProductMap(JSON.parse("…")) dans la page des taux. c1 = produits
6+# hypothécaires (par productConditionName). ⚠️ Séparateurs décimaux mixtes :
7+# affichés « 6.090 » (point), promos « 4,84 » (virgule) — les tacNans sont
8+# les APR des promos. Le tauxBase du produit variable est un ÉCART, pas la
9+# prime : la prime vient du tauxBase du produit fixe.
10+# -----------------------------------------------------------------------------
11+from __future__ import annotations
12+
13+import json
14+import re
15+
16+from .base import RateProvider
17+
18+PAGE_URL = "https://www.nbc.ca/personal/mortgages/rates.html"
19+
20+MAP_RX = re.compile(r'setProductMap\(JSON\.parse\("(.*?)"\)\)', re.S)
21+
22+# champs affichés fixes (fermés) -> terme mois ; taux1moisF exclu (< 3 mois)
23+FIXED_POSTED = {
24+ "taux3moisF": 3, "taux6moisF": 6, "taux1anF": 12, "taux2ansF": 24,
25+ "taux3ansF": 36, "taux4ansF": 48, "taux5ansF": 60, "taux6ansF": 72,
26+ "taux7ansF": 84, "taux10ansF": 120,
27+}
28+FIXED_OPEN = {"taux6moisO": 6, "taux1anO": 12}
29+# promo -> (terme mois, champ APR)
30+FIXED_PROMO = {
31+ "tauxPromo3ansF": (36, "tac3ans"),
32+ "tauxPromo4ansF": (48, "tac4ans"),
33+ "tauxPromo5ansF": (60, "tac5ans"),
34+}
35+
36+
37+def _num(v) -> float | None:
38+ if v in (None, ""):
39+ return None
40+ try:
41+ return float(str(v).replace("\xa0", "").replace(",", ".").strip())
42+ except ValueError:
43+ return None
44+
45+
46+class NationalBankProvider(RateProvider):
47+ provider_id = "national_bank"
48+ institution = "Banque Nationale"
49+ source_url = PAGE_URL
50+ request_delay = 1.5
51+
52+ def fetch(self) -> list[dict]:
53+ return self.parse(self.get(self.source_url).text)
54+
55+ def parse(self, payload: str) -> list[dict]:
56+ m = MAP_RX.search(payload)
57+ if not m:
58+ raise ValueError("setProductMap introuvable (structure changée)")
59+ dec = (m.group(1).replace("\\x22", '"')
60+ .replace("\\\\", "\\").replace("\\/", "/"))
61+ # la chaîne JS se termine par «"), true);» : raw_decode ignore la suite
62+ obj, _ = json.JSONDecoder().raw_decode(dec)
63+ products = json.loads(obj.get("c1") or "[]")
64+ by_name = {p.get("productConditionName"): p
65+ for p in products if isinstance(p, dict)}
66+ out: list[dict] = []
67+ fixed = by_name.get("Mortgage fixed rate") or {}
68+ for field, term in FIXED_POSTED.items():
69+ rate = _num(fixed.get(field))
70+ if rate and rate > 0:
71+ out.append(self.make_product(
72+ rate=rate, rate_type="fixed", term_months=term,
73+ kind="posted",
74+ product_name=f"Fixe fermé {_label(term)}",
75+ raw={"field": field, "value": fixed.get(field)}))
76+ for field, term in FIXED_OPEN.items():
77+ rate = _num(fixed.get(field))
78+ if rate and rate > 0:
79+ out.append(self.make_product(
80+ rate=rate, rate_type="fixed", term_months=term,
81+ kind="posted",
82+ product_name=f"Fixe ouvert {_label(term)}",
83+ raw={"field": field, "value": fixed.get(field)}))
84+ for field, (term, apr_field) in FIXED_PROMO.items():
85+ rate = _num(fixed.get(field))
86+ if rate and rate > 0:
87+ out.append(self.make_product(
88+ rate=rate, rate_type="fixed", term_months=term,
89+ kind="special", apr=_num(fixed.get(apr_field)),
90+ product_name=f"Fixe fermé {_label(term)} (promotion)",
91+ raw={"field": field, "value": fixed.get(field)}))
92+ prime = _num(fixed.get("tauxBase"))
93+ if prime and prime > 0:
94+ out.append(self.make_product(
95+ rate=prime, rate_type="other", term_months=12, kind="posted",
96+ product_name="Taux préférentiel BNC", purpose="unknown",
97+ raw={"field": "tauxBase", "value": fixed.get("tauxBase")}))
98+ variable = by_name.get("Mortgage variable rate") or {}
99+ v_posted = _num(variable.get("taux5ansO"))
100+ if v_posted and v_posted > 0:
101+ out.append(self.make_product(
102+ rate=v_posted, rate_type="variable", term_months=60,
103+ kind="posted", product_name="Variable 5 ans",
104+ conditions="Base = taux préférentiel BNC",
105+ raw={"field": "taux5ansO", "value": variable.get("taux5ansO")}))
106+ v_promo = _num(variable.get("tauxPromo5ansF"))
107+ if v_promo and v_promo > 0:
108+ out.append(self.make_product(
109+ rate=v_promo, rate_type="variable", term_months=60,
110+ kind="special", apr=_num(variable.get("tac5ans")),
111+ product_name="Variable 5 ans (promotion)",
112+ raw={"field": "tauxPromo5ansF",
113+ "value": variable.get("tauxPromo5ansF")}))
114+ capped = by_name.get("Variable capped-rate mortgage") or {}
115+ c_rate = _num(capped.get("taux5ansO"))
116+ if c_rate and c_rate > 0:
117+ cap = _num(capped.get("tauxPlafond"))
118+ out.append(self.make_product(
119+ rate=c_rate, rate_type="variable", term_months=60,
120+ kind="posted", product_name="Variable plafonné 5 ans",
121+ conditions=f"Taux plafond {cap} %" if cap else None,
122+ raw={"field": "taux5ansO", "value": capped.get("taux5ansO"),
123+ "tauxPlafond": capped.get("tauxPlafond")}))
124+ return out
125+
126+
127+def _label(months: int) -> str:
128+ if months < 12:
129+ return f"{months} mois"
130+ years = months // 12
131+ return f"{years} an" if years == 1 else f"{years} ans"
added immoka/mortgage/providers/rbc.py +112 −0
@@ -0,0 +1,112 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/rbc.py : RBC — API JSON publique publicrates (celle que
5+# consomme leur propre page de taux, 1 requête par code de taux).
6+# ⚠️ Les produits variables sont publiés en ÉCART vs prime (ex. -0.500) :
7+# reconstitués avec le code prime, jamais interprétés comme des taux.
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import json
12+
13+from .base import RateProvider
14+
15+API = ("https://apps.royalbank.com/apps/app-services/public-rates/api/"
16+ "publicrates?code={code}")
17+PAGE_URL = "https://www.rbcroyalbank.com/mortgages/mortgage-rates.html"
18+
19+PRIME_CODE = "0006470002"
20+
21+# code -> (rate_type, terme mois, kind, insured_status, nom, code APR|None)
22+CODE_MAP: dict[str, tuple] = {
23+ "0006340010": ("fixed", 6, "posted", "unknown", "Fixe 6 mois convertible", None),
24+ "0006340013": ("fixed", 12, "posted", "unknown", "Fixe fermé 1 an", None),
25+ "0006340016": ("fixed", 24, "posted", "unknown", "Fixe fermé 2 ans", None),
26+ "0006340019": ("fixed", 36, "posted", "unknown", "Fixe fermé 3 ans", None),
27+ "0006340022": ("fixed", 48, "posted", "unknown", "Fixe fermé 4 ans", None),
28+ "0006340025": ("fixed", 60, "posted", "unknown", "Fixe fermé 5 ans", None),
29+ "0006340042": ("fixed", 84, "posted", "unknown", "Fixe fermé 7 ans", None),
30+ "0006340030": ("fixed", 120, "posted", "unknown", "Fixe fermé 10 ans", None),
31+ "0386080056": ("fixed", 12, "special", "unknown", "Fixe fermé 1 an (offre spéciale)", "0386140074"),
32+ "0386080057": ("fixed", 24, "special", "unknown", "Fixe fermé 2 ans (offre spéciale)", "0386140075"),
33+ "0386080058": ("fixed", 36, "special", "unknown", "Fixe fermé 3 ans (offre spéciale)", "0386140076"),
34+ "0386080059": ("fixed", 48, "special", "unknown", "Fixe fermé 4 ans (offre spéciale)", "0386140077"),
35+ "0386080060": ("fixed", 60, "special", "unknown", "Fixe fermé 5 ans (offre spéciale)", "0386140078"),
36+ "0386080061": ("fixed", 84, "special", "unknown", "Fixe fermé 7 ans (offre spéciale)", "0386140079"),
37+ "0385940006": ("fixed", 60, "special", "insured", "Fixe fermé 5 ans (ratio élevé)", "0386090006"),
38+}
39+# codes variables : ÉCART vs prime -> taux = prime + valeur
40+SPREAD_MAP: dict[str, tuple] = {
41+ "0236440012": ("variable", 60, "posted", "unknown", "Variable fermé 5 ans", "0166950009"),
42+ "0386080070": ("variable", 60, "special", "unknown", "Variable fermé 5 ans (offre spéciale)", "0386140088"),
43+ "0385940016": ("variable", 60, "special", "insured", "Variable fermé 5 ans (ratio élevé)", "0386090016"),
44+}
45+
46+
47+def _value(responses: dict, code: str) -> float | None:
48+ resp = responses.get(code)
49+ if not isinstance(resp, dict):
50+ return None
51+ content = resp.get("result_content") or {}
52+ try:
53+ return float(content.get("Value"))
54+ except (TypeError, ValueError):
55+ return None
56+
57+
58+class RbcProvider(RateProvider):
59+ provider_id = "rbc"
60+ institution = "RBC Banque Royale"
61+ source_url = PAGE_URL
62+ request_delay = 0.6
63+
64+ def _codes(self) -> list[str]:
65+ codes = [PRIME_CODE] + list(CODE_MAP) + list(SPREAD_MAP)
66+ codes += [apr for *_, apr in CODE_MAP.values() if apr]
67+ codes += [apr for *_, apr in SPREAD_MAP.values() if apr]
68+ return codes
69+
70+ def fetch(self) -> list[dict]:
71+ responses: dict[str, dict] = {}
72+ for code in self._codes():
73+ try:
74+ responses[code] = json.loads(self.get(API.format(code=code)).text)
75+ except Exception: # noqa: BLE001 — un code raté n'annule pas le reste
76+ continue
77+ return self.parse_codes(responses)
78+
79+ def parse(self, payload: str) -> list[dict]:
80+ """Pour les tests fixtures : payload = JSON {code: réponse API}."""
81+ data = json.loads(payload)
82+ return self.parse_codes(data.get("responses") or data)
83+
84+ def parse_codes(self, responses: dict) -> list[dict]:
85+ out: list[dict] = []
86+ prime = _value(responses, PRIME_CODE)
87+ for code, (rtype, term, kind, insured, name, apr_code) in CODE_MAP.items():
88+ rate = _value(responses, code)
89+ if rate is None or rate <= 0:
90+ continue
91+ apr = _value(responses, apr_code) if apr_code else None
92+ out.append(self.make_product(
93+ rate=rate, rate_type=rtype, term_months=term, kind=kind,
94+ product_name=name, apr=apr, insured_status=insured,
95+ raw={"code": code, "value": rate}))
96+ if prime is not None:
97+ for code, (rtype, term, kind, insured, name, apr_code) in SPREAD_MAP.items():
98+ spread = _value(responses, code)
99+ if spread is None or abs(spread) > 3:
100+ continue # un écart vs prime > 3 pts : structure changée
101+ apr = _value(responses, apr_code) if apr_code else None
102+ out.append(self.make_product(
103+ rate=round(prime + spread, 3), rate_type=rtype,
104+ term_months=term, kind=kind, product_name=name, apr=apr,
105+ insured_status=insured,
106+ conditions=f"Prime RBC {prime} % {spread:+} pt",
107+ raw={"code": code, "spread": spread, "prime": prime}))
108+ out.append(self.make_product(
109+ rate=prime, rate_type="other", term_months=12, kind="posted",
110+ product_name="Taux préférentiel RBC", purpose="unknown",
111+ raw={"code": PRIME_CODE, "value": prime}))
112+ return out
added immoka/mortgage/providers/scotiabank.py +104 −0
@@ -0,0 +1,104 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/scotiabank.py : Banque Scotia — API JSON publique
5+# dmtsms.scotiabank.com (celle que consomme leur propre page de taux).
6+# nonspecialmortgage = taux affichés ; varmortgage = slots promotionnels
7+# (le mapping slot -> produit vient de la page ; on ne garde que les slots
8+# dont l'identité est connue, avec confiance réduite).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import json
13+
14+from .base import RateProvider
15+
16+POSTED_URL = "https://dmtsms.scotiabank.com/api/rates/daily/nonspecialmortgage"
17+PROMO_URL = "https://dmtsms.scotiabank.com/api/rates/daily/varmortgage"
18+PAGE_URL = ("https://www.scotiabank.com/ca/en/personal/rates-prices/"
19+ "mortgages-rates.html")
20+
21+# PRODUCT -> (rate_type, insured_status, nom, purpose)
22+POSTED_MAP = {
23+ "N.H.A. RESIDENTIAL, ETC": ("fixed", "insured", "Fixe affiché (assuré LNH)"),
24+ "CONVENTIONAL RESIDENTIAL, ETC": ("fixed", "uninsured", "Fixe affiché (conventionnel)"),
25+ "ULTIMATE VARIABLE RATE": ("variable", "unknown", "Ultimate Variable"),
26+ "OPEN": ("fixed", "unknown", "Fixe ouvert"),
27+ "FLEXIBLE": ("fixed", "unknown", "Flexible"),
28+}
29+
30+# Slots promotionnels dont l'identité est documentée sur la page Scotia
31+# (2026-08). Confiance réduite : si Scotia réordonne ses slots, la validation
32+# et la comparaison croisée limitent les dégâts.
33+PROMO_SLOTS = {
34+ "MORTGAGE PROMOTIONAL 1": ("variable", 60, "Flex Value fermé 5 ans (variable)"),
35+ "MORTGAGE PROMOTIONAL 2": ("variable", 36, "Ultimate Variable 3 ans"),
36+ "MORTGAGE PROMOTIONAL 3": ("variable", 60, "Flex Value ouvert 5 ans (variable)"),
37+}
38+
39+
40+def _term_months(t: dict) -> int | None:
41+ try:
42+ v = int(t["TERM_VALUE"])
43+ except (KeyError, ValueError, TypeError):
44+ return None
45+ unit = (t.get("TERM_UNIT") or "").upper()
46+ if unit == "Y":
47+ return v * 12
48+ if unit == "M":
49+ return v
50+ return None
51+
52+
53+class ScotiabankProvider(RateProvider):
54+ provider_id = "scotiabank"
55+ institution = "Banque Scotia"
56+ source_url = PAGE_URL
57+ request_delay = 1.0
58+
59+ def fetch(self) -> list[dict]:
60+ posted = self.get(POSTED_URL).text
61+ try:
62+ promo = self.get(PROMO_URL).text
63+ except Exception: # noqa: BLE001 — promos facultatives
64+ promo = ""
65+ return self.parse(posted) + (self.parse_promos(promo) if promo else [])
66+
67+ def parse(self, payload: str) -> list[dict]:
68+ data = json.loads(payload)
69+ out: list[dict] = []
70+ for prod in data.get("data") or []:
71+ name = (prod.get("PRODUCT") or "").strip()
72+ if name not in POSTED_MAP:
73+ continue # produits chalet/cap/right-rate : hors périmètre
74+ rtype, insured, label = POSTED_MAP[name]
75+ for t in prod.get("TERMS") or []:
76+ term = _term_months(t)
77+ rate = t.get("RATE")
78+ if term is None or not isinstance(rate, (int, float)) or rate <= 0:
79+ continue
80+ out.append(self.make_product(
81+ rate=float(rate), rate_type=rtype, term_months=term,
82+ kind="posted", product_name=label,
83+ insured_status=insured,
84+ conditions=name,
85+ raw={"product": name, "term": t}))
86+ return out
87+
88+ def parse_promos(self, payload: str) -> list[dict]:
89+ data = json.loads(payload)
90+ out: list[dict] = []
91+ for prod in data.get("data") or []:
92+ name = (prod.get("PRODUCT") or "").strip()
93+ if name not in PROMO_SLOTS:
94+ continue
95+ rate = prod.get("RATE")
96+ if not isinstance(rate, (int, float)) or rate <= 0:
97+ continue # slot inutilisé
98+ rtype, term, label = PROMO_SLOTS[name]
99+ out.append(self.make_product(
100+ rate=float(rate), rate_type=rtype, term_months=term,
101+ kind="special", product_name=label, confidence=0.8,
102+ conditions=f"Slot promotionnel Scotia ({name})",
103+ raw={"product": name, "rate": rate}))
104+ return out
added immoka/mortgage/providers/tangerine.py +55 −0
@@ -0,0 +1,55 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/tangerine.py : Tangerine — JSON statique publié
5+# (currentRates.json, le fichier que charge leur page /rates). Un seul taux
6+# par terme (pas de distinction affiché/spécial chez Tangerine) : kind
7+# "special" car c'est leur taux client réel.
8+# -----------------------------------------------------------------------------
9+from __future__ import annotations
10+
11+import json
12+
13+from .base import RateProvider, parse_rate
14+
15+RATES_URL = ("https://www.tangerine.ca/content/dam/tangerine-shared/"
16+ "product-rates/currentRates.json")
17+
18+
19+class TangerineProvider(RateProvider):
20+ provider_id = "tangerine"
21+ institution = "Tangerine"
22+ source_url = "https://www.tangerine.ca/en/rates"
23+ request_delay = 1.0
24+
25+ def fetch(self) -> list[dict]:
26+ return self.parse(self.get(RATES_URL).text)
27+
28+ def parse(self, payload: str) -> list[dict]:
29+ data = json.loads(payload)
30+ out: list[dict] = []
31+ for r in data.get("rates") or []:
32+ if r.get("group") != "mortgage":
33+ continue
34+ code = r.get("max_product_code")
35+ rate = r.get("interest_rate")
36+ term_years = r.get("term")
37+ if not isinstance(rate, (int, float)) or rate <= 0:
38+ continue
39+ if not isinstance(term_years, (int, float)) or term_years <= 0:
40+ continue
41+ if code == "Mortgage":
42+ rtype, label = "fixed", f"Fixe {int(term_years)} an(s)"
43+ elif code == "VarMortgage":
44+ rtype, label = "variable", f"Variable {int(term_years)} an(s)"
45+ else:
46+ continue # preferred_* et autres : clients connectés, ignorés
47+ apr = parse_rate(r.get("apr_value_en") or "")
48+ out.append(self.make_product(
49+ rate=float(rate), rate_type=rtype,
50+ term_months=int(term_years * 12), kind="special",
51+ product_name=label, apr=apr,
52+ conditions=f"Taux unique Tangerine (en date du {r.get('date')})",
53+ raw={"account_term": r.get("account_term"),
54+ "date": r.get("date"), "interest_rate": rate}))
55+ return out
added immoka/mortgage/providers/td.py +115 −0
@@ -0,0 +1,115 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/providers/td.py : TD Canada Trust — API JSON publique getRates
5+# (POST ratesType=resl, celle que consomme leur page de taux). Chaque code
6+# MTG{F|V}{mois}{C|O} porte deux tableaux highRatio / nonHighRatio de la
7+# forme [affiché, escompte, spécial, APR, drapeau]. Le « affiché » des
8+# produits variables est le TD Mortgage Prime. ⚠️ Pour les termes sans offre
9+# spéciale réelle, TD publie un « spécial » reconstruit (escompte négatif)
10+# dont l'APR correspond en fait au taux AFFICHÉ : ces lignes incohérentes
11+# (APR < spécial) sont écartées — jamais interprétées.
12+# -----------------------------------------------------------------------------
13+from __future__ import annotations
14+
15+import json
16+import re
17+
18+from .base import RateProvider
19+
20+API_URL = "https://psservice.td.com/ca/en/carate/getRates"
21+PAGE_URL = ("https://www.td.com/ca/en/personal-banking/products/mortgages/"
22+ "mortgage-rates")
23+
24+CODE_RX = re.compile(r"^MTG([FV])(\d{3})([CO])$")
25+
26+IDX_POSTED, IDX_SPECIAL, IDX_APR = 0, 2, 3
27+
28+
29+def _num(v) -> float | None:
30+ try:
31+ return float(str(v).strip())
32+ except (TypeError, ValueError):
33+ return None
34+
35+
36+def _label(months: int) -> str:
37+ if months < 12:
38+ return f"{months} mois"
39+ years = months // 12
40+ return f"{years} an" if years == 1 else f"{years} ans"
41+
42+
43+class TdProvider(RateProvider):
44+ provider_id = "td"
45+ institution = "TD Canada Trust"
46+ source_url = PAGE_URL
47+ request_delay = 1.0
48+
49+ def fetch(self) -> list[dict]:
50+ wait_kw = {"timeout": self.timeout,
51+ "headers": {"Content-Type": "application/json"}}
52+ resp = self.session.post(API_URL, json={"ratesType": "resl"}, **wait_kw)
53+ resp.raise_for_status()
54+ return self.parse(resp.text)
55+
56+ def parse(self, payload: str) -> list[dict]:
57+ data = json.loads(payload)
58+ if not isinstance(data, dict):
59+ raise ValueError("réponse TD inattendue (structure changée)")
60+ for wrapper in ("rates", "data", "result"):
61+ if wrapper in data and isinstance(data[wrapper], dict):
62+ data = data[wrapper]
63+ break
64+ out: list[dict] = []
65+ prime: float | None = None
66+ for code, tables in data.items():
67+ m = CODE_RX.match(str(code))
68+ if not m or not isinstance(tables, dict):
69+ continue # FLT* (FlexLine/HELOC) et codes inconnus : ignorés
70+ rtype = "fixed" if m.group(1) == "F" else "variable"
71+ term = int(m.group(2))
72+ openness = "fermé" if m.group(3) == "C" else "ouvert"
73+ base = ("Fixe" if rtype == "fixed" else "Variable")
74+ non_hr = tables.get("nonHighRatio") or []
75+ high_r = tables.get("highRatio") or []
76+ posted = _num(non_hr[IDX_POSTED]) if len(non_hr) > IDX_POSTED else None
77+ if posted and posted > 0:
78+ if rtype == "variable":
79+ # l'« affiché » des codes MTGV est le TD Mortgage Prime,
80+ # pas le taux du produit : jamais émis comme taux variable
81+ prime = posted
82+ else:
83+ out.append(self.make_product(
84+ rate=posted, rate_type=rtype, term_months=term,
85+ kind="posted",
86+ product_name=f"{base} {openness} {_label(term)}",
87+ raw={"code": code, "row": non_hr}))
88+ for arr, insured, suffix in ((non_hr, "uninsured", ""),
89+ (high_r, "insured", ", ratio élevé")):
90+ if len(arr) <= IDX_APR:
91+ continue
92+ special = _num(arr[IDX_SPECIAL])
93+ if special is None or special <= 0:
94+ continue
95+ if posted is not None and special == posted and rtype == "fixed" \
96+ and _num(arr[1]) in (0, None):
97+ continue # pas de spécial publié pour ce produit
98+ apr = _num(arr[IDX_APR])
99+ if apr is not None and apr < special - 0.02:
100+ continue # « spécial » reconstruit : APR = celui du taux affiché
101+ out.append(self.make_product(
102+ rate=special, rate_type=rtype, term_months=term,
103+ kind="special", apr=apr,
104+ product_name=f"{base} {openness} {_label(term)} "
105+ f"(offre spéciale{suffix})",
106+ insured_status=insured,
107+ conditions="Base = TD Mortgage Prime"
108+ if rtype == "variable" else None,
109+ raw={"code": code, "row": arr}))
110+ if prime and prime > 0:
111+ out.append(self.make_product(
112+ rate=prime, rate_type="other", term_months=12, kind="posted",
113+ product_name="TD Mortgage Prime", purpose="unknown",
114+ raw={"field": "MTGV*[0]", "value": prime}))
115+ return out
added immoka/mortgage/scheduler.py +120 −0
@@ -0,0 +1,120 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/scheduler.py : orchestration de la collecte des taux.
5+# Pipeline : provider.fetch() -> validation -> enregistrement historisé.
6+# Retries avec backoff exponentiel, timeout par provider, logs structurés,
7+# santé par provider (provider_runs). Une panne d'un provider n'affecte
8+# jamais les autres ni le calculateur (dernière donnée valide conservée).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import os
13+import time
14+import traceback
15+
16+import requests
17+
18+from . import store
19+from .providers import PROVIDERS
20+from .validate import validate_batch
21+
22+RETRIES = int(os.environ.get("IMMOKA_MORTGAGE_RETRIES", "3"))
23+BACKOFF_BASE_S = float(os.environ.get("IMMOKA_MORTGAGE_BACKOFF", "5"))
24+# Fréquence de collecte (minutes) — utilisée par watch() ci-dessous.
25+INTERVAL_MIN = int(os.environ.get("IMMOKA_MORTGAGE_INTERVAL_MIN", "180"))
26+
27+
28+def _log(**kw) -> None:
29+ print("[mortgage] " + " ".join(f"{k}={v}" for k, v in kw.items()),
30+ flush=True)
31+
32+
33+def run_provider(slug: str, con=None) -> dict:
34+ """Collecte UNE institution avec retries + backoff. Retourne le résumé."""
35+ if con is None:
36+ con = store.connect()
37+ cls = PROVIDERS[slug]
38+ t0 = time.time()
39+ products: list[dict] | None = None
40+ status, message = "success", ""
41+ for attempt in range(RETRIES):
42+ try:
43+ products = cls().fetch()
44+ break
45+ except requests.RequestException as exc:
46+ status, message = "http_error", str(exc)[:200]
47+ except Exception as exc: # noqa: BLE001 — parseur cassé, etc.
48+ status, message = "parser_error", str(exc)[:200]
49+ traceback.print_exc()
50+ if attempt < RETRIES - 1:
51+ time.sleep(BACKOFF_BASE_S * (2 ** attempt))
52+ duration_ms = int((time.time() - t0) * 1000)
53+ if products is None:
54+ store.log_run(con, slug, ok=False, status=status,
55+ duration_ms=duration_ms, message=message)
56+ _log(provider=slug, status=status, duration=f"{duration_ms}ms",
57+ message=message or "-")
58+ return {"provider": slug, "ok": False, "status": status,
59+ "message": message}
60+ valid, problems = validate_batch(products)
61+ if not valid:
62+ status = "empty" if not products else "validation_error"
63+ store.log_run(con, slug, ok=False, status=status,
64+ products=len(products), rejected=len(problems),
65+ duration_ms=duration_ms,
66+ message="; ".join(problems[:5]))
67+ _log(provider=slug, status=status, products=len(products),
68+ rejected=len(problems), duration=f"{duration_ms}ms")
69+ return {"provider": slug, "ok": False, "status": status,
70+ "problems": problems}
71+ res = store.record_observations(con, slug, valid)
72+ ok = True
73+ if problems or res["rejected"]:
74+ status = "success" # partiel : données saines enregistrées quand même
75+ store.log_run(con, slug, ok=ok, status=status, products=len(valid),
76+ changed=res["changed"],
77+ rejected=len(problems) + res["rejected"],
78+ duration_ms=duration_ms,
79+ message="; ".join((problems + res["rejected_details"])[:5]))
80+ _log(provider=slug, status=status, products=len(valid),
81+ changed=res["changed"], rejected=len(problems) + res["rejected"],
82+ duration=f"{duration_ms}ms")
83+ return {"provider": slug, "ok": True, "status": status,
84+ "products": len(valid), "changed": res["changed"],
85+ "rejected": len(problems) + res["rejected"]}
86+
87+
88+def run(only: list[str] | None = None) -> list[dict]:
89+ """Collecte toutes les institutions (ou celles listées). Séquentiel et
90+ poli — jamais de martèlement des sites bancaires."""
91+ con = store.connect()
92+ slugs = [s for s in sorted(PROVIDERS) if not only or s in only]
93+ results = [run_provider(s, con) for s in slugs]
94+ ok = sum(1 for r in results if r["ok"])
95+ _log(status="done", providers=len(results), ok=ok,
96+ failed=len(results) - ok)
97+ return results
98+
99+
100+def watch(interval_minutes: int | None = None) -> None:
101+ """Boucle autonome de collecte (défaut : IMMOKA_MORTGAGE_INTERVAL_MIN)."""
102+ minutes = interval_minutes or INTERVAL_MIN
103+ while True:
104+ try:
105+ run()
106+ except Exception: # noqa: BLE001 — la boucle ne meurt jamais
107+ traceback.print_exc()
108+ time.sleep(minutes * 60)
109+
110+
111+def maybe_run(min_age_minutes: int | None = None) -> None:
112+ """Collecte seulement si la dernière passe date de plus de
113+ `min_age_minutes` — appelé depuis la boucle watch d'ingest.py sans risque
114+ de sur-solliciter les banques."""
115+ age_min = min_age_minutes or INTERVAL_MIN
116+ con = store.connect()
117+ last = con.execute("SELECT MAX(ts) AS m FROM provider_runs").fetchone()
118+ if last and last["m"] and (time.time() - last["m"]) < age_min * 60:
119+ return
120+ run()
added immoka/mortgage/store.py +336 −0
@@ -0,0 +1,336 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/store.py : persistance des taux hypothécaires (data/mortgage.db).
5+# Historisation par périodes de validité : une ligne « courante » par produit
6+# (valid_to IS NULL) ; un changement de taux ferme la ligne et en ouvre une
7+# nouvelle. Aucune donnée n'est jamais écrasée — l'historique complet se
8+# reconstruit par produit. Santé des providers dans provider_runs.
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+import hashlib
13+import json
14+import sqlite3
15+import statistics
16+import time
17+from pathlib import Path
18+
19+DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "mortgage.db"
20+
21+# Un taux « courant » plus vieux que STALE_H heures est signalé périmé.
22+STALE_H = 24
23+# Rejet des sauts aberrants : variation > JUMP_MAX points en < JUMP_WINDOW_H h.
24+JUMP_MAX = 2.5
25+JUMP_WINDOW_H = 48
26+
27+_SCHEMA = """
28+CREATE TABLE IF NOT EXISTS rate_observations (
29+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30+ provider TEXT NOT NULL,
31+ institution TEXT NOT NULL,
32+ product_key TEXT NOT NULL, -- empreinte identité produit
33+ product_name TEXT,
34+ rate_type TEXT NOT NULL, -- fixed|variable|adjustable|other
35+ term_months INTEGER NOT NULL,
36+ kind TEXT NOT NULL, -- posted|special
37+ rate REAL NOT NULL, -- taux contractuel en % (ex. 4.19)
38+ apr REAL,
39+ insured_status TEXT DEFAULT 'unknown', -- insured|insurable|uninsured|unknown
40+ purpose TEXT DEFAULT 'purchase', -- purchase|renewal|refinance|unknown
41+ amortization_max_years INTEGER,
42+ conditions TEXT,
43+ source_url TEXT,
44+ confidence REAL DEFAULT 1.0,
45+ raw TEXT, -- JSON : observation brute (audit)
46+ valid_from REAL NOT NULL,
47+ valid_to REAL, -- NULL = taux courant
48+ last_checked REAL NOT NULL
49+);
50+CREATE INDEX IF NOT EXISTS idx_rateobs_current
51+ ON rate_observations(provider, product_key, valid_to);
52+CREATE INDEX IF NOT EXISTS idx_rateobs_lookup
53+ ON rate_observations(rate_type, term_months, valid_to);
54+
55+CREATE TABLE IF NOT EXISTS provider_runs (
56+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57+ provider TEXT NOT NULL,
58+ ts REAL NOT NULL,
59+ ok INTEGER,
60+ status TEXT, -- success|http_error|parser_error|validation_error|empty
61+ products INTEGER,
62+ changed INTEGER,
63+ rejected INTEGER,
64+ duration_ms INTEGER,
65+ message TEXT
66+);
67+CREATE INDEX IF NOT EXISTS idx_provider_runs ON provider_runs(provider, ts);
68+
69+-- Préparé pour les notifications futures (« alerte-moi si le 5 ans fixe
70+-- passe sous 3,99 % » / « si le paiement de cette propriété passe sous X $ »).
71+CREATE TABLE IF NOT EXISTS rate_alerts (
72+ id INTEGER PRIMARY KEY AUTOINCREMENT,
73+ created REAL NOT NULL,
74+ kind TEXT NOT NULL, -- rate_below|payment_below
75+ params TEXT, -- JSON : {rate_type, term_months, uid, down…}
76+ threshold REAL NOT NULL,
77+ contact TEXT,
78+ active INTEGER DEFAULT 1,
79+ fired_at REAL
80+);
81+"""
82+
83+_SCHEMA_READY = False
84+
85+
86+def connect() -> sqlite3.Connection:
87+ global _SCHEMA_READY
88+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
89+ con = sqlite3.connect(DB_PATH, timeout=60)
90+ con.row_factory = sqlite3.Row
91+ con.execute("PRAGMA journal_mode=WAL")
92+ con.execute("PRAGMA synchronous=NORMAL")
93+ con.execute("PRAGMA busy_timeout=120000")
94+ if not _SCHEMA_READY:
95+ con.executescript(_SCHEMA)
96+ con.commit()
97+ _SCHEMA_READY = True
98+ return con
99+
100+
101+def product_key(p: dict) -> str:
102+ """Identité stable d'un produit : institution + type + terme + nature du
103+ taux + assurabilité + objet + nom. Deux produits incompatibles ne
104+ partagent jamais la même clé (règle : ne jamais comparer l'incomparable)."""
105+ ident = "|".join([
106+ p["provider"], p["rate_type"], str(p["term_months"]), p["kind"],
107+ p.get("insured_status") or "unknown", p.get("purpose") or "purchase",
108+ (p.get("product_name") or "").strip().lower(),
109+ ])
110+ return hashlib.sha1(ident.encode()).hexdigest()[:16]
111+
112+
113+def record_observations(con: sqlite3.Connection, provider: str,
114+ products: list[dict]) -> dict:
115+ """Enregistre une passe de collecte validée.
116+
117+ Pour chaque produit : si le taux courant est identique → simple mise à
118+ jour de last_checked ; s'il a changé → fermeture de la période et
119+ insertion d'une nouvelle ligne. Rejette les sauts aberrants (> JUMP_MAX
120+ points en < JUMP_WINDOW_H h) sans écraser la bonne donnée précédente.
121+ Retourne {seen, changed, rejected, rejected_details}."""
122+ now = time.time()
123+ changed = 0
124+ rejected: list[str] = []
125+ for p in products:
126+ pk = product_key(p)
127+ cur = con.execute(
128+ "SELECT id, rate, last_checked FROM rate_observations "
129+ "WHERE provider=? AND product_key=? AND valid_to IS NULL",
130+ (provider, pk)).fetchone()
131+ if cur is not None:
132+ if abs(cur["rate"] - p["rate"]) < 1e-9:
133+ con.execute(
134+ "UPDATE rate_observations SET last_checked=?, apr=?, "
135+ "conditions=?, source_url=? WHERE id=?",
136+ (now, p.get("apr"), p.get("conditions"),
137+ p.get("source_url"), cur["id"]))
138+ continue
139+ # Garde-fou anti-aberration : ne jamais écraser une donnée saine
140+ # par un saut manifestement impossible.
141+ age_h = (now - (cur["last_checked"] or now)) / 3600.0
142+ if abs(cur["rate"] - p["rate"]) > JUMP_MAX and age_h < JUMP_WINDOW_H:
143+ rejected.append(
144+ f"{p.get('product_name') or pk}: {cur['rate']} -> "
145+ f"{p['rate']} (saut aberrant)")
146+ continue
147+ con.execute("UPDATE rate_observations SET valid_to=? WHERE id=?",
148+ (now, cur["id"]))
149+ changed += 1
150+ con.execute(
151+ "INSERT INTO rate_observations (provider, institution, "
152+ "product_key, product_name, rate_type, term_months, kind, rate, "
153+ "apr, insured_status, purpose, amortization_max_years, "
154+ "conditions, source_url, confidence, raw, valid_from, "
155+ "valid_to, last_checked) "
156+ "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)",
157+ (provider, p["institution"], pk, p.get("product_name"),
158+ p["rate_type"], p["term_months"], p["kind"], p["rate"],
159+ p.get("apr"), p.get("insured_status") or "unknown",
160+ p.get("purpose") or "purchase", p.get("amortization_max_years"),
161+ p.get("conditions"), p.get("source_url"),
162+ p.get("confidence", 1.0),
163+ json.dumps(p.get("raw"), ensure_ascii=False) if p.get("raw") else None,
164+ now, now))
165+ con.commit()
166+ return {"seen": len(products), "changed": changed,
167+ "rejected": len(rejected), "rejected_details": rejected}
168+
169+
170+def _row(r: sqlite3.Row) -> dict:
171+ d = dict(r)
172+ d.pop("raw", None)
173+ now = time.time()
174+ d["age_minutes"] = round((now - (d.get("last_checked") or now)) / 60)
175+ d["stale"] = d["age_minutes"] > STALE_H * 60
176+ return d
177+
178+
179+def current_rates(con: sqlite3.Connection, rate_type: str | None = None,
180+ term_months: int | None = None, kind: str | None = None,
181+ insured_status: str | None = None,
182+ purpose: str | None = None,
183+ provider: str | None = None) -> list[dict]:
184+ """Taux courants (dernière donnée valide par produit), filtrables."""
185+ q = ("SELECT * FROM rate_observations WHERE valid_to IS NULL")
186+ args: list = []
187+ for col, val in (("rate_type", rate_type), ("term_months", term_months),
188+ ("kind", kind), ("provider", provider),
189+ ("purpose", purpose)):
190+ if val is not None:
191+ q += f" AND {col}=?"
192+ args.append(val)
193+ if insured_status is not None:
194+ q += " AND insured_status IN (?, 'unknown')"
195+ args.append(insured_status)
196+ q += " ORDER BY provider, term_months, rate"
197+ return [_row(r) for r in con.execute(q, args).fetchall()]
198+
199+
200+def best_rate(con: sqlite3.Connection, rate_type: str = "fixed",
201+ term_months: int = 60, insured_status: str | None = None,
202+ purpose: str = "purchase") -> dict | None:
203+ """Meilleur taux courant pour un produit comparable (privilégie les taux
204+ « special », sinon posted). Retourne la ligne complète + contexte."""
205+ rows = current_rates(con, rate_type=rate_type, term_months=term_months,
206+ insured_status=insured_status, purpose=purpose)
207+ if not rows:
208+ return None
209+ # Un seul candidat par institution : special prioritaire, sinon posted.
210+ by_inst: dict[str, dict] = {}
211+ for r in rows:
212+ cur = by_inst.get(r["provider"])
213+ if cur is None:
214+ by_inst[r["provider"]] = r
215+ elif r["kind"] == "special" and cur["kind"] == "posted":
216+ by_inst[r["provider"]] = r
217+ elif r["kind"] == cur["kind"] and r["rate"] < cur["rate"]:
218+ by_inst[r["provider"]] = r
219+ candidates = sorted(by_inst.values(), key=lambda r: r["rate"])
220+ best = candidates[0]
221+ rates = [r["rate"] for r in candidates]
222+ return {
223+ **best,
224+ "median_rate": round(statistics.median(rates), 2) if rates else None,
225+ "institutions_count": len(candidates),
226+ "per_institution": candidates,
227+ }
228+
229+
230+def history(con: sqlite3.Connection, rate_type: str, term_months: int,
231+ provider: str | None = None, kind: str | None = None,
232+ days: int = 365) -> list[dict]:
233+ """Historique : périodes de validité (valid_from/valid_to) par produit."""
234+ since = time.time() - days * 86400
235+ q = ("SELECT provider, institution, product_name, kind, rate, "
236+ "insured_status, valid_from, valid_to, last_checked, source_url "
237+ "FROM rate_observations WHERE rate_type=? AND term_months=? "
238+ "AND (valid_to IS NULL OR valid_to >= ?)")
239+ args: list = [rate_type, term_months, since]
240+ if provider:
241+ q += " AND provider=?"
242+ args.append(provider)
243+ if kind:
244+ q += " AND kind=?"
245+ args.append(kind)
246+ q += " ORDER BY provider, valid_from"
247+ return [dict(r) for r in con.execute(q, args).fetchall()]
248+
249+
250+def rate_at(con: sqlite3.Connection, rate_type: str, term_months: int,
251+ ts: float, kind: str = "special") -> float | None:
252+ """Meilleur taux observé (toutes institutions) à un instant donné."""
253+ rows = con.execute(
254+ "SELECT MIN(rate) AS r FROM rate_observations "
255+ "WHERE rate_type=? AND term_months=? AND kind=? AND valid_from<=? "
256+ "AND (valid_to IS NULL OR valid_to>?) AND last_checked>=?",
257+ (rate_type, term_months, kind, ts, ts, ts - 14 * 86400)).fetchone()
258+ if rows is None or rows["r"] is None:
259+ # repli : posted si aucun special à cette date
260+ rows = con.execute(
261+ "SELECT MIN(rate) AS r FROM rate_observations "
262+ "WHERE rate_type=? AND term_months=? AND valid_from<=? "
263+ "AND (valid_to IS NULL OR valid_to>?)",
264+ (rate_type, term_months, ts, ts)).fetchone()
265+ return rows["r"] if rows else None
266+
267+
268+def market_stats(con: sqlite3.Connection, rate_type: str = "fixed",
269+ term_months: int = 60) -> dict | None:
270+ """Métriques Mortgage Intelligence : meilleur, médian, spread,
271+ variations 7/30/90 jours, plus bas observé 6 mois."""
272+ best = best_rate(con, rate_type=rate_type, term_months=term_months)
273+ if best is None:
274+ return None
275+ now = time.time()
276+ out = {
277+ "rate_type": rate_type,
278+ "term_months": term_months,
279+ "best": best["rate"],
280+ "best_provider": best["provider"],
281+ "best_institution": best["institution"],
282+ "best_kind": best["kind"],
283+ "median": best["median_rate"],
284+ "spread": (round(best["median_rate"] - best["rate"], 2)
285+ if best["median_rate"] is not None else None),
286+ "institutions_count": best["institutions_count"],
287+ }
288+ for label, days in (("var_7d", 7), ("var_30d", 30), ("var_90d", 90)):
289+ past = rate_at(con, rate_type, term_months, now - days * 86400)
290+ out[label] = round(best["rate"] - past, 2) if past is not None else None
291+ low = con.execute(
292+ "SELECT MIN(rate) AS r FROM rate_observations "
293+ "WHERE rate_type=? AND term_months=? AND kind='special' "
294+ "AND last_checked >= ?",
295+ (rate_type, term_months, now - 182 * 86400)).fetchone()
296+ out["lowest_6m"] = low["r"] if low and low["r"] is not None else None
297+ return out
298+
299+
300+def log_run(con: sqlite3.Connection, provider: str, ok: bool, status: str,
301+ products: int = 0, changed: int = 0, rejected: int = 0,
302+ duration_ms: int = 0, message: str = "") -> None:
303+ con.execute(
304+ "INSERT INTO provider_runs (provider, ts, ok, status, products, "
305+ "changed, rejected, duration_ms, message) VALUES (?,?,?,?,?,?,?,?,?)",
306+ (provider, time.time(), 1 if ok else 0, status, products, changed,
307+ rejected, duration_ms, message[:500]))
308+ con.commit()
309+
310+
311+def provider_health(con: sqlite3.Connection) -> list[dict]:
312+ """Dernier état de chaque provider : OK / WARNING / ERROR + fraîcheur."""
313+ rows = con.execute(
314+ "SELECT p.* FROM provider_runs p JOIN (SELECT provider, MAX(ts) AS m "
315+ "FROM provider_runs GROUP BY provider) x "
316+ "ON p.provider=x.provider AND p.ts=x.m ORDER BY p.provider").fetchall()
317+ now = time.time()
318+ out = []
319+ for r in rows:
320+ d = dict(r)
321+ n_current = con.execute(
322+ "SELECT COUNT(*) AS n, MAX(last_checked) AS mc "
323+ "FROM rate_observations WHERE provider=? AND valid_to IS NULL",
324+ (r["provider"],)).fetchone()
325+ age_min = round((now - r["ts"]) / 60)
326+ if r["ok"] and n_current["n"] > 0:
327+ level = "WARNING" if age_min > STALE_H * 60 or r["rejected"] else "OK"
328+ elif n_current["n"] > 0:
329+ level = "WARNING" # échec récent mais données valides conservées
330+ else:
331+ level = "ERROR"
332+ d.update({"level": level, "age_minutes": age_min,
333+ "current_products": n_current["n"],
334+ "last_data_at": n_current["mc"]})
335+ out.append(d)
336+ return out
added immoka/mortgage/validate.py +79 −0
@@ -0,0 +1,79 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# mortgage/validate.py : couche de validation des observations de taux.
5+# Règle absolue : ne jamais inventer ni laisser passer un taux impossible.
6+# 4.19 % -> 419 % doit être rejeté ; une bonne donnée n'est jamais écrasée
7+# par une donnée manifestement erronée (garde-fou complémentaire dans
8+# store.record_observations).
9+# -----------------------------------------------------------------------------
10+from __future__ import annotations
11+
12+RATE_MIN = 0.25 # sous 0,25 % : impossible pour une hypothèque canadienne
13+RATE_MAX = 20.0 # au-delà de 20 % : aberrant (même les prêts privés)
14+TERM_MIN_MONTHS = 3
15+TERM_MAX_MONTHS = 120
16+
17+VALID_RATE_TYPES = {"fixed", "variable", "adjustable", "other"}
18+VALID_KINDS = {"posted", "special"}
19+VALID_INSURED = {"insured", "insurable", "uninsured", "unknown"}
20+VALID_PURPOSES = {"purchase", "renewal", "refinance", "unknown"}
21+
22+REQUIRED_FIELDS = ("provider", "institution", "rate_type", "term_months",
23+ "kind", "rate", "source_url")
24+
25+
26+def validate_product(p: dict) -> tuple[bool, list[str]]:
27+ """Valide UNE observation normalisée. Retourne (ok, problèmes)."""
28+ issues: list[str] = []
29+ for f in REQUIRED_FIELDS:
30+ if p.get(f) in (None, ""):
31+ issues.append(f"champ manquant: {f}")
32+ if issues:
33+ return False, issues
34+ rate = p["rate"]
35+ if not isinstance(rate, (int, float)):
36+ issues.append(f"taux non numérique: {rate!r}")
37+ elif rate <= 0:
38+ issues.append(f"taux nul ou négatif: {rate}")
39+ elif rate < RATE_MIN or rate > RATE_MAX:
40+ issues.append(f"taux impossible: {rate}")
41+ term = p["term_months"]
42+ if not isinstance(term, int) or not (TERM_MIN_MONTHS <= term <= TERM_MAX_MONTHS):
43+ issues.append(f"terme invalide: {term!r}")
44+ if p["rate_type"] not in VALID_RATE_TYPES:
45+ issues.append(f"rate_type invalide: {p['rate_type']}")
46+ if p["kind"] not in VALID_KINDS:
47+ issues.append(f"kind invalide: {p['kind']}")
48+ if p.get("insured_status", "unknown") not in VALID_INSURED:
49+ issues.append(f"insured_status invalide: {p.get('insured_status')}")
50+ if p.get("purpose", "purchase") not in VALID_PURPOSES:
51+ issues.append(f"purpose invalide: {p.get('purpose')}")
52+ apr = p.get("apr")
53+ if apr is not None:
54+ if not isinstance(apr, (int, float)) or apr < RATE_MIN or apr > RATE_MAX:
55+ issues.append(f"APR impossible: {apr!r}")
56+ elif isinstance(rate, (int, float)) and apr < rate - 0.02:
57+ issues.append(f"APR ({apr}) inférieur au taux contractuel ({rate})")
58+ return not issues, issues
59+
60+
61+def validate_batch(products: list[dict]) -> tuple[list[dict], list[str]]:
62+ """Valide une passe complète : élimine invalides et duplicatas exacts.
63+ Retourne (produits valides, problèmes)."""
64+ from .store import product_key
65+ ok_products: list[dict] = []
66+ problems: list[str] = []
67+ seen: set[tuple[str, float]] = set()
68+ for p in products:
69+ valid, issues = validate_product(p)
70+ if not valid:
71+ name = p.get("product_name") or "?"
72+ problems.extend(f"{name}: {i}" for i in issues)
73+ continue
74+ key = (product_key(p), round(float(p["rate"]), 4))
75+ if key in seen:
76+ continue # duplicata exact silencieusement ignoré
77+ seen.add(key)
78+ ok_products.append(p)
79+ return ok_products, problems
added immoka/normalize.py +258 −0
@@ -0,0 +1,258 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# normalize.py : couche de normalisation commune (prix, types, adresses…)
5+# -----------------------------------------------------------------------------
6+"""Fonctions de normalisation partagées par tous les connecteurs.
7+
8+Les connecteurs remplissent les champs bruts tels que vus sur le site source ;
9+`PropertyListing.finalize()` (schema.py) appelle ces fonctions pour produire
10+des valeurs canoniques comparables entre agences.
11+"""
12+from __future__ import annotations
13+
14+import re
15+import unicodedata
16+
17+__all__ = [
18+ "strip_accents", "clean_address", "parse_price", "price_is_from",
19+ "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft",
20+ "parse_year", "normalize_property_type", "extract_bedrooms_bathrooms",
21+ "clean_title", "clean_description",
22+]
23+
24+
25+def strip_accents(text: str) -> str:
26+ return "".join(c for c in unicodedata.normalize("NFD", text or "")
27+ if unicodedata.category(c) != "Mn")
28+
29+
30+_SMALL_WORDS = {"a", "à", "au", "aux", "avec", "de", "des", "du", "en", "et",
31+ "la", "le", "les", "ou", "pour", "sur", "sous", "un", "une"}
32+
33+
34+def clean_title(text: str) -> str:
35+ """Titre propre : espaces normalisés, et les titres CRIÉS EN MAJUSCULES
36+ (fréquents chez certaines sources) ramenés en casse naturelle — chaque mot
37+ capitalisé sauf les mots-outils (les noms de villes restent capitalisés)."""
38+ t = re.sub(r"\s+", " ", (text or "")).strip()
39+ letters = [c for c in t if c.isalpha()]
40+ if len(letters) >= 8 and sum(c.isupper() for c in letters) / len(letters) > 0.85:
41+ words = []
42+ for i, w in enumerate(t.lower().split(" ")):
43+ words.append(w if (i and w in _SMALL_WORDS) else w[:1].upper() + w[1:])
44+ t = " ".join(words)
45+ return t
46+
47+
48+_TAG_RE = re.compile(r"<[^>]+>")
49+_BR_RE = re.compile(r"<br\s*/?>|</p>|</div>|</li>", re.I)
50+
51+
52+def clean_description(text: str) -> str:
53+ """Description sans HTML brut visible : balises retirées (sauts de ligne
54+ préservés), entités décodées, espaces/blancs normalisés."""
55+ import html as _html
56+ t = text or ""
57+ if "<" in t and ">" in t:
58+ t = _BR_RE.sub("\n", t)
59+ t = _TAG_RE.sub(" ", t)
60+ t = _html.unescape(t)
61+ t = re.sub(r"[ \t]+", " ", t)
62+ t = re.sub(r" ?\n ?", "\n", t)
63+ t = re.sub(r"\n{3,}", "\n\n", t)
64+ return t.strip()
65+
66+
67+def clean_address(text: str) -> str:
68+ """Nettoie une adresse civique (espaces, virgules doublées, apostrophes)."""
69+ t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip()
70+ t = re.sub(r"\s*,\s*", ", ", t)
71+ t = re.sub(r"(, )+", ", ", t).strip(", ")
72+ return t
73+
74+
75+# ---------------------------------------------------------------------------
76+# Prix
77+# ---------------------------------------------------------------------------
78+
79+_PRICE_RE = re.compile(r"(\d[\d\s  .,]*)\s*(?:\$|CAD)?", re.UNICODE)
80+
81+
82+def parse_price(label: str) -> float | None:
83+ """Extrait un prix de vente d'un libellé source.
84+
85+ Gère « 459 000 $ », « $459,000 », « 1 249 000$ +tx », « À partir de 399 900 $ ».
86+ Retourne None si aucun montant plausible (>= 10 000 $) n'est trouvé.
87+ """
88+ if not label:
89+ return None
90+ m = _PRICE_RE.search(label.replace(" ", " ").replace(" ", " "))
91+ if not m:
92+ return None
93+ raw = m.group(1).strip()
94+ # « 459 000 » / « 459,000 » / « 459000.00 » — retirer les séparateurs de milliers
95+ raw = raw.replace(" ", "")
96+ if "," in raw and "." in raw:
97+ raw = raw.replace(",", "") # 459,000.00
98+ elif raw.count(",") == 1 and len(raw.split(",")[1]) == 2:
99+ raw = raw.replace(",", ".") # 459000,00 (décimale FR)
100+ else:
101+ raw = raw.replace(",", "")
102+ try:
103+ value = float(raw)
104+ except ValueError:
105+ return None
106+ return value if value >= 10_000 else None
107+
108+
109+def price_is_from(label: str) -> bool:
110+ key = strip_accents((label or "").lower())
111+ return any(k in key for k in ("a partir", "starting", "from", "des "))
112+
113+
114+# ---------------------------------------------------------------------------
115+# Nombres génériques
116+# ---------------------------------------------------------------------------
117+
118+def parse_int(text) -> int | None:
119+ if text is None:
120+ return None
121+ if isinstance(text, (int, float)):
122+ return int(text)
123+ m = re.search(r"\d+", str(text))
124+ return int(m.group()) if m else None
125+
126+
127+def parse_float(text) -> float | None:
128+ if text is None:
129+ return None
130+ if isinstance(text, (int, float)):
131+ return float(text)
132+ m = re.search(r"\d[\d\s]*(?:[.,]\d+)?", str(text))
133+ if not m:
134+ return None
135+ try:
136+ return float(m.group().replace(" ", "").replace(",", "."))
137+ except ValueError:
138+ return None
139+
140+
141+_SQFT_RE = re.compile(r"([\d\s ,.]+)\s*(pi2|pi²|pc|sq\.?\s*?ft|ft2|ft²)",
142+ re.IGNORECASE)
143+_SQM_RE = re.compile(r"([\d\s ,.]+)\s*(m2|m²|mc)", re.IGNORECASE)
144+
145+
146+def parse_area_sqft(text: str) -> float | None:
147+ """Superficie habitable en pi² (convertit les m² au besoin)."""
148+ if not text:
149+ return None
150+ t = text.replace(" ", " ")
151+ m = _SQFT_RE.search(t)
152+ if m:
153+ v = parse_float(m.group(1))
154+ return round(v) if v and v > 50 else None
155+ m = _SQM_RE.search(t)
156+ if m:
157+ v = parse_float(m.group(1))
158+ return round(v * 10.7639) if v and v > 5 else None
159+ return None
160+
161+
162+def parse_lot_sqft(text: str) -> float | None:
163+ """Superficie de terrain en pi² — mêmes unités que parse_area_sqft."""
164+ return parse_area_sqft(text)
165+
166+
167+# « 1959 », « 2018 (Neuf) », « 1975, rénové »… mais JAMAIS « 20' X 34' irr. »
168+# ni « À construire » : la valeur doit COMMENCER par une année plausible.
169+_YEAR_RE = re.compile(r"^\s*(1[6-9]\d{2}|20[0-4]\d)\s*(?:$|[(,])")
170+
171+
172+def parse_year(text) -> int | None:
173+ """Année de construction plausible (1600-2049) depuis une valeur `details`.
174+
175+ Volontairement strict (année en tête de valeur, seule ou suivie d'une
176+ parenthèse/virgule) pour ne jamais promouvoir un libellé parasite vers la
177+ colonne year_built."""
178+ if text is None:
179+ return None
180+ if isinstance(text, (int, float)):
181+ y = int(text)
182+ return y if 1600 <= y <= 2049 else None
183+ m = _YEAR_RE.match(str(text))
184+ return int(m.group(1)) if m else None
185+
186+
187+# ---------------------------------------------------------------------------
188+# Type de propriété
189+# ---------------------------------------------------------------------------
190+
191+# House-Ka canonical vocabulary (frontend filters) — ENGLISH.
192+# Sources are CREA DDF sites (English labels), plus the odd French label.
193+_TYPE_MAP = [
194+ # (keywords in the normalized source text, canonical type)
195+ (("maison mobile", "unimodulaire", "mobile home", "manufactured home",
196+ "modular",), "Mobile home"),
197+ (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",),
198+ "Semi-detached"),
199+ (("maison de ville", "townhouse", "town house", "en rangee", "row house",
200+ "row / town",), "Townhouse"),
201+ (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse",
202+ "studio", "strata",), "Condo"),
203+ (("duplex",), "Duplex"),
204+ (("triplex",), "Triplex"),
205+ (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement",
206+ "immeuble a revenus", "revenus", "multi-family", "multi family",
207+ "multifamily", "fourplex",), "Multi-family"),
208+ (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",
209+ "recreational", "cabin",), "Cottage"),
210+ (("terre", "terrain", "lot ", "vacant land", "land",), "Land"),
211+ (("ferme", "fermette", "agricole", "agriculture", "hobby farm", "farm",
212+ "acreage",), "Farm"),
213+ (("plain-pied", "bungalow",), "House"),
214+ (("maison a etages", "a etage", "deux etages", "cottage",), "House"),
215+ (("unifamiliale", "maison", "house", "residence", "split", "detached",
216+ "single family", "single-family",), "House"),
217+ # enriched-sheet vocabulary: « 4 logements », « propriété à revenu »
218+ (("logements", "logement/", "unites et +", "revenu",), "Multi-family"),
219+ (("bi generation", "bi-generation", "bigeneration", "intergeneration",),
220+ "House"),
221+ (("domaine et villa", "villa", "domaine",), "House"),
222+ (("parking",), "Parking"),
223+ (("commercial", "commerce", "industriel", "industrie", "bureau", "local",
224+ "entreprise", "batisse", "restaurant", "depanneur", "hotel", "motel",
225+ "garage/", "concessionnaire", "coiffure", "esthetique", "camping",
226+ "retail", "office", "industrial", "warehouse", "business",
227+ "institutional",), "Commercial"),
228+]
229+
230+
231+def normalize_property_type(text: str) -> str:
232+ import html as _html
233+ key = strip_accents(_html.unescape(text or "").strip().lower())
234+ if not key:
235+ return ""
236+ for keywords, canon in _TYPE_MAP:
237+ if any(k in key for k in keywords):
238+ return canon
239+ return _html.unescape(text).strip().capitalize()
240+
241+
242+# ---------------------------------------------------------------------------
243+# Chambres / salles de bains depuis du texte libre
244+# ---------------------------------------------------------------------------
245+
246+_BED_RE = re.compile(r"(\d+)\s*(?:ch(?:ambre)?s?|cac|bed(?:room)?s?)\b",
247+ re.IGNORECASE)
248+_BATH_RE = re.compile(r"(\d+)\s*(?:sdb|salle?s?\s+de\s+bains?|bath(?:room)?s?)",
249+ re.IGNORECASE)
250+
251+
252+def extract_bedrooms_bathrooms(text: str) -> tuple[int | None, int | None]:
253+ if not text:
254+ return None, None
255+ beds = _BED_RE.search(text)
256+ baths = _BATH_RE.search(text)
257+ return (int(beds.group(1)) if beds else None,
258+ int(baths.group(1)) if baths else None)
added immoka/poi.py +248 −0
@@ -0,0 +1,248 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap)
5+# Pour chaque immeuble géolocalisé, une requête Overpass unique récupère
6+# les points d'intérêt utiles à un locataire (épicerie, pharmacie, école,
7+# garderie, parc, arrêt de bus, gym, clinique…) ; on retient le PLUS PROCHE
8+# de chaque catégorie avec sa distance. Cache permanent par coordonnées
9+# (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un
10+# même immeuble partagent la même entrée). Politesse : 1 requête/seconde.
11+# -----------------------------------------------------------------------------
12+from __future__ import annotations
13+
14+import json
15+import math
16+import time
17+
18+import requests
19+
20+from . import db
21+
22+# Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems
23+# tolère mieux les gros volumes que l'instance officielle
24+OVERPASS_URLS = [
25+ "https://overpass.kumi.systems/api/interpreter",
26+ "https://overpass-api.de/api/interpreter",
27+]
28+USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)"
29+REQUEST_DELAY = 2.0
30+REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois
31+
32+# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union
33+# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI
34+# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour
35+# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local.
36+TILE = 0.5
37+TILE_MARGIN = 0.04 # ~4 km > plus grand rayon de catégorie (3 km)
38+
39+# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m)
40+CATEGORIES: list[tuple[str, str, str, int]] = [
41+ ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500),
42+ ("depanneur", "Dépanneur", '["shop"="convenience"]', 800),
43+ ("pharmacie", "Pharmacie", '["amenity"="pharmacy"]', 1500),
44+ ("ecole", "École", '["amenity"="school"]', 1500),
45+ ("garderie", "Garderie", '["amenity"~"^(kindergarten|childcare)$"]', 1500),
46+ ("parc", "Parc", '["leisure"="park"]', 1200),
47+ ("bus", "Arrêt de bus", '["highway"="bus_stop"]', 600),
48+ ("metro", "Métro", '["railway"="station"]["station"="subway"]', 1500),
49+ ("gym", "Gym", '["leisure"="fitness_centre"]', 1500),
50+ ("cafe", "Café", '["amenity"="cafe"]', 1000),
51+ ("clinique", "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]', 2000),
52+ ("hopital", "Hôpital", '["amenity"="hospital"]', 3000),
53+ ("bibliotheque", "Bibliothèque", '["amenity"="library"]', 2000),
54+]
55+
56+LABELS = {cat: label for cat, label, _, _ in CATEGORIES}
57+
58+
59+def coord_key(lat: float, lng: float) -> str:
60+ return f"{round(lat, 4)},{round(lng, 4)}"
61+
62+
63+def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
64+ r = 6371000.0
65+ p1, p2 = math.radians(lat1), math.radians(lat2)
66+ dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
67+ a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
68+ return 2 * r * math.asin(math.sqrt(a))
69+
70+
71+def _tile_of(lat: float, lng: float) -> tuple[int, int]:
72+ return (math.floor(lat / TILE), math.floor(lng / TILE))
73+
74+
75+def _build_tile_query(ty: int, tx: int) -> str:
76+ """Tous les POI des catégories dans la tuile (bbox élargie de la marge)."""
77+ s = ty * TILE - TILE_MARGIN
78+ n = (ty + 1) * TILE + TILE_MARGIN
79+ w = tx * TILE - TILE_MARGIN
80+ e = (tx + 1) * TILE + TILE_MARGIN
81+ bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"
82+ parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES]
83+ return f'[out:json][timeout:180];({"".join(parts)});out center tags;'
84+
85+
86+def _match_category(tags: dict) -> str | None:
87+ """Retrouve la catégorie Lou-Ka d'un élément OSM retourné."""
88+ shop = tags.get("shop")
89+ amenity = tags.get("amenity")
90+ leisure = tags.get("leisure")
91+ if shop == "supermarket":
92+ return "epicerie"
93+ if shop == "convenience":
94+ return "depanneur"
95+ if amenity == "pharmacy":
96+ return "pharmacie"
97+ if amenity == "school":
98+ return "ecole"
99+ if amenity in ("kindergarten", "childcare"):
100+ return "garderie"
101+ if leisure == "park":
102+ return "parc"
103+ if tags.get("highway") == "bus_stop":
104+ return "bus"
105+ if tags.get("railway") == "station" and tags.get("station") == "subway":
106+ return "metro"
107+ if leisure == "fitness_centre":
108+ return "gym"
109+ if amenity == "cafe":
110+ return "cafe"
111+ if amenity in ("clinic", "doctors"):
112+ return "clinique"
113+ if amenity == "hospital":
114+ return "hopital"
115+ if amenity == "library":
116+ return "bibliotheque"
117+ return None
118+
119+
120+_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES}
121+
122+
123+class PoiClient:
124+ def __init__(self) -> None:
125+ self.session = requests.Session()
126+ self.session.headers["User-Agent"] = USER_AGENT
127+ self._last = 0.0
128+ self._url_idx = 0
129+
130+ def _post(self, query: str) -> list | None:
131+ """POST Overpass avec throttling et rotation de miroir sur erreur."""
132+ wait = REQUEST_DELAY - (time.time() - self._last)
133+ if wait > 0:
134+ time.sleep(wait)
135+ for essai in range(len(OVERPASS_URLS)):
136+ url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)]
137+ try:
138+ resp = self.session.post(url, data={"data": query}, timeout=90)
139+ self._last = time.time()
140+ resp.raise_for_status()
141+ self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS)
142+ return resp.json().get("elements") or []
143+ except Exception:
144+ self._last = time.time()
145+ continue
146+ return None
147+
148+ def fetch_tile(self, ty: int, tx: int) -> list[dict] | None:
149+ """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}]."""
150+ elements = self._post(_build_tile_query(ty, tx))
151+ if elements is None:
152+ return None
153+ pois = []
154+ for el in elements:
155+ tags = el.get("tags") or {}
156+ cat = _match_category(tags)
157+ if cat is None:
158+ continue
159+ elat = el.get("lat") or (el.get("center") or {}).get("lat")
160+ elng = el.get("lon") or (el.get("center") or {}).get("lon")
161+ if elat is None or elng is None:
162+ continue
163+ pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60],
164+ "lat": elat, "lng": elng})
165+ return pois
166+
167+
168+def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]:
169+ """Plus proche POI de chaque catégorie (dans son rayon), trié par distance."""
170+ out = []
171+ for cat, pois in pois_by_cat.items():
172+ radius = _RADII[cat]
173+ # préfiltre rectangulaire bon marché avant l'haversine
174+ dlat_max = radius / 111000.0
175+ dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat))))
176+ best = None
177+ for p in pois:
178+ if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max:
179+ continue
180+ d = _haversine_m(lat, lng, p["lat"], p["lng"])
181+ if d <= radius and (best is None or d < best["dist_m"]):
182+ best = {"cat": cat, "name": p["name"], "dist_m": round(d)}
183+ if best:
184+ out.append(best)
185+ return sorted(out, key=lambda p: p["dist_m"])
186+
187+
188+def run(limit: int | None = None) -> dict:
189+ """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas.
190+
191+ `limit` borne le nombre de requêtes Overpass de cette exécution
192+ (les entrées déjà en cache ne coûtent rien).
193+ """
194+ con = db.connect()
195+ client = PoiClient()
196+ rows = con.execute(
197+ """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings
198+ WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()
199+
200+ now = time.time()
201+ a_faire: list[tuple[float, float]] = []
202+ skipped = 0
203+ for r in rows:
204+ cached = con.execute(
205+ "SELECT ts FROM poi_cache WHERE coord_key=?",
206+ (f"{r['la']},{r['ln']}",)).fetchone()
207+ if cached and now - (cached["ts"] or 0) < REFRESH_AFTER:
208+ skipped += 1
209+ else:
210+ a_faire.append((r["la"], r["ln"]))
211+ if limit is not None:
212+ a_faire = a_faire[:limit]
213+
214+ # 1) télécharger les POI des tuiles nécessaires (une requête par tuile)
215+ tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire})
216+ pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {}
217+ tile_errors = []
218+ for t in tuiles:
219+ res = client.fetch_tile(*t)
220+ if res is None:
221+ tile_errors.append(t)
222+ else:
223+ by_cat: dict[str, list[dict]] = {}
224+ for p in res:
225+ by_cat.setdefault(p["cat"], []).append(p)
226+ pois_by_tile[t] = by_cat
227+
228+ # 2) calcul local du plus proche par catégorie pour chaque immeuble
229+ done = errors = 0
230+ for la, ln in a_faire:
231+ t = _tile_of(la, ln)
232+ if t not in pois_by_tile:
233+ errors += 1 # tuile en échec : re-tentée au prochain run
234+ continue
235+ pois = _nearest_by_cat(la, ln, pois_by_tile[t])
236+ con.execute(
237+ "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)"
238+ " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts",
239+ (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now))
240+ done += 1
241+ con.commit()
242+
243+ con.close()
244+ stats = {"fetched": done, "cached": skipped, "errors": errors,
245+ "tiles": len(tuiles), "tile_errors": len(tile_errors),
246+ "total_coords": len(rows)}
247+ print(f"[immo-ka] poi {stats}")
248+ return stats
added immoka/quality.py +254 −0
@@ -0,0 +1,254 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# quality.py : contrôle qualité des annonces — score de complétude, contrôles de
5+# cohérence immobiliers, seuil de publication (quarantaine sous le seuil).
6+#
7+# `refresh(con)` recalcule pour toutes les annonces actives :
8+# - quality_score : complétude 0-100 (pondération des champs décisifs)
9+# - quality_issues : JSON (anomalies détectées, dures ou informatives)
10+# - published : 1 = affichable sur le site, 0 = quarantaine
11+# et les champs dérivés dans details : prix_pi2 (prix/superficie) et
12+# transaction (vente | location, détectée du libellé de prix).
13+#
14+# Règle de publication : prix plausible + ville + type de bien + au moins une
15+# image + contenu exploitable (description ou caractéristiques). Une annonce
16+# sous le seuil reste en base (re-synchronisée/enrichie aux prochains cycles)
17+# mais n'est pas affichée — elle sort de quarantaine dès qu'elle est complétée.
18+# -----------------------------------------------------------------------------
19+from __future__ import annotations
20+
21+import json
22+import os
23+import re
24+import sqlite3
25+import time
26+
27+from .normalize import strip_accents
28+
29+# bornes de plausibilité (marché québécois)
30+PRICE_SALE_MIN, PRICE_SALE_MAX = 20_000, 80_000_000
31+PRICE_RENT_MIN, PRICE_RENT_MAX = 300, 25_000
32+AREA_MIN, AREA_MAX = 120, 25_000 # superficie habitable (pi²)
33+LOT_MAX = 200_000_000 # terrain (pi²) — grandes terres
34+YEAR_MIN = 1600
35+
36+_RENT_RE = re.compile(r"/\s*mois|par mois|/\s*mth|/\s*month|\bmensuel|\ba louer\b|"
37+ r"\blouer\b|\blocation\b|\blease\b|\bfor rent\b")
38+
39+
40+def _transaction(price, price_label: str, title: str) -> str:
41+ """vente | location — détectée du libellé (jamais confondre loyer et prix)."""
42+ key = strip_accents(f"{price_label} {title}".lower())
43+ if _RENT_RE.search(key):
44+ return "location"
45+ if price is not None and price < PRICE_RENT_MAX:
46+ # montant de loyer sans mot-clé : trop bas pour une vente au Québec
47+ return "location"
48+ return "vente"
49+
50+
51+def assess(row: dict) -> tuple[int, list[str], int, dict]:
52+ """(score 0-100, anomalies, publiable 0/1, champs dérivés) pour une annonce.
53+
54+ `row` : dict aux clés des colonnes listings (features/details/images
55+ peuvent être des chaînes JSON ou déjà décodés)."""
56+
57+ def _json(v, default):
58+ if isinstance(v, (list, dict)):
59+ return v
60+ try:
61+ return json.loads(v) if v else default
62+ except (TypeError, ValueError):
63+ return default
64+
65+ images = _json(row.get("images"), [])
66+ features = _json(row.get("features"), [])
67+ details = _json(row.get("details"), {})
68+ desc = (row.get("description") or "").strip()
69+ price = row.get("price")
70+ issues: list[str] = []
71+ derived: dict = {}
72+
73+ tx = _transaction(price, row.get("price_label") or "", row.get("title") or "")
74+ derived["transaction"] = tx
75+
76+ # -- cohérence : prix plausible pour le type de transaction ----------------
77+ price_ok = price is not None and price > 0
78+ if price_ok:
79+ lo, hi = ((PRICE_RENT_MIN, PRICE_RENT_MAX) if tx == "location"
80+ else (PRICE_SALE_MIN, PRICE_SALE_MAX))
81+ if not (lo <= price <= hi):
82+ issues.append(f"prix_hors_bornes:{price:.0f}$ ({tx})")
83+ price_ok = False
84+
85+ # -- cohérence : superficies plausibles -------------------------------------
86+ area = row.get("area_sqft")
87+ area_ok = area is not None and AREA_MIN <= area <= AREA_MAX
88+ if area is not None and not area_ok:
89+ issues.append(f"superficie_improbable:{area:.0f}pi2")
90+ lot = row.get("lot_sqft")
91+ if lot is not None and not (0 < lot <= LOT_MAX):
92+ issues.append(f"terrain_improbable:{lot:.0f}pi2")
93+
94+ # -- cohérence : pièces / chambres ------------------------------------------
95+ beds, baths = row.get("bedrooms"), row.get("bathrooms")
96+ if beds is not None and not (0 <= beds <= 30):
97+ issues.append(f"chambres_improbables:{beds}")
98+ beds = None
99+ if baths is not None and not (0 <= baths <= 20):
100+ issues.append(f"sdb_improbables:{baths}")
101+ baths = None
102+ ptype = (row.get("property_type") or "").strip()
103+ if beds and ptype == "Condo" and beds > 8:
104+ issues.append(f"chambres_vs_type:{beds}ch_condo")
105+
106+ # -- cohérence : année de construction ---------------------------------------
107+ year = row.get("year_built")
108+ current_year = time.gmtime().tm_year
109+ if year is not None and not (YEAR_MIN <= year <= current_year + 3):
110+ issues.append(f"annee_invalide:{year}")
111+
112+ # -- champ dérivé : prix au pi² (et m²) ---------------------------------------
113+ if price_ok and area_ok and tx == "vente":
114+ ppsf = price / area
115+ derived["prix_pi2"] = round(ppsf)
116+ derived["prix_m2"] = round(ppsf * 10.7639)
117+ if not (30 <= ppsf <= 3500):
118+ issues.append(f"prix_pi2_extreme:{ppsf:.0f}")
119+
120+ # -- score de complétude (0-100) ----------------------------------------------
121+ n_img = len(images)
122+ has_contact = bool(row.get("broker_name") or row.get("broker_phone"))
123+ score = 0
124+ score += 15 if price_ok else 0
125+ score += 8 if (row.get("city") or "").strip() else 0
126+ score += 7 if (row.get("address") or "").strip() else 0
127+ score += 8 if ptype else 0
128+ score += 10 if n_img >= 1 else 0
129+ score += 5 if n_img >= 8 else 0
130+ score += 12 if len(desc) >= 300 else 8 if len(desc) >= 80 else 4 if desc else 0
131+ score += 6 if beds is not None else 0
132+ score += 5 if baths is not None else 0
133+ score += 8 if area_ok else 0
134+ score += 4 if year is not None else 0
135+ score += 7 if row.get("lat") is not None else 0
136+ score += 3 if has_contact else 0
137+ score += 2 if lot is not None else 0
138+
139+ # -- seuil de publication ---------------------------------------------------
140+ # (une annonce SANS image reste publiable : le frontend applique l'image de
141+ # secours par type de bien ; le drapeau sans_image la marque à re-vérifier)
142+ if n_img < 1:
143+ issues.append("sans_image")
144+ # vendue/louée à la source : archivée (plus jamais affichée en résultats)
145+ statut = (row.get("status") or "").strip().lower()
146+ vendue = statut in ("vendu", "vendue", "loue", "louee", "loué", "louée",
147+ "sold", "rented", "retire", "retiré")
148+ if vendue:
149+ issues.append(f"statut:{statut}")
150+ # House-Ka : les cartes DDF (liste) n'ont ni description ni type — la
151+ # fiche s'enrichit au fil des passes de détail. Publication dès que le
152+ # prix est plausible et la ville connue ; type/description comptent dans
153+ # le score seulement.
154+ publishable = (
155+ not vendue
156+ and price_ok
157+ and bool((row.get("city") or "").strip())
158+ )
159+ if not publishable:
160+ why = []
161+ if not price_ok:
162+ why.append("prix")
163+ if not (row.get("city") or "").strip():
164+ why.append("ville")
165+ issues.append("quarantaine:" + "+".join(why))
166+
167+ return score, issues, int(publishable), derived
168+
169+
170+def refresh(con: sqlite3.Connection, sources: list[str] | None = None) -> dict:
171+ """Recalcule score/anomalies/publication pour les annonces actives.
172+
173+ Appelé après chaque synchronisation (ingest.run) — quelques secondes pour
174+ ~80 k lignes. Retourne un résumé {actives, publiees, quarantaine}."""
175+ _ensure_columns(con)
176+ sql = ("SELECT uid, source, title, price, price_label, city, address,"
177+ " property_type,"
178+ " bedrooms, bathrooms, area_sqft, lot_sqft, year_built, lat, status,"
179+ " broker_name, broker_phone, description, features, details, images,"
180+ " quality_score, quality_issues, published"
181+ " FROM listings WHERE active=1")
182+ args: list = []
183+ if sources:
184+ sql += f" AND source IN ({','.join('?' * len(sources))})"
185+ args = list(sources)
186+ updates = []
187+ n = pub = 0
188+ for r in con.execute(sql, args):
189+ row = dict(r)
190+ score, issues, publishable, derived = assess(row)
191+ n += 1
192+ pub += publishable
193+ details = {}
194+ try:
195+ details = json.loads(row.get("details") or "{}")
196+ except ValueError:
197+ pass
198+ changed_details = any(details.get(k) != v for k, v in derived.items())
199+ issues_json = json.dumps(issues, ensure_ascii=False) if issues else None
200+ if (score != row.get("quality_score") or publishable != row.get("published")
201+ or issues_json != row.get("quality_issues") or changed_details):
202+ details.update(derived)
203+ updates.append((score, issues_json, publishable,
204+ json.dumps(details, ensure_ascii=False), row["uid"]))
205+ if updates:
206+ con.executemany(
207+ "UPDATE listings SET quality_score=?, quality_issues=?, published=?,"
208+ " details=? WHERE uid=?", updates)
209+ con.commit()
210+ return {"actives": n, "publiees": pub, "quarantaine": n - pub,
211+ "recalculees": len(updates)}
212+
213+
214+def _ensure_columns(con: sqlite3.Connection) -> None:
215+ cols = {r["name"] for r in con.execute("PRAGMA table_info(listings)")}
216+ if "quality_score" not in cols:
217+ con.execute("ALTER TABLE listings ADD COLUMN quality_score INTEGER")
218+ if "quality_issues" not in cols:
219+ con.execute("ALTER TABLE listings ADD COLUMN quality_issues TEXT")
220+ if "published" not in cols:
221+ # 1 par défaut : la 1re passe refresh() met la vraie valeur partout
222+ con.execute("ALTER TABLE listings ADD COLUMN published INTEGER DEFAULT 1")
223+ con.execute("CREATE INDEX IF NOT EXISTS idx_listings_published"
224+ " ON listings(published)")
225+ con.commit()
226+
227+
228+def summary(con: sqlite3.Connection) -> dict:
229+ """Statistiques qualité pour /api/stats : complétude, quarantaine, anomalies."""
230+ _ensure_columns(con)
231+ row = con.execute(
232+ "SELECT COUNT(*) actives, SUM(published) publiees,"
233+ " ROUND(AVG(quality_score),1) completude_moyenne"
234+ " FROM listings WHERE active=1 AND dup_hidden=0").fetchone()
235+ per_source = [dict(r) for r in con.execute(
236+ "SELECT source, COUNT(*) n, SUM(published) publiees,"
237+ " ROUND(AVG(quality_score),1) completude,"
238+ " SUM(CASE WHEN quality_issues IS NOT NULL THEN 1 ELSE 0 END) anomalies"
239+ " FROM listings WHERE active=1 AND dup_hidden=0"
240+ " GROUP BY source ORDER BY n DESC")]
241+ anomalies: dict[str, int] = {}
242+ for r in con.execute(
243+ "SELECT quality_issues FROM listings WHERE active=1 AND dup_hidden=0"
244+ " AND quality_issues IS NOT NULL"):
245+ try:
246+ for issue in json.loads(r["quality_issues"]):
247+ anomalies[issue.split(":")[0]] = anomalies.get(issue.split(":")[0], 0) + 1
248+ except ValueError:
249+ continue
250+ d = dict(row)
251+ d["quarantaine"] = (d.get("actives") or 0) - (d.get("publiees") or 0)
252+ d["anomalies"] = dict(sorted(anomalies.items(), key=lambda kv: -kv[1]))
253+ d["par_source"] = per_source
254+ return d
added immoka/quartier.py +229 −0
@@ -0,0 +1,229 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# quartier.py : statistiques de quartier par annonce (à la Centris, en libre)
5+# Base statique data/quartier.db construite par scripts/build_*.py :
6+# - da_poly / da_stats : aires de diffusion 2021 + profil du recensement
7+# - da_pmd : mesures de proximité StatCan (scores 0..1)
8+# - da_defav : défavorisation matérielle/sociale INSPQ (quintiles)
9+# - heat : classe d'îlot de chaleur/fraîcheur INSPQ par immeuble
10+# - crime_mtl / igc : actes criminels SPVM (points) + indice de gravité
11+# Jointure : lat/lng -> DAUID par point-dans-polygone local (préfiltre bbox),
12+# mémorisée dans listings.dauid à l'enrichissement (boucle watch).
13+# -----------------------------------------------------------------------------
14+from __future__ import annotations
15+
16+import json
17+import math
18+import sqlite3
19+import time
20+from pathlib import Path
21+
22+from . import db
23+
24+QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db"
25+
26+# villes couvertes par les points SPVM (agglomération de Montréal)
27+_VILLES_SPVM = {"montreal", "montreal-est", "montreal-ouest", "westmount",
28+ "cote saint-luc", "cote-saint-luc", "hampstead", "mont-royal",
29+ "outremont", "verdun", "lasalle", "lachine", "anjou",
30+ "saint-leonard", "saint-laurent", "ahuntsic", "dorval",
31+ "pointe-claire", "kirkland", "beaconsfield", "dollard-des-ormeaux"}
32+
33+# correspondance ville -> fragment du nom de service dans la table igc
34+_IGC_SERVICE = {
35+ "quebec": "SPVQ", "levis": "Lévis", "montreal": "SPVM",
36+ "laval": "Laval", "longueuil": "Longueuil",
37+}
38+
39+
40+def disponible() -> bool:
41+ return QUARTIER_DB.exists()
42+
43+
44+def _connect() -> sqlite3.Connection:
45+ con = sqlite3.connect(f"file:{QUARTIER_DB}?mode=ro", uri=True)
46+ con.row_factory = sqlite3.Row
47+ return con
48+
49+
50+# ---------------------------------------------------------------------------
51+# lat/lng -> DAUID (point dans polygone, préfiltre bbox)
52+# ---------------------------------------------------------------------------
53+
54+def _dans_anneau(lat: float, lng: float, anneau: list) -> bool:
55+ """Lancer de rayon (even-odd). anneau = [[lng, lat], ...]."""
56+ dedans = False
57+ n = len(anneau)
58+ j = n - 1
59+ for i in range(n):
60+ xi, yi = anneau[i][0], anneau[i][1]
61+ xj, yj = anneau[j][0], anneau[j][1]
62+ if (yi > lat) != (yj > lat) and \
63+ lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi:
64+ dedans = not dedans
65+ j = i
66+ return dedans
67+
68+
69+def dauid_for(qcon: sqlite3.Connection, lat: float, lng: float) -> str | None:
70+ rows = qcon.execute(
71+ "SELECT dauid, poly FROM da_poly WHERE lat_min<=? AND lat_max>=?"
72+ " AND lng_min<=? AND lng_max>=?", (lat, lat, lng, lng)).fetchall()
73+ for r in rows:
74+ anneaux = json.loads(r["poly"])
75+ # even-odd sur tous les anneaux (les trous annulent)
76+ compte = sum(1 for a in anneaux if _dans_anneau(lat, lng, a))
77+ if compte % 2 == 1:
78+ return r["dauid"]
79+ return None
80+
81+
82+# ---------------------------------------------------------------------------
83+# Assemblage pour la fiche
84+# ---------------------------------------------------------------------------
85+
86+def _cle_ville(city: str) -> str:
87+ import unicodedata
88+ s = "".join(c for c in unicodedata.normalize("NFD", city or "")
89+ if unicodedata.category(c) != "Mn")
90+ return s.strip().lower()
91+
92+
93+def _crime_mtl(qcon: sqlite3.Connection, lat: float, lng: float) -> dict | None:
94+ """Comptage des actes criminels SPVM à < 500 m : 12 mois vs 12 précédents."""
95+ dlat = 500 / 111000.0
96+ dlng = 500 / (111000.0 * max(0.2, math.cos(math.radians(lat))))
97+ now = time.time()
98+ rows = qcon.execute(
99+ "SELECT lat, lng, ts, categorie FROM crime_mtl WHERE lat BETWEEN ? AND ?"
100+ " AND lng BETWEEN ? AND ? AND ts >= ?",
101+ (lat - dlat, lat + dlat, lng - dlng, lng + dlng, now - 730 * 86400)).fetchall()
102+ recent = avant = 0
103+ cats: dict[str, list[int]] = {} # categorie -> [12 mois, 12 prec.]
104+ for r in rows:
105+ # distance exacte (le bbox est un carré)
106+ d = math.hypot((r["lat"] - lat) * 111000.0,
107+ (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat)))
108+ if d > 500:
109+ continue
110+ c = cats.setdefault(r["categorie"] or "Autre", [0, 0])
111+ if r["ts"] >= now - 365 * 86400:
112+ recent += 1
113+ c[0] += 1
114+ else:
115+ avant += 1
116+ c[1] += 1
117+ if recent == 0 and avant == 0:
118+ return None
119+ categories = [{"nom": k, "n": v[0], "n_prec": v[1]}
120+ for k, v in sorted(cats.items(),
121+ key=lambda kv: -(kv[1][0] + kv[1][1]))]
122+ return {"type": "points", "rayon_m": 500, "douze_mois": recent,
123+ "douze_mois_precedents": avant, "categories": categories}
124+
125+
126+def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None:
127+ service = _IGC_SERVICE.get(_cle_ville(city))
128+ if not service:
129+ return None
130+ row = qcon.execute(
131+ "SELECT annee, indice FROM igc WHERE service LIKE '%' || ? || '%'"
132+ " ORDER BY annee DESC LIMIT 1", (service,)).fetchone()
133+ if row is None or row["indice"] is None:
134+ return None
135+ ref = qcon.execute(
136+ "SELECT indice FROM igc WHERE service LIKE '%canada%' AND annee=?",
137+ (row["annee"],)).fetchone()
138+ return {"type": "igc", "ville": city, "annee": row["annee"],
139+ "indice": round(row["indice"], 1),
140+ "indice_canada": round(ref["indice"], 1) if ref and ref["indice"] else None}
141+
142+
143+def fiche_quartier(lat: float | None, lng: float | None, city: str,
144+ dauid: str | None = None) -> dict | None:
145+ """Bloc « Le quartier » d'une fiche. None si données indisponibles."""
146+ if not disponible() or lat is None or lng is None:
147+ return None
148+ qcon = _connect()
149+ try:
150+ if not dauid:
151+ dauid = dauid_for(qcon, lat, lng)
152+ out: dict = {"dauid": dauid}
153+
154+ if dauid:
155+ r = qcon.execute("SELECT * FROM da_stats WHERE dauid=?", (dauid,)).fetchone()
156+ if r:
157+ out["demographie"] = {k: r[k] for k in
158+ ("population", "densite", "age_median",
159+ "revenu_median", "pct_locataires",
160+ "loyer_moyen", "pct_francais", "pct_univ")}
161+ # rangs centiles québécois (0-100) — voir scripts/merge_quartier.py
162+ r = qcon.execute("SELECT * FROM da_pmd_pct WHERE dauid=?", (dauid,)).fetchone()
163+ if r:
164+ out["proximite"] = {k: r[k] / 100.0 for k in r.keys()
165+ if k != "dauid" and r[k] is not None}
166+ r = qcon.execute("SELECT quintile_materiel, quintile_social FROM da_defav"
167+ " WHERE dauid=?", (dauid,)).fetchone()
168+ if r:
169+ out["defavorisation"] = dict(r)
170+
171+ # îlot de chaleur : coordonnée exacte, sinon la plus proche (~120 m)
172+ key = f"{round(lat, 4)},{round(lng, 4)}"
173+ r = qcon.execute("SELECT classe, ecart FROM heat WHERE coord_key=?",
174+ (key,)).fetchone()
175+ if r is None:
176+ r = qcon.execute(
177+ "SELECT classe, ecart FROM heat WHERE coord_key LIKE ?"
178+ " AND classe IS NOT NULL LIMIT 1",
179+ (f"{round(lat, 3)}%",)).fetchone()
180+ if r and r["classe"] is not None:
181+ out["chaleur"] = {"classe": r["classe"], "ecart": r["ecart"]}
182+
183+ # criminalité : points SPVM sur l'île, indice IGC ailleurs
184+ crime = None
185+ if _cle_ville(city) in _VILLES_SPVM:
186+ crime = _crime_mtl(qcon, lat, lng)
187+ if crime is None:
188+ crime = _crime_igc(qcon, city)
189+ if crime:
190+ out["crime"] = crime
191+
192+ return out if len(out) > 1 else None
193+ except sqlite3.Error:
194+ return None
195+ finally:
196+ qcon.close()
197+
198+
199+# ---------------------------------------------------------------------------
200+# Enrichissement : mémoriser le DAUID de chaque annonce (boucle watch)
201+# ---------------------------------------------------------------------------
202+
203+def enrich(limit: int | None = None) -> dict:
204+ """Remplit listings.dauid pour les annonces géolocalisées qui ne l'ont pas."""
205+ if not disponible():
206+ print("[immo-ka] quartier: data/quartier.db absent — étape sautée")
207+ return {"enriched": 0, "missing_db": True}
208+ con = db.connect()
209+ qcon = _connect()
210+ rows = con.execute(
211+ "SELECT uid, lat, lng FROM listings WHERE active=1 AND lat IS NOT NULL"
212+ " AND (dauid IS NULL OR dauid='')").fetchall()
213+ if limit is not None:
214+ rows = rows[:limit]
215+ done = introuvable = 0
216+ for r in rows:
217+ d = dauid_for(qcon, r["lat"], r["lng"])
218+ con.execute("UPDATE listings SET dauid=? WHERE uid=?",
219+ (d or "hors-zone", r["uid"]))
220+ if d:
221+ done += 1
222+ else:
223+ introuvable += 1
224+ con.commit()
225+ qcon.close()
226+ con.close()
227+ stats = {"enriched": done, "hors_zone": introuvable, "candidats": len(rows)}
228+ print(f"[immo-ka] quartier {stats}")
229+ return stats
added immoka/schema.py +153 −0
@@ -0,0 +1,153 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# schema.py : modèle de données standardisé (PropertyListing)
5+# -----------------------------------------------------------------------------
6+"""Schéma standard d'une propriété à vendre et normalisation des champs.
7+
8+Chaque connecteur, peu importe l'agence source (RE/MAX, Sutton, Via Capitale…),
9+doit produire des objets `PropertyListing` conformes à ce schéma. La méthode
10+`finalize()` applique ensuite la couche de normalisation commune
11+(immoka/normalize.py) : prix, type de propriété canonique, superficies en pi²,
12+chambres/salles de bains… — les connecteurs restent simples et remplissent
13+les champs bruts.
14+"""
15+from __future__ import annotations
16+
17+import hashlib
18+import json
19+from dataclasses import dataclass, field, asdict
20+
21+from .normalize import (
22+ clean_address,
23+ clean_description,
24+ clean_title,
25+ extract_bedrooms_bathrooms,
26+ normalize_property_type,
27+ parse_area_sqft,
28+ parse_int,
29+ parse_lot_sqft,
30+ parse_price,
31+ parse_year,
32+ price_is_from,
33+ strip_accents,
34+)
35+
36+__all__ = ["PropertyListing"]
37+
38+
39+@dataclass
40+class PropertyListing:
41+ """Propriété à vendre standardisée Immo-Ka."""
42+
43+ source: str # id de l'agence (voir data/sources.json)
44+ external_id: str # identifiant chez la source (souvent le n° Centris/MLS)
45+ url: str # page de la propriété chez la source
46+ title: str = "" # ex. "Maison à étages à vendre — Lévis"
47+ address: str = "" # adresse civique
48+ sector: str = "" # quartier/arrondissement
49+ city: str = "" # Québec, Lévis, Montréal…
50+ region: str = "" # région administrative (Capitale-Nationale…)
51+ property_type: str = "" # Maison, Condo, Duplex, Terrain… (canonique)
52+ price: float | None = None # prix demandé ($ CAD)
53+ price_label: str = "" # texte original (ex. "459 000 $ +tx")
54+ bedrooms: int | None = None # chambres
55+ bathrooms: int | None = None # salles de bains
56+ powder_rooms: int | None = None # salles d'eau
57+ area_sqft: float | None = None # superficie habitable (pi²)
58+ lot_sqft: float | None = None # superficie du terrain (pi²)
59+ year_built: int | None = None
60+ mls: str = "" # numéro Centris/MLS si affiché par la source
61+ status: str = "a-vendre" # a-vendre | vendu | conditionnel
62+ broker_name: str = "" # courtier inscripteur
63+ broker_phone: str = ""
64+ agency: str = "" # sous-agence / bureau (ex. « Royal LePage Altitude »,
65+ # « Groupe Sutton - Synergie ») — affichage des
66+ # Sources par sous-agence
67+ description: str = ""
68+ features: list[str] = field(default_factory=list) # caractéristiques (texte source)
69+ details: dict = field(default_factory=dict) # champs structurés (JSON)
70+ images: list[str] = field(default_factory=list) # URLs absolues
71+ lat: float | None = None
72+ lng: float | None = None
73+
74+ @property
75+ def uid(self) -> str:
76+ return f"{self.source}:{self.external_id}"
77+
78+ def content_hash(self) -> str:
79+ """Hash du contenu pour la détection de changements (pseudo-webhook)."""
80+ payload = asdict(self)
81+ blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
82+ return hashlib.sha256(blob.encode("utf-8")).hexdigest()
83+
84+ def finalize(self) -> "PropertyListing":
85+ """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
86+
87+ Idempotent ; ne remplace jamais une valeur explicite du connecteur.
88+ """
89+ self.title = clean_title(self.title)
90+ self.address = clean_address(self.address)
91+ self.city = (self.city or "").strip()
92+ self.property_type = normalize_property_type(self.property_type)
93+ self.description = clean_description(self.description)
94+
95+ # galerie : URLs valides seulement, placeholders retirés, doublons
96+ # (même photo en deux tailles) dédupliqués — voir immoka/imgaudit.py
97+ from .imgaudit import clean_gallery
98+ self.images = clean_gallery(self.images)
99+
100+ if self.price is None:
101+ self.price = parse_price(self.price_label)
102+ if self.price_label and price_is_from(self.price_label):
103+ self.details.setdefault("price_from", True)
104+
105+ # promotion details -> colonnes : les champs structurés de la fiche
106+ # (tableau DDF/Centris) priment sur l'extraction de texte libre ci-dessous
107+ if not self.property_type:
108+ for k in ("Building Type", "Property Type", "Type",
109+ "Type de propriété", "Genre de propriété"):
110+ if self.details.get(k):
111+ self.property_type = normalize_property_type(str(self.details[k]))
112+ break
113+ if self.year_built is None:
114+ for k in ("Constructed Date", "Année de construction"):
115+ if self.details.get(k):
116+ self.year_built = parse_year(self.details[k])
117+ break
118+ if self.area_sqft is None:
119+ for k in ("Size Interior", "Superficie habitable"):
120+ if self.details.get(k):
121+ self.area_sqft = parse_area_sqft(str(self.details[k]))
122+ break
123+ if self.lot_sqft is None:
124+ for k in ("Land Size", "Superficie du terrain"):
125+ if self.details.get(k):
126+ self.lot_sqft = parse_lot_sqft(str(self.details[k]))
127+ break
128+
129+ texte = " ".join(filter(None, (self.title, self.description,
130+ " ".join(self.features))))
131+ if self.bedrooms is None or self.bathrooms is None:
132+ beds, baths = extract_bedrooms_bathrooms(texte)
133+ if self.bedrooms is None:
134+ self.bedrooms = beds
135+ if self.bathrooms is None:
136+ self.bathrooms = baths
137+ if self.area_sqft is None:
138+ self.area_sqft = parse_area_sqft(texte)
139+ if self.lot_sqft is None and "terrain" in strip_accents(texte.lower()):
140+ self.lot_sqft = parse_lot_sqft(texte)
141+ self.year_built = parse_int(self.year_built)
142+
143+ # sous-agence : à défaut, on retombe sur le courtier/agence inscripteur
144+ if not self.agency:
145+ self.agency = self.broker_name
146+
147+ # coordonnées fournies par la source : rejeter tout point hors du
148+ # territoire couvert — le CANADA au complet (lat/lng inversés, 0/0, coquilles)
149+ if self.lat is not None and self.lng is not None:
150+ if not (41.6 <= self.lat <= 83.2 and -141.1 <= self.lng <= -52.5):
151+ self.lat = self.lng = None
152+
153+ return self
added immoka/seo.py +823 −0
@@ -0,0 +1,823 @@
1+# -----------------------------------------------------------------------------
2+# House-Ka — Homes-for-sale aggregator (Canada outside Québec, Ontario first)
3+# Author: Simon-Pierre Boucher — contact@spboucher.ai
4+# seo.py : server-side HTML rendering for search engines.
5+#
6+# The React SPA is untouched: this module pre-fills the initial HTML served by
7+# the web.py catch-all — unique <title>/meta/canonical/og:, schema.org JSON-LD,
8+# content and internal links inside <div id="root"> (replaced by React on
9+# mount). Also generates robots.txt and the sitemaps.
10+#
11+# Pages served:
12+# / enriched home (stats + city/type links)
13+# /property/{uid}[/{slug}] listing page (301 to the canonical slug,
14+# 410 if withdrawn, 404 if unknown)
15+# /for-sale/{city}[/{type}] programmatic city pages (+ type)
16+# /type/{type} per-property-type page (Canada-wide)
17+# /stats /agencies /terms /privacy /account /rates /contact dedicated meta
18+# /robots.txt /sitemap.xml /sitemaps/*.xml
19+# -----------------------------------------------------------------------------
20+from __future__ import annotations
21+
22+import html
23+import json
24+import os
25+import re
26+import time
27+import unicodedata
28+from datetime import datetime, timezone
29+from pathlib import Path
30+from urllib.parse import quote
31+
32+from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
33+
34+from . import db
35+
36+ROOT = Path(__file__).resolve().parent.parent
37+FRONTEND_DIST = ROOT / "frontend" / "dist"
38+FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"
39+
40+BASE_URL = os.environ.get("IMMOKA_BASE_URL", "https://www.house-ka.com").rstrip("/")
41+SITE_NAME = "House-Ka"
42+
43+# same visibility rule as /api/listings (DDF dedup + displayable price)
44+VISIBLE = "active=1 AND dup_hidden=0 AND published=1"
45+MIN_LISTINGS = 3 # quality floor: no near-empty city/type pages
46+PAGE_SIZE = 48 # listings per page on programmatic pages
47+
48+# province name (region column) -> two-letter code for schema.org
49+_PROVINCE_CODE = {
50+ "ontario": "ON", "british columbia": "BC", "alberta": "AB",
51+ "saskatchewan": "SK", "manitoba": "MB", "new brunswick": "NB",
52+ "nova scotia": "NS", "prince edward island": "PE",
53+ "newfoundland and labrador": "NL", "yukon": "YT",
54+ "northwest territories": "NT", "nunavut": "NU", "quebec": "QC",
55+}
56+
57+# -----------------------------------------------------------------------------
58+# Utilities
59+# -----------------------------------------------------------------------------
60+
61+def slugify(s: str) -> str:
62+ """URL slug — SAME algorithm as slugify() in frontend/src/api.ts."""
63+ s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
64+ s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
65+ return s[:80].strip("-")
66+
67+
68+def listing_slug(row) -> str:
69+ """Listing slug — SAME logic as listingPath() in frontend/src/api.ts."""
70+ base = slugify(row["address"] or row["title"] or "")
71+ city = slugify(row["city"] or "")
72+ if city and city not in base:
73+ base = slugify(f"{base} {city}") if base else city
74+ return base
75+
76+
77+def _esc(s) -> str:
78+ return html.escape(str(s or ""), quote=True)
79+
80+
81+def _fmt_n(n) -> str:
82+ return f"{int(n):,}"
83+
84+
85+def _fmt_price(p) -> str:
86+ return f"${_fmt_n(round(p))}" if p is not None else "Price on request"
87+
88+
89+def _iso_date(ts) -> str:
90+ try:
91+ return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")
92+ except (TypeError, ValueError):
93+ return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
94+
95+
96+# small in-memory TTL cache (data changes at watch pace, ~1 h)
97+_cache: dict[str, tuple[float, object]] = {}
98+
99+
100+def _cached(key: str, ttl: float, build):
101+ now = time.time()
102+ hit = _cache.get(key)
103+ if hit and now - hit[0] < ttl:
104+ return hit[1]
105+ val = build()
106+ _cache[key] = (now, val)
107+ return val
108+
109+
110+# -----------------------------------------------------------------------------
111+# Slug registries (cities, types) — rebuilt every 15 min
112+# -----------------------------------------------------------------------------
113+
114+def _build_registry() -> dict:
115+ con = db.connect()
116+ cities: dict[str, dict] = {}
117+ for r in con.execute(
118+ f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"
119+ " AND city<>'' GROUP BY city"):
120+ slug = slugify(r["city"])
121+ if len(slug) < 2:
122+ continue
123+ e = cities.setdefault(slug, {"label": r["city"], "n": 0, "values": [], "best": 0})
124+ e["n"] += r["n"]
125+ e["values"].append(r["city"])
126+ if r["n"] > e["best"]:
127+ e["best"] = r["n"]; e["label"] = r["city"]
128+ types: dict[str, dict] = {}
129+ for r in con.execute(
130+ f"SELECT property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"
131+ " AND property_type<>'' GROUP BY property_type"):
132+ slug = slugify(r["property_type"])
133+ if len(slug) < 2:
134+ continue
135+ e = types.setdefault(slug, {"label": r["property_type"], "n": 0, "values": [], "best": 0})
136+ e["n"] += r["n"]
137+ e["values"].append(r["property_type"])
138+ if r["n"] > e["best"]:
139+ e["best"] = r["n"]; e["label"] = r["property_type"]
140+ city_types: dict[tuple[str, str], int] = {}
141+ for r in con.execute(
142+ f"SELECT city, property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"
143+ " AND city<>'' AND property_type<>'' GROUP BY city, property_type"):
144+ cs, ts_ = slugify(r["city"]), slugify(r["property_type"])
145+ if len(cs) < 2 or len(ts_) < 2:
146+ continue
147+ city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]
148+ con.close()
149+ return {"cities": cities, "types": types, "city_types": city_types}
150+
151+
152+def registry() -> dict:
153+ return _cached("registry", 900, _build_registry)
154+
155+
156+# -----------------------------------------------------------------------------
157+# Template: dist/index.html, stripped of its static <title>/description
158+# -----------------------------------------------------------------------------
159+
160+_tpl_cache: tuple[float, str] | None = None
161+
162+
163+def _template() -> str:
164+ global _tpl_cache
165+ path = FRONTEND_DIR / "index.html"
166+ mtime = path.stat().st_mtime
167+ if _tpl_cache and _tpl_cache[0] == mtime:
168+ return _tpl_cache[1]
169+ tpl = path.read_text(encoding="utf-8")
170+ tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, flags=re.S)
171+ tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl)
172+ tpl = re.sub(r'<meta (?:property="og:|name="twitter:)[^>]*>\s*', "", tpl)
173+ _tpl_cache = (mtime, tpl)
174+ return tpl
175+
176+
177+def _page(title: str, description: str, canonical: str, body: str,
178+ jsonld: list[dict] | None = None, og_image: str | None = None,
179+ og_type: str = "website", noindex: bool = False,
180+ status: int = 200) -> HTMLResponse:
181+ head = [
182+ f"<title>{_esc(title)}</title>",
183+ f'<meta name="description" content="{_esc(description)}" />',
184+ f'<link rel="canonical" href="{_esc(canonical)}" />',
185+ f'<meta property="og:site_name" content="{SITE_NAME}" />',
186+ '<meta property="og:locale" content="en_CA" />',
187+ f'<meta property="og:type" content="{og_type}" />',
188+ f'<meta property="og:title" content="{_esc(title)}" />',
189+ f'<meta property="og:description" content="{_esc(description)}" />',
190+ f'<meta property="og:url" content="{_esc(canonical)}" />',
191+ ]
192+ if og_image:
193+ head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')
194+ else:
195+ og_image = BASE_URL + "/og.png"
196+ head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')
197+ head.append('<meta property="og:image:width" content="1200" />')
198+ head.append('<meta property="og:image:height" content="630" />')
199+ head.append('<meta name="twitter:card" content="summary_large_image" />')
200+ head.append(f'<meta name="twitter:image" content="{_esc(og_image)}" />')
201+ if noindex:
202+ head.append('<meta name="robots" content="noindex" />')
203+ for obj in (jsonld or []):
204+ head.append('<script type="application/ld+json">'
205+ + json.dumps(obj, ensure_ascii=False) + "</script>")
206+ tpl = _template()
207+ out = tpl.replace("</head>", " " + "\n ".join(head) + "\n </head>", 1)
208+ out = out.replace('<div id="root"></div>',
209+ f'<div id="root"><div class="container seo-ssr">{body}</div></div>', 1)
210+ return HTMLResponse(out, status_code=status)
211+
212+
213+# -----------------------------------------------------------------------------
214+# Reusable HTML blocks
215+# -----------------------------------------------------------------------------
216+
217+def fiche_href(uid: str, slug: str = "") -> str:
218+ path = f"/property/{quote(uid, safe='')}"
219+ return path + (f"/{slug}" if slug else "")
220+
221+
222+def _item_li(r) -> str:
223+ href = fiche_href(r["uid"], listing_slug(r))
224+ bits = [b for b in [
225+ r["property_type"],
226+ f"{r['bedrooms']} bed" if r["bedrooms"] is not None else "",
227+ f"{r['bathrooms']} bath" if r["bathrooms"] is not None else "",
228+ f"{_fmt_n(round(r['area_sqft']))} sq ft" if r["area_sqft"] else "",
229+ ] if b]
230+ name = r["address"] or r["title"] or "Property"
231+ loc = ", ".join(x for x in [r["sector"], r["city"]] if x)
232+ return (f'<li><a href="{href}"><strong>{_esc(name)}</strong></a> — '
233+ f'{_esc(_fmt_price(r["price"]))}'
234+ + (f' · {_esc(" · ".join(bits))}' if bits else "")
235+ + (f' · {_esc(loc)}' if loc else "") + "</li>")
236+
237+
238+def _agg_stats(con, cities: list[str] | None = None,
239+ types_values: list[str] | None = None) -> dict:
240+ where = VISIBLE
241+ args: list = []
242+ if cities:
243+ where += f" AND city IN ({','.join('?' * len(cities))})"
244+ args += cities
245+ if types_values:
246+ where += f" AND property_type IN ({','.join('?' * len(types_values))})"
247+ args += types_values
248+ row = con.execute(
249+ f"SELECT COUNT(*) n, AVG(price) avg_p, MIN(price) min_p, MAX(price) max_p"
250+ f" FROM listings WHERE {where}", args).fetchone()
251+ med = None
252+ if row["n"]:
253+ med_row = con.execute(
254+ f"SELECT price FROM listings WHERE {where}"
255+ f" ORDER BY price LIMIT 1 OFFSET ?", args + [row["n"] // 2]).fetchone()
256+ med = med_row["price"] if med_row else None
257+ return {"n": row["n"], "avg": row["avg_p"], "med": med,
258+ "min": row["min_p"], "max": row["max_p"], "where": where, "args": args}
259+
260+
261+def _pagination_html(base_path: str, page: int, pages: int) -> str:
262+ if pages <= 1:
263+ return ""
264+ out = ['<nav class="seo-pages" aria-label="Pagination">']
265+ if page > 1:
266+ prev = base_path if page == 2 else f"{base_path}?page={page - 1}"
267+ out.append(f'<a rel="prev" href="{prev}">← Previous page</a> ')
268+ out.append(f"<span>Page {page} of {pages}</span>")
269+ if page < pages:
270+ out.append(f' <a rel="next" href="{base_path}?page={page + 1}">Next page →</a>')
271+ out.append("</nav>")
272+ return "".join(out)
273+
274+
275+def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict:
276+ return {
277+ "@context": "https://schema.org",
278+ "@type": "BreadcrumbList",
279+ "itemListElement": [
280+ {"@type": "ListItem", "position": i + 1, "name": name,
281+ "item": BASE_URL + path}
282+ for i, (name, path) in enumerate(crumbs)
283+ ],
284+ }
285+
286+
287+# -----------------------------------------------------------------------------
288+# Home
289+# -----------------------------------------------------------------------------
290+
291+def _home_data() -> dict:
292+ def build():
293+ con = db.connect()
294+ row = con.execute(
295+ f"SELECT COUNT(*) total, COUNT(DISTINCT city) cities,"
296+ f" COUNT(DISTINCT source) sources, AVG(price) avg_p"
297+ f" FROM listings WHERE {VISIBLE}").fetchone()
298+ recent = [dict(r) for r in con.execute(
299+ f"SELECT uid, address, title, city, sector, property_type, price,"
300+ f" bedrooms, bathrooms, area_sqft FROM listings WHERE {VISIBLE}"
301+ f" ORDER BY first_seen DESC LIMIT 12")]
302+ con.close()
303+ return {**dict(row), "recent": recent}
304+ return _cached("home", 900, build)
305+
306+
307+def render_home() -> HTMLResponse:
308+ d = _home_data()
309+ reg = registry()
310+ total = _fmt_n(d["total"])
311+ title = f"House-Ka — {total} homes for sale across Canada · A Groupe KA service"
312+ desc = (f"{total} homes for sale in {_fmt_n(d['cities'])} Canadian cities and towns, "
313+ f"aggregated from {d['sources']} brokerage sources on the CREA DDF feed — "
314+ f"continuously updated. Average asking price: {_fmt_price(d['avg_p'])}.")
315+ top_cities = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:60]
316+ types = sorted(reg["types"].items(), key=lambda kv: -kv[1]["n"])
317+ body = [
318+ f"<h1>Homes for sale across Canada — {total} listings from real-estate brokerages</h1>",
319+ f"<p>House-Ka continuously aggregates homes for sale publicly listed by Canadian "
320+ f"real-estate brokerages and teams (CREA DDF feed) — {d['sources']} sources, "
321+ f"{_fmt_n(d['cities'])} cities, average asking price {_esc(_fmt_price(d['avg_p']))}. "
322+ f"Every listing links back to the brokerage's original page. Coverage starts with "
323+ f"Ontario and grows across the rest of Canada. Looking for Québec? "
324+ f'See our sister site <a href="https://www.immo-ka.com" rel="noopener">Immo-Ka</a>.</p>',
325+ "<h2>Homes for sale by city</h2>",
326+ "<ul>" + "".join(
327+ f'<li><a href="/for-sale/{s}">Homes for sale in {_esc(e["label"])}</a>'
328+ f" ({_fmt_n(e['n'])})</li>"
329+ for s, e in top_cities if e["n"] >= MIN_LISTINGS) + "</ul>",
330+ "<h2>By property type</h2>",
331+ "<ul>" + "".join(
332+ f'<li><a href="/type/{s}">{_esc(e["label"])} for sale in Canada</a>'
333+ f" ({_fmt_n(e['n'])})</li>"
334+ for s, e in types if e["n"] >= MIN_LISTINGS) + "</ul>",
335+ "<h2>Latest listings</h2>",
336+ "<ul>" + "".join(_item_li(r) for r in d["recent"]) + "</ul>",
337+ '<p><a href="/stats">Market statistics</a> · '
338+ '<a href="/agencies">Covered brokerages</a> · '
339+ '<a href="/rates">Mortgage rates</a></p>',
340+ ]
341+ jsonld = [{
342+ "@context": "https://schema.org",
343+ "@type": "WebSite",
344+ "name": SITE_NAME,
345+ "url": BASE_URL + "/",
346+ "inLanguage": "en-CA",
347+ "description": desc,
348+ "potentialAction": {
349+ "@type": "SearchAction",
350+ "target": {"@type": "EntryPoint",
351+ "urlTemplate": BASE_URL + "/?q={search_term_string}"},
352+ "query-input": "required name=search_term_string",
353+ },
354+ }, {
355+ "@context": "https://schema.org",
356+ "@type": "Organization",
357+ "name": "Groupe-Ka",
358+ "url": BASE_URL + "/",
359+ "email": "contact@groupe-ka.com",
360+ }]
361+ return _page(title, desc, BASE_URL + "/", "".join(body), jsonld)
362+
363+
364+# -----------------------------------------------------------------------------
365+# Programmatic pages: city, city+type, type
366+# -----------------------------------------------------------------------------
367+
368+def _render_category(city_slug: str | None, type_slug: str | None,
369+ page: int) -> HTMLResponse:
370+ reg = registry()
371+ city = reg["cities"].get(city_slug) if city_slug else None
372+ ptype = reg["types"].get(type_slug) if type_slug else None
373+ if (city_slug and not city) or (type_slug and not ptype):
374+ return render_404()
375+ if city_slug and type_slug and reg["city_types"].get((city_slug, type_slug), 0) < 1:
376+ return render_404()
377+
378+ con = db.connect()
379+ st = _agg_stats(con, city["values"] if city else None,
380+ ptype["values"] if ptype else None)
381+ if st["n"] < 1:
382+ con.close()
383+ return render_404()
384+
385+ pages = max(1, -(-st["n"] // PAGE_SIZE))
386+ if page < 1 or page > pages:
387+ con.close()
388+ return render_404()
389+ rows = con.execute(
390+ f"SELECT uid, address, title, city, sector, property_type, price,"
391+ f" bedrooms, bathrooms, area_sqft FROM listings WHERE {st['where']}"
392+ f" ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?",
393+ st["args"] + [PAGE_SIZE, (page - 1) * PAGE_SIZE]).fetchall()
394+
395+ # internal linking
396+ links = []
397+ if city:
398+ tlinks = []
399+ for (cs, ts_), n in sorted(reg["city_types"].items(), key=lambda kv: -kv[1]):
400+ if cs == city_slug and n >= 1 and ts_ in reg["types"] and ts_ != type_slug:
401+ lbl = reg["types"][ts_]["label"]
402+ tlinks.append(f'<li><a href="/for-sale/{cs}/{ts_}">'
403+ f"{_esc(lbl)} for sale in {_esc(city['label'])}</a> ({_fmt_n(n)})</li>")
404+ if tlinks:
405+ links.append("<h2>Other property types in "
406+ + _esc(city["label"]) + "</h2><ul>" + "".join(tlinks[:20]) + "</ul>")
407+ if type_slug:
408+ links.append(f'<p><a href="/for-sale/{city_slug}">All homes for sale '
409+ f"in {_esc(city['label'])}</a> · "
410+ f'<a href="/type/{type_slug}">{_esc(ptype["label"])} for sale in Canada</a></p>')
411+ top = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:30]
412+ links.append("<h2>Other cities</h2><ul>" + "".join(
413+ f'<li><a href="/for-sale/{s}{"/" + type_slug if type_slug and (s, type_slug) in reg["city_types"] else ""}">'
414+ f'Homes for sale in {_esc(e["label"])}</a> ({_fmt_n(e["n"])})</li>'
415+ for s, e in top if s != city_slug and e["n"] >= MIN_LISTINGS) + "</ul>")
416+ con.close()
417+
418+ if city and ptype:
419+ base_path = f"/for-sale/{city_slug}/{type_slug}"
420+ h1 = f"{ptype['label']} for sale in {city['label']}"
421+ what = f"{ptype['label'].lower()} listings in {city['label']}"
422+ elif city:
423+ base_path = f"/for-sale/{city_slug}"
424+ h1 = f"Homes for sale in {city['label']}"
425+ what = f"homes for sale in {city['label']}"
426+ else:
427+ base_path = f"/type/{type_slug}"
428+ h1 = f"{ptype['label']} for sale in Canada"
429+ what = f"{ptype['label'].lower()} listings across Canada"
430+
431+ canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "")
432+ title = f"{h1} — {_fmt_n(st['n'])} listings" + (f" (page {page})" if page > 1 else "") + " | House-Ka"
433+ desc = (f"{_fmt_n(st['n'])} {what}: median price {_fmt_price(st['med'])}, "
434+ f"average price {_fmt_price(st['avg'])}. Listings from Canadian brokerages "
435+ f"on the CREA DDF feed, continuously updated.")
436+ stats_p = (f"<p><strong>{_fmt_n(st['n'])}</strong> listings · median price "
437+ f"<strong>{_esc(_fmt_price(st['med']))}</strong> · average price "
438+ f"<strong>{_esc(_fmt_price(st['avg']))}</strong> · from "
439+ f"{_esc(_fmt_price(st['min']))} to {_esc(_fmt_price(st['max']))}.</p>")
440+ crumbs = [("Home", "/")]
441+ if city:
442+ crumbs.append((f"For sale in {city['label']}", f"/for-sale/{city_slug}"))
443+ if ptype:
444+ crumbs.append((f"{ptype['label']}", base_path))
445+ else:
446+ crumbs.append((h1, base_path))
447+ body = ('<nav aria-label="Breadcrumb">'
448+ + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs)
449+ + f"</nav><h1>{_esc(h1)}</h1>" + stats_p
450+ + "<ul>" + "".join(_item_li(r) for r in rows) + "</ul>"
451+ + _pagination_html(base_path, page, pages)
452+ + "".join(links))
453+ return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)])
454+
455+
456+# -----------------------------------------------------------------------------
457+# Listing page
458+# -----------------------------------------------------------------------------
459+
460+_TYPE_SCHEMA = {
461+ "house": "SingleFamilyResidence", "condo": "Apartment",
462+ "cottage": "House", "semi-detached": "House", "townhouse": "House",
463+ "duplex": "Residence", "triplex": "Residence",
464+ "multi-family": "Residence", "mobile-home": "House",
465+}
466+
467+
468+def render_listing(uid: str, slug: str | None) -> Response:
469+ con = db.connect()
470+ row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
471+ con.close()
472+ if row is None:
473+ return render_404()
474+
475+ city_slug = slugify(row["city"] or "")
476+ city_known = city_slug in registry()["cities"]
477+ city_href = f"/for-sale/{city_slug}" if city_known else "/"
478+
479+ if not row["active"]:
480+ # withdrawn / sold → 410 Gone, with escape hatches
481+ name = row["address"] or row["title"] or "Property"
482+ body = (f"<h1>This property is no longer for sale</h1>"
483+ f"<p>The listing “{_esc(name)}” ({_esc(row['city'] or 'Canada')}) has been "
484+ f"withdrawn or sold.</p><ul>"
485+ + (f'<li><a href="{city_href}">Homes for sale in '
486+ f"{_esc(row['city'])}</a></li>" if city_known else "")
487+ + '<li><a href="/">All homes for sale across Canada</a></li></ul>')
488+ return _page(f"Listing withdrawn — {name} | {SITE_NAME}",
489+ "This listing has been withdrawn or sold.",
490+ BASE_URL + fiche_href(uid), body, noindex=True, status=410)
491+
492+ expected = listing_slug(row)
493+ if expected and slug != expected:
494+ return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301)
495+
496+ d = dict(row)
497+ images = json.loads(d.get("images") or "[]")
498+ features = json.loads(d.get("features") or "[]")
499+ name = d["address"] or d["title"] or "Property for sale"
500+ loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Canada"
501+ canonical = BASE_URL + fiche_href(uid, expected)
502+ ptype = d["property_type"] or "Property"
503+
504+ specs = [(lbl, val) for lbl, val in [
505+ ("Type", ptype),
506+ ("Price", _fmt_price(d["price"]) if d["price"] is not None else d["price_label"]),
507+ ("Bedrooms", d["bedrooms"]),
508+ ("Bathrooms", d["bathrooms"]),
509+ ("Half baths", d["powder_rooms"]),
510+ ("Living area", f"{_fmt_n(round(d['area_sqft']))} sq ft" if d["area_sqft"] else None),
511+ ("Lot", f"{_fmt_n(round(d['lot_sqft']))} sq ft" if d["lot_sqft"] else None),
512+ ("Year built", d["year_built"]),
513+ ("City", d["city"]),
514+ ("Neighbourhood", d["sector"]),
515+ ("MLS® number", d["mls"]),
516+ ("Agent", d["broker_name"]),
517+ ("Brokerage", d["agency"]),
518+ ] if val not in (None, "", 0)]
519+ descr = (d["description"] or "").strip()
520+ if len(descr) > 1500:
521+ descr = descr[:1500].rsplit(" ", 1)[0] + "…"
522+
523+ type_slug = slugify(ptype)
524+ crumbs = [("Home", "/")]
525+ if city_known:
526+ crumbs.append((f"For sale in {d['city']}", city_href))
527+ if (city_slug, type_slug) in registry()["city_types"]:
528+ crumbs.append((ptype, f"/for-sale/{city_slug}/{type_slug}"))
529+ crumbs.append((name, fiche_href(uid, expected)))
530+
531+ body = [
532+ '<nav aria-label="Breadcrumb">'
533+ + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs[:-1])
534+ + f" › {_esc(name)}</nav>",
535+ f"<h1>{_esc(name)}</h1>",
536+ f"<p><strong>{_esc(ptype)} for sale in {_esc(loc)}</strong> — "
537+ f"{_esc(_fmt_price(d['price']) if d['price'] is not None else (d['price_label'] or 'Price on request'))}</p>",
538+ ]
539+ if images:
540+ body.append("".join(
541+ f'<img src="{_esc(u)}" alt="{_esc(name)} — photo {i + 1}" loading="lazy" />'
542+ for i, u in enumerate(images[:3])))
543+ body.append("<h2>Key facts</h2><ul>" + "".join(
544+ f"<li><strong>{_esc(l)}:</strong> {_esc(v)}</li>" for l, v in specs) + "</ul>")
545+ if descr:
546+ body.append(f"<h2>Description</h2><p>{_esc(descr)}</p>")
547+ if features:
548+ body.append("<h2>Details</h2><ul>" + "".join(
549+ f"<li>{_esc(f)}</li>" for f in features[:20]) + "</ul>")
550+ if d["url"]:
551+ body.append(f'<p><a href="{_esc(d["url"])}" rel="noopener">'
552+ f"See the original listing at {_esc(d['broker_name'] or d['agency'] or 'the brokerage')}</a></p>")
553+ if city_known:
554+ body.append(f'<p><a href="{city_href}">Homes for sale in {_esc(d["city"])}</a>'
555+ + (f' · <a href="/for-sale/{city_slug}/{type_slug}">{_esc(ptype)} for sale in '
556+ f'{_esc(d["city"])}</a>'
557+ if (city_slug, type_slug) in registry()["city_types"] else "") + "</p>")
558+
559+ about_type = _TYPE_SCHEMA.get(type_slug, "Residence")
560+ region_code = _PROVINCE_CODE.get((d["region"] or "").strip().lower(), "ON")
561+ about: dict = {
562+ "@type": about_type,
563+ "name": name,
564+ "address": {"@type": "PostalAddress",
565+ "streetAddress": d["address"] or None,
566+ "addressLocality": d["city"] or None,
567+ "addressRegion": region_code, "addressCountry": "CA"},
568+ }
569+ if d["lat"] is not None and d["lng"] is not None:
570+ about["geo"] = {"@type": "GeoCoordinates",
571+ "latitude": d["lat"], "longitude": d["lng"]}
572+ if d["bedrooms"] is not None:
573+ about["numberOfBedrooms"] = d["bedrooms"]
574+ if d["bathrooms"] is not None:
575+ about["numberOfBathroomsTotal"] = d["bathrooms"]
576+ if d["area_sqft"]:
577+ about["floorSize"] = {"@type": "QuantitativeValue",
578+ "value": round(d["area_sqft"]), "unitCode": "FTK"}
579+ if d["year_built"]:
580+ about["yearBuilt"] = d["year_built"]
581+ about = {k: v for k, v in about.items() if v is not None}
582+ about["address"] = {k: v for k, v in about["address"].items() if v is not None}
583+ jsonld: list[dict] = [{
584+ "@context": "https://schema.org",
585+ "@type": "RealEstateListing",
586+ "name": name,
587+ "url": canonical,
588+ "inLanguage": "en-CA",
589+ "datePosted": _iso_date(d.get("first_seen")),
590+ "image": images[:6] or None,
591+ "about": about,
592+ }, _breadcrumb_ld(crumbs)]
593+ if d["price"] is not None:
594+ jsonld[0]["offers"] = {"@type": "Offer", "price": round(d["price"], 2),
595+ "priceCurrency": "CAD",
596+ "availability": "https://schema.org/InStock"}
597+ jsonld[0] = {k: v for k, v in jsonld[0].items() if v is not None}
598+
599+ title = f"{name} — {ptype} for sale, {d['city'] or 'Canada'} | {_fmt_price(d['price']) if d['price'] is not None else 'Price on request'}"
600+ meta_desc = (f"{ptype} for sale in {loc}"
601+ + (f", {d['bedrooms']} bedrooms" if d["bedrooms"] else "")
602+ + (f", {_fmt_n(round(d['area_sqft']))} sq ft" if d["area_sqft"] else "")
603+ + f" — {_fmt_price(d['price']) if d['price'] is not None else 'price on request'}. "
604+ + (descr[:120] + "…" if len(descr) > 120 else descr))
605+ return _page(title, meta_desc, canonical, "".join(body), jsonld,
606+ og_image=images[0] if images else None, og_type="article")
607+
608+
609+# -----------------------------------------------------------------------------
610+# Static SPA pages (dedicated meta) and 404
611+# -----------------------------------------------------------------------------
612+
613+_STATIC_META = {
614+ "/stats": ("Canadian housing market statistics",
615+ "Average prices, listing volumes by source and data quality — "
616+ "continuous statistics from House-Ka.",
617+ False),
618+ "/agencies": ("Covered brokerages",
619+ "All the Canadian real-estate brokerages and teams aggregated by "
620+ "House-Ka through the CREA DDF feed.",
621+ False),
622+ "/rates": (
623+ "Mortgage rates in Canada — live comparator",
624+ "Compare mortgage rates actually published by RBC, TD, BMO, CIBC, "
625+ "Scotiabank, NBC, Desjardins, Tangerine, EQ and more — fixed and variable, "
626+ "with official source, freshness and history. Continuously collected by House-Ka.",
627+ False),
628+ "/terms": ("Terms of use",
629+ "Terms of use of the House-Ka platform (Groupe-Ka).", False),
630+ "/privacy": ("Privacy policy",
631+ "House-Ka privacy policy (Groupe-Ka) — PIPEDA.", False),
632+ "/account": ("My account", "Your Groupe KA account on House-Ka.", True),
633+ "/contact": ("Contact — Groupe KA",
634+ "Write to Groupe KA: contact@groupe-ka.com (projects and data), "
635+ "info@groupe-ka.com (media), admin@groupe-ka.com (legal and privacy). "
636+ "House-Ka is a Groupe KA service — https://www.groupe-ka.com.",
637+ False),
638+}
639+
640+
641+def render_static(path: str) -> HTMLResponse:
642+ t, desc, noindex = _STATIC_META[path]
643+ body = f"<h1>{_esc(t)}</h1><p>{_esc(desc)}</p>"
644+ return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex)
645+
646+
647+def render_404() -> HTMLResponse:
648+ body = ('<h1>Page not found</h1><p>The requested link does not exist.</p>'
649+ '<p><a href="/">All homes for sale across Canada</a></p>')
650+ return _page(f"Page not found | {SITE_NAME}", "Page not found.",
651+ BASE_URL + "/", body, noindex=True, status=404)
652+
653+
654+# -----------------------------------------------------------------------------
655+# robots.txt and sitemaps
656+# -----------------------------------------------------------------------------
657+
658+FICHES_PER_SITEMAP = 40000
659+
660+
661+def robots_txt() -> PlainTextResponse:
662+ return PlainTextResponse(
663+ "User-agent: *\n"
664+ "Allow: /\n"
665+ "Disallow: /api/\n"
666+ "Disallow: /account\n"
667+ f"\nSitemap: {BASE_URL}/sitemap.xml\n")
668+
669+
670+def _xml(urls: list[str]) -> Response:
671+ body = ('<?xml version="1.0" encoding="UTF-8"?>\n'
672+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
673+ + "\n".join(urls) + "\n</urlset>")
674+ return Response(body, media_type="application/xml")
675+
676+
677+def _url_el(loc: str, lastmod: str | None = None) -> str:
678+ lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""
679+ return f" <url><loc>{html.escape(loc)}</loc>{lm}</url>"
680+
681+
682+def sitemap_index() -> Response:
683+ def build():
684+ con = db.connect()
685+ n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]
686+ last = con.execute(
687+ f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"]
688+ con.close()
689+ parts = -(-n // FICHES_PER_SITEMAP) or 1
690+ lm = _iso_date(last)
691+ maps = [f"{BASE_URL}/sitemaps/listings-{i + 1}.xml" for i in range(parts)]
692+ maps += [f"{BASE_URL}/sitemaps/cities.xml",
693+ f"{BASE_URL}/sitemaps/cities-types.xml",
694+ f"{BASE_URL}/sitemaps/types.xml",
695+ f"{BASE_URL}/sitemaps/pages.xml"]
696+ body = ('<?xml version="1.0" encoding="UTF-8"?>\n'
697+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
698+ + "\n".join(f" <sitemap><loc>{html.escape(m)}</loc>"
699+ f"<lastmod>{lm}</lastmod></sitemap>" for m in maps)
700+ + "\n</sitemapindex>")
701+ return body
702+ return Response(_cached("sm:index", 3600, build), media_type="application/xml")
703+
704+
705+def sitemap_file(name: str) -> Response:
706+ m = re.fullmatch(r"listings-(\d+)\.xml", name)
707+ if m:
708+ part = int(m.group(1))
709+
710+ def build():
711+ con = db.connect()
712+ rows = con.execute(
713+ f"SELECT uid, address, title, city, updated_at FROM listings"
714+ f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?",
715+ (FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall()
716+ con.close()
717+ if not rows:
718+ return None
719+ return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)),
720+ _iso_date(r["updated_at"])) for r in rows]
721+ urls = _cached(f"sm:listings:{part}", 3600, build)
722+ if urls is None:
723+ return Response("Sitemap not found", status_code=404)
724+ return _xml(urls)
725+
726+ if name == "cities.xml":
727+ def build():
728+ reg = registry()
729+ lm = _city_lastmod()
730+ return [_url_el(f"{BASE_URL}/for-sale/{s}", lm.get(s))
731+ for s, e in sorted(reg["cities"].items())
732+ if e["n"] >= MIN_LISTINGS]
733+ return _xml(_cached("sm:cities", 3600, build))
734+
735+ if name == "cities-types.xml":
736+ def build():
737+ reg = registry()
738+ lm = _city_lastmod()
739+ return [_url_el(f"{BASE_URL}/for-sale/{cs}/{ts_}", lm.get(cs))
740+ for (cs, ts_), n in sorted(reg["city_types"].items())
741+ if n >= MIN_LISTINGS and cs in reg["cities"] and ts_ in reg["types"]
742+ and reg["cities"][cs]["n"] >= MIN_LISTINGS]
743+ return _xml(_cached("sm:cities-types", 3600, build))
744+
745+ if name == "types.xml":
746+ def build():
747+ reg = registry()
748+ return [_url_el(f"{BASE_URL}/type/{s}")
749+ for s, e in sorted(reg["types"].items()) if e["n"] >= MIN_LISTINGS]
750+ return _xml(_cached("sm:types", 3600, build))
751+
752+ if name == "pages.xml":
753+ return _xml([_url_el(f"{BASE_URL}{p}")
754+ for p in ["/", "/stats", "/agencies", "/rates",
755+ "/terms", "/privacy"]])
756+
757+ return Response("Sitemap not found", status_code=404)
758+
759+
760+def _city_lastmod() -> dict[str, str]:
761+ def build():
762+ con = db.connect()
763+ out: dict[str, str] = {}
764+ for r in con.execute(
765+ f"SELECT city, MAX(updated_at) m FROM listings WHERE {VISIBLE}"
766+ " AND city<>'' GROUP BY city"):
767+ s = slugify(r["city"])
768+ if s:
769+ prev = out.get(s)
770+ cur = _iso_date(r["m"])
771+ out[s] = max(prev, cur) if prev else cur
772+ con.close()
773+ return out
774+ return _cached("sm:citylastmod", 3600, build)
775+
776+
777+# -----------------------------------------------------------------------------
778+# Slug resolution for the frontend (client-side /for-sale pages)
779+# -----------------------------------------------------------------------------
780+
781+def resolve_slugs(ville: str | None, ptype: str | None) -> dict | None:
782+ reg = registry()
783+ out: dict = {}
784+ if ville:
785+ e = reg["cities"].get(ville)
786+ if not e:
787+ return None
788+ out["city"] = e["label"]
789+ out["city_n"] = e["n"]
790+ if ptype:
791+ e = reg["types"].get(ptype)
792+ if not e:
793+ return None
794+ out["property_type"] = e["label"]
795+ out["type_n"] = e["n"]
796+ return out
797+
798+
799+# -----------------------------------------------------------------------------
800+# Routing: called by the web.py catch-all
801+# -----------------------------------------------------------------------------
802+
803+def render_for_path(path: str, query: dict) -> Response | None:
804+ """SEO HTML for `path` (e.g. “/for-sale/ottawa”), or None → raw index."""
805+ path = path.rstrip("/") or "/"
806+ try:
807+ page = max(1, int(query.get("page", "1")))
808+ except ValueError:
809+ page = 1
810+
811+ if path == "/":
812+ return render_home()
813+ if path in _STATIC_META:
814+ return render_static(path)
815+
816+ parts = [p for p in path.split("/") if p]
817+ if parts[0] == "property" and len(parts) in (2, 3):
818+ return render_listing(parts[1], parts[2] if len(parts) == 3 else None)
819+ if parts[0] == "for-sale" and len(parts) in (2, 3):
820+ return _render_category(parts[1], parts[2] if len(parts) == 3 else None, page)
821+ if parts[0] == "type" and len(parts) == 2:
822+ return _render_category(None, parts[1], page)
823+ return render_404()
added immoka/stats.py +1034 −0
@@ -0,0 +1,1034 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# stats.py : tableau de bord analytique /api/stats/dashboard + rapport PDF
5+# /api/stats/report (module Stats commun Groupe KA v2 — voir
6+# frontend/src/ka/stats/SPEC.md). Toutes les valeurs viennent de la base
7+# (listings, price_log, sync_log) — AUCUNE statistique inventée : une
8+# mesure indisponible est simplement omise (le front affiche un état vide).
9+# v2 : sparklines KPI, jauges (géolocalisation, photos, publiable),
10+# multi-courbes (prix médian par type / grande ville), barres empilées
11+# (nouvelles inscriptions par bannière), distributions (prix, superficie,
12+# année de construction), heatmap horaire 7×24, tableaux quarantaine &
13+# bannières, records enrichis.
14+# -----------------------------------------------------------------------------
15+from __future__ import annotations
16+
17+import json
18+import statistics
19+import threading
20+import time
21+import unicodedata
22+from datetime import date, datetime, timedelta
23+from pathlib import Path
24+from zoneinfo import ZoneInfo
25+
26+from . import db
27+
28+TZ = ZoneInfo("America/Toronto")
29+SQFT_PER_M2 = 10.7639104
30+ROOT = Path(__file__).resolve().parent.parent
31+SOURCES_PATH = ROOT / "data" / "sources.json"
32+
33+# Position vs estimation Vrai-Prix (mêmes seuils que le fair value Lou-Ka) :
34+# sous le marché si écart <= -8 %, au-dessus si >= +8 % ; les écarts hors
35+# (-50 %, +100 %) sont presque toujours des erreurs de lecture -> ignorés.
36+FV_SEUIL_SOUS = -0.08
37+FV_SEUIL_SUR = 0.08
38+FV_DEV_BOUNDS = (-0.50, 1.00)
39+# bornes de plausibilité des prix de vente résidentiels (journal de prix) :
40+# les écarts extrêmes sont des erreurs de source, pas de vraies baisses.
41+PRICE_MIN, PRICE_MAX = 25_000, 50_000_000
42+
43+# Même règle de visibilité que le reste de l'API (web.DEDUP_CLAUSE) :
44+# doublons de sous-agences masqués + « Prix sur demande » exclus.
45+VISIBLE = " AND dup_hidden=0 AND published=1"
46+
47+PERIOD_LABELS = {
48+ "auj": "Aujourd'hui", "7j": "7 jours", "30j": "30 jours",
49+ "3m": "3 mois", "6m": "6 mois", "12m": "12 mois",
50+ "annee": "Année en cours", "tout": "Toute la période",
51+}
52+
53+# --- cache serveur (>= 5 min par période, contrat SPEC) -----------------------
54+_CACHE: dict[str, tuple[float, dict]] = {}
55+_CACHE_TTL = 300
56+_CACHE_LOCK = threading.Lock()
57+
58+
59+# --- utilitaires --------------------------------------------------------------
60+def _today() -> date:
61+ return datetime.now(TZ).date()
62+
63+
64+def _iso(d: date) -> str:
65+ return d.isoformat()
66+
67+
68+def _parse(d: str) -> date | None:
69+ try:
70+ return date.fromisoformat(d[:10])
71+ except (ValueError, TypeError):
72+ return None
73+
74+
75+def _epoch(d: date) -> float:
76+ """Minuit local (heure de l'Est) du jour donné, en epoch."""
77+ return datetime(d.year, d.month, d.day, tzinfo=TZ).timestamp()
78+
79+
80+def resolve_period(period: str | None, frm: str | None, to: str | None,
81+ data_start: date) -> tuple[date, date, str]:
82+ today = _today()
83+ f, t = _parse(frm or ""), _parse(to or "")
84+ if f and t:
85+ if t < f:
86+ f, t = t, f
87+ return f, t, f"{_iso(f)} → {_iso(t)}"
88+ p = (period or "30j").lower()
89+ spans = {"7j": 6, "30j": 29, "3m": 89, "6m": 181, "12m": 364}
90+ if p == "auj":
91+ return today, today, PERIOD_LABELS["auj"]
92+ if p == "annee":
93+ return date(today.year, 1, 1), today, PERIOD_LABELS["annee"]
94+ if p == "tout":
95+ return data_start, today, PERIOD_LABELS["tout"]
96+ days = spans.get(p, 29)
97+ label = PERIOD_LABELS.get(p, PERIOD_LABELS["30j"])
98+ return today - timedelta(days=days), today, label
99+
100+
101+def _fold(s: str) -> str:
102+ return "".join(c for c in unicodedata.normalize("NFKD", s.lower().strip())
103+ if not unicodedata.combining(c))
104+
105+
106+def _median(vals: list[float]) -> float | None:
107+ # défensif : la DB peut contenir des prix NULL — on les écarte
108+ vals = [v for v in vals if isinstance(v, (int, float))]
109+ return statistics.median(vals) if vals else None
110+
111+
112+def _fmt_money(v: float) -> str:
113+ return f"{round(v):,}".replace(",", " ") + " $"
114+
115+
116+def _fmt_pct(cur: float, prev: float) -> float | None:
117+ if prev <= 0:
118+ return None
119+ return round((cur - prev) / prev * 100.0, 1)
120+
121+
122+def _daterange(a: date, b: date):
123+ d = a
124+ while d <= b:
125+ yield d
126+ d += timedelta(days=1)
127+
128+
129+def _source_names() -> dict[str, str]:
130+ """id -> nom lisible depuis data/sources.json (repli : id brut)."""
131+ try:
132+ reg = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
133+ return {s["id"]: s.get("name") or s["id"] for s in reg}
134+ except (OSError, ValueError, KeyError, TypeError):
135+ return {}
136+
137+
138+# Familles de connecteurs (mêmes règles que web._FRANCHISES — dupliquées ici
139+# pour éviter l'import circulaire web ⇄ stats)
140+_FAMILLES = [
141+ ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")),
142+ ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")),
143+ ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")),
144+ ("Royal LePage", lambda s: s == "royal_lepage"),
145+ ("Groupe Sutton", lambda s: s == "sutton"),
146+ ("Keller Williams", lambda s: s.startswith("kw_")),
147+ ("DuProprio", lambda s: s == "duproprio"),
148+ ("Vendre.ca", lambda s: s == "vendre_ag_ca"),
149+]
150+
151+
152+def _famille_of(source: str, names: dict[str, str]) -> str:
153+ for name, match in _FAMILLES:
154+ if match(source):
155+ return name
156+ return names.get(source, source)
157+
158+
159+def _downsample(pts: list[dict], keep: int = 40) -> list[dict]:
160+ """Réduit une série de points {t,v} à <= keep points (sparklines)."""
161+ if len(pts) <= keep:
162+ return pts
163+ step = (len(pts) - 1) / (keep - 1)
164+ return [pts[round(i * step)] for i in range(keep)]
165+
166+
167+# Libellés lisibles des motifs de quarantaine/anomalies (quality.py)
168+_MOTIFS_QUALITE = {
169+ "quarantaine": "Sous le seuil de publication",
170+ "sans_image": "Sans image (secours affiché)",
171+ "prix_hors_bornes": "Prix hors bornes",
172+ "superficie_improbable": "Superficie improbable",
173+ "terrain_improbable": "Terrain improbable",
174+ "chambres_improbables": "Chambres improbables",
175+ "sdb_improbables": "Salles de bain improbables",
176+ "chambres_vs_type": "Chambres vs type incohérents",
177+ "annee_invalide": "Année de construction invalide",
178+ "prix_pi2_extreme": "Prix au pi² extrême",
179+}
180+
181+
182+# --- calcul du tableau de bord ------------------------------------------------
183+def _compute(frm_q: str | None, to_q: str | None, period: str | None) -> dict:
184+ con = db.connect()
185+ try:
186+ return _compute_con(con, frm_q, to_q, period)
187+ finally:
188+ con.close()
189+
190+
191+def _compute_con(con, frm_q, to_q, period) -> dict:
192+ today = _today()
193+ row = con.execute("SELECT MIN(first_seen) m FROM listings").fetchone()
194+ data_start = (datetime.fromtimestamp(row["m"], TZ).date()
195+ if row and row["m"] else today)
196+
197+ frm, to, label = resolve_period(period, frm_q, to_q, data_start)
198+ to = min(to, today)
199+ # fenêtre observée : la collecte a commencé le data_start — les séries
200+ # sont bornées à ce qui a réellement été mesuré (rien d'extrapolé).
201+ s_frm = max(frm, data_start)
202+ s_to = max(to, s_frm)
203+ ep_frm, ep_to = _epoch(s_frm), _epoch(s_to + timedelta(days=1))
204+ ndays = (s_to - s_frm).days + 1
205+ # période précédente de même longueur (pour les deltas)
206+ p_frm, p_to = s_frm - timedelta(days=ndays), s_frm - timedelta(days=1)
207+ # deltas seulement si la période précédente a été observée EN ENTIER —
208+ # comparer à une fenêtre tronquée fausserait les variations.
209+ prev_ok = p_frm >= data_start
210+ ep_pfrm, ep_pto = _epoch(p_frm), _epoch(p_to + timedelta(days=1))
211+
212+ # ---- reconstruction « annonces actives par jour » (événements) ----------
213+ actives_by_day: dict[str, int] = {}
214+ deltas: dict[date, int] = {}
215+ for r in con.execute(
216+ "SELECT date(first_seen,'unixepoch','localtime') fs,"
217+ " date(last_seen,'unixepoch','localtime') ls, active"
218+ " FROM listings WHERE 1=1" + VISIBLE):
219+ d0 = _parse(r["fs"])
220+ if d0 is None:
221+ continue
222+ deltas[d0] = deltas.get(d0, 0) + 1
223+ if not r["active"]:
224+ d1 = (_parse(r["ls"]) or d0) + timedelta(days=1)
225+ deltas[d1] = deltas.get(d1, 0) - 1
226+ run = 0
227+ for d in _daterange(data_start, today):
228+ run += deltas.get(d, 0)
229+ actives_by_day[_iso(d)] = run
230+
231+ # ---- KPI -----------------------------------------------------------------
232+ snap = con.execute(
233+ "SELECT COUNT(*) n, AVG(price) avg_p,"
234+ " COUNT(DISTINCT NULLIF(city,'')) cities"
235+ " FROM listings WHERE active=1" + VISIBLE).fetchone()
236+ prices = [r["price"] for r in con.execute(
237+ "SELECT price FROM listings WHERE active=1" + VISIBLE)]
238+ med_price = _median(prices)
239+ ppm2 = [r["v"] for r in con.execute(
240+ "SELECT price/(area_sqft/" + str(SQFT_PER_M2) + ") v FROM listings"
241+ " WHERE active=1 AND area_sqft>=200" + VISIBLE)]
242+ med_ppm2 = _median(ppm2)
243+ n_ppm2 = len(ppm2)
244+
245+ new_cur = con.execute(
246+ "SELECT COUNT(*) n FROM listings WHERE first_seen>=? AND first_seen<?"
247+ + VISIBLE, (ep_frm, ep_to)).fetchone()["n"]
248+ new_prev = con.execute(
249+ "SELECT COUNT(*) n FROM listings WHERE first_seen>=? AND first_seen<?"
250+ + VISIBLE, (ep_pfrm, ep_pto)).fetchone()["n"] if prev_ok else 0
251+ gone_cur = con.execute(
252+ "SELECT COUNT(*) n FROM listings WHERE active=0 AND last_seen>=?"
253+ " AND last_seen<?" + VISIBLE, (ep_frm, ep_to)).fetchone()["n"]
254+ gone_prev = con.execute(
255+ "SELECT COUNT(*) n FROM listings WHERE active=0 AND last_seen>=?"
256+ " AND last_seen<?" + VISIBLE, (ep_pfrm, ep_pto)).fetchone()["n"] if prev_ok else 0
257+ conn_cur = con.execute(
258+ "SELECT COUNT(DISTINCT source) n FROM sync_log WHERE ok=1 AND ts>=?"
259+ " AND ts<?", (ep_frm, ep_to)).fetchone()["n"]
260+
261+ act_now = snap["n"]
262+ act_prev = actives_by_day.get(_iso(p_to)) if prev_ok else None
263+
264+ def kpi(id_, lbl, val, unit="", dpct=None):
265+ k = {"id": id_, "label": lbl, "value": val, "unit": unit}
266+ if dpct is not None:
267+ k["delta_pct"] = dpct
268+ k["direction"] = "up" if dpct >= 0 else "down"
269+ return k
270+
271+ kpis = [
272+ kpi("actives", "Annonces actives", act_now, "",
273+ _fmt_pct(act_now, act_prev) if act_prev else None),
274+ kpi("nouvelles", "Nouvelles annonces (période)", new_cur, "",
275+ _fmt_pct(new_cur, new_prev) if prev_ok and new_prev else None),
276+ kpi("retirees", "Vendues / retirées (période)", gone_cur, "",
277+ _fmt_pct(gone_cur, gone_prev) if prev_ok and gone_prev else None),
278+ ]
279+ if snap["avg_p"]:
280+ kpis.append(kpi("prix_moyen", "Prix moyen demandé",
281+ round(snap["avg_p"]), "$"))
282+ if med_price:
283+ kpis.append(kpi("prix_median", "Prix médian demandé",
284+ round(med_price), "$"))
285+ if med_ppm2 and n_ppm2 >= 100:
286+ kpis.append(kpi("prix_m2",
287+ f"Prix médian au m² ({n_ppm2:,} annonces avec superficie)".replace(",", " "),
288+ round(med_ppm2), "$/m²"))
289+ # prix au pi² déclaré à la source (details.prix_pi2) — médiane
290+ ppi2 = [r["v"] for r in con.execute(
291+ "SELECT CAST(json_extract(details,'$.prix_pi2') AS REAL) v"
292+ " FROM listings WHERE active=1" + VISIBLE +
293+ " AND CAST(json_extract(details,'$.prix_pi2') AS REAL)"
294+ " BETWEEN 30 AND 10000")]
295+ med_ppi2 = _median(ppi2)
296+ if med_ppi2 and len(ppi2) >= 100:
297+ kpis.append(kpi(
298+ "prix_pi2",
299+ f"Prix médian au pi² ({len(ppi2):,} annonces le déclarant)".replace(",", " "),
300+ round(med_ppi2), "$/pi²"))
301+ # jours sur le marché (annonces actives) — médiane depuis first_seen
302+ now_ts = time.time()
303+ dom = [max((now_ts - r["fs"]) / 86400.0, 0.0) for r in con.execute(
304+ "SELECT first_seen fs FROM listings WHERE active=1" + VISIBLE)]
305+ med_dom = _median(dom)
306+ if med_dom is not None and dom:
307+ kpis.append(kpi("jours_marche", "Jours sur le marché (médiane, actives)",
308+ round(med_dom, 1), "j"))
309+ # baisses de prix observées dans la période (journal price_log) —
310+ # une entrée par annonce, bornes de plausibilité (voir en tête de fichier)
311+ drops = con.execute(
312+ """SELECT l.city city, MAX(p1.price - p2.price) amt,
313+ date(MAX(p2.ts),'unixepoch','localtime') dt
314+ FROM price_log p1
315+ JOIN price_log p2 ON p2.uid = p1.uid AND p2.ts > p1.ts
316+ JOIN listings l ON l.uid = p1.uid
317+ WHERE p2.ts>=? AND p2.ts<? AND p2.price < p1.price
318+ AND p1.price BETWEEN ? AND ? AND p2.price BETWEEN ? AND ?
319+ AND p2.price >= p1.price * 0.5 AND l.dup_hidden=0 AND l.published=1
320+ GROUP BY l.uid ORDER BY amt DESC""",
321+ (ep_frm, ep_to, PRICE_MIN, PRICE_MAX, PRICE_MIN, PRICE_MAX)).fetchall()
322+ kpis.append(kpi("baisses_prix", "Baisses de prix observées (période)",
323+ len(drops)))
324+ # qualité des données (quality.py) : score moyen + quarantaine
325+ qual = con.execute(
326+ "SELECT ROUND(AVG(quality_score),1) c FROM listings WHERE active=1"
327+ + VISIBLE).fetchone()
328+ quar = con.execute(
329+ "SELECT COUNT(*) n FROM listings"
330+ " WHERE active=1 AND dup_hidden=0 AND published=0").fetchone()["n"]
331+ if qual["c"] is not None:
332+ kpis.append(kpi("qualite", "Score de qualité moyen des fiches",
333+ qual["c"], "/100"))
334+ kpis.append(kpi("quarantaine", "Annonces en quarantaine (qualité)",
335+ quar))
336+ # position des prix vs estimation Vrai-Prix (juste valeur) — un seul
337+ # balayage réutilisé par le KPI, l'anneau et le tableau par ville
338+ b_lo, b_hi = FV_DEV_BOUNDS
339+ fv_rows = con.execute(
340+ "SELECT city, (price - CAST(json_extract(vraiprix,'$.value') AS REAL))"
341+ " / CAST(json_extract(vraiprix,'$.value') AS REAL) dev"
342+ " FROM listings WHERE active=1" + VISIBLE +
343+ " AND CAST(json_extract(vraiprix,'$.value') AS REAL) > 0"
344+ " AND price BETWEEN ? AND ?", (PRICE_MIN, PRICE_MAX)).fetchall()
345+ fv_sous = fv_marche = fv_sur = 0
346+ fv_city: dict[str, list[float]] = {}
347+ for r in fv_rows:
348+ dev = r["dev"]
349+ if dev is None or not (b_lo < dev < b_hi):
350+ continue
351+ if dev <= FV_SEUIL_SOUS:
352+ fv_sous += 1
353+ elif dev >= FV_SEUIL_SUR:
354+ fv_sur += 1
355+ else:
356+ fv_marche += 1
357+ if r["city"]:
358+ fv_city.setdefault(r["city"], []).append(dev)
359+ fv_n = fv_sous + fv_marche + fv_sur
360+ if fv_n:
361+ kpis.append(kpi("sous_marche", "Annonces sous l'estimation Vrai-Prix",
362+ fv_sous))
363+ kpis.append(kpi("villes", "Villes couvertes", snap["cities"]))
364+ kpis.append(kpi("connecteurs", "Connecteurs actifs (période)", conn_cur))
365+ # indice de tension : retraits / nouvelles entrées (mesuré, pas modélisé)
366+ if new_cur >= 50:
367+ kpis.append(kpi("tension", "Tension — retraits / nouvelles",
368+ round(100.0 * gone_cur / new_cur, 1), "%"))
369+
370+ # ---- jauges (v2) : couvertures mesurées sur les annonces publiées --------
371+ gauges: list[dict] = []
372+ if act_now:
373+ g_geo = con.execute(
374+ "SELECT COUNT(*) n FROM listings WHERE active=1"
375+ " AND lat IS NOT NULL AND lng IS NOT NULL" + VISIBLE).fetchone()["n"]
376+ g_photo = con.execute(
377+ "SELECT COUNT(*) n FROM listings WHERE active=1"
378+ " AND images IS NOT NULL AND images<>'' AND images<>'[]'"
379+ + VISIBLE).fetchone()["n"]
380+ g_vp = con.execute(
381+ "SELECT COUNT(*) n FROM listings WHERE active=1" + VISIBLE +
382+ " AND CAST(json_extract(vraiprix,'$.value') AS REAL) > 0"
383+ ).fetchone()["n"]
384+ gauges.append({"id": "geoloc", "label": "Fiches géolocalisées",
385+ "value": round(100.0 * g_geo / act_now, 1),
386+ "max": 100, "unit": "%"})
387+ gauges.append({"id": "photos", "label": "Fiches avec photos",
388+ "value": round(100.0 * g_photo / act_now, 1),
389+ "max": 100, "unit": "%"})
390+ if g_vp:
391+ gauges.append({"id": "vraiprix",
392+ "label": "Fiches avec estimation Vrai-Prix",
393+ "value": round(100.0 * g_vp / act_now, 1),
394+ "max": 100, "unit": "%"})
395+ act_all = con.execute(
396+ "SELECT COUNT(*) n FROM listings WHERE active=1 AND dup_hidden=0"
397+ ).fetchone()["n"]
398+ if act_all:
399+ gauges.append({"id": "publiable",
400+ "label": "Hors quarantaine (qualité publiable)",
401+ "value": round(100.0 * (act_all - quar) / act_all, 1),
402+ "max": 100, "unit": "%"})
403+ if qual["c"] is not None:
404+ gauges.append({"id": "completude",
405+ "label": "Complétude moyenne des fiches (0–100)",
406+ "value": qual["c"], "max": 100})
407+
408+ # ---- séries quotidiennes ---------------------------------------------------
409+ days = [_iso(d) for d in _daterange(s_frm, s_to)]
410+ new_by_day = {r["d"]: r["n"] for r in con.execute(
411+ "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
412+ " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
413+ " GROUP BY d", (ep_frm, ep_to))}
414+ gone_by_day = {r["d"]: r["n"] for r in con.execute(
415+ "SELECT date(last_seen,'unixepoch','localtime') d, COUNT(*) n"
416+ " FROM listings WHERE active=0 AND last_seen>=? AND last_seen<?"
417+ + VISIBLE + " GROUP BY d", (ep_frm, ep_to))}
418+ series = []
419+ if len(days) >= 2:
420+ series = [
421+ {"id": "actives", "title": "Annonces actives par jour",
422+ "unit": "annonces", "kind": "line",
423+ "points": [{"t": d, "v": actives_by_day.get(d, 0)} for d in days]},
424+ {"id": "nouvelles", "title": "Nouvelles annonces par jour",
425+ "unit": "annonces", "kind": "bar",
426+ "points": [{"t": d, "v": new_by_day.get(d, 0)} for d in days]},
427+ {"id": "retraits", "title": "Retraits (vendues / retirées) par jour",
428+ "unit": "annonces", "kind": "bar",
429+ "points": [{"t": d, "v": gone_by_day.get(d, 0)} for d in days]},
430+ ]
431+ # prix médian demandé des nouvelles inscriptions (jours à >= 3 entrées
432+ # seulement — rien d'interpolé, l'axe saute les jours creux)
433+ med_day: list[dict] = []
434+ day_prices: dict[str, list[float]] = {}
435+ for r in con.execute(
436+ "SELECT date(first_seen,'unixepoch','localtime') d, price"
437+ " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
438+ " AND price BETWEEN ? AND ?",
439+ (ep_frm, ep_to, PRICE_MIN, PRICE_MAX)):
440+ day_prices.setdefault(r["d"], []).append(r["price"])
441+ for d in days:
442+ ps = day_prices.get(d)
443+ if ps and len(ps) >= 3:
444+ med_day.append({"t": d, "v": round(statistics.median(ps))})
445+ if len(med_day) >= 5:
446+ series.append({
447+ "id": "prix_median_nouvelles",
448+ "title": "Prix médian demandé des nouvelles inscriptions"
449+ " (jours à ≥ 3 entrées)",
450+ "unit": "$", "kind": "area", "points": med_day})
451+ # comparaison période précédente (même longueur) : seulement si elle
452+ # a réellement été observée en entier (rien d'extrapolé)
453+ if prev_ok:
454+ pdays = [_iso(d) for d in _daterange(p_frm, p_to)]
455+ series[0]["compare"] = [{"t": d, "v": actives_by_day.get(d, 0)}
456+ for d in pdays]
457+ cmp_new = {r["d"]: r["n"] for r in con.execute(
458+ "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
459+ " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
460+ " GROUP BY d", (ep_pfrm, ep_pto))}
461+ series[1]["compare"] = [{"t": d, "v": cmp_new.get(d, 0)}
462+ for d in pdays]
463+
464+ # ---- sparklines des KPI (v2) — mêmes données que les séries ---------------
465+ if len(days) >= 2:
466+ conn_day = {r["d"]: r["n"] for r in con.execute(
467+ "SELECT date(ts,'unixepoch','localtime') d,"
468+ " COUNT(DISTINCT source) n FROM sync_log"
469+ " WHERE ok=1 AND ts>=? AND ts<? GROUP BY d", (ep_frm, ep_to))}
470+ sparks = {
471+ "actives": [{"t": d, "v": actives_by_day.get(d, 0)} for d in days],
472+ "nouvelles": [{"t": d, "v": new_by_day.get(d, 0)} for d in days],
473+ "retirees": [{"t": d, "v": gone_by_day.get(d, 0)} for d in days],
474+ "connecteurs": [{"t": d, "v": conn_day.get(d, 0)} for d in days],
475+ }
476+ for k in kpis:
477+ sp = sparks.get(k["id"])
478+ if sp and any(p["v"] for p in sp):
479+ k["spark"] = _downsample(sp)
480+
481+ # ---- multi-courbes (v2) : prix médian des nouvelles inscriptions ----------
482+ # Buckets quotidiens (<= 45 j) ou hebdomadaires ; un bucket n'est gardé que
483+ # si CHAQUE groupe y compte >= 3 inscriptions (axes alignés, rien d'inventé).
484+ def _multiserie(id_, title, group_sql, top_n):
485+ weekly = ndays > 45
486+ bucket_sql = ("strftime('%Y-%m-%d', first_seen, 'unixepoch',"
487+ " 'localtime', 'weekday 1', '-6 days')" if weekly else
488+ "date(first_seen,'unixepoch','localtime')")
489+ rows = con.execute(
490+ f"SELECT {group_sql} g, {bucket_sql} b, price FROM listings"
491+ " WHERE first_seen>=? AND first_seen<?" + VISIBLE +
492+ f" AND price BETWEEN ? AND ? AND {group_sql} <> ''",
493+ (ep_frm, ep_to, PRICE_MIN, PRICE_MAX)).fetchall()
494+ vol: dict[str, int] = {}
495+ data: dict[str, dict[str, list[float]]] = {}
496+ for r in rows:
497+ if "/" in r["g"]: # libellés composites de sources ("Laval /
498+ continue # North Shore") — bruit, pas une vraie ville
499+ vol[r["g"]] = vol.get(r["g"], 0) + 1
500+ data.setdefault(r["g"], {}).setdefault(r["b"], []).append(r["price"])
501+ groups = [g for g, _ in sorted(vol.items(), key=lambda kv: -kv[1])[:top_n]]
502+ if len(groups) < 2:
503+ return None
504+ buckets = sorted({b for g in groups for b in data[g]
505+ if all(len(data[gg].get(b, [])) >= 3 for gg in groups)})
506+ if len(buckets) < 4:
507+ return None
508+ return {"id": id_, "title": title + (" (semaines)" if weekly else ""),
509+ "unit": "$",
510+ "series": [{"label": g, "points": [
511+ {"t": b, "v": round(statistics.median(data[g][b]))}
512+ for b in buckets]} for g in groups]}
513+
514+ multiseries = []
515+ ms_type = _multiserie(
516+ "prix_type", "Prix médian des nouvelles inscriptions par type",
517+ "property_type", 3)
518+ if ms_type:
519+ multiseries.append(ms_type)
520+ ms_ville = _multiserie(
521+ "prix_ville", "Prix médian des nouvelles inscriptions — grandes villes",
522+ "city", 4)
523+ if ms_ville:
524+ multiseries.append(ms_ville)
525+
526+ # ---- barres empilées (v2) : nouvelles inscriptions par bannière -----------
527+ names = _source_names()
528+ stacked = []
529+ if len(days) >= 2:
530+ weekly_st = ndays > 60
531+ bucket_st = ("strftime('%Y-%m-%d', first_seen, 'unixepoch',"
532+ " 'localtime', 'weekday 1', '-6 days')" if weekly_st else
533+ "date(first_seen,'unixepoch','localtime')")
534+ fam_day: dict[str, dict[str, int]] = {}
535+ fam_tot: dict[str, int] = {}
536+ for r in con.execute(
537+ f"SELECT source s, {bucket_st} b, COUNT(*) n FROM listings"
538+ " WHERE first_seen>=? AND first_seen<?" + VISIBLE +
539+ " GROUP BY source, b", (ep_frm, ep_to)):
540+ fam = _famille_of(r["s"], names)
541+ fam_day.setdefault(fam, {})
542+ fam_day[fam][r["b"]] = fam_day[fam].get(r["b"], 0) + r["n"]
543+ fam_tot[fam] = fam_tot.get(fam, 0) + r["n"]
544+ if fam_tot:
545+ top_fams = [f for f, _ in
546+ sorted(fam_tot.items(), key=lambda kv: -kv[1])[:5]]
547+ others = [f for f in fam_day if f not in top_fams]
548+ keys = top_fams + (["Autres"] if others else [])
549+ buckets_st = sorted({b for d_ in fam_day.values() for b in d_})
550+ pts = []
551+ for b in buckets_st:
552+ vals = [fam_day[f].get(b, 0) for f in top_fams]
553+ if others:
554+ vals.append(sum(fam_day[f].get(b, 0) for f in others))
555+ pts.append({"t": b, "values": vals})
556+ if len(pts) >= 2:
557+ stacked.append({
558+ "id": "ajouts_bannieres",
559+ "title": "Nouvelles inscriptions par bannière"
560+ + (" (semaines)" if weekly_st else ""),
561+ "unit": "inscriptions", "keys": keys, "points": pts})
562+
563+ # ---- distributions (v2) : prix, superficie, année de construction ---------
564+ distributions = []
565+ price_bins = [("< 100 k$", 0, 100e3)] + [
566+ (f"{i}00–{i+1}00 k$", i * 100e3, (i + 1) * 100e3) for i in range(1, 10)
567+ ] + [("1–1,5 M$", 1e6, 1.5e6), ("1,5–2 M$", 1.5e6, 2e6),
568+ ("2 M$ +", 2e6, None)]
569+ bins_p = []
570+ for lbl, lo, hi in price_bins:
571+ q = ("SELECT COUNT(*) n FROM listings WHERE active=1 AND price>=?"
572+ + VISIBLE)
573+ args: list = [lo]
574+ if hi is not None:
575+ q += " AND price<?"
576+ args.append(hi)
577+ bins_p.append({"label": lbl,
578+ "value": con.execute(q, args).fetchone()["n"]})
579+ if sum(b["value"] for b in bins_p):
580+ distributions.append({
581+ "id": "prix", "unit": "annonces",
582+ "title": "Distribution des prix demandés (annonces actives)",
583+ "bins": bins_p})
584+ area_bins = [("< 500", 100, 500), ("500–1 000", 500, 1000),
585+ ("1 000–1 500", 1000, 1500), ("1 500–2 000", 1500, 2000),
586+ ("2 000–2 500", 2000, 2500), ("2 500–3 000", 2500, 3000),
587+ ("3 000–4 000", 3000, 4000), ("4 000 +", 4000, 20000)]
588+ bins_a = [{"label": f"{lbl} pi²",
589+ "value": con.execute(
590+ "SELECT COUNT(*) n FROM listings WHERE active=1"
591+ " AND area_sqft>=? AND area_sqft<?" + VISIBLE,
592+ (lo, hi)).fetchone()["n"]}
593+ for lbl, lo, hi in area_bins]
594+ if sum(b["value"] for b in bins_a) >= 100:
595+ distributions.append({
596+ "id": "superficie", "unit": "annonces",
597+ "title": "Distribution des superficies habitables (renseignées)",
598+ "bins": bins_a})
599+ yr_now = today.year
600+ year_bins = ([("< 1900", 1600, 1900), ("1900–1949", 1900, 1950)] +
601+ [(f"{d}–{d+9}", d, d + 10) for d in range(1950, 2020, 10)] +
602+ [("2020 +", 2020, yr_now + 2)])
603+ bins_y = [{"label": lbl,
604+ "value": con.execute(
605+ "SELECT COUNT(*) n FROM listings WHERE active=1"
606+ " AND year_built>=? AND year_built<?" + VISIBLE,
607+ (lo, hi)).fetchone()["n"]}
608+ for lbl, lo, hi in year_bins]
609+ if sum(b["value"] for b in bins_y) >= 100:
610+ distributions.append({
611+ "id": "annee", "unit": "annonces",
612+ "title": "Distribution des années de construction (renseignées)",
613+ "bins": bins_y})
614+
615+ # ---- heatmap horaire (v2) : détection des nouvelles annonces (7×24) -------
616+ # first_seen = moment où la synchronisation a détecté l'annonce — c'est le
617+ # rythme réel d'alimentation de la plateforme (8 dernières semaines).
618+ h56 = _epoch(max(data_start, s_to - timedelta(days=55)))
619+ hourly_cells = [
620+ {"dow": (int(r["w"]) + 6) % 7, "hour": int(r["h"]), "value": r["n"]}
621+ for r in con.execute(
622+ "SELECT strftime('%w', first_seen,'unixepoch','localtime') w,"
623+ " strftime('%H', first_seen,'unixepoch','localtime') h, COUNT(*) n"
624+ " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
625+ " GROUP BY w, h", (h56, _epoch(s_to + timedelta(days=1))))]
626+ hourly = ({"title": "Détection de nouvelles annonces par heure"
627+ " (8 dernières semaines)", "cells": hourly_cells}
628+ if len(hourly_cells) >= 12 else None)
629+
630+ # ---- répartitions (photo des annonces actives) -----------------------------
631+ types = [{"label": r["t"] or "Autre / non précisé", "value": r["n"]}
632+ for r in con.execute(
633+ "SELECT property_type t, COUNT(*) n FROM listings"
634+ " WHERE active=1" + VISIBLE +
635+ " GROUP BY property_type ORDER BY n DESC LIMIT 9")]
636+ ranges = [("Moins de 200 k$", 0, 200e3), ("200 – 300 k$", 200e3, 300e3),
637+ ("300 – 400 k$", 300e3, 400e3), ("400 – 500 k$", 400e3, 500e3),
638+ ("500 – 750 k$", 500e3, 750e3), ("750 k$ – 1 M$", 750e3, 1e6),
639+ ("1 – 2 M$", 1e6, 2e6), ("2 M$ et plus", 2e6, None)]
640+ price_items = []
641+ for lbl, lo, hi in ranges:
642+ q = "SELECT COUNT(*) n FROM listings WHERE active=1 AND price>=?" + VISIBLE
643+ args: list = [lo]
644+ if hi is not None:
645+ q += " AND price<?"
646+ args.append(hi)
647+ price_items.append({"label": lbl,
648+ "value": con.execute(q, args).fetchone()["n"]})
649+ # nouvelles inscriptions de la période par fourchette + delta honnête
650+ # (vs période précédente entièrement observée seulement)
651+ new_price_items = []
652+ for lbl, lo, hi in ranges:
653+ base = ("SELECT COUNT(*) n FROM listings WHERE first_seen>=?"
654+ " AND first_seen<? AND price>=?" + VISIBLE)
655+ args_c: list = [ep_frm, ep_to, lo]
656+ args_p: list = [ep_pfrm, ep_pto, lo]
657+ if hi is not None:
658+ base += " AND price<?"
659+ args_c.append(hi)
660+ args_p.append(hi)
661+ n_c = con.execute(base, args_c).fetchone()["n"]
662+ item = {"label": lbl, "value": n_c}
663+ if prev_ok:
664+ n_p = con.execute(base, args_p).fetchone()["n"]
665+ if n_p:
666+ item["delta_pct"] = _fmt_pct(n_c, n_p)
667+ new_price_items.append(item)
668+ beds = [{"label": ("8 chambres et +" if r["b"] >= 8
669+ else f"{int(r['b'])} chambre" + ("s" if r["b"] > 1 else "")),
670+ "value": r["n"]}
671+ for r in con.execute(
672+ "SELECT MIN(bedrooms,8) b, COUNT(*) n FROM listings"
673+ " WHERE active=1 AND bedrooms IS NOT NULL" + VISIBLE +
674+ " GROUP BY MIN(bedrooms,8) ORDER BY b")]
675+ names = _source_names()
676+ by_source = [{"label": names.get(r["s"], r["s"]), "value": r["n"]}
677+ for r in con.execute(
678+ "SELECT source s, COUNT(*) n FROM listings"
679+ " WHERE active=1" + VISIBLE +
680+ " GROUP BY source ORDER BY n DESC LIMIT 12")]
681+ breakdowns = []
682+ if fv_n:
683+ breakdowns.append({
684+ "id": "fairvalue",
685+ "title": "Position des prix demandés vs estimation Vrai-Prix",
686+ "kind": "donut", "items": [
687+ {"label": "Sous le marché", "value": fv_sous},
688+ {"label": "Dans le marché", "value": fv_marche},
689+ {"label": "Au-dessus du marché", "value": fv_sur}]})
690+ breakdowns += [
691+ {"id": "types", "title": "Répartition par type de propriété",
692+ "kind": "donut", "items": types},
693+ {"id": "prix", "title": "Répartition par fourchette de prix demandé",
694+ "kind": "bar", "items": price_items},
695+ ]
696+ if sum(i["value"] for i in new_price_items):
697+ breakdowns.append({
698+ "id": "prix_nouvelles",
699+ "title": "Nouvelles inscriptions par fourchette de prix (période)",
700+ "kind": "bar", "items": new_price_items})
701+ if beds:
702+ breakdowns.append({"id": "chambres",
703+ "title": "Répartition par nombre de chambres (renseignées)",
704+ "kind": "bar", "items": beds})
705+ if by_source:
706+ breakdowns.append({"id": "sources",
707+ "title": "Top sources (annonces actives)",
708+ "kind": "bar", "items": by_source})
709+
710+ # ---- géographie : par région (fusion accents/casse, libellé le + fréquent)
711+ reg_counts: dict[str, dict[str, int]] = {}
712+ for r in con.execute(
713+ "SELECT region, COUNT(*) n FROM listings WHERE active=1"
714+ " AND region<>''" + VISIBLE + " GROUP BY region"):
715+ raw = (r["region"] or "").strip()
716+ key = _fold(raw)
717+ if not key or key.isdigit():
718+ continue
719+ reg_counts.setdefault(key, {})[raw] = reg_counts.get(key, {}).get(raw, 0) + r["n"]
720+ geo_items = []
721+ for key, variants in reg_counts.items():
722+ best_variant = max(variants, key=variants.get)
723+ geo_items.append({"label": best_variant, "value": sum(variants.values())})
724+ geo_items.sort(key=lambda x: -x["value"])
725+ geo = ({"title": "Annonces actives par région", "items": geo_items[:14]}
726+ if geo_items else None)
727+
728+ # ---- heatmap : nouvelles annonces par jour (26 dernières semaines max) ----
729+ h_frm = max(data_start, s_to - timedelta(days=181))
730+ hm = [{"date": r["d"], "value": r["n"]} for r in con.execute(
731+ "SELECT date(first_seen,'unixepoch','localtime') d, COUNT(*) n"
732+ " FROM listings WHERE first_seen>=? AND first_seen<?" + VISIBLE +
733+ " GROUP BY d", (_epoch(h_frm), _epoch(s_to + timedelta(days=1))))]
734+ heatmap = ({"title": "Nouvelles annonces par jour", "cells": hm}
735+ if len(hm) >= 2 else None)
736+
737+ # ---- tableaux ---------------------------------------------------------------
738+ # Top villes : actives, prix moyen/médian, nouvelles sur la période + delta
739+ city_prices: dict[str, list[float]] = {}
740+ for r in con.execute(
741+ "SELECT city, price FROM listings WHERE active=1 AND city<>''"
742+ + VISIBLE):
743+ city_prices.setdefault(r["city"], []).append(r["price"])
744+ new_city = {r["city"]: r["n"] for r in con.execute(
745+ "SELECT city, COUNT(*) n FROM listings WHERE city<>''"
746+ " AND first_seen>=? AND first_seen<?" + VISIBLE + " GROUP BY city",
747+ (ep_frm, ep_to))}
748+ new_city_prev = {r["city"]: r["n"] for r in con.execute(
749+ "SELECT city, COUNT(*) n FROM listings WHERE city<>''"
750+ " AND first_seen>=? AND first_seen<?" + VISIBLE + " GROUP BY city",
751+ (ep_pfrm, ep_pto))} if prev_ok else {}
752+ gone_city = {r["city"]: r["n"] for r in con.execute(
753+ "SELECT city, COUNT(*) n FROM listings WHERE active=0 AND city<>''"
754+ " AND last_seen>=? AND last_seen<?" + VISIBLE + " GROUP BY city",
755+ (ep_frm, ep_to))}
756+ top = sorted(city_prices.items(), key=lambda kv: -len(kv[1]))[:50]
757+ top_rows = []
758+ for city, ps in top:
759+ n_new = new_city.get(city, 0)
760+ n_prev = new_city_prev.get(city, 0)
761+ net = n_new - gone_city.get(city, 0)
762+ d = _fmt_pct(n_new, n_prev) if prev_ok and n_prev else None
763+ pn = [v for v in ps if isinstance(v, (int, float))] # prix NULL écartés
764+ top_rows.append([
765+ city, len(ps),
766+ _fmt_money(sum(pn) / len(pn)) if pn else "—",
767+ _fmt_money(statistics.median(pn)) if pn else "—", n_new,
768+ f"{'+' if net >= 0 else ''}{net}",
769+ (f"{'+' if d >= 0 else ''}{str(d).replace('.', ',')} %"
770+ if d is not None else "—"),
771+ ])
772+ tables = [{
773+ "id": "top_villes", "title": "Top villes",
774+ "columns": ["Ville", "Actives", "Prix moyen", "Prix médian",
775+ "Nouvelles (période)", "Δ net (période)", "Var. nouvelles"],
776+ "rows": top_rows,
777+ }]
778+ # Top sources : actives, prix moyen, nouvelles, qualité, quarantaine, synchro
779+ top_srcs = con.execute(
780+ """SELECT source s, COUNT(*) n,
781+ AVG(CASE WHEN price>0 THEN price END) avg_p,
782+ SUM(CASE WHEN first_seen>=? AND first_seen<? THEN 1 ELSE 0 END) new_n,
783+ ROUND(AVG(quality_score),0) qual
784+ FROM listings WHERE active=1""" + VISIBLE +
785+ " GROUP BY source ORDER BY n DESC LIMIT 50", (ep_frm, ep_to)).fetchall()
786+ quar_src = {r["s"]: r["n"] for r in con.execute(
787+ "SELECT source s, COUNT(*) n FROM listings"
788+ " WHERE active=1 AND dup_hidden=0 AND published=0 GROUP BY source")}
789+ last_sync = {r["source"]: r["ts"] for r in con.execute(
790+ "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
791+ if top_srcs:
792+ src_rows = []
793+ for r in top_srcs:
794+ ls = last_sync.get(r["s"])
795+ src_rows.append([
796+ names.get(r["s"], r["s"]), r["n"],
797+ _fmt_money(r["avg_p"]) if r["avg_p"] else "—",
798+ r["new_n"],
799+ f"{r['qual']:.0f} /100" if r["qual"] is not None else "—",
800+ quar_src.get(r["s"], 0),
801+ (datetime.fromtimestamp(ls, TZ).strftime("%Y-%m-%d %H:%M")
802+ if ls else "—")])
803+ tables.append({
804+ "id": "top_sources", "title": "Top sources & courtiers",
805+ "columns": ["Source", "Annonces actives", "Prix moyen",
806+ "Nouvelles (période)", "Qualité", "Quarantaine",
807+ "Dernière synchro"],
808+ "rows": src_rows})
809+ # Délai de présence (retirées de la période) par ville
810+ dur_city: dict[str, list[float]] = {}
811+ for r in con.execute(
812+ "SELECT city, (last_seen-first_seen)/86400.0 d FROM listings"
813+ " WHERE active=0 AND city<>'' AND last_seen>=? AND last_seen<?"
814+ + VISIBLE, (ep_frm, ep_to)):
815+ dur_city.setdefault(r["city"], []).append(max(r["d"], 0.0))
816+ dur_rows = []
817+ for city, ds in sorted(dur_city.items(), key=lambda kv: -len(kv[1]))[:50]:
818+ if len(ds) < 3:
819+ continue
820+ dur_rows.append([
821+ city, len(ds),
822+ str(round(sum(ds) / len(ds), 1)).replace(".", ","),
823+ str(round(statistics.median(ds), 1)).replace(".", ","),
824+ ])
825+ if dur_rows:
826+ tables.append({
827+ "id": "delai_villes",
828+ "title": "Délai de présence avant retrait, par ville (période)",
829+ "columns": ["Ville", "Retirées", "Délai moyen (j)", "Délai médian (j)"],
830+ "rows": dur_rows,
831+ })
832+ # Écart moyen à l'estimation Vrai-Prix par ville (volume suffisant)
833+ fv_rows_city = []
834+ for city, devs in sorted(fv_city.items(), key=lambda kv: -len(kv[1]))[:25]:
835+ if len(devs) < 50:
836+ continue
837+ dev_pct = round(100.0 * sum(devs) / len(devs), 1)
838+ fv_rows_city.append([
839+ city, len(devs),
840+ f"{'+' if dev_pct >= 0 else ''}{str(dev_pct).replace('.', ',')} %",
841+ sum(1 for d in devs if d <= FV_SEUIL_SOUS)])
842+ if fv_rows_city:
843+ tables.append({
844+ "id": "fv_villes",
845+ "title": "Écart à l'estimation Vrai-Prix par ville",
846+ "columns": ["Ville", "Annonces évaluées", "Écart moyen",
847+ "Sous le marché"],
848+ "rows": fv_rows_city})
849+ # Couche qualité : anomalies & quarantaine par motif (quality.py)
850+ anomalies: dict[str, int] = {}
851+ for r in con.execute(
852+ "SELECT quality_issues FROM listings WHERE active=1"
853+ " AND dup_hidden=0 AND quality_issues IS NOT NULL"):
854+ try:
855+ for issue in json.loads(r["quality_issues"]):
856+ key = issue.split(":")[0]
857+ anomalies[key] = anomalies.get(key, 0) + 1
858+ except ValueError:
859+ continue
860+ if anomalies and act_all:
861+ tables.append({
862+ "id": "quarantaine_motifs",
863+ "title": "Couche qualité — anomalies et quarantaine par motif",
864+ "columns": ["Motif", "Annonces touchées", "% des actives"],
865+ "rows": [[_MOTIFS_QUALITE.get(k, k), v,
866+ str(round(100.0 * v / act_all, 1)).replace(".", ",") + " %"]
867+ for k, v in sorted(anomalies.items(), key=lambda kv: -kv[1])],
868+ })
869+ # Bannières / familles de connecteurs : volume, nouvelles, prix, fraîcheur
870+ fam_info: dict[str, dict] = {}
871+ for r in con.execute(
872+ "SELECT source s, price p FROM listings WHERE active=1" + VISIBLE):
873+ e = fam_info.setdefault(_famille_of(r["s"], names),
874+ {"srcs": set(), "prices": [], "new": 0,
875+ "sync": None})
876+ e["srcs"].add(r["s"])
877+ e["prices"].append(r["p"])
878+ for r in con.execute(
879+ "SELECT source s, COUNT(*) n FROM listings WHERE first_seen>=?"
880+ " AND first_seen<?" + VISIBLE + " GROUP BY source",
881+ (ep_frm, ep_to)):
882+ fam = _famille_of(r["s"], names)
883+ if fam in fam_info:
884+ fam_info[fam]["new"] += r["n"]
885+ for src, ts in last_sync.items():
886+ fam = _famille_of(src, names)
887+ if fam in fam_info:
888+ e = fam_info[fam]
889+ e["sync"] = max(e["sync"] or 0, ts)
890+ if fam_info:
891+ fam_rows = []
892+ for fam, e in sorted(fam_info.items(),
893+ key=lambda kv: -len(kv[1]["prices"]))[:30]:
894+ fam_rows.append([
895+ fam, len(e["srcs"]), len(e["prices"]), e["new"],
896+ (_fmt_money(statistics.median(pn))
897+ if (pn := [v for v in e["prices"]
898+ if isinstance(v, (int, float))]) else "—"),
899+ (datetime.fromtimestamp(e["sync"], TZ).strftime("%Y-%m-%d %H:%M")
900+ if e["sync"] else "—")])
901+ tables.append({
902+ "id": "familles",
903+ "title": "Bannières & familles de connecteurs",
904+ "columns": ["Bannière / famille", "Connecteurs", "Annonces actives",
905+ "Nouvelles (période)", "Prix médian",
906+ "Dernière synchro"],
907+ "rows": fam_rows})
908+
909+ # ---- records & faits marquants ---------------------------------------------
910+ records = []
911+ if new_by_day:
912+ best = max(new_by_day.items(), key=lambda kv: kv[1])
913+ records.append({"label": "Jour record de nouvelles annonces",
914+ "value": f"{best[1]:,} annonces".replace(",", " "),
915+ "date": best[0]})
916+ if gone_by_day:
917+ worst = max(gone_by_day.items(), key=lambda kv: kv[1])
918+ records.append({"label": "Jour record de retraits",
919+ "value": f"{worst[1]:,} annonces".replace(",", " "),
920+ "date": worst[0]})
921+ fast = con.execute(
922+ "SELECT city, address, (last_seen-first_seen)/86400.0 d,"
923+ " date(last_seen,'unixepoch','localtime') dt FROM listings"
924+ " WHERE active=0 AND last_seen>=? AND last_seen<?"
925+ " AND last_seen-first_seen>=3600" # >= 1 h : écarte les artefacts de sync
926+ + VISIBLE + " ORDER BY (last_seen-first_seen) ASC LIMIT 1",
927+ (ep_frm, ep_to)).fetchone()
928+ if fast:
929+ d = fast["d"]
930+ val = (f"{round(d * 24, 1)} h" if d < 1 else f"{round(d, 1)} j").replace(".", ",")
931+ records.append({"label": "Retrait le plus rapide (mise en ligne → retrait)",
932+ "value": val + (f" · {fast['city']}" if fast["city"] else ""),
933+ "date": fast["dt"]})
934+ if drops: # balayage price_log fait plus haut (KPI baisses_prix)
935+ drop = drops[0]
936+ records.append({"label": "Plus forte baisse de prix demandé",
937+ "value": "−" + _fmt_money(drop["amt"]) +
938+ (f" · {drop['city']}" if drop["city"] else ""),
939+ "date": drop["dt"]})
940+ if new_city:
941+ c, n = max(new_city.items(), key=lambda kv: kv[1])
942+ records.append({"label": "Ville la plus active (nouvelles annonces)",
943+ "value": f"{c} — {n:,} annonces".replace(",", " ")})
944+ if top_srcs:
945+ src = max(top_srcs, key=lambda r: r["new_n"])
946+ if src["new_n"]:
947+ records.append({"label": "Source la plus active (nouvelles annonces)",
948+ "value": f"{names.get(src['s'], src['s'])}"
949+ f" — {src['new_n']:,}".replace(",", " ")})
950+ top_price = con.execute(
951+ "SELECT city, price FROM listings WHERE active=1" + VISIBLE +
952+ " AND price BETWEEN ? AND ? ORDER BY price DESC LIMIT 1",
953+ (PRICE_MIN, PRICE_MAX)).fetchone()
954+ if top_price:
955+ records.append({"label": "Inscription active la plus chère",
956+ "value": _fmt_money(top_price["price"]) +
957+ (f" · {top_price['city']}"
958+ if top_price["city"] else "")})
959+ med_cities = {c: statistics.median(pn) for c, ps in city_prices.items()
960+ if len(pn := [v for v in ps
961+ if isinstance(v, (int, float))]) >= 30}
962+ if med_cities:
963+ c_hi = max(med_cities, key=med_cities.get)
964+ c_lo = min(med_cities, key=med_cities.get)
965+ records.append({"label": "Ville la plus chère (prix médian, ≥ 30 annonces)",
966+ "value": f"{c_hi} — {_fmt_money(med_cities[c_hi])}"})
967+ records.append({"label": "Ville la plus abordable (prix médian, ≥ 30 annonces)",
968+ "value": f"{c_lo} — {_fmt_money(med_cities[c_lo])}"})
969+ big_area = con.execute(
970+ "SELECT city, area_sqft a FROM listings WHERE active=1" + VISIBLE +
971+ " AND area_sqft BETWEEN 100 AND 50000"
972+ " ORDER BY area_sqft DESC LIMIT 1").fetchone()
973+ if big_area:
974+ records.append({"label": "Plus grande superficie habitable (plausible)",
975+ "value": f"{round(big_area['a']):,} pi²".replace(",", " ") +
976+ (f" · {big_area['city']}"
977+ if big_area["city"] else "")})
978+ if fam_info:
979+ fam_big = max(fam_info.items(), key=lambda kv: len(kv[1]["srcs"]))
980+ if len(fam_big[1]["srcs"]) > 1:
981+ records.append({"label": "Bannière au plus grand réseau agrégé",
982+ "value": f"{fam_big[0]} — "
983+ f"{len(fam_big[1]['srcs'])} connecteurs"})
984+
985+ out = {
986+ "updated": datetime.now(TZ).isoformat(timespec="seconds"),
987+ "period": {"from": _iso(frm), "to": _iso(to), "label": label,
988+ "observed_from": _iso(data_start)},
989+ "kpis": kpis,
990+ "series": series,
991+ "breakdowns": breakdowns,
992+ "tables": tables,
993+ "records": records,
994+ }
995+ if gauges:
996+ out["gauges"] = gauges
997+ if multiseries:
998+ out["multiseries"] = multiseries
999+ if stacked:
1000+ out["stacked"] = stacked
1001+ if distributions:
1002+ out["distributions"] = distributions
1003+ if geo:
1004+ out["geo"] = geo
1005+ if heatmap:
1006+ out["heatmap"] = heatmap
1007+ if hourly:
1008+ out["hourly"] = hourly
1009+ try:
1010+ from . import statsextra, statsfiche
1011+ pnls = statsfiche.panels(con) + statsextra.panels(con)
1012+ if pnls:
1013+ out["panels"] = pnls
1014+ except Exception:
1015+ pass
1016+ return out
1017+
1018+
1019+def dashboard(period: str | None = None, frm: str | None = None,
1020+ to: str | None = None) -> dict:
1021+ key = f"{period or ''}|{frm or ''}|{to or ''}"
1022+ now = time.time()
1023+ with _CACHE_LOCK:
1024+ hit = _CACHE.get(key)
1025+ if hit and now - hit[0] < _CACHE_TTL:
1026+ return hit[1]
1027+ data = _compute(frm, to, period)
1028+ with _CACHE_LOCK:
1029+ _CACHE[key] = (time.time(), data)
1030+ # garder le cache borné
1031+ if len(_CACHE) > 64:
1032+ for k in sorted(_CACHE, key=lambda k: _CACHE[k][0])[:32]:
1033+ _CACHE.pop(k, None)
1034+ return data
added immoka/vraiprix_local.py +187 −0
@@ -0,0 +1,437 @@
1+# -----------------------------------------------------------------------------
2+# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# vraiprix_local.py : appariement LOCAL contre la base Vrai-Prix (vraiprix.db,
5+# 3,7 M unités d'évaluation avec adresse, lat/lng, estimation, fourchette).
6+# Une jointure d'adresse (FTS) par annonce → remplit d'un coup :
7+# · lat/lng manquants (géocodage instantané, sans API externe)
8+# · l'estimation Vrai-Prix (valeur + P10-P90 + lien /estimation/{id})
9+# Bien plus rapide que l'API vrai-prix (une requête réseau par annonce).
10+# -----------------------------------------------------------------------------
11+from __future__ import annotations
12+
13+import json
14+import os
15+import re
16+import sqlite3
17+import time
18+import unicodedata
19+
20+from . import db
21+
22+# Emplacement de la base Vrai-Prix (copiée à côté de immoka.db sur le nœud).
23+VP_DB = os.environ.get(
24+ "VRAIPRIX_DB",
25+ str((__import__("pathlib").Path(__file__).resolve().parent.parent
26+ / "data" / "vraiprix.db")))
27+SITE = "https://www.vrai-prix.com"
28+_BBOX = (44.5, 63.0, -80.0, -56.0) # Québec
29+
30+
31+def _norm(s: str) -> str:
32+ return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower())
33+ if unicodedata.category(c) != "Mn").strip()
34+
35+
36+# mots de « type de voie » ignorés dans l'appariement (variabilité rue/av/boul…)
37+_VOIE = {"rue", "av", "ave", "avenue", "boul", "boulevard", "bd", "blvd",
38+ "ch", "chemin", "place", "pl", "rang", "rangs", "rg", "montee",
39+ "montée", "mtee", "cote", "côte", "route", "rte", "terrasse", "tsse",
40+ "ter", "impasse", "imp", "croissant", "crois", "croiss", "cours",
41+ "allee", "allée", "prom", "promenade", "carre", "aut", "autoroute",
42+ "de", "du", "des", "la", "le", "les", "l", "d", "et", "sur",
43+ "est", "ouest", "nord", "sud", "st", "ste", "saint", "sainte"}
44+
45+_APP_RE = r"\b(?:app?t?|appartement|unite|suite|local|bureau|#)\s*[\w-]+"
46+
47+
48+def _street_words(norm_addr: str) -> set:
49+ """Mots significatifs de la rue (sans n° civique, type de voie, n° d'app.)."""
50+ a = re.sub(_APP_RE, " ", norm_addr.split(",")[0])
51+ a = re.sub(r"[^a-z0-9 ]+", " ", a)
52+ a = re.sub(r"^\s*\d+[a-z]{0,2}(?:\s+\d+)?\s+", " ", a) # civique(s) en tête
53+ toks = [t for t in a.split() if t]
54+ return {t for t in toks if t not in _VOIE and len(t) > 1}
55+
56+
57+def _addr_parts(address: str) -> tuple[list, list]:
58+ """→ (civiques candidats, mots de rue). Gère « 822Z » (suffixe de lettre),
59+ « 102 50 Rue X » (app-civique : les deux nombres sont candidats) et
60+ conserve les rues numériques (« Route 202 », « 117e Avenue »)."""
61+ a = _norm(address).split(",")[0]
62+ a = re.sub(_APP_RE, " ", a)
63+ a = re.sub(r"[^a-z0-9 ]+", " ", a)
64+ toks = [t for t in a.split() if t]
65+ civs = []
66+ while toks and len(civs) < 2:
67+ m = re.match(r"^(\d+)[a-z]{0,2}$", toks[0])
68+ if not m:
69+ break
70+ if civs and not toks[0].isdigit(): # ordinal de rue (2e, 3e…) : garder
71+ break
72+ civs.append(m.group(1))
73+ toks = toks[1:]
74+ words = [t for t in toks if t not in _VOIE and len(t) > 1]
75+ return civs, words
76+
77+
78+def _fts_query(address: str) -> tuple[str, str]:
79+ """Requête FTS AND (n° civique + mots significatifs de la rue)."""
80+ civs, words = _addr_parts(address)
81+ civ = civs[-1] if civs else ""
82+ parts = ([civ] if civ else []) + words
83+ if not parts:
84+ return "", civ
85+ return " AND ".join(f'"{p}"' for p in parts), civ
86+
87+
88+def available() -> bool:
89+ return os.path.exists(VP_DB)
90+
91+
92+def _pack(r) -> dict:
93+ d = {
94+ "id": r["id_provinc"], "lat": r["lat"], "lng": r["lng"],
95+ "value": r["est_hedo"] or r["est_2026"], "low": r["p10"], "high": r["p90"],
96+ "confidence": None, "confidence_pct": None,
97+ "url": f"{SITE}/estimation/{r['id_provinc']}",
98+ }
99+ d.update(_role_fields(r))
100+ return d
101+
102+
103+# clés « rôle d'évaluation » ajoutées au JSON vraiprix (valeurs officielles)
104+ROLE_KEYS = ("valeur_role", "valeur_terrain", "valeur_batiment",
105+ "annee_construction_role", "superficie_terrain_role_m2",
106+ "aire_etages_role_m2")
107+
108+
109+def _role_fields(r) -> dict:
110+ """Champs du rôle d'évaluation foncière de l'unité appariée (officiels) :
111+ valeurs (rôle/terrain/bâtiment), année de construction et superficies."""
112+ out = {}
113+ for src, dst in (("valeur_role", "valeur_role"),
114+ ("valeur_terrain", "valeur_terrain"),
115+ ("valeur_batiment", "valeur_batiment"),
116+ ("annee_construction", "annee_construction_role"),
117+ ("superficie_terrain_m2", "superficie_terrain_role_m2"),
118+ ("aire_etages_m2", "aire_etages_role_m2")):
119+ try:
120+ v = r[src]
121+ except (KeyError, IndexError):
122+ v = None
123+ if v:
124+ out[dst] = v
125+ return out
126+
127+
128+# le terrain du rôle d'une COPROPRIÉTÉ est souvent celui de l'immeuble entier :
129+# jamais de repli lot_sqft pour ces types
130+_NO_LOT_TYPES = ("condo", "appartement", "loft", "copropriete")
131+
132+
133+def _apply_role_fallback(con, uid: str, year_built, lot_sqft,
134+ property_type: str, est: dict) -> tuple[int, int]:
135+ """Repli des COLONNES depuis le rôle quand la source ne fournit rien :
136+ year_built ← annee_construction_role, lot_sqft ← superficie_terrain_role_m2
137+ (sauf copropriétés). Provenance marquée dans details.*_source='role'.
138+ Retourne (année_remplie, terrain_rempli) ∈ {0,1}²."""
139+ fy = fl = 0
140+ y = est.get("annee_construction_role")
141+ if year_built is None and y and 1600 <= int(y) <= 2049:
142+ con.execute(
143+ "UPDATE listings SET year_built=?,"
144+ " details=json_set(COALESCE(details,'{}'),'$.year_built_source','role')"
145+ " WHERE uid=? AND year_built IS NULL", (int(y), uid))
146+ fy = 1
147+ t = est.get("superficie_terrain_role_m2")
148+ pt = _norm(property_type or "")
149+ if (lot_sqft is None and t and float(t) > 0
150+ and not any(k in pt for k in _NO_LOT_TYPES)):
151+ con.execute(
152+ "UPDATE listings SET lot_sqft=?,"
153+ " details=json_set(COALESCE(details,'{}'),'$.lot_sqft_source','role')"
154+ " WHERE uid=? AND lot_sqft IS NULL",
155+ (round(float(t) * 10.7639), uid))
156+ fl = 1
157+ return fy, fl
158+
159+
160+def _meters(a1: float, o1: float, a2: float, o2: float) -> float:
161+ """Distance approx. en mètres (équirectangulaire, ~exact à courte portée)."""
162+ import math
163+ dlat = (a2 - a1) * 111_000.0
164+ dlng = (o2 - o1) * 111_000.0 * math.cos(math.radians(a1))
165+ return (dlat * dlat + dlng * dlng) ** 0.5
166+
167+
168+# mots génériques ignorés dans la comparaison de municipalités
169+_MUNI_GEN = {"saint", "sainte", "ville", "de", "du", "des", "la", "le", "les",
170+ "sur", "au", "aux", "lac", "notre", "dame", "canton", "cantons",
171+ "municipalite", "paroisse", "village", "mont"}
172+
173+
174+def _muni_norm(s: str) -> str:
175+ s = re.sub(r"[^a-z0-9 ]+", " ", _norm(s or ""))
176+ s = re.sub(r"\bst\b", "saint", s)
177+ s = re.sub(r"\bste\b", "sainte", s)
178+ return " ".join(s.split())
179+
180+
181+def _muni_one(nc: str, um: str) -> bool:
182+ if nc in um or um in nc:
183+ return True
184+ return bool((set(nc.split()) - _MUNI_GEN) & (set(um.split()) - _MUNI_GEN))
185+
186+
187+def _muni_match(city: str, unit_muni: str, address: str = "") -> bool:

Diff truncated — file too large.

Menu

Theme