Skip to content

Commit b3db0dc

Browse files
authored
fix: Filter sitemap-derived URLs by enqueue strategy (#1864)
## Summary `SitemapRequestLoader` accepted every `<loc>` it parsed (including nested sitemaps and `robots.txt` directives) without checking the host, so a sitemap could push arbitrary URLs into the queue. This wires the existing `EnqueueStrategy` mechanism into the sitemap loader and the `robots.txt` discovery path. ## What changed - `SitemapRequestLoader.__init__` gains `enqueue_strategy: EnqueueStrategy = 'same-hostname'`, applied to nested `<sitemap><loc>` and `<urlset><url><loc>` entries against the parent sitemap URL. Pass `'all'` to opt out. The strategy is stamped on emitted `Request`s, so `BasicCrawler._check_url_after_redirects` continues policing across redirects. - `RobotsTxtFile.get_sitemaps(*, enqueue_strategy)` filters cross-host `Sitemap:` directives (already disallowed by spec). The `discover_valid_sitemaps` path is closed as well. - Hoisted the strategy matcher into `crawlee._utils.urls.matches_enqueue_strategy` so request loaders and `BasicCrawler` share one implementation. `BasicCrawler` now uses `yarl.URL` to match the rest of the codebase. - Memoised the public-suffix lookup used by `same-domain` so it isn't re-run per URL on hot paths. ## Behavior change The new default `'same-hostname'` matches `enqueue_links`. Callers depending on cross-host sitemap entries must pass `enqueue_strategy='all'` (or `'same-domain'`). `RobotsTxtFile.get_sitemaps()` no longer returns cross-host entries; its tests were rewritten to assert filtering, with a new `test_extract_same_host_sitemaps_urls` covering the legitimate path.
1 parent cbe4699 commit b3db0dc

9 files changed

Lines changed: 544 additions & 201 deletions

File tree

docs/guides/request_loaders.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ The <ApiLink to="class/SitemapRequestLoader">`SitemapRequestLoader`</ApiLink> is
136136
The `SitemapRequestLoader` is designed specifically for sitemaps that follow the standard Sitemaps protocol. HTML pages containing links are not supported by this loader - those should be handled by regular crawlers using the `enqueue_links` functionality.
137137
:::
138138

139-
The loader supports filtering URLs using glob patterns and regular expressions, allowing you to include or exclude specific types of URLs. The <ApiLink to="class/SitemapRequestLoader">`SitemapRequestLoader`</ApiLink> provides streaming processing of sitemaps, ensuring efficient memory usage without loading the entire sitemap into memory.
139+
The loader supports filtering URLs using glob patterns and regular expressions, allowing you to include or exclude specific types of URLs. By default, the loader also keeps only URLs whose host matches their parent sitemap (`enqueue_strategy='same-hostname'`), matching the `enqueue_links` default. Pass `enqueue_strategy='all'` to disable this filter, or `'same-domain'` / `'same-origin'` for other scopes. The <ApiLink to="class/SitemapRequestLoader">`SitemapRequestLoader`</ApiLink> provides streaming processing of sitemaps, ensuring efficient memory usage without loading the entire sitemap into memory.
140140

141141
<RunnableCodeBlock className="language-python" language="python">
142142
{SitemapExample}

src/crawlee/_utils/robots.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
from yarl import URL
88

99
from crawlee._utils.sitemap import Sitemap
10+
from crawlee._utils.urls import filter_url
1011
from crawlee._utils.web import is_status_code_client_error
1112

1213
if TYPE_CHECKING:
1314
from typing_extensions import Self
1415

16+
from crawlee._types import EnqueueStrategy
1517
from crawlee.http_clients import HttpClient
1618
from crawlee.proxy_configuration import ProxyInfo
1719

@@ -21,7 +23,11 @@
2123

2224
class RobotsTxtFile:
2325
def __init__(
24-
self, url: str, robots: Protego, http_client: HttpClient | None = None, proxy_info: ProxyInfo | None = None
26+
self,
27+
url: str,
28+
robots: Protego,
29+
http_client: HttpClient | None = None,
30+
proxy_info: ProxyInfo | None = None,
2531
) -> None:
2632
self._robots = robots
2733
self._original_url = URL(url).origin()
@@ -39,18 +45,6 @@ async def from_content(cls, url: str, content: str) -> Self:
3945
robots = Protego.parse(content)
4046
return cls(url, robots)
4147

42-
@classmethod
43-
async def find(cls, url: str, http_client: HttpClient, proxy_info: ProxyInfo | None = None) -> Self:
44-
"""Determine the location of a robots.txt file for a URL and fetch it.
45-
46-
Args:
47-
url: The URL whose domain will be used to find the corresponding robots.txt file.
48-
http_client: Optional `ProxyInfo` to be used when fetching the robots.txt file. If None, no proxy is used.
49-
proxy_info: The `HttpClient` instance used to perform the network request for fetching the robots.txt file.
50-
"""
51-
robots_url = URL(url).with_path('/robots.txt')
52-
return await cls.load(str(robots_url), http_client, proxy_info)
53-
5448
@classmethod
5549
async def load(cls, url: str, http_client: HttpClient, proxy_info: ProxyInfo | None = None) -> Self:
5650
"""Load the robots.txt file for a given URL.
@@ -77,6 +71,18 @@ async def load(cls, url: str, http_client: HttpClient, proxy_info: ProxyInfo | N
7771

7872
return cls(url, robots, http_client=http_client, proxy_info=proxy_info)
7973

74+
@classmethod
75+
async def find(cls, url: str, http_client: HttpClient, proxy_info: ProxyInfo | None = None) -> Self:
76+
"""Determine the location of a robots.txt file for a URL and fetch it.
77+
78+
Args:
79+
url: The URL whose domain will be used to find the corresponding robots.txt file.
80+
http_client: Optional `ProxyInfo` to be used when fetching the robots.txt file. If None, no proxy is used.
81+
proxy_info: The `HttpClient` instance used to perform the network request for fetching the robots.txt file.
82+
"""
83+
robots_url = URL(url).with_path('/robots.txt')
84+
return await cls.load(str(robots_url), http_client, proxy_info)
85+
8086
def is_allowed(self, url: str, user_agent: str = '*') -> bool:
8187
"""Check if the given URL is allowed for the given user agent.
8288
@@ -89,9 +95,25 @@ def is_allowed(self, url: str, user_agent: str = '*') -> bool:
8995
return True
9096
return bool(self._robots.can_fetch(str(check_url), user_agent))
9197

92-
def get_sitemaps(self) -> list[str]:
93-
"""Get the list of sitemaps urls from the robots.txt file."""
94-
return list(self._robots.sitemaps)
98+
def get_sitemaps(self, *, enqueue_strategy: EnqueueStrategy) -> list[str]:
99+
"""Get the list of sitemap URLs from the robots.txt file, filtered by enqueue strategy.
100+
101+
Args:
102+
enqueue_strategy: Strategy used to filter sitemap entries relative to the robots.txt URL's host.
103+
Pass `'same-hostname'` to match the sitemap protocol's same-host expectation, or `'all'` to
104+
disable host filtering. Regardless of the strategy, entries with non-`http(s)` schemes are
105+
always filtered out.
106+
"""
107+
sitemaps: list[str] = []
108+
for sitemap_url in self._robots.sitemaps:
109+
ok, reason = filter_url(target=sitemap_url, strategy=enqueue_strategy, origin=self._original_url)
110+
if not ok:
111+
logger.warning(
112+
f'Skipping sitemap {sitemap_url!r} listed in robots.txt at {str(self._original_url)!r}: {reason}.'
113+
)
114+
continue
115+
sitemaps.append(sitemap_url)
116+
return sitemaps
95117

96118
def get_crawl_delay(self, user_agent: str = '*') -> int | None:
97119
"""Get the crawl delay for the given user agent.
@@ -103,15 +125,23 @@ def get_crawl_delay(self, user_agent: str = '*') -> int | None:
103125
crawl_delay = self._robots.crawl_delay(user_agent)
104126
return int(crawl_delay) if crawl_delay is not None else None
105127

106-
async def parse_sitemaps(self) -> Sitemap:
107-
"""Parse the sitemaps from the robots.txt file and return a `Sitemap` instance."""
108-
sitemaps = self.get_sitemaps()
128+
async def parse_sitemaps(self, *, enqueue_strategy: EnqueueStrategy) -> Sitemap:
129+
"""Parse the sitemaps from the robots.txt file and return a `Sitemap` instance.
130+
131+
Args:
132+
enqueue_strategy: Forwarded to `get_sitemaps`; see that method for details.
133+
"""
134+
sitemaps = self.get_sitemaps(enqueue_strategy=enqueue_strategy)
109135
if not self._http_client:
110136
raise ValueError('HTTP client is required to parse sitemaps.')
111137

112138
return await Sitemap.load(sitemaps, self._http_client, self._proxy_info)
113139

114-
async def parse_urls_from_sitemaps(self) -> list[str]:
115-
"""Parse the sitemaps in the robots.txt file and return a list URLs."""
116-
sitemap = await self.parse_sitemaps()
140+
async def parse_urls_from_sitemaps(self, *, enqueue_strategy: EnqueueStrategy) -> list[str]:
141+
"""Parse the sitemaps in the robots.txt file and return a list URLs.
142+
143+
Args:
144+
enqueue_strategy: Forwarded to `get_sitemaps`; see that method for details.
145+
"""
146+
sitemap = await self.parse_sitemaps(enqueue_strategy=enqueue_strategy)
117147
return sitemap.urls

src/crawlee/_utils/sitemap.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ def _check_and_add(url: str) -> bool:
546546

547547
# Try getting sitemaps from robots.txt first
548548
robots = await RobotsTxtFile.find(url=hostname_urls[0], http_client=http_client, proxy_info=proxy_info)
549-
for sitemap_url in robots.get_sitemaps():
549+
for sitemap_url in robots.get_sitemaps(enqueue_strategy='same-hostname'):
550550
if _check_and_add(sitemap_url):
551551
yield sitemap_url
552552

src/crawlee/_utils/urls.py

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
from __future__ import annotations
22

3+
import tempfile
4+
from functools import lru_cache
35
from typing import TYPE_CHECKING
46

57
from pydantic import AnyHttpUrl, TypeAdapter
8+
from tldextract import TLDExtract
9+
from typing_extensions import assert_never
610
from yarl import URL
711

812
if TYPE_CHECKING:
913
from collections.abc import Iterator
1014
from logging import Logger
1115

16+
from crawlee._types import EnqueueStrategy
17+
18+
19+
_ALLOWED_SCHEMES: frozenset[str] = frozenset({'http', 'https'})
20+
"""URL schemes Crawlee accepts for fetching and enqueuing."""
21+
22+
UNSUPPORTED_SCHEME_MESSAGE = 'unsupported URL scheme (only http and https are allowed).'
23+
"""Reusable suffix for log messages explaining why a non-`http(s)` URL was rejected."""
24+
25+
_HTTP_URL_ADAPTER: TypeAdapter[AnyHttpUrl] = TypeAdapter(AnyHttpUrl)
26+
"""Pydantic validator for HTTP and HTTPS URLs."""
27+
1228

1329
def is_url_absolute(url: str) -> bool:
1430
"""Check if a URL is absolute."""
@@ -38,16 +54,102 @@ def to_absolute_url_iterator(base_url: str, urls: Iterator[str], logger: Logger
3854
yield converted_url
3955

4056

41-
_http_url_adapter = TypeAdapter(AnyHttpUrl)
42-
43-
4457
def validate_http_url(value: str | None) -> str | None:
4558
"""Validate the given HTTP URL.
4659
60+
Args:
61+
value: The URL to validate, or `None` to skip validation.
62+
4763
Raises:
48-
pydantic.ValidationError: If the URL is not valid.
64+
pydantic.ValidationError: If the URL is malformed or its scheme is not `http`/`https`.
4965
"""
5066
if value is not None:
51-
_http_url_adapter.validate_python(value)
67+
_HTTP_URL_ADAPTER.validate_python(value)
5268

5369
return value
70+
71+
72+
def filter_url(
73+
*,
74+
target: str | URL,
75+
strategy: EnqueueStrategy,
76+
origin: str | URL,
77+
) -> tuple[bool, str | None]:
78+
"""Check whether `target` is eligible to be enqueued under `strategy` relative to `origin`.
79+
80+
Combines the two checks every enqueue site needs: the URL must use a supported scheme
81+
(`http` or `https`), and it must match `strategy` relative to `origin`. Callers that need to
82+
distinguish a scheme rejection from a strategy mismatch (for different log levels or dedup)
83+
can compare the returned reason against `UNSUPPORTED_SCHEME_MESSAGE`.
84+
85+
Args:
86+
target: The URL being evaluated.
87+
strategy: The enqueue strategy to apply.
88+
origin: The reference URL the target is compared against.
89+
90+
Returns:
91+
`(True, None)` if `target` is eligible. Otherwise `(False, reason)` where `reason` is
92+
a human-readable rejection message suitable for log output.
93+
"""
94+
target_url = _to_url(target)
95+
96+
if not _is_supported_url_scheme(target_url):
97+
return False, UNSUPPORTED_SCHEME_MESSAGE
98+
99+
if not _matches_enqueue_strategy(strategy, target_url=target_url, origin_url=_to_url(origin)):
100+
return False, f'does not match enqueue strategy {strategy!r}'
101+
102+
return True, None
103+
104+
105+
def _is_supported_url_scheme(url: str | URL) -> bool:
106+
"""Return whether `url` uses a scheme Crawlee accepts (http or https)."""
107+
return _to_url(url).scheme in _ALLOWED_SCHEMES
108+
109+
110+
def _matches_enqueue_strategy(
111+
strategy: EnqueueStrategy,
112+
*,
113+
target_url: URL,
114+
origin_url: URL,
115+
) -> bool:
116+
"""Check whether `target_url` matches `origin_url` under `strategy`. Scheme is not considered."""
117+
if strategy == 'all':
118+
return True
119+
120+
if origin_url.host is None or target_url.host is None:
121+
return False
122+
123+
if strategy == 'same-hostname':
124+
return target_url.host == origin_url.host
125+
126+
if strategy == 'same-domain':
127+
return _domain_under_public_suffix(origin_url.host) == _domain_under_public_suffix(target_url.host)
128+
129+
if strategy == 'same-origin':
130+
return (
131+
target_url.host == origin_url.host
132+
and target_url.scheme == origin_url.scheme
133+
and target_url.port == origin_url.port
134+
)
135+
136+
assert_never(strategy)
137+
138+
139+
def _to_url(value: str | URL) -> URL:
140+
return URL(value) if isinstance(value, str) else value
141+
142+
143+
@lru_cache(maxsize=1)
144+
def _get_tld_extractor() -> TLDExtract:
145+
"""Return a lazily-initialized `TLDExtract` instance shared across the module."""
146+
# `mkdtemp` (vs `TemporaryDirectory`) returns a path whose lifetime is tied to the process — `TemporaryDirectory`
147+
# is collected immediately when its return value is discarded, which would race the directory out from under
148+
# tldextract.
149+
return TLDExtract(cache_dir=tempfile.mkdtemp())
150+
151+
152+
@lru_cache(maxsize=2048)
153+
def _domain_under_public_suffix(host: str) -> str:
154+
"""Return the registrable domain for `host`, cached to avoid re-running the PSL lookup."""
155+
return _get_tld_extractor().extract_str(host).top_domain_under_public_suffix

0 commit comments

Comments
 (0)