spb/ka4 Public
ka4 — explorateur structuré du web québécois (édition agressive, Groupe KA). Crawling concurrent, Claude Sonnet+Haiku, Firecrawl/Scrapfly, graphe de connaissances.
Python 97.8%
Shell 2.2%
1# Author: Simon-Pierre Boucher2# Contact: contact@spboucher.ai3"""Normalisation pour la résolution d'entités et le regroupement géographique.45- normalize_name : clé canonique d'un nom (accents, ponctuation, suffixes légaux retirés).6- canonical_location : regroupe les variantes ("Montreal"/"Montréal"/"Montréal, QC") en un7 libellé unique, et rattache les grandes villes du Québec à un libellé stable.8- domain : domaine racine d'une URL (pour relier des entités du même site).9"""1011from __future__ import annotations1213import re14import unicodedata15from urllib.parse import urlparse1617_LEGAL = {18 "inc", "ltee", "ltd", "limited", "limitee", "enr", "senc", "sencrl", "srl",19 "corp", "corporation", "cie", "co", "llc", "llp", "sec", "sa", "sas",20}212223def strip_accents(s: str) -> str:24 return "".join(c for c in unicodedata.normalize("NFD", s or "") if unicodedata.category(c) != "Mn")252627def normalize_name(name: str) -> str:28 """Clé de résolution : minuscules, sans accents/ponctuation ni suffixe légal."""29 s = strip_accents((name or "").lower())30 s = re.sub(r"[^a-z0-9 ]+", " ", s)31 toks = [t for t in s.split() if t and t not in _LEGAL]32 return " ".join(toks).strip()333435def domain(url: str | None) -> str:36 if not url:37 return ""38 u = url.strip()39 if "://" not in u:40 u = "http://" + u41 try:42 net = urlparse(u).netloc.lower()43 except Exception:44 return ""45 if net.startswith("www."):46 net = net[4:]47 return net484950# Grandes villes / variantes fréquentes -> libellé canonique51_CITY_MAP = {52 "montreal": "Montréal", "mtl": "Montréal", "ville-marie": "Montréal",53 "quebec": "Québec", "quebec city": "Québec", "ville de quebec": "Québec",54 "laval": "Laval", "gatineau": "Gatineau", "hull": "Gatineau",55 "sherbrooke": "Sherbrooke", "trois rivieres": "Trois-Rivières",56 "saguenay": "Saguenay", "chicoutimi": "Saguenay", "jonquiere": "Saguenay",57 "levis": "Lévis", "longueuil": "Longueuil", "terrebonne": "Terrebonne",58 "brossard": "Brossard", "repentigny": "Repentigny", "drummondville": "Drummondville",59 "saint jean sur richelieu": "Saint-Jean-sur-Richelieu", "granby": "Granby",60 "blainville": "Blainville", "saint jerome": "Saint-Jérôme", "mirabel": "Mirabel",61 "rimouski": "Rimouski", "victoriaville": "Victoriaville", "shawinigan": "Shawinigan",62 "rouyn noranda": "Rouyn-Noranda", "sept iles": "Sept-Îles", "val d or": "Val-d'Or",63 "boucherville": "Boucherville", "mascouche": "Mascouche", "salaberry": "Salaberry-de-Valleyfield",64 "chateauguay": "Châteauguay", "saint hyacinthe": "Saint-Hyacinthe", "sorel": "Sorel-Tracy",65 "joliette": "Joliette", "magog": "Magog", "alma": "Alma", "thetford": "Thetford Mines",66}676869def canonical_location(text: str | None) -> str:70 """Retourne un libellé de lieu canonique (ville QC connue, sinon 1er segment nettoyé)."""71 if not text:72 return ""73 base = strip_accents(text.lower())74 base = re.sub(r"[^a-z0-9 ]+", " ", base)75 base = re.sub(r"\s+", " ", base).strip()76 for kw, label in _CITY_MAP.items():77 if re.search(rf"\b{re.escape(kw)}\b", base):78 return label79 # sinon : 1er segment avant virgule, sans mentions province/pays80 seg = text.split(",")[0].strip()81 seg = re.sub(r"(?i)\b(qc|québec|quebec|canada)\b", "", seg).strip(" ,-")82 return seg[:60]83