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>
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 |  |
| 10 | − | |
| 10 | + | |
| 11 | 11 |  |
| 12 | − | |
| 12 | + | |
| 13 | 13 |  |
| 14 | 14 | |
| 15 | − | |
| 16 | − | |
| 17 | − | |
| 18 | − | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 19 | 20 |  |
| 21 | + | |
| 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 | −|  |  | | |
| 43 | +|  |  | | |
| 44 | + | |
| 45 | +| Carte Ka Maps — grappes de prix en direct | Fiche — galerie, Vrai-Prix, courtier | | |
| 46 | +|---|---| | |
| 47 | +|  |  | | |
| 48 | + | |
| 49 | +| Fiche — pièces, emplacement, quartier | Page ville indexable (`/a-vendre/quebec`) | | |
| 50 | +|---|---| | |
| 51 | +|  |  | | |
| 52 | + | |
| 53 | +| Statistiques du marché agrégé | Agences — 22 bannières, 159 sous-agences | | |
| 54 | +|---|---| | |
| 55 | +|  |  | | |
| 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 & 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 | |