SPB Git

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%
3.4 KB · 104 lines python
Raw Blame History
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