SPB Git forge

spb/immo-ka

Public

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

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%

feat: SEO complet (rendu serveur immoka/seo.py) + README détaillé avec captures

- Rendu HTML côté serveur sur chaque route via le catch-all : title/meta/canonical/og
  uniques, JSON-LD (RealEstateListing+Offer+BreadcrumbList, WebSite+SearchAction),
  contenu et liens internes dans #root que React remplace au montage
- Pages programmatiques /a-vendre/{ville}[/{type}] et /type/{type} : stats
  (médiane/moyenne), liste paginée 48/page, maillage interne, seuil 3 annonces
- Fiches /propriete/{uid}/{slug} : 301 depuis l'uid nu, 410 Gone si retirée,
  404 réel si inconnue (fini les soft-404)
- sitemap.xml (index + fiches-N/villes/villes-types/types/pages, lastmod, cache 1 h),
  robots.txt, /api/seo/resolve
- Frontend : routes SPA miroirs (pages/Category.tsx), slugs partagés Python/TS,
  document.title dynamique, liens de cartes avec slug
- README refait : badges à jour (57 900+ propriétés, 95 connecteurs, 22 bannières),
  9 captures d'écran fraîches (accueil, grille, carte, fiche, quartier, page ville,
  stats, agences, mobile), sections kit complet / architecture / SEO / API / structure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent 42e99ec

19 changed files +1,193 −61

modified README.md +186 −51
@@ -7,26 +7,30 @@
7 7 **[www.immo-ka.com](https://www.immo-ka.com)**
8 8
9 9 ![Python](https://img.shields.io/badge/Python-3.14-141814?style=for-the-badge&logo=python&logoColor=c7f230)
10 −![FastAPI](https://img.shields.io/badge/FastAPI-API-141814?style=for-the-badge&logo=fastapi&logoColor=c7f230)
10 +![FastAPI](https://img.shields.io/badge/FastAPI-API_+_SSR-141814?style=for-the-badge&logo=fastapi&logoColor=c7f230)
11 11 ![React](https://img.shields.io/badge/React_18-Vite_+_TS-141814?style=for-the-badge&logo=react&logoColor=c7f230)
12 −![MapLibre](https://img.shields.io/badge/MapLibre-carte-141814?style=for-the-badge&logo=maplibre&logoColor=c7f230)
12 +![Ka Maps](https://img.shields.io/badge/Ka_Maps-Mapbox_3D-141814?style=for-the-badge&logo=mapbox&logoColor=c7f230)
13 13 ![SQLite](https://img.shields.io/badge/SQLite-WAL-141814?style=for-the-badge&logo=sqlite&logoColor=c7f230)
14 14
15 −![Propriétés](https://img.shields.io/badge/propri%C3%A9t%C3%A9s_agr%C3%A9g%C3%A9es-54_800%2B-e23744?style=flat-square)
16 −![Connecteurs](https://img.shields.io/badge/connecteurs-83-e23744?style=flat-square)
17 −![Bannières](https://img.shields.io/badge/banni%C3%A8res-15-e23744?style=flat-square)
18 −![Couverture](https://img.shields.io/badge/couverture-tout_le_Qu%C3%A9bec-e23744?style=flat-square)
15 +![Propriétés](https://img.shields.io/badge/propri%C3%A9t%C3%A9s_actives-57_900%2B-e23744?style=flat-square)
16 +![Connecteurs](https://img.shields.io/badge/connecteurs-95-e23744?style=flat-square)
17 +![Bannières](https://img.shields.io/badge/banni%C3%A8res-22-e23744?style=flat-square)
18 +![Sous-agences](https://img.shields.io/badge/sous--agences-159-e23744?style=flat-square)
19 +![Villes](https://img.shields.io/badge/villes-2_825-e23744?style=flat-square)
19 20 ![MàJ](https://img.shields.io/badge/mise_%C3%A0_jour-automatique-e23744?style=flat-square)
21 +![SEO](https://img.shields.io/badge/SEO-rendu_serveur_+_sitemaps-e23744?style=flat-square)
20 22
21 −**Fiches enrichies** : galerie photo · caractéristiques Centris complètes · pièces &
22 −dimensions · **estimation de valeur Vrai-Prix** (fourchette + confiance + lien
23 −analyse) · **quartier** (recensement StatCan, criminalité, îlot de chaleur,
24 −proximité) · carte MapLibre · découpage **par sous-agence** (bureau).
23 +**Fiches enrichies** : galerie photo complète · caractéristiques Centris · pièces &
24 +dimensions · **estimation de valeur Vrai-Prix** (fourchette + confiance + analyse) ·
25 +**statistiques de quartier** (recensement StatCan, proximité, îlot de chaleur) ·
26 +carte 3D Ka Maps · historique de prix · favoris **KA ID** partagés dans tout le
27 +Groupe KA · **référencement programmatique** (une page indexable par ville et par
28 +type, HTML complet rendu côté serveur).
25 29
26 −*Agrégateur indépendant des maisons, condos et terrains à vendre au Québec —
30 +*Agrégateur indépendant des maisons, condos, plex et terrains à vendre au Québec —
27 31 un connecteur dédié par agence de courtage, chaque propriété normalisée vers un
28 −schéma unique, avec galerie photo, caractéristiques complètes et lien direct vers
29 −l'annonce originale de l'agence. Toujours à jour, automatiquement.*
32 +schéma unique, dédupliquée par numéro Centris, avec lien direct vers l'annonce
33 +originale. Toujours à jour, automatiquement.*
30 34
31 35 </div>
32 36
@@ -34,9 +38,31 @@ l'annonce originale de l'agence. Toujours à jour, automatiquement.*
34 38
35 39 ## Aperçu
36 40
37 −| Accueil — liste, filtres & carte | Fiche propriété — galerie & specs |
41 +| Accueil — moteur de recherche | Grille de résultats |
38 42 |---|---|
39 −| ![Accueil Immo-Ka](docs/screenshots/home.png) | ![Fiche propriété](docs/screenshots/listing.png) |
43 +| ![Accueil Immo-Ka](docs/screenshots/home.png) | ![Grille de propriétés](docs/screenshots/grille.png) |
44 +
45 +| Carte Ka Maps — grappes de prix en direct | Fiche — galerie, Vrai-Prix, courtier |
46 +|---|---|
47 +| ![Carte interactive](docs/screenshots/carte.png) | ![Fiche propriété](docs/screenshots/listing.png) |
48 +
49 +| Fiche — pièces, emplacement, quartier | Page ville indexable (`/a-vendre/quebec`) |
50 +|---|---|
51 +| ![Quartier et emplacement](docs/screenshots/listing-quartier.png) | ![Page ville](docs/screenshots/ville.png) |
52 +
53 +| Statistiques du marché agrégé | Agences — 22 bannières, 159 sous-agences |
54 +|---|---|
55 +| ![Statistiques](docs/screenshots/stats.png) | ![Agences](docs/screenshots/agences.png) |
56 +
57 +<div align="center">
58 +
59 +| Mobile |
60 +|---|
61 +| <img src="docs/screenshots/mobile.png" alt="Version mobile" width="360" /> |
62 +
63 +</div>
64 +
65 +---
40 66
41 67 ## Pourquoi Immo-Ka ?
42 68
@@ -48,58 +74,152 @@ schéma unique, et détecte les changements en continu.
48 74
49 75 > Les sites d'agences n'offrent pas de webhooks. Immo-Ka reproduit l'équivalent :
50 76 > **synchronisation périodique + hash de contenu** → ajouts, changements de prix et
51 −> retraits (propriété vendue) détectés automatiquement.
77 +> retraits (propriété vendue) détectés automatiquement, avec délai de grâce contre
78 +> les ratés ponctuels et détection de dérive par source.
52 79
53 80 C'est le pendant « à vendre » de [Lou-Ka](https://www.lou-ka.com) (location) — même
54 −architecture, schéma adapté à la vente.
81 +architecture, schéma adapté à la vente. Immo-Ka fait partie de l'univers
82 +**Groupe KA** (Lou-Ka, Auto-Ka, Fabri-Ka, Food-Ka, Ora-Ka, Vrai-Prix) : compte
83 +**KA ID** unique et favoris partagés entre toutes les plateformes.
84 +
85 +## Le kit complet
86 +
87 +| | Fonctionnalité | Détails |
88 +|---|---|---|
89 +| 🔎 | **Recherche & filtres** | texte libre (adresse, ville, n° MLS), ville + secteur, type, agence, prix, chambres, salles de bain, superficie ; tri prix/récence ; état dans l'URL (partageable) |
90 +| 🗺 | **Carte Ka Maps** | framework carto maison du Groupe KA (moteur Mapbox GL 3D) : grappes de prix, marqueurs colorés selon l'écart au Vrai-Prix, « rechercher en déplaçant la carte », popups éditoriales, bascule 2D/3D |
91 +| 🏠 | **Fiche complète** | galerie + lightbox (jusqu'à 40+ photos 1600 px), fiche technique à rangées, tableau des caractéristiques Centris, pièces & dimensions par étage, inclusions, description, mini-carte d'emplacement (5/15 min à pied) |
92 +| 💰 | **Vrai-Prix** | estimation de valeur marchande (modèle hédonique + comparables du moteur [Vrai-Prix](https://github.com/spboucher-ai/vrai-prix)) : jauge P10–P90, marqueurs estimation/prix demandé, verdict (sur-évalué / aligné / sous l'estimation), lien vers l'analyse détaillée |
93 +| 📈 | **Historique de prix** | baisses et hausses du prix demandé horodatées (table `price_log`) |
94 +| 🏘 | **Quartier** | aire de diffusion du recensement (± 500 habitants) : revenu médian, loyer moyen, âge médian, % locataires, % français, proximité épiceries/parcs/soins (StatCan), îlot de chaleur (INSPQ) |
95 +| 📊 | **Stats** | volumes par type et par agence, prix moyens, **écart prix demandé vs estimation Vrai-Prix par bannière** (médiane, P25–P75, % sur-évalués) |
96 +| 🏢 | **Agences** | registre éclaté par bannière → sous-agence (bureau), avec compte d'annonces dédupliqué |
97 +| ♥ | **Favoris & KA ID** | connexion « Se connecter avec KA ID » (SSO du hub groupe-ka.com, JWT HS256), favoris stockés au hub — le même cœur ♥ suit l'utilisateur sur toutes les plateformes Ka |
98 +| 🔍 | **SEO programmatique** | HTML complet rendu serveur sur chaque route, une page indexable par ville / type / ville+type, sitemaps, données structurées — voir section dédiée |
55 99
56 100 ## Couverture (métriques en direct)
57 101
58 −| Agence | Propriétés | Méthode technique |
102 +**57 900+ propriétés actives** · 26 sources avec annonces · 22 bannières ·
103 +159 sous-agences · 2 825 villes · prix moyen ≈ 683 000 $
104 +
105 +| Source | Annonces | Méthode technique |
59 106 |---|---:|---|
60 −| RE/MAX Québec | ~25 300 | Meilisearch public + sharding FSA postal + page détail |
61 −| Via Capitale | ~5 250 | portail central (Firecrawl stealth, pagination `criteresJson`) |
62 −| Sutton | ~4 500 | sitemap `sitemap_inscription.cfm` → n° Centris |
63 −| Proprio Direct | ~2 440 | API interne JSON |
64 −| Engel & Völkers | ~1 240 | property-sitemap → JSON-LD |
65 −| Royal LePage | ~1 000+ | recherche par ville filtrée QC + sharding + dédup MLS |
66 −| Century 21 | ~1 600 | sous-agences source.immo (« All Quebec ») |
67 −| Keller Williams (Urbain + Distinction) | ~510 | HTML rendu serveur + sitemap |
68 −| Barnes Québec | ~340 | Algolia + page détail |
69 −| Profusion (Christie's) | ~360 | WordPress + JSON-LD |
70 −| M Immobilier · Vendirect · Sotheby's · L'Expert PM | ~180 | Centris / source.immo / JSON-LD |
71 −| Kijiji · LesPAC · Ubee (petites annonces / plateformes) | *actif* | immobilier à vendre QC seulement (pas location, pas entreprises) |
72 −| Sous-agences RE/MAX / Via Capitale / Century 21 | *backup* | 60+ connecteurs `*_ag_*`, dédup par n° Centris |
73 −
74 −**≈ 40 000 propriétés dédupliquées**, mises à jour automatiquement. Les sous-agences
75 −servent de plan B : si le flux central d'une bannière tombe, elles prennent le relais
76 −sans double-comptage (déduplication par numéro Centris).
107 +| RE/MAX Québec | ~21 300 | Meilisearch public + sharding FSA postal + page détail enrichie |
108 +| DuProprio | ~7 300 | API interne JSON (sans courtier) |
109 +| Kijiji Québec | ~7 000 | flux annonces immobilier à vendre (filtre anti-prix-bidon < 5 000 $) |
110 +| LesPAC Québec | ~4 100 | listing + fiche détail (adresse civique, galerie basephoto) |
111 +| Via Capitale | ~4 100 | portail central (Firecrawl stealth, pagination `criteresJson`) |
112 +| Groupe Sutton | ~3 100 | sitemap `sitemap_inscription.cfm` → n° Centris |
113 +| Proprio Direct | ~2 500 | API interne JSON |
114 +| Century 21 Québec | ~1 600 | sous-agences source.immo (« All Quebec ») |
115 +| Domicilia (réseau Humania) | ~1 400 | API du réseau |
116 +| Ubee · Vendirect · Engel & Völkers | ~3 400 | plateformes + property-sitemap → JSON-LD |
117 +| Royal LePage | ~900 | recherche par ville filtrée QC + sharding + dédup MLS |
118 +| Barnes · Profusion (Christie's) · KW · M Immobilier · Sotheby's · L'Expert PM | ~1 200 | Algolia, WordPress JSON-LD, HTML SSR, Centris |
119 +| Sous-agences RE/MAX · Via Capitale · Century 21 | *backup* | 60+ connecteurs `*_ag_*` auto-générés, dédup par n° Centris |
120 +
121 +Les sous-agences servent de plan B : si le flux central d'une bannière tombe, elles
122 +prennent le relais **sans double-comptage** (déduplication pré-calculée par numéro
123 +Centris, colonne `dup_hidden`).
77 124
78 125 ## Architecture
79 126
80 127 ```mermaid
81 128 flowchart LR
82 − subgraph Sources["Agences de courtage du Québec"]
83 − S1["RE/MAX · Royal LePage · Sutton<br/>Via Capitale · Century 21 · Proprio Direct<br/>Engel & Völkers · Barnes · Profusion<br/>… 80 connecteurs"]
129 + subgraph Sources["Agences & plateformes du Québec"]
130 + S1["RE/MAX · Sutton · Via Capitale<br/>Century 21 · Royal LePage · Proprio Direct<br/>DuProprio · Kijiji · LesPAC · E&V · Barnes<br/>… 95 connecteurs"]
84 131 end
85 132 subgraph ImmoKa["Immo-Ka"]
86 − C["Connecteurs<br/><i>1 adaptateur / agence</i>"] --> N["Normalisation<br/><i>schéma PropertyListing</i>"]
87 − N --> D[("SQLite WAL<br/>hash + diff + dédup Centris")]
88 − D --> A["API FastAPI<br/>/api/listings · /api/facets · geojson"]
89 − A --> F["React 18 + Vite + MapLibre<br/>liste · carte · fiche détaillée"]
133 + C["Connecteurs<br/><i>1 adaptateur / source</i>"] --> N["Normalisation<br/><i>schéma PropertyListing</i>"]
134 + N --> D[("SQLite WAL<br/>hash + diff + dédup Centris<br/>+ géocodage + POI + quartier")]
135 + D --> A["API FastAPI<br/>listings · facets · geojson · stats"]
136 + D --> SEO["Rendu SEO serveur<br/><i>immoka/seo.py</i><br/>HTML + JSON-LD + sitemaps"]
137 + A --> F["React 18 + Vite + Ka Maps<br/>liste · carte · fiche · stats"]
138 + SEO --> G["🕷 Moteurs de recherche"]
90 139 end
91 140 W["⏱ Watcher (PM2)"] -.-> C
92 141 S1 --> C
93 142 F --> U["🔑 Acheteur"]
143 + H["🌐 Hub groupe-ka.com<br/>KA ID · favoris"] <--> F
94 144 ```
95 145
96 146 | Couche | Rôle | Fichiers |
97 147 |---|---|---|
98 −| **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` |
99 −| **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` |
100 −| **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` |
101 −| **API** | filtres ville/secteur/région/type/agence/prix/chambres, facettes, stats, GeoJSON | `immoka/web.py` |
102 −| **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/` |
148 +| **Connecteurs** | 1 module par source : API JSON interne (Meilisearch, Algolia, source.immo, wp-json), JSON-LD, sitemap, ou Firecrawl pour les sites SPA/anti-bot ; registres auto-générateurs pour les bannières à sous-agences | `immoka/connectors/*.py` |
149 +| **Schéma** | `PropertyListing` : adresse, secteur, ville, région, type canonique, prix, chambres/SdB, superficies, année, MLS, courtier, pièces (dimensions), **toutes les photos**, caractéristiques structurées | `immoka/schema.py`, `immoka/normalize.py` |
150 +| **Diff engine** | upsert par hash de contenu, délai de grâce (2 syncs), détection de dérive par source, historique de prix, **déduplication inter-agences par n° Centris** | `immoka/db.py`, `immoka/ingest.py` |
151 +| **Enrichissement** | géocodage avec cache, commodités de proximité (POI), statistiques de quartier StatCan/INSPQ, estimations Vrai-Prix | `immoka/geocode.py`, `immoka/poi.py`, `immoka/quartier.py`, `immoka/vraiprix*.py` |
152 +| **API** | filtres ville/secteur/région/type/agence/prix/chambres/superficie, facettes, stats, GeoJSON borné par bbox pour la carte | `immoka/web.py` |
153 +| **SEO** | rendu HTML serveur par route, pages programmatiques, sitemaps, robots.txt, 301/410/404 | `immoka/seo.py` |
154 +| **Auth & favoris** | SSO KA ID (JWT HS256 du hub, stdlib pure), profil hub, favoris centraux | `immoka/auth.py`, `immoka/hubprofile.py`, `immoka/hubfav.py`, `immoka/favorites.py` |
155 +| **Frontend** | React 18 + Vite + TS + react-router + Ka Maps : accueil (liste/carte), fiche, pages ville/type, Stats, Agences, profil KA | `frontend/` |
156 +
157 +## Référencement (SEO)
158 +
159 +Le SPA React est doublé d'un **rendu HTML côté serveur** (`immoka/seo.py`, branché
160 +sur le catch-all FastAPI) : chaque URL livre son contenu complet dès la première
161 +requête, sans exécution JavaScript — React prend le relais au montage.
162 +
163 +- **Chaque page** a un `<title>`, une meta description, un canonical et des balises
164 + `og:`/`twitter:` uniques, plus le contenu réel (H1, stats, liste d'annonces avec
165 + liens) directement dans le HTML initial.
166 +- **Pages programmatiques** alignées sur les recherches réelles :
167 + `/a-vendre/{ville}` (1 589 pages), `/a-vendre/{ville}/{type}` (2 702 pages),
168 + `/type/{type}` (21 pages) — statistiques (nombre, prix médian/moyen), liste
169 + paginée (48/page), maillage interne (types de la ville, villes voisines),
170 + seuil de qualité (≥ 3 annonces).
171 +- **Fiches** : URL stable et lisible `/propriete/{uid}/{slug-adresse-ville}`
172 + (301 depuis l'uid nu), JSON-LD `RealEstateListing` + `Offer` (prix CAD, adresse,
173 + géolocalisation, photos) + `BreadcrumbList`.
174 +- **Cycle de vie propre** : propriété retirée/vendue → **410 Gone** avec liens de
175 + sortie ; uid inconnu → **404** réel. Fini les soft-404.
176 +- **`sitemap.xml`** : index + sous-sitemaps (57 900+ fiches, villes, villes-types,
177 + types, pages) avec `lastmod`, régénérés automatiquement. **`robots.txt`** propre.
178 +- Le slug est calculé par le **même algorithme** en Python (`seo.py`) et en
179 + TypeScript (`api.ts`) — les liens du SPA et du serveur coïncident.
180 +
181 +## API
182 +
183 +Toutes les réponses sont en JSON, compressées (gzip), CORS ouvert.
184 +
185 +| Endpoint | Description |
186 +|---|---|
187 +| `GET /api/listings` | recherche paginée — filtres `city`, `sector`, `region`, `property_type`, `source`, `price_min/max`, `bedrooms_min`, `bathrooms_min`, `area_min`, `q`, `sort`, `limit`, `offset` |
188 +| `GET /api/listings/{uid}` | fiche complète : photos, caractéristiques, pièces, historique de prix, POI, quartier, Vrai-Prix |
189 +| `GET /api/listings.geojson` | marqueurs de carte allégés, bornés par `bbox=ouest,sud,est,nord` |
190 +| `GET /api/facets` | valeurs distinctes (villes, secteurs, types, sources) pour les filtres |
191 +| `GET /api/sources` · `GET /api/agencies` | registre des sources et arbre bannière → sous-agences |
192 +| `GET /api/stats` | totaux, prix moyens, écarts Vrai-Prix par bannière, journal des syncs |
193 +| `GET /api/seo/resolve` | slug de page programmatique → valeurs exactes |
194 +| `GET /sitemap.xml` · `/sitemaps/*.xml` · `/robots.txt` | référencement |
195 +| `POST /api/sync` | déclenche une synchronisation (toutes les sources ou une seule) |
196 +
197 +## Structure du projet
198 +
199 +```
200 +agent-courtage/
201 +├── run.py # point d'entrée : list · sync · serve · watch
202 +├── immoka/
203 +│ ├── web.py # API FastAPI + service du frontend + catch-all SEO
204 +│ ├── seo.py # rendu HTML serveur, sitemaps, robots, slugs
205 +│ ├── schema.py # PropertyListing (schéma normalisé)
206 +│ ├── normalize.py # types canoniques, extraction chambres/SdB
207 +│ ├── db.py # SQLite WAL : upsert, diff, dédup Centris, price_log
208 +│ ├── ingest.py # orchestration des syncs
209 +│ ├── geocode.py · poi.py · quartier.py # enrichissement
210 +│ ├── vraiprix.py · vraiprix_local.py # estimations de valeur
211 +│ ├── auth.py · hubprofile.py · hubfav.py · favorites.py # KA ID + favoris
212 +│ └── connectors/ # 95 connecteurs (31 modules + registres auto-générés)
213 +├── frontend/ # React 18 + Vite + TS
214 +│ └── src/
215 +│ ├── pages/ # Home · Listing · Category · Stats · Sources · Profil
216 +│ ├── components/ # ListingCard · MapView · PropertyMap · QuartierBlock · Icons
217 +│ └── kamaps/ # intégration Ka Maps (Groupe KA)
218 +├── data/
219 +│ ├── sources.json # registre des sources (50 entrées, statuts)
220 +│ └── immoka.db # SQLite (non versionnée)
221 +└── docs/screenshots/ # captures de ce README
222 +```
103 223
104 224 ## Démarrage rapide
105 225
@@ -112,10 +232,17 @@ cd frontend && npm install && npm run build && cd ..
112 232 # (optionnel) sites JavaScript / anti-bot
113 233 echo "FIRECRAWL_API_KEY=fc-votre-cle" > .env
114 234
115 −.venv/bin/python run.py list # connecteurs enregistrés
235 +.venv/bin/python run.py list # 95 connecteurs enregistrés
116 236 .venv/bin/python run.py sync # toutes les agences (ou: sync remax_quebec sutton)
117 −.venv/bin/python run.py serve 8090 # API + frontend → http://localhost:8090
118 −.venv/bin/python run.py watch 180 # synchronisation en boucle
237 +.venv/bin/python run.py serve 8090 # API + frontend + SEO → http://localhost:8090
238 +.venv/bin/python run.py watch 180 # synchronisation en boucle (minutes)
239 +```
240 +
241 +Vérifier le rendu SEO en local :
242 +
243 +```bash
244 +curl -s localhost:8090/a-vendre/quebec | grep '<title>'
245 +curl -s localhost:8090/sitemap.xml
119 246 ```
120 247
121 248 ## Ajouter une agence
@@ -133,8 +260,16 @@ génère un connecteur par sous-agence depuis un registre JSON — nommer les
133 260 ## Déploiement
134 261
135 262 Déployé sur le cluster MacLustr (nœud M4M64a) via PM2 :
136 −`immo-ka-web` (API + frontend), `immo-ka-sync` (watcher horaire), `immo-ka-ngrok`
137 −(tunnel `www.immo-ka.com`). Base SQLite en mode WAL pour les accès concurrents.
263 +
264 +| Processus | Rôle |
265 +|---|---|
266 +| `immo-ka-web` | API + frontend + rendu SEO (`run.py serve 8096`) |
267 +| `immo-ka-sync` | watcher de synchronisation (`run.py watch`) |
268 +| `immo-ka-ngrok` | tunnel `www.immo-ka.com` |
269 +
270 +Base SQLite en mode WAL pour les accès concurrents lecture/écriture. Variables
271 +d'environnement sur le nœud (`.env`) : `KA_SSO_SECRET`, `KA_HUB_URL`,
272 +`IMMOKA_BASE_URL`, `AUTH_SECRET`, `FIRECRAWL_API_KEY`.
138 273
139 274 ---
140 275
added docs/screenshots/agences.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

modified docs/screenshots/home.png +0 −0

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

modified frontend/index.html +1 −1
@@ -8,7 +8,7 @@
8 8 <meta charset="UTF-8" />
9 9 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
10 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." />
11 + <meta name="description" content="Des dizaines de milliers de propriétés à vendre partout au Québec, agrégées depuis les agences de courtage et plateformes (RE/MAX, Via Capitale, Sutton, Proprio Direct, DuProprio…) — mises à jour en continu, chaque fiche renvoie à l'annonce originale." />
12 12 <meta name="theme-color" content="#1a1214" />
13 13 <meta name="mobile-web-app-capable" content="yes" />
14 14 <meta name="apple-mobile-web-app-capable" content="yes" />
modified frontend/src/App.tsx +6 −0
@@ -8,6 +8,7 @@ import { useEffect, useState } from "react";
8 8 import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";
9 9 import { fetchFacets, fetchSources, fetchStats, logout, registerSourceNames, sourceName } from "./api";
10 10 import { AccountProvider, useAccount } from "./account";
11 +import CategoryPage from "./pages/Category";
11 12 import Home from "./pages/Home";
12 13 import { PrivacyPage, TermsPage } from "./pages/Legal";
13 14 import ListingPage from "./pages/Listing";
@@ -209,6 +210,11 @@ export default function App() {
209 210 <Routes>
210 211 <Route path="/" element={<Home />} />
211 212 <Route path="/propriete/:uid" element={<ListingPage />} />
213 + <Route path="/propriete/:uid/:slug" element={<ListingPage />} />
214 + {/* pages programmatiques SEO — mêmes URL que le rendu serveur */}
215 + <Route path="/a-vendre/:ville" element={<CategoryPage />} />
216 + <Route path="/a-vendre/:ville/:type" element={<CategoryPage />} />
217 + <Route path="/type/:type" element={<CategoryPage />} />
212 218 <Route path="/stats" element={<StatsPage />} />
213 219 <Route path="/agences" element={<SourcesPage />} />
214 220 <Route path="/profil" element={<ProfilPage />} />
modified frontend/src/api.ts +34 −0
@@ -192,6 +192,40 @@ export const fmtDate = (ts: number): string =>
192 192 day: "numeric", month: "long", year: "numeric",
193 193 });
194 194
195 +// --- Référencement ------------------------------------------------------------
196 +/** Slug URL — MÊME algorithme que slugify() de immoka/seo.py. */
197 +export function slugify(s: string): string {
198 + return (s || "")
199 + .normalize("NFKD")
200 + .replace(/[\u0300-\u036f]/g, "") // diacritiques combinants (é → e)
201 + .replace(/[^\x00-\x7f]/g, "") // équivalent de encode("ascii","ignore")
202 + .toLowerCase()
203 + .replace(/[^a-z0-9]+/g, "-")
204 + .replace(/^-+|-+$/g, "")
205 + .slice(0, 80)
206 + .replace(/-+$/g, "");
207 +}
208 +
209 +/** Chemin canonique d'une fiche : /propriete/{uid}/{slug-adresse-ville}. */
210 +export function fichePath(l: Pick<Listing, "uid" | "address" | "title" | "city">): string {
211 + let slug = slugify(l.address || l.title || "");
212 + const city = slugify(l.city || "");
213 + if (city && !slug.includes(city)) slug = slug ? slugify(`${slug} ${city}`) : city;
214 + return `/propriete/${encodeURIComponent(l.uid)}${slug ? `/${slug}` : ""}`;
215 +}
216 +
217 +/** Titre d'onglet par page (le HTML initial est déjà titré côté serveur). */
218 +export function setDocTitle(t?: string) {
219 + document.title = t ? `${t} | Immo-Ka` : "Immo-Ka — Propriétés à vendre au Québec";
220 +}
221 +
222 +/** Slug de page programmatique → valeurs exactes (ville, type). */
223 +export const resolveSeo = (ville?: string, type?: string) =>
224 + get<{ city?: string; city_n?: number; property_type?: string; type_n?: number }>(
225 + `/api/seo/resolve?${new URLSearchParams({
226 + ...(ville ? { ville } : {}), ...(type ? { type } : {}),
227 + })}`);
228 +
195 229 // --- Vrai-Prix : estimation de valeur marchande ------------------------------
196 230 export interface VraiPrix {
197 231 id: string;
modified frontend/src/components/ListingCard.tsx +2 −2
@@ -4,7 +4,7 @@
4 4 // components/ListingCard.tsx : carte de propriété (grille de résultats)
5 5 // -----------------------------------------------------------------------------
6 6 import { Link } from "react-router-dom";
7 −import { Listing, fmtArea, fmtPrice, sourceName } from "../api";
7 +import { Listing, fichePath, fmtArea, fmtPrice, sourceName } from "../api";
8 8 import { useAccount } from "../account";
9 9 import { Ico, IcoHeart } from "./Icons";
10 10
@@ -19,7 +19,7 @@ export default function ListingCard({ l }: { l: Listing }) {
19 19 if (area) meta.push({ ico: "area", txt: area });
20 20
21 21 return (
22 − <Link to={`/propriete/${encodeURIComponent(l.uid)}`} className="card">
22 + <Link to={fichePath(l)} className="card">
23 23 <div className="card-img">
24 24 {img ? (
25 25 <img src={img} alt={l.title || l.address} loading="lazy" />
added frontend/src/pages/Category.tsx +122 −0
@@ -0,0 +1,122 @@
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/Category.tsx : pages programmatiques /a-vendre/{ville}[/{type}] et
5 +// /type/{type} — mêmes URL que le rendu SEO serveur (immoka/seo.py), le SPA
6 +// prend le relais au montage. Résolution slug → valeurs via /api/seo/resolve.
7 +// -----------------------------------------------------------------------------
8 +import { useEffect, useState } from "react";
9 +import { Link, useParams, useSearchParams } from "react-router-dom";
10 +import {
11 + Listing, fetchListings, fetchSources, fmtPrice, registerSourceNames,
12 + resolveSeo, setDocTitle,
13 +} from "../api";
14 +import ListingCard from "../components/ListingCard";
15 +import { Ico } from "../components/Icons";
16 +
17 +const PAGE_SIZE = 48; // aligné sur immoka/seo.py
18 +
19 +export default function CategoryPage() {
20 + const { ville, type } = useParams<{ ville?: string; type?: string }>();
21 + const [params, setParams] = useSearchParams();
22 + const page = Math.max(1, Number(params.get("page")) || 1);
23 +
24 + const [labels, setLabels] = useState<{ city?: string; property_type?: string } | null>(null);
25 + const [listings, setListings] = useState<Listing[] | null>(null);
26 + const [total, setTotal] = useState(0);
27 + const [error, setError] = useState<string | null>(null);
28 +
29 + useEffect(() => {
30 + fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
31 + }, []);
32 +
33 + useEffect(() => {
34 + setLabels(null); setListings(null); setError(null);
35 + resolveSeo(ville, type)
36 + .then(setLabels)
37 + .catch(() => setError("Page introuvable."));
38 + }, [ville, type]);
39 +
40 + useEffect(() => {
41 + if (!labels) return;
42 + setListings(null);
43 + fetchListings(
44 + { city: labels.city, property_type: labels.property_type, sort: "price_asc" },
45 + PAGE_SIZE, (page - 1) * PAGE_SIZE)
46 + .then((r) => { setListings(r.listings); setTotal(r.total); })
47 + .catch((e) => setError(String(e)));
48 + window.scrollTo(0, 0);
49 + }, [labels, page]);
50 +
51 + const h1 = labels
52 + ? labels.city && labels.property_type
53 + ? `${labels.property_type} à vendre à ${labels.city}`
54 + : labels.city
55 + ? `Propriétés à vendre à ${labels.city}`
56 + : `${labels.property_type} à vendre au Québec`
57 + : "";
58 + useEffect(() => { if (h1) setDocTitle(h1); }, [h1]);
59 +
60 + if (error)
61 + return (
62 + <div className="notice container">
63 + <div className="big"><Ico name="alert" size={44} /></div>
64 + <h2>Page introuvable</h2>
65 + <p>{error}</p>
66 + <Link className="btn btn-primary" to="/">Toutes les propriétés</Link>
67 + </div>
68 + );
69 +
70 + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
71 + const refine = new URLSearchParams();
72 + if (labels?.city) refine.set("city", labels.city);
73 + if (labels?.property_type) refine.set("property_type", labels.property_type);
74 +
75 + return (
76 + <div className="container">
77 + <nav className="crumbs" aria-label="Fil d'Ariane" style={{ margin: "14px 0 4px" }}>
78 + <Link to="/">Accueil</Link>
79 + {labels?.city && ville && <> › <Link to={`/a-vendre/${ville}`}>À vendre à {labels.city}</Link></>}
80 + {labels?.property_type && <> › <span>{labels.property_type}</span></>}
81 + </nav>
82 + <h1>{h1 || "Chargement…"}</h1>
83 + {listings && (
84 + <p style={{ opacity: 0.75, margin: "6px 0 14px" }}>
85 + <b>{total.toLocaleString("fr-CA")}</b> annonces
86 + {listings.length > 0 && listings[0].price != null && (
87 + <> · à partir de <b>{fmtPrice(listings[0].price)}</b></>
88 + )}
89 + {" · "}
90 + <Link to={`/?${refine}`}>Affiner la recherche (carte, filtres)</Link>
91 + </p>
92 + )}
93 + {!listings && !error && (
94 + <div className="grid" aria-busy="true">
95 + {Array.from({ length: 8 }).map((_, i) => (
96 + <div key={i} className="card skel"><div className="sk-img" /><div className="sk-line" /></div>
97 + ))}
98 + </div>
99 + )}
100 + {listings && (
101 + <div className="grid">
102 + {listings.map((l) => <ListingCard key={l.uid} l={l} />)}
103 + </div>
104 + )}
105 + {pages > 1 && (
106 + <nav className="pager" aria-label="Pagination" style={{ margin: "18px 0" }}>
107 + {page > 1 && (
108 + <button className="btn" onClick={() => setParams(page === 2 ? {} : { page: String(page - 1) })}>
109 + ← Précédente
110 + </button>
111 + )}
112 + <span style={{ margin: "0 10px" }}>Page {page} de {pages}</span>
113 + {page < pages && (
114 + <button className="btn" onClick={() => setParams({ page: String(page + 1) })}>
115 + Suivante →
116 + </button>
117 + )}
118 + </nav>
119 + )}
120 + </div>
121 + );
122 +}
modified frontend/src/pages/Home.tsx +2 −1
@@ -8,7 +8,7 @@ import { useSearchParams } from "react-router-dom";
8 8 import {
9 9 Facets, Listing, ListingFilters, Stats,
10 10 fetchFacets, fetchListings, fetchSources, fetchStats,
11 − registerSourceNames, sourceName,
11 + registerSourceNames, setDocTitle, sourceName,
12 12 } from "../api";
13 13 import ListingCard from "../components/ListingCard";
14 14 import { Ico } from "../components/Icons";
@@ -45,6 +45,7 @@ function useDebounced<T>(value: T, delayMs = 300): T {
45 45 }
46 46
47 47 export default function Home() {
48 + useEffect(() => { setDocTitle(); }, []); // titre par défaut (navigation SPA)
48 49 const [listings, setListings] = useState<Listing[] | null>(null);
49 50 const [total, setTotal] = useState(0);
50 51 const [params, setParams] = useSearchParams();
modified frontend/src/pages/Listing.tsx +7 −2
@@ -9,7 +9,7 @@ import { Suspense, lazy, useEffect, useRef, useState } from "react";
9 9 import { Link, useParams } from "react-router-dom";
10 10 import {
11 11 Listing, Room, fetchListing, fetchSources, fmtArea, fmtDate, fmtPrice,
12 − registerSourceNames, sourceName,
12 + registerSourceNames, setDocTitle, sourceName,
13 13 } from "../api";
14 14
15 15 const PropertyMap = lazy(() => import("../components/PropertyMap"));
@@ -98,7 +98,12 @@ export default function ListingPage() {
98 98 fetchSources().then((r) => registerSourceNames(r.sources)).catch(() => {});
99 99 if (!uid) return;
100 100 setL(null); setError(null);
101 − fetchListing(uid).then(setL).catch((e) => setError(String(e)));
101 + fetchListing(uid)
102 + .then((d) => {
103 + setL(d);
104 + setDocTitle(`${d.address || d.title} — ${d.property_type || "Propriété"} à vendre, ${d.city || "Québec"}`);
105 + })
106 + .catch((e) => setError(String(e)));
102 107 window.scrollTo(0, 0);
103 108 }, [uid]);
104 109
added immoka/seo.py +797 −0
@@ -0,0 +1,797 @@
1 +# -----------------------------------------------------------------------------
2 +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# seo.py : rendu HTML côté serveur pour le référencement.
5 +#
6 +# Le SPA React reste inchangé : ce module pré-remplit le HTML initial servi par
7 +# le catch-all de web.py — <title>/meta/canonical/og: uniques, JSON-LD
8 +# schema.org, contenu et liens internes dans <div id="root"> (que React
9 +# remplace au montage). Il génère aussi robots.txt et les sitemaps.
10 +#
11 +# Pages servies :
12 +# / accueil enrichi (stats + liens villes/types)
13 +# /propriete/{uid}[/{slug}] fiche (301 vers le slug canonique,
14 +# 410 si retirée, 404 si inconnue)
15 +# /a-vendre/{ville}[/{type}] pages programmatiques par ville (+ type)
16 +# /type/{type} page par type de propriété (province)
17 +# /stats /agences /conditions /confidentialite /profil meta dédiées
18 +# /robots.txt /sitemap.xml /sitemaps/*.xml
19 +# -----------------------------------------------------------------------------
20 +from __future__ import annotations
21 +
22 +import html
23 +import json
24 +import os
25 +import re
26 +import time
27 +import unicodedata
28 +from datetime import datetime, timezone
29 +from pathlib import Path
30 +from urllib.parse import quote
31 +
32 +from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse, Response
33 +
34 +from . import db
35 +
36 +ROOT = Path(__file__).resolve().parent.parent
37 +FRONTEND_DIST = ROOT / "frontend" / "dist"
38 +FRONTEND_DIR = FRONTEND_DIST if FRONTEND_DIST.exists() else ROOT / "frontend"
39 +
40 +BASE_URL = os.environ.get("IMMOKA_BASE_URL", "https://www.immo-ka.com").rstrip("/")
41 +SITE_NAME = "Immo-Ka"
42 +
43 +# même règle de visibilité que /api/listings (dédup Centris + prix affichable)
44 +VISIBLE = "active=1 AND dup_hidden=0 AND price IS NOT NULL"
45 +MIN_LISTINGS = 3 # seuil de qualité : pas de page ville/type quasi vide
46 +PAGE_SIZE = 48 # annonces par page sur les pages programmatiques
47 +
48 +# -----------------------------------------------------------------------------
49 +# Utilitaires
50 +# -----------------------------------------------------------------------------
51 +
52 +def slugify(s: str) -> str:
53 + """Slug URL — MÊME algorithme que slugify() de frontend/src/api.ts."""
54 + s = unicodedata.normalize("NFKD", s or "").encode("ascii", "ignore").decode()
55 + s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
56 + return s[:80].strip("-")
57 +
58 +
59 +def listing_slug(row) -> str:
60 + """Slug d'une fiche — MÊME logique que fichePath() de frontend/src/api.ts."""
61 + base = slugify(row["address"] or row["title"] or "")
62 + city = slugify(row["city"] or "")
63 + if city and city not in base:
64 + base = slugify(f"{base} {city}") if base else city
65 + return base
66 +
67 +
68 +def _esc(s) -> str:
69 + return html.escape(str(s or ""), quote=True)
70 +
71 +
72 +def _fmt_n(n) -> str:
73 + return f"{int(n):,}".replace(",", " ")
74 +
75 +
76 +def _fmt_price(p) -> str:
77 + return f"{_fmt_n(round(p))} $" if p is not None else "Prix sur demande"
78 +
79 +
80 +def _iso_date(ts) -> str:
81 + try:
82 + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d")
83 + except (TypeError, ValueError):
84 + return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
85 +
86 +
87 +# petit cache TTL en mémoire (les données changent au rythme du watch, ~1 h)
88 +_cache: dict[str, tuple[float, object]] = {}
89 +
90 +
91 +def _cached(key: str, ttl: float, build):
92 + now = time.time()
93 + hit = _cache.get(key)
94 + if hit and now - hit[0] < ttl:
95 + return hit[1]
96 + val = build()
97 + _cache[key] = (now, val)
98 + return val
99 +
100 +
101 +# -----------------------------------------------------------------------------
102 +# Registres de slugs (villes, types) — reconstruits toutes les 15 min
103 +# -----------------------------------------------------------------------------
104 +
105 +def _build_registry() -> dict:
106 + con = db.connect()
107 + cities: dict[str, dict] = {}
108 + for r in con.execute(
109 + f"SELECT city, COUNT(*) n FROM listings WHERE {VISIBLE}"
110 + " AND city<>'' GROUP BY city"):
111 + slug = slugify(r["city"])
112 + if len(slug) < 2:
113 + continue
114 + e = cities.setdefault(slug, {"label": r["city"], "n": 0, "values": [], "best": 0})
115 + e["n"] += r["n"]
116 + e["values"].append(r["city"])
117 + if r["n"] > e["best"]:
118 + e["best"] = r["n"]; e["label"] = r["city"]
119 + types: dict[str, dict] = {}
120 + for r in con.execute(
121 + f"SELECT property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"
122 + " AND property_type<>'' GROUP BY property_type"):
123 + slug = slugify(r["property_type"])
124 + if len(slug) < 2:
125 + continue
126 + e = types.setdefault(slug, {"label": r["property_type"], "n": 0, "values": [], "best": 0})
127 + e["n"] += r["n"]
128 + e["values"].append(r["property_type"])
129 + if r["n"] > e["best"]:
130 + e["best"] = r["n"]; e["label"] = r["property_type"]
131 + city_types: dict[tuple[str, str], int] = {}
132 + for r in con.execute(
133 + f"SELECT city, property_type, COUNT(*) n FROM listings WHERE {VISIBLE}"
134 + " AND city<>'' AND property_type<>'' GROUP BY city, property_type"):
135 + cs, ts_ = slugify(r["city"]), slugify(r["property_type"])
136 + if len(cs) < 2 or len(ts_) < 2:
137 + continue
138 + city_types[(cs, ts_)] = city_types.get((cs, ts_), 0) + r["n"]
139 + con.close()
140 + return {"cities": cities, "types": types, "city_types": city_types}
141 +
142 +
143 +def registry() -> dict:
144 + return _cached("registry", 900, _build_registry)
145 +
146 +
147 +# -----------------------------------------------------------------------------
148 +# Gabarit : dist/index.html, dépouillé de son <title>/description statiques
149 +# -----------------------------------------------------------------------------
150 +
151 +_tpl_cache: tuple[float, str] | None = None
152 +
153 +
154 +def _template() -> str:
155 + global _tpl_cache
156 + path = FRONTEND_DIR / "index.html"
157 + mtime = path.stat().st_mtime
158 + if _tpl_cache and _tpl_cache[0] == mtime:
159 + return _tpl_cache[1]
160 + tpl = path.read_text(encoding="utf-8")
161 + tpl = re.sub(r"<title>.*?</title>\s*", "", tpl, flags=re.S)
162 + tpl = re.sub(r'<meta name="description"[^>]*>\s*', "", tpl)
163 + _tpl_cache = (mtime, tpl)
164 + return tpl
165 +
166 +
167 +def _page(title: str, description: str, canonical: str, body: str,
168 + jsonld: list[dict] | None = None, og_image: str | None = None,
169 + og_type: str = "website", noindex: bool = False,
170 + status: int = 200) -> HTMLResponse:
171 + head = [
172 + f"<title>{_esc(title)}</title>",
173 + f'<meta name="description" content="{_esc(description)}" />',
174 + f'<link rel="canonical" href="{_esc(canonical)}" />',
175 + f'<meta property="og:site_name" content="{SITE_NAME}" />',
176 + '<meta property="og:locale" content="fr_CA" />',
177 + f'<meta property="og:type" content="{og_type}" />',
178 + f'<meta property="og:title" content="{_esc(title)}" />',
179 + f'<meta property="og:description" content="{_esc(description)}" />',
180 + f'<meta property="og:url" content="{_esc(canonical)}" />',
181 + ]
182 + if og_image:
183 + head.append(f'<meta property="og:image" content="{_esc(og_image)}" />')
184 + head.append('<meta name="twitter:card" content="summary_large_image" />')
185 + else:
186 + head.append('<meta name="twitter:card" content="summary" />')
187 + if noindex:
188 + head.append('<meta name="robots" content="noindex" />')
189 + for obj in (jsonld or []):
190 + head.append('<script type="application/ld+json">'
191 + + json.dumps(obj, ensure_ascii=False) + "</script>")
192 + tpl = _template()
193 + out = tpl.replace("</head>", " " + "\n ".join(head) + "\n </head>", 1)
194 + out = out.replace('<div id="root"></div>',
195 + f'<div id="root"><div class="container seo-ssr">{body}</div></div>', 1)
196 + return HTMLResponse(out, status_code=status)
197 +
198 +
199 +# -----------------------------------------------------------------------------
200 +# Blocs HTML réutilisables
201 +# -----------------------------------------------------------------------------
202 +
203 +def fiche_href(uid: str, slug: str = "") -> str:
204 + path = f"/propriete/{quote(uid, safe='')}"
205 + return path + (f"/{slug}" if slug else "")
206 +
207 +
208 +def _item_li(r) -> str:
209 + href = fiche_href(r["uid"], listing_slug(r))
210 + bits = [b for b in [
211 + r["property_type"],
212 + f"{r['bedrooms']} ch." if r["bedrooms"] is not None else "",
213 + f"{r['bathrooms']} sdb" if r["bathrooms"] is not None else "",
214 + f"{_fmt_n(round(r['area_sqft']))} pi²" if r["area_sqft"] else "",
215 + ] if b]
216 + name = r["address"] or r["title"] or "Propriété"
217 + loc = ", ".join(x for x in [r["sector"], r["city"]] if x)
218 + return (f'<li><a href="{href}"><strong>{_esc(name)}</strong></a> — '
219 + f'{_esc(_fmt_price(r["price"]))}'
220 + + (f' · {_esc(" · ".join(bits))}' if bits else "")
221 + + (f' · {_esc(loc)}' if loc else "") + "</li>")
222 +
223 +
224 +def _agg_stats(con, cities: list[str] | None = None,
225 + types_values: list[str] | None = None) -> dict:
226 + where = VISIBLE
227 + args: list = []
228 + if cities:
229 + where += f" AND city IN ({','.join('?' * len(cities))})"
230 + args += cities
231 + if types_values:
232 + where += f" AND property_type IN ({','.join('?' * len(types_values))})"
233 + args += types_values
234 + row = con.execute(
235 + f"SELECT COUNT(*) n, AVG(price) avg_p, MIN(price) min_p, MAX(price) max_p"
236 + f" FROM listings WHERE {where}", args).fetchone()
237 + med = None
238 + if row["n"]:
239 + med_row = con.execute(
240 + f"SELECT price FROM listings WHERE {where}"
241 + f" ORDER BY price LIMIT 1 OFFSET ?", args + [row["n"] // 2]).fetchone()
242 + med = med_row["price"] if med_row else None
243 + return {"n": row["n"], "avg": row["avg_p"], "med": med,
244 + "min": row["min_p"], "max": row["max_p"], "where": where, "args": args}
245 +
246 +
247 +def _pagination_html(base_path: str, page: int, pages: int) -> str:
248 + if pages <= 1:
249 + return ""
250 + out = ['<nav class="seo-pages" aria-label="Pagination">']
251 + if page > 1:
252 + prev = base_path if page == 2 else f"{base_path}?page={page - 1}"
253 + out.append(f'<a rel="prev" href="{prev}">← Page précédente</a> ')
254 + out.append(f"<span>Page {page} de {pages}</span>")
255 + if page < pages:
256 + out.append(f' <a rel="next" href="{base_path}?page={page + 1}">Page suivante →</a>')
257 + out.append("</nav>")
258 + return "".join(out)
259 +
260 +
261 +def _breadcrumb_ld(crumbs: list[tuple[str, str]]) -> dict:
262 + return {
263 + "@context": "https://schema.org",
264 + "@type": "BreadcrumbList",
265 + "itemListElement": [
266 + {"@type": "ListItem", "position": i + 1, "name": name,
267 + "item": BASE_URL + path}
268 + for i, (name, path) in enumerate(crumbs)
269 + ],
270 + }
271 +
272 +
273 +# -----------------------------------------------------------------------------
274 +# Accueil
275 +# -----------------------------------------------------------------------------
276 +
277 +def _home_data() -> dict:
278 + def build():
279 + con = db.connect()
280 + row = con.execute(
281 + f"SELECT COUNT(*) total, COUNT(DISTINCT city) cities,"
282 + f" COUNT(DISTINCT source) sources, AVG(price) avg_p"
283 + f" FROM listings WHERE {VISIBLE}").fetchone()
284 + recent = [dict(r) for r in con.execute(
285 + f"SELECT uid, address, title, city, sector, property_type, price,"
286 + f" bedrooms, bathrooms, area_sqft FROM listings WHERE {VISIBLE}"
287 + f" ORDER BY first_seen DESC LIMIT 12")]
288 + con.close()
289 + return {**dict(row), "recent": recent}
290 + return _cached("home", 900, build)
291 +
292 +
293 +def render_home() -> HTMLResponse:
294 + d = _home_data()
295 + reg = registry()
296 + total = _fmt_n(d["total"])
297 + title = f"Immo-Ka — {total} propriétés à vendre au Québec, toutes les agences"
298 + desc = (f"{total} propriétés à vendre dans {_fmt_n(d['cities'])} villes du Québec, "
299 + f"agrégées depuis {d['sources']} sources (RE/MAX, Via Capitale, Sutton, "
300 + f"Proprio Direct, DuProprio, Centris et plus) — mises à jour en continu. "
301 + f"Prix moyen : {_fmt_price(d['avg_p'])}.")
302 + top_cities = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:60]
303 + types = sorted(reg["types"].items(), key=lambda kv: -kv[1]["n"])
304 + body = [
305 + f"<h1>Propriétés à vendre au Québec — {total} annonces de toutes les agences</h1>",
306 + f"<p>Immo-Ka agrège en continu les propriétés à vendre affichées publiquement par "
307 + f"les agences de courtage immobilier et les plateformes du Québec : RE/MAX, "
308 + f"Via Capitale, Sutton, Proprio Direct, Engel &amp; Völkers, Sotheby's, DuProprio, "
309 + f"LesPAC et plus — {d['sources']} sources, {_fmt_n(d['cities'])} villes, "
310 + f"prix moyen {_esc(_fmt_price(d['avg_p']))}. Chaque fiche renvoie à l'annonce "
311 + f"originale de l'agence.</p>",
312 + "<h2>Propriétés à vendre par ville</h2>",
313 + "<ul>" + "".join(
314 + f'<li><a href="/a-vendre/{s}">Propriétés à vendre à {_esc(e["label"])}</a>'
315 + f" ({_fmt_n(e['n'])})</li>"
316 + for s, e in top_cities if e["n"] >= MIN_LISTINGS) + "</ul>",
317 + "<h2>Par type de propriété</h2>",
318 + "<ul>" + "".join(
319 + f'<li><a href="/type/{s}">{_esc(e["label"])} à vendre au Québec</a>'
320 + f" ({_fmt_n(e['n'])})</li>"
321 + for s, e in types if e["n"] >= MIN_LISTINGS) + "</ul>",
322 + "<h2>Dernières propriétés ajoutées</h2>",
323 + "<ul>" + "".join(_item_li(r) for r in d["recent"]) + "</ul>",
324 + '<p><a href="/stats">Statistiques du marché</a> · '
325 + '<a href="/agences">Agences couvertes</a></p>',
326 + ]
327 + jsonld = [{
328 + "@context": "https://schema.org",
329 + "@type": "WebSite",
330 + "name": SITE_NAME,
331 + "url": BASE_URL + "/",
332 + "inLanguage": "fr-CA",
333 + "description": desc,
334 + "potentialAction": {
335 + "@type": "SearchAction",
336 + "target": {"@type": "EntryPoint",
337 + "urlTemplate": BASE_URL + "/?q={search_term_string}"},
338 + "query-input": "required name=search_term_string",
339 + },
340 + }, {
341 + "@context": "https://schema.org",
342 + "@type": "Organization",
343 + "name": "Groupe-Ka",
344 + "url": BASE_URL + "/",
345 + "email": "contact@groupe-ka.com",
346 + }]
347 + return _page(title, desc, BASE_URL + "/", "".join(body), jsonld)
348 +
349 +
350 +# -----------------------------------------------------------------------------
351 +# Pages programmatiques : ville, ville+type, type
352 +# -----------------------------------------------------------------------------
353 +
354 +def _render_category(city_slug: str | None, type_slug: str | None,
355 + page: int) -> HTMLResponse:
356 + reg = registry()
357 + city = reg["cities"].get(city_slug) if city_slug else None
358 + ptype = reg["types"].get(type_slug) if type_slug else None
359 + if (city_slug and not city) or (type_slug and not ptype):
360 + return render_404()
361 + if city_slug and type_slug and reg["city_types"].get((city_slug, type_slug), 0) < 1:
362 + return render_404()
363 +
364 + con = db.connect()
365 + st = _agg_stats(con, city["values"] if city else None,
366 + ptype["values"] if ptype else None)
367 + if st["n"] < 1:
368 + con.close()
369 + return render_404()
370 +
371 + pages = max(1, -(-st["n"] // PAGE_SIZE))
372 + if page < 1 or page > pages:
373 + con.close()
374 + return render_404()
375 + rows = con.execute(
376 + f"SELECT uid, address, title, city, sector, property_type, price,"
377 + f" bedrooms, bathrooms, area_sqft FROM listings WHERE {st['where']}"
378 + f" ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?",
379 + st["args"] + [PAGE_SIZE, (page - 1) * PAGE_SIZE]).fetchall()
380 +
381 + # maillage interne
382 + links = []
383 + if city:
384 + # types offerts dans cette ville
385 + tlinks = []
386 + for (cs, ts_), n in sorted(reg["city_types"].items(), key=lambda kv: -kv[1]):
387 + if cs == city_slug and n >= 1 and ts_ in reg["types"] and ts_ != type_slug:
388 + lbl = reg["types"][ts_]["label"]
389 + tlinks.append(f'<li><a href="/a-vendre/{cs}/{ts_}">'
390 + f"{_esc(lbl)} à vendre à {_esc(city['label'])}</a> ({_fmt_n(n)})</li>")
391 + if tlinks:
392 + links.append("<h2>Autres types de propriété à "
393 + + _esc(city["label"]) + "</h2><ul>" + "".join(tlinks[:20]) + "</ul>")
394 + if type_slug:
395 + links.append(f'<p><a href="/a-vendre/{city_slug}">Toutes les propriétés à vendre '
396 + f"à {_esc(city['label'])}</a> · "
397 + f'<a href="/type/{type_slug}">{_esc(ptype["label"])} à vendre au Québec</a></p>')
398 + top = sorted(reg["cities"].items(), key=lambda kv: -kv[1]["n"])[:30]
399 + links.append("<h2>Autres villes</h2><ul>" + "".join(
400 + f'<li><a href="/a-vendre/{s}{"/" + type_slug if type_slug and (s, type_slug) in reg["city_types"] else ""}">'
401 + f'Propriétés à vendre à {_esc(e["label"])}</a> ({_fmt_n(e["n"])})</li>'
402 + for s, e in top if s != city_slug and e["n"] >= MIN_LISTINGS) + "</ul>")
403 + con.close()
404 +
405 + if city and ptype:
406 + base_path = f"/a-vendre/{city_slug}/{type_slug}"
407 + h1 = f"{ptype['label']} à vendre à {city['label']}"
408 + what = f"{ptype['label'].lower()} à vendre à {city['label']}"
409 + elif city:
410 + base_path = f"/a-vendre/{city_slug}"
411 + h1 = f"Propriétés à vendre à {city['label']}"
412 + what = f"propriétés à vendre à {city['label']}"
413 + else:
414 + base_path = f"/type/{type_slug}"
415 + h1 = f"{ptype['label']} à vendre au Québec"
416 + what = f"{ptype['label'].lower()} à vendre au Québec"
417 +
418 + canonical = BASE_URL + base_path + (f"?page={page}" if page > 1 else "")
419 + title = f"{h1} — {_fmt_n(st['n'])} annonces" + (f" (page {page})" if page > 1 else "") + " | Immo-Ka"
420 + desc = (f"{_fmt_n(st['n'])} {what} : prix médian {_fmt_price(st['med'])}, "
421 + f"prix moyen {_fmt_price(st['avg'])}. Annonces de toutes les agences "
422 + f"(RE/MAX, Via Capitale, Sutton, DuProprio…), mises à jour en continu.")
423 + stats_p = (f"<p><strong>{_fmt_n(st['n'])}</strong> annonces · prix médian "
424 + f"<strong>{_esc(_fmt_price(st['med']))}</strong> · prix moyen "
425 + f"<strong>{_esc(_fmt_price(st['avg']))}</strong> · de "
426 + f"{_esc(_fmt_price(st['min']))} à {_esc(_fmt_price(st['max']))}.</p>")
427 + crumbs = [("Accueil", "/")]
428 + if city:
429 + crumbs.append((f"À vendre à {city['label']}", f"/a-vendre/{city_slug}"))
430 + if ptype:
431 + crumbs.append((f"{ptype['label']}", base_path))
432 + else:
433 + crumbs.append((h1, base_path))
434 + body = (f'<nav aria-label="Fil d\'Ariane">'
435 + + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs)
436 + + f"</nav><h1>{_esc(h1)}</h1>" + stats_p
437 + + "<ul>" + "".join(_item_li(r) for r in rows) + "</ul>"
438 + + _pagination_html(base_path, page, pages)
439 + + "".join(links))
440 + return _page(title, desc, canonical, body, [_breadcrumb_ld(crumbs)])
441 +
442 +
443 +# -----------------------------------------------------------------------------
444 +# Fiche propriété
445 +# -----------------------------------------------------------------------------
446 +
447 +_TYPE_SCHEMA = {
448 + "maison": "SingleFamilyResidence", "condo": "Apartment",
449 + "chalet": "House", "bi-generation": "House", "domaine-et-villa": "House",
450 + "duplex": "Residence", "triplex": "Residence", "quadruplex": "Residence",
451 + "multiplex": "Residence", "loft": "Apartment", "maison-mobile": "House",
452 +}
453 +
454 +
455 +def render_listing(uid: str, slug: str | None) -> Response:
456 + con = db.connect()
457 + row = con.execute("SELECT * FROM listings WHERE uid=?", (uid,)).fetchone()
458 + con.close()
459 + if row is None:
460 + return render_404()
461 +
462 + city_slug = slugify(row["city"] or "")
463 + city_known = city_slug in registry()["cities"]
464 + city_href = f"/a-vendre/{city_slug}" if city_known else "/"
465 +
466 + if not row["active"]:
467 + # propriété retirée / vendue → 410 Gone, avec des portes de sortie
468 + name = row["address"] or row["title"] or "Propriété"
469 + body = (f"<h1>Cette propriété n'est plus à vendre</h1>"
470 + f"<p>L'annonce « {_esc(name)} » ({_esc(row['city'] or 'Québec')}) a été "
471 + f"retirée ou vendue.</p><ul>"
472 + + (f'<li><a href="{city_href}">Propriétés à vendre à '
473 + f"{_esc(row['city'])}</a></li>" if city_known else "")
474 + + '<li><a href="/">Toutes les propriétés à vendre au Québec</a></li></ul>')
475 + return _page(f"Propriété retirée — {name} | {SITE_NAME}",
476 + "Cette annonce a été retirée ou vendue.",
477 + BASE_URL + fiche_href(uid), body, noindex=True, status=410)
478 +
479 + expected = listing_slug(row)
480 + if expected and slug != expected:
481 + return RedirectResponse(BASE_URL + fiche_href(uid, expected), status_code=301)
482 +
483 + d = dict(row)
484 + images = json.loads(d.get("images") or "[]")
485 + features = json.loads(d.get("features") or "[]")
486 + name = d["address"] or d["title"] or "Propriété à vendre"
487 + loc = ", ".join(x for x in [d["sector"], d["city"]] if x) or "Québec"
488 + canonical = BASE_URL + fiche_href(uid, expected)
489 + ptype = d["property_type"] or "Propriété"
490 +
491 + specs = [(lbl, val) for lbl, val in [
492 + ("Type", ptype),
493 + ("Prix", _fmt_price(d["price"]) if d["price"] is not None else d["price_label"]),
494 + ("Chambres", d["bedrooms"]),
495 + ("Salles de bain", d["bathrooms"]),
496 + ("Salles d'eau", d["powder_rooms"]),
497 + ("Superficie habitable", f"{_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else None),
498 + ("Terrain", f"{_fmt_n(round(d['lot_sqft']))} pi²" if d["lot_sqft"] else None),
499 + ("Année de construction", d["year_built"]),
500 + ("Ville", d["city"]),
501 + ("Secteur", d["sector"]),
502 + ("N° MLS", d["mls"]),
503 + ("Courtier", d["broker_name"]),
504 + ("Agence", d["agency"]),
505 + ] if val not in (None, "", 0)]
506 + descr = (d["description"] or "").strip()
507 + if len(descr) > 1500:
508 + descr = descr[:1500].rsplit(" ", 1)[0] + "…"
509 +
510 + type_slug = slugify(ptype)
511 + crumbs = [("Accueil", "/")]
512 + if city_known:
513 + crumbs.append((f"À vendre à {d['city']}", city_href))
514 + if (city_slug, type_slug) in registry()["city_types"]:
515 + crumbs.append((ptype, f"/a-vendre/{city_slug}/{type_slug}"))
516 + crumbs.append((name, fiche_href(uid, expected)))
517 +
518 + body = [
519 + '<nav aria-label="Fil d\'Ariane">'
520 + + " › ".join(f'<a href="{p}">{_esc(n)}</a>' for n, p in crumbs[:-1])
521 + + f" › {_esc(name)}</nav>",
522 + f"<h1>{_esc(name)}</h1>",
523 + f"<p><strong>{_esc(ptype)} à vendre à {_esc(loc)}</strong> — "
524 + f"{_esc(_fmt_price(d['price']) if d['price'] is not None else (d['price_label'] or 'Prix sur demande'))}</p>",
525 + ]
526 + if images:
527 + body.append("".join(
528 + f'<img src="{_esc(u)}" alt="{_esc(name)} — photo {i + 1}" loading="lazy" />'
529 + for i, u in enumerate(images[:3])))
530 + body.append("<h2>Caractéristiques</h2><ul>" + "".join(
531 + f"<li><strong>{_esc(l)} :</strong> {_esc(v)}</li>" for l, v in specs) + "</ul>")
532 + if descr:
533 + body.append(f"<h2>Description</h2><p>{_esc(descr)}</p>")
534 + if features:
535 + body.append("<h2>Détails</h2><ul>" + "".join(
536 + f"<li>{_esc(f)}</li>" for f in features[:20]) + "</ul>")
537 + if d["url"]:
538 + body.append(f'<p><a href="{_esc(d["url"])}" rel="noopener">'
539 + f"Voir l'annonce originale chez {_esc(d['broker_name'] or d['agency'] or 'l’agence')}</a></p>")
540 + if city_known:
541 + body.append(f'<p><a href="{city_href}">Propriétés à vendre à {_esc(d["city"])}</a>'
542 + + (f' · <a href="/a-vendre/{city_slug}/{type_slug}">{_esc(ptype)} à vendre à '
543 + f'{_esc(d["city"])}</a>'
544 + if (city_slug, type_slug) in registry()["city_types"] else "") + "</p>")
545 +
546 + about_type = _TYPE_SCHEMA.get(type_slug, "Residence")
547 + about: dict = {
548 + "@type": about_type,
549 + "name": name,
550 + "address": {"@type": "PostalAddress",
551 + "streetAddress": d["address"] or None,
552 + "addressLocality": d["city"] or None,
553 + "addressRegion": "QC", "addressCountry": "CA"},
554 + }
555 + if d["lat"] is not None and d["lng"] is not None:
556 + about["geo"] = {"@type": "GeoCoordinates",
557 + "latitude": d["lat"], "longitude": d["lng"]}
558 + if d["bedrooms"] is not None:
559 + about["numberOfBedrooms"] = d["bedrooms"]
560 + if d["bathrooms"] is not None:
561 + about["numberOfBathroomsTotal"] = d["bathrooms"]
562 + if d["area_sqft"]:
563 + about["floorSize"] = {"@type": "QuantitativeValue",
564 + "value": round(d["area_sqft"]), "unitCode": "FTK"}
565 + if d["year_built"]:
566 + about["yearBuilt"] = d["year_built"]
567 + about = {k: v for k, v in about.items() if v is not None}
568 + about["address"] = {k: v for k, v in about["address"].items() if v is not None}
569 + jsonld: list[dict] = [{
570 + "@context": "https://schema.org",
571 + "@type": "RealEstateListing",
572 + "name": name,
573 + "url": canonical,
574 + "inLanguage": "fr-CA",
575 + "datePosted": _iso_date(d.get("first_seen")),
576 + "image": images[:6] or None,
577 + "about": about,
578 + }, _breadcrumb_ld(crumbs)]
579 + if d["price"] is not None:
580 + jsonld[0]["offers"] = {"@type": "Offer", "price": round(d["price"], 2),
581 + "priceCurrency": "CAD",
582 + "availability": "https://schema.org/InStock"}
583 + jsonld[0] = {k: v for k, v in jsonld[0].items() if v is not None}
584 +
585 + title = f"{name} — {ptype} à vendre, {d['city'] or 'Québec'} | {_fmt_price(d['price']) if d['price'] is not None else 'Prix sur demande'}"
586 + meta_desc = (f"{ptype} à vendre à {loc}"
587 + + (f", {d['bedrooms']} chambres" if d["bedrooms"] else "")
588 + + (f", {_fmt_n(round(d['area_sqft']))} pi²" if d["area_sqft"] else "")
589 + + f" — {_fmt_price(d['price']) if d['price'] is not None else 'prix sur demande'}. "
590 + + (descr[:120] + "…" if len(descr) > 120 else descr))
591 + return _page(title, meta_desc, canonical, "".join(body), jsonld,
592 + og_image=images[0] if images else None, og_type="article")
593 +
594 +
595 +# -----------------------------------------------------------------------------
596 +# Pages statiques du SPA (meta dédiées) et 404
597 +# -----------------------------------------------------------------------------
598 +
599 +_STATIC_META = {
600 + "/stats": ("Statistiques du marché immobilier québécois",
601 + "Prix moyens, écarts prix demandé vs estimation Vrai-Prix par bannière, "
602 + "et volumes d'annonces par source — statistiques en continu d'Immo-Ka.",
603 + False),
604 + "/agences": ("Agences immobilières couvertes",
605 + "Toutes les bannières et agences agrégées par Immo-Ka : RE/MAX, Via Capitale, "
606 + "Sutton, Proprio Direct, Engel & Völkers, Sotheby's, DuProprio et plus.",
607 + False),
608 + "/conditions": ("Conditions d'utilisation",
609 + "Conditions d'utilisation de la plateforme Immo-Ka (Groupe-Ka).", False),
610 + "/confidentialite": ("Politique de confidentialité",
611 + "Politique de confidentialité d'Immo-Ka (Groupe-Ka) — Loi 25.", False),
612 + "/profil": ("Mon profil", "Votre compte Groupe KA sur Immo-Ka.", True),
613 +}
614 +
615 +
616 +def render_static(path: str) -> HTMLResponse:
617 + t, desc, noindex = _STATIC_META[path]
618 + body = f"<h1>{_esc(t)}</h1><p>{_esc(desc)}</p>"
619 + return _page(f"{t} | {SITE_NAME}", desc, BASE_URL + path, body, noindex=noindex)
620 +
621 +
622 +def render_404() -> HTMLResponse:
623 + body = ('<h1>Page introuvable</h1><p>Le lien demandé n\'existe pas.</p>'
624 + '<p><a href="/">Toutes les propriétés à vendre au Québec</a></p>')
625 + return _page(f"Page introuvable | {SITE_NAME}", "Page introuvable.",
626 + BASE_URL + "/", body, noindex=True, status=404)
627 +
628 +
629 +# -----------------------------------------------------------------------------
630 +# robots.txt et sitemaps
631 +# -----------------------------------------------------------------------------
632 +
633 +FICHES_PER_SITEMAP = 40000
634 +
635 +
636 +def robots_txt() -> PlainTextResponse:
637 + return PlainTextResponse(
638 + "User-agent: *\n"
639 + "Allow: /\n"
640 + "Disallow: /api/\n"
641 + "Disallow: /profil\n"
642 + f"\nSitemap: {BASE_URL}/sitemap.xml\n")
643 +
644 +
645 +def _xml(urls: list[str]) -> Response:
646 + body = ('<?xml version="1.0" encoding="UTF-8"?>\n'
647 + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
648 + + "\n".join(urls) + "\n</urlset>")
649 + return Response(body, media_type="application/xml")
650 +
651 +
652 +def _url_el(loc: str, lastmod: str | None = None) -> str:
653 + lm = f"<lastmod>{lastmod}</lastmod>" if lastmod else ""
654 + return f" <url><loc>{html.escape(loc)}</loc>{lm}</url>"
655 +
656 +
657 +def sitemap_index() -> Response:
658 + def build():
659 + con = db.connect()
660 + n = con.execute(f"SELECT COUNT(*) c FROM listings WHERE {VISIBLE}").fetchone()["c"]
661 + last = con.execute(
662 + f"SELECT MAX(updated_at) m FROM listings WHERE {VISIBLE}").fetchone()["m"]
663 + con.close()
664 + parts = -(-n // FICHES_PER_SITEMAP) or 1
665 + lm = _iso_date(last)
666 + maps = [f"{BASE_URL}/sitemaps/fiches-{i + 1}.xml" for i in range(parts)]
667 + maps += [f"{BASE_URL}/sitemaps/villes.xml",
668 + f"{BASE_URL}/sitemaps/villes-types.xml",
669 + f"{BASE_URL}/sitemaps/types.xml",
670 + f"{BASE_URL}/sitemaps/pages.xml"]
671 + body = ('<?xml version="1.0" encoding="UTF-8"?>\n'
672 + '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
673 + + "\n".join(f" <sitemap><loc>{html.escape(m)}</loc>"
674 + f"<lastmod>{lm}</lastmod></sitemap>" for m in maps)
675 + + "\n</sitemapindex>")
676 + return body
677 + return Response(_cached("sm:index", 3600, build), media_type="application/xml")
678 +
679 +
680 +def sitemap_file(name: str) -> Response:
681 + m = re.fullmatch(r"fiches-(\d+)\.xml", name)
682 + if m:
683 + part = int(m.group(1))
684 +
685 + def build():
686 + con = db.connect()
687 + rows = con.execute(
688 + f"SELECT uid, address, title, city, updated_at FROM listings"
689 + f" WHERE {VISIBLE} ORDER BY uid LIMIT ? OFFSET ?",
690 + (FICHES_PER_SITEMAP, (part - 1) * FICHES_PER_SITEMAP)).fetchall()
691 + con.close()
692 + if not rows:
693 + return None
694 + return [_url_el(BASE_URL + fiche_href(r["uid"], listing_slug(r)),
695 + _iso_date(r["updated_at"])) for r in rows]
696 + urls = _cached(f"sm:fiches:{part}", 3600, build)
697 + if urls is None:
698 + return Response("Sitemap introuvable", status_code=404)
699 + return _xml(urls)
700 +
701 + if name == "villes.xml":
702 + def build():
703 + reg = registry()
704 + lm = _city_lastmod()
705 + return [_url_el(f"{BASE_URL}/a-vendre/{s}", lm.get(s))
706 + for s, e in sorted(reg["cities"].items())
707 + if e["n"] >= MIN_LISTINGS]
708 + return _xml(_cached("sm:villes", 3600, build))
709 +
710 + if name == "villes-types.xml":
711 + def build():
712 + reg = registry()
713 + lm = _city_lastmod()
714 + return [_url_el(f"{BASE_URL}/a-vendre/{cs}/{ts_}", lm.get(cs))
715 + for (cs, ts_), n in sorted(reg["city_types"].items())
716 + if n >= MIN_LISTINGS and cs in reg["cities"] and ts_ in reg["types"]
717 + and reg["cities"][cs]["n"] >= MIN_LISTINGS]
718 + return _xml(_cached("sm:villes-types", 3600, build))
719 +
720 + if name == "types.xml":
721 + def build():
722 + reg = registry()
723 + return [_url_el(f"{BASE_URL}/type/{s}")
724 + for s, e in sorted(reg["types"].items()) if e["n"] >= MIN_LISTINGS]
725 + return _xml(_cached("sm:types", 3600, build))
726 +
727 + if name == "pages.xml":
728 + return _xml([_url_el(f"{BASE_URL}{p}")
729 + for p in ["/", "/stats", "/agences", "/conditions", "/confidentialite"]])
730 +
731 + return Response("Sitemap introuvable", status_code=404)
732 +
733 +
734 +def _city_lastmod() -> dict[str, str]:
735 + def build():
736 + con = db.connect()
737 + out: dict[str, str] = {}
738 + for r in con.execute(
739 + f"SELECT city, MAX(updated_at) m FROM listings WHERE {VISIBLE}"
740 + " AND city<>'' GROUP BY city"):
741 + s = slugify(r["city"])
742 + if s:
743 + prev = out.get(s)
744 + cur = _iso_date(r["m"])
745 + out[s] = max(prev, cur) if prev else cur
746 + con.close()
747 + return out
748 + return _cached("sm:citylastmod", 3600, build)
749 +
750 +
751 +# -----------------------------------------------------------------------------
752 +# Résolution de slugs pour le frontend (pages /a-vendre côté client)
753 +# -----------------------------------------------------------------------------
754 +
755 +def resolve_slugs(ville: str | None, ptype: str | None) -> dict | None:
756 + reg = registry()
757 + out: dict = {}
758 + if ville:
759 + e = reg["cities"].get(ville)
760 + if not e:
761 + return None
762 + out["city"] = e["label"]
763 + out["city_n"] = e["n"]
764 + if ptype:
765 + e = reg["types"].get(ptype)
766 + if not e:
767 + return None
768 + out["property_type"] = e["label"]
769 + out["type_n"] = e["n"]
770 + return out
771 +
772 +
773 +# -----------------------------------------------------------------------------
774 +# Routage : appelé par le catch-all de web.py
775 +# -----------------------------------------------------------------------------
776 +
777 +def render_for_path(path: str, query: dict) -> Response | None:
778 + """HTML SEO pour `path` (ex. « /a-vendre/levis »), ou None → index brut."""
779 + path = path.rstrip("/") or "/"
780 + try:
781 + page = max(1, int(query.get("page", "1")))
782 + except ValueError:
783 + page = 1
784 +
785 + if path == "/":
786 + return render_home()
787 + if path in _STATIC_META:
788 + return render_static(path)
789 +
790 + parts = [p for p in path.split("/") if p]
791 + if parts[0] == "propriete" and len(parts) in (2, 3):
792 + return render_listing(parts[1], parts[2] if len(parts) == 3 else None)
793 + if parts[0] == "a-vendre" and len(parts) in (2, 3):
794 + return _render_category(parts[1], parts[2] if len(parts) == 3 else None, page)
795 + if parts[0] == "type" and len(parts) == 2:
796 + return _render_category(None, parts[1], page)
797 + return render_404()
modified immoka/web.py +36 −4
@@ -9,13 +9,13 @@ import json
9 9 import threading
10 10 from pathlib import Path
11 11
12 −from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
12 +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request
13 13 from fastapi.middleware.cors import CORSMiddleware
14 14 from fastapi.middleware.gzip import GZipMiddleware
15 15 from fastapi.responses import FileResponse
16 16 from fastapi.staticfiles import StaticFiles
17 17
18 −from . import auth, db, favorites, ingest
18 +from . import auth, db, favorites, ingest, seo
19 19
20 20 ROOT = Path(__file__).resolve().parent.parent
21 21 SOURCES_PATH = ROOT / "data" / "sources.json"
@@ -413,6 +413,31 @@ def trigger_sync(background: BackgroundTasks, source: str | None = None):
413 413 return {"status": "démarré", "source": source or "toutes"}
414 414
415 415
416 +# --- Référencement : robots, sitemaps, résolution de slugs (voir immoka/seo.py)
417 +@app.get("/robots.txt")
418 +def robots():
419 + return seo.robots_txt()
420 +
421 +
422 +@app.get("/sitemap.xml")
423 +def sitemap():
424 + return seo.sitemap_index()
425 +
426 +
427 +@app.get("/sitemaps/{name}")
428 +def sitemap_part(name: str):
429 + return seo.sitemap_file(name)
430 +
431 +
432 +@app.get("/api/seo/resolve")
433 +def seo_resolve(ville: str | None = None, type: str | None = None):
434 + """Slug d'URL programmatique → valeurs exactes (pages /a-vendre du SPA)."""
435 + out = seo.resolve_slugs(ville, type)
436 + if out is None:
437 + raise HTTPException(404, "Slug inconnu")
438 + return out
439 +
440 +
416 441 # --- Frontend statique -------------------------------------------------------
417 442 if FRONTEND_DIR.exists():
418 443
@@ -420,8 +445,15 @@ if FRONTEND_DIR.exists():
420 445 app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets")
421 446
422 447 @app.get("/{full_path:path}")
423 − def spa(full_path: str):
448 + def spa(full_path: str, request: Request):
424 449 target = FRONTEND_DIR / full_path
425 450 if full_path and target.is_file():
426 451 return FileResponse(target)
427 − return FileResponse(FRONTEND_DIR / "index.html")
452 + # rendu SEO côté serveur : HTML complet par route (meta, JSON-LD,
453 + # contenu dans #root que React remplace au montage). Ne doit JAMAIS
454 + # casser l'app → repli sur l'index brut à la moindre erreur.
455 + try:
456 + return seo.render_for_path("/" + full_path,
457 + dict(request.query_params))
458 + except Exception:
459 + return FileResponse(FRONTEND_DIR / "index.html")
428 460