# Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai # Project: Toit-Ka # ----------------------------------------------------------------------------- # villes.py : canonicalisation des villes — LE chantier de la fusion. # # Les deux sources cumulent ~1 900 valeurs distinctes de `city` pour ~300 # municipalités réelles. Pathologies observées (données réelles) : # « Brossard ) » parenthèse orpheline # « Gatineau (Hôpital » parenthèse jamais fermée (+ double espace) # « Gatineau (Gatineau) » ville dupliquée # « Montréal (Ville-Marie) » arrondissement collé — et l'ordre inverse # « Ville-Marie (Centre-Ville…) » arrondissement en tête # « Saint-Hubert (Longueuil) » ancien nom (ville fusionnée) # « St-Lambert (Montérégie) » région collée # « Québec - Beauport » secteur au tiret # « Longueuil / South Shore » libellé marketing bilingue # « Québec City », « Ville De Quebec », « Montreal Downtown », « City Of Montréal » # « Trois Rivieres », « Mont Tremblant », « Vaudreuil Dorion » accents/traits perdus # « Montrã©al » double encodage UTF-8 # « Saint » troncature irrécupérable # # Deux passes : # 1. clean_raw(raw) -> (base, secteur) — règles déterministes ci-dessus ; # 2. build_mapping(comptes) — regroupe les bases nettoyées par SLUG (accents # et traits d'union confondus) et élit le libellé canonique du groupe # (libellé du lexique s'il existe, sinon variante la plus fréquente). # Le résultat est persisté dans la table `city_map` (audit + réutilisation). # ----------------------------------------------------------------------------- from __future__ import annotations import html import re import unicodedata # --- slug (MÊME algorithme que seo.py / frontend api.ts) ---------------------- def slugify(s: str) -> str: # « 3½ » -> « 3-1-2 », « 6½+ » -> « 6-1-2-plus » (types locatifs) s = (s or "").replace("½", " 1 2 ").replace("+", " plus ") s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode() s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") return s[:80].strip("-") # --- lexique ------------------------------------------------------------------- # Libellés officiels des municipalités les plus fréquentes : sert à (a) résoudre # les parenthèses « X (Y) », (b) élire le bon libellé d'un groupe de slugs # (« Trois Rivieres » -> « Trois-Rivières » même si la variante cassée domine). VILLES_CONNUES = [ "Montréal", "Québec", "Laval", "Gatineau", "Longueuil", "Sherbrooke", "Lévis", "Trois-Rivières", "Terrebonne", "Saint-Jean-sur-Richelieu", "Brossard", "Repentigny", "Drummondville", "Saint-Jérôme", "Granby", "Blainville", "Saint-Hyacinthe", "Mirabel", "Shawinigan", "Dollard-des-Ormeaux", "Rimouski", "Châteauguay", "Saint-Eustache", "Mascouche", "Victoriaville", "Salaberry-de-Valleyfield", "Rouyn-Noranda", "Sorel-Tracy", "Vaudreuil-Dorion", "Côte-Saint-Luc", "Val-d'Or", "Alma", "Saint-Georges", "Sainte--Julie", "Sainte-Julie", "Boucherville", "Saguenay", "Chambly", "Saint-Constant", "Magog", "Boisbriand", "Sainte-Thérèse", "La Prairie", "Thetford Mines", "Sept-Îles", "Saint-Bruno-de-Montarville", "Baie-Comeau", "Joliette", "Varennes", "Mont-Royal", "Westmount", "Pointe-Claire", "Kirkland", "Beaconsfield", "Dorval", "Sainte-Adèle", "Saint-Sauveur", "Mont-Tremblant", "Sainte-Agathe-des-Monts", "Bromont", "Chelsea", "Cantley", "Val-des-Monts", "L'Assomption", "Saint-Lambert", "Candiac", "Delson", "Beloeil", "Mont-Saint-Hilaire", "Otterburn Park", "Carignan", "Saint-Basile-le-Grand", "McMasterville", "Sainte-Catherine", "Mercier", "Pincourt", "L'Île-Perrot", "Notre-Dame-de-l'Île-Perrot", "Les Coteaux", "Rigaud", "Hudson", "Saint-Lazare", "Saint-Zotique", "Beauharnois", "Lachute", "Rosemère", "Lorraine", "Bois-des-Filion", "Sainte-Anne-des-Plaines", "Saint-Colomban", "Saint-Lin--Laurentides", "Prévost", "Piedmont", "Saint-Donat", "Rawdon", "Saint-Charles-Borromée", "Notre-Dame-des-Prairies", "L'Épiphanie", "Berthierville", "Louiseville", "Nicolet", "Bécancour", "Plessisville", "Princeville", "Warwick", "Asbestos", "Val-des-Sources", "Windsor", "Coaticook", "Lac-Mégantic", "Cowansville", "Farnham", "Waterloo", "Sutton", "Lac-Brome", "Beauport", "Charlesbourg", "Sainte-Foy", "Ancienne-Lorette", "L'Ancienne-Lorette", "Saint-Augustin-de-Desmaures", "Stoneham-et-Tewkesbury", "Lac-Beauport", "Shannon", "Pont-Rouge", "Donnacona", "Portneuf", "Sainte-Marie", "Saint-Joseph-de-Beauce", "Beauceville", "Montmagny", "La Pocatière", "Rivière-du-Loup", "Témiscouata-sur-le-Lac", "Matane", "Mont-Joli", "Amqui", "Gaspé", "Chandler", "New Richmond", "Carleton-sur-Mer", "Bonaventure", "Percé", "Sainte-Anne-des-Monts", "Baie-Saint-Paul", "La Malbaie", "Clermont", "Roberval", "Saint-Félicien", "Dolbeau-Mistassini", "Amos", "La Sarre", "Malartic", "Senneterre", "Ville-Marie", "Témiscaming", "Chibougamau", "Mont-Laurier", "Maniwaki", "Sainte-Adele", "Saint-Hippolyte", "Sainte-Sophie", "Saint-Calixte", "Chertsey", "Saint-Côme", "Sainte-Marguerite-du-Lac-Masson", "Morin-Heights", "Saint-Adolphe-d'Howard", "Val-David", "Val-Morin", "Labelle", "La Conception", "Saint-Faustin--Lac-Carré", "Mont-Blanc", "Huntingdon", "Ormstown", "Napierville", "Saint-Rémi", "Sainte-Martine", "Coteau-du-Lac", "Les Cèdres", "Vaudreuil-sur-le-Lac", "Oka", "Saint-Joseph-du-Lac", "Pointe-Calumet", "Sainte-Marthe-sur-le-Lac", "Deux-Montagnes", "Saint-Placide", "Brownsburg-Chatham", "Grenville", "Hawkesbury", "Papineauville", "Thurso", "Plaisance", "Montebello", "Fossambault-sur-le-Lac", "Sainte-Catherine-de-la-Jacques-Cartier", "Québec Ouest", "Saint-Raymond", "Cap-Santé", "Neuville", "Montréal-Est", "Montréal-Ouest", "Hampstead", "Mont-Royal", "Sainte-Anne-de-Bellevue", "Baie-D'Urfé", "Senneville", "L'Île-Bizard", "Sainte-Geneviève", "Roxboro", "Pierrefonds", "Greenfield Park", "Saint-Hubert", "LeMoyne", "Marieville", "Richelieu", "Saint-Césaire", "Rougemont", "Acton Vale", "Contrecoeur", "Verchères", "Calixa-Lavallée", "Saint-Amable", "Sainte-Julienne", "Saint-Esprit", "Saint-Roch-de-l'Achigan", "Saint-Lin-Laurentides", "L'Épiphanie", "Charlemagne", "Le Gardeur", "Lavaltrie", "Lanoraie", "Saint-Zénon", "Saint-Michel-des-Saints", "Entrelacs", "Notre-Dame-de-la-Merci", "Saint-Alphonse-Rodriguez", "Sainte-Béatrix", "Saint-Jean-de-Matha", "Saint-Gabriel", "Venise-en-Québec", "Bedford", "Dunham", "Frelighsburg", "Stanbridge East", "Knowlton", "Eastman", "Orford", "North Hatley", "Ayer's Cliff", "Stanstead", "Sainte-Catherine-de-Hatley", "Hatley", "Compton", "Waterville", "East Angus", "Weedon", "Disraeli", "Thetford Mines", "Black Lake", "Saint-Ferdinand", "Lyster", "Laurier-Station", "Saint-Apollinaire", "Saint-Agapit", "Saint-Antoine-de-Tilly", "Sainte-Croix", "Lotbinière", "Saint-Flavien", "Dosquet", "Val-Alain", "Saint-Patrice-de-Beaurivage", "Saint-Gilles", "Saint-Étienne-de-Lauzon", "Saint-Nicolas", "Saint-Rédempteur", "Charny", "Saint-Jean-Chrysostome", "Saint-Romuald", "Pintendre", "Saint-Henri", "Beaumont", "Saint-Michel-de-Bellechasse", "Saint-Vallier", "Berthier-sur-Mer", "Saint-Anselme", "Sainte-Claire", "Saint-Lazare-de-Bellechasse", "Armagh", "Saint-Damien-de-Buckland", "Saint-Nérée", "La Durantaye", ] # Arrondissements / anciens noms -> ville-mère. La clé est un SLUG. ARRONDISSEMENTS: dict[str, tuple[str, str]] = { # --- Montréal --- "ville-marie": ("Montréal", "Ville-Marie"), "le-plateau-mont-royal": ("Montréal", "Le Plateau-Mont-Royal"), "plateau-mont-royal": ("Montréal", "Le Plateau-Mont-Royal"), "rosemont-la-petite-patrie": ("Montréal", "Rosemont/La Petite-Patrie"), "rosemont": ("Montréal", "Rosemont"), "la-petite-patrie": ("Montréal", "La Petite-Patrie"), "mercier-hochelaga-maisonneuve": ("Montréal", "Mercier/Hochelaga-Maisonneuve"), "hochelaga-maisonneuve": ("Montréal", "Hochelaga-Maisonneuve"), "mercier": ("Montréal", "Mercier"), "le-sud-ouest": ("Montréal", "Le Sud-Ouest"), "sud-ouest": ("Montréal", "Le Sud-Ouest"), "lasalle": ("Montréal", "LaSalle"), "saint-leonard": ("Montréal", "Saint-Léonard"), "montreal-nord": ("Montréal", "Montréal-Nord"), "ahuntsic-cartierville": ("Montréal", "Ahuntsic-Cartierville"), "ahuntsic": ("Montréal", "Ahuntsic"), "cartierville": ("Montréal", "Cartierville"), "verdun": ("Montréal", "Verdun"), "ile-des-soeurs": ("Montréal", "L'Île-des-Sœurs"), "l-ile-des-soeurs": ("Montréal", "L'Île-des-Sœurs"), "outremont": ("Montréal", "Outremont"), "anjou": ("Montréal", "Anjou"), "lachine": ("Montréal", "Lachine"), "saint-laurent": ("Montréal", "Saint-Laurent"), "villeray-saint-michel-parc-extension": ("Montréal", "Villeray/Saint-Michel/Parc-Extension"), "villeray": ("Montréal", "Villeray"), "parc-extension": ("Montréal", "Parc-Extension"), "cote-des-neiges-notre-dame-de-grace": ("Montréal", "Côte-des-Neiges/Notre-Dame-de-Grâce"), "cote-des-neiges": ("Montréal", "Côte-des-Neiges"), "notre-dame-de-grace": ("Montréal", "Notre-Dame-de-Grâce"), "riviere-des-prairies-pointe-aux-trembles": ("Montréal", "Rivière-des-Prairies/Pointe-aux-Trembles"), "riviere-des-prairies": ("Montréal", "Rivière-des-Prairies"), "pointe-aux-trembles": ("Montréal", "Pointe-aux-Trembles"), "pierrefonds-roxboro": ("Montréal", "Pierrefonds-Roxboro"), "pierrefonds": ("Montréal", "Pierrefonds"), "roxboro": ("Montréal", "Roxboro"), "l-ile-bizard-sainte-genevieve": ("Montréal", "L'Île-Bizard/Sainte-Geneviève"), "l-ile-bizard": ("Montréal", "L'Île-Bizard"), "griffintown": ("Montréal", "Griffintown"), "vieux-montreal": ("Montréal", "Vieux-Montréal"), "centre-ville": ("Montréal", "Centre-ville"), # --- Québec --- "beauport": ("Québec", "Beauport"), "charlesbourg": ("Québec", "Charlesbourg"), "sainte-foy": ("Québec", "Sainte-Foy"), "ste-foy": ("Québec", "Sainte-Foy"), "sainte-foy-sillery-cap-rouge": ("Québec", "Sainte-Foy/Sillery/Cap-Rouge"), "sillery": ("Québec", "Sillery"), "cap-rouge": ("Québec", "Cap-Rouge"), "limoilou": ("Québec", "Limoilou"), "la-cite-limoilou": ("Québec", "La Cité-Limoilou"), "vanier": ("Québec", "Vanier"), "les-rivieres": ("Québec", "Les Rivières"), "la-haute-saint-charles": ("Québec", "La Haute-Saint-Charles"), "duberger": ("Québec", "Duberger"), "loretteville": ("Québec", "Loretteville"), "val-belair": ("Québec", "Val-Bélair"), "cap-diamant": ("Québec", "Cap-Diamant"), "vieux-quebec": ("Québec", "Vieux-Québec"), "saint-roch": ("Québec", "Saint-Roch"), "saint-sacrement": ("Québec", "Saint-Sacrement"), "montcalm": ("Québec", "Montcalm"), "lebourgneuf": ("Québec", "Lebourgneuf"), # --- Gatineau --- "aylmer": ("Gatineau", "Aylmer"), "hull": ("Gatineau", "Hull"), "buckingham": ("Gatineau", "Buckingham"), "masson-angers": ("Gatineau", "Masson-Angers"), # --- Longueuil --- "saint-hubert": ("Longueuil", "Saint-Hubert"), "le-vieux-longueuil": ("Longueuil", "Le Vieux-Longueuil"), "vieux-longueuil": ("Longueuil", "Le Vieux-Longueuil"), "greenfield-park": ("Longueuil", "Greenfield Park"), # --- Laval --- "chomedey": ("Laval", "Chomedey"), "fabreville": ("Laval", "Fabreville"), "sainte-rose": ("Laval", "Sainte-Rose"), "duvernay": ("Laval", "Duvernay"), "laval-des-rapides": ("Laval", "Laval-des-Rapides"), "pont-viau": ("Laval", "Pont-Viau"), "vimont": ("Laval", "Vimont"), "auteuil": ("Laval", "Auteuil"), "sainte-dorothee": ("Laval", "Sainte-Dorothée"), "laval-ouest": ("Laval", "Laval-Ouest"), "chomedy": ("Laval", "Chomedey"), # --- Saguenay --- "jonquiere": ("Saguenay", "Jonquière"), "chicoutimi": ("Saguenay", "Chicoutimi"), "la-baie": ("Saguenay", "La Baie"), # --- Lévis --- "saint-romuald-2": ("Lévis", "Saint-Romuald"), } # Slugs de RÉGIONS administratives / marketing : jamais une ville. REGIONS = { "monteregie", "lanaudiere", "laurentides", "estrie", "outaouais", "mauricie", "centre-du-quebec", "abitibi-temiscamingue", "bas-saint-laurent", "gaspesie", "gaspesie-iles-de-la-madeleine", "cote-nord", "saguenay-lac-saint-jean", "chaudiere-appalaches", "capitale-nationale", "nord-du-quebec", "montreal-region", "region-de-quebec", "grand-montreal", "rive-sud", "rive-nord", "south-shore", "north-shore", "quebec-rive-nord", "quebec-rive-sud", "montreal-ile", "monteregie-rive-sud", } # Alias directs (slug -> (ville, secteur)) : variantes anglaises, libellés # marketing, préfixes administratifs. ALIAS: dict[str, tuple[str, str]] = { "montreal-downtown": ("Montréal", "Centre-ville"), "downtown-montreal": ("Montréal", "Centre-ville"), "city-of-montreal": ("Montréal", ""), "montreal-city": ("Montréal", ""), "ville-de-montreal": ("Montréal", ""), "quebec-city": ("Québec", ""), "ville-de-quebec": ("Québec", ""), "city-of-quebec": ("Québec", ""), "old-montreal": ("Montréal", "Vieux-Montréal"), "old-quebec": ("Québec", "Vieux-Québec"), "st-jerome": ("Saint-Jérôme", ""), "trois-rivieres-ouest": ("Trois-Rivières", "Trois-Rivières-Ouest"), } def _accents(label: str) -> int: return sum(1 for c in label if ord(c) > 127) + label.count("-") # si deux libellés du lexique partagent un slug (« Sainte-Adèle » / # « Sainte-Adele »), garder la variante la plus riche (accents, traits d'union) _VILLE_PAR_SLUG: dict[str, str] = {} for _v in VILLES_CONNUES: _s = slugify(_v) if _s not in _VILLE_PAR_SLUG or _accents(_v) > _accents(_VILLE_PAR_SLUG[_s]): _VILLE_PAR_SLUG[_s] = _v # les arrondissements sont subordonnés : jamais élus comme ville canonique for _s in list(ARRONDISSEMENTS): _VILLE_PAR_SLUG.pop(_s, None) # --- nettoyage d'une valeur brute --------------------------------------------- _WS = re.compile(r"\s+") _MOJIBAKE = re.compile(r"[ÃÂ][©®¨«¢€°]|Ã[a-z]") def _fix_mojibake(s: str) -> str: """Répare un double encodage UTF-8 -> latin-1 (« Montrã©al » -> « Montréal »).""" if not _MOJIBAKE.search(s): return s try: fixed = s.encode("latin-1").decode("utf-8") return fixed if fixed and "�" not in fixed else s except (UnicodeEncodeError, UnicodeDecodeError): return s def _pre(s: str) -> str: s = _fix_mojibake(s or "") s = html.unescape(html.unescape(s)) s = s.replace("’", "'").replace("`", "'") s = _WS.sub(" ", s).strip(" \t,;·—–-") return s.strip() def _expand_st(s: str) -> str: s = re.sub(r"\bSt-", "Saint-", s) s = re.sub(r"\bSte-", "Sainte-", s) s = re.sub(r"\bSt\.\s*", "Saint-", s) s = re.sub(r"\bSte\.\s*", "Sainte-", s) return s def _dedupe_words(s: str) -> str: """« Gatineau Gatineau » -> « Gatineau » (mot entier répété).""" parts = s.split(" ") if len(parts) == 2 and slugify(parts[0]) == slugify(parts[1]): return parts[0] return s def _resolve(base: str, inner: str) -> tuple[str, str]: """Résout « base (inner) » -> (ville, secteur).""" bs, is_ = slugify(base), slugify(inner) if not base: base, bs = inner, is_ inner, is_ = "", "" if is_ and is_ == bs: # « Gatineau (Gatineau) » inner, is_ = "", "" if is_ in REGIONS: # « St-Lambert (Montérégie) » inner, is_ = "", "" if bs in ALIAS: v, sect = ALIAS[bs] return v, sect or inner if bs in _VILLE_PAR_SLUG: # « Montréal (Ville-Marie) » return _VILLE_PAR_SLUG[bs], inner if bs in ARRONDISSEMENTS: # « Ville-Marie (…) », « Aylmer (Gatineau) » ville, sect = ARRONDISSEMENTS[bs] return ville, sect if is_ in ARRONDISSEMENTS: # « X (Hull) » -> Gatineau ? non : X prime pass if is_ and is_ in _VILLE_PAR_SLUG: # « Quartier-Inconnu (Longueuil) » return _VILLE_PAR_SLUG[is_], base return base, inner def clean_raw(raw: str) -> tuple[str, str]: """Valeur brute -> (ville nettoyée, secteur extrait). Ville '' si irrécupérable.""" s = _pre(raw) if not s or len(s) < 2 or s.isdigit(): return "", "" # parenthèses : fermées, orphelines ou jamais fermées base, inner = s, "" if "(" in s: base, _, rest = s.partition("(") inner = rest.rstrip(")").strip() base = base.strip() elif s.endswith(")"): base = s.rstrip(")").strip() # « Québec - Beauport » / « Longueuil / South Shore » for sep in (" - ", " – ", " / "): if sep in base: left, _, right = base.partition(sep) if slugify(left) in _VILLE_PAR_SLUG or slugify(left) in ALIAS: base, inner = left.strip(), inner or right.strip() if slugify(inner) in REGIONS: inner = "" break base = _dedupe_words(_expand_st(base)) inner = _expand_st(inner) if slugify(base) in REGIONS: # « Montérégie » seul : pas une ville return "", "" if slugify(base) in {"saint", "sainte", "st", "ste"}: # troncature return "", "" ville, secteur = _resolve(base, inner) ville = _WS.sub(" ", ville).strip(" -–") secteur = _WS.sub(" ", secteur).strip(" -–") if len(ville) < 2: return "", "" return ville, secteur # --- élection du libellé canonique par groupe de slug -------------------------- def _label_score(label: str, count: int) -> tuple: """Préférence : lexique connu > accents/traits présents > fréquence.""" known = slugify(label) in _VILLE_PAR_SLUG and label == _VILLE_PAR_SLUG[slugify(label)] accents = sum(1 for c in label if unicodedata.category(c) == "Lu" or ord(c) > 127) hyphens = label.count("-") return (known, accents + hyphens, count) def build_mapping(counts: dict[str, int]) -> dict[str, tuple[str, str]]: """{valeur brute: occurrences} -> {valeur brute: (ville canonique, secteur)}. 1. clean_raw sur chaque valeur brute ; 2. groupement des villes nettoyées par slug ; 3. libellé canonique du groupe = libellé du lexique, sinon la variante au meilleur score (accents/traits d'union puis fréquence). """ cleaned: dict[str, tuple[str, str]] = {} groups: dict[str, dict[str, int]] = {} for raw, n in counts.items(): ville, secteur = clean_raw(raw) cleaned[raw] = (ville, secteur) if ville: groups.setdefault(slugify(ville), {}) groups[slugify(ville)][ville] = groups[slugify(ville)].get(ville, 0) + n canon: dict[str, str] = {} for slug, variants in groups.items(): if slug in _VILLE_PAR_SLUG: canon[slug] = _VILLE_PAR_SLUG[slug] else: canon[slug] = max(variants.items(), key=lambda kv: _label_score(*kv))[0] return {raw: (canon.get(slugify(v), v) if v else "", s) for raw, (v, s) in cleaned.items()}