feat: thème rouge, sous-agences, quartier StatCan, estimation Vrai-Prix, géocodage
- Palette rouge (distincte de Lou-Ka), pagination 12/page, page Sources par sous-agence - 74 connecteurs (DuProprio, Via Capitale portail, Royal LePage par bureau, C21 sous-agences…) - Bloc quartier (recensement 2021, crime, chaleur, proximité — données StatCan/INSPQ) - Estimation Vrai-Prix par fiche (valeur + fourchette + confiance + lien analyse) - Géocodage Adresses Québec + Nominatim ; dédup pré-calculée (perf) ; SQLite WAL Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing 27 changed files with +1,774 and −138
deleted
.claude/scheduled_tasks.lock
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −{"sessionId":"c9b9d228-3b87-491a-943d-e0458a7449fb","pid":46756,"procStart":"Wed Aug 12 10:21:44 2026","acquiredAt":1786533230546} | |
| \ No newline at end of file | ||
modified
README.md
+10 −5
@@ -12,11 +12,16 @@ | ||
| 12 | 12 |  |
| 13 | 13 |  |
| 14 | 14 | |
| 15 | − | |
| 16 | − | |
| 17 | − | |
| 18 | − | |
| 19 | − | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 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). | |
| 20 | 25 | |
| 21 | 26 | *Agrégateur indépendant des maisons, condos et terrains à vendre au Québec — |
| 22 | 27 | un connecteur dédié par agence de courtage, chaque propriété normalisée vers un |
modified
data/sources.json
+12 −1
@@ -74,7 +74,7 @@ | ||
| 74 | 74 | "connector": "exp_quebec", |
| 75 | 75 | "status": "a_faire", |
| 76 | 76 | "type": "agence", |
| 77 | − "note": "Next.js (exprealty.ca/fr-CA/search) : inscriptions chargées côté client via backend BoldTrail/kvCORE ; absentes de __NEXT_DATA__ et des chunks JS ; Firecrawl proxy=stealth rend la page mais pas les fiches (fetch client non déclenché). Aucun endpoint API découvrable (seul images.expcloud.com exposé). Reste : capturer l'XHR live de recherche BoldTrail (state=QC) via un navigateur instrumenté, puis rejouer l'API JSON." | |
| 77 | + "note": "Next.js. Scrapfly ASP+render_js rend le shell mais les inscriptions sont hydratees cote client via BoldTrail/kvCORE (2-4 cartes seulement rendues ; virtualisation). La route _next/data/<buildId>/fr-CA/search.json ne contient PAS les listings. Reste : capturer l'XHR BoldTrail live (js_scenario/scroll Scrapfly ou navigateur instrumente) ou l'API kvCORE authentifiee." | |
| 78 | 78 | }, |
| 79 | 79 | { |
| 80 | 80 | "id": "kw_urbain", |
@@ -240,6 +240,17 @@ | ||
| 240 | 240 | "role": "sous-agence", |
| 241 | 241 | "platform": "source.immo", |
| 242 | 242 | "note": "Century 21 Vision (Gatineau) — compte source.immo distinct (~139 à vendre)." |
| 243 | + }, | |
| 244 | + { | |
| 245 | + "id": "duproprio", | |
| 246 | + "name": "DuProprio (sans courtier)", | |
| 247 | + "url": "https://duproprio.com", | |
| 248 | + "listing_url": "https://duproprio.com/sitemaps/fr/index.xml.gz", | |
| 249 | + "coverage": "Toute la province — vente par le propriétaire (FSBO), ~7 600 propriétés QC", | |
| 250 | + "connector": "duproprio", | |
| 251 | + "status": "actif", | |
| 252 | + "type": "fsbo", | |
| 253 | + "note": "FSBO. Enumeration via sitemaps FR par region QC (/sitemaps/fr/<region>-listings.xml.gz) : l'URL encode region/ville/type/id. Fiche detail JSON-LD Product (prix, description, galerie photos.duproprio.com). Fetch direct + Scrapfly ASP en secours. agency='DuProprio (sans courtier)'." | |
| 243 | 254 | } |
| 244 | 255 | ] |
| 245 | 256 | } |
| \ No newline at end of file | ||
modified
docs/screenshots/home.png
+0 −0
Binary file not shown.
modified
docs/screenshots/listing.png
+0 −0
Binary file not shown.
modified
frontend/index.html
+2 −2
@@ -9,14 +9,14 @@ | ||
| 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 | 11 | <meta name="description" content="Immo-Ka agrège les propriétés à vendre affichées par les agences de courtage immobilier du Québec (RE/MAX, Sutton, Via Capitale, Proprio Direct…), toujours à jour." /> |
| 12 | − <meta name="theme-color" content="#f5f3ee" /> | |
| 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" /> |
| 15 | 15 | <meta name="apple-mobile-web-app-title" content="Immo-Ka" /> |
| 16 | 16 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 17 | 17 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 18 | 18 | <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" /> |
| 19 | − <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23121712'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='30' font-weight='900' fill='%23d9f26b' text-anchor='middle'%3EIK%3C/text%3E%3C/svg%3E" /> | |
| 19 | + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%231a1214'/%3E%3Ctext x='32' y='45' font-family='Arial Black,sans-serif' font-size='30' font-weight='900' fill='%23e23744' text-anchor='middle'%3EIK%3C/text%3E%3C/svg%3E" /> | |
| 20 | 20 | </head> |
| 21 | 21 | <body> |
| 22 | 22 | <div id="root"></div> |
modified
frontend/src/api.ts
+41 −0
@@ -49,6 +49,9 @@ export interface Listing { | ||
| 49 | 49 | lat: number | null; |
| 50 | 50 | lng: number | null; |
| 51 | 51 | price_history?: { ts: number; price: number | null }[]; |
| 52 | + poi?: Poi[]; // commodités de proximité (fiche seulement) | |
| 53 | + quartier?: Quartier | null; // stats de quartier (fiche seulement) | |
| 54 | + vraiprix?: VraiPrix | null; // estimation de valeur marchande (Vrai-Prix) | |
| 52 | 55 | first_seen?: number; |
| 53 | 56 | last_seen?: number; |
| 54 | 57 | updated_at?: number; |
@@ -149,6 +152,16 @@ export const fetchFacets = (city?: string) => | ||
| 149 | 152 | export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); |
| 150 | 153 | export const fetchStats = () => get<Stats>("/api/stats"); |
| 151 | 154 | |
| 155 | +export interface SubAgency { name: string; count: number; sources: string[] } | |
| 156 | +export interface Franchise { | |
| 157 | + franchise: string; | |
| 158 | + total: number; | |
| 159 | + sub_agencies: number; | |
| 160 | + agencies: SubAgency[]; | |
| 161 | +} | |
| 162 | +export const fetchAgencies = () => | |
| 163 | + get<{ franchises: Franchise[] }>("/api/agencies"); | |
| 164 | + | |
| 152 | 165 | // --- Formatage -------------------------------------------------------------- |
| 153 | 166 | export const fmtPrice = (p: number | null, label?: string) => |
| 154 | 167 | p != null |
@@ -162,3 +175,31 @@ export const fmtDate = (ts: number): string => | ||
| 162 | 175 | new Date(ts * 1000).toLocaleDateString("fr-CA", { |
| 163 | 176 | day: "numeric", month: "long", year: "numeric", |
| 164 | 177 | }); |
| 178 | + | |
| 179 | +// --- Vrai-Prix : estimation de valeur marchande ------------------------------ | |
| 180 | +export interface VraiPrix { | |
| 181 | + id: string; | |
| 182 | + value: number | null; | |
| 183 | + low: number | null; | |
| 184 | + high: number | null; | |
| 185 | + confidence_pct: number | null; | |
| 186 | + confidence: string | null; // A | B | C | D | |
| 187 | + url: string; // page d'analyse détaillée | |
| 188 | +} | |
| 189 | + | |
| 190 | +// --- Quartier (recensement 2021, proximité StatCan, chaleur INSPQ, crime) ---- | |
| 191 | +export interface Poi { cat: string; name: string; dist_m: number } | |
| 192 | +export interface Quartier { | |
| 193 | + dauid?: string | null; | |
| 194 | + demographie?: { | |
| 195 | + population: number | null; densite: number | null; age_median: number | null; | |
| 196 | + revenu_median: number | null; pct_locataires: number | null; | |
| 197 | + loyer_moyen: number | null; pct_francais: number | null; pct_univ: number | null; | |
| 198 | + }; | |
| 199 | + proximite?: Record<string, number>; | |
| 200 | + defavorisation?: { quintile_materiel: number | null; quintile_social: number | null }; | |
| 201 | + chaleur?: { classe: number; ecart: number | null }; | |
| 202 | + crime?: | |
| 203 | + | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number } | |
| 204 | + | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null }; | |
| 205 | +} | |
added
frontend/src/components/QuartierBlock.tsx
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/QuartierBlock.tsx : section « Le quartier » de la fiche — | |
| 5 | +// démographie du recensement 2021 (aire de diffusion ~500 hab.), scores de | |
| 6 | +// proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité. | |
| 7 | +// ----------------------------------------------------------------------------- | |
| 8 | +import { Quartier } from "../api"; | |
| 9 | + | |
| 10 | +const fmtMoney = (v: number | null | undefined) => | |
| 11 | + v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"; | |
| 12 | +const fmtPct = (v: number | null | undefined) => | |
| 13 | + v == null ? null : Math.round(v) + " %"; | |
| 14 | + | |
| 15 | +// scores PMD affichés (clé backend -> libellé) | |
| 16 | +const PROX_LABELS: [string, string][] = [ | |
| 17 | + ["prox_epicerie", "Épiceries"], | |
| 18 | + ["prox_transport", "Transport en commun"], | |
| 19 | + ["prox_parc", "Parcs"], | |
| 20 | + ["prox_ecole_prim", "Écoles primaires"], | |
| 21 | + ["prox_sante", "Soins de santé"], | |
| 22 | + ["prox_pharmacie", "Pharmacies"], | |
| 23 | +]; | |
| 24 | + | |
| 25 | +function chaleurBadge(classe: number, ecart: number | null) { | |
| 26 | + if (classe <= 3) | |
| 27 | + return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "🌿" }; | |
| 28 | + if (classe >= 7) | |
| 29 | + return { | |
| 30 | + txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`, | |
| 31 | + cls: "q-badge hot", ico: "🌡", | |
| 32 | + }; | |
| 33 | + return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "🌤" }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export default function QuartierBlock({ q }: { q: Quartier }) { | |
| 37 | + const d = q.demographie; | |
| 38 | + const stats: [string, string | null][] = d | |
| 39 | + ? [ | |
| 40 | + ["Revenu médian des ménages", fmtMoney(d.revenu_median)], | |
| 41 | + ["Ménages locataires", fmtPct(d.pct_locataires)], | |
| 42 | + ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)], | |
| 43 | + ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null], | |
| 44 | + ["Français à la maison", fmtPct(d.pct_francais)], | |
| 45 | + ["Diplôme universitaire", fmtPct(d.pct_univ)], | |
| 46 | + ] | |
| 47 | + : []; | |
| 48 | + const statsOk = stats.filter(([, v]) => v != null) as [string, string][]; | |
| 49 | + const prox = q.proximite ?? {}; | |
| 50 | + const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number"); | |
| 51 | + | |
| 52 | + if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime) | |
| 53 | + return null; | |
| 54 | + | |
| 55 | + return ( | |
| 56 | + <section className="quartier"> | |
| 57 | + <h2>Le quartier</h2> | |
| 58 | + <p className="q-sub"> | |
| 59 | + Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants). | |
| 60 | + </p> | |
| 61 | + | |
| 62 | + {statsOk.length > 0 && ( | |
| 63 | + <div className="q-grid"> | |
| 64 | + {statsOk.map(([label, val]) => ( | |
| 65 | + <div className="q-cell" key={label}> | |
| 66 | + <div className="q-val">{val}</div> | |
| 67 | + <div className="q-label">{label}</div> | |
| 68 | + </div> | |
| 69 | + ))} | |
| 70 | + </div> | |
| 71 | + )} | |
| 72 | + | |
| 73 | + {proxOk.length > 0 && ( | |
| 74 | + <div className="q-prox"> | |
| 75 | + {proxOk.map(([k, label]) => { | |
| 76 | + const v = Math.max(0, Math.min(1, prox[k])); | |
| 77 | + return ( | |
| 78 | + <div className="q-bar" key={k}> | |
| 79 | + <span className="q-bar-label">{label}</span> | |
| 80 | + <span className="q-bar-track"> | |
| 81 | + <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} /> | |
| 82 | + </span> | |
| 83 | + <span className="q-bar-num">{Math.round(v * 100)}</span> | |
| 84 | + </div> | |
| 85 | + ); | |
| 86 | + })} | |
| 87 | + <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div> | |
| 88 | + </div> | |
| 89 | + )} | |
| 90 | + | |
| 91 | + <div className="q-badges"> | |
| 92 | + {q.chaleur && (() => { | |
| 93 | + const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart); | |
| 94 | + return <span className={b.cls}>{b.ico} {b.txt}</span>; | |
| 95 | + })()} | |
| 96 | + {q.crime?.type === "points" && ( | |
| 97 | + <span className="q-badge neutral"> | |
| 98 | + 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à | |
| 99 | + moins de 500 m (12 mois) | |
| 100 | + {q.crime.douze_mois_precedents > 0 && ( | |
| 101 | + q.crime.douze_mois <= q.crime.douze_mois_precedents | |
| 102 | + ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 103 | + : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)` | |
| 104 | + )} | |
| 105 | + </span> | |
| 106 | + )} | |
| 107 | + {q.crime?.type === "igc" && (() => { | |
| 108 | + const c = q.crime; | |
| 109 | + if (c.indice_canada != null && c.indice_canada > 0) { | |
| 110 | + const delta = Math.round(100 * (c.indice - c.indice_canada) / c.indice_canada); | |
| 111 | + const sous = delta <= 0; | |
| 112 | + return ( | |
| 113 | + <span className={`q-badge ${sous ? "cool" : "neutral"}`}> | |
| 114 | + 🛡 Criminalité {Math.abs(delta)} % {sous ? "sous" : "au-dessus de"} la | |
| 115 | + moyenne canadienne{sous ? " ✅" : ""} | |
| 116 | + <small className="q-badge-sub">({c.ville} {c.annee} : {c.indice} · Canada : {c.indice_canada})</small> | |
| 117 | + </span> | |
| 118 | + ); | |
| 119 | + } | |
| 120 | + return ( | |
| 121 | + <span className="q-badge neutral"> | |
| 122 | + 🛡 Gravité de la criminalité ({c.ville}, {c.annee}) : <b>{c.indice}</b> | |
| 123 | + </span> | |
| 124 | + ); | |
| 125 | + })()} | |
| 126 | + </div> | |
| 127 | + | |
| 128 | + <div className="fine"> | |
| 129 | + Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ | |
| 130 | + (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}. | |
| 131 | + Statistiques du secteur, pas de l'immeuble. | |
| 132 | + </div> | |
| 133 | + </section> | |
| 134 | + ); | |
| 135 | +} | |
modified
frontend/src/pages/Home.tsx
+39 −20
@@ -16,16 +16,27 @@ const MapView = lazy(() => import("../components/MapView")); | ||
| 16 | 16 | |
| 17 | 17 | const PRICE_STEPS = [100000, 200000, 300000, 400000, 500000, 600000, 750000, 1000000, 1500000, 2000000, 3000000]; |
| 18 | 18 | const AREA_STEPS = [800, 1000, 1500, 2000, 3000]; |
| 19 | −const PAGE = 60; | |
| 19 | +const PAGE = 12; | |
| 20 | 20 | |
| 21 | 21 | const fmtK = (n: number) => |
| 22 | 22 | n >= 1_000_000 ? `${n / 1_000_000} M$` : `${Math.round(n / 1000)} k$`; |
| 23 | 23 | |
| 24 | +// fenêtre de pagination : 1 … (p-1) p (p+1) … N | |
| 25 | +function pageNumbers(p: number, n: number): (number | "…")[] { | |
| 26 | + if (n <= 7) return Array.from({ length: n }, (_, i) => i + 1); | |
| 27 | + const out: (number | "…")[] = [1]; | |
| 28 | + const lo = Math.max(2, p - 1), hi = Math.min(n - 1, p + 1); | |
| 29 | + if (lo > 2) out.push("…"); | |
| 30 | + for (let i = lo; i <= hi; i++) out.push(i); | |
| 31 | + if (hi < n - 1) out.push("…"); | |
| 32 | + out.push(n); | |
| 33 | + return out; | |
| 34 | +} | |
| 35 | + | |
| 24 | 36 | export default function Home() { |
| 25 | 37 | const [listings, setListings] = useState<Listing[] | null>(null); |
| 26 | 38 | const [total, setTotal] = useState(0); |
| 27 | − const [offset, setOffset] = useState(0); | |
| 28 | − const [loadingMore, setLoadingMore] = useState(false); | |
| 39 | + const [page, setPage] = useState(1); // pagination 12/page | |
| 29 | 40 | const [facets, setFacets] = useState<Facets | null>(null); |
| 30 | 41 | const [sectors, setSectors] = useState<string[]>([]); |
| 31 | 42 | const [stats, setStats] = useState<Stats | null>(null); |
@@ -68,21 +79,23 @@ export default function Home() { | ||
| 68 | 79 | fetchFacets(city || undefined).then((f) => setSectors(f.sectors)).catch(() => setSectors([])); |
| 69 | 80 | }, [city]); |
| 70 | 81 | |
| 82 | + // revenir à la page 1 quand les filtres changent | |
| 83 | + useEffect(() => { setPage(1); }, [filters]); | |
| 84 | + | |
| 85 | + // charger la page courante (12 annonces) — remplace la grille | |
| 71 | 86 | useEffect(() => { |
| 72 | 87 | let cancelled = false; |
| 73 | − setListings(null); setError(null); setOffset(0); | |
| 74 | − fetchListings(filters, PAGE, 0) | |
| 75 | − .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); setOffset(r.listings.length); } }) | |
| 88 | + setListings(null); setError(null); | |
| 89 | + fetchListings(filters, PAGE, (page - 1) * PAGE) | |
| 90 | + .then((r) => { if (!cancelled) { setListings(r.listings); setTotal(r.total); } }) | |
| 76 | 91 | .catch((e) => !cancelled && setError(String(e))); |
| 77 | 92 | return () => { cancelled = true; }; |
| 78 | − }, [filters]); | |
| 93 | + }, [filters, page]); | |
| 79 | 94 | |
| 80 | − const loadMore = () => { | |
| 81 | − setLoadingMore(true); | |
| 82 | − fetchListings(filters, PAGE, offset) | |
| 83 | − .then((r) => { setListings((cur) => [...(cur ?? []), ...r.listings]); setOffset((o) => o + r.listings.length); }) | |
| 84 | − .catch(() => {}) | |
| 85 | − .finally(() => setLoadingMore(false)); | |
| 95 | + const totalPages = Math.max(1, Math.ceil(total / PAGE)); | |
| 96 | + const gotoPage = (p: number) => { | |
| 97 | + setPage(Math.min(Math.max(1, p), totalPages)); | |
| 98 | + document.getElementById("results-top")?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 86 | 99 | }; |
| 87 | 100 | |
| 88 | 101 | const resetAll = () => { |
@@ -263,7 +276,7 @@ export default function Home() { | ||
| 263 | 276 | </div> |
| 264 | 277 | )} |
| 265 | 278 | |
| 266 | − <div className="results-head"> | |
| 279 | + <div className="results-head" id="results-top"> | |
| 267 | 280 | <h2>Propriétés à vendre</h2> |
| 268 | 281 | <div className="results-tools"> |
| 269 | 282 | {listings && <span>{total.toLocaleString("fr-CA")} résultat{total > 1 ? "s" : ""}</span>} |
@@ -323,12 +336,18 @@ export default function Home() { | ||
| 323 | 336 | <div className="grid"> |
| 324 | 337 | {listings.map((l) => <ListingCard key={l.uid} l={l} />)} |
| 325 | 338 | </div> |
| 326 | − {offset < total && ( | |
| 327 | − <div className="more-wrap"> | |
| 328 | − <button className="btn btn-primary" onClick={loadMore} disabled={loadingMore}> | |
| 329 | − {loadingMore ? "Chargement…" : `Voir plus (${(total - offset).toLocaleString("fr-CA")} restantes)`} | |
| 330 | − </button> | |
| 331 | − </div> | |
| 339 | + {totalPages > 1 && ( | |
| 340 | + <nav className="pager" aria-label="Pagination"> | |
| 341 | + <button className="pager-btn" onClick={() => gotoPage(page - 1)} disabled={page <= 1}>‹ Préc.</button> | |
| 342 | + {pageNumbers(page, totalPages).map((p, i) => | |
| 343 | + p === "…" | |
| 344 | + ? <span key={`e${i}`} className="pager-gap">…</span> | |
| 345 | + : <button key={p} className={`pager-btn ${p === page ? "on" : ""}`} | |
| 346 | + onClick={() => gotoPage(p as number)}>{p}</button> | |
| 347 | + )} | |
| 348 | + <button className="pager-btn" onClick={() => gotoPage(page + 1)} disabled={page >= totalPages}>Suiv. ›</button> | |
| 349 | + <span className="pager-info">Page {page} / {totalPages}</span> | |
| 350 | + </nav> | |
| 332 | 351 | )} |
| 333 | 352 | </> |
| 334 | 353 | )} |
modified
frontend/src/pages/Listing.tsx
+44 −0
@@ -13,6 +13,7 @@ import { | ||
| 13 | 13 | } from "../api"; |
| 14 | 14 | |
| 15 | 15 | const MiniMap = lazy(() => import("../components/MapView")); |
| 16 | +import QuartierBlock from "../components/QuartierBlock"; | |
| 16 | 17 | |
| 17 | 18 | // --- Galerie : balayage natif (scroll-snap) + vignettes + plein écran -------- |
| 18 | 19 | function Galerie({ images, titre }: { images: string[]; titre: string }) { |
@@ -201,6 +202,13 @@ export default function ListingPage() { | ||
| 201 | 202 | </div> |
| 202 | 203 | </section> |
| 203 | 204 | )} |
| 205 | + | |
| 206 | + {l.quartier && ( | |
| 207 | + <section className="f-bloc" id="quartier"> | |
| 208 | + <h2>Le quartier</h2> | |
| 209 | + <QuartierBlock q={l.quartier} /> | |
| 210 | + </section> | |
| 211 | + )} | |
| 204 | 212 | </div> |
| 205 | 213 | |
| 206 | 214 | {/* -------- colonne droite : synthèse, specs, courtier ---------------- */} |
@@ -223,6 +231,42 @@ export default function ListingPage() { | ||
| 223 | 231 | </div> |
| 224 | 232 | )} |
| 225 | 233 | |
| 234 | + {l.vraiprix && l.vraiprix.value != null && ( | |
| 235 | + <div className="vraiprix"> | |
| 236 | + <div className="vp-head"> | |
| 237 | + <span className="vp-logo">Vrai‑Prix</span> | |
| 238 | + {l.vraiprix.confidence && ( | |
| 239 | + <span className={`vp-conf vp-conf-${l.vraiprix.confidence}`}> | |
| 240 | + confiance {l.vraiprix.confidence} | |
| 241 | + </span> | |
| 242 | + )} | |
| 243 | + </div> | |
| 244 | + <div className="vp-k">Valeur marchande estimée</div> | |
| 245 | + <div className="vp-value">{fmtPrice(l.vraiprix.value)}</div> | |
| 246 | + {l.vraiprix.low != null && l.vraiprix.high != null && ( | |
| 247 | + <div className="vp-range"> | |
| 248 | + fourchette {fmtPrice(l.vraiprix.low)} – {fmtPrice(l.vraiprix.high)} | |
| 249 | + </div> | |
| 250 | + )} | |
| 251 | + {l.price != null && ( | |
| 252 | + (() => { | |
| 253 | + const diff = l.price - (l.vraiprix!.value as number); | |
| 254 | + const pct = Math.round((diff / (l.vraiprix!.value as number)) * 100); | |
| 255 | + const cls = diff > 0 ? "over" : "under"; | |
| 256 | + const txt = diff > 0 | |
| 257 | + ? `Prix demandé ${pct}% au-dessus de l'estimation` | |
| 258 | + : `Prix demandé ${Math.abs(pct)}% sous l'estimation`; | |
| 259 | + return Math.abs(pct) >= 1 | |
| 260 | + ? <div className={`vp-delta vp-${cls}`}>{diff > 0 ? "▲" : "▼"} {txt}</div> | |
| 261 | + : <div className="vp-delta vp-fair">≈ Prix aligné sur l'estimation</div>; | |
| 262 | + })() | |
| 263 | + )} | |
| 264 | + <a className="vp-link" href={l.vraiprix.url} target="_blank" rel="noopener noreferrer"> | |
| 265 | + Voir l'analyse détaillée ↗ | |
| 266 | + </a> | |
| 267 | + </div> | |
| 268 | + )} | |
| 269 | + | |
| 226 | 270 | {(l.broker_name || l.broker_phone) && ( |
| 227 | 271 | <div className="broker"> |
| 228 | 272 | <div className="broker-k">Courtier</div> |
modified
frontend/src/pages/Sources.tsx
+60 −48
@@ -1,75 +1,87 @@ | ||
| 1 | 1 | // ----------------------------------------------------------------------------- |
| 2 | 2 | // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) |
| 3 | 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | −// pages/Sources.tsx : registre des agences de courtage agrégées | |
| 4 | +// pages/Sources.tsx : registre des agences — éclaté par bannière → sous-agence | |
| 5 | 5 | // ----------------------------------------------------------------------------- |
| 6 | 6 | import { useEffect, useState } from "react"; |
| 7 | 7 | import { Link } from "react-router-dom"; |
| 8 | −import { Source, fetchSources } from "../api"; | |
| 8 | +import { Franchise, fetchAgencies } from "../api"; | |
| 9 | 9 | |
| 10 | 10 | export default function SourcesPage() { |
| 11 | − const [sources, setSources] = useState<Source[] | null>(null); | |
| 11 | + const [data, setData] = useState<Franchise[] | null>(null); | |
| 12 | 12 | const [error, setError] = useState<string | null>(null); |
| 13 | + const [open, setOpen] = useState<Record<string, boolean>>({}); | |
| 13 | 14 | |
| 14 | 15 | useEffect(() => { |
| 15 | − fetchSources().then((r) => setSources(r.sources)).catch((e) => setError(String(e))); | |
| 16 | + fetchAgencies().then((r) => setData(r.franchises)).catch((e) => setError(String(e))); | |
| 16 | 17 | }, []); |
| 17 | 18 | |
| 18 | − const active = (sources ?? []).filter((s) => s.status === "actif" || s.active_listings > 0); | |
| 19 | − const todo = (sources ?? []).filter((s) => !(s.status === "actif" || s.active_listings > 0)); | |
| 19 | + const totalProps = (data ?? []).reduce((s, f) => s + f.total, 0); | |
| 20 | + const totalSub = (data ?? []).reduce((s, f) => s + f.sub_agencies, 0); | |
| 20 | 21 | |
| 21 | 22 | return ( |
| 22 | 23 | <div className="container sources"> |
| 23 | − <span className="kicker">Registre — agences de courtage</span> | |
| 24 | − <h1>Agences agrégées</h1> | |
| 24 | + <span className="kicker">Registre — agences & sous-agences</span> | |
| 25 | + <h1>Sources par sous-agence</h1> | |
| 25 | 26 | <p className="sub"> |
| 26 | − Bannières et agences immobilières du Québec recensées par Immo-Ka. Chaque agence | |
| 27 | − « active » est synchronisée périodiquement par un connecteur dédié ; les autres sont | |
| 28 | − en attente de connecteur. | |
| 27 | + Chaque bannière immobilière du Québec est éclatée par sous-agence (bureau). | |
| 28 | + Chaque annonce est synchronisée par un connecteur dédié et dédoublonnée par | |
| 29 | + numéro Centris. Cliquez un compte pour voir les propriétés. | |
| 29 | 30 | </p> |
| 30 | 31 | |
| 31 | 32 | {error && <div className="notice">⚠️ {error}</div>} |
| 32 | − {!sources && !error && <div className="notice">Chargement…</div>} | |
| 33 | + {!data && !error && <div className="notice">Chargement…</div>} | |
| 33 | 34 | |
| 34 | − {sources && ( | |
| 35 | + {data && ( | |
| 35 | 36 | <> |
| 36 | − <div className="src-wrap"> | |
| 37 | − <table className="src-table"> | |
| 38 | − <thead> | |
| 39 | − <tr> | |
| 40 | − <th>Agence</th> | |
| 41 | − <th>Couverture</th> | |
| 42 | − <th>Type</th> | |
| 43 | − <th>Statut</th> | |
| 44 | − <th style={{ textAlign: "right" }}>Propriétés</th> | |
| 45 | − </tr> | |
| 46 | − </thead> | |
| 47 | − <tbody> | |
| 48 | − {[...active, ...todo].map((s) => ( | |
| 49 | − <tr key={s.id}> | |
| 50 | − <td> | |
| 51 | − <a href={s.url} target="_blank" rel="noopener noreferrer">{s.name}</a> | |
| 52 | − </td> | |
| 53 | − <td style={{ color: "var(--ink-2)", maxWidth: 420 }}>{s.coverage ?? "—"}</td> | |
| 54 | − <td style={{ color: "var(--ink-3)" }}>{s.type ?? "—"}</td> | |
| 55 | − <td> | |
| 56 | − {s.active_listings > 0 || s.status === "actif" | |
| 57 | − ? <span className="pill ok">actif</span> | |
| 58 | − : <span className="pill todo">à venir</span>} | |
| 59 | − </td> | |
| 60 | − <td style={{ textAlign: "right" }}> | |
| 61 | − {s.active_listings | |
| 62 | − ? <Link className="count-pill" to={`/?source=${encodeURIComponent(s.id)}`}>{s.active_listings.toLocaleString("fr-CA")}</Link> | |
| 63 | − : "—"} | |
| 64 | − </td> | |
| 65 | − </tr> | |
| 66 | − ))} | |
| 67 | − </tbody> | |
| 68 | − </table> | |
| 37 | + <p className="stats-foot" style={{ marginTop: 0 }}> | |
| 38 | + <b>{totalProps.toLocaleString("fr-CA")}</b> propriétés ·{" "} | |
| 39 | + <b>{data.length}</b> bannières ·{" "} | |
| 40 | + <b>{totalSub.toLocaleString("fr-CA")}</b> sous-agences | |
| 41 | + </p> | |
| 42 | + | |
| 43 | + <div className="franchise-list"> | |
| 44 | + {data.map((f) => { | |
| 45 | + const isOpen = open[f.franchise] ?? false; | |
| 46 | + return ( | |
| 47 | + <div className="franchise" key={f.franchise}> | |
| 48 | + <button | |
| 49 | + className="franchise-head" | |
| 50 | + onClick={() => setOpen({ ...open, [f.franchise]: !isOpen })} | |
| 51 | + aria-expanded={isOpen} | |
| 52 | + > | |
| 53 | + <span className="fh-caret">{isOpen ? "▾" : "▸"}</span> | |
| 54 | + <span className="fh-name">{f.franchise}</span> | |
| 55 | + <span className="fh-sub"> | |
| 56 | + {f.sub_agencies} sous-agence{f.sub_agencies > 1 ? "s" : ""} | |
| 57 | + </span> | |
| 58 | + <span className="fh-count">{f.total.toLocaleString("fr-CA")}</span> | |
| 59 | + </button> | |
| 60 | + | |
| 61 | + {isOpen && ( | |
| 62 | + <ul className="subagency-list"> | |
| 63 | + {f.agencies.map((a) => ( | |
| 64 | + <li key={a.name}> | |
| 65 | + <span className="sa-name">{a.name}</span> | |
| 66 | + <Link | |
| 67 | + className="count-pill" | |
| 68 | + to={`/?source=${encodeURIComponent(a.sources[0])}`} | |
| 69 | + > | |
| 70 | + {a.count.toLocaleString("fr-CA")} | |
| 71 | + </Link> | |
| 72 | + </li> | |
| 73 | + ))} | |
| 74 | + </ul> | |
| 75 | + )} | |
| 76 | + </div> | |
| 77 | + ); | |
| 78 | + })} | |
| 69 | 79 | </div> |
| 80 | + | |
| 70 | 81 | <p className="stats-foot"> |
| 71 | − {active.length} agence{active.length > 1 ? "s" : ""} active{active.length > 1 ? "s" : ""} sur {sources.length} recensées. | |
| 72 | − Les sous-agences RE/MAX servent de source de secours (dédoublonnées par n° Centris). | |
| 82 | + Les sous-agences des grandes bannières (RE/MAX, Via Capitale, Century 21…) | |
| 83 | + servent aussi de source de secours, dédoublonnées par n° Centris — aucun | |
| 84 | + double-comptage. | |
| 73 | 85 | </p> |
| 74 | 86 | </> |
| 75 | 87 | )} |
modified
frontend/src/styles.css
+151 −43
@@ -4,29 +4,31 @@ | ||
| 4 | 4 | styles.css : système de design « éditorial sharp » — thème clair |
| 5 | 5 | · Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono |
| 6 | 6 | · Bordures encre + ombres décalées (néo-brutalisme raffiné) |
| 7 | − · Accent électrique lime sur encre verte profonde · 100 % adaptatif mobile | |
| 7 | + · Accent rouge vif sur encre chocolat · 100 % adaptatif mobile | |
| 8 | 8 | ----------------------------------------------------------------------------- */ |
| 9 | 9 | :root { |
| 10 | − --paper: #f5f3ee; | |
| 10 | + /* Palette ROUGE — distincte de Lou-Ka (lime/vert). Noms de tokens conservés : | |
| 11 | + `--lime` = accent vif (rouge), `--green` = rouge profond (secondaire). */ | |
| 12 | + --paper: #f7f1ef; | |
| 11 | 13 | --surface: #ffffff; |
| 12 | − --surface-2: #faf9f5; | |
| 13 | − --ink: #141814; | |
| 14 | − --ink-2: #4d5551; | |
| 15 | − --ink-3: #8b928c; | |
| 16 | − --line: rgba(20, 24, 20, 0.14); | |
| 17 | − --line-strong: rgba(20, 24, 20, 0.85); | |
| 18 | − --green: #1c5c41; | |
| 19 | − --green-deep: #123f2e; | |
| 20 | − --lime: #d9f26b; | |
| 21 | − --lime-soft: #f0f9d2; | |
| 14 | + --surface-2: #fdf6f5; | |
| 15 | + --ink: #1a1214; /* encre chocolat/near-black chaud */ | |
| 16 | + --ink-2: #5a4d50; | |
| 17 | + --ink-3: #9b8b8e; | |
| 18 | + --line: rgba(26, 18, 20, 0.14); | |
| 19 | + --line-strong: rgba(26, 18, 20, 0.85); | |
| 20 | + --green: #b3202b; /* rouge profond (accent secondaire / kicker) */ | |
| 21 | + --green-deep: #7f1620; | |
| 22 | + --lime: #e23744; /* rouge vif (accent principal) */ | |
| 23 | + --lime-soft: #fbe0e2; | |
| 22 | 24 | --amber: #e8a33d; |
| 23 | 25 | --amber-soft: #fdf3e2; |
| 24 | − --danger: #b3423a; | |
| 25 | − --r-card: 10px; | |
| 26 | − --r-ctl: 6px; | |
| 27 | − --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05); | |
| 26 | + --danger: #b3202b; | |
| 27 | + --r-card: 14px; | |
| 28 | + --r-ctl: 8px; | |
| 29 | + --shadow-flat: 0 1px 2px rgba(26, 18, 20, 0.06); | |
| 28 | 30 | --shadow-off: 6px 6px 0 var(--ink); |
| 29 | − --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08); | |
| 31 | + --shadow-off-soft: 8px 8px 0 rgba(226, 55, 68, 0.14); | |
| 30 | 32 | --font-display: "Space Grotesk", system-ui, sans-serif; |
| 31 | 33 | --font-body: "Inter", system-ui, sans-serif; |
| 32 | 34 | --font-mono: "JetBrains Mono", ui-monospace, monospace; |
@@ -49,7 +51,7 @@ h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; marg | ||
| 49 | 51 | a { color: inherit; text-decoration: none; } |
| 50 | 52 | button { font-family: inherit; } |
| 51 | 53 | img { display: block; } |
| 52 | −::selection { background: var(--lime); color: var(--ink); } | |
| 54 | +::selection { background: var(--lime); color: #fff; } | |
| 53 | 55 | |
| 54 | 56 | .mono { font-family: var(--font-mono); } |
| 55 | 57 | .kicker { |
@@ -65,7 +67,7 @@ img { display: block; } | ||
| 65 | 67 | /* ================= Header ================= */ |
| 66 | 68 | .header { |
| 67 | 69 | position: sticky; top: 0; z-index: 50; |
| 68 | − background: rgba(245, 243, 238, 0.88); | |
| 70 | + background: rgba(247, 241, 239, 0.88); | |
| 69 | 71 | backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); |
| 70 | 72 | border-bottom: 2px solid var(--ink); |
| 71 | 73 | } |
@@ -81,7 +83,7 @@ img { display: block; } | ||
| 81 | 83 | .nav a.active { background: var(--ink); color: var(--lime); } |
| 82 | 84 | |
| 83 | 85 | /* ================= Ticker ================= */ |
| 84 | −.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(217, 242, 107, 0.25); } | |
| 86 | +.ticker { background: var(--ink); color: var(--lime); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase; padding: 7px 0; white-space: nowrap; border-bottom: 1px solid rgba(226, 55, 68, 0.25); } | |
| 85 | 87 | .ticker-track { display: inline-flex; gap: 0; animation: ticker 48s linear infinite; will-change: transform; } |
| 86 | 88 | .ticker span { padding: 0 26px; position: relative; } |
| 87 | 89 | .ticker span::after { content: "◆"; position: absolute; right: -6px; opacity: 0.5; font-size: 8px; top: 3px; } |
@@ -93,13 +95,13 @@ img { display: block; } | ||
| 93 | 95 | @media (max-width: 640px) { .hero { padding: 34px 0 14px; } } |
| 94 | 96 | .hero h1 { font-size: clamp(38px, 6.4vw, 78px); font-weight: 700; line-height: 0.98; text-transform: uppercase; letter-spacing: -0.035em; max-width: 940px; margin-top: 14px; } |
| 95 | 97 | .hero h1 .outline { color: transparent; -webkit-text-stroke: 2px var(--ink); } |
| 96 | −.hero h1 .hl { background: var(--lime); padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); } | |
| 98 | +.hero h1 .hl { background: var(--lime); color: #fff; padding: 0 10px; border-radius: 8px; display: inline-block; transform: rotate(-1deg); } | |
| 97 | 99 | .hero p.lede { color: var(--ink-2); font-size: 16.5px; max-width: 680px; margin: 20px 0 0; } |
| 98 | 100 | .stat-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; } |
| 99 | −.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.12); } | |
| 101 | +.stat-chip { background: var(--surface); border: 1.5px solid var(--ink); border-radius: 999px; padding: 8px 16px; font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); display: flex; gap: 8px; align-items: center; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.12); } | |
| 100 | 102 | .stat-chip b { color: var(--ink); font-weight: 700; } |
| 101 | 103 | .stat-chip .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 0 4px var(--lime-soft); animation: pulse 2.4s ease infinite; } |
| 102 | −@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(217, 242, 107, 0.4); } } | |
| 104 | +@keyframes pulse { 50% { box-shadow: 0 0 0 7px rgba(226, 55, 68, 0.4); } } | |
| 103 | 105 | |
| 104 | 106 | /* ================= Filter bar ================= */ |
| 105 | 107 | .filterbar { background: var(--surface); border: 2px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 14px; margin: 30px 0 6px; display: flex; flex-direction: column; gap: 0; min-width: 0; } |
@@ -117,7 +119,7 @@ img { display: block; } | ||
| 117 | 119 | .range-sep { color: var(--ink-3); font-family: var(--font-mono); font-size: 11px; } |
| 118 | 120 | .f-more { flex: none; align-self: stretch; border: 1.5px solid var(--ink); border-radius: 9px; background: var(--surface); color: var(--ink); padding: 0 18px; cursor: pointer; font-family: var(--font-display); font-weight: 700; font-size: 14px; min-height: 52px; transition: all 0.13s ease; } |
| 119 | 121 | .f-more:hover { background: var(--lime-soft); } |
| 120 | −.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.22); } | |
| 122 | +.f-more.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(26, 18, 20,0.22); } | |
| 121 | 123 | .f-adv { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px 22px; border-top: 1.5px dashed var(--line); margin-top: 14px; padding-top: 14px; min-width: 0; animation: adv-in 0.18s ease; } |
| 122 | 124 | @keyframes adv-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } } |
| 123 | 125 | .f-group { display: flex; flex-direction: column; gap: 7px; min-width: 0; } |
@@ -135,7 +137,7 @@ img { display: block; } | ||
| 135 | 137 | |
| 136 | 138 | .pills { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 2px; } |
| 137 | 139 | .pill { display: inline-flex; align-items: center; gap: 7px; border: 1.5px solid var(--ink); background: var(--lime-soft); color: var(--ink); border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 600; font-family: var(--font-mono); cursor: pointer; transition: all 0.13s ease; } |
| 138 | −.pill:hover { background: var(--lime); } | |
| 140 | +.pill:hover { background: var(--lime); color: #fff; } | |
| 139 | 141 | .pill-x { font-size: 10px; opacity: 0.65; } |
| 140 | 142 | .pill-clear { background: var(--surface); border-color: var(--danger); color: var(--danger); } |
| 141 | 143 | .pill-clear:hover { background: var(--danger); color: #fff; } |
@@ -143,17 +145,17 @@ img { display: block; } | ||
| 143 | 145 | |
| 144 | 146 | .btn { border: 1.5px solid var(--ink); border-radius: var(--r-ctl); padding: 11px 20px; font-weight: 700; font-size: 14px; cursor: pointer; min-height: 44px; transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease; font-family: var(--font-display); letter-spacing: 0.01em; } |
| 145 | 147 | .btn:active { transform: translate(2px, 2px); box-shadow: none !important; } |
| 146 | −.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.25); } | |
| 148 | +.btn-primary { background: var(--ink); color: var(--lime); box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); } | |
| 147 | 149 | .btn-primary:hover { background: var(--green-deep); } |
| 148 | 150 | .btn-ghost { background: transparent; color: var(--ink); } |
| 149 | −.btn-ghost:hover { background: var(--lime); box-shadow: 4px 4px 0 rgba(20,24,20,0.2); } | |
| 151 | +.btn-ghost:hover { background: var(--lime); color: #fff; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.2); } | |
| 150 | 152 | |
| 151 | 153 | /* ================= Chips ================= */ |
| 152 | 154 | .chips { display: flex; gap: 8px; margin: 16px 0 4px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: none; } |
| 153 | 155 | .chips::-webkit-scrollbar { display: none; } |
| 154 | 156 | .chip { border: 1.5px solid var(--ink); background: var(--surface); color: var(--ink); border-radius: 999px; padding: 8px 18px; font-size: 13.5px; font-weight: 600; cursor: pointer; font-family: var(--font-display); white-space: nowrap; min-height: 40px; transition: all 0.13s ease; } |
| 155 | 157 | .chip:hover { background: var(--lime-soft); transform: translateY(-1px); } |
| 156 | −.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(20,24,20,0.2); } | |
| 158 | +.chip.on { background: var(--ink); color: var(--lime); box-shadow: 3px 3px 0 rgba(26, 18, 20,0.2); } | |
| 157 | 159 | |
| 158 | 160 | /* ================= Results ================= */ |
| 159 | 161 | .results-head { display: flex; align-items: baseline; gap: 14px; margin: 28px 0 18px; flex-wrap: wrap; } |
@@ -229,7 +231,7 @@ img { display: block; } | ||
| 229 | 231 | .carousel-track::-webkit-scrollbar { display: none; } |
| 230 | 232 | .carousel-track img { flex: 0 0 100%; width: 100%; object-fit: cover; scroll-snap-align: center; cursor: zoom-in; } |
| 231 | 233 | .carousel-empty { display: flex; align-items: center; justify-content: center; aspect-ratio: 16/11; font-size: 48px; } |
| 232 | −.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(20, 24, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; } | |
| 234 | +.carousel-count { position: absolute; right: 12px; bottom: 12px; z-index: 2; background: rgba(26, 18, 20, 0.82); color: var(--lime); font-family: var(--font-mono); font-size: 12px; font-weight: 700; padding: 4px 10px; border-radius: 999px; } | |
| 233 | 235 | .carousel-nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 2; width: 44px; height: 44px; border-radius: 50%; border: 1.5px solid var(--ink); background: rgba(255, 255, 255, 0.92); font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; } |
| 234 | 236 | .carousel-nav.prev { left: 10px; } .carousel-nav.next { right: 10px; } |
| 235 | 237 | @media (max-width: 640px) { .carousel-nav { display: none; } } |
@@ -266,8 +268,8 @@ img { display: block; } | ||
| 266 | 268 | .broker-name { font-family: var(--font-display); font-weight: 700; font-size: 15px; margin-top: 3px; } |
| 267 | 269 | .broker-tel { display: inline-block; margin-top: 6px; color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); } |
| 268 | 270 | |
| 269 | −.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(20,24,20,0.25); transition: all 0.14s ease; margin-top: 6px; } | |
| 270 | −.cta:hover { background: var(--lime); color: var(--ink); } | |
| 271 | +.cta { display: block; text-align: center; background: var(--ink); color: var(--lime); font-weight: 700; font-family: var(--font-display); border-radius: var(--r-ctl); padding: 15px; border: 1.5px solid var(--ink); min-height: 48px; box-shadow: 4px 4px 0 rgba(26, 18, 20,0.25); transition: all 0.14s ease; margin-top: 6px; } | |
| 272 | +.cta:hover { background: var(--lime); color: #fff; } | |
| 271 | 273 | .cta:active { transform: translate(2px, 2px); box-shadow: none; } |
| 272 | 274 | .f-hero .fine { font-size: 11.5px; color: var(--ink-3); margin-top: 14px; text-align: center; font-family: var(--font-mono); letter-spacing: 0.02em; } |
| 273 | 275 | |
@@ -295,7 +297,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 295 | 297 | .mini-map { height: 320px; border: 1.5px solid var(--line-strong); border-radius: var(--r-card); overflow: hidden; } |
| 296 | 298 | .mini-map .mapview { border: none; border-radius: 0; box-shadow: none; height: 100%; } |
| 297 | 299 | |
| 298 | −.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(245, 243, 238, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); } | |
| 300 | +.cta-sticky { position: fixed; left: 0; right: 0; bottom: 0; z-index: 45; display: flex; align-items: center; gap: 12px; background: rgba(247, 241, 239, 0.94); backdrop-filter: blur(12px); border-top: 2px solid var(--ink); padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); } | |
| 299 | 301 | .cta-sticky .cta { flex: 1; margin: 0; } |
| 300 | 302 | .cta-sticky-prix { font-family: var(--font-display); font-weight: 700; font-size: 19px; white-space: nowrap; } |
| 301 | 303 | @media (min-width: 900px) { .cta-sticky { display: none; } } |
@@ -314,7 +316,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 314 | 316 | .src-table tr:last-child td { border-bottom: none; } |
| 315 | 317 | .src-table tr:hover td { background: var(--surface-2); } |
| 316 | 318 | .src-table a { color: var(--green-deep); font-weight: 600; border-bottom: 1.5px solid var(--lime); } |
| 317 | −.pill.ok { background: var(--lime); color: var(--ink); border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; } | |
| 319 | +.pill.ok { background: var(--lime); color: #fff; border: 1px solid var(--ink); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; } | |
| 318 | 320 | .pill.todo { background: var(--amber-soft); color: #8a5a12; border: 1px solid var(--amber); border-radius: 4px; padding: 3px 10px; font-family: var(--font-mono); font-size: 10.5px; font-weight: 700; text-transform: uppercase; } |
| 319 | 321 | .count-pill { font-weight: 700; font-family: var(--font-display); font-size: 16px; border-bottom: 1.5px solid var(--lime); } |
| 320 | 322 | |
@@ -323,11 +325,11 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 323 | 325 | .stats-title { font-size: clamp(30px, 4.6vw, 46px); text-transform: uppercase; margin: 10px 0 6px; } |
| 324 | 326 | .stats-page .sub { color: var(--ink-2); max-width: 660px; margin-bottom: 30px; } |
| 325 | 327 | .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 14px; margin-bottom: 30px; } |
| 326 | −.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.1); } | |
| 328 | +.tile { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); padding: 18px 20px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.1); } | |
| 327 | 329 | .tile-v { font-family: var(--font-display); font-weight: 700; font-size: clamp(22px, 3vw, 32px); letter-spacing: -0.03em; line-height: 1.05; } |
| 328 | 330 | .tile-k { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3); margin-top: 6px; } |
| 329 | 331 | .hero-tile { background: var(--ink); color: var(--lime); border-color: var(--ink); } |
| 330 | −.hero-tile .tile-k { color: rgba(217, 242, 107, 0.7); } | |
| 332 | +.hero-tile .tile-k { color: rgba(226, 55, 68, 0.7); } | |
| 331 | 333 | .viz-card { background: var(--surface); border: 1.5px solid var(--ink); border-radius: var(--r-card); box-shadow: var(--shadow-off-soft); padding: 26px 28px 20px; margin-bottom: 22px; } |
| 332 | 334 | .viz-card h2 { font-size: 19px; text-transform: uppercase; letter-spacing: -0.01em; } |
| 333 | 335 | .viz-sub { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-3); margin: 4px 0 20px; } |
@@ -357,7 +359,7 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 357 | 359 | .map-loading { display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-family: var(--font-mono); font-size: 13px; } |
| 358 | 360 | @media (max-width: 780px) { .map-split { grid-template-columns: 1fr; height: calc(100dvh - 230px); } .map-list { display: none; } .results-tools { width: 100%; justify-content: space-between; } } |
| 359 | 361 | .maplibregl-popup-content { padding: 0; border-radius: var(--r-card); overflow: hidden; border: 1.5px solid var(--line-strong); box-shadow: var(--shadow-off); font-family: var(--font-body); } |
| 360 | −.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(20, 24, 20, 0.8); } | |
| 362 | +.maplibregl-popup-close-button { font-size: 18px; padding: 2px 8px; color: var(--paper); z-index: 2; text-shadow: 0 0 4px rgba(26, 18, 20, 0.8); } | |
| 361 | 363 | .mv-pop img, .mv-noimg { width: 100%; height: 130px; object-fit: cover; } |
| 362 | 364 | .mv-noimg { display: flex; align-items: center; justify-content: center; font-size: 34px; background: var(--surface-2); } |
| 363 | 365 | .mv-pop-body { padding: 10px 12px 12px; } |
@@ -369,13 +371,13 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 369 | 371 | .mv-pop-cta:hover { background: var(--green-deep); } |
| 370 | 372 | |
| 371 | 373 | /* ================= Menu mobile ================= */ |
| 372 | −.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(20, 24, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; } | |
| 373 | −.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(20,24,20,0.15); } | |
| 374 | +.menu-btn { display: none; position: relative; z-index: 60; width: 44px; height: 44px; margin-left: auto; border: 1.5px solid var(--ink); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; padding: 0; flex-direction: column; align-items: center; justify-content: center; gap: 5px; box-shadow: 3px 3px 0 rgba(26, 18, 20, 0.15); transition: box-shadow 0.15s ease, transform 0.15s ease; } | |
| 375 | +.menu-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 rgba(26, 18, 20,0.15); } | |
| 374 | 376 | .menu-btn span { display: block; width: 18px; height: 2px; background: var(--ink); border-radius: 2px; transition: transform 0.25s ease, opacity 0.2s ease; } |
| 375 | 377 | .menu-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } |
| 376 | 378 | .menu-btn.open span:nth-child(2) { opacity: 0; } |
| 377 | 379 | .menu-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } |
| 378 | −.mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(20, 24, 20, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; } | |
| 380 | +.mobile-menu { display: none; position: absolute; left: 0; right: 0; top: 100%; background: var(--paper); border-bottom: 2px solid var(--ink); box-shadow: 0 18px 30px rgba(26, 18, 20, 0.18); padding: 8px 16px calc(16px + env(safe-area-inset-bottom)); max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.22s ease; } | |
| 379 | 381 | .mobile-menu.open { max-height: 480px; opacity: 1; } |
| 380 | 382 | .mm-link { display: flex; align-items: center; gap: 12px; padding: 15px 10px; min-height: 52px; font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; border-bottom: 1.5px dashed var(--line); opacity: 0; transform: translateY(-8px); transition: opacity 0.25s ease, transform 0.25s ease; } |
| 381 | 383 | .mobile-menu.open .mm-link { opacity: 1; transform: translateY(0); } |
@@ -384,16 +386,16 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 384 | 386 | .mm-arrow { margin-left: auto; opacity: 0.25; } |
| 385 | 387 | .mm-link:active { background: var(--lime-soft); border-radius: var(--r-ctl); } |
| 386 | 388 | .mm-foot { padding: 12px 10px 4px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); letter-spacing: 0.04em; } |
| 387 | −.mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(20, 24, 20, 0.35); backdrop-filter: blur(2px); } | |
| 389 | +.mm-backdrop { position: fixed; inset: 0; z-index: 40; background: rgba(26, 18, 20, 0.35); backdrop-filter: blur(2px); } | |
| 388 | 390 | @media (max-width: 760px) { .menu-btn { display: flex; } .nav { display: none; } .mobile-menu { display: block; } } |
| 389 | 391 | |
| 390 | 392 | /* ================= Footer ================= */ |
| 391 | −.footer { background: var(--ink); color: rgba(245, 243, 238, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; } | |
| 393 | +.footer { background: var(--ink); color: rgba(247, 241, 239, 0.75); margin-top: 20px; padding: 44px 0 max(40px, env(safe-area-inset-bottom)); font-size: 13px; position: relative; z-index: 1; } | |
| 392 | 394 | .footer .fbrand { font-family: var(--font-display); font-weight: 700; font-size: 34px; color: var(--paper); letter-spacing: -0.04em; margin-bottom: 12px; } |
| 393 | 395 | .footer .fbrand .ka { color: var(--lime); } |
| 394 | 396 | .footer b { color: var(--paper); } |
| 395 | 397 | .footer .frow { display: flex; flex-direction: column; gap: 5px; max-width: 760px; } |
| 396 | −.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(245,243,238,0.45); margin-top: 18px; } | |
| 398 | +.footer .fmono { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: rgba(247, 241, 239,0.45); margin-top: 18px; } | |
| 397 | 399 | |
| 398 | 400 | /* ================= Mobile : feuille de filtres + FAB ================= */ |
| 399 | 401 | .sheet-head { display: none; } |
@@ -418,10 +420,116 @@ table.rooms tr:nth-child(even) td { background: var(--surface-2); } | ||
| 418 | 420 | .sheet-close { border: 1.5px solid var(--ink); background: var(--surface); border-radius: 50%; width: 36px; height: 36px; font-size: 15px; cursor: pointer; line-height: 1; } |
| 419 | 421 | .filterbar.open .sheet-apply { display: block; width: 100%; } |
| 420 | 422 | .sheet-backdrop { display: block; position: fixed; inset: 0; z-index: 90; background: rgba(16, 18, 16, 0.45); backdrop-filter: blur(2px); } |
| 421 | − .fab { display: flex; align-items: center; gap: 6px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(20, 24, 20, 0.25); } | |
| 423 | + .fab { display: flex; align-items: center; gap: 6px; position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(18px + env(safe-area-inset-bottom)); z-index: 80; background: var(--ink); color: var(--lime); border: 1.5px solid var(--ink); border-radius: 999px; padding: 13px 24px; font-family: var(--font-display); font-weight: 700; font-size: 15px; cursor: pointer; box-shadow: 0 8px 24px rgba(16, 18, 16, 0.35), 4px 4px 0 rgba(26, 18, 20, 0.25); } | |
| 422 | 424 | .fab:active { transform: translateX(-50%) scale(0.97); } |
| 423 | 425 | .results-head { margin-top: 20px; } |
| 424 | 426 | .chips { margin-right: -16px; padding-right: 16px; } |
| 425 | 427 | .notice { padding: 48px 16px; } |
| 426 | 428 | } |
| 427 | 429 | @media (max-width: 900px) { .f-search input, .f-native, .f-ctl select { font-size: 16px; } } |
| 430 | + | |
| 431 | +/* --- Sources : bannières → sous-agences -------------------------------- */ | |
| 432 | +.franchise-list { display: flex; flex-direction: column; gap: 10px; margin: 18px 0; } | |
| 433 | +.franchise { border: 2px solid var(--ink); background: var(--surface); box-shadow: var(--shadow-off-soft); } | |
| 434 | +.franchise-head { | |
| 435 | + width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; | |
| 436 | + background: none; border: 0; cursor: pointer; font-family: var(--font-display); | |
| 437 | + font-size: 16px; font-weight: 600; color: var(--ink); text-align: left; | |
| 438 | +} | |
| 439 | +.franchise-head:hover { background: var(--surface-2); } | |
| 440 | +.fh-caret { color: var(--green); width: 14px; } | |
| 441 | +.fh-name { flex: 1; } | |
| 442 | +.fh-sub { font-size: 12px; font-weight: 500; color: var(--ink-3); font-family: var(--font-mono); } | |
| 443 | +.fh-count { | |
| 444 | + font-family: var(--font-mono); font-weight: 700; background: var(--ink); | |
| 445 | + color: var(--lime); padding: 3px 10px; min-width: 64px; text-align: right; | |
| 446 | +} | |
| 447 | +.subagency-list { list-style: none; margin: 0; padding: 0 16px 10px 40px; border-top: 1px solid var(--line); } | |
| 448 | +.subagency-list li { | |
| 449 | + display: flex; align-items: center; justify-content: space-between; gap: 12px; | |
| 450 | + padding: 8px 0; border-bottom: 1px dotted var(--line); font-size: 14px; | |
| 451 | +} | |
| 452 | +.subagency-list li:last-child { border-bottom: 0; } | |
| 453 | +.sa-name { color: var(--ink-2); } | |
| 454 | + | |
| 455 | +/* --- Pagination (accueil) ---------------------------------------------- */ | |
| 456 | +.pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; | |
| 457 | + gap: 6px; margin: 32px 0 8px; } | |
| 458 | +.pager-btn { | |
| 459 | + font-family: var(--font-mono); font-size: 14px; min-width: 40px; padding: 8px 12px; | |
| 460 | + border: 2px solid var(--ink); background: var(--surface); color: var(--ink); | |
| 461 | + cursor: pointer; box-shadow: var(--shadow-off-soft); | |
| 462 | +} | |
| 463 | +.pager-btn:hover:not(:disabled) { background: var(--lime-soft); } | |
| 464 | +.pager-btn.on { background: var(--ink); color: var(--lime); font-weight: 700; } | |
| 465 | +.pager-btn:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; } | |
| 466 | +.pager-gap { padding: 0 4px; color: var(--ink-3); } | |
| 467 | +.pager-info { width: 100%; text-align: center; margin-top: 8px; font-size: 12px; | |
| 468 | + color: var(--ink-3); font-family: var(--font-mono); } | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | +/* --- Le quartier (census / proximité / chaleur / crime) — porté de Lou-Ka --- */ | |
| 473 | +/* --- Section « Le quartier » (fiche) --------------------------------------- */ | |
| 474 | +.quartier { margin-top: 34px; } | |
| 475 | +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; } | |
| 476 | +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; } | |
| 477 | +.q-grid { | |
| 478 | + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; | |
| 479 | +} | |
| 480 | +.q-grid { grid-template-columns: repeat(2, 1fr); } | |
| 481 | +.q-cell { | |
| 482 | + background: var(--surface); border: 1.5px solid var(--line-strong); | |
| 483 | + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat); | |
| 484 | +} | |
| 485 | +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; } | |
| 486 | +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); | |
| 487 | + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; } | |
| 488 | +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; } | |
| 489 | +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| 490 | +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); } | |
| 491 | +.q-bar-label { flex-basis: 120px; font-size: 12px; } | |
| 492 | +.q-bar-track { | |
| 493 | + flex: 1; height: 10px; background: var(--surface-2); | |
| 494 | + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; | |
| 495 | +} | |
| 496 | +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; } | |
| 497 | +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono); | |
| 498 | + font-size: 11.5px; font-weight: 700; } | |
| 499 | +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } | |
| 500 | +.q-badge { | |
| 501 | + display: inline-flex; align-items: center; gap: 6px; | |
| 502 | + border: 1.5px solid var(--line-strong); border-radius: 999px; | |
| 503 | + padding: 7px 13px; font-size: 12.5px; background: var(--surface); | |
| 504 | +} | |
| 505 | +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | |
| 506 | +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; } | |
| 507 | +.quartier .fine { margin-top: 10px; } | |
| 508 | +.q-cell { padding: 9px 11px; } | |
| 509 | +.q-val { font-size: 16px; } | |
| 510 | +.q-bar { gap: 8px; font-size: 12px; } | |
| 511 | +.q-bar-track { height: 8px; } | |
| 512 | +.q-badge-sub { display: block; font-size: 10.5px; color: var(--ink-3); font-weight: 400; margin-left: 4px; } | |
| 513 | + | |
| 514 | +/* --- Vrai-Prix : estimation de valeur ---------------------------------- */ | |
| 515 | +.vraiprix { border: 2px solid var(--ink); background: var(--surface-2); | |
| 516 | + box-shadow: var(--shadow-off-soft); padding: 14px; margin: 14px 0; } | |
| 517 | +.vp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } | |
| 518 | +.vp-logo { font-family: var(--font-display); font-weight: 700; color: var(--green-deep); | |
| 519 | + letter-spacing: -.3px; } | |
| 520 | +.vp-conf { font-family: var(--font-mono); font-size: 11px; padding: 2px 7px; border: 1px solid var(--ink); } | |
| 521 | +.vp-conf-A { background: var(--lime); color: #fff; } .vp-conf-B { background: var(--lime-soft); } | |
| 522 | +.vp-conf-C { background: var(--amber-soft); } .vp-conf-D { background: #f0d9d9; } | |
| 523 | +.vp-k { font-size: 11px; color: var(--ink-3); text-transform: uppercase; | |
| 524 | + letter-spacing: .5px; margin-top: 8px; } | |
| 525 | +.vp-value { font-family: var(--font-display); font-size: 26px; font-weight: 700; | |
| 526 | + letter-spacing: -1px; color: var(--ink); } | |
| 527 | +.vp-range { font-size: 12px; color: var(--ink-2); font-family: var(--font-mono); } | |
| 528 | +.vp-delta { font-size: 13px; margin-top: 8px; padding: 6px 8px; border-radius: var(--r-ctl); } | |
| 529 | +.vp-over { background: #f6e2e2; color: #8a2b2b; } | |
| 530 | +.vp-under { background: #e2f0e6; color: #1c5c41; } | |
| 531 | +.vp-fair { background: var(--surface); color: var(--ink-2); } | |
| 532 | +.vp-link { display: block; text-align: center; margin-top: 10px; font-weight: 700; | |
| 533 | + font-size: 13px; padding: 8px; background: var(--green-deep); color: #fff; | |
| 534 | + text-decoration: none; } | |
| 535 | +.vp-link:hover { background: var(--green); } | |
modified
immoka/connectors/base.py
+33 −0
@@ -18,6 +18,7 @@ USER_AGENT = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | ||
| 18 | 18 | "ImmoKaBot/1.0 (+https://www.immo-ka.com/bot; contact@spboucher.ai)") |
| 19 | 19 | |
| 20 | 20 | FIRECRAWL_API = "https://api.firecrawl.dev/v1/scrape" |
| 21 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 21 | 22 | |
| 22 | 23 | |
| 23 | 24 | class BaseConnector: |
@@ -86,6 +87,38 @@ class BaseConnector: | ||
| 86 | 87 | data = resp.json() |
| 87 | 88 | return (data.get("data") or {}).get("html", "") |
| 88 | 89 | |
| 90 | + def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 91 | + rendering_wait: int = 0, country: str = "ca", | |
| 92 | + wait_for_selector: str | None = None) -> str: | |
| 93 | + """HTML rendu via Scrapfly (ASP = bypass anti-bot avancé + rendu JS). | |
| 94 | + | |
| 95 | + Nécessite SCRAPFLY_KEY dans l'environnement (.env). Meilleur que Firecrawl | |
| 96 | + sur Cloudflare avancé et sur les SPA chargées via XHR (BoldTrail, etc.). | |
| 97 | + Retourne le HTML (result.content) ou "" en cas d'échec ASP. | |
| 98 | + """ | |
| 99 | + key = os.environ.get("SCRAPFLY_KEY") | |
| 100 | + if not key: | |
| 101 | + raise RuntimeError("SCRAPFLY_KEY manquant (voir .env)") | |
| 102 | + params: dict = {"key": key, "url": url, "country": country} | |
| 103 | + if asp: | |
| 104 | + params["asp"] = "true" | |
| 105 | + if render_js: | |
| 106 | + params["render_js"] = "true" | |
| 107 | + if rendering_wait: | |
| 108 | + params["rendering_wait"] = rendering_wait | |
| 109 | + if wait_for_selector: | |
| 110 | + params["wait_for_selector"] = wait_for_selector | |
| 111 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 112 | + if wait > 0: | |
| 113 | + time.sleep(wait) | |
| 114 | + resp = requests.get(SCRAPFLY_API, params=params, timeout=180) | |
| 115 | + self._last_request = time.time() | |
| 116 | + try: | |
| 117 | + data = resp.json() | |
| 118 | + except ValueError: | |
| 119 | + return "" | |
| 120 | + return ((data.get("result") or {}).get("content")) or "" | |
| 121 | + | |
| 89 | 122 | def detail(self, external_id: str, key: str, fetch_fn) -> dict: |
| 90 | 123 | """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si |
| 91 | 124 | la propriété est nouvelle ou si sa clé (hash du contenu liste) a changé. |
added
immoka/connectors/duproprio.py
+168 −0
@@ -0,0 +1,168 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de maisons à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# connectors/duproprio.py : DuProprio (FSBO — vente sans courtier) | |
| 5 | +# | |
| 6 | +# DuProprio est la grande plateforme québécoise de vente « à vendre par le | |
| 7 | +# propriétaire » (sans agence). Segment complémentaire absent de toutes les | |
| 8 | +# bannières de courtage. Énumération EXHAUSTIVE via les sitemaps FR par région | |
| 9 | +# du Québec (`/sitemaps/fr/<region>-listings.xml.gz`) : l'URL encode déjà | |
| 10 | +# région, ville, type et l'id DuProprio (= external_id). La fiche détail | |
| 11 | +# (JSON-LD Product) fournit prix, description et galerie ; enrichissement | |
| 12 | +# plafonné + cache (comme les autres connecteurs). Scrapfly en secours si le | |
| 13 | +# fetch direct est bloqué. | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import gzip | |
| 18 | +import io | |
| 19 | +import os | |
| 20 | +import re | |
| 21 | + | |
| 22 | +from .base import BaseConnector | |
| 23 | +from . import _detailutil as du | |
| 24 | +from ..normalize import normalize_property_type, parse_price | |
| 25 | +from ..schema import PropertyListing | |
| 26 | + | |
| 27 | +SITEMAP_INDEX = "https://duproprio.com/sitemaps/fr/index.xml.gz" | |
| 28 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_DUPROPRIO_DETAIL_LIMIT", "500")) | |
| 29 | +AGENCY = "DuProprio (sans courtier)" | |
| 30 | + | |
| 31 | +_LOC_RE = re.compile(r"<loc>([^<]+)</loc>") | |
| 32 | +# /fr/<region>/<ville>/<type>-a-vendre/<slug>-<id> | |
| 33 | +_URL_RE = re.compile( | |
| 34 | + r"https://duproprio\.com/fr/([a-z0-9-]+)/([a-z0-9-]+)/([a-z0-9-]+?)-a-vendre/([a-z0-9-]+?)-(\d{5,})/?$", | |
| 35 | + re.I) | |
| 36 | +_PHOTO_RE = re.compile( | |
| 37 | + r"https://photos\.duproprio\.com/photos/public/for_sale/\d+/\d+/[^\"'\\ ]+?\.jpg", re.I) | |
| 38 | +_LDPROD_RE = re.compile(r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>', re.S | re.I) | |
| 39 | + | |
| 40 | + | |
| 41 | +class DuProprioConnector(BaseConnector): | |
| 42 | + source_id = "duproprio" | |
| 43 | + request_delay = 0.4 | |
| 44 | + | |
| 45 | + def fetch(self) -> list[PropertyListing]: | |
| 46 | + by_id: dict[str, PropertyListing] = {} | |
| 47 | + for sm in self._region_listing_sitemaps(): | |
| 48 | + for url in self._sitemap_locs(sm): | |
| 49 | + lst = self._to_listing(url) | |
| 50 | + if lst and lst.external_id not in by_id: | |
| 51 | + by_id[lst.external_id] = lst | |
| 52 | + listings = list(by_id.values()) | |
| 53 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_dp_detail, | |
| 54 | + key="v1", fetch_html=self._fetch_detail) | |
| 55 | + return listings | |
| 56 | + | |
| 57 | + # -- sitemaps -------------------------------------------------------------- | |
| 58 | + def _region_listing_sitemaps(self) -> list[str]: | |
| 59 | + xml = self._get_gz(SITEMAP_INDEX) | |
| 60 | + # uniquement les sitemaps d'INSCRIPTIONS par région (Québec) | |
| 61 | + return [u for u in _LOC_RE.findall(xml) if u.endswith("-listings.xml.gz")] | |
| 62 | + | |
| 63 | + def _sitemap_locs(self, url: str) -> list[str]: | |
| 64 | + return [u for u in _LOC_RE.findall(self._get_gz(url)) if "/fr/" in u] | |
| 65 | + | |
| 66 | + def _get_gz(self, url: str) -> str: | |
| 67 | + try: | |
| 68 | + raw = self.get(url).content | |
| 69 | + except Exception: | |
| 70 | + return "" | |
| 71 | + try: | |
| 72 | + return gzip.GzipFile(fileobj=io.BytesIO(raw)).read().decode("utf-8", "ignore") | |
| 73 | + except OSError: | |
| 74 | + return raw.decode("utf-8", "ignore") # déjà décompressé | |
| 75 | + | |
| 76 | + # -- mapping --------------------------------------------------------------- | |
| 77 | + def _to_listing(self, url: str) -> PropertyListing | None: | |
| 78 | + m = _URL_RE.match(url.strip()) | |
| 79 | + if not m: | |
| 80 | + return None | |
| 81 | + region_s, city_s, type_s, addr_s, did = m.groups() | |
| 82 | + return PropertyListing( | |
| 83 | + source=self.source_id, external_id=did, url=url, | |
| 84 | + title=_deslug(addr_s), | |
| 85 | + address=_deslug(re.sub(r"^(hab|com|ter|multi|imm)-", "", addr_s)), | |
| 86 | + city=_deslug(city_s), | |
| 87 | + region=_region(region_s), | |
| 88 | + property_type=normalize_property_type(type_s.replace("-", " ")), | |
| 89 | + agency=AGENCY, broker_name="", | |
| 90 | + ) | |
| 91 | + | |
| 92 | + def _fetch_detail(self, url: str) -> str: | |
| 93 | + html = "" | |
| 94 | + try: | |
| 95 | + html = self.get(url).text | |
| 96 | + except Exception: | |
| 97 | + html = "" | |
| 98 | + if "application/ld+json" not in html: # bloqué -> Scrapfly (ASP) | |
| 99 | + try: | |
| 100 | + html = self.get_scrapfly(url, render_js=False, asp=True) | |
| 101 | + except Exception: | |
| 102 | + pass | |
| 103 | + return html | |
| 104 | + | |
| 105 | + | |
| 106 | +# --------------------------------------------------------------------------- | |
| 107 | +def _parse_dp_detail(html: str) -> dict: | |
| 108 | + import json, html as _h | |
| 109 | + out: dict = {} | |
| 110 | + for block in _LDPROD_RE.findall(html): | |
| 111 | + try: | |
| 112 | + d = json.loads(block) | |
| 113 | + except ValueError: | |
| 114 | + continue | |
| 115 | + if isinstance(d, dict) and d.get("@type") == "Product": | |
| 116 | + off = d.get("offers") or {} | |
| 117 | + p = parse_price(str(off.get("price") or "")) if off.get("price") else None | |
| 118 | + if p: | |
| 119 | + out["price"] = p | |
| 120 | + out["price_label"] = f"{p:,.0f} $".replace(",", " ") | |
| 121 | + if d.get("description"): | |
| 122 | + out["description"] = _h.unescape(str(d["description"])).strip()[:4000] | |
| 123 | + break | |
| 124 | + # galerie COMPLÈTE : le JSON « "photos":[{...}] » de la page liste toutes les | |
| 125 | + # photos (le JSON-LD/HTML n'en montre qu'une). On prend la plus haute résolution. | |
| 126 | + imgs, seen = [], set() | |
| 127 | + mgal = re.search(r'"photos"\s*:\s*(\[\{.*?\}\])', html, re.S) | |
| 128 | + if mgal: | |
| 129 | + try: | |
| 130 | + for ph in json.loads(mgal.group(1)): | |
| 131 | + fmt = ph.get("formats") or {} | |
| 132 | + rel = fmt.get("1600") or fmt.get("1024") or fmt.get("600") | |
| 133 | + if rel: | |
| 134 | + u = rel if rel.startswith("http") else f"https://photos.duproprio.com/{rel.lstrip('/')}" | |
| 135 | + if u not in seen: | |
| 136 | + seen.add(u); imgs.append(u) | |
| 137 | + except ValueError: | |
| 138 | + pass | |
| 139 | + if not imgs: # repli : URLs photos dans le HTML brut | |
| 140 | + for u in _PHOTO_RE.findall(html): | |
| 141 | + if u not in seen: | |
| 142 | + seen.add(u); imgs.append(u) | |
| 143 | + if imgs: | |
| 144 | + out["images"] = imgs | |
| 145 | + text = re.sub(r"\s+", " ", _h_unescape(html)) | |
| 146 | + mb = re.search(r"(\d+)\s*chambre", text, re.I) | |
| 147 | + if mb: | |
| 148 | + out["bedrooms"] = int(mb.group(1)) | |
| 149 | + ms = re.search(r"(\d+)\s*salle[s]?\s*de\s*bain", text, re.I) | |
| 150 | + if ms: | |
| 151 | + out["bathrooms"] = int(ms.group(1)) | |
| 152 | + return out | |
| 153 | + | |
| 154 | + | |
| 155 | +def _h_unescape(html: str) -> str: | |
| 156 | + import html as _h | |
| 157 | + return _h.unescape(re.sub(r"<[^>]+>", " ", html)) | |
| 158 | + | |
| 159 | + | |
| 160 | +def _deslug(s: str) -> str: | |
| 161 | + return re.sub(r"\s+", " ", s.replace("-", " ").replace("_", " ")).strip().title() | |
| 162 | + | |
| 163 | + | |
| 164 | +def _region(slug: str) -> str: | |
| 165 | + r = _deslug(slug) | |
| 166 | + return (r.replace("Quebec", "Québec").replace("Montreal", "Montréal") | |
| 167 | + .replace("Monteregie", "Montérégie").replace("Laurentides", "Laurentides") | |
| 168 | + .replace("Mauricie", "Mauricie").replace("Estrie", "Estrie")) | |
modified
immoka/connectors/remax_quebec.py
+10 −1
@@ -61,7 +61,7 @@ class RemaxQuebecConnector(BaseConnector): | ||
| 61 | 61 | con = db.connect() |
| 62 | 62 | budget = DETAIL_LIMIT |
| 63 | 63 | for lst in listings: |
| 64 | − key = f"v2|{lst.price_label or '-'}" # v2 = parser enrichi ; refetch si prix change | |
| 64 | + key = f"v3|{lst.price_label or '-'}" # v3 = + agence/bureau ; refetch si prix change | |
| 65 | 65 | cached = db.get_cached_detail(con, self.source_id, lst.external_id, key) |
| 66 | 66 | if cached is None: |
| 67 | 67 | if budget <= 0: |
@@ -269,6 +269,12 @@ def parse_remax_detail(html: str) -> dict: | ||
| 269 | 269 | if mp: |
| 270 | 270 | out["broker_phone"] = mp.group(0) |
| 271 | 271 | |
| 272 | + # agence / bureau (« RE/MAX ALLIANCE INC. ») — pour les Sources par sous-agence | |
| 273 | + ma = re.search(r'RE/MAX[^<,|"\n]{1,40}\bINC\.?', html) | |
| 274 | + if ma: | |
| 275 | + out["agency"] = _html.unescape(re.sub(r'\s+', ' ', ma.group(0))).strip().title() \ | |
| 276 | + .replace("Re/Max", "RE/MAX").replace("Inc.", "inc.") | |
| 277 | + | |
| 272 | 278 | text = _flatten(html) |
| 273 | 279 | |
| 274 | 280 | # champs numériques principaux |
@@ -352,6 +358,9 @@ def _apply_detail(lst: PropertyListing, d: dict) -> None: | ||
| 352 | 358 | # le nom réel du courtier (page détail) remplace le placeholder « RE/MAX Québec » |
| 353 | 359 | if d.get("broker_name"): |
| 354 | 360 | lst.broker_name = d["broker_name"] |
| 361 | + # agence/bureau -> sous-agence pour les Sources | |
| 362 | + if d.get("agency"): | |
| 363 | + lst.agency = d["agency"] | |
| 355 | 364 | for f in ("description", "bedrooms", "bathrooms", "powder_rooms", |
| 356 | 365 | "year_built", "area_sqft", "lot_sqft", "lat", "lng", |
| 357 | 366 | "broker_phone"): |
modified
immoka/connectors/royal_lepage.py
+25 −1
@@ -15,14 +15,22 @@ | ||
| 15 | 15 | from __future__ import annotations |
| 16 | 16 | |
| 17 | 17 | import html as _html |
| 18 | +import os | |
| 18 | 19 | import re |
| 19 | 20 | |
| 20 | 21 | from .base import BaseConnector |
| 22 | +from . import _detailutil as du | |
| 21 | 23 | from ..normalize import parse_price |
| 22 | 24 | from ..schema import PropertyListing |
| 23 | 25 | |
| 24 | 26 | BASE = "https://www.royallepage.ca/fr/search/homes" |
| 25 | 27 | MAX_PAGES = 30 # une recherche plafonne à ~27 pages (~1250) |
| 28 | +# Le nom du BUREAU (« Royal LePage Humania »…) n'est pas sur la carte : il vient | |
| 29 | +# de la fiche détail. Enrichissement plafonné + cache (agency se remplit au fil | |
| 30 | +# des cycles). = affichage des Sources par sous-agence. | |
| 31 | +DETAIL_LIMIT = int(os.environ.get("IMMOKA_RLP_DETAIL_LIMIT", "500")) | |
| 32 | +_OFFICE_RE = re.compile(r'agent-info__brokerage".*?<a[^>]*>\s*([^<,\n]+?)\s*(?:,|\n|</a>)', | |
| 33 | + re.S | re.I) | |
| 26 | 34 | |
| 27 | 35 | # Points d'ancrage couvrant le Québec (nom, lat, lng). L'union des recherches |
| 28 | 36 | # + dédoublonnage MLS couvre la province. |
@@ -65,7 +73,14 @@ class RoyalLepageConnector(BaseConnector): | ||
| 65 | 73 | by_id: dict[str, PropertyListing] = {} |
| 66 | 74 | for name, lat, lng in ANCHORS: |
| 67 | 75 | self._search_anchor(name, lat, lng, by_id) |
| 68 | − return list(by_id.values()) | |
| 76 | + listings = list(by_id.values()) | |
| 77 | + # bureau (agency) via la fiche détail — plafonné, cache accumulé | |
| 78 | + du.enrich(self, listings, DETAIL_LIMIT, _parse_rlp_office, key="office1") | |
| 79 | + for lst in listings: | |
| 80 | + office = lst.details.pop("__agency", "") | |
| 81 | + if office: | |
| 82 | + lst.agency = office | |
| 83 | + return listings | |
| 69 | 84 | |
| 70 | 85 | def _search_anchor(self, name: str, lat: float, lng: float, out: dict) -> None: |
| 71 | 86 | empty_streak = 0 |
@@ -130,3 +145,12 @@ class RoyalLepageConnector(BaseConnector): | ||
| 130 | 145 | broker_name="Royal LePage", |
| 131 | 146 | )) |
| 132 | 147 | return out |
| 148 | + | |
| 149 | + | |
| 150 | +def _parse_rlp_office(html: str) -> dict: | |
| 151 | + """Extrait le bureau/brokerage de la fiche détail Royal LePage.""" | |
| 152 | + m = _OFFICE_RE.search(html) | |
| 153 | + if not m: | |
| 154 | + return {} | |
| 155 | + office = _html.unescape(m.group(1)).strip() | |
| 156 | + return {"details": {"__agency": office}} if office else {} | |
modified
immoka/connectors/sutton.py
+9 −1
@@ -69,7 +69,10 @@ class SuttonConnector(BaseConnector): | ||
| 69 | 69 | # fiche détail : prix, chambres/sdb, galerie complète (~80 photos), |
| 70 | 70 | # description, pièces, inclusions, caractéristiques — plafonné, cache |
| 71 | 71 | # accumulé entre cycles (tout le parc finit enrichi au fil des syncs). |
| 72 | − du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v3") | |
| 72 | + du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v4") | |
| 73 | + for lst in listings: | |
| 74 | + office = lst.details.pop("__agency", "") | |
| 75 | + lst.agency = office or "Groupe Sutton Québec" | |
| 73 | 76 | return listings |
| 74 | 77 | |
| 75 | 78 | def _sitemap_listings(self): |
@@ -301,4 +304,9 @@ def parse_sutton_detail(html: str) -> dict: | ||
| 301 | 304 | _det = du.centris_details(du.flatten(html)) |
| 302 | 305 | if _det: |
| 303 | 306 | out.setdefault("details", {}).update(_det) |
| 307 | + | |
| 308 | + # sous-agence (bureau) : lien /agence-immobiliere/NNN-<slug>.html | |
| 309 | + ma = re.search(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', html, re.I) | |
| 310 | + if ma: | |
| 311 | + out.setdefault("details", {})["__agency"] = _deslug(ma.group(1)) | |
| 304 | 312 | return out |
modified
immoka/connectors/via_capitale.py
+4 −0
@@ -78,6 +78,10 @@ class ViaCapitaleConnector(BaseConnector): | ||
| 78 | 78 | cards = [] |
| 79 | 79 | for attempt in range(2): |
| 80 | 80 | try: |
| 81 | + # Firecrawl (proxy stealth) : c'est LUI qui rend les cartes de | |
| 82 | + # Via Capitale. Testé : Scrapfly ASP franchit Cloudflare mais | |
| 83 | + # ne déclenche pas le rendu des inscriptions (0 carte) ; on | |
| 84 | + # garde donc Firecrawl, qui ramène bien les ~5 300 propriétés. | |
| 81 | 85 | html = self.get_rendered(url, wait_for=6000, proxy="stealth") |
| 82 | 86 | except Exception: |
| 83 | 87 | html = "" |
modified
immoka/db.py
+71 −8
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS listings ( | ||
| 51 | 51 | status TEXT DEFAULT 'a-vendre', |
| 52 | 52 | broker_name TEXT, |
| 53 | 53 | broker_phone TEXT, |
| 54 | + agency TEXT, | |
| 54 | 55 | description TEXT, |
| 55 | 56 | features TEXT, -- JSON (liste de textes source) |
| 56 | 57 | details TEXT, -- JSON (champs structurés) |
@@ -63,7 +64,8 @@ CREATE TABLE IF NOT EXISTS listings ( | ||
| 63 | 64 | last_seen REAL, |
| 64 | 65 | updated_at REAL, |
| 65 | 66 | miss_count INTEGER DEFAULT 0, |
| 66 | − active INTEGER DEFAULT 1 | |
| 67 | + active INTEGER DEFAULT 1, | |
| 68 | + dup_hidden INTEGER DEFAULT 0 -- 1 = doublon de sous-agence masqué (dédup Centris) | |
| 67 | 69 | ); |
| 68 | 70 | CREATE INDEX IF NOT EXISTS idx_listings_source ON listings(source); |
| 69 | 71 | CREATE INDEX IF NOT EXISTS idx_listings_city ON listings(city); |
@@ -108,20 +110,80 @@ CREATE TABLE IF NOT EXISTS geocode_cache ( | ||
| 108 | 110 | failed INTEGER DEFAULT 0, |
| 109 | 111 | ts REAL |
| 110 | 112 | ); |
| 113 | + | |
| 114 | +CREATE TABLE IF NOT EXISTS poi_cache ( | |
| 115 | + coord_key TEXT PRIMARY KEY, -- "lat,lng" arrondi à 4 décimales (~11 m) | |
| 116 | + lat REAL, | |
| 117 | + lng REAL, | |
| 118 | + pois TEXT, -- JSON : [{cat, name, dist_m}] (plus proche/catégorie) | |
| 119 | + ts REAL | |
| 120 | +); | |
| 111 | 121 | """ |
| 112 | 122 | |
| 113 | 123 | |
| 124 | +_SCHEMA_READY = False # le schéma/migration ne s'exécute qu'UNE fois par process | |
| 125 | + | |
| 126 | + | |
| 127 | +def _init_schema(con: sqlite3.Connection) -> None: | |
| 128 | + """Création de schéma + migration — coûteuse (write-lock). À faire une seule | |
| 129 | + fois par process : l'exécuter à chaque connexion sérialisait les requêtes du | |
| 130 | + web sur un verrou d'écriture (deadlock/starvation sous charge).""" | |
| 131 | + con.executescript(_SCHEMA) | |
| 132 | + cols = {r["name"] for r in con.execute("PRAGMA table_info(listings)")} | |
| 133 | + if "agency" not in cols: | |
| 134 | + con.execute("ALTER TABLE listings ADD COLUMN agency TEXT") | |
| 135 | + if "dup_hidden" not in cols: | |
| 136 | + con.execute("ALTER TABLE listings ADD COLUMN dup_hidden INTEGER DEFAULT 0") | |
| 137 | + if "dauid" not in cols: # aire de diffusion 2021 (stats de quartier) | |
| 138 | + con.execute("ALTER TABLE listings ADD COLUMN dauid TEXT") | |
| 139 | + if "vraiprix" not in cols: # estimation Vrai-Prix (JSON) + lien analyse | |
| 140 | + con.execute("ALTER TABLE listings ADD COLUMN vraiprix TEXT") | |
| 141 | + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_duphidden ON listings(dup_hidden)") | |
| 142 | + con.commit() | |
| 143 | + | |
| 144 | + | |
| 145 | +def refresh_dedup(con: sqlite3.Connection) -> int: | |
| 146 | + """Pré-calcule la déduplication de la famille sous-agences (`*_ag_*`) dans la | |
| 147 | + colonne `dup_hidden`, pour que la lecture soit instantanée (« AND dup_hidden=0 ») | |
| 148 | + au lieu d'un sous-select corrélé par ligne (300 s sur 75 k lignes). | |
| 149 | + | |
| 150 | + Règle (identique à l'ancienne clause) : une fiche de sous-agence est masquée | |
| 151 | + si une fiche de plus haute priorité — même n° Centris (external_id), active — | |
| 152 | + existe : le flux central/agence principale d'abord, sinon la sous-agence au | |
| 153 | + plus petit uid. Les fiches non sous-agence ne sont jamais masquées. | |
| 154 | + Retourne le nombre de fiches masquées.""" | |
| 155 | + AG = "source LIKE '%\\_ag\\_%' ESCAPE '\\'" | |
| 156 | + NOTAG = "source NOT LIKE '%\\_ag\\_%' ESCAPE '\\'" | |
| 157 | + con.execute("UPDATE listings SET dup_hidden=0") | |
| 158 | + # 1) masquer les sous-agences dont le n° Centris est porté par une fiche | |
| 159 | + # canonique (non sous-agence) active — semi-jointure, rapide. | |
| 160 | + con.execute( | |
| 161 | + f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG}" | |
| 162 | + f" AND external_id IN (SELECT external_id FROM listings" | |
| 163 | + f" WHERE active=1 AND {NOTAG})") | |
| 164 | + # 2) parmi les sous-agences restantes (sans canonique), ne garder que le plus | |
| 165 | + # petit uid par n° Centris. | |
| 166 | + con.execute( | |
| 167 | + f"UPDATE listings SET dup_hidden=1 WHERE active=1 AND {AG} AND dup_hidden=0" | |
| 168 | + f" AND uid > (SELECT MIN(d.uid) FROM listings d WHERE d.active=1" | |
| 169 | + f" AND d.external_id=listings.external_id AND d.{AG} AND d.dup_hidden=0)") | |
| 170 | + n = con.execute("SELECT COUNT(*) c FROM listings WHERE dup_hidden=1").fetchone()["c"] | |
| 171 | + con.commit() | |
| 172 | + return n | |
| 173 | + | |
| 174 | + | |
| 114 | 175 | def connect() -> sqlite3.Connection: |
| 176 | + global _SCHEMA_READY | |
| 115 | 177 | DB_PATH.parent.mkdir(parents=True, exist_ok=True) |
| 116 | 178 | con = sqlite3.connect(DB_PATH, timeout=60) |
| 117 | 179 | con.row_factory = sqlite3.Row |
| 118 | − # WAL + busy_timeout : tolère les accès concurrents (watcher + web + sync | |
| 119 | − # manuel écrivent la même base) sans « database is locked ». | |
| 180 | + # WAL + busy_timeout : accès concurrents (watcher + web) sans « db is locked ». | |
| 120 | 181 | con.execute("PRAGMA journal_mode=WAL") |
| 121 | 182 | con.execute("PRAGMA busy_timeout=60000") |
| 122 | 183 | con.execute("PRAGMA synchronous=NORMAL") |
| 123 | − con.executescript(_SCHEMA) | |
| 124 | − con.commit() | |
| 184 | + if not _SCHEMA_READY: | |
| 185 | + _init_schema(con) | |
| 186 | + _SCHEMA_READY = True | |
| 125 | 187 | return con |
| 126 | 188 | |
| 127 | 189 | |
@@ -191,6 +253,7 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 191 | 253 | area_sqft=lst.area_sqft, lot_sqft=lst.lot_sqft, |
| 192 | 254 | year_built=lst.year_built, mls=lst.mls, status=lst.status, |
| 193 | 255 | broker_name=lst.broker_name, broker_phone=lst.broker_phone, |
| 256 | + agency=lst.agency, | |
| 194 | 257 | description=lst.description, |
| 195 | 258 | features=json.dumps(lst.features, ensure_ascii=False), |
| 196 | 259 | details=json.dumps(lst.details, ensure_ascii=False), |
@@ -203,14 +266,14 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 203 | 266 | address, sector, city, region, property_type, price, |
| 204 | 267 | price_label, bedrooms, bathrooms, powder_rooms, area_sqft, |
| 205 | 268 | lot_sqft, year_built, mls, status, broker_name, broker_phone, |
| 206 | − description, features, details, images, lat, lng, | |
| 269 | + agency, description, features, details, images, lat, lng, | |
| 207 | 270 | content_hash, first_seen, last_seen, updated_at, |
| 208 | 271 | miss_count, active) |
| 209 | 272 | VALUES (:uid,:source,:external_id,:url,:title,:address, |
| 210 | 273 | :sector,:city,:region,:property_type,:price,:price_label, |
| 211 | 274 | :bedrooms,:bathrooms,:powder_rooms,:area_sqft,:lot_sqft, |
| 212 | 275 | :year_built,:mls,:status,:broker_name,:broker_phone, |
| 213 | − :description,:features,:details,:images,:lat,:lng, | |
| 276 | + :agency,:description,:features,:details,:images,:lat,:lng, | |
| 214 | 277 | :content_hash,:now,:now,:now,0,1)""", params) |
| 215 | 278 | if lst.price is not None: |
| 216 | 279 | con.execute("INSERT INTO price_log (uid, ts, price) VALUES (?,?,?)", |
@@ -227,7 +290,7 @@ def sync_source(con: sqlite3.Connection, source: str, | ||
| 227 | 290 | area_sqft=:area_sqft, lot_sqft=:lot_sqft, |
| 228 | 291 | year_built=:year_built, mls=:mls, status=:status, |
| 229 | 292 | broker_name=:broker_name, broker_phone=:broker_phone, |
| 230 | − description=:description, features=:features, | |
| 293 | + agency=:agency, description=:description, features=:features, | |
| 231 | 294 | details=:details, images=:images, |
| 232 | 295 | lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng), |
| 233 | 296 | content_hash=:content_hash, last_seen=:now, |
added
immoka/geocode.py
+262 −0
@@ -0,0 +1,262 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# geocode.py : géocodage des adresses via Nominatim (OpenStreetMap) | |
| 5 | +# - cache persistant (table geocode_cache) : une adresse n'est géocodée | |
| 6 | +# qu'une seule fois, et par immeuble (adresse normalisée), pas par annonce | |
| 7 | +# - politesse : 1 requête/seconde max, User-Agent identifiable | |
| 8 | +# - validation : les coordonnées doivent tomber dans la région attendue | |
| 9 | +# (bounding box par ville), sinon flag geocode_failed — jamais de | |
| 10 | +# coordonnées bidon | |
| 11 | +# ----------------------------------------------------------------------------- | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import re | |
| 15 | +import time | |
| 16 | + | |
| 17 | +import requests | |
| 18 | + | |
| 19 | +from . import db | |
| 20 | +from .normalize import strip_accents | |
| 21 | + | |
| 22 | +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" | |
| 23 | +# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre | |
| 24 | +# les rues trop récentes pour OpenStreetMap (développements neufs de Lévis…) | |
| 25 | +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" | |
| 26 | + "Adresse_Geocodage/GeocodeServer/findAddressCandidates") | |
| 27 | +AQ_MIN_SCORE = 75 | |
| 28 | +# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête | |
| 29 | +# User-Agent contient des accents (latin-1) | |
| 30 | +USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)" | |
| 31 | +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s | |
| 32 | +REQUEST_DELAY_AQ = 0.12 # Adresses Québec (MERN) : pas de limite stricte | |
| 33 | +RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours | |
| 34 | + | |
| 35 | +# Bounding boxes (lat_min, lat_max, lng_min, lng_max) | |
| 36 | +_BBOX_QUEBEC = (46.55, 47.10, -71.75, -70.85) # Québec, Lévis, St-Augustin | |
| 37 | +# garde-fou provincial : couvre Gatineau, l'Abitibi, le Saguenay, la Côte-Nord | |
| 38 | +# et la Gaspésie (expansion provinciale) — même boîte que schema.finalize() | |
| 39 | +_BBOX_SUD_QC = (44.50, 63.00, -80.00, -56.00) | |
| 40 | + | |
| 41 | +_VILLES_QUEBEC = {"quebec", "levis", "saint-augustin-de-desmaures", | |
| 42 | + "l'ancienne-lorette", "ancienne-lorette"} | |
| 43 | + | |
| 44 | + | |
| 45 | +def _bbox_for(city: str) -> tuple[float, float, float, float]: | |
| 46 | + key = strip_accents((city or "").strip().lower()) | |
| 47 | + return _BBOX_QUEBEC if key in _VILLES_QUEBEC else _BBOX_SUD_QC | |
| 48 | + | |
| 49 | + | |
| 50 | +def _in_bbox(lat: float, lng: float, bbox: tuple) -> bool: | |
| 51 | + return bbox[0] <= lat <= bbox[1] and bbox[2] <= lng <= bbox[3] | |
| 52 | + | |
| 53 | + | |
| 54 | +def norm_key(address: str) -> str: | |
| 55 | + """Clé de cache : adresse normalisée (casse, accents, espaces, n° d'app.).""" | |
| 56 | + s = strip_accents((address or "").lower()) | |
| 57 | + s = re.sub(r"\b(?:app?t?|appartement|unite|suite|#)\.?\s*[\w-]+\b", " ", s) | |
| 58 | + s = re.sub(r"[^a-z0-9]+", " ", s) | |
| 59 | + return re.sub(r"\s+", " ", s).strip() | |
| 60 | + | |
| 61 | + | |
| 62 | +class Geocoder: | |
| 63 | + def __init__(self, con) -> None: | |
| 64 | + self.con = con | |
| 65 | + self.session = requests.Session() | |
| 66 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 67 | + self._last = 0.0 | |
| 68 | + | |
| 69 | + @staticmethod | |
| 70 | + def _clean(address: str) -> str: | |
| 71 | + """Nettoyages qui aident Nominatim sur les adresses québécoises.""" | |
| 72 | + s = address.strip() | |
| 73 | + # « 101-2905 Rue X » = unité 101, civique 2905 -> garder le civique | |
| 74 | + s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s) | |
| 75 | + # « 6275 et 6375 boulevard X » -> premier civique | |
| 76 | + s = re.sub(r"^(\d+)\s+et\s+\d+\s", r"\1 ", s) | |
| 77 | + # « Montréal - Laval » / « Montréal - Île-des-Soeurs » -> garder le vrai lieu | |
| 78 | + s = re.sub(r"montr[ée]al\s*-\s*", "", s, flags=re.I) | |
| 79 | + # « bureau 105 » / « suite 3 » / « local B » : suffixes de bureau | |
| 80 | + s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?)\s*[\w-]+\b", "", s, flags=re.I) | |
| 81 | + # « Vanier (Québec) » -> « Vanier, Québec » | |
| 82 | + s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I) | |
| 83 | + # abréviations cardinales : « Rue Salaberry O » -> « Ouest » | |
| 84 | + s = re.sub(r"\bO\.?(?=,|\s*$)", "Ouest", s) | |
| 85 | + s = re.sub(r"\bE\.?(?=,|\s*$)", "Est", s) | |
| 86 | + return s | |
| 87 | + | |
| 88 | + def _build_query(self, address: str, city: str) -> str: | |
| 89 | + q = self._clean(address) | |
| 90 | + key = strip_accents(q.lower()) | |
| 91 | + if city and strip_accents(city.lower()) not in key: | |
| 92 | + q += f", {city}" | |
| 93 | + if "quebec" not in strip_accents(q.lower()) and "qc" not in q.lower(): | |
| 94 | + q += ", Québec" | |
| 95 | + return q + ", Canada" | |
| 96 | + | |
| 97 | + def _query_nominatim(self, params: dict) -> tuple[float, float] | None: | |
| 98 | + """Une requête Nominatim, throttlée.""" | |
| 99 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 100 | + if wait > 0: | |
| 101 | + time.sleep(wait) | |
| 102 | + try: | |
| 103 | + resp = self.session.get(NOMINATIM_URL, params={ | |
| 104 | + "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params, | |
| 105 | + }, timeout=20) | |
| 106 | + self._last = time.time() | |
| 107 | + resp.raise_for_status() | |
| 108 | + hits = resp.json() | |
| 109 | + except Exception: | |
| 110 | + self._last = time.time() | |
| 111 | + return None | |
| 112 | + if not hits: | |
| 113 | + return None | |
| 114 | + try: | |
| 115 | + return float(hits[0]["lat"]), float(hits[0]["lon"]) | |
| 116 | + except (KeyError, ValueError): | |
| 117 | + return None | |
| 118 | + | |
| 119 | + def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None: | |
| 120 | + """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS). | |
| 121 | + | |
| 122 | + Couvre les rues trop récentes pour OSM. Seuil de score AQ_MIN_SCORE | |
| 123 | + pour éviter les correspondances approximatives sur une autre rue. | |
| 124 | + """ | |
| 125 | + premier = self._clean(address).split(",")[0].strip() | |
| 126 | + ville = (city or "Québec").strip() | |
| 127 | + wait = REQUEST_DELAY_AQ - (time.time() - self._last) | |
| 128 | + if wait > 0: | |
| 129 | + time.sleep(wait) | |
| 130 | + try: | |
| 131 | + resp = self.session.get(AQ_URL, params={ | |
| 132 | + "SingleLine": f"{premier}, {ville}", | |
| 133 | + "f": "json", "outSR": 4326, "maxLocations": 1, | |
| 134 | + }, timeout=20) | |
| 135 | + self._last = time.time() | |
| 136 | + resp.raise_for_status() | |
| 137 | + cands = resp.json().get("candidates") or [] | |
| 138 | + except Exception: | |
| 139 | + self._last = time.time() | |
| 140 | + return None | |
| 141 | + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE: | |
| 142 | + return None | |
| 143 | + loc = cands[0].get("location") or {} | |
| 144 | + try: | |
| 145 | + return float(loc["y"]), float(loc["x"]) | |
| 146 | + except (KeyError, ValueError): | |
| 147 | + return None | |
| 148 | + | |
| 149 | + def _attempts(self, address: str, city: str) -> list[dict]: | |
| 150 | + """Stratégies de requête, de la plus précise à la moins précise. | |
| 151 | + | |
| 152 | + 1) structurée civique+rue (évite l'ambiguïté « Québec » ville/province) | |
| 153 | + 2) recherche libre complète | |
| 154 | + 3) structurée rue seule -> centroïde de rue (repli acceptable pour la | |
| 155 | + carte quand le numéro civique est absent d'OpenStreetMap) | |
| 156 | + """ | |
| 157 | + premier = self._clean(address).split(",")[0].strip() | |
| 158 | + ville = (city or "").strip() | |
| 159 | + if not ville: | |
| 160 | + k = strip_accents(address.lower()) | |
| 161 | + ville = "Lévis" if "levis" in k else "Québec" | |
| 162 | + commun = {"city": ville, "state": "Québec", "country": "Canada"} | |
| 163 | + | |
| 164 | + tries: list[dict] = [] | |
| 165 | + m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier) | |
| 166 | + if m: | |
| 167 | + tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) | |
| 168 | + tries.append({"q": self._build_query(address, city)}) | |
| 169 | + if m: | |
| 170 | + tries.append({"street": m.group(2), **commun}) | |
| 171 | + return tries | |
| 172 | + | |
| 173 | + def resolve(self, address: str, city: str) -> tuple[float, float] | None: | |
| 174 | + """Adresse -> (lat, lng), via cache puis Nominatim. None si introuvable.""" | |
| 175 | + key = norm_key(address) | |
| 176 | + if not key or len(key) < 6: | |
| 177 | + return None | |
| 178 | + row = self.con.execute( | |
| 179 | + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?", | |
| 180 | + (key,)).fetchone() | |
| 181 | + if row is not None: | |
| 182 | + if not row["failed"]: | |
| 183 | + return (row["lat"], row["lng"]) | |
| 184 | + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: | |
| 185 | + return None # échec récent : ne pas marteler l'API | |
| 186 | + | |
| 187 | + bbox = _bbox_for(city) | |
| 188 | + coords = None | |
| 189 | + # Adresses Québec (MERN) d'abord : rapide, autoritatif au Québec, pas de | |
| 190 | + # limite stricte. Nominatim (1 req/s) seulement en repli. | |
| 191 | + provider = "adresses_quebec" | |
| 192 | + c = self._query_adresses_quebec(address, city) | |
| 193 | + if c is not None and _in_bbox(*c, bbox): | |
| 194 | + coords = c | |
| 195 | + if coords is None: | |
| 196 | + provider = "nominatim" | |
| 197 | + for params in self._attempts(address, city): | |
| 198 | + c = self._query_nominatim(params) | |
| 199 | + if c is not None and _in_bbox(*c, bbox): | |
| 200 | + coords = c | |
| 201 | + break | |
| 202 | + ok = coords is not None | |
| 203 | + self.con.execute( | |
| 204 | + "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)" | |
| 205 | + " VALUES (?,?,?,?,?,?)" | |
| 206 | + " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat," | |
| 207 | + " lng=excluded.lng, provider=excluded.provider," | |
| 208 | + " failed=excluded.failed, ts=excluded.ts", | |
| 209 | + (key, coords[0] if ok else None, coords[1] if ok else None, | |
| 210 | + provider, 0 if ok else 1, time.time())) | |
| 211 | + self.con.commit() | |
| 212 | + return coords if ok else None | |
| 213 | + | |
| 214 | + | |
| 215 | +def run(limit: int | None = None) -> dict: | |
| 216 | + """Géocode les annonces actives sans coordonnées, par adresse unique. | |
| 217 | + | |
| 218 | + `limit` borne le nombre de NOUVELLES requêtes Nominatim (les hits de | |
| 219 | + cache sont gratuits et toujours appliqués). | |
| 220 | + """ | |
| 221 | + con = db.connect() | |
| 222 | + geo = Geocoder(con) | |
| 223 | + rows = con.execute( | |
| 224 | + """SELECT uid, address, city FROM listings | |
| 225 | + WHERE active=1 AND lat IS NULL AND address<>'' AND geocode_failed=0 | |
| 226 | + AND dup_hidden=0 | |
| 227 | + ORDER BY address""").fetchall() | |
| 228 | + | |
| 229 | + # regrouper par immeuble (adresse normalisée) | |
| 230 | + groupes: dict[str, list] = {} | |
| 231 | + for r in rows: | |
| 232 | + groupes.setdefault(norm_key(r["address"]), []).append(r) | |
| 233 | + | |
| 234 | + done = failed = requests_made = 0 | |
| 235 | + for key, members in groupes.items(): | |
| 236 | + if not key: | |
| 237 | + continue | |
| 238 | + cached = con.execute( | |
| 239 | + "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone() | |
| 240 | + if cached is None: | |
| 241 | + if limit is not None and requests_made >= limit: | |
| 242 | + continue | |
| 243 | + requests_made += 1 | |
| 244 | + coords = geo.resolve(members[0]["address"], members[0]["city"]) | |
| 245 | + if coords: | |
| 246 | + for r in members: | |
| 247 | + con.execute("UPDATE listings SET lat=?, lng=? WHERE uid=?", | |
| 248 | + (coords[0], coords[1], r["uid"])) | |
| 249 | + done += len(members) | |
| 250 | + else: | |
| 251 | + # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon | |
| 252 | + for r in members: | |
| 253 | + con.execute("UPDATE listings SET geocode_failed=1 WHERE uid=?", | |
| 254 | + (r["uid"],)) | |
| 255 | + failed += len(members) | |
| 256 | + con.commit() | |
| 257 | + | |
| 258 | + con.close() | |
| 259 | + stats = {"geocoded": done, "failed": failed, | |
| 260 | + "unique_addresses": len(groupes), "api_requests": requests_made} | |
| 261 | + print(f"[immo-ka] geocode {stats}") | |
| 262 | + return stats | |
modified
immoka/ingest.py
+6 −0
@@ -46,6 +46,12 @@ def run(sources: list[str] | None = None) -> list[dict]: | ||
| 46 | 46 | db.log_failure(con, sid, f"{exc}") |
| 47 | 47 | traceback.print_exc() |
| 48 | 48 | results.append({"source": sid, "error": str(exc)}) |
| 49 | + # recalcule la déduplication (pré-calculée pour des lectures instantanées) | |
| 50 | + try: | |
| 51 | + hidden = db.refresh_dedup(con) | |
| 52 | + print(f"[immo-ka] dédup: {hidden} doublon(s) de sous-agence masqué(s)") | |
| 53 | + except Exception: | |
| 54 | + traceback.print_exc() | |
| 49 | 55 | con.close() |
| 50 | 56 | return results |
| 51 | 57 | |
added
immoka/poi.py
+248 −0
@@ -0,0 +1,248 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# poi.py : commodités de proximité par immeuble via Overpass (OpenStreetMap) | |
| 5 | +# Pour chaque immeuble géolocalisé, une requête Overpass unique récupère | |
| 6 | +# les points d'intérêt utiles à un locataire (épicerie, pharmacie, école, | |
| 7 | +# garderie, parc, arrêt de bus, gym, clinique…) ; on retient le PLUS PROCHE | |
| 8 | +# de chaque catégorie avec sa distance. Cache permanent par coordonnées | |
| 9 | +# (table poi_cache, clé arrondie à 4 décimales ≈ 11 m : les unités d'un | |
| 10 | +# même immeuble partagent la même entrée). Politesse : 1 requête/seconde. | |
| 11 | +# ----------------------------------------------------------------------------- | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import json | |
| 15 | +import math | |
| 16 | +import time | |
| 17 | + | |
| 18 | +import requests | |
| 19 | + | |
| 20 | +from . import db | |
| 21 | + | |
| 22 | +# Miroirs Overpass (rotation en cas d'erreur/limitation) — kumi.systems | |
| 23 | +# tolère mieux les gros volumes que l'instance officielle | |
| 24 | +OVERPASS_URLS = [ | |
| 25 | + "https://overpass.kumi.systems/api/interpreter", | |
| 26 | + "https://overpass-api.de/api/interpreter", | |
| 27 | +] | |
| 28 | +USER_AGENT = "ImmoKaBot/1.0 (agregateur logements Quebec; +contact@spboucher.ai)" | |
| 29 | +REQUEST_DELAY = 2.0 | |
| 30 | +REFRESH_AFTER = 90 * 86400 # les POI bougent peu : rafraîchir aux ~3 mois | |
| 31 | + | |
| 32 | +# Stratégie « par tuiles » : plutôt qu'une requête par immeuble (l'union | |
| 33 | +# d'around() est très coûteuse côté Overpass), on télécharge TOUS les POI | |
| 34 | +# des catégories par tuile de 0,5° couvrant nos immeubles (~10 tuiles pour | |
| 35 | +# Québec/Lévis + Grand Montréal), puis on calcule les plus proches en local. | |
| 36 | +TILE = 0.5 | |
| 37 | +TILE_MARGIN = 0.04 # ~4 km > plus grand rayon de catégorie (3 km) | |
| 38 | + | |
| 39 | +# Catégories : (clé, libellé FR, sélecteur Overpass, rayon m) | |
| 40 | +CATEGORIES: list[tuple[str, str, str, int]] = [ | |
| 41 | + ("epicerie", "Épicerie", '["shop"="supermarket"]', 1500), | |
| 42 | + ("depanneur", "Dépanneur", '["shop"="convenience"]', 800), | |
| 43 | + ("pharmacie", "Pharmacie", '["amenity"="pharmacy"]', 1500), | |
| 44 | + ("ecole", "École", '["amenity"="school"]', 1500), | |
| 45 | + ("garderie", "Garderie", '["amenity"~"^(kindergarten|childcare)$"]', 1500), | |
| 46 | + ("parc", "Parc", '["leisure"="park"]', 1200), | |
| 47 | + ("bus", "Arrêt de bus", '["highway"="bus_stop"]', 600), | |
| 48 | + ("metro", "Métro", '["railway"="station"]["station"="subway"]', 1500), | |
| 49 | + ("gym", "Gym", '["leisure"="fitness_centre"]', 1500), | |
| 50 | + ("cafe", "Café", '["amenity"="cafe"]', 1000), | |
| 51 | + ("clinique", "Clinique / CLSC", '["amenity"~"^(clinic|doctors)$"]', 2000), | |
| 52 | + ("hopital", "Hôpital", '["amenity"="hospital"]', 3000), | |
| 53 | + ("bibliotheque", "Bibliothèque", '["amenity"="library"]', 2000), | |
| 54 | +] | |
| 55 | + | |
| 56 | +LABELS = {cat: label for cat, label, _, _ in CATEGORIES} | |
| 57 | + | |
| 58 | + | |
| 59 | +def coord_key(lat: float, lng: float) -> str: | |
| 60 | + return f"{round(lat, 4)},{round(lng, 4)}" | |
| 61 | + | |
| 62 | + | |
| 63 | +def _haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: | |
| 64 | + r = 6371000.0 | |
| 65 | + p1, p2 = math.radians(lat1), math.radians(lat2) | |
| 66 | + dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) | |
| 67 | + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 | |
| 68 | + return 2 * r * math.asin(math.sqrt(a)) | |
| 69 | + | |
| 70 | + | |
| 71 | +def _tile_of(lat: float, lng: float) -> tuple[int, int]: | |
| 72 | + return (math.floor(lat / TILE), math.floor(lng / TILE)) | |
| 73 | + | |
| 74 | + | |
| 75 | +def _build_tile_query(ty: int, tx: int) -> str: | |
| 76 | + """Tous les POI des catégories dans la tuile (bbox élargie de la marge).""" | |
| 77 | + s = ty * TILE - TILE_MARGIN | |
| 78 | + n = (ty + 1) * TILE + TILE_MARGIN | |
| 79 | + w = tx * TILE - TILE_MARGIN | |
| 80 | + e = (tx + 1) * TILE + TILE_MARGIN | |
| 81 | + bbox = f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}" | |
| 82 | + parts = [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES] | |
| 83 | + return f'[out:json][timeout:180];({"".join(parts)});out center tags;' | |
| 84 | + | |
| 85 | + | |
| 86 | +def _match_category(tags: dict) -> str | None: | |
| 87 | + """Retrouve la catégorie Lou-Ka d'un élément OSM retourné.""" | |
| 88 | + shop = tags.get("shop") | |
| 89 | + amenity = tags.get("amenity") | |
| 90 | + leisure = tags.get("leisure") | |
| 91 | + if shop == "supermarket": | |
| 92 | + return "epicerie" | |
| 93 | + if shop == "convenience": | |
| 94 | + return "depanneur" | |
| 95 | + if amenity == "pharmacy": | |
| 96 | + return "pharmacie" | |
| 97 | + if amenity == "school": | |
| 98 | + return "ecole" | |
| 99 | + if amenity in ("kindergarten", "childcare"): | |
| 100 | + return "garderie" | |
| 101 | + if leisure == "park": | |
| 102 | + return "parc" | |
| 103 | + if tags.get("highway") == "bus_stop": | |
| 104 | + return "bus" | |
| 105 | + if tags.get("railway") == "station" and tags.get("station") == "subway": | |
| 106 | + return "metro" | |
| 107 | + if leisure == "fitness_centre": | |
| 108 | + return "gym" | |
| 109 | + if amenity == "cafe": | |
| 110 | + return "cafe" | |
| 111 | + if amenity in ("clinic", "doctors"): | |
| 112 | + return "clinique" | |
| 113 | + if amenity == "hospital": | |
| 114 | + return "hopital" | |
| 115 | + if amenity == "library": | |
| 116 | + return "bibliotheque" | |
| 117 | + return None | |
| 118 | + | |
| 119 | + | |
| 120 | +_RADII = {cat: radius for cat, _l, _s, radius in CATEGORIES} | |
| 121 | + | |
| 122 | + | |
| 123 | +class PoiClient: | |
| 124 | + def __init__(self) -> None: | |
| 125 | + self.session = requests.Session() | |
| 126 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 127 | + self._last = 0.0 | |
| 128 | + self._url_idx = 0 | |
| 129 | + | |
| 130 | + def _post(self, query: str) -> list | None: | |
| 131 | + """POST Overpass avec throttling et rotation de miroir sur erreur.""" | |
| 132 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 133 | + if wait > 0: | |
| 134 | + time.sleep(wait) | |
| 135 | + for essai in range(len(OVERPASS_URLS)): | |
| 136 | + url = OVERPASS_URLS[(self._url_idx + essai) % len(OVERPASS_URLS)] | |
| 137 | + try: | |
| 138 | + resp = self.session.post(url, data={"data": query}, timeout=90) | |
| 139 | + self._last = time.time() | |
| 140 | + resp.raise_for_status() | |
| 141 | + self._url_idx = (self._url_idx + essai) % len(OVERPASS_URLS) | |
| 142 | + return resp.json().get("elements") or [] | |
| 143 | + except Exception: | |
| 144 | + self._last = time.time() | |
| 145 | + continue | |
| 146 | + return None | |
| 147 | + | |
| 148 | + def fetch_tile(self, ty: int, tx: int) -> list[dict] | None: | |
| 149 | + """Tous les POI catégorisés d'une tuile : [{cat, name, lat, lng}].""" | |
| 150 | + elements = self._post(_build_tile_query(ty, tx)) | |
| 151 | + if elements is None: | |
| 152 | + return None | |
| 153 | + pois = [] | |
| 154 | + for el in elements: | |
| 155 | + tags = el.get("tags") or {} | |
| 156 | + cat = _match_category(tags) | |
| 157 | + if cat is None: | |
| 158 | + continue | |
| 159 | + elat = el.get("lat") or (el.get("center") or {}).get("lat") | |
| 160 | + elng = el.get("lon") or (el.get("center") or {}).get("lon") | |
| 161 | + if elat is None or elng is None: | |
| 162 | + continue | |
| 163 | + pois.append({"cat": cat, "name": (tags.get("name") or LABELS[cat])[:60], | |
| 164 | + "lat": elat, "lng": elng}) | |
| 165 | + return pois | |
| 166 | + | |
| 167 | + | |
| 168 | +def _nearest_by_cat(lat: float, lng: float, pois_by_cat: dict[str, list[dict]]) -> list[dict]: | |
| 169 | + """Plus proche POI de chaque catégorie (dans son rayon), trié par distance.""" | |
| 170 | + out = [] | |
| 171 | + for cat, pois in pois_by_cat.items(): | |
| 172 | + radius = _RADII[cat] | |
| 173 | + # préfiltre rectangulaire bon marché avant l'haversine | |
| 174 | + dlat_max = radius / 111000.0 | |
| 175 | + dlng_max = radius / (111000.0 * max(0.2, math.cos(math.radians(lat)))) | |
| 176 | + best = None | |
| 177 | + for p in pois: | |
| 178 | + if abs(p["lat"] - lat) > dlat_max or abs(p["lng"] - lng) > dlng_max: | |
| 179 | + continue | |
| 180 | + d = _haversine_m(lat, lng, p["lat"], p["lng"]) | |
| 181 | + if d <= radius and (best is None or d < best["dist_m"]): | |
| 182 | + best = {"cat": cat, "name": p["name"], "dist_m": round(d)} | |
| 183 | + if best: | |
| 184 | + out.append(best) | |
| 185 | + return sorted(out, key=lambda p: p["dist_m"]) | |
| 186 | + | |
| 187 | + | |
| 188 | +def run(limit: int | None = None) -> dict: | |
| 189 | + """Remplit poi_cache pour les immeubles géolocalisés qui n'y sont pas. | |
| 190 | + | |
| 191 | + `limit` borne le nombre de requêtes Overpass de cette exécution | |
| 192 | + (les entrées déjà en cache ne coûtent rien). | |
| 193 | + """ | |
| 194 | + con = db.connect() | |
| 195 | + client = PoiClient() | |
| 196 | + rows = con.execute( | |
| 197 | + """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings | |
| 198 | + WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall() | |
| 199 | + | |
| 200 | + now = time.time() | |
| 201 | + a_faire: list[tuple[float, float]] = [] | |
| 202 | + skipped = 0 | |
| 203 | + for r in rows: | |
| 204 | + cached = con.execute( | |
| 205 | + "SELECT ts FROM poi_cache WHERE coord_key=?", | |
| 206 | + (f"{r['la']},{r['ln']}",)).fetchone() | |
| 207 | + if cached and now - (cached["ts"] or 0) < REFRESH_AFTER: | |
| 208 | + skipped += 1 | |
| 209 | + else: | |
| 210 | + a_faire.append((r["la"], r["ln"])) | |
| 211 | + if limit is not None: | |
| 212 | + a_faire = a_faire[:limit] | |
| 213 | + | |
| 214 | + # 1) télécharger les POI des tuiles nécessaires (une requête par tuile) | |
| 215 | + tuiles = sorted({_tile_of(la, ln) for la, ln in a_faire}) | |
| 216 | + pois_by_tile: dict[tuple[int, int], dict[str, list[dict]]] = {} | |
| 217 | + tile_errors = [] | |
| 218 | + for t in tuiles: | |
| 219 | + res = client.fetch_tile(*t) | |
| 220 | + if res is None: | |
| 221 | + tile_errors.append(t) | |
| 222 | + else: | |
| 223 | + by_cat: dict[str, list[dict]] = {} | |
| 224 | + for p in res: | |
| 225 | + by_cat.setdefault(p["cat"], []).append(p) | |
| 226 | + pois_by_tile[t] = by_cat | |
| 227 | + | |
| 228 | + # 2) calcul local du plus proche par catégorie pour chaque immeuble | |
| 229 | + done = errors = 0 | |
| 230 | + for la, ln in a_faire: | |
| 231 | + t = _tile_of(la, ln) | |
| 232 | + if t not in pois_by_tile: | |
| 233 | + errors += 1 # tuile en échec : re-tentée au prochain run | |
| 234 | + continue | |
| 235 | + pois = _nearest_by_cat(la, ln, pois_by_tile[t]) | |
| 236 | + con.execute( | |
| 237 | + "INSERT INTO poi_cache (coord_key, lat, lng, pois, ts) VALUES (?,?,?,?,?)" | |
| 238 | + " ON CONFLICT(coord_key) DO UPDATE SET pois=excluded.pois, ts=excluded.ts", | |
| 239 | + (coord_key(la, ln), la, ln, json.dumps(pois, ensure_ascii=False), now)) | |
| 240 | + done += 1 | |
| 241 | + con.commit() | |
| 242 | + | |
| 243 | + con.close() | |
| 244 | + stats = {"fetched": done, "cached": skipped, "errors": errors, | |
| 245 | + "tiles": len(tuiles), "tile_errors": len(tile_errors), | |
| 246 | + "total_coords": len(rows)} | |
| 247 | + print(f"[immo-ka] poi {stats}") | |
| 248 | + return stats | |
added
immoka/quartier.py
+222 −0
@@ -0,0 +1,222 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# quartier.py : statistiques de quartier par annonce (à la Centris, en libre) | |
| 5 | +# Base statique data/quartier.db construite par scripts/build_*.py : | |
| 6 | +# - da_poly / da_stats : aires de diffusion 2021 + profil du recensement | |
| 7 | +# - da_pmd : mesures de proximité StatCan (scores 0..1) | |
| 8 | +# - da_defav : défavorisation matérielle/sociale INSPQ (quintiles) | |
| 9 | +# - heat : classe d'îlot de chaleur/fraîcheur INSPQ par immeuble | |
| 10 | +# - crime_mtl / igc : actes criminels SPVM (points) + indice de gravité | |
| 11 | +# Jointure : lat/lng -> DAUID par point-dans-polygone local (préfiltre bbox), | |
| 12 | +# mémorisée dans listings.dauid à l'enrichissement (boucle watch). | |
| 13 | +# ----------------------------------------------------------------------------- | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +import json | |
| 17 | +import math | |
| 18 | +import sqlite3 | |
| 19 | +import time | |
| 20 | +from pathlib import Path | |
| 21 | + | |
| 22 | +from . import db | |
| 23 | + | |
| 24 | +QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db" | |
| 25 | + | |
| 26 | +# villes couvertes par les points SPVM (agglomération de Montréal) | |
| 27 | +_VILLES_SPVM = {"montreal", "montreal-est", "montreal-ouest", "westmount", | |
| 28 | + "cote saint-luc", "cote-saint-luc", "hampstead", "mont-royal", | |
| 29 | + "outremont", "verdun", "lasalle", "lachine", "anjou", | |
| 30 | + "saint-leonard", "saint-laurent", "ahuntsic", "dorval", | |
| 31 | + "pointe-claire", "kirkland", "beaconsfield", "dollard-des-ormeaux"} | |
| 32 | + | |
| 33 | +# correspondance ville -> fragment du nom de service dans la table igc | |
| 34 | +_IGC_SERVICE = { | |
| 35 | + "quebec": "SPVQ", "levis": "Lévis", "montreal": "SPVM", | |
| 36 | + "laval": "Laval", "longueuil": "Longueuil", | |
| 37 | +} | |
| 38 | + | |
| 39 | + | |
| 40 | +def disponible() -> bool: | |
| 41 | + return QUARTIER_DB.exists() | |
| 42 | + | |
| 43 | + | |
| 44 | +def _connect() -> sqlite3.Connection: | |
| 45 | + con = sqlite3.connect(f"file:{QUARTIER_DB}?mode=ro", uri=True) | |
| 46 | + con.row_factory = sqlite3.Row | |
| 47 | + return con | |
| 48 | + | |
| 49 | + | |
| 50 | +# --------------------------------------------------------------------------- | |
| 51 | +# lat/lng -> DAUID (point dans polygone, préfiltre bbox) | |
| 52 | +# --------------------------------------------------------------------------- | |
| 53 | + | |
| 54 | +def _dans_anneau(lat: float, lng: float, anneau: list) -> bool: | |
| 55 | + """Lancer de rayon (even-odd). anneau = [[lng, lat], ...].""" | |
| 56 | + dedans = False | |
| 57 | + n = len(anneau) | |
| 58 | + j = n - 1 | |
| 59 | + for i in range(n): | |
| 60 | + xi, yi = anneau[i][0], anneau[i][1] | |
| 61 | + xj, yj = anneau[j][0], anneau[j][1] | |
| 62 | + if (yi > lat) != (yj > lat) and \ | |
| 63 | + lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi: | |
| 64 | + dedans = not dedans | |
| 65 | + j = i | |
| 66 | + return dedans | |
| 67 | + | |
| 68 | + | |
| 69 | +def dauid_for(qcon: sqlite3.Connection, lat: float, lng: float) -> str | None: | |
| 70 | + rows = qcon.execute( | |
| 71 | + "SELECT dauid, poly FROM da_poly WHERE lat_min<=? AND lat_max>=?" | |
| 72 | + " AND lng_min<=? AND lng_max>=?", (lat, lat, lng, lng)).fetchall() | |
| 73 | + for r in rows: | |
| 74 | + anneaux = json.loads(r["poly"]) | |
| 75 | + # even-odd sur tous les anneaux (les trous annulent) | |
| 76 | + compte = sum(1 for a in anneaux if _dans_anneau(lat, lng, a)) | |
| 77 | + if compte % 2 == 1: | |
| 78 | + return r["dauid"] | |
| 79 | + return None | |
| 80 | + | |
| 81 | + | |
| 82 | +# --------------------------------------------------------------------------- | |
| 83 | +# Assemblage pour la fiche | |
| 84 | +# --------------------------------------------------------------------------- | |
| 85 | + | |
| 86 | +def _cle_ville(city: str) -> str: | |
| 87 | + import unicodedata | |
| 88 | + s = "".join(c for c in unicodedata.normalize("NFD", city or "") | |
| 89 | + if unicodedata.category(c) != "Mn") | |
| 90 | + return s.strip().lower() | |
| 91 | + | |
| 92 | + | |
| 93 | +def _crime_mtl(qcon: sqlite3.Connection, lat: float, lng: float) -> dict | None: | |
| 94 | + """Comptage des actes criminels SPVM à < 500 m : 12 mois vs 12 précédents.""" | |
| 95 | + dlat = 500 / 111000.0 | |
| 96 | + dlng = 500 / (111000.0 * max(0.2, math.cos(math.radians(lat)))) | |
| 97 | + now = time.time() | |
| 98 | + rows = qcon.execute( | |
| 99 | + "SELECT lat, lng, ts FROM crime_mtl WHERE lat BETWEEN ? AND ?" | |
| 100 | + " AND lng BETWEEN ? AND ? AND ts >= ?", | |
| 101 | + (lat - dlat, lat + dlat, lng - dlng, lng + dlng, now - 730 * 86400)).fetchall() | |
| 102 | + recent = avant = 0 | |
| 103 | + for r in rows: | |
| 104 | + # distance exacte (le bbox est un carré) | |
| 105 | + d = math.hypot((r["lat"] - lat) * 111000.0, | |
| 106 | + (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat))) | |
| 107 | + if d > 500: | |
| 108 | + continue | |
| 109 | + if r["ts"] >= now - 365 * 86400: | |
| 110 | + recent += 1 | |
| 111 | + else: | |
| 112 | + avant += 1 | |
| 113 | + if recent == 0 and avant == 0: | |
| 114 | + return None | |
| 115 | + return {"type": "points", "rayon_m": 500, "douze_mois": recent, | |
| 116 | + "douze_mois_precedents": avant} | |
| 117 | + | |
| 118 | + | |
| 119 | +def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None: | |
| 120 | + service = _IGC_SERVICE.get(_cle_ville(city)) | |
| 121 | + if not service: | |
| 122 | + return None | |
| 123 | + row = qcon.execute( | |
| 124 | + "SELECT annee, indice FROM igc WHERE service LIKE '%' || ? || '%'" | |
| 125 | + " ORDER BY annee DESC LIMIT 1", (service,)).fetchone() | |
| 126 | + if row is None or row["indice"] is None: | |
| 127 | + return None | |
| 128 | + ref = qcon.execute( | |
| 129 | + "SELECT indice FROM igc WHERE service LIKE '%canada%' AND annee=?", | |
| 130 | + (row["annee"],)).fetchone() | |
| 131 | + return {"type": "igc", "ville": city, "annee": row["annee"], | |
| 132 | + "indice": round(row["indice"], 1), | |
| 133 | + "indice_canada": round(ref["indice"], 1) if ref and ref["indice"] else None} | |
| 134 | + | |
| 135 | + | |
| 136 | +def fiche_quartier(lat: float | None, lng: float | None, city: str, | |
| 137 | + dauid: str | None = None) -> dict | None: | |
| 138 | + """Bloc « Le quartier » d'une fiche. None si données indisponibles.""" | |
| 139 | + if not disponible() or lat is None or lng is None: | |
| 140 | + return None | |
| 141 | + qcon = _connect() | |
| 142 | + try: | |
| 143 | + if not dauid: | |
| 144 | + dauid = dauid_for(qcon, lat, lng) | |
| 145 | + out: dict = {"dauid": dauid} | |
| 146 | + | |
| 147 | + if dauid: | |
| 148 | + r = qcon.execute("SELECT * FROM da_stats WHERE dauid=?", (dauid,)).fetchone() | |
| 149 | + if r: | |
| 150 | + out["demographie"] = {k: r[k] for k in | |
| 151 | + ("population", "densite", "age_median", | |
| 152 | + "revenu_median", "pct_locataires", | |
| 153 | + "loyer_moyen", "pct_francais", "pct_univ")} | |
| 154 | + # rangs centiles québécois (0-100) — voir scripts/merge_quartier.py | |
| 155 | + r = qcon.execute("SELECT * FROM da_pmd_pct WHERE dauid=?", (dauid,)).fetchone() | |
| 156 | + if r: | |
| 157 | + out["proximite"] = {k: r[k] / 100.0 for k in r.keys() | |
| 158 | + if k != "dauid" and r[k] is not None} | |
| 159 | + r = qcon.execute("SELECT quintile_materiel, quintile_social FROM da_defav" | |
| 160 | + " WHERE dauid=?", (dauid,)).fetchone() | |
| 161 | + if r: | |
| 162 | + out["defavorisation"] = dict(r) | |
| 163 | + | |
| 164 | + # îlot de chaleur : coordonnée exacte, sinon la plus proche (~120 m) | |
| 165 | + key = f"{round(lat, 4)},{round(lng, 4)}" | |
| 166 | + r = qcon.execute("SELECT classe, ecart FROM heat WHERE coord_key=?", | |
| 167 | + (key,)).fetchone() | |
| 168 | + if r is None: | |
| 169 | + r = qcon.execute( | |
| 170 | + "SELECT classe, ecart FROM heat WHERE coord_key LIKE ?" | |
| 171 | + " AND classe IS NOT NULL LIMIT 1", | |
| 172 | + (f"{round(lat, 3)}%",)).fetchone() | |
| 173 | + if r and r["classe"] is not None: | |
| 174 | + out["chaleur"] = {"classe": r["classe"], "ecart": r["ecart"]} | |
| 175 | + | |
| 176 | + # criminalité : points SPVM sur l'île, indice IGC ailleurs | |
| 177 | + crime = None | |
| 178 | + if _cle_ville(city) in _VILLES_SPVM: | |
| 179 | + crime = _crime_mtl(qcon, lat, lng) | |
| 180 | + if crime is None: | |
| 181 | + crime = _crime_igc(qcon, city) | |
| 182 | + if crime: | |
| 183 | + out["crime"] = crime | |
| 184 | + | |
| 185 | + return out if len(out) > 1 else None | |
| 186 | + except sqlite3.Error: | |
| 187 | + return None | |
| 188 | + finally: | |
| 189 | + qcon.close() | |
| 190 | + | |
| 191 | + | |
| 192 | +# --------------------------------------------------------------------------- | |
| 193 | +# Enrichissement : mémoriser le DAUID de chaque annonce (boucle watch) | |
| 194 | +# --------------------------------------------------------------------------- | |
| 195 | + | |
| 196 | +def enrich(limit: int | None = None) -> dict: | |
| 197 | + """Remplit listings.dauid pour les annonces géolocalisées qui ne l'ont pas.""" | |
| 198 | + if not disponible(): | |
| 199 | + print("[immo-ka] quartier: data/quartier.db absent — étape sautée") | |
| 200 | + return {"enriched": 0, "missing_db": True} | |
| 201 | + con = db.connect() | |
| 202 | + qcon = _connect() | |
| 203 | + rows = con.execute( | |
| 204 | + "SELECT uid, lat, lng FROM listings WHERE active=1 AND lat IS NOT NULL" | |
| 205 | + " AND (dauid IS NULL OR dauid='')").fetchall() | |
| 206 | + if limit is not None: | |
| 207 | + rows = rows[:limit] | |
| 208 | + done = introuvable = 0 | |
| 209 | + for r in rows: | |
| 210 | + d = dauid_for(qcon, r["lat"], r["lng"]) | |
| 211 | + con.execute("UPDATE listings SET dauid=? WHERE uid=?", | |
| 212 | + (d or "hors-zone", r["uid"])) | |
| 213 | + if d: | |
| 214 | + done += 1 | |
| 215 | + else: | |
| 216 | + introuvable += 1 | |
| 217 | + con.commit() | |
| 218 | + qcon.close() | |
| 219 | + con.close() | |
| 220 | + stats = {"enriched": done, "hors_zone": introuvable, "candidats": len(rows)} | |
| 221 | + print(f"[immo-ka] quartier {stats}") | |
| 222 | + return stats | |
modified
immoka/schema.py
+7 −0
@@ -58,6 +58,9 @@ class PropertyListing: | ||
| 58 | 58 | status: str = "a-vendre" # a-vendre | vendu | conditionnel |
| 59 | 59 | broker_name: str = "" # courtier inscripteur |
| 60 | 60 | broker_phone: str = "" |
| 61 | + agency: str = "" # sous-agence / bureau (ex. « Royal LePage Altitude », | |
| 62 | + # « Groupe Sutton - Synergie ») — affichage des | |
| 63 | + # Sources par sous-agence | |
| 61 | 64 | description: str = "" |
| 62 | 65 | features: list[str] = field(default_factory=list) # caractéristiques (texte source) |
| 63 | 66 | details: dict = field(default_factory=dict) # champs structurés (JSON) |
@@ -104,6 +107,10 @@ class PropertyListing: | ||
| 104 | 107 | self.lot_sqft = parse_lot_sqft(texte) |
| 105 | 108 | self.year_built = parse_int(self.year_built) |
| 106 | 109 | |
| 110 | + # sous-agence : à défaut, on retombe sur le courtier/agence inscripteur | |
| 111 | + if not self.agency: | |
| 112 | + self.agency = self.broker_name | |
| 113 | + | |
| 107 | 114 | # coordonnées fournies par la source : rejeter tout point hors de la |
| 108 | 115 | # province (lat/lng inversés, 0/0, coquilles) |
| 109 | 116 | if self.lat is not None and self.lng is not None: |
added
immoka/vraiprix.py
+121 −0
@@ -0,0 +1,121 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# vraiprix.py : estimation de valeur marchande via Vrai-Prix (www.vrai-prix.com) | |
| 5 | +# Pour chaque annonce : /api/search?q=<adresse> → id de l'unité d'évaluation, | |
| 6 | +# puis /api/estimate?id → valeur estimée, fourchette P10-P90, indice de | |
| 7 | +# confiance. Repli : POST /api/estimate (municipalité + type + superficie). | |
| 8 | +# Résultat stocké dans listings.vraiprix (JSON) + lien vers /estimation/{id}. | |
| 9 | +# ----------------------------------------------------------------------------- | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import json | |
| 13 | +import time | |
| 14 | +import unicodedata | |
| 15 | + | |
| 16 | +import requests | |
| 17 | + | |
| 18 | +from . import db | |
| 19 | + | |
| 20 | +BASE = "https://www.vrai-prix.com" | |
| 21 | +UA = "ImmoKaBot/1.0 (+https://www.immo-ka.com; contact@spboucher.ai)" | |
| 22 | +DELAY = 0.25 # politesse | |
| 23 | + | |
| 24 | +# type Immo-Ka -> typeProp Vrai-Prix (repli manuel) | |
| 25 | +_TYPE_MAP = { | |
| 26 | + "Maison": "unifamilial", "Condo": "condo", "Duplex": "plex", | |
| 27 | + "Triplex": "plex", "Multiplex": "plex", "Jumelé": "unifamilial", | |
| 28 | + "Maison de ville": "unifamilial", "Chalet": "unifamilial", | |
| 29 | + "Fermette/Agricole": "unifamilial", "Maison mobile": "unifamilial", | |
| 30 | +} | |
| 31 | + | |
| 32 | + | |
| 33 | +def _norm(s: str) -> str: | |
| 34 | + return "".join(c for c in unicodedata.normalize("NFD", (s or "").lower()) | |
| 35 | + if unicodedata.category(c) != "Mn").strip() | |
| 36 | + | |
| 37 | + | |
| 38 | +def _session() -> requests.Session: | |
| 39 | + s = requests.Session() | |
| 40 | + s.headers["User-Agent"] = UA | |
| 41 | + return s | |
| 42 | + | |
| 43 | + | |
| 44 | +def _search_id(s: requests.Session, address: str, city: str) -> str | None: | |
| 45 | + """Retourne l'id de l'unité Vrai-Prix la mieux appariée à l'adresse.""" | |
| 46 | + q = f"{address}, {city}".strip(", ") | |
| 47 | + if len(q) < 4: | |
| 48 | + return None | |
| 49 | + try: | |
| 50 | + r = s.get(f"{BASE}/api/search", params={"q": q}, timeout=20) | |
| 51 | + results = r.json().get("results", []) | |
| 52 | + except Exception: | |
| 53 | + return None | |
| 54 | + if not results: | |
| 55 | + return None | |
| 56 | + ncity = _norm(city) | |
| 57 | + # préférer une correspondance de municipalité, sinon le 1er | |
| 58 | + for res in results: | |
| 59 | + if ncity and _norm(res.get("municipalite", "")) == ncity: | |
| 60 | + return res.get("id") | |
| 61 | + return results[0].get("id") | |
| 62 | + | |
| 63 | + | |
| 64 | +def _estimate_by_id(s: requests.Session, uid: str) -> dict | None: | |
| 65 | + try: | |
| 66 | + r = s.get(f"{BASE}/api/estimate", params={"id": uid}, timeout=25) | |
| 67 | + return r.json() | |
| 68 | + except Exception: | |
| 69 | + return None | |
| 70 | + | |
| 71 | + | |
| 72 | +def _pack(unit_id: str, result: dict) -> dict: | |
| 73 | + """Champs conservés pour l'affichage sur la fiche Immo-Ka.""" | |
| 74 | + return { | |
| 75 | + "id": unit_id, | |
| 76 | + "value": result.get("estimate"), | |
| 77 | + "low": result.get("low"), | |
| 78 | + "high": result.get("high"), | |
| 79 | + "confidence_pct": result.get("confidencePct"), | |
| 80 | + "confidence": result.get("confidenceLevel"), | |
| 81 | + "url": f"{BASE}/estimation/{unit_id}", | |
| 82 | + } | |
| 83 | + | |
| 84 | + | |
| 85 | +def estimate_for(s: requests.Session, address: str, city: str) -> dict | None: | |
| 86 | + uid = _search_id(s, address, city) | |
| 87 | + if not uid: | |
| 88 | + return None | |
| 89 | + data = _estimate_by_id(s, uid) | |
| 90 | + if not data or not isinstance(data.get("result"), dict): | |
| 91 | + return None | |
| 92 | + return _pack(uid, data["result"]) | |
| 93 | + | |
| 94 | + | |
| 95 | +def run(limit: int | None = None) -> dict: | |
| 96 | + """Enrichit les annonces sans estimation Vrai-Prix (adresse requise).""" | |
| 97 | + con = db.connect() | |
| 98 | + s = _session() | |
| 99 | + rows = con.execute( | |
| 100 | + "SELECT uid, address, city FROM listings" | |
| 101 | + " WHERE active=1 AND vraiprix IS NULL AND address<>''" | |
| 102 | + " ORDER BY first_seen DESC").fetchall() | |
| 103 | + done = miss = 0 | |
| 104 | + for r in rows: | |
| 105 | + if limit is not None and done + miss >= limit: | |
| 106 | + break | |
| 107 | + est = estimate_for(s, r["address"], r["city"]) | |
| 108 | + # on marque toujours (même un échec) pour ne pas re-sonder sans cesse ; | |
| 109 | + # « {} » = tenté sans correspondance. | |
| 110 | + con.execute("UPDATE listings SET vraiprix=? WHERE uid=?", | |
| 111 | + (json.dumps(est or {}, ensure_ascii=False), r["uid"])) | |
| 112 | + con.commit() | |
| 113 | + if est: | |
| 114 | + done += 1 | |
| 115 | + else: | |
| 116 | + miss += 1 | |
| 117 | + time.sleep(DELAY) | |
| 118 | + con.close() | |
| 119 | + out = {"estimated": done, "no_match": miss} | |
| 120 | + print(f"[immo-ka] vraiprix: {out}") | |
| 121 | + return out | |
modified
immoka/web.py
+79 −7
@@ -36,13 +36,11 @@ _sync_lock = threading.Lock() | ||
| 36 | 36 | # (le central enrichi d'abord, puis la sous-agence au plus petit uid). Les fiches |
| 37 | 37 | # du central et des autres agences ne sont jamais masquées. → aucun double-comptage, |
| 38 | 38 | # et les sous-agences prennent le relais automatiquement si le central disparaît. |
| 39 | −DEDUP_CLAUSE = ( | |
| 40 | − " AND NOT EXISTS (SELECT 1 FROM listings d WHERE d.active=1" | |
| 41 | − " AND d.external_id = listings.external_id AND d.uid <> listings.uid" | |
| 42 | − " AND (listings.source LIKE '%\\_ag\\_%' ESCAPE '\\')" | |
| 43 | − " AND ((d.source NOT LIKE '%\\_ag\\_%' ESCAPE '\\')" | |
| 44 | − " OR (d.source LIKE '%\\_ag\\_%' ESCAPE '\\' AND d.uid < listings.uid)))" | |
| 45 | −) | |
| 39 | +# Déduplication PRÉ-CALCULÉE : la colonne `dup_hidden` (remplie par | |
| 40 | +# db.refresh_dedup après chaque sync) marque les doublons de sous-agences. La | |
| 41 | +# lecture est ainsi instantanée (index) au lieu d'un sous-select corrélé par | |
| 42 | +# ligne (~300 s sur 75 k lignes). Voir db.refresh_dedup pour la règle. | |
| 43 | +DEDUP_CLAUSE = " AND dup_hidden=0" | |
| 46 | 44 | |
| 47 | 45 | |
| 48 | 46 | def _row_to_dict(row) -> dict: |
@@ -50,6 +48,11 @@ def _row_to_dict(row) -> dict: | ||
| 50 | 48 | d["features"] = json.loads(d.get("features") or "[]") |
| 51 | 49 | d["images"] = json.loads(d.get("images") or "[]") |
| 52 | 50 | d["details"] = json.loads(d.get("details") or "{}") |
| 51 | + if d.get("vraiprix"): | |
| 52 | + try: | |
| 53 | + d["vraiprix"] = json.loads(d["vraiprix"]) or None | |
| 54 | + except (ValueError, TypeError): | |
| 55 | + d["vraiprix"] = None | |
| 53 | 56 | return d |
| 54 | 57 | |
| 55 | 58 | |
@@ -124,6 +127,20 @@ def get_listing(uid: str): | ||
| 124 | 127 | d["price_history"] = [dict(r) for r in con.execute( |
| 125 | 128 | "SELECT ts, price FROM price_log WHERE uid=? ORDER BY ts DESC LIMIT 10", |
| 126 | 129 | (uid,)).fetchall()] |
| 130 | + # commodités de proximité (cache par immeuble, voir immoka/poi.py) | |
| 131 | + if d.get("lat") is not None and d.get("lng") is not None: | |
| 132 | + key = f"{round(d['lat'], 4)},{round(d['lng'], 4)}" | |
| 133 | + poi_row = con.execute( | |
| 134 | + "SELECT pois FROM poi_cache WHERE coord_key=?", (key,)).fetchone() | |
| 135 | + d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] | |
| 136 | + else: | |
| 137 | + d["poi"] = [] | |
| 138 | + # statistiques de quartier (recensement, proximité, chaleur, criminalité) | |
| 139 | + from . import quartier | |
| 140 | + dauid = d.get("dauid") | |
| 141 | + d["quartier"] = quartier.fiche_quartier( | |
| 142 | + d.get("lat"), d.get("lng"), d.get("city") or "", | |
| 143 | + dauid if dauid and dauid != "hors-zone" else None) | |
| 127 | 144 | con.close() |
| 128 | 145 | if d is None: |
| 129 | 146 | raise HTTPException(404, "Propriété introuvable") |
@@ -217,6 +234,61 @@ def sources(): | ||
| 217 | 234 | return {"sources": registry} |
| 218 | 235 | |
| 219 | 236 | |
| 237 | +# Rattachement d'une source à sa bannière (franchise) pour le regroupement. | |
| 238 | +_FRANCHISES = [ | |
| 239 | + ("RE/MAX", lambda s: s == "remax_quebec" or s.startswith("remax_ag_")), | |
| 240 | + ("Via Capitale", lambda s: s == "via_capitale" or s.startswith("via_ag_")), | |
| 241 | + ("Century 21", lambda s: s == "century21" or s.startswith("c21_ag_")), | |
| 242 | + ("Royal LePage", lambda s: s == "royal_lepage"), | |
| 243 | + ("Groupe Sutton", lambda s: s == "sutton"), | |
| 244 | + ("Keller Williams", lambda s: s.startswith("kw_")), | |
| 245 | + ("DuProprio", lambda s: s == "duproprio"), | |
| 246 | +] | |
| 247 | + | |
| 248 | + | |
| 249 | +def _franchise_of(source: str, source_names: dict) -> str: | |
| 250 | + for name, match in _FRANCHISES: | |
| 251 | + if match(source): | |
| 252 | + return name | |
| 253 | + return source_names.get(source, source) # agence indépendante = elle-même | |
| 254 | + | |
| 255 | + | |
| 256 | +@app.get("/api/agencies") | |
| 257 | +def agencies(): | |
| 258 | + """Arbre bannière → sous-agences (bureaux) avec le nombre d'inscriptions. | |
| 259 | + | |
| 260 | + Alimente la page « Sources » de l'app : chaque bannière est éclatée par | |
| 261 | + sous-agence via le champ `agency` (bureau). Dédupliqué (n° Centris).""" | |
| 262 | + con = db.connect() | |
| 263 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | |
| 264 | + source_names = {s["id"]: s["name"] for s in registry} | |
| 265 | + rows = con.execute( | |
| 266 | + "SELECT source, COALESCE(NULLIF(agency,''), '') agency, COUNT(*) n" | |
| 267 | + " FROM listings WHERE active=1" + DEDUP_CLAUSE + | |
| 268 | + " GROUP BY source, agency").fetchall() | |
| 269 | + con.close() | |
| 270 | + tree: dict[str, dict] = {} | |
| 271 | + for r in rows: | |
| 272 | + fr = _franchise_of(r["source"], source_names) | |
| 273 | + node = tree.setdefault(fr, {"franchise": fr, "total": 0, "agencies": {}}) | |
| 274 | + node["total"] += r["n"] | |
| 275 | + # nom de sous-agence : le bureau (agency) sinon le nom de la source | |
| 276 | + label = r["agency"] or source_names.get(r["source"], r["source"]) | |
| 277 | + a = node["agencies"].setdefault(label, {"name": label, "count": 0, | |
| 278 | + "sources": set()}) | |
| 279 | + a["count"] += r["n"] | |
| 280 | + a["sources"].add(r["source"]) | |
| 281 | + out = [] | |
| 282 | + for node in tree.values(): | |
| 283 | + ags = sorted(node["agencies"].values(), key=lambda x: -x["count"]) | |
| 284 | + for a in ags: | |
| 285 | + a["sources"] = sorted(a["sources"]) | |
| 286 | + out.append({"franchise": node["franchise"], "total": node["total"], | |
| 287 | + "sub_agencies": len(ags), "agencies": ags}) | |
| 288 | + out.sort(key=lambda x: -x["total"]) | |
| 289 | + return {"franchises": out} | |
| 290 | + | |
| 291 | + | |
| 220 | 292 | @app.get("/api/stats") |
| 221 | 293 | def stats(): |
| 222 | 294 | con = db.connect() |
modified
run.py
+15 −0
@@ -9,6 +9,9 @@ | ||
| 9 | 9 | python run.py watch [minutes] # synchronise en boucle (défaut 60 min) |
| 10 | 10 | python run.py serve [port] # démarre l'API + le frontend (défaut 8090) |
| 11 | 11 | python run.py list # liste les connecteurs enregistrés |
| 12 | + python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes) | |
| 13 | + python run.py poi [n] # commodités de proximité par immeuble (max n requêtes) | |
| 14 | + python run.py quartier [n] # rattache chaque annonce à son aire de diffusion (DAUID) | |
| 12 | 15 | """ |
| 13 | 16 | from __future__ import annotations |
| 14 | 17 | |
@@ -40,6 +43,18 @@ def main() -> None: | ||
| 40 | 43 | for sid in sorted(CONNECTORS): |
| 41 | 44 | print(sid) |
| 42 | 45 | print(f"-- {len(CONNECTORS)} connecteur(s)") |
| 46 | + elif cmd == "geocode": | |
| 47 | + from immoka import geocode | |
| 48 | + geocode.run(int(sys.argv[2]) if len(sys.argv) > 2 else None) | |
| 49 | + elif cmd == "poi": | |
| 50 | + from immoka import poi | |
| 51 | + poi.run(int(sys.argv[2]) if len(sys.argv) > 2 else None) | |
| 52 | + elif cmd == "quartier": | |
| 53 | + from immoka import quartier | |
| 54 | + quartier.enrich(int(sys.argv[2]) if len(sys.argv) > 2 else None) | |
| 55 | + elif cmd == "vraiprix": | |
| 56 | + from immoka import vraiprix | |
| 57 | + vraiprix.run(int(sys.argv[2]) if len(sys.argv) > 2 else None) | |
| 43 | 58 | elif cmd == "serve": |
| 44 | 59 | import uvicorn |
| 45 | 60 | port = int(sys.argv[2]) if len(sys.argv) > 2 else 8090 |
| 46 | 61 | |