feat: initial commit
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 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 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 | +|  |  | | |
| 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|m²)?)")): | |
| 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*(m²|mc|pi²|pi|ft)\s*\|\s*Superficie habitable', re.I) | |
| 200 | +_LOT_RE = re.compile(r'([\d ,]+)\s*(m²|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("&", "&") | |
| 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(" ", " ").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|m²|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 | ||