# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # dedup.py : déduplication INTER-SOURCES — une même unité publiée sur plusieurs # plateformes (ex. un gestionnaire agrégé en direct ET via LogisQuébec, # ou un particulier sur Kijiji ET Facebook) ne doit apparaître qu'une # fois côté site. # # Principe (recalculé intégralement à chaque passe, idempotent) : # 1. Blocage : regrouper les annonces actives par proximité (grille lat/lng # ~110 m + cases voisines) OU par clé d'adresse normalisée (n° civique + # rue sans type de voie + ville). # 2. Confirmation d'une paire : même type d'unité (si connu des deux côtés), # prix à ±4 % (si connu), et signal géographique/adresse concordant. # 3. Résolution : le membre de la source la PLUS autoritaire devient canonique # (dup_of=NULL) ; les autres pointent vers lui (dup_of=uid canonique). Le # canonique liste les autres plateformes dans dup_sources (badge « aussi # publiée sur… »). Rien n'est supprimé. # # Le web (web.py) n'affiche que les annonces canoniques : `WHERE dup_of IS NULL`. # Appelé par ingest.watch() APRÈS le géocodage (les coordonnées sont l'ingrédient # principal du blocage). # ----------------------------------------------------------------------------- from __future__ import annotations import json from . import db from .normalize import strip_accents # Autorité des sources : plus l'indice est BAS, plus la source est prioritaire # comme canonique. 0 = gestionnaire/courtier en direct (donnée d'origine), # 10 = portail agrégateur, 20 = petites annonces / particuliers. PORTALS = {"logisquebec", "louer_ca", "rentals_ca", "zumper", "padmapper", "realtor", "mercini"} CLASSIFIEDS = {"kijiji", "lespac", "fb_marketplace", "bons_locataires", "roomies", "duproprio", "oklouer"} def _authority(source: str) -> int: if source in CLASSIFIEDS: return 20 if source in PORTALS: return 10 return 0 # tout connecteur de gestionnaire/courtier en direct # types de voie retirés pour comparer les rues (fr + en) _STREET_TYPES = { "rue", "avenue", "av", "ave", "boulevard", "boul", "bd", "chemin", "ch", "montee", "montée", "rang", "place", "pl", "cote", "côte", "impasse", "terrasse", "tsse", "allee", "allée", "croissant", "crois", "cours", "street", "st", "road", "rd", "drive", "dr", "lane", "ln", "court", "crt", "way", "circle", "blvd", "de", "du", "des", "la", "le", "les", "l", "d", } _UNIT_MARKERS = {"app", "apt", "appartement", "unit", "unite", "suite", "bureau"} # points cardinaux unifiés FR/EN (« Sherbrooke Ouest » == « Sherbrooke West ») _DIRECTIONS = {"ouest": "o", "west": "o", "w": "o", "est": "e", "east": "e", "e": "e", "nord": "n", "north": "n", "n": "n", "sud": "s", "south": "s", "s": "s"} # marqueurs de fin de rue : province/pays (PAS les noms de ville — « Sherbrooke » # est aussi un nom de rue fréquent ; le nom de la ville de l'annonce est géré # séparément) _ADDR_STOP = {"qc", "quebec", "canada", "que", "ca"} def _parse_address(address: str, city: str) -> tuple[str | None, str | None]: """Retourne (clé d'adresse, numéro d'unité) à partir d'une adresse civique. - clé = « civique|rue-significative|ville » sans accents ni type de voie, points cardinaux unifiés FR/EN ; None si aucun numéro civique fiable. - numéro d'unité (app/apt/#/suite ou nombre surnuméraire en tête) : sert à NE PAS fusionner deux logements distincts au même immeuble. None si absent. Gère « 304 4557 Rue Sherbrooke » (304 = unité, 4557 = civique), « 12100, rue Rodolphe-Forget » (virgule après le civique) et « 4557 Sherbrooke O ». """ a = strip_accents((address or "").lower()) toks = [t for t in _re_split(a) if t] # virgules -> séparateurs if not toks: return None, None unit = None for i, t in enumerate(toks): # marqueur explicite « app 304 » if t in _UNIT_MARKERS and i + 1 < len(toks) and toks[i + 1].isdigit(): unit = toks[i + 1] lead_nums = [] # nombres en tête j = 0 while j < len(toks) and toks[j].isdigit(): lead_nums.append(toks[j]) j += 1 if not lead_nums: return None, unit civic = lead_nums[-1] # le nombre collé à la rue if len(lead_nums) > 1 and unit is None: unit = lead_nums[0] c = strip_accents((city or "").lower()).strip() city_toks = set(_re_split(c)) words = [] for t in toks[j:]: if t in _ADDR_STOP or t.isdigit(): # province/pays/code postal break if words and t in city_toks: # ville de l'annonce répétée break if t in _STREET_TYPES or t in _UNIT_MARKERS: continue words.append(_DIRECTIONS.get(t, t)) if not words: return None, unit return f"{civic}|{'-'.join(words)}|{c}", unit def _re_split(s: str) -> list[str]: out, cur = [], [] for ch in s: if ch.isalnum(): cur.append(ch) elif cur: out.append("".join(cur)) cur = [] if cur: out.append("".join(cur)) return out PRICE_TOL = 0.04 # ±4 % sur le loyer def _same_listing(x: dict, y: dict) -> bool: """Confirme que deux annonces (de sources différentes) sont la MÊME unité. Précision d'abord : on préfère afficher un doublon plutôt que masquer par erreur un logement distinct. La confirmation exige une clé d'adresse identique (même numéro civique + même rue + même ville) — la proximité GPS seule est rejetée car elle fusionnait des immeubles voisins différents. """ # clé d'adresse civique identique : condition nécessaire if not (x["akey"] and y["akey"] and x["akey"] == y["akey"]): return False # numéros d'unité connus et DIFFÉRENTS -> logements distincts du même # immeuble : ne pas fusionner if x["unit"] and y["unit"] and x["unit"] != y["unit"]: return False # type d'unité : s'il est connu des deux côtés, il doit concorder ux, uy = x["unit_type"], y["unit_type"] if ux and uy and ux != uy: return False # prix : s'il est connu des deux côtés, tolérance ±4 % px, py = x["price"], y["price"] if px and py and abs(px - py) > PRICE_TOL * max(px, py): return False # à ce stade : même adresse civique, unité/type/prix compatibles. # exiger un second signal concordant (type OU prix) pour éviter de fusionner # deux logements différents partageant seulement l'adresse (plex, tour) if ux and uy and ux == uy: return True if px and py and abs(px - py) <= PRICE_TOL * max(px, py): return True if x["unit"] and y["unit"] and x["unit"] == y["unit"]: return True return False def run(con=None) -> dict: """Recalcule les groupes de doublons inter-sources. Idempotent.""" own = con is None if own: con = db.connect() rows = con.execute( "SELECT uid, source, external_id, unit_type, price, address, city," " lat, lng, images, description, published FROM listings" " WHERE active=1").fetchall() items = [] by_addr: dict[str, list[int]] = {} for r in rows: akey, unit = _parse_address(r["address"], r["city"]) it = { "uid": r["uid"], "source": r["source"], "unit_type": r["unit_type"] or "", "price": r["price"], "akey": akey, "unit": unit, "auth": _authority(r["source"]), "published": r["published"] if r["published"] is not None else 1, "richness": len(r["description"] or "") + 50 * _img_count(r["images"]), } idx = len(items) items.append(it) if akey: by_addr.setdefault(akey, []).append(idx) # union-find sur les paires confirmées ; un groupe ne peut PAS contenir deux # annonces de la même source (une source ne publie pas deux fois la même # unité) : cette contrainte empêche l'enchaînement transitif qui fusionnait # des logements distincts d'un même immeuble. parent = list(range(len(items))) srcset: list[set[str]] = [{it["source"]} for it in items] def find(i): while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i def union(i, j) -> bool: ri, rj = find(i), find(j) if ri == rj: return False if srcset[ri] & srcset[rj]: # sources en conflit : refuser la fusion return False parent[ri] = rj srcset[rj] |= srcset[ri] return True def consider(cand: list[int]): n = len(cand) if n < 2 or n > 400: # garde-fou : blocs trop denses ignorés return for a in range(n): for b in range(a + 1, n): ia, ib = cand[a], cand[b] if items[ia]["source"] == items[ib]["source"]: continue # jamais dédupliquer au sein d'une source if find(ia) == find(ib): continue if _same_listing(items[ia], items[ib]): union(ia, ib) for group in by_addr.values(): consider(group) # regrouper et choisir le canonique (autorité, puis richesse, puis uid) groups: dict[int, list[int]] = {} for i in range(len(items)): groups.setdefault(find(i), []).append(i) updates = [] n_dupes = n_groups = 0 for members in groups.values(): if len(members) < 2: updates.append((None, None, items[members[0]]["uid"])) continue # une annonce en quarantaine ne doit jamais masquer un doublon publié members.sort(key=lambda i: (-items[i]["published"], items[i]["auth"], -items[i]["richness"], items[i]["uid"])) canon = members[0] canon_uid = items[canon]["uid"] other_sources = sorted({items[i]["source"] for i in members[1:]}) updates.append((None, json.dumps(other_sources, ensure_ascii=False), canon_uid)) for i in members[1:]: updates.append((canon_uid, None, items[i]["uid"])) n_dupes += 1 n_groups += 1 con.executemany( "UPDATE listings SET dup_of=?, dup_sources=? WHERE uid=?", updates) con.commit() if own: con.close() stats = {"groups": n_groups, "duplicates_hidden": n_dupes, "listings_scanned": len(items)} return stats def _img_count(images_json: str | None) -> int: try: return len(json.loads(images_json or "[]")) except (ValueError, TypeError): return 0 if __name__ == "__main__": print(run())