URL Canonicalization Helper
Normalize scheme, host, port, dot segments, and query order while dropping tracking parameters, producing a stable cache key.
Code
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()Report a problem with this exampleOpens a pre-filled issue on GitHub.