← 中文资源

简体中文版本

URL 规范化工具

统一协议、主机、端口、路径与查询顺序,移除跟踪参数,输出可稳定比较的规范化地址。

阅读英文原文

功能说明

同一个页面可能有多种写法:大小写不同的协议、带默认端口、结尾多一个点、路径里包含点段、查询参数顺序不同、附带跟踪参数。直接把原始地址当作缓存键或去重键会造成重复抓取和缓存击穿。这个工具把这些差异归一化,输出唯一的规范形式。

处理内容包括协议小写、主机小写并去掉末尾点、国际化域名转 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()