# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # tal.py : historique au Tribunal administratif du logement (TAL / ex-RDL) # # Les décisions du TAL sont diffusées gratuitement par SOQUIJ # (citoyens.soquij.qc.ca, tribunal no 35, plein texte depuis ~2009). Le site # est protégé par une détection de bot (byscuit) : la RECHERCHE exige un # rendu JS + IP résidentielle (Scrapfly, asp + render_js + session), mais la # page d'une DÉCISION se charge ensuite sans rendu (même session = moins # cher). Le moteur ne supporte pas les phrases entre guillemets : les mots # sont combinés en ET implicite, d'où la stratégie # recherche « » # puis VÉRIFICATION de l'adresse exacte dans le texte intégral. # # Tout est mis en cache dans data/tal.db (base séparée : jamais de verrou # sur louka.db) par adresse normalisée (no civique + rue + ville) : # · tal_lookup : une ligne par adresse vérifiée (ou en file d'attente) ; # · tal_decisions : décisions confirmées à l'adresse, classées par mots # clés (non-paiement, résiliation, expulsion, reprise…) # et par partie demanderesse (locateur vs locataire). # # Usage : python run.py tal [n] [budget_s] # précalcul par lots # Lecture : lookup(address, city) — bloc « Historique TAL » de la fiche # (/api/tal) ; une adresse jamais vérifiée est mise en file # prioritaire et traitée au prochain passage du watch. # ----------------------------------------------------------------------------- from __future__ import annotations import html import json import os import re import sqlite3 import threading import time import unicodedata import urllib.parse import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path DATA_DIR = Path(__file__).resolve().parent.parent / "data" DB_PATH = DATA_DIR / "tal.db" LOUKA_DB = DATA_DIR / "louka.db" SCRAPFLY_API = "https://api.scrapfly.io/scrape" SOQUIJ_BASE = "https://citoyens.soquij.qc.ca" TRIBUNAL_ID = "35" # Tribunal administratif du logement / RDL REFRESH_DAYS = 180 # revérifier une adresse après ~6 mois ERROR_RETRY_DAYS = 7 # réessayer plus vite après une erreur MAX_DECISIONS = 12 # décisions lues au maximum par adresse WORKERS = 4 _SCHEMA = """ CREATE TABLE IF NOT EXISTS tal_lookup ( addr_key TEXT PRIMARY KEY, civic TEXT, street TEXT, city TEXT, query TEXT, status TEXT, -- queued | ok | error found INTEGER, -- résultats bruts SOQUIJ (avant vérification) matched INTEGER, -- décisions confirmées à l'adresse priority INTEGER DEFAULT 0, -- 1 = demandé par une fiche (file rapide) checked_at TEXT, error TEXT ); CREATE TABLE IF NOT EXISTS tal_decisions ( addr_key TEXT, decision_id TEXT, -- ID hexadécimal SOQUIJ citation TEXT, -- ex. « 2026 QCTAL 21509 » parties TEXT, date TEXT, demandeur TEXT, -- locateur | locataire | NULL (indéterminé) tags TEXT, -- JSON : ["non-paiement", "résiliation de bail", …] verdict TEXT, -- accueillie | rejetée | NULL url TEXT, PRIMARY KEY (addr_key, decision_id) ); """ # --------------------------------------------------------------------------- # Normalisation d'adresse # --------------------------------------------------------------------------- _TYPES_VOIE = { "rue", "avenue", "av", "ave", "boulevard", "boul", "blvd", "bd", "chemin", "ch", "place", "pl", "cote", "allee", "montee", "carre", "impasse", "terrasse", "tsse", "croissant", "crois", "rang", "route", "rte", "promenade", "prom", "voie", "square", "cours", "sentier", "parc", # types anglais (annonces montréalaises) "st", "street", "rd", "road", "dr", "drive", "cres", "crescent", "ct", "court", "lane", "ln", "hwy", "way", } _STOPWORDS = {"de", "du", "des", "la", "le", "les", "l", "d", "a", "au", "aux", "app", "apt", "unite", "suite", "bureau", "local"} def _fold(s: str) -> str: """Minuscules sans accents (comparaisons et clés).""" s = unicodedata.normalize("NFD", s) return "".join(c for c in s if not unicodedata.combining(c)).lower() def parse_address(address: str | None, city: str | None = None) -> dict | None: """« 1573, rue Pierre-Corneille, Québec, Québec, G2E4W9 » -> civic + rue + mots significatifs pour la recherche SOQUIJ.""" if not address: return None parts = [p.strip() for p in address.split(",") if p.strip()] if not parts: return None m = re.match(r"^(\d+[a-zA-Z]?)\b[\s,]*(.*)$", parts[0]) if not m: return None civic = m.group(1) street = m.group(2).strip() if not street and len(parts) > 1: # « 1573 » puis « rue X » à part street = parts[1] if not street: return None ville = (city or "").strip() if not ville and len(parts) > 2: for p in parts[1:]: # sauter « Suite 1106 », « app. 4 »… if not re.match(r"^(app|apt|suite|unit|unité|bureau|local|#|\d)", p, re.I): ville = p break toks = [t for t in re.split(r"[^\w-]+", street) if t] core = [t for t in toks if _fold(t).rstrip(".") not in _TYPES_VOIE and _fold(t) not in _STOPWORDS and len(t) >= 2 and not re.fullmatch(r"\d+[a-zA-Z]?", t)] # no d'unité (« 16F ») if not core: # ex. « 1re Avenue » : tout garder core = toks if not core: return None key = f"{civic}|{_fold(' '.join(core))}|{_fold(ville)}" return {"civic": civic, "street": street, "city": ville, "core": core, "query": f"{civic} {' '.join(core)}", "key": key} # --------------------------------------------------------------------------- # Accès SOQUIJ via Scrapfly # --------------------------------------------------------------------------- def _scrapfly(url: str, *, render: bool, session: str) -> str: key = os.environ.get("SCRAPFLY_KEY") if not key: raise RuntimeError("SCRAPFLY_KEY absent de l'environnement") params = {"key": key, "url": url, "asp": "true", "country": "ca", "proxy_pool": "public_residential_pool", "session": session} if render: params["render_js"] = "true" params["rendering_wait"] = "5000" u = SCRAPFLY_API + "?" + urllib.parse.urlencode(params) data = None for attempt in range(3): try: with urllib.request.urlopen(u, timeout=170) as resp: data = json.load(resp) break except urllib.error.HTTPError as exc: # 429 = limite de concurrence Scrapfly : attendre puis réessayer if exc.code == 429 and attempt < 2: time.sleep(8 * (attempt + 1)) continue raise res = (data or {}).get("result") or {} if res.get("status_code") != 200: raise RuntimeError(f"SOQUIJ HTTP {res.get('status_code')}") return res.get("content") or "" def _to_text(html_doc: str) -> str: """HTML -> texte plat, entités décodées, espaces normalisés.""" doc = re.sub(r"|", " ", html_doc, flags=re.S | re.I) doc = re.sub(r"<[^>]+>", " ", doc) doc = html.unescape(doc).replace("\xa0", " ") return re.sub(r"\s+", " ", doc) def _search(query: str, session: str) -> list[dict]: """Recherche plein texte TAL ; -> [{id, parties, date}] (max 200).""" recher = f"{TRIBUNAL_ID}_{query}_0___date" target = SOQUIJ_BASE + "/index.php?" + urllib.parse.urlencode( {"type": "listemc", "recher": recher}, encoding="iso-8859-1", errors="replace") content = _scrapfly(target, render=True, session=session) text = _to_text(content) if "Décisions trouvées" not in text and "Aucun résultat" not in text: raise RuntimeError("page de résultats SOQUIJ non reconnue (anti-bot ?)") rows = [] for m in re.finditer( r'href="(/php/decision\.php\?ID=([0-9A-Fa-f]+))"[^>]*>(.*?)' r'.*?class="tb-date"[^>]*>\s*([\d-]{8,10})', content, re.S): rows.append({"id": m.group(2).upper(), "parties": _to_text(m.group(3)).strip(), "date": m.group(4), "url": SOQUIJ_BASE + m.group(1)}) return rows _RE_CITATION = re.compile(r"\b(\d{4}\s+QC(?:TAL|RDL)\s+\d+)\b") _TAGS = [ ("non-paiement de loyer", ("non-paiement", "loyer impaye", "recouvrement du loyer", "ne paie pas le loyer", "loyers impayes")), ("résiliation de bail", ("resiliation",)), ("expulsion", ("expulsion",)), ("éviction", ("eviction",)), ("reprise du logement", ("reprise du logement", "reprise de logement")), ("fixation de loyer", ("fixation du loyer", "fixation de loyer")), ("insalubrité", ("insalubr",)), ("harcèlement", ("harcel",)), ] def _classify(folded: str) -> tuple[str | None, list[str], str | None]: """Texte plié -> (partie demanderesse, tags, verdict sommaire).""" demandeur = None m = re.search(r"\b(locateurs?|locatrices?|locataires?)\b[^a-z]{0,40}" r"parties?\s+demanderesses?", folded) if m: demandeur = "locataire" if m.group(1).startswith("locata") else "locateur" tags = [tag for tag, needles in _TAGS if any(n in folded for n in needles)] verdict = None if re.search(r"\baccueille\b", folded): verdict = "accueillie" elif re.search(r"\brejette la demande\b", folded): verdict = "rejetée" return demandeur, tags, verdict def _address_in_text(folded: str, civic: str, core: list[str]) -> bool: """Le no civique suivi de près par le nom de la rue ? (adresse confirmée)""" name = _fold(" ".join(core)) pat = rf"\b{re.escape(civic.lower())}\b.{{0,80}}?{re.escape(name)}" if re.search(pat, folded): return True # tolérance : dernier mot du nom seulement (ex. « st-jacques ») last = _fold(core[-1]) return bool(len(last) >= 4 and re.search(rf"\b{re.escape(civic.lower())}\b.{{0,60}}?{re.escape(last)}", folded)) def _check_address(parsed: dict, session: str) -> dict: """Recherche + vérification pour UNE adresse. -> résultat à persister.""" rows = _search(parsed["query"], session) decisions = [] for r in rows[:MAX_DECISIONS]: try: page = _scrapfly(r["url"], render=False, session=session) except Exception: continue text = _to_text(page) folded = _fold(text) if not _address_in_text(folded, parsed["civic"], parsed["core"]): continue m = _RE_CITATION.search(text) demandeur, tags, verdict = _classify(folded) decisions.append({ "decision_id": r["id"], "citation": re.sub(r"\s+", " ", m.group(1)) if m else None, "parties": r["parties"], "date": r["date"], "demandeur": demandeur, "tags": tags, "verdict": verdict, "url": r["url"]}) return {"found": len(rows), "decisions": decisions} # --------------------------------------------------------------------------- # Persistance # --------------------------------------------------------------------------- _lock = threading.Lock() def _connect(ro: bool = False) -> sqlite3.Connection: if ro: con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True, timeout=15) else: DATA_DIR.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(DB_PATH, timeout=30) con.executescript(_SCHEMA) con.row_factory = sqlite3.Row return con def _save(con: sqlite3.Connection, parsed: dict, status: str, found: int = 0, decisions: list[dict] | None = None, error: str | None = None) -> None: now = time.strftime("%Y-%m-%d %H:%M:%S") with con: con.execute( "INSERT OR REPLACE INTO tal_lookup " "(addr_key, civic, street, city, query, status, found, matched," " priority, checked_at, error) VALUES (?,?,?,?,?,?,?,?,0,?,?)", (parsed["key"], parsed["civic"], parsed["street"], parsed["city"], parsed["query"], status, found, len(decisions or []), now, error)) con.execute("DELETE FROM tal_decisions WHERE addr_key=?", (parsed["key"],)) for d in decisions or []: con.execute( "INSERT OR REPLACE INTO tal_decisions VALUES (?,?,?,?,?,?,?,?,?)", (parsed["key"], d["decision_id"], d["citation"], d["parties"], d["date"], d["demandeur"], json.dumps(d["tags"], ensure_ascii=False), d["verdict"], d["url"])) # --------------------------------------------------------------------------- # API de lecture (fiche / web.py) # --------------------------------------------------------------------------- def lookup(address: str | None, city: str | None = None) -> dict: """Historique TAL d'une adresse — lecture du cache ; une adresse inconnue est mise en file prioritaire (traitée au prochain passage du watch).""" parsed = parse_address(address, city) if parsed is None: return {"status": "na"} con = _connect() try: row = con.execute("SELECT * FROM tal_lookup WHERE addr_key=?", (parsed["key"],)).fetchone() if row is None: with _lock, con: con.execute( "INSERT OR IGNORE INTO tal_lookup " "(addr_key, civic, street, city, query, status, priority) " "VALUES (?,?,?,?,?,'queued',1)", (parsed["key"], parsed["civic"], parsed["street"], parsed["city"], parsed["query"])) return {"status": "pending"} if row["status"] != "ok": return {"status": "pending" if row["status"] == "queued" else "error"} decs = [dict(r) for r in con.execute( "SELECT * FROM tal_decisions WHERE addr_key=? ORDER BY date DESC", (parsed["key"],)).fetchall()] finally: con.close() items = [{"date": d["date"], "citation": d["citation"], "demandeur": d["demandeur"], "tags": json.loads(d["tags"] or "[]"), "verdict": d["verdict"], "url": d["url"]} for d in decs] contre = sum(1 for d in items if d["demandeur"] == "locateur") evict = sum(1 for d in items if d["demandeur"] == "locateur" and set(d["tags"]) & {"expulsion", "éviction", "reprise du logement", "résiliation de bail", "non-paiement de loyer"}) return {"status": "ok", "checked_at": row["checked_at"], "n": len(items), "contre_locataire": contre, "eviction": evict, "last_date": items[0]["date"] if items else None, "decisions": items} # --------------------------------------------------------------------------- # Précalcul par lots (boucle watch / run.py tal) # --------------------------------------------------------------------------- def _candidates(con: sqlite3.Connection, limit: int) -> list[dict]: """File prioritaire, puis adresses jamais vérifiées (annonces récentes d'abord), puis revérifications périmées.""" out: list[dict] = [] seen: set[str] = set() for r in con.execute( "SELECT * FROM tal_lookup WHERE status='queued' " "ORDER BY priority DESC LIMIT ?", (limit,)): p = parse_address(f"{r['civic']} {r['street']}", r["city"]) if p and p["key"] not in seen: seen.add(p["key"]) out.append(p) if len(out) >= limit: return out[:limit] known = {r["addr_key"]: r for r in con.execute( "SELECT addr_key, status, checked_at FROM tal_lookup")} stale_ok = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - REFRESH_DAYS * 86400)) stale_err = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - ERROR_RETRY_DAYS * 86400)) try: lk = sqlite3.connect(f"file:{LOUKA_DB}?mode=ro", uri=True, timeout=15) lk.row_factory = sqlite3.Row rows = lk.execute( "SELECT DISTINCT address, city FROM listings " "WHERE active=1 AND published=1 AND address IS NOT NULL " "AND address <> '' ORDER BY rowid DESC").fetchall() lk.close() except Exception: rows = [] for r in rows: if len(out) >= limit: break p = parse_address(r["address"], r["city"]) if p is None or p["key"] in seen: continue k = known.get(p["key"]) if k is not None: if k["status"] == "ok" and (k["checked_at"] or "") > stale_ok: continue if k["status"] == "error" and (k["checked_at"] or "") > stale_err: continue if k["status"] == "queued": continue # déjà compté plus haut seen.add(p["key"]) out.append(p) return out def precompute(limit: int = 60, budget: int = 600) -> dict: """Vérifie jusqu'à `limit` adresses (budget en secondes, WORKERS fils).""" t0 = time.time() con = _connect() cands = _candidates(con, limit) if not cands: con.close() return {"checked": 0, "matched": 0, "todo": 0} checked = matched = errors = 0 with ThreadPoolExecutor(max_workers=WORKERS) as pool: futs = {} for i, p in enumerate(cands): if time.time() - t0 > budget: break futs[pool.submit(_check_address, p, f"louka-tal-{i % WORKERS}")] = p for fut in as_completed(futs): p = futs[fut] try: res = fut.result() _save(con, p, "ok", res["found"], res["decisions"]) checked += 1 matched += len(res["decisions"]) if res["decisions"]: print(f"[tal] {p['civic']} {p['street']} ({p['city']}) : " f"{len(res['decisions'])} décision(s) confirmée(s)") except Exception as exc: _save(con, p, "error", error=str(exc)[:300]) errors += 1 con.close() stats = {"checked": checked, "matched": matched, "errors": errors, "seconds": round(time.time() - t0, 1)} print(f"[tal] {stats}") return stats