README — refonte ultra détaillée : badges à jour (22 900+ annonces, 205 connecteurs, 820 villes), galerie de screenshots web + iOS (docs/screenshots/, script Playwright scripts/screenshots.mjs), sections Lou-Ka Maps / SEO-SSR / SSO KA / quartier / PDF / iOS, CLI et API complètes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18 changed files +410 −64
modified
.gitignore
+1 −1
@@ -11,4 +11,4 @@ data/staging-*.db | ||
| 11 | 11 | data/quartier.db |
| 12 | 12 | |
| 13 | 13 | # app iOS native — dépôt séparé (git.spboucher.ai/lou-ka-ios) |
| 14 | −ios/ | |
| 14 | +/ios/ | |
modified
README.md
+355 −63
@@ -7,85 +7,366 @@ | ||
| 7 | 7 | **[www.lou-ka.com](https://www.lou-ka.com)** |
| 8 | 8 | |
| 9 | 9 |  |
| 10 | − | |
| 10 | + | |
| 11 | 11 |  |
| 12 | 12 |  |
| 13 | − | |
| 13 | + | |
| 14 | + | |
| 14 | 15 | |
| 15 | − | |
| 16 | − | |
| 17 | − | |
| 18 | − | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 19 | 22 | |
| 20 | 23 | *Agrégateur indépendant de logements locatifs — chaque annonce avec toutes ses photos, |
| 21 | −ses détails standardisés, et un lien direct vers l'annonce originale du gestionnaire. | |
| 22 | −Toujours à jour, automatiquement.* | |
| 24 | +ses détails standardisés, son quartier documenté, et un lien direct vers l'annonce | |
| 25 | +originale du gestionnaire. Toujours à jour, automatiquement.* | |
| 26 | + | |
| 27 | +<img src="docs/screenshots/accueil.png" alt="Page d'accueil Lou-Ka" width="920"> | |
| 23 | 28 | |
| 24 | 29 | </div> |
| 25 | 30 | |
| 26 | 31 | --- |
| 27 | 32 | |
| 33 | +## Sommaire | |
| 34 | + | |
| 35 | +- [Pourquoi Lou-Ka ?](#pourquoi-lou-ka-) | |
| 36 | +- [Visite guidée](#visite-guidée) | |
| 37 | +- [L'architecture en 30 secondes](#larchitecture-en-30-secondes) | |
| 38 | +- [Le pipeline en détail](#le-pipeline-en-détail) | |
| 39 | +- [Lou-Ka Maps](#lou-ka-maps) | |
| 40 | +- [SEO — SSR léger, sitemaps, JSON-LD](#seo--ssr-léger-sitemaps-json-ld) | |
| 41 | +- [Comptes, SSO KA & profils](#comptes-sso-ka--profils) | |
| 42 | +- [Stats de marché & PDF](#stats-de-marché--pdf) | |
| 43 | +- [App iOS native](#app-ios-native) | |
| 44 | +- [Structure du dépôt](#structure-du-dépôt) | |
| 45 | +- [Démarrage rapide](#démarrage-rapide) | |
| 46 | +- [CLI `run.py`](#cli-runpy) | |
| 47 | +- [Variables d'environnement](#variables-denvironnement) | |
| 48 | +- [API](#api) | |
| 49 | +- [Ajouter un gestionnaire (≈ 30 lignes)](#ajouter-un-gestionnaire--30-lignes) | |
| 50 | +- [Tests](#tests) | |
| 51 | +- [Couverture](#couverture) | |
| 52 | +- [Production](#production) | |
| 53 | +- [Principes](#principes) | |
| 54 | + | |
| 55 | +--- | |
| 56 | + | |
| 28 | 57 | ## Pourquoi Lou-Ka ? |
| 29 | 58 | |
| 30 | −Chercher un appartement au Québec, c'est ouvrir 70 sites web différents — chacun avec sa | |
| 31 | −propre navigation, ses propres filtres, son propre format. **Lou-Ka retourne le problème** : | |
| 32 | −un connecteur dédié par gestionnaire immobilier visite chaque site, normalise chaque annonce | |
| 33 | −vers un schéma unique, et détecte les changements en continu. | |
| 59 | +Chercher un appartement au Québec, c'est ouvrir des dizaines de sites web différents — | |
| 60 | +chacun avec sa propre navigation, ses propres filtres, son propre format. **Lou-Ka | |
| 61 | +retourne le problème** : un connecteur dédié par gestionnaire immobilier visite chaque | |
| 62 | +site, normalise chaque annonce vers un schéma unique, et détecte les changements en | |
| 63 | +continu. | |
| 34 | 64 | |
| 35 | 65 | > Les sites d'agences n'offrent pas de webhooks. Lou-Ka reproduit l'équivalent : |
| 36 | 66 | > **synchronisation périodique + hash de contenu** → ajouts, mises à jour et retraits |
| 37 | −> détectés automatiquement. Une annonce qui disparaît du site source disparaît de Lou-Ka. | |
| 67 | +> détectés automatiquement. Une annonce qui disparaît du site source disparaît de Lou-Ka | |
| 68 | +> (et répond `410 Gone` aux moteurs de recherche). | |
| 69 | + | |
| 70 | +Lou-Ka n'est pas une plateforme d'annonces : c'est un **index fidèle**. Aucun prix | |
| 71 | +inventé, aucune coordonnée devinée, et chaque fiche renvoie vers l'annonce originale | |
| 72 | +du gestionnaire via une passerelle de sortie transparente. | |
| 73 | + | |
| 74 | +## Visite guidée | |
| 75 | + | |
| 76 | +| | | | |
| 77 | +|:---:|:---:| | |
| 78 | +| **Recherche filtrée** — ville, quartier, type (3½…), loyer, animaux, meublé, texte libre | **Vue carte** — Lou-Ka Maps, clusters par prix, « Rechercher dans cette zone » | | |
| 79 | +| <img src="docs/screenshots/annonces.jpg" alt="Grille d'annonces" width="440"> | <img src="docs/screenshots/carte.jpg" alt="Vue carte Lou-Ka Maps" width="440"> | | |
| 80 | +| **Fiche complète** — galerie, badge marché, inclusions, quartier, PDF | **Pages villes (SEO)** — `/ville/quebec`, facettes par type, loyers médians | | |
| 81 | +| <img src="docs/screenshots/fiche-logement.jpg" alt="Fiche d'un logement" width="440"> | <img src="docs/screenshots/ville-quebec.jpg" alt="Page ville Québec" width="440"> | | |
| 82 | +| **Observatoire du marché** — KPI temps réel, loyers par région, rapport PDF | **Registre des sources** — chaque gestionnaire, son statut, sa dernière synchro | | |
| 83 | +| <img src="docs/screenshots/stats.png" alt="Page stats" width="440"> | <img src="docs/screenshots/sources.png" alt="Registre des sources" width="440"> | | |
| 84 | + | |
| 85 | +<div align="center"> | |
| 86 | + | |
| 87 | +**Mobile-first & PWA installable** | |
| 88 | + | |
| 89 | +<img src="docs/screenshots/accueil-mobile.png" alt="Version mobile" width="300"> | |
| 90 | + | |
| 91 | +</div> | |
| 38 | 92 | |
| 39 | 93 | ## L'architecture en 30 secondes |
| 40 | 94 | |
| 41 | 95 | ```mermaid |
| 42 | 96 | flowchart LR |
| 43 | − subgraph Sources["74 gestionnaires immobiliers"] | |
| 44 | − S1["Logisco · Cogir · CAPREIT<br/>Immostar · DMA · Laberge<br/>Akelius · Devimco · Mondev<br/>… 68 connecteurs actifs"] | |
| 97 | + subgraph Sources["265 sources recensées"] | |
| 98 | + S1["Gestionnaires : Logisco · Cogir<br/>CAPREIT · Akelius · Mondev …<br/>Portails : Kijiji · LesPAC<br/>RE/MAX · Sutton · Royal LePage"] | |
| 45 | 99 | end |
| 46 | 100 | subgraph LouKa["Lou-Ka"] |
| 47 | − C["Connecteurs<br/><i>1 adaptateur / site</i>"] --> N["Normalisation<br/><i>schéma Listing unique</i>"] | |
| 48 | − N --> D[("SQLite<br/>hash + diff")] | |
| 49 | − D --> A["API FastAPI<br/>/api/listings · /api/facets"] | |
| 50 | − A --> F["React 18 + Vite<br/>PWA mobile · thème clair"] | |
| 101 | + C["205 connecteurs<br/><i>1 adaptateur / site</i>"] --> N["Normalisation<br/><i>schéma Listing unique</i>"] | |
| 102 | + N --> T["Text mining<br/><i>digest déterministe</i>"] | |
| 103 | + T --> D[("SQLite<br/>hash + diff")] | |
| 104 | + D --> E["Enrichissements<br/><i>géocodage · POI · quartier</i>"] | |
| 105 | + E --> A["API FastAPI<br/>+ SSR SEO"] | |
| 106 | + A --> F["React 18 + Vite<br/>Lou-Ka Maps · PWA"] | |
| 107 | + A --> I["App iOS<br/>SwiftUI natif"] | |
| 51 | 108 | end |
| 52 | 109 | W["⏱ Watcher horaire<br/>(PM2)"] -.-> C |
| 53 | 110 | S1 --> C |
| 54 | 111 | F --> U["🔑 Locataire"] |
| 112 | + I --> U | |
| 55 | 113 | ``` |
| 56 | 114 | |
| 57 | 115 | | Couche | Rôle | Fichiers | |
| 58 | 116 | |---|---|---| |
| 59 | −| **Connecteurs** | 1 module Python par gestionnaire : HTML rendu serveur, API JSON internes (Building Stack, RealVuu, Planpoint, Rentsync, source.immo, JetEngine…), ou Firecrawl pour les sites derrière Cloudflare | `louka/connectors/*.py` | | |
| 60 | −| **Schéma** | `Listing` standardisé : adresse, secteur, ville, type (3½…), prix, disponibilité, commodités, **toutes les images** | `louka/schema.py` | | |
| 61 | −| **Diff engine** | Upsert par hash de contenu — nouvelle / modifiée / disparue (désactivée) | `louka/db.py` | | |
| 62 | −| **API** | Filtres ville / secteur / taille / prix / gestionnaire / recherche, facettes, stats, déclencheur de sync | `louka/web.py` | | |
| 63 | −| **Frontend** | Design « éditorial sharp » : Space Grotesk, ombres décalées, accent lime, ticker temps réel, bottom sheet mobile, galeries photos, PWA installable | `frontend/` | | |
| 117 | +| **Connecteurs** | 1 module Python par source : HTML rendu serveur, API JSON internes (Building Stack, RealVuu, Planpoint, Rentsync, source.immo, JetEngine, WordPress REST…), Firecrawl (Cloudflare) ou Scrapfly (anti-bot) | `louka/connectors/*.py` | | |
| 118 | +| **Schéma** | `Listing` standardisé : adresse, secteur, ville, type (3½…), prix, disponibilité, superficie, commodités, **toutes les images** | `louka/schema.py` | | |
| 119 | +| **Normalisation** | prix, dates ISO, types d'unités, pi², adresses, commodités canoniques | `louka/normalize.py` | | |
| 120 | +| **Text mining** | digest structuré des descriptions — règles regex FR, < 5 ms/annonce, **aucun LLM** | `louka/textmine.py` | | |
| 121 | +| **Diff engine** | upsert par hash de contenu → nouvelle / modifiée / disparue, avec garde-fou anti-dérive | `louka/db.py` | | |
| 122 | +| **Enrichissements** | géocodage (Nominatim + Adresses Québec), POI OSM, statistiques de quartier (recensement 2021, INSPQ, SPVM) | `louka/geocode.py` · `poi.py` · `quartier.py` | | |
| 123 | +| **API + SEO** | filtres, facettes, GeoJSON, stats, PDF ; SSR léger, sitemaps, JSON-LD, 410/404 | `louka/web.py` · `seo.py` | | |
| 124 | +| **Frontend** | design « éditorial sharp » : Space Grotesk, ombres décalées, accent lime, ticker temps réel, PWA | `frontend/` | | |
| 125 | +| **iOS** | app SwiftUI native (dépôt séparé), moteur de reco on-device | `ios/` | | |
| 126 | + | |
| 127 | +## Le pipeline en détail | |
| 128 | + | |
| 129 | +### Connecteurs auto-découverts | |
| 130 | + | |
| 131 | +Le registre parcourt `louka/connectors/` (`pkgutil.iter_modules`) : **déposer un module | |
| 132 | +suffit**, aucun fichier partagé à modifier. `BaseConnector` fournit `get()` (throttlé, | |
| 133 | +poli, User-Agent identifié `LouKaBot`), `get_rendered()` (Firecrawl pour les sites | |
| 134 | +JavaScript/Cloudflare) et `scrapfly()` (anti-bot ASP). Un connecteur qui casse n'affecte | |
| 135 | +jamais les autres : try/except par annonce, journal `sync_log` par source. | |
| 136 | + | |
| 137 | +Dix connecteurs de **portails et bannières de courtage** (Kijiji, LesPAC, RE/MAX, | |
| 138 | +Sutton, Royal LePage, Via Capitale, Proprio Direct, Ubee, Barnes, M Immobilier) | |
| 139 | +complètent les gestionnaires directs, avec limites configurables par variables | |
| 140 | +d'environnement (`LOUKA_*_DETAIL_LIMIT`, `LOUKA_*_MAX_PAGES`). | |
| 141 | + | |
| 142 | +### Diff engine avec garde-fou anti-dérive | |
| 143 | + | |
| 144 | +Chaque annonce est hachée (`content_hash`). À chaque synchro : ajouts, mises à jour et | |
| 145 | +retraits sont déduits du diff. Si une source retourne soudainement beaucoup moins | |
| 146 | +d'annonces que d'habitude (site en panne, HTML changé), le garde-fou | |
| 147 | +(`DRIFT_RATIO = 0.25`) **suspend les retraits** au lieu de vider l'inventaire — la | |
| 148 | +fiabilité d'un agrégateur se joue là. L'historique de prix est conservé dans | |
| 149 | +`price_log` et affiché sur les fiches. | |
| 150 | + | |
| 151 | +### Géocodage sans invention | |
| 152 | + | |
| 153 | +Cache par immeuble (`geocode_cache`), Nominatim (1 req/s) puis **Adresses Québec | |
| 154 | +(MERN ArcGIS)** en repli — y compris un mode **EN LOT** (lots de 200) porté d'Immo-Ka. | |
| 155 | +Chaque résultat est validé contre une bounding box provinciale : hors Québec = rejeté. | |
| 156 | +~70 % des annonces sont géolocalisées ; jamais de coordonnées inventées. | |
| 157 | + | |
| 158 | +### Quartier & proximité | |
| 159 | + | |
| 160 | +- **POI** : commodités à proximité via Overpass/OSM, mutualisées par immeuble | |
| 161 | + (`poi_cache`, clé lat/lng à ~11 m). | |
| 162 | +- **Quartier** : aires de diffusion du recensement 2021 (point-dans-polygone local sur | |
| 163 | + `data/quartier.db`), proximité aux services (PMD StatCan), défavorisation (INSPQ), | |
| 164 | + îlots de chaleur, criminalité (SPVM), écoles (MEQ) — le bloc « Le quartier » de | |
| 165 | + chaque fiche. | |
| 166 | + | |
| 167 | +## Lou-Ka Maps | |
| 168 | + | |
| 169 | +Carte propulsée par **[@groupe-ka/ka-maps](docs/ka-maps-lou-ka.md)** — le framework | |
| 170 | +cartographique partagé du Groupe KA (moteur **Mapbox GL JS**, style Standard **3D**), | |
| 171 | +monté en `React.lazy` : | |
| 172 | + | |
| 173 | +- `GET /api/listings.geojson?bbox=…` piloté par le viewport, requêtes annulables ; | |
| 174 | +- clustering par immeuble avec prix moyen par grappe (`valueClamp [250, 8000]`) ; | |
| 175 | +- « Rechercher en déplaçant la carte », caméra partageable dans l'URL (`?lat&lng&zoom`) ; | |
| 176 | +- bascule 3D/2D, synchronisation liste ↔ carte, compteur « N sur la carte · N hors carte ». | |
| 177 | + | |
| 178 | +## SEO — SSR léger, sitemaps, JSON-LD | |
| 179 | + | |
| 180 | +Le serveur FastAPI (`louka/seo.py`) renvoie **du HTML complet dès la première requête** : | |
| 181 | +même `index.html` que le build Vite, mais avec `<head>` unique (title, description, | |
| 182 | +canonical, hreflang, Open Graph, Twitter) et le contenu essentiel pré-rendu dans | |
| 183 | +`<div id="root">` — React hydrate ensuite. | |
| 184 | + | |
| 185 | +- **Pages programmatiques** : `/villes`, `/ville/{slug}`, `/ville/{slug}/{type}` | |
| 186 | + (« Trois-Rivières » → `trois-rivieres`, « 3½ » → `3-1-2`) ; | |
| 187 | +- **JSON-LD** : `RealEstateListing` + `Apartment` + `Offer` (CAD) + `BreadcrumbList` ; | |
| 188 | +- **Sitemaps dynamiques** : index + pages + villes + annonces (chunks de 10 000) ; | |
| 189 | +- **`410 Gone`** pour les annonces retirées, vrais `404` ailleurs — fin des soft-404 ; | |
| 190 | +- GZip, `/assets` immuable 1 an, HTML `no-cache`. | |
| 191 | + | |
| 192 | +## Comptes, SSO KA & profils | |
| 193 | + | |
| 194 | +- **« Se connecter avec KA »** : SSO via le hub [groupe-ka.com](https://www.groupe-ka.com) | |
| 195 | + (JWT HS256, audience `lou-ka`). Le `ka_id` est **émis par le hub** — source de vérité | |
| 196 | + du groupe ; Google OAuth reste disponible en direct. | |
| 197 | +- Sessions signées HMAC-SHA256 en cookie httpOnly (30 jours), stdlib seulement. | |
| 198 | +- **Locataire** : favoris (poussés vers « Mon univers Ka » du hub), profil carte de | |
| 199 | + membre, page publique opt-in `/u/{ka_id}` — jamais le courriel. | |
| 200 | +- **Gestionnaire** : réclame la page de *sa* source (1:1, premier arrivé), la | |
| 201 | + personnalise, et elle devient publique sur `/g/{source_id}`. | |
| 202 | + | |
| 203 | +## Stats de marché & PDF | |
| 204 | + | |
| 205 | +`louka/marketstats.py` calcule en direct les agrégats du marché (loyer médian/moyen, | |
| 206 | +par région, ville, taille, baisses de prix) — **source unique** pour la page `/stats`, | |
| 207 | +l'API `/api/stats/detailed` et le **rapport de marché PDF** multi-pages | |
| 208 | +(`/api/stats/rapport.pdf`, reportlab). Chaque annonce a aussi sa **fiche PDF** | |
| 209 | +(`/api/listings/{uid}/pdf`) avec QR code vers la fiche en ligne. | |
| 210 | + | |
| 211 | +## App iOS native | |
| 212 | + | |
| 213 | +App **SwiftUI 100 % native** (iOS 17+, zéro dépendance externe), distribuée sur | |
| 214 | +**TestFlight** — dépôt séparé : `git.spboucher.ai/lou-ka-ios`. | |
| 215 | + | |
| 216 | +| Annonces | Découvrir | Carte | | |
| 217 | +|:---:|:---:|:---:| | |
| 218 | +| <img src="docs/screenshots/ios/accueil.jpg" alt="iOS — Annonces" width="240"> | <img src="docs/screenshots/ios/decouverte.jpg" alt="iOS — Découvrir" width="240"> | <img src="docs/screenshots/ios/carte.jpg" alt="iOS — Carte" width="240"> | | |
| 219 | + | |
| 220 | +| Fiche | Stats | Sources | | |
| 221 | +|:---:|:---:|:---:| | |
| 222 | +| <img src="docs/screenshots/ios/fiche.jpg" alt="iOS — Fiche" width="240"> | <img src="docs/screenshots/ios/stats.jpg" alt="iOS — Stats" width="240"> | <img src="docs/screenshots/ios/sources.jpg" alt="iOS — Sources" width="240"> | | |
| 223 | + | |
| 224 | +Le mode **Découvrir** propose un flux d'exploration par balayage avec un **moteur de | |
| 225 | +recommandation on-device** (lissage de Laplace, reclassement toutes les 8 décisions, | |
| 226 | +~1/6 d'exploration) — aucune donnée ne quitte l'appareil. | |
| 227 | + | |
| 228 | +## Structure du dépôt | |
| 229 | + | |
| 230 | +``` | |
| 231 | +lou-ka/ | |
| 232 | +├── run.py # point d'entrée CLI (sync / watch / serve / geocode / poi / quartier / record) | |
| 233 | +├── requirements.txt # 8 dépendances backend (FastAPI, uvicorn, bs4, reportlab…) | |
| 234 | +├── louka/ # paquet backend (Python 3.14) | |
| 235 | +│ ├── web.py # app FastAPI : API JSON + service du build Vite | |
| 236 | +│ ├── seo.py # SSR léger, pages programmatiques, sitemaps, robots, JSON-LD | |
| 237 | +│ ├── db.py # SQLite : migrations auto, upsert par hash, anti-dérive | |
| 238 | +│ ├── schema.py # dataclass Listing | |
| 239 | +│ ├── normalize.py # normalisation commune (prix, dates, types, adresses) | |
| 240 | +│ ├── textmine.py # digest déterministe des descriptions (regex FR) | |
| 241 | +│ ├── ingest.py # pipeline sync / watch | |
| 242 | +│ ├── auth.py # SSO KA + Google OAuth, sessions HMAC | |
| 243 | +│ ├── accounts.py # rôles, favoris, réclamation de page gestionnaire | |
| 244 | +│ ├── profile.py / hubprofile.py / hubfav.py # profils & intégration hub Groupe KA | |
| 245 | +│ ├── geocode.py # Nominatim + Adresses Québec (mode EN LOT) | |
| 246 | +│ ├── poi.py / quartier.py # commodités OSM, stats de quartier | |
| 247 | +│ ├── marketstats.py # agrégats du marché (source unique) | |
| 248 | +│ ├── pdfgen.py # fiches PDF + rapport de marché (reportlab, QR) | |
| 249 | +│ ├── fixtures.py # enregistrement/rejeu HTTP pour tests hors-ligne | |
| 250 | +│ └── connectors/ # 205 connecteurs + base.py (auto-découverte) | |
| 251 | +├── frontend/ # React 18 + Vite + TypeScript | |
| 252 | +│ └── src/ | |
| 253 | +│ ├── pages/ # Home, Listing, Ville, Stats, Sources, Profil, Favoris… | |
| 254 | +│ ├── components/ # ListingCard, MapView, QuartierBlock, CookieConsent… | |
| 255 | +│ └── kamaps/ # intégration @groupe-ka/ka-maps (Mapbox GL) | |
| 256 | +├── ios/ # app SwiftUI (dépôt séparé, gitignoré ici) | |
| 257 | +├── data/ | |
| 258 | +│ ├── sources.json # registre des 265 sources (statut + raison si non connectable) | |
| 259 | +│ ├── louka.db # base de production (gitignorée) | |
| 260 | +│ └── quartier.db # base statique de quartier (gitignorée) | |
| 261 | +├── scripts/ | |
| 262 | +│ ├── build_recensement.py # recensement 2021 (StatCan) → staging | |
| 263 | +│ ├── build_contexte.py # proximité PMD, défavorisation INSPQ, écoles MEQ | |
| 264 | +│ ├── build_environnement.py # îlots de chaleur, criminalité SPVM | |
| 265 | +│ ├── merge_quartier.py # fusion → data/quartier.db | |
| 266 | +│ └── screenshots.mjs # captures du site prod pour ce README (Playwright) | |
| 267 | +├── tests/ # pytest + fixtures HTTP rejouables hors-ligne | |
| 268 | +├── reports/ # audits : 195 fiches connecteurs, expansion par région, quartier… | |
| 269 | +└── docs/ # ka-maps-lou-ka.md + screenshots/ | |
| 270 | +``` | |
| 64 | 271 | |
| 65 | 272 | ## Démarrage rapide |
| 66 | 273 | |
| 67 | 274 | ```bash |
| 68 | −git clone https://github.com/spboucher-ai/lou-ka.git && cd lou-ka | |
| 275 | +git clone https://git.spboucher.ai/lou-ka.git && cd lou-ka | |
| 276 | +# miroir : https://github.com/spboucher-ai/lou-ka.git | |
| 69 | 277 | |
| 70 | 278 | # Backend |
| 71 | 279 | python3 -m venv .venv && .venv/bin/pip install -r requirements.txt |
| 72 | 280 | |
| 73 | −# Frontend | |
| 281 | +# Frontend (nécessite le framework ka-maps bâti à côté : ../ka-maps) | |
| 74 | 282 | cd frontend && npm install && npm run build && cd .. |
| 75 | 283 | |
| 76 | −# (optionnel) sites JavaScript/anti-bot | |
| 77 | −echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env | |
| 284 | +# (optionnel) sites JavaScript / anti-bot | |
| 285 | +cat > .env <<EOF | |
| 286 | +FIRECRAWL_API_KEY=fc-votre-cle | |
| 287 | +SCRAPFLY_KEY=votre-cle | |
| 288 | +EOF | |
| 78 | 289 | |
| 79 | 290 | # Ingestion puis service |
| 80 | −.venv/bin/python run.py sync # toutes les sources (ou: run.py sync logisco msi) | |
| 291 | +.venv/bin/python run.py sync # toutes les sources (ou : run.py sync logisco msi) | |
| 81 | 292 | .venv/bin/python run.py serve 8080 # → http://localhost:8080 |
| 82 | 293 | .venv/bin/python run.py watch 60 # resynchronisation en boucle (minutes) |
| 83 | 294 | ``` |
| 84 | 295 | |
| 296 | +En développement frontend : `cd frontend && npm run dev` (Vite sur `:5173`, | |
| 297 | +proxy `/api` → `:8080`). | |
| 298 | + | |
| 299 | +## CLI `run.py` | |
| 300 | + | |
| 301 | +| Commande | Rôle | | |
| 302 | +|---|---| | |
| 303 | +| `run.py sync [source ...]` | ingestion — toutes les sources ou une liste | | |
| 304 | +| `run.py watch [minutes]` | boucle de resynchronisation (défaut 60) | | |
| 305 | +| `run.py serve [port]` | uvicorn `louka.web:app` (défaut 8080) | | |
| 306 | +| `run.py geocode [n]` | géocodage **en lot** (Adresses Québec, lots de 200) | | |
| 307 | +| `run.py geocode1 [n]` | géocodage un-par-un (Nominatim, 1 req/s) | | |
| 308 | +| `run.py poi [n]` | commodités OSM à proximité (Overpass) | | |
| 309 | +| `run.py quartier [n]` | enrichissement quartier (recensement, INSPQ, SPVM) | | |
| 310 | +| `run.py record <source>` | (ré)enregistre les fixtures HTTP d'un connecteur | | |
| 311 | + | |
| 312 | +## Variables d'environnement | |
| 313 | + | |
| 314 | +`run.py` charge `.env` à la racine (parsing maison, aucune dépendance). | |
| 315 | + | |
| 316 | +| Variable | Rôle | | |
| 317 | +|---|---| | |
| 318 | +| `FIRECRAWL_API_KEY` | rendu JavaScript / contournement Cloudflare (optionnel) | | |
| 319 | +| `SCRAPFLY_KEY` | scraping anti-bot ASP (optionnel) | | |
| 320 | +| `SESSION_SECRET` | signature HMAC des sessions | | |
| 321 | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | connexion Google OAuth | | |
| 322 | +| `KA_SSO_SECRET` | secret partagé du SSO KA (JWT HS256) | | |
| 323 | +| `KA_HUB_URL` | hub Groupe KA (défaut `https://www.groupe-ka.com`) | | |
| 324 | +| `LOUKA_BASE_URL` | URL publique (derrière ngrok/proxy) | | |
| 325 | +| `LOUKA_*_DETAIL_LIMIT` / `LOUKA_*_MAX_PAGES` | limites par portail (Kijiji, LesPAC, RE/MAX…) | | |
| 326 | +| `VITE_MAPBOX_TOKEN` | jeton Mapbox du frontend (repli public codé en dur) | | |
| 327 | + | |
| 328 | +## API | |
| 329 | + | |
| 330 | +### Annonces & recherche | |
| 331 | + | |
| 332 | +| Endpoint | Description | | |
| 333 | +|---|---| | |
| 334 | +| `GET /api/listings` | recherche filtrée : `city, sector, unit_type, source, price_min/max, pets, furnished, available_by, area_min, q, limit, offset` | | |
| 335 | +| `GET /api/listings.geojson?bbox=O,S,E,N` | FeatureCollection pour la carte (+ `totalGeocoded`, `totalMatching`) | | |
| 336 | +| `GET /api/listings/{uid}` | fiche complète : images, POI, quartier, digest, historique de prix | | |
| 337 | +| `GET /api/listings/{uid}/pdf` | fiche de propriété PDF (QR code) | | |
| 338 | +| `GET /api/facets` | villes, quartiers (`?city=`), types, sources — pour construire les filtres | | |
| 339 | + | |
| 340 | +### Marché & registre | |
| 341 | + | |
| 342 | +| Endpoint | Description | | |
| 343 | +|---|---| | |
| 344 | +| `GET /api/stats` | totaux Québec / Lévis / Grand Montréal / autres, loyer moyen, dernières synchros | | |
| 345 | +| `GET /api/stats/detailed` | agrégats complets (régions, villes, tailles, baisses de prix) | | |
| 346 | +| `GET /api/stats/rapport.pdf` | rapport de marché PDF multi-pages | | |
| 347 | +| `GET /api/sources` | registre des 265 sources + compteurs + dernière synchro | | |
| 348 | +| `POST /api/sync` | déclenche une synchronisation en arrière-plan (`?source=`) | | |
| 349 | + | |
| 350 | +### Comptes & profils | |
| 351 | + | |
| 352 | +| Endpoint | Description | | |
| 353 | +|---|---| | |
| 354 | +| `GET /api/auth/ka/login` → `/callback` | SSO KA (hub groupe-ka.com) | | |
| 355 | +| `GET /api/auth/google/login` → `/callback` | Google OAuth direct | | |
| 356 | +| `GET /api/me` · `POST /api/me/role` · `PUT /api/me/profile` · `POST /api/me/avatar` | session & profil | | |
| 357 | +| `GET/POST/DELETE /api/favorites[/{uid}]` | favoris (synchronisés vers le hub KA) | | |
| 358 | +| `POST /api/org/claim` · `PUT /api/org` · `GET /api/org/{source_id}` | pages gestionnaires | | |
| 359 | +| `GET /api/users/{ka_id}` | profil public opt-in | | |
| 360 | + | |
| 361 | +### Pages HTML servies par le SSR (`louka/seo.py`) | |
| 362 | + | |
| 363 | +`/` · `/villes` · `/ville/{slug}[/{type}]` · `/logement/{uid}` (410 si retirée) · | |
| 364 | +`/g/{source}` · `/stats` · `/sources` · `/robots.txt` · `/sitemap*.xml` | |
| 365 | + | |
| 85 | 366 | ## Ajouter un gestionnaire (≈ 30 lignes) |
| 86 | 367 | |
| 87 | 368 | L'enregistrement est **auto-découvrant** : déposez un module dans `louka/connectors/`, |
| 88 | −c'est tout — aucun fichier partagé à modifier. | |
| 369 | +c'est tout. | |
| 89 | 370 | |
| 90 | 371 | ```python |
| 91 | 372 | # louka/connectors/mon_agence.py |
@@ -107,58 +388,68 @@ class MonAgenceConnector(BaseConnector): | ||
| 107 | 388 | )] |
| 108 | 389 | ``` |
| 109 | 390 | |
| 110 | −Puis : `.venv/bin/python run.py sync mon_agence` — et l'annonce apparaît sur le site, | |
| 391 | +Puis : `.venv/bin/python run.py sync mon_agence` — l'annonce apparaît sur le site, | |
| 111 | 392 | avec sa fiche, sa galerie et son lien source. Ajoutez l'entrée correspondante dans |
| 112 | −`data/sources.json` pour la page **Sources**. | |
| 393 | +`data/sources.json` pour la page **Sources**, et `run.py record mon_agence` pour | |
| 394 | +figer ses fixtures de test. | |
| 113 | 395 | |
| 114 | −## API | |
| 396 | +## Tests | |
| 115 | 397 | |
| 116 | −| Endpoint | Description | | |
| 117 | −|---|---| | |
| 118 | −| `GET /api/listings?city=§or=&unit_type=&source=&price_min=&price_max=&q=` | Recherche filtrée, triée par prix | | |
| 119 | −| `GET /api/listings/{uid}` | Fiche complète (toutes les images, commodités, source) | | |
| 120 | −| `GET /api/facets` | Valeurs distinctes pour construire les filtres | | |
| 121 | −| `GET /api/sources` | Registre des 74 gestionnaires + compteurs + dernière sync | | |
| 122 | −| `GET /api/stats` | Totaux par région, loyer moyen, journal de synchronisation | | |
| 123 | −| `POST /api/sync` | Déclenche une synchronisation en arrière-plan | | |
| 398 | +```bash | |
| 399 | +.venv/bin/python -m pytest | |
| 400 | +``` | |
| 124 | 401 | |
| 125 | −## Couverture | |
| 402 | +- `tests/test_connectors.py` — chaque connecteur rejoué **hors-ligne** contre ses | |
| 403 | + fixtures HTTP enregistrées (`tests/fixtures/<source>/` + `expected.json`) ; | |
| 404 | +- `tests/test_normalize.py` — normalisation (prix, dates, types, adresses) ; | |
| 405 | +- `tests/test_textmine.py` — digest des descriptions. | |
| 126 | 406 | |
| 127 | −**Ville de Québec & Lévis** — Logisco, Cogir, Groupe Laberge, Immostar, DMA/Locago, | |
| 128 | −Groupe Dallaire, Trudel, Immeubles Roussin, Immeubles Simard, MSI, Gestipro, Logisma, | |
| 129 | −Lafrance & Mathieu, SIB, SDG, SGIQ, GIM Côté, Logisbourg, Bribourg, Paul-E. Richard, | |
| 130 | −Headway, Contraste, Appartements Urbains, Picard, Brochu, GParadis, CAPREIT, Lokalia, | |
| 131 | −Immoappart, OK Louer, et une douzaine de complexes (Huma, Le Clif, Terra, La Klé, | |
| 132 | −Sentinelle, Rivero, Viridi, Quartier les Éléments…). | |
| 407 | +## Couverture | |
| 133 | 408 | |
| 134 | −**Grand Montréal** — Akelius, InterRent, Boardwalk, Minto, MetCap, Realstar, Hazelview, | |
| 135 | −Groupe Copley, Cromwell, Lynk/Olymbec, Trylon, Plan A, Lofts MTL, Axia, Mondev, Devimco, | |
| 136 | −Collection Équinoxe (Batimo/EMD), Progim, Rentalys, UTILE, Werkliv, 1 Square Phillips, | |
| 137 | −Firma, Le Domaine, Beaudoin, Denux, Gestion Montréal, Nid d'Amour, SHDM… | |
| 409 | +**11 régions, 820 villes.** Grand Montréal & environs, Québec métro & Lévis, | |
| 410 | +Outaouais, Estrie / Montérégie-Est, Mauricie / Centre-du-Québec, Lanaudière / | |
| 411 | +Laurentides, Saguenay–Lac-Saint-Jean, Chaudière-Appalaches, Bas-Saint-Laurent, | |
| 412 | +Abitibi, Côte-Nord & Gaspésie. | |
| 138 | 413 | |
| 139 | −Chaque source non-connectable est **documentée avec sa raison** dans `data/sources.json` | |
| 140 | −(ex. : aucun prix affiché, inventaire vide, site placeholder). | |
| 414 | +Côté sources : des gestionnaires directs (Logisco, Cogir, CAPREIT, Akelius, Trudel, | |
| 415 | +Immostar, Groupe Dallaire, Mondev, Devimco…), des dizaines de complexes, et les grands | |
| 416 | +portails (Kijiji, LesPAC) + bannières de courtage (RE/MAX, Sutton, Royal LePage, | |
| 417 | +Via Capitale, Proprio Direct…). Chaque source **non connectable est documentée avec sa | |
| 418 | +raison** dans `data/sources.json` (aucun prix affiché, inventaire vide, site | |
| 419 | +placeholder…), et chaque connecteur a sa fiche d'audit dans `reports/connectors/`. | |
| 141 | 420 | |
| 142 | 421 | ## Production |
| 143 | 422 | |
| 144 | 423 | Déployé sous **PM2** (3 processus) derrière **ngrok** : |
| 145 | 424 | |
| 146 | 425 | ``` |
| 147 | −lou-ka-web .venv/bin/python run.py serve 8095 # API + frontend | |
| 426 | +lou-ka-web .venv/bin/python run.py serve 8095 # API + SSR + frontend | |
| 148 | 427 | lou-ka-sync .venv/bin/python run.py watch 60 # resync horaire |
| 149 | 428 | lou-ka-ngrok ngrok http --url=www.lou-ka.com 8095 # tunnel |
| 150 | 429 | ``` |
| 151 | 430 | |
| 152 | 431 | Philosophie d'exploitation : **on ne pousse que le code — le serveur maintient ses |
| 153 | −données lui-même.** | |
| 432 | +données lui-même** (`data/*.db`, `frontend/dist` et `.env` ne sont jamais versionnés). | |
| 154 | 433 | |
| 155 | 434 | ## Principes |
| 156 | 435 | |
| 157 | −1. **Politesse** — délai ≥ 0,5 s entre requêtes, garde-fous de crawl, User-Agent identifié. | |
| 158 | −2. **Fidélité** — aucun prix inventé : si la source n'affiche pas de prix, `price = null`. | |
| 159 | −3. **Traçabilité** — chaque fiche renvoie vers l'annonce originale du gestionnaire. | |
| 160 | −4. **Robustesse** — un connecteur qui casse n'affecte jamais les autres (auto-découverte | |
| 161 | − tolérante, try/except par annonce, journal `sync_log`). | |
| 436 | +1. **Politesse** — délai ≥ 0,5 s entre requêtes, garde-fous de crawl, User-Agent | |
| 437 | + identifié (`LouKaBot`, page de transparence `/bot`). | |
| 438 | +2. **Fidélité** — aucun prix inventé : si la source n'affiche pas de prix, | |
| 439 | + `price = null` ; aucune coordonnée devinée : géocodage validé ou rien. | |
| 440 | +3. **Traçabilité** — chaque fiche renvoie vers l'annonce originale du gestionnaire | |
| 441 | + (passerelle de sortie `/passerelle/{uid}`). | |
| 442 | +4. **Robustesse** — un connecteur qui casse n'affecte jamais les autres ; le | |
| 443 | + garde-fou anti-dérive suspend les retraits quand une source déraille. | |
| 444 | +5. **Respect de la vie privée** — aucun traceur tiers, consentement honnête, | |
| 445 | + profil public strictement opt-in. | |
| 446 | + | |
| 447 | +## Écosystème Groupe KA | |
| 448 | + | |
| 449 | +Lou-Ka est une plateforme du **[Groupe KA](https://www.groupe-ka.com)**, aux côtés | |
| 450 | +d'**Immo-Ka** (propriétés à vendre) et **Vrai-Prix** — avec en partage : le SSO KA | |
| 451 | +(`ka_id` émis par le hub), le framework cartographique **Ka Maps**, et le géocodage | |
| 452 | +Adresses Québec. | |
| 162 | 453 | |
| 163 | 454 | --- |
| 164 | 455 | |
@@ -169,10 +460,11 @@ données lui-même.** | ||
| 169 | 460 | **Simon-Pierre Boucher** |
| 170 | 461 | |
| 171 | 462 | [](mailto:contact@spboucher.ai) |
| 463 | +[](https://git.spboucher.ai) | |
| 172 | 464 | [](https://github.com/spboucher-ai) |
| 173 | 465 | |
| 174 | −*Conçu, construit et déployé en une journée — de la recherche de marché | |
| 175 | −(74 gestionnaires recensés et vérifiés) au produit en production.* | |
| 466 | +*Du recensement de marché (~240 gestionnaires vérifiés ville par ville) au produit en | |
| 467 | +production — 22 900+ annonces, 205 connecteurs, SSR SEO, cartes 3D et app iOS.* | |
| 176 | 468 | |
| 177 | 469 | © 2026 Simon-Pierre Boucher — tous droits réservés. |
| 178 | 470 | |
added
docs/screenshots/accueil-mobile.png
+0 −0
Binary file not shown.
added
docs/screenshots/accueil.png
+0 −0
Binary file not shown.
added
docs/screenshots/annonces.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/carte.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/fiche-logement.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/ios/accueil.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/ios/carte.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/ios/decouverte.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/ios/fiche.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/ios/sources.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/ios/stats.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/sources.png
+0 −0
Binary file not shown.
added
docs/screenshots/stats.png
+0 −0
Binary file not shown.
added
docs/screenshots/ville-quebec.jpg
+0 −0
Binary file not shown.
added
docs/screenshots/villes.jpg
+0 −0
Binary file not shown.
added
scripts/screenshots.mjs
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +// Capture les screenshots du site prod pour le README (docs/screenshots/). | |
| 2 | +// Usage : npm i playwright puis `node scripts/screenshots.mjs` | |
| 3 | +// (LOUKA_BASE pour cibler un autre déploiement, OUT_DIR pour changer la sortie) | |
| 4 | +import { chromium, devices } from 'playwright'; | |
| 5 | +import { mkdirSync } from 'fs'; | |
| 6 | + | |
| 7 | +const OUT = process.env.OUT_DIR || new URL('../docs/screenshots/', import.meta.url).pathname; | |
| 8 | +mkdirSync(OUT, { recursive: true }); | |
| 9 | + | |
| 10 | +const BASE = process.env.LOUKA_BASE || 'https://www.lou-ka.com'; | |
| 11 | + | |
| 12 | +const shots = [ | |
| 13 | + { name: 'accueil', url: '/', width: 1440, height: 900 }, | |
| 14 | + { name: 'annonces', url: '/', width: 1440, height: 900, scrollTo: 1400 }, | |
| 15 | + { name: 'carte', url: '/?view=carte', width: 1440, height: 900, settle: 8000 }, | |
| 16 | + { name: 'accueil-mobile', url: '/', mobile: true }, | |
| 17 | + { name: 'fiche-logement', url: '/logement/gimcote:26286', width: 1440, height: 900 }, | |
| 18 | + { name: 'ville-quebec', url: '/ville/quebec', width: 1440, height: 900 }, | |
| 19 | + { name: 'villes', url: '/villes', width: 1440, height: 900 }, | |
| 20 | + { name: 'sources', url: '/sources', width: 1440, height: 900 }, | |
| 21 | + { name: 'stats', url: '/stats', width: 1440, height: 900 }, | |
| 22 | +]; | |
| 23 | + | |
| 24 | +const browser = await chromium.launch(); | |
| 25 | +for (const s of shots) { | |
| 26 | + const ctx = s.mobile | |
| 27 | + ? await browser.newContext({ ...devices['iPhone 14'], locale: 'fr-CA' }) | |
| 28 | + : await browser.newContext({ | |
| 29 | + viewport: { width: s.width, height: s.height }, | |
| 30 | + deviceScaleFactor: 2, | |
| 31 | + locale: 'fr-CA', | |
| 32 | + }); | |
| 33 | + // Consentement déjà donné → pas de bandeau dans les captures | |
| 34 | + await ctx.addInitScript(() => { | |
| 35 | + localStorage.setItem( | |
| 36 | + 'louka-consent-v1', | |
| 37 | + JSON.stringify({ essential: true, preferences: true, statistics: false, ts: 1 }) | |
| 38 | + ); | |
| 39 | + }); | |
| 40 | + const page = await ctx.newPage(); | |
| 41 | + try { | |
| 42 | + await page.goto(BASE + s.url, { waitUntil: 'networkidle', timeout: 45000 }); | |
| 43 | + } catch { | |
| 44 | + // networkidle peut ne jamais arriver (ticker temps réel) — on capture quand même | |
| 45 | + } | |
| 46 | + if (s.scrollTo) { | |
| 47 | + await page.mouse.wheel(0, s.scrollTo); | |
| 48 | + } | |
| 49 | + await page.waitForTimeout(s.settle ?? 2500); | |
| 50 | + await page.screenshot({ path: `${OUT}${s.name}.png` }); | |
| 51 | + console.log('ok', s.name); | |
| 52 | + await ctx.close(); | |
| 53 | +} | |
| 54 | +await browser.close(); | |
| 55 | ||