This commit is contained in:
Adrián Chaves 2026-07-05 07:00:58 -05:00 committed by GitHub
commit ff031ab186
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 5922 additions and 718 deletions

View File

@ -31,6 +31,7 @@ extensions = [
"sphinx_scrapy",
"scrapyfixautodoc", # Must be after "sphinx.ext.autodoc"
"sphinx.ext.coverage",
"sphinx_reredirects",
"sphinx_rtd_dark_mode",
]
@ -141,12 +142,21 @@ coverage_ignore_pyobjects = [
r"^scrapy\.linkextractors\.lxmlhtml\.LxmlParserLinkExtractor",
]
# -- Options for the autodoc extension ----------------------------------------
autodoc_type_aliases = {
"RequestScopes": "RequestScopes",
}
# -- Options for the InterSphinx extension -----------------------------------
# https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration
intersphinx_disabled_reftypes: Sequence[str] = []
# -- sphinx-reredirects -------------------------------------------------------
redirects = {
"topics/autothrottle": "throttling.html",
}
# sphinx-scrapy ---------------------------------------------------------------
scrapy_intersphinx_enable = [

View File

@ -158,7 +158,7 @@ Solving specific problems
topics/leaks
topics/media-pipeline
topics/deploy
topics/autothrottle
topics/throttling
topics/benchmarking
topics/jobs
topics/coroutines
@ -198,8 +198,9 @@ Solving specific problems
:doc:`topics/deploy`
Deploying your Scrapy spiders and run them in a remote server.
:doc:`topics/autothrottle`
Adjust crawl rate dynamically based on load.
:doc:`topics/throttling`
Control request throttling to avoid overloading websites and comply with
rate limits.
:doc:`topics/benchmarking`
Check how Scrapy performs on your hardware.

View File

@ -81,11 +81,9 @@ error happens while handling it.
While this enables you to do very fast crawls (sending multiple concurrent
requests at the same time, in a fault-tolerant way) Scrapy also gives you
control over the politeness of the crawl through :ref:`a few settings
<topics-settings-ref>`. You can do things like setting a download delay between
each request, limiting the amount of concurrent requests per domain or per IP, and
even :ref:`using an auto-throttling extension <topics-autothrottle>` that tries
to figure these settings out automatically.
control over :ref:`throttling <throttling>`, e.g. you can set a delay between
requests to the same domain, set a maximum concurrency per domain, or customize
the :ref:`backoff <backoff>` behavior.
.. note::

View File

@ -130,6 +130,28 @@ and defines some attributes and methods:
the scraped data as dicts and also finding new URLs to
follow and creating new requests (:class:`~scrapy.Request`) from them.
Crawling faster for this tutorial
---------------------------------
By default, Scrapy :ref:`throttles <throttling>` crawls to be polite: it sends
at most one request at a time to each website, waiting one second between
requests. That keeps you from overwhelming websites, but it also makes crawling
slow, and it means the requests above are sent one after another rather than in
parallel.
``quotes.toscrape.com`` is a sandbox meant for scraping practice, so we can
safely crawl it faster. Open ``tutorial/settings.py`` and add a
:setting:`THROTTLER_SCOPES` entry that raises the concurrency and lowers the
delay for that domain only:
.. code-block:: python
THROTTLER_SCOPES = {
"quotes.toscrape.com": {"concurrency": 16, "delay": 0.1},
}
The polite defaults still apply to every other website.
How to run our spider
---------------------

View File

@ -1173,7 +1173,7 @@ Deprecations
(:issue:`7005`, :issue:`7043`)
- The ``CONCURRENT_REQUESTS_PER_IP`` setting is deprecated, use
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` instead.
``CONCURRENT_REQUESTS_PER_DOMAIN`` instead.
(:issue:`6917`, :issue:`6921`)
- The ``scrapy.core.downloader.handlers.http`` module is deprecated. You
@ -1505,7 +1505,7 @@ Scrapy 2.13.3 (2025-07-02)
--------------------------
- Changed the values for :setting:`DOWNLOAD_DELAY` (from ``0`` to ``1``) and
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` (from ``8`` to ``1``) in the
``CONCURRENT_REQUESTS_PER_DOMAIN`` (from ``8`` to ``1``) in the
default project template.
(:issue:`6597`, :issue:`6918`, :issue:`6923`)
@ -2491,10 +2491,9 @@ New features
:meth:`~scrapy.crawler.Crawler.get_spider_middleware`.
(:issue:`6181`)
- Slot delay updates by the :ref:`AutoThrottle extension
<topics-autothrottle>` based on response latencies can now be disabled for
specific requests via the :reqmeta:`autothrottle_dont_adjust_delay` meta
key.
- Slot delay updates by the ``scrapy.extensions.throttle.AutoThrottle``
extension based on response latencies can now be disabled for specific
requests via the ``autothrottle_dont_adjust_delay`` meta key.
(:issue:`6246`, :issue:`6527`)
- If :setting:`SPIDER_LOADER_WARN_ONLY` is set to ``True``,
@ -3260,9 +3259,9 @@ New features
~~~~~~~~~~~~
- Settings corresponding to :setting:`DOWNLOAD_DELAY`,
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and
``CONCURRENT_REQUESTS_PER_DOMAIN`` and
:setting:`RANDOMIZE_DOWNLOAD_DELAY` can now be set on a per-domain basis
via the new :setting:`DOWNLOAD_SLOTS` setting. (:issue:`5328`)
via the new ``DOWNLOAD_SLOTS`` setting. (:issue:`5328`)
- Added :meth:`.TextResponse.jmespath`, a shortcut for JMESPath selectors
available since parsel_ 1.8.1. (:issue:`5894`, :issue:`5915`)
@ -6276,7 +6275,7 @@ New features
``scrapy.pqueues.DownloaderAwarePriorityQueue``, may be
:ref:`enabled <broad-crawls-scheduler-priority-queue>` for a significant
scheduling improvement on crawls targeting multiple web domains, at the
cost of no :setting:`CONCURRENT_REQUESTS_PER_IP` support (:issue:`3520`)
cost of no ``CONCURRENT_REQUESTS_PER_IP`` support (:issue:`3520`)
* A new :attr:`.Request.cb_kwargs` attribute
provides a cleaner way to pass keyword arguments to callback methods
@ -6476,8 +6475,7 @@ The following deprecated APIs have been removed (:issue:`3578`):
* From :class:`~scrapy.spiders.Spider` (and subclasses):
* ``DOWNLOAD_DELAY`` (use :ref:`download_delay
<spider-download_delay-attribute>`)
* ``DOWNLOAD_DELAY`` (use ``download_delay``)
* ``set_crawler`` (use :meth:`~scrapy.spiders.Spider.from_crawler`)
@ -7450,7 +7448,7 @@ This 1.1 release brings a lot of interesting features and bug fixes:
- Item loaders now support nested loaders (:issue:`1467`).
- ``FormRequest.from_response`` improvements (:issue:`1382`, :issue:`1137`).
- Added setting :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` and improved
- Added setting ``AUTOTHROTTLE_TARGET_CONCURRENCY`` and improved
AutoThrottle docs (:issue:`1324`).
- Added ``response.text`` to get body as unicode (:issue:`1730`).
- Anonymous S3 connections (:issue:`1358`).
@ -8653,7 +8651,7 @@ Scrapy changes:
- added :ref:`topics-contracts`, a mechanism for testing spiders in a formal/reproducible way
- added options ``-o`` and ``-t`` to the :command:`runspider` command
- documented :doc:`topics/autothrottle` and added to extensions installed by default. You still need to enable it with :setting:`AUTOTHROTTLE_ENABLED`
- documented ``scrapy.extensions.throttle.AutoThrottle`` and added to extensions installed by default. You still need to enable it with ``AUTOTHROTTLE_ENABLED``
- major Stats Collection refactoring: removed separation of global/per-spider stats, removed stats-related signals (``stats_spider_opened``, etc). Stats are much simpler now, backward compatibility is kept on the Stats Collector API and signals.
- added a ``process_start_requests()`` method to spider middlewares
- dropped Signals singleton. Signals should now be accessed through the Crawler.signals attribute. See the signals documentation for more info.
@ -8755,7 +8753,7 @@ New features and settings
- In request errbacks, offending requests are now received in ``failure.request`` attribute (:rev:`2738`)
- Big downloader refactoring to support per domain/ip concurrency limits (:rev:`2732`)
- ``CONCURRENT_REQUESTS_PER_SPIDER`` setting has been deprecated and replaced by:
- :setting:`CONCURRENT_REQUESTS`, :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, :setting:`CONCURRENT_REQUESTS_PER_IP`
- :setting:`CONCURRENT_REQUESTS`, ``CONCURRENT_REQUESTS_PER_DOMAIN``, ``CONCURRENT_REQUESTS_PER_IP``
- check the documentation for more details
- Added builtin caching DNS resolver (:rev:`2728`)
- Moved Amazon AWS-related components/extensions (SQS spider queue, SimpleDB stats collector) to a separate project: [scaws](https://github.com/scrapinghub/scaws) (:rev:`2706`, :rev:`2714`)

View File

@ -3,6 +3,7 @@ pydantic
scrapy-spider-metadata
sphinx
sphinx-notfound-page
sphinx-reredirects
sphinx-rtd-theme
sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.8

View File

@ -134,6 +134,7 @@ sphinx==9.1.0
# sphinx-llms-txt
# sphinx-markdown-builder
# sphinx-notfound-page
# sphinx-reredirects
# sphinx-rtd-theme
# sphinx-scrapy
# sphinxcontrib-jquery
@ -147,6 +148,8 @@ sphinx-markdown-builder @ git+https://github.com/zytedata/sphinx-markdown-builde
# via sphinx-scrapy
sphinx-notfound-page==1.1.0
# via -r docs/requirements.in
sphinx-reredirects==1.1.0
# via -r docs/requirements.in
sphinx-rtd-dark-mode==1.3.0
# via -r docs/requirements.in
sphinx-rtd-theme==3.1.0

View File

@ -93,6 +93,8 @@ how you :ref:`configure the downloader middlewares
modify the downloader and scheduler behaviour, although this is an
advanced use and this API is not yet stable.
.. autoattribute:: throttler
.. attribute:: spider
Spider currently being crawled. This is an instance of the spider class

View File

@ -1,190 +0,0 @@
.. _topics-autothrottle:
======================
AutoThrottle extension
======================
This is an extension for automatically throttling crawling speed based on load
of both the Scrapy server and the website you are crawling.
Design goals
============
1. be nicer to sites instead of using default download delay of zero
2. automatically adjust Scrapy to the optimum crawling speed, so the user
doesn't have to tune the download delays to find the optimum one.
The user only needs to specify the maximum concurrent requests
it allows, and the extension does the rest.
.. _autothrottle-algorithm:
How it works
============
Scrapy allows defining the concurrency and delay of different download slots,
e.g. through the :setting:`DOWNLOAD_SLOTS` setting. By default requests are
assigned to slots based on their URL domain, although it is possible to
customize the download slot of any request.
The AutoThrottle extension adjusts the delay of each download slot dynamically,
to make your spider send :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` concurrent
requests on average to each remote website.
It uses download latency to compute the delays. The main idea is the
following: if a server needs ``latency`` seconds to respond, a client
should send a request each ``latency/N`` seconds to have ``N`` requests
processed in parallel.
Instead of adjusting the delays one can just set a small fixed
download delay and impose hard limits on concurrency using
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`. It will provide a similar
effect, but there are some important differences:
* because the download delay is small there will be occasional bursts
of requests;
* often non-200 (error) responses can be returned faster than regular
responses, so with a small download delay and a hard concurrency limit
crawler will be sending requests to server faster when server starts to
return errors. But this is an opposite of what crawler should do - in case
of errors it makes more sense to slow down: these errors may be caused by
the high request rate.
AutoThrottle doesn't have these issues.
Throttling algorithm
====================
AutoThrottle algorithm adjusts download delays based on the following rules:
1. spiders always start with a download delay of
:setting:`AUTOTHROTTLE_START_DELAY`;
2. when a response is received, the target download delay is calculated as
``latency / N`` where ``latency`` is a latency of the response,
and ``N`` is :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY`.
3. download delay for next requests is set to the average of previous
download delay and the target download delay;
4. latencies of non-200 responses are not allowed to decrease the delay;
5. download delay can't become less than :setting:`DOWNLOAD_DELAY` or greater
than :setting:`AUTOTHROTTLE_MAX_DELAY`
.. note:: The AutoThrottle extension honours the standard Scrapy settings for
concurrency and delay. This means that it will respect
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` and
never set a download delay lower than :setting:`DOWNLOAD_DELAY`.
.. _download-latency:
In Scrapy, the download latency is measured as the time elapsed between
establishing the TCP connection and receiving the HTTP headers.
Note that these latencies are very hard to measure accurately in a cooperative
multitasking environment because Scrapy may be busy processing a spider
callback, for example, and unable to attend downloads. However, these latencies
should still give a reasonable estimate of how busy Scrapy (and ultimately, the
server) is, and this extension builds on that premise.
.. reqmeta:: autothrottle_dont_adjust_delay
Prevent specific requests from triggering slot delay adjustments
================================================================
AutoThrottle adjusts the delay of download slots based on the latencies of
responses that belong to that download slot. The only exceptions are non-200
responses, which are only taken into account to increase that delay, but
ignored if they would decrease that delay.
You can also set the ``autothrottle_dont_adjust_delay`` request metadata key to
``True`` in any request to prevent its response latency from impacting the
delay of its download slot:
.. code-block:: python
from scrapy import Request
Request("https://example.com", meta={"autothrottle_dont_adjust_delay": True})
Note, however, that AutoThrottle still determines the starting delay of every
download slot by setting the ``download_delay`` attribute on the running
spider. If you want AutoThrottle not to impact a download slot at all, in
addition to setting this meta key in all requests that use that download slot,
you might want to set a custom value for the ``delay`` attribute of that
download slot, e.g. using :setting:`DOWNLOAD_SLOTS`.
Settings
========
The settings used to control the AutoThrottle extension are:
* :setting:`AUTOTHROTTLE_ENABLED`
* :setting:`AUTOTHROTTLE_START_DELAY`
* :setting:`AUTOTHROTTLE_MAX_DELAY`
* :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY`
* :setting:`AUTOTHROTTLE_DEBUG`
* :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`
* :setting:`DOWNLOAD_DELAY`
For more information see :ref:`autothrottle-algorithm`.
.. setting:: AUTOTHROTTLE_ENABLED
AUTOTHROTTLE_ENABLED
~~~~~~~~~~~~~~~~~~~~
Default: ``False``
Enables the AutoThrottle extension.
.. setting:: AUTOTHROTTLE_START_DELAY
AUTOTHROTTLE_START_DELAY
~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``5.0``
The initial download delay (in seconds).
.. setting:: AUTOTHROTTLE_MAX_DELAY
AUTOTHROTTLE_MAX_DELAY
~~~~~~~~~~~~~~~~~~~~~~
Default: ``60.0``
The maximum download delay (in seconds) to be set in case of high latencies.
.. setting:: AUTOTHROTTLE_TARGET_CONCURRENCY
AUTOTHROTTLE_TARGET_CONCURRENCY
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Default: ``1.0``
Average number of requests Scrapy should be sending in parallel to remote
websites. It must be higher than ``0.0``.
By default, AutoThrottle adjusts the delay to send a single
concurrent request to each of the remote websites. Set this option to
a higher value (e.g. ``2.0``) to increase the throughput and the load on remote
servers. A lower ``AUTOTHROTTLE_TARGET_CONCURRENCY`` value
(e.g. ``0.5``) makes the crawler more conservative and polite.
Note that :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` is still respected
when AutoThrottle extension is enabled. This means that if
``AUTOTHROTTLE_TARGET_CONCURRENCY`` is set to a value higher than
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, the crawler won't reach this number
of concurrent requests.
At every given time point Scrapy can be sending more or less concurrent
requests than ``AUTOTHROTTLE_TARGET_CONCURRENCY``; it is a suggested
value the crawler tries to approach, not a hard limit.
.. setting:: AUTOTHROTTLE_DEBUG
AUTOTHROTTLE_DEBUG
~~~~~~~~~~~~~~~~~~
Default: ``False``
Enable AutoThrottle debug mode which will display stats on every response
received, so you can see how the throttling parameters are being adjusted in
real time.

View File

@ -47,8 +47,8 @@ Increase concurrency
====================
Concurrency is the number of requests that are processed in parallel. There is
a global limit (:setting:`CONCURRENT_REQUESTS`) and an additional limit that
can be set per domain (:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`).
a global limit (:setting:`CONCURRENT_REQUESTS`) and an additional per-scope
(per-domain by default) limit (:setting:`THROTTLER_SCOPE_CONCURRENCY`).
The default global concurrency limit in Scrapy is not suitable for crawling
many different domains in parallel, so you will want to increase it. How much

View File

@ -9,37 +9,24 @@ A Scrapy component is any class whose objects are built using
That includes the classes that you may assign to the following settings:
- :setting:`ADDONS`
- :setting:`TWISTED_DNS_RESOLVER`
- :setting:`DOWNLOAD_HANDLERS`
- :setting:`DOWNLOADER_MIDDLEWARES`
- :setting:`DUPEFILTER_CLASS`
- :setting:`EXTENSIONS`
- :setting:`FEED_EXPORTERS`
- :setting:`FEED_STORAGES`
- :setting:`ITEM_PIPELINES`
- :setting:`SCHEDULER`
- :setting:`SCHEDULER_DISK_QUEUE`
- :setting:`SCHEDULER_MEMORY_QUEUE`
- :setting:`SCHEDULER_PRIORITY_QUEUE`
- :setting:`SCHEDULER_START_DISK_QUEUE`
- :setting:`SCHEDULER_START_MEMORY_QUEUE`
- :setting:`SPIDER_MIDDLEWARES`
- :setting:`ADDONS`
- :setting:`DOWNLOAD_HANDLERS`
- :setting:`DOWNLOADER_MIDDLEWARES`
- :setting:`DUPEFILTER_CLASS`
- :setting:`EXTENSIONS`
- :setting:`FEED_EXPORTERS`
- :setting:`FEED_STORAGES`
- :setting:`ITEM_PIPELINES`
- :setting:`SCHEDULER`
- :setting:`SCHEDULER_DISK_QUEUE`
- :setting:`SCHEDULER_MEMORY_QUEUE`
- :setting:`SCHEDULER_PRIORITY_QUEUE`
- :setting:`SCHEDULER_START_DISK_QUEUE`
- :setting:`SCHEDULER_START_MEMORY_QUEUE`
- :setting:`SPIDER_MIDDLEWARES`
- :setting:`THROTTLER`
- :setting:`THROTTLER_SCOPE_MANAGER`
- :setting:`TWISTED_DNS_RESOLVER`
Third-party Scrapy components may also let you define additional Scrapy
components, usually configurable through :ref:`settings <topics-settings>`, to

View File

@ -169,6 +169,13 @@ middleware, see the :ref:`downloader middleware usage guide
For a list of the components enabled by default (and their orders) see the
:setting:`DOWNLOADER_MIDDLEWARES_BASE` setting.
.. _backoff-mw:
BackoffMiddleware
-----------------
.. autoclass:: scrapy.downloadermiddlewares.backoff.BackoffMiddleware
.. _cookies-mw:
CookiesMiddleware
@ -942,6 +949,22 @@ Default: ``20``
The maximum number of redirections that will be followed for a single request.
If maximum redirections are exceeded, the request is aborted and ignored.
.. setting:: REDIRECT_MAX_DELAY
REDIRECT_MAX_DELAY
^^^^^^^^^^^^^^^^^^
Default: ``120.0``
If a redirect response provides a ``Retry-After`` header, the redirect is only
followed after the specified delay. This setting caps that delay to a specific
number of seconds. Set to ``0.0`` to disable these delays altogether.
Note that redirect delays only affect the request being redirected, they do not
delay other requests to the same domain. ``Retry-After`` only does that during
:ref:`backoff <retry-after>`.
MetaRefreshMiddleware
---------------------

View File

@ -706,7 +706,6 @@ are some special keys recognized by Scrapy and its built-in extensions.
Those are:
* :reqmeta:`allow_offsite`
* :reqmeta:`autothrottle_dont_adjust_delay`
* :reqmeta:`bindaddress`
* :reqmeta:`cookiejar`
* :reqmeta:`dont_cache`
@ -714,6 +713,7 @@ Those are:
* :reqmeta:`dont_obey_robotstxt`
* :reqmeta:`dont_redirect`
* :reqmeta:`dont_retry`
* :reqmeta:`dont_throttle`
* :reqmeta:`download_fail_on_dataloss`
* :reqmeta:`download_latency`
* :reqmeta:`download_maxsize`
@ -733,6 +733,8 @@ Those are:
* :reqmeta:`redirect_reasons`
* :reqmeta:`redirect_urls`
* :reqmeta:`referrer_policy`
* :reqmeta:`delay`
* :reqmeta:`throttler_scopes`
* :reqmeta:`verbatim_url`
.. reqmeta:: bindaddress

View File

@ -538,21 +538,9 @@ CONCURRENT_REQUESTS
Default: ``16``
The maximum number of concurrent (i.e. simultaneous) requests that will be
performed by the Scrapy downloader.
Maximum number of total concurrent requests allowed.
.. setting:: CONCURRENT_REQUESTS_PER_DOMAIN
CONCURRENT_REQUESTS_PER_DOMAIN
------------------------------
Default: ``1`` (:ref:`fallback <default-settings>`: ``8``)
The maximum number of concurrent (i.e. simultaneous) requests that will be
performed to any single domain.
See also: :ref:`topics-autothrottle` and its
:setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` option.
.. seealso:: :ref:`throttling`
.. setting:: DEFAULT_DROPITEM_LOG_LEVEL
@ -873,6 +861,7 @@ Default:
"scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,
"scrapy.downloadermiddlewares.backoff.BackoffMiddleware": 650,
"scrapy.downloadermiddlewares.cookies.CookiesMiddleware": 700,
"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 750,
"scrapy.downloadermiddlewares.stats.DownloaderStats": 850,
@ -894,45 +883,6 @@ Default: ``True``
Whether to enable downloader stats collection.
.. setting:: DOWNLOAD_DELAY
DOWNLOAD_DELAY
--------------
Default: ``1`` (:ref:`fallback <default-settings>`: ``0``)
Minimum seconds to wait between 2 consecutive requests to the same domain.
Use :setting:`DOWNLOAD_DELAY` to throttle your crawling speed, to avoid hitting
servers too hard.
Decimal numbers are supported. For example, to send a maximum of 4 requests
every 10 seconds::
DOWNLOAD_DELAY = 2.5
This setting is also affected by the :setting:`RANDOMIZE_DOWNLOAD_DELAY`
setting, which is enabled by default.
Note that :setting:`DOWNLOAD_DELAY` can lower the effective per-domain
concurrency below :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`. If the response
time of a domain is lower than :setting:`DOWNLOAD_DELAY`, the effective
concurrency for that domain is 1. When testing throttling configurations, it
usually makes sense to lower :setting:`CONCURRENT_REQUESTS_PER_DOMAIN` first,
and only increase :setting:`DOWNLOAD_DELAY` once
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN` is 1 but a higher throttling is
desired.
.. _spider-download_delay-attribute:
.. note::
This delay can be set per spider using :attr:`download_delay` spider attribute.
It is also possible to change this setting per domain, although it requires
non-trivial code. See the implementation of the :ref:`AutoThrottle
<topics-autothrottle>` extension for an example.
.. setting:: DOWNLOAD_BIND_ADDRESS
DOWNLOAD_BIND_ADDRESS
@ -1039,30 +989,6 @@ handler (without replacement), place this in your ``settings.py``:
:ref:`security-local-resources`
.. setting:: DOWNLOAD_SLOTS
DOWNLOAD_SLOTS
--------------
Default: ``{}``
Allows to define concurrency/delay parameters on per slot (domain) basis:
.. code-block:: python
DOWNLOAD_SLOTS = {
"quotes.toscrape.com": {"concurrency": 1, "delay": 2, "randomize_delay": False},
"books.toscrape.com": {"delay": 3, "randomize_delay": False},
}
.. note::
For other downloader slots default settings values will be used:
- :setting:`DOWNLOAD_DELAY`: ``delay``
- :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`: ``concurrency``
- :setting:`RANDOMIZE_DOWNLOAD_DELAY`: ``randomize_delay``
.. setting:: DOWNLOAD_TIMEOUT
@ -1733,26 +1659,6 @@ Example::
NEWSPIDER_MODULE = 'mybot.spiders_dev'
.. setting:: RANDOMIZE_DOWNLOAD_DELAY
RANDOMIZE_DOWNLOAD_DELAY
------------------------
Default: ``True``
If enabled, Scrapy will wait a random amount of time (between 0.5 * :setting:`DOWNLOAD_DELAY` and 1.5 * :setting:`DOWNLOAD_DELAY`) while fetching requests from the same
website.
This randomization decreases the chance of the crawler being detected (and
subsequently blocked) by sites which analyze requests looking for statistically
significant similarities in the time between their requests.
The randomization policy is the same used by `wget`_ ``--random-wait`` option.
If :setting:`DOWNLOAD_DELAY` is zero (default) this option has no effect.
.. _wget: https://www.gnu.org/software/wget/manual/wget.html
.. setting:: REACTOR_THREADPOOL_MAXSIZE
REACTOR_THREADPOOL_MAXSIZE

View File

@ -502,6 +502,25 @@ headers_received
:param spider: the spider associated with the response
:type spider: :class:`~scrapy.Spider` object
robots_parsed
~~~~~~~~~~~~~
.. signal:: robots_parsed
.. function:: robots_parsed(robotparser, request)
Sent by
:class:`~scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware` after it
downloads and parses a ``robots.txt`` file, for the host that *request*
targets.
This signal supports :ref:`asynchronous handlers <signal-deferred>`.
:param robotparser: the parser holding the parsed ``robots.txt`` contents
:type robotparser: :class:`~scrapy.robotstxt.RobotParser` object
:param request: the request that triggered the ``robots.txt`` download
:type request: :class:`~scrapy.Request` object
Response signals
----------------

1016
docs/topics/throttling.rst Normal file

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ dependencies = [
"parsel>=1.5.0",
"protego>=0.1.15",
"pyOpenSSL>=22.0.0",
"queuelib>=1.4.2",
"queuelib>=1.6.1",
"service_identity>=23.1.0",
"tldextract",
"w3lib>=1.17.0",

View File

@ -1,48 +1,39 @@
from __future__ import annotations
import random
import warnings
from collections import deque
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from time import monotonic
from typing import TYPE_CHECKING, Any
from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.python.failure import Failure
from twisted.internet.defer import inlineCallbacks
from scrapy import Request, Spider, signals
from scrapy.core.downloader.handlers import DownloadHandlers
from scrapy.core.downloader.middleware import DownloaderMiddlewareManager
from scrapy.resolver import dnscache
from scrapy.utils.asyncio import (
AsyncioLoopingCall,
CallLaterResult,
call_later,
create_looping_call,
)
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import (
_defer_sleep_async,
_schedule_coro,
deferred_from_coro,
maybe_deferred_to_future,
)
from scrapy.utils.deprecate import warn_on_deprecated_spider_attribute
from scrapy.utils.defer import _defer_sleep_async, deferred_from_coro
from scrapy.utils.deprecate import create_deprecated_class
from scrapy.utils.httpobj import urlparse_cached
if TYPE_CHECKING:
from collections.abc import Generator
from twisted.internet.task import LoopingCall
from twisted.internet.defer import Deferred
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.settings import BaseSettings
from scrapy.signalmanager import SignalManager
from scrapy.throttler import ThrottlerProtocol, ThrottlerScopeManagerProtocol
from scrapy.utils.asyncio import CallLaterResult
@dataclass(slots=True, eq=False)
class Slot:
class _Slot:
"""Downloader slot"""
concurrency: int
@ -80,46 +71,151 @@ class Slot:
)
def _get_concurrency_delay(
concurrency: int, spider: Spider, settings: BaseSettings
) -> tuple[int, float]:
delay: float = settings.getfloat("DOWNLOAD_DELAY")
if hasattr(spider, "download_delay"):
delay = spider.download_delay
Slot = create_deprecated_class(
"Slot",
_Slot,
old_class_path="scrapy.core.downloader.Slot",
subclass_warn_message=("{cls} inherits from the deprecated Slot class."),
instance_warn_message=("The Slot class is deprecated."),
)
if hasattr(spider, "max_concurrent_requests"): # pragma: no cover
warn_on_deprecated_spider_attribute(
"max_concurrent_requests", "CONCURRENT_REQUESTS"
class _DeprecatedSlotView:
"""Deprecated per-domain slot view backed by the downloader and throttler."""
__slots__ = ("_downloader", "_key", "_scope")
def __init__(
self,
downloader: Downloader,
key: str,
scope: ThrottlerScopeManagerProtocol,
) -> None:
self._downloader = downloader
self._key = key
self._scope = scope
@property
def active(self) -> set[Request]:
return {
r
for r in self._downloader.active
if r.meta.get(Downloader.DOWNLOAD_SLOT) == self._key
}
@property
def transferring(self) -> set[Request]:
return {
r
for r in self._downloader._transferring
if r.meta.get(Downloader.DOWNLOAD_SLOT) == self._key
}
# This deprecated view reads throttler scope state from private attributes
# of the default scope manager rather than through the scope manager
# protocol: these are read-only compatibility accessors, so keeping them off
# the protocol avoids forcing custom THROTTLER_SCOPE_MANAGER implementations
# to provide members that only exist to feed this shim. A custom manager that
# lacks the attribute simply falls back to the historical default.
@property
def lastseen(self) -> float:
return getattr(self._scope, "_last_seen", None) or 0.0
@property
def delay(self) -> float:
return getattr(self._scope, "_delay", 0.0)
@delay.setter
def delay(self, value: float) -> None:
self._scope.set_base_delay(value, only_increase=False)
@property
def randomize_delay(self) -> bool:
return bool(getattr(self._scope, "_jitter", None))
@property
def concurrency(self) -> int:
warnings.warn(
"Slot.concurrency is deprecated. Per-slot concurrency limits are "
"now managed by the throttling system.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
concurrency = spider.max_concurrent_requests
return getattr(self._scope, "_concurrency", None) or 0
return concurrency, delay
def free_transfer_slots(self) -> int:
concurrency = getattr(self._scope, "_concurrency", None) or 0
return concurrency - len(self.transferring)
def download_delay(self) -> float:
delay = self.delay
if self.randomize_delay:
return random.uniform(0.5 * delay, 1.5 * delay) # noqa: S311
return delay
def close(self) -> None:
pass
def __repr__(self) -> str:
return f"_DeprecatedSlotView({self._key!r})"
class _DeprecatedSlotsView(Mapping[str, _DeprecatedSlotView]):
"""Deprecated mapping view of active downloads, keyed by slot name."""
__slots__ = ("_downloader", "_throttler")
def __init__(self, downloader: Downloader, throttler: ThrottlerProtocol) -> None:
self._downloader = downloader
self._throttler = throttler
def _active_keys(self) -> set[str]:
return {
r.meta[Downloader.DOWNLOAD_SLOT]
for r in self._downloader.active
if Downloader.DOWNLOAD_SLOT in r.meta
}
def __getitem__(self, key: str) -> _DeprecatedSlotView:
if key not in self._active_keys():
raise KeyError(key)
scope = self._throttler.get_scope_manager(key)
return _DeprecatedSlotView(self._downloader, key, scope)
def __iter__(self) -> Iterator[str]:
return iter(self._active_keys())
def __len__(self) -> int:
return len(self._active_keys())
def __contains__(self, key: object) -> bool:
return key in self._active_keys()
class Downloader:
DOWNLOAD_SLOT = "download_slot"
_SLOT_GC_INTERVAL: float = 60.0 # seconds
def __init__(self, crawler: Crawler):
self.crawler: Crawler = crawler
self.settings: BaseSettings = crawler.settings
self.signals: SignalManager = crawler.signals
self.slots: dict[str, Slot] = {}
self.active: set[Request] = set()
self._transferring: set[Request] = set()
self.handlers: DownloadHandlers = DownloadHandlers(crawler)
self.total_concurrency: int = self.settings.getint("CONCURRENT_REQUESTS")
self.domain_concurrency: int = self.settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN"
)
self.ip_concurrency: int = self.settings.getint("CONCURRENT_REQUESTS_PER_IP")
self.randomize_delay: bool = self.settings.getbool("RANDOMIZE_DOWNLOAD_DELAY")
self.middleware: DownloaderMiddlewareManager = (
DownloaderMiddlewareManager.from_crawler(crawler)
)
self._slot_gc_loop: AsyncioLoopingCall | LoopingCall | None = None
self.per_slot_settings: dict[str, dict[str, Any]] = self.settings.getdict(
"DOWNLOAD_SLOTS"
)
if self.per_slot_settings:
warnings.warn(
"The DOWNLOAD_SLOTS setting is deprecated. Use THROTTLER_SCOPES for "
"per-domain configuration instead.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
@inlineCallbacks
@_warn_spider_arg
@ -140,94 +236,63 @@ class Downloader:
def needs_backout(self) -> bool:
return len(self.active) >= self.total_concurrency
@_warn_spider_arg
def _get_slot(
self, request: Request, spider: Spider | None = None
) -> tuple[str, Slot]:
key = self.get_slot_key(request)
if key not in self.slots:
assert self.crawler.spider
slot_settings = self.per_slot_settings.get(key, {})
conc = self.ip_concurrency or self.domain_concurrency
conc, delay = _get_concurrency_delay(
conc, self.crawler.spider, self.settings
)
conc, delay = (
slot_settings.get("concurrency", conc),
slot_settings.get("delay", delay),
)
randomize_delay = slot_settings.get("randomize_delay", self.randomize_delay)
new_slot = Slot(conc, delay, randomize_delay)
self.slots[key] = new_slot
self._start_slot_gc()
@property
def domain_concurrency(self) -> int:
warnings.warn(
"Downloader.domain_concurrency is deprecated. Per-domain concurrency "
"limits are now managed by the throttling system.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
return self.settings.getint("CONCURRENT_REQUESTS_PER_DOMAIN")
return key, self.slots[key]
@property
def randomize_delay(self) -> bool:
warnings.warn(
"Downloader.randomize_delay is deprecated. Delay randomization is now "
"managed by the throttling system.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
return self.settings.getbool("RANDOMIZE_DOWNLOAD_DELAY")
@property
def slots(self) -> _DeprecatedSlotsView:
warnings.warn(
"Downloader.slots is deprecated. Use the throttler API instead.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
assert self.crawler.throttler is not None
return _DeprecatedSlotsView(self, self.crawler.throttler)
def _get_slot_key(self, request: Request) -> str:
assert self.crawler.throttler is not None
return self.crawler.throttler.get_slot_key(request)
def get_slot_key(self, request: Request) -> str:
# Retained as public backward-compatible API. It mirrors the historical
# keying (an explicit download_slot wins, else the domain); the slot key
# used at run time comes from the throttler (see _get_slot_key()).
meta_slot: str | None = request.meta.get(self.DOWNLOAD_SLOT)
if meta_slot is not None:
return meta_slot
return urlparse_cached(request).hostname or ""
key = urlparse_cached(request).hostname or ""
if self.ip_concurrency:
key = dnscache.get(key, key)
return key
# passed as download_func into self.middleware.download() in self.fetch()
async def _enqueue_request(self, request: Request) -> Response:
key, slot = self._get_slot(request)
key = self._get_slot_key(request)
request.meta[self.DOWNLOAD_SLOT] = key
slot.active.add(request)
self.signals.send_catch_log(
signal=signals.request_reached_downloader,
request=request,
spider=self.crawler.spider,
)
d: Deferred[Response] = Deferred()
slot.queue.append((request, d))
self._process_queue(slot)
return await self._download(request)
async def _download(self, request: Request) -> Response:
self._transferring.add(request)
try:
return await maybe_deferred_to_future(d) # fired in _wait_for_download()
finally:
slot.active.remove(request)
def _process_queue(self, slot: Slot) -> None:
if slot.latercall:
# block processing until slot.latercall is called
return
# Delay queue processing if a download_delay is configured
now = monotonic()
delay = slot.download_delay()
if delay:
penalty = delay - now + slot.lastseen
if penalty > 0:
slot.latercall = call_later(penalty, self._latercall, slot)
return
# Process enqueued requests if there are free slots to transfer for this slot
while slot.queue and slot.free_transfer_slots() > 0:
slot.lastseen = now
request, queue_dfd = slot.queue.popleft()
_schedule_coro(self._wait_for_download(slot, request, queue_dfd))
# prevent burst if inter-request delays were configured
if delay:
self._process_queue(slot)
break
def _latercall(self, slot: Slot) -> None:
slot.latercall = None
self._process_queue(slot)
async def _download(self, slot: Slot, request: Request) -> Response:
# The order is very important for the following logic. Do not change!
slot.transferring.add(request)
try:
# 1. Download the response
response: Response = await self.handlers.download_request_async(request)
# 2. Notify response_downloaded listeners about the recent download
# before querying queue for next request
self.signals.send_catch_log(
signal=signals.response_downloaded,
response=response,
@ -239,46 +304,12 @@ class Downloader:
await _defer_sleep_async()
raise
finally:
# 3. After response arrives, remove the request from transferring
# state to free up the transferring slot so it can be used by the
# following requests (perhaps those which came from the downloader
# middleware itself)
slot.transferring.remove(request)
self._process_queue(slot)
self._transferring.discard(request)
self.signals.send_catch_log(
signal=signals.request_left_downloader,
request=request,
spider=self.crawler.spider,
)
async def _wait_for_download(
self, slot: Slot, request: Request, queue_dfd: Deferred[Response]
) -> None:
try:
response = await self._download(slot, request)
except Exception:
queue_dfd.errback(Failure())
else:
queue_dfd.callback(response) # awaited in _enqueue_request()
def close(self) -> None:
self._stop_slot_gc()
for slot in self.slots.values():
slot.close()
def _slot_gc(self, age: float = 60) -> None:
mintime = monotonic() - age
for key, slot in list(self.slots.items()):
if not slot.active and slot.lastseen + slot.delay < mintime:
self.slots.pop(key).close()
def _start_slot_gc(self) -> None:
if self._slot_gc_loop:
return
self._slot_gc_loop = create_looping_call(self._slot_gc)
self._slot_gc_loop.start(self._SLOT_GC_INTERVAL, now=False)
def _stop_slot_gc(self) -> None:
if self._slot_gc_loop:
self._slot_gc_loop.stop()
self._slot_gc_loop = None
pass

View File

@ -7,11 +7,34 @@ from .base import BaseDownloadHandler
if TYPE_CHECKING:
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
class BaseHttpDownloadHandler(BaseDownloadHandler, ABC):
"""Base class for built-in HTTP download handlers."""
@staticmethod
def _max_per_host_concurrency(settings: BaseSettings) -> int:
"""Highest per-host concurrency the throttler may admit: the per-domain
limit, the default ``other``-scope limit, and any explicit
:setting:`THROTTLER_SCOPES` concurrency.
Since :setting:`CONCURRENT_REQUESTS` caps the total number of requests in
flight, no host can ever exceed it, so it is also the upper bound of the
result.
"""
global_concurrency = settings.getint("CONCURRENT_REQUESTS")
candidates = [
settings.getint("CONCURRENT_REQUESTS_PER_DOMAIN"),
settings.getint("THROTTLER_SCOPE_CONCURRENCY"),
]
candidates += [
int(scope["concurrency"])
for scope in settings.getdict("THROTTLER_SCOPES").values()
if "concurrency" in scope
]
return min(max(candidates), global_concurrency)
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE")

View File

@ -84,11 +84,8 @@ class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_Respon
crawler.settings.get("DOWNLOAD_BIND_ADDRESS")
)
self._proxy_auth_encoding: str = crawler.settings.get("HTTPPROXY_AUTH_ENCODING")
# these are useful for many handlers but used in different ways by them
self._pool_size_total: int = crawler.settings.getint("CONCURRENT_REQUESTS")
self._pool_size_per_host: int = crawler.settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN"
)
self._pool_size_per_host: int = self._max_per_host_concurrency(crawler.settings)
@staticmethod
@abstractmethod

View File

@ -92,8 +92,8 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
from twisted.internet import reactor
self._pool: HTTPConnectionPool = HTTPConnectionPool(reactor, persistent=True)
self._pool.maxPersistentPerHost = crawler.settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN"
self._pool.maxPersistentPerHost = self._max_per_host_concurrency(
crawler.settings
)
self._pool._factory.noisy = False

View File

@ -31,6 +31,7 @@ from scrapy.exceptions import (
from scrapy.http import Request, Response
from scrapy.utils.asyncio import (
AsyncioLoopingCall,
call_later,
create_looping_call,
is_asyncio_available,
)
@ -57,6 +58,7 @@ if TYPE_CHECKING:
from scrapy.settings import BaseSettings, Settings
from scrapy.signalmanager import SignalManager
from scrapy.spiders import Spider
from scrapy.utils.asyncio import CallLaterResult
logger = logging.getLogger(__name__)
@ -129,6 +131,25 @@ class ExecutionEngine:
self._start_request_processing_awaitable: (
asyncio.Future[None] | Deferred[None] | None
) = None
# Requests currently held by the throttler, waiting for their
# scopes to allow them through to the downloader.
self._throttler_waiting: set[Request] = set()
# Number of in-flight asynchronous enqueue operations (see
# ``_enqueue_request_async``), so the spider is not considered idle
# while a request is still on its way into the scheduler.
self._scheduling: int = 0
# A coalesced wakeup timer, armed when the scheduler reports (through
# ``get_next_request_delay``) that every pending request is time-blocked.
self._delay_wakeup: CallLaterResult | None = None
# The scheduler's ``get_next_request_delay`` bound method, cached once
# per crawl in ``open_spider_async`` (``None`` if the scheduler does not
# expose one). Used both to arm the wakeup timer and to tell whether the
# scheduler holds time-blocked requests itself.
self._get_next_request_delay: Callable[[], float | None] | None = None
# Whether the scheduler exposes enqueue_request_async, cached once per
# crawl in ``open_spider_async``; checked for every scheduled request.
self._scheduler_enqueues_async: bool = False
self._throttler_backout_warned: bool = False
downloader_cls: type[Downloader] = load_object(self.settings["DOWNLOADER"])
try:
self.scheduler_cls: type[BaseScheduler] = self._get_scheduler_class(
@ -262,9 +283,16 @@ class ExecutionEngine:
def pause(self) -> None:
self.paused = True
self._cancel_delay_wakeup()
def unpause(self) -> None:
self.paused = False
# pause() cancels the coalesced delay wakeup and the loop stops
# re-running itself while paused, so re-run it here: this re-arms the
# wakeup and keeps a crawl held only by a time-based gate (nothing in
# flight to re-run the loop from _download) from stalling.
if self._slot is not None:
self._slot.nextcall.schedule()
async def _process_start_next(self) -> None:
"""Processes the next item or request from Spider.start().
@ -331,13 +359,46 @@ class ExecutionEngine:
if self._slot is None or self._slot.closing is not None or self.paused:
return
self._cancel_delay_wakeup()
while not self.needs_backout():
if not self._start_scheduled_request():
break
self._maybe_arm_delay_wakeup()
if self.spider_is_idle() and self._slot.close_if_idle:
self._spider_idle()
def _cancel_delay_wakeup(self) -> None:
if self._delay_wakeup is not None:
self._delay_wakeup.cancel()
self._delay_wakeup = None
def _maybe_arm_delay_wakeup(self) -> None:
"""Arm a single coalesced wakeup when the scheduler reports that every
pending request is time-blocked.
Concurrency-blocked states do not need this: a freed slot already
re-runs the loop from :meth:`_download`'s ``finally``. Only time-based
gates (delay, backoff, quota windows) need a timer, since nothing else
would re-run the loop while they are closed.
"""
assert self._slot is not None
if (
self._get_next_request_delay is None
or not self._slot.scheduler.has_pending_requests()
):
return
# A positive delay means a time-based gate nothing else would wake us
# for. ``None`` (nothing time-blocked) and ``0`` (something is ready but
# could not be sent, e.g. the downloader is full) are handled elsewhere:
# a freed slot re-runs the loop, and a ``0``-second timer would busy-loop.
delay = self._get_next_request_delay()
if delay is None or delay <= 0:
return
self._delay_wakeup = call_later(delay, self._slot.nextcall.schedule)
def needs_backout(self) -> bool:
"""Returns ``True`` if no more requests can be sent at the moment, or
``False`` otherwise.
@ -351,6 +412,42 @@ class ExecutionEngine:
or bool(self._slot.closing)
or self.downloader.needs_backout()
or self.scraper.slot.needs_backout()
or self._throttler_needs_backout()
)
def _throttler_needs_backout(self) -> bool:
"""Apply backpressure while requests are held by the throttler.
Requests waiting on throttling do not occupy a downloader concurrency
slot, so without this check the engine would keep draining the
scheduler into pending throttling waits. Count them together with the
in-flight requests against the global concurrency limit.
"""
if not self._throttler_waiting:
return False
if (
len(self._throttler_waiting) + len(self.downloader.active)
< self.downloader.total_concurrency
):
return False
self._maybe_warn_throttler_backout()
return True
def _maybe_warn_throttler_backout(self) -> None:
if self._throttler_backout_warned:
return
# A throttler-aware scheduler holds time-blocked requests itself instead
# of letting them pile up in _throttler_waiting and back-pressure the
# engine here, so the warning does not apply when one is in use.
if self._get_next_request_delay is not None:
return
self._throttler_backout_warned = True
logger.warning(
"Throttling is holding requests back and they are now consuming the "
"global concurrency budget while they wait for a free slot. Consider "
"switching to scrapy.core.scheduler.ThrottlerAwareScheduler, which "
"holds throttled requests without consuming concurrency slots.",
extra={"spider": self.spider},
)
def _remove_request(self, _: Any, request: Request) -> None:
@ -426,6 +523,10 @@ class ExecutionEngine:
return False
if self.downloader.active: # downloader has pending requests
return False
if self._throttler_waiting: # requests held by the throttler
return False
if self._scheduling: # requests still on their way into the scheduler
return False
if self._start is not None: # not all start requests are handled
return False
return not self._slot.scheduler.has_pending_requests()
@ -438,6 +539,7 @@ class ExecutionEngine:
self._slot.nextcall.schedule() # type: ignore[union-attr]
def _schedule_request(self, request: Request) -> None:
assert self._slot is not None
request_scheduled_result = self.signals.send_catch_log(
signals.request_scheduled,
request=request,
@ -447,11 +549,39 @@ class ExecutionEngine:
for _, result in request_scheduled_result:
if isinstance(result, Failure) and isinstance(result.value, IgnoreRequest):
return
if not self._slot.scheduler.enqueue_request(request): # type: ignore[union-attr]
scheduler = self._slot.scheduler
if self._scheduler_enqueues_async:
self._scheduling += 1
_schedule_coro(self._enqueue_request_async(request))
return
if not scheduler.enqueue_request(request):
self.signals.send_catch_log(
signals.request_dropped, request=request, spider=self.spider
)
async def _enqueue_request_async(self, request: Request) -> None:
# _scheduling is incremented in _schedule_request before this coroutine
# is scheduled, so it must be decremented on every path (hence finally),
# otherwise spider_is_idle() never reports idle.
try:
if self._slot is None:
return
stored = await self._slot.scheduler.enqueue_request_async(request) # type: ignore[attr-defined]
if not stored:
self.signals.send_catch_log(
signals.request_dropped, request=request, spider=self.spider
)
except Exception:
logger.error(
"Error while enqueuing request",
exc_info=True,
extra={"spider": self.spider},
)
finally:
self._scheduling -= 1
if self._slot is not None:
self._slot.nextcall.schedule()
def download(self, request: Request) -> Deferred[Response]:
"""Return a Deferred which fires with a Response as result, only downloader middlewares are applied"""
warnings.warn(
@ -481,6 +611,20 @@ class ExecutionEngine:
return await self.download_async(response_or_request)
return response_or_request
@inlineCallbacks
def _acquire_throttler(
self, request: Request
) -> Generator[Deferred[Any], Any, None]:
"""Wait at the throttling gate before *request* is sent, tracking it as
held meanwhile."""
self._throttler_waiting.add(request)
throttler = self.crawler.throttler
assert throttler is not None
try:
yield deferred_from_coro(throttler.acquire(request))
finally:
self._throttler_waiting.discard(request)
@inlineCallbacks
def _download(
self, request: Request
@ -488,8 +632,14 @@ class ExecutionEngine:
assert self._slot is not None # typing
assert self.spider is not None
self._slot.add_request(request)
throttler = self.crawler.throttler
assert throttler is not None
# A throttler-aware scheduler reserves the request (a concurrency slot)
# before handing it here, so everything past this point runs inside the
# try/finally that releases it, even if add_request() were to raise.
try:
self._slot.add_request(request)
yield self._acquire_throttler(request)
result: Response | Request
if self._downloader_fetch_needs_spider:
result = yield self.downloader.fetch(request, self.spider)
@ -515,6 +665,7 @@ class ExecutionEngine:
)
return result
finally:
throttler.release(request)
self._slot.nextcall.schedule()
def open_spider(
@ -537,6 +688,10 @@ class ExecutionEngine:
self.spider = self.crawler.spider
nextcall = CallLaterOnce(self._start_scheduled_requests)
scheduler = build_from_crawler(self.scheduler_cls, self.crawler)
self._get_next_request_delay = getattr(
scheduler, "get_next_request_delay", None
)
self._scheduler_enqueues_async = hasattr(scheduler, "enqueue_request_async")
self._slot = _Slot(close_if_idle, nextcall, scheduler)
self._start = await self.scraper.spidermw.process_start()
if hasattr(scheduler, "open") and (d := scheduler.open(self.crawler.spider)):
@ -613,6 +768,8 @@ class ExecutionEngine:
"Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider}
)
self._cancel_delay_wakeup()
try:
await self._slot.close()
except Exception:

View File

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast
from twisted.internet.defer import Deferred # noqa: TC002
from scrapy.spiders import Spider # noqa: TC001
from scrapy.throttler import iter_scopes
from scrapy.utils.job import job_dir
from scrapy.utils.misc import build_from_crawler, load_object
@ -25,6 +26,7 @@ if TYPE_CHECKING:
from scrapy.http.request import Request
from scrapy.pqueues import ScrapyPriorityQueue
from scrapy.statscollectors import StatsCollector
from scrapy.throttler import ThrottlerProtocol
logger = logging.getLogger(__name__)
@ -63,6 +65,33 @@ class BaseScheduler(metaclass=BaseSchedulerMeta):
plays a great part in determining the order in which those requests are downloaded. See :ref:`request-order`.
The methods defined in this class constitute the minimal interface that the Scrapy engine will interact with.
Asynchronous API
================
Beyond the minimal interface above, the engine also uses the following
optional members when a scheduler defines them, to support asynchronous
scheduling. :meth:`next_request` itself stays synchronous.
.. method:: enqueue_request_async(request)
:async:
Asynchronous counterpart of :meth:`enqueue_request`, following the same
return contract. When a scheduler defines it, the engine awaits it
*instead of* calling :meth:`enqueue_request`. Define it when enqueuing a
request needs to ``await`` (e.g. to resolve throttler scopes
asynchronously).
.. method:: get_next_request_delay()
Return the number of seconds until some pending request that
:meth:`next_request` is currently withholding for a time-based reason
becomes available, or ``None`` if no pending request is time-blocked.
When a scheduler defines it, the engine uses it to schedule a single
wakeup after :meth:`next_request` returns ``None`` while requests remain
pending, so a crawl held back only by time does not stall.
.. versionadded:: VERSION
"""
@classmethod
@ -209,9 +238,8 @@ class Scheduler(BaseScheduler):
-------------------------
While pending requests are below the configured values of
:setting:`CONCURRENT_REQUESTS` or
:setting:`CONCURRENT_REQUESTS_PER_DOMAIN`, those requests are sent
concurrently.
:setting:`CONCURRENT_REQUESTS` or :setting:`THROTTLER_SCOPE_CONCURRENCY`,
those requests are sent concurrently.
As a result, the first few requests of a crawl may not follow the desired
order. Lowering those settings to ``1`` enforces the desired order except
@ -374,12 +402,21 @@ class Scheduler(BaseScheduler):
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
dqok = self._dqpush(request)
return self._store(request)
def _store(self, request: Request, *push_args: Any) -> bool:
"""Push *request* into the disk queue, falling back to the memory queue,
and increment the relevant ``scheduler/enqueued*`` stats.
Extra positional arguments are forwarded to the underlying queue
``push`` calls; subclasses that store requests under additional keys
(e.g. a throttler scope set) pass them through here.
"""
assert self.stats is not None
if dqok:
if self._dqpush(request, *push_args):
self.stats.inc_value("scheduler/enqueued/disk")
else:
self._mqpush(request)
self._mqpush(request, *push_args)
self.stats.inc_value("scheduler/enqueued/memory")
self.stats.inc_value("scheduler/enqueued")
return True
@ -411,11 +448,11 @@ class Scheduler(BaseScheduler):
"""
return len(self.dqs) + len(self.mqs) if self.dqs is not None else len(self.mqs)
def _dqpush(self, request: Request) -> bool:
def _dqpush(self, request: Request, *push_args: Any) -> bool:
if self.dqs is None:
return False
try:
self.dqs.push(request)
self.dqs.push(request, *push_args)
except ValueError as e: # non serializable request
if self.logunser:
msg = (
@ -435,8 +472,8 @@ class Scheduler(BaseScheduler):
return False
return True
def _mqpush(self, request: Request) -> None:
self.mqs.push(request)
def _mqpush(self, request: Request, *push_args: Any) -> None:
self.mqs.push(request, *push_args)
def _dqpop(self) -> Request | None:
if self.dqs is not None:
@ -496,3 +533,69 @@ class Scheduler(BaseScheduler):
def _write_dqs_state(self, dqdir: str, state: Any) -> None:
with Path(dqdir, "active.json").open("w", encoding="utf-8") as f:
json.dump(state, f)
class ThrottlerAwareScheduler(Scheduler):
"""A :setting:`SCHEDULER` that only ever hands the engine requests that
their :ref:`throttler scopes <throttler-scopes>` allow to be sent **right
now**.
The default scheduler hands requests to the engine as concurrency allows
and lets them wait at the throttling gate
(:meth:`~scrapy.throttler.ThrottlerProtocol.acquire`) while holding
a concurrency slot. When a crawl mixes heavily-throttled scopes with
unthrottled ones, enough throttled requests waiting on a clock can fill
:setting:`CONCURRENT_REQUESTS` and starve unthrottled requests that could be
sent right away. This scheduler instead withholds a request until it is
sendable, so held-back requests occupy neither concurrency slots nor memory
(beyond what is needed to track each distinct scope set) and cannot starve
other scopes.
It also honors the per-request :reqmeta:`delay`, holding an
individual request back without blocking others that share its scopes, which
the default scheduler cannot do.
When several requests could be sent at the same time, the one with the
highest request :attr:`~scrapy.Request.priority` is sent first; ties are
broken by preferring the least-busy scopes.
It requires :setting:`SCHEDULER_PRIORITY_QUEUE` to be set to
:class:`~scrapy.pqueues.ThrottlerAwarePriorityQueue` (or a compatible
subclass).
"""
def open(self, spider: Spider) -> Deferred[None] | None:
result = super().open(spider)
if not hasattr(self.mqs, "get_next_request_delay"):
raise ValueError(
f"{type(self).__name__} requires SCHEDULER_PRIORITY_QUEUE to be "
f"set to a throttler-aware priority queue such as "
f"scrapy.pqueues.ThrottlerAwarePriorityQueue, but the "
f"configured one ({type(self.mqs).__name__}) is not."
)
assert self.crawler is not None
assert self.crawler.throttler is not None
self._throttler: ThrottlerProtocol = self.crawler.throttler
return result
def enqueue_request(self, request: Request) -> bool:
raise RuntimeError(
"ThrottlerAwareScheduler requires the asynchronous enqueue path; "
"enqueue_request_async() is used by the engine instead of "
"enqueue_request()."
)
async def enqueue_request_async(self, request: Request) -> bool:
if not request.dont_filter and self.df.request_seen(request):
self.df.log(request, self.spider)
return False
scope_set = frozenset(iter_scopes(await self._throttler.get_scopes(request)))
return self._store(request, scope_set)
def get_next_request_delay(self) -> float | None:
delays = [
delay
for pq in (self.mqs, self.dqs)
if pq is not None and (delay := pq.get_next_request_delay()) is not None # type: ignore[attr-defined]
]
return min(delays) if delays else None

View File

@ -46,6 +46,7 @@ if TYPE_CHECKING:
from scrapy.logformatter import LogFormatter
from scrapy.statscollectors import StatsCollector
from scrapy.throttler import ThrottlerProtocol
from scrapy.utils.request import RequestFingerprinterProtocol
@ -83,6 +84,13 @@ class Crawler:
self.stats: StatsCollector | None = None
self.logformatter: LogFormatter | None = None
self.request_fingerprinter: RequestFingerprinterProtocol | None = None
self.throttler: ThrottlerProtocol | None = None
"""The throttler of this crawler, an instance of
:setting:`THROTTLER`.
It is ``None`` until the crawl starts. Components can use it to inspect
or drive :ref:`throttling <throttling>` at run time, e.g. through
:meth:`~scrapy.throttler.ThrottlerProtocol.delay_scope`."""
self.spider: Spider | None = None
self.engine: ExecutionEngine | None = None
@ -96,6 +104,10 @@ class Crawler:
return
self.addons.load_settings(self.settings)
self._apply_deprecated_spider_attr("download_delay", "DOWNLOAD_DELAY")
self._apply_deprecated_spider_attr(
"max_concurrent_requests", "THROTTLER_SCOPE_CONCURRENCY"
)
self.stats = load_object(self.settings["STATS_CLASS"])(self)
lf_cls: type[LogFormatter] = load_object(self.settings["LOG_FORMATTER"])
@ -105,6 +117,10 @@ class Crawler:
load_object(self.settings["REQUEST_FINGERPRINTER_CLASS"]),
self,
)
self.throttler = build_from_crawler(
load_object(self.settings["THROTTLER"]),
self,
)
use_reactor = self.settings.getbool("TWISTED_REACTOR_ENABLED")
if use_reactor:
@ -151,6 +167,30 @@ class Crawler:
"Overridden settings:\n%(settings)s", {"settings": pprint.pformat(d)}
)
def _apply_deprecated_spider_attr(self, attr: str, setting: str) -> None:
"""Bridge a deprecated spider attribute onto *setting*, warning about
the deprecation (and about being ignored when *setting* is already set
at spider or higher priority)."""
spider = self.spider if self.spider is not None else self.spidercls
if not hasattr(spider, attr):
return
if (self.settings.getpriority(setting) or 0) >= SETTINGS_PRIORITIES["spider"]:
warnings.warn(
f"The {attr!r} spider attribute is deprecated. It is also being "
f"ignored because {setting} is already set at spider or higher "
f"priority. Remove the {attr!r} attribute from your spider.",
category=ScrapyDeprecationWarning,
stacklevel=3,
)
return
warnings.warn(
f"The {attr!r} spider attribute is deprecated. Use the {setting} "
f"setting or THROTTLER_SCOPES instead.",
category=ScrapyDeprecationWarning,
stacklevel=3,
)
self.settings.set(setting, getattr(spider, attr), priority="spider")
def _apply_reactorless_default_settings(self) -> None:
"""Change some setting defaults when not using a Twisted reactor.

View File

@ -0,0 +1,130 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from scrapy.exceptions import NotConfigured
from scrapy.throttler import iter_scopes
from scrapy.utils._headers import _parse_ratelimit_reset, _parse_retry_after
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.misc import _load_objects
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
import scrapy
from scrapy.crawler import Crawler
from scrapy.http import Request, Response
from scrapy.throttler import ThrottlerProtocol
logger = logging.getLogger(__name__)
class BackoffMiddleware:
"""Downloader middleware that drives :ref:`backoff <backoff>` from download
outcomes.
It observes every response and download exception and, for those matching
:setting:`BACKOFF_HTTP_CODES` or :setting:`BACKOFF_EXCEPTIONS` (globally or
per :setting:`THROTTLER_SCOPES` scope), tells the :ref:`throttler
<throttling>` to back off the request's scopes through its
:meth:`~scrapy.throttler.ThrottlerProtocol.back_off` API.
It is enabled by default; set :setting:`BACKOFF_ENABLED` to ``False`` to
disable it without removing it from :setting:`DOWNLOADER_MIDDLEWARES`.
See :ref:`throttling` for details.
"""
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler)
def __init__(self, crawler: Crawler):
if not crawler.settings.getbool("BACKOFF_ENABLED"):
raise NotConfigured
assert crawler.throttler is not None
self._throttler: ThrottlerProtocol = crawler.throttler
settings = crawler.settings
self._http_codes: set[int] = {
int(code) for code in settings.getlist("BACKOFF_HTTP_CODES")
}
self._exceptions: tuple[type[BaseException], ...] = _load_objects(
settings.getlist("BACKOFF_EXCEPTIONS")
)
# Per-scope overrides; a scope absent from these uses the globals above.
self._scope_http_codes: dict[str, set[int]] = {}
self._scope_exceptions: dict[str, tuple[type[BaseException], ...]] = {}
# Unions of the globals and every per-scope override, used as a cheap
# pre-filter to skip outcomes that no scope backs off on.
self._any_http_codes: set[int] = set(self._http_codes)
self._any_exceptions: tuple[type[BaseException], ...] = self._exceptions
for scope_id, scope_config in settings.getdict("THROTTLER_SCOPES").items():
backoff = scope_config.get("backoff") or {}
if "http_codes" in backoff:
codes = {int(code) for code in backoff["http_codes"]}
self._scope_http_codes[scope_id] = codes
self._any_http_codes |= codes
if "exceptions" in backoff:
exceptions = _load_objects(backoff["exceptions"])
self._scope_exceptions[scope_id] = exceptions
self._any_exceptions += exceptions
@_warn_spider_arg
def process_response(
self,
request: Request,
response: Response,
spider: scrapy.Spider | None = None,
) -> Response:
if (
response.status not in self._any_http_codes
or "cached" in response.flags
or request.meta.get("dont_throttle")
):
return response
matched = [
scope
for scope in iter_scopes(self._throttler.get_resolved_scopes(request))
if response.status in self._scope_http_codes.get(scope, self._http_codes)
]
if matched:
self._throttler.back_off(matched, delay=self._response_delay(response))
return response
@_warn_spider_arg
def process_exception(
self,
request: Request,
exception: Exception,
spider: scrapy.Spider | None = None,
) -> None:
if request.meta.get("dont_throttle") or not isinstance(
exception, self._any_exceptions
):
return
matched = [
scope
for scope in iter_scopes(self._throttler.get_resolved_scopes(request))
if isinstance(
exception, self._scope_exceptions.get(scope, self._exceptions)
)
]
if matched:
self._throttler.back_off(matched)
@staticmethod
def _response_delay(response: Response) -> float | None:
"""Return the hard minimum delay requested by *response* through a
``Retry-After`` or ``RateLimit-Reset`` header, or ``None``."""
delays = [
delay
for delay in (
_parse_retry_after(response),
_parse_ratelimit_reset(response),
)
if delay is not None
]
return max(delays) if delays else None

View File

@ -10,6 +10,7 @@ from scrapy import signals
from scrapy.exceptions import IgnoreRequest, NotConfigured
from scrapy.http import HtmlResponse, Response
from scrapy.spidermiddlewares.referer import RefererMiddleware
from scrapy.utils._headers import _parse_retry_after
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import global_object_name
@ -36,6 +37,7 @@ class BaseRedirectMiddleware:
raise NotConfigured
self.max_redirect_times: int = settings.getint("REDIRECT_MAX_TIMES")
self.max_delay: float = settings.getfloat("REDIRECT_MAX_DELAY")
self.priority_adjust: int = settings.getint("REDIRECT_PRIORITY_ADJUST")
self._referer_spider_middleware: RefererMiddleware | None = None
@ -174,9 +176,30 @@ class BaseRedirectMiddleware:
del redirect_request.headers["Authorization"]
self.handle_referer(redirect_request, response)
self._apply_retry_after(redirect_request, response)
return redirect_request
def _apply_retry_after(self, redirect_request: Request, response: Response) -> None:
"""Delay the redirect when *response* carries a ``Retry-After`` header.
The delay is capped at :setting:`REDIRECT_MAX_DELAY` and applied through
the :reqmeta:`delay` request metadata key, which holds back
this request only (without counting as a :ref:`backoff <backoff>`
trigger for its scopes). :setting:`REDIRECT_MAX_DELAY` set to ``0``
disables it.
"""
if not self.max_delay:
return
retry_after = _parse_retry_after(response)
if retry_after is None:
return
redirect_request.meta["delay"] = min(retry_after, self.max_delay)
# This is a fresh request that may inherit an already-honored delay from
# its source request's meta; clear that state so the new delay applies.
redirect_request.meta.pop("_throttler_delayed", None)
redirect_request.meta.pop("_throttler_delay_deadline", None)
def _redirect_request_using_get(
self, request: Request, response: Response, redirect_url: str
) -> Request:

View File

@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
from twisted.internet.defer import Deferred
from scrapy import signals
from scrapy.exceptions import IgnoreRequest, NotConfigured
from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK
@ -98,7 +99,7 @@ class RobotsTxtMiddleware:
assert self.crawler.stats
try:
resp = await self.crawler.engine.download_async(robotsreq)
self._parse_robots(resp, netloc)
await self._parse_robots(resp, netloc, request)
except Exception as e:
if not isinstance(e, IgnoreRequest):
logger.error(
@ -115,13 +116,20 @@ class RobotsTxtMiddleware:
return await maybe_deferred_to_future(parser)
return parser
def _parse_robots(self, response: Response, netloc: str) -> None:
async def _parse_robots(
self, response: Response, netloc: str, request: Request
) -> None:
assert self.crawler.stats
self.crawler.stats.inc_value("robotstxt/response_count")
self.crawler.stats.inc_value(
f"robotstxt/response_status_count/{response.status}"
)
rp = self._parserimpl.from_crawler(self.crawler, response.body)
await self.crawler.signals.send_catch_log_async(
signal=signals.robots_parsed,
robotparser=rp,
request=request,
)
rp_dfd = self._parsers[netloc]
assert isinstance(rp_dfd, Deferred)
self._parsers[netloc] = rp

View File

@ -2,17 +2,19 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from warnings import warn
from scrapy import Request, Spider, signals
from scrapy.exceptions import NotConfigured
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.utils.httpobj import urlparse_cached
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy.core.downloader import Slot
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.throttler import ThrottlerProtocol
logger = logging.getLogger(__name__)
@ -24,6 +26,15 @@ class AutoThrottle:
if not crawler.settings.getbool("AUTOTHROTTLE_ENABLED"):
raise NotConfigured
warn(
"You have set the AUTOTHROTTLE_ENABLED setting to True, however "
"the AutoThrottle extension is deprecated; use throttler and "
"backoff settings instead: "
"https://docs.scrapy.org/en/latest/topics/throttling.html",
ScrapyDeprecationWarning,
stacklevel=2,
)
self.debug: bool = crawler.settings.getbool("AUTOTHROTTLE_DEBUG")
self.target_concurrency: float = crawler.settings.getfloat(
"AUTOTHROTTLE_TARGET_CONCURRENCY"
@ -33,6 +44,7 @@ class AutoThrottle:
f"AUTOTHROTTLE_TARGET_CONCURRENCY "
f"({self.target_concurrency!r}) must be higher than 0."
)
self._started_scopes: set[str] = set()
crawler.signals.connect(self._spider_opened, signal=signals.spider_opened)
crawler.signals.connect(
self._response_downloaded, signal=signals.response_downloaded
@ -45,7 +57,9 @@ class AutoThrottle:
def _spider_opened(self, spider: Spider) -> None:
self.mindelay = self._min_delay(spider)
self.maxdelay = self._max_delay(spider)
spider.download_delay = self._start_delay(spider) # type: ignore[attr-defined]
self._startdelay = max(
self.mindelay, self.crawler.settings.getfloat("AUTOTHROTTLE_START_DELAY")
)
def _min_delay(self, spider: Spider) -> float:
s = self.crawler.settings
@ -54,55 +68,51 @@ class AutoThrottle:
def _max_delay(self, spider: Spider) -> float:
return self.crawler.settings.getfloat("AUTOTHROTTLE_MAX_DELAY")
def _start_delay(self, spider: Spider) -> float:
return max(
self.mindelay, self.crawler.settings.getfloat("AUTOTHROTTLE_START_DELAY")
)
def _response_downloaded(
self, response: Response, request: Request, spider: Spider
) -> None:
key, slot = self._get_slot(request, spider)
throttler = self.crawler.throttler
assert throttler is not None
latency = request.meta.get("download_latency")
if (
latency is None
or slot is None
or request.meta.get("autothrottle_dont_adjust_delay", False) is True
):
return
olddelay = slot.delay
self._adjust_delay(slot, latency, response)
scope_id = urlparse_cached(request).hostname or ""
olddelay = self._scope_delay(throttler, scope_id)
newdelay = self._adjust_delay(olddelay, latency, response)
throttler.set_scope_delay(scope_id, newdelay)
if self.debug:
diff = slot.delay - olddelay
size = len(response.body)
conc = len(slot.transferring)
logger.info(
"slot: %(slot)s | conc:%(concurrency)2d | "
"slot: %(slot)s | "
"delay:%(delay)5d ms (%(delaydiff)+d) | "
"latency:%(latency)5d ms | size:%(size)6d bytes",
{
"slot": key,
"concurrency": conc,
"delay": slot.delay * 1000,
"delaydiff": diff * 1000,
"slot": scope_id,
"delay": newdelay * 1000,
"delaydiff": (newdelay - olddelay) * 1000,
"latency": latency * 1000,
"size": size,
"size": len(response.body),
},
extra={"spider": spider},
)
def _get_slot(
self, request: Request, spider: Spider
) -> tuple[str | None, Slot | None]:
key: str | None = request.meta.get("download_slot")
if key is None:
return None, None
assert self.crawler.engine
return key, self.crawler.engine.downloader.slots.get(key)
def _scope_delay(self, throttler: ThrottlerProtocol, scope_id: str) -> float:
"""Return the current delay of *scope_id*, applying AUTOTHROTTLE_START_DELAY
the first time the scope is seen."""
delay = throttler.get_scope_delay(scope_id)
if scope_id not in self._started_scopes:
self._started_scopes.add(scope_id)
delay = max(delay, self._startdelay)
return delay
def _adjust_delay(self, slot: Slot, latency: float, response: Response) -> None:
"""Define delay adjustment policy"""
def _adjust_delay(
self, olddelay: float, latency: float, response: Response
) -> float:
"""Return the new delay given the current *olddelay* and the observed
*latency*."""
# If a server needs `latency` seconds to respond then
# we should send a request each `latency/N` seconds
@ -110,7 +120,7 @@ class AutoThrottle:
target_delay = latency / self.target_concurrency
# Adjust the delay to make it closer to target_delay
new_delay = (slot.delay + target_delay) / 2.0
new_delay = (olddelay + target_delay) / 2.0
# If target delay is bigger than old delay, then use it instead of mean.
# It works better with problematic sites.
@ -123,7 +133,7 @@ class AutoThrottle:
# than old one, as error pages (and redirections) are usually small and
# so tend to reduce latency, thus provoking a positive feedback by
# reducing delay instead of increase.
if response.status != 200 and new_delay <= slot.delay:
return
if response.status != 200 and new_delay <= olddelay:
return olddelay
slot.delay = new_delay
return new_delay

View File

@ -1,7 +1,10 @@
from __future__ import annotations
import hashlib
import heapq
import json
import logging
import time
from typing import TYPE_CHECKING, Protocol, cast
from scrapy.utils.misc import build_from_crawler
@ -13,8 +16,8 @@ if TYPE_CHECKING:
from typing_extensions import Self
from scrapy import Request
from scrapy.core.downloader import Downloader
from scrapy.crawler import Crawler
from scrapy.throttler import ScopeID, ThrottlerProtocol
logger = logging.getLogger(__name__)
@ -235,9 +238,9 @@ class ScrapyPriorityQueue:
if self.curprio is None:
return None
try:
queue = self._start_queues[self.curprio]
except KeyError:
queue = self.queues[self.curprio]
except KeyError:
queue = self._start_queues[self.curprio]
# Protocols can't declare optional members
return cast("Request", queue.peek()) # type: ignore[attr-defined]
@ -261,24 +264,6 @@ class ScrapyPriorityQueue:
)
class DownloaderInterface:
def __init__(self, crawler: Crawler):
assert crawler.engine
self.downloader: Downloader = crawler.engine.downloader
def stats(self, possible_slots: Iterable[str]) -> list[tuple[int, str]]:
return [(self._active_downloads(slot), slot) for slot in possible_slots]
def get_slot_key(self, request: Request) -> str:
return self.downloader.get_slot_key(request)
def _active_downloads(self, slot: str) -> int:
"""Return a number of requests in a Downloader for a given slot"""
if slot not in self.downloader.slots:
return 0
return len(self.downloader.slots[slot].active)
class DownloaderAwarePriorityQueue:
"""PriorityQueue which takes Downloader activity into account:
domains (slots) with the least amount of active downloads are dequeued
@ -332,11 +317,6 @@ class DownloaderAwarePriorityQueue:
*,
start_queue_cls: type[QueueProtocol] | None = None,
):
if crawler.settings.getint("CONCURRENT_REQUESTS_PER_IP") != 0:
raise ValueError(
f'"{self.__class__}" does not support CONCURRENT_REQUESTS_PER_IP'
)
if slot_startprios and not isinstance(slot_startprios, dict):
raise ValueError(
"DownloaderAwarePriorityQueue accepts "
@ -348,7 +328,8 @@ class DownloaderAwarePriorityQueue:
"queue class can be resumed."
)
self._downloader_interface: DownloaderInterface = DownloaderInterface(crawler)
assert crawler.throttler is not None
self._throttler: ThrottlerProtocol = crawler.throttler
self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls
self._start_queue_cls: type[QueueProtocol] | None = start_queue_cls
self.key: str = key
@ -360,19 +341,19 @@ class DownloaderAwarePriorityQueue:
for slot, startprios in slot_startprios.items():
self.pqueues[slot] = self.pqfactory(slot, startprios)
def _next_slot(self, stats: list[tuple[int, str]], *, update_state: bool) -> str:
def _next_slot(self, stats: list[tuple[float, str]], *, update_state: bool) -> str:
last = self._last_selected_slot
min_active: int | None = None
min_load: float | None = None
best_slot: str | None = None
best_slot_after_last: str | None = None
for active, slot in stats:
if min_active is None or active < min_active:
min_active = active
for load, slot in stats:
if min_load is None or load < min_load:
min_load = load
best_slot = slot
best_slot_after_last = None
if last is not None and slot > last:
best_slot_after_last = slot
elif active == min_active:
elif load == min_load:
if best_slot is None or slot < best_slot:
best_slot = slot
if (
@ -398,8 +379,11 @@ class DownloaderAwarePriorityQueue:
start_queue_cls=self._start_queue_cls,
)
def _slot_stats(self) -> list[tuple[float, str]]:
return [(self._throttler.get_scope_load(slot), slot) for slot in self.pqueues]
def pop(self) -> Request | None:
stats = self._downloader_interface.stats(self.pqueues)
stats = self._slot_stats()
if not stats:
return None
@ -412,7 +396,7 @@ class DownloaderAwarePriorityQueue:
return request
def push(self, request: Request) -> None:
slot = self._downloader_interface.get_slot_key(request)
slot = self._throttler.get_slot_key(request)
if slot not in self.pqueues:
self.pqueues[slot] = self.pqfactory(slot)
queue = self.pqueues[slot]
@ -425,7 +409,7 @@ class DownloaderAwarePriorityQueue:
Raises :exc:`NotImplementedError` if the underlying queue class does
not implement a ``peek`` method, which is optional for queues.
"""
stats = self._downloader_interface.stats(self.pqueues)
stats = self._slot_stats()
if not stats:
return None
slot = self._next_slot(stats, update_state=False)
@ -442,3 +426,260 @@ class DownloaderAwarePriorityQueue:
def __contains__(self, slot: str) -> bool:
return slot in self.pqueues
def _scope_set_key(scope_set: frozenset[ScopeID]) -> str:
"""Return a reversible, JSON-safe string key for *scope_set*.
Used both as the in-memory dict key encoding for the on-disk state and to
derive the per-scope-set subdirectory name. The encoding is
order-independent (the scope ids are sorted)."""
return json.dumps(sorted(scope_set))
def _scope_set_from_key(key: str) -> frozenset[ScopeID]:
return frozenset(json.loads(key))
class ThrottlerAwarePriorityQueue:
"""Priority queue that only ever pops a request that can be sent right now
based on its :ref:`throttler scope set <throttler-scopes>` and
per-request :reqmeta:`delay`.
The downstream queue class must support ``peek``.
Disk persistence
================
.. warning:: The files that this class generates on disk are an
implementation detail, and may change without a warning in a future
version of Scrapy. Do not rely on the following information for
anything other than debugging purposes.
When instantiated with a non-empty *key* argument, *key* is used as a
persistence directory, and inside it this class creates a subdirectory per
scope set, named from a path-safe, order-independent encoding of its scope
ids.
For example, a request whose scope set is ``{"example.com",
"cost:group-1"}`` is stored under a subdirectory derived in two steps:
#. The scope ids are sorted and JSON-encoded into an order-independent key
(so ``{"example.com", "cost:group-1"}`` and ``{"cost:group-1",
"example.com"}`` map to the same one)::
["cost:group-1", "example.com"]
#. That key is made path-safe: every character outside ``[A-Za-z0-9-._]``
becomes ``_`` (here the ``:`` and the JSON quotes, brackets and spaces;
the ``-`` and ``.`` are kept), and an MD5 suffix disambiguates keys that
collapse to the same path::
__cost_group-1____example.com__-fc6ba2aff8f421bf981b662d77739902
"""
@classmethod
def from_crawler(
cls,
crawler: Crawler,
downstream_queue_cls: type[QueueProtocol],
key: str,
startprios: dict[str, Iterable[int]] | None = None,
*,
start_queue_cls: type[QueueProtocol] | None = None,
) -> Self:
return cls(
crawler,
downstream_queue_cls,
key,
startprios,
start_queue_cls=start_queue_cls,
)
def __init__(
self,
crawler: Crawler,
downstream_queue_cls: type[QueueProtocol],
key: str,
slot_startprios: dict[str, Iterable[int]] | None = None,
*,
start_queue_cls: type[QueueProtocol] | None = None,
):
if slot_startprios and not isinstance(slot_startprios, dict):
raise ValueError(
"ThrottlerAwarePriorityQueue accepts ``slot_startprios`` as a "
f"dict; {slot_startprios.__class__!r} instance is passed. Most "
"likely, it means the state is created by an incompatible "
"priority queue. Only a crawl started with the same priority "
"queue class can be resumed."
)
assert crawler.throttler is not None
self._throttler: ThrottlerProtocol = crawler.throttler
self.downstream_queue_cls: type[QueueProtocol] = downstream_queue_cls
self._start_queue_cls: type[QueueProtocol] | None = start_queue_cls
self.key: str = key
self.crawler: Crawler = crawler
self.pqueues: dict[frozenset[ScopeID], ScrapyPriorityQueue] = {}
if slot_startprios:
for set_key, startprios in slot_startprios.items():
scope_set = _scope_set_from_key(set_key)
self.pqueues[scope_set] = self._pqfactory(scope_set, startprios)
# Requests held back by their own per-request delay wait
# here instead of in their scope-set queue, so a not-yet-due request
# never sits at a queue head and blocks the other requests that share
# its scopes (the head-of-line blocking this scheduler exists to
# avoid). Once the delay elapses, _promote_ready() moves the request
# into its scope-set queue, where it competes normally.
#
# This is a min-heap of (deadline, seq, scope_set, request). heapq
# needs a total order and always compares entries to keep its invariant;
# seq (a monotonic counter) makes the (deadline, seq) prefix totally
# ordered, so a deadline tie is broken there and the scope_set (a
# frozenset, whose < is only the partial subset order) and the request
# (no ordering at all) are never compared. The order among tied
# deadlines is irrelevant beyond being deterministic.
self._delayed: list[tuple[float, int, frozenset[ScopeID], Request]] = []
self._delayed_seq: int = 0
def _pqfactory(
self, scope_set: frozenset[ScopeID], startprios: Iterable[int] = ()
) -> ScrapyPriorityQueue:
return ScrapyPriorityQueue(
self.crawler,
self.downstream_queue_cls,
self.key + "/" + _path_safe(_scope_set_key(scope_set)),
startprios,
start_queue_cls=self._start_queue_cls,
)
def push(self, request: Request, scope_set: frozenset[ScopeID]) -> None:
now = time.monotonic()
self._promote_ready(now)
delay = self._throttler.get_request_delay(request, now)
if delay > 0:
self._delayed_seq += 1
heapq.heappush(
self._delayed, (now + delay, self._delayed_seq, scope_set, request)
)
return
self._push_to_queue(request, scope_set)
def _push_to_queue(self, request: Request, scope_set: frozenset[ScopeID]) -> None:
if scope_set not in self.pqueues:
self.pqueues[scope_set] = self._pqfactory(scope_set)
self.pqueues[scope_set].push(request)
def _promote_ready(self, now: float) -> None:
"""Move every held-back request whose per-request delay has elapsed into
its scope-set queue, where it competes normally for its scopes."""
while self._delayed and self._delayed[0][0] <= now:
self._release_delayed(heapq.heappop(self._delayed))
def _release_delayed(
self, entry: tuple[float, int, frozenset[ScopeID], Request]
) -> None:
_, _, scope_set, request = entry
# The per-request delay has been honored (or the queue is closing), so
# mark it consumed: the request must not be delayed again, and on resume
# it must not re-block its scope set on a stale, no-longer-meaningful
# deadline.
request.meta["_throttler_delayed"] = True
try:
self._push_to_queue(request, scope_set)
except ValueError as e:
# A disk queue serializes on push; held-back requests defer that
# serialization until here, so a non-serializable one would
# otherwise raise while flushing on close and take the rest of the
# disk queue down with it. Drop it with a warning instead, matching
# how the scheduler handles unserializable requests at enqueue time.
logger.warning(
"Unable to serialize request: %(request)s - reason: %(reason)s",
{"request": request, "reason": e},
exc_info=True,
extra={"spider": getattr(self.crawler, "spider", None)},
)
if self.crawler.stats is not None:
self.crawler.stats.inc_value("scheduler/unserializable")
def _select(
self,
) -> tuple[frozenset[ScopeID], ScrapyPriorityQueue] | None:
"""Return the sendable ``(scope_set, queue)`` pair to pop from, or
``None`` if no queue can be popped from right now.
Among the sendable queues (those whose scope set can be sent right now),
the one whose head has the highest request priority is chosen; ties are
broken by ascending load (the maximum
:meth:`~scrapy.throttler.ThrottlerProtocol.get_scope_load` over the
scopes of the queue), i.e. by preferring the least-busy scopes.
"""
self._promote_ready(time.monotonic())
best_sort_key: tuple[int, float] | None = None
best: tuple[frozenset[ScopeID], ScrapyPriorityQueue] | None = None
for scope_set, queue in self.pqueues.items():
head = queue.peek()
if head is None or not self._throttler.is_ready(head):
continue
load = max(
(self._throttler.get_scope_load(scope_id) for scope_id in scope_set),
default=0.0,
)
sort_key = (queue.priority(head), load)
if best_sort_key is None or sort_key < best_sort_key:
best_sort_key = sort_key
best = (scope_set, queue)
return best
def pop(self) -> Request | None:
selected = self._select()
if selected is None:
return None
scope_set, queue = selected
request = queue.pop()
if request is not None:
self._throttler.reserve(request)
if len(queue) == 0:
del self.pqueues[scope_set]
return request
def get_next_request_delay(self) -> float | None:
now = time.monotonic()
self._promote_ready(now)
delay: float | None = None
for queue in self.pqueues.values():
head = queue.peek()
if head is None:
continue
if self._throttler.is_ready(head):
return 0.0
head_delay = self._throttler.get_time_until_ready(head)
if head_delay is None:
continue
if delay is None or head_delay < delay:
delay = head_delay
# A request held back only by its own delay is not in any
# scope-set queue, so factor in when the earliest one is due.
if self._delayed:
next_delayed = max(0.0, self._delayed[0][0] - now)
if delay is None or next_delayed < delay:
delay = next_delayed
return delay
def close(self) -> dict[str, list[int]]:
# Flush held-back requests into their scope-set queues so they are
# persisted (and restored on resume) rather than lost.
while self._delayed:
self._release_delayed(heapq.heappop(self._delayed))
active = {
_scope_set_key(scope_set): queue.close()
for scope_set, queue in self.pqueues.items()
}
self.pqueues.clear()
return active
def __len__(self) -> int:
queued = sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0
return queued + len(self._delayed)

View File

@ -67,6 +67,16 @@ class RobotParser(metaclass=ABCMeta):
:type user_agent: str or bytes
"""
def crawl_delay(self, user_agent: str | bytes) -> float | None:
"""Return the ``Crawl-delay`` directive for ``user_agent`` as a number
of seconds, or ``None`` if it is not set or the backend does not support
it.
:param user_agent: User agent
:type user_agent: str or bytes
"""
return None
class PythonRobotParser(RobotParser):
def __init__(self, robotstxt_body: bytes, spider: Spider | None):
@ -85,6 +95,10 @@ class PythonRobotParser(RobotParser):
url = to_unicode(url)
return self.rp.can_fetch(user_agent, url)
def crawl_delay(self, user_agent: str | bytes) -> float | None:
delay = self.rp.crawl_delay(to_unicode(user_agent))
return None if delay is None else float(delay)
class RerpRobotParser(RobotParser):
def __init__(self, robotstxt_body: bytes, spider: Spider | None):
@ -121,3 +135,7 @@ class ProtegoRobotParser(RobotParser):
user_agent = to_unicode(user_agent)
url = to_unicode(url)
return self.rp.can_fetch(url, user_agent)
def crawl_delay(self, user_agent: str | bytes) -> float | None:
delay = self.rp.crawl_delay(to_unicode(user_agent))
return None if delay is None else float(delay)

View File

@ -149,15 +149,6 @@ class BaseSettings(MutableMapping[str, Any]):
:param default: the value to return if no setting is found
:type default: object
"""
if name == "CONCURRENT_REQUESTS_PER_IP" and (
isinstance(self[name], int) and self[name] != 0
):
warnings.warn(
"The CONCURRENT_REQUESTS_PER_IP setting is deprecated, use CONCURRENT_REQUESTS_PER_DOMAIN instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
if name == "DNS_RESOLVER":
warnings.warn(
"The DNS_RESOLVER setting is deprecated, please use "

View File

@ -16,7 +16,6 @@ Scrapy developers, if you add a setting here remember to:
import sys
from importlib import import_module
from pathlib import Path
from typing import Any
__all__ = [
"ADDONS",
@ -33,6 +32,14 @@ __all__ = [
"AWS_SESSION_TOKEN",
"AWS_USE_SSL",
"AWS_VERIFY",
"BACKOFF_DELAY_FACTOR",
"BACKOFF_ENABLED",
"BACKOFF_EXCEPTIONS",
"BACKOFF_HTTP_CODES",
"BACKOFF_JITTER",
"BACKOFF_MAX_DELAY",
"BACKOFF_MIN_DELAY",
"BACKOFF_WINDOW",
"BOT_NAME",
"CLOSESPIDER_ERRORCOUNT",
"CLOSESPIDER_ITEMCOUNT",
@ -170,6 +177,7 @@ __all__ = [
"RANDOMIZE_DOWNLOAD_DELAY",
"REACTOR_THREADPOOL_MAXSIZE",
"REDIRECT_ENABLED",
"REDIRECT_MAX_DELAY",
"REDIRECT_MAX_TIMES",
"REDIRECT_PRIORITY_ADJUST",
"REFERER_ENABLED",
@ -209,6 +217,16 @@ __all__ = [
"TELNETCONSOLE_PORT",
"TELNETCONSOLE_USERNAME",
"TEMPLATES_DIR",
"THROTTLER",
"THROTTLER_DEBUG",
"THROTTLER_ROBOTSTXT_MAX_DELAY",
"THROTTLER_ROBOTSTXT_OBEY",
"THROTTLER_SCOPES",
"THROTTLER_SCOPE_CONCURRENCY",
"THROTTLER_SCOPE_LIMIT",
"THROTTLER_SCOPE_MANAGER",
"THROTTLER_SCOPE_MAX_IDLE",
"THROTTLER_WINDOW",
"TWISTED_DNS_RESOLVER",
"TWISTED_REACTOR",
"TWISTED_REACTOR_ENABLED",
@ -235,6 +253,19 @@ AWS_SESSION_TOKEN = None
AWS_USE_SSL = None
AWS_VERIFY = None
BACKOFF_DELAY_FACTOR = 2.0
BACKOFF_ENABLED = True
BACKOFF_EXCEPTIONS = [
"scrapy.exceptions.DownloadFailedError",
"scrapy.exceptions.DownloadTimeoutError",
"scrapy.exceptions.ResponseDataLossError",
]
BACKOFF_HTTP_CODES = [429, 502, 503, 504, 520, 521, 522, 523, 524]
BACKOFF_JITTER = 0.1
BACKOFF_MAX_DELAY = 300.0
BACKOFF_MIN_DELAY = 1.0
BACKOFF_WINDOW = 60.0
BOT_NAME = "scrapybot"
CLOSESPIDER_ERRORCOUNT = 0
@ -324,6 +355,7 @@ DOWNLOADER_MIDDLEWARES_BASE = {
"scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,
"scrapy.downloadermiddlewares.backoff.BackoffMiddleware": 650,
"scrapy.downloadermiddlewares.cookies.CookiesMiddleware": 700,
"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 750,
"scrapy.downloadermiddlewares.stats.DownloaderStats": 850,
@ -493,6 +525,7 @@ RANDOMIZE_DOWNLOAD_DELAY = True
REACTOR_THREADPOOL_MAXSIZE = 10
REDIRECT_ENABLED = True
REDIRECT_MAX_DELAY = 120.0
REDIRECT_MAX_TIMES = 20 # uses Firefox default setting
REDIRECT_PRIORITY_ADJUST = +2
@ -574,9 +607,21 @@ TELNETCONSOLE_PASSWORD = None
TEMPLATES_DIR = str((Path(__file__).parent / ".." / "templates").resolve())
THROTTLER = "scrapy.throttler.Throttler"
THROTTLER_SCOPE_MANAGER = "scrapy.throttler.ThrottlerScopeManager"
THROTTLER_SCOPES = {}
THROTTLER_WINDOW = 60.0
THROTTLER_ROBOTSTXT_OBEY = True
THROTTLER_ROBOTSTXT_MAX_DELAY = 60.0
THROTTLER_SCOPE_CONCURRENCY = 1
THROTTLER_SCOPE_LIMIT = 100000
THROTTLER_SCOPE_MAX_IDLE = 3600.0
THROTTLER_DEBUG = False
TWISTED_DNS_RESOLVER = "scrapy.resolver.CachingThreadedResolver"
TWISTED_REACTOR_ENABLED = True
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"
URLLENGTH_LIMIT = 2083
@ -584,19 +629,3 @@ URLLENGTH_LIMIT = 2083
USER_AGENT = f"Scrapy/{import_module('scrapy').__version__} (+https://scrapy.org)"
WARN_ON_GENERATOR_RETURN_VALUE = True
def __getattr__(name: str) -> Any:
if name == "CONCURRENT_REQUESTS_PER_IP":
import warnings # noqa: PLC0415
from scrapy.exceptions import ScrapyDeprecationWarning # noqa: PLC0415
warnings.warn(
"The scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_IP attribute is deprecated, use scrapy.settings.default_settings.CONCURRENT_REQUESTS_PER_DOMAIN instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return 0
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -21,6 +21,7 @@ response_received = object()
response_downloaded = object()
headers_received = object()
bytes_received = object()
robots_parsed = object()
item_scraped = object()
item_dropped = object()
item_error = object()

View File

@ -21,9 +21,8 @@ ADDONS = {}
# Obey robots.txt rules
ROBOTSTXT_OBEY = True
# Concurrency and throttling settings
#CONCURRENT_REQUESTS = 16
CONCURRENT_REQUESTS_PER_DOMAIN = 1
THROTTLER_SCOPE_CONCURRENCY = 1
DOWNLOAD_DELAY = 1
# Disable cookies (enabled by default)
@ -62,19 +61,6 @@ DOWNLOAD_DELAY = 1
# "$project_name.pipelines.${ProjectName}Pipeline": 300,
#}
# Enable and configure the AutoThrottle extension (disabled by default)
# See https://docs.scrapy.org/en/latest/topics/autothrottle.html
#AUTOTHROTTLE_ENABLED = True
# The initial download delay
#AUTOTHROTTLE_START_DELAY = 5
# The maximum download delay to be set in case of high latencies
#AUTOTHROTTLE_MAX_DELAY = 60
# The average number of requests Scrapy should be sending in parallel to
# each remote server
#AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
# Enable showing throttling stats for every response received:
#AUTOTHROTTLE_DEBUG = False
# Enable and configure HTTP caching (disabled by default)
# See https://docs.scrapy.org/en/latest/topics/downloader-middleware.html#httpcache-middleware-settings
#HTTPCACHE_ENABLED = True

1408
scrapy/throttler.py Normal file

File diff suppressed because it is too large Load Diff

49
scrapy/utils/_headers.py Normal file
View File

@ -0,0 +1,49 @@
from __future__ import annotations
import datetime as dt
from email.utils import parsedate_to_datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from scrapy.http import Response
def _decoded_header(response: Response, name: str) -> str | None:
"""Return the stripped UTF-8 value of the *name* header of *response*, or
``None`` if it is absent or not valid UTF-8."""
raw = response.headers.get(name)
if not raw:
return None
try:
return raw.decode("utf-8").strip()
except UnicodeDecodeError:
return None
def _parse_retry_after(response: Response) -> float | None:
value = _decoded_header(response, "Retry-After")
if value is None:
return None
if value.isdigit():
return float(value) # seconds
try:
date = parsedate_to_datetime(value)
except (TypeError, ValueError, OverflowError):
return None
if date.tzinfo is None:
date = date.replace(tzinfo=dt.timezone.utc)
now = dt.datetime.now(dt.timezone.utc)
seconds_to_wait = (date - now).total_seconds()
# Keep sub-second precision (a date less than a second away must not be
# truncated to 0 and dropped); a past or present date yields no delay.
return max(0.0, seconds_to_wait) or None
def _parse_ratelimit_reset(response: Response) -> float | None:
value = _decoded_header(response, "RateLimit-Reset")
if value is None:
return None
try:
return float(value)
except ValueError:
return None

View File

@ -5,11 +5,11 @@ from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable, Sequence
from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar
from twisted.internet.defer import Deferred
from twisted.internet.task import LoopingCall
from twisted.internet.task import LoopingCall, deferLater
from twisted.internet.threads import deferToThread
from scrapy.utils.asyncgen import as_async_generator
@ -311,3 +311,106 @@ async def run_in_thread(
from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415
return await maybe_deferred_to_future(deferToThread(func, *args, **kwargs))
async def sleep(seconds: float) -> None:
"""Sleep for *seconds*, working in asyncio-reactor, non-asyncio-reactor,
and reactorless modes.
Uses :func:`asyncio.sleep` when asyncio is available, and
:func:`~twisted.internet.task.deferLater` otherwise.
.. versionadded:: VERSION
"""
if is_asyncio_available():
await asyncio.sleep(seconds)
else:
from twisted.internet import reactor
# circular import
from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415
await maybe_deferred_to_future(deferLater(reactor, seconds, lambda: None))
async def wait_for_first(
deferreds: Sequence[Deferred[Any]],
*,
timeout: float | None = None,
) -> tuple[set[Deferred[Any]], set[Deferred[Any]]]:
"""Wait for the first of *deferreds* to fire, or until *timeout* seconds pass.
Returns ``(done, pending)`` two sets partitioning the input
:class:`~twisted.internet.defer.Deferred` objects mirroring the API of
:func:`asyncio.wait`.
Unfired deferreds in the ``pending`` set are neither cancelled nor
otherwise modified; the caller is responsible for any cleanup.
A deferred that fails counts as done and its failure is **not** re-raised
here (it stays on the deferred for the caller to inspect or handle), exactly
as a failed awaitable lands in the ``done`` set of :func:`asyncio.wait`.
Returns ``(set(), set())`` immediately when *deferreds* is empty.
Works transparently in asyncio-reactor, non-asyncio-reactor, and
reactorless modes.
.. versionadded:: VERSION
"""
if not deferreds:
return set(), set()
if is_asyncio_available():
# circular import
from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415
future_to_deferred = {maybe_deferred_to_future(d): d for d in deferreds}
done_futures, pending_futures = await asyncio.wait(
list(future_to_deferred),
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
return (
{future_to_deferred[f] for f in done_futures},
{future_to_deferred[f] for f in pending_futures},
)
from twisted.internet import reactor
# circular import
from scrapy.utils.defer import maybe_deferred_to_future # noqa: PLC0415
# Fire a single signal Deferred as soon as any input Deferred (or the
# timeout) completes, whether it succeeds or fails. Unlike a DeferredList
# with fireOnOneErrback (which would raise the first failure into this
# coroutine), _fire passes each result through untouched, so a failure just
# marks its Deferred as done and stays there for the caller — matching the
# asyncio branch and asyncio.wait(return_when=FIRST_COMPLETED).
signal: Deferred[None] = Deferred()
def _fire(result: Any) -> Any:
if not signal.called:
signal.callback(None)
return result
for d in deferreds:
d.addBoth(_fire)
timeout_deferred: Deferred[Any] | None = None
if timeout is not None:
timeout_deferred = deferLater(reactor, timeout, lambda: None)
timeout_deferred.addBoth(_fire)
# Swallow the CancelledError from the finally-block cancel() below.
timeout_deferred.addErrback(lambda _: None)
try:
await maybe_deferred_to_future(signal)
finally:
if timeout_deferred is not None and not timeout_deferred.called:
timeout_deferred.cancel()
return (
{d for d in deferreds if d.called},
{d for d in deferreds if not d.called},
)

View File

@ -90,6 +90,11 @@ def load_object(path: str | Callable[..., Any]) -> Any:
return obj
def _load_objects(objects: Iterable[str | Callable[..., Any]]) -> tuple[Any, ...]:
"""Resolve *objects* (objects or import paths) to a tuple of objects."""
return tuple(load_object(obj) if isinstance(obj, str) else obj for obj in objects)
def walk_modules_iter(path: str) -> Iterable[ModuleType]:
"""Loads a module and all its submodules from the given module path and
returns them. If *any* module throws an exception while importing, that

View File

@ -15,6 +15,7 @@ from twisted.web.client import Agent
from scrapy.crawler import AsyncCrawlerRunner, CrawlerRunner, CrawlerRunnerBase
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings import default_settings
from scrapy.utils.reactor import is_asyncio_reactor_installed, is_reactor_installed
from scrapy.utils.spider import DefaultSpider
@ -66,13 +67,19 @@ def get_crawler(
will be used to populate the crawler settings with a project level
priority.
"""
# When needed, useful settings can be added here, e.g. ones that prevent
# deprecation warnings.
settings: dict[str, Any] = {
"TELNETCONSOLE_ENABLED": False,
**get_reactor_settings(),
**(settings_dict or {}),
}
if prevent_warnings:
# Pin the per-scope concurrency to CONCURRENT_REQUESTS_PER_DOMAIN's
# default: keeps the suite's historical behavior and silences the
# warn-then-flip warning (see throttler._warn_on_deprecated_concurrency).
settings.setdefault(
"THROTTLER_SCOPE_CONCURRENCY",
default_settings.CONCURRENT_REQUESTS_PER_DOMAIN,
)
runner: CrawlerRunnerBase
if is_reactor_installed():
runner = CrawlerRunner(settings)

View File

@ -14,7 +14,7 @@ from twisted.web import server, static
from twisted.web.client import Agent, BrowserLikePolicyForHTTPS, readBody
from twisted.web.client import Response as TxResponse
from scrapy.core.downloader import Downloader, Slot, tls
from scrapy.core.downloader import Downloader, Slot, _Slot, tls
from scrapy.core.downloader.contextfactory import (
_load_context_factory_from_settings,
_ScrapyClientContextFactory,
@ -41,8 +41,20 @@ if TYPE_CHECKING:
class TestSlot:
def test_repr(self):
slot = Slot(concurrency=8, delay=0.1, randomize_delay=True)
assert repr(slot) == "Slot(concurrency=8, delay=0.1, randomize_delay=True)"
slot = _Slot()
assert repr(slot) == "_Slot()"
def test_deprecated(self):
with pytest.warns(ScrapyDeprecationWarning, match="Slot class is deprecated"):
Slot()
def test_deprecated_subclass(self):
with pytest.warns(
ScrapyDeprecationWarning, match="inherits from the deprecated Slot"
):
class MySlot(Slot):
pass
@pytest.mark.requires_reactor # this test is related to the Twisted HTTP code

View File

@ -5,6 +5,7 @@ import logging
import re
import signal
import threading
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
from unittest.mock import MagicMock
@ -52,6 +53,77 @@ def get_raw_crawler(
return Crawler(spidercls or DefaultSpider, settings)
class TestScopeConcurrencyBridge:
"""CONCURRENT_REQUESTS_PER_DOMAIN is deprecated and bridged into
THROTTLER_SCOPE_CONCURRENCY by setting priority (see
scrapy.throttler._default_scope_concurrency)."""
@staticmethod
def _resolve(settings: Settings) -> int:
from scrapy.throttler import _default_scope_concurrency # noqa: PLC0415
return _default_scope_concurrency(settings)
def test_neither_set_preserves_per_domain_default(self) -> None:
# Neither setting overridden: keep the historical per-domain default.
assert self._resolve(Settings()) == 8
def test_per_domain_overrides(self) -> None:
settings = Settings()
settings.set("CONCURRENT_REQUESTS_PER_DOMAIN", 4, priority="project")
assert self._resolve(settings) == 4
def test_scope_concurrency_overrides(self) -> None:
settings = Settings()
settings.set("THROTTLER_SCOPE_CONCURRENCY", 4, priority="project")
assert self._resolve(settings) == 4
def test_new_setting_wins_on_non_default_tie(self) -> None:
settings = Settings()
settings.set("CONCURRENT_REQUESTS_PER_DOMAIN", 4, priority="project")
settings.set("THROTTLER_SCOPE_CONCURRENCY", 9, priority="project")
assert self._resolve(settings) == 9
def test_higher_priority_deprecated_setting_wins(self) -> None:
settings = Settings()
settings.set("THROTTLER_SCOPE_CONCURRENCY", 9, priority="project")
settings.set("CONCURRENT_REQUESTS_PER_DOMAIN", 4, priority="cmdline")
assert self._resolve(settings) == 4
def test_warns_flip_when_neither_set(self) -> None:
with pytest.warns(
ScrapyDeprecationWarning,
match="Once CONCURRENT_REQUESTS_PER_DOMAIN is removed",
):
get_crawler(prevent_warnings=False)
def test_warns_deprecated_when_per_domain_set(self) -> None:
with pytest.warns(
ScrapyDeprecationWarning,
match="CONCURRENT_REQUESTS_PER_DOMAIN setting is deprecated",
):
get_crawler(
settings_dict={"CONCURRENT_REQUESTS_PER_DOMAIN": 4},
prevent_warnings=False,
)
def test_no_concurrency_warning_when_scope_set(self) -> None:
with warnings.catch_warnings(record=True) as recorded:
warnings.simplefilter("always")
get_crawler(
settings_dict={"THROTTLER_SCOPE_CONCURRENCY": 4},
prevent_warnings=False,
)
messages = [str(w.message) for w in recorded]
assert not any(
"Once CONCURRENT_REQUESTS_PER_DOMAIN is removed" in m for m in messages
)
assert not any(
"CONCURRENT_REQUESTS_PER_DOMAIN setting is deprecated" in m
for m in messages
)
class TestBaseCrawler:
@staticmethod
def assertOptionIsDefault(settings: Settings, key: str) -> None:
@ -87,6 +159,30 @@ class TestCrawler(TestBaseCrawler):
assert not settings.frozen
assert crawler.settings.frozen
def test_spider_download_delay_deprecated(self) -> None:
class DelaySpider(DefaultSpider):
download_delay = 2.5
crawler = Crawler(DelaySpider)
with pytest.warns(
ScrapyDeprecationWarning, match="'download_delay' spider attribute"
):
crawler._apply_settings()
assert crawler.settings.getfloat("DOWNLOAD_DELAY") == 2.5
def test_spider_download_delay_overridden_by_setting(self) -> None:
class DelaySpider(DefaultSpider):
download_delay = 2.5
crawler = Crawler(DelaySpider)
crawler.settings.set("DOWNLOAD_DELAY", 5.0, priority="spider")
with pytest.warns(
ScrapyDeprecationWarning,
match="'download_delay' spider attribute.*being ignored",
):
crawler._apply_settings()
assert crawler.settings.getfloat("DOWNLOAD_DELAY") == 5.0
def test_crawler_accepts_dict(self) -> None:
crawler = get_crawler(DefaultSpider, {"foo": "bar"})
assert crawler.settings["foo"] == "bar"

View File

@ -8,6 +8,7 @@ import pytest
from twisted.internet.defer import Deferred, DeferredList
from twisted.python import failure
from scrapy import signals
from scrapy.downloadermiddlewares.robotstxt import RobotsTxtMiddleware
from scrapy.exceptions import CannotResolveHostError, IgnoreRequest, NotConfigured
from scrapy.http import Request, Response, TextResponse
@ -27,6 +28,7 @@ class TestRobotsTxtMiddleware:
self.crawler: mock.MagicMock = mock.MagicMock()
self.crawler.settings = Settings()
self.crawler.engine.download_async = mock.AsyncMock()
self.crawler.signals.send_catch_log_async = mock.AsyncMock(return_value=[])
def teardown_method(self):
del self.crawler
@ -74,6 +76,22 @@ Disallow: /some/randome/page.html
Request("http://site.local/wiki/Käyttäjä:"), middleware
)
@coroutine_test
async def test_robotstxt_emits_robots_parsed_signal(self):
crawler = self._get_successful_crawler()
middleware = RobotsTxtMiddleware(crawler)
request = Request("http://site.local/allowed")
await self.assertNotIgnored(request, middleware)
calls = [
kwargs
for _, kwargs in crawler.signals.send_catch_log_async.call_args_list
if kwargs.get("signal") is signals.robots_parsed
]
assert len(calls) == 1
assert calls[0]["request"] is request
assert calls[0]["robotparser"] is not None
assert "crawler" not in calls[0]
@coroutine_test
async def test_robotstxt_multiple_reqs(self) -> None:
middleware = RobotsTxtMiddleware(self._get_successful_crawler())

View File

@ -1,39 +1,32 @@
import time
from typing import Any
from urllib.parse import urlparse
import pytest
from scrapy import Request
from scrapy.core.downloader import Downloader, Slot
from scrapy.core.downloader import Downloader
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.spider import DefaultSpider
from scrapy.utils.test import get_crawler
from tests.mockserver.http import MockServer
from tests.spiders import MetaSpider
from tests.utils.decorators import coroutine_test, inline_callbacks_test
from tests.utils.decorators import coroutine_test
class DownloaderSlotsSettingsTestSpider(MetaSpider):
name = "downloader_slots"
custom_settings = {
"DOWNLOAD_DELAY": 1,
"RANDOMIZE_DOWNLOAD_DELAY": False,
"DOWNLOAD_SLOTS": {
"quotes.toscrape.com": {
"concurrency": 1,
"delay": 2,
"randomize_delay": False,
"throttle": False,
},
"books.toscrape.com": {"delay": 3, "randomize_delay": False},
"quotes.toscrape.com": {"concurrency": 1},
"books.toscrape.com": {"concurrency": 2},
},
}
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
assert self.mockserver
self.default_slot = self.mockserver.host
self.default_slot = urlparse(self.mockserver.url("/")).netloc
self.times: dict[str, list[float]] = {}
async def start(self):
@ -45,65 +38,84 @@ class DownloaderSlotsSettingsTestSpider(MetaSpider):
def parse(self, response):
slot = response.meta.get("download_slot", self.default_slot)
self.times[slot].append(time.time())
self.times[slot].append(response.meta.get("download_latency"))
url = self.mockserver.url(f"/?downloader_slot={slot}&req=2")
yield Request(url, callback=self.not_parse, meta={"download_slot": slot})
def not_parse(self, response):
slot = response.meta.get("download_slot", self.default_slot)
self.times[slot].append(time.time())
class TestCrawl:
@classmethod
def setup_class(cls):
cls.mockserver = MockServer()
cls.mockserver.__enter__()
@classmethod
def teardown_class(cls):
cls.mockserver.__exit__(None, None, None)
@inline_callbacks_test
def test_delay(self):
crawler = get_crawler(DownloaderSlotsSettingsTestSpider)
yield crawler.crawl(mockserver=self.mockserver)
slots = crawler.engine.downloader.slots
times = crawler.spider.times
tolerance = 0.3
delays_real = {k: v[1] - v[0] for k, v in times.items()}
error_delta = {
k: 1 - min(delays_real[k], v.delay) / max(delays_real[k], v.delay)
for k, v in slots.items()
}
assert max(list(error_delta.values())) < tolerance
self.times[slot].append(response.meta.get("download_latency"))
@coroutine_test
async def test_params():
params = {
"concurrency": 1,
"delay": 2,
"randomize_delay": False,
}
settings = {
"DOWNLOAD_SLOTS": {
"example.com": params,
},
}
async def test_concurrency_key_deprecated():
settings = {"DOWNLOAD_SLOTS": {"example.com": {"concurrency": 3}}}
crawler = get_crawler(DefaultSpider, settings_dict=settings)
crawler.spider = crawler._create_spider()
with pytest.warns(ScrapyDeprecationWarning) as warns:
downloader = Downloader(crawler)
messages = [str(w.message) for w in warns]
assert any("DOWNLOAD_SLOTS setting is deprecated" in m for m in messages)
downloader._get_slot(Request("https://example.com"))
downloader.close()
@coroutine_test
async def test_download_slots_deprecated():
settings = {"DOWNLOAD_SLOTS": {"example.com": {"concurrency": 2}}}
crawler = get_crawler(DefaultSpider, settings_dict=settings)
crawler.spider = crawler._create_spider()
with pytest.warns(
ScrapyDeprecationWarning, match="DOWNLOAD_SLOTS setting is deprecated"
):
Downloader(crawler).close()
@coroutine_test
async def test_slots_deprecated():
crawler = get_crawler(DefaultSpider)
crawler.spider = crawler._create_spider()
downloader = Downloader(crawler)
request = Request("https://example.com")
_, actual = downloader._get_slot(request)
request.meta[Downloader.DOWNLOAD_SLOT] = "example.com"
downloader.active.add(request)
with pytest.warns(ScrapyDeprecationWarning, match="Downloader.slots is deprecated"):
slot = downloader.slots.get("example.com")
assert slot is not None
assert isinstance(slot.active, set)
assert request in slot.active
downloader.active.discard(request)
downloader.close()
@coroutine_test
async def test_download_slot_meta_deprecated():
crawler = get_crawler(DefaultSpider)
crawler.spider = crawler._create_spider()
downloader = Downloader(crawler)
request = Request("https://example.com")
request.meta["download_slot"] = "custom"
with pytest.warns(
ScrapyDeprecationWarning, match="'download_slot' request meta key is deprecated"
):
key, _ = downloader._get_slot(request)
downloader.close()
assert key == "custom"
@coroutine_test
async def test_delay_deprecated():
settings = {
"DOWNLOAD_SLOTS": {"example.com": {"delay": 2, "randomize_delay": False}}
}
crawler = get_crawler(DefaultSpider, settings_dict=settings)
crawler.spider = crawler._create_spider()
with pytest.warns(ScrapyDeprecationWarning) as warns:
downloader = Downloader(crawler)
messages = [str(w.message) for w in warns]
assert any("DOWNLOAD_SLOTS setting is deprecated" in m for m in messages)
downloader._get_slot(Request("https://example.com"))
downloader.close()
expected = Slot(**params)
for param in params:
assert getattr(expected, param) == getattr(actual, param), (
f"Slot.{param}: {getattr(expected, param)!r} != {getattr(actual, param)!r}"
)
@coroutine_test
@ -122,7 +134,7 @@ async def test_get_slot_deprecated_spider_arg():
downloader.close()
assert key1 == key2
assert slot1 == slot2
assert slot1._key == slot2._key
@pytest.mark.parametrize(
@ -132,6 +144,7 @@ async def test_get_slot_deprecated_spider_arg():
"scrapy.pqueues.DownloaderAwarePriorityQueue",
],
)
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
@coroutine_test
async def test_none_slot_with_priority_queue(
mockserver: MockServer, priority_queue_class: str

View File

@ -595,6 +595,21 @@ class TestEngineDownloadAsync:
engine._slot.add_request.assert_called_once_with(request)
engine._slot.remove_request.assert_called_once_with(request)
@coroutine_test
async def test_download_async_fetch_needs_spider(self, engine):
"""A downloader whose fetch() requires a spider gets it passed in."""
engine._downloader_fetch_needs_spider = True
request = Request("http://example.com")
response = Response("http://example.com", body=b"test body")
engine.spider = Mock()
engine.downloader.fetch.return_value = defer.succeed(response)
engine._slot.add_request = Mock()
engine._slot.remove_request = Mock()
result = await self._download(engine, request)
assert result == response
engine.downloader.fetch.assert_called_once_with(request, engine.spider)
@pytest.mark.filterwarnings("ignore::scrapy.exceptions.ScrapyDeprecationWarning")
class TestEngineDownload(TestEngineDownloadAsync):
@ -640,6 +655,154 @@ async def test_request_scheduled_signal():
crawler.signals.disconnect(signal_handler, signals.request_scheduled)
class TestEngineThrottler:
@pytest.fixture
def engine(self):
crawler = get_crawler(MySpider)
engine = ExecutionEngine(crawler, lambda _: None)
yield engine
engine.downloader.close()
def test_pause_cancels_delay_wakeup(self, engine):
wakeup = Mock()
engine._delay_wakeup = wakeup
engine.pause()
assert engine.paused is True
wakeup.cancel.assert_called_once_with()
assert engine._delay_wakeup is None
engine.unpause()
assert engine.paused is False
@pytest.mark.requires_reactor # call_later() needs a reactor or asyncio loop
def test_maybe_arm_delay_wakeup_arms_timer(self, engine):
engine._get_next_request_delay = lambda: 5.0
engine._slot = Mock()
engine._slot.scheduler.has_pending_requests.return_value = True
engine._maybe_arm_delay_wakeup()
assert engine._delay_wakeup is not None
# Cancel the scheduled reactor call so it does not leak into other tests.
engine._cancel_delay_wakeup()
def test_maybe_arm_delay_wakeup_no_delay(self, engine):
engine._get_next_request_delay = lambda: None
engine._slot = Mock()
engine._slot.scheduler.has_pending_requests.return_value = True
engine._maybe_arm_delay_wakeup()
assert engine._delay_wakeup is None
def test_maybe_arm_delay_wakeup_zero_delay(self, engine):
# A 0 delay means a request is ready but could not be sent (e.g. the
# downloader is at capacity); arming a 0-second timer would busy-loop
# the engine, so no timer must be armed.
engine._get_next_request_delay = lambda: 0.0
engine._slot = Mock()
engine._slot.scheduler.has_pending_requests.return_value = True
engine._maybe_arm_delay_wakeup()
assert engine._delay_wakeup is None
def test_maybe_arm_delay_wakeup_not_supported(self, engine):
# A scheduler without get_next_request_delay never arms a timer.
engine._get_next_request_delay = None
engine._slot = Mock()
engine._slot.scheduler.has_pending_requests.return_value = True
engine._maybe_arm_delay_wakeup()
assert engine._delay_wakeup is None
def test_maybe_warn_throttler_backout(self, engine):
# A scheduler without get_next_request_delay is not throttler-aware, so
# the warning recommends switching to one.
engine._get_next_request_delay = None
with LogCapture() as log:
engine._maybe_warn_throttler_backout()
# A second call is a no-op (the warning is emitted only once).
engine._maybe_warn_throttler_backout()
assert engine._throttler_backout_warned is True
assert str(log).count("ThrottlerAwareScheduler") == 1
def test_maybe_warn_throttler_backout_throttler_aware(self, engine):
# A throttler-aware scheduler (one with get_next_request_delay) holds
# throttled requests itself, so no warning is emitted.
engine._get_next_request_delay = lambda: None
with LogCapture() as log:
engine._maybe_warn_throttler_backout()
assert engine._throttler_backout_warned is False
assert "ThrottlerAwareScheduler" not in str(log)
def test_spider_is_idle_false_while_scheduling(self, engine):
engine._slot = Mock()
engine.scraper.slot = Mock()
engine.scraper.slot.is_idle.return_value = True
engine.downloader = Mock()
engine.downloader.active = []
engine._throttler_waiting = set()
engine._start = None
engine._scheduling = 1
# An in-flight async enqueue keeps the spider from being considered idle.
assert engine.spider_is_idle() is False
@coroutine_test
async def test_enqueue_request_async_dropped(self, engine):
scheduler = Mock()
async def enqueue_request_async(request):
return False
scheduler.enqueue_request_async = enqueue_request_async
engine._slot = Mock()
engine._slot.scheduler = scheduler
engine.spider = Mock()
dropped = []
def on_dropped(request, spider):
dropped.append(request)
engine.signals.connect(on_dropped, signals.request_dropped, weak=False)
engine._scheduling = 1
request = Request("http://a.example")
await engine._enqueue_request_async(request)
assert dropped == [request]
assert engine._scheduling == 0
engine._slot.nextcall.schedule.assert_called_once_with()
@coroutine_test
async def test_enqueue_request_async_error(self, engine):
scheduler = Mock()
async def enqueue_request_async(request):
raise RuntimeError("boom")
scheduler.enqueue_request_async = enqueue_request_async
engine._slot = Mock()
engine._slot.scheduler = scheduler
engine.spider = Mock()
engine._scheduling = 1
with LogCapture() as log:
await engine._enqueue_request_async(Request("http://a.example"))
assert "Error while enqueuing request" in str(log)
assert engine._scheduling == 0
engine._slot.nextcall.schedule.assert_called_once_with()
@coroutine_test
async def test_enqueue_request_async_slot_gone(self, engine):
scheduler = Mock()
async def enqueue_request_async(request):
# The spider is closed while the enqueue is in flight.
engine._slot = None
return True
scheduler.enqueue_request_async = enqueue_request_async
slot = Mock()
slot.scheduler = scheduler
engine._slot = slot
engine.spider = Mock()
engine._scheduling = 1
await engine._enqueue_request_async(Request("http://a.example"))
assert engine._scheduling == 0
# No reschedule is attempted once the slot is gone.
slot.nextcall.schedule.assert_not_called()
class TestEngineCloseSpider:
"""Tests for exception handling coverage during close_spider_async()."""
@ -688,7 +851,7 @@ class TestEngineCloseSpider:
engine = ExecutionEngine(crawler, lambda _: None)
crawler.engine = engine
await engine.open_spider_async()
del engine.downloader.slots
engine.downloader.close = Mock(side_effect=Exception("close failed"))
await engine.close_spider_async()
assert "Downloader close failure" in caplog.text

View File

@ -4,14 +4,19 @@ from unittest.mock import Mock
import pytest
import queuelib
from scrapy.core.downloader import Downloader
from scrapy.http.request import Request
from scrapy.pqueues import DownloaderAwarePriorityQueue, ScrapyPriorityQueue
from scrapy.pqueues import (
DownloaderAwarePriorityQueue,
ScrapyPriorityQueue,
ThrottlerAwarePriorityQueue,
)
from scrapy.spiders import Spider
from scrapy.squeues import FifoMemoryQueue, PickleFifoDiskQueue
from scrapy.throttler import iter_scopes
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.test import get_crawler
from tests.test_scheduler import MockDownloader
from tests.utils.decorators import coroutine_test
class TestPriorityQueue:
@ -186,22 +191,22 @@ class TestDownloaderAwarePriorityQueue:
# No active downloads are tracked in the downloader, so every slot has
# the same score and tie-breaking must not starve a slot.
req_a1 = Request("https://example.org/a1")
req_a1.meta[Downloader.DOWNLOAD_SLOT] = "slot-a"
req_a1.meta["throttler_scopes"] = "slot-a"
req_b1 = Request("https://example.org/b1")
req_b1.meta[Downloader.DOWNLOAD_SLOT] = "slot-b"
req_b1.meta["throttler_scopes"] = "slot-b"
req_a2 = Request("https://example.org/a2")
req_a2.meta[Downloader.DOWNLOAD_SLOT] = "slot-a"
req_a2.meta["throttler_scopes"] = "slot-a"
req_b2 = Request("https://example.org/b2")
req_b2.meta[Downloader.DOWNLOAD_SLOT] = "slot-b"
req_b2.meta["throttler_scopes"] = "slot-b"
for request in (req_a1, req_b1, req_a2, req_b2):
self.queue.push(request)
slots = [
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta["throttler_scopes"],
self.queue.pop().meta["throttler_scopes"],
self.queue.pop().meta["throttler_scopes"],
self.queue.pop().meta["throttler_scopes"],
]
assert slots == ["slot-a", "slot-b", "slot-a", "slot-b"]
@ -210,48 +215,49 @@ class TestDownloaderAwarePriorityQueue:
# If the selected slot becomes empty, rotation should continue from
# that slot marker to avoid restarting from the smallest slot.
req_a1 = Request("https://example.org/a1")
req_a1.meta[Downloader.DOWNLOAD_SLOT] = "slot-a"
req_a1.meta["throttler_scopes"] = "slot-a"
req_a2 = Request("https://example.org/a2")
req_a2.meta[Downloader.DOWNLOAD_SLOT] = "slot-a"
req_a2.meta["throttler_scopes"] = "slot-a"
req_b1 = Request("https://example.org/b1")
req_b1.meta[Downloader.DOWNLOAD_SLOT] = "slot-b"
req_b1.meta["throttler_scopes"] = "slot-b"
req_c1 = Request("https://example.org/c1")
req_c1.meta[Downloader.DOWNLOAD_SLOT] = "slot-c"
req_c1.meta["throttler_scopes"] = "slot-c"
for request in (req_a1, req_a2, req_b1, req_c1):
self.queue.push(request)
slots = [
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta[Downloader.DOWNLOAD_SLOT],
self.queue.pop().meta["throttler_scopes"],
self.queue.pop().meta["throttler_scopes"],
self.queue.pop().meta["throttler_scopes"],
self.queue.pop().meta["throttler_scopes"],
]
assert slots == ["slot-a", "slot-b", "slot-c", "slot-a"]
def test_pop_prefers_slot_with_fewer_active_downloads(self):
downloader = self.queue._downloader_interface.downloader
throttler = self.queue._downloader_interface._throttler
assert throttler is not None
req_a = Request("https://example.org/a")
req_a.meta[Downloader.DOWNLOAD_SLOT] = "slot-a"
req_a.meta["throttler_scopes"] = "slot-a"
req_b = Request("https://example.org/b")
req_b.meta[Downloader.DOWNLOAD_SLOT] = "slot-b"
req_b.meta["throttler_scopes"] = "slot-b"
req_c = Request("https://example.org/c")
req_c.meta[Downloader.DOWNLOAD_SLOT] = "slot-c"
req_c.meta["throttler_scopes"] = "slot-c"
for req in (req_a, req_b, req_c):
self.queue.push(req)
downloader.increment("slot-a")
downloader.increment("slot-c")
throttler._get_scope_manager("slot-a")._active = 1
throttler._get_scope_manager("slot-c")._active = 1
popped = self.queue.pop()
assert popped.url == req_b.url
def test_contains(self):
req = Request("https://example.org/")
req.meta[Downloader.DOWNLOAD_SLOT] = "example-slot"
req.meta["throttler_scopes"] = "example-slot"
assert "example-slot" not in self.queue
self.queue.push(req)
assert "example-slot" in self.queue
@ -309,3 +315,263 @@ def test_pop_order(input_, output):
actual_output_urls.append(request.url)
assert actual_output_urls == expected_output_urls
class TestThrottlerAwarePriorityQueue:
def _queue(self, crawler, key=""):
return build_from_crawler(
ThrottlerAwarePriorityQueue,
crawler,
downstream_queue_cls=FifoMemoryQueue,
key=key,
)
async def _push(self, queue, crawler, request):
scope_set = frozenset(iter_scopes(await crawler.throttler.get_scopes(request)))
queue.push(request, scope_set)
@coroutine_test
async def test_partitions_by_scope_set(self):
crawler = get_crawler(Spider)
queue = self._queue(crawler)
await self._push(queue, crawler, Request("http://a.com/1"))
await self._push(queue, crawler, Request("http://a.com/2"))
await self._push(queue, crawler, Request("http://b.com/1"))
# Two distinct scope sets -> two internal queues, three requests.
assert len(queue.pqueues) == 2
assert len(queue) == 3
@coroutine_test
async def test_pop_skips_blocked_scope(self):
crawler = get_crawler(
Spider,
settings_dict={
"THROTTLER_SCOPES": {"slow.com": {"delay": 1000.0}},
"RANDOMIZE_DOWNLOAD_DELAY": False,
},
)
queue = self._queue(crawler)
await self._push(queue, crawler, Request("http://slow.com/1"))
await self._push(queue, crawler, Request("http://slow.com/2"))
await self._push(queue, crawler, Request("http://fast.com/1"))
# The first slow request is sendable (no delay accrued yet); after it is
# popped (and reserved), the second slow request is blocked, but the
# fast one is still served.
popped = [queue.pop(), queue.pop(), queue.pop()]
urls = [r.url if r else None for r in popped]
assert "http://fast.com/1" in urls
assert "http://slow.com/1" in urls
# The blocked second slow request stays in the queue.
assert None in urls
assert len(queue) == 1
delay = queue.get_next_request_delay()
assert delay is not None
assert delay == pytest.approx(1000.0, abs=1.0)
@coroutine_test
async def test_pop_holds_request_with_delay(self):
crawler = get_crawler(Spider, settings_dict={"RANDOMIZE_DOWNLOAD_DELAY": False})
queue = self._queue(crawler)
await self._push(
queue,
crawler,
Request("http://slow.com/1", meta={"delay": 1000.0}),
)
await self._push(queue, crawler, Request("http://fast.com/1"))
# The delayed request is held back even though its scope is otherwise
# unconstrained; the request without a delay is served.
popped = [queue.pop(), queue.pop()]
urls = [r.url if r else None for r in popped]
assert "http://fast.com/1" in urls
assert None in urls
assert len(queue) == 1
delay = queue.get_next_request_delay()
assert delay is not None
assert delay == pytest.approx(1000.0, abs=1.0)
@coroutine_test
async def test_delayed_request_does_not_block_scope_set(self):
crawler = get_crawler(Spider, settings_dict={"RANDOMIZE_DOWNLOAD_DELAY": False})
queue = self._queue(crawler)
# Both requests share the same (example.com) scope set; only the first
# carries a per-request delay.
await self._push(
queue,
crawler,
Request("http://example.com/slow", meta={"delay": 1000.0}),
)
await self._push(queue, crawler, Request("http://example.com/fast"))
# The delayed request is held aside, so the other request in the same
# scope set is served right away instead of being stuck behind it.
assert queue.pop().url == "http://example.com/fast"
# The delayed request is not lost, just not poppable yet.
assert queue.pop() is None
assert len(queue) == 1
assert queue.get_next_request_delay() == pytest.approx(1000.0, abs=1.0)
@coroutine_test
async def test_delayed_request_promoted_when_due(self):
crawler = get_crawler(Spider, settings_dict={"RANDOMIZE_DOWNLOAD_DELAY": False})
queue = self._queue(crawler)
request = Request("http://example.com/slow", meta={"delay": 1000.0})
await self._push(queue, crawler, request)
assert queue.pop() is None # held back by its per-request delay
# Once the delay elapses the request is promoted into its scope-set
# queue, served, and flagged so the delay is not applied a second time.
queue._promote_ready(queue._delayed[0][0])
popped = queue.pop()
assert popped is request
assert popped.meta["_throttler_delayed"] is True
assert len(queue) == 0
@coroutine_test
async def test_delayed_request_persisted_on_close(self):
# With a JOBDIR (disk queue), a request held back by its per-request
# delay must not be lost on a graceful stop: close() flushes it to disk
# so it is restored on resume.
crawler = get_crawler(Spider, settings_dict={"RANDOMIZE_DOWNLOAD_DELAY": False})
temp_dir = tempfile.mkdtemp()
queue = build_from_crawler(
ThrottlerAwarePriorityQueue,
crawler,
downstream_queue_cls=PickleFifoDiskQueue,
key=temp_dir,
)
await self._push(
queue,
crawler,
Request("http://example.com/slow", meta={"delay": 1000.0}),
)
assert len(queue) == 1 # held in memory, not yet in any scope-set queue
state = queue.close() # graceful stop
resumed = build_from_crawler(
ThrottlerAwarePriorityQueue,
crawler,
downstream_queue_cls=PickleFifoDiskQueue,
key=temp_dir,
startprios=state,
)
assert len(resumed) == 1
popped = resumed.pop()
assert popped is not None
assert popped.url == "http://example.com/slow"
# Its delay is marked consumed, so it does not re-block on resume.
assert popped.meta["_throttler_delayed"] is True
resumed.close()
@coroutine_test
async def test_least_loaded_first(self):
crawler = get_crawler(
Spider,
settings_dict={
"THROTTLER_SCOPES": {
"a.com": {"concurrency": 4},
"b.com": {"concurrency": 4},
}
},
)
queue = self._queue(crawler)
# Make a.com busier than b.com.
busy = Request("http://a.com/0")
await crawler.throttler.get_scopes(busy)
crawler.throttler.reserve(busy)
await self._push(queue, crawler, Request("http://a.com/1"))
await self._push(queue, crawler, Request("http://b.com/1"))
# Equal priority, so the lower-load scope (b.com) is served first.
assert queue.pop().url == "http://b.com/1"
@coroutine_test
async def test_priority_beats_load(self):
crawler = get_crawler(
Spider,
settings_dict={
"THROTTLER_SCOPES": {
"a.com": {"concurrency": 4},
"b.com": {"concurrency": 4},
}
},
)
queue = self._queue(crawler)
# Make a.com busier than b.com.
busy = Request("http://a.com/0")
await crawler.throttler.get_scopes(busy)
crawler.throttler.reserve(busy)
# The a.com request has higher priority, and a.com still has room, so it
# is served first despite a.com being the busier scope.
await self._push(queue, crawler, Request("http://a.com/1", priority=10))
await self._push(queue, crawler, Request("http://b.com/1"))
assert queue.pop().url == "http://a.com/1"
@coroutine_test
async def test_empty_and_close(self):
crawler = get_crawler(Spider)
queue = self._queue(crawler)
assert queue.pop() is None
assert queue.get_next_request_delay() is None
await self._push(queue, crawler, Request("http://a.com/1"))
assert queue.close() != {}
@coroutine_test
async def test_get_next_request_delay_zero_when_ready(self):
crawler = get_crawler(Spider)
queue = self._queue(crawler)
await self._push(queue, crawler, Request("http://a.com/1"))
# A sendable head means no wait is needed.
assert queue.get_next_request_delay() == 0.0
@coroutine_test
async def test_get_next_request_delay_ignores_empty_queues(self):
crawler = get_crawler(Spider)
queue = self._queue(crawler)
# An empty (but still registered) internal queue is skipped.
queue.pqueues[frozenset({"a.com"})] = queue._pqfactory(frozenset({"a.com"}))
assert queue.get_next_request_delay() is None
@coroutine_test
async def test_get_next_request_delay_keeps_minimum(self):
crawler = get_crawler(
Spider,
settings_dict={
"THROTTLER_SCOPES": {
"a.com": {"delay": 10.0},
"b.com": {"delay": 1000.0},
},
"RANDOMIZE_DOWNLOAD_DELAY": False,
},
)
queue = self._queue(crawler)
# Two requests per scope so a blocked head remains after the first one
# (sendable, since no delay has accrued yet) is popped and reserved.
await self._push(queue, crawler, Request("http://a.com/1"))
await self._push(queue, crawler, Request("http://a.com/2"))
await self._push(queue, crawler, Request("http://b.com/1"))
await self._push(queue, crawler, Request("http://b.com/2"))
queue.pop()
queue.pop()
# Both scopes are now time-blocked; the smaller per-scope delay wins,
# so the larger one exercises the "not below the running minimum" branch.
delay = queue.get_next_request_delay()
assert delay == pytest.approx(10.0, abs=1.0)
@coroutine_test
async def test_pop_handles_drained_selected_queue(self):
crawler = get_crawler(Spider)
queue = self._queue(crawler)
await self._push(queue, crawler, Request("http://a.com/1"))
inner = next(iter(queue.pqueues.values()))
# _select() still reports a sendable head, but pop() yields nothing: the
# request-is-None guard must not try to reserve a missing request.
inner.pop = lambda: None
assert queue.pop() is None
def test_non_dict_slot_startprios(self):
crawler = get_crawler(Spider)
with pytest.raises(ValueError, match="slot_startprios"):
build_from_crawler(
ThrottlerAwarePriorityQueue,
crawler,
downstream_queue_cls=FifoMemoryQueue,
key="",
startprios=[1, 2, 3],
)

View File

@ -88,6 +88,16 @@ class BaseRobotParserTest:
assert rp.allowed("https://site.local/index.html", "*")
assert rp.allowed("https://site.local/disallowed", "*")
def test_crawl_delay(self):
robotstxt_body = b"User-agent: *\nCrawl-delay: 10\n"
rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_body)
assert rp.crawl_delay("*") == 10.0
def test_crawl_delay_unset(self):
robotstxt_body = b"User-agent: *\nDisallow: /private\n"
rp = self.parser_cls.from_crawler(crawler=None, robotstxt_body=robotstxt_body)
assert rp.crawl_delay("*") is None
def test_unicode_url_and_useragent(self):
robotstxt_robotstxt_body = """
User-Agent: *
@ -171,6 +181,9 @@ class TestRerpRobotParser(BaseRobotParserTest):
def test_length_based_precedence(self):
pytest.skip("Rerp does not support length based directives precedence.")
def test_crawl_delay(self):
pytest.skip("Rerp does not expose Crawl-delay directives.")
class TestProtegoRobotParser(BaseRobotParserTest):
def setup_method(self):

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import warnings
from abc import ABC, abstractmethod
from collections import deque
@ -10,7 +11,7 @@ from unittest.mock import Mock
import pytest
from scrapy.core.downloader import Downloader
from scrapy.core.scheduler import BaseScheduler, Scheduler
from scrapy.core.scheduler import BaseScheduler, Scheduler, ThrottlerAwareScheduler
from scrapy.crawler import Crawler
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request
@ -26,6 +27,8 @@ if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from pathlib import Path
from scrapy.http.request import CallbackT
class MemoryScheduler(BaseScheduler):
paused = False
@ -406,3 +409,251 @@ class TestIncompatibility:
ValueError, match="does not support CONCURRENT_REQUESTS_PER_IP"
):
self._incompatible()
_THROTTLER_AWARE_PQ = "scrapy.pqueues.ThrottlerAwarePriorityQueue"
class TestThrottlerAwareScheduler:
def _crawler(self, settings_dict: dict[str, Any] | None = None) -> Crawler:
settings = {
"SCHEDULER_PRIORITY_QUEUE": _THROTTLER_AWARE_PQ,
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
**(settings_dict or {}),
}
return get_crawler(Spider, settings)
def _scheduler(self, crawler: Crawler) -> ThrottlerAwareScheduler:
spider = Spider(name="spider")
crawler.spider = spider
scheduler = ThrottlerAwareScheduler.from_crawler(crawler)
scheduler.open(spider)
return scheduler
@coroutine_test
async def test_enqueue_async_and_dequeue(self) -> None:
scheduler = self._scheduler(self._crawler())
assert await scheduler.enqueue_request_async(Request("http://a.com/1")) is True
assert scheduler.has_pending_requests()
assert len(scheduler) == 1
request = scheduler.next_request()
assert request is not None
assert request.url == "http://a.com/1"
assert scheduler.next_request() is None
assert not scheduler.has_pending_requests()
scheduler.close("finished")
def test_sync_enqueue_raises(self) -> None:
scheduler = self._scheduler(self._crawler())
with pytest.raises(RuntimeError, match="asynchronous enqueue path"):
scheduler.enqueue_request(Request("http://a.com/1"))
scheduler.close("finished")
def test_requires_throttler_aware_priority_queue(self) -> None:
crawler = self._crawler(
{"SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.ScrapyPriorityQueue"}
)
spider = Spider(name="spider")
crawler.spider = spider
scheduler = ThrottlerAwareScheduler.from_crawler(crawler)
with pytest.raises(ValueError, match="throttler-aware priority queue"):
scheduler.open(spider)
@coroutine_test
async def test_delay_blocks_and_reports_delay(self) -> None:
crawler = self._crawler(
{
"THROTTLER_SCOPES": {"slow.com": {"delay": 1000.0}},
"RANDOMIZE_DOWNLOAD_DELAY": False,
},
)
scheduler = self._scheduler(crawler)
await scheduler.enqueue_request_async(Request("http://slow.com/1"))
await scheduler.enqueue_request_async(Request("http://slow.com/2"))
# The first request is sendable; the second is blocked by the delay.
first = scheduler.next_request()
assert first is not None
assert scheduler.next_request() is None
assert scheduler.has_pending_requests()
assert scheduler.get_next_request_delay() == pytest.approx(1000.0, abs=1.0)
scheduler.close("finished")
@coroutine_test
async def test_no_delay_when_only_concurrency_blocked(self) -> None:
crawler = self._crawler(
{"THROTTLER_SCOPES": {"slow.com": {"concurrency": 1}}},
)
scheduler = self._scheduler(crawler)
await scheduler.enqueue_request_async(Request("http://slow.com/1"))
await scheduler.enqueue_request_async(Request("http://slow.com/2"))
assert scheduler.next_request() is not None
assert scheduler.next_request() is None
# A purely concurrency-blocked state has no time-based wakeup.
assert scheduler.get_next_request_delay() is None
scheduler.close("finished")
@coroutine_test
async def test_delayed_request_survives_jobdir_stop(self, tmp_path: Path) -> None:
# A request held back by its per-request delay must not be
# lost on a graceful stop when a JOBDIR is configured: it is flushed to
# the disk queue on close and restored on resume.
crawler = self._crawler(
{"JOBDIR": str(tmp_path), "RANDOMIZE_DOWNLOAD_DELAY": False}
)
scheduler = self._scheduler(crawler)
request = Request("http://a.com/slow", meta={"delay": 1000.0})
assert await scheduler.enqueue_request_async(request) is True
assert len(scheduler) == 1
# The delay holds it back, so nothing is dequeued before the stop.
assert scheduler.next_request() is None
scheduler.close("finished")
# Resume from the same JOBDIR: the request is still there and, having
# been held once, is now sendable.
resumed = self._scheduler(
self._crawler({"JOBDIR": str(tmp_path), "RANDOMIZE_DOWNLOAD_DELAY": False})
)
assert len(resumed) == 1
resumed_request = resumed.next_request()
assert resumed_request is not None
assert resumed_request.url == "http://a.com/slow"
resumed.close("finished")
@coroutine_test
async def test_enqueue_async_filters_duplicates(self) -> None:
crawler = self._crawler(
{"DUPEFILTER_CLASS": "scrapy.dupefilters.RFPDupeFilter"}
)
scheduler = self._scheduler(crawler)
assert crawler.spider is not None
crawler.spider.crawler = crawler # the dupefilter logs via spider.crawler
assert await scheduler.enqueue_request_async(Request("http://a.com/1")) is True
# The same request is filtered out the second time around.
assert await scheduler.enqueue_request_async(Request("http://a.com/1")) is False
assert len(scheduler) == 1
scheduler.close("finished")
@coroutine_test
async def test_enqueue_async_unserializable_falls_back_to_memory(
self, tmp_path: Path, caplog
) -> None:
crawler = self._crawler({"JOBDIR": str(tmp_path), "SCHEDULER_DEBUG": True})
scheduler = self._scheduler(crawler)
# A lambda callback cannot be serialized to disk, so the request falls
# back to the in-memory queue and the failure is logged once.
request = Request(
"http://a.com/1", callback=cast("CallbackT", lambda response: None)
)
with caplog.at_level(logging.WARNING, logger="scrapy.core.scheduler"):
assert await scheduler.enqueue_request_async(request) is True
assert "Unable to serialize request" in caplog.text
assert crawler.stats is not None
assert crawler.stats.get_value("scheduler/unserializable") == 1
assert crawler.stats.get_value("scheduler/enqueued/memory") == 1
scheduler.close("finished")
@coroutine_test
async def test_enqueue_async_unserializable_without_debug(
self, tmp_path: Path
) -> None:
# Same fallback as above, but with SCHEDULER_DEBUG off the failure is
# tracked in stats without logging a warning.
crawler = self._crawler({"JOBDIR": str(tmp_path)})
scheduler = self._scheduler(crawler)
request = Request(
"http://a.com/1", callback=cast("CallbackT", lambda response: None)
)
assert await scheduler.enqueue_request_async(request) is True
assert crawler.stats is not None
assert crawler.stats.get_value("scheduler/unserializable") == 1
scheduler.close("finished")
@coroutine_test
async def test_resume_from_disk(self, tmp_path: Path) -> None:
settings = {"JOBDIR": str(tmp_path)}
scheduler = self._scheduler(self._crawler(settings))
await scheduler.enqueue_request_async(Request("http://a.com/1"))
await scheduler.enqueue_request_async(Request("http://b.com/1"))
scheduler.close("shutdown")
scheduler2 = self._scheduler(self._crawler(settings))
assert len(scheduler2) == 2
urls = set()
while (request := scheduler2.next_request()) is not None:
urls.add(request.url)
assert urls == {"http://a.com/1", "http://b.com/1"}
scheduler2.close("finished")
class TestIntegrationWithThrottlerAwareScheduler:
@inline_callbacks_test
def test_integration(self):
crawler = get_crawler(
spidercls=StartUrlsSpider,
settings_dict={
"SCHEDULER": "scrapy.core.scheduler.ThrottlerAwareScheduler",
"SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.ThrottlerAwarePriorityQueue",
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
},
)
with MockServer() as mockserver:
url = mockserver.url("/status?n=200", is_secure=False)
start_urls = [url] * 6
yield crawler.crawl(start_urls)
assert crawler.stats.get_value("downloader/response_count") == len(
start_urls
)
@inline_callbacks_test
def test_integration_follow_requests(self):
# Exercises the engine's asynchronous enqueue path for requests yielded
# from a callback (not just start requests), and the idle guard that
# keeps the spider open while an enqueue is in flight.
class FollowSpider(Spider):
name = "follow"
def __init__(self, base_url, **kwargs):
self.base_url = base_url
super().__init__(**kwargs)
async def start(self):
yield Request(self.base_url + "/status?n=200")
def parse(self, response):
if b"follow" not in response.url.encode():
yield Request(response.url + "&follow=1", callback=self.parse)
with MockServer() as mockserver:
base_url = mockserver.url("", is_secure=False).rstrip("/")
crawler = get_crawler(
spidercls=FollowSpider,
settings_dict={
"SCHEDULER": "scrapy.core.scheduler.ThrottlerAwareScheduler",
"SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.ThrottlerAwarePriorityQueue",
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
},
)
yield crawler.crawl(base_url=base_url)
assert crawler.stats.get_value("downloader/response_count") == 2
@inline_callbacks_test
def test_integration_with_delay(self):
# A small per-scope delay forces the engine to arm the throttler wakeup
# timer between requests; the crawl must still complete.
crawler = get_crawler(
spidercls=StartUrlsSpider,
settings_dict={
"SCHEDULER": "scrapy.core.scheduler.ThrottlerAwareScheduler",
"SCHEDULER_PRIORITY_QUEUE": "scrapy.pqueues.ThrottlerAwarePriorityQueue",
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
"RANDOMIZE_DOWNLOAD_DELAY": False,
"THROTTLER_SCOPES": {"127.0.0.1": {"delay": 0.05}},
},
)
with MockServer() as mockserver:
url = mockserver.url("/status?n=200", is_secure=False)
start_urls = [url] * 4
yield crawler.crawl(start_urls)
assert crawler.stats.get_value("downloader/response_count") == len(
start_urls
)

1173
tests/test_throttler.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,8 @@ from scrapy.utils.asyncio import (
AsyncioLoopingCall,
_parallel_asyncio,
is_asyncio_available,
sleep,
wait_for_first,
)
from tests.utils.decorators import coroutine_test
@ -158,3 +160,42 @@ class TestAsyncioLoopingCall:
looping_call.start(0.1)
assert not looping_call.running
assert "Error calling the AsyncioLoopingCall function" in caplog.text
class TestSleep:
@coroutine_test
async def test_sleep(self):
# A zero-second sleep completes without error under any reactor mode.
await sleep(0)
class TestWaitForFirst:
@coroutine_test
async def test_empty(self):
# No deferreds -> two empty sets, returned immediately.
assert await wait_for_first([]) == (set(), set())
@coroutine_test
async def test_returns_done_deferred(self):
fired: Deferred[None] = Deferred()
fired.callback(None)
done, pending = await wait_for_first([fired], timeout=1)
assert done == {fired}
assert pending == set()
@coroutine_test
async def test_no_timeout(self):
# timeout=None -> no timeout deferred is created.
fired: Deferred[None] = Deferred()
fired.callback(None)
done, pending = await wait_for_first([fired])
assert done == {fired}
assert pending == set()
@coroutine_test
async def test_timeout_elapses(self):
# When the timeout fires first, every input deferred is still pending.
never: Deferred[None] = Deferred()
done, pending = await wait_for_first([never], timeout=0.01)
assert done == set()
assert pending == {never}

View File

@ -144,7 +144,7 @@ deps =
lxml==4.6.4
parsel==1.5.0
pyOpenSSL==22.0.0
queuelib==1.4.2
queuelib==1.6.1
service_identity==23.1.0
w3lib==1.17.0
zope.interface==5.1.0
@ -260,7 +260,7 @@ deps =
lxml==5.3.2
parsel==1.5.0
pyOpenSSL==24.3.0
queuelib==1.4.2
queuelib==1.6.1
service_identity==23.1.0
# w3lib 1.17 fails to import on PyPy 3.11 because its encoding regex uses
# an inline flag placement that Python 3.11 treats as an error: global