SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
16.5 KB · 199 lines typescript
Raw Blame History
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