// Toveli — normalisation d'adresses, de catégories et de régions (TypeScript, sans dépendance). // Inspiré de la logique de dédoublonnage de Lou-Ka (louka/dedup.py : grille ~110 m + clé d'adresse) // et d'Immo-Ka (immoka/db.py dedup_by_address), réécrite ici pour le rapprochement annonce → propriété. export function stripAccents(s: string): string { return s.normalize("NFD").replace(/[̀-ͯ]/g, ""); } const STREET_ABBR: Record = { av: "avenue", ave: "avenue", "av.": "avenue", boul: "boulevard", "boul.": "boulevard", blvd: "boulevard", bd: "boulevard", ch: "chemin", "ch.": "chemin", rte: "route", "rte.": "route", st: "saint", "st.": "saint", ste: "sainte", "ste.": "sainte", pl: "place", prom: "promenade", mtee: "montee", "mtée": "montee", cr: "croissant", crois: "croissant", terr: "terrasse", imp: "impasse", all: "allee", "allée": "allee", e: "est", o: "ouest", n: "nord", s: "sud", w: "ouest", rd: "road", dr: "drive", cres: "crescent", }; const NOISE = new Set(["rue", "de", "du", "des", "la", "le", "les", "l", "d", "the", "street", "app", "apt", "unite", "unit", "bureau", "suite", "local"]); export interface AddressParts { civic: string | null; unit: string | null; street: string; } /** Découpe « 1225-A Rue Saint-Marc, app. 4 » → civic 1225a, unit 4, street « saint marc ». */ export function parseAddress(raw: string | null | undefined): AddressParts { if (!raw) return { civic: null, unit: null, street: "" }; let s = stripAccents(raw.toLowerCase()).replace(/[’']/g, " ").replace(/[,;]/g, " "); let unit: string | null = null; const um = s.match(/\b(?:app|apt|unite|unit|suite|local|#)\s*\.?\s*([0-9a-z-]+)/); if (um) { unit = um[1]; s = s.replace(um[0], " "); } const cm = s.match(/^\s*(\d+)(?:\s*-?\s*([a-z])\b)?(?:\s*-\s*\d+[a-z]?)?\s+/); let civic: string | null = null; if (cm) { civic = cm[1] + (cm[2] ?? ""); s = s.slice(cm[0].length); } const tokens = s.replace(/[^a-z0-9\s-]/g, " ").replace(/-/g, " ").split(/\s+/).filter(Boolean) .map((t) => STREET_ABBR[t] ?? t).filter((t) => !NOISE.has(t)); return { civic, unit, street: tokens.join(" ") }; } export function normCity(city: string | null | undefined): string { if (!city) return ""; let c = stripAccents(city.toLowerCase()).replace(/[’']/g, " ").replace(/[^a-z0-9\s-]/g, " ").replace(/-/g, " ").replace(/\s+/g, " ").trim(); c = c.replace(/\bst\b/g, "saint").replace(/\bste\b/g, "sainte").replace(/ city$/, "").replace(/^ville de /, ""); if (c === "quebec city") c = "quebec"; return c; } /** Clé d'adresse canonique « civic|street|city » ; null si l'adresse n'a pas de numéro civique. */ export function addressKey(address: string | null | undefined, city: string | null | undefined): string | null { const p = parseAddress(address); if (!p.civic || !p.street) return null; const c = normCity(city); if (!c) return null; return `${p.civic}|${p.street}|${c}`; } export function coordKey(lat: number, lng: number, digits = 4): string { return `${lat.toFixed(digits)},${lng.toFixed(digits)}`; } export type Category = "maison" | "condo" | "plex" | "terrain" | "commercial" | "chalet" | "logement" | "chambre" | "autre"; /** Immo-Ka property_type (libellés sources) → catégorie canonique Toveli. */ export function saleCategory(propertyType: string | null | undefined, title?: string | null): Category { const t = stripAccents((propertyType ?? "").toLowerCase()); if (!t) { const ti = stripAccents((title ?? "").toLowerCase()); if (/condo|copropriete/.test(ti)) return "condo"; if (/plex|multilogement/.test(ti)) return "plex"; if (/terrain|lot /.test(ti)) return "terrain"; if (/maison|bungalow|cottage|jumele/.test(ti)) return "maison"; return "autre"; } if (/condo|copropriete|appartement|loft|penthouse/.test(t)) return "condo"; if (/duplex|triplex|quadruplex|quintuplex|multiplex|plex|multi|immeuble a revenus|revenus/.test(t)) return "plex"; if (/terrain|lot\b|vacant/.test(t)) return "terrain"; if (/commercial|industriel|bureau|portefeuille|entreprise|immobilier\b/.test(t)) return "commercial"; if (/chalet|recreative|villegiature/.test(t)) return "chalet"; if (/maison|bungalow|cottage|jumele|detach|ville|mobile|fermette|agricole|residentiel|propriete|unifamilial|etage|plain-pied/.test(t)) return "maison"; return "autre"; } /** Lou-Ka unit_type (4½, Studio, Maison, Chambre…) → catégorie canonique. */ export function rentCategory(unitType: string | null | undefined): Category { const t = stripAccents((unitType ?? "").toLowerCase()); if (/chambre|room/.test(t)) return "chambre"; if (/maison|house/.test(t)) return "maison"; if (/condo/.test(t)) return "condo"; return "logement"; } /** Catégorie canonique → type_prop du moteur Vrai-Prix (rôle MAMH). */ export function categoryToTypeProp(c: Category | string | null | undefined): string { switch (c) { case "maison": return "unifamilial"; case "condo": return "condo_ou_multi"; case "plex": return "plex"; case "terrain": return "terrain"; case "chalet": return "chalet"; default: return "autre"; } } export const CATEGORY_LABEL: Record = { maison: "Maison", condo: "Condo", plex: "Plex", terrain: "Terrain", commercial: "Commercial", chalet: "Chalet", logement: "Logement", chambre: "Chambre", autre: "Autre", }; // ─── Régions administratives du Québec ─────────────────────────────────────── export const REGIONS = [ "Bas-Saint-Laurent", "Saguenay–Lac-Saint-Jean", "Capitale-Nationale", "Mauricie", "Estrie", "Montréal", "Outaouais", "Abitibi-Témiscamingue", "Côte-Nord", "Nord-du-Québec", "Gaspésie–Îles-de-la-Madeleine", "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", "Montérégie", "Centre-du-Québec", ] as const; const REGION_ALIASES: Record = { "01": "Bas-Saint-Laurent", "bas saint laurent": "Bas-Saint-Laurent", "02": "Saguenay–Lac-Saint-Jean", "saguenay lac saint jean": "Saguenay–Lac-Saint-Jean", "saguenay": "Saguenay–Lac-Saint-Jean", "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", "04": "Mauricie", "mauricie": "Mauricie", "05": "Estrie", "estrie": "Estrie", "cantons de l est": "Estrie", "06": "Montréal", "montreal": "Montréal", "ile de montreal": "Montréal", "07": "Outaouais", "outaouais": "Outaouais", "08": "Abitibi-Témiscamingue", "abitibi temiscamingue": "Abitibi-Témiscamingue", "abitibi": "Abitibi-Témiscamingue", "09": "Côte-Nord", "cote nord": "Côte-Nord", "10": "Nord-du-Québec", "nord du quebec": "Nord-du-Québec", "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", "12": "Chaudière-Appalaches", "chaudiere appalaches": "Chaudière-Appalaches", "quebec rive sud": "Chaudière-Appalaches", "levis": "Chaudière-Appalaches", "13": "Laval", "laval": "Laval", "laval rive nord": "Laval", "14": "Lanaudière", "lanaudiere": "Lanaudière", "15": "Laurentides", "laurentides": "Laurentides", "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", "17": "Centre-du-Québec", "centre du quebec": "Centre-du-Québec", }; const CITY_REGION: Record = { 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", 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", 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", laval: "Laval", 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", gatineau: "Outaouais", "chelsea": "Outaouais", "cantley": "Outaouais", "val des monts": "Outaouais", "maniwaki": "Outaouais", "l ange gardien": "Outaouais", "papineauville": "Outaouais", "thurso": "Outaouais", "pontiac": "Outaouais", 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", "trois rivieres": "Mauricie", shawinigan: "Mauricie", "la tuque": "Mauricie", louiseville: "Mauricie", "saint tite": "Mauricie", 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", "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", 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", 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", 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", "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", "baie comeau": "Côte-Nord", "sept iles": "Côte-Nord", "port cartier": "Côte-Nord", "forestville": "Côte-Nord", 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", chibougamau: "Nord-du-Québec", "lebel sur quevillon": "Nord-du-Québec", matagami: "Nord-du-Québec", }; export function normRegion(raw: string | null | undefined, city?: string | null): string | null { if (raw) { const k = stripAccents(raw.toLowerCase()).replace(/[’'-]/g, " ").replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim(); if (REGION_ALIASES[k]) return REGION_ALIASES[k]; for (const r of REGIONS) if (stripAccents(r.toLowerCase()).replace(/[–-]/g, " ") === k) return r; } if (city) { const c = normCity(city); if (CITY_REGION[c]) return CITY_REGION[c]; const first = c.split(" ")[0]; if (CITY_REGION[first] && ["montreal", "quebec", "laval", "gatineau", "sherbrooke", "levis", "longueuil"].includes(first)) return CITY_REGION[first]; } return null; } /** Affichage d'une municipalité : on garde la graphie de la source (pas de recomposition hasardeuse), sauf Québec/Montréal unifiés. */ export function displayCity(city: string | null | undefined): string { if (!city) return ""; const c = city.trim().replace(/\s+/g, " "); const n = normCity(c); if (n === "quebec") return "Québec"; if (n === "montreal") return "Montréal"; if (c === c.toLowerCase() || c === c.toUpperCase()) return c.toLowerCase().replace(/(^|[\s-])([a-z])/g, (m, sep, ch) => sep + ch.toUpperCase()); return c; } export function epochToISO(v: number | string | null | undefined): string | null { if (v == null || v === "") return null; if (typeof v === "number") return new Date(v * 1000).toISOString(); const n = Number(v); if (!Number.isNaN(n) && n > 1e9) return new Date(n * 1000).toISOString(); const d = new Date(v.replace(" ", "T")); return Number.isNaN(d.getTime()) ? null : d.toISOString(); } export function sha1Short(s: string): string { // FNV-1a 64 bits en deux moitiés → 16 hex ; suffisant pour un identifiant interne stable (pas cryptographique) let h1 = 0x811c9dc5, h2 = 0x01000193; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0; h2 = Math.imul(h2 ^ c, 0x9e3779b1) >>> 0; } return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0"); }