|
1 | 1 | # Inspiration: https://github.com/apify/crawlee/blob/v3.7.3/packages/basic-crawler/src/internals/basic-crawler.ts |
| 2 | + |
2 | 3 | from __future__ import annotations |
3 | 4 |
|
4 | 5 | import asyncio |
|
13 | 14 | from contextlib import AsyncExitStack, suppress |
14 | 15 | from datetime import timedelta |
15 | 16 | from functools import partial |
| 17 | +from http import HTTPStatus |
16 | 18 | from io import StringIO |
17 | 19 | from pathlib import Path |
18 | 20 | from typing import TYPE_CHECKING, Any, Generic, Literal, ParamSpec, cast |
|
43 | 45 | ) |
44 | 46 | from crawlee._utils.docs import docs_group |
45 | 47 | from crawlee._utils.file import atomic_write, export_csv_to_stream, export_json_to_stream |
| 48 | +from crawlee._utils.http import parse_retry_after_header |
| 49 | +from crawlee._utils.log import LoggerOnce |
46 | 50 | from crawlee._utils.recurring_task import RecurringTask |
47 | 51 | from crawlee._utils.robots import RobotsTxtFile |
48 | 52 | from crawlee._utils.urls import UNSUPPORTED_SCHEME_MESSAGE, convert_to_absolute_url, filter_url, is_url_absolute |
|
61 | 65 | ) |
62 | 66 | from crawlee.events._types import Event, EventCrawlerStatusData |
63 | 67 | from crawlee.http_clients import ImpitHttpClient |
| 68 | +from crawlee.request_loaders import ThrottlingRequestManager |
64 | 69 | from crawlee.router import Router |
65 | 70 | from crawlee.sessions import SessionPool |
66 | 71 | from crawlee.statistics import Statistics, StatisticsState |
@@ -491,18 +496,18 @@ async def persist_state_factory() -> KeyValueStore: |
491 | 496 | run_task_function=self.__run_task_function, |
492 | 497 | ) |
493 | 498 | self._crawler_state_rec_task = RecurringTask( |
494 | | - func=self._crawler_state_task, delay=status_message_logging_interval |
| 499 | + func=self._crawler_state_task, |
| 500 | + delay=status_message_logging_interval, |
495 | 501 | ) |
496 | 502 | self._previous_crawler_state: TStatisticsState | None = None |
497 | 503 |
|
498 | 504 | # State flags |
499 | 505 | self._keep_alive = keep_alive |
500 | 506 | self._running = False |
501 | 507 | self._has_finished_before = False |
502 | | - |
503 | 508 | self._failed = False |
504 | | - |
505 | 509 | self._unexpected_stop = False |
| 510 | + self._logger_once = LoggerOnce(self._logger) |
506 | 511 |
|
507 | 512 | @property |
508 | 513 | def log(self) -> logging.Logger: |
@@ -697,19 +702,22 @@ async def run( |
697 | 702 |
|
698 | 703 | self._running = True |
699 | 704 |
|
| 705 | + if self._respect_robots_txt_file and not isinstance(self._request_manager, ThrottlingRequestManager): |
| 706 | + self._logger.warning( |
| 707 | + 'The `respect_robots_txt_file` option is enabled, but the crawler is not using ' |
| 708 | + '`ThrottlingRequestManager`. Crawl-delay directives from robots.txt will not be enforced. To enable ' |
| 709 | + 'crawl-delay support, configure the crawler to use `ThrottlingRequestManager` as the request manager.' |
| 710 | + ) |
| 711 | + |
700 | 712 | if self._has_finished_before: |
701 | 713 | await self._statistics.reset() |
702 | 714 |
|
703 | 715 | if self._use_session_pool: |
704 | 716 | await self._session_pool.reset_store() |
705 | 717 |
|
706 | | - request_manager = await self.get_request_manager() |
707 | | - if purge_request_queue and isinstance(request_manager, RequestQueue): |
708 | | - await request_manager.drop() |
709 | | - self._request_manager = await RequestQueue.open( |
710 | | - storage_client=self._service_locator.get_storage_client(), |
711 | | - configuration=self._service_locator.get_configuration(), |
712 | | - ) |
| 718 | + if purge_request_queue: |
| 719 | + request_manager = await self.get_request_manager() |
| 720 | + await request_manager.purge() |
713 | 721 |
|
714 | 722 | if requests is not None: |
715 | 723 | await self.add_requests(requests) |
@@ -1535,16 +1543,51 @@ def _raise_for_error_status_code(self, status_code: int) -> None: |
1535 | 1543 | if is_status_code_server_error(status_code) and not is_ignored_status: |
1536 | 1544 | raise HttpStatusCodeError('Error status code returned', status_code) |
1537 | 1545 |
|
1538 | | - def _raise_for_session_blocked_status_code(self, session: Session | None, status_code: int) -> None: |
| 1546 | + def _raise_for_session_blocked_status_code( |
| 1547 | + self, |
| 1548 | + session: Session | None, |
| 1549 | + status_code: int, |
| 1550 | + *, |
| 1551 | + request_url: str, |
| 1552 | + retry_after_header: str | None = None, |
| 1553 | + ) -> None: |
1539 | 1554 | """Raise an exception if the given status code indicates the session is blocked. |
1540 | 1555 |
|
| 1556 | + If the status code is 429 (Too Many Requests), the domain is recorded as rate-limited in the |
| 1557 | + `ThrottlingRequestManager` for per-domain backoff. |
| 1558 | +
|
1541 | 1559 | Args: |
1542 | | - session: The session used for the request. If None, no check is performed. |
| 1560 | + session: The session used for the request. If `None`, no check is performed. |
1543 | 1561 | status_code: The HTTP status code to check. |
| 1562 | + request_url: The request URL, used for per-domain rate limit tracking. |
| 1563 | + retry_after_header: The value of the `Retry-After` response header, if present. |
1544 | 1564 |
|
1545 | 1565 | Raises: |
1546 | 1566 | SessionError: If the status code indicates the session is blocked. |
1547 | 1567 | """ |
| 1568 | + if status_code == HTTPStatus.TOO_MANY_REQUESTS: |
| 1569 | + if isinstance(self._request_manager, ThrottlingRequestManager): |
| 1570 | + retry_after = parse_retry_after_header(retry_after_header) |
| 1571 | + if not self._request_manager.record_domain_delay(request_url, retry_after=retry_after): |
| 1572 | + domain = (URL(request_url).host or '').lower() |
| 1573 | + if domain: |
| 1574 | + self._logger_once.log( |
| 1575 | + f'Received an HTTP 429 (Too Many Requests) response from domain "{domain}", but it is ' |
| 1576 | + f'not in the `ThrottlingRequestManager.domains` list. Per-domain backoff will not be ' |
| 1577 | + f'applied for this domain. Add it to `domains=` to enable throttling.', |
| 1578 | + key=f'unconfigured_throttle_domain:{domain}', |
| 1579 | + level=logging.WARNING, |
| 1580 | + ) |
| 1581 | + else: |
| 1582 | + self._logger_once.log( |
| 1583 | + 'Received an HTTP 429 (Too Many Requests) response, but the crawler is not using ' |
| 1584 | + '`ThrottlingRequestManager`. Per-domain backoff and `Retry-After` headers will not be honored. ' |
| 1585 | + 'To enable per-domain rate limiting, configure the crawler to use `ThrottlingRequestManager` ' |
| 1586 | + 'as the request manager.', |
| 1587 | + key='no_throttling_manager_on_429', |
| 1588 | + level=logging.WARNING, |
| 1589 | + ) |
| 1590 | + |
1548 | 1591 | if session is not None and session.is_blocked_status_code( |
1549 | 1592 | status_code=status_code, |
1550 | 1593 | ignore_http_error_status_codes=self._ignore_http_error_status_codes, |
@@ -1575,7 +1618,15 @@ async def _is_allowed_based_on_robots_txt_file(self, url: str) -> bool: |
1575 | 1618 | if not self._respect_robots_txt_file: |
1576 | 1619 | return True |
1577 | 1620 | robots_txt_file = await self._get_robots_txt_file_for_url(url) |
1578 | | - return not robots_txt_file or robots_txt_file.is_allowed(url) |
| 1621 | + if not robots_txt_file: |
| 1622 | + return True |
| 1623 | + |
| 1624 | + if isinstance(self._request_manager, ThrottlingRequestManager): |
| 1625 | + crawl_delay = robots_txt_file.get_crawl_delay() |
| 1626 | + if crawl_delay is not None: |
| 1627 | + self._request_manager.set_crawl_delay(url, crawl_delay) |
| 1628 | + |
| 1629 | + return robots_txt_file.is_allowed(url) |
1579 | 1630 |
|
1580 | 1631 | async def _get_robots_txt_file_for_url(self, url: str) -> RobotsTxtFile | None: |
1581 | 1632 | """Get the RobotsTxtFile for a given URL. |
|
0 commit comments