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%
10.9 KB · 277 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# dedup.py : déduplication INTER-SOURCES — une même unité publiée sur plusieurs5#            plateformes (ex. un gestionnaire agrégé en direct ET via LogisQuébec,6#            ou un particulier sur Kijiji ET Facebook) ne doit apparaître qu'une7#            fois côté site.8#9# Principe (recalculé intégralement à chaque passe, idempotent) :10#   1. Blocage : regrouper les annonces actives par proximité (grille lat/lng11#      ~110 m + cases voisines) OU par clé d'adresse normalisée (n° civique +12#      rue sans type de voie + ville).13#   2. Confirmation d'une paire : même type d'unité (si connu des deux côtés),14#      prix à ±4 % (si connu), et signal géographique/adresse concordant.15#   3. Résolution : le membre de la source la PLUS autoritaire devient canonique16#      (dup_of=NULL) ; les autres pointent vers lui (dup_of=uid canonique). Le17#      canonique liste les autres plateformes dans dup_sources (badge « aussi18#      publiée sur… »). Rien n'est supprimé.19#20# Le web (web.py) n'affiche que les annonces canoniques : `WHERE dup_of IS NULL`.21# Appelé par ingest.watch() APRÈS le géocodage (les coordonnées sont l'ingrédient22# principal du blocage).23# -----------------------------------------------------------------------------24from __future__ import annotations2526import json2728from . import db29from .normalize import strip_accents3031# Autorité des sources : plus l'indice est BAS, plus la source est prioritaire32# comme canonique. 0 = gestionnaire/courtier en direct (donnée d'origine),33# 10 = portail agrégateur, 20 = petites annonces / particuliers.34PORTALS = {"logisquebec", "louer_ca", "rentals_ca", "zumper", "padmapper",35           "realtor", "mercini"}36CLASSIFIEDS = {"kijiji", "lespac", "fb_marketplace", "bons_locataires",37               "roomies", "duproprio", "oklouer"}383940def _authority(source: str) -> int:41    if source in CLASSIFIEDS:42        return 2043    if source in PORTALS:44        return 1045    return 0            # tout connecteur de gestionnaire/courtier en direct464748# types de voie retirés pour comparer les rues (fr + en)49_STREET_TYPES = {50    "rue", "avenue", "av", "ave", "boulevard", "boul", "bd", "chemin", "ch",51    "montee", "montée", "rang", "place", "pl", "cote", "côte", "impasse",52    "terrasse", "tsse", "allee", "allée", "croissant", "crois", "cours",53    "street", "st", "road", "rd", "drive", "dr", "lane", "ln", "court", "crt",54    "way", "circle", "blvd", "de", "du", "des", "la", "le", "les", "l", "d",55}565758_UNIT_MARKERS = {"app", "apt", "appartement", "unit", "unite", "suite", "bureau"}59# points cardinaux unifiés FR/EN (« Sherbrooke Ouest » == « Sherbrooke West »)60_DIRECTIONS = {"ouest": "o", "west": "o", "w": "o", "est": "e", "east": "e",61               "e": "e", "nord": "n", "north": "n", "n": "n", "sud": "s",62               "south": "s", "s": "s"}63# marqueurs de fin de rue : province/pays (PAS les noms de ville — « Sherbrooke »64# est aussi un nom de rue fréquent ; le nom de la ville de l'annonce est géré65# séparément)66_ADDR_STOP = {"qc", "quebec", "canada", "que", "ca"}676869def _parse_address(address: str, city: str) -> tuple[str | None, str | None]:70    """Retourne (clé d'adresse, numéro d'unité) à partir d'une adresse civique.7172    - clé = « civique|rue-significative|ville » sans accents ni type de voie,73      points cardinaux unifiés FR/EN ; None si aucun numéro civique fiable.74    - numéro d'unité (app/apt/#/suite ou nombre surnuméraire en tête) : sert à75      NE PAS fusionner deux logements distincts au même immeuble. None si absent.7677    Gère « 304 4557 Rue Sherbrooke » (304 = unité, 4557 = civique), « 12100,78    rue Rodolphe-Forget » (virgule après le civique) et « 4557 Sherbrooke O ».79    """80    a = strip_accents((address or "").lower())81    toks = [t for t in _re_split(a) if t]          # virgules -> séparateurs82    if not toks:83        return None, None8485    unit = None86    for i, t in enumerate(toks):                    # marqueur explicite « app 304 »87        if t in _UNIT_MARKERS and i + 1 < len(toks) and toks[i + 1].isdigit():88            unit = toks[i + 1]8990    lead_nums = []                                  # nombres en tête91    j = 092    while j < len(toks) and toks[j].isdigit():93        lead_nums.append(toks[j])94        j += 195    if not lead_nums:96        return None, unit97    civic = lead_nums[-1]                            # le nombre collé à la rue98    if len(lead_nums) > 1 and unit is None:99        unit = lead_nums[0]100101    c = strip_accents((city or "").lower()).strip()102    city_toks = set(_re_split(c))103    words = []104    for t in toks[j:]:105        if t in _ADDR_STOP or t.isdigit():          # province/pays/code postal106            break107        if words and t in city_toks:                # ville de l'annonce répétée108            break109        if t in _STREET_TYPES or t in _UNIT_MARKERS:110            continue111        words.append(_DIRECTIONS.get(t, t))112    if not words:113        return None, unit114    return f"{civic}|{'-'.join(words)}|{c}", unit115116117def _re_split(s: str) -> list[str]:118    out, cur = [], []119    for ch in s:120        if ch.isalnum():121            cur.append(ch)122        elif cur:123            out.append("".join(cur))124            cur = []125    if cur:126        out.append("".join(cur))127    return out128129130PRICE_TOL = 0.04           # ±4 % sur le loyer131132133def _same_listing(x: dict, y: dict) -> bool:134    """Confirme que deux annonces (de sources différentes) sont la MÊME unité.135136    Précision d'abord : on préfère afficher un doublon plutôt que masquer par137    erreur un logement distinct. La confirmation exige une clé d'adresse138    identique (même numéro civique + même rue + même ville) — la proximité GPS139    seule est rejetée car elle fusionnait des immeubles voisins différents.140    """141    # clé d'adresse civique identique : condition nécessaire142    if not (x["akey"] and y["akey"] and x["akey"] == y["akey"]):143        return False144    # numéros d'unité connus et DIFFÉRENTS -> logements distincts du même145    # immeuble : ne pas fusionner146    if x["unit"] and y["unit"] and x["unit"] != y["unit"]:147        return False148    # type d'unité : s'il est connu des deux côtés, il doit concorder149    ux, uy = x["unit_type"], y["unit_type"]150    if ux and uy and ux != uy:151        return False152    # prix : s'il est connu des deux côtés, tolérance ±4 %153    px, py = x["price"], y["price"]154    if px and py and abs(px - py) > PRICE_TOL * max(px, py):155        return False156    # à ce stade : même adresse civique, unité/type/prix compatibles.157    # exiger un second signal concordant (type OU prix) pour éviter de fusionner158    # deux logements différents partageant seulement l'adresse (plex, tour)159    if ux and uy and ux == uy:160        return True161    if px and py and abs(px - py) <= PRICE_TOL * max(px, py):162        return True163    if x["unit"] and y["unit"] and x["unit"] == y["unit"]:164        return True165    return False166167168def run(con=None) -> dict:169    """Recalcule les groupes de doublons inter-sources. Idempotent."""170    own = con is None171    if own:172        con = db.connect()173    rows = con.execute(174        "SELECT uid, source, external_id, unit_type, price, address, city,"175        " lat, lng, images, description, published FROM listings"176        " WHERE active=1").fetchall()177178    items = []179    by_addr: dict[str, list[int]] = {}180    for r in rows:181        akey, unit = _parse_address(r["address"], r["city"])182        it = {183            "uid": r["uid"], "source": r["source"], "unit_type": r["unit_type"] or "",184            "price": r["price"], "akey": akey, "unit": unit,185            "auth": _authority(r["source"]),186            "published": r["published"] if r["published"] is not None else 1,187            "richness": len(r["description"] or "") + 50 * _img_count(r["images"]),188        }189        idx = len(items)190        items.append(it)191        if akey:192            by_addr.setdefault(akey, []).append(idx)193194    # union-find sur les paires confirmées ; un groupe ne peut PAS contenir deux195    # annonces de la même source (une source ne publie pas deux fois la même196    # unité) : cette contrainte empêche l'enchaînement transitif qui fusionnait197    # des logements distincts d'un même immeuble.198    parent = list(range(len(items)))199    srcset: list[set[str]] = [{it["source"]} for it in items]200201    def find(i):202        while parent[i] != i:203            parent[i] = parent[parent[i]]204            i = parent[i]205        return i206207    def union(i, j) -> bool:208        ri, rj = find(i), find(j)209        if ri == rj:210            return False211        if srcset[ri] & srcset[rj]:      # sources en conflit : refuser la fusion212            return False213        parent[ri] = rj214        srcset[rj] |= srcset[ri]215        return True216217    def consider(cand: list[int]):218        n = len(cand)219        if n < 2 or n > 400:          # garde-fou : blocs trop denses ignorés220            return221        for a in range(n):222            for b in range(a + 1, n):223                ia, ib = cand[a], cand[b]224                if items[ia]["source"] == items[ib]["source"]:225                    continue          # jamais dédupliquer au sein d'une source226                if find(ia) == find(ib):227                    continue228                if _same_listing(items[ia], items[ib]):229                    union(ia, ib)230231    for group in by_addr.values():232        consider(group)233234    # regrouper et choisir le canonique (autorité, puis richesse, puis uid)235    groups: dict[int, list[int]] = {}236    for i in range(len(items)):237        groups.setdefault(find(i), []).append(i)238239    updates = []240    n_dupes = n_groups = 0241    for members in groups.values():242        if len(members) < 2:243            updates.append((None, None, items[members[0]]["uid"]))244            continue245        # une annonce en quarantaine ne doit jamais masquer un doublon publié246        members.sort(key=lambda i: (-items[i]["published"], items[i]["auth"],247                                    -items[i]["richness"], items[i]["uid"]))248        canon = members[0]249        canon_uid = items[canon]["uid"]250        other_sources = sorted({items[i]["source"] for i in members[1:]})251        updates.append((None, json.dumps(other_sources, ensure_ascii=False),252                        canon_uid))253        for i in members[1:]:254            updates.append((canon_uid, None, items[i]["uid"]))255            n_dupes += 1256        n_groups += 1257258    con.executemany(259        "UPDATE listings SET dup_of=?, dup_sources=? WHERE uid=?", updates)260    con.commit()261    if own:262        con.close()263    stats = {"groups": n_groups, "duplicates_hidden": n_dupes,264             "listings_scanned": len(items)}265    return stats266267268def _img_count(images_json: str | None) -> int:269    try:270        return len(json.loads(images_json or "[]"))271    except (ValueError, TypeError):272        return 0273274275if __name__ == "__main__":276    print(run())277