spb/toit-ka Public
Toit-Ka — louer ou acheter un toit au Québec, un seul endroit (fusion Lou-Ka × Immo-Ka) — www.toit-ka.com
Python 40.2%
TypeScript 39%
CSS 20.2%
HTML 0.7%
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3# Project: Toit-Ka4# -----------------------------------------------------------------------------5# villes.py : canonicalisation des villes — LE chantier de la fusion.6#7# Les deux sources cumulent ~1 900 valeurs distinctes de `city` pour ~3008# municipalités réelles. Pathologies observées (données réelles) :9# « Brossard ) » parenthèse orpheline10# « Gatineau (Hôpital » parenthèse jamais fermée (+ double espace)11# « Gatineau (Gatineau) » ville dupliquée12# « Montréal (Ville-Marie) » arrondissement collé — et l'ordre inverse13# « Ville-Marie (Centre-Ville…) » arrondissement en tête14# « Saint-Hubert (Longueuil) » ancien nom (ville fusionnée)15# « St-Lambert (Montérégie) » région collée16# « Québec - Beauport » secteur au tiret17# « Longueuil / South Shore » libellé marketing bilingue18# « Québec City », « Ville De Quebec », « Montreal Downtown », « City Of Montréal »19# « Trois Rivieres », « Mont Tremblant », « Vaudreuil Dorion » accents/traits perdus20# « Montrã©al » double encodage UTF-821# « Saint » troncature irrécupérable22#23# Deux passes :24# 1. clean_raw(raw) -> (base, secteur) — règles déterministes ci-dessus ;25# 2. build_mapping(comptes) — regroupe les bases nettoyées par SLUG (accents26# et traits d'union confondus) et élit le libellé canonique du groupe27# (libellé du lexique s'il existe, sinon variante la plus fréquente).28# Le résultat est persisté dans la table `city_map` (audit + réutilisation).29# -----------------------------------------------------------------------------30from __future__ import annotations3132import html33import re34import unicodedata3536# --- slug (MÊME algorithme que seo.py / frontend api.ts) ----------------------373839def slugify(s: str) -> str:40 # « 3½ » -> « 3-1-2 », « 6½+ » -> « 6-1-2-plus » (types locatifs)41 s = (s or "").replace("½", " 1 2 ").replace("+", " plus ")42 s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()43 s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")44 return s[:80].strip("-")454647# --- lexique -------------------------------------------------------------------4849# Libellés officiels des municipalités les plus fréquentes : sert à (a) résoudre50# les parenthèses « X (Y) », (b) élire le bon libellé d'un groupe de slugs51# (« Trois Rivieres » -> « Trois-Rivières » même si la variante cassée domine).52VILLES_CONNUES = [53 "Montréal", "Québec", "Laval", "Gatineau", "Longueuil", "Sherbrooke",54 "Lévis", "Trois-Rivières", "Terrebonne", "Saint-Jean-sur-Richelieu",55 "Brossard", "Repentigny", "Drummondville", "Saint-Jérôme", "Granby",56 "Blainville", "Saint-Hyacinthe", "Mirabel", "Shawinigan", "Dollard-des-Ormeaux",57 "Rimouski", "Châteauguay", "Saint-Eustache", "Mascouche", "Victoriaville",58 "Salaberry-de-Valleyfield", "Rouyn-Noranda", "Sorel-Tracy", "Vaudreuil-Dorion",59 "Côte-Saint-Luc", "Val-d'Or", "Alma", "Saint-Georges", "Sainte--Julie",60 "Sainte-Julie", "Boucherville", "Saguenay", "Chambly", "Saint-Constant",61 "Magog", "Boisbriand", "Sainte-Thérèse", "La Prairie", "Thetford Mines",62 "Sept-Îles", "Saint-Bruno-de-Montarville", "Baie-Comeau", "Joliette",63 "Varennes", "Mont-Royal", "Westmount", "Pointe-Claire", "Kirkland",64 "Beaconsfield", "Dorval", "Sainte-Adèle", "Saint-Sauveur", "Mont-Tremblant",65 "Sainte-Agathe-des-Monts", "Bromont", "Chelsea", "Cantley", "Val-des-Monts",66 "L'Assomption", "Saint-Lambert", "Candiac", "Delson", "Beloeil",67 "Mont-Saint-Hilaire", "Otterburn Park", "Carignan", "Saint-Basile-le-Grand",68 "McMasterville", "Sainte-Catherine", "Mercier", "Pincourt", "L'Île-Perrot",69 "Notre-Dame-de-l'Île-Perrot", "Les Coteaux", "Rigaud", "Hudson",70 "Saint-Lazare", "Saint-Zotique", "Beauharnois", "Lachute", "Rosemère",71 "Lorraine", "Bois-des-Filion", "Sainte-Anne-des-Plaines", "Saint-Colomban",72 "Saint-Lin--Laurentides", "Prévost", "Piedmont", "Saint-Donat", "Rawdon",73 "Saint-Charles-Borromée", "Notre-Dame-des-Prairies", "L'Épiphanie",74 "Berthierville", "Louiseville", "Nicolet", "Bécancour", "Plessisville",75 "Princeville", "Warwick", "Asbestos", "Val-des-Sources", "Windsor",76 "Coaticook", "Lac-Mégantic", "Cowansville", "Farnham", "Waterloo",77 "Sutton", "Lac-Brome", "Beauport", "Charlesbourg", "Sainte-Foy",78 "Ancienne-Lorette", "L'Ancienne-Lorette", "Saint-Augustin-de-Desmaures",79 "Stoneham-et-Tewkesbury", "Lac-Beauport", "Shannon", "Pont-Rouge",80 "Donnacona", "Portneuf", "Sainte-Marie", "Saint-Joseph-de-Beauce",81 "Beauceville", "Montmagny", "La Pocatière", "Rivière-du-Loup",82 "Témiscouata-sur-le-Lac", "Matane", "Mont-Joli", "Amqui", "Gaspé",83 "Chandler", "New Richmond", "Carleton-sur-Mer", "Bonaventure",84 "Percé", "Sainte-Anne-des-Monts", "Baie-Saint-Paul", "La Malbaie",85 "Clermont", "Roberval", "Saint-Félicien", "Dolbeau-Mistassini",86 "Amos", "La Sarre", "Malartic", "Senneterre", "Ville-Marie",87 "Témiscaming", "Chibougamau", "Mont-Laurier", "Maniwaki",88 "Sainte-Adele", "Saint-Hippolyte", "Sainte-Sophie", "Saint-Calixte",89 "Chertsey", "Saint-Côme", "Sainte-Marguerite-du-Lac-Masson",90 "Morin-Heights", "Saint-Adolphe-d'Howard", "Val-David", "Val-Morin",91 "Labelle", "La Conception", "Saint-Faustin--Lac-Carré", "Mont-Blanc",92 "Huntingdon", "Ormstown", "Napierville", "Saint-Rémi", "Sainte-Martine",93 "Coteau-du-Lac", "Les Cèdres", "Vaudreuil-sur-le-Lac", "Oka",94 "Saint-Joseph-du-Lac", "Pointe-Calumet", "Sainte-Marthe-sur-le-Lac",95 "Deux-Montagnes", "Saint-Placide", "Brownsburg-Chatham", "Grenville",96 "Hawkesbury", "Papineauville", "Thurso", "Plaisance", "Montebello",97 "Fossambault-sur-le-Lac", "Sainte-Catherine-de-la-Jacques-Cartier",98 "Québec Ouest", "Saint-Raymond", "Cap-Santé", "Neuville",99 "Montréal-Est", "Montréal-Ouest", "Hampstead", "Mont-Royal",100 "Sainte-Anne-de-Bellevue", "Baie-D'Urfé", "Senneville",101 "L'Île-Bizard", "Sainte-Geneviève", "Roxboro", "Pierrefonds",102 "Greenfield Park", "Saint-Hubert", "LeMoyne", "Marieville",103 "Richelieu", "Saint-Césaire", "Rougemont", "Acton Vale",104 "Contrecoeur", "Verchères", "Calixa-Lavallée", "Saint-Amable",105 "Sainte-Julienne", "Saint-Esprit", "Saint-Roch-de-l'Achigan",106 "Saint-Lin-Laurentides", "L'Épiphanie", "Charlemagne", "Le Gardeur",107 "Lavaltrie", "Lanoraie", "Saint-Zénon", "Saint-Michel-des-Saints",108 "Entrelacs", "Notre-Dame-de-la-Merci", "Saint-Alphonse-Rodriguez",109 "Sainte-Béatrix", "Saint-Jean-de-Matha", "Saint-Gabriel",110 "Venise-en-Québec", "Bedford", "Dunham", "Frelighsburg",111 "Stanbridge East", "Knowlton", "Eastman", "Orford", "North Hatley",112 "Ayer's Cliff", "Stanstead", "Sainte-Catherine-de-Hatley", "Hatley",113 "Compton", "Waterville", "East Angus", "Weedon", "Disraeli",114 "Thetford Mines", "Black Lake", "Saint-Ferdinand", "Lyster",115 "Laurier-Station", "Saint-Apollinaire", "Saint-Agapit", "Saint-Antoine-de-Tilly",116 "Sainte-Croix", "Lotbinière", "Saint-Flavien", "Dosquet",117 "Val-Alain", "Saint-Patrice-de-Beaurivage", "Saint-Gilles",118 "Saint-Étienne-de-Lauzon", "Saint-Nicolas", "Saint-Rédempteur",119 "Charny", "Saint-Jean-Chrysostome", "Saint-Romuald", "Pintendre",120 "Saint-Henri", "Beaumont", "Saint-Michel-de-Bellechasse",121 "Saint-Vallier", "Berthier-sur-Mer", "Saint-Anselme",122 "Sainte-Claire", "Saint-Lazare-de-Bellechasse", "Armagh",123 "Saint-Damien-de-Buckland", "Saint-Nérée", "La Durantaye",124]125126# Arrondissements / anciens noms -> ville-mère. La clé est un SLUG.127ARRONDISSEMENTS: dict[str, tuple[str, str]] = {128 # --- Montréal ---129 "ville-marie": ("Montréal", "Ville-Marie"),130 "le-plateau-mont-royal": ("Montréal", "Le Plateau-Mont-Royal"),131 "plateau-mont-royal": ("Montréal", "Le Plateau-Mont-Royal"),132 "rosemont-la-petite-patrie": ("Montréal", "Rosemont/La Petite-Patrie"),133 "rosemont": ("Montréal", "Rosemont"),134 "la-petite-patrie": ("Montréal", "La Petite-Patrie"),135 "mercier-hochelaga-maisonneuve": ("Montréal", "Mercier/Hochelaga-Maisonneuve"),136 "hochelaga-maisonneuve": ("Montréal", "Hochelaga-Maisonneuve"),137 "mercier": ("Montréal", "Mercier"),138 "le-sud-ouest": ("Montréal", "Le Sud-Ouest"),139 "sud-ouest": ("Montréal", "Le Sud-Ouest"),140 "lasalle": ("Montréal", "LaSalle"),141 "saint-leonard": ("Montréal", "Saint-Léonard"),142 "montreal-nord": ("Montréal", "Montréal-Nord"),143 "ahuntsic-cartierville": ("Montréal", "Ahuntsic-Cartierville"),144 "ahuntsic": ("Montréal", "Ahuntsic"),145 "cartierville": ("Montréal", "Cartierville"),146 "verdun": ("Montréal", "Verdun"),147 "ile-des-soeurs": ("Montréal", "L'Île-des-Sœurs"),148 "l-ile-des-soeurs": ("Montréal", "L'Île-des-Sœurs"),149 "outremont": ("Montréal", "Outremont"),150 "anjou": ("Montréal", "Anjou"),151 "lachine": ("Montréal", "Lachine"),152 "saint-laurent": ("Montréal", "Saint-Laurent"),153 "villeray-saint-michel-parc-extension": ("Montréal", "Villeray/Saint-Michel/Parc-Extension"),154 "villeray": ("Montréal", "Villeray"),155 "parc-extension": ("Montréal", "Parc-Extension"),156 "cote-des-neiges-notre-dame-de-grace": ("Montréal", "Côte-des-Neiges/Notre-Dame-de-Grâce"),157 "cote-des-neiges": ("Montréal", "Côte-des-Neiges"),158 "notre-dame-de-grace": ("Montréal", "Notre-Dame-de-Grâce"),159 "riviere-des-prairies-pointe-aux-trembles": ("Montréal", "Rivière-des-Prairies/Pointe-aux-Trembles"),160 "riviere-des-prairies": ("Montréal", "Rivière-des-Prairies"),161 "pointe-aux-trembles": ("Montréal", "Pointe-aux-Trembles"),162 "pierrefonds-roxboro": ("Montréal", "Pierrefonds-Roxboro"),163 "pierrefonds": ("Montréal", "Pierrefonds"),164 "roxboro": ("Montréal", "Roxboro"),165 "l-ile-bizard-sainte-genevieve": ("Montréal", "L'Île-Bizard/Sainte-Geneviève"),166 "l-ile-bizard": ("Montréal", "L'Île-Bizard"),167 "griffintown": ("Montréal", "Griffintown"),168 "vieux-montreal": ("Montréal", "Vieux-Montréal"),169 "centre-ville": ("Montréal", "Centre-ville"),170 # --- Québec ---171 "beauport": ("Québec", "Beauport"),172 "charlesbourg": ("Québec", "Charlesbourg"),173 "sainte-foy": ("Québec", "Sainte-Foy"),174 "ste-foy": ("Québec", "Sainte-Foy"),175 "sainte-foy-sillery-cap-rouge": ("Québec", "Sainte-Foy/Sillery/Cap-Rouge"),176 "sillery": ("Québec", "Sillery"),177 "cap-rouge": ("Québec", "Cap-Rouge"),178 "limoilou": ("Québec", "Limoilou"),179 "la-cite-limoilou": ("Québec", "La Cité-Limoilou"),180 "vanier": ("Québec", "Vanier"),181 "les-rivieres": ("Québec", "Les Rivières"),182 "la-haute-saint-charles": ("Québec", "La Haute-Saint-Charles"),183 "duberger": ("Québec", "Duberger"),184 "loretteville": ("Québec", "Loretteville"),185 "val-belair": ("Québec", "Val-Bélair"),186 "cap-diamant": ("Québec", "Cap-Diamant"),187 "vieux-quebec": ("Québec", "Vieux-Québec"),188 "saint-roch": ("Québec", "Saint-Roch"),189 "saint-sacrement": ("Québec", "Saint-Sacrement"),190 "montcalm": ("Québec", "Montcalm"),191 "lebourgneuf": ("Québec", "Lebourgneuf"),192 # --- Gatineau ---193 "aylmer": ("Gatineau", "Aylmer"),194 "hull": ("Gatineau", "Hull"),195 "buckingham": ("Gatineau", "Buckingham"),196 "masson-angers": ("Gatineau", "Masson-Angers"),197 # --- Longueuil ---198 "saint-hubert": ("Longueuil", "Saint-Hubert"),199 "le-vieux-longueuil": ("Longueuil", "Le Vieux-Longueuil"),200 "vieux-longueuil": ("Longueuil", "Le Vieux-Longueuil"),201 "greenfield-park": ("Longueuil", "Greenfield Park"),202 # --- Laval ---203 "chomedey": ("Laval", "Chomedey"),204 "fabreville": ("Laval", "Fabreville"),205 "sainte-rose": ("Laval", "Sainte-Rose"),206 "duvernay": ("Laval", "Duvernay"),207 "laval-des-rapides": ("Laval", "Laval-des-Rapides"),208 "pont-viau": ("Laval", "Pont-Viau"),209 "vimont": ("Laval", "Vimont"),210 "auteuil": ("Laval", "Auteuil"),211 "sainte-dorothee": ("Laval", "Sainte-Dorothée"),212 "laval-ouest": ("Laval", "Laval-Ouest"),213 "chomedy": ("Laval", "Chomedey"),214 # --- Saguenay ---215 "jonquiere": ("Saguenay", "Jonquière"),216 "chicoutimi": ("Saguenay", "Chicoutimi"),217 "la-baie": ("Saguenay", "La Baie"),218 # --- Lévis ---219 "saint-romuald-2": ("Lévis", "Saint-Romuald"),220}221222# Slugs de RÉGIONS administratives / marketing : jamais une ville.223REGIONS = {224 "monteregie", "lanaudiere", "laurentides", "estrie", "outaouais",225 "mauricie", "centre-du-quebec", "abitibi-temiscamingue",226 "bas-saint-laurent", "gaspesie", "gaspesie-iles-de-la-madeleine",227 "cote-nord", "saguenay-lac-saint-jean", "chaudiere-appalaches",228 "capitale-nationale", "nord-du-quebec", "montreal-region",229 "region-de-quebec", "grand-montreal", "rive-sud", "rive-nord",230 "south-shore", "north-shore", "quebec-rive-nord", "quebec-rive-sud",231 "montreal-ile", "monteregie-rive-sud",232}233234# Alias directs (slug -> (ville, secteur)) : variantes anglaises, libellés235# marketing, préfixes administratifs.236ALIAS: dict[str, tuple[str, str]] = {237 "montreal-downtown": ("Montréal", "Centre-ville"),238 "downtown-montreal": ("Montréal", "Centre-ville"),239 "city-of-montreal": ("Montréal", ""),240 "montreal-city": ("Montréal", ""),241 "ville-de-montreal": ("Montréal", ""),242 "quebec-city": ("Québec", ""),243 "ville-de-quebec": ("Québec", ""),244 "city-of-quebec": ("Québec", ""),245 "old-montreal": ("Montréal", "Vieux-Montréal"),246 "old-quebec": ("Québec", "Vieux-Québec"),247 "st-jerome": ("Saint-Jérôme", ""),248 "trois-rivieres-ouest": ("Trois-Rivières", "Trois-Rivières-Ouest"),249}250251def _accents(label: str) -> int:252 return sum(1 for c in label if ord(c) > 127) + label.count("-")253254255# si deux libellés du lexique partagent un slug (« Sainte-Adèle » /256# « Sainte-Adele »), garder la variante la plus riche (accents, traits d'union)257_VILLE_PAR_SLUG: dict[str, str] = {}258for _v in VILLES_CONNUES:259 _s = slugify(_v)260 if _s not in _VILLE_PAR_SLUG or _accents(_v) > _accents(_VILLE_PAR_SLUG[_s]):261 _VILLE_PAR_SLUG[_s] = _v262# les arrondissements sont subordonnés : jamais élus comme ville canonique263for _s in list(ARRONDISSEMENTS):264 _VILLE_PAR_SLUG.pop(_s, None)265266267# --- nettoyage d'une valeur brute ---------------------------------------------268269_WS = re.compile(r"\s+")270_MOJIBAKE = re.compile(r"[ÃÂ][©®¨«¢€°]|Ã[a-z]")271272273def _fix_mojibake(s: str) -> str:274 """Répare un double encodage UTF-8 -> latin-1 (« Montrã©al » -> « Montréal »)."""275 if not _MOJIBAKE.search(s):276 return s277 try:278 fixed = s.encode("latin-1").decode("utf-8")279 return fixed if fixed and "�" not in fixed else s280 except (UnicodeEncodeError, UnicodeDecodeError):281 return s282283284def _pre(s: str) -> str:285 s = _fix_mojibake(s or "")286 s = html.unescape(html.unescape(s))287 s = s.replace("’", "'").replace("`", "'")288 s = _WS.sub(" ", s).strip(" \t,;·—–-")289 return s.strip()290291292def _expand_st(s: str) -> str:293 s = re.sub(r"\bSt-", "Saint-", s)294 s = re.sub(r"\bSte-", "Sainte-", s)295 s = re.sub(r"\bSt\.\s*", "Saint-", s)296 s = re.sub(r"\bSte\.\s*", "Sainte-", s)297 return s298299300def _dedupe_words(s: str) -> str:301 """« Gatineau Gatineau » -> « Gatineau » (mot entier répété)."""302 parts = s.split(" ")303 if len(parts) == 2 and slugify(parts[0]) == slugify(parts[1]):304 return parts[0]305 return s306307308def _resolve(base: str, inner: str) -> tuple[str, str]:309 """Résout « base (inner) » -> (ville, secteur)."""310 bs, is_ = slugify(base), slugify(inner)311 if not base:312 base, bs = inner, is_313 inner, is_ = "", ""314 if is_ and is_ == bs: # « Gatineau (Gatineau) »315 inner, is_ = "", ""316 if is_ in REGIONS: # « St-Lambert (Montérégie) »317 inner, is_ = "", ""318 if bs in ALIAS:319 v, sect = ALIAS[bs]320 return v, sect or inner321 if bs in _VILLE_PAR_SLUG: # « Montréal (Ville-Marie) »322 return _VILLE_PAR_SLUG[bs], inner323 if bs in ARRONDISSEMENTS: # « Ville-Marie (…) », « Aylmer (Gatineau) »324 ville, sect = ARRONDISSEMENTS[bs]325 return ville, sect326 if is_ in ARRONDISSEMENTS: # « X (Hull) » -> Gatineau ? non : X prime327 pass328 if is_ and is_ in _VILLE_PAR_SLUG: # « Quartier-Inconnu (Longueuil) »329 return _VILLE_PAR_SLUG[is_], base330 return base, inner331332333def clean_raw(raw: str) -> tuple[str, str]:334 """Valeur brute -> (ville nettoyée, secteur extrait). Ville '' si irrécupérable."""335 s = _pre(raw)336 if not s or len(s) < 2 or s.isdigit():337 return "", ""338339 # parenthèses : fermées, orphelines ou jamais fermées340 base, inner = s, ""341 if "(" in s:342 base, _, rest = s.partition("(")343 inner = rest.rstrip(")").strip()344 base = base.strip()345 elif s.endswith(")"):346 base = s.rstrip(")").strip()347348 # « Québec - Beauport » / « Longueuil / South Shore »349 for sep in (" - ", " – ", " / "):350 if sep in base:351 left, _, right = base.partition(sep)352 if slugify(left) in _VILLE_PAR_SLUG or slugify(left) in ALIAS:353 base, inner = left.strip(), inner or right.strip()354 if slugify(inner) in REGIONS:355 inner = ""356 break357358 base = _dedupe_words(_expand_st(base))359 inner = _expand_st(inner)360361 if slugify(base) in REGIONS: # « Montérégie » seul : pas une ville362 return "", ""363 if slugify(base) in {"saint", "sainte", "st", "ste"}: # troncature364 return "", ""365366 ville, secteur = _resolve(base, inner)367 ville = _WS.sub(" ", ville).strip(" -–")368 secteur = _WS.sub(" ", secteur).strip(" -–")369 if len(ville) < 2:370 return "", ""371 return ville, secteur372373374# --- élection du libellé canonique par groupe de slug --------------------------375376377def _label_score(label: str, count: int) -> tuple:378 """Préférence : lexique connu > accents/traits présents > fréquence."""379 known = slugify(label) in _VILLE_PAR_SLUG and label == _VILLE_PAR_SLUG[slugify(label)]380 accents = sum(1 for c in label if unicodedata.category(c) == "Lu" or ord(c) > 127)381 hyphens = label.count("-")382 return (known, accents + hyphens, count)383384385def build_mapping(counts: dict[str, int]) -> dict[str, tuple[str, str]]:386 """{valeur brute: occurrences} -> {valeur brute: (ville canonique, secteur)}.387388 1. clean_raw sur chaque valeur brute ;389 2. groupement des villes nettoyées par slug ;390 3. libellé canonique du groupe = libellé du lexique, sinon la variante au391 meilleur score (accents/traits d'union puis fréquence).392 """393 cleaned: dict[str, tuple[str, str]] = {}394 groups: dict[str, dict[str, int]] = {}395 for raw, n in counts.items():396 ville, secteur = clean_raw(raw)397 cleaned[raw] = (ville, secteur)398 if ville:399 groups.setdefault(slugify(ville), {})400 groups[slugify(ville)][ville] = groups[slugify(ville)].get(ville, 0) + n401402 canon: dict[str, str] = {}403 for slug, variants in groups.items():404 if slug in _VILLE_PAR_SLUG:405 canon[slug] = _VILLE_PAR_SLUG[slug]406 else:407 canon[slug] = max(variants.items(), key=lambda kv: _label_score(*kv))[0]408409 return {raw: (canon.get(slugify(v), v) if v else "", s)410 for raw, (v, s) in cleaned.items()}411