SPB Git

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%
19.3 KB · 411 lines python
Raw Blame History
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