beam: URL canonicalization utility with tests

This commit is contained in:
beam committed 2026-10-29 00:11:17 +00:00
1 parent a979e630a4
commit 367d66e574
2 files changed
+116

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
"""Canonicalize URLs for deduplication.
Handles: scheme/host lowercasing, default-port removal, trailing-slash
normalization, fragment stripping, query-key sorting, percent-decoding of
unreserved chars, IDNA host encoding, userinfo removal, dot-segment removal.
"""
from __future__ import annotations
import re
from urllib.parse import (
urlparse, urlunparse, parse_qsl, urlencode, unquote, quote,
)
_UNRESERVED = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
_DEFAULT_PORTS = {"http": 80, "https": 443, "ftp": 21}
def _norm_host(host: str) -> str:
host = host.strip().lower().rstrip(".")
# IDNA encode if non-ascii
try:
host = host.encode("idna").decode("ascii")
except (UnicodeError, UnicodeDecodeError):
pass
return host
def _norm_path(path: str) -> str:
# remove dot segments
segs = path.split("/")
out = []
for s in segs:
if s == "." or s == "":
# collapse empty only if not leading? keep single leading
if s == "":
if not out or out[-1] != "":
out.append("")
continue
continue
if s == "..":
if len(out) > 1:
out.pop()
continue
out.append(s)
# percent-decode unreserved, then re-encode
p = "/".join(out)
if p == "":
p = "/"
# normalize percent encoding of unreserved
decoded = unquote(p)
reenc = "".join(c if c in _UNRESERVED or c == "/" else quote(c, safe="") for c in decoded)
# collapse multiple slashes
reenc = re.sub(r"/{2,}", "/", reenc)
if reenc != "/" and reenc.endswith("/"):
# keep trailing slash only for root already; remove for non-root? We'll keep root "/"
reenc = reenc.rstrip("/")
return reenc if reenc else "/"
def canonicalize(url: str) -> str:
if not isinstance(url, str) or not url.strip():
raise ValueError("url must be non-empty string")
url = url.strip()
# add scheme if missing
if "://" not in url and not url.startswith("//"):
url = "http://" + url
if url.startswith("//"):
url = "http:" + url
u = urlparse(url)
scheme = (u.scheme or "http").lower()
if scheme not in ("http", "https", "ftp"):
scheme = u.scheme.lower()
host = _norm_host(u.hostname or "")
port = u.port
if port is not None and port == _DEFAULT_PORTS.get(scheme):
port = None
netloc = host
if port is not None:
netloc = f"{host}:{port}"
# userinfo dropped
path = _norm_path(u.path or "/")
# query sort
q = parse_qsl(u.query, keep_blank_values=True)
q.sort(key=lambda kv: (kv[0], kv[1]))
query = urlencode(q, doseq=True)
# fragment dropped
return urlunparse((scheme, netloc, path, "", query, ""))
def equivalent(a: str, b: str) -> bool:
return canonicalize(a) == canonicalize(b)
+30
View File
@@ -0,0 +1,30 @@
import sys
sys.path.insert(0, "/root/.hermes/wizard-outputs/beam")
from canonical_url import canonicalize, equivalent
cases = [
("HTTP://Example.COM/Foo/", "http://example.com/Foo"),
("http://example.com:80/path?b=2&a=1#frag", "http://example.com/path?a=1&b=2"),
("http://example.com/a/../b/./c", "http://example.com/b/c"),
("https://example.com//a//b", "https://example.com/a/b"),
("http://user:pass@example.com/x", "http://example.com/x"),
("http://example.com/%7Efoo", "http://example.com/~foo"),
("example.com/path", "http://example.com/path"),
("//example.com/path", "http://example.com/path"),
]
for a,b in cases:
ca, cb = canonicalize(a), canonicalize(b)
assert ca == cb, (a, ca, b, cb)
assert equivalent(a,b)
print("all cases pass")
# distinct
assert canonicalize("http://a.com/?x=1") != canonicalize("http://a.com/?x=2")
assert canonicalize("http://a.com") != canonicalize("https://a.com")
print("distinct ok")
# invalid
try:
canonicalize("")
raise SystemExit("expected ValueError")
except ValueError:
pass
print("invalid ok")