Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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