Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/examples/code_examples/adaptive_playwright_crawler.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async def request_handler_for_label(
context.log.info(context.parsed_content.title)

# Locate element h2 within 5 seconds
h2 = await context.query_selector_one('h2', timedelta(milliseconds=5000))
h2 = await context.query_selector_one('h2', timeout=timedelta(milliseconds=5000))
# Do stuff with element found by the selector
context.log.info(h2)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def is_matching_selector(

@override
def find_links(
self, parsed_content: LexborHTMLParser, selector: str, attribute: str
self, parsed_content: LexborHTMLParser, selector: str, *, attribute: str
) -> Iterable[str]:
"""Extract href attributes from elements matching the selector.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ async def main() -> None:
@crawler.router.default_handler
async def request_handler(context: AdaptivePlaywrightCrawlingContext) -> None:
# Locate element h2 within 5 seconds
h2 = await context.query_selector_one('h2', timedelta(milliseconds=5000))
h2 = await context.query_selector_one('h2', timeout=timedelta(milliseconds=5000))
# Do stuff with element found by the selector
context.log.info(h2)

Expand Down
34 changes: 34 additions & 0 deletions docs/upgrading/upgrading_to_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,37 @@ We drop support for Python 3.10, which reached its end of life in October 2026.
## `RequestQueueClient.is_finished` is abstract

`RequestQueueClient.is_finished` is now an abstract method. In v1, it fell back to `is_empty`, which ignores requests still being processed and could report the queue as finished too early. A subclass that doesn't implement it raises `TypeError` on instantiation. If you have a custom request queue client, implement `is_finished` so that it returns `True` only when the queue is empty and no requests are in progress. The built-in clients already implement it.

## Keyword-only arguments

Secondary arguments of the public API are now keyword-only. The main argument of each call, such as a key, a value, a selector or a path, stays positional. A call that passes any of the following arguments positionally raises `TypeError`, so pass them by name:

- `context.push_data(data, *, dataset_id, dataset_name, dataset_alias)`, and the same for `RequestHandlerRunResult.push_data`.
- `context.add_requests(requests, *, rq_id, rq_name, rq_alias)`, and the same for `RequestHandlerRunResult.add_requests`.
- `context.block_requests(*, url_patterns, extra_url_patterns)`, and `block_requests(page, *, url_patterns, extra_url_patterns)`.
- `BasicCrawlingContext.create_modified_copy(*, push_data, add_requests, get_key_value_store)`.
- `BasicCrawler.get_data(*, dataset_id, dataset_name, dataset_alias, ...)` and `BasicCrawler.export_data(path, *, dataset_id, dataset_name, dataset_alias, ...)`.
- `KeyValueStore.set_value(key, value, *, content_type)`, also on the key-value store returned by `context.get_key_value_store()`.
- `KeyValueStore.iterate_keys`, `iterate_values`, `iterate_entries` and `list_keys`, which take `exclusive_start_key` and `limit` by keyword only.
- `Dataset.export_to(key, *, content_type, to_kvs_id, ...)`.
- `ConcurrencySettings(*, min_concurrency, max_concurrency, max_tasks_per_minute, desired_concurrency)`.
- `RequestList(requests, *, name, persist_state_key, persist_requests_key)`.
- `ProxyConfiguration.new_url(*, session_id, request, proxy_tier)` and `ProxyConfiguration.new_proxy_info(*, session_id, request, proxy_tier)`. All three arguments of `new_proxy_info` now default to `None`.
- `AdaptivePlaywrightCrawler.with_beautifulsoup_static_parser(*, ...)` and `with_parsel_static_parser(*, ...)`.
- `AdaptivePlaywrightCrawlingContext.wait_for_selector`, `query_selector_one`, `query_selector_all` and `parse_with_static_parser`, which take `timeout` by keyword only.
- `HeaderGenerator.get_specific_headers(header_names, *, browser_type)`.
- `BrowserController.new_page(*, browser_new_context_options, proxy_info)`, including the Playwright and Stagehand controllers.
- `PlaywrightPersistentBrowser(browser_type, user_data_dir, *, browser_launch_options)`.
- `AbstractHttpParser.find_links(parsed_content, selector, *, attribute)`, including the built-in parsers. Custom parsers keep working, since the crawler passes `attribute` by keyword.

For example:

```python
# v1
await kvs.set_value('report', data, 'application/json')
h2 = await context.query_selector_one('h2', timedelta(seconds=5))

# v2
await kvs.set_value('report', data, content_type='application/json')
h2 = await context.query_selector_one('h2', timeout=timedelta(seconds=5))
```
8 changes: 8 additions & 0 deletions src/crawlee/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ class ConcurrencySettings:

def __init__(
self,
*,
min_concurrency: int = 1,
max_concurrency: int = 100,
max_tasks_per_minute: float = float('inf'),
Expand Down Expand Up @@ -223,6 +224,7 @@ async def set_value(
self,
key: str,
value: Any,
*,
content_type: str | None = None,
) -> None: ...

Expand All @@ -242,6 +244,7 @@ async def set_value(
self,
key: str,
value: Any,
*,
content_type: str | None = None,
) -> None:
self.updates[key] = KeyValueStoreValue(value, content_type)
Expand Down Expand Up @@ -278,6 +281,7 @@ def __init__(
async def add_requests(
self,
requests: Sequence[str | Request],
*,
rq_id: str | None = None,
rq_name: str | None = None,
rq_alias: str | None = None,
Expand All @@ -294,6 +298,7 @@ async def add_requests(
async def push_data(
self,
data: Sequence[Mapping[str, JsonSerializable]] | Mapping[str, JsonSerializable],
*,
dataset_id: str | None = None,
dataset_name: str | None = None,
dataset_alias: str | None = None,
Expand Down Expand Up @@ -336,6 +341,7 @@ class AddRequestsFunction(Protocol):
def __call__(
self,
requests: Sequence[str | Request],
*,
rq_id: str | None = None,
rq_name: str | None = None,
rq_alias: str | None = None,
Expand Down Expand Up @@ -538,6 +544,7 @@ class PushDataFunction(Protocol):
def __call__(
self,
data: Sequence[Mapping[str, JsonSerializable]] | Mapping[str, JsonSerializable],
*,
dataset_id: str | None = None,
dataset_name: str | None = None,
dataset_alias: str | None = None,
Expand Down Expand Up @@ -673,6 +680,7 @@ def __hash__(self) -> int:

def create_modified_copy(
self,
*,
push_data: PushDataFunction | None = None,
add_requests: AddRequestsFunction | None = None,
get_key_value_store: GetKeyValueStoreFromRequestHandlerFunction | None = None,
Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/_utils/recoverable_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ async def persist_state(self, event_data: EventPersistStateData | None = None) -
await self._key_value_store.set_value(
self._persist_state_key,
self._state.model_dump(mode='json', by_alias=True),
'application/json',
content_type='application/json',
)
else:
self._log.debug('Persistence is not enabled - not doing anything')
Expand Down
1 change: 1 addition & 0 deletions src/crawlee/browsers/_browser_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def browser_type(self) -> BrowserType:
@abstractmethod
async def new_page(
self,
*,
browser_new_context_options: Mapping[str, Any] | None = None,
proxy_info: ProxyInfo | None = None,
) -> Page:
Expand Down
1 change: 1 addition & 0 deletions src/crawlee/browsers/_playwright_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def __init__(
self,
browser_type: BrowserType,
user_data_dir: str | Path | None,
*,
browser_launch_options: dict[str, Any],
) -> None:
self._browser_type = browser_type
Expand Down
1 change: 1 addition & 0 deletions src/crawlee/browsers/_playwright_browser_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ def browser_type(self) -> BrowserType:
@override
async def new_page(
self,
*,
browser_new_context_options: Mapping[str, Any] | None = None,
proxy_info: ProxyInfo | None = None,
) -> Page:
Expand Down
4 changes: 3 additions & 1 deletion src/crawlee/browsers/_playwright_browser_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ async def new_browser(self) -> PlaywrightBrowserController:
if self._use_incognito_pages:
browser: Browser | PlaywrightPersistentBrowser = await browser_type.launch(**self._browser_launch_options)
else:
browser = PlaywrightPersistentBrowser(browser_type, self._user_data_dir, self._browser_launch_options)
browser = PlaywrightPersistentBrowser(
browser_type, self._user_data_dir, browser_launch_options=self._browser_launch_options
)

return PlaywrightBrowserController(
browser,
Expand Down
1 change: 1 addition & 0 deletions src/crawlee/browsers/_stagehand_browser_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def browser_type(self) -> BrowserType:
@override
async def new_page(
self,
*,
browser_new_context_options: Mapping[str, Any] | None = None,
proxy_info: ProxyInfo | None = None,
) -> StagehandPage:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ async def extract_links(
# A relative `<base href>` is resolved against the page URL, and one that does not resolve to an absolute
# URL (malformed, `mailto:`, ...) is ignored.
base_url = context.request.loaded_url or context.request.url
extracted_base_urls = list(self._parser.find_links(parsed_content, 'base[href]', 'href'))
extracted_base_urls = list(self._parser.find_links(parsed_content, 'base[href]', attribute='href'))
if extracted_base_urls:
try:
resolved_base_url = convert_to_absolute_url(base_url, extracted_base_urls[0])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ def is_matching_selector(self, parsed_content: TParseResult, selector: str) -> b
"""

@abstractmethod
def find_links(self, parsed_content: TParseResult, selector: str, attribute: str) -> Iterable[str]:
def find_links(self, parsed_content: TParseResult, selector: str, *, attribute: str) -> Iterable[str]:
"""Find all links in result using selector.

Args:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ async def request_handler_for_label(context: AdaptivePlaywrightCrawlingContext)
context.log.info(context.parsed_content.title)

# Locate element h2 within 5 seconds
h2 = await context.query_selector_one('h2', timedelta(milliseconds=5000))
h2 = await context.query_selector_one('h2', timeout=timedelta(milliseconds=5000))
# Do stuff with element found by the selector
context.log.info(h2)

Expand Down Expand Up @@ -234,8 +234,9 @@ async def adaptive_post_navigation_hook_pw(context: PlaywrightPostNavCrawlingCon
self._static_parser = static_parser

@classmethod
def with_beautifulsoup_static_parser( # noqa: PLR0917
def with_beautifulsoup_static_parser(
cls,
*,
rendering_type_predictor: RenderingTypePredictor | None = None,
result_checker: Callable[[RequestHandlerRunResult], bool] | None = None,
result_comparator: Callable[[RequestHandlerRunResult, RequestHandlerRunResult], bool] | None = None,
Expand All @@ -262,6 +263,7 @@ def with_beautifulsoup_static_parser( # noqa: PLR0917
@classmethod
def with_parsel_static_parser(
cls,
*,
rendering_type_predictor: RenderingTypePredictor | None = None,
result_checker: Callable[[RequestHandlerRunResult], bool] | None = None,
result_comparator: Callable[[RequestHandlerRunResult, RequestHandlerRunResult], bool] | None = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ def response(self) -> Response:
raise AdaptiveContextError('Page was not crawled with PlaywrightCrawler.')
return self._response

async def wait_for_selector(self, selector: str, timeout: timedelta = timedelta(seconds=5)) -> None:
async def wait_for_selector(self, selector: str, *, timeout: timedelta = timedelta(seconds=5)) -> None:
"""Locate element by css selector and return `None` once it is found.

If element is not found within timeout, `TimeoutError` is raised.
Expand All @@ -91,7 +91,7 @@ async def wait_for_selector(self, selector: str, timeout: timedelta = timedelta(
await self.page.locator(selector).wait_for(timeout=timeout.total_seconds() * 1000)

async def query_selector_one(
self, selector: str, timeout: timedelta = timedelta(seconds=5)
self, selector: str, *, timeout: timedelta = timedelta(seconds=5)
) -> TStaticSelectResult | None:
"""Locate element by css selector and return first element found.

Expand All @@ -109,7 +109,7 @@ async def query_selector_one(
return None

async def query_selector_all(
self, selector: str, timeout: timedelta = timedelta(seconds=5)
self, selector: str, *, timeout: timedelta = timedelta(seconds=5)
) -> Sequence[TStaticSelectResult]:
"""Locate element by css selector and return all elements found.

Expand Down Expand Up @@ -147,7 +147,7 @@ async def query_selector_all(
)

async def parse_with_static_parser(
self, selector: str | None = None, timeout: timedelta = timedelta(seconds=5)
self, selector: str | None = None, *, timeout: timedelta = timedelta(seconds=5)
) -> TStaticParseResult:
"""Parse whole page with static parser. If `selector` argument is used, wait for selector first.

Expand All @@ -161,7 +161,7 @@ async def parse_with_static_parser(
Result of used static parser `parse_text` method.
"""
if selector:
await self.wait_for_selector(selector, timeout)
await self.wait_for_selector(selector, timeout=timeout)
if self._page:
return await self._static_parser.parse_text(await self.page.content())
return self.parsed_content
Expand Down
7 changes: 5 additions & 2 deletions src/crawlee/crawlers/_basic/_basic_crawler.py
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,7 @@ async def _save_crawler_state(self) -> None:

async def get_data(
self,
*,
dataset_id: str | None = None,
dataset_name: str | None = None,
dataset_alias: str | None = None,
Expand Down Expand Up @@ -944,10 +945,10 @@ async def get_data(
async def export_data(
self,
path: str | Path,
*,
dataset_id: str | None = None,
dataset_name: str | None = None,
dataset_alias: str | None = None,
*,
collect_all_keys: bool = False,
**additional_kwargs: Unpack[ExportDataKwargs],
) -> None:
Expand Down Expand Up @@ -993,6 +994,7 @@ async def export_data(
async def _push_data(
self,
data: Sequence[Mapping[str, JsonSerializable]] | Mapping[str, JsonSerializable],
*,
dataset_id: str | None = None,
dataset_name: str | None = None,
dataset_alias: str | None = None,
Expand Down Expand Up @@ -1386,6 +1388,7 @@ async def _add_requests(
self,
context: BasicCrawlingContext,
requests: Sequence[str | Request],
*,
rq_id: str | None = None,
rq_name: str | None = None,
rq_alias: str | None = None,
Expand Down Expand Up @@ -1436,7 +1439,7 @@ async def _commit_key_value_store_changes(
for (id, name, alias), changes in result.key_value_store_changes.items():
store = await get_kvs(id=id, name=name, alias=alias)
for key, value in changes.updates.items():
await store.set_value(key, value.content, value.content_type)
await store.set_value(key, value.content, content_type=value.content_type)

async def __is_finished_function(self) -> bool:
self._stop_if_max_requests_count_exceeded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async def select(self, parsed_content: Tag, selector: str) -> Sequence[Tag]:
return tuple(match for match in parsed_content.select(selector))

@override
def find_links(self, parsed_content: Tag, selector: str, attribute: str) -> Iterable[str]:
def find_links(self, parsed_content: Tag, selector: str, *, attribute: str) -> Iterable[str]:
link: Tag
urls: list[str] = []
for link in parsed_content.select(selector):
Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/crawlers/_http/_http_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@ def is_matching_selector(self, parsed_content: bytes, selector: str) -> bool: #

@override
def find_links(
self, parsed_content: bytes, selector: str, attribute: str
self, parsed_content: bytes, selector: str, *, attribute: str
) -> Iterable[str]: # Intentional unused argument.
return []
2 changes: 1 addition & 1 deletion src/crawlee/crawlers/_parsel/_parsel_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def is_matching_selector(self, parsed_content: Selector, selector: str) -> bool:
return parsed_content.type in ('html', 'xml') and parsed_content.css(selector).get() is not None

@override
def find_links(self, parsed_content: Selector, selector: str, attribute: str) -> Iterable[str]:
def find_links(self, parsed_content: Selector, selector: str, *, attribute: str) -> Iterable[str]:
link: Selector
urls: list[str] = []
for link in parsed_content.css(selector):
Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/crawlers/_playwright/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class BlockRequestsFunction(Protocol):
"""

async def __call__(
self, url_patterns: list[str] | None = None, extra_url_patterns: list[str] | None = None
self, *, url_patterns: list[str] | None = None, extra_url_patterns: list[str] | None = None
) -> None:
"""Call dunder method.

Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/crawlers/_playwright/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async def check_finished() -> None:


async def block_requests(
page: Page, url_patterns: list[str] | None = None, extra_url_patterns: list[str] | None = None
page: Page, *, url_patterns: list[str] | None = None, extra_url_patterns: list[str] | None = None
) -> None:
"""Blocks network requests matching specified URL patterns.

Expand Down
2 changes: 1 addition & 1 deletion src/crawlee/fingerprint_suite/_header_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def _select_specific_headers(self, all_headers: dict[str, str], header_names: se
return HttpHeaders({key: value for key, value in all_headers.items() if key in header_names})

def get_specific_headers(
self, header_names: set[str] | None = None, browser_type: SupportedBrowserType = 'chrome'
self, header_names: set[str] | None = None, *, browser_type: SupportedBrowserType = 'chrome'
) -> HttpHeaders:
"""Return subset of headers based on the selected `header_names`.

Expand Down
6 changes: 3 additions & 3 deletions src/crawlee/proxy_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def _create_url(self, url: str | None) -> URL | None:
return URL(url)

async def new_proxy_info(
self, session_id: str | None, request: Request | None, proxy_tier: int | None
self, *, session_id: str | None = None, request: Request | None = None, proxy_tier: int | None = None
) -> ProxyInfo | None:
"""Return a new ProxyInfo object based on the configured proxy rotation strategy.

Expand Down Expand Up @@ -157,7 +157,7 @@ async def new_proxy_info(
return info

async def new_url(
self, session_id: str | None = None, request: Request | None = None, proxy_tier: int | None = None
self, *, session_id: str | None = None, request: Request | None = None, proxy_tier: int | None = None
) -> str | None:
"""Return a proxy URL string based on the configured proxy rotation strategy.

Expand All @@ -170,7 +170,7 @@ async def new_url(
proxy_tier: Specific proxy tier to use. If not provided, will be automatically
selected based on configuration.
"""
proxy_info = await self.new_proxy_info(session_id, request, proxy_tier)
proxy_info = await self.new_proxy_info(session_id=session_id, request=request, proxy_tier=proxy_tier)
return proxy_info.url if proxy_info else None

async def _pick_url(
Expand Down
Loading
Loading