Registre des loyers : extraction pancanadienne (57 433 déclarations géocodées) + bloc fiche
- louka/rdl.py : balayage par boîtes de l API publique de registre-des-loyers.ca (subdivision récursive sur erreur ou volume suspect), base séparée data/rdl.db, requête de proximité (bbox + haversine) avec médianes globale/récente/par chambres - run.py rdl + GET /api/rdl?lat&lng&radius - fiche : bloc « Registre des loyers » sous l analyse de prix — médiane du secteur, écart du loyer demandé vs les déclarations du même nombre de chambres, 8 déclarations les plus proches avec la date de la valeur (année du loyer, mois si le bail débute la même année), crédit Vivre en ville Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 changed files +398 −0
modified
.gitignore
+1 −0
@@ -27,3 +27,4 @@ data/louka_ct.db | ||
| 27 | 27 | data/louka_ct.db-shm |
| 28 | 28 | data/louka_ct.db-wal |
| 29 | 29 | frontend/tsconfig.tsbuildinfo |
| 30 | +data/rdl.db | |
modified
frontend/src/api.ts
+18 −0
@@ -93,6 +93,7 @@ export interface Listing { | ||
| 93 | 93 | images: string[]; |
| 94 | 94 | lat: number | null; |
| 95 | 95 | lng: number | null; |
| 96 | + bedrooms: number | null; | |
| 96 | 97 | poi?: Poi[]; // commodités de proximité (fiche seulement) |
| 97 | 98 | quartier?: Quartier | null; // stats de quartier (fiche seulement) |
| 98 | 99 | digest?: Digest | null; // description structurée (fiche seulement) |
@@ -204,6 +205,23 @@ export interface FairValueDetail { | ||
| 204 | 205 | export const fetchFairValue = (uid: string) => |
| 205 | 206 | get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`); |
| 206 | 207 | |
| 208 | +export interface RdlItem { | |
| 209 | + address: string; city: string | null; price: number; rooms: number | null; | |
| 210 | + year: number | null; date: string | null; dist_m: number; | |
| 211 | + heating: boolean; furnished: boolean; | |
| 212 | +} | |
| 213 | + | |
| 214 | +export interface RdlNearby { | |
| 215 | + n: number; radius_m: number; median?: number; | |
| 216 | + median_recent?: number | null; n_recent?: number; | |
| 217 | + by_rooms?: Record<string, { n: number; median: number }>; | |
| 218 | + items: RdlItem[]; | |
| 219 | +} | |
| 220 | + | |
| 221 | +/** Loyers déclarés au Registre des loyers autour d'un point (fiche). */ | |
| 222 | +export const fetchRdl = (lat: number, lng: number, radius = 600) => | |
| 223 | + get<RdlNearby>(`/api/rdl?lat=${lat}&lng=${lng}&radius=${radius}`); | |
| 224 | + | |
| 207 | 225 | /** 250 -> « 250 m », 1240 -> « 1,2 km » */ |
| 208 | 226 | export const fmtDist = (m: number): string => |
| 209 | 227 | m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; |
added
frontend/src/components/RegistreLoyers.tsx
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +// ----------------------------------------------------------------------------- | |
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +// components/RegistreLoyers.tsx : bloc « Registre des loyers » (fiche) | |
| 5 | +// Loyers réellement payés, déclarés volontairement par des locataires au | |
| 6 | +// Registre des loyers (registre-des-loyers.ca, initiative de Vivre en | |
| 7 | +// ville) — autour de l'adresse de l'annonce : médiane du secteur, | |
| 8 | +// comparaison avec le loyer demandé et déclarations les plus proches. | |
| 9 | +// ----------------------------------------------------------------------------- | |
| 10 | +import { useEffect, useState } from "react"; | |
| 11 | +import { fetchRdl, fmtDist, fmtPrice, RdlNearby } from "../api"; | |
| 12 | + | |
| 13 | +/** Date de la valeur : l'année du loyer déclaré ; précision au mois quand le | |
| 14 | + * début de bail tombe dans cette même année (sinon le bail peut être bien | |
| 15 | + * antérieur au loyer déclaré et la date induirait en erreur). */ | |
| 16 | +function fmtDate(date: string | null | undefined, | |
| 17 | + year: number | null | undefined): string { | |
| 18 | + if (date && year != null && date.startsWith(String(year))) { | |
| 19 | + const d = new Date(date + "T00:00:00"); | |
| 20 | + if (!Number.isNaN(d.getTime())) | |
| 21 | + return d.toLocaleDateString("fr-CA", { month: "short", year: "numeric" }); | |
| 22 | + } | |
| 23 | + return year != null ? String(year) : (date ? date.slice(0, 4) : "—"); | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** -8 % -> « 8 % sous », +12 % -> « 12 % au-dessus » */ | |
| 27 | +function ecart(price: number, ref: number): string | null { | |
| 28 | + if (!ref) return null; | |
| 29 | + const pct = Math.round(((price - ref) / ref) * 100); | |
| 30 | + if (Math.abs(pct) < 3) return "dans la moyenne des loyers déclarés"; | |
| 31 | + return pct > 0 | |
| 32 | + ? `${pct} % au-dessus des loyers déclarés du secteur` | |
| 33 | + : `${-pct} % sous les loyers déclarés du secteur`; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** Variante avec le nombre de chambres explicité (comparaison plus juste). */ | |
| 37 | +function ecartRooms(price: number | null, ref: number, rooms: string, | |
| 38 | + n: number): string | null { | |
| 39 | + if (price == null || !ref) return null; | |
| 40 | + const pct = Math.round(((price - ref) / ref) * 100); | |
| 41 | + const base = `${n} loyers déclarés de ${rooms} ch. du secteur`; | |
| 42 | + if (Math.abs(pct) < 3) return `dans la moyenne des ${base}`; | |
| 43 | + return pct > 0 ? `${pct} % au-dessus des ${base}` | |
| 44 | + : `${-pct} % sous les ${base}`; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export default function RegistreLoyers({ lat, lng, price, bedrooms }: | |
| 48 | + { lat: number | null; lng: number | null; price: number | null; | |
| 49 | + bedrooms?: number | null }) { | |
| 50 | + const [d, setD] = useState<RdlNearby | null>(null); | |
| 51 | + useEffect(() => { | |
| 52 | + setD(null); | |
| 53 | + if (lat == null || lng == null) return; | |
| 54 | + fetchRdl(lat, lng).then(setD).catch(() => setD(null)); | |
| 55 | + }, [lat, lng]); | |
| 56 | + if (lat == null || lng == null || !d || d.n === 0) return null; | |
| 57 | + | |
| 58 | + // Référence de comparaison : la médiane du même nombre de chambres quand | |
| 59 | + // le secteur en compte assez (≥ 5 déclarations), sinon la médiane globale. | |
| 60 | + const rKey = bedrooms != null ? String(Math.round(bedrooms)) : null; | |
| 61 | + const sameRooms = rKey && d.by_rooms?.[rKey] && d.by_rooms[rKey].n >= 5 | |
| 62 | + ? d.by_rooms[rKey] : null; | |
| 63 | + const globalRef = d.median_recent ?? d.median ?? 0; | |
| 64 | + const cmp = sameRooms | |
| 65 | + ? ecartRooms(price, sameRooms.median, rKey!, sameRooms.n) | |
| 66 | + : price != null ? ecart(price, globalRef) : null; | |
| 67 | + return ( | |
| 68 | + <section className="f-bloc f-rdl" id="registre-loyers"> | |
| 69 | + <h2>Registre des loyers</h2> | |
| 70 | + <p className="rdl-resume"> | |
| 71 | + <b>{d.n.toLocaleString("fr-CA")}</b> loyer{d.n > 1 ? "s" : ""} déclaré | |
| 72 | + {d.n > 1 ? "s" : ""} par des locataires à moins de {fmtDist(d.radius_m)} | |
| 73 | + {" — médiane "} | |
| 74 | + <b>{fmtPrice(globalRef)}</b> <small>/ mois</small> | |
| 75 | + {d.median_recent != null && d.n_recent | |
| 76 | + ? ` (${d.n_recent.toLocaleString("fr-CA")} déclarations depuis 2023)` | |
| 77 | + : ""} | |
| 78 | + </p> | |
| 79 | + {cmp && <p className="rdl-ecart">Ce loyer est <b>{cmp}</b>.</p>} | |
| 80 | + {d.by_rooms && Object.keys(d.by_rooms).length > 1 && ( | |
| 81 | + <div className="rdl-rooms"> | |
| 82 | + {Object.entries(d.by_rooms) | |
| 83 | + .sort(([a], [b]) => Number(a) - Number(b)) | |
| 84 | + .map(([rooms, v]) => ( | |
| 85 | + <span key={rooms} | |
| 86 | + className={"rdl-room" + (rooms === rKey ? " rdl-room-on" : "")}> | |
| 87 | + {rooms} ch. : <b>{fmtPrice(v.median)}</b> <i>({v.n})</i> | |
| 88 | + </span> | |
| 89 | + ))} | |
| 90 | + </div> | |
| 91 | + )} | |
| 92 | + {d.items.length > 0 && ( | |
| 93 | + <table className="rdl-table"> | |
| 94 | + <caption className="sr-only"> | |
| 95 | + Loyers déclarés les plus proches (adresse, chambres, année, loyer) | |
| 96 | + </caption> | |
| 97 | + <tbody> | |
| 98 | + {d.items.slice(0, 8).map((it, i) => ( | |
| 99 | + <tr key={i}> | |
| 100 | + <td className="rdl-addr">{it.address}</td> | |
| 101 | + <td>{it.rooms != null ? `${it.rooms} ch.` : "—"}</td> | |
| 102 | + <td className="rdl-date">{fmtDate(it.date, it.year)}</td> | |
| 103 | + <td className="rdl-prix">{fmtPrice(it.price)}</td> | |
| 104 | + <td className="rdl-dist">{fmtDist(it.dist_m)}</td> | |
| 105 | + </tr> | |
| 106 | + ))} | |
| 107 | + </tbody> | |
| 108 | + </table> | |
| 109 | + )} | |
| 110 | + <p className="fine"> | |
| 111 | + Loyers réellement payés, déclarés volontairement par des locataires au{" "} | |
| 112 | + <a href="https://registre-des-loyers.ca/fr/qc/carte" target="_blank" | |
| 113 | + rel="noopener noreferrer">Registre des loyers</a>{" "} | |
| 114 | + (initiative de Vivre en ville) — données citoyennes non vérifiées, | |
| 115 | + fournies à titre indicatif. | |
| 116 | + </p> | |
| 117 | + </section> | |
| 118 | + ); | |
| 119 | +} | |
modified
frontend/src/pages/Listing.tsx
+3 −0
@@ -17,6 +17,7 @@ import QuartierBlock from "../components/QuartierBlock"; | ||
| 17 | 17 | import SmartImg from "../components/SmartImg"; |
| 18 | 18 | import FairValueBadge from "../components/FairValueBadge"; |
| 19 | 19 | import PriceAnalysis from "../components/PriceAnalysis"; |
| 20 | +import RegistreLoyers from "../components/RegistreLoyers"; | |
| 20 | 21 | import { IcoAlert, IcoDoc } from "../components/Icons"; |
| 21 | 22 | import KaScoresBlock from "../components/KaScoresBlock"; |
| 22 | 23 | import { markSeen } from "../search/seen"; |
@@ -458,6 +459,8 @@ export default function ListingPage() { | ||
| 458 | 459 | <div className="f-col"> |
| 459 | 460 | <PriceAnalysis uid={l.uid} price={l.price} /> |
| 460 | 461 | |
| 462 | + <RegistreLoyers lat={l.lat} lng={l.lng} price={l.price} bedrooms={l.bedrooms} /> | |
| 463 | + | |
| 461 | 464 | {l.lat != null && l.lng != null && ( |
| 462 | 465 | <section className="f-bloc f-carte" id="emplacement"> |
| 463 | 466 | <h2>Emplacement</h2> |
modified
frontend/src/styles.css
+16 −0
@@ -1855,3 +1855,19 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | ||
| 1855 | 1855 | @media (max-width: 780px) { |
| 1856 | 1856 | .ms .ka-3d { top: 148px; right: 10px; } |
| 1857 | 1857 | } |
| 1858 | + | |
| 1859 | + | |
| 1860 | +/* ---- Registre des loyers (fiche) — bloc citoyen registre-des-loyers.ca ---- */ | |
| 1861 | +.f-rdl .rdl-resume { margin: 2px 0 6px; } | |
| 1862 | +.f-rdl .rdl-ecart { margin: 0 0 8px; } | |
| 1863 | +.rdl-rooms { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 4px 0 10px; } | |
| 1864 | +.rdl-room { font-size: 13px; color: var(--ink-2); white-space: nowrap; } | |
| 1865 | +.rdl-room i { font-style: normal; color: var(--ink-3); } | |
| 1866 | +.rdl-table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| 1867 | +.rdl-table td { padding: 5px 8px 5px 0; border-top: 1px solid var(--line, #e6e4df); | |
| 1868 | + vertical-align: top; } | |
| 1869 | +.rdl-table .rdl-addr { max-width: 46%; } | |
| 1870 | +.rdl-table .rdl-prix { font-weight: 600; white-space: nowrap; } | |
| 1871 | +.rdl-table .rdl-date { white-space: nowrap; color: var(--ink-2); } | |
| 1872 | +.rdl-table .rdl-dist { color: var(--ink-3); white-space: nowrap; text-align: right; } | |
| 1873 | +.rdl-room-on { background: var(--sand, #f4efe7); border-radius: 6px; padding: 2px 8px; } | |
added
louka/rdl.py
+226 −0
@@ -0,0 +1,226 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# rdl.py : Registre des loyers (registre-des-loyers.ca) — extraction + requêtes | |
| 5 | +# | |
| 6 | +# Le Registre des loyers est une initiative citoyenne (Vivre en ville) où | |
| 7 | +# les locataires déclarent volontairement leur loyer. La carte publique | |
| 8 | +# expose une API JSON ouverte : | |
| 9 | +# GET /api/v1/housings/{NElat},{NElng},{SWlat},{SWlng} | |
| 10 | +# On balaie la province par grandes boîtes (aucune pagination côté serveur ; | |
| 11 | +# la boîte « tout le Québec » dépasse ses capacités -> découpage, avec | |
| 12 | +# subdivision récursive en cas d'erreur 500), puis on stocke le tout dans | |
| 13 | +# data/rdl.db (base séparée : jamais de verrou sur louka.db). | |
| 14 | +# | |
| 15 | +# Usage : python run.py rdl # rafraîchît la base locale | |
| 16 | +# Lecture : nearby(lat, lng, radius_m) # loyers déclarés autour d'un point | |
| 17 | +# (bloc « Registre des loyers » de la fiche, /api/rdl) | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import json | |
| 22 | +import math | |
| 23 | +import sqlite3 | |
| 24 | +import time | |
| 25 | +import urllib.request | |
| 26 | +from pathlib import Path | |
| 27 | +from statistics import median | |
| 28 | + | |
| 29 | +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "rdl.db" | |
| 30 | +API = "https://registre-des-loyers.ca/api/v1/housings/{},{},{},{}" | |
| 31 | +UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)" | |
| 32 | +DELAY = 2.0 # politesse entre deux boîtes | |
| 33 | + | |
| 34 | +# Boîtes (NE_lat, NE_lng, SW_lat, SW_lng) couvrant le pays entier (le registre | |
| 35 | +# est pancanadien ; Lou-Ka n'affiche que ce qui tombe près d'une annonce). | |
| 36 | +# Elles se recoupent légèrement, la déduplication se fait par id. | |
| 37 | +BOXES: list[tuple[float, float, float, float]] = [ | |
| 38 | + (46.0, -70.0, 45.0, -75.0), # Grand Montréal + Montérégie + Estrie | |
| 39 | + (47.2, -70.0, 46.0, -76.0), # Québec, Mauricie, Centre-du-Québec | |
| 40 | + (46.2, -74.8, 45.2, -77.5), # Outaouais | |
| 41 | + (49.5, -63.5, 47.2, -80.0), # Abitibi, Saguenay, Gaspésie | |
| 42 | + (47.2, -63.5, 45.0, -70.0), # Bas-Saint-Laurent, Beauce est | |
| 43 | + (63.0, -55.0, 49.5, -80.0), # Côte-Nord, Nord-du-Québec | |
| 44 | + (45.05, -70.0, 44.5, -80.0), # frange frontalière sud | |
| 45 | + (57.0, -74.5, 41.6, -96.0), # Ontario | |
| 46 | + (60.0, -96.0, 48.9, -110.0), # Prairies (MB, SK) | |
| 47 | + (60.0, -110.0, 48.0, -140.0), # Alberta, Colombie-Britannique | |
| 48 | + (70.0, -60.0, 60.0, -142.0), # territoires + Nunavik | |
| 49 | + (49.0, -52.0, 43.0, -70.0), # Atlantique (NB, NÉ, ÎPÉ, TNL sud) | |
| 50 | + (61.0, -52.0, 49.0, -57.0), # Terre-Neuve nord + Labrador est | |
| 51 | +] | |
| 52 | + | |
| 53 | +# au-delà de ce volume on subdivise par prudence (plafond serveur inconnu) | |
| 54 | +SUSPECT = 40000 | |
| 55 | + | |
| 56 | +_SCHEMA = """ | |
| 57 | +CREATE TABLE IF NOT EXISTS rdl_housings ( | |
| 58 | + id INTEGER PRIMARY KEY, | |
| 59 | + full_address TEXT, street_number TEXT, apartment_number TEXT, | |
| 60 | + street_name TEXT, city TEXT, zip TEXT, | |
| 61 | + lat REAL, lng REAL, | |
| 62 | + price REAL, rooms INTEGER, year INTEGER, start_date TEXT, | |
| 63 | + type_of_accomodation TEXT, | |
| 64 | + heating_included INTEGER, electricity_included INTEGER, | |
| 65 | + furnishing_included INTEGER, parking_included INTEGER, | |
| 66 | + animal_allowed INTEGER, | |
| 67 | + address_slug TEXT, updated_at TEXT, fetched_at TEXT | |
| 68 | +); | |
| 69 | +CREATE INDEX IF NOT EXISTS idx_rdl_latlng ON rdl_housings (lat, lng); | |
| 70 | +""" | |
| 71 | + | |
| 72 | + | |
| 73 | +def _connect(ro: bool = False) -> sqlite3.Connection: | |
| 74 | + if ro: | |
| 75 | + con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) | |
| 76 | + else: | |
| 77 | + DB_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| 78 | + con = sqlite3.connect(DB_PATH) | |
| 79 | + con.row_factory = sqlite3.Row | |
| 80 | + return con | |
| 81 | + | |
| 82 | + | |
| 83 | +def _fetch_box(ne_lat: float, ne_lng: float, sw_lat: float, sw_lng: float, | |
| 84 | + depth: int = 0) -> list[dict]: | |
| 85 | + """Une boîte ; en cas d'erreur serveur (boîte trop lourde), découpe en 4.""" | |
| 86 | + url = API.format(ne_lat, ne_lng, sw_lat, sw_lng) | |
| 87 | + req = urllib.request.Request(url, headers={"User-Agent": UA}) | |
| 88 | + try: | |
| 89 | + with urllib.request.urlopen(req, timeout=120) as resp: | |
| 90 | + data = json.load(resp) | |
| 91 | + rows = data.get("data", {}).get("housings") or [] | |
| 92 | + if len(rows) < SUSPECT or depth >= 3: | |
| 93 | + return rows | |
| 94 | + raise RuntimeError(f"{len(rows)} résultats — subdivision de prudence") | |
| 95 | + except Exception as exc: | |
| 96 | + if depth >= 3: | |
| 97 | + print(f" ! abandon boîte {url}: {exc}") | |
| 98 | + return [] | |
| 99 | + mid_lat = (ne_lat + sw_lat) / 2 | |
| 100 | + mid_lng = (ne_lng + sw_lng) / 2 | |
| 101 | + out: list[dict] = [] | |
| 102 | + for quad in ((ne_lat, ne_lng, mid_lat, mid_lng), | |
| 103 | + (ne_lat, mid_lng, mid_lat, sw_lng), | |
| 104 | + (mid_lat, ne_lng, sw_lat, mid_lng), | |
| 105 | + (mid_lat, mid_lng, sw_lat, sw_lng)): | |
| 106 | + time.sleep(DELAY) | |
| 107 | + out.extend(_fetch_box(*quad, depth=depth + 1)) | |
| 108 | + return out | |
| 109 | + | |
| 110 | + | |
| 111 | +def _num(v, cast=float): | |
| 112 | + try: | |
| 113 | + return cast(v) | |
| 114 | + except (TypeError, ValueError): | |
| 115 | + return None | |
| 116 | + | |
| 117 | + | |
| 118 | +def refresh() -> None: | |
| 119 | + """Balaie la province et remplace le contenu de data/rdl.db.""" | |
| 120 | + now = time.strftime("%Y-%m-%d %H:%M:%S") | |
| 121 | + seen: dict[int, dict] = {} | |
| 122 | + for i, box in enumerate(BOXES, 1): | |
| 123 | + rows = _fetch_box(*box) | |
| 124 | + fresh = 0 | |
| 125 | + for h in rows: | |
| 126 | + hid = _num(h.get("id"), int) | |
| 127 | + if hid is None or hid in seen: | |
| 128 | + continue | |
| 129 | + seen[hid] = h | |
| 130 | + fresh += 1 | |
| 131 | + print(f"[rdl] boîte {i}/{len(BOXES)} : {len(rows)} reçus, " | |
| 132 | + f"{fresh} nouveaux ({len(seen)} au total)") | |
| 133 | + time.sleep(DELAY) | |
| 134 | + | |
| 135 | + con = _connect() | |
| 136 | + con.executescript(_SCHEMA) | |
| 137 | + with con: | |
| 138 | + con.execute("DELETE FROM rdl_housings") | |
| 139 | + con.executemany( | |
| 140 | + "INSERT OR REPLACE INTO rdl_housings VALUES " | |
| 141 | + "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", | |
| 142 | + [(hid, | |
| 143 | + h.get("full_address"), h.get("street_number"), | |
| 144 | + h.get("apartment_number"), h.get("street_name"), | |
| 145 | + h.get("city"), h.get("zip"), | |
| 146 | + _num(h.get("latitude")), _num(h.get("longitude")), | |
| 147 | + _num(h.get("price")), _num(h.get("number_of_closed_room"), int), | |
| 148 | + _num(h.get("year"), int), | |
| 149 | + (h.get("start_date") or "")[:10] or None, | |
| 150 | + h.get("type_of_accomodation"), | |
| 151 | + 1 if h.get("heating_included") else 0, | |
| 152 | + 1 if h.get("electricity_included") else 0, | |
| 153 | + 1 if h.get("furnishing_included") else 0, | |
| 154 | + 1 if h.get("parking_included") else 0, | |
| 155 | + 1 if h.get("animal_allowed") else 0, | |
| 156 | + h.get("address_slug"), h.get("updated_at"), now) | |
| 157 | + for hid, h in seen.items()]) | |
| 158 | + n, cities = con.execute( | |
| 159 | + "SELECT COUNT(*), COUNT(DISTINCT city) FROM rdl_housings").fetchone() | |
| 160 | + print(f"[rdl] terminé : {n} loyers déclarés, {cities} villes -> {DB_PATH}") | |
| 161 | + con.close() | |
| 162 | + | |
| 163 | + | |
| 164 | +def _dist_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: | |
| 165 | + dlat = math.radians(lat2 - lat1) | |
| 166 | + dlng = math.radians(lng2 - lng1) | |
| 167 | + a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) | |
| 168 | + * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2) | |
| 169 | + return 6371000 * 2 * math.asin(math.sqrt(a)) | |
| 170 | + | |
| 171 | + | |
| 172 | +def nearby(lat: float, lng: float, radius_m: int = 600, | |
| 173 | + limit: int = 12) -> dict | None: | |
| 174 | + """Loyers déclarés autour d'un point + agrégats (bloc fiche / API).""" | |
| 175 | + if not DB_PATH.exists(): | |
| 176 | + return None | |
| 177 | + dlat = radius_m / 111320.0 | |
| 178 | + dlng = radius_m / (111320.0 * max(0.2, math.cos(math.radians(lat)))) | |
| 179 | + con = _connect(ro=True) | |
| 180 | + rows = con.execute( | |
| 181 | + "SELECT * FROM rdl_housings WHERE lat BETWEEN ? AND ? " | |
| 182 | + "AND lng BETWEEN ? AND ? AND price > 100 AND price < 20000", | |
| 183 | + (lat - dlat, lat + dlat, lng - dlng, lng + dlng)).fetchall() | |
| 184 | + con.close() | |
| 185 | + hits = [] | |
| 186 | + for r in rows: | |
| 187 | + if r["lat"] is None or r["lng"] is None: | |
| 188 | + continue | |
| 189 | + d = _dist_m(lat, lng, r["lat"], r["lng"]) | |
| 190 | + if d <= radius_m: | |
| 191 | + hits.append((d, r)) | |
| 192 | + if not hits: | |
| 193 | + return {"n": 0, "radius_m": radius_m, "items": []} | |
| 194 | + hits.sort(key=lambda t: t[0]) | |
| 195 | + | |
| 196 | + prices = [r["price"] for _, r in hits] | |
| 197 | + recent = [r["price"] for _, r in hits if (r["year"] or 0) >= 2023] | |
| 198 | + by_rooms: dict[str, dict] = {} | |
| 199 | + for _, r in hits: | |
| 200 | + if r["rooms"] is None: | |
| 201 | + continue | |
| 202 | + b = by_rooms.setdefault(str(r["rooms"]), {"n": 0, "prices": []}) | |
| 203 | + b["n"] += 1 | |
| 204 | + b["prices"].append(r["price"]) | |
| 205 | + for b in by_rooms.values(): | |
| 206 | + b["median"] = round(median(b.pop("prices"))) | |
| 207 | + | |
| 208 | + def item(d: float, r: sqlite3.Row) -> dict: | |
| 209 | + addr = " ".join(x for x in (r["street_number"], r["street_name"]) if x) | |
| 210 | + if r["apartment_number"]: | |
| 211 | + addr += f", app. {r['apartment_number']}" | |
| 212 | + return {"address": addr or r["full_address"], "city": r["city"], | |
| 213 | + "price": r["price"], "rooms": r["rooms"], "year": r["year"], | |
| 214 | + "date": r["start_date"], "dist_m": round(d), | |
| 215 | + "heating": bool(r["heating_included"]), | |
| 216 | + "furnished": bool(r["furnishing_included"])} | |
| 217 | + | |
| 218 | + return { | |
| 219 | + "n": len(hits), | |
| 220 | + "radius_m": radius_m, | |
| 221 | + "median": round(median(prices)), | |
| 222 | + "median_recent": round(median(recent)) if recent else None, | |
| 223 | + "n_recent": len(recent), | |
| 224 | + "by_rooms": by_rooms, | |
| 225 | + "items": [item(d, r) for d, r in hits[:limit]], | |
| 226 | + } | |
modified
louka/web.py
+12 −0
@@ -542,6 +542,18 @@ def fairvalue_detail(uid: str): | ||
| 542 | 542 | return d |
| 543 | 543 | |
| 544 | 544 | |
| 545 | +@app.get("/api/rdl") | |
| 546 | +def rdl_nearby(lat: float, lng: float, radius: int = 600): | |
| 547 | + """Loyers déclarés au Registre des loyers (registre-des-loyers.ca) | |
| 548 | + autour d'un point — bloc « Registre des loyers » de la fiche.""" | |
| 549 | + from . import rdl | |
| 550 | + radius = max(100, min(radius, 2000)) | |
| 551 | + d = rdl.nearby(lat, lng, radius) | |
| 552 | + if d is None: | |
| 553 | + raise HTTPException(404, "Registre des loyers non disponible") | |
| 554 | + return d | |
| 555 | + | |
| 556 | + | |
| 545 | 557 | @app.get("/api/listings/{uid}") |
| 546 | 558 | def get_listing(uid: str): |
| 547 | 559 | con = db.connect() |
modified
run.py
+3 −0
@@ -93,6 +93,9 @@ def main() -> None: | ||
| 93 | 93 | from louka import kascores |
| 94 | 94 | force = len(sys.argv) > 2 and sys.argv[2] == "all" |
| 95 | 95 | print(kascores.run(recompute_all=force)) |
| 96 | + elif cmd == "rdl": | |
| 97 | + from louka import rdl | |
| 98 | + rdl.refresh() | |
| 96 | 99 | elif cmd == "record": |
| 97 | 100 | from louka import fixtures |
| 98 | 101 | from louka.connectors import CONNECTORS |
| 99 | 102 | |