1"""
2URL cleaning, normalization, and page-level link extraction for :mod:`turbohtml.extract`.
3
4The normalization core follows the `WHATWG URL standard <https://url.spec.whatwg.org/>`__ wherever it defines the
5behavior: input stripping (basic URL parser, spec 4.4), scheme lowercasing (scheme state), domain-to-ASCII for special
6URL hosts and percent-encoding for opaque hosts (host parsing, spec 3.5), default-port removal (port state), and
7dot-segment resolution including the
8``%2e`` forms (path state), the empty-path-to-``/`` serialization of special URLs (URL serializing, spec 4.5), and
9the path/query/fragment percent-encode sets (percent-encoded bytes, spec 1.3). On top of that sits the crawl-oriented
10canonicalization ``courlan`` and ``w3lib`` users expect -- query-parameter sorting, tracking-parameter removal, an
11optional strict parameter allowlist, and a URL-based language filter -- each documented where it goes beyond the spec.
12The pipeline runs in C (``_url_clean``, ``_url_normalize`` and ``Document._extract_links``); this module holds the
13option record and the two vocabularies it hands over.
14"""
15
16from __future__ import annotations
17
18from dataclasses import dataclass
19from typing import Final
20
21from turbohtml._html import _url_clean, _url_normalize, parse
22
23__all__ = [
24 "UrlCleaning",
25 "clean_url",
26 "extract_links",
27 "normalize_url",
28]
29
30_ISO_639_1: Final[frozenset[str]] = frozenset([
31 *("aa", "ab", "ae", "af", "ak", "am", "an", "ar", "as", "av", "ay", "az", "ba", "be", "bg", "bh", "bi", "bm"),
32 *("bn", "bo", "br", "bs", "ca", "ce", "ch", "co", "cr", "cs", "cu", "cv", "cy", "da", "de", "dv", "dz", "ee"),
33 *("el", "en", "eo", "es", "et", "eu", "fa", "ff", "fi", "fj", "fo", "fr", "fy", "ga", "gd", "gl", "gn", "gu"),
34 *("gv", "ha", "he", "hi", "ho", "hr", "ht", "hu", "hy", "hz", "ia", "id", "ie", "ig", "ii", "ik", "io", "is"),
35 *("it", "iu", "ja", "jv", "ka", "kg", "ki", "kj", "kk", "kl", "km", "kn", "ko", "kr", "ks", "ku", "kv", "kw"),
36 *("ky", "la", "lb", "lg", "li", "ln", "lo", "lt", "lu", "lv", "mg", "mh", "mi", "mk", "ml", "mn", "mr", "ms"),
37 *("mt", "my", "na", "nb", "nd", "ne", "ng", "nl", "nn", "no", "nr", "nv", "ny", "oc", "oj", "om", "or", "os"),
38 *("pa", "pi", "pl", "ps", "pt", "qu", "rm", "rn", "ro", "ru", "rw", "sa", "sc", "sd", "se", "sg", "si", "sk"),
39 *("sl", "sm", "sn", "so", "sq", "sr", "ss", "st", "su", "sv", "sw", "ta", "te", "tg", "th", "ti", "tk", "tl"),
40 *("tn", "to", "tr", "ts", "tt", "tw", "ty", "ug", "uk", "ur", "uz", "ve", "vi", "vo", "wa", "wo", "xh", "yi"),
41 *("yo", "za", "zh", "zu"),
42])
43"""The ISO 639-1 two-letter language codes, gating which URL segments count as language markers."""
44
45_CONTENT_PARAMS: Final[frozenset[str]] = frozenset({
46 "aid",
47 "article_id",
48 "artnr",
49 "id",
50 "itemid",
51 "objectid",
52 "p",
53 "page",
54 "page_id",
55 "pagenum",
56 "pid",
57 "post",
58 "postid",
59 "product_id",
60})
61"""The content-identifying query parameters strict mode keeps; everything else is presumed decorative."""
62
63_LANGUAGE_PARAMS: Final[frozenset[str]] = frozenset({"lang", "language"})
64
65
66@dataclass(frozen=True, slots=True)
67class UrlCleaning:
68 """
69 Options shared by :func:`clean_url`, :func:`normalize_url`, and :func:`extract_links`.
70
71 :param strict: keep only the content-identifying query parameters (page, id, post, ... plus the language
72 parameters) instead of merely dropping known trackers, and drop the fragment. The default keeps every
73 parameter that is not a known tracker.
74 :param trailing_slash: keep a path's trailing slash. ``False`` trims it from any path but the root ``/`` when no
75 query string follows, folding ``/dir/`` and ``/dir`` into one form.
76 :param strip_fragment: always drop the fragment. The default keeps it (scrubbed of tracker parameters), since the
77 fragment can address content (``#page2``, text fragments).
78 :param language: an ISO 639-1 code; :func:`clean_url` and :func:`extract_links` then reject URLs whose language
79 markers (a leading path segment such as ``/de/``, a ``lang``/``language`` query parameter, or an anchor's
80 ``hreflang``) point at another language. :func:`normalize_url` ignores this field.
81 :param query_allow: when set, keep only these query parameters (matched case-insensitively against the decoded
82 name), the ``w3lib.url.url_query_cleaner`` keep-list; a listed parameter survives even when it is a known
83 tracker. Mutually exclusive with ``strict``, which is itself an allowlist.
84 :param query_deny: always drop these query parameters (matched the same way), the ``url_query_cleaner``
85 ``remove=True`` mode; the tracker or ``strict`` filtering still applies to the rest.
86 """
87
88 strict: bool = False
89 trailing_slash: bool = True
90 strip_fragment: bool = False
91 language: str | None = None
92 query_allow: frozenset[str] | None = None
93 query_deny: frozenset[str] = frozenset()
94
95 def __post_init__(self) -> None:
96 """Reject a non-ISO-639-1 language and the contradiction of two query allowlists at once."""
97 if self.language is not None and self.language not in _ISO_639_1:
98 msg = f"language must be an ISO 639-1 code, got {self.language!r}"
99 raise ValueError(msg)
100 if self.strict and self.query_allow is not None:
101 msg = "strict and query_allow are mutually exclusive, each is a query-parameter allowlist"
102 raise ValueError(msg)
103
104 @classmethod
105 def w3lib(cls) -> UrlCleaning:
106 """Return ``w3lib.url.canonicalize_url``'s mode: fragments dropped, every non-tracker parameter kept."""
107 return cls(strip_fragment=True)
108
109
110_DEFAULT: Final = UrlCleaning()
111
112
113def clean_url(url: str, options: UrlCleaning | None = None, /) -> str | None:
114 """
115 Scrub a URL scraped from markup and normalize it, or return ``None`` when nothing usable remains.
116
117 The scrub recovers from HTML transport damage: it strips the surrounding whitespace and control characters the
118 WHATWG basic URL parser removes (spec 4.4 steps 1-2, extended to embedded spaces since those only appear in
119 scraped junk), unwraps ``<![CDATA[...]]>``, truncates at a stray ``<``/``>``/``"`` delimiter, and undoes a
120 leftover ``&`` escape. The survivor must be an ``http``/``https`` URL with a plausible host and pass the
121 ``language`` filter, then it is returned through :func:`normalize_url`.
122
123 :param url: the URL as found in the wild.
124 :param options: the cleaning options; defaults to :class:`UrlCleaning` (drop trackers, keep slash and fragment).
125 :returns: the cleaned, normalized URL, or ``None`` for anything that is not a fetchable web URL.
126 :raises TypeError: if ``url`` is not a ``str``.
127 """
128 active = options or _DEFAULT
129 return _url_clean(url, *_knobs(active), active.language, _ISO_639_1)
130
131
132def normalize_url(url: str, options: UrlCleaning | None = None, /) -> str:
133 """
134 Return the canonical form of a URL, so that two spellings of the same resource compare equal.
135
136 The spec-defined part lowercases the scheme. Special URL hosts are lowercased and converted to ASCII (browsers
137 serialize ``http://münchen.de`` as ``http://xn--mnchen-3ya.de``). Non-special hosts preserve ASCII case and
138 percent-encode Unicode (``git://münchen.de`` becomes ``git://m%C3%BCnchen.de``). Normalization also drops a default
139 port (port state), resolves ``.``/``..``/``%2e`` path segments (path state), percent-encodes what
140 the path/query/fragment percent-encode sets require -- with uppercase hex digits, leaving existing escapes alone
141 -- and serializes an empty special-URL path as ``/`` (URL serializing). Beyond the spec, query parameters are
142 sorted and known tracking parameters dropped (strict mode instead keeps only the content-identifying allowlist),
143 and a fragment shaped like a query string is scrubbed the same way. Unlike ``courlan``, repeated slashes are kept
144 (the spec preserves them) and punycode is the output form, not the input form.
145 For special schemes, a host after the scheme is recognized even when the input omits the authority slashes;
146 a hostless ``file:`` path is serialized with an empty authority.
147
148 A Unicode host longer than 16384 code points, or holding a code point UTS #46 disallows in a domain (a C1
149 control, a noncharacter), keeps its lowercased Unicode form instead of punycode. The cap bounds the domain-to-ASCII
150 step, whose combining-mark reorder and punycode encoder are quadratic in the host length. The WHATWG standard
151 leaves overlong labels undefined (`whatwg/url#824 <https://github.com/whatwg/url/issues/824>`_), and DNS caps a
152 domain at 253 octets, far below the cap.
153
154 :param url: an absolute or relative URL; a relative one keeps its shape, only its components are normalized.
155 :param options: the cleaning options; defaults to :class:`UrlCleaning` (drop trackers, keep slash and fragment).
156 :returns: the normalized URL.
157 :raises TypeError: if ``url`` is not a ``str``.
158 :raises ValueError: if the URL cannot be split into components (e.g. an unclosed IPv6 bracket) or carries a
159 forbidden or empty domain after host decoding or IDNA mapping, or a character that cannot be percent-encoded
160 (a lone surrogate).
161 """
162 return _url_normalize(url, *_knobs(options or _DEFAULT))
163
164
165def extract_links(
166 html: str,
167 base_url: str | None = None,
168 options: UrlCleaning | None = None,
169 /,
170 *,
171 external_only: bool = False,
172) -> set[str]:
173 """
174 Collect the cleaned page links of an HTML document, the ``courlan.extract_links`` counterpart.
175
176 The document is parsed with the WHATWG tree builder, so links are read from the real DOM rather than regex
177 matches. An anchor (``<a>``/``<area>``) contributes its ``href`` unless its ``rel`` carries ``nofollow`` or, with
178 a ``language`` filter active, its ``hreflang`` names another language (``x-default`` passes). Each candidate is
179 resolved against the document base URL (a ``<base href>`` wins over ``base_url``, per HTML spec 4.2.3), cleaned
180 through :func:`clean_url`, and deduplicated across trivial variants (the ``http``/``https`` twin and the
181 trailing-slash twin), the first occurrence in document order winning.
182
183 :param html: the page markup.
184 :param base_url: the URL the page was fetched from; relative links resolve against it, and it anchors the
185 external/internal split. Without it relative links are dropped, since they cannot be made absolute.
186 :param options: the cleaning options; defaults to :class:`UrlCleaning` (drop trackers, keep slash and fragment).
187 :param external_only: keep only links leaving ``base_url``'s site, where the site boundary is the registrable
188 domain (the public suffix plus one label, ``spam.example.co.uk`` and ``example.co.uk`` counting as one site);
189 the eTLD+1 is read from the shipped IANA and Public Suffix List tables, so sibling subdomains stay internal.
190 :returns: the surviving absolute URLs.
191 :raises ValueError: if ``external_only`` is set without a ``base_url`` to define what external means.
192 """
193 active = options or _DEFAULT
194 return parse(html)._extract_links( # ruff:ignore[private-member-access] # the Document type's private C entry point
195 base_url, external_only, *_knobs(active), active.language, _ISO_639_1
196 )
197
198
199def _knobs(
200 options: UrlCleaning,
201) -> tuple[bool, bool, bool, frozenset[str] | None, frozenset[str], frozenset[str], frozenset[str]]:
202 """Return the option fields and vocabularies every C entry point takes, in its argument order."""
203 return (
204 options.strict,
205 options.trailing_slash,
206 options.strip_fragment,
207 options.query_allow,
208 options.query_deny,
209 _CONTENT_PARAMS,
210 _LANGUAGE_PARAMS,
211 )