SPB Git

spb/lou-ka

Public

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

feat(tal): historique du Tribunal administratif du logement par adresse

Décisions publiques du TAL (diffusées par SOQUIJ) croisées avec l'adresse
de chaque annonce : recherche « no civique + rue » via Scrapfly (le site
exige rendu JS + IP résidentielle ; pas de phrases entre guillemets),
puis vérification de l'adresse exacte dans le texte intégral et
classification (non-paiement, résiliation, expulsion, reprise/éviction,
insalubrité…, demande du locateur vs du locataire, verdict). Cache par
adresse dans data/tal.db, backfill borné dans la boucle watch + file
prioritaire alimentée par les fiches consultées (/api/tal), bloc
« Historique au TAL » sur la fiche (aucun nom de partie affiché —
référence neutre + lien SOQUIJ).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 24, 2026) parent 438b294 Browse files

9 changed files +616 −0

modified .gitignore +1 −0
@@ -34,3 +34,4 @@ data/gaz.db
34 data/commerces.db 34 data/commerces.db
35 data/transit.db 35 data/transit.db
36 data/hydro.db 36 data/hydro.db
37+data/tal.db
modified frontend/src/api.ts +24 −0
@@ -240,6 +240,30 @@ export interface RdlNearby {
240 export const fetchRdl = (lat: number, lng: number, radius = 600) => 240 export const fetchRdl = (lat: number, lng: number, radius = 600) =>
241 get<RdlNearby>(`/api/rdl?lat=${lat}&lng=${lng}&radius=${radius}`); 241 get<RdlNearby>(`/api/rdl?lat=${lat}&lng=${lng}&radius=${radius}`);
242 242
243+export interface TalDecision {
244+ date: string | null;
245+ citation: string | null;
246+ demandeur: "locateur" | "locataire" | null;
247+ tags: string[];
248+ verdict: "accueillie" | "rejetée" | null;
249+ url: string;
250+}
251+
252+export interface TalHistory {
253+ status: "ok" | "pending" | "error" | "na";
254+ checked_at?: string;
255+ n?: number;
256+ contre_locataire?: number;
257+ eviction?: number;
258+ last_date?: string | null;
259+ decisions?: TalDecision[];
260+}
261+
262+/** Décisions du Tribunal administratif du logement (SOQUIJ) à l'adresse. */
263+export const fetchTal = (address: string, city?: string | null) =>
264+ get<TalHistory>(`/api/tal?address=${encodeURIComponent(address)}` +
265+ (city ? `&city=${encodeURIComponent(city)}` : ""));
266+
243 /** 250 -> « 250 m », 1240 -> « 1,2 km » */ 267 /** 250 -> « 250 m », 1240 -> « 1,2 km » */
244 export const fmtDist = (m: number): string => 268 export const fmtDist = (m: number): string =>
245 m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`; 269 m < 1000 ? `${Math.round(m / 10) * 10} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`;
added frontend/src/components/HistoriqueTAL.tsx +105 −0
@@ -0,0 +1,105 @@
1+// -----------------------------------------------------------------------------
2+// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+// components/HistoriqueTAL.tsx : bloc « Historique TAL » (fiche)
5+// Décisions du Tribunal administratif du logement (diffusées par SOQUIJ)
6+// rendues à l'adresse de l'annonce : le locataire potentiel voit si
7+// l'immeuble a un historique de litiges (non-paiement, résiliation,
8+// expulsion, reprise/éviction, insalubrité…) et qui était à l'initiative
9+// du recours (propriétaire ou locataire). Par respect de la vie privée,
10+// aucun nom de partie n'est affiché — seulement la référence neutre
11+// (ex. « 2026 QCTAL 21509 ») avec lien vers le texte intégral sur SOQUIJ.
12+// -----------------------------------------------------------------------------
13+import { useEffect, useState } from "react";
14+import { fetchTal, TalHistory } from "../api";
15+
16+const fmtDate = (d: string | null): string => {
17+ if (!d) return "—";
18+ const dt = new Date(d + "T00:00:00");
19+ return Number.isNaN(dt.getTime())
20+ ? d
21+ : dt.toLocaleDateString("fr-CA", { month: "long", year: "numeric" });
22+};
23+
24+export default function HistoriqueTAL({ address, city }:
25+ { address: string | null; city: string | null }) {
26+ const [d, setD] = useState<TalHistory | null>(null);
27+ useEffect(() => {
28+ setD(null);
29+ if (!address) return;
30+ fetchTal(address, city).then(setD).catch(() => setD(null));
31+ }, [address, city]);
32+ if (!address || !d || d.status === "na" || d.status === "error") return null;
33+
34+ const decisions = d.decisions ?? [];
35+ const alerte = (d.eviction ?? 0) > 0 || (d.contre_locataire ?? 0) > 0;
36+ return (
37+ <section className="f-bloc f-tal" id="historique-tal">
38+ <h2>Historique au TAL</h2>
39+ {d.status === "pending" && (
40+ <p className="tal-pending">
41+ <span className="zi-badge zi-nc">Vérification en cours</span>{" "}
42+ Cette adresse est en file de vérification auprès des décisions
43+ publiées du Tribunal administratif du logement.
44+ </p>
45+ )}
46+ {d.status === "ok" && decisions.length === 0 && (
47+ <p className="tal-ok">
48+ <span className="zi-badge zi-ok">Aucune décision trouvée</span>{" "}
49+ Aucune décision publiée du Tribunal administratif du logement n'a
50+ été repérée à cette adresse.
51+ </p>
52+ )}
53+ {d.status === "ok" && decisions.length > 0 && (
54+ <>
55+ <p className="tal-resume">
56+ <span className={`zi-badge ${alerte ? "zi-modere" : "zi-nc"}`}>
57+ {d.n} décision{(d.n ?? 0) > 1 ? "s" : ""} au TAL
58+ </span>{" "}
59+ {d.contre_locataire
60+ ? `dont ${d.contre_locataire} à l'initiative du propriétaire`
61+ : "aucune à l'initiative du propriétaire"}
62+ {d.last_date ? ` — la plus récente : ${fmtDate(d.last_date)}` : ""}.
63+ </p>
64+ <ul className="tal-liste">
65+ {decisions.slice(0, 8).map((dec) => (
66+ <li key={dec.url}>
67+ <div className="tal-ligne">
68+ <span className="tal-date">{fmtDate(dec.date)}</span>
69+ {dec.demandeur && (
70+ <span className={`tal-part tal-part-${dec.demandeur}`}>
71+ demande du {dec.demandeur}
72+ </span>
73+ )}
74+ {dec.verdict && (
75+ <span className="tal-verdict">demande {dec.verdict}</span>
76+ )}
77+ </div>
78+ {dec.tags.length > 0 && (
79+ <div className="tal-tags">
80+ {dec.tags.map((t) => (
81+ <span className="tal-tag" key={t}>{t}</span>
82+ ))}
83+ </div>
84+ )}
85+ <a href={dec.url} target="_blank" rel="noopener noreferrer"
86+ className="tal-ref">
87+ {dec.citation || "Texte intégral"} — SOQUIJ
88+ </a>
89+ </li>
90+ ))}
91+ </ul>
92+ </>
93+ )}
94+ <p className="fine">
95+ Décisions publiques du{" "}
96+ <a href="https://www.tal.gouv.qc.ca/" target="_blank"
97+ rel="noopener noreferrer">Tribunal administratif du logement</a>,
98+ diffusées par SOQUIJ (citoyens.soquij.qc.ca) et repérées par
99+ correspondance d'adresse dans le texte des décisions — vérification
100+ automatisée fournie à titre indicatif, sans valeur juridique. Une
101+ décision peut concerner un autre logement du même immeuble.
102+ </p>
103+ </section>
104+ );
105+}
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 HistoriqueTAL from "../components/HistoriqueTAL";
20 import RegistreLoyers from "../components/RegistreLoyers"; 21 import RegistreLoyers from "../components/RegistreLoyers";
21 import RisqueInondation from "../components/RisqueInondation"; 22 import RisqueInondation from "../components/RisqueInondation";
22 import QualiteAir from "../components/QualiteAir"; 23 import QualiteAir from "../components/QualiteAir";
@@ -481,6 +482,8 @@ export default function ListingPage() {
481 482
482 <RisqueInondation lat={l.lat} lng={l.lng} /> 483 <RisqueInondation lat={l.lat} lng={l.lng} />
483 484
485+ <HistoriqueTAL address={l.address} city={l.city} />
486+
484 <QualiteAir lat={l.lat} lng={l.lng} /> 487 <QualiteAir lat={l.lat} lng={l.lng} />
485 488
486 <CommercesProches lat={l.lat} lng={l.lng} /> 489 <CommercesProches lat={l.lat} lng={l.lng} />
modified frontend/src/styles.css +16 −0
@@ -1886,6 +1886,22 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1886 .zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); } 1886 .zi-liste li { padding: 4px 0; border-top: 1px solid var(--line, #e6e4df); }
1887 1887
1888 1888
1889+/* ---- Historique TAL (fiche) — décisions SOQUIJ à l'adresse ---- */
1890+.f-tal .tal-pending, .f-tal .tal-ok, .f-tal .tal-resume { margin: 2px 0 8px; }
1891+.tal-liste { margin: 8px 0 0; padding: 0; list-style: none; font-size: 13px; }
1892+.tal-liste li { padding: 8px 0; border-top: 1px solid var(--line, #e6e4df); }
1893+.tal-ligne { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
1894+.tal-date { font-weight: 600; color: var(--ink-2); }
1895+.tal-part { font-size: 12px; padding: 1px 8px; border-radius: 999px;
1896+ background: var(--sand, #f0ede8); color: var(--ink-2); }
1897+.tal-part-locateur { background: #fdf3e0; color: #8a5a00; }
1898+.tal-verdict { font-size: 12px; color: var(--ink-3); }
1899+.tal-tags { display: flex; flex-wrap: wrap; gap: 5px; margin: 5px 0 3px; }
1900+.tal-tag { font-size: 12px; padding: 1px 8px; border-radius: 999px;
1901+ background: #fde8e8; color: #a12622; }
1902+.tal-ref { font-size: 12.5px; color: var(--ink-3); }
1903+
1904+
1889 /* ---- Registre des loyers v2 : tuiles, bande de position, barres ---- */ 1905 /* ---- Registre des loyers v2 : tuiles, bande de position, barres ---- */
1890 .rdl-kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 12px; } 1906 .rdl-kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 2px 0 12px; }
1891 .rdl-kpi { flex: 1 1 90px; min-width: 90px; background: var(--surface-2, #f7f5f1); 1907 .rdl-kpi { flex: 1 1 90px; min-width: 90px; background: var(--surface-2, #f7f5f1);
modified louka/ingest.py +6 −0
@@ -142,6 +142,12 @@ def watch(interval_seconds: int = 3600) -> None:
142 hydro.precompute(limit=100, budget=30) 142 hydro.precompute(limit=100, budget=30)
143 except Exception as exc: 143 except Exception as exc:
144 print(f"[lou-ka] hydro: erreur non bloquante: {exc}", file=sys.stderr) 144 print(f"[lou-ka] hydro: erreur non bloquante: {exc}", file=sys.stderr)
145+ try: # historique TAL (SOQUIJ) par adresse — file des fiches d'abord,
146+ # puis backfill des adresses jamais vérifiées (coût borné)
147+ from . import tal
148+ tal.precompute(limit=60, budget=600)
149+ except Exception as exc:
150+ print(f"[lou-ka] tal: erreur non bloquante: {exc}", file=sys.stderr)
145 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s") 151 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
146 time.sleep(interval_seconds) 152 time.sleep(interval_seconds)
147 153
added louka/tal.py +446 −0
@@ -0,0 +1,446 @@
1+# -----------------------------------------------------------------------------
2+# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4+# tal.py : historique au Tribunal administratif du logement (TAL / ex-RDL)
5+#
6+# Les décisions du TAL sont diffusées gratuitement par SOQUIJ
7+# (citoyens.soquij.qc.ca, tribunal no 35, plein texte depuis ~2009). Le site
8+# est protégé par une détection de bot (byscuit) : la RECHERCHE exige un
9+# rendu JS + IP résidentielle (Scrapfly, asp + render_js + session), mais la
10+# page d'une DÉCISION se charge ensuite sans rendu (même session = moins
11+# cher). Le moteur ne supporte pas les phrases entre guillemets : les mots
12+# sont combinés en ET implicite, d'où la stratégie
13+# recherche « <no civique> <nom de rue sans le type> »
14+# puis VÉRIFICATION de l'adresse exacte dans le texte intégral.
15+#
16+# Tout est mis en cache dans data/tal.db (base séparée : jamais de verrou
17+# sur louka.db) par adresse normalisée (no civique + rue + ville) :
18+# · tal_lookup : une ligne par adresse vérifiée (ou en file d'attente) ;
19+# · tal_decisions : décisions confirmées à l'adresse, classées par mots
20+# clés (non-paiement, résiliation, expulsion, reprise…)
21+# et par partie demanderesse (locateur vs locataire).
22+#
23+# Usage : python run.py tal [n] [budget_s] # précalcul par lots
24+# Lecture : lookup(address, city) — bloc « Historique TAL » de la fiche
25+# (/api/tal) ; une adresse jamais vérifiée est mise en file
26+# prioritaire et traitée au prochain passage du watch.
27+# -----------------------------------------------------------------------------
28+from __future__ import annotations
29+
30+import html
31+import json
32+import os
33+import re
34+import sqlite3
35+import threading
36+import time
37+import unicodedata
38+import urllib.parse
39+import urllib.request
40+from concurrent.futures import ThreadPoolExecutor, as_completed
41+from pathlib import Path
42+
43+DATA_DIR = Path(__file__).resolve().parent.parent / "data"
44+DB_PATH = DATA_DIR / "tal.db"
45+LOUKA_DB = DATA_DIR / "louka.db"
46+
47+SCRAPFLY_API = "https://api.scrapfly.io/scrape"
48+SOQUIJ_BASE = "https://citoyens.soquij.qc.ca"
49+TRIBUNAL_ID = "35" # Tribunal administratif du logement / RDL
50+
51+REFRESH_DAYS = 180 # revérifier une adresse après ~6 mois
52+ERROR_RETRY_DAYS = 7 # réessayer plus vite après une erreur
53+MAX_DECISIONS = 12 # décisions lues au maximum par adresse
54+WORKERS = 4
55+
56+_SCHEMA = """
57+CREATE TABLE IF NOT EXISTS tal_lookup (
58+ addr_key TEXT PRIMARY KEY,
59+ civic TEXT, street TEXT, city TEXT, query TEXT,
60+ status TEXT, -- queued | ok | error
61+ found INTEGER, -- résultats bruts SOQUIJ (avant vérification)
62+ matched INTEGER, -- décisions confirmées à l'adresse
63+ priority INTEGER DEFAULT 0, -- 1 = demandé par une fiche (file rapide)
64+ checked_at TEXT,
65+ error TEXT
66+);
67+CREATE TABLE IF NOT EXISTS tal_decisions (
68+ addr_key TEXT,
69+ decision_id TEXT, -- ID hexadécimal SOQUIJ
70+ citation TEXT, -- ex. « 2026 QCTAL 21509 »
71+ parties TEXT,
72+ date TEXT,
73+ demandeur TEXT, -- locateur | locataire | NULL (indéterminé)
74+ tags TEXT, -- JSON : ["non-paiement", "résiliation de bail", …]
75+ verdict TEXT, -- accueillie | rejetée | NULL
76+ url TEXT,
77+ PRIMARY KEY (addr_key, decision_id)
78+);
79+"""
80+
81+# ---------------------------------------------------------------------------
82+# Normalisation d'adresse
83+# ---------------------------------------------------------------------------
84+
85+_TYPES_VOIE = {
86+ "rue", "avenue", "av", "ave", "boulevard", "boul", "blvd", "bd", "chemin",
87+ "ch", "place", "pl", "cote", "allee", "montee", "carre", "impasse",
88+ "terrasse", "tsse", "croissant", "crois", "rang", "route", "rte",
89+ "promenade", "prom", "voie", "square", "cours", "sentier", "parc",
90+ # types anglais (annonces montréalaises)
91+ "st", "street", "rd", "road", "dr", "drive", "cres", "crescent",
92+ "ct", "court", "lane", "ln", "hwy", "way",
93+}
94+_STOPWORDS = {"de", "du", "des", "la", "le", "les", "l", "d", "a", "au", "aux",
95+ "app", "apt", "unite", "suite", "bureau", "local"}
96+
97+
98+def _fold(s: str) -> str:
99+ """Minuscules sans accents (comparaisons et clés)."""
100+ s = unicodedata.normalize("NFD", s)
101+ return "".join(c for c in s if not unicodedata.combining(c)).lower()
102+
103+
104+def parse_address(address: str | None, city: str | None = None) -> dict | None:
105+ """« 1573, rue Pierre-Corneille, Québec, Québec, G2E4W9 » ->
106+ civic + rue + mots significatifs pour la recherche SOQUIJ."""
107+ if not address:
108+ return None
109+ parts = [p.strip() for p in address.split(",") if p.strip()]
110+ if not parts:
111+ return None
112+ m = re.match(r"^(\d+[a-zA-Z]?)\b[\s,]*(.*)$", parts[0])
113+ if not m:
114+ return None
115+ civic = m.group(1)
116+ street = m.group(2).strip()
117+ if not street and len(parts) > 1: # « 1573 » puis « rue X » à part
118+ street = parts[1]
119+ if not street:
120+ return None
121+ ville = (city or "").strip()
122+ if not ville and len(parts) > 2:
123+ for p in parts[1:]: # sauter « Suite 1106 », « app. 4 »…
124+ if not re.match(r"^(app|apt|suite|unit|unité|bureau|local|#|\d)",
125+ p, re.I):
126+ ville = p
127+ break
128+
129+ toks = [t for t in re.split(r"[^\w-]+", street) if t]
130+ core = [t for t in toks
131+ if _fold(t).rstrip(".") not in _TYPES_VOIE
132+ and _fold(t) not in _STOPWORDS and len(t) >= 2
133+ and not re.fullmatch(r"\d+[a-zA-Z]?", t)] # no d'unité (« 16F »)
134+ if not core: # ex. « 1re Avenue » : tout garder
135+ core = toks
136+ if not core:
137+ return None
138+ key = f"{civic}|{_fold(' '.join(core))}|{_fold(ville)}"
139+ return {"civic": civic, "street": street, "city": ville,
140+ "core": core, "query": f"{civic} {' '.join(core)}", "key": key}
141+
142+
143+# ---------------------------------------------------------------------------
144+# Accès SOQUIJ via Scrapfly
145+# ---------------------------------------------------------------------------
146+
147+def _scrapfly(url: str, *, render: bool, session: str) -> str:
148+ key = os.environ.get("SCRAPFLY_KEY")
149+ if not key:
150+ raise RuntimeError("SCRAPFLY_KEY absent de l'environnement")
151+ params = {"key": key, "url": url, "asp": "true", "country": "ca",
152+ "proxy_pool": "public_residential_pool", "session": session}
153+ if render:
154+ params["render_js"] = "true"
155+ params["rendering_wait"] = "5000"
156+ u = SCRAPFLY_API + "?" + urllib.parse.urlencode(params)
157+ data = None
158+ for attempt in range(3):
159+ try:
160+ with urllib.request.urlopen(u, timeout=170) as resp:
161+ data = json.load(resp)
162+ break
163+ except urllib.error.HTTPError as exc:
164+ # 429 = limite de concurrence Scrapfly : attendre puis réessayer
165+ if exc.code == 429 and attempt < 2:
166+ time.sleep(8 * (attempt + 1))
167+ continue
168+ raise
169+ res = (data or {}).get("result") or {}
170+ if res.get("status_code") != 200:
171+ raise RuntimeError(f"SOQUIJ HTTP {res.get('status_code')}")
172+ return res.get("content") or ""
173+
174+
175+def _to_text(html_doc: str) -> str:
176+ """HTML -> texte plat, entités décodées, espaces normalisés."""
177+ doc = re.sub(r"<script.*?</script>|<style.*?</style>", " ",
178+ html_doc, flags=re.S | re.I)
179+ doc = re.sub(r"<[^>]+>", " ", doc)
180+ doc = html.unescape(doc).replace("\xa0", " ")
181+ return re.sub(r"\s+", " ", doc)
182+
183+
184+def _search(query: str, session: str) -> list[dict]:
185+ """Recherche plein texte TAL ; -> [{id, parties, date}] (max 200)."""
186+ recher = f"{TRIBUNAL_ID}_{query}_0___date"
187+ target = SOQUIJ_BASE + "/index.php?" + urllib.parse.urlencode(
188+ {"type": "listemc", "recher": recher},
189+ encoding="iso-8859-1", errors="replace")
190+ content = _scrapfly(target, render=True, session=session)
191+ text = _to_text(content)
192+ if "Décisions trouvées" not in text and "Aucun résultat" not in text:
193+ raise RuntimeError("page de résultats SOQUIJ non reconnue (anti-bot ?)")
194+ rows = []
195+ for m in re.finditer(
196+ r'href="(/php/decision\.php\?ID=([0-9A-Fa-f]+))"[^>]*>(.*?)</a>'
197+ r'.*?class="tb-date"[^>]*>\s*([\d-]{8,10})', content, re.S):
198+ rows.append({"id": m.group(2).upper(),
199+ "parties": _to_text(m.group(3)).strip(),
200+ "date": m.group(4),
201+ "url": SOQUIJ_BASE + m.group(1)})
202+ return rows
203+
204+
205+_RE_CITATION = re.compile(r"\b(\d{4}\s+QC(?:TAL|RDL)\s+\d+)\b")
206+
207+_TAGS = [
208+ ("non-paiement de loyer", ("non-paiement", "loyer impaye",
209+ "recouvrement du loyer", "ne paie pas le loyer",
210+ "loyers impayes")),
211+ ("résiliation de bail", ("resiliation",)),
212+ ("expulsion", ("expulsion",)),
213+ ("éviction", ("eviction",)),
214+ ("reprise du logement", ("reprise du logement", "reprise de logement")),
215+ ("fixation de loyer", ("fixation du loyer", "fixation de loyer")),
216+ ("insalubrité", ("insalubr",)),
217+ ("harcèlement", ("harcel",)),
218+]
219+
220+
221+def _classify(folded: str) -> tuple[str | None, list[str], str | None]:
222+ """Texte plié -> (partie demanderesse, tags, verdict sommaire)."""
223+ demandeur = None
224+ m = re.search(r"\b(locateurs?|locatrices?|locataires?)\b[^a-z]{0,40}"
225+ r"parties?\s+demanderesses?", folded)
226+ if m:
227+ demandeur = "locataire" if m.group(1).startswith("locata") else "locateur"
228+ tags = [tag for tag, needles in _TAGS
229+ if any(n in folded for n in needles)]
230+ verdict = None
231+ if re.search(r"\baccueille\b", folded):
232+ verdict = "accueillie"
233+ elif re.search(r"\brejette la demande\b", folded):
234+ verdict = "rejetée"
235+ return demandeur, tags, verdict
236+
237+
238+def _address_in_text(folded: str, civic: str, core: list[str]) -> bool:
239+ """Le no civique suivi de près par le nom de la rue ? (adresse confirmée)"""
240+ name = _fold(" ".join(core))
241+ pat = rf"\b{re.escape(civic.lower())}\b.{{0,80}}?{re.escape(name)}"
242+ if re.search(pat, folded):
243+ return True
244+ # tolérance : dernier mot du nom seulement (ex. « st-jacques »)
245+ last = _fold(core[-1])
246+ return bool(len(last) >= 4 and
247+ re.search(rf"\b{re.escape(civic.lower())}\b.{{0,60}}?{re.escape(last)}", folded))
248+
249+
250+def _check_address(parsed: dict, session: str) -> dict:
251+ """Recherche + vérification pour UNE adresse. -> résultat à persister."""
252+ rows = _search(parsed["query"], session)
253+ decisions = []
254+ for r in rows[:MAX_DECISIONS]:
255+ try:
256+ page = _scrapfly(r["url"], render=False, session=session)
257+ except Exception:
258+ continue
259+ text = _to_text(page)
260+ folded = _fold(text)
261+ if not _address_in_text(folded, parsed["civic"], parsed["core"]):
262+ continue
263+ m = _RE_CITATION.search(text)
264+ demandeur, tags, verdict = _classify(folded)
265+ decisions.append({
266+ "decision_id": r["id"],
267+ "citation": re.sub(r"\s+", " ", m.group(1)) if m else None,
268+ "parties": r["parties"], "date": r["date"],
269+ "demandeur": demandeur, "tags": tags, "verdict": verdict,
270+ "url": r["url"]})
271+ return {"found": len(rows), "decisions": decisions}
272+
273+
274+# ---------------------------------------------------------------------------
275+# Persistance
276+# ---------------------------------------------------------------------------
277+
278+_lock = threading.Lock()
279+
280+
281+def _connect(ro: bool = False) -> sqlite3.Connection:
282+ if ro:
283+ con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=15)
284+ else:
285+ DATA_DIR.mkdir(parents=True, exist_ok=True)
286+ con = sqlite3.connect(DB_PATH, timeout=30)
287+ con.executescript(_SCHEMA)
288+ con.row_factory = sqlite3.Row
289+ return con
290+
291+
292+def _save(con: sqlite3.Connection, parsed: dict, status: str,
293+ found: int = 0, decisions: list[dict] | None = None,
294+ error: str | None = None) -> None:
295+ now = time.strftime("%Y-%m-%d %H:%M:%S")
296+ with con:
297+ con.execute(
298+ "INSERT OR REPLACE INTO tal_lookup "
299+ "(addr_key, civic, street, city, query, status, found, matched,"
300+ " priority, checked_at, error) VALUES (?,?,?,?,?,?,?,?,0,?,?)",
301+ (parsed["key"], parsed["civic"], parsed["street"], parsed["city"],
302+ parsed["query"], status, found, len(decisions or []), now, error))
303+ con.execute("DELETE FROM tal_decisions WHERE addr_key=?",
304+ (parsed["key"],))
305+ for d in decisions or []:
306+ con.execute(
307+ "INSERT OR REPLACE INTO tal_decisions VALUES (?,?,?,?,?,?,?,?,?)",
308+ (parsed["key"], d["decision_id"], d["citation"], d["parties"],
309+ d["date"], d["demandeur"], json.dumps(d["tags"],
310+ ensure_ascii=False), d["verdict"], d["url"]))
311+
312+
313+# ---------------------------------------------------------------------------
314+# API de lecture (fiche / web.py)
315+# ---------------------------------------------------------------------------
316+
317+def lookup(address: str | None, city: str | None = None) -> dict:
318+ """Historique TAL d'une adresse — lecture du cache ; une adresse inconnue
319+ est mise en file prioritaire (traitée au prochain passage du watch)."""
320+ parsed = parse_address(address, city)
321+ if parsed is None:
322+ return {"status": "na"}
323+ con = _connect()
324+ try:
325+ row = con.execute("SELECT * FROM tal_lookup WHERE addr_key=?",
326+ (parsed["key"],)).fetchone()
327+ if row is None:
328+ with _lock, con:
329+ con.execute(
330+ "INSERT OR IGNORE INTO tal_lookup "
331+ "(addr_key, civic, street, city, query, status, priority) "
332+ "VALUES (?,?,?,?,?,'queued',1)",
333+ (parsed["key"], parsed["civic"], parsed["street"],
334+ parsed["city"], parsed["query"]))
335+ return {"status": "pending"}
336+ if row["status"] != "ok":
337+ return {"status": "pending" if row["status"] == "queued"
338+ else "error"}
339+ decs = [dict(r) for r in con.execute(
340+ "SELECT * FROM tal_decisions WHERE addr_key=? ORDER BY date DESC",
341+ (parsed["key"],)).fetchall()]
342+ finally:
343+ con.close()
344+ items = [{"date": d["date"], "citation": d["citation"],
345+ "demandeur": d["demandeur"], "tags": json.loads(d["tags"] or "[]"),
346+ "verdict": d["verdict"], "url": d["url"]} for d in decs]
347+ contre = sum(1 for d in items if d["demandeur"] == "locateur")
348+ evict = sum(1 for d in items if d["demandeur"] == "locateur"
349+ and set(d["tags"]) &
350+ {"expulsion", "éviction", "reprise du logement",
351+ "résiliation de bail", "non-paiement de loyer"})
352+ return {"status": "ok", "checked_at": row["checked_at"],
353+ "n": len(items), "contre_locataire": contre, "eviction": evict,
354+ "last_date": items[0]["date"] if items else None,
355+ "decisions": items}
356+
357+
358+# ---------------------------------------------------------------------------
359+# Précalcul par lots (boucle watch / run.py tal)
360+# ---------------------------------------------------------------------------
361+
362+def _candidates(con: sqlite3.Connection, limit: int) -> list[dict]:
363+ """File prioritaire, puis adresses jamais vérifiées (annonces récentes
364+ d'abord), puis revérifications périmées."""
365+ out: list[dict] = []
366+ seen: set[str] = set()
367+
368+ for r in con.execute(
369+ "SELECT * FROM tal_lookup WHERE status='queued' "
370+ "ORDER BY priority DESC LIMIT ?", (limit,)):
371+ p = parse_address(f"{r['civic']} {r['street']}", r["city"])
372+ if p and p["key"] not in seen:
373+ seen.add(p["key"])
374+ out.append(p)
375+ if len(out) >= limit:
376+ return out[:limit]
377+
378+ known = {r["addr_key"]: r for r in con.execute(
379+ "SELECT addr_key, status, checked_at FROM tal_lookup")}
380+ stale_ok = time.strftime("%Y-%m-%d %H:%M:%S",
381+ time.localtime(time.time() - REFRESH_DAYS * 86400))
382+ stale_err = time.strftime("%Y-%m-%d %H:%M:%S",
383+ time.localtime(time.time() - ERROR_RETRY_DAYS * 86400))
384+ try:
385+ lk = sqlite3.connect(f"file:{LOUKA_DB}?mode=ro", uri=True, timeout=15)
386+ lk.row_factory = sqlite3.Row
387+ rows = lk.execute(
388+ "SELECT DISTINCT address, city FROM listings "
389+ "WHERE active=1 AND published=1 AND address IS NOT NULL "
390+ "AND address <> '' ORDER BY rowid DESC").fetchall()
391+ lk.close()
392+ except Exception:
393+ rows = []
394+ for r in rows:
395+ if len(out) >= limit:
396+ break
397+ p = parse_address(r["address"], r["city"])
398+ if p is None or p["key"] in seen:
399+ continue
400+ k = known.get(p["key"])
401+ if k is not None:
402+ if k["status"] == "ok" and (k["checked_at"] or "") > stale_ok:
403+ continue
404+ if k["status"] == "error" and (k["checked_at"] or "") > stale_err:
405+ continue
406+ if k["status"] == "queued":
407+ continue # déjà compté plus haut
408+ seen.add(p["key"])
409+ out.append(p)
410+ return out
411+
412+
413+def precompute(limit: int = 60, budget: int = 600) -> dict:
414+ """Vérifie jusqu'à `limit` adresses (budget en secondes, WORKERS fils)."""
415+ t0 = time.time()
416+ con = _connect()
417+ cands = _candidates(con, limit)
418+ if not cands:
419+ con.close()
420+ return {"checked": 0, "matched": 0, "todo": 0}
421+ checked = matched = errors = 0
422+ with ThreadPoolExecutor(max_workers=WORKERS) as pool:
423+ futs = {}
424+ for i, p in enumerate(cands):
425+ if time.time() - t0 > budget:
426+ break
427+ futs[pool.submit(_check_address, p,
428+ f"louka-tal-{i % WORKERS}")] = p
429+ for fut in as_completed(futs):
430+ p = futs[fut]
431+ try:
432+ res = fut.result()
433+ _save(con, p, "ok", res["found"], res["decisions"])
434+ checked += 1
435+ matched += len(res["decisions"])
436+ if res["decisions"]:
437+ print(f"[tal] {p['civic']} {p['street']} ({p['city']}) : "
438+ f"{len(res['decisions'])} décision(s) confirmée(s)")
439+ except Exception as exc:
440+ _save(con, p, "error", error=str(exc)[:300])
441+ errors += 1
442+ con.close()
443+ stats = {"checked": checked, "matched": matched, "errors": errors,
444+ "seconds": round(time.time() - t0, 1)}
445+ print(f"[tal] {stats}")
446+ return stats
modified louka/web.py +9 −0
@@ -601,6 +601,15 @@ def rdl_nearby(lat: float, lng: float, radius: int = 600):
601 return d 601 return d
602 602
603 603
604+@app.get("/api/tal")
605+def tal_history(address: str, city: str | None = None):
606+ """Historique au Tribunal administratif du logement (décisions SOQUIJ)
607+ à l'adresse de l'annonce — bloc « Historique TAL » de la fiche.
608+ Une adresse jamais vérifiée est mise en file prioritaire (status pending)."""
609+ from . import tal
610+ return tal.lookup(address, city)
611+
612+
604 @app.get("/api/listings/{uid}") 613 @app.get("/api/listings/{uid}")
605 def get_listing(uid: str): 614 def get_listing(uid: str):
606 con = db.connect() 615 con = db.connect()
modified run.py +6 −0
@@ -110,6 +110,12 @@ def main() -> None:
110 elif cmd == "rdl": 110 elif cmd == "rdl":
111 from louka import rdl 111 from louka import rdl
112 rdl.refresh() 112 rdl.refresh()
113+ elif cmd == "tal":
114+ # historique TAL (décisions SOQUIJ) par adresse, par lots
115+ from louka import tal
116+ n = int(sys.argv[2]) if len(sys.argv) > 2 else 60
117+ b = int(sys.argv[3]) if len(sys.argv) > 3 else 600
118+ tal.precompute(limit=n, budget=b)
113 elif cmd == "record": 119 elif cmd == "record":
114 from louka import fixtures 120 from louka import fixtures
115 from louka.connectors import CONNECTORS 121 from louka.connectors import CONNECTORS
116 122
Menu

Theme