SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

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 1 mo ago (Aug 24, 2026) parent 37f3f5f

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