spb/trouve-ka Public
Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com
Python 76.8%
TypeScript 15.7%
SQL 3.9%
Shell 1.4%
CSS 1.3%
Dockerfile 0.7%
1# Trouve-KA — canonicalisation d'URL2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai45"""Canonicalisation d'URL prudente.67Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources8distinctes par accident. On normalise seulement ce qui est sûr :9fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus,10encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus.11"""1213from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote1415# Paramètres de tracking sûrs à retirer (jamais porteurs de contenu)16TRACKING_PARAMS = {17 "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id",18 "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid",19 "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid",20 "ref_src", "cmpid", "s_kwcid", "spm",21}2223DEFAULT_PORTS = {"http": 80, "https": 443}242526def is_http_url(url: str) -> bool:27 try:28 scheme = urlsplit(url).scheme.lower()29 except ValueError:30 return False31 return scheme in ("http", "https")323334def canonicalize_url(url: str, base: str | None = None) -> str | None:35 """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http)."""36 url = url.strip()37 if not url:38 return None39 if base:40 from urllib.parse import urljoin4142 url = urljoin(base, url)43 try:44 parts = urlsplit(url)45 except ValueError:46 return None47 scheme = parts.scheme.lower()48 if scheme not in ("http", "https"):49 return None50 host = parts.hostname51 if not host:52 return None53 host = host.strip(".").lower()54 try:55 host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host56 except UnicodeError:57 return None5859 port = parts.port60 netloc = host61 if port and port != DEFAULT_PORTS.get(scheme):62 netloc = f"{host}:{port}"6364 # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée)65 path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._")66 # Slashs multiples consécutifs → un seul (sûr pour HTTP)67 while "//" in path:68 path = path.replace("//", "/")69 if not path:70 path = "/"7172 # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre73 query = ""74 if parts.query:75 kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)76 if k.lower() not in TRACKING_PARAMS]77 query = urlencode(kept)7879 # Fragment : toujours retiré (jamais envoyé au serveur)80 return urlunsplit((scheme, netloc, path, query, ""))818283def extract_domain(url: str) -> str | None:84 """Domaine enregistrable approximatif : hôte sans le préfixe www."""85 try:86 host = urlsplit(url).hostname87 except ValueError:88 return None89 if not host:90 return None91 host = host.lower().strip(".")92 return host[4:] if host.startswith("www.") else host939495def display_url(url: str, max_len: int = 80) -> str:96 """URL d'affichage façon breadcrumb : quebec.ca › services › permis."""97 parts = urlsplit(url)98 host = (parts.hostname or "").removeprefix("www.")99 segments = [unquote(s) for s in parts.path.split("/") if s]100 crumb = " › ".join([host, *segments[:3]])101 if len(crumb) > max_len:102 crumb = crumb[: max_len - 1] + "…"103 return crumb104