Commit
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>
modified
.gitignore
+1 −0
@@ -27,3 +27,4 @@ data/louka_ct.db | |||
| 27 | data/louka_ct.db-shm | 27 | data/louka_ct.db-shm |
| 28 | data/louka_ct.db-wal | 28 | data/louka_ct.db-wal |
| 29 | frontend/tsconfig.tsbuildinfo | 29 | frontend/tsconfig.tsbuildinfo |
| 30 | +data/rdl.db | ||
modified
frontend/src/api.ts
+18 −0
@@ -93,6 +93,7 @@ export interface Listing { | |||
| 93 | images: string[]; | 93 | images: string[]; |
| 94 | lat: number | null; | 94 | lat: number | null; |
| 95 | lng: number | null; | 95 | lng: number | null; |
| 96 | + bedrooms: number | null; | ||
| 96 | poi?: Poi[]; // commodités de proximité (fiche seulement) | 97 | poi?: Poi[]; // commodités de proximité (fiche seulement) |
| 97 | quartier?: Quartier | null; // stats de quartier (fiche seulement) | 98 | quartier?: Quartier | null; // stats de quartier (fiche seulement) |
| 98 | digest?: Digest | null; // description structurée (fiche seulement) | 99 | digest?: Digest | null; // description structurée (fiche seulement) |
@@ -204,6 +205,23 @@ export interface FairValueDetail { | |||
| 204 | export const fetchFairValue = (uid: string) => | 205 | export const fetchFairValue = (uid: string) => |
| 205 | get<FairValueDetail>(`/api/fairvalue/${encodeURIComponent(uid)}`); | 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 | /** 250 -> « 250 m », 1240 -> « 1,2 km » */ | 225 | /** 250 -> « 250 m », 1240 -> « 1,2 km » */ |
| 208 | export const fmtDist = (m: number): string => | 226 | export const fmtDist = (m: number): string => |
| 209 | m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; | 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 | import SmartImg from "../components/SmartImg"; | 17 | import SmartImg from "../components/SmartImg"; |
| 18 | import FairValueBadge from "../components/FairValueBadge"; | 18 | import FairValueBadge from "../components/FairValueBadge"; |
| 19 | import PriceAnalysis from "../components/PriceAnalysis"; | 19 | import PriceAnalysis from "../components/PriceAnalysis"; |
| 20 | +import RegistreLoyers from "../components/RegistreLoyers"; | ||
| 20 | import { IcoAlert, IcoDoc } from "../components/Icons"; | 21 | import { IcoAlert, IcoDoc } from "../components/Icons"; |
| 21 | import KaScoresBlock from "../components/KaScoresBlock"; | 22 | import KaScoresBlock from "../components/KaScoresBlock"; |
| 22 | import { markSeen } from "../search/seen"; | 23 | import { markSeen } from "../search/seen"; |
@@ -458,6 +459,8 @@ export default function ListingPage() { | |||
| 458 | <div className="f-col"> | 459 | <div className="f-col"> |
| 459 | <PriceAnalysis uid={l.uid} price={l.price} /> | 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 | {l.lat != null && l.lng != null && ( | 464 | {l.lat != null && l.lng != null && ( |
| 462 | <section className="f-bloc f-carte" id="emplacement"> | 465 | <section className="f-bloc f-carte" id="emplacement"> |
| 463 | <h2>Emplacement</h2> | 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 | @media (max-width: 780px) { | 1855 | @media (max-width: 780px) { |
| 1856 | .ms .ka-3d { top: 148px; right: 10px; } | 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 | return d | 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 | @app.get("/api/listings/{uid}") | 557 | @app.get("/api/listings/{uid}") |
| 546 | def get_listing(uid: str): | 558 | def get_listing(uid: str): |
| 547 | con = db.connect() | 559 | con = db.connect() |
modified
run.py
+3 −0
@@ -93,6 +93,9 @@ def main() -> None: | |||
| 93 | from louka import kascores | 93 | from louka import kascores |
| 94 | force = len(sys.argv) > 2 and sys.argv[2] == "all" | 94 | force = len(sys.argv) > 2 and sys.argv[2] == "all" |
| 95 | print(kascores.run(recompute_all=force)) | 95 | print(kascores.run(recompute_all=force)) |
| 96 | + elif cmd == "rdl": | ||
| 97 | + from louka import rdl | ||
| 98 | + rdl.refresh() | ||
| 96 | elif cmd == "record": | 99 | elif cmd == "record": |
| 97 | from louka import fixtures | 100 | from louka import fixtures |
| 98 | from louka.connectors import CONNECTORS | 101 | from louka.connectors import CONNECTORS |
| 99 | 102 | ||