功能说明
同一个页面可能有多种写法:大小写不同的协议、带默认端口、结尾多一个点、路径里包含点段、查询参数顺序不同、附带跟踪参数。直接把原始地址当作缓存键或去重键会造成重复抓取和缓存击穿。这个工具把这些差异归一化,输出唯一的规范形式。
处理内容包括协议小写、主机小写并去掉末尾点、国际化域名转 punycode、去掉默认端口、路径消解点段、查询参数排序、删除 utm 系列和点击标识、丢弃片段标识。结果可以直接用作缓存键、去重键或日志字段。
关键代码
urlsplit 拆出各组成部分;unsupported scheme 直接拒绝,避免把 javascript 或 file 协议混入。parts.port 在端口非法时抛异常,需要捕获后转成业务错误。主机包含冒号时视为 IPv6 字面量并加方括号,否则走 IDNA 编码。posixpath.normpath 消解双点和单点,再确保以斜杠开头。查询参数用 parse_qsl 解析成对,过滤跟踪前缀后排序,最后 urlunsplit 以空片段重建。
使用注意
规范化会改变地址语义的写法,但不会跟踪短链接或重定向,短链仍需先展开。排序查询参数可能影响依赖顺序的后端,签名场景需与对端约定。删除 utm 参数适合缓存键,不适合原样回传的分享链接。包含用户信息的地址会被 urlsplit 保留但这里未输出,需要认证信息时应显式处理。IDNA 编码只适用于域名,IPv6 需走方括号分支。
验证方式
第一条输入输出 https://example.com/Docs?a=1&b=2,可以看到协议和主机被小写、默认端口和末尾点被去掉、路径中的上一级被消解、utm_source 被删除、片段被丢弃、参数按字母排序。第二条输出带根路径的 http://example.com/。国际化域名输出 xn– 前缀,路径中的非 ASCII 字符被百分号编码。FTP 地址会打印 rejected 提示。
代码示例
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()