SPB Git forge

spb/house-ka

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

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

91 changed files +20,001 −0

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.