URL Canonicalization Helper

Normalize scheme, host, port, dot segments, and query order while dropping tracking parameters, producing a stable cache key.

PythonSecurity Boundaries

Code

snippet
import posixpath
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit

DEFAULT_PORTS = {"http": 80, "https": 443}
TRACKING_PREFIXES = ("utm_", "gclid", "fbclid")


def _normalize_host(hostname: str) -> str:
    host = hostname.rstrip(".").lower()
    if ":" in host:
        return host
    try:
        return host.encode("idna").decode("ascii")
    except UnicodeError as error:
        raise ValueError(f"invalid hostname: {hostname}") from error


def canonicalize_url(raw: str) -> str:
    parts = urlsplit(raw.strip())
    scheme = parts.scheme.lower()
    if scheme not in DEFAULT_PORTS:
        raise ValueError(f"unsupported scheme: {scheme}")
    if parts.hostname is None:
        raise ValueError("url must include a host")
    try:
        port = parts.port
    except ValueError as error:
        raise ValueError(f"invalid port in {raw}") from error
    host = _normalize_host(parts.hostname)
    if ":" in host:
        host = f"[{host}]"
    netloc = host if port in (None, DEFAULT_PORTS[scheme]) else f"{host}:{port}"
    path = posixpath.normpath(parts.path or "/")
    if not path.startswith("/"):
        path = "/" + path
    encoded_path = quote(path, safe="/:@!$&'()*+,;=~-._%")
    pairs = [
        (key, value)
        for key, value in parse_qsl(parts.query, keep_blank_values=True)
        if not key.lower().startswith(TRACKING_PREFIXES)
    ]
    pairs.sort()
    return urlunsplit((scheme, netloc, encoded_path, urlencode(pairs), ""))


def main() -> None:
    raw = "HTTPS://Example.COM.:443/Path/../Docs?b=2&a=1&utm_source=news#top"
    print(canonicalize_url(raw))
    print(canonicalize_url("http://example.com"))
    print(canonicalize_url("https://b\u00fccher.example/stra\u00dfe?q=a b"))
    try:
        canonicalize_url("ftp://example.com/file")
    except ValueError as error:
        print("rejected:", error)


if __name__ == "__main__":
    main()