SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent b7041a2

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 ![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=d9f26b)
10 −![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=d9f26b)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API_+_SSR-141814?style=for-the-badge&logo=fastapi&logoColor=d9f26b)
11 11 ![React](https://img.shields.io/badge/React_18-Vite_+_TS-141814?style=for-the-badge&logo=react&logoColor=d9f26b)
12 12 ![SQLite](https://img.shields.io/badge/SQLite-storage-141814?style=for-the-badge&logo=sqlite&logoColor=d9f26b)
13 −![PWA](https://img.shields.io/badge/PWA-mobile_ready-141814?style=for-the-badge&logoColor=d9f26b)
13 +![Mapbox](https://img.shields.io/badge/Lou--Ka_Maps-Mapbox_GL_3D-141814?style=for-the-badge&logo=mapbox&logoColor=d9f26b)
14 +![Swift](https://img.shields.io/badge/iOS-SwiftUI-141814?style=for-the-badge&logo=swift&logoColor=d9f26b)
14 15
15 −![Sources](https://img.shields.io/badge/sources_recens%C3%A9es-255-1c5c41?style=flat-square)
16 −![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-194-1c5c41?style=flat-square)
17 −![Annonces](https://img.shields.io/badge/annonces_agr%C3%A9g%C3%A9es-8000%2B-1c5c41?style=flat-square)
18 −![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-1c5c41?style=flat-square)
16 +![Annonces](https://img.shields.io/badge/annonces_actives-22_900%2B-1c5c41?style=flat-square)
17 +![Sources](https://img.shields.io/badge/sources_recens%C3%A9es-265-1c5c41?style=flat-square)
18 +![Connecteurs](https://img.shields.io/badge/connecteurs-205-1c5c41?style=flat-square)
19 +![Villes](https://img.shields.io/badge/villes-820-1c5c41?style=flat-square)
20 +![Régions](https://img.shields.io/badge/r%C3%A9gions-11-1c5c41?style=flat-square)
21 +![Géocodées](https://img.shields.io/badge/g%C3%A9olocalis%C3%A9es-70%25-1c5c41?style=flat-square)
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=&sector=&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 [![Email](https://img.shields.io/badge/contact@spboucher.ai-141814?style=for-the-badge&logo=minutemailer&logoColor=d9f26b)](mailto:contact@spboucher.ai)
463 +[![Git](https://img.shields.io/badge/git.spboucher.ai-141814?style=for-the-badge&logo=git&logoColor=d9f26b)](https://git.spboucher.ai)
172 464 [![GitHub](https://img.shields.io/badge/spboucher--ai-141814?style=for-the-badge&logo=github&logoColor=d9f26b)](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