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%
18.7 KB · 447 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# tal.py : historique au Tribunal administratif du logement (TAL / ex-RDL)5#6#   Les décisions du TAL sont diffusées gratuitement par SOQUIJ7#   (citoyens.soquij.qc.ca, tribunal no 35, plein texte depuis ~2009). Le site8#   est protégé par une détection de bot (byscuit) : la RECHERCHE exige un9#   rendu JS + IP résidentielle (Scrapfly, asp + render_js + session), mais la10#   page d'une DÉCISION se charge ensuite sans rendu (même session = moins11#   cher). Le moteur ne supporte pas les phrases entre guillemets : les mots12#   sont combinés en ET implicite, d'où la stratégie13#       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 verrou17#   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 mots20#                       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 lots24#   Lecture : lookup(address, city) — bloc « Historique TAL » de la fiche25#             (/api/tal) ; une adresse jamais vérifiée est mise en file26#             prioritaire et traitée au prochain passage du watch.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import html31import json32import os33import re34import sqlite335import threading36import time37import unicodedata38import urllib.parse39import urllib.request40from concurrent.futures import ThreadPoolExecutor, as_completed41from pathlib import Path4243DATA_DIR = Path(__file__).resolve().parent.parent / "data"44DB_PATH = DATA_DIR / "tal.db"45LOUKA_DB = DATA_DIR / "louka.db"4647SCRAPFLY_API = "https://api.scrapfly.io/scrape"48SOQUIJ_BASE = "https://citoyens.soquij.qc.ca"49TRIBUNAL_ID = "35"                 # Tribunal administratif du logement / RDL5051REFRESH_DAYS = 180                 # revérifier une adresse après ~6 mois52ERROR_RETRY_DAYS = 7               # réessayer plus vite après une erreur53MAX_DECISIONS = 12                 # décisions lues au maximum par adresse54WORKERS = 45556_SCHEMA = """57CREATE 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 | error61    found      INTEGER,            -- résultats bruts SOQUIJ (avant vérification)62    matched    INTEGER,            -- décisions confirmées à l'adresse63    priority   INTEGER DEFAULT 0,  -- 1 = demandé par une fiche (file rapide)64    checked_at TEXT,65    error      TEXT66);67CREATE TABLE IF NOT EXISTS tal_decisions (68    addr_key    TEXT,69    decision_id TEXT,               -- ID hexadécimal SOQUIJ70    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 | NULL76    url         TEXT,77    PRIMARY KEY (addr_key, decision_id)78);79"""8081# ---------------------------------------------------------------------------82# Normalisation d'adresse83# ---------------------------------------------------------------------------8485_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"}969798def _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()102103104def 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 None109    parts = [p.strip() for p in address.split(",") if p.strip()]110    if not parts:111        return None112    m = re.match(r"^(\d+[a-zA-Z]?)\b[\s,]*(.*)$", parts[0])113    if not m:114        return None115    civic = m.group(1)116    street = m.group(2).strip()117    if not street and len(parts) > 1:          # « 1573 » puis « rue X » à part118        street = parts[1]119    if not street:120        return None121    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 = p127                break128129    toks = [t for t in re.split(r"[^\w-]+", street) if t]130    core = [t for t in toks131            if _fold(t).rstrip(".") not in _TYPES_VOIE132            and _fold(t) not in _STOPWORDS and len(t) >= 2133            and not re.fullmatch(r"\d+[a-zA-Z]?", t)]   # no d'unité (« 16F »)134    if not core:                               # ex. « 1re Avenue » : tout garder135        core = toks136    if not core:137        return None138    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}141142143# ---------------------------------------------------------------------------144# Accès SOQUIJ via Scrapfly145# ---------------------------------------------------------------------------146147def _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 = None158    for attempt in range(3):159        try:160            with urllib.request.urlopen(u, timeout=170) as resp:161                data = json.load(resp)162            break163        except urllib.error.HTTPError as exc:164            # 429 = limite de concurrence Scrapfly : attendre puis réessayer165            if exc.code == 429 and attempt < 2:166                time.sleep(8 * (attempt + 1))167                continue168            raise169    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 ""173174175def _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)182183184def _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 rows203204205_RE_CITATION = re.compile(r"\b(\d{4}\s+QC(?:TAL|RDL)\s+\d+)\b")206207_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]219220221def _classify(folded: str) -> tuple[str | None, list[str], str | None]:222    """Texte plié -> (partie demanderesse, tags, verdict sommaire)."""223    demandeur = None224    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 _TAGS229            if any(n in folded for n in needles)]230    verdict = None231    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, verdict236237238def _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 True244    # tolérance : dernier mot du nom seulement (ex. « st-jacques »)245    last = _fold(core[-1])246    return bool(len(last) >= 4 and247                re.search(rf"\b{re.escape(civic.lower())}\b.{{0,60}}?{re.escape(last)}", folded))248249250def _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            continue259        text = _to_text(page)260        folded = _fold(text)261        if not _address_in_text(folded, parsed["civic"], parsed["core"]):262            continue263        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}272273274# ---------------------------------------------------------------------------275# Persistance276# ---------------------------------------------------------------------------277278_lock = threading.Lock()279280281def _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.Row289    return con290291292def _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"]))311312313# ---------------------------------------------------------------------------314# API de lecture (fiche / web.py)315# ---------------------------------------------------------------------------316317def lookup(address: str | None, city: str | None = None) -> dict:318    """Historique TAL d'une adresse — lecture du cache ; une adresse inconnue319    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}356357358# ---------------------------------------------------------------------------359# Précalcul par lots (boucle watch / run.py tal)360# ---------------------------------------------------------------------------361362def _candidates(con: sqlite3.Connection, limit: int) -> list[dict]:363    """File prioritaire, puis adresses jamais vérifiées (annonces récentes364    d'abord), puis revérifications périmées."""365    out: list[dict] = []366    seen: set[str] = set()367368    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]377378    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.Row387        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            break397        p = parse_address(r["address"], r["city"])398        if p is None or p["key"] in seen:399            continue400        k = known.get(p["key"])401        if k is not None:402            if k["status"] == "ok" and (k["checked_at"] or "") > stale_ok:403                continue404            if k["status"] == "error" and (k["checked_at"] or "") > stale_err:405                continue406            if k["status"] == "queued":407                continue                      # déjà compté plus haut408        seen.add(p["key"])409        out.append(p)410    return out411412413def 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 = 0422    with ThreadPoolExecutor(max_workers=WORKERS) as pool:423        futs = {}424        for i, p in enumerate(cands):425            if time.time() - t0 > budget:426                break427            futs[pool.submit(_check_address, p,428                             f"louka-tal-{i % WORKERS}")] = p429        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 += 1435                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 += 1442    con.close()443    stats = {"checked": checked, "matched": matched, "errors": errors,444             "seconds": round(time.time() - t0, 1)}445    print(f"[tal] {stats}")446    return stats447