SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

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>
Simon-Pierre Boucher committed (Aug 24, 2026) parent 37f3f5f Browse files

8 changed files +398 −0

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
Menu

Theme