Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/crawlee/_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,20 @@ def enqueue_strategy(self) -> EnqueueStrategy:
def enqueue_strategy(self, new_enqueue_strategy: EnqueueStrategy) -> None:
self.crawlee_data.enqueue_strategy = new_enqueue_strategy

@property
def skip_navigation(self) -> bool:
"""Tells the crawler processing this request to skip the navigation and process the request directly.

When this is set to `True`, the crawling context will not contain the results of the navigation
(e.g. the HTTP response, parsed content or `loaded_url`). Accessing these properties will raise
a `NavigationSkippedError` at runtime.
"""
return self.crawlee_data.skip_navigation

@skip_navigation.setter
def skip_navigation(self, new_value: bool) -> None:
self.crawlee_data.skip_navigation = new_value

@property
def was_already_handled(self) -> bool:
"""Indicates whether the request was handled."""
Expand Down
65 changes: 65 additions & 0 deletions src/crawlee/crawlers/_basic/_basic_crawler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from asyncio import CancelledError
from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable, Sequence
from contextlib import AsyncExitStack
from dataclasses import replace as dataclass_replace
from datetime import timedelta
from functools import partial
from http import HTTPStatus
Expand Down Expand Up @@ -57,6 +58,7 @@
ContextPipelineInterruptedError,
HttpClientStatusCodeError,
HttpStatusCodeError,
NavigationSkippedError,
PersistentRateLimitError,
RequestCollisionError,
RequestHandlerError,
Expand Down Expand Up @@ -115,6 +117,33 @@
SkippedRequestCallback = Callable[[str, SkippedReason], Awaitable[None]]


class _NavigationSkippedRequestProxy:
"""Wraps a `Request` whose navigation was skipped due to `skip_navigation`.

All attribute access is forwarded to the wrapped request, except `loaded_url`, which raises a
`NavigationSkippedError` — mirroring the JS SDK behavior for skipped navigations.
"""

__slots__ = ('_raise_on_loaded_url', '_request')
Comment on lines +120 to +127

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: raise_on_loaded_url only ever has one value, so __getattr__ could raise NavigationSkippedError directly and the extra slot and callable can go. The docstring also has a non-ASCII em dash and a reference to the JS SDK, which the repo avoids in source.


def __init__(self, request: Request, raise_on_loaded_url: Callable[[], str]) -> None:
self._request = request
self._raise_on_loaded_url = raise_on_loaded_url

def __getattr__(self, name: str) -> Any:
# Attribute writes (e.g. `request.state = ...` in the router) must land on the wrapped request,
# so intercept only reads that the wrapped request cannot resolve itself.
if name == 'loaded_url':
return self._raise_on_loaded_url()
return getattr(self._request, name)
Comment on lines +133 to +138

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: copy.copy() or pickle of context.request recurses forever here: while the copy is being rebuilt, _request is unset, so __setattr__ reads it, __getattr__ runs, reads self._request again, and so on until RecursionError. Raising AttributeError for _request in __getattr__ fixes it. repr(context.request) also shows the proxy object, so a __repr__ forwarding to the request would help in logs. The comment is also inaccurate: __getattr__ runs when the proxy cannot resolve the name, not the wrapped request.


def __setattr__(self, name: str, value: Any) -> None:
if name in ('_request', '_raise_on_loaded_url'):
super().__setattr__(name, value)
else:
setattr(self._request, name, value)


class _BasicCrawlerOptions(TypedDict):
"""Non-generic options the `BasicCrawler` constructor."""

Expand Down Expand Up @@ -1722,6 +1751,11 @@ async def _defer_throttled_request(self, request: Request, error: RequestThrottl

async def _run_request_handler(self, context: BasicCrawlingContext) -> None:
context.request.state = RequestState.BEFORE_NAV

if context.request.skip_navigation:
await self._run_request_handler_with_skipped_navigation(context)
return

await self._context_pipeline(
context,
lambda final_context: wait_for(
Expand All @@ -1733,6 +1767,37 @@ async def _run_request_handler(self, context: BasicCrawlingContext) -> None:
),
)

async def _run_request_handler_with_skipped_navigation(self, context: BasicCrawlingContext) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: Bypassing the pipeline means the handler gets a plain BasicCrawlingContext, even when it is typed as ParselCrawlingContext or PlaywrightCrawlingContext. context.page, context.selector or enqueue_links then fail with AttributeError. JS keeps the pipeline and guards only the navigation steps, so a browser crawler still opens the page and skips just the goto, and HTTP crawlers install getters for response, body, parsed content etc. that raise NavigationSkippedError. Could we follow that design and skip the navigation middleware per crawler?

"""Run the request handler without navigating, for requests with `skip_navigation` set.

The context pipeline is bypassed entirely: its middleware steps are responsible for the navigation,
so the crawling context they would produce does not contain the navigation results. Navigation-only
context members (`request.loaded_url`) raise a `NavigationSkippedError` instead of returning `None`.
"""
self._logger.debug(f'Skipping navigation for request {context.request.url} (`skip_navigation` was used).')

def _raise_on_loaded_url() -> str:
raise NavigationSkippedError(
'The `request.loaded_url` property is not available - `skip_navigation` was used.'
)

guarded_request = cast(
'Request',
_NavigationSkippedRequestProxy(context.request, _raise_on_loaded_url),
)
guarded_context = cast(
'TCrawlingContext',
dataclass_replace(context, request=guarded_request), # ty: ignore[invalid-argument-type]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: With the locked ty version this ignore is reported as unused, so the type-check job fails.

Suggested change
dataclass_replace(context, request=guarded_request), # ty: ignore[invalid-argument-type]
dataclass_replace(context, request=guarded_request),

)

await wait_for(
lambda: self.router(guarded_context),
timeout=self._request_handler_timeout,
timeout_message=f'{self._request_handler_timeout_text}'
f' {self._request_handler_timeout.total_seconds()} seconds',
logger=self._logger,
)
Comment on lines +1793 to +1799

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: The router runs outside ContextPipeline, so handler errors are not wrapped in RequestHandlerError. A raw exception reaches except Exception as internal_error in __run_task_function and terminates the whole crawl, with no retries and no failed-request handler. I reproduced it with a handler that raises ValueError.

Suggested change
await wait_for(
lambda: self.router(guarded_context),
timeout=self._request_handler_timeout,
timeout_message=f'{self._request_handler_timeout_text}'
f' {self._request_handler_timeout.total_seconds()} seconds',
logger=self._logger,
)
try:
await wait_for(
lambda: self.router(guarded_context),
timeout=self._request_handler_timeout,
timeout_message=f'{self._request_handler_timeout_text}'
f' {self._request_handler_timeout.total_seconds()} seconds',
logger=self._logger,
)
except SessionError:
raise
except Exception as e:
raise RequestHandlerError(e, context) from e


def _raise_for_error_status_code(self, status_code: int) -> None:
"""Raise an exception if the given status code is considered an error.

Expand Down
11 changes: 11 additions & 0 deletions src/crawlee/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
'ContextPipelineInterruptedError',
'HttpClientStatusCodeError',
'HttpStatusCodeError',
'NavigationSkippedError',
'PersistentRateLimitError',
'ProxyError',
'RequestCollisionError',
Expand All @@ -35,6 +36,16 @@ class UserHandlerTimeoutError(UserDefinedErrorHandlerError):
"""Raised when a router fails due to user raised timeout. This is different from user-defined handler timing out."""


@docs_group('Errors')
class NavigationSkippedError(Exception):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: In JS, NavigationSkippedError extends NonRetryableError, so the request fails right away. Here it is a plain Exception, so reading loaded_url is retried max_request_retries times and fails the same way every time. Setting request.no_retry (or treating this error as non-retryable) would match JS.

"""Raised when a navigation-only crawling context member is accessed, but `skip_navigation` was used.

When a request has `skip_navigation` set to `True`, the crawler does not open the page and the crawling
context does not contain the results of the navigation (e.g. `http_response`, `$`, parsed content or
`request.loaded_url`). Accessing such properties raises this error at runtime.
Comment on lines +41 to +45

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Only request.loaded_url raises this error at the moment. http_response, parsed content and the page are simply missing from the context (AttributeError), and $ is a JS-only name. The same goes for the Request.skip_navigation docstring. Either describe the current behavior or implement the placeholders.

"""


@docs_group('Errors')
class SessionError(Exception):
"""Errors of `SessionError` type will trigger a session rotation.
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/crawlers/_basic/test_basic_crawler.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from crawlee.configuration import Configuration
from crawlee.crawlers import BasicCrawler
from crawlee.errors import (
NavigationSkippedError,
PersistentRateLimitError,
RequestCollisionError,
RequestThrottledError,
Expand Down Expand Up @@ -3169,3 +3170,45 @@ async def test_stall_ignores_shared_concurrency() -> None:
pytest.raises(PersistentRateLimitError),
):
await cast('Any', crawler)._BasicCrawler__is_finished_function()


async def test_skip_navigation_runs_handler_without_navigation() -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: BasicCrawler never navigates, so these tests cannot show that navigation is actually skipped. Could you add a test with HttpCrawler (assert http_client.crawl is not called), one for a handler that raises (retried, then the failed-request handler gets the original error), and one for AdaptivePlaywrightCrawler?

"""A request with `skip_navigation` set is processed directly, without the navigation pipeline."""
crawler = BasicCrawler(
configure_logging=False,
concurrency_settings=ConcurrencySettings(desired_concurrency=1, max_concurrency=1),
)
processed: list[str] = []
navigation_members: dict[str, str] = {}
Comment on lines +3181 to +3182

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The contributing guide prefers Mock over hand-rolled lists and flag dicts, e.g. processed = Mock() and processed.call_args_list == [call('plain'), call('skipped')].


@crawler.router.default_handler
async def handler(context: BasicCrawlingContext) -> None:
processed.append(context.request.unique_key)
try:
_ = context.request.loaded_url
except NavigationSkippedError:
navigation_members[context.request.unique_key] = 'raised'

plain_request = Request.from_url('https://plain.placeholder.com', unique_key='plain')
skipped_request = Request.from_url('https://skipped.placeholder.com', unique_key='skipped')
skipped_request.skip_navigation = True

stats = await crawler.run([plain_request, skipped_request])

assert stats.requests_finished == 2
assert processed == ['plain', 'skipped']
# `loaded_url` only raises for the skip-navigation request; on the plain one it is just `None`.
assert navigation_members == {'skipped': 'raised'}


async def test_skip_navigation_request_serialization_roundtrip() -> None:
"""`skip_navigation` survives serialization via the `skipNavigation` user data alias."""
request = Request.from_url('https://placeholder.com', unique_key='roundtrip')
assert request.skip_navigation is False

request.skip_navigation = True
data = request.model_dump(by_alias=True)
assert data['userData']['__crawlee']['skipNavigation'] is True

restored = Request.model_validate(data)
assert restored.skip_navigation is True
Loading