TypeScript 50.6%
JavaScript 46.1%
SQL 2.1%
Shell 0.6%
CSS 0.5%
1// Toveli — normalisation d'adresses, de catégories et de régions (TypeScript, sans dépendance).2// Inspiré de la logique de dédoublonnage de Lou-Ka (louka/dedup.py : grille ~110 m + clé d'adresse)3// et d'Immo-Ka (immoka/db.py dedup_by_address), réécrite ici pour le rapprochement annonce → propriété.45export function stripAccents(s: string): string {6 return s.normalize("NFD").replace(/[̀-ͯ]/g, "");7}89const STREET_ABBR: Record<string, string> = {10 av: "avenue", ave: "avenue", "av.": "avenue",11 boul: "boulevard", "boul.": "boulevard", blvd: "boulevard", bd: "boulevard",12 ch: "chemin", "ch.": "chemin", rte: "route", "rte.": "route",13 st: "saint", "st.": "saint", ste: "sainte", "ste.": "sainte",14 pl: "place", prom: "promenade", mtee: "montee", "mtée": "montee", cr: "croissant", crois: "croissant",15 terr: "terrasse", imp: "impasse", all: "allee", "allée": "allee",16 e: "est", o: "ouest", n: "nord", s: "sud", w: "ouest",17 rd: "road", dr: "drive", cres: "crescent",18};19const NOISE = new Set(["rue", "de", "du", "des", "la", "le", "les", "l", "d", "the", "street", "app", "apt", "unite", "unit", "bureau", "suite", "local"]);2021export interface AddressParts { civic: string | null; unit: string | null; street: string; }2223/** Découpe « 1225-A Rue Saint-Marc, app. 4 » → civic 1225a, unit 4, street « saint marc ». */24export function parseAddress(raw: string | null | undefined): AddressParts {25 if (!raw) return { civic: null, unit: null, street: "" };26 let s = stripAccents(raw.toLowerCase()).replace(/[’']/g, " ").replace(/[,;]/g, " ");27 let unit: string | null = null;28 const um = s.match(/\b(?:app|apt|unite|unit|suite|local|#)\s*\.?\s*([0-9a-z-]+)/);29 if (um) { unit = um[1]; s = s.replace(um[0], " "); }30 const cm = s.match(/^\s*(\d+)(?:\s*-?\s*([a-z])\b)?(?:\s*-\s*\d+[a-z]?)?\s+/);31 let civic: string | null = null;32 if (cm) { civic = cm[1] + (cm[2] ?? ""); s = s.slice(cm[0].length); }33 const tokens = s.replace(/[^a-z0-9\s-]/g, " ").replace(/-/g, " ").split(/\s+/).filter(Boolean)34 .map((t) => STREET_ABBR[t] ?? t).filter((t) => !NOISE.has(t));35 return { civic, unit, street: tokens.join(" ") };36}3738export function normCity(city: string | null | undefined): string {39 if (!city) return "";40 let c = stripAccents(city.toLowerCase()).replace(/[’']/g, " ").replace(/[^a-z0-9\s-]/g, " ").replace(/-/g, " ").replace(/\s+/g, " ").trim();41 c = c.replace(/\bst\b/g, "saint").replace(/\bste\b/g, "sainte").replace(/ city$/, "").replace(/^ville de /, "");42 if (c === "quebec city") c = "quebec";43 return c;44}4546/** Clé d'adresse canonique « civic|street|city » ; null si l'adresse n'a pas de numéro civique. */47export function addressKey(address: string | null | undefined, city: string | null | undefined): string | null {48 const p = parseAddress(address);49 if (!p.civic || !p.street) return null;50 const c = normCity(city);51 if (!c) return null;52 return `${p.civic}|${p.street}|${c}`;53}5455export function coordKey(lat: number, lng: number, digits = 4): string {56 return `${lat.toFixed(digits)},${lng.toFixed(digits)}`;57}5859export type Category = "maison" | "condo" | "plex" | "terrain" | "commercial" | "chalet" | "logement" | "chambre" | "autre";6061/** Immo-Ka property_type (libellés sources) → catégorie canonique Toveli. */62export function saleCategory(propertyType: string | null | undefined, title?: string | null): Category {63 const t = stripAccents((propertyType ?? "").toLowerCase());64 if (!t) {65 const ti = stripAccents((title ?? "").toLowerCase());66 if (/condo|copropriete/.test(ti)) return "condo";67 if (/plex|multilogement/.test(ti)) return "plex";68 if (/terrain|lot /.test(ti)) return "terrain";69 if (/maison|bungalow|cottage|jumele/.test(ti)) return "maison";70 return "autre";71 }72 if (/condo|copropriete|appartement|loft|penthouse/.test(t)) return "condo";73 if (/duplex|triplex|quadruplex|quintuplex|multiplex|plex|multi|immeuble a revenus|revenus/.test(t)) return "plex";74 if (/terrain|lot\b|vacant/.test(t)) return "terrain";75 if (/commercial|industriel|bureau|portefeuille|entreprise|immobilier\b/.test(t)) return "commercial";76 if (/chalet|recreative|villegiature/.test(t)) return "chalet";77 if (/maison|bungalow|cottage|jumele|detach|ville|mobile|fermette|agricole|residentiel|propriete|unifamilial|etage|plain-pied/.test(t)) return "maison";78 return "autre";79}8081/** Lou-Ka unit_type (4½, Studio, Maison, Chambre…) → catégorie canonique. */82export function rentCategory(unitType: string | null | undefined): Category {83 const t = stripAccents((unitType ?? "").toLowerCase());84 if (/chambre|room/.test(t)) return "chambre";85 if (/maison|house/.test(t)) return "maison";86 if (/condo/.test(t)) return "condo";87 return "logement";88}8990/** Catégorie canonique → type_prop du moteur Vrai-Prix (rôle MAMH). */91export function categoryToTypeProp(c: Category | string | null | undefined): string {92 switch (c) {93 case "maison": return "unifamilial";94 case "condo": return "condo_ou_multi";95 case "plex": return "plex";96 case "terrain": return "terrain";97 case "chalet": return "chalet";98 default: return "autre";99 }100}101102export const CATEGORY_LABEL: Record<string, string> = {103 maison: "Maison", condo: "Condo", plex: "Plex", terrain: "Terrain", commercial: "Commercial",104 chalet: "Chalet", logement: "Logement", chambre: "Chambre", autre: "Autre",105};106107// ─── Régions administratives du Québec ───────────────────────────────────────108export const REGIONS = [109 "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale", "Mauricie", "Estrie", "Montréal",110 "Outaouais", "Abitibi-Témiscamingue", "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine",111 "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", "Montérégie", "Centre-du-Québec",112] as const;113114const REGION_ALIASES: Record<string, string> = {115 "01": "Bas-Saint-Laurent", "bas saint laurent": "Bas-Saint-Laurent",116 "02": "Saguenay–Lac-Saint-Jean", "saguenay lac saint jean": "Saguenay–Lac-Saint-Jean", "saguenay": "Saguenay–Lac-Saint-Jean",117 "03": "Capitale-Nationale", "capitale nationale": "Capitale-Nationale", "region de quebec": "Capitale-Nationale", "quebec rive nord": "Capitale-Nationale", "quebec": "Capitale-Nationale", "charlevoix": "Capitale-Nationale", "portneuf": "Capitale-Nationale",118 "04": "Mauricie", "mauricie": "Mauricie",119 "05": "Estrie", "estrie": "Estrie", "cantons de l est": "Estrie",120 "06": "Montréal", "montreal": "Montréal", "ile de montreal": "Montréal",121 "07": "Outaouais", "outaouais": "Outaouais",122 "08": "Abitibi-Témiscamingue", "abitibi temiscamingue": "Abitibi-Témiscamingue", "abitibi": "Abitibi-Témiscamingue",123 "09": "Côte-Nord", "cote nord": "Côte-Nord",124 "10": "Nord-du-Québec", "nord du quebec": "Nord-du-Québec",125 "11": "Gaspésie–Îles-de-la-Madeleine", "gaspesie iles de la madeleine": "Gaspésie–Îles-de-la-Madeleine", "gaspesie": "Gaspésie–Îles-de-la-Madeleine",126 "12": "Chaudière-Appalaches", "chaudiere appalaches": "Chaudière-Appalaches", "quebec rive sud": "Chaudière-Appalaches", "levis": "Chaudière-Appalaches",127 "13": "Laval", "laval": "Laval", "laval rive nord": "Laval",128 "14": "Lanaudière", "lanaudiere": "Lanaudière",129 "15": "Laurentides", "laurentides": "Laurentides",130 "16": "Montérégie", "monteregie": "Montérégie", "monteregie rive sud montreal": "Montérégie", "rive sud de montreal": "Montérégie", "rive sud": "Montérégie", "vaudreuil soulanges": "Montérégie",131 "17": "Centre-du-Québec", "centre du quebec": "Centre-du-Québec",132};133134const CITY_REGION: Record<string, string> = {135 montreal: "Montréal", westmount: "Montréal", "mont royal": "Montréal", "montreal est": "Montréal", "montreal ouest": "Montréal", "cote saint luc": "Montréal", hampstead: "Montréal", "dollard des ormeaux": "Montréal", "pointe claire": "Montréal", kirkland: "Montréal", "beaconsfield": "Montréal", "dorval": "Montréal", "sainte anne de bellevue": "Montréal", "baie d urfe": "Montréal", "senneville": "Montréal", "pierrefonds": "Montréal", "saint laurent": "Montréal", "lasalle": "Montréal", "verdun": "Montréal", "outremont": "Montréal", "anjou": "Montréal", "lachine": "Montréal", "montreal nord": "Montréal", "saint leonard": "Montréal", "ahuntsic": "Montréal", "rosemont": "Montréal", "villeray": "Montréal", "le plateau mont royal": "Montréal", "ville marie": "Montréal", "hochelaga": "Montréal", "mercier": "Montréal", "riviere des prairies": "Montréal", "pointe aux trembles": "Montréal", "cote des neiges": "Montréal", "notre dame de grace": "Montréal", "sud ouest": "Montréal", "le sud ouest": "Montréal",136 quebec: "Capitale-Nationale", "l ancienne lorette": "Capitale-Nationale", "saint augustin de desmaures": "Capitale-Nationale", "beauport": "Capitale-Nationale", "charlesbourg": "Capitale-Nationale", "sainte foy": "Capitale-Nationale", "baie saint paul": "Capitale-Nationale", "la malbaie": "Capitale-Nationale", "stoneham et tewkesbury": "Capitale-Nationale", "lac beauport": "Capitale-Nationale", "boischatel": "Capitale-Nationale", "pont rouge": "Capitale-Nationale", "donnacona": "Capitale-Nationale", "saint raymond": "Capitale-Nationale", "sainte catherine de la jacques cartier": "Capitale-Nationale", "shannon": "Capitale-Nationale", "chateau richer": "Capitale-Nationale", "sainte anne de beaupre": "Capitale-Nationale",137 levis: "Chaudière-Appalaches", "saint georges": "Chaudière-Appalaches", thetford: "Chaudière-Appalaches", "thetford mines": "Chaudière-Appalaches", montmagny: "Chaudière-Appalaches", "sainte marie": "Chaudière-Appalaches", beauceville: "Chaudière-Appalaches", "saint joseph de beauce": "Chaudière-Appalaches", "saint henri": "Chaudière-Appalaches", "saint lambert de lauzon": "Chaudière-Appalaches",138 laval: "Laval",139 longueuil: "Montérégie", brossard: "Montérégie", "saint lambert": "Montérégie", boucherville: "Montérégie", "saint bruno de montarville": "Montérégie", "saint hubert": "Montérégie", "sainte julie": "Montérégie", varennes: "Montérégie", chambly: "Montérégie", "la prairie": "Montérégie", candiac: "Montérégie", "saint constant": "Montérégie", chateauguay: "Montérégie", "saint jean sur richelieu": "Montérégie", "saint hyacinthe": "Montérégie", granby: "Montérégie", "vaudreuil dorion": "Montérégie", "salaberry de valleyfield": "Montérégie", "beloeil": "Montérégie", "mont saint hilaire": "Montérégie", "saint basile le grand": "Montérégie", "sorel tracy": "Montérégie", "sainte catherine": "Montérégie", "delson": "Montérégie", "saint remi": "Montérégie", "pincourt": "Montérégie", "l ile perrot": "Montérégie", "saint lazare": "Montérégie", "hudson": "Montérégie", "rigaud": "Montérégie", "carignan": "Montérégie", "otterburn park": "Montérégie", "mcmasterville": "Montérégie", "saint amable": "Montérégie", "contrecoeur": "Montérégie", "cowansville": "Montérégie", "bromont": "Montérégie", "sutton": "Montérégie", "acton vale": "Montérégie", "farnham": "Montérégie", "greenfield park": "Montérégie",140 gatineau: "Outaouais", "chelsea": "Outaouais", "cantley": "Outaouais", "val des monts": "Outaouais", "maniwaki": "Outaouais", "l ange gardien": "Outaouais", "papineauville": "Outaouais", "thurso": "Outaouais", "pontiac": "Outaouais",141 sherbrooke: "Estrie", magog: "Estrie", "coaticook": "Estrie", "lac megantic": "Estrie", "windsor": "Estrie", "east angus": "Estrie", "orford": "Estrie", "stanstead": "Estrie", "asbestos": "Estrie", "val des sources": "Estrie", "richmond": "Estrie", "north hatley": "Estrie",142 "trois rivieres": "Mauricie", shawinigan: "Mauricie", "la tuque": "Mauricie", louiseville: "Mauricie", "saint tite": "Mauricie",143 terrebonne: "Lanaudière", repentigny: "Lanaudière", mascouche: "Lanaudière", "l assomption": "Lanaudière", joliette: "Lanaudière", charlemagne: "Lanaudière", "saint charles borromee": "Lanaudière", "rawdon": "Lanaudière", "saint lin laurentides": "Lanaudière", "lavaltrie": "Lanaudière", "berthierville": "Lanaudière", "notre dame des prairies": "Lanaudière", "saint donat": "Lanaudière", "lanoraie": "Lanaudière", "saint esprit": "Lanaudière",144 "saint jerome": "Laurentides", blainville: "Laurentides", boisbriand: "Laurentides", "sainte therese": "Laurentides", mirabel: "Laurentides", "saint eustache": "Laurentides", "deux montagnes": "Laurentides", "sainte adele": "Laurentides", "saint sauveur": "Laurentides", "mont tremblant": "Laurentides", "sainte agathe des monts": "Laurentides", rosemere: "Laurentides", lorraine: "Laurentides", "bois des filion": "Laurentides", "sainte anne des plaines": "Laurentides", "prevost": "Laurentides", "saint colomban": "Laurentides", "saint hippolyte": "Laurentides", "lachute": "Laurentides", "morin heights": "Laurentides", "val david": "Laurentides", "piedmont": "Laurentides", "saint joseph du lac": "Laurentides", "oka": "Laurentides", "pointe calumet": "Laurentides", "sainte marthe sur le lac": "Laurentides",145 saguenay: "Saguenay–Lac-Saint-Jean", chicoutimi: "Saguenay–Lac-Saint-Jean", jonquiere: "Saguenay–Lac-Saint-Jean", alma: "Saguenay–Lac-Saint-Jean", roberval: "Saguenay–Lac-Saint-Jean", dolbeau: "Saguenay–Lac-Saint-Jean", "dolbeau mistassini": "Saguenay–Lac-Saint-Jean", "saint felicien": "Saguenay–Lac-Saint-Jean", "la baie": "Saguenay–Lac-Saint-Jean",146 rimouski: "Bas-Saint-Laurent", "riviere du loup": "Bas-Saint-Laurent", matane: "Bas-Saint-Laurent", "mont joli": "Bas-Saint-Laurent", "temiscouata sur le lac": "Bas-Saint-Laurent", "amqui": "Bas-Saint-Laurent", "la pocatiere": "Bas-Saint-Laurent",147 drummondville: "Centre-du-Québec", victoriaville: "Centre-du-Québec", "becancour": "Centre-du-Québec", nicolet: "Centre-du-Québec", "plessisville": "Centre-du-Québec",148 "rouyn noranda": "Abitibi-Témiscamingue", "val d or": "Abitibi-Témiscamingue", "val dor": "Abitibi-Témiscamingue", amos: "Abitibi-Témiscamingue", "la sarre": "Abitibi-Témiscamingue",149 "baie comeau": "Côte-Nord", "sept iles": "Côte-Nord", "port cartier": "Côte-Nord", "forestville": "Côte-Nord",150 gaspe: "Gaspésie–Îles-de-la-Madeleine", "les iles de la madeleine": "Gaspésie–Îles-de-la-Madeleine", "chandler": "Gaspésie–Îles-de-la-Madeleine", "perce": "Gaspésie–Îles-de-la-Madeleine", "carleton sur mer": "Gaspésie–Îles-de-la-Madeleine", "sainte anne des monts": "Gaspésie–Îles-de-la-Madeleine", "bonaventure": "Gaspésie–Îles-de-la-Madeleine", "new richmond": "Gaspésie–Îles-de-la-Madeleine",151 chibougamau: "Nord-du-Québec", "lebel sur quevillon": "Nord-du-Québec", matagami: "Nord-du-Québec",152};153154export function normRegion(raw: string | null | undefined, city?: string | null): string | null {155 if (raw) {156 const k = stripAccents(raw.toLowerCase()).replace(/[’'-]/g, " ").replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();157 if (REGION_ALIASES[k]) return REGION_ALIASES[k];158 for (const r of REGIONS) if (stripAccents(r.toLowerCase()).replace(/[–-]/g, " ") === k) return r;159 }160 if (city) {161 const c = normCity(city);162 if (CITY_REGION[c]) return CITY_REGION[c];163 const first = c.split(" ")[0];164 if (CITY_REGION[first] && ["montreal", "quebec", "laval", "gatineau", "sherbrooke", "levis", "longueuil"].includes(first)) return CITY_REGION[first];165 }166 return null;167}168169/** Affichage d'une municipalité : on garde la graphie de la source (pas de recomposition hasardeuse), sauf Québec/Montréal unifiés. */170export function displayCity(city: string | null | undefined): string {171 if (!city) return "";172 const c = city.trim().replace(/\s+/g, " ");173 const n = normCity(c);174 if (n === "quebec") return "Québec";175 if (n === "montreal") return "Montréal";176 if (c === c.toLowerCase() || c === c.toUpperCase()) return c.toLowerCase().replace(/(^|[\s-])([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase());177 return c;178}179180export function epochToISO(v: number | string | null | undefined): string | null {181 if (v == null || v === "") return null;182 if (typeof v === "number") return new Date(v * 1000).toISOString();183 const n = Number(v);184 if (!Number.isNaN(n) && n > 1e9) return new Date(n * 1000).toISOString();185 const d = new Date(v.replace(" ", "T"));186 return Number.isNaN(d.getTime()) ? null : d.toISOString();187}188189export function sha1Short(s: string): string {190 // FNV-1a 64 bits en deux moitiés → 16 hex ; suffisant pour un identifiant interne stable (pas cryptographique)191 let h1 = 0x811c9dc5, h2 = 0x01000193;192 for (let i = 0; i < s.length; i++) {193 const c = s.charCodeAt(i);194 h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;195 h2 = Math.imul(h2 ^ c, 0x9e3779b1) >>> 0;196 }197 return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0");198}199