diff --git a/src/crawlee/_utils/requests.py b/src/crawlee/_utils/requests.py index fa31d4621d..56d2cb1251 100644 --- a/src/crawlee/_utils/requests.py +++ b/src/crawlee/_utils/requests.py @@ -19,8 +19,10 @@ def normalize_url(url: str, *, keep_url_fragment: bool = False) -> str: This function cleans and standardizes a URL by removing leading and trailing whitespaces, converting the scheme and netloc to lower case, stripping unwanted tracking parameters (specifically those beginning with 'utm_'), sorting the remaining query parameters alphabetically, - and optionally retaining the URL fragment. The goal is to ensure that URLs that are functionally - identical but differ in trivial ways (such as parameter order or casing) are treated as the same. + and optionally retaining the URL fragment. URL paths, queries, and fragments keep their original + casing, since only the scheme and the host are case-insensitive (RFC 3986, section 6.2.2.1). + The goal is to ensure that URLs that are functionally + identical but differ in trivial ways (such as parameter order or scheme/host casing) are treated as the same. Args: url: The URL to be normalized. @@ -44,7 +46,11 @@ def normalize_url(url: str, *, keep_url_fragment: bool = False) -> str: yarl_new_url.path.removesuffix('/'), keep_query=True, keep_fragment=keep_url_fragment ) - return str(yarl_new_url).lower() + # Only the scheme and the host are case-insensitive (RFC 3986, section 6.2.2.1), and yarl has + # already lowercased both during parsing. The path, query, and fragment must keep their original + # casing, since lowercasing them can silently merge distinct URLs (e.g. case-sensitive paths or + # tokens) during deduplication. + return str(yarl_new_url) def compute_unique_key( diff --git a/tests/unit/_utils/test_requests.py b/tests/unit/_utils/test_requests.py index 8198909592..306d821cb2 100644 --- a/tests/unit/_utils/test_requests.py +++ b/tests/unit/_utils/test_requests.py @@ -15,22 +15,26 @@ 'http://example.com/?another_key=another_value&key=value', False, ), - ('HTTPS://EXAMPLE.COM/?KEY=VALUE', 'https://example.com/?key=value', False), + ('HTTPS://EXAMPLE.COM/?KEY=VALUE', 'https://example.com/?KEY=VALUE', False), ('', '', False), ('http://example.com/#fragment', 'http://example.com/#fragment', True), ('http://example.com/#fragment', 'http://example.com', False), (' https://example.com/ ', 'https://example.com', False), ('http://example.com/?b=2&a=1', 'http://example.com/?a=1&b=2', False), + ('https://example.com/Product/ABC', 'https://example.com/Product/ABC', False), + ('https://example.com/?token=SeCrEt', 'https://example.com/?token=SeCrEt', False), ], ids=[ 'remove_utm_params', 'retain_sort_non_utm_params', - 'convert_scheme_netloc_to_lowercase', + 'convert_scheme_netloc_to_lowercase_only', 'handle_empty_url', 'retain_fragment', 'remove_fragment', 'trim_whitespace', 'sort_query_params', + 'preserve_path_casing', + 'preserve_query_casing', ], ) def test_normalize_url(url: str, expected_output: str, *, keep_url_fragment: bool) -> None: