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.
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("&", "&")).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.