# Trouve-KA — canonicalisation d'URL # Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai """Canonicalisation d'URL prudente. Règle cardinale (CLAUDE.md §5.4) : ne JAMAIS fusionner deux ressources distinctes par accident. On normalise seulement ce qui est sûr : fragments, ports par défaut, casse de l'hôte, paramètres de tracking connus, encodage. On ne touche ni à la casse du chemin ni aux paramètres inconnus. """ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, quote, unquote # Paramètres de tracking sûrs à retirer (jamais porteurs de contenu) TRACKING_PARAMS = { "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "utm_id", "fbclid", "gclid", "gclsrc", "dclid", "msclkid", "twclid", "igshid", "mc_cid", "mc_eid", "_ga", "_gl", "yclid", "wbraid", "gbraid", "ref_src", "cmpid", "s_kwcid", "spm", } DEFAULT_PORTS = {"http": 80, "https": 443} def is_http_url(url: str) -> bool: try: scheme = urlsplit(url).scheme.lower() except ValueError: return False return scheme in ("http", "https") def canonicalize_url(url: str, base: str | None = None) -> str | None: """Normalise une URL. Retourne None si l'URL n'est pas crawlable (schéma non http).""" url = url.strip() if not url: return None if base: from urllib.parse import urljoin url = urljoin(base, url) try: parts = urlsplit(url) except ValueError: return None scheme = parts.scheme.lower() if scheme not in ("http", "https"): return None host = parts.hostname if not host: return None host = host.strip(".").lower() try: host = host.encode("idna").decode("ascii") if any(ord(c) > 127 for c in host) else host except UnicodeError: return None port = parts.port netloc = host if port and port != DEFAULT_PORTS.get(scheme): netloc = f"{host}:{port}" # Chemin : ré-encoder proprement sans changer la sémantique (casse préservée) path = quote(unquote(parts.path or "/"), safe="/%:@!$&'()*+,;=~-._") # Slashs multiples consécutifs → un seul (sûr pour HTTP) while "//" in path: path = path.replace("//", "/") if not path: path = "/" # Query : retirer uniquement les paramètres de tracking connus; préserver l'ordre query = "" if parts.query: kept = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k.lower() not in TRACKING_PARAMS] query = urlencode(kept) # Fragment : toujours retiré (jamais envoyé au serveur) return urlunsplit((scheme, netloc, path, query, "")) def extract_domain(url: str) -> str | None: """Domaine enregistrable approximatif : hôte sans le préfixe www.""" try: host = urlsplit(url).hostname except ValueError: return None if not host: return None host = host.lower().strip(".") return host[4:] if host.startswith("www.") else host def display_url(url: str, max_len: int = 80) -> str: """URL d'affichage façon breadcrumb : quebec.ca › services › permis.""" parts = urlsplit(url) host = (parts.hostname or "").removeprefix("www.") segments = [unquote(s) for s in parts.path.split("/") if s] crumb = " › ".join([host, *segments[:3]]) if len(crumb) > max_len: crumb = crumb[: max_len - 1] + "…" return crumb