SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 64% TypeScript 21.1% CSS 14.3% HTML 0.6%

feat: initial commit

Simon-Pierre Boucher committed 2 h ago (Aug 12, 2026)

Showing 56 changed files with +9,175 and −0

added .claude/scheduled_tasks.lock +1 −0
@@ -0,0 +1 @@
1 +{"sessionId":"c9b9d228-3b87-491a-943d-e0458a7449fb","pid":46756,"procStart":"Wed Aug 12 10:21:44 2026","acquiredAt":1786533230546}
\ No newline at end of file
added .gitignore +12 −0
@@ -0,0 +1,12 @@
1 +.venv/
2 +__pycache__/
3 +*.pyc
4 +.env
5 +data/immoka.db
6 +data/*.db-journal
7 +data/*.db-wal
8 +data/*.db-shm
9 +node_modules/
10 +frontend/dist/
11 +frontend/tsconfig.tsbuildinfo
12 +.DS_Store
added README.md +142 −0
@@ -0,0 +1,142 @@
1 +<div align="center">
2 +
3 +# Immo·Ka
4 +
5 +### Toutes les propriétés à vendre du Québec. Un seul endroit.
6 +
7 +**[www.immo-ka.com](https://www.immo-ka.com)**
8 +
9 +![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=c7f230)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=c7f230)
11 +![React](https://img.shields.io/badge/React_18-Vite_+_TS-141814?style=for-the-badge&logo=react&logoColor=c7f230)
12 +![MapLibre](https://img.shields.io/badge/MapLibre-carte-141814?style=for-the-badge&logo=maplibre&logoColor=c7f230)
13 +![SQLite](https://img.shields.io/badge/SQLite-WAL-141814?style=for-the-badge&logo=sqlite&logoColor=c7f230)
14 +
15 +![Propriétés](https://img.shields.io/badge/propri%C3%A9t%C3%A9s_agr%C3%A9g%C3%A9es-40_000%2B-1c5c41?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs-73-1c5c41?style=flat-square)
17 +![Agences](https://img.shields.io/badge/agences_actives-21-1c5c41?style=flat-square)
18 +![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-1c5c41?style=flat-square)
19 +![MàJ](https://img.shields.io/badge/mise_%C3%A0_jour-automatique-1c5c41?style=flat-square)
20 +
21 +*Agrégateur indépendant des maisons, condos et terrains à vendre au Québec —
22 +un connecteur dédié par agence de courtage, chaque propriété normalisée vers un
23 +schéma unique, avec galerie photo, caractéristiques complètes et lien direct vers
24 +l'annonce originale de l'agence. Toujours à jour, automatiquement.*
25 +
26 +</div>
27 +
28 +---
29 +
30 +## Aperçu
31 +
32 +| Accueil — liste, filtres & carte | Fiche propriété — galerie & specs |
33 +|---|---|
34 +| ![Accueil Immo-Ka](docs/screenshots/home.png) | ![Fiche propriété](docs/screenshots/listing.png) |
35 +
36 +## Pourquoi Immo-Ka ?
37 +
38 +Chercher une propriété à vendre au Québec, c'est jongler entre les sites de RE/MAX,
39 +Royal LePage, Sutton, Via Capitale, Century 21 et des dizaines d'autres bannières —
40 +chacun avec sa navigation, ses filtres, son format. **Immo-Ka retourne le problème** :
41 +un connecteur par agence visite chaque source, normalise chaque propriété vers un
42 +schéma unique, et détecte les changements en continu.
43 +
44 +> Les sites d'agences n'offrent pas de webhooks. Immo-Ka reproduit l'équivalent :
45 +> **synchronisation périodique + hash de contenu** → ajouts, changements de prix et
46 +> retraits (propriété vendue) détectés automatiquement.
47 +
48 +C'est le pendant « à vendre » de [Lou-Ka](https://www.lou-ka.com) (location) — même
49 +architecture, schéma adapté à la vente.
50 +
51 +## Couverture (métriques en direct)
52 +
53 +| Agence | Propriétés | Méthode technique |
54 +|---|---:|---|
55 +| RE/MAX Québec | ~25 300 | Meilisearch public + sharding FSA postal + page détail |
56 +| Via Capitale | ~5 250 | portail central (Firecrawl stealth, pagination `criteresJson`) |
57 +| Sutton | ~4 500 | sitemap `sitemap_inscription.cfm` → n° Centris |
58 +| Proprio Direct | ~2 440 | API interne JSON |
59 +| Engel & Völkers | ~1 240 | property-sitemap → JSON-LD |
60 +| Royal LePage | ~1 000+ | recherche par ville filtrée QC + sharding + dédup MLS |
61 +| Century 21 | ~1 600 | sous-agences source.immo (« All Quebec ») |
62 +| Keller Williams (Urbain + Distinction) | ~510 | HTML rendu serveur + sitemap |
63 +| Barnes Québec | ~340 | Algolia + page détail |
64 +| Profusion (Christie's) | ~360 | WordPress + JSON-LD |
65 +| M Immobilier · Vendirect · Sotheby's · L'Expert PM | ~180 | Centris / source.immo / JSON-LD |
66 +| Sous-agences RE/MAX / Via Capitale / Century 21 | *backup* | 60+ connecteurs `*_ag_*`, dédup par n° Centris |
67 +
68 +**≈ 40 000 propriétés dédupliquées**, mises à jour automatiquement. Les sous-agences
69 +servent de plan B : si le flux central d'une bannière tombe, elles prennent le relais
70 +sans double-comptage (déduplication par numéro Centris).
71 +
72 +## Architecture
73 +
74 +```mermaid
75 +flowchart LR
76 + subgraph Sources["Agences de courtage du Québec"]
77 + S1["RE/MAX · Royal LePage · Sutton<br/>Via Capitale · Century 21 · Proprio Direct<br/>Engel & Völkers · Barnes · Profusion<br/>… 73 connecteurs"]
78 + end
79 + subgraph ImmoKa["Immo-Ka"]
80 + C["Connecteurs<br/><i>1 adaptateur / agence</i>"] --> N["Normalisation<br/><i>schéma PropertyListing</i>"]
81 + N --> D[("SQLite WAL<br/>hash + diff + dédup Centris")]
82 + D --> A["API FastAPI<br/>/api/listings · /api/facets · geojson"]
83 + A --> F["React 18 + Vite + MapLibre<br/>liste · carte · fiche détaillée"]
84 + end
85 + W["⏱ Watcher (PM2)"] -.-> C
86 + S1 --> C
87 + F --> U["🔑 Acheteur"]
88 +```
89 +
90 +| Couche | Rôle | Fichiers |
91 +|---|---|---|
92 +| **Connecteurs** | 1 module par agence : API JSON interne (Meilisearch, Algolia, source.immo, wp-json), JSON-LD, sitemap, ou Firecrawl pour les sites SPA/anti-bot | `immoka/connectors/*.py` |
93 +| **Schéma** | `PropertyListing` : adresse, secteur, ville, région, type, prix, chambres/SdB, superficie, terrain, année, MLS, courtier, pièces (dimensions), **toutes les photos** | `immoka/schema.py` |
94 +| **Diff engine** | upsert par hash de contenu, délai de grâce, détection de dérive, historique de prix, **déduplication inter-agences par n° Centris** | `immoka/db.py`, `immoka/web.py` |
95 +| **API** | filtres ville/secteur/région/type/agence/prix/chambres, facettes, stats, GeoJSON | `immoka/web.py` |
96 +| **Frontend** | React 18 + Vite + TS + react-router + MapLibre : accueil (liste/carte), fiche par propriété (galerie + lightbox, caractéristiques, pièces, historique de prix, courtier, mini-carte), Stats, Agences | `frontend/` |
97 +
98 +## Démarrage rapide
99 +
100 +```bash
101 +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
102 +
103 +# frontend
104 +cd frontend && npm install && npm run build && cd ..
105 +
106 +# (optionnel) sites JavaScript / anti-bot
107 +echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
108 +
109 +.venv/bin/python run.py list # connecteurs enregistrés
110 +.venv/bin/python run.py sync # toutes les agences (ou: sync remax_quebec sutton)
111 +.venv/bin/python run.py serve 8090 # API + frontend → http://localhost:8090
112 +.venv/bin/python run.py watch 180 # synchronisation en boucle
113 +```
114 +
115 +## Ajouter une agence
116 +
117 +1. Créer `immoka/connectors/<id>.py` : sous-classe de `BaseConnector`, définir
118 + `source_id`, implémenter `fetch() -> list[PropertyListing]`. Le registre est
119 + **auto-découvrant** — aucun fichier partagé à modifier.
120 +2. Ajouter l'entrée dans `data/sources.json`.
121 +3. Tester : `.venv/bin/python run.py sync <id>`.
122 +
123 +Pour une bannière à sous-agences (RE/MAX, Via Capitale, Century 21), un module
124 +génère un connecteur par sous-agence depuis un registre JSON — nommer les
125 +`source_id` en `<banniere>_ag_<slug>` active automatiquement la déduplication.
126 +
127 +## Déploiement
128 +
129 +Déployé sur le cluster MacLustr (nœud M4M64a) via PM2 :
130 +`immo-ka-web` (API + frontend), `immo-ka-sync` (watcher horaire), `immo-ka-ngrok`
131 +(tunnel `www.immo-ka.com`). Base SQLite en mode WAL pour les accès concurrents.
132 +
133 +---
134 +
135 +## Auteur
136 +
137 +**Simon-Pierre Boucher**
138 +📧 [contact@spboucher.ai](mailto:contact@spboucher.ai)
139 +
140 +*Agrégateur indépendant — les annonces proviennent des sites publics des agences de
141 +courtage et sont rafraîchies automatiquement ; chaque fiche renvoie vers l'annonce
142 +originale de l'agence.*
added data/century21_agencies.json +20 −0
@@ -0,0 +1,20 @@
1 +[
2 + {
3 + "banner": "quebec",
4 + "domain": "quebec.c21.ca",
5 + "account": "e728ef88-d30f-4d7f-a70a-48e51d7192be",
6 + "api": "9d6d59f3-d804-4b31-8a82-36c22a442097",
7 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
8 + "view": "a713184b-848d-4c89-b9fd-d20e2c170441",
9 + "note": "Compte source.immo « Century 21 - All Quebec » (partagé par quebec.c21.ca / excel.c21.ca / century21immoplus.com) — inventaire provincial complet C21."
10 + },
11 + {
12 + "banner": "vision",
13 + "domain": "vision.c21.ca",
14 + "account": "1a90fdfc-2d65-4adf-a422-f1afcb6d294b",
15 + "api": "300bfc22-883c-4991-83cf-1f49dd72f905",
16 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
17 + "view": "62380760-d31e-49ce-83c7-76edcfa0a417",
18 + "note": "Century 21 Vision (Gatineau/Outaouais) — compte source.immo distinct."
19 + }
20 +]
added data/remax_agencies.json +272 −0
@@ -0,0 +1,272 @@
1 +[
2 + {
3 + "banner": "remax-1erchoix",
4 + "domain": "https://www.remax1erchoix.com",
5 + "platform": "nos-proprietes"
6 + },
7 + {
8 + "banner": "remax-2000",
9 + "domain": "https://www.remax-2000.com",
10 + "platform": "centris-other"
11 + },
12 + {
13 + "banner": "remax-2001",
14 + "domain": "https://www.remax2001.com",
15 + "platform": "nos-proprietes"
16 + },
17 + {
18 + "banner": "remax-3000",
19 + "domain": "https://remax3000.com",
20 + "platform": "nos-proprietes"
21 + },
22 + {
23 + "banner": "remax-acces",
24 + "domain": "https://www.remaxacces.com",
25 + "platform": "nos-proprietes"
26 + },
27 + {
28 + "banner": "remax-action",
29 + "domain": "https://remax-action.ca",
30 + "platform": "nos-proprietes"
31 + },
32 + {
33 + "banner": "remax-alliance",
34 + "domain": "https://www.remax-alliance.ca",
35 + "platform": "nos-proprietes"
36 + },
37 + {
38 + "banner": "remax-avantages",
39 + "domain": "https://www.remax-avantages.com",
40 + "platform": "nos-proprietes"
41 + },
42 + {
43 + "banner": "remax-avanttout",
44 + "domain": "https://www.remaxavanttout.com",
45 + "platform": "nos-proprietes"
46 + },
47 + {
48 + "banner": "remax-cite",
49 + "domain": "https://remax-cite.com",
50 + "platform": "nos-proprietes"
51 + },
52 + {
53 + "banner": "remax-crystal",
54 + "domain": "https://remaxcrystal.com",
55 + "platform": "nos-proprietes"
56 + },
57 + {
58 + "banner": "remax-d-ici",
59 + "domain": "https://remaxdici.com",
60 + "platform": "nos-proprietes"
61 + },
62 + {
63 + "banner": "remax-dabord",
64 + "domain": "https://www.remax-dabord.com",
65 + "platform": "centris-other"
66 + },
67 + {
68 + "banner": "remax-defi",
69 + "domain": "https://remax-defi1996.com",
70 + "platform": "nos-proprietes"
71 + },
72 + {
73 + "banner": "remax-defrancheville",
74 + "domain": "https://remaxdefrancheville.com",
75 + "platform": "nos-proprietes"
76 + },
77 + {
78 + "banner": "remax-direct",
79 + "domain": "https://remax-direct.com",
80 + "platform": "nos-proprietes"
81 + },
82 + {
83 + "banner": "remax-distinction",
84 + "domain": "https://remaxdistinction.com/",
85 + "platform": "nos-proprietes"
86 + },
87 + {
88 + "banner": "remax-ducartier",
89 + "domain": "https://www.remaxducartier.com",
90 + "platform": "centris-other"
91 + },
92 + {
93 + "banner": "remax-ducartieractif",
94 + "domain": "https://www.remaxactif.com",
95 + "platform": "centris-other"
96 + },
97 + {
98 + "banner": "remax-ducartierbonjour",
99 + "domain": "https://remaxbonjour.com",
100 + "platform": "centris-other"
101 + },
102 + {
103 + "banner": "remax-dynamique",
104 + "domain": "https://remaxdynamique.com",
105 + "platform": "nos-proprietes"
106 + },
107 + {
108 + "banner": "remax-dynastie",
109 + "domain": "https://remax-dynastie.com",
110 + "platform": "centris-other"
111 + },
112 + {
113 + "banner": "remax-elegance",
114 + "domain": "https://remax-elegance.ca",
115 + "platform": "nos-proprietes"
116 + },
117 + {
118 + "banner": "remax-elite",
119 + "domain": "https://remax-elite.ca",
120 + "platform": "nos-proprietes"
121 + },
122 + {
123 + "banner": "remax-energie",
124 + "domain": "https://remax-energie.com",
125 + "platform": "nos-proprietes"
126 + },
127 + {
128 + "banner": "remax-evolution",
129 + "domain": "https://remax-evolution.com",
130 + "platform": "centris-other"
131 + },
132 + {
133 + "banner": "remax-excellence",
134 + "domain": "https://remax-excellence.ca",
135 + "platform": "nos-proprietes"
136 + },
137 + {
138 + "banner": "remax-extra",
139 + "domain": "https://remax-extra.ca",
140 + "platform": "centris-other"
141 + },
142 + {
143 + "banner": "remax-fortindelage",
144 + "domain": "https://remaxfortindelage.com",
145 + "platform": "nos-proprietes"
146 + },
147 + {
148 + "banner": "remax-futur",
149 + "domain": "https://remax-futur.ca",
150 + "platform": "centris-other"
151 + },
152 + {
153 + "banner": "remax-harmonie",
154 + "domain": "https://remaxharmonie.com",
155 + "platform": "nos-proprietes"
156 + },
157 + {
158 + "banner": "remax-harricana",
159 + "domain": "https://remax-harricana.com",
160 + "platform": "nos-proprietes"
161 + },
162 + {
163 + "banner": "remax-haute-performance",
164 + "domain": null,
165 + "platform": null
166 + },
167 + {
168 + "banner": "remax-horizon",
169 + "domain": "https://remax-horizon.ca",
170 + "platform": "nos-proprietes"
171 + },
172 + {
173 + "banner": "remax-imagine",
174 + "domain": "https://remax-imagineprivilege.com",
175 + "platform": "nos-proprietes"
176 + },
177 + {
178 + "banner": "remax-immobilia",
179 + "domain": "https://remaximmobilia.ca",
180 + "platform": "nos-proprietes"
181 + },
182 + {
183 + "banner": "remax-immobilierplus",
184 + "domain": "https://remax-immobilierplus.com",
185 + "platform": "nos-proprietes"
186 + },
187 + {
188 + "banner": "remax-infinite",
189 + "domain": "https://remax-infinite.com",
190 + "platform": "nos-proprietes"
191 + },
192 + {
193 + "banner": "remax-invest",
194 + "domain": "https://remaxinvest.ca",
195 + "platform": "nos-proprietes"
196 + },
197 + {
198 + "banner": "remax-lespace",
199 + "domain": "https://www.remaxlespace.com",
200 + "platform": "nos-proprietes"
201 + },
202 + {
203 + "banner": "remax-lespace-commercial",
204 + "domain": "https://remaxlespace.com",
205 + "platform": "nos-proprietes"
206 + },
207 + {
208 + "banner": "remax-mont-laurier",
209 + "domain": null,
210 + "platform": null
211 + },
212 + {
213 + "banner": "remax-performance",
214 + "domain": "https://remaxperformance.net",
215 + "platform": "nos-proprietes"
216 + },
217 + {
218 + "banner": "remax-platine",
219 + "domain": "https://www.remax-platine.com",
220 + "platform": "nos-proprietes"
221 + },
222 + {
223 + "banner": "remax-plus",
224 + "domain": "https://remaxplus.ca",
225 + "platform": "nos-proprietes"
226 + },
227 + {
228 + "banner": "remax-prestige",
229 + "domain": "https://remaxprestige.ca",
230 + "platform": "nos-proprietes"
231 + },
232 + {
233 + "banner": "remax-privilege",
234 + "domain": "https://remax-imagineprivilege.com",
235 + "platform": "nos-proprietes"
236 + },
237 + {
238 + "banner": "remax-pro-commercial",
239 + "domain": "https://remax-pro-commercial.ca",
240 + "platform": "nos-proprietes"
241 + },
242 + {
243 + "banner": "remax-professionnel",
244 + "domain": "https://www.remax-professionnel.com",
245 + "platform": "nos-proprietes"
246 + },
247 + {
248 + "banner": "remax-renaissance",
249 + "domain": "https://remax-renaissance.ca",
250 + "platform": "nos-proprietes"
251 + },
252 + {
253 + "banner": "remax-royaljordan",
254 + "domain": "https://remax-royaljordan.com",
255 + "platform": "nos-proprietes"
256 + },
257 + {
258 + "banner": "remax-signature",
259 + "domain": "https://remaxsignature.ca",
260 + "platform": "nos-proprietes"
261 + },
262 + {
263 + "banner": "remax-solutions",
264 + "domain": "https://nancyforlini.com",
265 + "platform": "centris-other"
266 + },
267 + {
268 + "banner": "remax-vision",
269 + "domain": "https://remaxvision.ca",
270 + "platform": "nos-proprietes"
271 + }
272 +]
\ No newline at end of file
added data/sources.json +245 −0
@@ -0,0 +1,245 @@
1 +{
2 + "_comment": "Immo-Ka — Registre des sources (agences de courtage immobilier, province de Québec). Un connecteur par agence. Auteur : Simon-Pierre Boucher — contact@spboucher.ai",
3 + "sources": [
4 + {
5 + "id": "remax_quebec",
6 + "name": "RE/MAX Québec",
7 + "url": "https://www.remax-quebec.com",
8 + "listing_url": "https://www.remax-quebec.com/fr/maison-a-vendre",
9 + "coverage": "Toute la province — ~5 000 courtiers, ~119 agences (bannière #1 au Québec)",
10 + "connector": "remax_quebec",
11 + "status": "actif",
12 + "type": "franchise"
13 + },
14 + {
15 + "id": "royal_lepage",
16 + "name": "Royal LePage (Québec)",
17 + "url": "https://www.royallepage.ca",
18 + "listing_url": "https://www.royallepage.ca/fr/search/homes/1/",
19 + "coverage": "Toute la province — ~2 233 courtiers, ~47 agences (Bridgemarq)",
20 + "connector": "royal_lepage",
21 + "status": "actif",
22 + "type": "franchise",
23 + "note": "Recherche par ville filtrable QC : GET /fr/search/homes/{page}/?search_str=<ville>, QC&prov_code=QC&city_name=<ville>&lat=&lng=&search_type=city -> 100% QC (46/page), classé par proximité, cap ~1250/recherche. Sharding par ~28 points d'ancrage QC + dédup MLS (= n° Centris dans /.../mls{no}/). Requêtes directes (pas de Firecrawl). Champs liste: adresse, ville, type, prix, CAC, SDB, coords (data-rlp-key), photo jumplisting, MLS."
24 + },
25 + {
26 + "id": "sutton",
27 + "name": "Groupe Sutton Québec",
28 + "url": "https://suttonquebec.com",
29 + "listing_url": "https://suttonquebec.com/maisons-a-vendre.html",
30 + "coverage": "Toute la province — ~1 300-1 500 courtiers, ~20 agences",
31 + "connector": "sutton",
32 + "status": "actif",
33 + "type": "franchise"
34 + },
35 + {
36 + "id": "via_capitale",
37 + "name": "Via Capitale",
38 + "url": "https://www.viacapitalevendu.com",
39 + "listing_url": "https://www.viacapitalevendu.com/recherche/residentiel/",
40 + "coverage": "Toute la province — ~5 300 propriétés, ~900-1 000 courtiers (québécoise, Bridgemarq)",
41 + "connector": "via_capitale",
42 + "status": "actif",
43 + "type": "franchise",
44 + "note": "ASP.NET rendu serveur derrière Cloudflare : accès via Firecrawl proxy=stealth. Résultats paginés ?page=N (34/page, ~155 pages). Parse: adresse, ville, secteur, région, type, prix, photos (images.viacapitale.info). CAC/SDB non captés à ce stade (icônes)."
45 + },
46 + {
47 + "id": "century21",
48 + "name": "Century 21 (Québec)",
49 + "url": "https://www.c21.ca",
50 + "listing_url": "https://listings.moxiworks.com/m214-1731446488/fr-ca",
51 + "coverage": "Toute la province — ~339 courtiers, ~22 agences",
52 + "connector": "century21",
53 + "status": "actif",
54 + "type": "franchise",
55 + "note": "Central c21.ca->iframe moxiworks verrouillé (503/WAF). Couvert via sous-agences source.immo (c21_ag_*)."
56 + },
57 + {
58 + "id": "proprio_direct",
59 + "name": "Proprio Direct",
60 + "url": "https://propriodirect.com",
61 + "listing_url": "https://propriodirect.com/propriete-a-vendre/",
62 + "coverage": "Toute la province — ~746 courtiers, agence unique (Bridgemarq)",
63 + "connector": "proprio_direct",
64 + "status": "actif",
65 + "type": "agence",
66 + "note": "Feed DDF (CREA) servi par API JSON interne POST propriodirect.com/fr/api/searchListings (corps {from:offset}, 30/page, champ total). ~3201 propriétés à vendre, données complètes + galeries photos. Hôte canonique sans www."
67 + },
68 + {
69 + "id": "exp_quebec",
70 + "name": "eXp Agence immobilière (Québec)",
71 + "url": "https://www.exprealty.ca",
72 + "listing_url": "https://www.exprealty.ca/fr-CA/search",
73 + "coverage": "Toute la province — ~400+ courtiers, agence virtuelle",
74 + "connector": "exp_quebec",
75 + "status": "a_faire",
76 + "type": "agence",
77 + "note": "Next.js (exprealty.ca/fr-CA/search) : inscriptions chargées côté client via backend BoldTrail/kvCORE ; absentes de __NEXT_DATA__ et des chunks JS ; Firecrawl proxy=stealth rend la page mais pas les fiches (fetch client non déclenché). Aucun endpoint API découvrable (seul images.expcloud.com exposé). Reste : capturer l'XHR live de recherche BoldTrail (state=QC) via un navigateur instrumenté, puis rejouer l'API JSON."
78 + },
79 + {
80 + "id": "kw_urbain",
81 + "name": "Keller Williams Urbain",
82 + "url": "https://www.kwurbain.ca",
83 + "listing_url": "https://www.kwurbain.ca/inscriptions/",
84 + "coverage": "Grand Montréal + Rive-Sud/Rive-Nord",
85 + "connector": "kw_urbain",
86 + "status": "actif",
87 + "type": "franchise",
88 + "note": "Cartes HTML rendues serveur (plateforme realestate.marketingwebsites.ca) — 349 inscriptions sur une page. Champs: adresse, ville, région, prix, chambres, sdb, photo."
89 + },
90 + {
91 + "id": "kw_distinction",
92 + "name": "Keller Williams Distinction",
93 + "url": "https://www.kwdistinction.com",
94 + "listing_url": "https://www.kwdistinction.com/sitemap.xml",
95 + "coverage": "Gatineau / Outaouais",
96 + "connector": "kw_distinction",
97 + "status": "actif",
98 + "type": "franchise",
99 + "note": "ASP.NET (tonikwebstudio) : grille/fiche en AJAX (prix/photos non rendus statiquement, ni fiablement via Firecrawl). Enumération via sitemap.xml (167 propr.) ; slug d'URL = ville/type/chambres/rue/no-Centris. Champs liste: adresse, ville, secteur, type, chambres, Centris. Prix/photos NON dispo."
100 + },
101 + {
102 + "id": "vendirect",
103 + "name": "Vendirect",
104 + "url": "https://vendirect.ca",
105 + "listing_url": "https://vendirect.ca/proprietes/",
106 + "coverage": "Toute la province — ~609 courtiers, commission réduite",
107 + "connector": "vendirect",
108 + "status": "actif",
109 + "type": "agence",
110 + "note": "WordPress + plugin source-immo. API publique api-v1.source.immo (en-têtes x-si-account/api/appId depuis _configs.json), pagination next_token. ~64 propriétés à vendre, données complètes (prix, ch., sdb, pi², année, coords, photo)."
111 + },
112 + {
113 + "id": "expert_immobilier_pm",
114 + "name": "L'Expert Immobilier P.M.",
115 + "url": "https://expertimmobilierpm.com",
116 + "listing_url": "https://expertimmobilierpm.com/proprietes/",
117 + "coverage": "Grand Montréal — ~305 courtiers, grosse indépendante",
118 + "connector": "expert_immobilier_pm",
119 + "status": "actif",
120 + "type": "agence",
121 + "note": "Vrai site QC trouve (l'ancienne URL lexpertimmobilier.com = agence de Casablanca). WordPress + plugin mw-properties2 (plateforme marketingwebsites.ca, comme KW Urbain). /proprietes/ rend 9 fiches serveur (inventaire complet derriere l'API REST mw-properties/v2 en 401). Cartes: adresse, ville, type, prix, chambres, sdb, superficie ; fiche detail: galerie complete (marketingwebsites) + description. external_id = no Centris."
122 + },
123 + {
124 + "id": "engel_volkers",
125 + "name": "Engel & Völkers Montréal / Québec",
126 + "url": "https://immobilier.evquebec.com",
127 + "listing_url": "https://immobilier.evquebec.com/property-sitemap.xml",
128 + "coverage": "Montréal, Québec, Tremblant — luxe #1 au Québec",
129 + "connector": "engel_volkers",
130 + "status": "actif",
131 + "type": "luxe",
132 + "note": "immobilier.evquebec.com : property-sitemap.xml (~1240 fiches FR /propriete/{no-centris}/). Fiche = JSON-LD (prix/adresse/description) ; galerie séquentielle media.evquebec.com/listings/{id}/NN.webp (sondée jusqu'au 1er trou). Enrichissement cache+plafond (IMMOKA_EV_DETAIL_LIMIT)."
133 + },
134 + {
135 + "id": "sothebys_quebec",
136 + "name": "Sotheby's International Realty Québec",
137 + "url": "https://sothebysrealty.ca",
138 + "listing_url": "https://sothebysrealty.ca/en/search-results/province-quebec-real-estate/",
139 + "coverage": "Toute la province — luxe",
140 + "connector": "sothebys_quebec",
141 + "status": "actif",
142 + "type": "luxe",
143 + "note": "Recherche rendue côté client. Fiches enumérées via pages de résultats (grid public plafonné ~40 fiches province-wide ; pagination AJAX non exposée en URL). Chaque fiche enrichie via son JSON-LD Product : prix, galerie S3 complète (35-75 photos), description, MLS (sku), type. Limite : couverture = set public courant (~40) ; coverage complète nécessiterait l'endpoint AJAX de pagination."
144 + },
145 + {
146 + "id": "profusion",
147 + "name": "Profusion Immobilier (Christie's)",
148 + "url": "https://www.profusion.global",
149 + "listing_url": "https://www.profusion.global/acheter/nos-inscriptions/",
150 + "coverage": "Grand Montréal + province — luxe, affiliée Christie's",
151 + "connector": "profusion",
152 + "status": "actif",
153 + "type": "luxe",
154 + "note": "WordPress DIVI ; nos-inscriptions liste ~357 fiches /propriete/{no-centris}/ (HTML). Fiche = JSON-LD RealEstateListing (adresse, description, prix, courtier) + galerie profusionimmo.ca. Enrichissement cache+plafond (IMMOKA_PROFUSION_DETAIL_LIMIT)."
155 + },
156 + {
157 + "id": "barnes_quebec",
158 + "name": "BARNES Québec",
159 + "url": "https://barnes-quebec.com",
160 + "listing_url": "https://barnes-quebec.com/property",
161 + "coverage": "Montréal + province — luxe international",
162 + "connector": "barnes_quebec",
163 + "status": "actif",
164 + "type": "luxe",
165 + "note": "Algolia (App HCW55VIQNM, index quebec_all). ~344 propriétés, prix/MLS/chambres/sdb/superficie/géoloc. Images non indexées (viendront de la page détail). Dédoublonnage FR/EN par adresse."
166 + },
167 + {
168 + "id": "m_immobilier",
169 + "name": "M Immobilier",
170 + "url": "https://www.mimmobilier.com",
171 + "listing_url": "https://www.mimmobilier.com/properties",
172 + "coverage": "Grand Montréal — indépendante primée (Leading RE)",
173 + "connector": "m_immobilier",
174 + "status": "actif",
175 + "type": "luxe",
176 + "note": "Cartes HTML rendues serveur (/properties, PAS /proprietes). ~65 propriétés à vendre (locations filtrées). Champs: ville, adresse, prix, chambres, sdb, photos Centris, no Centris. Type non exposé."
177 + },
178 + {
179 + "id": "via_ag_elite",
180 + "name": "Via Capitale Elite",
181 + "url": "https://viacapitaleelite.immo",
182 + "listing_url": "https://viacapitaleelite.immo/proprietes/",
183 + "coverage": "Sous-agence Via Capitale (source.immo)",
184 + "connector": "via_ag_elite",
185 + "status": "actif",
186 + "type": "franchise",
187 + "role": "backup"
188 + },
189 + {
190 + "id": "via_ag_select",
191 + "name": "Via Capitale Select",
192 + "url": "https://viacapitaleselect.com",
193 + "listing_url": "https://viacapitaleselect.com/proprietes/",
194 + "coverage": "Sous-agence Via Capitale (source.immo)",
195 + "connector": "via_ag_select",
196 + "status": "actif",
197 + "type": "franchise",
198 + "role": "backup"
199 + },
200 + {
201 + "id": "via_ag_dumontroyal",
202 + "name": "Via Capitale Dumontroyal",
203 + "url": "https://viacapitaledumontroyal.com",
204 + "listing_url": "https://viacapitaledumontroyal.com/proprietes/",
205 + "coverage": "Sous-agence Via Capitale (source.immo)",
206 + "connector": "via_ag_dumontroyal",
207 + "status": "actif",
208 + "type": "franchise",
209 + "role": "backup"
210 + },
211 + {
212 + "id": "via_ag_acces",
213 + "name": "Via Capitale Acces",
214 + "url": "https://viacapitaleacces.com",
215 + "listing_url": "https://viacapitaleacces.com/proprietes/",
216 + "coverage": "Sous-agence Via Capitale (source.immo)",
217 + "connector": "via_ag_acces",
218 + "status": "actif",
219 + "type": "franchise",
220 + "role": "backup"
221 + },
222 + {
223 + "id": "c21_ag_quebec",
224 + "name": "Century 21 Québec (All Quebec)",
225 + "url": "https://quebec.c21.ca",
226 + "listing_url": "https://quebec.c21.ca",
227 + "connector": "c21_ag_quebec",
228 + "status": "actif",
229 + "role": "sous-agence",
230 + "platform": "source.immo",
231 + "note": "Compte source.immo partagé (quebec/excel/immoplus) = inventaire provincial C21 complet (~1600 à vendre). Pagination st/nt. Dédup par n° Centris."
232 + },
233 + {
234 + "id": "c21_ag_vision",
235 + "name": "Century 21 Vision (Outaouais)",
236 + "url": "https://vision.c21.ca",
237 + "listing_url": "https://vision.c21.ca",
238 + "connector": "c21_ag_vision",
239 + "status": "actif",
240 + "role": "sous-agence",
241 + "platform": "source.immo",
242 + "note": "Century 21 Vision (Gatineau) — compte source.immo distinct (~139 à vendre)."
243 + }
244 + ]
245 +}
\ No newline at end of file
added data/via_capitale_agencies.json +42 −0
@@ -0,0 +1,42 @@
1 +[
2 + {
3 + "banner": "acces",
4 + "domain": "viacapitaleacces.com",
5 + "account": "48247805-5907-4a46-aa10-b606c6eb2537",
6 + "api": "a86ab526-4a65-4cff-9a9a-b199c976c60e",
7 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
8 + "view": "66dd42f9-edf2-429d-b80a-a94df7c00809"
9 + },
10 + {
11 + "banner": "dumontroyal",
12 + "domain": "viacapitaledumontroyal.com",
13 + "account": "3c1eb232-8f05-4703-97a3-ee620701b0a6",
14 + "api": "83e0694b-3c10-45a2-9fde-052212e802d8",
15 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
16 + "view": "e8f1592f-c854-4eea-a860-805e04aef699"
17 + },
18 + {
19 + "banner": "elite",
20 + "domain": "viacapitaleelite.immo",
21 + "account": "a3f57dc8-da3a-4a4b-a984-f395c1f40cf8",
22 + "api": "2e1dc1e4-10ba-45a7-982c-6993bb4ba5bf",
23 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
24 + "view": "65433174-7ca3-4c9b-aae2-a8cd8fd710bd"
25 + },
26 + {
27 + "banner": "partenaires",
28 + "domain": "viacapitalepartenaires.com",
29 + "account": "d5b8980e-7a50-41f6-930b-aabe04749e1b",
30 + "api": "08a2d194-d716-4d54-8a8d-44e306375670",
31 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
32 + "view": "c0e35dbb-d63e-4454-990a-0a9ff4493570"
33 + },
34 + {
35 + "banner": "select",
36 + "domain": "viacapitaleselect.com",
37 + "account": "742afc00-e1d2-4ad6-95ef-bbe8564c2e54",
38 + "api": "0265b828-eedf-47d6-a92d-18fefae37fa3",
39 + "app": "ead7575f-8d1c-42e7-9f59-4cf9e065167e",
40 + "view": "b5c1115d-34e7-4de6-9b66-7b3699992ed2"
41 + }
42 +]
\ No newline at end of file
added docs/screenshots/home.png +0 −0

Binary file not shown.

added docs/screenshots/listing.png +0 −0

Binary file not shown.

added frontend/index.html +25 −0
@@ -0,0 +1,25 @@
1 +<!doctype html>
2 +<!-- ---------------------------------------------------------------------------
3 + Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
4 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +---------------------------------------------------------------------------- -->
6 +<html lang="fr">
7 + <head>
8 + <meta charset="UTF-8" />
9 + <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 + <title>Immo-Ka — Propriétés à vendre au Québec</title>
11 + <meta name="description" content="Immo-Ka agrège les propriétés à vendre affichées par les agences de courtage immobilier du Québec (RE/MAX, Sutton, Via Capitale, Proprio Direct…), toujours à jour." />
12 + <meta name="theme-color" content="#f5f3ee" />
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="Immo-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=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
19 + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23121712'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='30' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3EIK%3C/text%3E%3C/svg%3E" />
20 + </head>
21 + <body>
22 + <div id="root"></div>
23 + <script type="module" src="/src/main.tsx"></script>
24 + </body>
25 +</html>
added frontend/package-lock.json +2212 −0
@@ -0,0 +1,2212 @@
1 +{
2 + "name": "immo-ka-frontend",
3 + "version": "1.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "immo-ka-frontend",
9 + "version": "1.0.0",
10 + "dependencies": {
11 + "maplibre-gl": "^4.7.1",
12 + "react": "^18.3.1",
13 + "react-dom": "^18.3.1",
14 + "react-router-dom": "^6.26.0"
15 + },
16 + "devDependencies": {
17 + "@types/react": "^18.3.3",
18 + "@types/react-dom": "^18.3.0",
19 + "@vitejs/plugin-react": "^4.3.1",
20 + "typescript": "^5.5.4",
21 + "vite": "^5.4.0"
22 + }
23 + },
24 + "node_modules/@babel/code-frame": {
25 + "version": "7.29.7",
26 + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
27 + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
28 + "dev": true,
29 + "license": "MIT",
30 + "dependencies": {
31 + "@babel/helper-validator-identifier": "^7.29.7",
32 + "js-tokens": "^4.0.0",
33 + "picocolors": "^1.1.1"
34 + },
35 + "engines": {
36 + "node": ">=6.9.0"
37 + }
38 + },
39 + "node_modules/@babel/compat-data": {
40 + "version": "7.29.7",
41 + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
42 + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
43 + "dev": true,
44 + "license": "MIT",
45 + "engines": {
46 + "node": ">=6.9.0"
47 + }
48 + },
49 + "node_modules/@babel/core": {
50 + "version": "7.29.7",
51 + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
52 + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
53 + "dev": true,
54 + "license": "MIT",
55 + "dependencies": {
56 + "@babel/code-frame": "^7.29.7",
57 + "@babel/generator": "^7.29.7",
58 + "@babel/helper-compilation-targets": "^7.29.7",
59 + "@babel/helper-module-transforms": "^7.29.7",
60 + "@babel/helpers": "^7.29.7",
61 + "@babel/parser": "^7.29.7",
62 + "@babel/template": "^7.29.7",
63 + "@babel/traverse": "^7.29.7",
64 + "@babel/types": "^7.29.7",
65 + "@jridgewell/remapping": "^2.3.5",
66 + "convert-source-map": "^2.0.0",
67 + "debug": "^4.1.0",
68 + "gensync": "^1.0.0-beta.2",
69 + "json5": "^2.2.3",
70 + "semver": "^6.3.1"
71 + },
72 + "engines": {
73 + "node": ">=6.9.0"
74 + },
75 + "funding": {
76 + "type": "opencollective",
77 + "url": "https://opencollective.com/babel"
78 + }
79 + },
80 + "node_modules/@babel/generator": {
81 + "version": "7.29.8",
82 + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
83 + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
84 + "dev": true,
85 + "license": "MIT",
86 + "dependencies": {
87 + "@babel/parser": "^7.29.8",
88 + "@babel/types": "^7.29.8",
89 + "@jridgewell/gen-mapping": "^0.3.12",
90 + "@jridgewell/trace-mapping": "^0.3.28",
91 + "jsesc": "^3.0.2"
92 + },
93 + "engines": {
94 + "node": ">=6.9.0"
95 + }
96 + },
97 + "node_modules/@babel/helper-compilation-targets": {
98 + "version": "7.29.7",
99 + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
100 + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
101 + "dev": true,
102 + "license": "MIT",
103 + "dependencies": {
104 + "@babel/compat-data": "^7.29.7",
105 + "@babel/helper-validator-option": "^7.29.7",
106 + "browserslist": "^4.24.0",
107 + "lru-cache": "^5.1.1",
108 + "semver": "^6.3.1"
109 + },
110 + "engines": {
111 + "node": ">=6.9.0"
112 + }
113 + },
114 + "node_modules/@babel/helper-globals": {
115 + "version": "7.29.7",
116 + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
117 + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
118 + "dev": true,
119 + "license": "MIT",
120 + "engines": {
121 + "node": ">=6.9.0"
122 + }
123 + },
124 + "node_modules/@babel/helper-module-imports": {
125 + "version": "7.29.7",
126 + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
127 + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
128 + "dev": true,
129 + "license": "MIT",
130 + "dependencies": {
131 + "@babel/traverse": "^7.29.7",
132 + "@babel/types": "^7.29.7"
133 + },
134 + "engines": {
135 + "node": ">=6.9.0"
136 + }
137 + },
138 + "node_modules/@babel/helper-module-transforms": {
139 + "version": "7.29.7",
140 + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
141 + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
142 + "dev": true,
143 + "license": "MIT",
144 + "dependencies": {
145 + "@babel/helper-module-imports": "^7.29.7",
146 + "@babel/helper-validator-identifier": "^7.29.7",
147 + "@babel/traverse": "^7.29.7"
148 + },
149 + "engines": {
150 + "node": ">=6.9.0"
151 + },
152 + "peerDependencies": {
153 + "@babel/core": "^7.0.0"
154 + }
155 + },
156 + "node_modules/@babel/helper-plugin-utils": {
157 + "version": "7.29.7",
158 + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
159 + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
160 + "dev": true,
161 + "license": "MIT",
162 + "engines": {
163 + "node": ">=6.9.0"
164 + }
165 + },
166 + "node_modules/@babel/helper-string-parser": {
167 + "version": "7.29.7",
168 + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
169 + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
170 + "dev": true,
171 + "license": "MIT",
172 + "engines": {
173 + "node": ">=6.9.0"
174 + }
175 + },
176 + "node_modules/@babel/helper-validator-identifier": {
177 + "version": "7.29.7",
178 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
179 + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
180 + "dev": true,
181 + "license": "MIT",
182 + "engines": {
183 + "node": ">=6.9.0"
184 + }
185 + },
186 + "node_modules/@babel/helper-validator-option": {
187 + "version": "7.29.7",
188 + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
189 + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
190 + "dev": true,
191 + "license": "MIT",
192 + "engines": {
193 + "node": ">=6.9.0"
194 + }
195 + },
196 + "node_modules/@babel/helpers": {
197 + "version": "7.29.7",
198 + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
199 + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
200 + "dev": true,
201 + "license": "MIT",
202 + "dependencies": {
203 + "@babel/template": "^7.29.7",
204 + "@babel/types": "^7.29.7"
205 + },
206 + "engines": {
207 + "node": ">=6.9.0"
208 + }
209 + },
210 + "node_modules/@babel/parser": {
211 + "version": "7.29.8",
212 + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
213 + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
214 + "dev": true,
215 + "license": "MIT",
216 + "dependencies": {
217 + "@babel/types": "^7.29.8"
218 + },
219 + "bin": {
220 + "parser": "bin/babel-parser.js"
221 + },
222 + "engines": {
223 + "node": ">=6.0.0"
224 + }
225 + },
226 + "node_modules/@babel/plugin-transform-react-jsx-self": {
227 + "version": "7.29.7",
228 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
229 + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
230 + "dev": true,
231 + "license": "MIT",
232 + "dependencies": {
233 + "@babel/helper-plugin-utils": "^7.29.7"
234 + },
235 + "engines": {
236 + "node": ">=6.9.0"
237 + },
238 + "peerDependencies": {
239 + "@babel/core": "^7.0.0-0"
240 + }
241 + },
242 + "node_modules/@babel/plugin-transform-react-jsx-source": {
243 + "version": "7.29.7",
244 + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
245 + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
246 + "dev": true,
247 + "license": "MIT",
248 + "dependencies": {
249 + "@babel/helper-plugin-utils": "^7.29.7"
250 + },
251 + "engines": {
252 + "node": ">=6.9.0"
253 + },
254 + "peerDependencies": {
255 + "@babel/core": "^7.0.0-0"
256 + }
257 + },
258 + "node_modules/@babel/template": {
259 + "version": "7.29.7",
260 + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
261 + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
262 + "dev": true,
263 + "license": "MIT",
264 + "dependencies": {
265 + "@babel/code-frame": "^7.29.7",
266 + "@babel/parser": "^7.29.7",
267 + "@babel/types": "^7.29.7"
268 + },
269 + "engines": {
270 + "node": ">=6.9.0"
271 + }
272 + },
273 + "node_modules/@babel/traverse": {
274 + "version": "7.29.8",
275 + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
276 + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
277 + "dev": true,
278 + "license": "MIT",
279 + "dependencies": {
280 + "@babel/code-frame": "^7.29.7",
281 + "@babel/generator": "^7.29.8",
282 + "@babel/helper-globals": "^7.29.7",
283 + "@babel/parser": "^7.29.8",
284 + "@babel/template": "^7.29.7",
285 + "@babel/types": "^7.29.8",
286 + "debug": "^4.3.1"
287 + },
288 + "engines": {
289 + "node": ">=6.9.0"
290 + }
291 + },
292 + "node_modules/@babel/types": {
293 + "version": "7.29.8",
294 + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
295 + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
296 + "dev": true,
297 + "license": "MIT",
298 + "dependencies": {
299 + "@babel/helper-string-parser": "^7.29.7",
300 + "@babel/helper-validator-identifier": "^7.29.7"
301 + },
302 + "engines": {
303 + "node": ">=6.9.0"
304 + }
305 + },
306 + "node_modules/@esbuild/aix-ppc64": {
307 + "version": "0.21.5",
308 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
309 + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
310 + "cpu": [
311 + "ppc64"
312 + ],
313 + "dev": true,
314 + "license": "MIT",
315 + "optional": true,
316 + "os": [
317 + "aix"
318 + ],
319 + "engines": {
320 + "node": ">=12"
321 + }
322 + },
323 + "node_modules/@esbuild/android-arm": {
324 + "version": "0.21.5",
325 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
326 + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
327 + "cpu": [
328 + "arm"
329 + ],
330 + "dev": true,
331 + "license": "MIT",
332 + "optional": true,
333 + "os": [
334 + "android"
335 + ],
336 + "engines": {
337 + "node": ">=12"
338 + }
339 + },
340 + "node_modules/@esbuild/android-arm64": {
341 + "version": "0.21.5",
342 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
343 + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
344 + "cpu": [
345 + "arm64"
346 + ],
347 + "dev": true,
348 + "license": "MIT",
349 + "optional": true,
350 + "os": [
351 + "android"
352 + ],
353 + "engines": {
354 + "node": ">=12"
355 + }
356 + },
357 + "node_modules/@esbuild/android-x64": {
358 + "version": "0.21.5",
359 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
360 + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
361 + "cpu": [
362 + "x64"
363 + ],
364 + "dev": true,
365 + "license": "MIT",
366 + "optional": true,
367 + "os": [
368 + "android"
369 + ],
370 + "engines": {
371 + "node": ">=12"
372 + }
373 + },
374 + "node_modules/@esbuild/darwin-arm64": {
375 + "version": "0.21.5",
376 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
377 + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
378 + "cpu": [
379 + "arm64"
380 + ],
381 + "dev": true,
382 + "license": "MIT",
383 + "optional": true,
384 + "os": [
385 + "darwin"
386 + ],
387 + "engines": {
388 + "node": ">=12"
389 + }
390 + },
391 + "node_modules/@esbuild/darwin-x64": {
392 + "version": "0.21.5",
393 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
394 + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
395 + "cpu": [
396 + "x64"
397 + ],
398 + "dev": true,
399 + "license": "MIT",
400 + "optional": true,
401 + "os": [
402 + "darwin"
403 + ],
404 + "engines": {
405 + "node": ">=12"
406 + }
407 + },
408 + "node_modules/@esbuild/freebsd-arm64": {
409 + "version": "0.21.5",
410 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
411 + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
412 + "cpu": [
413 + "arm64"
414 + ],
415 + "dev": true,
416 + "license": "MIT",
417 + "optional": true,
418 + "os": [
419 + "freebsd"
420 + ],
421 + "engines": {
422 + "node": ">=12"
423 + }
424 + },
425 + "node_modules/@esbuild/freebsd-x64": {
426 + "version": "0.21.5",
427 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
428 + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
429 + "cpu": [
430 + "x64"
431 + ],
432 + "dev": true,
433 + "license": "MIT",
434 + "optional": true,
435 + "os": [
436 + "freebsd"
437 + ],
438 + "engines": {
439 + "node": ">=12"
440 + }
441 + },
442 + "node_modules/@esbuild/linux-arm": {
443 + "version": "0.21.5",
444 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
445 + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
446 + "cpu": [
447 + "arm"
448 + ],
449 + "dev": true,
450 + "license": "MIT",
451 + "optional": true,
452 + "os": [
453 + "linux"
454 + ],
455 + "engines": {
456 + "node": ">=12"
457 + }
458 + },
459 + "node_modules/@esbuild/linux-arm64": {
460 + "version": "0.21.5",
461 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
462 + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
463 + "cpu": [
464 + "arm64"
465 + ],
466 + "dev": true,
467 + "license": "MIT",
468 + "optional": true,
469 + "os": [
470 + "linux"
471 + ],
472 + "engines": {
473 + "node": ">=12"
474 + }
475 + },
476 + "node_modules/@esbuild/linux-ia32": {
477 + "version": "0.21.5",
478 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
479 + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
480 + "cpu": [
481 + "ia32"
482 + ],
483 + "dev": true,
484 + "license": "MIT",
485 + "optional": true,
486 + "os": [
487 + "linux"
488 + ],
489 + "engines": {
490 + "node": ">=12"
491 + }
492 + },
493 + "node_modules/@esbuild/linux-loong64": {
494 + "version": "0.21.5",
495 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
496 + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
497 + "cpu": [
498 + "loong64"
499 + ],
500 + "dev": true,
501 + "license": "MIT",
502 + "optional": true,
503 + "os": [
504 + "linux"
505 + ],
506 + "engines": {
507 + "node": ">=12"
508 + }
509 + },
510 + "node_modules/@esbuild/linux-mips64el": {
511 + "version": "0.21.5",
512 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
513 + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
514 + "cpu": [
515 + "mips64el"
516 + ],
517 + "dev": true,
518 + "license": "MIT",
519 + "optional": true,
520 + "os": [
521 + "linux"
522 + ],
523 + "engines": {
524 + "node": ">=12"
525 + }
526 + },
527 + "node_modules/@esbuild/linux-ppc64": {
528 + "version": "0.21.5",
529 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
530 + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
531 + "cpu": [
532 + "ppc64"
533 + ],
534 + "dev": true,
535 + "license": "MIT",
536 + "optional": true,
537 + "os": [
538 + "linux"
539 + ],
540 + "engines": {
541 + "node": ">=12"
542 + }
543 + },
544 + "node_modules/@esbuild/linux-riscv64": {
545 + "version": "0.21.5",
546 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
547 + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
548 + "cpu": [
549 + "riscv64"
550 + ],
551 + "dev": true,
552 + "license": "MIT",
553 + "optional": true,
554 + "os": [
555 + "linux"
556 + ],
557 + "engines": {
558 + "node": ">=12"
559 + }
560 + },
561 + "node_modules/@esbuild/linux-s390x": {
562 + "version": "0.21.5",
563 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
564 + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
565 + "cpu": [
566 + "s390x"
567 + ],
568 + "dev": true,
569 + "license": "MIT",
570 + "optional": true,
571 + "os": [
572 + "linux"
573 + ],
574 + "engines": {
575 + "node": ">=12"
576 + }
577 + },
578 + "node_modules/@esbuild/linux-x64": {
579 + "version": "0.21.5",
580 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
581 + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
582 + "cpu": [
583 + "x64"
584 + ],
585 + "dev": true,
586 + "license": "MIT",
587 + "optional": true,
588 + "os": [
589 + "linux"
590 + ],
591 + "engines": {
592 + "node": ">=12"
593 + }
594 + },
595 + "node_modules/@esbuild/netbsd-x64": {
596 + "version": "0.21.5",
597 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
598 + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
599 + "cpu": [
600 + "x64"
601 + ],
602 + "dev": true,
603 + "license": "MIT",
604 + "optional": true,
605 + "os": [
606 + "netbsd"
607 + ],
608 + "engines": {
609 + "node": ">=12"
610 + }
611 + },
612 + "node_modules/@esbuild/openbsd-x64": {
613 + "version": "0.21.5",
614 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
615 + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
616 + "cpu": [
617 + "x64"
618 + ],
619 + "dev": true,
620 + "license": "MIT",
621 + "optional": true,
622 + "os": [
623 + "openbsd"
624 + ],
625 + "engines": {
626 + "node": ">=12"
627 + }
628 + },
629 + "node_modules/@esbuild/sunos-x64": {
630 + "version": "0.21.5",
631 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
632 + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
633 + "cpu": [
634 + "x64"
635 + ],
636 + "dev": true,
637 + "license": "MIT",
638 + "optional": true,
639 + "os": [
640 + "sunos"
641 + ],
642 + "engines": {
643 + "node": ">=12"
644 + }
645 + },
646 + "node_modules/@esbuild/win32-arm64": {
647 + "version": "0.21.5",
648 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
649 + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
650 + "cpu": [
651 + "arm64"
652 + ],
653 + "dev": true,
654 + "license": "MIT",
655 + "optional": true,
656 + "os": [
657 + "win32"
658 + ],
659 + "engines": {
660 + "node": ">=12"
661 + }
662 + },
663 + "node_modules/@esbuild/win32-ia32": {
664 + "version": "0.21.5",
665 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
666 + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
667 + "cpu": [
668 + "ia32"
669 + ],
670 + "dev": true,
671 + "license": "MIT",
672 + "optional": true,
673 + "os": [
674 + "win32"
675 + ],
676 + "engines": {
677 + "node": ">=12"
678 + }
679 + },
680 + "node_modules/@esbuild/win32-x64": {
681 + "version": "0.21.5",
682 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
683 + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
684 + "cpu": [
685 + "x64"
686 + ],
687 + "dev": true,
688 + "license": "MIT",
689 + "optional": true,
690 + "os": [
691 + "win32"
692 + ],
693 + "engines": {
694 + "node": ">=12"
695 + }
696 + },
697 + "node_modules/@jridgewell/gen-mapping": {
698 + "version": "0.3.13",
699 + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
700 + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
701 + "dev": true,
702 + "license": "MIT",
703 + "dependencies": {
704 + "@jridgewell/sourcemap-codec": "^1.5.0",
705 + "@jridgewell/trace-mapping": "^0.3.24"
706 + }
707 + },
708 + "node_modules/@jridgewell/remapping": {
709 + "version": "2.3.5",
710 + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
711 + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
712 + "dev": true,
713 + "license": "MIT",
714 + "dependencies": {
715 + "@jridgewell/gen-mapping": "^0.3.5",
716 + "@jridgewell/trace-mapping": "^0.3.24"
717 + }
718 + },
719 + "node_modules/@jridgewell/resolve-uri": {
720 + "version": "3.1.2",
721 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
722 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
723 + "dev": true,
724 + "license": "MIT",
725 + "engines": {
726 + "node": ">=6.0.0"
727 + }
728 + },
729 + "node_modules/@jridgewell/sourcemap-codec": {
730 + "version": "1.5.5",
731 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
732 + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
733 + "dev": true,
734 + "license": "MIT"
735 + },
736 + "node_modules/@jridgewell/trace-mapping": {
737 + "version": "0.3.31",
738 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
739 + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
740 + "dev": true,
741 + "license": "MIT",
742 + "dependencies": {
743 + "@jridgewell/resolve-uri": "^3.1.0",
744 + "@jridgewell/sourcemap-codec": "^1.4.14"
745 + }
746 + },
747 + "node_modules/@mapbox/geojson-rewind": {
748 + "version": "0.5.2",
749 + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
750 + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
751 + "license": "ISC",
752 + "dependencies": {
753 + "get-stream": "^6.0.1",
754 + "minimist": "^1.2.6"
755 + },
756 + "bin": {
757 + "geojson-rewind": "geojson-rewind"
758 + }
759 + },
760 + "node_modules/@mapbox/jsonlint-lines-primitives": {
761 + "version": "2.0.3",
762 + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
763 + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
764 + "license": "MIT",
765 + "engines": {
766 + "node": ">= 22"
767 + }
768 + },
769 + "node_modules/@mapbox/point-geometry": {
770 + "version": "0.1.0",
771 + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
772 + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
773 + "license": "ISC"
774 + },
775 + "node_modules/@mapbox/tiny-sdf": {
776 + "version": "2.2.0",
777 + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
778 + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
779 + "license": "BSD-2-Clause"
780 + },
781 + "node_modules/@mapbox/unitbezier": {
782 + "version": "0.0.1",
783 + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
784 + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
785 + "license": "BSD-2-Clause"
786 + },
787 + "node_modules/@mapbox/vector-tile": {
788 + "version": "1.3.1",
789 + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
790 + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
791 + "license": "BSD-3-Clause",
792 + "dependencies": {
793 + "@mapbox/point-geometry": "~0.1.0"
794 + }
795 + },
796 + "node_modules/@mapbox/whoots-js": {
797 + "version": "3.1.0",
798 + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
799 + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
800 + "license": "ISC",
801 + "engines": {
802 + "node": ">=6.0.0"
803 + }
804 + },
805 + "node_modules/@maplibre/maplibre-gl-style-spec": {
806 + "version": "20.4.0",
807 + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz",
808 + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==",
809 + "license": "ISC",
810 + "dependencies": {
811 + "@mapbox/jsonlint-lines-primitives": "~2.0.2",
812 + "@mapbox/unitbezier": "^0.0.1",
813 + "json-stringify-pretty-compact": "^4.0.0",
814 + "minimist": "^1.2.8",
815 + "quickselect": "^2.0.0",
816 + "rw": "^1.3.3",
817 + "tinyqueue": "^3.0.0"
818 + },
819 + "bin": {
820 + "gl-style-format": "dist/gl-style-format.mjs",
821 + "gl-style-migrate": "dist/gl-style-migrate.mjs",
822 + "gl-style-validate": "dist/gl-style-validate.mjs"
823 + }
824 + },
825 + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": {
826 + "version": "2.0.0",
827 + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
828 + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
829 + "license": "ISC"
830 + },
831 + "node_modules/@napi-rs/lzma-linux-x64-gnu": {
832 + "version": "1.5.1",
833 + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
834 + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
835 + "cpu": [
836 + "x64"
837 + ],
838 + "dev": true,
839 + "libc": [
840 + "glibc"
841 + ],
842 + "license": "MIT",
843 + "optional": true,
844 + "os": [
845 + "linux"
846 + ],
847 + "engines": {
848 + "node": "^22.20 || ^24.12 || >=25"
849 + }
850 + },
851 + "node_modules/@remix-run/router": {
852 + "version": "1.23.3",
853 + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
854 + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
855 + "license": "MIT",
856 + "engines": {
857 + "node": ">=14.0.0"
858 + }
859 + },
860 + "node_modules/@rolldown/pluginutils": {
861 + "version": "1.0.0-beta.27",
862 + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
863 + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
864 + "dev": true,
865 + "license": "MIT"
866 + },
867 + "node_modules/@rollup/rollup-android-arm-eabi": {
868 + "version": "4.62.4",
869 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
870 + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==",
871 + "cpu": [
872 + "arm"
873 + ],
874 + "dev": true,
875 + "license": "MIT",
876 + "optional": true,
877 + "os": [
878 + "android"
879 + ]
880 + },
881 + "node_modules/@rollup/rollup-android-arm64": {
882 + "version": "4.62.4",
883 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz",
884 + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==",
885 + "cpu": [
886 + "arm64"
887 + ],
888 + "dev": true,
889 + "license": "MIT",
890 + "optional": true,
891 + "os": [
892 + "android"
893 + ]
894 + },
895 + "node_modules/@rollup/rollup-darwin-arm64": {
896 + "version": "4.62.4",
897 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz",
898 + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==",
899 + "cpu": [
900 + "arm64"
901 + ],
902 + "dev": true,
903 + "license": "MIT",
904 + "optional": true,
905 + "os": [
906 + "darwin"
907 + ]
908 + },
909 + "node_modules/@rollup/rollup-darwin-x64": {
910 + "version": "4.62.4",
911 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz",
912 + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==",
913 + "cpu": [
914 + "x64"
915 + ],
916 + "dev": true,
917 + "license": "MIT",
918 + "optional": true,
919 + "os": [
920 + "darwin"
921 + ]
922 + },
923 + "node_modules/@rollup/rollup-freebsd-arm64": {
924 + "version": "4.62.4",
925 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz",
926 + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==",
927 + "cpu": [
928 + "arm64"
929 + ],
930 + "dev": true,
931 + "license": "MIT",
932 + "optional": true,
933 + "os": [
934 + "freebsd"
935 + ]
936 + },
937 + "node_modules/@rollup/rollup-freebsd-x64": {
938 + "version": "4.62.4",
939 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz",
940 + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==",
941 + "cpu": [
942 + "x64"
943 + ],
944 + "dev": true,
945 + "license": "MIT",
946 + "optional": true,
947 + "os": [
948 + "freebsd"
949 + ]
950 + },
951 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
952 + "version": "4.62.4",
953 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz",
954 + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==",
955 + "cpu": [
956 + "arm"
957 + ],
958 + "dev": true,
959 + "libc": [
960 + "glibc"
961 + ],
962 + "license": "MIT",
963 + "optional": true,
964 + "os": [
965 + "linux"
966 + ]
967 + },
968 + "node_modules/@rollup/rollup-linux-arm-musleabihf": {
969 + "version": "4.62.4",
970 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz",
971 + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==",
972 + "cpu": [
973 + "arm"
974 + ],
975 + "dev": true,
976 + "libc": [
977 + "musl"
978 + ],
979 + "license": "MIT",
980 + "optional": true,
981 + "os": [
982 + "linux"
983 + ]
984 + },
985 + "node_modules/@rollup/rollup-linux-arm64-gnu": {
986 + "version": "4.62.4",
987 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz",
988 + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==",
989 + "cpu": [
990 + "arm64"
991 + ],
992 + "dev": true,
993 + "libc": [
994 + "glibc"
995 + ],
996 + "license": "MIT",
997 + "optional": true,
998 + "os": [
999 + "linux"
1000 + ]
1001 + },
1002 + "node_modules/@rollup/rollup-linux-arm64-musl": {
1003 + "version": "4.62.4",
1004 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz",
1005 + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==",
1006 + "cpu": [
1007 + "arm64"
1008 + ],
1009 + "dev": true,
1010 + "libc": [
1011 + "musl"
1012 + ],
1013 + "license": "MIT",
1014 + "optional": true,
1015 + "os": [
1016 + "linux"
1017 + ]
1018 + },
1019 + "node_modules/@rollup/rollup-linux-loong64-gnu": {
1020 + "version": "4.62.4",
1021 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz",
1022 + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==",
1023 + "cpu": [
1024 + "loong64"
1025 + ],
1026 + "dev": true,
1027 + "libc": [
1028 + "glibc"
1029 + ],
1030 + "license": "MIT",
1031 + "optional": true,
1032 + "os": [
1033 + "linux"
1034 + ]
1035 + },
1036 + "node_modules/@rollup/rollup-linux-loong64-musl": {
1037 + "version": "4.62.4",
1038 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz",
1039 + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==",
1040 + "cpu": [
1041 + "loong64"
1042 + ],
1043 + "dev": true,
1044 + "libc": [
1045 + "musl"
1046 + ],
1047 + "license": "MIT",
1048 + "optional": true,
1049 + "os": [
1050 + "linux"
1051 + ]
1052 + },
1053 + "node_modules/@rollup/rollup-linux-ppc64-gnu": {
1054 + "version": "4.62.4",
1055 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz",
1056 + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==",
1057 + "cpu": [
1058 + "ppc64"
1059 + ],
1060 + "dev": true,
1061 + "libc": [
1062 + "glibc"
1063 + ],
1064 + "license": "MIT",
1065 + "optional": true,
1066 + "os": [
1067 + "linux"
1068 + ]
1069 + },
1070 + "node_modules/@rollup/rollup-linux-ppc64-musl": {
1071 + "version": "4.62.4",
1072 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz",
1073 + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==",
1074 + "cpu": [
1075 + "ppc64"
1076 + ],
1077 + "dev": true,
1078 + "libc": [
1079 + "musl"
1080 + ],
1081 + "license": "MIT",
1082 + "optional": true,
1083 + "os": [
1084 + "linux"
1085 + ]
1086 + },
1087 + "node_modules/@rollup/rollup-linux-riscv64-gnu": {
1088 + "version": "4.62.4",
1089 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz",
1090 + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==",
1091 + "cpu": [
1092 + "riscv64"
1093 + ],
1094 + "dev": true,
1095 + "libc": [
1096 + "glibc"
1097 + ],
1098 + "license": "MIT",
1099 + "optional": true,
1100 + "os": [
1101 + "linux"
1102 + ]
1103 + },
1104 + "node_modules/@rollup/rollup-linux-riscv64-musl": {
1105 + "version": "4.62.4",
1106 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz",
1107 + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==",
1108 + "cpu": [
1109 + "riscv64"
1110 + ],
1111 + "dev": true,
1112 + "libc": [
1113 + "musl"
1114 + ],
1115 + "license": "MIT",
1116 + "optional": true,
1117 + "os": [
1118 + "linux"
1119 + ]
1120 + },
1121 + "node_modules/@rollup/rollup-linux-s390x-gnu": {
1122 + "version": "4.62.4",
1123 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz",
1124 + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==",
1125 + "cpu": [
1126 + "s390x"
1127 + ],
1128 + "dev": true,
1129 + "libc": [
1130 + "glibc"
1131 + ],
1132 + "license": "MIT",
1133 + "optional": true,
1134 + "os": [
1135 + "linux"
1136 + ]
1137 + },
1138 + "node_modules/@rollup/rollup-linux-x64-gnu": {
1139 + "version": "4.62.4",
1140 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz",
1141 + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==",
1142 + "cpu": [
1143 + "x64"
1144 + ],
1145 + "dev": true,
1146 + "libc": [
1147 + "glibc"
1148 + ],
1149 + "license": "MIT",
1150 + "optional": true,
1151 + "os": [
1152 + "linux"
1153 + ]
1154 + },
1155 + "node_modules/@rollup/rollup-linux-x64-musl": {
1156 + "version": "4.62.4",
1157 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz",
1158 + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==",
1159 + "cpu": [
1160 + "x64"
1161 + ],
1162 + "dev": true,
1163 + "libc": [
1164 + "musl"
1165 + ],
1166 + "license": "MIT",
1167 + "optional": true,
1168 + "os": [
1169 + "linux"
1170 + ]
1171 + },
1172 + "node_modules/@rollup/rollup-openbsd-x64": {
1173 + "version": "4.62.4",
1174 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz",
1175 + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==",
1176 + "cpu": [
1177 + "x64"
1178 + ],
1179 + "dev": true,
1180 + "license": "MIT",
1181 + "optional": true,
1182 + "os": [
1183 + "openbsd"
1184 + ]
1185 + },
1186 + "node_modules/@rollup/rollup-openharmony-arm64": {
1187 + "version": "4.62.4",
1188 + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz",
1189 + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==",
1190 + "cpu": [
1191 + "arm64"
1192 + ],
1193 + "dev": true,
1194 + "license": "MIT",
1195 + "optional": true,
1196 + "os": [
1197 + "openharmony"
1198 + ]
1199 + },
1200 + "node_modules/@rollup/rollup-win32-arm64-msvc": {
1201 + "version": "4.62.4",
1202 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz",
1203 + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==",
1204 + "cpu": [
1205 + "arm64"
1206 + ],
1207 + "dev": true,
1208 + "license": "MIT",
1209 + "optional": true,
1210 + "os": [
1211 + "win32"
1212 + ]
1213 + },
1214 + "node_modules/@rollup/rollup-win32-ia32-msvc": {
1215 + "version": "4.62.4",
1216 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz",
1217 + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==",
1218 + "cpu": [
1219 + "ia32"
1220 + ],
1221 + "dev": true,
1222 + "license": "MIT",
1223 + "optional": true,
1224 + "os": [
1225 + "win32"
1226 + ]
1227 + },
1228 + "node_modules/@rollup/rollup-win32-x64-gnu": {
1229 + "version": "4.62.4",
1230 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz",
1231 + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==",
1232 + "cpu": [
1233 + "x64"
1234 + ],
1235 + "dev": true,
1236 + "license": "MIT",
1237 + "optional": true,
1238 + "os": [
1239 + "win32"
1240 + ]
1241 + },
1242 + "node_modules/@rollup/rollup-win32-x64-msvc": {
1243 + "version": "4.62.4",
1244 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz",
1245 + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==",
1246 + "cpu": [
1247 + "x64"
1248 + ],
1249 + "dev": true,
1250 + "license": "MIT",
1251 + "optional": true,
1252 + "os": [
1253 + "win32"
1254 + ]
1255 + },
1256 + "node_modules/@types/babel__core": {
1257 + "version": "7.20.5",
1258 + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
1259 + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
1260 + "dev": true,
1261 + "license": "MIT",
1262 + "dependencies": {
1263 + "@babel/parser": "^7.20.7",
1264 + "@babel/types": "^7.20.7",
1265 + "@types/babel__generator": "*",
1266 + "@types/babel__template": "*",
1267 + "@types/babel__traverse": "*"
1268 + }
1269 + },
1270 + "node_modules/@types/babel__generator": {
1271 + "version": "7.27.0",
1272 + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
1273 + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
1274 + "dev": true,
1275 + "license": "MIT",
1276 + "dependencies": {
1277 + "@babel/types": "^7.0.0"
1278 + }
1279 + },
1280 + "node_modules/@types/babel__template": {
1281 + "version": "7.4.4",
1282 + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
1283 + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
1284 + "dev": true,
1285 + "license": "MIT",
1286 + "dependencies": {
1287 + "@babel/parser": "^7.1.0",
1288 + "@babel/types": "^7.0.0"
1289 + }
1290 + },
1291 + "node_modules/@types/babel__traverse": {
1292 + "version": "7.28.0",
1293 + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
1294 + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
1295 + "dev": true,
1296 + "license": "MIT",
1297 + "dependencies": {
1298 + "@babel/types": "^7.28.2"
1299 + }
1300 + },
1301 + "node_modules/@types/estree": {
1302 + "version": "1.0.9",
1303 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
1304 + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
1305 + "dev": true,
1306 + "license": "MIT"
1307 + },
1308 + "node_modules/@types/geojson": {
1309 + "version": "7946.0.16",
1310 + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
1311 + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
1312 + "license": "MIT"
1313 + },
1314 + "node_modules/@types/geojson-vt": {
1315 + "version": "3.2.5",
1316 + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz",
1317 + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==",
1318 + "license": "MIT",
1319 + "dependencies": {
1320 + "@types/geojson": "*"
1321 + }
1322 + },
1323 + "node_modules/@types/mapbox__point-geometry": {
1324 + "version": "0.1.4",
1325 + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
1326 + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
1327 + "license": "MIT"
1328 + },
1329 + "node_modules/@types/mapbox__vector-tile": {
1330 + "version": "1.3.4",
1331 + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
1332 + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
1333 + "license": "MIT",
1334 + "dependencies": {
1335 + "@types/geojson": "*",
1336 + "@types/mapbox__point-geometry": "*",
1337 + "@types/pbf": "*"
1338 + }
1339 + },
1340 + "node_modules/@types/pbf": {
1341 + "version": "3.0.5",
1342 + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
1343 + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
1344 + "license": "MIT"
1345 + },
1346 + "node_modules/@types/prop-types": {
1347 + "version": "15.7.15",
1348 + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
1349 + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
1350 + "dev": true,
1351 + "license": "MIT"
1352 + },
1353 + "node_modules/@types/react": {
1354 + "version": "18.3.31",
1355 + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
1356 + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
1357 + "dev": true,
1358 + "license": "MIT",
1359 + "dependencies": {
1360 + "@types/prop-types": "*",
1361 + "csstype": "^3.2.2"
1362 + }
1363 + },
1364 + "node_modules/@types/react-dom": {
1365 + "version": "18.3.7",
1366 + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
1367 + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
1368 + "dev": true,
1369 + "license": "MIT",
1370 + "peerDependencies": {
1371 + "@types/react": "^18.0.0"
1372 + }
1373 + },
1374 + "node_modules/@types/supercluster": {
1375 + "version": "7.1.3",
1376 + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
1377 + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
1378 + "license": "MIT",
1379 + "dependencies": {
1380 + "@types/geojson": "*"
1381 + }
1382 + },
1383 + "node_modules/@vitejs/plugin-react": {
1384 + "version": "4.7.0",
1385 + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
1386 + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
1387 + "dev": true,
1388 + "license": "MIT",
1389 + "dependencies": {
1390 + "@babel/core": "^7.28.0",
1391 + "@babel/plugin-transform-react-jsx-self": "^7.27.1",
1392 + "@babel/plugin-transform-react-jsx-source": "^7.27.1",
1393 + "@rolldown/pluginutils": "1.0.0-beta.27",
1394 + "@types/babel__core": "^7.20.5",
1395 + "react-refresh": "^0.17.0"
1396 + },
1397 + "engines": {
1398 + "node": "^14.18.0 || >=16.0.0"
1399 + },
1400 + "peerDependencies": {
1401 + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
1402 + }
1403 + },
1404 + "node_modules/baseline-browser-mapping": {
1405 + "version": "2.11.13",
1406 + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz",
1407 + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==",
1408 + "dev": true,
1409 + "license": "Apache-2.0",
1410 + "bin": {
1411 + "baseline-browser-mapping": "dist/cli.cjs"
1412 + },
1413 + "engines": {
1414 + "node": ">=6.0.0"
1415 + }
1416 + },
1417 + "node_modules/browserslist": {
1418 + "version": "4.28.8",
1419 + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
1420 + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
1421 + "dev": true,
1422 + "funding": [
1423 + {
1424 + "type": "opencollective",
1425 + "url": "https://opencollective.com/browserslist"
1426 + },
1427 + {
1428 + "type": "tidelift",
1429 + "url": "https://tidelift.com/funding/github/npm/browserslist"
1430 + },
1431 + {
1432 + "type": "github",
1433 + "url": "https://github.com/sponsors/ai"
1434 + }
1435 + ],
1436 + "license": "MIT",
1437 + "dependencies": {
1438 + "baseline-browser-mapping": "^2.11.12",
1439 + "caniuse-lite": "^1.0.30001809",
1440 + "electron-to-chromium": "^1.5.402",
1441 + "node-releases": "^2.0.53",
1442 + "update-browserslist-db": "^1.3.0"
1443 + },
1444 + "bin": {
1445 + "browserslist": "cli.js"
1446 + },
1447 + "engines": {
1448 + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
1449 + }
1450 + },
1451 + "node_modules/caniuse-lite": {
1452 + "version": "1.0.30001809",
1453 + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz",
1454 + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==",
1455 + "dev": true,
1456 + "funding": [
1457 + {
1458 + "type": "opencollective",
1459 + "url": "https://opencollective.com/browserslist"
1460 + },
1461 + {
1462 + "type": "tidelift",
1463 + "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
1464 + },
1465 + {
1466 + "type": "github",
1467 + "url": "https://github.com/sponsors/ai"
1468 + }
1469 + ],
1470 + "license": "CC-BY-4.0"
1471 + },
1472 + "node_modules/convert-source-map": {
1473 + "version": "2.0.0",
1474 + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
1475 + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
1476 + "dev": true,
1477 + "license": "MIT"
1478 + },
1479 + "node_modules/csstype": {
1480 + "version": "3.2.3",
1481 + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1482 + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1483 + "dev": true,
1484 + "license": "MIT"
1485 + },
1486 + "node_modules/debug": {
1487 + "version": "4.4.3",
1488 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
1489 + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
1490 + "dev": true,
1491 + "license": "MIT",
1492 + "dependencies": {
1493 + "ms": "^2.1.3"
1494 + },
1495 + "engines": {
1496 + "node": ">=6.0"
1497 + },
1498 + "peerDependenciesMeta": {
1499 + "supports-color": {
1500 + "optional": true
1501 + }
1502 + }
1503 + },
1504 + "node_modules/earcut": {
1505 + "version": "3.2.3",
1506 + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz",
1507 + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
1508 + "license": "ISC"
1509 + },
1510 + "node_modules/electron-to-chromium": {
1511 + "version": "1.5.405",
1512 + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.405.tgz",
1513 + "integrity": "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==",
1514 + "dev": true,
1515 + "license": "ISC"
1516 + },
1517 + "node_modules/esbuild": {
1518 + "version": "0.21.5",
1519 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
1520 + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1521 + "dev": true,
1522 + "hasInstallScript": true,
1523 + "license": "MIT",
1524 + "bin": {
1525 + "esbuild": "bin/esbuild"
1526 + },
1527 + "engines": {
1528 + "node": ">=12"
1529 + },
1530 + "optionalDependencies": {
1531 + "@esbuild/aix-ppc64": "0.21.5",
1532 + "@esbuild/android-arm": "0.21.5",
1533 + "@esbuild/android-arm64": "0.21.5",
1534 + "@esbuild/android-x64": "0.21.5",
1535 + "@esbuild/darwin-arm64": "0.21.5",
1536 + "@esbuild/darwin-x64": "0.21.5",
1537 + "@esbuild/freebsd-arm64": "0.21.5",
1538 + "@esbuild/freebsd-x64": "0.21.5",
1539 + "@esbuild/linux-arm": "0.21.5",
1540 + "@esbuild/linux-arm64": "0.21.5",
1541 + "@esbuild/linux-ia32": "0.21.5",
1542 + "@esbuild/linux-loong64": "0.21.5",
1543 + "@esbuild/linux-mips64el": "0.21.5",
1544 + "@esbuild/linux-ppc64": "0.21.5",
1545 + "@esbuild/linux-riscv64": "0.21.5",
1546 + "@esbuild/linux-s390x": "0.21.5",
1547 + "@esbuild/linux-x64": "0.21.5",
1548 + "@esbuild/netbsd-x64": "0.21.5",
1549 + "@esbuild/openbsd-x64": "0.21.5",
1550 + "@esbuild/sunos-x64": "0.21.5",
1551 + "@esbuild/win32-arm64": "0.21.5",
1552 + "@esbuild/win32-ia32": "0.21.5",
1553 + "@esbuild/win32-x64": "0.21.5"
1554 + }
1555 + },
1556 + "node_modules/escalade": {
1557 + "version": "3.2.0",
1558 + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
1559 + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
1560 + "dev": true,
1561 + "license": "MIT",
1562 + "engines": {
1563 + "node": ">=6"
1564 + }
1565 + },
1566 + "node_modules/fsevents": {
1567 + "version": "2.3.3",
1568 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1569 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1570 + "dev": true,
1571 + "hasInstallScript": true,
1572 + "license": "MIT",
1573 + "optional": true,
1574 + "os": [
1575 + "darwin"
1576 + ],
1577 + "engines": {
1578 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1579 + }
1580 + },
1581 + "node_modules/gensync": {
1582 + "version": "1.0.0-beta.2",
1583 + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
1584 + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
1585 + "dev": true,
1586 + "license": "MIT",
1587 + "engines": {
1588 + "node": ">=6.9.0"
1589 + }
1590 + },
1591 + "node_modules/geojson-vt": {
1592 + "version": "4.0.3",
1593 + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz",
1594 + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==",
1595 + "license": "ISC"
1596 + },
1597 + "node_modules/get-stream": {
1598 + "version": "6.0.1",
1599 + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
1600 + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
1601 + "license": "MIT",
1602 + "engines": {
1603 + "node": ">=10"
1604 + },
1605 + "funding": {
1606 + "url": "https://github.com/sponsors/sindresorhus"
1607 + }
1608 + },
1609 + "node_modules/gl-matrix": {
1610 + "version": "3.4.4",
1611 + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
1612 + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
1613 + "license": "MIT"
1614 + },
1615 + "node_modules/global-prefix": {
1616 + "version": "4.0.0",
1617 + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz",
1618 + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==",
1619 + "license": "MIT",
1620 + "dependencies": {
1621 + "ini": "^4.1.3",
1622 + "kind-of": "^6.0.3",
1623 + "which": "^4.0.0"
1624 + },
1625 + "engines": {
1626 + "node": ">=16"
1627 + }
1628 + },
1629 + "node_modules/ieee754": {
1630 + "version": "1.2.1",
1631 + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
1632 + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
1633 + "funding": [
1634 + {
1635 + "type": "github",
1636 + "url": "https://github.com/sponsors/feross"
1637 + },
1638 + {
1639 + "type": "patreon",
1640 + "url": "https://www.patreon.com/feross"
1641 + },
1642 + {
1643 + "type": "consulting",
1644 + "url": "https://feross.org/support"
1645 + }
1646 + ],
1647 + "license": "BSD-3-Clause"
1648 + },
1649 + "node_modules/ini": {
1650 + "version": "4.1.3",
1651 + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
1652 + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==",
1653 + "license": "ISC",
1654 + "engines": {
1655 + "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
1656 + }
1657 + },
1658 + "node_modules/isexe": {
1659 + "version": "3.1.5",
1660 + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
1661 + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
1662 + "license": "BlueOak-1.0.0",
1663 + "engines": {
1664 + "node": ">=18"
1665 + }
1666 + },
1667 + "node_modules/js-tokens": {
1668 + "version": "4.0.0",
1669 + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
1670 + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
1671 + "license": "MIT"
1672 + },
1673 + "node_modules/jsesc": {
1674 + "version": "3.1.0",
1675 + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
1676 + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
1677 + "dev": true,
1678 + "license": "MIT",
1679 + "bin": {
1680 + "jsesc": "bin/jsesc"
1681 + },
1682 + "engines": {
1683 + "node": ">=6"
1684 + }
1685 + },
1686 + "node_modules/json-stringify-pretty-compact": {
1687 + "version": "4.0.0",
1688 + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
1689 + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
1690 + "license": "MIT"
1691 + },
1692 + "node_modules/json5": {
1693 + "version": "2.2.3",
1694 + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
1695 + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
1696 + "dev": true,
1697 + "license": "MIT",
1698 + "bin": {
1699 + "json5": "lib/cli.js"
1700 + },
1701 + "engines": {
1702 + "node": ">=6"
1703 + }
1704 + },
1705 + "node_modules/kdbush": {
1706 + "version": "4.1.0",
1707 + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
1708 + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
1709 + "license": "ISC"
1710 + },
1711 + "node_modules/kind-of": {
1712 + "version": "6.0.3",
1713 + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
1714 + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
1715 + "license": "MIT",
1716 + "engines": {
1717 + "node": ">=0.10.0"
1718 + }
1719 + },
1720 + "node_modules/loose-envify": {
1721 + "version": "1.4.0",
1722 + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
1723 + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
1724 + "license": "MIT",
1725 + "dependencies": {
1726 + "js-tokens": "^3.0.0 || ^4.0.0"
1727 + },
1728 + "bin": {
1729 + "loose-envify": "cli.js"
1730 + }
1731 + },
1732 + "node_modules/lru-cache": {
1733 + "version": "5.1.1",
1734 + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
1735 + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
1736 + "dev": true,
1737 + "license": "ISC",
1738 + "dependencies": {
1739 + "yallist": "^3.0.2"
1740 + }
1741 + },
1742 + "node_modules/maplibre-gl": {
1743 + "version": "4.7.1",
1744 + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
1745 + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
1746 + "license": "BSD-3-Clause",
1747 + "dependencies": {
1748 + "@mapbox/geojson-rewind": "^0.5.2",
1749 + "@mapbox/jsonlint-lines-primitives": "^2.0.2",
1750 + "@mapbox/point-geometry": "^0.1.0",
1751 + "@mapbox/tiny-sdf": "^2.0.6",
1752 + "@mapbox/unitbezier": "^0.0.1",
1753 + "@mapbox/vector-tile": "^1.3.1",
1754 + "@mapbox/whoots-js": "^3.1.0",
1755 + "@maplibre/maplibre-gl-style-spec": "^20.3.1",
1756 + "@types/geojson": "^7946.0.14",
1757 + "@types/geojson-vt": "3.2.5",
1758 + "@types/mapbox__point-geometry": "^0.1.4",
1759 + "@types/mapbox__vector-tile": "^1.3.4",
1760 + "@types/pbf": "^3.0.5",
1761 + "@types/supercluster": "^7.1.3",
1762 + "earcut": "^3.0.0",
1763 + "geojson-vt": "^4.0.2",
1764 + "gl-matrix": "^3.4.3",
1765 + "global-prefix": "^4.0.0",
1766 + "kdbush": "^4.0.2",
1767 + "murmurhash-js": "^1.0.0",
1768 + "pbf": "^3.3.0",
1769 + "potpack": "^2.0.0",
1770 + "quickselect": "^3.0.0",
1771 + "supercluster": "^8.0.1",
1772 + "tinyqueue": "^3.0.0",
1773 + "vt-pbf": "^3.1.3"
1774 + },
1775 + "engines": {
1776 + "node": ">=16.14.0",
1777 + "npm": ">=8.1.0"
1778 + },
1779 + "funding": {
1780 + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
1781 + }
1782 + },
1783 + "node_modules/minimist": {
1784 + "version": "1.2.8",
1785 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
1786 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
1787 + "license": "MIT",
1788 + "funding": {
1789 + "url": "https://github.com/sponsors/ljharb"
1790 + }
1791 + },
1792 + "node_modules/ms": {
1793 + "version": "2.1.3",
1794 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1795 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1796 + "dev": true,
1797 + "license": "MIT"
1798 + },
1799 + "node_modules/murmurhash-js": {
1800 + "version": "1.0.0",
1801 + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
1802 + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
1803 + "license": "MIT"
1804 + },
1805 + "node_modules/nanoid": {
1806 + "version": "3.3.18",
1807 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
1808 + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
1809 + "dev": true,
1810 + "funding": [
1811 + {
1812 + "type": "github",
1813 + "url": "https://github.com/sponsors/ai"
1814 + }
1815 + ],
1816 + "license": "MIT",
1817 + "bin": {
1818 + "nanoid": "bin/nanoid.cjs"
1819 + },
1820 + "engines": {
1821 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
1822 + }
1823 + },
1824 + "node_modules/node-releases": {
1825 + "version": "2.0.53",
1826 + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
1827 + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
1828 + "dev": true,
1829 + "license": "MIT",
1830 + "engines": {
1831 + "node": ">=18"
1832 + }
1833 + },
1834 + "node_modules/pbf": {
1835 + "version": "3.3.0",
1836 + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
1837 + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
1838 + "license": "BSD-3-Clause",
1839 + "dependencies": {
1840 + "ieee754": "^1.1.12",
1841 + "resolve-protobuf-schema": "^2.1.0"
1842 + },
1843 + "bin": {
1844 + "pbf": "bin/pbf"
1845 + }
1846 + },
1847 + "node_modules/picocolors": {
1848 + "version": "1.1.1",
1849 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
1850 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
1851 + "dev": true,
1852 + "license": "ISC"
1853 + },
1854 + "node_modules/postcss": {
1855 + "version": "8.5.26",
1856 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
1857 + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
1858 + "dev": true,
1859 + "funding": [
1860 + {
1861 + "type": "opencollective",
1862 + "url": "https://opencollective.com/postcss/"
1863 + },
1864 + {
1865 + "type": "tidelift",
1866 + "url": "https://tidelift.com/funding/github/npm/postcss"
1867 + },
1868 + {
1869 + "type": "github",
1870 + "url": "https://github.com/sponsors/ai"
1871 + }
1872 + ],
1873 + "license": "MIT",
1874 + "dependencies": {
1875 + "nanoid": "^3.3.17",
1876 + "picocolors": "^1.1.1",
1877 + "source-map-js": "^1.2.1"
1878 + },
1879 + "engines": {
1880 + "node": "^10 || ^12 || >=14"
1881 + }
1882 + },
1883 + "node_modules/potpack": {
1884 + "version": "2.1.0",
1885 + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
1886 + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
1887 + "license": "ISC"
1888 + },
1889 + "node_modules/protocol-buffers-schema": {
1890 + "version": "3.6.1",
1891 + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
1892 + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
1893 + "license": "MIT"
1894 + },
1895 + "node_modules/quickselect": {
1896 + "version": "3.0.0",
1897 + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
1898 + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
1899 + "license": "ISC"
1900 + },
1901 + "node_modules/react": {
1902 + "version": "18.3.1",
1903 + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
1904 + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
1905 + "license": "MIT",
1906 + "dependencies": {
1907 + "loose-envify": "^1.1.0"
1908 + },
1909 + "engines": {
1910 + "node": ">=0.10.0"
1911 + }
1912 + },
1913 + "node_modules/react-dom": {
1914 + "version": "18.3.1",
1915 + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
1916 + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
1917 + "license": "MIT",
1918 + "dependencies": {
1919 + "loose-envify": "^1.1.0",
1920 + "scheduler": "^0.23.2"
1921 + },
1922 + "peerDependencies": {
1923 + "react": "^18.3.1"
1924 + }
1925 + },
1926 + "node_modules/react-refresh": {
1927 + "version": "0.17.0",
1928 + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1929 + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1930 + "dev": true,
1931 + "license": "MIT",
1932 + "engines": {
1933 + "node": ">=0.10.0"
1934 + }
1935 + },
1936 + "node_modules/react-router": {
1937 + "version": "6.30.4",
1938 + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
1939 + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
1940 + "license": "MIT",
1941 + "dependencies": {
1942 + "@remix-run/router": "1.23.3"
1943 + },
1944 + "engines": {
1945 + "node": ">=14.0.0"
1946 + },
1947 + "peerDependencies": {
1948 + "react": ">=16.8"
1949 + }
1950 + },
1951 + "node_modules/react-router-dom": {
1952 + "version": "6.30.4",
1953 + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
1954 + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
1955 + "license": "MIT",
1956 + "dependencies": {
1957 + "@remix-run/router": "1.23.3",
1958 + "react-router": "6.30.4"
1959 + },
1960 + "engines": {
1961 + "node": ">=14.0.0"
1962 + },
1963 + "peerDependencies": {
1964 + "react": ">=16.8",
1965 + "react-dom": ">=16.8"
1966 + }
1967 + },
1968 + "node_modules/resolve-protobuf-schema": {
1969 + "version": "2.1.0",
1970 + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
1971 + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
1972 + "license": "MIT",
1973 + "dependencies": {
1974 + "protocol-buffers-schema": "^3.3.1"
1975 + }
1976 + },
1977 + "node_modules/rollup": {
1978 + "version": "4.62.4",
1979 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
1980 + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==",
1981 + "dev": true,
1982 + "license": "MIT",
1983 + "dependencies": {
1984 + "@types/estree": "1.0.9"
1985 + },
1986 + "bin": {
1987 + "rollup": "dist/bin/rollup"
1988 + },
1989 + "engines": {
1990 + "node": ">=18.0.0",
1991 + "npm": ">=8.0.0"
1992 + },
1993 + "optionalDependencies": {
1994 + "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
1995 + "@rollup/rollup-android-arm-eabi": "4.62.4",
1996 + "@rollup/rollup-android-arm64": "4.62.4",
1997 + "@rollup/rollup-darwin-arm64": "4.62.4",
1998 + "@rollup/rollup-darwin-x64": "4.62.4",
1999 + "@rollup/rollup-freebsd-arm64": "4.62.4",
2000 + "@rollup/rollup-freebsd-x64": "4.62.4",
2001 + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4",
2002 + "@rollup/rollup-linux-arm-musleabihf": "4.62.4",
2003 + "@rollup/rollup-linux-arm64-gnu": "4.62.4",
2004 + "@rollup/rollup-linux-arm64-musl": "4.62.4",
2005 + "@rollup/rollup-linux-loong64-gnu": "4.62.4",
2006 + "@rollup/rollup-linux-loong64-musl": "4.62.4",
2007 + "@rollup/rollup-linux-ppc64-gnu": "4.62.4",
2008 + "@rollup/rollup-linux-ppc64-musl": "4.62.4",
2009 + "@rollup/rollup-linux-riscv64-gnu": "4.62.4",
2010 + "@rollup/rollup-linux-riscv64-musl": "4.62.4",
2011 + "@rollup/rollup-linux-s390x-gnu": "4.62.4",
2012 + "@rollup/rollup-linux-x64-gnu": "4.62.4",
2013 + "@rollup/rollup-linux-x64-musl": "4.62.4",
2014 + "@rollup/rollup-openbsd-x64": "4.62.4",
2015 + "@rollup/rollup-openharmony-arm64": "4.62.4",
2016 + "@rollup/rollup-win32-arm64-msvc": "4.62.4",
2017 + "@rollup/rollup-win32-ia32-msvc": "4.62.4",
2018 + "@rollup/rollup-win32-x64-gnu": "4.62.4",
2019 + "@rollup/rollup-win32-x64-msvc": "4.62.4",
2020 + "fsevents": "~2.3.2"
2021 + }
2022 + },
2023 + "node_modules/rw": {
2024 + "version": "1.3.3",
2025 + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
2026 + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
2027 + "license": "BSD-3-Clause"
2028 + },
2029 + "node_modules/scheduler": {
2030 + "version": "0.23.2",
2031 + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
2032 + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
2033 + "license": "MIT",
2034 + "dependencies": {
2035 + "loose-envify": "^1.1.0"
2036 + }
2037 + },
2038 + "node_modules/semver": {
2039 + "version": "6.3.1",
2040 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
2041 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
2042 + "dev": true,
2043 + "license": "ISC",
2044 + "bin": {
2045 + "semver": "bin/semver.js"
2046 + }
2047 + },
2048 + "node_modules/source-map-js": {
2049 + "version": "1.2.1",
2050 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
2051 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
2052 + "dev": true,
2053 + "license": "BSD-3-Clause",
2054 + "engines": {
2055 + "node": ">=0.10.0"
2056 + }
2057 + },
2058 + "node_modules/supercluster": {
2059 + "version": "8.0.1",
2060 + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
2061 + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
2062 + "license": "ISC",
2063 + "dependencies": {
2064 + "kdbush": "^4.0.2"
2065 + }
2066 + },
2067 + "node_modules/tinyqueue": {
2068 + "version": "3.0.0",
2069 + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
2070 + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
2071 + "license": "ISC"
2072 + },
2073 + "node_modules/typescript": {
2074 + "version": "5.9.3",
2075 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
2076 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
2077 + "dev": true,
2078 + "license": "Apache-2.0",
2079 + "bin": {
2080 + "tsc": "bin/tsc",
2081 + "tsserver": "bin/tsserver"
2082 + },
2083 + "engines": {
2084 + "node": ">=14.17"
2085 + }
2086 + },
2087 + "node_modules/update-browserslist-db": {
2088 + "version": "1.3.1",
2089 + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
2090 + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
2091 + "dev": true,
2092 + "funding": [
2093 + {
2094 + "type": "opencollective",
2095 + "url": "https://opencollective.com/browserslist"
2096 + },
2097 + {
2098 + "type": "tidelift",
2099 + "url": "https://tidelift.com/funding/github/npm/browserslist"
2100 + },
2101 + {
2102 + "type": "github",
2103 + "url": "https://github.com/sponsors/ai"
2104 + }
2105 + ],
2106 + "license": "MIT",
2107 + "dependencies": {
2108 + "escalade": "^3.2.0",
2109 + "picocolors": "^1.1.1"
2110 + },
2111 + "bin": {
2112 + "update-browserslist-db": "cli.js"
2113 + },
2114 + "peerDependencies": {
2115 + "browserslist": ">= 4.21.0"
2116 + }
2117 + },
2118 + "node_modules/vite": {
2119 + "version": "5.4.21",
2120 + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
2121 + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
2122 + "dev": true,
2123 + "license": "MIT",
2124 + "dependencies": {
2125 + "esbuild": "^0.21.3",
2126 + "postcss": "^8.4.43",
2127 + "rollup": "^4.20.0"
2128 + },
2129 + "bin": {
2130 + "vite": "bin/vite.js"
2131 + },
2132 + "engines": {
2133 + "node": "^18.0.0 || >=20.0.0"
2134 + },
2135 + "funding": {
2136 + "url": "https://github.com/vitejs/vite?sponsor=1"
2137 + },
2138 + "optionalDependencies": {
2139 + "fsevents": "~2.3.3"
2140 + },
2141 + "peerDependencies": {
2142 + "@types/node": "^18.0.0 || >=20.0.0",
2143 + "less": "*",
2144 + "lightningcss": "^1.21.0",
2145 + "sass": "*",
2146 + "sass-embedded": "*",
2147 + "stylus": "*",
2148 + "sugarss": "*",
2149 + "terser": "^5.4.0"
2150 + },
2151 + "peerDependenciesMeta": {
2152 + "@types/node": {
2153 + "optional": true
2154 + },
2155 + "less": {
2156 + "optional": true
2157 + },
2158 + "lightningcss": {
2159 + "optional": true
2160 + },
2161 + "sass": {
2162 + "optional": true
2163 + },
2164 + "sass-embedded": {
2165 + "optional": true
2166 + },
2167 + "stylus": {
2168 + "optional": true
2169 + },
2170 + "sugarss": {
2171 + "optional": true
2172 + },
2173 + "terser": {
2174 + "optional": true
2175 + }
2176 + }
2177 + },
2178 + "node_modules/vt-pbf": {
2179 + "version": "3.1.3",
2180 + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
2181 + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
2182 + "license": "MIT",
2183 + "dependencies": {
2184 + "@mapbox/point-geometry": "0.1.0",
2185 + "@mapbox/vector-tile": "^1.3.1",
2186 + "pbf": "^3.2.1"
2187 + }
2188 + },
2189 + "node_modules/which": {
2190 + "version": "4.0.0",
2191 + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
2192 + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==",
2193 + "license": "ISC",
2194 + "dependencies": {
2195 + "isexe": "^3.1.1"
2196 + },
2197 + "bin": {
2198 + "node-which": "bin/which.js"
2199 + },
2200 + "engines": {
2201 + "node": "^16.13.0 || >=18.0.0"
2202 + }
2203 + },
2204 + "node_modules/yallist": {
2205 + "version": "3.1.1",
2206 + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
2207 + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
2208 + "dev": true,
2209 + "license": "ISC"
2210 + }
2211 + }
2212 +}
added frontend/package.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "name": "immo-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 + "maplibre-gl": "^4.7.1",
14 + "react": "^18.3.1",
15 + "react-dom": "^18.3.1",
16 + "react-router-dom": "^6.26.0"
17 + },
18 + "devDependencies": {
19 + "@types/react": "^18.3.3",
20 + "@types/react-dom": "^18.3.0",
21 + "@vitejs/plugin-react": "^4.3.1",
22 + "typescript": "^5.5.4",
23 + "vite": "^5.4.0"
24 + }
25 +}
added frontend/src/App.tsx +166 −0
@@ -0,0 +1,166 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// App.tsx : layout global (header + ticker en direct + footer encre) et routage
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { NavLink, Route, Routes, useLocation } from "react-router-dom";
8 +import { fetchFacets, fetchSources, fetchStats, registerSourceNames, sourceName } from "./api";
9 +import Home from "./pages/Home";
10 +import ListingPage from "./pages/Listing";
11 +import SourcesPage from "./pages/Sources";
12 +import StatsPage from "./pages/Stats";
13 +
14 +function Ticker() {
15 + const [items, setItems] = useState<string[]>([]);
16 +
17 + useEffect(() => {
18 + Promise.all([fetchStats(), fetchFacets(), fetchSources()])
19 + .then(([stats, facets, src]) => {
20 + registerSourceNames(src.sources);
21 + const parts: string[] = [`${stats.total.toLocaleString("fr-CA")} propriétés à vendre`];
22 + if (stats.cities) parts.push(`${stats.cities} villes`);
23 + if (stats.avg_price != null)
24 + parts.push(`Prix moyen ${Math.round(stats.avg_price).toLocaleString("fr-CA")} $`);
25 + for (const s of facets.sources.slice(0, 12))
26 + parts.push(`${sourceName(s.source)} · ${s.n}`);
27 + parts.push("Mise à jour automatique");
28 + setItems(parts);
29 + })
30 + .catch(() => setItems(["Immo-Ka — propriétés à vendre au Québec"]));
31 + }, []);
32 +
33 + if (items.length === 0) return null;
34 + return (
35 + <div className="ticker" aria-hidden="true">
36 + <div className="ticker-track">
37 + {[...items, ...items].map((t, i) => (
38 + <span key={i}>{t}</span>
39 + ))}
40 + </div>
41 + </div>
42 + );
43 +}
44 +
45 +const NAV_LINKS = [
46 + { to: "/", label: "Propriétés", icon: "🏠", end: true },
47 + { to: "/?view=carte", label: "Carte", icon: "◈", end: false, force: true },
48 + { to: "/stats", label: "Stats", icon: "📊", end: false },
49 + { to: "/agences", label: "Agences", icon: "🗂", end: false },
50 +];
51 +
52 +function Header() {
53 + const [open, setOpen] = useState(false);
54 + const location = useLocation();
55 +
56 + useEffect(() => { setOpen(false); }, [location]);
57 + useEffect(() => {
58 + document.body.style.overflow = open ? "hidden" : "";
59 + return () => { document.body.style.overflow = ""; };
60 + }, [open]);
61 +
62 + return (
63 + <>
64 + <header className="header">
65 + <div className="container header-inner">
66 + <NavLink to="/" className="brand" aria-label="Immo-Ka — accueil">
67 + Immo<span className="ka">Ka</span>
68 + <span className="brand-tag">Toutes les agences du Québec — toujours à jour</span>
69 + </NavLink>
70 + <nav className="nav" aria-label="Navigation principale">
71 + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>
72 + Propriétés
73 + </NavLink>
74 + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
75 + Stats
76 + </NavLink>
77 + <NavLink to="/agences" className={({ isActive }) => (isActive ? "active" : "")}>
78 + Agences
79 + </NavLink>
80 + </nav>
81 + <button
82 + className={`menu-btn ${open ? "open" : ""}`}
83 + aria-expanded={open}
84 + aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
85 + onClick={() => setOpen(!open)}
86 + >
87 + <span /><span /><span />
88 + </button>
89 + </div>
90 +
91 + <div className={`mobile-menu ${open ? "open" : ""}`} role="navigation" aria-label="Menu mobile">
92 + {NAV_LINKS.map((l, i) => (
93 + <NavLink
94 + key={l.to}
95 + to={l.to}
96 + end={l.end}
97 + style={{ transitionDelay: open ? `${60 + i * 45}ms` : "0ms" }}
98 + className={({ isActive }) => `mm-link ${isActive && !l.force ? "active" : ""}`}
99 + onClick={() => setOpen(false)}
100 + >
101 + <span className="mm-ico" aria-hidden="true">{l.icon}</span>
102 + {l.label}
103 + <span className="mm-arrow" aria-hidden="true">→</span>
104 + </NavLink>
105 + ))}
106 + <div className="mm-foot">
107 + Agrégateur indépendant — mis à jour automatiquement, chaque fiche
108 + renvoie à l'annonce originale de l'agence.
109 + </div>
110 + </div>
111 + </header>
112 + {open && <div className="mm-backdrop" onClick={() => setOpen(false)} aria-hidden="true" />}
113 + <Ticker />
114 + </>
115 + );
116 +}
117 +
118 +function Footer() {
119 + return (
120 + <footer className="footer">
121 + <div className="container">
122 + <div className="fbrand">
123 + Immo<span className="ka">Ka</span>
124 + </div>
125 + <div className="frow">
126 + <div>
127 + <b>Agrégateur indépendant</b> des propriétés à vendre dans la province de Québec.
128 + Les annonces proviennent des sites publics des agences de courtage (RE/MAX, Sutton,
129 + Via Capitale, Proprio Direct, Barnes, M Immobilier…) et sont rafraîchies
130 + automatiquement — chaque fiche renvoie vers l'annonce originale.
131 + </div>
132 + </div>
133 + <div className="fmono">
134 + © {new Date().getFullYear()} Simon-Pierre Boucher — contact@spboucher.ai
135 + </div>
136 + </div>
137 + </footer>
138 + );
139 +}
140 +
141 +export default function App() {
142 + return (
143 + <>
144 + <Header />
145 + <main>
146 + <Routes>
147 + <Route path="/" element={<Home />} />
148 + <Route path="/propriete/:uid" element={<ListingPage />} />
149 + <Route path="/stats" element={<StatsPage />} />
150 + <Route path="/agences" element={<SourcesPage />} />
151 + <Route
152 + path="*"
153 + element={
154 + <div className="notice container">
155 + <div className="big">🧭</div>
156 + <h2>Page introuvable</h2>
157 + <p>Le lien demandé n'existe pas.</p>
158 + </div>
159 + }
160 + />
161 + </Routes>
162 + </main>
163 + <Footer />
164 + </>
165 + );
166 +}
added frontend/src/api.ts +164 −0
@@ -0,0 +1,164 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// api.ts : types + client API robuste (timeout, erreurs typées)
5 +// -----------------------------------------------------------------------------
6 +
7 +export interface Room {
8 + nom?: string;
9 + niveau?: string;
10 + dimensions?: string;
11 + revetement?: string;
12 +}
13 +
14 +/** details : dictionnaire libre de caractéristiques Centris (libellé → valeur),
15 + * avec la clé spéciale `pieces` (liste des pièces + dimensions). */
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 + first_seen?: number;
53 + last_seen?: number;
54 + updated_at?: number;
55 + active?: number;
56 +}
57 +
58 +export interface Facets {
59 + cities: string[];
60 + sectors: string[];
61 + property_types: string[];
62 + sources: { source: string; n: number }[];
63 +}
64 +
65 +export interface Source {
66 + id: string;
67 + name: string;
68 + url: string;
69 + listing_url?: string;
70 + coverage?: string;
71 + type?: string;
72 + connector?: string | null;
73 + status: string;
74 + active_listings: number;
75 + last_sync: number | null;
76 +}
77 +
78 +export interface Stats {
79 + total: number;
80 + sources: number;
81 + cities: number;
82 + avg_price: number | null;
83 + min_price: number | null;
84 + max_price: number | null;
85 + recent_syncs?: {
86 + source: string; ts: number; found: number; added: number;
87 + updated: number; removed: number; ok: number; message: string;
88 + }[];
89 +}
90 +
91 +// --- Noms d'agences (jolis libellés) ----------------------------------------
92 +const SOURCE_NAMES: Record<string, string> = {};
93 +export function registerSourceNames(sources: Source[]) {
94 + for (const s of sources) SOURCE_NAMES[s.id] = s.name;
95 +}
96 +export function sourceName(id: string): string {
97 + if (SOURCE_NAMES[id]) return SOURCE_NAMES[id];
98 + // repli lisible pour les sous-agences RE/MAX générées (remax_ag_xxx)
99 + if (id.startsWith("remax_ag_"))
100 + return "RE/MAX " + id.slice(9).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
101 + return id.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
102 +}
103 +
104 +async function get<T>(path: string): Promise<T> {
105 + const ctrl = new AbortController();
106 + const timer = setTimeout(() => ctrl.abort(), 25000);
107 + try {
108 + const res = await fetch(path, { signal: ctrl.signal });
109 + if (!res.ok) throw new Error(`API ${res.status} — ${path}`);
110 + return (await res.json()) as T;
111 + } finally {
112 + clearTimeout(timer);
113 + }
114 +}
115 +
116 +export interface ListingFilters {
117 + city?: string;
118 + sector?: string;
119 + region?: string;
120 + property_type?: string;
121 + source?: string;
122 + price_min?: string;
123 + price_max?: string;
124 + bedrooms_min?: string;
125 + bathrooms_min?: string;
126 + area_min?: string;
127 + q?: string;
128 + sort?: string; // price_asc | price_desc | recent
129 +}
130 +
131 +export function listingParams(f: ListingFilters): URLSearchParams {
132 + const params = new URLSearchParams();
133 + for (const [k, v] of Object.entries(f)) if (v) params.set(k, v);
134 + return params;
135 +}
136 +
137 +export function fetchListings(f: ListingFilters, limit = 60, offset = 0) {
138 + const params = listingParams(f);
139 + params.set("limit", String(limit));
140 + params.set("offset", String(offset));
141 + return get<{ total: number; count: number; listings: Listing[] }>(
142 + `/api/listings?${params}`);
143 +}
144 +
145 +export const fetchListing = (uid: string) =>
146 + get<Listing>(`/api/listings/${encodeURIComponent(uid)}`);
147 +export const fetchFacets = (city?: string) =>
148 + get<Facets>(`/api/facets${city ? `?city=${encodeURIComponent(city)}` : ""}`);
149 +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources");
150 +export const fetchStats = () => get<Stats>("/api/stats");
151 +
152 +// --- Formatage --------------------------------------------------------------
153 +export const fmtPrice = (p: number | null, label?: string) =>
154 + p != null
155 + ? p.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"
156 + : label || "Prix sur demande";
157 +
158 +export const fmtArea = (a: number | null): string | null =>
159 + a != null ? `${Math.round(a).toLocaleString("fr-CA")} pi²` : null;
160 +
161 +export const fmtDate = (ts: number): string =>
162 + new Date(ts * 1000).toLocaleDateString("fr-CA", {
163 + day: "numeric", month: "long", year: "numeric",
164 + });
added frontend/src/components/ListingCard.tsx +52 −0
@@ -0,0 +1,52 @@
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/ListingCard.tsx : carte de propriété (grille de résultats)
5 +// -----------------------------------------------------------------------------
6 +import { Link } from "react-router-dom";
7 +import { Listing, fmtArea, fmtPrice, sourceName } from "../api";
8 +
9 +export default function ListingCard({ l }: { l: Listing }) {
10 + const img = l.images && l.images.length > 0 ? l.images[0] : null;
11 + const meta: string[] = [];
12 + if (l.bedrooms != null) meta.push(`${l.bedrooms} ch.`);
13 + if (l.bathrooms != null) meta.push(`${l.bathrooms} sdb`);
14 + const area = fmtArea(l.area_sqft);
15 + if (area) meta.push(area);
16 +
17 + return (
18 + <Link to={`/propriete/${encodeURIComponent(l.uid)}`} className="card">
19 + <div className="card-img">
20 + {img ? (
21 + <img src={img} alt={l.title || l.address} loading="lazy" />
22 + ) : (
23 + <div className="noimg">🏠</div>
24 + )}
25 + {l.property_type && <span className="badge type">{l.property_type}</span>}
26 + {l.images.length > 1 && (
27 + <span className="badge right">📷 {l.images.length}</span>
28 + )}
29 + </div>
30 + <div className="card-body">
31 + <div className="card-price">{fmtPrice(l.price, l.price_label)}</div>
32 + <div className="card-title">{l.address || l.title}</div>
33 + <div className="card-meta">
34 + {l.sector && <span>{l.sector}</span>}
35 + {l.sector && l.city && <span className="sep" />}
36 + {l.city && <span>{l.city}</span>}
37 + </div>
38 + {meta.length > 0 && (
39 + <div className="card-specs">
40 + {meta.map((m) => (
41 + <span key={m}>{m}</span>
42 + ))}
43 + </div>
44 + )}
45 + <div className="card-foot">
46 + <span className="source-tag">{sourceName(l.source)}</span>
47 + {l.mls && <span className="avail">MLS {l.mls}</span>}
48 + </div>
49 + </div>
50 + </Link>
51 + );
52 +}
added frontend/src/components/MapView.tsx +182 −0
@@ -0,0 +1,182 @@
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/MapView.tsx : carte interactive MapLibre GL
5 +// · chargée paresseusement (React.lazy) — le bundle carte ne pèse rien tant
6 +// que l'utilisateur reste en vue liste
7 +// · données GeoJSON (/api/listings.geojson) synchronisées avec les filtres
8 +// · clustering natif, marqueurs-prix, fiche popup, tuiles OpenFreeMap
9 +// -----------------------------------------------------------------------------
10 +import { useEffect, useRef } from "react";
11 +import { useNavigate } from "react-router-dom";
12 +import maplibregl from "maplibre-gl";
13 +import "maplibre-gl/dist/maplibre-gl.css";
14 +import { ListingFilters, listingParams, sourceName } from "../api";
15 +
16 +const STYLE_URL = "https://tiles.openfreemap.org/styles/positron";
17 +const QUEBEC_CENTER: [number, number] = [-72.5, 46.4];
18 +
19 +function geojsonUrl(f: ListingFilters): string {
20 + return `/api/listings.geojson?${listingParams(f)}`;
21 +}
22 +
23 +function popupHTML(p: Record<string, unknown>): string {
24 + const prix = p.price != null
25 + ? `${Number(p.price).toLocaleString("fr-CA", { maximumFractionDigits: 0 })} $`
26 + : (p.price_label as string) || "Prix sur demande";
27 + const img = p.image
28 + ? `<img src="${p.image}" alt="" loading="lazy"/>`
29 + : `<div class="mv-noimg">🏠</div>`;
30 + const meta = [
31 + p.property_type,
32 + p.bedrooms != null ? `${p.bedrooms} ch.` : "",
33 + p.bathrooms != null ? `${p.bathrooms} sdb` : "",
34 + ].filter(Boolean).join(" · ");
35 + return `
36 + <div class="mv-pop">
37 + ${img}
38 + <div class="mv-pop-body">
39 + <div class="mv-pop-price">${prix}</div>
40 + <div class="mv-pop-title">${p.address ?? p.title ?? ""}</div>
41 + <div class="mv-pop-meta">${meta}</div>
42 + <div class="mv-pop-src">${sourceName(String(p.source ?? ""))}</div>
43 + <a class="mv-pop-cta" href="/propriete/${encodeURIComponent(String(p.uid))}">Voir la fiche →</a>
44 + </div>
45 + </div>`;
46 +}
47 +
48 +export default function MapView({ filters }: { filters: ListingFilters }) {
49 + const div = useRef<HTMLDivElement>(null);
50 + const mapRef = useRef<maplibregl.Map | null>(null);
51 + const loadedRef = useRef(false);
52 + const navigate = useNavigate();
53 +
54 + useEffect(() => {
55 + if (!div.current || mapRef.current) return;
56 + const map = new maplibregl.Map({
57 + container: div.current,
58 + style: STYLE_URL,
59 + center: QUEBEC_CENTER,
60 + zoom: 6,
61 + attributionControl: { compact: true },
62 + });
63 + mapRef.current = map;
64 + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
65 + map.addControl(new maplibregl.GeolocateControl({ trackUserLocation: false }), "top-right");
66 + map.touchZoomRotate.disableRotation();
67 +
68 + map.on("load", () => {
69 + map.addSource("props", {
70 + type: "geojson",
71 + data: { type: "FeatureCollection", features: [] },
72 + cluster: true,
73 + clusterMaxZoom: 15,
74 + clusterRadius: 46,
75 + });
76 + map.addLayer({
77 + id: "clusters", type: "circle", source: "props",
78 + filter: ["has", "point_count"],
79 + paint: {
80 + "circle-color": "#141814",
81 + "circle-radius": ["step", ["get", "point_count"], 15, 10, 19, 50, 24, 200, 30],
82 + "circle-stroke-width": 3,
83 + "circle-stroke-color": "#d9f26b",
84 + },
85 + });
86 + map.addLayer({
87 + id: "cluster-count", type: "symbol", source: "props",
88 + filter: ["has", "point_count"],
89 + layout: {
90 + "text-field": ["get", "point_count_abbreviated"],
91 + "text-font": ["Noto Sans Bold"],
92 + "text-size": 13,
93 + },
94 + paint: { "text-color": "#d9f26b" },
95 + });
96 + map.addLayer({
97 + id: "point", type: "circle", source: "props",
98 + filter: ["!", ["has", "point_count"]],
99 + paint: {
100 + "circle-color": "#1c5c41",
101 + "circle-radius": 5,
102 + "circle-stroke-width": 1.5,
103 + "circle-stroke-color": "#f5f3ee",
104 + },
105 + });
106 + map.addLayer({
107 + id: "point-price", type: "symbol", source: "props",
108 + filter: ["!", ["has", "point_count"]],
109 + layout: {
110 + "text-field": [
111 + "case",
112 + ["to-boolean", ["get", "price"]],
113 + ["concat", ["number-format", ["get", "price"], { locale: "fr-CA", "max-fraction-digits": 0 }], " $"],
114 + "•",
115 + ],
116 + "text-font": ["Noto Sans Bold"],
117 + "text-size": 12,
118 + "text-offset": [0, -1.1],
119 + "text-allow-overlap": false,
120 + "text-optional": true,
121 + } as maplibregl.SymbolLayerSpecification["layout"],
122 + paint: {
123 + "text-color": "#141814",
124 + "text-halo-color": "#d9f26b",
125 + "text-halo-width": 2,
126 + },
127 + });
128 +
129 + map.on("click", "clusters", async (e: maplibregl.MapLayerMouseEvent) => {
130 + const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0];
131 + const src = map.getSource("props") as maplibregl.GeoJSONSource;
132 + const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id);
133 + map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom });
134 + });
135 + const openPopup = (e: maplibregl.MapLayerMouseEvent) => {
136 + const f = e.features?.[0];
137 + if (!f) return;
138 + const coords = (f.geometry as GeoJSON.Point).coordinates.slice() as [number, number];
139 + const pop = new maplibregl.Popup({ offset: 14, maxWidth: "290px", closeButton: true })
140 + .setLngLat(coords).setHTML(popupHTML(f.properties as Record<string, unknown>)).addTo(map);
141 + pop.getElement().querySelector(".mv-pop-cta")?.addEventListener("click", (ev: Event) => {
142 + ev.preventDefault();
143 + navigate(`/propriete/${encodeURIComponent(String((f.properties as any).uid))}`);
144 + });
145 + };
146 + map.on("click", "point", openPopup);
147 + map.on("click", "point-price", openPopup);
148 + for (const layer of ["clusters", "point", "point-price"]) {
149 + map.on("mouseenter", layer, () => { map.getCanvas().style.cursor = "pointer"; });
150 + map.on("mouseleave", layer, () => { map.getCanvas().style.cursor = ""; });
151 + }
152 + loadedRef.current = true;
153 + });
154 +
155 + return () => { map.remove(); mapRef.current = null; loadedRef.current = false; };
156 + }, [navigate]);
157 +
158 + useEffect(() => {
159 + const map = mapRef.current;
160 + if (!map) return;
161 + let cancelled = false;
162 + const apply = async () => {
163 + try {
164 + const res = await fetch(geojsonUrl(filters));
165 + const data = (await res.json()) as GeoJSON.FeatureCollection;
166 + if (cancelled || !mapRef.current) return;
167 + (map.getSource("props") as maplibregl.GeoJSONSource | undefined)?.setData(data);
168 + if (data.features.length > 0) {
169 + const b = new maplibregl.LngLatBounds();
170 + for (const f of data.features)
171 + b.extend((f.geometry as GeoJSON.Point).coordinates as [number, number]);
172 + map.fitBounds(b, { padding: 56, maxZoom: 14, duration: 500 });
173 + }
174 + } catch { /* réseau : la carte garde ses données précédentes */ }
175 + };
176 + if (loadedRef.current) apply();
177 + else map.once("load", () => apply());
178 + return () => { cancelled = true; };
179 + }, [filters]);
180 +
181 + return <div ref={div} className="mapview" role="application" aria-label="Carte des propriétés" />;
182 +}
added frontend/src/main.tsx +18 −0
@@ -0,0 +1,18 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// main.tsx : point d'entrée React
5 +// -----------------------------------------------------------------------------
6 +import React from "react";
7 +import ReactDOM from "react-dom/client";
8 +import { BrowserRouter } from "react-router-dom";
9 +import App from "./App";
10 +import "./styles.css";
11 +
12 +ReactDOM.createRoot(document.getElementById("root")!).render(
13 + <React.StrictMode>
14 + <BrowserRouter>
15 + <App />
16 + </BrowserRouter>
17 + </React.StrictMode>
18 +);
added frontend/src/pages/Home.tsx +341 −0
@@ -0,0 +1,341 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Home.tsx : accueil — héro, statistiques, filtres avancés, grille + carte
5 +// -----------------------------------------------------------------------------
6 +import { Suspense, lazy, useEffect, useMemo, 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 +
15 +const MapView = lazy(() => import("../components/MapView"));
16 +
17 +const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000];
18 +const AREA_STEPS = [800, 1000, 1500, 2000, 3000];
19 +const PAGE = 60;
20 +
21 +const fmtK = (n: number) =>
22 + n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`;
23 +
24 +export default function Home() {
25 + const [listings, setListings] = useState<Listing[] | null>(null);
26 + const [total, setTotal] = useState(0);
27 + const [offset, setOffset] = useState(0);
28 + const [loadingMore, setLoadingMore] = useState(false);
29 + const [facets, setFacets] = useState<Facets | null>(null);
30 + const [sectors, setSectors] = useState<string[]>([]);
31 + const [stats, setStats] = useState<Stats | null>(null);
32 + const [error, setError] = useState<string | null>(null);
33 +
34 + const [params] = useSearchParams();
35 + const [q, setQ] = useState(params.get("q") ?? "");
36 + const [city, setCity] = useState(params.get("city") ?? "");
37 + const [sector, setSector] = useState(params.get("sector") ?? "");
38 + const [ptype, setPtype] = useState(params.get("property_type") ?? "");
39 + const [source, setSource] = useState(params.get("source") ?? "");
40 + const [priceMin, setPriceMin] = useState(params.get("price_min") ?? "");
41 + const [priceMax, setPriceMax] = useState(params.get("price_max") ?? "");
42 + const [bedsMin, setBedsMin] = useState(params.get("bedrooms_min") ?? "");
43 + const [bathsMin, setBathsMin] = useState(params.get("bathrooms_min") ?? "");
44 + const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");
45 + const [sort, setSort] = useState(params.get("sort") ?? "recent");
46 +
47 + const [sheetOpen, setSheetOpen] = useState(false);
48 + const [advOpen, setAdvOpen] = useState(false);
49 + const activeFilters = [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin].filter(Boolean).length;
50 + const advCount = [bedsMin, bathsMin, areaMin, source, sector].filter(Boolean).length;
51 +
52 + const [view, setView] = useState<"liste" | "carte">(params.get("view") === "carte" ? "carte" : "liste");
53 + useEffect(() => { setView(params.get("view") === "carte" ? "carte" : "liste"); }, [params]);
54 +
55 + const filters: ListingFilters = useMemo(() => ({
56 + q, city, sector, region: "", property_type: ptype, source,
57 + price_min: priceMin, price_max: priceMax,
58 + bedrooms_min: bedsMin, bathrooms_min: bathsMin, area_min: areaMin, sort,
59 + }), [q, city, sector, ptype, source, priceMin, priceMax, bedsMin, bathsMin, areaMin, sort]);
60 +
61 + useEffect(() => {
62 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
63 + fetchFacets().then(setFacets).catch(() => {});
64 + fetchStats().then(setStats).catch(() => {});
65 + }, []);
66 +
67 + useEffect(() => {
68 + fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([]));
69 + }, [city]);
70 +
71 + useEffect(() => {
72 + let cancelled = false;
73 + setListings(null); setError(null); setOffset(0);
74 + fetchListings(filters, PAGE, 0)
75 + .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); setOffset(r.listings.length); } })
76 + .catch((e) => !cancelled && setError(String(e)));
77 + return () => { cancelled = true; };
78 + }, [filters]);
79 +
80 + const loadMore = () => {
81 + setLoadingMore(true);
82 + fetchListings(filters, PAGE, offset)
83 + .then((r) => { setListings((cur) => [...(cur ?? []), ...r.listings]); setOffset((o) => o + r.listings.length); })
84 + .catch(() => {})
85 + .finally(() => setLoadingMore(false));
86 + };
87 +
88 + const resetAll = () => {
89 + setQ(""); setCity(""); setSector(""); setPtype(""); setSource("");
90 + setPriceMin(""); setPriceMax(""); setBedsMin(""); setBathsMin(""); setAreaMin("");
91 + };
92 +
93 + const pills: { label: string; clear: () => void }[] = [];
94 + if (q) pills.push({ label: `« ${q} »`, clear: () => setQ("") });
95 + if (city) pills.push({ label: city, clear: () => { setCity(""); setSector(""); } });
96 + if (sector) pills.push({ label: sector, clear: () => setSector("") });
97 + if (ptype) pills.push({ label: ptype, clear: () => setPtype("") });
98 + if (priceMin) pills.push({ label: `≥ ${fmtK(Number(priceMin))}`, clear: () => setPriceMin("") });
99 + if (priceMax) pills.push({ label: `≤ ${fmtK(Number(priceMax))}`, clear: () => setPriceMax("") });
100 + if (bedsMin) pills.push({ label: `${bedsMin}+ ch.`, clear: () => setBedsMin("") });
101 + if (bathsMin) pills.push({ label: `${bathsMin}+ sdb`, clear: () => setBathsMin("") });
102 + if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });
103 + if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
104 +
105 + const topTypes = (facets?.property_types ?? []).slice(0, 7);
106 +
107 + return (
108 + <div className="container">
109 + <section className="hero">
110 + <span className="kicker">Agrégateur — Toutes les agences du Québec</span>
111 + <h1>
112 + Toutes les propriétés <span className="outline">à vendre</span>,<br />
113 + <span className="hl">un seul</span> endroit.
114 + </h1>
115 + <p className="lede">
116 + Immo-Ka rassemble les propriétés à vendre affichées par les agences de courtage
117 + immobilier du Québec — RE/MAX, Sutton, Via Capitale, Proprio Direct, Barnes et plus —
118 + mises à jour automatiquement, avec toutes les photos, les détails complets et un lien
119 + direct vers l'annonce originale.
120 + </p>
121 + <div className="stat-row">
122 + <span className="stat-chip"><span className="pulse" /> Données synchronisées en continu</span>
123 + {stats && (
124 + <>
125 + <span className="stat-chip"><b>{stats.total.toLocaleString("fr-CA")}</b> propriétés</span>
126 + <span className="stat-chip"><b>{stats.sources}</b> agences</span>
127 + <span className="stat-chip"><b>{stats.cities?.toLocaleString("fr-CA")}</b> villes</span>
128 + {stats.avg_price != null && (
129 + <span className="stat-chip">prix moyen <b>{Math.round(stats.avg_price).toLocaleString("fr-CA")} $</b></span>
130 + )}
131 + </>
132 + )}
133 + </div>
134 + </section>
135 +
136 + {sheetOpen && <div className="sheet-backdrop" onClick={() => setSheetOpen(false)} aria-hidden="true" />}
137 + <section className={`filterbar ${sheetOpen ? "open" : ""}`} aria-label="Filtres">
138 + <div className="sheet-head">
139 + <span>Filtres</span>
140 + <button className="sheet-close" onClick={() => setSheetOpen(false)} aria-label="Fermer les filtres">✕</button>
141 + </div>
142 +
143 + <div className="f-primary">
144 + <div className="f-search">
145 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
146 + <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
147 + </svg>
148 + <input id="f-q" placeholder="Adresse, ville, n° MLS…" value={q}
149 + onChange={(e) => setQ(e.target.value)} aria-label="Recherche" />
150 + {q && <button className="f-clear" onClick={() => setQ("")} aria-label="Effacer la recherche">✕</button>}
151 + </div>
152 + <label className="f-ctl">
153 + <span>Ville</span>
154 + <select value={city} onChange={(e) => { setCity(e.target.value); setSector(""); }}>
155 + <option value="">Toutes</option>
156 + {(facets?.cities ?? []).map((c) => <option key={c} value={c}>{c}</option>)}
157 + </select>
158 + </label>
159 + <label className="f-ctl">
160 + <span>Type</span>
161 + <select value={ptype} onChange={(e) => setPtype(e.target.value)}>
162 + <option value="">Tous</option>
163 + {(facets?.property_types ?? []).map((t) => <option key={t} value={t}>{t}</option>)}
164 + </select>
165 + </label>
166 + <div className="f-ctl">
167 + <span>Prix</span>
168 + <div className="range-pair">
169 + <select aria-label="Prix minimum" value={priceMin} onChange={(e) => setPriceMin(e.target.value)}>
170 + <option value="">Min</option>
171 + {PRICE_STEPS.map((p) => (
172 + <option key={p} value={p} disabled={!!priceMax && p >= Number(priceMax)}>{fmtK(p)}</option>
173 + ))}
174 + </select>
175 + <span className="range-sep">—</span>
176 + <select aria-label="Prix maximum" value={priceMax} onChange={(e) => setPriceMax(e.target.value)}>
177 + <option value="">Max</option>
178 + {PRICE_STEPS.map((p) => (
179 + <option key={p} value={p} disabled={!!priceMin && p <= Number(priceMin)}>{fmtK(p)}</option>
180 + ))}
181 + </select>
182 + </div>
183 + </div>
184 + <button className={`f-more ${advOpen || advCount > 0 ? "on" : ""}`} onClick={() => setAdvOpen(!advOpen)} aria-expanded={advOpen}>
185 + Plus de filtres{advCount > 0 ? ` · ${advCount}` : ""} {advOpen ? "▴" : "▾"}
186 + </button>
187 + </div>
188 +
189 + {(advOpen || sheetOpen) && (
190 + <div className="f-adv">
191 + <div className="f-group">
192 + <label>Quartier / secteur</label>
193 + <select className="f-native" value={sector} onChange={(e) => setSector(e.target.value)}>
194 + <option value="">Tous</option>
195 + {sectors.map((s) => <option key={s} value={s}>{s}</option>)}
196 + </select>
197 + </div>
198 + <div className="f-group">
199 + <label>Chambres (min.)</label>
200 + <div className="seg" role="group">
201 + <button className={bedsMin === "" ? "on" : ""} onClick={() => setBedsMin("")}>Toutes</button>
202 + {["1", "2", "3", "4", "5"].map((n) => (
203 + <button key={n} className={bedsMin === n ? "on" : ""} onClick={() => setBedsMin(n)}>{n}+</button>
204 + ))}
205 + </div>
206 + </div>
207 + <div className="f-group">
208 + <label>Salles de bain (min.)</label>
209 + <div className="seg" role="group">
210 + <button className={bathsMin === "" ? "on" : ""} onClick={() => setBathsMin("")}>Toutes</button>
211 + {["1", "2", "3"].map((n) => (
212 + <button key={n} className={bathsMin === n ? "on" : ""} onClick={() => setBathsMin(n)}>{n}+</button>
213 + ))}
214 + </div>
215 + </div>
216 + <div className="f-group">
217 + <label>Superficie minimale</label>
218 + <div className="seg" role="group">
219 + <button className={areaMin === "" ? "on" : ""} onClick={() => setAreaMin("")}>Peu importe</button>
220 + {AREA_STEPS.map((a) => (
221 + <button key={a} className={areaMin === String(a) ? "on" : ""} onClick={() => setAreaMin(String(a))}>{a}+</button>
222 + ))}
223 + </div>
224 + </div>
225 + <div className="f-group">
226 + <label>Agence</label>
227 + <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
228 + <option value="">Toutes</option>
229 + {(facets?.sources ?? []).map((s) => (
230 + <option key={s.source} value={s.source}>{sourceName(s.source)} ({s.n})</option>
231 + ))}
232 + </select>
233 + </div>
234 + <div className="f-group f-group-end">
235 + <button className="btn btn-ghost" onClick={resetAll} disabled={activeFilters === 0}>
236 + Tout réinitialiser{activeFilters > 0 ? ` (${activeFilters})` : ""}
237 + </button>
238 + </div>
239 + </div>
240 + )}
241 +
242 + <button className="btn btn-primary sheet-apply" onClick={() => setSheetOpen(false)}>
243 + Voir les résultats {listings ? `(${total})` : ""}
244 + </button>
245 + </section>
246 +
247 + <div className="chips" role="group" aria-label="Filtres rapides">
248 + {topTypes.map((t) => (
249 + <button key={t} className={`chip ${ptype === t ? "on" : ""}`} onClick={() => setPtype(ptype === t ? "" : t)}>
250 + {t}
251 + </button>
252 + ))}
253 + </div>
254 +
255 + {pills.length > 0 && (
256 + <div className="pills" aria-label="Filtres actifs">
257 + {pills.map((p) => (
258 + <button key={p.label} className="pill" onClick={p.clear} aria-label={`Retirer le filtre ${p.label}`}>
259 + {p.label} <span className="pill-x">✕</span>
260 + </button>
261 + ))}
262 + <button className="pill pill-clear" onClick={resetAll}>Tout effacer</button>
263 + </div>
264 + )}
265 +
266 + <div className="results-head">
267 + <h2>Propriétés à vendre</h2>
268 + <div className="results-tools">
269 + {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>}
270 + <label className="sort-ctl">
271 + <select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Trier">
272 + <option value="recent">Plus récents</option>
273 + <option value="price_asc">Prix ↑</option>
274 + <option value="price_desc">Prix ↓</option>
275 + </select>
276 + </label>
277 + <div className="view-toggle" role="tablist" aria-label="Mode d'affichage">
278 + <button role="tab" aria-selected={view === "liste"} className={view === "liste" ? "on" : ""} onClick={() => setView("liste")}>☰ Liste</button>
279 + <button role="tab" aria-selected={view === "carte"} className={view === "carte" ? "on" : ""} onClick={() => setView("carte")}>◈ Carte</button>
280 + </div>
281 + </div>
282 + </div>
283 +
284 + {error && (
285 + <div className="notice">
286 + <div className="big">⚠️</div>
287 + <h2>Impossible de charger les annonces</h2>
288 + <p>{error}</p>
289 + <button className="btn btn-primary" onClick={() => window.location.reload()}>Réessayer</button>
290 + </div>
291 + )}
292 +
293 + {!error && view === "liste" && listings === null && (
294 + <div className="grid" aria-busy="true">
295 + {Array.from({ length: 8 }).map((_, i) => (
296 + <div className="skel" key={i}><div className="sk-img" /><div className="sk-line" /><div className="sk-line short" /></div>
297 + ))}
298 + </div>
299 + )}
300 +
301 + {!error && view === "liste" && listings !== null && listings.length === 0 && (
302 + <div className="notice">
303 + <div className="big">🔍</div>
304 + <h2>Aucune propriété ne correspond</h2>
305 + <p>Essayez d'élargir vos critères
306 + {activeFilters > 0 && <> — ou <button className="link-btn" onClick={resetAll}>retirez les {activeFilters} filtres actifs</button></>}.</p>
307 + </div>
308 + )}
309 +
310 + {!error && view === "carte" && (
311 + <div className="map-split">
312 + <div className="map-list" aria-label="Résultats en liste">
313 + {(listings ?? []).map((l) => <ListingCard key={l.uid} l={l} />)}
314 + </div>
315 + <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
316 + <MapView filters={filters} />
317 + </Suspense>
318 + </div>
319 + )}
320 +
321 + {!error && view === "liste" && listings !== null && listings.length > 0 && (
322 + <>
323 + <div className="grid">
324 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
325 + </div>
326 + {offset < total && (
327 + <div className="more-wrap">
328 + <button className="btn btn-primary" onClick={loadMore} disabled={loadingMore}>
329 + {loadingMore ? "Chargement…" : `Voir plus (${(total - offset).toLocaleString("fr-CA")} restantes)`}
330 + </button>
331 + </div>
332 + )}
333 + </>
334 + )}
335 +
336 + <button className="fab" onClick={() => setSheetOpen(true)} aria-label="Ouvrir les filtres">
337 + ⚙ Filtres{activeFilters > 0 ? ` · ${activeFilters}` : ""}
338 + </button>
339 + </div>
340 + );
341 +}
added frontend/src/pages/Listing.tsx +257 −0
@@ -0,0 +1,257 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Listing.tsx : fiche complète d'une propriété
5 +// galerie + lightbox · specs · caractéristiques (details) · pièces ·
6 +// inclusions · description · historique de prix · courtier · mini-carte
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 MiniMap = lazy(() => import("../components/MapView"));
16 +
17 +// --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran --------
18 +function Galerie({ images, titre }: { images: string[]; titre: string }) {
19 + const [idx, setIdx] = useState(0);
20 + const [zoom, setZoom] = useState(false);
21 + const track = useRef<HTMLDivElement>(null);
22 +
23 + const onScroll = () => {
24 + const el = track.current;
25 + if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));
26 + };
27 + const goto = (i: number) =>
28 + track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });
29 +
30 + useEffect(() => {
31 + if (!zoom) return;
32 + const onKey = (e: KeyboardEvent) => {
33 + if (e.key === "Escape") setZoom(false);
34 + if (e.key === "ArrowLeft") setIdx((i) => Math.max(0, i - 1));
35 + if (e.key === "ArrowRight") setIdx((i) => Math.min(images.length - 1, i + 1));
36 + };
37 + window.addEventListener("keydown", onKey);
38 + return () => window.removeEventListener("keydown", onKey);
39 + }, [zoom, images.length]);
40 +
41 + if (images.length === 0)
42 + return <div className="carousel"><div className="noimg carousel-empty">🏠</div></div>;
43 +
44 + return (
45 + <>
46 + <div className="carousel">
47 + <div className="carousel-track" ref={track} onScroll={onScroll}>
48 + {images.map((u, i) => (
49 + <img key={u} src={u} loading={i === 0 ? "eager" : "lazy"}
50 + alt={`${titre} — photo ${i + 1} de ${images.length}`} onClick={() => setZoom(true)} />
51 + ))}
52 + </div>
53 + <span className="carousel-count" aria-live="polite">{idx + 1}/{images.length}</span>
54 + {idx > 0 && <button className="carousel-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}>‹</button>}
55 + {idx < images.length - 1 && <button className="carousel-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}>›</button>}
56 + </div>
57 + {images.length > 1 && (
58 + <div className="thumbs">
59 + {images.map((u, i) => (
60 + <button key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)} aria-label={`Photo ${i + 1}`}>
61 + <img src={u} alt="" loading="lazy" />
62 + </button>
63 + ))}
64 + </div>
65 + )}
66 + {zoom && (
67 + <div className="lightbox" onClick={() => setZoom(false)} role="dialog" aria-label="Photo agrandie">
68 + <button className="lb-close" aria-label="Fermer" onClick={() => setZoom(false)}>✕</button>
69 + {idx > 0 && <button className="lb-nav prev" aria-label="Précédente" onClick={(e) => { e.stopPropagation(); setIdx(idx - 1); }}>‹</button>}
70 + <img src={images[idx]} alt="" onClick={(e) => e.stopPropagation()} />
71 + {idx < images.length - 1 && <button className="lb-nav next" aria-label="Suivante" onClick={(e) => { e.stopPropagation(); setIdx(idx + 1); }}>›</button>}
72 + <span className="lb-count">{idx + 1} / {images.length}</span>
73 + </div>
74 + )}
75 + </>
76 + );
77 +}
78 +
79 +const DETAIL_HIDDEN = new Set(["pieces", "price_from"]);
80 +
81 +export default function ListingPage() {
82 + const { uid } = useParams<{ uid: string }>();
83 + const [l, setL] = useState<Listing | null>(null);
84 + const [error, setError] = useState<string | null>(null);
85 +
86 + useEffect(() => {
87 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
88 + if (!uid) return;
89 + setL(null); setError(null);
90 + fetchListing(uid).then(setL).catch((e) => setError(String(e)));
91 + window.scrollTo(0, 0);
92 + }, [uid]);
93 +
94 + if (error)
95 + return (
96 + <div className="notice container">
97 + <div className="big">⚠️</div>
98 + <h2>Propriété introuvable</h2>
99 + <p>{error}</p>
100 + <Link className="btn btn-primary" to="/">Retour aux propriétés</Link>
101 + </div>
102 + );
103 +
104 + if (!l)
105 + return (
106 + <div className="container detail">
107 + <div className="fiche" aria-busy="true">
108 + <div className="skel"><div className="sk-img" /></div>
109 + <div className="skel"><div className="sk-line" /><div className="sk-line" /><div className="sk-line short" /></div>
110 + </div>
111 + </div>
112 + );
113 +
114 + const specs: { k: string; v: string }[] = [];
115 + if (l.property_type) specs.push({ k: "Type", v: l.property_type });
116 + if (l.bedrooms != null) specs.push({ k: "Chambres", v: String(l.bedrooms) });
117 + if (l.bathrooms != null) specs.push({ k: "Salles de bain", v: String(l.bathrooms) });
118 + if (l.powder_rooms != null) specs.push({ k: "Salles d'eau", v: String(l.powder_rooms) });
119 + if (l.area_sqft != null) specs.push({ k: "Superficie", v: fmtArea(l.area_sqft)! });
120 + if (l.lot_sqft != null) specs.push({ k: "Terrain", v: fmtArea(l.lot_sqft)! });
121 + if (l.year_built != null) specs.push({ k: "Année", v: String(l.year_built) });
122 + if (l.mls) specs.push({ k: "MLS / Centris", v: l.mls });
123 +
124 + const rooms: Room[] = Array.isArray(l.details?.pieces) ? (l.details!.pieces as Room[]) : [];
125 + const detEntries = Object.entries(l.details ?? {})
126 + .filter(([k, v]) => !DETAIL_HIDDEN.has(k) && (typeof v === "string" || typeof v === "number") && String(v).trim());
127 +
128 + const hist = (l.price_history ?? []).filter((h) => h.price != null);
129 + const baisse = hist.length >= 2 && hist[0].price !== hist[1].price
130 + ? { de: hist[1].price!, a: hist[0].price! } : null;
131 + const updated = l.updated_at ? fmtDate(l.updated_at) : null;
132 +
133 + return (
134 + <div className="container detail">
135 + <nav className="crumbs" aria-label="Fil d'Ariane">
136 + <Link to="/">Propriétés</Link> ›
137 + {l.city && <span>{l.city}</span>} ›
138 + <span>{l.address || l.title}</span>
139 + </nav>
140 +
141 + <div className="fiche">
142 + {/* -------- colonne gauche : galerie, description, caractéristiques ---- */}
143 + <div className="f-col">
144 + <section className="f-bloc f-galerie" aria-label="Photos">
145 + <Galerie images={l.images ?? []} titre={l.address || l.title} />
146 + </section>
147 +
148 + {l.description && (
149 + <section className="f-bloc f-desc" id="description">
150 + <h2>Description</h2>
151 + <p className="desc-text">{l.description}</p>
152 + </section>
153 + )}
154 +
155 + {detEntries.length > 0 && (
156 + <section className="f-bloc" id="caracteristiques">
157 + <h2>Caractéristiques</h2>
158 + <div className="dtable">
159 + {detEntries.map(([k, v]) => (
160 + <div className="drow" key={k}><span>{k}</span><b>{String(v)}</b></div>
161 + ))}
162 + </div>
163 + </section>
164 + )}
165 +
166 + {rooms.length > 0 && (
167 + <section className="f-bloc" id="pieces">
168 + <h2>Pièces</h2>
169 + <div className="rooms-wrap">
170 + <table className="rooms">
171 + <thead><tr><th>Pièce</th><th>Niveau</th><th>Dimensions</th><th>Revêtement</th></tr></thead>
172 + <tbody>
173 + {rooms.map((r, i) => (
174 + <tr key={i}>
175 + <td>{r.nom || "—"}</td><td>{r.niveau || "—"}</td>
176 + <td>{r.dimensions || "—"}</td><td>{r.revetement || "—"}</td>
177 + </tr>
178 + ))}
179 + </tbody>
180 + </table>
181 + </div>
182 + </section>
183 + )}
184 +
185 + {l.features && l.features.length > 0 && (
186 + <section className="f-bloc" id="inclusions">
187 + <h2>Inclusions</h2>
188 + <div className="amenity-row">
189 + {l.features.map((f, i) => <span className="amenity" key={i}>✓ {f}</span>)}
190 + </div>
191 + </section>
192 + )}
193 +
194 + {l.lat != null && l.lng != null && (
195 + <section className="f-bloc" id="carte">
196 + <h2>Emplacement</h2>
197 + <div className="mini-map">
198 + <Suspense fallback={<div className="mapview map-loading">Chargement de la carte…</div>}>
199 + <MiniMap filters={{ q: l.mls || l.address }} />
200 + </Suspense>
201 + </div>
202 + </section>
203 + )}
204 + </div>
205 +
206 + {/* -------- colonne droite : synthèse, specs, courtier ---------------- */}
207 + <div className="f-col">
208 + <section className="f-bloc f-hero">
209 + <div className="price">{fmtPrice(l.price, l.price_label)}</div>
210 + {l.property_type && <div className="deal-badge deal-ok">{l.property_type}{l.details?.price_from ? " · à partir de" : ""}</div>}
211 + <h1>{l.address || l.title}</h1>
212 + <div className="loc">{[l.sector, l.city, l.region].filter(Boolean).join(" · ")}</div>
213 +
214 + <div className="specs-grid">
215 + {specs.map((s) => (
216 + <div className="spec" key={s.k}><b>{s.v}</b><span>{s.k}</span></div>
217 + ))}
218 + </div>
219 +
220 + {baisse && (
221 + <div className={`prix-histo ${baisse.a < baisse.de ? "down" : ""}`}>
222 + {baisse.a < baisse.de ? "📉" : "📈"} Prix passé de {fmtPrice(baisse.de)} à <b>{fmtPrice(baisse.a)}</b>
223 + </div>
224 + )}
225 +
226 + {(l.broker_name || l.broker_phone) && (
227 + <div className="broker">
228 + <div className="broker-k">Courtier</div>
229 + {l.broker_name && <div className="broker-name">{l.broker_name}</div>}
230 + {l.broker_phone && <a className="broker-tel" href={`tel:${l.broker_phone.replace(/\s/g, "")}`}>📞 {l.broker_phone}</a>}
231 + </div>
232 + )}
233 +
234 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
235 + Voir l'annonce chez {sourceName(l.source)} ↗
236 + </a>
237 + <div className="fine">
238 + Agrégé par Immo-Ka — {sourceName(l.source)}{updated ? ` · synchronisé le ${updated}` : ""}.
239 + </div>
240 + </section>
241 + </div>
242 + </div>
243 +
244 + <div className="fine f-foot">
245 + Les prix et disponibilités sont ceux affichés par la source — chaque fiche renvoie à
246 + l'annonce originale de l'agence.
247 + </div>
248 +
249 + <div className="cta-sticky">
250 + <span className="cta-sticky-prix">{fmtPrice(l.price, l.price_label)}</span>
251 + <a className="cta" href={l.url} target="_blank" rel="noopener noreferrer">
252 + Voir chez {sourceName(l.source)} ↗
253 + </a>
254 + </div>
255 + </div>
256 + );
257 +}
added frontend/src/pages/Sources.tsx +78 −0
@@ -0,0 +1,78 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Sources.tsx : registre des agences de courtage agrégées
5 +// -----------------------------------------------------------------------------
6 +import { useEffect, useState } from "react";
7 +import { Link } from "react-router-dom";
8 +import { Source, fetchSources } from "../api";
9 +
10 +export default function SourcesPage() {
11 + const [sources, setSources] = useState<Source[] | null>(null);
12 + const [error, setError] = useState<string | null>(null);
13 +
14 + useEffect(() => {
15 + fetchSources().then((r) => setSources(r.sources)).catch((e) => setError(String(e)));
16 + }, []);
17 +
18 + const active = (sources ?? []).filter((s) => s.status === "actif" || s.active_listings > 0);
19 + const todo = (sources ?? []).filter((s) => !(s.status === "actif" || s.active_listings > 0));
20 +
21 + return (
22 + <div className="container sources">
23 + <span className="kicker">Registre — agences de courtage</span>
24 + <h1>Agences agrégées</h1>
25 + <p className="sub">
26 + Bannières et agences immobilières du Québec recensées par Immo-Ka. Chaque agence
27 + « active » est synchronisée périodiquement par un connecteur dédié ; les autres sont
28 + en attente de connecteur.
29 + </p>
30 +
31 + {error && <div className="notice">⚠️ {error}</div>}
32 + {!sources && !error && <div className="notice">Chargement…</div>}
33 +
34 + {sources && (
35 + <>
36 + <div className="src-wrap">
37 + <table className="src-table">
38 + <thead>
39 + <tr>
40 + <th>Agence</th>
41 + <th>Couverture</th>
42 + <th>Type</th>
43 + <th>Statut</th>
44 + <th style={{ textAlign: "right" }}>Propriétés</th>
45 + </tr>
46 + </thead>
47 + <tbody>
48 + {[...active, ...todo].map((s) => (
49 + <tr key={s.id}>
50 + <td>
51 + <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a>
52 + </td>
53 + <td style={{ color: "var(--ink-2)", maxWidth: 420 }}>{s.coverage ?? "—"}</td>
54 + <td style={{ color: "var(--ink-3)" }}>{s.type ?? "—"}</td>
55 + <td>
56 + {s.active_listings > 0 || s.status === "actif"
57 + ? <span className="pill ok">actif</span>
58 + : <span className="pill todo">à venir</span>}
59 + </td>
60 + <td style={{ textAlign: "right" }}>
61 + {s.active_listings
62 + ? <Link className="count-pill" to={`/?source=${encodeURIComponent(s.id)}`}>{s.active_listings.toLocaleString("fr-CA")}</Link>
63 + : "—"}
64 + </td>
65 + </tr>
66 + ))}
67 + </tbody>
68 + </table>
69 + </div>
70 + <p className="stats-foot">
71 + {active.length} agence{active.length > 1 ? "s" : ""} active{active.length > 1 ? "s" : ""} sur {sources.length} recensées.
72 + Les sous-agences RE/MAX servent de source de secours (dédoublonnées par n° Centris).
73 + </p>
74 + </>
75 + )}
76 + </div>
77 + );
78 +}
added frontend/src/pages/Stats.tsx +135 −0
@@ -0,0 +1,135 @@
1 +// -----------------------------------------------------------------------------
2 +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/Stats.tsx : statistiques du marché agrégé (totaux + répartitions)
5 +// Les répartitions sont calculées à partir de /api/facets et /api/listings
6 +// (échantillon trié par prix) — le backend n'expose pas d'agrégats détaillés.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useMemo, useState } from "react";
9 +import { Link } from "react-router-dom";
10 +import {
11 + Facets, Listing, Stats,
12 + fetchFacets, fetchListings, fetchSources, fetchStats,
13 + registerSourceNames, sourceName,
14 +} from "../api";
15 +
16 +function Bars({ rows, unit }: { rows: { key: string; n: number; href?: string }[]; unit?: string }) {
17 + const max = Math.max(1, ...rows.map((r) => r.n));
18 + return (
19 + <div className="hbars">
20 + {rows.map((r) => (
21 + <div className="hbar-row" key={r.key}>
22 + <div className="hbar-label">{r.href ? <Link to={r.href}>{r.key}</Link> : r.key}</div>
23 + <div className="hbar-track"><span className="hbar-fill" style={{ width: `${(r.n / max) * 100}%` }} /></div>
24 + <div className="hbar-value">{r.n.toLocaleString("fr-CA")}{unit ? <em> {unit}</em> : ""}</div>
25 + </div>
26 + ))}
27 + </div>
28 + );
29 +}
30 +
31 +export default function StatsPage() {
32 + const [stats, setStats] = useState<Stats | null>(null);
33 + const [facets, setFacets] = useState<Facets | null>(null);
34 + const [sample, setSample] = useState<Listing[] | null>(null);
35 +
36 + useEffect(() => {
37 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
38 + fetchStats().then(setStats).catch(() => {});
39 + fetchFacets().then(setFacets).catch(() => {});
40 + fetchListings({ sort: "price_asc" }, 2000, 0).then((r) => setSample(r.listings)).catch(() => {});
41 + }, []);
42 +
43 + const byType = useMemo(() => {
44 + if (!sample) return [];
45 + const m = new Map<string, number>();
46 + for (const l of sample) if (l.property_type) m.set(l.property_type, (m.get(l.property_type) ?? 0) + 1);
47 + return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?property_type=${encodeURIComponent(key)}` }))
48 + .sort((a, b) => b.n - a.n).slice(0, 12);
49 + }, [sample]);
50 +
51 + const byCity = useMemo(() => {
52 + if (!sample) return [];
53 + const m = new Map<string, number>();
54 + for (const l of sample) if (l.city) m.set(l.city, (m.get(l.city) ?? 0) + 1);
55 + return [...m.entries()].map(([key, n]) => ({ key, n, href: `/?city=${encodeURIComponent(key)}` }))
56 + .sort((a, b) => b.n - a.n).slice(0, 15);
57 + }, [sample]);
58 +
59 + const bySource = useMemo(() => {
60 + if (!facets) return [];
61 + return facets.sources.map((s) => ({ key: sourceName(s.source), n: s.n, href: `/?source=${encodeURIComponent(s.source)}` }))
62 + .sort((a, b) => b.n - a.n).slice(0, 15);
63 + }, [facets]);
64 +
65 + const tiles = [
66 + { v: stats ? stats.total.toLocaleString("fr-CA") : "…", k: "Propriétés à vendre", hero: true },
67 + { v: stats ? String(stats.sources) : "…", k: "Agences agrégées" },
68 + { v: stats ? stats.cities.toLocaleString("fr-CA") : "…", k: "Villes couvertes" },
69 + { v: stats?.avg_price != null ? `${Math.round(stats.avg_price).toLocaleString("fr-CA")} $` : "…", k: "Prix moyen demandé" },
70 + { v: stats?.min_price != null ? `${Math.round(stats.min_price).toLocaleString("fr-CA")} $` : "…", k: "Prix minimum" },
71 + { v: stats?.max_price != null ? `${Math.round(stats.max_price).toLocaleString("fr-CA")} $` : "…", k: "Prix maximum" },
72 + ];
73 +
74 + return (
75 + <div className="container stats-page">
76 + <span className="kicker">Le marché agrégé</span>
77 + <h1 className="stats-title">Statistiques</h1>
78 + <p className="sub">
79 + Portrait en direct des propriétés à vendre agrégées par Immo-Ka à travers toutes les
80 + agences connectées. Répartitions calculées sur un échantillon des annonces actives.
81 + </p>
82 +
83 + <div className="tiles">
84 + {tiles.map((t) => (
85 + <div className={`tile ${t.hero ? "hero-tile" : ""}`} key={t.k}>
86 + <div className="tile-v">{t.v}</div>
87 + <div className="tile-k">{t.k}</div>
88 + </div>
89 + ))}
90 + </div>
91 +
92 + <div className="viz-grid">
93 + <div className="viz-card">
94 + <h2>Par type de propriété</h2>
95 + <div className="viz-sub">Échantillon des annonces actives</div>
96 + {byType.length ? <Bars rows={byType} /> : <p className="fine">Chargement…</p>}
97 + </div>
98 + <div className="viz-card">
99 + <h2>Par agence</h2>
100 + <div className="viz-sub">Annonces actives (après déduplication)</div>
101 + {bySource.length ? <Bars rows={bySource} /> : <p className="fine">Chargement…</p>}
102 + </div>
103 + </div>
104 +
105 + <div className="viz-card">
106 + <h2>Par ville</h2>
107 + <div className="viz-sub">Top 15 · échantillon des annonces actives</div>
108 + {byCity.length ? <Bars rows={byCity} /> : <p className="fine">Chargement…</p>}
109 + </div>
110 +
111 + {stats?.recent_syncs && stats.recent_syncs.length > 0 && (
112 + <div className="viz-card">
113 + <h2>Synchronisations récentes</h2>
114 + <div className="viz-sub">Journal du moteur d'agrégation</div>
115 + <div className="viz-table">
116 + <table>
117 + <thead><tr><th>Agence</th><th>Trouvées</th><th>Ajoutées</th><th>MàJ</th><th>Retirées</th><th>Quand</th></tr></thead>
118 + <tbody>
119 + {stats.recent_syncs.slice(0, 15).map((s, i) => (
120 + <tr key={i}>
121 + <td>{sourceName(s.source)}</td>
122 + <td>{s.found}</td><td>{s.added}</td><td>{s.updated}</td><td>{s.removed}</td>
123 + <td style={{ color: "var(--ink-3)" }}>{new Date(s.ts * 1000).toLocaleString("fr-CA")}</td>
124 + </tr>
125 + ))}
126 + </tbody>
127 + </table>
128 + </div>
129 + </div>
130 + )}
131 +
132 + <p className="stats-foot">Mise à jour automatique — chaque fiche renvoie à l'annonce originale de l'agence.</p>
133 + </div>
134 + );
135 +}
added frontend/src/styles.css +427 −0
@@ -0,0 +1,427 @@
1 +/* -----------------------------------------------------------------------------
2 + Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 + styles.css : système de design « éditorial sharp » — thème clair
5 + · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
6 + · Bordures encre + ombres décalées (néo-brutalisme raffiné)
7 + · Accent électrique lime sur encre verte profonde · 100 % adaptatif mobile
8 +----------------------------------------------------------------------------- */
9 +:root {
10 + --paper: #f5f3ee;
11 + --surface: #ffffff;
12 + --surface-2: #faf9f5;
13 + --ink: #141814;
14 + --ink-2: #4d5551;
15 + --ink-3: #8b928c;
16 + --line: rgba(20, 24, 20, 0.14);
17 + --line-strong: rgba(20, 24, 20, 0.85);
18 + --green: #1c5c41;
19 + --green-deep: #123f2e;
20 + --lime: #d9f26b;
21 + --lime-soft: #f0f9d2;
22 + --amber: #e8a33d;
23 + --amber-soft: #fdf3e2;
24 + --danger: #b3423a;
25 + --r-card: 10px;
26 + --r-ctl: 6px;
27 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
28 + --shadow-off: 6px 6px 0 var(--ink);
29 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
30 + --font-display: "Space Grotesk", system-ui, sans-serif;
31 + --font-body: "Inter", system-ui, sans-serif;
32 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
33 +}
34 +
35 +* { box-sizing: border-box; }
36 +html { scroll-behavior: smooth; scroll-padding-top: 76px; }
37 +body {
38 + margin: 0; background: var(--paper); color: var(--ink);
39 + font-family: var(--font-body); font-size: 15px; line-height: 1.55;
40 + -webkit-font-smoothing: antialiased; padding-bottom: env(safe-area-inset-bottom);
41 +}
42 +body::before {
43 + content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.35;
44 + 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'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
45 +}
46 +#root { position: relative; z-index: 1; }
47 +
48 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0; }
49 +a { color: inherit; text-decoration: none; }
50 +button { font-family: inherit; }
51 +img { display: block; }
52 +::selection { background: var(--lime); color: var(--ink); }
53 +
54 +.mono { font-family: var(--font-mono); }
55 +.kicker {
56 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
57 + text-transform: uppercase; letter-spacing: 0.14em; color: var(--green);
58 + display: inline-flex; align-items: center; gap: 8px;
59 +}
60 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
61 +
62 +.container { max-width: 1240px; margin: 0 auto; padding: 0 24px; }
63 +@media (max-width: 640px) { .container { padding: 0 16px; } }
64 +
65 +/* ================= Header ================= */
66 +.header {
67 + position: sticky; top: 0; z-index: 50;
68 + background: rgba(245, 243, 238, 0.88);
69 + backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
70 + border-bottom: 2px solid var(--ink);
71 +}
72 +.header-inner { display: flex; align-items: center; gap: 20px; height: 64px; }
73 +.brand { font-family: var(--font-display); font-weight: 700; font-size: 26px; letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; }
74 +.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; }
75 +.brand:hover .ka { transform: rotate(0deg); }
76 +.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; }
77 +@media (max-width: 860px) { .brand-tag { display: none; } }
78 +.nav { margin-left: auto; display: flex; gap: 4px; }
79 +.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; }
80 +.nav a:hover { border-color: var(--ink); color: var(--ink); }
81 +.nav a.active { background: var(--ink); color: var(--lime); }
82 +
83 +/* ================= Ticker ================= */
84 +.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(217, 242, 107, 0.25); }
85 +.ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; }
86 +.ticker span { padding: 0 26px; position: relative; }
87 +.ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; }
88 +@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
89 +@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } * { transition-duration: 0.01ms !important; } }
90 +
91 +/* ================= Hero ================= */
92 +.hero { padding: 58px 0 22px; }
93 +@media (max-width: 640px) { .hero { padding: 34px 0 14px; } }
94 +.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; }
95 +.hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); }
96 +.hero h1 .hl { background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); }
97 +.hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; }
98 +.stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
99 +.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(20, 24, 20, 0.12); }
100 +.stat-chip b { color: var(--ink); font-weight: 700; }
101 +.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; }
102 +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } }
103 +
104 +/* ================= Filter bar ================= */
105 +.filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
106 +.f-primary { display: flex; gap: 10px; align-items: stretch; flex-wrap: wrap; min-width: 0; }
107 +.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 13px; min-height: 52px; color: var(--ink-2); transition: box-shadow 0.15s ease; }
108 +.f-search:focus-within { box-shadow: 3px 3px 0 var(--lime); background: var(--surface); }
109 +.f-search input { border: none; background: none; outline: none; flex: 1; min-width: 0; font-size: 15px; color: var(--ink); font-family: inherit; }
110 +.f-clear { border: none; background: var(--line); color: var(--ink-2); border-radius: 50%; width: 20px; height: 20px; font-size: 10px; cursor: pointer; flex: none; display: grid; place-items: center; }
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; }
112 +.f-ctl:focus-within { box-shadow: 3px 3px 0 var(--lime); }
113 +.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); }
114 +.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; }
115 +.range-pair { display: flex; align-items: center; gap: 4px; }
116 +.range-pair select { max-width: 92px; }
117 +.range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; }
118 +.f-more { flex: none; align-self: stretch; 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: all 0.13s ease; }
119 +.f-more:hover { background: var(--lime-soft); }
120 +.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.22); }
121 +.f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0; animation: adv-in 0.18s ease; }
122 +@keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
123 +.f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
124 +.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); }
125 +.f-group-end { justify-content: flex-end; }
126 +.f-group .btn:disabled { opacity: 0.4; cursor: default; }
127 +.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; }
128 +.f-native:focus { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
129 +.seg { display: inline-flex; flex-wrap: wrap; row-gap: 6px; }
130 +.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: 38px; transition: all 0.12s ease; }
131 +.seg button:first-child { border-radius: 8px 0 0 8px; margin-left: 0; }
132 +.seg button:last-child { border-radius: 0 8px 8px 0; }
133 +.seg button:hover { background: var(--lime-soft); color: var(--ink); }
134 +.seg button.on { background: var(--ink); color: var(--lime); position: relative; z-index: 1; }
135 +
136 +.pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; }
137 +.pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease; }
138 +.pill:hover { background: var(--lime); }
139 +.pill-x { font-size: 10px; opacity: 0.65; }
140 +.pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); }
141 +.pill-clear:hover { background: var(--danger); color: #fff; }
142 +.link-btn { background: none; border: none; padding: 0; color: var(--green); font: inherit; text-decoration: underline; cursor: pointer; }
143 +
144 +.btn { 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; transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; }
145 +.btn:active { transform: translate(2px, 2px); box-shadow: none !important; }
146 +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); }
147 +.btn-primary:hover { background: var(--green-deep); }
148 +.btn-ghost { background: transparent; color: var(--ink); }
149 +.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); }
150 +
151 +/* ================= Chips ================= */
152 +.chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; }
153 +.chips::-webkit-scrollbar { display: none; }
154 +.chip { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px; transition: all 0.13s ease; }
155 +.chip:hover { background: var(--lime-soft); transform: translateY(-1px); }
156 +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.2); }
157 +
158 +/* ================= Results ================= */
159 +.results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; }
160 +.results-head h2 { font-size: 22px; text-transform: uppercase; letter-spacing: -0.02em; }
161 +.results-head > span { font-family: var(--font-mono); color: var(--ink-3); font-size: 12px; letter-spacing: 0.06em; }
162 +.results-tools { display: flex; align-items: center; gap: 12px; margin-left: auto; }
163 +.sort-ctl select { border: 1.5px solid var(--line-strong); background: var(--surface); border-radius: var(--r-ctl); padding: 7px 28px 7px 12px; 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; }
164 +.view-toggle { display: inline-flex; border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); overflow: hidden; background: var(--surface); box-shadow: var(--shadow-flat); }
165 +.view-toggle button { border: 0; background: transparent; padding: 7px 14px; cursor: pointer; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); }
166 +.view-toggle button + button { border-left: 1.5px solid var(--line-strong); }
167 +.view-toggle button.on { background: var(--ink); color: var(--lime); }
168 +
169 +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 22px; padding-bottom: 20px; }
170 +@media (max-width: 640px) { .grid { grid-template-columns: 1fr; gap: 16px; } }
171 +.more-wrap { display: flex; justify-content: center; padding: 8px 0 70px; }
172 +
173 +.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; }
174 +.card:hover { transform: translate(-3px, -3px); box-shadow: var(--shadow-off); }
175 +.card:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
176 +.card-img { position: relative; aspect-ratio: 16/10.5; background: repeating-linear-gradient(45deg, #eceae3 0 12px, #f3f1ea 12px 24px); overflow: hidden; }
177 +.card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.4s ease; }
178 +.card:hover .card-img img { transform: scale(1.05); }
179 +.card-img .noimg { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--ink-3); font-size: 34px; }
180 +.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); }
181 +.badge.type { background: var(--ink); color: var(--lime); border-color: var(--ink); }
182 +.badge.right { left: auto; right: 12px; background: rgba(255,255,255,0.92); border-color: transparent; }
183 +.card-body { padding: 16px 18px; display: flex; flex-direction: column; gap: 6px; flex: 1; }
184 +.card-price { font-family: var(--font-display); font-weight: 700; font-size: 21px; letter-spacing: -0.02em; }
185 +.card-title { font-weight: 600; font-size: 14.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186 +.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); }
187 +.card-meta .sep { width: 4px; height: 4px; background: var(--lime); border: 1px solid var(--ink); border-radius: 1px; transform: rotate(45deg); }
188 +.card-specs { display: flex; gap: 12px; font-size: 12.5px; color: var(--ink-2); font-family: var(--font-mono); }
189 +.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; }
190 +.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%; }
191 +.avail { font-size: 11px; color: var(--ink-3); font-weight: 500; text-align: right; font-family: var(--font-mono); }
192 +
193 +/* ================= Skeletons ================= */
194 +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
195 +.skel { border-radius: var(--r-card); border: 1.5px solid var(--line); overflow: hidden; background: var(--surface); }
196 +.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; }
197 +.skel .sk-img { aspect-ratio: 16/10.5; }
198 +.skel .sk-line { height: 14px; border-radius: 4px; margin: 12px 16px; }
199 +.skel .sk-line.short { width: 45%; }
200 +
201 +/* ================= Empty / error ================= */
202 +.notice { text-align: center; padding: 72px 24px; color: var(--ink-2); }
203 +.notice .big { font-size: 44px; margin-bottom: 10px; }
204 +.notice h2 { text-transform: uppercase; }
205 +
206 +/* ================= Detail page ================= */
207 +.detail { padding: 30px 0 90px; }
208 +.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; }
209 +.crumbs a { border-bottom: 1.5px solid transparent; }
210 +.crumbs a:hover { color: var(--green); border-color: var(--green); }
211 +
212 +.fiche { display: flex; flex-direction: column; gap: 22px; }
213 +.f-col { display: contents; }
214 +.f-galerie { order: 1; } .f-hero { order: 2; }
215 +.f-desc { order: 3; } #caracteristiques { order: 4; } #pieces { order: 5; }
216 +#inclusions { order: 6; } #carte { order: 7; }
217 +@media (min-width: 900px) {
218 + .fiche { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
219 + .f-col { display: flex; flex-direction: column; gap: 26px; min-width: 0; }
220 + .f-col:last-child { position: sticky; top: 88px; }
221 +}
222 +.f-bloc { min-width: 0; }
223 +.f-bloc h2 { font-size: 21px; letter-spacing: -0.02em; margin-bottom: 12px; border-left: 4px solid var(--lime); padding-left: 10px; }
224 +.f-bloc:empty { display: none; }
225 +
226 +/* galerie */
227 +.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); }
228 +.carousel-track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; aspect-ratio: 16/11; }
229 +.carousel-track::-webkit-scrollbar { display: none; }
230 +.carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; }
231 +.carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; }
232 +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(20, 24, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; }
233 +.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; }
234 +.carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; }
235 +@media (max-width: 640px) { .carousel-nav { display: none; } }
236 +.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 8px; margin-top: 10px; }
237 +.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; }
238 +.thumbs button:hover { transform: translateY(-2px); }
239 +.thumbs button.on { border-color: var(--ink); box-shadow: 3px 3px 0 var(--lime); }
240 +.thumbs img { width: 100%; height: 100%; object-fit: cover; }
241 +@media (max-width: 640px) { .thumbs { display: flex; overflow-x: auto; scrollbar-width: none; } .thumbs::-webkit-scrollbar { display: none; } .thumbs button { flex: 0 0 84px; } }
242 +
243 +/* lightbox */
244 +.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; }
245 +.lightbox img { max-width: 94vw; max-height: 86vh; border-radius: 6px; border: 2px solid var(--lime); object-fit: contain; }
246 +.lb-close { position: absolute; top: 18px; right: 22px; background: none; border: none; color: #fff; font-size: 30px; cursor: pointer; }
247 +.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; }
248 +.lb-nav.prev { left: 6px; } .lb-nav.next { right: 6px; }
249 +.lb-count { position: absolute; bottom: 24px; color: #c9ccc2; font-family: var(--font-mono); font-size: 13px; }
250 +
251 +/* hero panneau (colonne droite) */
252 +.f-hero { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px; }
253 +.f-hero .price { font-family: var(--font-display); font-size: clamp(30px, 6vw, 40px); font-weight: 700; letter-spacing: -0.03em; }
254 +.f-hero h1 { font-size: clamp(20px, 4.5vw, 24px); margin-top: 8px; }
255 +.f-hero .loc { color: var(--ink-2); font-size: 14px; margin-top: 2px; }
256 +.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); }
257 +.deal-ok { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
258 +
259 +.specs-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; margin: 18px 0; }
260 +.spec { border: 1.5px solid var(--line-strong); border-radius: var(--r-ctl); padding: 9px 11px; background: var(--surface-2); }
261 +.spec b { display: block; font-family: var(--font-display); font-size: 16px; }
262 +.spec span { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
263 +
264 +.broker { margin: 16px 0; padding: 14px; border: 1.5px dashed var(--line-strong); border-radius: var(--r-ctl); background: var(--surface-2); }
265 +.broker-k { font-family: var(--font-mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); }
266 +.broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; }
267 +.broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
268 +
269 +.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(20,24,20,0.25); transition: all 0.14s ease; margin-top: 6px; }
270 +.cta:hover { background: var(--lime); color: var(--ink); }
271 +.cta:active { transform: translate(2px, 2px); box-shadow: none; }
272 +.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; }
273 +
274 +.desc-text { color: var(--ink-2); font-size: 14.5px; white-space: pre-line; line-height: 1.6; margin: 0; }
275 +
276 +/* tableau caractéristiques */
277 +.dtable { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 22px; }
278 +.drow { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--line); font-size: 13.5px; }
279 +.drow span { color: var(--ink-3); } .drow b { text-align: right; font-family: var(--font-display); }
280 +
281 +/* tableau pièces */
282 +.rooms-wrap { overflow-x: auto; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); }
283 +table.rooms { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 460px; }
284 +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; }
285 +table.rooms td { padding: 8px 12px; border-bottom: 1px solid var(--line); }
286 +table.rooms tr:last-child td { border-bottom: none; }
287 +table.rooms tr:nth-child(even) td { background: var(--surface-2); }
288 +
289 +.amenity-row { display: flex; flex-wrap: wrap; gap: 7px; }
290 +.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; }
291 +
292 +.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); }
293 +.prix-histo.down { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); }
294 +
295 +.mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; }
296 +.mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; }
297 +
298 +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(245, 243, 238, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); }
299 +.cta-sticky .cta { flex: 1; margin: 0; }
300 +.cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; }
301 +@media (min-width: 900px) { .cta-sticky { display: none; } }
302 +@media (max-width: 899px) { .detail { padding-bottom: 84px; } }
303 +.f-foot { margin-top: 26px; }
304 +.fine { font-size: 12px; color: var(--ink-3); }
305 +
306 +/* ================= Sources / Agences ================= */
307 +.sources { padding: 44px 0 90px; }
308 +.sources h1 { font-size: clamp(28px, 4vw, 40px); text-transform: uppercase; margin: 10px 0 6px; }
309 +.sources .sub { color: var(--ink-2); margin-bottom: 30px; max-width: 720px; }
310 +.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); }
311 +.src-table { width: 100%; border-collapse: separate; border-spacing: 0; min-width: 760px; }
312 +.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); }
313 +.src-table td { padding: 13px 18px; border-bottom: 1px solid var(--line); font-size: 14px; vertical-align: top; }
314 +.src-table tr:last-child td { border-bottom: none; }
315 +.src-table tr:hover td { background: var(--surface-2); }
316 +.src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); }
317 +.pill.ok { background: var(--lime); color: var(--ink); 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; }
318 +.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; }
319 +.count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); }
320 +
321 +/* ================= Stats ================= */
322 +.stats-page { padding: 44px 0 90px; }
323 +.stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; }
324 +.stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; }
325 +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; }
326 +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); }
327 +.tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; }
328 +.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; }
329 +.hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); }
330 +.hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); }
331 +.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; }
332 +.viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; }
333 +.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; }
334 +.viz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
335 +@media (max-width: 900px) { .viz-grid { grid-template-columns: 1fr; } }
336 +.hbars { display: flex; flex-direction: column; gap: 7px; }
337 +.hbar-row { display: grid; grid-template-columns: minmax(96px, 180px) 1fr auto; gap: 12px; align-items: center; min-height: 26px; }
338 +.hbar-label { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
339 +.hbar-label a { border-bottom: 1.5px solid var(--lime); }
340 +.hbar-label a:hover { color: var(--green-deep); }
341 +.hbar-track { background: var(--surface-2); border-radius: 0 4px 4px 0; height: 18px; overflow: hidden; }
342 +.hbar-fill { display: block; height: 100%; background: var(--green); border-radius: 0 4px 4px 0; min-width: 2px; transition: background 0.12s ease; }
343 +.hbar-row:hover .hbar-fill { background: var(--ink); box-shadow: inset 0 0 0 2px var(--lime); }
344 +.hbar-value { font-family: var(--font-mono); font-size: 11.5px; font-weight: 700; white-space: nowrap; }
345 +.hbar-value em { font-style: normal; font-weight: 500; color: var(--ink-3); }
346 +.viz-table { margin-top: 4px; }
347 +.viz-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
348 +.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); }
349 +.viz-table td { padding: 6px 10px; border-bottom: 1px solid var(--line); }
350 +.stats-foot { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); letter-spacing: 0.04em; margin-top: 6px; }
351 +
352 +/* ================= Carte ================= */
353 +.map-split { display: grid; grid-template-columns: minmax(300px, 400px) 1fr; gap: 18px; height: calc(100dvh - 200px); min-height: 420px; }
354 +.map-list { overflow-y: auto; display: flex; flex-direction: column; gap: 14px; padding-right: 4px; scrollbar-width: thin; }
355 +.map-list .card { margin: 0; flex: 0 0 auto; }
356 +.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); }
357 +.map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; }
358 +@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; } }
359 +.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); }
360 +.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(20, 24, 20, 0.8); }
361 +.mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; }
362 +.mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); }
363 +.mv-pop-body { padding: 10px 12px 12px; }
364 +.mv-pop-price { font-family: var(--font-display); font-weight: 700; font-size: 18px; }
365 +.mv-pop-title { font-size: 13px; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
366 +.mv-pop-meta { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); margin-top: 6px; }
367 +.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; }
368 +.mv-pop-cta { display: block; margin-top: 10px; padding: 8px 10px; text-align: center; background: var(--ink); color: var(--lime); border-radius: var(--r-ctl); font-weight: 600; font-size: 13px; }
369 +.mv-pop-cta:hover { background: var(--green-deep); }
370 +
371 +/* ================= Menu mobile ================= */
372 +.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(20, 24, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; }
373 +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,24,20,0.15); }
374 +.menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; }
375 +.menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
376 +.menu-btn.open span:nth-child(2) { opacity: 0; }
377 +.menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
378 +.mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(20, 24, 20, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; }
379 +.mobile-menu.open { max-height: 480px; opacity: 1; }
380 +.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; }
381 +.mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); }
382 +.mm-link.active { color: var(--green); }
383 +.mm-ico { width: 26px; text-align: center; font-size: 17px; }
384 +.mm-arrow { margin-left: auto; opacity: 0.25; }
385 +.mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); }
386 +.mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; }
387 +.mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px); }
388 +@media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } .mobile-menu { display: block; } }
389 +
390 +/* ================= Footer ================= */
391 +.footer { background: var(--ink); color: rgba(245, 243, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; }
392 +.footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; }
393 +.footer .fbrand .ka { color: var(--lime); }
394 +.footer b { color: var(--paper); }
395 +.footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 760px; }
396 +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; }
397 +
398 +/* ================= Mobile : feuille de filtres + FAB ================= */
399 +.sheet-head { display: none; }
400 +.sheet-apply { display: none; }
401 +.sheet-backdrop { display: none; }
402 +.fab { display: none; }
403 +@media (max-width: 640px) {
404 + .header-inner { height: 56px; }
405 + .brand { font-size: 21px; }
406 + .hero h1 { font-size: clamp(30px, 9.4vw, 44px); }
407 + .hero p.lede { font-size: 15px; }
408 + .stat-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 6px; margin-right: -16px; padding-right: 16px; }
409 + .stat-row::-webkit-scrollbar { display: none; }
410 + .stat-chip { flex: 0 0 auto; white-space: nowrap; }
411 + .filterbar { display: none; }
412 + .filterbar.open .f-primary { flex-direction: column; }
413 + .filterbar.open .f-ctl select { max-width: none; width: 100%; }
414 + .filterbar.open .f-more { display: none; }
415 + .filterbar.open { display: flex; flex-direction: column; gap: 12px; position: fixed; left: 0; right: 0; bottom: 0; z-index: 95; 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: 16px 18px calc(18px + env(safe-area-inset-bottom)); box-shadow: 0 -16px 48px rgba(16, 18, 16, 0.35); animation: sheet-up 0.22s ease; }
416 + @keyframes sheet-up { from { transform: translateY(30%); opacity: 0.4; } to { transform: none; opacity: 1; } }
417 + .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: -16px; background: var(--surface); padding: 6px 0 8px; border-bottom: 1.5px solid var(--line); margin-bottom: 2px; z-index: 1; }
418 + .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; }
419 + .filterbar.open .sheet-apply { display: block; width: 100%; }
420 + .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); }
421 + .fab { display: flex; align-items: center; gap: 6px; 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(20, 24, 20, 0.25); }
422 + .fab:active { transform: translateX(-50%) scale(0.97); }
423 + .results-head { margin-top: 20px; }
424 + .chips { margin-right: -16px; padding-right: 16px; }
425 + .notice { padding: 48px 16px; }
426 +}
427 +@media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } }
added frontend/src/vite-env.d.ts +1 −0
@@ -0,0 +1 @@
1 +/// <reference types="vite/client" />
added frontend/tsconfig.json +20 −0
@@ -0,0 +1,20 @@
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 +}
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/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 +166 −0
@@ -0,0 +1,166 @@
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 + data = json.loads(block)
29 + except ValueError:
30 + continue
31 + graph = data.get("@graph", [data]) if isinstance(data, dict) else data
32 + for node in (graph if isinstance(graph, list) else [graph]):
33 + if isinstance(node, dict):
34 + yield node
35 +
36 +
37 +def ld_description(html: str) -> str:
38 + """Description depuis un nœud JSON-LD de type propriété (le plus long trouvé)."""
39 + best = ""
40 + for n in ld_nodes(html):
41 + t = n.get("@type")
42 + types = t if isinstance(t, list) else [t]
43 + if any(x in _LD_PROP_TYPES for x in types) and n.get("description"):
44 + d = _html.unescape(str(n["description"])).strip()
45 + if len(d) > len(best):
46 + best = d
47 + return best
48 +
49 +
50 +def gmaps_coords(html: str) -> tuple[float, float] | None:
51 + m = _COORD_RE.search(html)
52 + if not m:
53 + return None
54 + try:
55 + lat, lng = float(m.group(1)), float(m.group(2))
56 + except ValueError:
57 + return None
58 + if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:
59 + return lat, lng
60 + return None
61 +
62 +
63 +def flatten(html: str) -> str:
64 + """HTML -> texte « valeur | libellé » pour extraire les tableaux Centris."""
65 + t = _html.unescape(re.sub(r"<[^>]+>", " | ", html))
66 + t = re.sub(r"[ \t\r\n]*\|[ \t\r\n|]*", " | ", t)
67 + return re.sub(r"[ \t]+", " ", t)
68 +
69 +
70 +# libellés Centris standard cherchés dans « valeur | libellé » OU « libellé | valeur »
71 +_LABELS = [
72 + "Type de propriété", "Genre de propriété", "Style de bâtiment",
73 + "Année de construction", "Superficie habitable", "Superficie du terrain",
74 + "Superficie du bâtiment (au sol)", "Nombre de pièces", "Nombre d'unités",
75 + "Stationnement (total)", "Stationnement", "Garage", "Système de chauffage",
76 + "Énergie pour le chauffage", "Type de fenestration", "Fenêtres", "Toiture",
77 + "Revêtement", "Sous-sol", "Piscine", "Zonage", "Système d'égouts",
78 + "Approvisionnement en eau", "Déménagement", "Taxes municipales",
79 + "Taxes scolaires", "Évaluation municipale (terrain)",
80 + "Évaluation municipale (bâtiment)", "Cuisine",
81 +]
82 +
83 +
84 +def centris_details(text: str) -> dict:
85 + """Extrait les caractéristiques Centris d'un texte aplati (les deux ordres)."""
86 + out: dict = {}
87 + for label in _LABELS:
88 + lab = re.escape(label)
89 + m = (re.search(r"([^|]{1,55})\s*\|\s*" + lab + r"\b", text)
90 + or re.search(lab + r"\b\s*\|\s*([^|]{1,55})", text))
91 + if m:
92 + val = m.group(1).strip(" |")
93 + if val and 1 <= len(val) <= 55 and val.lower() != label.lower():
94 + out[label] = val
95 + return out
96 +
97 +
98 +_INT_RE = re.compile(r"\d+")
99 +
100 +
101 +def _int(v):
102 + if v is None:
103 + return None
104 + if isinstance(v, (int, float)):
105 + return int(v) or None
106 + m = _INT_RE.search(str(v))
107 + return int(m.group()) if m else None
108 +
109 +
110 +def enrich(connector, listings, limit, parse_fn, key="v1", fetch_html=None):
111 + """Enrichit `listings` via leur page détail, avec cache BD + plafond `limit`.
112 +
113 + - `parse_fn(html) -> dict` : extrait les champs riches d'une page détail.
114 + - `key` : versionne le cache (changer pour forcer un rafraîchissement).
115 + - `fetch_html(url) -> str` : par défaut connector.get(url).text ; passer
116 + connector.get_rendered pour les sites derrière Firecrawl/anti-bot.
117 + """
118 + if limit <= 0:
119 + return
120 + from .. import db
121 + fetch_html = fetch_html or (lambda u: connector.get(u).text)
122 + con = db.connect()
123 + budget = limit
124 + try:
125 + for lst in listings:
126 + cached = db.get_cached_detail(con, connector.source_id, lst.external_id, key)
127 + if cached is None:
128 + if budget <= 0:
129 + continue
130 + try:
131 + cached = parse_fn(fetch_html(lst.url))
132 + except Exception:
133 + cached = {}
134 + db.put_cached_detail(con, connector.source_id, lst.external_id, key, cached)
135 + budget -= 1
136 + apply_detail(lst, cached)
137 + finally:
138 + con.close()
139 +
140 +
141 +def apply_detail(lst: PropertyListing, d: dict) -> None:
142 + """Applique un payload détail au PropertyListing sans écraser les valeurs déjà
143 + présentes (sauf images : on garde la plus grande galerie)."""
144 + if not d:
145 + return
146 + imgs = d.get("images")
147 + if imgs and len(imgs) > len(lst.images):
148 + lst.images = imgs
149 + if d.get("features"):
150 + # fusionne en dédoublonnant
151 + seen = {f.lower() for f in lst.features}
152 + for f in d["features"]:
153 + if f.lower() not in seen:
154 + lst.features.append(f)
155 + seen.add(f.lower())
156 + if d.get("details"):
157 + lst.details.update(d["details"])
158 + if d.get("broker_name"):
159 + lst.broker_name = d["broker_name"]
160 + for f in ("description", "price_label"):
161 + if d.get(f) and not getattr(lst, f, ""):
162 + setattr(lst, f, d[f])
163 + for f in ("bedrooms", "bathrooms", "powder_rooms", "year_built",
164 + "area_sqft", "lot_sqft", "lat", "lng", "broker_phone", "price"):
165 + if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):
166 + setattr(lst, f, d[f])
added immoka/connectors/barnes_quebec.py +164 −0
@@ -0,0 +1,164 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/barnes_quebec.py : BARNES Québec (barnes-quebec.com)
5 +# Site WordPress indexé dans Algolia. La config expose l'App ID et une clé
6 +# API dans le HTML ; l'index « quebec_all » contient les propriétés (type
7 +# "property") avec adresse, prix, MLS, chambres, salles de bains, superficie
8 +# et géolocalisation. Les enregistrements sont dupliqués par langue → on
9 +# dédoublonne par référence MLS. Les permaliens pointent vers le domaine de
10 +# staging Kinsta ; on les réécrit vers le domaine de production.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import os
15 +import re
16 +
17 +from .base import BaseConnector
18 +from . import _detailutil as du
19 +from ..schema import PropertyListing
20 +
21 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_BARNES_DETAIL_LIMIT", "400"))
22 +# Galerie WordPress : .../wp-content/uploads/AAAA/MM/{ref}-{hash}-{L}x{H}.jpg
23 +_IMG_RE = re.compile(
24 + r'https://barnes-quebec\.com/wp-content/uploads/\d{4}/\d{2}/[^"\'\\ ]+?\.(?:jpg|jpeg|png|webp)',
25 + re.I)
26 +_SIZE_RE = re.compile(r'-(\d{2,4})x(\d{2,4})(?=\.[a-z]+$)', re.I)
27 +
28 +APP_ID = "HCW55VIQNM"
29 +API_KEY = "f4a20779fb6ded84a9c96a9b5976328b"
30 +INDEX = "quebec_all"
31 +QUERY_URL = f"https://{APP_ID}-dsn.algolia.net/1/indexes/{INDEX}/query"
32 +SITE = "https://barnes-quebec.com"
33 +STAGING = "stg-quebec-staging.kinsta.cloud"
34 +HITS_PER_PAGE = 100
35 +MAX_PAGES = 40
36 +
37 +
38 +class BarnesQuebecConnector(BaseConnector):
39 + source_id = "barnes_quebec"
40 + request_delay = 0.25
41 + use_detail_cache = False
42 +
43 + def fetch(self) -> list[PropertyListing]:
44 + best: dict[str, tuple[int, PropertyListing]] = {}
45 + page = 0
46 + while page < MAX_PAGES:
47 + data = self._query(page)
48 + hits = data.get("hits", [])
49 + if not hits:
50 + break
51 + for h in hits:
52 + if h.get("type") != "property":
53 + continue
54 + lst = self._to_listing(h)
55 + if lst is None:
56 + continue
57 + # une propriété apparaît en plusieurs langues et parfois en
58 + # doublon « Contact us » (sans prix). Clé stable = adresse
59 + # normalisée ; on garde la meilleure fiche (prix connu + FR).
60 + key = " ".join(lst.title.split()).lower()
61 + score = (2 if lst.price else 0) + (1 if h.get("lang_fr") == 1 else 0)
62 + if key not in best or score > best[key][0]:
63 + best[key] = (score, lst)
64 + if page + 1 >= data.get("nbPages", 0):
65 + break
66 + page += 1
67 + listings = [lst for _, lst in best.values()]
68 + # Algolia ne contient pas les photos : on les récupère sur la fiche.
69 + du.enrich(self, listings, DETAIL_LIMIT, parse_barnes_detail, key="v2")
70 + return listings
71 +
72 + def _query(self, page: int) -> dict:
73 + resp = self.post(
74 + QUERY_URL,
75 + headers={"X-Algolia-API-Key": API_KEY,
76 + "X-Algolia-Application-Id": APP_ID,
77 + "Content-Type": "application/json"},
78 + json={"params": f"hitsPerPage={HITS_PER_PAGE}&page={page}"},
79 + )
80 + return resp.json()
81 +
82 + def _to_listing(self, h: dict) -> PropertyListing | None:
83 + object_id = str(h.get("objectID") or "")
84 + mls = str(h.get("property_mls_reference") or "").strip()
85 + if not object_id and not mls:
86 + return None
87 +
88 + permalink = (h.get("permalink") or "").replace(STAGING, "barnes-quebec.com")
89 + if permalink.startswith("http://"):
90 + permalink = "https://" + permalink[len("http://"):]
91 +
92 + price = h.get("property_price") or 0
93 + try:
94 + price = float(price)
95 + except (TypeError, ValueError):
96 + price = 0.0
97 + price_label = h.get("property_pretty_price") or ""
98 +
99 + area = _pos(h.get("property_area"))
100 + land = _pos(h.get("property_land_area"))
101 + lat = h.get("property_address_latitude") or None
102 + lng = h.get("property_address_longitude") or None
103 + try:
104 + lat = float(lat) if lat else None
105 + lng = float(lng) if lng else None
106 + except (TypeError, ValueError):
107 + lat = lng = None
108 + if lat == 0 or lng == 0:
109 + lat = lng = None
110 +
111 + return PropertyListing(
112 + source=self.source_id,
113 + external_id=mls or object_id,
114 + url=permalink or SITE + "/property/",
115 + title=(h.get("title") or "").strip(),
116 + address=(h.get("title") or "").strip(),
117 + city=(h.get("property_address_city") or "").strip(),
118 + region=(h.get("region") or "").strip(),
119 + property_type=(h.get("property_type") or "").strip(),
120 + price=price if price > 0 else None,
121 + price_label=price_label,
122 + bedrooms=_pos(h.get("property_bedrooms_integer")),
123 + bathrooms=_pos(h.get("property_bathrooms")),
124 + area_sqft=area,
125 + lot_sqft=land,
126 + mls=mls,
127 + description=(h.get("content") or "")[:4000],
128 + lat=lat,
129 + lng=lng,
130 + broker_name="BARNES Québec",
131 + )
132 +
133 +
134 +def _pos(v):
135 + try:
136 + n = float(v)
137 + return int(n) if n and n > 0 else None
138 + except (TypeError, ValueError):
139 + return None
140 +
141 +
142 +def parse_barnes_detail(html: str) -> dict:
143 + """Galerie photo pleine résolution (absente d'Algolia) + description/pièces."""
144 + out: dict = {}
145 + # regroupe par image de base (sans le suffixe -LxH), garde la plus grande
146 + best: dict[str, tuple[int, str]] = {}
147 + for u in _IMG_RE.findall(html):
148 + m = _SIZE_RE.search(u)
149 + area = int(m.group(1)) * int(m.group(2)) if m else 10 ** 8 # sans suffixe = original
150 + base = _SIZE_RE.sub("", u)
151 + if base not in best or area > best[base][0]:
152 + best[base] = (area, u)
153 + imgs = [u for _, u in best.values()]
154 + # ignore les vignettes de courtiers/logos (gardent souvent une réf MLS chiffrée)
155 + imgs = [u for u in imgs if re.search(r"/\d{6,}", u)] or imgs
156 + if imgs:
157 + out["images"] = imgs[:60]
158 + desc = du.ld_description(html)
159 + if desc:
160 + out["description"] = desc
161 + _det = du.centris_details(du.flatten(html))
162 + if _det:
163 + out.setdefault("details", {}).update(_det)
164 + return out
added immoka/connectors/base.py +113 −0
@@ -0,0 +1,113 @@
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 os
10 +import time
11 +
12 +import requests
13 +
14 +from ..schema import PropertyListing
15 +
16 +USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
17 + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36 "
18 + "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)")
19 +
20 +FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape"
21 +
22 +
23 +class BaseConnector:
24 + """Un connecteur = un adaptateur propre à un site d'agence de courtage.
25 +
26 + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne
27 + la liste complète des propriétés actuellement affichées sur le site.
28 + Le pipeline (ingest.py) s'occupe du diff avec la base de données.
29 + """
30 +
31 + source_id: str = ""
32 + request_delay: float = 0.6 # politesse entre requêtes
33 + timeout: int = 30
34 + use_detail_cache: bool = True # cache BD des pages détail
35 +
36 + def __init__(self) -> None:
37 + self.session = requests.Session()
38 + self.session.headers["User-Agent"] = USER_AGENT
39 + self._last_request = 0.0
40 + self._detail_con = None
41 +
42 + # -- backends -------------------------------------------------------------
43 + def get(self, url: str, **kw) -> requests.Response:
44 + """GET direct avec throttling poli."""
45 + wait = self.request_delay - (time.time() - self._last_request)
46 + if wait > 0:
47 + time.sleep(wait)
48 + resp = self.session.get(url, timeout=self.timeout, **kw)
49 + self._last_request = time.time()
50 + resp.raise_for_status()
51 + return resp
52 +
53 + def post(self, url: str, **kw) -> requests.Response:
54 + """POST direct avec throttling poli (APIs de recherche internes)."""
55 + wait = self.request_delay - (time.time() - self._last_request)
56 + if wait > 0:
57 + time.sleep(wait)
58 + resp = self.session.post(url, timeout=self.timeout, **kw)
59 + self._last_request = time.time()
60 + resp.raise_for_status()
61 + return resp
62 +
63 + def get_rendered(self, url: str, wait_for: int = 0,
64 + proxy: str | None = None) -> str:
65 + """Récupère le HTML rendu (JavaScript exécuté) via Firecrawl.
66 +
67 + Nécessite FIRECRAWL_API_KEY dans l'environnement (.env).
68 + À utiliser pour les sites SPA ou derrière Cloudflare.
69 + `proxy="stealth"` franchit les challenges anti-bot (Cloudflare, etc.).
70 + """
71 + key = os.environ.get("FIRECRAWL_API_KEY")
72 + if not key:
73 + raise RuntimeError("FIRECRAWL_API_KEY manquant (voir .env)")
74 + payload: dict = {"url": url, "formats": ["html"], "timeout": 90000}
75 + if wait_for:
76 + payload["waitFor"] = wait_for
77 + if proxy:
78 + payload["proxy"] = proxy
79 + resp = requests.post(
80 + FIRECRAWL_API,
81 + json=payload,
82 + headers={"Authorization": f"Bearer {key}"},
83 + timeout=150,
84 + )
85 + resp.raise_for_status()
86 + data = resp.json()
87 + return (data.get("data") or {}).get("html", "")
88 +
89 + def detail(self, external_id: str, key: str, fetch_fn) -> dict:
90 + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si
91 + la propriété est nouvelle ou si sa clé (hash du contenu liste) a changé.
92 +
93 + Permet d'extraire les champs riches (description, courtier, photos…)
94 + sans revisiter chaque page détail à chaque synchronisation.
95 + `fetch_fn` doit retourner un dict JSON-sérialisable.
96 + """
97 + if not self.use_detail_cache:
98 + return fetch_fn() or {}
99 + from .. import db
100 + if self._detail_con is None:
101 + self._detail_con = db.connect()
102 + cached = db.get_cached_detail(self._detail_con, self.source_id,
103 + str(external_id), key)
104 + if cached is not None:
105 + return cached
106 + payload = fetch_fn() or {}
107 + db.put_cached_detail(self._detail_con, self.source_id,
108 + str(external_id), key, payload)
109 + return payload
110 +
111 + # -- contrat --------------------------------------------------------------
112 + def fetch(self) -> list[PropertyListing]:
113 + raise NotImplementedError
added immoka/connectors/century21_agences.py +90 −0
@@ -0,0 +1,90 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/century21_agences.py : Century 21 par SOUS-AGENCES (robuste)
5 +#
6 +# Le portail central c21.ca délègue à une iframe moxiworks verrouillée
7 +# (503/WAF, même via Firecrawl). Mais les bureaux Century 21 du Québec tournent
8 +# sur la plateforme source.immo (ID-3 Technologies) : quebec.c21.ca,
9 +# excel.c21.ca et century21immoplus.com partagent un même compte
10 +# « Century 21 - All Quebec » (inventaire provincial complet), et
11 +# vision.c21.ca a son propre compte. L'API publique (api-v1.source.immo)
12 +# est paginée par st/nt (voir sourceimmo.py) — inventaire complet accessible.
13 +#
14 +# Une classe par sous-agence est générée depuis data/century21_agencies.json
15 +# (source_id « c21_ag_<slug> »). Le n° Centris sert de clé de déduplication
16 +# (voir web.py, sources « %_ag_% ») — aucun double-comptage entre les skins
17 +# partageant le même compte ni avec le reste du site.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import json
22 +import re
23 +from pathlib import Path
24 +
25 +from .sourceimmo import SourceImmoConnector
26 +from ..schema import PropertyListing
27 +
28 +REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "century21_agencies.json"
29 +
30 +
31 +class Century21AgenceConnector(SourceImmoConnector):
32 + """Sous-agence Century 21 sur source.immo. Les sous-classes fournissent
33 + account_id / api_key / app_id / view_id / site_url / agency_name."""
34 +
35 + agency_name = "Century 21"
36 +
37 + def fetch(self) -> list[PropertyListing]:
38 + # auto-guérison : recharger les identifiants depuis la config publique
39 + # du site (résilient si source.immo fait tourner ses UUID).
40 + try:
41 + cfg = self.get(
42 + f"{self.site_url.rstrip('/')}/wp-content/uploads/_sourceimmo/_configs.json"
43 + ).json()
44 + self.account_id = cfg.get("account_id", self.account_id)
45 + self.api_key = cfg.get("api_key", self.api_key)
46 + self.app_id = cfg.get("app_id", self.app_id)
47 + self.view_id = cfg.get("default_view", self.view_id)
48 + except Exception:
49 + pass # on garde les valeurs du registre
50 + return super().fetch()
51 +
52 + def _to_listing(self, it: dict) -> PropertyListing | None:
53 + lst = super()._to_listing(it)
54 + if lst is not None:
55 + lst.broker_name = self.agency_name
56 + ref = it.get("ref_number") or it.get("id")
57 + if ref and self.site_url:
58 + lst.url = f"{self.site_url.rstrip('/')}/propriete/{ref}"
59 + return lst
60 +
61 +
62 +def _load() -> list[dict]:
63 + try:
64 + return json.loads(REGISTRY.read_text(encoding="utf-8"))
65 + except Exception:
66 + return []
67 +
68 +
69 +def _slug(banner: str) -> str:
70 + return "c21_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.lower()).strip("_")
71 +
72 +
73 +for _ag in _load():
74 + if not all(_ag.get(k) for k in ("domain", "account", "api", "app", "view")):
75 + continue
76 + _sid = _slug(_ag["banner"])
77 + _name = "Century 21 " + _ag["banner"].replace("-", " ").title()
78 + globals()[_sid.upper()] = type(
79 + "".join(p.title() for p in _sid.split("_")),
80 + (Century21AgenceConnector,),
81 + {
82 + "source_id": _sid,
83 + "account_id": _ag["account"],
84 + "api_key": _ag["api"],
85 + "app_id": _ag["app"],
86 + "view_id": _ag["view"],
87 + "site_url": f"https://{_ag['domain']}",
88 + "agency_name": _name,
89 + },
90 + )
added immoka/connectors/engel_volkers.py +159 −0
@@ -0,0 +1,159 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/engel_volkers.py : Engel & Völkers Montréal / Québec (luxe)
5 +# Le site local immobilier.evquebec.com publie un property-sitemap.xml
6 +# exhaustif (~1240 fiches, /propriete/{no-centris}/). Chaque fiche a un
7 +# JSON-LD RealEstateListing (prix, adresse, description, n° Centris). La
8 +# galerie est servie séquentiellement sur media.evquebec.com/listings/{id}/NN.webp.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as _html
13 +import os
14 +import re
15 +
16 +from .base import BaseConnector
17 +from . import _detailutil as du
18 +from ..schema import PropertyListing
19 +
20 +SITEMAP = "https://immobilier.evquebec.com/property-sitemap.xml"
21 +MEDIA = "https://media.evquebec.com/listings"
22 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_EV_DETAIL_LIMIT", "400"))
23 +GALLERY_MAX = 60
24 +
25 +_PROP_RE = re.compile(r'https://immobilier\.evquebec\.com/propriete/(\d{5,})/')
26 +_NAME_RE = re.compile(r'^(.*?)\s+à vendre\b\s*-?\s*(.*)$', re.I)
27 +
28 +
29 +class EngelVolkersConnector(BaseConnector):
30 + source_id = "engel_volkers"
31 + request_delay = 0.4
32 + use_detail_cache = True
33 +
34 + def fetch(self) -> list[PropertyListing]:
35 + try:
36 + xml = self.get(SITEMAP).text
37 + except Exception:
38 + return []
39 + ids, seen = [], set()
40 + for m in _PROP_RE.finditer(xml): # ne garder que les URLs FR
41 + if m.group(1) not in seen:
42 + seen.add(m.group(1))
43 + ids.append(m.group(1))
44 + listings = [PropertyListing(source=self.source_id, external_id=cid,
45 + url=f"https://immobilier.evquebec.com/propriete/{cid}/",
46 + mls=cid, broker_name="Engel & Völkers Québec")
47 + for cid in ids]
48 + self._enrich(listings)
49 + return listings
50 +
51 + def _enrich(self, listings: list[PropertyListing]) -> None:
52 + from .. import db
53 + con = db.connect()
54 + budget = DETAIL_LIMIT
55 + try:
56 + for lst in listings:
57 + d = db.get_cached_detail(con, self.source_id, lst.external_id, "v1")
58 + if d is None:
59 + if budget <= 0:
60 + continue
61 + try:
62 + d = self._parse(self.get(lst.url).text, lst.external_id)
63 + except Exception:
64 + d = {}
65 + db.put_cached_detail(con, self.source_id, lst.external_id, "v1", d)
66 + budget -= 1
67 + _apply(lst, d)
68 + finally:
69 + con.close()
70 +
71 + def _parse(self, html: str, centris: str) -> dict:
72 + out: dict = {}
73 + for n in du.ld_nodes(html):
74 + t = n.get("@type")
75 + types = t if isinstance(t, list) else [t]
76 + if "RealEstateListing" in types:
77 + if n.get("description"):
78 + out["description"] = _html.unescape(str(n["description"])).strip()
79 + offer = n.get("offers") or {}
80 + try:
81 + p = float(offer.get("price"))
82 + if p >= 10_000:
83 + out["price"] = p
84 + except (TypeError, ValueError):
85 + pass
86 + name = _html.unescape(n.get("name", "")).strip()
87 + if name:
88 + out.update(_parse_name(name))
89 + break
90 + # bedrooms / bathrooms depuis le texte
91 + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
92 + mb = re.search(r"(\d+)\s*chambre", text, re.I)
93 + if mb:
94 + out["bedrooms"] = int(mb.group(1))
95 + ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I)
96 + if ms:
97 + out["bathrooms"] = int(ms.group(1))
98 + out["images"] = self._gallery(centris)
99 + return out
100 +
101 + def _gallery(self, centris: str) -> list[str]:
102 + """Galerie séquentielle NN.webp — on s'arrête au premier trou (HEAD rapide)."""
103 + urls = []
104 + for i in range(1, GALLERY_MAX + 1):
105 + u = f"{MEDIA}/{centris}/{i:02d}.webp"
106 + try:
107 + r = self.session.head(u, timeout=8)
108 + except Exception:
109 + break
110 + if r.status_code != 200:
111 + break
112 + urls.append(u)
113 + return urls
114 +
115 +
116 +def _parse_name(name: str) -> dict:
117 + """« Condo à vendre - Centre-ville - 1288 Av. …, Montréal (Ville-Marie), Montréal »."""
118 + out: dict = {}
119 + m = _NAME_RE.search(name)
120 + if not m:
121 + return out
122 + out["property_type"] = m.group(1).strip()
123 + rest = m.group(2).strip()
124 + segs = [s.strip() for s in rest.split(" - ") if s.strip()]
125 + loc = segs[-1] if segs else rest
126 + # municipalité = segment contenant « ( » ; adresse = ce qui précède
127 + commas = [c.strip() for c in loc.split(",")]
128 + muni_idx = next((i for i, c in enumerate(commas) if "(" in c), None)
129 + if muni_idx is not None:
130 + muni = commas[muni_idx]
131 + ps = re.search(r"\(([^)]+)\)", muni)
132 + out["city"] = re.sub(r"\s*\([^)]*\)", "", muni).strip()
133 + if ps:
134 + out["sector"] = ps.group(1).strip()
135 + addr = ", ".join(commas[:muni_idx]).strip()
136 + if addr:
137 + out["address"] = addr
138 + elif len(commas) >= 2:
139 + # pas d'arrondissement : dernier segment = municipalité, le reste = adresse
140 + out["city"] = commas[-1]
141 + out["address"] = ", ".join(commas[:-1]).strip()
142 + else:
143 + out["address"] = loc
144 + if len(segs) > 1 and not out.get("sector"):
145 + out["sector"] = segs[0]
146 + return out
147 +
148 +
149 +def _apply(lst: PropertyListing, d: dict) -> None:
150 + if not d:
151 + return
152 + for f in ("address", "city", "sector", "property_type", "description"):
153 + if d.get(f) and not getattr(lst, f, ""):
154 + setattr(lst, f, d[f])
155 + for f in ("price", "bedrooms", "bathrooms"):
156 + if d.get(f) is not None and getattr(lst, f, None) in (None, 0):
157 + setattr(lst, f, d[f])
158 + if d.get("images") and len(d["images"]) > len(lst.images):
159 + lst.images = d["images"]
added immoka/connectors/expert_immobilier_pm.py +148 −0
@@ -0,0 +1,148 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/expert_immobilier_pm.py : L'Expert Immobilier P.M. (Laval, QC)
5 +# Vrai site : expertimmobilierpm.com (WordPress + plugin mw-properties2, la
6 +# même plateforme marketingwebsites.ca que KW Urbain). NB : l'ancienne URL du
7 +# registre (lexpertimmobilier.com) était une agence de Casablanca — corrigée.
8 +# La page /proprietes/ rend les fiches côté serveur (n° Centris dans l'URL) ;
9 +# la fiche détail fournit la galerie complète (marketingwebsites) + description.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import html as _html
14 +import os
15 +import re
16 +from urllib.parse import unquote_plus
17 +
18 +from .base import BaseConnector
19 +from . import _detailutil as du
20 +from ..normalize import parse_price
21 +from ..schema import PropertyListing
22 +
23 +BASE = "https://expertimmobilierpm.com"
24 +LISTINGS = f"{BASE}/proprietes/"
25 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_EPM_DETAIL_LIMIT", "300"))
26 +
27 +# lien fiche : /properties/{Adresse+Avec+Plus}/{no-centris}
28 +_LINK_RE = re.compile(r'/properties/([^"/]+)/(\d{6,})')
29 +_IMG_RE = re.compile(
30 + r'https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
31 + re.I)
32 +_META_DESC_RE = re.compile(r'<meta\s+name="description"\s+content="([^"]{40,})"', re.I)
33 +
34 +_TYPE_MAP = {
35 + "plain-pied": "Maison", "à étages": "Maison", "a etages": "Maison",
36 + "à paliers": "Maison", "mobile": "Maison mobile", "cottage": "Maison",
37 + "appartement": "Condo", "condo": "Condo", "copropriété": "Condo", "loft": "Condo",
38 + "duplex": "Duplex", "triplex": "Triplex", "quadruplex": "Multiplex",
39 + "quintuplex": "Multiplex", "plex": "Multiplex", "revenus": "Multiplex",
40 + "terrain": "Terrain", "fermette": "Fermette/Agricole", "ferme": "Fermette/Agricole",
41 + "chalet": "Chalet", "commercial": "Commercial", "commerciale": "Commercial",
42 +}
43 +
44 +
45 +def _norm_type(txt: str) -> str:
46 + k = txt.lower()
47 + for key, canon in _TYPE_MAP.items():
48 + if key in k:
49 + return canon
50 + return ""
51 +
52 +
53 +class ExpertImmobilierPmConnector(BaseConnector):
54 + source_id = "expert_immobilier_pm"
55 + request_delay = 0.6
56 +
57 + def fetch(self) -> list[PropertyListing]:
58 + try:
59 + html = self.get(LISTINGS).text
60 + except Exception:
61 + return []
62 + # premières positions de chaque n° Centris (les liens titre+image se
63 + # répètent : on garde la 1re occurrence de chaque fiche, dans l'ordre)
64 + firsts: list[tuple[str, str, int]] = [] # (addr_slug, centris, index)
65 + seen: set[str] = set()
66 + for m in _LINK_RE.finditer(html):
67 + c = m.group(2)
68 + if c not in seen:
69 + seen.add(c)
70 + firsts.append((m.group(1), c, m.start()))
71 + by_id: dict[str, PropertyListing] = {}
72 + for i, (addr_slug, centris, idx) in enumerate(firsts):
73 + end = firsts[i + 1][2] if i + 1 < len(firsts) else len(html)
74 + by_id[centris] = self._to_listing(addr_slug, centris, html[idx:end])
75 + listings = list(by_id.values())
76 + du.enrich(self, listings, DETAIL_LIMIT, parse_epm_detail, key="v2")
77 + return listings
78 +
79 + def _to_listing(self, addr_slug: str, centris: str, block: str) -> PropertyListing:
80 + address = unquote_plus(addr_slug).strip()
81 + flat = _html.unescape(re.sub(r"<[^>]+>", " | ", block))
82 + flat = re.sub(r"\s*\|\s*", " | ", flat)
83 + flat = re.sub(r"[ \t]+", " ", flat)
84 + # tout est dans la carte : type | # centris | adresse | ville | N Chambres
85 + # | N Bains | S MC Habitable | $prix
86 + mt = re.search(r"([A-Za-zÀ-ÿ'’ -]{4,40})\s*(?:\|\s*)+#\s*" + centris, flat)
87 + ptype = _norm_type(mt.group(1)) if mt else ""
88 + city = ""
89 + mc = re.search(re.escape(address) + r"\s*(?:\|\s*)+([A-Za-zÀ-ÿ'()/. -]{3,45}?)\s*\|",
90 + flat)
91 + if mc:
92 + city = mc.group(1).strip(" |")
93 + beds = re.search(r"\|\s*(\d{1,2})\s*\|\s*Chambres?", flat)
94 + baths = re.search(r"\|\s*(\d{1,2})\s*\|\s*Bains?", flat)
95 + area = re.search(r"([\d.,]+)\s*MC\s*\|\s*Habitable", flat, re.I)
96 + area_sqft = None
97 + if area:
98 + try:
99 + area_sqft = round(float(area.group(1).replace(",", ".")) * 10.7639)
100 + except ValueError:
101 + pass
102 + mprice = re.search(r"\$\s*([\d ,]{4,})", flat)
103 + price = parse_price(mprice.group(0)) if mprice else None
104 + thumb = _IMG_RE.search(block)
105 + return PropertyListing(
106 + source=self.source_id,
107 + external_id=centris,
108 + url=f"{BASE}/properties/{addr_slug}/{centris}",
109 + title=address,
110 + address=address,
111 + city=city,
112 + region="",
113 + property_type=ptype,
114 + price=price,
115 + price_label=(mprice.group(0).strip() if mprice else ""),
116 + bedrooms=int(beds.group(1)) if beds else None,
117 + bathrooms=int(baths.group(1)) if baths else None,
118 + area_sqft=area_sqft,
119 + mls=centris,
120 + images=[thumb.group(0)] if thumb else [],
121 + broker_name="L'Expert Immobilier P.M.",
122 + )
123 +
124 +
125 +def parse_epm_detail(html: str) -> dict:
126 + """Fiche détail : galerie complète (marketingwebsites), description, ch/sdb, prix."""
127 + out: dict = {}
128 + seen, imgs = set(), []
129 + for u in _IMG_RE.findall(html):
130 + base = re.sub(r"/thumbs-\d+/", "/", u) # préférer la pleine résolution
131 + if base not in seen:
132 + seen.add(base)
133 + imgs.append(base)
134 + if imgs:
135 + out["images"] = imgs
136 + md = _META_DESC_RE.search(html)
137 + if md:
138 + out["description"] = _html.unescape(md.group(1)).strip()
139 + # prix : uniquement un montant plausible suivi de $ (chambres/sdb viennent
140 + # de la carte, plus fiables que le texte aplati de la fiche)
141 + t = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
142 + for m in re.finditer(r"([\d][\d ]{4,})\s*\$", t):
143 + p = parse_price(m.group(0))
144 + if p and p >= 20000:
145 + out["price"] = p
146 + out["price_label"] = m.group(0).strip()
147 + break
148 + return out
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/kw_distinction.py +82 −0
@@ -0,0 +1,82 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kw_distinction.py : Keller Williams Distinction (Gatineau/Outaouais)
5 +# Site ASP.NET (plateforme tonikwebstudio) : la grille de résultats et les
6 +# fiches détail sont rendues via AJAX/UpdatePanel (prix et photos absents du
7 +# HTML statique, et non rendus de façon fiable même par Firecrawl). En revanche
8 +# le sitemap.xml énumère TOUTES les propriétés, et l'URL encode ville, type,
9 +# nombre de chambres, rue et numéro Centris — de quoi bâtir une fiche solide
10 +# au niveau liste. Le numéro Centris sert de clé de déduplication.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +import re
15 +
16 +from .base import BaseConnector
17 +from ..schema import PropertyListing
18 +
19 +BASE = "https://www.kwdistinction.com"
20 +SITEMAP = f"{BASE}/sitemap.xml"
21 +# /maisons-a-vendre-{ville}/{type}/{maison-N-chambres}/{rue}/{no-centris}
22 +_URL_RE = re.compile(
23 + r'/maisons?-a-vendre-([a-z0-9-]+)/([a-z0-9-]+)/(?:maison-(\d+)-chambres?|[a-z0-9-]+)'
24 + r'/([a-z0-9-]+)/(\d{6,})', re.I)
25 +
26 +_TYPE_MAP = {
27 + "maison-de-plain-pied": "Maison", "maison-a-etages": "Maison",
28 + "maison-a-paliers": "Maison", "maison-a-paliers-multiples": "Maison",
29 + "maison-mobile": "Maison mobile", "appartement": "Condo", "condo": "Condo",
30 + "duplex": "Duplex", "triplex": "Triplex", "quadruplex": "Multiplex",
31 + "quintuplex": "Multiplex", "terrain": "Terrain", "fermette": "Fermette/Agricole",
32 + "chalet": "Chalet", "commercial": "Commercial", "commerciale": "Commercial",
33 +}
34 +
35 +
36 +def _deslug(s: str) -> str:
37 + return re.sub(r"\s+", " ", s.replace("--", " ").replace("-", " ")).strip().title()
38 +
39 +
40 +def _split_city(slug: str) -> tuple[str, str]:
41 + """« aylmer--gatineau- » -> (Gatineau, Aylmer) ; « cayamant » -> (Cayamant, '')."""
42 + slug = slug.strip("-")
43 + fixes = {"Gatineau": "Gatineau", "Quebec": "Québec", "Montreal": "Montréal"}
44 + if "--" in slug:
45 + sector, city = slug.split("--", 1)
46 + city = _deslug(city)
47 + return fixes.get(city, city), _deslug(sector)
48 + city = _deslug(slug)
49 + return fixes.get(city, city), ""
50 +
51 +
52 +class KwDistinctionConnector(BaseConnector):
53 + source_id = "kw_distinction"
54 + request_delay = 0.5
55 +
56 + def fetch(self) -> list[PropertyListing]:
57 + try:
58 + xml = self.get(SITEMAP).text
59 + except Exception:
60 + return []
61 + by_id: dict[str, PropertyListing] = {}
62 + for m in _URL_RE.finditer(xml):
63 + ville, ptype, beds, rue, centris = m.groups()
64 + if centris in by_id:
65 + continue
66 + city, sector = _split_city(ville)
67 + path = m.group(0)
68 + by_id[centris] = PropertyListing(
69 + source=self.source_id,
70 + external_id=centris,
71 + url=f"{BASE}{path}",
72 + title=_deslug(rue),
73 + address=_deslug(rue),
74 + city=city,
75 + sector=sector,
76 + region="Outaouais",
77 + property_type=_TYPE_MAP.get(ptype.lower(), ""),
78 + bedrooms=int(beds) if beds else None,
79 + mls=centris,
80 + broker_name="Keller Williams Distinction",
81 + )
82 + return list(by_id.values())
added immoka/connectors/kw_urbain.py +108 −0
@@ -0,0 +1,108 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/kw_urbain.py : Keller Williams Urbain (kwurbain.ca)
5 +# Page /inscriptions rendue serveur : toutes les inscriptions de l'agence en
6 +# cartes HTML (plateforme marketingwebsites.ca, commune aux bureaux KW Canada).
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import html as _html
11 +import os
12 +import re
13 +
14 +from .base import BaseConnector
15 +from . import _detailutil as du
16 +from ..normalize import parse_price
17 +from ..schema import PropertyListing
18 +
19 +BASE = "https://www.kwurbain.ca"
20 +LISTINGS = f"{BASE}/inscriptions/"
21 +IMG_TMPL = "https://realestate.marketingwebsites.ca/property-images/{id}/{id}-{n:02d}.jpg"
22 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400"))
23 +_KW_IMG_RE = re.compile(
24 + r'https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
25 + re.I)
26 +_META_DESC_RE = re.compile(r'<meta\s+name="description"\s+content="([^"]{40,})"', re.I)
27 +
28 +_CARD_RE = re.compile(r'property-card mix property-(\d+)"(.*?)(?=property-card mix property-\d+"|</div>\s*</div>\s*</div>\s*</section>|$)', re.S)
29 +_H5_RE = re.compile(r'<h5>(.*?)</h5>', re.S)
30 +_PRICE_RE = re.compile(r'<span>(.*?)</span>', re.S)
31 +_SUB_RE = re.compile(r'card-subtitle[^>]*>(.*?)</h6>', re.S)
32 +_BED_RE = re.compile(r'fa-bed"></i>\s*(\d+)')
33 +_BATH_RE = re.compile(r'fa-bath"></i>\s*(\d+)')
34 +_STATUS_RE = re.compile(r'card-status[^>]*>(.*?)</div>', re.S)
35 +
36 +
37 +def _txt(s: str) -> str:
38 + return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
39 +
40 +
41 +class KwUrbainConnector(BaseConnector):
42 + source_id = "kw_urbain"
43 + request_delay = 0.6
44 +
45 + def fetch(self) -> list[PropertyListing]:
46 + html = self.get(LISTINGS).text
47 + listings: list[PropertyListing] = []
48 + for pid, block in _CARD_RE.findall(html):
49 + lst = self._to_listing(pid, block)
50 + if lst is not None:
51 + listings.append(lst)
52 + # fiche détail : galerie complète + description
53 + du.enrich(self, listings, DETAIL_LIMIT, parse_kw_detail, key="v2")
54 + return listings
55 +
56 + def _to_listing(self, pid: str, block: str) -> PropertyListing | None:
57 + addr = _txt((_H5_RE.search(block) or [None, ""])[1] if _H5_RE.search(block) else "")
58 + m_addr = _H5_RE.search(block)
59 + addr = _txt(m_addr.group(1)) if m_addr else ""
60 + m_price = _PRICE_RE.search(block)
61 + price_label = _txt(m_price.group(1)) if m_price else ""
62 + m_sub = _SUB_RE.search(block)
63 + sub = _txt(m_sub.group(1)) if m_sub else "" # "Blainville, Laurentides J7B1M1"
64 + city = region = ""
65 + if sub:
66 + parts = [p.strip() for p in sub.split(",")]
67 + city = parts[0]
68 + if len(parts) > 1:
69 + # « Laurentides J7B1M1 » -> retirer le code postal
70 + region = re.sub(r"\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d\s*$", "", parts[1]).strip()
71 + beds = _BED_RE.search(block)
72 + baths = _BATH_RE.search(block)
73 + return PropertyListing(
74 + source=self.source_id,
75 + external_id=pid,
76 + url=f"{BASE}/inscriptions/inscription/{pid}",
77 + title=addr,
78 + address=addr,
79 + city=city,
80 + region=region,
81 + price=parse_price(price_label),
82 + price_label=price_label,
83 + bedrooms=int(beds.group(1)) if beds else None,
84 + bathrooms=int(baths.group(1)) if baths else None,
85 + images=[IMG_TMPL.format(id=pid, n=1)],
86 + broker_name="Keller Williams Urbain",
87 + )
88 +
89 +
90 +def parse_kw_detail(html: str) -> dict:
91 + """Galerie complète + description (balise meta) de la fiche KW."""
92 + out: dict = {}
93 + seen, imgs = set(), []
94 + for u in _KW_IMG_RE.findall(html):
95 + if u not in seen:
96 + seen.add(u)
97 + imgs.append(u)
98 + if imgs:
99 + out["images"] = imgs
100 + m = _META_DESC_RE.search(html)
101 + if m:
102 + d = _html.unescape(m.group(1)).strip()
103 + if d and not d.lower().startswith(("keller williams", "trouvez")):
104 + out["description"] = d
105 + _det = du.centris_details(du.flatten(html))
106 + if _det:
107 + out.setdefault("details", {}).update(_det)
108 + return out
added immoka/connectors/m_immobilier.py +138 −0
@@ -0,0 +1,138 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/m_immobilier.py : M Immobilier (mimmobilier.com)
5 +# Agence indépendante de prestige (Grand Montréal), inscriptions Centris.
6 +# La page /properties est rendue serveur : chaque carte « cardProperty »
7 +# porte l'URL (avec no Centris), la ville, l'adresse, chambres, salles de
8 +# bains, prix et les photos (/images/centris-slideshow/{id}-N-*.jpg).
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as _html
13 +import os
14 +import re
15 +
16 +from .base import BaseConnector
17 +from . import _detailutil as du
18 +from ..normalize import parse_price
19 +from ..schema import PropertyListing
20 +
21 +BASE = "https://www.mimmobilier.com"
22 +LISTING_URL = f"{BASE}/properties"
23 +CARD_SPLIT = "cardProperty col-span-12"
24 +HREF_RE = re.compile(r'href="(/properties/[^"]+/(\d+))"')
25 +IMG_RE = re.compile(r'(/images/centris-slideshow/\d+-\d+-\d+\.(?:jpg|jpeg|png|webp))', re.I)
26 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_M_DETAIL_LIMIT", "300"))
27 +
28 +
29 +class MImmobilierConnector(BaseConnector):
30 + source_id = "m_immobilier"
31 + request_delay = 0.6
32 + use_detail_cache = False
33 +
34 + def fetch(self) -> list[PropertyListing]:
35 + html = self.get(LISTING_URL).text
36 + cards = html.split(CARD_SPLIT)[1:]
37 + out: dict[str, PropertyListing] = {}
38 + for card in cards:
39 + card = CARD_SPLIT + card[:6000]
40 + lst = self._parse_card(card)
41 + if lst and lst.uid not in out:
42 + out[lst.uid] = lst
43 + listings = list(out.values())
44 + # fiche détail : galerie complète (135 photos) + description + type
45 + du.enrich(self, listings, DETAIL_LIMIT, parse_m_detail, key="v2")
46 + return listings
47 +
48 + def _parse_card(self, card: str) -> PropertyListing | None:
49 + m = HREF_RE.search(card)
50 + if not m:
51 + return None
52 + url = BASE + m.group(1)
53 + external_id = m.group(2)
54 +
55 + images = []
56 + for im in IMG_RE.findall(card):
57 + full = BASE + im
58 + if full not in images:
59 + images.append(full)
60 +
61 + # texte du carton, ligne par ligne
62 + text = _html.unescape(re.sub(r"<[^>]+>", "\n", card))
63 + lines = [l.strip() for l in text.splitlines() if l.strip()]
64 +
65 + status_txt = lines[1] if len(lines) > 1 else ""
66 + # ville et adresse : les 2 lignes après le statut « À vendre »
67 + city = address = ""
68 + try:
69 + k = next(i for i, l in enumerate(lines)
70 + if l.lower().startswith(("à vendre", "a vendre", "vendu")))
71 + status_txt = lines[k]
72 + city = lines[k + 1] if k + 1 < len(lines) else ""
73 + address = lines[k + 2] if k + 2 < len(lines) else ""
74 + except StopIteration:
75 + pass
76 +
77 + price_label = _after(lines, "prix")
78 + if price_label and "/ m" in price_label.lower():
79 + return None # location, pas une vente
80 + price = parse_price(price_label or "")
81 + beds = _int(_after(lines, "chambres"))
82 + baths = _int(_after(lines, "salles de bains"))
83 +
84 + vendu = "vendu" in status_txt.lower()
85 + return PropertyListing(
86 + source=self.source_id,
87 + external_id=external_id,
88 + url=url,
89 + title=f"{address}, {city}".strip(", "),
90 + address=address,
91 + city=city,
92 + property_type="", # non exposé sur le carton
93 + price=price,
94 + price_label=price_label or "",
95 + bedrooms=beds,
96 + bathrooms=baths,
97 + mls=external_id,
98 + status="vendu" if vendu else "a-vendre",
99 + images=images,
100 + broker_name="M Immobilier",
101 + )
102 +
103 +
104 +def parse_m_detail(html: str) -> dict:
105 + """Galerie Centris complète + description (JSON-LD) + caractéristiques."""
106 + out: dict = {}
107 + imgs = []
108 + seen = set()
109 + for im in IMG_RE.findall(html):
110 + full = BASE + im
111 + if full not in seen:
112 + seen.add(full)
113 + imgs.append(full)
114 + if imgs:
115 + out["images"] = imgs
116 + desc = du.ld_description(html)
117 + if desc:
118 + out["description"] = desc
119 + _det = du.centris_details(du.flatten(html))
120 + if _det:
121 + out.setdefault("details", {}).update(_det)
122 + return out
123 +
124 +
125 +def _after(lines: list[str], label: str) -> str:
126 + lab = label.lower()
127 + for i, l in enumerate(lines):
128 + if l.lower() == lab and i + 1 < len(lines):
129 + return lines[i + 1]
130 + return ""
131 +
132 +
133 +def _int(s: str):
134 + if not s:
135 + return None
136 + # « 4 + 1 » -> 5 (chambres principales + sous-sol)
137 + nums = [int(x) for x in re.findall(r"\d+", s)]
138 + return sum(nums) if nums else None
added immoka/connectors/profusion.py +168 −0
@@ -0,0 +1,168 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/profusion.py : Profusion Immobilier (luxe, affilié Forbes/Christie's)
5 +# Énumération : la page « nos-inscriptions » liste toutes les fiches en HTML
6 +# rendu serveur, sous forme /propriete/{no-centris}/. Chaque fiche publie un
7 +# JSON-LD RealEstateListing (adresse, description, prix, courtier) + un bloc
8 +# de specs « Libellé: valeur » + une galerie profusionimmo.ca.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import html as _html
13 +import json
14 +import os
15 +import re
16 +
17 +from .base import BaseConnector
18 +from . import _detailutil as du
19 +from ..schema import PropertyListing
20 +
21 +SITE = "https://www.profusion.global"
22 +LISTING_PAGE = f"{SITE}/acheter/nos-inscriptions/"
23 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_PROFUSION_DETAIL_LIMIT", "500"))
24 +
25 +_PROP_RE = re.compile(r'/propriete/(\d{5,})/')
26 +_IMG_RE = re.compile(r'https://profusionimmo\.ca/public/user_files/proprietes/fiche/\d+-\d+\.jpg', re.I)
27 +# nom JSON-LD : « {Type} à vendre à {Ville (Secteur)} – {Adresse civique} »
28 +_NAME_RE = re.compile(r'^(.*?)\s+à vendre à\s+(.+?)\s*$', re.I)
29 +_SPEC_RE = re.compile(r'([A-Za-zÀ-ÿ\'’()/ ]{3,40})\s*:\s*([^<|]{1,60})')
30 +
31 +
32 +class ProfusionConnector(BaseConnector):
33 + source_id = "profusion"
34 + request_delay = 0.5
35 + use_detail_cache = True
36 +
37 + def fetch(self) -> list[PropertyListing]:
38 + try:
39 + html = self.get(LISTING_PAGE).text
40 + except Exception:
41 + return []
42 + ids: list[str] = []
43 + seen = set()
44 + for m in _PROP_RE.finditer(html):
45 + if m.group(1) not in seen:
46 + seen.add(m.group(1))
47 + ids.append(m.group(1))
48 + listings = [PropertyListing(source=self.source_id, external_id=cid,
49 + url=f"{SITE}/propriete/{cid}/", mls=cid,
50 + broker_name="Profusion Immobilier")
51 + for cid in ids]
52 + self._enrich(listings)
53 + return listings
54 +
55 + def _enrich(self, listings: list[PropertyListing]) -> None:
56 + from .. import db
57 + con = db.connect()
58 + budget = DETAIL_LIMIT
59 + try:
60 + for lst in listings:
61 + d = db.get_cached_detail(con, self.source_id, lst.external_id, "v1")
62 + if d is None:
63 + if budget <= 0:
64 + continue
65 + try:
66 + d = parse_profusion_detail(self.get(lst.url).text)
67 + except Exception:
68 + d = {}
69 + db.put_cached_detail(con, self.source_id, lst.external_id, "v1", d)
70 + budget -= 1
71 + _apply(lst, d)
72 + finally:
73 + con.close()
74 +
75 +
76 +def parse_profusion_detail(html: str) -> dict:
77 + out: dict = {}
78 + title = ""
79 + for n in du.ld_nodes(html):
80 + t = n.get("@type")
81 + types = t if isinstance(t, list) else [t]
82 + if "RealEstateListing" in types:
83 + title = _html.unescape(n.get("name", "")).strip()
84 + if n.get("description"):
85 + out["description"] = _html.unescape(str(n["description"])).strip()
86 + offer = n.get("offers") or {}
87 + price = offer.get("price")
88 + try:
89 + price = float(price)
90 + if price >= 10_000:
91 + out["price"] = price
92 + except (TypeError, ValueError):
93 + pass
94 + seller = offer.get("seller") or {}
95 + if seller.get("name"):
96 + out["broker_name"] = seller["name"]
97 + if seller.get("telephone"):
98 + out["broker_phone"] = str(seller["telephone"])
99 + break
100 + if title:
101 + out["title"] = title
102 + m = _NAME_RE.search(title)
103 + if m:
104 + type_phrase, loc = m.group(1).strip(), m.group(2).strip()
105 + out["property_type"] = type_phrase # « Maison », « Terrain », « Condo »…
106 + # séparer « Ville (Secteur) » de l'adresse civique (après le tiret)
107 + parts = re.split(r"\s+[–—-]\s+", loc, maxsplit=1)
108 + city_block = parts[0].strip()
109 + if len(parts) > 1:
110 + out["address"] = parts[1].strip()
111 + ps = re.search(r"\(([^)]+)\)", city_block)
112 + out["city"] = re.sub(r"\s*\([^)]*\)", "", city_block).strip()
113 + if ps:
114 + out["sector"] = ps.group(1).strip()
115 +
116 + # galerie complète
117 + imgs, seen = [], set()
118 + for u in _IMG_RE.findall(html):
119 + if u not in seen:
120 + seen.add(u)
121 + imgs.append(u)
122 + if imgs:
123 + out["images"] = imgs
124 +
125 + # specs « Libellé: valeur »
126 + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
127 + details = {}
128 + for label, rx in (("Type de propriété", r"Type de propri[ée]t[ée]\s*:\s*([^:]{2,40}?)(?:\s{2,}|Chambres|Ann[ée]e|Superficie|$)"),
129 + ("Chambres", r"Chambres\s*:\s*(\d+)"),
130 + ("Salles de bain", r"Salles? de bain\s*:\s*(\d+)"),
131 + ("Année de construction", r"Ann[ée]e\s*:\s*(\d{4})"),
132 + ("Superficie habitable", r"Superficie\s*:\s*([\d ,]+\s*(?:pi²|pi2|)?)")):
133 + m = re.search(rx, text, re.I)
134 + if m:
135 + details[label] = m.group(1).strip()
136 + ptype = details.get("Type de propriété", "")
137 + if ptype:
138 + out["property_type"] = ptype
139 + if details.get("Chambres"):
140 + out["bedrooms"] = int(re.search(r"\d+", details["Chambres"]).group())
141 + if details.get("Salles de bain"):
142 + out["bathrooms"] = int(re.search(r"\d+", details["Salles de bain"]).group())
143 + if details.get("Année de construction") and details["Année de construction"] != "0":
144 + out["year_built"] = int(details["Année de construction"])
145 + if details:
146 + out["details"] = {k: v for k, v in details.items() if v and v != "0"}
147 + return out
148 +
149 +
150 +def _apply(lst: PropertyListing, d: dict) -> None:
151 + if not d:
152 + return
153 + # le vrai courtier (fiche) remplace le placeholder « Profusion Immobilier »
154 + if d.get("broker_name"):
155 + lst.broker_name = d["broker_name"]
156 + for f in ("title", "address", "city", "sector", "property_type",
157 + "description", "broker_phone"):
158 + if d.get(f) and not getattr(lst, f, ""):
159 + setattr(lst, f, d[f])
160 + if not lst.address and d.get("title"):
161 + lst.address = d["title"]
162 + for f in ("price", "bedrooms", "bathrooms", "year_built"):
163 + if d.get(f) is not None and getattr(lst, f, None) in (None, 0):
164 + setattr(lst, f, d[f])
165 + if d.get("images") and len(d["images"]) > len(lst.images):
166 + lst.images = d["images"]
167 + if d.get("details"):
168 + lst.details.update(d["details"])
added immoka/connectors/proprio_direct.py +140 −0
@@ -0,0 +1,140 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/proprio_direct.py : Proprio Direct (propriodirect.com)
5 +# L'inventaire (alimenté par le feed DDF de CREA) est servi par une API JSON
6 +# interne : POST https://propriodirect.com/fr/api/searchListings
7 +# Corps { "from": <offset> } ; 30 résultats/page, champ `total`. Chaque item
8 +# contient adresse, ville, région, prix, chambres, sdb, galerie photos et slug.
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +import os
13 +
14 +from .base import BaseConnector
15 +from . import _detailutil as du
16 +from ..schema import PropertyListing
17 +
18 +SITE = "https://propriodirect.com"
19 +SEARCH_URL = f"{SITE}/fr/api/searchListings"
20 +PAGE_SIZE = 30
21 +MAX_PAGES = 300
22 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_PD_DETAIL_LIMIT", "400"))
23 +
24 +# tags source (feed DDF) -> caractéristiques lisibles
25 +_TAG_LABELS = {
26 + "car_garage": "Garage", "car_parking": "Stationnement", "car_foyer": "Foyer",
27 + "car_piscine": "Piscine", "car_climatisation": "Climatisation",
28 + "car_sous_sol": "Sous-sol", "car_bord_eau": "Bord de l'eau",
29 + "prox_park": "Parc à proximité", "prox_ecole_primaire": "École primaire",
30 + "prox_ecole_secondaire": "École secondaire", "prox_transport": "Transport en commun",
31 + "prox_garderie": "Garderie", "prox_piste_cyclable": "Piste cyclable",
32 + "prox_hopital": "Hôpital", "prox_autoroute": "Autoroute",
33 + "new_price": "Nouveau prix",
34 +}
35 +
36 +# genre (code CREA/DDF) -> vocabulaire brut (finalize() normalise)
37 +_GENRE = {
38 + "PP": "Maison", "ET": "Maison", "MA": "Maison", "PL": "Maison",
39 + "CO": "Condo", "AP": "Condo", "LO": "Condo",
40 + "DX": "Duplex", "TX": "Triplex", "QX": "Quadruplex", "MX": "Multiplex",
41 + "TE": "Terrain", "TR": "Terrain",
42 + "CH": "Chalet", "FE": "Fermette/Agricole", "FR": "Fermette/Agricole",
43 + "CM": "Commercial", "IN": "Commercial",
44 +}
45 +
46 +
47 +class ProprioDirectConnector(BaseConnector):
48 + source_id = "proprio_direct"
49 + request_delay = 0.4
50 + use_detail_cache = False
51 +
52 + def _headers(self) -> dict:
53 + return {
54 + "Accept": "application/json",
55 + "Content-Type": "application/json",
56 + "Origin": SITE,
57 + "Referer": f"{SITE}/propriete-a-vendre/",
58 + }
59 +
60 + def fetch(self) -> list[PropertyListing]:
61 + out: list[PropertyListing] = []
62 + seen: set[str] = set()
63 + offset = 0
64 + for _ in range(MAX_PAGES):
65 + try:
66 + data = self.post(SEARCH_URL, headers=self._headers(),
67 + json={"from": offset,
68 + "includeGeoJson": False}).json()
69 + except Exception:
70 + break
71 + items = data.get("listings", []) if isinstance(data, dict) else []
72 + if not items:
73 + break
74 + for it in items:
75 + lst = self._to_listing(it)
76 + if lst and lst.uid not in seen:
77 + seen.add(lst.uid)
78 + out.append(lst)
79 + total = data.get("total") or 0
80 + offset += PAGE_SIZE
81 + if offset >= total:
82 + break
83 + # fiche détail : description (galerie déjà complète via l'API)
84 + du.enrich(self, out, DETAIL_LIMIT, _parse_pd_detail, key="v2")
85 + return out
86 +
87 + def _to_listing(self, it: dict) -> PropertyListing | None:
88 + rid = it.get("id")
89 + if not rid or it.get("sold") or not it.get("forSale", True):
90 + return None
91 + cents = it.get("priceInCents")
92 + price = (cents / 100.0) if isinstance(cents, (int, float)) and cents > 0 else None
93 + slug = it.get("slugURLFr") or it.get("slugURL") or ""
94 + url = f"{SITE}{slug}" if slug.startswith("/") else (slug or SITE)
95 + geo = it.get("geoLocation") or {}
96 +
97 + return PropertyListing(
98 + source=self.source_id,
99 + external_id=str(rid),
100 + url=url,
101 + title=it.get("addressLine", ""),
102 + address=it.get("addressLine", ""),
103 + sector=it.get("cityNeighbourhoodText", "") or "",
104 + city=it.get("cityName", ""),
105 + region=it.get("regionName", ""),
106 + property_type=_GENRE.get((it.get("genre") or "").upper(),
107 + it.get("genreName", "")),
108 + price=price,
109 + price_label=it.get("priceText", "") or "",
110 + bedrooms=_pos_int(it.get("numberOfRooms")),
111 + bathrooms=_pos_int(it.get("numberOfBathrooms")),
112 + mls=str(rid),
113 + images=list(it.get("photosOriginal") or it.get("photosSmall") or []),
114 + lat=geo.get("lat"),
115 + lng=geo.get("lon"),
116 + broker_name="Proprio Direct",
117 + features=[_TAG_LABELS[t] for t in (it.get("tags") or []) if t in _TAG_LABELS],
118 + details={"postal_code": it.get("postalCode", "")},
119 + )
120 +
121 +
122 +def _parse_pd_detail(html: str) -> dict:
123 + """Fiche Proprio Direct : description + caractéristiques Centris détaillées
124 + (photos déjà fournies par l'API)."""
125 + out: dict = {}
126 + desc = du.ld_description(html)
127 + if desc:
128 + out["description"] = desc
129 + det = du.centris_details(du.flatten(html))
130 + if det:
131 + out.setdefault("details", {}).update(det)
132 + return out
133 +
134 +
135 +def _pos_int(v):
136 + try:
137 + n = int(v)
138 + return n if n > 0 else None
139 + except (TypeError, ValueError):
140 + return None
added immoka/connectors/remax_agences.py +243 −0
@@ -0,0 +1,243 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/remax_agences.py : BACKUP RE/MAX — un connecteur par sous-agence
5 +#
6 +# Les ~54 bannières RE/MAX du Québec (RE/MAX 1er Choix, du Cartier, 3000,
7 +# Platine…) ont chacune leur SITE PROPRE, indépendant du portail central
8 +# remax-quebec.com. La majorité (42) partagent la même plateforme Centris
9 +# (« /fr/nos-proprietes/{ville}/{adresse}/{no-centris} », rendu serveur,
10 +# sitemap.xml exhaustif). C'est un plan B si la clé Meilisearch centrale
11 +# (voir remax_quebec.py) est rotée ou bloquée.
12 +#
13 +# Une CLASSE PAR AGENCE est générée dynamiquement depuis
14 +# data/remax_agencies.json (source_id « remax_ag_<slug> »). L'énumération
15 +# se fait via le sitemap (1 requête → toutes les propriétés) ; le numéro
16 +# Centris (dans l'URL) sert de clé de déduplication contre le flux central
17 +# (voir la fusion dédupliquée dans immoka/web.py) — aucun double-comptage.
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import json
22 +import os
23 +import re
24 +from pathlib import Path
25 +
26 +from .base import BaseConnector
27 +from ..schema import PropertyListing
28 +
29 +REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "remax_agencies.json"
30 +
31 +# Enrichissement page détail (prix/chambres/photos) : coûteux (1 requête par
32 +# propriété). Désactivé par défaut : le flux central est déjà enrichi et ces
33 +# fiches sont dédoublonnées contre lui. Activable par agence si le central tombe.
34 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_AGENCE_DETAIL_LIMIT", "0"))
35 +
36 +_PROP_RE = re.compile(r'/fr/nos-proprietes/([a-z0-9-]+)/([^<"/]+)/(\d+)', re.I)
37 +# 2e plateforme Centris (« centris-other ») : sitemap_inscriptions.xml, URL
38 +# /fr/proprietes/{type-ville-secteur}/{adresse}-{no-centris}
39 +_PROP_RE2 = re.compile(r'/fr/proprietes/([a-z0-9-]+)/([a-z0-9-]+?)-(\d{6,})(?:/)?(?=["<]|$)', re.I)
40 +# préfixes de type de propriété (les plus longs d'abord) pour découper le 1er segment
41 +_TYPE_PREFIXES = [
42 + "batisse-commerciale-bureau", "chalet-et-maison-de-campagne",
43 + "maison-a-paliers-multiples", "maison-de-plain-pied", "maison-a-etages",
44 + "propriete-a-revenus", "local-commercial", "fond-de-commerce",
45 + "terre-agricole", "maison-mobile", "quadruplex", "quintuplex",
46 + "appartement", "condominium", "fermette", "triplex", "duplex", "chalet",
47 + "terrain", "maison", "condo", "commerce", "bureau", "industriel", "ferme",
48 + "garage", "loft", "terre",
49 +]
50 +_IMG_RE = re.compile(
51 + r'https://media\.remax-quebec\.com/img/(?:fiche|www_full|www_large|v4thumbs)/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
52 + re.I)
53 +_IMG_SIZE_RE = re.compile(r'/img/(?:fiche|www_[a-z]+|v4thumbs)/', re.I)
54 +
55 +
56 +class RemaxAgenceConnector(BaseConnector):
57 + """Base des connecteurs de sous-agences RE/MAX (plateforme « nos-proprietes »).
58 +
59 + Les sous-classes (générées plus bas) définissent `source_id`, `domain`
60 + et `agency_name`.
61 + """
62 +
63 + domain: str = ""
64 + agency_name: str = ""
65 + platform: str = "nos-proprietes" # ou "centris-other"
66 + request_delay = 0.4
67 + use_detail_cache = True
68 +
69 + def fetch(self) -> list[PropertyListing]:
70 + urls = self._sitemap_property_urls()
71 + by_id: dict[str, PropertyListing] = {}
72 + for url in urls:
73 + lst = self._to_listing(url)
74 + if lst is not None:
75 + by_id[lst.external_id] = lst
76 + if DETAIL_LIMIT:
77 + self._enrich(list(by_id.values()))
78 + return list(by_id.values())
79 +
80 + def _sitemap_url(self) -> str:
81 + return ("sitemap_inscriptions.xml" if self.platform == "centris-other"
82 + else "sitemap.xml")
83 +
84 + def _sitemap_property_urls(self) -> list[str]:
85 + sm = self._sitemap_url()
86 + xml = ""
87 + for host in (self.domain, f"www.{self.domain}"):
88 + try:
89 + xml = self.get(f"https://{host}/{sm}").text
90 + if xml:
91 + break
92 + except Exception:
93 + continue
94 + if not xml:
95 + return []
96 + rx = _PROP_RE2 if self.platform == "centris-other" else _PROP_RE
97 + seen, out = set(), []
98 + for m in rx.finditer(xml):
99 + centris = m.groups()[-1]
100 + if centris not in seen:
101 + seen.add(centris)
102 + out.append(m.group(0))
103 + return out
104 +
105 + def _to_listing(self, path: str) -> PropertyListing | None:
106 + if self.platform == "centris-other":
107 + return self._to_listing_centris(path)
108 + m = _PROP_RE.search(path)
109 + if not m:
110 + return None
111 + city_slug, addr_slug, centris = m.group(1), m.group(2), m.group(3)
112 + url = f"https://{self.domain}{path if path.startswith('/') else '/' + path}"
113 + city, sector = _split_city(city_slug)
114 + return PropertyListing(
115 + source=self.source_id, external_id=centris, url=url,
116 + title=_deslug(addr_slug), address=_deslug(addr_slug),
117 + city=city, sector=sector, mls=centris, broker_name=self.agency_name,
118 + )
119 +
120 + def _to_listing_centris(self, url: str) -> PropertyListing | None:
121 + m = _PROP_RE2.search(url)
122 + if not m:
123 + return None
124 + seg1, addr_slug, centris = m.group(1), m.group(2), m.group(3)
125 + ptype, rest = _split_type(seg1) # type + « ville-secteur »
126 + city, sector = _split_city(rest)
127 + return PropertyListing(
128 + source=self.source_id, external_id=centris,
129 + url=url if url.startswith("http") else f"https://{self.domain}{url}",
130 + title=_deslug(addr_slug), address=_deslug(addr_slug),
131 + property_type=ptype, city=city, sector=sector,
132 + mls=centris, broker_name=self.agency_name,
133 + )
134 +
135 + # -- enrichissement optionnel (prix/chambres/photos) ----------------------
136 + def _enrich(self, listings: list[PropertyListing]) -> None:
137 + from .. import db
138 + con = db.connect()
139 + budget = DETAIL_LIMIT
140 + for lst in listings:
141 + cached = db.get_cached_detail(con, self.source_id, lst.external_id, "d")
142 + if cached is None:
143 + if budget <= 0:
144 + continue
145 + cached = self._scrape_detail(lst.url)
146 + db.put_cached_detail(con, self.source_id, lst.external_id, "d", cached)
147 + budget -= 1
148 + _apply(lst, cached)
149 + con.close()
150 +
151 + def _scrape_detail(self, url: str) -> dict:
152 + try:
153 + html = self.get(url).text
154 + except Exception:
155 + return {}
156 + out: dict = {}
157 + seen, imgs = set(), []
158 + for u in _IMG_RE.findall(html):
159 + full = _IMG_SIZE_RE.sub("/img/www_full/", u)
160 + fn = full.rsplit("/", 1)[-1]
161 + if fn not in seen and "nophoto" not in full:
162 + seen.add(fn)
163 + imgs.append(full)
164 + if imgs:
165 + out["images"] = imgs
166 + import html as _h
167 + t = _h.unescape(re.sub(r"<[^>]+>", " ", html))
168 + t = re.sub(r"\s+", " ", t)
169 + mp = re.search(r"([\d ]{4,})\s*\$", t)
170 + if mp:
171 + out["price_label"] = mp.group(0).strip()
172 + mb = re.search(r"(\d+)\s*chambre", t, re.I)
173 + if mb:
174 + out["bedrooms"] = int(mb.group(1))
175 + ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", t, re.I)
176 + if ms:
177 + out["bathrooms"] = int(ms.group(1))
178 + return out
179 +
180 +
181 +def _apply(lst: PropertyListing, d: dict) -> None:
182 + if d.get("images"):
183 + lst.images = d["images"]
184 + if d.get("price_label") and not lst.price_label:
185 + lst.price_label = d["price_label"]
186 + for f in ("bedrooms", "bathrooms"):
187 + if d.get(f) is not None and getattr(lst, f, None) is None:
188 + setattr(lst, f, d[f])
189 +
190 +
191 +def _deslug(s: str) -> str:
192 + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
193 +
194 +
195 +def _split_type(seg1: str) -> tuple[str, str]:
196 + """« propriete-a-revenus-laval-laval-des-rapides » -> (type, « laval-... »)."""
197 + for pref in _TYPE_PREFIXES:
198 + if seg1.startswith(pref):
199 + return _deslug(pref), seg1[len(pref):].lstrip("-")
200 + # pas de type reconnu : tout est considéré comme localisation
201 + return "", seg1
202 +
203 +
204 +# quelques villes composées fréquentes dans les slugs de la plateforme
205 +def _split_city(slug: str) -> tuple[str, str]:
206 + parts = slug.split("-")
207 + city = parts[0].title() if parts else ""
208 + fixes = {"Quebec": "Québec", "Levis": "Lévis", "Montreal": "Montréal",
209 + "Trois": "Trois-Rivières", "St": "Saint"}
210 + city = fixes.get(city, city)
211 + sector = _deslug("-".join(parts[1:])) if len(parts) > 1 else ""
212 + return city, sector
213 +
214 +
215 +# ---------------------------------------------------------------------------
216 +# Génération d'une classe par sous-agence (plateforme « nos-proprietes »)
217 +# ---------------------------------------------------------------------------
218 +
219 +def _slugify_id(banner: str) -> str:
220 + return "remax_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.replace("remax-", "")).strip("_")
221 +
222 +
223 +def _load_agencies() -> list[dict]:
224 + try:
225 + return json.loads(REGISTRY.read_text(encoding="utf-8"))
226 + except Exception:
227 + return []
228 +
229 +
230 +# Crée dynamiquement RemaxAg<...> pour chaque agence dotée d'un site propre,
231 +# sur l'une des deux plateformes Centris reconnues (nos-proprietes / centris-other).
232 +for _ag in _load_agencies():
233 + _plat = _ag.get("platform")
234 + if _plat not in ("nos-proprietes", "centris-other") or not _ag.get("domain"):
235 + continue
236 + _dom = re.sub(r"^https?://", "", _ag["domain"]).rstrip("/")
237 + _sid = _slugify_id(_ag["banner"])
238 + _name = "RE/MAX " + _ag["banner"].replace("remax-", "").replace("-", " ").title()
239 + globals()[_sid.upper()] = type(
240 + "".join(p.title() for p in _sid.split("_")),
241 + (RemaxAgenceConnector,),
242 + {"source_id": _sid, "domain": _dom, "agency_name": _name, "platform": _plat},
243 + )
added immoka/connectors/remax_quebec.py +376 −0
@@ -0,0 +1,376 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/remax_quebec.py : RE/MAX Québec (remax-quebec.com)
5 +# Le site interroge un index Meilisearch public (search-only key exposée dans
6 +# la config de la page). L'index « inscriptions » couvre toute la province.
7 +# Meilisearch plafonne à 1000 hits par requête (maxTotalHits) : on shard donc
8 +# par région de tri d'acheminement postale (FSA, 3 premiers caractères) et on
9 +# dédoublonne par numéro d'inscription. Couverture validée vs le total global.
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import html as _html
14 +import os
15 +import re
16 +
17 +from .base import BaseConnector
18 +from ..normalize import parse_area_sqft, parse_price
19 +from ..schema import PropertyListing
20 +
21 +SEARCH_URL = "https://search.remax-quebec.com/indexes/inscriptions/search"
22 +# Clé de recherche (search-only) exposée publiquement dans la config du site.
23 +SEARCH_KEY = "b0b93998ab78573e8b937b528ad37d2ce3fbc97e07a9f2c909c4220db910c152"
24 +SITE = "https://www.remax-quebec.com"
25 +
26 +# Territoires postaux du Québec : préfixes G, H et J.
27 +FSA_LETTERS = ("G", "H", "J")
28 +SOLD_TOKENS = ("vendu", "sold", "loué", "loue", "rented")
29 +
30 +# Enrichissement page détail (photos + specs) : plafonné par exécution pour
31 +# garder chaque sync borné. Les fiches déjà en cache sont réutilisées gratis ;
32 +# seules les nouvelles/modifiées consomment le budget. Sur plusieurs cycles,
33 +# tout le parc finit enrichi. Surchargeable via IMMOKA_REMAX_DETAIL_LIMIT.
34 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_REMAX_DETAIL_LIMIT", "3000"))
35 +
36 +
37 +class RemaxQuebecConnector(BaseConnector):
38 + source_id = "remax_quebec"
39 + request_delay = 0.15 # API JSON rapide ; on reste poli
40 + use_detail_cache = True # cache BD des pages détail (photos/specs)
41 +
42 + def fetch(self) -> list[PropertyListing]:
43 + by_id: dict[int, dict] = {}
44 + for fsa in self._fsa_candidates():
45 + hits = self._search(fsa)
46 + for h in hits:
47 + nid = h.get("no_inscription")
48 + if nid is not None:
49 + by_id[nid] = h # dédoublonnage inter-shards
50 + listings = []
51 + for h in by_id.values():
52 + lst = self._to_listing(h)
53 + if lst is not None:
54 + listings.append(lst)
55 + self._enrich(listings)
56 + return listings
57 +
58 + # -- enrichissement (photos + specs via la page détail) -------------------
59 + def _enrich(self, listings: list[PropertyListing]) -> None:
60 + from .. import db
61 + con = db.connect()
62 + budget = DETAIL_LIMIT
63 + for lst in listings:
64 + key = f"v2|{lst.price_label or '-'}" # v2 = parser enrichi ; refetch si prix change
65 + cached = db.get_cached_detail(con, self.source_id, lst.external_id, key)
66 + if cached is None:
67 + if budget <= 0:
68 + continue # enrichi à un prochain cycle
69 + cached = self._scrape_detail(lst.url)
70 + db.put_cached_detail(con, self.source_id, lst.external_id, key, cached)
71 + budget -= 1
72 + _apply_detail(lst, cached)
73 + con.close()
74 +
75 + def _scrape_detail(self, url: str) -> dict:
76 + try:
77 + html = self.get(url).text
78 + except Exception:
79 + return {}
80 + return parse_remax_detail(html)
81 +
82 + # -- sharding --------------------------------------------------------------
83 + @staticmethod
84 + def _fsa_candidates():
85 + for letter in FSA_LETTERS:
86 + for digit in "0123456789":
87 + for last in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
88 + yield f"{letter}{digit}{last}"
89 +
90 + def _search(self, q: str) -> list[dict]:
91 + try:
92 + resp = self.post(
93 + SEARCH_URL,
94 + headers={"Authorization": f"Bearer {SEARCH_KEY}",
95 + "Content-Type": "application/json"},
96 + json={"q": q, "limit": 1000},
97 + )
98 + except Exception:
99 + return []
100 + data = resp.json()
101 + return data.get("hits", []) if isinstance(data, dict) else []
102 +
103 + # -- mapping ---------------------------------------------------------------
104 + def _to_listing(self, h: dict) -> PropertyListing | None:
105 + nid = h.get("no_inscription")
106 + if nid is None:
107 + return None
108 + price_label = (h.get("display_price") or {}).get("fr", "") or ""
109 + # exclure les propriétés vendues / retirées (l'index les conserve)
110 + if any(tok in price_label.lower() for tok in SOLD_TOKENS):
111 + return None
112 +
113 + slug = (h.get("slug") or {}).get("fr", "") or ""
114 + url = f"{SITE}/fr/proprietes/{slug}" if slug else f"{SITE}/fr"
115 + full_addr = (h.get("full_address") or {}).get("fr", "") or ""
116 + address, sector, city = _split_address(full_addr)
117 + prop_type, region = _from_slug(slug)
118 +
119 + return PropertyListing(
120 + source=self.source_id,
121 + external_id=str(nid),
122 + url=url,
123 + title=full_addr,
124 + address=address,
125 + sector=sector,
126 + city=city,
127 + region=region,
128 + property_type=prop_type,
129 + price=parse_price(price_label),
130 + price_label=price_label,
131 + broker_name="RE/MAX Québec",
132 + )
133 +
134 +
135 +# ---------------------------------------------------------------------------
136 +# Analyse de l'adresse et du slug
137 +# ---------------------------------------------------------------------------
138 +
139 +_PAREN_RE = re.compile(r"\(([^)]*)\)")
140 +_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)
141 +
142 +
143 +def _split_address(full: str) -> tuple[str, str, str]:
144 + """« 16146 Rue Forsyth, Montréal (Rivière-des-Prairies) (X), H1A5S9 »
145 + -> (adresse, secteur, ville)."""
146 + if not full:
147 + return "", "", ""
148 + parts = [p.strip() for p in full.split(",")]
149 + # retirer le code postal final
150 + if parts and _POSTAL_RE.search(parts[-1]):
151 + parts = parts[:-1]
152 + address = parts[0] if parts else ""
153 + city = sector = ""
154 + if len(parts) >= 2:
155 + muni = parts[1]
156 + parens = _PAREN_RE.findall(muni)
157 + city = _PAREN_RE.sub("", muni).strip()
158 + if parens:
159 + sector = parens[-1].strip()
160 + # secteur supplémentaire dans les champs suivants (avant le postal)
161 + for extra in parts[2:]:
162 + e = _PAREN_RE.sub("", extra).strip() or extra.strip()
163 + if e and not sector:
164 + sector = e
165 + return address, sector, city
166 +
167 +
168 +_TYPE_SLUG = {
169 + "maison": "Maison", "house": "Maison",
170 + "condo": "Condo", "appartement": "Condo", "apartment": "Condo",
171 + "plex": "Multiplex", "duplex": "Duplex", "triplex": "Triplex",
172 + "terrain": "Terrain", "land": "Terrain",
173 + "chalet": "Chalet", "cottage": "Chalet",
174 + "fermette": "Fermette/Agricole", "ferme": "Fermette/Agricole",
175 + "commercial": "Commercial", "commerciale": "Commercial",
176 +}
177 +
178 +
179 +# ---------------------------------------------------------------------------
180 +# Page détail : photos + description + caractéristiques
181 +# ---------------------------------------------------------------------------
182 +
183 +_LD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I)
184 +# Les photos apparaissent dans plusieurs buckets de taille (www_full, _medium,
185 +# _small…) selon le lazy-load ; on les capte toutes et on les normalise en
186 +# pleine résolution, dédoublonnées par nom de fichier.
187 +_IMG_RE = re.compile(
188 + r'https://media\.remax-quebec\.com/img/www_[a-z]+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
189 + re.I)
190 +_IMG_SIZE_RE = re.compile(r'/www_[a-z]+/', re.I)
191 +# Dans la section caractéristiques, la valeur précède le libellé :
192 +# « 2 (1 + 1) | Chambres », « 1 | Salle de bain », « 1975 | Année de construction »
193 +_SPEC_RE = {
194 + "bedrooms": re.compile(r'(\d+)(?:\s*\([^)]*\))?\s*\|\s*Chambre', re.I),
195 + "bathrooms": re.compile(r'(\d+)\s*\|\s*Salle de bain', re.I),
196 + "powder_rooms": re.compile(r"(\d+)\s*\|\s*Salle d'eau", re.I),
197 + "year_built": re.compile(r'(\d{4})\s*\|\s*Ann[ée]+e de construction', re.I),
198 +}
199 +_AREA_RE = re.compile(r'([\d ,]+)\s*(|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I)
200 +_LOT_RE = re.compile(r'([\d ,]+)\s*(|mc|pi²|pi|ft)\s*\|\s*Superficie du terrain', re.I)
201 +_COORD_RE = re.compile(r'query=(-?\d+\.\d+)%2C\+?(-?\d+\.\d+)')
202 +_ROOM_RE = re.compile(r'rooms-details-section__vertical-table[^>]*>(.*?)</div>\s*</div>', re.S)
203 +_QSTAT_RE = re.compile(
204 + r'quick-stat-text[^>]*>\s*<span[^>]*>\s*(.*?)\s*</span>\s*<span[^>]*>\s*(.*?)\s*</span>', re.S)
205 +_INCL_RE = re.compile(r'inclusions-exclusions-section(.*?)(?:</section>|realtor-section|financial-section)', re.S)
206 +_PHONE_RE = re.compile(r'\d{3}[\s ]\d{3}-\d{4}')
207 +
208 +# Ensemble de libellés Centris standard capturés dans le tableau « valeur | libellé ».
209 +_DETAIL_LABELS = [
210 + "Type de propriété", "Genre de propriété", "Style de bâtiment",
211 + "Année de construction", "Nombre d'unités", "Superficie du bâtiment (au sol)",
212 + "Superficie du terrain", "Superficie habitable", "Stationnement (total)",
213 + "Système de chauffage", "Énergie pour le chauffage", "Fenêtres", "Type de fenestration",
214 + "Toiture", "Revêtement", "Sous-sol", "Piscine", "Garage", "Zonage",
215 + "Système d'égouts", "Approvisionnement en eau", "Déménagement",
216 + "Taxes municipales", "Taxes scolaires", "Évaluation municipale (terrain)",
217 + "Évaluation municipale (bâtiment)", "Cuisine", "Salle de bain / Salle d'eau",
218 +]
219 +
220 +
221 +def _flatten(html: str) -> str:
222 + t = _html.unescape(re.sub(r'<[^>]+>', ' | ', html))
223 + t = re.sub(r'[ \t\r\n]*\|[ \t\r\n|]*', ' | ', t)
224 + return re.sub(r'[ \t]+', ' ', t)
225 +
226 +
227 +def parse_remax_detail(html: str) -> dict:
228 + """Extrait TOUT le contenu d'une page détail RE/MAX : photos, description,
229 + coordonnées, caractéristiques complètes, pièces (dimensions), inclusions,
230 + taxes/évaluation, courtier. Objectif : rien perdre par rapport à la fiche source."""
231 + import json
232 + out: dict = {}
233 + details: dict = {}
234 +
235 + # description via JSON-LD RealEstateListing
236 + for block in _LD_RE.findall(html):
237 + try:
238 + data = json.loads(block)
239 + except ValueError:
240 + continue
241 + for node in (data if isinstance(data, list) else [data]):
242 + if isinstance(node, dict) and node.get("@type") == "RealEstateListing" and node.get("description"):
243 + out["description"] = _html.unescape(node["description"]).strip()
244 +
245 + # photos : toutes tailles → pleine résolution, dédoublonnées par nom de fichier
246 + seen, images = set(), []
247 + for u in _IMG_RE.findall(html):
248 + full = _IMG_SIZE_RE.sub("/www_full/", u)
249 + fn = full.rsplit("/", 1)[-1]
250 + if fn not in seen and "nophoto" not in full:
251 + seen.add(fn)
252 + images.append(full)
253 + if images:
254 + out["images"] = images
255 +
256 + # coordonnées GPS (lien Google Maps)
257 + m = _COORD_RE.search(html)
258 + if m:
259 + out["lat"], out["lng"] = float(m.group(1)), float(m.group(2))
260 +
261 + # courtier + téléphone (le nom précède « Courtier immobilier »)
262 + mb = re.search(r'>\s*([A-ZÀ-Ÿ][A-Za-zÀ-ÿ .\'-]{4,45})\s*</span>[^<]*'
263 + r'(?:<[^>]+>\s*)*Courtier immobilier', html)
264 + if mb:
265 + name = _html.unescape(re.sub(r'\s+', ' ', mb.group(1))).strip()
266 + if name:
267 + out["broker_name"] = name
268 + mp = _PHONE_RE.search(html)
269 + if mp:
270 + out["broker_phone"] = mp.group(0)
271 +
272 + text = _flatten(html)
273 +
274 + # champs numériques principaux
275 + for field, rx in _SPEC_RE.items():
276 + m = rx.search(text)
277 + if m:
278 + out[field] = int(m.group(1))
279 + m = _AREA_RE.search(text)
280 + if m:
281 + out["area_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")
282 + m = _LOT_RE.search(text)
283 + if m:
284 + out["lot_sqft"] = parse_area_sqft(f"{m.group(1)} {m.group(2)}")
285 +
286 + # tableau de caractéristiques complet (valeur | libellé) → details
287 + for label in _DETAIL_LABELS:
288 + m = re.search(r'([^|]{1,60})\s*\|\s*' + re.escape(label) + r'\b', text)
289 + if m:
290 + val = m.group(1).strip(" |")
291 + if val and 1 <= len(val) <= 60 and val.lower() != label.lower():
292 + details[label] = val
293 +
294 + # pièces avec dimensions/niveau/revêtement
295 + rooms = []
296 + for blk in _ROOM_RE.findall(html):
297 + rt = _html.unescape(re.sub(r'<[^>]+>', ' ', blk))
298 + rt = re.sub(r'\s+', ' ', rt).strip()
299 + name = re.split(r'Niveau\s*:', rt)[0].strip()
300 + niveau = (re.search(r'Niveau\s*:\s*([^:]+?)(?:Dimensions|Revêtement|$)', rt) or [None, ""])
301 + dim = re.search(r'Dimensions\s*:\s*([0-9\'".,X x×]+)', rt)
302 + rev = re.search(r'Revêtement\s*:\s*([A-Za-zÀ-ÿ ,-]+?)(?:\s*Détails|\s*$)', rt)
303 + if name:
304 + rooms.append({
305 + "nom": name[:40],
306 + "niveau": (niveau[1].strip() if niveau else ""),
307 + "dimensions": (dim.group(1).strip() if dim else ""),
308 + "revetement": (rev.group(1).strip() if rev else ""),
309 + })
310 + if rooms:
311 + details["pieces"] = rooms[:20]
312 +
313 + # quick stats (Nb de pièces, superficie…)
314 + for label, val in _QSTAT_RE.findall(html):
315 + label = _html.unescape(re.sub(r'\s+', ' ', label)).strip()
316 + val = _html.unescape(re.sub(r'\s+', ' ', val)).strip()
317 + if label and val and label not in details:
318 + details[label] = val
319 +
320 + # inclusions / exclusions → features
321 + mi = _INCL_RE.search(html)
322 + feats = []
323 + if mi:
324 + blk = _html.unescape(re.sub(r'<[^>]+>', '\n', mi.group(1)))
325 + for line in blk.split('\n'):
326 + line = line.strip(" \t•-")
327 + if 3 <= len(line) <= 120 and not line.lower().startswith(("inclusion", "exclusion")):
328 + feats.append(line)
329 + if feats:
330 + out["features"] = feats[:25]
331 +
332 + # superficie habitable depuis details si pas déjà captée
333 + if out.get("area_sqft") is None and details.get("Superficie habitable"):
334 + out["area_sqft"] = parse_area_sqft(details["Superficie habitable"])
335 + if out.get("lot_sqft") is None and details.get("Superficie du terrain"):
336 + out["lot_sqft"] = parse_area_sqft(details["Superficie du terrain"])
337 +
338 + if details:
339 + out["details"] = details
340 + return out
341 +
342 +
343 +def _apply_detail(lst: PropertyListing, d: dict) -> None:
344 + if not d:
345 + return
346 + if d.get("images"):
347 + lst.images = d["images"]
348 + if d.get("features"):
349 + lst.features = d["features"]
350 + if d.get("details"):
351 + lst.details.update(d["details"])
352 + # le nom réel du courtier (page détail) remplace le placeholder « RE/MAX Québec »
353 + if d.get("broker_name"):
354 + lst.broker_name = d["broker_name"]
355 + for f in ("description", "bedrooms", "bathrooms", "powder_rooms",
356 + "year_built", "area_sqft", "lot_sqft", "lat", "lng",
357 + "broker_phone"):
358 + if d.get(f) is not None and getattr(lst, f, None) in (None, "", 0):
359 + setattr(lst, f, d[f])
360 +
361 +
362 +def _from_slug(slug: str) -> tuple[str, str]:
363 + """slug « maison-a-vendre-laurentides/1744-rue-carmen-val-david-9004907 »
364 + -> (type de propriété, région)."""
365 + if not slug:
366 + return "", ""
367 + head = slug.split("/", 1)[0] # maison-a-vendre-laurentides
368 + prop_type = ""
369 + for key, canon in _TYPE_SLUG.items():
370 + if re.search(rf"\b{key}", head):
371 + prop_type = canon
372 + break
373 + # région = ce qui suit « -a-vendre-/-for-sale- »
374 + m = re.search(r"(?:a-vendre|for-sale)-(.+)$", head)
375 + region = m.group(1).replace("-", " ").title() if m else ""
376 + return prop_type, region
added immoka/connectors/royal_lepage.py +132 −0
@@ -0,0 +1,132 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/royal_lepage.py : Royal LePage (royallepage.ca) — Québec
5 +#
6 +# Le portail rend ses résultats côté serveur, mais /fr/search/homes/{page}/
7 +# sans géo renvoie le Canada entier. La recherche par ville EST filtrable :
8 +# /fr/search/homes/{page}/?search_str=...&prov_code=QC&city_name=...&lat=..&
9 +# lng=..&search_type=city renvoie 100 % de propriétés du Québec, classées par
10 +# proximité (46/page). Chaque recherche plafonne à ~1 250 résultats ; on
11 +# SHARD donc par points d'ancrage répartis dans la province et on dédoublonne
12 +# par numéro MLS (= n° Centris, dans l'URL /.../mls{no}/). Aucune dépendance
13 +# Firecrawl : requêtes directes.
14 +# -----------------------------------------------------------------------------
15 +from __future__ import annotations
16 +
17 +import html as _html
18 +import re
19 +
20 +from .base import BaseConnector
21 +from ..normalize import parse_price
22 +from ..schema import PropertyListing
23 +
24 +BASE = "https://www.royallepage.ca/fr/search/homes"
25 +MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250)
26 +
27 +# Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches
28 +# + dédoublonnage MLS couvre la province.
29 +ANCHORS = [
30 + ("Montréal", 45.5089, -73.5542), ("Québec", 46.8139, -71.2080),
31 + ("Gatineau", 45.4765, -75.7013), ("Sherbrooke", 45.4040, -71.8929),
32 + ("Trois-Rivières", 46.3432, -72.5432), ("Saguenay", 48.4280, -71.0680),
33 + ("Laval", 45.6066, -73.7124), ("Longueuil", 45.5312, -73.5185),
34 + ("Drummondville", 45.8833, -72.4833), ("Saint-Jérôme", 45.7803, -74.0038),
35 + ("Granby", 45.4001, -72.7300), ("Rimouski", 48.4489, -68.5236),
36 + ("Rouyn-Noranda", 48.2360, -79.0230), ("Val-d'Or", 48.0975, -77.7972),
37 + ("Gaspé", 48.8330, -64.4870), ("Sept-Îles", 50.2001, -66.3821),
38 + ("Baie-Comeau", 49.2166, -68.1487), ("Joliette", 46.0167, -73.4500),
39 + ("Saint-Hyacinthe", 45.6300, -72.9569), ("Sorel-Tracy", 46.0500, -73.1200),
40 + ("Victoriaville", 46.0533, -71.9667), ("Mont-Laurier", 46.5500, -75.5000),
41 + ("Alma", 48.5500, -71.6500), ("Matane", 48.8500, -67.5300),
42 + ("Saint-Sauveur", 45.8939, -74.2051), ("Baie-Saint-Paul", 47.4400, -70.5000),
43 + ("Salaberry-de-Valleyfield", 45.2500, -74.1300), ("Thetford Mines", 46.1000, -71.3000),
44 +]
45 +
46 +_CARD_RE = re.compile(r'card--listing-card js-listing js-property-details(.*?)'
47 + r'(?=card--listing-card js-listing js-property-details|</main>|$)', re.S)
48 +_URL_RE = re.compile(r'/fr/property/quebec/[^"\']+?/mls[a-z0-9]+/', re.I)
49 +_MLS_RE = re.compile(r'/mls([a-z0-9]+)/', re.I)
50 +_KEY_RE = re.compile(r'data-rlp-key="(-?\d+\.\d+)\.(-?\d+\.\d+)"')
51 +_PHOTO_RE = re.compile(r'(//rlp\.jumplisting\.com/photos/[^"\']+)')
52 +_CAC_RE = re.compile(r'(\d+)\s*CAC', re.I)
53 +_SDB_RE = re.compile(r'(\d+)(?:\+\d+)?\s*SDB', re.I)
54 +_PRICE_RE = re.compile(r'([\d\s ]{4,})\s*\|?\s*\$')
55 +_TYPE_RE = re.compile(r'\|\s*(Maison|Condo|Appartement|Duplex|Triplex|Plex|Terrain|'
56 + r'Chalet|Fermette|Ferme|Loft|Terre|Maison de ville|Jumelé|'
57 + r'Quadruplex|Quintuplex|Commercial)\s*\|', re.I)
58 +
59 +
60 +class RoyalLepageConnector(BaseConnector):
61 + source_id = "royal_lepage"
62 + request_delay = 0.4
63 +
64 + def fetch(self) -> list[PropertyListing]:
65 + by_id: dict[str, PropertyListing] = {}
66 + for name, lat, lng in ANCHORS:
67 + self._search_anchor(name, lat, lng, by_id)
68 + return list(by_id.values())
69 +
70 + def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None:
71 + empty_streak = 0
72 + for page in range(1, MAX_PAGES + 1):
73 + params = {
74 + "search_str": f"{name}, QC", "prov_code": "QC",
75 + "city_name": name, "lat": lat, "lng": lng, "search_type": "city",
76 + }
77 + try:
78 + html = self.get(f"{BASE}/{page}/", params=params,
79 + headers={"X-Requested-With": "XMLHttpRequest"}).text
80 + except Exception:
81 + break
82 + new = 0
83 + for lst in self._parse_cards(html):
84 + if lst.external_id not in out:
85 + out[lst.external_id] = lst
86 + new += 1
87 + if new == 0:
88 + empty_streak += 1
89 + if empty_streak >= 2: # cap serveur atteint (page répétée)
90 + break
91 + else:
92 + empty_streak = 0
93 +
94 + def _parse_cards(self, html: str) -> list[PropertyListing]:
95 + out = []
96 + for seg in _CARD_RE.findall(html):
97 + murl = _URL_RE.search(seg)
98 + if not murl:
99 + continue
100 + url = "https://www.royallepage.ca" + murl.group(0)
101 + mls = _MLS_RE.search(url)
102 + ext = mls.group(1) if mls else url
103 + # /fr/property/quebec/{ville}/{adresse}/{id}/mls{no}/
104 + parts = murl.group(0).strip("/").split("/")
105 + city = parts[3].replace("-", " ").title() if len(parts) > 3 else ""
106 + address = parts[4].replace("-", " ").title() if len(parts) > 4 else ""
107 + flat = _html.unescape(re.sub(r"<[^>]+>", " | ", seg))
108 + flat = re.sub(r"(\s*\|\s*)+", " | ", re.sub(r"\s+", " ", flat))
109 + beds = _CAC_RE.search(flat)
110 + baths = _SDB_RE.search(flat)
111 + price = _PRICE_RE.search(flat)
112 + typ = _TYPE_RE.search(flat)
113 + key = _KEY_RE.search(seg)
114 + photo = _PHOTO_RE.search(seg)
115 + images = []
116 + if photo:
117 + images = ["https:" + photo.group(1).replace("_0_med", "_0_lg")]
118 + out.append(PropertyListing(
119 + source=self.source_id, external_id=str(ext), url=url,
120 + title=address, address=address, city=city, region="Québec",
121 + property_type=(typ.group(1) if typ else ""),
122 + price=(parse_price(price.group(1)) if price else None),
123 + price_label=(price.group(0).strip() if price else ""),
124 + bedrooms=int(beds.group(1)) if beds else None,
125 + bathrooms=int(baths.group(1)) if baths else None,
126 + mls=(mls.group(1) if mls else ""),
127 + images=images,
128 + lat=float(key.group(1)) if key else None,
129 + lng=float(key.group(2)) if key else None,
130 + broker_name="Royal LePage",
131 + ))
132 + return out
added immoka/connectors/sothebys_quebec.py +149 −0
@@ -0,0 +1,149 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sothebys_quebec.py : Sotheby's International Realty Québec
5 +# Recherche rendue côté client : le grid public est plafonné (~40/page) et la
6 +# pagination passe par du JS (pas d'URL). On SHARD donc par région
7 +# administrative du Québec (chaque page « region-{slug}-real-estate » renvoie
8 +# son lot), on dédoublonne par id, puis on enrichit chaque fiche via son
9 +# JSON-LD `Product` (prix, galerie S3 complète, description, n° MLS = sku).
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import html as _html
14 +import json
15 +import os
16 +import re
17 +
18 +from .base import BaseConnector
19 +from . import _detailutil as du
20 +from ..schema import PropertyListing
21 +
22 +SITE = "https://sothebysrealty.ca"
23 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_SOTHEBYS_DETAIL_LIMIT", "400"))
24 +
25 +# Régions administratives du Québec (slugs de recherche Sotheby's).
26 +QC_REGIONS = [
27 + "region-montreal", "region-laval", "region-monteregie", "region-laurentides",
28 + "region-lanaudiere", "region-eastern-townships", "region-outaouais",
29 + "region-capitale-nationale", "region-mauricie", "region-centre-du-quebec",
30 + "region-chaudiere-appalaches", "region-bas-saint-laurent",
31 + "region-saguenay-lac-saint-jean", "region-cote-nord", "region-charlevoix",
32 + "region-gaspesie", "region-abitibi-temiscamingue",
33 +]
34 +
35 +_PROP_RE = re.compile(
36 + r'/(?:fr|en)/property/quebec/region-([a-z-]+)/([a-z0-9-]+?)-real-estate/(\d{5,})/([a-z0-9-]+)/',
37 + re.I)
38 +
39 +
40 +class SothebysQuebecConnector(BaseConnector):
41 + source_id = "sothebys_quebec"
42 + request_delay = 1.0
43 +
44 + def _get_tolerant(self, url: str) -> str:
45 + """GET qui ignore le statut HTTP : les pages de résultats Sotheby's
46 + renvoient un 404 tout en servant le contenu complet."""
47 + import time
48 + wait = self.request_delay - (time.time() - self._last_request)
49 + if wait > 0:
50 + time.sleep(wait)
51 + resp = self.session.get(url, timeout=self.timeout)
52 + self._last_request = time.time()
53 + return resp.text
54 +
55 + def fetch(self) -> list[PropertyListing]:
56 + by_id: dict[str, PropertyListing] = {}
57 + for region in QC_REGIONS:
58 + url = f"{SITE}/en/search-results/{region}-real-estate/"
59 + try:
60 + html = self._get_tolerant(url)
61 + except Exception:
62 + continue
63 + for m in _PROP_RE.finditer(html):
64 + lst = self._to_listing(m)
65 + if lst and lst.external_id not in by_id:
66 + by_id[lst.external_id] = lst
67 + listings = list(by_id.values())
68 + # fiche détail : JSON-LD Product (prix, galerie complète, description, MLS)
69 + du.enrich(self, listings, DETAIL_LIMIT, parse_sothebys_detail, key="v1")
70 + return listings
71 +
72 + def _to_listing(self, m: re.Match) -> PropertyListing | None:
73 + region_slug, city_slug, pid, addr_slug = m.group(1), m.group(2), m.group(3), m.group(4)
74 + url = f"{SITE}/fr/property/quebec/region-{region_slug}/{city_slug}-real-estate/{pid}/{addr_slug}/"
75 + return PropertyListing(
76 + source=self.source_id,
77 + external_id=pid,
78 + url=url,
79 + title=_deslug(addr_slug),
80 + address=_deslug(addr_slug),
81 + city=_deslug(city_slug),
82 + region=_deslug(region_slug),
83 + broker_name="Sotheby's International Realty Québec",
84 + )
85 +
86 +
87 +def parse_sothebys_detail(html: str) -> dict:
88 + """Extrait le JSON-LD Product : prix, galerie S3 complète, description, MLS."""
89 + out: dict = {}
90 + for node in du.ld_nodes(html):
91 + if node.get("@type") != "Product":
92 + continue
93 + offers = node.get("offers") or {}
94 + if isinstance(offers, list):
95 + offers = offers[0] if offers else {}
96 + price = offers.get("price")
97 + try:
98 + out["price"] = float(price) if price is not None else None
99 + except (TypeError, ValueError):
100 + pass
101 + if node.get("description"):
102 + out["description"] = _html.unescape(str(node["description"])).strip()
103 + if node.get("sku"):
104 + out["mls"] = str(node["sku"])
105 + cat = node.get("category")
106 + if cat:
107 + out["details"] = {"Type de propriété": str(cat)}
108 + break
109 +
110 + # galerie complète : photos S3 de CETTE fiche (pleine résolution 'r'),
111 + # dédoublonnées par identifiant de base — plus complètes que le JSON-LD.
112 + mid = re.search(r'/live/images/listings/(\d+)/', html)
113 + if mid:
114 + lid = mid.group(1)
115 + seen, gallery = set(), []
116 + for u in re.findall(
117 + rf'https://sircmedia\.s3\.ca-central-1\.amazonaws\.com/live/images/listings/{lid}/'
118 + r'[^"\' ]+\.(?:jpg|jpeg|webp)', html):
119 + base = re.sub(r'(?:nr|m|r)?\.(?:jpg|jpeg|webp)$', '', u.rsplit("/", 1)[-1])
120 + full = re.sub(r'(?:nr|m)?\.jpg$', 'r.jpg', u)
121 + if base not in seen:
122 + seen.add(base)
123 + gallery.append(full)
124 + if gallery:
125 + out["images"] = gallery
126 + # repli sur les images du JSON-LD si aucune photo S3 trouvée
127 + if not out.get("images"):
128 + for node in du.ld_nodes(html):
129 + if node.get("@type") == "Product" and node.get("image"):
130 + imgs = node["image"]
131 + out["images"] = imgs if isinstance(imgs, list) else [imgs]
132 + break
133 +
134 + # chambres / salles de bains (best-effort, valeurs plausibles seulement)
135 + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
136 + mb = re.search(r"\b(\d{1,2})\s*(?:Bedroom|Chambre|bed\b|ch\.)", text, re.I)
137 + if mb and int(mb.group(1)) <= 20:
138 + out["bedrooms"] = int(mb.group(1))
139 + ms = re.search(r"\b(\d{1,2})\s*(?:Bathroom|Salle de bain|bath\b)", text, re.I)
140 + if ms and int(ms.group(1)) <= 20:
141 + out["bathrooms"] = int(ms.group(1))
142 + coords = du.gmaps_coords(html)
143 + if coords:
144 + out["lat"], out["lng"] = coords
145 + return out
146 +
147 +
148 +def _deslug(s: str) -> str:
149 + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
added immoka/connectors/sourceimmo.py +160 −0
@@ -0,0 +1,160 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sourceimmo.py : base réutilisable pour les agences bâties sur la
5 +# plateforme « source.immo » (plugin WordPress source-immo). L'app AngularJS
6 +# appelle une API ASP.NET publique :
7 +# GET https://api-v1.source.immo/api/listing/view/{view}/{locale}/items
8 +# avec les en-têtes x-si-account / x-si-api / x-si-appId (exposés dans la
9 +# config publique {_configs.json} du site). Pagination par `next_token`.
10 +# Une sous-classe fournit account_id / api_key / app_id / view_id.
11 +# -----------------------------------------------------------------------------
12 +from __future__ import annotations
13 +
14 +from .base import BaseConnector
15 +from ..schema import PropertyListing
16 +
17 +API_ROOT = "https://api-v1.source.immo/api"
18 +
19 +# Codes de catégorie source.immo -> vocabulaire brut (finalize() normalise)
20 +_CATEGORY = {
21 + "RESIDENTIAL": "Maison", "SINGLE-FAMILY": "Maison",
22 + "MULTI-FAMILY": "Multiplex", "CONDO": "Condo", "APARTMENT": "Condo",
23 + "LOT": "Terrain", "LAND": "Terrain", "FARM": "Fermette/Agricole",
24 + "COMMERCIAL": "Commercial", "INDUSTRIAL": "Commercial",
25 +}
26 +_SUBCATEGORY = {
27 + "2X": "Duplex", "3X": "Triplex", "4X": "Quadruplex", "5X": "Quintuplex",
28 +}
29 +
30 +
31 +class SourceImmoConnector(BaseConnector):
32 + """Base : définir source_id, account_id, api_key, app_id, view_id, site_url."""
33 +
34 + account_id = ""
35 + api_key = ""
36 + app_id = ""
37 + view_id = ""
38 + site_url = ""
39 + locale = "fr"
40 + request_delay = 0.25
41 + use_detail_cache = False
42 + max_pages = 400 # garde-fou (100 items/page)
43 +
44 + def _headers(self) -> dict:
45 + return {
46 + "x-si-account": self.account_id,
47 + "x-si-api": self.api_key,
48 + "x-si-appId": self.app_id,
49 + "Accept": "application/json",
50 + }
51 +
52 + def fetch(self) -> list[PropertyListing]:
53 + url = f"{API_ROOT}/listing/view/{self.view_id}/{self.locale}/items"
54 + out: list[PropertyListing] = []
55 + params: dict = {}
56 + seen: set[str] = set()
57 + prev_token = None
58 + for _ in range(self.max_pages):
59 + try:
60 + data = self.get(url, headers=self._headers(), params=params).json()
61 + except Exception:
62 + break
63 + items = data.get("items", []) if isinstance(data, dict) else []
64 + if not items:
65 + break
66 + before = len(out)
67 + for it in items:
68 + lst = self._to_listing(it)
69 + if lst and lst.uid not in seen:
70 + seen.add(lst.uid)
71 + out.append(lst)
72 + # Pagination source.immo : l'API attend `st` (search_token) + `nt`
73 + # (next_token) — c'est ce que fait l'app AngularJS. Avec le mauvais
74 + # nom de paramètre, l'API renvoie toujours la 1re page (faux plafond
75 + # de 100 items). Avec st+nt, on parcourt tout l'inventaire.
76 + meta = data.get("metadata") or {}
77 + token = meta.get("next_token")
78 + if not token or token == prev_token or len(out) == before:
79 + break
80 + prev_token = token
81 + params = {"st": meta.get("search_token") or "", "nt": token}
82 + return out
83 +
84 + # -- mapping ---------------------------------------------------------------
85 + def _to_listing(self, it: dict) -> PropertyListing | None:
86 + ref = it.get("ref_number") or it.get("id")
87 + if not ref or not it.get("for_sale_flag", True):
88 + return None
89 + if (it.get("status_code") or "").upper() not in ("", "AVAILABLE"):
90 + return None
91 +
92 + loc = it.get("location") or {}
93 + addr = loc.get("address") or {}
94 + street = " ".join(filter(None, [addr.get("street_number"),
95 + addr.get("street_name")])).strip()
96 + price = ((it.get("price") or {}).get("sell") or {}).get("amount")
97 +
98 + cat = (it.get("category_code") or "").upper()
99 + sub = (it.get("subcategory_code") or "").upper()
100 + prop_type = _SUBCATEGORY.get(sub) or _CATEGORY.get(cat, "")
101 +
102 + unit = it.get("main_unit") or {}
103 + building = it.get("building") or {}
104 + dim = building.get("dimension") or {}
105 + area = dim.get("area_sf")
106 + land = it.get("land") or {}
107 + lot = (land.get("dimension") or {}).get("area_sf")
108 +
109 + photo = it.get("photo_url") or ""
110 + images = [photo.replace("-sm.", "-lg.")] if photo else []
111 +
112 + # caractéristiques disponibles dans l'item (aucune requête détail)
113 + details = {"postal_code": addr.get("postal_code", "")}
114 + b_ass = (building.get("assessment") or {}).get("amount")
115 + l_ass = (land.get("assessment") or {}).get("amount")
116 + if b_ass or l_ass:
117 + details["Évaluation municipale"] = (
118 + f"{(b_ass or 0) + (l_ass or 0):,.0f} $".replace(",", " "))
119 + if dim.get("area_sf"):
120 + details["Superficie du bâtiment (au sol)"] = f"{dim['area_sf']:,.0f} pi²".replace(",", " ")
121 + if unit.get("room_count"):
122 + details["Nombre de pièces"] = unit["room_count"]
123 + features = []
124 + attrs = it.get("attributes") or {}
125 + if attrs.get("PARKING"):
126 + features.append("Stationnement")
127 + if it.get("virtual_tour_flag"):
128 + features.append("Visite virtuelle")
129 + if it.get("video_flag"):
130 + features.append("Vidéo")
131 +
132 + return PropertyListing(
133 + source=self.source_id,
134 + external_id=str(ref),
135 + url=self._listing_url(it),
136 + title=street,
137 + address=street,
138 + city="", # code ville source.immo non résolu ici
139 + sector="",
140 + region=str(loc.get("region_code") or ""),
141 + property_type=prop_type,
142 + price=float(price) if price is not None else None,
143 + price_label=(f"{float(price):,.0f} $".replace(",", " ") if price else ""),
144 + bedrooms=unit.get("bedroom_count"),
145 + bathrooms=unit.get("bathroom_count"),
146 + powder_rooms=unit.get("waterroom_count"),
147 + area_sqft=float(area) if area else None,
148 + lot_sqft=float(lot) if lot else None,
149 + year_built=building.get("construction_year"),
150 + mls=str(ref),
151 + lat=loc.get("latitude"),
152 + lng=loc.get("longitude"),
153 + images=images,
154 + features=features,
155 + details=details,
156 + )
157 +
158 + def _listing_url(self, it: dict) -> str:
159 + # URL lisible non essentielle ; on pointe la recherche du site par défaut.
160 + return self.site_url
added immoka/connectors/sutton.py +304 −0
@@ -0,0 +1,304 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com)
5 +# Site rendu serveur : chaque page de résultats publie 10 propriétés en
6 +# JSON-LD schema.org (ItemList), avec prix, chambres, salles de bains,
7 +# superficie et adresse. Les photos suivent le motif immo.vrtx.co/pic450/{id}.
8 +# -----------------------------------------------------------------------------
9 +from __future__ import annotations
10 +
11 +import html as _html
12 +import os
13 +import re
14 +
15 +from .base import BaseConnector
16 +from . import _detailutil as du
17 +from .jsonld import item_list_elements, sqm_to_sqft
18 +from ..schema import PropertyListing
19 +
20 +BASE = "https://www.suttonquebec.com"
21 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_SUTTON_DETAIL_LIMIT", "400"))
22 +# toutes les photos de la fiche (la liste n'en montre que quelques-unes)
23 +_DET_IMG_RE = re.compile(r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I)
24 +_DESC_RE = re.compile(r'Description de la propri[ée]t[ée](?:\s+[àa]\s+vendre)?[^A-Za-z0-9]*(.*?)'
25 + r'(?:Caract[ée]ristiques|Pi[èe]ce|Inclusions|Exclusions|Addenda|$)', re.I | re.S)
26 +_ROOM_ROW_RE = re.compile(
27 + r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+'
28 + r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b')
29 +# Pages de résultats couvrant le résidentiel à vendre au Québec.
30 +LISTING_PAGES = [
31 + "/fr/maisons-a-vendre.html",
32 + "/fr/condos-lofts-appartements-a-vendre.html",
33 + "/fr/chalets-a-vendre.html",
34 + "/fr/fermes-fermettes-a-vendre.html",
35 +]
36 +MAX_PAGES = 60 # garde-fou (10 propriétés/page)
37 +IMG_RE = re.compile(r'https://immo\.vrtx\.co/pic\d+/(\d+)(?:-\d+)?\.(?:jpg|jpeg|png|webp)',
38 + re.I)
39 +NOINS_RE = re.compile(r'noInscription=(\d+)')
40 +
41 +# Sitemap : couverture EXHAUSTIVE (l'index /sitemap.cfm pointe vers des
42 +# sitemaps d'inscriptions paginés). typeInscription=1 = à vendre, =2 = à louer.
43 +SITEMAP_INDEX = f"{BASE}/sitemap.cfm"
44 +SITEMAP_MAX_PAGES = 40 # garde-fou (≈12 pages réelles × ~1000)
45 +_SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I)
46 +_SM_LOC_RE = re.compile(
47 + r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?'
48 + r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I)
49 +
50 +
51 +class SuttonConnector(BaseConnector):
52 + source_id = "sutton"
53 + request_delay = 0.5
54 +
55 + def fetch(self) -> list[PropertyListing]:
56 + by_id: dict[str, PropertyListing] = {}
57 + # 1) couverture exhaustive via le sitemap (adresse/type depuis le slug)
58 + for slug, centris, url in self._sitemap_listings():
59 + if centris in by_id:
60 + continue
61 + ptype, addr, city = _from_slug(slug)
62 + by_id[centris] = PropertyListing(
63 + source=self.source_id, external_id=centris, url=url,
64 + title=addr or _deslug(slug), address=addr, city=city,
65 + property_type=ptype, mls=centris,
66 + broker_name="Groupe Sutton Québec",
67 + )
68 + listings = list(by_id.values())
69 + # fiche détail : prix, chambres/sdb, galerie complète (~80 photos),
70 + # description, pièces, inclusions, caractéristiques — plafonné, cache
71 + # accumulé entre cycles (tout le parc finit enrichi au fil des syncs).
72 + du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v3")
73 + return listings
74 +
75 + def _sitemap_listings(self):
76 + """Itère (slug, centris, url) de toutes les inscriptions À VENDRE."""
77 + try:
78 + index = self.get(SITEMAP_INDEX).text
79 + except Exception:
80 + return
81 + subs = []
82 + for s in _SM_SUB_RE.findall(index):
83 + s = s.replace("&amp;", "&")
84 + if s not in subs:
85 + subs.append(s)
86 + if not subs: # repli : pagination directe
87 + subs = [f"sitemap_inscription.cfm?page={p}&platine=0"
88 + for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"]
89 + seen = set()
90 + for sub in subs[:SITEMAP_MAX_PAGES]:
91 + try:
92 + xml = self.get(f"{BASE}/{sub}").text
93 + except Exception:
94 + continue
95 + for m in _SM_LOC_RE.finditer(xml):
96 + slug, centris, typ = m.group(1), m.group(2), m.group(3)
97 + if typ != "1" or centris in seen: # 1 = à vendre uniquement
98 + continue
99 + seen.add(centris)
100 + url = (f"{BASE}/fr/inscription/{slug}.html"
101 + f"?noInscription={centris}&typeInscription=1")
102 + yield slug, centris, url
103 +
104 + def _fetch_section(self, path: str, out: dict) -> None:
105 + page = 1
106 + while page <= MAX_PAGES:
107 + url = f"{BASE}{path}?page={page}"
108 + try:
109 + html = self.get(url).text
110 + except Exception:
111 + break
112 + elems = item_list_elements(html)
113 + if not elems:
114 + break
115 + images_by_id = self._images_by_id(html)
116 + new = 0
117 + for el in elems:
118 + lst = self._to_listing(el, images_by_id)
119 + if lst and lst.uid not in out:
120 + out[lst.uid] = lst
121 + new += 1
122 + # dernière page : moins d'items que la pleine page, ou rien de neuf
123 + if len(elems) < 10 or new == 0:
124 + break
125 + page += 1
126 +
127 + @staticmethod
128 + def _images_by_id(html: str) -> dict[str, list[str]]:
129 + groups: dict[str, list[str]] = {}
130 + for m in IMG_RE.finditer(html):
131 + groups.setdefault(m.group(1), [])
132 + if m.group(0) not in groups[m.group(1)]:
133 + groups[m.group(1)].append(m.group(0))
134 + return groups
135 +
136 + def _to_listing(self, el: dict, images_by_id: dict) -> PropertyListing | None:
137 + item = el.get("item", el)
138 + url = item.get("url") or el.get("url") or ""
139 + if not url:
140 + return None
141 + if url.startswith("fr/") or url.startswith("/fr/"):
142 + url = f"{BASE}/{url.lstrip('/')}"
143 + m = NOINS_RE.search(url) or NOINS_RE.search(el.get("url", ""))
144 + external_id = m.group(1) if m else url.rsplit("/", 1)[-1].split(".")[0]
145 +
146 + addr = el.get("address") or item.get("address") or {}
147 + offers = el.get("offers") or item.get("offers") or {}
148 + price = offers.get("price")
149 + try:
150 + price = float(price) if price is not None else None
151 + except (TypeError, ValueError):
152 + price = None
153 +
154 + area = None
155 + fs = item.get("floorSize") or {}
156 + if isinstance(fs, dict) and fs.get("value"):
157 + val = fs["value"]
158 + # unitCode MTK = m² ; sinon on suppose des pi². Convertit si plausible.
159 + if fs.get("unitCode") == "MTK" and float(val) < 400:
160 + area = sqm_to_sqft(val)
161 + else:
162 + try:
163 + area = round(float(val))
164 + except (TypeError, ValueError):
165 + area = None
166 +
167 + return PropertyListing(
168 + source=self.source_id,
169 + external_id=str(external_id),
170 + url=url,
171 + title=(item.get("name") or el.get("name") or "").strip(),
172 + address=addr.get("streetAddress", ""),
173 + sector=addr.get("addressLocality", ""),
174 + city=addr.get("addressLocality", ""),
175 + region=addr.get("addressRegion", ""),
176 + property_type=item.get("@type", ""),
177 + price=price,
178 + price_label=(f"{price:,.0f} $".replace(",", " ") if price else ""),
179 + bedrooms=_as_int(item.get("numberOfBedrooms")),
180 + bathrooms=_as_int(item.get("numberOfBathroomsTotal")),
181 + area_sqft=area,
182 + images=images_by_id.get(str(external_id), []),
183 + broker_name="Groupe Sutton Québec",
184 + )
185 +
186 +
187 +def _as_int(v):
188 + try:
189 + return int(float(v))
190 + except (TypeError, ValueError):
191 + return None
192 +
193 +
194 +def _deslug(s: str) -> str:
195 + return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()
196 +
197 +
198 +# villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug)
199 +_CITY_HINTS = (
200 + "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie",
201 + "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville",
202 + "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve",
203 + "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs",
204 + "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou",
205 + "montreal-pierrefonds-roxboro", "trois-rivieres", "saint-jean-sur-richelieu",
206 + "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot",
207 + "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec",
208 + "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog",
209 +)
210 +
211 +
212 +def _from_slug(slug: str) -> tuple[str, str, str]:
213 + """« maison-de-plain-pied-a-vendre-522-cedar-street-beaconsfield »
214 + -> (type, adresse, ville). Best-effort (l'adresse exacte vient de la fiche)."""
215 + m = re.search(r"^(.*?)-a-vendre-(.+)$", slug, re.I)
216 + if not m:
217 + return "", _deslug(slug), ""
218 + ptype = _deslug(m.group(1))
219 + rest = m.group(2).lower()
220 + city = ""
221 + for hint in _CITY_HINTS:
222 + if rest.endswith("-" + hint) or rest == hint:
223 + city = _deslug(hint)
224 + rest = rest[: -len(hint)].rstrip("-")
225 + break
226 + else:
227 + # sinon : dernier token comme ville (approx ; corrigé par la fiche détail)
228 + parts = rest.rsplit("-", 1)
229 + if len(parts) == 2 and not parts[1].isdigit():
230 + city, rest = _deslug(parts[1]), parts[0]
231 + return ptype, _deslug(rest), city
232 +
233 +
234 +def _merge(dst: PropertyListing, src: PropertyListing) -> None:
235 + """Complète dst (issu du sitemap) avec les champs riches de src (JSON-LD)."""
236 + for f in ("price", "bedrooms", "bathrooms", "area_sqft"):
237 + if getattr(dst, f, None) in (None, 0) and getattr(src, f, None) not in (None, 0):
238 + setattr(dst, f, getattr(src, f))
239 + for f in ("address", "city", "sector", "region", "price_label", "property_type"):
240 + if not getattr(dst, f, "") and getattr(src, f, ""):
241 + setattr(dst, f, getattr(src, f))
242 + if len(src.images) > len(dst.images):
243 + dst.images = src.images
244 +
245 +
246 +def parse_sutton_detail(html: str) -> dict:
247 + """Fiche Sutton : galerie complète, description, pièces (dimensions), inclusions."""
248 + out: dict = {}
249 + # photos : normaliser toute taille -> pic450, dédoublonner par id-index
250 + seen, imgs = {}, []
251 + for m in _DET_IMG_RE.finditer(html):
252 + key = m.group(1)
253 + if key not in seen:
254 + seen[key] = 1
255 + imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg")
256 + if imgs:
257 + out["images"] = imgs
258 +
259 + text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
260 +
261 + # prix : premier montant du texte (le prix demandé précède évaluation/taxes)
262 + for mnt in re.findall(r"([\d][\d ]{3,})\s*\$", text):
263 + val = _as_int(mnt.replace(" ", "").replace("\xa0", ""))
264 + if val and val >= 20000:
265 + out["price"] = float(val)
266 + out["price_label"] = f"{val:,.0f} $".replace(",", " ")
267 + break
268 + mb = re.search(r"(\d+)\s*chambre", text, re.I)
269 + if mb:
270 + out["bedrooms"] = int(mb.group(1))
271 + ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I)
272 + if ms:
273 + out["bathrooms"] = int(ms.group(1))
274 +
275 + md = _DESC_RE.search(text)
276 + if md:
277 + desc = md.group(1).strip()
278 + if len(desc) > 30:
279 + out["description"] = desc[:4000]
280 +
281 + # pièces : « Nom DimxDim P Revêtement Niveau »
282 + rooms = []
283 + for m in _ROOM_ROW_RE.finditer(text):
284 + nom = m.group(1).strip(" -")
285 + # retirer l'en-tête de tableau qui peut coller au 1er nom
286 + nom = re.sub(r"^.*?(?:Niveau|Rev[êe]tement|Dimension)\s+", "", nom).strip(" -")
287 + if not nom or nom.lower() in ("pièce", "piece"):
288 + continue
289 + rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""),
290 + "revetement": m.group(3).strip(), "niveau": m.group(4)})
291 + if rooms:
292 + out.setdefault("details", {})["pieces"] = rooms[:20]
293 +
294 + # inclusions
295 + mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S)
296 + if mi:
297 + inc = mi.group(1).strip()
298 + feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90]
299 + if feats:
300 + out["features"] = feats[:20]
301 + _det = du.centris_details(du.flatten(html))
302 + if _det:
303 + out.setdefault("details", {}).update(_det)
304 + return out
added immoka/connectors/vendirect.py +27 −0
@@ -0,0 +1,27 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/vendirect.py : Vendirect (vendirect.ca)
5 +# Site WordPress + plugin source-immo. On lit directement l'API publique
6 +# source.immo (voir SourceImmoConnector). Les identifiants proviennent de la
7 +# config publique du site :
8 +# vendirect.ca/wp-content/uploads/_sourceimmo/_configs.json
9 +# -----------------------------------------------------------------------------
10 +from __future__ import annotations
11 +
12 +from .sourceimmo import SourceImmoConnector
13 +
14 +
15 +class VendirectConnector(SourceImmoConnector):
16 + source_id = "vendirect"
17 + account_id = "41ebec38-dea0-428c-b016-8e9d1b3b8f31"
18 + api_key = "fd4655c3-c5e6-4761-9e08-59b80b191ebd"
19 + app_id = "ead7575f-8d1c-42e7-9f59-4cf9e065167e"
20 + view_id = "49c63dde-266d-48c9-8a48-efd4334864d1"
21 + site_url = "https://vendirect.ca/proprietes/"
22 +
23 + def _to_listing(self, it):
24 + lst = super()._to_listing(it)
25 + if lst is not None:
26 + lst.broker_name = "Vendirect"
27 + return lst
added immoka/connectors/via_capitale.py +218 −0
@@ -0,0 +1,218 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/via_capitale.py : Via Capitale (viacapitalevendu.com)
5 +# Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)
6 +# mais protégé par Cloudflare : on passe donc par Firecrawl (get_rendered) qui
7 +# franchit le challenge. Les résultats résidentiels sont paginés (34/page,
8 +# ~5 300 propriétés). On parse les cartes HTML et on dédoublonne par code
9 +# d'inscription. Photos : images.viacapitale.info/images/inscriptions/{id}/…
10 +# -----------------------------------------------------------------------------
11 +from __future__ import annotations
12 +
13 +import html as _htmlmod2
14 +import os
15 +import re
16 +
17 +from .base import BaseConnector
18 +from . import _detailutil as du
19 +from ..normalize import parse_price
20 +from ..schema import PropertyListing
21 +
22 +# Fiche descriptive accessible en requête simple (galerie ~60 photos + description).
23 +DETAIL_LIMIT = int(os.environ.get("IMMOKA_VC_DETAIL_LIMIT", "300"))
24 +_FICHE_IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I)
25 +
26 +SITE = "https://www.viacapitalevendu.com"
27 +SEARCH = f"{SITE}/recherche/residentiel/"
28 +MAX_PAGES = 200 # garde-fou (~155 pages attendues)
29 +# La pagination du portail n'est active QUE si le paramètre `criteresJson`
30 +# (blob base64 des critères de recherche) est présent — sinon `?page=N` retombe
31 +# toujours sur la 1re page. Ce blob = critères vides + « AVendre:true » ; il est
32 +# statique et réutilisable pour parcourir les ~155 pages (~5 300 propriétés).
33 +CRITERES_JSON = (
34 + "eyJDYXJhY3RlcmlzdGlxdWVzIjpbXSwiQXV0cmVzQ3JpdGVyZSI6W10sIlR5cGVEZVByb3ByaWV0ZSI6"
35 + "W10sIlR5cGVEZUJhdGltZW50IjpudWxsLCJQZXJpb2Rlc0FmZmljaGFnZSI6bnVsbCwiQW5uZWVEZUNv"
36 + "bnN0cnVjdGlvbiI6bnVsbCwiUmVnaW9uIjpudWxsLCJOb21SZWdpb24iOm51bGwsIk5vbWJyZURlQ2hh"
37 + "bWJyZSI6MCwiTm9tYnJlRGVCYWluIjowLCJQcml4TWluaW11bSI6IjAiLCJQcml4TWF4aW11bSI6IjAi"
38 + "LCJQcml4TG9jYXRpb25NaW5pbXVtIjoiMCIsIlByaXhMb2NhdGlvbk1heGltdW0iOiIwIiwiT3JkZXJC"
39 + "eSI6bnVsbCwiVHlwZSI6MCwiQVZlbmRyZSI6dHJ1ZSwiRnJvbUFmZmljaGVyVG91dGVzUHJvcHJpZXRl"
40 + "IjpmYWxzZSwiU3VwZXJmaWNpZU1pbmltdW0iOiIiLCJTdXBlcmZpY2llTWF4aW11bSI6IiIsIlVuaXRl"
41 + "TWVzdXJlIjoiUEMiLCJNb3RzQ2xlcyI6bnVsbCwiWm9uYWdlIjpudWxsLCJOb21icmVVbml0ZXMiOjEs"
42 + "IlN1Y2N1cnNhbGVDb2RlIjpudWxsLCJBZ2VuY2VDb2RlIjpudWxsLCJNZW1icmVDb2RlIjpudWxsLCJF"
43 + "cXVpcGVJZCI6MCwiU3VjY3Vyc2FsZU5hbWUiOm51bGwsIkFnZW5jZU5hbWUiOm51bGwsIk1lbWJyZU5h"
44 + "bWUiOm51bGwsIkVxdWlwZU5hbWUiOm51bGwsImlucHV0UmVnaW9ucyI6bnVsbCwiUmV0dXJuVXJsIjpu"
45 + "dWxsLCJMYXRpdHVkZSI6bnVsbCwiTG9uZ2l0dWRlIjpudWxsLCJOb0luc2NyaXB0aW9uTm9uRGlzcG8i"
46 + "Om51bGwsIkVuUmVjaGVyY2hlIjpmYWxzZSwiUGx1c0RlQ3JpdGVyZXMiOiJmYWxzZSIsIlNwZWNpYWxp"
47 + "dGUiOm51bGwsIk5vbVBsYW5FYXUiOm51bGwsIk5vbVBsYW5FYXVNb2JpbGUiOm51bGwsIklkUGxhbkVh"
48 + "dSI6bnVsbCwiSWRQbGFuRWF1TW9iaWxlIjowLCJHZW5yZVByb3ByaWV0ZUZyVXJsIjpudWxsLCJHZW5y"
49 + "ZVByb3ByaWV0ZUVuVXJsIjpudWxsLCJzZWxlY1BBTW9iaWxlIjpudWxsLCJzZWxlY0NhcmFjTW9iaWxl"
50 + "IjpbbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbF0sInNlbGVj"
51 + "QUNNb2JpbGUiOltudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxs"
52 + "XSwic2VsZWNUQk1vYmlsZSI6W251bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxu"
53 + "dWxsLG51bGxdfQ=="
54 +)
55 +IMG_HOST = "https://images.viacapitale.info"
56 +
57 +# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée
58 +CARD_SPLIT = re.compile(r'<a href="(https://www\.viacapitalevendu\.com/[^"]*?-(\d{6,}))"')
59 +PRICE_RE = re.compile(r'class="price">\s*([\d  ]+\$)', re.I)
60 +ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)
61 +IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I)
62 +TYPE_RE = re.compile(
63 + r'(Maison[\w\s\-àâéèêëîïôûùç]*|Condo[\w\s]*|Duplex|Triplex|Quadruplex|Quintuplex|'
64 + r'Plex|Terrain|Chalet|Fermette|Ferme|Loft|Terre|Maison de ville|Jumelé)',
65 + re.I)
66 +
67 +
68 +class ViaCapitaleConnector(BaseConnector):
69 + source_id = "via_capitale"
70 +
71 + def fetch(self) -> list[PropertyListing]:
72 + by_id: dict[str, PropertyListing] = {}
73 + empty_streak = 0
74 + for page in range(1, MAX_PAGES + 1):
75 + url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}"
76 + # Cloudflare renvoie parfois un challenge/vide par intermittence :
77 + # on retente la page une fois avant de la considérer vraiment vide.
78 + cards = []
79 + for attempt in range(2):
80 + try:
81 + html = self.get_rendered(url, wait_for=6000, proxy="stealth")
82 + except Exception:
83 + html = ""
84 + cards = self._parse_cards(html)
85 + if cards:
86 + break
87 + new = 0
88 + for lst in cards:
89 + if lst.uid not in by_id:
90 + by_id[lst.uid] = lst
91 + new += 1
92 + if not cards or new == 0:
93 + empty_streak += 1
94 + # tolérance élevée : ~155 pages attendues, on ne s'arrête qu'après
95 + # plusieurs pages consécutives réellement sans nouveauté
96 + if empty_streak >= 5:
97 + break
98 + else:
99 + empty_streak = 0
100 + listings = list(by_id.values())
101 + # fiche détail (requête simple) : galerie complète + description
102 + du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v2")
103 + return listings
104 +
105 + def _parse_cards(self, html: str) -> list[PropertyListing]:
106 + # découpe la page en segments, un par carte (lien fiche + id)
107 + matches = list(CARD_SPLIT.finditer(html))
108 + out = []
109 + for i, m in enumerate(matches):
110 + url, code = m.group(1), m.group(2)
111 + seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches) else m.start() + 2500]
112 + out.append(self._to_listing(url, code, seg))
113 + return [x for x in out if x]
114 +
115 + def _to_listing(self, url: str, code: str, seg: str) -> PropertyListing | None:
116 + price_m = PRICE_RE.search(seg)
117 + price_label = (price_m.group(1).strip() if price_m else "").replace(" ", " ")
118 + addr_m = ADDR_RE.search(seg)
119 + address_full = _unescape(addr_m.group(1).strip()) if addr_m else ""
120 + type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg))
121 + # ne garder que le libellé de type (avant l'adresse recopiée dans le bloc)
122 + prop_type = re.split(r"\s{2,}|\n", type_m.group(1))[0].strip() if type_m else ""
123 +
124 + address, city, sector = _split_addr(address_full)
125 +
126 + images = []
127 + for im in IMG_RE.finditer(seg):
128 + u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}"
129 + if u not in images:
130 + images.append(u)
131 +
132 + region, _city_slug = _from_slug(url)
133 + return PropertyListing(
134 + source=self.source_id,
135 + external_id=code,
136 + url=f"{SITE}/inscription/fichedescriptive/?code={code}",
137 + title=f"{prop_type}{address}".strip(" —") or address_full,
138 + address=address,
139 + city=city or _city_slug,
140 + sector=sector,
141 + region=region,
142 + property_type=prop_type,
143 + price=parse_price(price_label),
144 + price_label=price_label,
145 + images=images,
146 + broker_name="Via Capitale",
147 + )
148 +
149 +
150 +# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-maison-... »
151 +_REGIONS = (
152 + "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale",
153 + "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue",
154 + "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine",
155 + "chaudiere-appalaches", "laval", "lanaudiere", "laurentides",
156 + "monteregie", "centre-du-quebec",
157 +)
158 +
159 +
160 +import html as _htmlmod
161 +
162 +_PAREN = re.compile(r"\(([^)]*)\)")
163 +
164 +
165 +def _unescape(s: str) -> str:
166 + return _htmlmod.unescape(s).replace("\xa0", " ").replace("&nbsp;", " ").strip()
167 +
168 +
169 +def _split_addr(full: str) -> tuple[str, str, str]:
170 + """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) »
171 + -> (adresse, ville, secteur)."""
172 + if not full:
173 + return "", "", ""
174 + parts = [p.strip() for p in full.split(",", 1)]
175 + address = parts[0]
176 + city = sector = ""
177 + if len(parts) > 1:
178 + muni = parts[1]
179 + parens = _PAREN.findall(muni)
180 + city = _PAREN.sub("", muni).strip()
181 + if parens:
182 + sector = parens[-1].strip()
183 + return address, city, sector
184 +
185 +
186 +def parse_vc_detail(html: str) -> dict:
187 + """Fiche Via Capitale : galerie complète + description (plus long bloc)."""
188 + out: dict = {}
189 + seen, imgs = set(), []
190 + for m in _FICHE_IMG_RE.finditer(html):
191 + u = f"https://images.viacapitale.info/images/inscriptions/{m.group(1)}/{m.group(2)}"
192 + if u not in seen:
193 + seen.add(u)
194 + imgs.append(u)
195 + if imgs:
196 + out["images"] = imgs
197 + # description = plus long bloc de texte visible (marketing de la propriété)
198 + best = ""
199 + for b in re.findall(r"<div[^>]*>(.*?)</div>", html, re.S):
200 + txt = re.sub(r"\s+", " ", _htmlmod2.unescape(re.sub(r"<[^>]+>", " ", b))).strip()
201 + if len(txt) > len(best) and "window" not in txt and "function" not in txt and "{" not in txt:
202 + best = txt
203 + if len(best) > 120:
204 + out["description"] = best[:4000]
205 + _det = du.centris_details(du.flatten(html))
206 + if _det:
207 + out.setdefault("details", {}).update(_det)
208 + return out
209 +
210 +
211 +def _from_slug(url: str) -> tuple[str, str]:
212 + tail = url.rstrip("/").rsplit("/", 1)[-1]
213 + for reg in _REGIONS:
214 + if tail.startswith(reg):
215 + rest = tail[len(reg) + 1:]
216 + city = rest.split("-")[0].replace("-", " ").title() if rest else ""
217 + return reg.replace("-", " ").title(), city
218 + return "", ""
added immoka/connectors/via_capitale_agences.py +90 −0
@@ -0,0 +1,90 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# connectors/via_capitale_agences.py : Via Capitale par SOUS-AGENCES (robuste)
5 +#
6 +# Le portail central viacapitalevendu.com est derrière Cloudflare (scrape
7 +# fragile, 1 page). Les sous-agences Via Capitale (Élite, Sélect, du Mont-Royal,
8 +# Accès…) tournent chacune sur la plateforme source.immo (plugin WordPress),
9 +# dont l'API publique (api-v1.source.immo) est stable et paginée — même
10 +# mécanique que Vendirect (voir sourceimmo.py). On génère une classe par
11 +# sous-agence depuis data/via_capitale_agencies.json ; le n° Centris
12 +# (ref_number) sert de clé de déduplication (voir web.py, sources « %_ag_% »).
13 +#
14 +# La config publique de chaque site est à
15 +# /wp-content/uploads/_sourceimmo/_configs.json (account_id/api_key/app_id +
16 +# default_view). Le registre est régénérable par scripts/refresh_via_capitale.
17 +# -----------------------------------------------------------------------------
18 +from __future__ import annotations
19 +
20 +import json
21 +import re
22 +from pathlib import Path
23 +
24 +from .sourceimmo import SourceImmoConnector
25 +from ..schema import PropertyListing
26 +
27 +REGISTRY = Path(__file__).resolve().parent.parent.parent / "data" / "via_capitale_agencies.json"
28 +
29 +
30 +class ViaCapitaleAgenceConnector(SourceImmoConnector):
31 + """Sous-agence Via Capitale sur source.immo. Les sous-classes fournissent
32 + account_id / api_key / app_id / view_id / site_url / agency_name."""
33 +
34 + agency_name = "Via Capitale"
35 +
36 + def fetch(self) -> list[PropertyListing]:
37 + # auto-guérison : recharger les identifiants depuis la config publique
38 + # du site (résilient si source.immo fait tourner ses UUID).
39 + try:
40 + cfg = self.get(
41 + f"{self.site_url.rstrip('/')}/wp-content/uploads/_sourceimmo/_configs.json"
42 + ).json()
43 + self.account_id = cfg.get("account_id", self.account_id)
44 + self.api_key = cfg.get("api_key", self.api_key)
45 + self.app_id = cfg.get("app_id", self.app_id)
46 + self.view_id = cfg.get("default_view", self.view_id)
47 + except Exception:
48 + pass # on garde les valeurs du registre
49 + return super().fetch()
50 +
51 + def _to_listing(self, it: dict) -> PropertyListing | None:
52 + lst = super()._to_listing(it)
53 + if lst is not None:
54 + lst.broker_name = self.agency_name
55 + # URL lisible vers la fiche de la sous-agence quand le slug est dispo
56 + ref = it.get("ref_number") or it.get("id")
57 + if ref and self.site_url:
58 + lst.url = f"{self.site_url.rstrip('/')}/propriete/{ref}"
59 + return lst
60 +
61 +
62 +def _load() -> list[dict]:
63 + try:
64 + return json.loads(REGISTRY.read_text(encoding="utf-8"))
65 + except Exception:
66 + return []
67 +
68 +
69 +def _slug(banner: str) -> str:
70 + return "via_ag_" + re.sub(r"[^a-z0-9]+", "_", banner.lower()).strip("_")
71 +
72 +
73 +for _ag in _load():
74 + if not all(_ag.get(k) for k in ("domain", "account", "api", "app", "view")):
75 + continue
76 + _sid = _slug(_ag["banner"])
77 + _name = "Via Capitale " + _ag["banner"].replace("-", " ").title()
78 + globals()[_sid.upper()] = type(
79 + "".join(p.title() for p in _sid.split("_")),
80 + (ViaCapitaleAgenceConnector,),
81 + {
82 + "source_id": _sid,
83 + "account_id": _ag["account"],
84 + "api_key": _ag["api"],
85 + "app_id": _ag["app"],
86 + "view_id": _ag["view"],
87 + "site_url": f"https://{_ag['domain']}",
88 + "agency_name": _name,
89 + },
90 + )
added immoka/db.py +318 −0
@@ -0,0 +1,318 @@
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 + description TEXT,
55 + features TEXT, -- JSON (liste de textes source)
56 + details TEXT, -- JSON (champs structurés)
57 + images TEXT, -- JSON
58 + lat REAL,
59 + lng REAL,
60 + geocode_failed INTEGER DEFAULT 0,
61 + content_hash TEXT,
62 + first_seen REAL,
63 + last_seen REAL,
64 + updated_at REAL,
65 + miss_count INTEGER DEFAULT 0,
66 + active INTEGER DEFAULT 1
67 +);
68 +CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source);
69 +CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city);
70 +CREATE INDEX IF NOT EXISTS idx_listings_type ON listings(property_type);
71 +CREATE INDEX IF NOT EXISTS idx_listings_active ON listings(active);
72 +CREATE INDEX IF NOT EXISTS idx_listings_extid ON listings(external_id);
73 +
74 +CREATE TABLE IF NOT EXISTS sync_log (
75 + id INTEGER PRIMARY KEY AUTOINCREMENT,
76 + source TEXT,
77 + ts REAL,
78 + found INTEGER,
79 + added INTEGER,
80 + updated INTEGER,
81 + removed INTEGER,
82 + ok INTEGER,
83 + message TEXT,
84 + stats TEXT -- JSON : taux de champs null, missed, alerte…
85 +);
86 +
87 +CREATE TABLE IF NOT EXISTS detail_cache (
88 + source TEXT NOT NULL,
89 + external_id TEXT NOT NULL,
90 + key TEXT, -- hash du contenu « liste » de l'annonce
91 + payload TEXT, -- JSON opaque propre au connecteur
92 + fetched_at REAL,
93 + PRIMARY KEY (source, external_id)
94 +);
95 +
96 +CREATE TABLE IF NOT EXISTS price_log (
97 + uid TEXT NOT NULL,
98 + ts REAL NOT NULL,
99 + price REAL -- prix observé (baisses/hausses de prix demandé)
100 +);
101 +CREATE INDEX IF NOT EXISTS idx_price_log_uid ON price_log(uid);
102 +
103 +CREATE TABLE IF NOT EXISTS geocode_cache (
104 + address TEXT PRIMARY KEY,
105 + lat REAL,
106 + lng REAL,
107 + provider TEXT,
108 + failed INTEGER DEFAULT 0,
109 + ts REAL
110 +);
111 +"""
112 +
113 +
114 +def connect() -> sqlite3.Connection:
115 + DB_PATH.parent.mkdir(parents=True, exist_ok=True)
116 + con = sqlite3.connect(DB_PATH, timeout=60)
117 + con.row_factory = sqlite3.Row
118 + # WAL + busy_timeout : tolère les accès concurrents (watcher + web + sync
119 + # manuel écrivent la même base) sans « database is locked ».
120 + con.execute("PRAGMA journal_mode=WAL")
121 + con.execute("PRAGMA busy_timeout=60000")
122 + con.execute("PRAGMA synchronous=NORMAL")
123 + con.executescript(_SCHEMA)
124 + con.commit()
125 + return con
126 +
127 +
128 +# ---------------------------------------------------------------------------
129 +# Synchronisation d'une source
130 +# ---------------------------------------------------------------------------
131 +
132 +def _drift_alert(con: sqlite3.Connection, source: str, found: int,
133 + null_price_rate: float) -> str | None:
134 + """Détecte une dérive du connecteur (chute du volume ou des prix extraits)."""
135 + hist = con.execute(
136 + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1"
137 + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall()
138 + if len(hist) < 3:
139 + return None
140 + med_found = statistics.median(r["found"] for r in hist)
141 + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found:
142 + return (f"dérive: {found} propriété(s) trouvée(s) contre une médiane de "
143 + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur")
144 + if found >= DRIFT_MIN_BASE and null_price_rate >= 0.8:
145 + rates = []
146 + for r in hist:
147 + try:
148 + rates.append(json.loads(r["stats"] or "{}")["null_price_rate"])
149 + except (KeyError, ValueError, TypeError):
150 + continue
151 + if rates and statistics.median(rates) <= 0.3:
152 + return (f"dérive: {null_price_rate:.0%} des propriétés sans prix "
153 + f"(habituellement {statistics.median(rates):.0%}) — "
154 + "le format de la source a probablement changé")
155 + return None
156 +
157 +
158 +def sync_source(con: sqlite3.Connection, source: str,
159 + listings: list[PropertyListing]) -> dict:
160 + """Synchronise les propriétés d'une source.
161 +
162 + - nouvelle propriété -> insertion
163 + - propriété modifiée -> mise à jour (comparaison de content_hash)
164 + - propriété disparue -> miss_count += 1, puis active=0 après MISS_GRACE
165 + exécutions consécutives (vendue ou retirée)
166 + - dérive détectée -> alerte consignée, retraits suspendus
167 + """
168 + now = time.time()
169 + added = updated = 0
170 + seen_uids = set()
171 +
172 + n = len(listings)
173 + null_price = sum(1 for l in listings if l.price is None)
174 + null_addr = sum(1 for l in listings if not l.address)
175 + null_price_rate = round(null_price / n, 3) if n else 0.0
176 +
177 + alert = _drift_alert(con, source, n, null_price_rate)
178 +
179 + for lst in listings:
180 + seen_uids.add(lst.uid)
181 + h = lst.content_hash()
182 + row = con.execute("SELECT content_hash, price FROM listings WHERE uid=?",
183 + (lst.uid,)).fetchone()
184 + params = dict(
185 + uid=lst.uid, source=lst.source, external_id=lst.external_id,
186 + url=lst.url, title=lst.title, address=lst.address,
187 + sector=lst.sector, city=lst.city, region=lst.region,
188 + property_type=lst.property_type, price=lst.price,
189 + price_label=lst.price_label, bedrooms=lst.bedrooms,
190 + bathrooms=lst.bathrooms, powder_rooms=lst.powder_rooms,
191 + area_sqft=lst.area_sqft, lot_sqft=lst.lot_sqft,
192 + year_built=lst.year_built, mls=lst.mls, status=lst.status,
193 + broker_name=lst.broker_name, broker_phone=lst.broker_phone,
194 + description=lst.description,
195 + features=json.dumps(lst.features, ensure_ascii=False),
196 + details=json.dumps(lst.details, ensure_ascii=False),
197 + images=json.dumps(lst.images, ensure_ascii=False),
198 + lat=lst.lat, lng=lst.lng, content_hash=h, now=now,
199 + )
200 + if row is None:
201 + con.execute(
202 + """INSERT INTO listings (uid, source, external_id, url, title,
203 + address, sector, city, region, property_type, price,
204 + price_label, bedrooms, bathrooms, powder_rooms, area_sqft,
205 + lot_sqft, year_built, mls, status, broker_name, broker_phone,
206 + description, features, details, images, lat, lng,
207 + content_hash, first_seen, last_seen, updated_at,
208 + miss_count, active)
209 + VALUES (:uid,:source,:external_id,:url,:title,:address,
210 + :sector,:city,:region,:property_type,:price,:price_label,
211 + :bedrooms,:bathrooms,:powder_rooms,:area_sqft,:lot_sqft,
212 + :year_built,:mls,:status,:broker_name,:broker_phone,
213 + :description,:features,:details,:images,:lat,:lng,
214 + :content_hash,:now,:now,:now,0,1)""", params)
215 + if lst.price is not None:
216 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
217 + (lst.uid, now, lst.price))
218 + added += 1
219 + elif row["content_hash"] != h:
220 + # COALESCE : ne jamais écraser des coordonnées géocodées par null
221 + con.execute(
222 + """UPDATE listings SET url=:url, title=:title,
223 + address=:address, sector=:sector, city=:city, region=:region,
224 + property_type=:property_type, price=:price,
225 + price_label=:price_label, bedrooms=:bedrooms,
226 + bathrooms=:bathrooms, powder_rooms=:powder_rooms,
227 + area_sqft=:area_sqft, lot_sqft=:lot_sqft,
228 + year_built=:year_built, mls=:mls, status=:status,
229 + broker_name=:broker_name, broker_phone=:broker_phone,
230 + description=:description, features=:features,
231 + details=:details, images=:images,
232 + lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng),
233 + content_hash=:content_hash, last_seen=:now,
234 + updated_at=:now, miss_count=0, active=1
235 + WHERE uid=:uid""", params)
236 + if lst.price != row["price"]: # baisse/hausse de prix -> historique
237 + con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)",
238 + (lst.uid, now, lst.price))
239 + updated += 1
240 + else:
241 + con.execute(
242 + "UPDATE listings SET last_seen=?, miss_count=0, active=1 WHERE uid=?",
243 + (now, lst.uid))
244 +
245 + # Propriétés de cette source qui n'apparaissent plus : délai de grâce,
246 + # puis désactivation (vendue/retirée). Suspendu si dérive détectée.
247 + removed = missed = 0
248 + if not alert:
249 + for r in con.execute(
250 + "SELECT uid, miss_count FROM listings WHERE source=? AND active=1",
251 + (source,)).fetchall():
252 + if r["uid"] in seen_uids:
253 + continue
254 + missed += 1
255 + if r["miss_count"] + 1 >= MISS_GRACE:
256 + con.execute(
257 + "UPDATE listings SET active=0, miss_count=?, updated_at=?"
258 + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"]))
259 + removed += 1
260 + else:
261 + con.execute("UPDATE listings SET miss_count=miss_count+1 WHERE uid=?",
262 + (r["uid"],))
263 +
264 + stats = {
265 + "null_price_rate": null_price_rate,
266 + "null_address_rate": round(null_addr / n, 3) if n else 0.0,
267 + "missed": missed,
268 + }
269 + if alert:
270 + stats["alert"] = alert
271 + con.execute(
272 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok,"
273 + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)",
274 + (source, now, n, added, updated, removed, alert or "ok",
275 + json.dumps(stats, ensure_ascii=False)))
276 + con.commit()
277 + out = {"source": source, "found": n, "added": added,
278 + "updated": updated, "removed": removed}
279 + if alert:
280 + out["alert"] = alert
281 + return out
282 +
283 +
284 +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None:
285 + con.execute(
286 + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)"
287 + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message))
288 + con.commit()
289 +
290 +
291 +# ---------------------------------------------------------------------------
292 +# Cache des pages détail (« détail si nouveau/modifié »)
293 +# ---------------------------------------------------------------------------
294 +
295 +def get_cached_detail(con: sqlite3.Connection, source: str,
296 + external_id: str, key: str) -> dict | None:
297 + """Payload détail mis en cache si la clé (hash liste) n'a pas changé."""
298 + row = con.execute(
299 + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?",
300 + (source, external_id)).fetchone()
301 + if row and row["key"] == key and row["payload"]:
302 + try:
303 + return json.loads(row["payload"])
304 + except ValueError:
305 + return None
306 + return None
307 +
308 +
309 +def put_cached_detail(con: sqlite3.Connection, source: str,
310 + external_id: str, key: str, payload: dict) -> None:
311 + con.execute(
312 + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)"
313 + " VALUES (?,?,?,?,?)"
314 + " ON CONFLICT(source, external_id) DO UPDATE SET"
315 + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at",
316 + (source, external_id, key, json.dumps(payload, ensure_ascii=False),
317 + time.time()))
318 + con.commit()
added immoka/ingest.py +58 −0
@@ -0,0 +1,58 @@
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 + stats = db.sync_source(con, sid, finalized)
38 + stats["seconds"] = round(time.time() - t0, 1)
39 + if dropped:
40 + stats["dropped"] = dropped
41 + if stats.get("alert"):
42 + print(f"[immo-ka] ⚠ ALERTE {sid} : {stats['alert']}")
43 + print(f"[immo-ka] {stats}")
44 + results.append(stats)
45 + except Exception as exc: # robustesse : une source ne bloque pas les autres
46 + db.log_failure(con, sid, f"{exc}")
47 + traceback.print_exc()
48 + results.append({"source": sid, "error": str(exc)})
49 + con.close()
50 + return results
51 +
52 +
53 +def watch(interval_seconds: int = 3600) -> None:
54 + """Boucle de synchronisation périodique (équivalent webhook, via PM2/cron)."""
55 + while True:
56 + run()
57 + print(f"[immo-ka] prochaine synchronisation dans {interval_seconds // 60} min")
58 + time.sleep(interval_seconds)
added immoka/normalize.py +182 −0
@@ -0,0 +1,182 @@
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 + "normalize_property_type", "extract_bedrooms_bathrooms",
21 +]
22 +
23 +
24 +def strip_accents(text: str) -> str:
25 + return "".join(c for c in unicodedata.normalize("NFD", text or "")
26 + if unicodedata.category(c) != "Mn")
27 +
28 +
29 +def clean_address(text: str) -> str:
30 + """Nettoie une adresse civique (espaces, virgules doublées, apostrophes)."""
31 + t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip()
32 + t = re.sub(r"\s*,\s*", ", ", t)
33 + t = re.sub(r"(, )+", ", ", t).strip(", ")
34 + return t
35 +
36 +
37 +# ---------------------------------------------------------------------------
38 +# Prix
39 +# ---------------------------------------------------------------------------
40 +
41 +_PRICE_RE = re.compile(r"(\d[\d\s  .,]*)\s*(?:\$|CAD)?", re.UNICODE)
42 +
43 +
44 +def parse_price(label: str) -> float | None:
45 + """Extrait un prix de vente d'un libellé source.
46 +
47 + Gère « 459 000 $ », « $459,000 », « 1 249 000$ +tx », « À partir de 399 900 $ ».
48 + Retourne None si aucun montant plausible (>= 10 000 $) n'est trouvé.
49 + """
50 + if not label:
51 + return None
52 + m = _PRICE_RE.search(label.replace(" ", " ").replace(" ", " "))
53 + if not m:
54 + return None
55 + raw = m.group(1).strip()
56 + # « 459 000 » / « 459,000 » / « 459000.00 » — retirer les séparateurs de milliers
57 + raw = raw.replace(" ", "")
58 + if "," in raw and "." in raw:
59 + raw = raw.replace(",", "") # 459,000.00
60 + elif raw.count(",") == 1 and len(raw.split(",")[1]) == 2:
61 + raw = raw.replace(",", ".") # 459000,00 (décimale FR)
62 + else:
63 + raw = raw.replace(",", "")
64 + try:
65 + value = float(raw)
66 + except ValueError:
67 + return None
68 + return value if value >= 10_000 else None
69 +
70 +
71 +def price_is_from(label: str) -> bool:
72 + key = strip_accents((label or "").lower())
73 + return any(k in key for k in ("a partir", "starting", "from", "des "))
74 +
75 +
76 +# ---------------------------------------------------------------------------
77 +# Nombres génériques
78 +# ---------------------------------------------------------------------------
79 +
80 +def parse_int(text) -> int | None:
81 + if text is None:
82 + return None
83 + if isinstance(text, (int, float)):
84 + return int(text)
85 + m = re.search(r"\d+", str(text))
86 + return int(m.group()) if m else None
87 +
88 +
89 +def parse_float(text) -> float | None:
90 + if text is None:
91 + return None
92 + if isinstance(text, (int, float)):
93 + return float(text)
94 + m = re.search(r"\d[\d\s]*(?:[.,]\d+)?", str(text))
95 + if not m:
96 + return None
97 + try:
98 + return float(m.group().replace(" ", "").replace(",", "."))
99 + except ValueError:
100 + return None
101 +
102 +
103 +_SQFT_RE = re.compile(r"([\d\s ,.]+)\s*(pi2|pi²|pc|sq\.?\s*?ft|ft2|ft²)",
104 + re.IGNORECASE)
105 +_SQM_RE = re.compile(r"([\d\s ,.]+)\s*(m2||mc)", re.IGNORECASE)
106 +
107 +
108 +def parse_area_sqft(text: str) -> float | None:
109 + """Superficie habitable en pi² (convertit les m² au besoin)."""
110 + if not text:
111 + return None
112 + t = text.replace(" ", " ")
113 + m = _SQFT_RE.search(t)
114 + if m:
115 + v = parse_float(m.group(1))
116 + return round(v) if v and v > 50 else None
117 + m = _SQM_RE.search(t)
118 + if m:
119 + v = parse_float(m.group(1))
120 + return round(v * 10.7639) if v and v > 5 else None
121 + return None
122 +
123 +
124 +def parse_lot_sqft(text: str) -> float | None:
125 + """Superficie de terrain en pi² — mêmes unités que parse_area_sqft."""
126 + return parse_area_sqft(text)
127 +
128 +
129 +# ---------------------------------------------------------------------------
130 +# Type de propriété
131 +# ---------------------------------------------------------------------------
132 +
133 +# Vocabulaire canonique d'Immo-Ka (affiché dans les filtres du frontend)
134 +_TYPE_MAP = [
135 + # (mots-clés dans le texte source normalisé, type canonique)
136 + (("plain-pied", "bungalow",), "Maison"),
137 + (("maison a etages", "a etage", "deux etages", "cottage",), "Maison"),
138 + (("maison mobile", "unimodulaire",), "Maison mobile"),
139 + (("jumele", "semi-detache", "semi detache",), "Jumelé"),
140 + (("maison de ville", "townhouse", "en rangee",), "Maison de ville"),
141 + (("unifamiliale", "maison", "house", "residence", "split",), "Maison"),
142 + (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse",
143 + "studio",), "Condo"),
144 + (("duplex",), "Duplex"),
145 + (("triplex",), "Triplex"),
146 + (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement",
147 + "immeuble a revenus", "revenus",), "Multiplex"),
148 + (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",),
149 + "Chalet"),
150 + (("terre", "terrain", "lot ", "land",), "Terrain"),
151 + (("ferme", "fermette", "agricole", "hobby farm",), "Fermette/Agricole"),
152 + (("commercial", "commerce", "industriel", "bureau", "local",), "Commercial"),
153 +]
154 +
155 +
156 +def normalize_property_type(text: str) -> str:
157 + key = strip_accents((text or "").strip().lower())
158 + if not key:
159 + return ""
160 + for keywords, canon in _TYPE_MAP:
161 + if any(k in key for k in keywords):
162 + return canon
163 + return text.strip().capitalize()
164 +
165 +
166 +# ---------------------------------------------------------------------------
167 +# Chambres / salles de bains depuis du texte libre
168 +# ---------------------------------------------------------------------------
169 +
170 +_BED_RE = re.compile(r"(\d+)\s*(?:ch(?:ambre)?s?|cac|bed(?:room)?s?)\b",
171 + re.IGNORECASE)
172 +_BATH_RE = re.compile(r"(\d+)\s*(?:sdb|salle?s?\s+de\s+bains?|bath(?:room)?s?)",
173 + re.IGNORECASE)
174 +
175 +
176 +def extract_bedrooms_bathrooms(text: str) -> tuple[int | None, int | None]:
177 + if not text:
178 + return None, None
179 + beds = _BED_RE.search(text)
180 + baths = _BATH_RE.search(text)
181 + return (int(beds.group(1)) if beds else None,
182 + int(baths.group(1)) if baths else None)
added immoka/schema.py +113 −0
@@ -0,0 +1,113 @@
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 + extract_bedrooms_bathrooms,
24 + normalize_property_type,
25 + parse_area_sqft,
26 + parse_int,
27 + parse_lot_sqft,
28 + parse_price,
29 + price_is_from,
30 + strip_accents,
31 +)
32 +
33 +__all__ = ["PropertyListing"]
34 +
35 +
36 +@dataclass
37 +class PropertyListing:
38 + """Propriété à vendre standardisée Immo-Ka."""
39 +
40 + source: str # id de l'agence (voir data/sources.json)
41 + external_id: str # identifiant chez la source (souvent le n° Centris/MLS)
42 + url: str # page de la propriété chez la source
43 + title: str = "" # ex. "Maison à étages à vendre — Lévis"
44 + address: str = "" # adresse civique
45 + sector: str = "" # quartier/arrondissement
46 + city: str = "" # Québec, Lévis, Montréal…
47 + region: str = "" # région administrative (Capitale-Nationale…)
48 + property_type: str = "" # Maison, Condo, Duplex, Terrain… (canonique)
49 + price: float | None = None # prix demandé ($ CAD)
50 + price_label: str = "" # texte original (ex. "459 000 $ +tx")
51 + bedrooms: int | None = None # chambres
52 + bathrooms: int | None = None # salles de bains
53 + powder_rooms: int | None = None # salles d'eau
54 + area_sqft: float | None = None # superficie habitable (pi²)
55 + lot_sqft: float | None = None # superficie du terrain (pi²)
56 + year_built: int | None = None
57 + mls: str = "" # numéro Centris/MLS si affiché par la source
58 + status: str = "a-vendre" # a-vendre | vendu | conditionnel
59 + broker_name: str = "" # courtier inscripteur
60 + broker_phone: str = ""
61 + description: str = ""
62 + features: list[str] = field(default_factory=list) # caractéristiques (texte source)
63 + details: dict = field(default_factory=dict) # champs structurés (JSON)
64 + images: list[str] = field(default_factory=list) # URLs absolues
65 + lat: float | None = None
66 + lng: float | None = None
67 +
68 + @property
69 + def uid(self) -> str:
70 + return f"{self.source}:{self.external_id}"
71 +
72 + def content_hash(self) -> str:
73 + """Hash du contenu pour la détection de changements (pseudo-webhook)."""
74 + payload = asdict(self)
75 + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
76 + return hashlib.sha256(blob.encode("utf-8")).hexdigest()
77 +
78 + def finalize(self) -> "PropertyListing":
79 + """Applique la normalisation commune. Appelé par le pipeline d'ingestion.
80 +
81 + Idempotent ; ne remplace jamais une valeur explicite du connecteur.
82 + """
83 + self.title = (self.title or "").strip()
84 + self.address = clean_address(self.address)
85 + self.city = (self.city or "").strip()
86 + self.property_type = normalize_property_type(self.property_type)
87 +
88 + if self.price is None:
89 + self.price = parse_price(self.price_label)
90 + if self.price_label and price_is_from(self.price_label):
91 + self.details.setdefault("price_from", True)
92 +
93 + texte = " ".join(filter(None, (self.title, self.description,
94 + " ".join(self.features))))
95 + if self.bedrooms is None or self.bathrooms is None:
96 + beds, baths = extract_bedrooms_bathrooms(texte)
97 + if self.bedrooms is None:
98 + self.bedrooms = beds
99 + if self.bathrooms is None:
100 + self.bathrooms = baths
101 + if self.area_sqft is None:
102 + self.area_sqft = parse_area_sqft(texte)
103 + if self.lot_sqft is None and "terrain" in strip_accents(texte.lower()):
104 + self.lot_sqft = parse_lot_sqft(texte)
105 + self.year_built = parse_int(self.year_built)
106 +
107 + # coordonnées fournies par la source : rejeter tout point hors de la
108 + # province (lat/lng inversés, 0/0, coquilles)
109 + if self.lat is not None and self.lng is not None:
110 + if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0):
111 + self.lat = self.lng = None
112 +
113 + return self
added immoka/web.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 +# web.py : API FastAPI (JSON) + service du frontend statique (frontend/)
5 +# -----------------------------------------------------------------------------
6 +from __future__ import annotations
7 +
8 +import json
9 +import threading
10 +from pathlib import Path
11 +
12 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
13 +from fastapi.middleware.cors import CORSMiddleware
14 +from fastapi.responses import FileResponse
15 +from fastapi.staticfiles import StaticFiles
16 +
17 +from . import db, ingest
18 +
19 +ROOT = Path(__file__).resolve().parent.parent
20 +SOURCES_PATH = ROOT / "data" / "sources.json"
21 +# Frontend : build Vite (React) si présent, sinon la page statique de secours.
22 +FRONTEND_DIST = ROOT / "frontend" / "dist"
23 +FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"
24 +
25 +app = FastAPI(title="Immo-Ka API", version="0.1",
26 + description="Agrégateur de maisons à vendre — province de Québec")
27 +app.add_middleware(CORSMiddleware, allow_origins=["*"],
28 + allow_methods=["*"], allow_headers=["*"])
29 +
30 +_sync_lock = threading.Lock()
31 +
32 +# Déduplication de la famille RE/MAX : le flux central (remax_quebec) et les
33 +# ~42 connecteurs de sous-agences (remax_ag_*) décrivent les MÊMES inscriptions,
34 +# identifiées de façon unique par leur numéro Centris (= external_id). On masque
35 +# donc toute fiche de sous-agence dès qu'une fiche de plus haute priorité existe
36 +# (le central enrichi d'abord, puis la sous-agence au plus petit uid). Les fiches
37 +# du central et des autres agences ne sont jamais masquées. → aucun double-comptage,
38 +# et les sous-agences prennent le relais automatiquement si le central disparaît.
39 +DEDUP_CLAUSE = (
40 + " AND NOT EXISTS (SELECT 1 FROM listings d WHERE d.active=1"
41 + " AND d.external_id = listings.external_id AND d.uid <> listings.uid"
42 + " AND (listings.source LIKE '%\\_ag\\_%' ESCAPE '\\')"
43 + " AND ((d.source NOT LIKE '%\\_ag\\_%' ESCAPE '\\')"
44 + " OR (d.source LIKE '%\\_ag\\_%' ESCAPE '\\' AND d.uid < listings.uid)))"
45 +)
46 +
47 +
48 +def _row_to_dict(row) -> dict:
49 + d = dict(row)
50 + d["features"] = json.loads(d.get("features") or "[]")
51 + d["images"] = json.loads(d.get("images") or "[]")
52 + d["details"] = json.loads(d.get("details") or "{}")
53 + return d
54 +
55 +
56 +@app.get("/api/listings")
57 +def list_listings(
58 + city: str | None = None,
59 + sector: str | None = None,
60 + region: str | None = None,
61 + property_type: str | None = None,
62 + source: str | None = None,
63 + price_max: float | None = None,
64 + price_min: float | None = None,
65 + bedrooms_min: int | None = None,
66 + bathrooms_min: int | None = None,
67 + area_min: float | None = None, # superficie habitable minimale (pi²)
68 + q: str | None = None,
69 + active: int = 1,
70 + sort: str = "price_asc", # price_asc | price_desc | recent
71 + limit: int = Query(500, le=2000),
72 + offset: int = 0,
73 +):
74 + con = db.connect()
75 + sql = "SELECT * FROM listings WHERE 1=1"
76 + args: list = []
77 + if active in (0, 1):
78 + sql += " AND active=?"; args.append(active)
79 + if city:
80 + sql += " AND city=?"; args.append(city)
81 + if sector:
82 + sql += " AND sector LIKE ?"; args.append(f"%{sector}%")
83 + if region:
84 + sql += " AND region=?"; args.append(region)
85 + if property_type:
86 + sql += " AND property_type=?"; args.append(property_type)
87 + if source:
88 + sql += " AND source=?"; args.append(source)
89 + if price_max is not None:
90 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
91 + if price_min is not None:
92 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
93 + if bedrooms_min is not None:
94 + sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)
95 + if bathrooms_min is not None:
96 + sql += " AND bathrooms IS NOT NULL AND bathrooms>=?"; args.append(bathrooms_min)
97 + if area_min is not None:
98 + sql += " AND area_sqft IS NOT NULL AND area_sqft>=?"; args.append(area_min)
99 + if q:
100 + sql += " AND (title LIKE ? OR address LIKE ? OR city LIKE ? OR mls LIKE ?)"
101 + args += [f"%{q}%"] * 4
102 + sql += DEDUP_CLAUSE
103 + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
104 + order = {
105 + "price_asc": " ORDER BY price IS NULL, price ASC",
106 + "price_desc": " ORDER BY price IS NULL, price DESC",
107 + "recent": " ORDER BY first_seen DESC",
108 + }.get(sort, " ORDER BY price IS NULL, price ASC")
109 + sql += order + " LIMIT ? OFFSET ?"
110 + args += [limit, offset]
111 + rows = [_row_to_dict(r) for r in con.execute(sql, args).fetchall()]
112 + con.close()
113 + return {"total": total, "count": len(rows), "listings": rows}
114 +
115 +
116 +@app.get("/api/listings/{uid}")
117 +def get_listing(uid: str):
118 + con = db.connect()
119 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
120 + d = None
121 + if row is not None:
122 + d = _row_to_dict(row)
123 + # historique de prix (baisses/hausses du prix demandé)
124 + d["price_history"] = [dict(r) for r in con.execute(
125 + "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10",
126 + (uid,)).fetchall()]
127 + con.close()
128 + if d is None:
129 + raise HTTPException(404, "Propriété introuvable")
130 + return d
131 +
132 +
133 +@app.get("/api/listings.geojson")
134 +def listings_geojson(
135 + city: str | None = None,
136 + property_type: str | None = None,
137 + source: str | None = None,
138 + price_max: float | None = None,
139 + price_min: float | None = None,
140 + bedrooms_min: int | None = None,
141 +):
142 + """Propriétés géolocalisées (marqueurs de carte, champs allégés)."""
143 + con = db.connect()
144 + sql = ("SELECT uid, title, address, price, price_label, property_type,"
145 + " bedrooms, bathrooms, source, city, sector, images, lat, lng"
146 + " FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL")
147 + args: list = []
148 + if city:
149 + sql += " AND city=?"; args.append(city)
150 + if property_type:
151 + sql += " AND property_type=?"; args.append(property_type)
152 + if source:
153 + sql += " AND source=?"; args.append(source)
154 + if price_max is not None:
155 + sql += " AND price IS NOT NULL AND price<=?"; args.append(price_max)
156 + if price_min is not None:
157 + sql += " AND price IS NOT NULL AND price>=?"; args.append(price_min)
158 + if bedrooms_min is not None:
159 + sql += " AND bedrooms IS NOT NULL AND bedrooms>=?"; args.append(bedrooms_min)
160 + sql += DEDUP_CLAUSE
161 + features = []
162 + for r in con.execute(sql, args).fetchall():
163 + images = json.loads(r["images"] or "[]")
164 + features.append({
165 + "type": "Feature",
166 + "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]},
167 + "properties": {
168 + "uid": r["uid"], "title": r["title"], "address": r["address"],
169 + "price": r["price"], "price_label": r["price_label"],
170 + "property_type": r["property_type"], "bedrooms": r["bedrooms"],
171 + "bathrooms": r["bathrooms"], "source": r["source"],
172 + "city": r["city"], "sector": r["sector"],
173 + "image": images[0] if images else None,
174 + },
175 + })
176 + con.close()
177 + return {"type": "FeatureCollection", "features": features}
178 +
179 +
180 +@app.get("/api/facets")
181 +def facets(city: str | None = None):
182 + """Valeurs distinctes pour construire les filtres du frontend."""
183 + con = db.connect()
184 + sector_sql = "SELECT DISTINCT sector FROM listings WHERE active=1 AND sector<>''"
185 + sector_args: list = []
186 + if city:
187 + sector_sql += " AND city=?"
188 + sector_args.append(city)
189 + out = {
190 + "cities": [r["city"] for r in con.execute(
191 + "SELECT DISTINCT city FROM listings WHERE active=1 AND city<>'' ORDER BY city")],
192 + "sectors": [r["sector"] for r in con.execute(
193 + sector_sql + " ORDER BY sector", sector_args)],
194 + "property_types": [r["property_type"] for r in con.execute(
195 + "SELECT DISTINCT property_type FROM listings WHERE active=1"
196 + " AND property_type<>'' ORDER BY property_type")],
197 + "sources": [dict(r) for r in con.execute(
198 + "SELECT source, COUNT(*) n FROM listings WHERE active=1"
199 + + DEDUP_CLAUSE + " GROUP BY source ORDER BY n DESC")],
200 + }
201 + con.close()
202 + return out
203 +
204 +
205 +@app.get("/api/sources")
206 +def sources():
207 + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"]
208 + con = db.connect()
209 + counts = {r["source"]: r["n"] for r in con.execute(
210 + "SELECT source, COUNT(*) n FROM listings WHERE active=1 GROUP BY source")}
211 + last = {r["source"]: r["ts"] for r in con.execute(
212 + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")}
213 + con.close()
214 + for s in registry:
215 + s["active_listings"] = counts.get(s["id"], 0)
216 + s["last_sync"] = last.get(s["id"])
217 + return {"sources": registry}
218 +
219 +
220 +@app.get("/api/stats")
221 +def stats():
222 + con = db.connect()
223 + row = con.execute(
224 + """SELECT COUNT(*) total,
225 + COUNT(DISTINCT source) sources,
226 + COUNT(DISTINCT city) cities,
227 + AVG(price) avg_price,
228 + MIN(price) min_price,
229 + MAX(price) max_price
230 + FROM listings WHERE active=1""" + DEDUP_CLAUSE).fetchone()
231 + log = [dict(r) for r in con.execute(
232 + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")]
233 + con.close()
234 + return {**dict(row), "recent_syncs": log}
235 +
236 +
237 +@app.post("/api/sync")
238 +def trigger_sync(background: BackgroundTasks, source: str | None = None):
239 + """Déclenche une synchronisation (équivalent d'un webhook entrant)."""
240 + def _job():
241 + with _sync_lock:
242 + ingest.run([source] if source else None)
243 + background.add_task(_job)
244 + return {"status": "démarré", "source": source or "toutes"}
245 +
246 +
247 +# --- Frontend statique -------------------------------------------------------
248 +if FRONTEND_DIR.exists():
249 +
250 + if (FRONTEND_DIR / "assets").is_dir():
251 + app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
252 +
253 + @app.get("/{full_path:path}")
254 + def spa(full_path: str):
255 + target = FRONTEND_DIR / full_path
256 + if full_path and target.is_file():
257 + return FileResponse(target)
258 + return FileResponse(FRONTEND_DIR / "index.html")
added requirements.txt +4 −0
@@ -0,0 +1,4 @@
1 +requests>=2.31
2 +beautifulsoup4>=4.12
3 +fastapi>=0.110
4 +uvicorn>=0.29
added run.py +53 −0
@@ -0,0 +1,53 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# run.py : point d'entrée — `sync`, `watch`, `serve`
6 +# -----------------------------------------------------------------------------
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise les propriétés
9 + python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8090)
11 + python run.py list # liste les connecteurs enregistrés
12 +"""
13 +from __future__ import annotations
14 +
15 +import os
16 +import sys
17 +from pathlib import Path
18 +
19 +# Charger .env (FIRECRAWL_API_KEY, etc.) sans dépendance externe
20 +_env = Path(__file__).parent / ".env"
21 +if _env.exists():
22 + for line in _env.read_text().splitlines():
23 + line = line.strip()
24 + if line and not line.startswith("#") and "=" in line:
25 + k, _, v = line.partition("=")
26 + os.environ.setdefault(k.strip(), v.strip())
27 +
28 +
29 +def main() -> None:
30 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
31 + if cmd == "sync":
32 + from immoka import ingest
33 + ingest.run(sys.argv[2:] or None)
34 + elif cmd == "watch":
35 + from immoka import ingest
36 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 60
37 + ingest.watch(minutes * 60)
38 + elif cmd == "list":
39 + from immoka.connectors import CONNECTORS
40 + for sid in sorted(CONNECTORS):
41 + print(sid)
42 + print(f"-- {len(CONNECTORS)} connecteur(s)")
43 + elif cmd == "serve":
44 + import uvicorn
45 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
46 + uvicorn.run("immoka.web:app", host="0.0.0.0", port=port)
47 + else:
48 + print(__doc__)
49 + sys.exit(1)
50 +
51 +
52 +if __name__ == "__main__":
53 + main()
added scripts/refresh_via_capitale.py +72 −0
@@ -0,0 +1,72 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Immo-Ka — régénère data/via_capitale_agencies.json en sondant les domaines
4 +# des sous-agences Via Capitale (plateforme source.immo). Étend facilement la
5 +# couverture : ajoute des bannières/domaines à CANDIDATES et relance.
6 +# Usage : .venv/bin/python scripts/refresh_via_capitale.py
7 +# -----------------------------------------------------------------------------
8 +from __future__ import annotations
9 +
10 +import json
11 +import re
12 +import urllib.request
13 +from pathlib import Path
14 +
15 +UA = {"User-Agent": "Mozilla/5.0 Chrome/126"}
16 +CFG = "/wp-content/uploads/_sourceimmo/_configs.json"
17 +OUT = Path(__file__).resolve().parent.parent / "data" / "via_capitale_agencies.json"
18 +
19 +# Domaines connus + bannières à tester (viacapitale<x>.{immo,com,ca}).
20 +KNOWN_DOMAINS = [
21 + "viacapitaleelite.immo", "viacapitaleselect.com",
22 + "viacapitaledumontroyal.com", "viacapitaleacces.com",
23 + "viacapitalepartenaires.com",
24 +]
25 +BANNERS = [
26 + "elite", "select", "dumontroyal", "acces", "horizon", "diamant", "expertise",
27 + "cite", "vendu", "action", "distinction", "premium", "platine", "signature",
28 + "prestige", "excellence", "sommet", "boreal", "alliance", "synergie", "expert",
29 + "optima", "concept", "capitale", "3000", "2000", "or", "plus", "inter",
30 +]
31 +TLDS = [".immo", ".com", ".ca"]
32 +
33 +
34 +def fetch_cfg(domain: str) -> dict | None:
35 + for host in (domain, f"www.{domain}"):
36 + try:
37 + b = urllib.request.urlopen(
38 + urllib.request.Request(f"https://{host}{CFG}", headers=UA), timeout=12
39 + ).read().decode("utf-8", "ignore")
40 + d = json.loads(b)
41 + if d.get("account_id") and d.get("api_key") and d.get("default_view"):
42 + return {"domain": host, "cfg": d}
43 + except Exception:
44 + continue
45 + return None
46 +
47 +
48 +def main() -> None:
49 + domains = set(KNOWN_DOMAINS)
50 + for b in BANNERS:
51 + for t in TLDS:
52 + domains.add(f"viacapitale{b}{t}")
53 + reg, seen = [], set()
54 + for dom in sorted(domains):
55 + r = fetch_cfg(dom)
56 + if not r or r["domain"] in seen:
57 + continue
58 + seen.add(r["domain"])
59 + d = r["cfg"]
60 + banner = re.sub(r"^www\.viacapitale|\.(immo|com|ca)$", "", r["domain"])
61 + reg.append({
62 + "banner": banner, "domain": r["domain"],
63 + "account": d["account_id"], "api": d["api_key"],
64 + "app": d["app_id"], "view": d["default_view"],
65 + })
66 + print(f"OK {banner:16} {r['domain']}")
67 + OUT.write_text(json.dumps(reg, ensure_ascii=False, indent=1), encoding="utf-8")
68 + print(f"→ {len(reg)} sous-agences écrites dans {OUT}")
69 +
70 +
71 +if __name__ == "__main__":
72 + main()
73