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%
7.4 KB · 186 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# recycled.py : détection des annonces RECYCLÉES — le même logement republié5#               (souvent plus cher) après un passage antérieur sur le marché.6#7# Méthode : les candidates sont les annonces INACTIVES du même immeuble8# (building_key — même clé d'adresse que la déduplication). Chaque paire reçoit9# un score de similarité 0–100 fondé sur des signaux indépendants :10#   numéro d'unité identique, type d'unité, chambres, superficie (±3 %),11#   photos identiques (hash SHA1 du contrôle d'images), texte de description12#   (similarité de Jaccard sur les mots).13# Seules les correspondances ≥ SEUIL_AFFICHAGE sont retournées, avec les14# signaux qui les fondent (inférence explicable, jamais de fusion automatique).15# Cache : table recycled_cache (TTL 7 jours — recalcul léger, borné à16# l'immeuble).17# -----------------------------------------------------------------------------18from __future__ import annotations1920import json21import re22import time2324from . import db2526TTL = 7 * 8640027SEUIL_AFFICHAGE = 70     # score minimal pour présenter une correspondance28MAX_CANDIDATES = 200     # garde-fou (tours à très fort volume)2930_WORD = re.compile(r"[a-zà-ÿ0-9]{3,}")313233def _tokens(text: str | None) -> set[str]:34    return set(_WORD.findall((text or "").lower()))353637def _jaccard(a: set[str], b: set[str]) -> float:38    if not a or not b:39        return 0.040    return len(a & b) / len(a | b)414243def _sha1_set(con, images_json: str | None) -> set[str]:44    try:45        urls = json.loads(images_json or "[]")[:20]46    except (ValueError, TypeError):47        return set()48    if not urls:49        return set()50    marks = ",".join("?" * len(urls))51    return {r["sha1"] for r in con.execute(52        f"SELECT sha1 FROM image_checks WHERE url IN ({marks})"53        " AND sha1 IS NOT NULL", urls)}545556def _unit_of(address: str | None, city: str | None) -> str | None:57    from .dedup import _parse_address58    return _parse_address(address or "", city or "")[1]596061def _score(con, cur: dict, cand: dict) -> tuple[int, list[str]]:62    """Score 0–100 + signaux lisibles justifiant la correspondance."""63    score = 064    signals: list[str] = []65    if cur["unit"] and cand["unit"]:66        if cur["unit"] == cand["unit"]:67            score += 3568            signals.append(f"même numéro d'unité ({cur['unit']})")69        else:70            return 0, []          # unités connues et différentes : pas le même71    if cur["unit_type"] and cand["unit_type"]:72        if cur["unit_type"] == cand["unit_type"]:73            score += 1074            signals.append(f"même type ({cur['unit_type']})")75        else:76            return 0, []77    if cur["bedrooms"] is not None and cand["bedrooms"] is not None:78        if cur["bedrooms"] == cand["bedrooms"]:79            score += 880            signals.append("même nombre de chambres")81        else:82            return 0, []83    if cur["area"] and cand["area"]:84        if abs(cur["area"] - cand["area"]) <= 0.03 * max(cur["area"], cand["area"]):85            score += 1586            signals.append(f"même superficie (≈{round(cand['area'])} pi²)")87        else:88            score -= 1089    # photos identiques (hash du contenu, pas l'URL)90    sh_cur = _sha1_set(con, cur["images"])91    sh_cand = _sha1_set(con, cand["images"])92    if sh_cur and sh_cand:93        overlap = len(sh_cur & sh_cand) / min(len(sh_cur), len(sh_cand))94        if overlap >= 0.5:95            score += 3096            signals.append(f"{len(sh_cur & sh_cand)} photo(s) identique(s)")97        elif overlap > 0:98            score += 1299            signals.append("photos partiellement identiques")100    # texte de description101    jac = _jaccard(_tokens(cur["description"]), _tokens(cand["description"]))102    if jac >= 0.7:103        score += 20104        signals.append("description quasi identique")105    elif jac >= 0.45:106        score += 10107        signals.append("description très similaire")108    # même source + même identifiant externe = republication certaine109    if cur["source"] == cand["source"]:110        score += 5111    return min(100, score), signals112113114def lookup(uid: str, con=None) -> dict:115    """Correspondances probables (annonces antérieures du même logement)."""116    own = con is None117    if own:118        con = db.connect()119    try:120        cached = con.execute(121            "SELECT matches, computed_at FROM recycled_cache WHERE uid=?",122            (uid,)).fetchone()123        if cached and time.time() - cached["computed_at"] < TTL:124            return json.loads(cached["matches"])125126        cur = con.execute(127            "SELECT uid, source, address, city, unit_type, bedrooms, price,"128            " area_sqft AS area, description, images, first_seen, building_key,"129            " dup_of FROM listings WHERE uid=?", (uid,)).fetchone()130        out: dict = {"matches": [], "statut": "inferred",131                     "methode": ("similarité multi-signaux (unité, type, "132                                 "chambres, superficie, photos par hash, "133                                 "texte) parmi les annonces antérieures du "134                                 "même immeuble")}135        if cur is None or not cur["building_key"]:136            _store(con, uid, out)137            return out138        cur_d = dict(cur)139        cur_d["unit"] = _unit_of(cur["address"], cur["city"])140141        # exclure le groupe de doublons inter-sources courant (même annonce)142        dup_group = {uid, cur["dup_of"] or ""}143        for r in con.execute("SELECT uid FROM listings WHERE dup_of=?", (uid,)):144            dup_group.add(r["uid"])145146        cands = con.execute(147            "SELECT uid, source, address, city, unit_type, bedrooms, price,"148            " area_sqft AS area, description, images, first_seen, last_seen"149            " FROM listings WHERE building_key=? AND active=0 AND uid<>?"150            " ORDER BY last_seen DESC LIMIT ?",151            (cur["building_key"], uid, MAX_CANDIDATES)).fetchall()152        matches = []153        for c in cands:154            if c["uid"] in dup_group:155                continue156            # une annonce antérieure = terminée avant l'apparition de l'actuelle157            if (c["last_seen"] or 0) > (cur["first_seen"] or 0) + 7 * 86400:158                continue159            cd = dict(c)160            cd["unit"] = _unit_of(c["address"], c["city"])161            score, signals = _score(con, cur_d, cd)162            if score >= SEUIL_AFFICHAGE:163                matches.append({164                    "uid": c["uid"], "source": c["source"],165                    "prix": c["price"], "unit_type": c["unit_type"],166                    "derniere_observation": c["last_seen"],167                    "premiere_observation": c["first_seen"],168                    "confiance": score, "signaux": signals,169                })170        matches.sort(key=lambda m: -m["confiance"])171        out["matches"] = matches[:5]172        _store(con, uid, out)173        return out174    finally:175        if own:176            con.close()177178179def _store(con, uid: str, out: dict) -> None:180    con.execute(181        "INSERT INTO recycled_cache (uid, matches, computed_at) VALUES (?,?,?)"182        " ON CONFLICT(uid) DO UPDATE SET matches=excluded.matches,"183        " computed_at=excluded.computed_at",184        (uid, json.dumps(out, ensure_ascii=False), time.time()))185    con.commit()186