Merge remote-tracking branch 'origin/master' into faster-shutdown-2

This commit is contained in:
Adrian Chaves 2026-06-16 15:54:32 +02:00
commit f2a3f19aa6
214 changed files with 5355 additions and 5311 deletions

View File

@ -12,13 +12,13 @@ Key takeaways:
> Note: What follows is based on
> https://raw.githubusercontent.com/jackyzha0/quartz/acfaa472253a432d350e9b6904c0cde14f8c487f/.github/pull_request_template.md
We more than welcome contributions, and are OK with the use of LLMs tools. How
We more than welcome contributions, and are OK with the use of LLM tools. How
you use those tools depends on whether or not they make you more productive.
But one thing that bugs us a lot are PRs that are made entirely with these
tools, without any revision or any effort trying to refine their output
whatsoever. This is just pure laziness, and unacceptable. Doing so will just
end up wasting everyone time (ours and yours).
end up wasting everyone's time (ours and yours).
So to be the most productive for all parties, we would encourage any
contributors to, at the very least, pay attention to what the model is doing,

View File

@ -18,23 +18,23 @@ jobs:
fail-fast: false
matrix:
include:
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: pylint
- python-version: "3.10"
env:
TOXENV: typing
TOXENV: mypy
- python-version: "3.10"
env:
TOXENV: typing-tests
TOXENV: mypy-tests
# Keep in sync with pyproject.toml tool.sphinx-scrapy.python-version.
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: docs
- python-version: "3.13"
env:
TOXENV: docs-tests
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: twinecheck

View File

@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.13"
python-version: "3.14"
- run: |
python -m pip install --upgrade build
python -m build

View File

@ -19,11 +19,11 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
env:
- TOXENV: py
include:
- python-version: '3.13'
- python-version: '3.14'
env:
TOXENV: no-reactor

View File

@ -32,10 +32,13 @@ jobs:
- python-version: "3.13"
env:
TOXENV: py
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: py
- python-version: "3.14"
env:
TOXENV: default-reactor
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: no-reactor
# pinned due to https://github.com/pypy/pypy/issues/5388
@ -43,41 +46,41 @@ jobs:
env:
TOXENV: pypy3
# pinned deps
# min deps
- python-version: "3.10.19"
env:
TOXENV: pinned
TOXENV: min
- python-version: "3.10.19"
env:
TOXENV: default-reactor-pinned
TOXENV: min-default-reactor
- python-version: "3.10.19"
env:
TOXENV: no-reactor-pinned
TOXENV: min-no-reactor
# pinned due to https://github.com/pypy/pypy/issues/5388
- python-version: pypy3.11-7.3.20
env:
TOXENV: pypy3-pinned
TOXENV: min-pypy3
- python-version: "3.10.19"
env:
TOXENV: extra-deps-pinned
TOXENV: min-extra-deps
- python-version: "3.10.19"
env:
TOXENV: botocore-pinned
TOXENV: min-botocore
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: extra-deps
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: no-reactor-extra-deps
# pinned due to https://github.com/pypy/pypy/issues/5388
- python-version: pypy3.11-7.3.20
env:
TOXENV: pypy3-extra-deps
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: botocore
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: mitmproxy
@ -90,7 +93,7 @@ jobs:
python-version: ${{ matrix.python-version }}
- name: Install system libraries
if: contains(matrix.python-version, 'pypy') || contains(matrix.env.TOXENV, 'pinned')
if: contains(matrix.python-version, 'pypy') || contains(matrix.env.TOXENV, 'min')
run: |
sudo apt-get update
sudo apt-get install libxml2-dev libxslt-dev

View File

@ -32,22 +32,25 @@ jobs:
- python-version: "3.13"
env:
TOXENV: py
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: py
- python-version: "3.14"
env:
TOXENV: default-reactor
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: no-reactor
# pinned deps
# min deps
- python-version: "3.10.11"
env:
TOXENV: pinned
TOXENV: min
- python-version: "3.10.11"
env:
TOXENV: extra-deps-pinned
TOXENV: min-extra-deps
- python-version: "3.13"
- python-version: "3.14"
env:
TOXENV: extra-deps

View File

@ -27,6 +27,6 @@ repos:
hooks:
- id: sphinx-lint
- repo: https://github.com/scrapy/sphinx-scrapy
rev: 0.8.5
rev: 0.8.6
hooks:
- id: sphinx-scrapy

View File

@ -2,7 +2,7 @@ version: 2
build:
os: ubuntu-24.04
tools:
python: "3.13"
python: "3.14"
commands:
- pip install tox
- tox -e docs

6
CITATION.cff Normal file
View File

@ -0,0 +1,6 @@
cff-version: 1.2.0
message: If you use Scrapy in published research, please cite it as below.
title: Scrapy
authors:
- name: Scrapy contributors
url: https://scrapy.org

View File

@ -4,8 +4,8 @@
| Version | Supported |
| ------- | ------------------ |
| 2.15.x | :white_check_mark: |
| < 2.15.x | :x: |
| 2.16.x | :white_check_mark: |
| < 2.16.x | :x: |
## Reporting a Vulnerability

View File

@ -11,6 +11,7 @@ from scrapy.utils.reactor import set_asyncio_event_loop_policy
from scrapy.utils.reactorless import install_reactor_import_hook
from tests.keys import generate_keys
from tests.mockserver.http import MockServer
from tests.mockserver.mitm_proxy import MitmProxy
if TYPE_CHECKING:
from collections.abc import Generator
@ -23,16 +24,13 @@ def _py_files(folder):
collect_ignore = [
# may need extra deps
"docs/_ext",
# not a test, but looks like a test
"scrapy/utils/testproc.py",
"scrapy/utils/testsite.py",
# contains scripts to be run by tests/test_crawler.py::AsyncCrawlerProcessSubprocess
# contains scripts to be run by tests/test_crawler_subprocess.py::AsyncCrawlerProcessSubprocess
*_py_files("tests/AsyncCrawlerProcess"),
# contains scripts to be run by tests/test_crawler.py::AsyncCrawlerRunnerSubprocess
# contains scripts to be run by tests/test_crawler_subprocess.py::AsyncCrawlerRunnerSubprocess
*_py_files("tests/AsyncCrawlerRunner"),
# contains scripts to be run by tests/test_crawler.py::CrawlerProcessSubprocess
# contains scripts to be run by tests/test_crawler_subprocess.py::CrawlerProcessSubprocess
*_py_files("tests/CrawlerProcess"),
# contains scripts to be run by tests/test_crawler.py::CrawlerRunnerSubprocess
# contains scripts to be run by tests/test_crawler_subprocess.py::CrawlerRunnerSubprocess
*_py_files("tests/CrawlerRunner"),
]
@ -75,6 +73,45 @@ def mockserver() -> Generator[MockServer]:
yield mockserver
@pytest.fixture # function scope because it modifies os.environ
def mitm_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy()
url = proxy.start()
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally:
proxy.stop()
@pytest.fixture # function scope because it modifies os.environ
def mitm_proxy_server_https(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy()
url = proxy.start().replace("http://", "https://")
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally:
proxy.stop()
@pytest.fixture # function scope because it modifies os.environ
def socks5_proxy_server(monkeypatch: pytest.MonkeyPatch) -> Generator[MitmProxy]:
proxy = MitmProxy(mode="socks5")
url = proxy.start()
monkeypatch.setenv("http_proxy", url)
monkeypatch.setenv("https_proxy", url)
try:
yield proxy
finally:
proxy.stop()
@pytest.fixture(scope="session")
def reactor_pytest(request) -> str:
return request.config.getoption("--reactor")

View File

@ -11,7 +11,7 @@ from sphinx.application import Sphinx
def maybe_skip_member(app: Sphinx, what, name: str, obj, skip: bool, options) -> bool:
if not skip:
# autodocs was generating a text "alias of" for the following members
# autodoc was generating the text "alias of" for the following members
return name in {"default_item_class", "default_selector_class"}
return skip

View File

@ -1,6 +1,6 @@
{% extends "!layout.html" %}
{# Overriden to include a link to scrapy.org, not just to the docs root #}
{# Overridden to include a link to scrapy.org, not just to the docs root #}
{%- block sidebartitle %}
{# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #}

View File

@ -154,6 +154,7 @@ scrapy_intersphinx_enable = [
"coverage",
"cryptography",
"cssselect",
"form2request",
"itemloaders",
"parsel",
"pytest",

View File

@ -82,10 +82,18 @@ to steal from us!
Does Scrapy work with HTTP proxies?
-----------------------------------
Yes. Support for HTTP proxies is provided (since Scrapy 0.8) through the HTTP
Proxy downloader middleware. See
Yes. Support for HTTP proxies is provided through the HTTP Proxy downloader
middleware. See
:class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware`.
Does Scrapy work with SOCKS proxies?
------------------------------------
Yes, when using
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`. See
:class:`~scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware` and the
handler documentation.
How can I scrape an item with attributes in different pages?
------------------------------------------------------------
@ -360,7 +368,10 @@ method for this purpose. For example:
Does Scrapy support IPv6 addresses?
-----------------------------------
Yes, by setting :setting:`TWISTED_DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``.
Yes, but when using
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` or
:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` you need to
set :setting:`TWISTED_DNS_RESOLVER` to ``scrapy.resolver.CachingHostnameResolver``.
Note that by doing so, you lose the ability to set a specific timeout for DNS requests
(the value of the :setting:`DNS_TIMEOUT` setting is ignored).

View File

@ -3,6 +3,284 @@
Release notes
=============
.. _release-2.16.0:
Scrapy 2.16.0 (2026-05-19)
--------------------------
Highlights:
- Official support for Python 3.14
- Support for Twisted 26.4.0+
Modified requirements
~~~~~~~~~~~~~~~~~~~~~
- Increased the minimum versions of the following dependencies:
- service_identity_: 18.1.0 → 23.1.0
(:issue:`7347`)
- Added support for Twisted 26.4.0+.
(:issue:`7347`, :issue:`7505`, :issue:`7520`)
- Added support for Python 3.14.
(:issue:`6604`, :issue:`7460`)
Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- The following classes and functions, intended for internal use by
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`
and :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`, have
been made private:
- ``scrapy.core.downloader.handlers.http11.ScrapyAgent``
- ``scrapy.core.downloader.handlers.http11.ScrapyProxyAgent``
- ``scrapy.core.downloader.handlers.http11.TunnelingAgent``
- ``scrapy.core.downloader.handlers.http11.TunnelingTCP4ClientEndpoint``
- ``scrapy.core.downloader.handlers.http11.tunnel_request_data()``
- ``scrapy.core.downloader.handlers.http2.ScrapyH2Agent``
(:issue:`7496`, :issue:`7510`)
Deprecations
~~~~~~~~~~~~
- ``scrapy.FormRequest`` is deprecated. You can use the :doc:`form2request
<form2request:index>` library instead, see :ref:`form`.
(:issue:`6438`)
- ``scrapy.utils.python.MutableChain`` is deprecated.
(:issue:`7504`)
Deprecation removals
~~~~~~~~~~~~~~~~~~~~
- The ``start_requests()`` method of :class:`~scrapy.Spider`, deprecated in
2.13.0, is removed and no longer called. Use :meth:`~scrapy.Spider.start`
instead, or both to maintain support for lower Scrapy versions.
(:issue:`7490`)
- Support for ``process_start_requests()`` methods of :ref:`spider middlewares
<topics-spider-middleware>`, deprecated in 2.13.0, is removed. Use
:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` instead,
or both to maintain support for lower Scrapy versions.
(:issue:`7490`)
- Support for synchronous ``process_spider_output()`` methods of spider
middlewares, deprecated in Scrapy 2.13.0, is removed. You should upgrade
the affected middlewares to have asynchronous ``process_spider_output()``
methods.
(:issue:`7504`)
- The ``spider`` arguments of the following methods of
:class:`~scrapy.core.scraper.Scraper`, deprecated in Scrapy 2.13.0, are
removed:
- ``close_spider()``
- ``enqueue_scrape()``
- ``handle_spider_error()``
- ``handle_spider_output()``
(:issue:`7487`)
- HTTP/1.0 support code, deprecated in Scrapy 2.13.0, is removed. This
includes:
- ``scrapy.core.downloader.handlers.http10.HTTP10DownloadHandler``
- The ``scrapy.core.downloader.webclient`` module.
- The ``DOWNLOADER_HTTPCLIENTFACTORY`` setting.
(:issue:`7486`)
- The following functions, deprecated in Scrapy 2.13.0, are removed, you
should import them from :mod:`w3lib.url` directly instead:
- ``scrapy.utils.url.add_or_replace_parameter()``
- ``scrapy.utils.url.add_or_replace_parameters()``
- ``scrapy.utils.url.any_to_uri()``
- ``scrapy.utils.url.canonicalize_url()``
- ``scrapy.utils.url.file_uri_to_path()``
- ``scrapy.utils.url.is_url()``
- ``scrapy.utils.url.parse_data_uri()``
- ``scrapy.utils.url.parse_url()``
- ``scrapy.utils.url.path_to_file_uri()``
- ``scrapy.utils.url.safe_download_url()``
- ``scrapy.utils.url.safe_url_string()``
- ``scrapy.utils.url.url_query_cleaner()``
- ``scrapy.utils.url.url_query_parameter()``
(:issue:`7487`)
- The following test-related code, deprecated in Scrapy 2.13.0, is removed:
- the ``scrapy.utils.testproc`` module
- the ``scrapy.utils.testsite`` module
- ``scrapy.utils.test.assert_gcs_environ()``
- ``scrapy.utils.test.get_ftp_content_and_delete()``
- ``scrapy.utils.test.get_gcs_content_and_delete()``
- ``scrapy.utils.test.mock_google_cloud_storage()``
- ``scrapy.utils.test.skip_if_no_boto()``
- ``scrapy.utils.test.TestSpider``
(:issue:`7487`)
- ``scrapy.utils.versions.scrapy_components_versions()``, deprecated in
Scrapy 2.13.0, is removed, you can use
:func:`scrapy.utils.versions.get_versions` instead.
(:issue:`7487`)
- ``scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware`` and
``scrapy.utils.url.escape_ajax()``, deprecated in Scrapy 2.13.0, are
removed.
(:issue:`7487`)
- The ``__init__()`` method of priority queue classes (see
:setting:`SCHEDULER_PRIORITY_QUEUE`) now needs to support a keyword-only
``start_queue_cls`` parameter, not supporting it was deprecated in Scrapy
2.13.0.
(:issue:`7487`)
- ``scrapy.spiders.init.InitSpider``, deprecated in Scrapy 2.13.0, is
removed.
(:issue:`7487`)
New features
~~~~~~~~~~~~
- New features and improvements for
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`:
- Support for proxies.
- Support for the :reqmeta:`download_latency` meta key.
- Support for :attr:`Response.certificate
<scrapy.http.Response.certificate>`.
- Default headers set by the ``httpx`` library are no longer added to
requests.
(:issue:`7441`, :issue:`7524`)
- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now
skips HTTPS proxy certificate verification when the
:setting:`DOWNLOAD_VERIFY_CERTIFICATES` setting is set to ``False``.
(:issue:`7496`)
Improvements
~~~~~~~~~~~~
- :func:`time.monotonic` is used instead of :func:`time.time` to calculate
elapsed time in various places.
(:issue:`7377`)
- Improved extraction of the file extension from the URL in
:class:`~scrapy.pipelines.files.FilesPipeline`.
(:issue:`4225`, :issue:`7414`)
- Other code refactoring and improvements.
(:issue:`7401`)
Bug fixes
~~~~~~~~~
- :class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler` now
raises an exception when a request has an ``https://`` destination and an
``https://`` proxy, which is not supported by this handler. Previously it
tried to connect to the proxy via HTTP in this case.
(:issue:`7496`)
- :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` now
raises an exception for requests with ``http://`` URLs instead of trying to
connect, which is not supported by this handler.
(:issue:`7496`)
- :class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` no longer
adds the ``:status`` pseudo-header to :attr:`Response.headers
<scrapy.http.Response.headers>`.
(:issue:`7441`)
- Fixed :func:`scrapy.utils.response.open_in_browser` removing the ``<head>``
tag when adding the ``<base>`` tag.
(:issue:`7459`)
Documentation
~~~~~~~~~~~~~
- Documented that
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`
doesn't support HTTPS proxies for HTTPS destinations and that
:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler` doesn't
support proxies at all.
(:issue:`7496`)
- Added an example of using
:class:`logging.handlers.TimedRotatingFileHandler` to rotate Scrapy logs.
(:issue:`3628`, :issue:`7501`)
- Added a ``CITATION.cff`` file.
(:issue:`7502`, :issue:`7519`)
- Mentioned ``DOWNLOADER_CLIENT_TLS_METHOD`` in :ref:`bans`.
(:issue:`5232`, :issue:`7518`)
- Other documentation improvements and fixes.
(:issue:`7417`,
:issue:`7463`,
:issue:`7472`,
:issue:`7480`,
:issue:`7489`,
:issue:`7503`,
:issue:`7507`)
Quality assurance
~~~~~~~~~~~~~~~~~
- Added tests that connect to https://books.toscrape.com/ to test the
behavior with a real website. These tests are marked with the
``requires_internet`` pytest mark and can be skipped with e.g.
``-m 'not requires_internet'`` if you cannot or don't want to run them.
(:issue:`7520`)
- Type hints improvements and fixes.
(:issue:`7492`, :issue:`7532`)
- CI and test improvements and fixes.
(:issue:`7441`, :issue:`7466`, :issue:`7491`, :issue:`7496`)
.. _release-2.15.2:
Scrapy 2.15.2 (2026-04-28)
@ -1355,8 +1633,8 @@ Highlights:
- Added the :reqmeta:`allow_offsite` request meta key
- :ref:`Spider middlewares that don't support asynchronous spider output
<sync-async-spider-middleware>` are deprecated
- Spider middlewares that don't support asynchronous spider output are
deprecated
- Added a base class for :ref:`universal spider middlewares
<universal-spider-middleware>`
@ -1494,13 +1772,11 @@ Deprecations
``start_queue_cls`` parameter.
(:issue:`6752`)
- :ref:`Spider middlewares that don't support asynchronous spider output
<sync-async-spider-middleware>` are deprecated. The async iterable
downgrading feature, needed for using such middlewares with asynchronous
callbacks and with other spider middlewares that produce asynchronous
iterables, is also deprecated. Please update all such middlewares to
support asynchronous spider output.
(:issue:`6664`)
- Spider middlewares that don't support asynchronous spider output are
deprecated. The async iterable downgrading feature, needed for using such
middlewares with asynchronous callbacks and with other spider middlewares
that produce asynchronous iterables, is also deprecated. Please update all
such middlewares to support asynchronous spider output. (:issue:`6664`)
- Functions that were imported from :mod:`w3lib.url` and re-exported in
:mod:`scrapy.utils.url` are now deprecated, you should import them from
@ -1759,9 +2035,8 @@ Documentation
- Documented the setting values set in the default project template.
(:issue:`6762`, :issue:`6775`)
- Improved the :ref:`docs <sync-async-spider-middleware>` about asynchronous
iterable support in spider middlewares.
(:issue:`6688`)
- Improved the docs about asynchronous iterable support in spider
middlewares. (:issue:`6688`)
- Improved the :ref:`docs <coroutine-deferred-apis>` about using
:class:`~twisted.internet.defer.Deferred`-based APIs in coroutine-based
@ -1925,7 +2200,7 @@ Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- User-defined cookies for HTTPS requests will have the ``secure`` flag set
to ``True`` unless it's set to ``False`` explictly. This is important when
to ``True`` unless it's set to ``False`` explicitly. This is important when
these cookies are reused in HTTP requests, e.g. after a redirect to an HTTP
URL.
(:issue:`6357`)
@ -1960,7 +2235,7 @@ Backward-incompatible changes
``crawler.settings`` instead. When they call ``__init__()`` of the base
class they should pass the ``crawler`` argument to it too.
- A ``from_settings()`` method shouldn't be defined. Class-specific
initialization code should go into either an overriden ``from_crawler()``
initialization code should go into either an overridden ``from_crawler()``
method or into ``__init__()``.
- It's now possible to override ``from_crawler()`` and it's not necessary
to call ``MediaPipeline.from_crawler()`` in it if other recommendations
@ -7189,7 +7464,7 @@ This 1.1 release brings a lot of interesting features and bug fixes:
selectors engine without needing to upgrade Scrapy.
- HTTPS downloader now does TLS protocol negotiation by default,
instead of forcing TLS 1.0. You can also set the SSL/TLS method
using the new :setting:`DOWNLOADER_CLIENT_TLS_METHOD`.
using the new ``DOWNLOADER_CLIENT_TLS_METHOD`` setting.
- These bug fixes may require your attention:

View File

@ -5,4 +5,4 @@ sphinx
sphinx-notfound-page
sphinx-rtd-theme
sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.5
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@0.8.6

View File

@ -153,7 +153,7 @@ sphinx-rtd-theme==3.1.0
# via
# -r docs/requirements.in
# sphinx-rtd-dark-mode
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@96826815002921f27a2e369b12c0c25af7a1f8b2
sphinx-scrapy @ git+https://github.com/scrapy/sphinx-scrapy.git@b1d55db4d16a5425fc68576d63519bbfe26dd9c0
# via -r docs/requirements.in
sphinx-sitemap==2.9.0
# via sphinx-scrapy

View File

@ -4,19 +4,27 @@
asyncio
=======
Scrapy has partial support for :mod:`asyncio`. After you :ref:`install the
asyncio reactor <install-asyncio>`, you may use :mod:`asyncio` and
:mod:`asyncio`-powered libraries in any :doc:`coroutine <coroutines>`.
Scrapy supports :mod:`asyncio` natively. New projects created with
:command:`scrapy startproject` have asyncio enabled by default, and you can use
:mod:`asyncio` and :mod:`asyncio`-powered libraries in any :doc:`coroutine
<coroutines>`.
The rest of this page covers advanced topics. If you are starting a new project,
no additional setup is needed.
.. _install-asyncio:
Installing the asyncio reactor
==============================
Configuring the asyncio reactor
===============================
To enable :mod:`asyncio` support, your :setting:`TWISTED_REACTOR` setting needs
to be set to ``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``,
which is the default value.
New projects generated with :command:`scrapy startproject` have the asyncio
reactor configured by default. No manual setup is needed.
The :setting:`TWISTED_REACTOR` setting controls which Twisted reactor Scrapy
uses. Its default value is
``'twisted.internet.asyncioreactor.AsyncioSelectorReactor'``, which enables
:mod:`asyncio` support.
If you are using :class:`~scrapy.crawler.AsyncCrawlerRunner` or
:class:`~scrapy.crawler.CrawlerRunner`, you also need to

View File

@ -23,9 +23,6 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``):
- :class:`~scrapy.Request` callbacks.
If you are using any custom or third-party :ref:`spider middleware
<topics-spider-middleware>`, see :ref:`sync-async-spider-middleware`.
- The :meth:`process_item` method of
:ref:`item pipelines <topics-item-pipeline>`.
@ -39,13 +36,9 @@ hence use coroutine syntax (e.g. ``await``, ``async for``, ``async with``):
- The
:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output`
method of :ref:`spider middlewares <topics-spider-middleware>`.
If defined as a coroutine, it must be an :term:`asynchronous generator`.
The input ``result`` parameter is an :term:`asynchronous iterable`.
See also :ref:`sync-async-spider-middleware` and
:ref:`universal-spider-middleware`.
method of :ref:`spider middlewares <topics-spider-middleware>`, which
*must* be defined as an :term:`asynchronous generator` except in
:ref:`universal spider middlewares <universal-spider-middleware>`.
- The :meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_start` method
of :ref:`spider middlewares <custom-spider-middleware>`, which *must* be
@ -274,139 +267,6 @@ You can also send multiple requests in parallel:
responses = await asyncio.gather(*tasks)
yield {
"h1": response.css("h1::text").get(),
"price": responses[0][1].css(".price::text").get(),
"price2": responses[1][1].css(".color::text").get(),
"price": responses[0].css(".price::text").get(),
"price2": responses[1].css(".color::text").get(),
}
.. _sync-async-spider-middleware:
Mixing synchronous and asynchronous spider middlewares
======================================================
The output of a :class:`~scrapy.Request` callback is passed as the ``result``
parameter to the
:meth:`~scrapy.spidermiddlewares.SpiderMiddleware.process_spider_output` method
of the first :ref:`spider middleware <topics-spider-middleware>` from the
:ref:`list of active spider middlewares <topics-spider-middleware-setting>`.
Then the output of that ``process_spider_output`` method is passed to the
``process_spider_output`` method of the next spider middleware, and so on for
every active spider middleware.
Scrapy supports mixing :ref:`coroutine methods <async>` and synchronous methods
in this chain of calls.
However, if any of the ``process_spider_output`` methods is defined as a
synchronous method, and the previous ``Request`` callback or
``process_spider_output`` method is a coroutine, there are some drawbacks to
the asynchronous-to-synchronous conversion that Scrapy does so that the
synchronous ``process_spider_output`` method gets a synchronous iterable as its
``result`` parameter:
- The whole output of the previous ``Request`` callback or
``process_spider_output`` method is awaited at this point.
- If an exception raises while awaiting the output of the previous
``Request`` callback or ``process_spider_output`` method, none of that
output will be processed.
This contrasts with the regular behavior, where all items yielded before
an exception raises are processed.
Asynchronous-to-synchronous conversions are supported for backward
compatibility, but they are deprecated and will stop working in a future
version of Scrapy.
To avoid asynchronous-to-synchronous conversions, when defining ``Request``
callbacks as coroutine methods or when using spider middlewares whose
``process_spider_output`` method is an :term:`asynchronous generator`, all
active spider middlewares must either have their ``process_spider_output``
method defined as an asynchronous generator or :ref:`define a
process_spider_output_async method <universal-spider-middleware>`.
.. _sync-async-spider-middleware-users:
For middleware users
--------------------
If you have asynchronous callbacks or use asynchronous-only spider middlewares
you should make sure the asynchronous-to-synchronous conversions
:ref:`described above <sync-async-spider-middleware>` don't happen. To do this,
make sure all spider middlewares you use support asynchronous spider output.
Even if you don't have asynchronous callbacks and don't use asynchronous-only
spider middlewares in your project, it's still a good idea to make sure all
middlewares you use support asynchronous spider output, so that it will be easy
to start using asynchronous callbacks in the future. Because of this, Scrapy
logs a warning when it detects a synchronous-only spider middleware.
If you want to update middlewares you wrote, see the :ref:`following section
<sync-async-spider-middleware-authors>`. If you have 3rd-party middlewares that
aren't yet updated by their authors, you can :ref:`subclass <tut-inheritance>`
them to make them :ref:`universal <universal-spider-middleware>` and use the
subclasses in your projects.
.. _sync-async-spider-middleware-authors:
For middleware authors
----------------------
If you have a spider middleware that defines a synchronous
``process_spider_output`` method, you should update it to support asynchronous
spider output for :ref:`better compatibility <sync-async-spider-middleware>`,
even if you don't yet use it with asynchronous callbacks, especially if you
publish this middleware for other people to use. You have two options for this:
1. Make the middleware asynchronous, by making the ``process_spider_output``
method an :term:`asynchronous generator`.
2. Make the middleware universal, as described in the :ref:`next section
<universal-spider-middleware>`.
If your middleware won't be used in projects with synchronous-only middlewares,
e.g. because it's an internal middleware and you know that all other
middlewares in your projects are already updated, it's safe to choose the first
option. Otherwise, it's better to choose the second option.
.. _universal-spider-middleware:
Universal spider middlewares
----------------------------
To allow writing a spider middleware that supports asynchronous execution of
its ``process_spider_output`` method in Scrapy 2.7 and later (avoiding
:ref:`asynchronous-to-synchronous conversions <sync-async-spider-middleware>`)
while maintaining support for older Scrapy versions, you may define
``process_spider_output`` as a synchronous method and define an
:term:`asynchronous generator` version of that method with an alternative name:
``process_spider_output_async``.
For example:
.. code-block:: python
class UniversalSpiderMiddleware:
def process_spider_output(self, response, result):
for r in result:
# ... do something with r
yield r
async def process_spider_output_async(self, response, result):
async for r in result:
# ... do something with r
yield r
.. note:: This is an interim measure to allow, for a time, to write code that
works in Scrapy 2.7 and later without requiring
asynchronous-to-synchronous conversions, and works in earlier Scrapy
versions as well.
In some future version of Scrapy, however, this feature will be
deprecated and, eventually, in a later version of Scrapy, this
feature will be removed, and all spider middlewares will be expected
to define their ``process_spider_output`` method as an asynchronous
generator.
Since 2.13.0, Scrapy provides a base class,
:class:`~scrapy.spidermiddlewares.base.BaseSpiderMiddleware`, which implements
the ``process_spider_output()`` and ``process_spider_output_async()`` methods,
so instead of duplicating the processing code you can override the
``get_processed_request()`` and/or the ``get_processed_item()`` method.

View File

@ -130,44 +130,39 @@ these exceptions.
.. _download-handlers-ref:
Built-in download handlers reference
====================================
Built-in HTTP download handlers reference
=========================================
DataURIDownloadHandler
----------------------
Scrapy ships several handlers for HTTP and HTTPS requests. While all of them
support basic features, they may differ in support of specific Scrapy features
and settings and HTTP protocol features. See the documentation of specific
handlers and specific settings for more information. Additionally, as the
underlying HTTP client implementations differ between handlers, the behavior of
specific websites may be different when doing the same Scrapy requests but
using different handlers.
.. autoclass:: scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler
Here is a comparison of some features of the built-in HTTP handlers, see the
individual handler docs for more differences:
| Supported scheme: ``data``.
| Lazy: no.
================== ================= ===================== ====================
Feature H2DownloadHandler HTTP11DownloadHandler HttpxDownloadHandler
================== ================= ===================== ====================
Requires asyncio No No Yes
Requires a reactor Yes Yes No
HTTP/1.1 No Yes Yes
HTTP/2 Yes No Yes
TLS implementation ``cryptography`` ``cryptography`` Stdlib ``ssl``
HTTP proxies No Yes Yes
SOCKS proxies No No Yes
================== ================= ===================== ====================
This handler supports RFC 2397 ``data:content/type;base64,`` data URIs.
You can find additional HTTP download handlers in the
scrapy-download-handlers-incubator_ package. This package is made by the Scrapy
developers and contains experimental handlers that may be included in some
later Scrapy version but can already be used. Please refer to the documentation
of this package for more information.
FileDownloadHandler
-------------------
.. autoclass:: scrapy.core.downloader.handlers.file.FileDownloadHandler
| Supported scheme: ``file``.
| Lazy: no.
This handler supports ``file:///path`` local file URIs. It doesn't
support remote files.
FTPDownloadHandler
------------------
.. autoclass:: scrapy.core.downloader.handlers.ftp.FTPDownloadHandler
| Supported scheme: ``ftp``.
| Lazy: no.
This handler supports ``ftp://host/path`` FTP URIs.
It's implemented using :mod:`twisted.protocols.ftp`.
.. note::
This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``.
.. _scrapy-download-handlers-incubator: https://github.com/scrapy-plugins/scrapy-download-handlers-incubator
.. _twisted-http2-handler:
@ -177,7 +172,9 @@ H2DownloadHandler
.. autoclass:: scrapy.core.downloader.handlers.http2.H2DownloadHandler
| Supported scheme: ``https``.
| Lazy: yes.
| :ref:`Lazy <lazy-download-handlers>`: yes.
| :ref:`Requires asyncio support <using-asyncio>`: no.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: yes.
This handler supports ``https://host/path`` URLs and uses the HTTP/2 protocol
for them.
@ -196,61 +193,96 @@ If you want to use this handler you need to replace the default one for the
"https": "scrapy.core.downloader.handlers.http2.H2DownloadHandler",
}
Features and limitations
^^^^^^^^^^^^^^^^^^^^^^^^
.. warning::
This handler is experimental, and not yet recommended for production
environments. Future Scrapy versions may introduce related changes without
a deprecation period or warning.
.. note::
=========================== ================================================
HTTP proxies No (not implemented)
SOCKS proxies No (not supported by the library)
HTTP/2 Yes
``response.certificate`` :class:`twisted.internet.ssl.Certificate` object
Per-request ``bindaddress`` Yes
TLS implementation ``pyOpenSSL``/``cryptography``
=========================== ================================================
Known limitations of the HTTP/2 implementation in this handler include:
Other limitations:
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
HTTP/2 unencrypted (refer `http2 faq`_).
- No support for HTTP/1.1.
- No setting to specify a maximum `frame size`_ larger than the default
value, 16384. Connections to servers that send a larger frame will
fail.
- IPv6 support requires setting :setting:`TWISTED_DNS_RESOLVER`
to ``scrapy.resolver.CachingHostnameResolver``.
- No support for `server pushes`_, which are ignored.
- No support for the :signal:`bytes_received` and :signal:`headers_received`
signals.
- No support for the :signal:`bytes_received` and
:signal:`headers_received` signals.
Known limitations of the HTTP/2 support:
- No support for HTTP/2 Cleartext (h2c), since no major browser supports
HTTP/2 unencrypted (refer `http2 faq`_).
- No setting to specify a maximum `frame size`_ larger than the default
value, 16384. Connections to servers that send a larger frame will fail.
- No support for `server pushes`_, which are ignored.
.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption
.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2
.. note::
This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``.
HTTP11DownloadHandler
---------------------
.. autoclass:: scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler
| Supported schemes: ``http``, ``https``.
| Lazy: no.
| :ref:`Lazy <lazy-download-handlers>`: no.
| :ref:`Requires asyncio support <using-asyncio>`: no.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: yes.
This handler supports ``http://host/path`` and ``https://host/path`` URLs and
uses the HTTP/1.1 protocol for them.
It's implemented using :mod:`twisted.web.client`.
.. note::
This handler is not supported when :setting:`TWISTED_REACTOR_ENABLED` is ``False``.
Features and limitations
^^^^^^^^^^^^^^^^^^^^^^^^
=========================== ================================================
HTTP proxies Yes
SOCKS proxies No (not supported by the library)
HTTP/2 No (implemented as a separate handler)
``response.certificate`` :class:`twisted.internet.ssl.Certificate` object
Per-request ``bindaddress`` Yes
TLS implementation ``pyOpenSSL``/``cryptography``
=========================== ================================================
Other limitations:
- IPv6 support requires setting :setting:`TWISTED_DNS_RESOLVER`
to ``scrapy.resolver.CachingHostnameResolver``.
- HTTPS proxies to HTTPS destinations are not supported.
HttpxDownloadHandler
--------------------
.. versionadded:: 2.15.0
.. autoclass:: scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler
| Supported schemes: ``http``, ``https``.
| Lazy: no.
| :ref:`Lazy <lazy-download-handlers>`: no.
| :ref:`Requires asyncio support <using-asyncio>`: yes.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: no.
This handler supports ``http://host/path`` and ``https://host/path`` URLs and
uses the HTTP/1.1 protocol for them.
uses the HTTP/1.1 or HTTP/2 protocol for them.
It's implemented using the ``httpx`` library and needs it to be installed.
@ -264,33 +296,83 @@ If you want to use this handler you need to replace the default ones for the
"https": "scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler",
}
Features and limitations
^^^^^^^^^^^^^^^^^^^^^^^^
.. warning::
This handler is experimental, and not yet recommended for production
environments. Future Scrapy versions may introduce related changes without
a deprecation period or warning or even remove it altogether.
.. note::
=========================== =======================================
HTTP proxies Yes
SOCKS proxies Yes (SOCKS5; requires ``httpx[socks]``)
HTTP/2 Yes (requires ``httpx[http2]``)
``response.certificate`` DER bytes
Per-request ``bindaddress`` No (not supported by the library)
TLS implementation Standard library ``ssl``
=========================== =======================================
As this handler is based on a different HTTP client implementation compared
to :class:`~.HTTP11DownloadHandler`, it's expected that its behavior on
some websites may be different. Additionally, these are the Scrapy features
that are explicitly not supported when using it:
Other limitations:
- Proxy support (the :reqmeta:`proxy` meta key).
- The handler creates a separate connection pool for each proxy URL (due to
limitations of ``httpx``) which may lead to higher resource usage when
using proxy rotation.
- Per-request bind address support (the :reqmeta:`bindaddress` meta key).
The global :setting:`DOWNLOAD_BIND_ADDRESS` setting is supported but the
port number, if specified, will be ignored.
.. setting:: HTTPX_HTTP2_ENABLED
- The :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS` and
:setting:`DOWNLOADER_CLIENT_TLS_METHOD` settings.
HTTPX_HTTP2_ENABLED
^^^^^^^^^^^^^^^^^^^
- Settings specific to the Twisted networking or HTTP implementation, like
:setting:`DNS_RESOLVER`.
Default: ``False``
- Using :ref:`non-asyncio reactors <disable-asyncio>` (``httpx`` requires
``asyncio``).
Whether to enable HTTP/2 support in this handler. The ``httpx[http2]`` extra
needs to be installed if you want to enable this setting.
.. versionadded:: VERSION
Built-in non-HTTP download handlers reference
=============================================
DataURIDownloadHandler
----------------------
.. autoclass:: scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler
| Supported scheme: ``data``.
| :ref:`Lazy <lazy-download-handlers>`: no.
| :ref:`Requires asyncio support <using-asyncio>`: no.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: no.
This handler supports RFC 2397 ``data:content/type;base64,`` data URIs.
FileDownloadHandler
-------------------
.. autoclass:: scrapy.core.downloader.handlers.file.FileDownloadHandler
| Supported scheme: ``file``.
| :ref:`Lazy <lazy-download-handlers>`: no.
| :ref:`Requires asyncio support <using-asyncio>`: no.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: no.
This handler supports ``file:///path`` local file URIs. It doesn't
support remote files.
FTPDownloadHandler
------------------
.. autoclass:: scrapy.core.downloader.handlers.ftp.FTPDownloadHandler
| Supported scheme: ``ftp``.
| :ref:`Lazy <lazy-download-handlers>`: no.
| :ref:`Requires asyncio support <using-asyncio>`: no.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: yes.
This handler supports ``ftp://host/path`` FTP URIs.
It's implemented using :mod:`twisted.protocols.ftp`.
S3DownloadHandler
-----------------
@ -298,7 +380,9 @@ S3DownloadHandler
.. autoclass:: scrapy.core.downloader.handlers.s3.S3DownloadHandler
| Supported scheme: ``s3``.
| Lazy: yes.
| :ref:`Lazy <lazy-download-handlers>`: yes.
| :ref:`Requires asyncio support <using-asyncio>`: no.
| :ref:`Requires a Twisted reactor <asyncio-without-reactor>`: no.
This handler supports ``s3://bucket/path`` S3 URIs.

View File

@ -307,26 +307,15 @@ HttpAuthMiddleware
.. class:: HttpAuthMiddleware
This middleware authenticates all requests generated from certain spiders
using `Basic access authentication`_ (aka. HTTP auth).
This middleware authenticates requests using `Basic access authentication`_
(aka. HTTP auth).
To enable HTTP authentication for a spider, set the ``http_user`` and
``http_pass`` spider attributes to the authentication data and the
``http_auth_domain`` spider attribute to the domain which requires this
authentication (its subdomains will be also handled in the same way).
You can set ``http_auth_domain`` to ``None`` to enable the
authentication for all requests but you risk leaking your authentication
credentials to unrelated domains.
Use the :setting:`HTTPAUTH_USER`, :setting:`HTTPAUTH_PASS`, and
:setting:`HTTPAUTH_DOMAIN` settings to configure it. You can also override
the credentials per request via :attr:`~scrapy.Request.meta` keys
:reqmeta:`http_user`, :reqmeta:`http_pass`, and :reqmeta:`http_auth_domain`.
.. warning::
In previous Scrapy versions HttpAuthMiddleware sent the authentication
data with all requests, which is a security problem if the spider
makes requests to several different domains. Currently if the
``http_auth_domain`` attribute is not set, the middleware will use the
domain of the first request, which will work for some spiders but not
for others. In the future the middleware will produce an error instead.
Example:
Example using settings (e.g. in :attr:`~scrapy.Spider.custom_settings`):
.. code-block:: python
@ -334,13 +323,62 @@ HttpAuthMiddleware
class SomeIntranetSiteSpider(CrawlSpider):
http_user = "someuser"
http_pass = "somepass"
http_auth_domain = "intranet.example.com"
name = "intranet.example.com"
custom_settings = {
"HTTPAUTH_USER": "someuser",
"HTTPAUTH_PASS": "somepass",
"HTTPAUTH_DOMAIN": "intranet.example.com",
}
# .. rest of the spider code omitted ...
Example using per-request meta:
.. code-block:: python
async def start(self):
yield Request(
"https://intranet.example.com/protected/",
meta={
"http_user": "someuser",
"http_pass": "somepass",
"http_auth_domain": "intranet.example.com",
},
)
.. setting:: HTTPAUTH_USER
HTTPAUTH_USER
~~~~~~~~~~~~~
Default: ``""``
The username to use for HTTP basic authentication, applied to all requests
whose URL matches :setting:`HTTPAUTH_DOMAIN`.
.. setting:: HTTPAUTH_PASS
HTTPAUTH_PASS
~~~~~~~~~~~~~
Default: ``""``
The password to use for HTTP basic authentication.
.. setting:: HTTPAUTH_DOMAIN
HTTPAUTH_DOMAIN
~~~~~~~~~~~~~~~
Default: ``None``
The domain (and its subdomains) to which HTTP basic authentication credentials
are sent. Set to ``None`` to send credentials with all requests, but be aware
that this risks leaking credentials to unrelated domains.
This setting must be explicitly configured whenever :setting:`HTTPAUTH_USER`
or :setting:`HTTPAUTH_PASS` is set.
.. _Basic access authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
@ -745,7 +783,24 @@ HttpProxyMiddleware
Handling of this meta key needs to be implemented inside the :ref:`download
handler <topics-download-handlers>`, so it's not guaranteed to be supported
by all 3rd-party handlers. It's currently unsupported by
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`.
:class:`~scrapy.core.downloader.handlers.http2.H2DownloadHandler`.
.. note::
Usually a proxy URL uses the ``http://`` scheme. More rarely, it uses the
``https://`` one. While both kinds of proxy URLs can be used with both HTTP
and HTTPS destination URLs, the specifics of the network exchange are
different for all 4 cases and it's possible that HTTPS proxies are fully or
partially unsupported by a given download handler. Currently,
:class:`~scrapy.core.downloader.handlers.http11.HTTP11DownloadHandler`
supports HTTPS proxies only for HTTP destinations.
.. note::
If the download handler supports it, you can use a SOCKS proxy URL (e.g.
``socks5://username:password@some_proxy_server:port``).
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`
supports SOCKS proxies while other built-in handlers don't.
HttpProxyMiddleware settings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -1042,6 +1097,21 @@ has been exceeded (see :setting:`RETRY_TIMES`). To learn about uncaught
exception propagation, see
:meth:`~scrapy.downloadermiddlewares.DownloaderMiddleware.process_exception`.
.. setting:: RETRY_GIVE_UP_LOG_LEVEL
RETRY_GIVE_UP_LOG_LEVEL
^^^^^^^^^^^^^^^^^^^^^^^
Default: ``"ERROR"``
:ref:`Logging level <levels>` used for the message logged when a request
exceeds its retries.
Can be a level name (e.g. ``"WARNING"``) or a number (e.g. ``logging.WARNING``
or ``30``).
See also: :reqmeta:`give_up_log_level`, :func:`get_retry_request`.
.. setting:: RETRY_PRIORITY_ADJUST
RETRY_PRIORITY_ADJUST
@ -1141,9 +1211,9 @@ Based on :class:`~urllib.robotparser.RobotFileParser`:
* is compliant with `Martijn Koster's 1996 draft specification
<https://www.robotstxt.org/norobots-rfc.txt>`_
* lacks support for wildcard matching
* lacks support for wildcard matching (before Python 3.14.5)
* doesn't use the length based rule
* doesn't use the length based rule (before Python 3.14.5)
It is faster than Protego and backward-compatible with versions of Scrapy before 1.8.0.

View File

@ -83,7 +83,7 @@ request with Scrapy.
It might be enough to yield a :class:`~scrapy.Request` with the same HTTP
method and URL. However, you may also need to reproduce the body, headers and
form parameters (see :class:`~scrapy.FormRequest`) of that request.
form parameters (see :ref:`form`) of that request.
As all major browsers allow to export the requests in curl_ format, Scrapy
incorporates the method :meth:`~scrapy.Request.from_curl` to generate an equivalent

View File

@ -93,24 +93,25 @@ described next.
1. Declaring a serializer in the field
--------------------------------------
If you use :class:`~scrapy.Item` you can declare a serializer in the
:ref:`field metadata <topics-items-fields>`. The serializer must be
a callable which receives a value and returns its serialized form.
Every :ref:`item type <item-types>` except :class:`dict` lets you declare a
serializer in the :ref:`field metadata <topics-items-fields>`. The serializer
must be a callable which receives a value and returns its serialized form.
Example:
.. code-block:: python
import scrapy
from dataclasses import dataclass, field
def serialize_price(value):
return f"$ {str(value)}"
class Product(scrapy.Item):
name = scrapy.Field()
price = scrapy.Field(serializer=serialize_price)
@dataclass
class Product:
name: str
price: float = field(metadata={"serializer": serialize_price})
2. Overriding the serialize_field() method

View File

@ -136,7 +136,18 @@ Core Stats extension
Enable the collection of core statistics, provided the stats collection is
enabled (see :ref:`topics-stats`).
.. _topics-extensions-ref-telnetconsole:
The following stats are collected:
* ``start_time``: start date/time of the crawl (:class:`~datetime.datetime`).
* ``finish_time``: end date/time of the crawl (:class:`~datetime.datetime`).
* ``elapsed_time_seconds``: total crawl duration in seconds (:class:`float`).
* ``finish_reason``: the closing reason string (e.g. ``"finished"``,
``"closespider_timeout"``).
* ``item_scraped_count``: total number of items that passed all pipelines.
* ``item_dropped_count``: total number of items dropped by a pipeline.
* ``item_dropped_reasons_count/<ExceptionName>``: per-exception drop count
(e.g. ``item_dropped_reasons_count/DropItem``).
* ``response_received_count``: total number of HTTP responses received.
Log Count extension
~~~~~~~~~~~~~~~~~~~
@ -146,6 +157,8 @@ Log Count extension
.. autoclass:: LogCount
.. _topics-extensions-ref-telnetconsole:
Telnet console extension
~~~~~~~~~~~~~~~~~~~~~~~~
@ -247,6 +260,7 @@ settings:
* :setting:`CLOSESPIDER_TIMEOUT_NO_ITEM`
* :setting:`CLOSESPIDER_ITEMCOUNT`
* :setting:`CLOSESPIDER_PAGECOUNT`
* :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM`
* :setting:`CLOSESPIDER_ERRORCOUNT`
.. note::
@ -260,12 +274,11 @@ settings:
CLOSESPIDER_TIMEOUT
"""""""""""""""""""
Default: ``0``
Default: ``0.0``
An integer which specifies a number of seconds. If the spider remains open for
more than that number of seconds, it will be automatically closed with the
reason ``closespider_timeout``. If zero (or non set), spiders won't be closed by
timeout.
If the spider remains open for more than this number of seconds, it will be
automatically closed with the reason ``closespider_timeout``. If zero (or non
set), spiders won't be closed by timeout.
.. setting:: CLOSESPIDER_TIMEOUT_NO_ITEM

View File

@ -104,6 +104,15 @@ If you wish to log the requests that couldn't be serialized, you can set the
:setting:`SCHEDULER_DEBUG` setting to ``True`` in the project's settings page.
It is ``False`` by default.
.. note:: Because requests are serialized with :mod:`pickle`, the objects you
store on a request, such as the values of its
:attr:`~scrapy.Request.cb_kwargs` and :attr:`~scrapy.Request.meta`
dictionaries, are deep-copied when the request is written to and later read
back from the job directory. As a result, the callback receives a *copy* of
those objects rather than the original ones, and changes made to the copy are
not reflected in the original object. Keep this in mind if you rely on
sharing mutable state through ``cb_kwargs`` or ``meta``.
.. _job-dir-contents:
Job directory contents

View File

@ -68,19 +68,21 @@ Response, Item, Spider and Selector objects.
You can enter the telnet console and inspect how many objects (of the classes
mentioned above) are currently alive using the ``prefs()`` function which is an
alias to the :func:`~scrapy.utils.trackref.print_live_refs` function::
alias to the :func:`~scrapy.utils.trackref.print_live_refs` function:
.. code-block:: bash
telnet localhost 6023
.. code-block:: pycon
.. code-block:: pycon
>>> prefs()
Live References
>>> prefs()
Live References
ExampleSpider 1 oldest: 15s ago
HtmlResponse 10 oldest: 1s ago
Selector 2 oldest: 0s ago
FormRequest 878 oldest: 7s ago
ExampleSpider 1 oldest: 15s ago
HtmlResponse 10 oldest: 1s ago
Selector 2 oldest: 0s ago
Request 878 oldest: 7s ago
As you can see, that report also shows the "age" of the oldest object in each
class. If you're running multiple spiders per process chances are you can

View File

@ -102,14 +102,13 @@ One approach to overcome this is to define items using the
.. code-block:: python
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class InventoryItem:
name: Optional[str] = field(default=None)
price: Optional[float] = field(default=None)
stock: Optional[int] = field(default=None)
name: str | None = field(default=None)
price: float | None = field(default=None)
stock: int | None = field(default=None)
.. _topics-loaders-processors:
@ -228,7 +227,8 @@ metadata. Here is an example:
.. code-block:: python
import scrapy
from dataclasses import dataclass, field
from itemloaders.processors import Join, MapCompose, TakeFirst
from w3lib.html import remove_tags
@ -238,14 +238,21 @@ metadata. Here is an example:
return value
class Product(scrapy.Item):
name = scrapy.Field(
input_processor=MapCompose(remove_tags),
output_processor=Join(),
@dataclass
class Product:
name: str | None = field(
default=None,
metadata={
"input_processor": MapCompose(remove_tags),
"output_processor": Join(),
},
)
price = scrapy.Field(
input_processor=MapCompose(remove_tags, filter_price),
output_processor=TakeFirst(),
price: str | None = field(
default=None,
metadata={
"input_processor": MapCompose(remove_tags, filter_price),
"output_processor": TakeFirst(),
},
)

View File

@ -194,6 +194,48 @@ If :setting:`LOG_SHORT_NAMES` is set, then the logs will not display the Scrapy
component that prints the log. It is unset by default, hence logs contain the
Scrapy component responsible for that log output.
Rotating log files
------------------
Scrapy's :setting:`LOG_FILE` setting writes logs to a single file. It does not
rotate log files automatically, but you can use Python's standard
:mod:`logging.handlers` module when running Scrapy from a script.
For example, to rotate the log file every day:
.. skip: next
.. code-block:: python
import logging
from logging.handlers import TimedRotatingFileHandler
from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings
from myproject.spiders.myspider import MySpider
settings = get_project_settings()
process = CrawlerProcess(settings, install_root_handler=False)
handler = TimedRotatingFileHandler(
"scrapy.log",
when="midnight",
backupCount=7,
encoding=settings.get("LOG_ENCODING"),
)
handler.setFormatter(
logging.Formatter(settings.get("LOG_FORMAT"), settings.get("LOG_DATEFORMAT"))
)
root_logger = logging.getLogger()
root_logger.setLevel(settings.get("LOG_LEVEL"))
root_logger.addHandler(handler)
process.crawl(MySpider)
process.start()
Command-line options
--------------------

View File

@ -81,9 +81,6 @@ thumbnailing and normalizing images to JPEG/RGB format.
Enabling your Media Pipeline
============================
.. setting:: IMAGES_STORE
.. setting:: FILES_STORE
To enable your media pipeline you must first add it to your project
:setting:`ITEM_PIPELINES` setting.
@ -102,6 +99,8 @@ For Files Pipeline, use:
.. note::
You can also use both the Files and Images Pipeline at the same time.
.. setting:: IMAGES_STORE
.. setting:: FILES_STORE
Then, configure the target storage setting to a valid value that will be used
for storing the downloaded images. Otherwise the pipeline will remain disabled,
@ -337,17 +336,18 @@ respectively), the pipeline will put the results under the respective field
When using :ref:`item types <item-types>` for which fields are defined beforehand,
you must define both the URLs field and the results field. For example, when
using the images pipeline, items must define both the ``image_urls`` and the
``images`` field. For instance, using the :class:`~scrapy.Item` class:
``images`` field. For instance, using a dataclass:
.. code-block:: python
import scrapy
from dataclasses import dataclass, field
class MyItem(scrapy.Item):
@dataclass
class MyItem:
# ... other item fields ...
image_urls = scrapy.Field()
images = scrapy.Field()
image_urls: list[str] = field(default_factory=list)
images: list[dict] = field(default_factory=list)
If you want to use another field name for the URLs key or for the results key,
it is also possible to override it.

View File

@ -387,6 +387,26 @@ crawl::
curl http://scrapy2.mycompany.com:6800/schedule.json -d project=myproject -d spider=spider1 -d part=2
curl http://scrapy3.mycompany.com:6800/schedule.json -d project=myproject -d spider=spider1 -d part=3
.. _large-project-startup:
Reducing startup time in large projects
=======================================
When running a spider with ``scrapy crawl``, Scrapy loads all modules listed in
:setting:`SPIDER_MODULES` to find the target spider. In large projects with
many spiders, this can noticeably increase startup time and memory usage.
To avoid loading every spider module, override :setting:`SPIDER_MODULES` on the
command line to point only to the module that contains the spider you want to
run:
.. code-block:: shell
scrapy crawl myspider -s SPIDER_MODULES=myproject.spiders.myspider
Because :setting:`SPIDER_MODULES` is a list setting, you can include multiple
modules by separating them with commas.
.. _bans:
Avoiding getting banned
@ -409,6 +429,10 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
* use a pool of rotating IPs. For example, the free `Tor project`_ or paid
services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a
super proxy that you can attach your own proxies to.
* for HTTPS websites, if blocking appears related to TLS behavior, consider
adjusting the :setting:`DOWNLOAD_TLS_MIN_VERSION` and
:setting:`DOWNLOAD_TLS_MAX_VERSION` settings, since some websites may respond
differently depending on the TLS method used by the client.
* use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy
plugin <https://github.com/scrapy-plugins/scrapy-zyte-api>`__ and additional
features, like `AI web scraping <https://www.zyte.com/ai-web-scraping/>`__

View File

@ -117,6 +117,9 @@ Request objects
:param encoding: the encoding of this request (defaults to ``'utf-8'``).
This encoding will be used to percent-encode the URL and to convert the
body to bytes (if given as a string).
To disable URL percent-encoding for a request, use the
:reqmeta:`verbatim_url` request meta key.
:type encoding: str
:param priority: sets :attr:`priority`, defaults to ``0``.
@ -136,9 +139,13 @@ Request objects
.. attribute:: Request.url
A string containing the URL of this request. Keep in mind that this
attribute contains the escaped URL, so it can differ from the URL passed in
the ``__init__()`` method.
A string containing the URL of this request.
Keep in mind that this attribute contains the escaped URL, so it can
differ from the URL passed in the ``__init__()`` method.
If :reqmeta:`verbatim_url` is set to ``True``, the URL is kept as
passed to ``__init__()``.
This attribute is read-only. To change the URL of a Request use
:meth:`replace`.
@ -181,6 +188,13 @@ Request objects
``failure.request.cb_kwargs`` in the request's errback. For more information,
see :ref:`errback-cb_kwargs`.
.. note:: When :setting:`JOBDIR` is set, requests are serialized to disk
with :mod:`pickle` (see :ref:`request-serialization`). As a result,
the callback receives a deep copy of any object stored in
``cb_kwargs``, so mutating such an object in the callback does not
affect the original. Avoid relying on shared mutable state passed
through ``cb_kwargs`` in that case.
.. attribute:: Request.meta
:value: {}
@ -246,6 +260,78 @@ Request objects
.. automethod:: to_dict
.. _form:
Creating requests that submit HTML forms
----------------------------------------
Use :doc:`form2request <form2request:index>` to build request data from an HTML
``<form>`` element and convert it to a :class:`~scrapy.Request`.
Install it with pip:
.. code-block:: bash
pip install form2request
Select the desired form with CSS or XPath, then build and convert request
data:
.. code-block:: python
from form2request import form2request
def parse(self, response):
form = response.css("form#search")
request_data = form2request(form, data={"q": "scrapy"})
yield request_data.to_scrapy(callback=self.parse_results)
Use ``data`` to override field values. To drop a field from the resulting
request, set its value to ``None``.
By default, form2request simulates clicking the first submit button. To submit
without clicking any button, pass ``click=False``. To click a specific submit
button, pass its element:
.. code-block:: python
def parse(self, response):
form = response.css("form#checkout")
submit = form.css('button[name="pay"]')
request_data = form2request(form, click=submit)
.. _topics-request-response-ref-request-userlogin:
Using form2request to simulate a user login
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It is usual for web sites to provide pre-populated form fields through ``<input
type="hidden">`` elements, such as session related data or authentication
tokens (for login pages). Build the request from the form and only override the
credentials:
.. code-block:: python
import scrapy
from form2request import form2request
class LoginSpider(scrapy.Spider):
name = "example.com"
start_urls = ["http://www.example.com/users/login.php"]
def parse(self, response):
form = response.css("form")
request_data = form2request(
form,
data={"username": "john", "password": "secret"},
)
yield request_data.to_scrapy(callback=self.after_login)
def after_login(self, response): ...
Other functions related to requests
-----------------------------------
@ -469,6 +555,11 @@ in your :meth:`fingerprint` method implementation:
.. autofunction:: scrapy.utils.request.fingerprint
By default, request fingerprinting canonicalizes the request URL. If
:reqmeta:`verbatim_url` is set to ``True``, fingerprinting does not
canonicalize the URL, and the ``keep_fragments`` parameter is ignored (it is
effectively true).
For example, to take the value of a request header named ``X-ID`` into
account:
@ -630,14 +721,19 @@ Those are:
* :reqmeta:`download_timeout`
* ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info)
* ``ftp_user`` (See :setting:`FTP_USER` for more info)
* :reqmeta:`give_up_log_level`
* :reqmeta:`handle_httpstatus_all`
* :reqmeta:`handle_httpstatus_list`
* :reqmeta:`http_auth_domain`
* :reqmeta:`http_pass`
* :reqmeta:`http_user`
* :reqmeta:`is_start_request`
* :reqmeta:`max_retry_times`
* :reqmeta:`proxy`
* :reqmeta:`redirect_reasons`
* :reqmeta:`redirect_urls`
* :reqmeta:`referrer_policy`
* :reqmeta:`verbatim_url`
.. reqmeta:: bindaddress
@ -705,15 +801,59 @@ download_fail_on_dataloss
Whether or not to fail on broken responses. See:
:setting:`DOWNLOAD_FAIL_ON_DATALOSS`.
.. reqmeta:: give_up_log_level
give_up_log_level
-----------------
:ref:`Logging level <levels>` used for the message logged when a request
exceeds its retries. See :setting:`RETRY_GIVE_UP_LOG_LEVEL` for details.
.. reqmeta:: http_auth_domain
http_auth_domain
----------------
Overrides :setting:`HTTPAUTH_DOMAIN` for this request.
.. reqmeta:: http_pass
http_pass
---------
Overrides :setting:`HTTPAUTH_PASS` for this request.
.. reqmeta:: http_user
http_user
---------
Overrides :setting:`HTTPAUTH_USER` for this request.
.. reqmeta:: max_retry_times
max_retry_times
---------------
The meta key is used set retry times per request. When initialized, the
The meta key is used set retry times per request. When set, the
:reqmeta:`max_retry_times` meta key takes higher precedence over the
:setting:`RETRY_TIMES` setting.
.. reqmeta:: verbatim_url
verbatim_url
------------
Set this key to ``True`` to keep the request URL as passed to
:class:`~scrapy.Request`, without URL percent-encoding.
When this key is enabled, :func:`~scrapy.utils.request.fingerprint` does not
canonicalize the request URL, so requests whose URLs differ only in
characters that would otherwise be canonicalized get different fingerprints.
In this mode, the ``keep_fragments`` parameter is ignored, and it is
effectively true.
.. _topics-stop-response-download:
@ -771,159 +911,6 @@ Request subclasses
Here is the list of built-in :class:`~scrapy.Request` subclasses. You can also subclass
it to implement your own custom functionality.
FormRequest objects
-------------------
The FormRequest class extends the base :class:`~scrapy.Request` with functionality for
dealing with HTML forms. It uses `lxml.html forms`_ to pre-populate form
fields with form data from :class:`Response` objects.
.. _lxml.html forms: https://lxml.de/lxmlhtml.html#forms
.. currentmodule:: None
.. class:: scrapy.FormRequest(url, [formdata, ...])
:canonical: scrapy.http.request.form.FormRequest
The :class:`~scrapy.FormRequest` class adds a new keyword parameter to the ``__init__()`` method. The
remaining arguments are the same as for the :class:`~scrapy.Request` class and are
not documented here.
:param formdata: is a dictionary (or iterable of (key, value) tuples)
containing HTML Form data which will be url-encoded and assigned to the
body of the request.
:type formdata: dict or collections.abc.Iterable
The :class:`~scrapy.FormRequest` objects support the following class method in
addition to the standard :class:`~scrapy.Request` methods:
.. classmethod:: from_response(response, [formname=None, formid=None, formnumber=0, formdata=None, formxpath=None, formcss=None, clickdata=None, dont_click=False, ...])
Returns a new :class:`~scrapy.FormRequest` object with its form field values
pre-populated with those found in the HTML ``<form>`` element contained
in the given response. For an example see
:ref:`topics-request-response-ref-request-userlogin`.
The policy is to automatically simulate a click, by default, on any form
control that looks clickable, like a ``<input type="submit">``. Even
though this is quite convenient, and often the desired behaviour,
sometimes it can cause problems which could be hard to debug. For
example, when working with forms that are filled and/or submitted using
javascript, the default :meth:`from_response` behaviour may not be the
most appropriate. To disable this behaviour you can set the
``dont_click`` argument to ``True``. Also, if you want to change the
control clicked (instead of disabling it) you can also use the
``clickdata`` argument.
.. caution:: Using this method with select elements which have leading
or trailing whitespace in the option values will not work due to a
`bug in lxml`_, which should be fixed in lxml 3.8 and above.
:param response: the response containing a HTML form which will be used
to pre-populate the form fields
:type response: :class:`~scrapy.http.Response` object
:param formname: if given, the form with name attribute set to this value will be used.
:type formname: str
:param formid: if given, the form with id attribute set to this value will be used.
:type formid: str
:param formxpath: if given, the first form that matches the xpath will be used.
:type formxpath: str
:param formcss: if given, the first form that matches the css selector will be used.
:type formcss: str
:param formnumber: the number of form to use, when the response contains
multiple forms. The first one (and also the default) is ``0``.
:type formnumber: int
:param formdata: fields to override in the form data. If a field was
already present in the response ``<form>`` element, its value is
overridden by the one passed in this parameter. If a value passed in
this parameter is ``None``, the field will not be included in the
request, even if it was present in the response ``<form>`` element.
:type formdata: dict
:param clickdata: attributes to lookup the control clicked. If it's not
given, the form data will be submitted simulating a click on the
first clickable element. In addition to html attributes, the control
can be identified by its zero-based index relative to other
submittable inputs inside the form, via the ``nr`` attribute.
:type clickdata: dict
:param dont_click: If True, the form data will be submitted without
clicking in any element.
:type dont_click: bool
The other parameters of this class method are passed directly to the
:class:`~scrapy.FormRequest` ``__init__()`` method.
.. currentmodule:: scrapy.http
Request usage examples
----------------------
Using FormRequest to send data via HTTP POST
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If you want to simulate a HTML Form POST in your spider and send a couple of
key-value fields, you can return a :class:`~scrapy.FormRequest` object (from your
spider) like this:
.. skip: next
.. code-block:: python
return [
FormRequest(
url="http://www.example.com/post/action",
formdata={"name": "John Doe", "age": "27"},
callback=self.after_post,
)
]
.. _topics-request-response-ref-request-userlogin:
Using FormRequest.from_response() to simulate a user login
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It is usual for web sites to provide pre-populated form fields through ``<input
type="hidden">`` elements, such as session related data or authentication
tokens (for login pages). When scraping, you'll want these fields to be
automatically pre-populated and only override a couple of them, such as the
user name and password. You can use the :meth:`.FormRequest.from_response`
method for this job. Here's an example spider which uses it:
.. code-block:: python
import scrapy
def authentication_failed(response):
# TODO: Check the contents of the response and return True if it failed
# or False if it succeeded.
pass
class LoginSpider(scrapy.Spider):
name = "example.com"
start_urls = ["http://www.example.com/users/login.php"]
def parse(self, response):
return scrapy.FormRequest.from_response(
response,
formdata={"username": "john", "password": "secret"},
callback=self.after_login,
)
def after_login(self, response):
if authentication_failed(response):
self.logger.error("Login failed")
return
# continue scraping with authenticated session...
JsonRequest
-----------
@ -998,7 +985,7 @@ Response objects
:type request: scrapy.Request
:param certificate: an object representing the server's SSL certificate.
:type certificate: twisted.internet.ssl.Certificate
:type certificate: typing.Any
:param ip_address: The IP address of the server from which the Response originated.
:type ip_address: :class:`ipaddress.IPv4Address` or :class:`ipaddress.IPv6Address`
@ -1023,7 +1010,7 @@ Response objects
A dictionary-like (:class:`scrapy.http.headers.Headers`) object which contains
the response headers. Values can be accessed using
:meth:`~scrapy.http.headers.Headers.get` to return the first header value with
:meth:`~scrapy.http.headers.Headers.get` to return the last header value with
the specified name or :meth:`~scrapy.http.headers.Headers.getlist` to return
all header values with the specified name. For example, this call will give you
all cookies in the headers::
@ -1090,8 +1077,8 @@ Response objects
.. attribute:: Response.certificate
A :class:`twisted.internet.ssl.Certificate` object representing
the server's SSL certificate.
An object representing the server's SSL certificate. Its type and
contents depend on the download handler that produced the response.
Only populated for ``https`` responses, ``None`` otherwise.

View File

@ -734,35 +734,52 @@ specific cipher that is not included in ``DEFAULT`` if a website requires it.
Handling of this setting needs to be implemented inside the :ref:`download
handler <topics-download-handlers>`, so it's not guaranteed to be supported
by all 3rd-party handlers. It's currently unsupported by
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`.
by all 3rd-party handlers.
.. setting:: DOWNLOADER_CLIENT_TLS_METHOD
.. setting:: DOWNLOAD_TLS_MAX_VERSION
DOWNLOADER_CLIENT_TLS_METHOD
----------------------------
DOWNLOAD_TLS_MAX_VERSION
------------------------
Default: ``'TLS'``
Default: ``None``
Use this setting to customize the TLS/SSL method used by the HTTPS download
handler.
Use this setting to change the maximum version of the TLS protocol allowed to
be used by Scrapy.
This setting must be one of these string values:
This setting must be either ``None``, in which case it doesn't affect the
version selection, or one of these string values:
- ``'TLS'``: maps to OpenSSL's ``TLS_method()`` (a.k.a ``SSLv23_method()``),
which allows protocol negotiation, starting from the highest supported
by the platform; **default, recommended**
- ``'TLSv1.0'``: this value forces HTTPS connections to use TLS version 1.0 ;
set this if you want the behavior of Scrapy<1.1
- ``'TLSv1.1'``: forces TLS version 1.1
- ``'TLSv1.2'``: forces TLS version 1.2
- ``'TLSv1.0'``
- ``'TLSv1.1'``
- ``'TLSv1.2'``
- ``'TLSv1.3'``
The range of allowed TLS versions advertised by Scrapy when making TLS
connections will depend on the TLS implementation defaults and the values of
:setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION`.
It's possible to re-enable versions that are supported by the TLS
implementation but disabled by default by adjusting these settings, but it's
impossible to enable unsupported ones, such as any versions below 1.2 in many
modern environments.
.. note::
Handling of this setting needs to be implemented inside the :ref:`download
handler <topics-download-handlers>`, so it's not guaranteed to be supported
by all 3rd-party handlers. It's currently unsupported by
:class:`~scrapy.core.downloader.handlers._httpx.HttpxDownloadHandler`.
by all 3rd-party handlers. Additionally, the set of supported TLS versions
depends on the TLS implementation being used by the handler.
.. setting:: DOWNLOAD_TLS_MIN_VERSION
DOWNLOAD_TLS_MIN_VERSION
------------------------
Default: ``None``
Use this setting to change the minimum version of the TLS protocol allowed to
be used by Scrapy.
See :setting:`DOWNLOAD_TLS_MAX_VERSION` for the details and limitations.
.. setting:: DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING
@ -809,7 +826,6 @@ Default:
"scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400,
"scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500,
"scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
"scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560,
"scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,

View File

@ -117,36 +117,28 @@ one or more of these methods:
:type response: :class:`~scrapy.http.Response` object
.. method:: process_spider_output(response, result)
:async:
This method is called with the results returned from the Spider, after
it has processed the response.
This method is an :term:`asynchronous generator` called with the
results from the spider after the spider has processed the response.
:meth:`process_spider_output` must return an iterable of
:class:`~scrapy.Request` objects and :ref:`item objects
<topics-items>`.
Consider defining this method as an :term:`asynchronous generator`,
which will be a requirement in a future version of Scrapy. However, if
you plan on sharing your spider middleware with other people, consider
either :ref:`enforcing Scrapy 2.7 <enforce-component-requirements>`
as a minimum requirement of your spider middleware, or :ref:`making
your spider middleware universal <universal-spider-middleware>` so that
it works with Scrapy versions earlier than Scrapy 2.7.
.. seealso:: :ref:`universal-spider-middleware`.
:param response: the response which generated this output from the
spider
:type response: :class:`~scrapy.http.Response` object
:param result: the result returned by the spider
:type result: an iterable of :class:`~scrapy.Request` objects and
:ref:`item objects <topics-items>`
:param result: the results from the spider
:type result: an :term:`asynchronous iterable` of
:class:`~scrapy.Request` objects and :ref:`item objects
<topics-items>`
.. method:: process_spider_output_async(response, result)
:async:
If defined, this method must be an :term:`asynchronous generator`,
which will be called instead of :meth:`process_spider_output` if
``result`` is an :term:`asynchronous iterable`.
Alternative name for :meth:`process_spider_output` used when
implementing a :ref:`universal spider middleware
<universal-spider-middleware>`.
.. method:: process_spider_exception(response, exception)
@ -174,13 +166,40 @@ one or more of these methods:
:type exception: :exc:`Exception` object
.. _universal-spider-middleware:
Universal spider middlewares
----------------------------
In Scrapy 2.6.3 and lower, ``process_spider_output()`` must be a *synchronous*
generator.
To support those versions and higher Scrapy versions in the same middleware,
rename your asynchronous :meth:`~SpiderMiddleware.process_spider_output`
method to :meth:`~SpiderMiddleware.process_spider_output_async`, and define a
synchronous ``process_spider_output()`` method to be used by 2.6.3 and lower
versions.
For example:
.. code-block:: python
class UniversalSpiderMiddleware:
async def process_spider_output_async(self, response, result):
async for r in result:
# ... do something with r
yield r
def process_spider_output(self, response, result):
for r in result:
# ... do something with r
yield r
Base class for custom spider middlewares
----------------------------------------
Scrapy provides a base class for custom spider middlewares. It's not required
to use it but it can help with simplifying middleware implementations and
reducing the amount of boilerplate code in :ref:`universal middlewares
<universal-spider-middleware>`.
to use it but it can help with simplifying middleware implementations.
.. module:: scrapy.spidermiddlewares.base

View File

@ -354,11 +354,6 @@ Otherwise, you would cause iteration over a ``start_urls`` string
(a very common python pitfall)
resulting in each character being seen as a separate url.
A valid use case is to set the http auth credentials
used by :class:`~scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware`::
scrapy crawl myspider -a http_user=myuser -a http_pass=mypassword
Spider arguments can also be passed through the Scrapyd ``schedule.json`` API.
See `Scrapyd documentation`_.
@ -457,13 +452,14 @@ with a ``TestItem`` declared in a ``myproject.items`` module:
.. code-block:: python
import scrapy
from dataclasses import dataclass
class TestItem(scrapy.Item):
id = scrapy.Field()
name = scrapy.Field()
description = scrapy.Field()
@dataclass
class TestItem:
id: str | None = None
name: str | None = None
description: str | None = None
.. currentmodule:: scrapy.spiders
@ -556,7 +552,6 @@ Let's now take a look at an example CrawlSpider with rules:
.. code-block:: python
import scrapy
from scrapy.spiders import CrawlSpider, Rule
from scrapy.linkextractors import LinkExtractor
@ -576,7 +571,7 @@ Let's now take a look at an example CrawlSpider with rules:
def parse_item(self, response):
self.logger.info("Hi, this is an item page! %s", response.url)
item = scrapy.Item()
item = {}
item["id"] = response.xpath('//td[@id="item_id"]/text()').re(r"ID: (\d+)")
item["name"] = response.xpath('//td[@id="item_name"]/text()').get()
item["description"] = response.xpath(
@ -714,9 +709,9 @@ These spiders are pretty easy to use, let's have a look at one example:
)
item = TestItem()
item["id"] = node.xpath("@id").get()
item["name"] = node.xpath("name").get()
item["description"] = node.xpath("description").get()
item.id = node.xpath("@id").get()
item.name = node.xpath("name").get()
item.description = node.xpath("description").get()
return item
Basically what we did up there was to create a spider that downloads a feed from
@ -778,9 +773,9 @@ Let's see an example similar to the previous one, but using a
self.logger.info("Hi, this is a row!: %r", row)
item = TestItem()
item["id"] = row["id"]
item["name"] = row["name"]
item["description"] = row["description"]
item.id = row["id"]
item.name = row["name"]
item.description = row["description"]
return item

View File

@ -46,12 +46,12 @@ the console you need to type::
Password:
>>>
By default Username is ``scrapy`` and Password is autogenerated. The
autogenerated Password can be seen on Scrapy logs like the example below::
By default, the username is ``scrapy`` and the password is autogenerated. The
autogenerated password can be seen on Scrapy logs like the example below::
2018-10-16 14:35:21 [scrapy.extensions.telnet] INFO: Telnet Password: 16f92501e8a59326
Default Username and Password can be overridden by the settings
The default username and password can be overridden by the settings
:setting:`TELNETCONSOLE_USERNAME` and :setting:`TELNETCONSOLE_PASSWORD`.
.. warning::
@ -94,8 +94,6 @@ convenience:
+----------------+-------------------------------------------------------------------+
| ``p`` | a shortcut to the :func:`pprint.pprint` function |
+----------------+-------------------------------------------------------------------+
| ``hpy`` | for memory debugging (see :ref:`topics-leaks`) |
+----------------+-------------------------------------------------------------------+
Telnet console usage examples
=============================

View File

@ -18,7 +18,7 @@ class Root(Resource):
self.tail.clear()
self.start = self.lastmark = self.lasttime = time()
def getChild(self, request, name):
def getChild(self, path, request):
return self
def render(self, request):

View File

@ -35,10 +35,6 @@ class QPSSpider(Spider):
self.download_delay = float(self.download_delay)
async def start(self):
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self):
url = self.benchurl
if self.latency is not None:
url += f"?latency={self.latency}"

View File

@ -7,8 +7,7 @@ name = "Scrapy"
dynamic = ["version"]
description = "A high-level Web Crawling and Web Scraping framework"
dependencies = [
# Twisted pinned until Scrapy is updated for its internal TLS API changes
"Twisted>=21.7.0,<=25.5.0",
"Twisted>=21.7.0",
"cryptography>=37.0.0",
"cssselect>=0.9.1",
"defusedxml>=0.7.1",
@ -20,7 +19,7 @@ dependencies = [
"protego>=0.1.15",
"pyOpenSSL>=22.0.0",
"queuelib>=1.4.2",
"service_identity>=18.1.0",
"service_identity>=23.1.0",
"tldextract",
"w3lib>=1.17.0",
"zope.interface>=5.1.0",
@ -40,6 +39,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
@ -86,12 +86,10 @@ pattern = "^(?P<version>.+)$"
[tool.mypy]
strict = true
allow_any_generics = true # 67 errors
extra_checks = false # weird addErrback() errors
untyped_calls_exclude = [
"twisted",
]
warn_return_any = false # 37 errors
[[tool.mypy.overrides]]
module = "tests.*"
@ -122,24 +120,9 @@ implicit_reexport = true
module = "scrapy.settings.default_settings"
ignore_errors = true
# deprecated modules
[[tool.mypy.overrides]]
module = [
"scrapy.core.downloader.webclient",
"scrapy.spiders.init",
"scrapy.utils.testsite",
"tests.test_webclient",
]
allow_any_generics = true
allow_untyped_calls = true
allow_untyped_defs = true
check_untyped_defs = false
warn_return_any = false
# usually no type hints
[[tool.mypy.overrides]]
module = [
# "IPython.*",
"bpython",
"brotli",
"brotlicffi",
@ -154,7 +137,7 @@ module = [
ignore_missing_imports = true
[tool.bumpversion]
current_version = "2.15.2"
current_version = "2.16.0"
commit = true
tag = true
tag_name = "{new_version}"
@ -174,6 +157,8 @@ parse = """(?P<major>0|[1-9]\\d*)\\.(?P<minor>0|[1-9]\\d*)"""
serialize = ["{major}.{minor}"]
[tool.coverage.run]
# sysmon, default on 3.14, is too slow: https://github.com/coveragepy/coveragepy/issues/2172
core = "ctrace"
branch = true
include = ["scrapy/*"]
omit = ["tests/*"]
@ -200,6 +185,7 @@ jobs = 1 # >1 hides results
extension-pkg-allow-list=[
"lxml",
]
load-plugins = ["pylint_per_file_ignores"]
[tool.pylint."MESSAGES CONTROL"]
enable = [
@ -241,6 +227,7 @@ disable = [
"too-many-positional-arguments",
"too-many-public-methods",
"too-many-return-statements",
"undefined-variable",
"unused-argument",
"unused-variable",
"useless-import-alias", # used as a hint to mypy
@ -259,15 +246,13 @@ disable = [
"unused-import",
# Ones that we may want to address (fix, ignore per-line or move to "don't want to fix")
"abstract-method",
"arguments-differ",
"arguments-renamed",
"dangerous-default-value",
"keyword-arg-before-vararg",
"pointless-statement",
"raise-missing-from",
"unnecessary-dunder-call",
"used-before-assignment",
]
# requires `pylint_per_file_ignores` plugin
per-file-ignores = [
# Extended list of ones that we may want to address, only for tests
"./tests/*:abstract-method,arguments-renamed,dangerous-default-value,pointless-statement,raise-missing-from,unnecessary-dunder-call,used-before-assignment",
]
[tool.pytest.ini_options]
@ -284,6 +269,7 @@ markers = [
"requires_botocore: marks tests that need botocore (but not boto3)",
"requires_boto3: marks tests that need botocore and boto3",
"requires_mitmproxy: marks tests that need mitmproxy",
"requires_internet: marks tests that need real Internet access",
]
filterwarnings = [
"ignore::DeprecationWarning:twisted.web.static",
@ -466,4 +452,4 @@ split-on-trailing-comma = false
convention = "pep257"
[tool.sphinx-scrapy]
python-version = "3.13" # Keep in sync with .github/workflows/checks.yml.
python-version = "3.14" # Keep in sync with .github/workflows/checks.yml.

View File

@ -1 +1 @@
2.15.2
2.16.0

View File

@ -147,7 +147,7 @@ class Command(ScrapyCommand):
name: str,
url: str,
template_name: str,
template_file: str | os.PathLike,
template_file: str | os.PathLike[str],
) -> None:
"""Generate the spider module, based on the given template"""
assert self.settings is not None

View File

@ -144,17 +144,16 @@ class Command(BaseRunSpiderCommand):
def iterate_spider_output(self, result: _T) -> Iterable[Any]: ...
def iterate_spider_output(self, result: Any) -> Iterable[Any] | Deferred[Any]:
d: Deferred[Any]
if inspect.isasyncgen(result):
d = deferred_from_coro(
collect_asyncgen(aiter_errback(result, self.handle_exception))
)
d.addCallback(self.iterate_spider_output)
return d
return d.addCallback(self.iterate_spider_output)
d = deferred_from_coro(result)
if inspect.iscoroutine(result):
d = deferred_from_coro(result)
d.addCallback(self.iterate_spider_output)
return d
return arg_to_iter(deferred_from_coro(result))
return d.addCallback(self.iterate_spider_output)
return arg_to_iter(d)
def add_items(self, lvl: int, new_items: list[Any]) -> None:
old_items = self.items.get(lvl, [])

View File

@ -27,7 +27,7 @@ class Contract:
request_cls: type[Request] | None = None
name: str
def __init__(self, method: Callable, *args: Any):
def __init__(self, method: Callable[..., Any], *args: Any):
self.testcase_pre = _create_testcase(method, f"@{self.name} pre-hook")
self.testcase_post = _create_testcase(method, f"@{self.name} post-hook")
self.args: tuple[Any, ...] = args
@ -105,7 +105,7 @@ class ContractsManager:
return methods
def extract_contracts(self, method: Callable) -> list[Contract]:
def extract_contracts(self, method: Callable[..., Any]) -> list[Contract]:
contracts: list[Contract] = []
assert method.__doc__ is not None
for line_ in method.__doc__.split("\n"):
@ -125,7 +125,7 @@ class ContractsManager:
def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]:
requests: list[Request | None] = []
for method in self.tested_methods_from_spidercls(type(spider)):
bound_method = spider.__getattribute__(method)
bound_method = getattr(spider, method)
try:
requests.append(self.from_method(bound_method, results))
except Exception:
@ -134,7 +134,9 @@ class ContractsManager:
return requests
def from_method(self, method: Callable, results: TestResult) -> Request | None:
def from_method(
self, method: Callable[..., Any], results: TestResult
) -> Request | None:
contracts = self.extract_contracts(method)
if contracts:
request_cls = Request
@ -170,7 +172,7 @@ class ContractsManager:
return None
def _clean_req(
self, request: Request, method: Callable, results: TestResult
self, request: Request, method: Callable[..., Any], results: TestResult
) -> None:
"""stop the request from returning objects and records any errors"""
@ -195,7 +197,7 @@ class ContractsManager:
request.errback = eb_wrapper
def _create_testcase(method: Callable, desc: str) -> TestCase:
def _create_testcase(method: Callable[..., Any], desc: str) -> TestCase:
spider = method.__self__.name # type: ignore[attr-defined]
class ContractTestCase(TestCase):

View File

@ -130,11 +130,12 @@ class Downloader:
) -> Generator[Deferred[Any], Any, Response | Request]:
self.active.add(request)
try:
return (
yield deferred_from_coro(
result: Response | Request = yield (
deferred_from_coro(
self.middleware.download_async(self._enqueue_request, request)
)
)
return result
finally:
self.active.remove(request)
@ -165,7 +166,8 @@ class Downloader:
return key, self.slots[key]
def get_slot_key(self, request: Request) -> str:
if (meta_slot := request.meta.get(self.DOWNLOAD_SLOT)) is not None:
meta_slot: str | None = request.meta.get(self.DOWNLOAD_SLOT)
if meta_slot is not None:
return meta_slot
key = urlparse_cached(request).hostname or ""

View File

@ -1,14 +1,13 @@
from __future__ import annotations
import warnings
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, cast
from OpenSSL import SSL
from twisted.internet._sslverify import _setAcceptableProtocols
from twisted.internet.ssl import (
AcceptableCiphers,
CertificateOptions,
TLSVersion,
optionsForClientTLS,
)
from twisted.web.client import BrowserLikePolicyForHTTPS
@ -17,17 +16,19 @@ from zope.interface.declarations import implementer
from zope.interface.verify import verifyObject
from scrapy.core.downloader.tls import (
_TWISTED_VERSION_MAP,
DEFAULT_CIPHERS,
_openssl_methods,
_ScrapyClientTLSOptions,
openssl_methods,
_ScrapyClientTLSOptions26,
)
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils._deps_compat import TWISTED_TLS_NEW_IMPL
from scrapy.utils.deprecate import create_deprecated_class
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.ssl import _get_cert_options_version_kwargs, _get_tls_version_limits
if TYPE_CHECKING:
from collections.abc import Generator
from twisted.internet._sslverify import ClientTLSOptions
# typing.Self requires Python 3.11
@ -37,24 +38,14 @@ if TYPE_CHECKING:
from scrapy.settings import BaseSettings
@contextmanager
def _filter_method_warning() -> Generator[None]:
with warnings.catch_warnings():
# Twisted deprecation, https://github.com/scrapy/scrapy/issues/3288
warnings.filterwarnings(
"ignore",
message=r"Passing method to twisted\.internet\.ssl\.CertificateOptions",
category=DeprecationWarning,
)
yield
@implementer(IPolicyForHTTPS)
class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
"""Non-peer-certificate verifying HTTPS context factory.
Default OpenSSL method is ``TLS_METHOD`` (also called ``SSLv23_METHOD``)
which allows TLS protocol negotiation.
Uses :setting:`DOWNLOADER_CLIENT_TLS_CIPHERS`,
:setting:`DOWNLOAD_TLS_MIN_VERSION` and :setting:`DOWNLOAD_TLS_MAX_VERSION`
to configure the :class:`~twisted.internet.ssl.CertificateOptions`
instance.
The purpose of this custom class is to provide a ``creatorForNetloc()``
method that returns a ``_ScrapyClientTLSOptions`` instance configured based
@ -63,15 +54,19 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
def __init__(
self,
method: int = SSL.SSLv23_METHOD, # noqa: S503
method: int | None = SSL.SSLv23_METHOD, # noqa: S503
tls_verbose_logging: bool = False,
tls_ciphers: str | None = None,
*args: Any,
verify_certificates: bool = False,
tls_min_version: TLSVersion | None = None,
tls_max_version: TLSVersion | None = None,
**kwargs: Any,
):
super().__init__(*args, **kwargs) # type: ignore[no-untyped-call]
self._ssl_method: int = method
self._ssl_method: int | None = method
self.tls_min_version: TLSVersion | None = tls_min_version
self.tls_max_version: TLSVersion | None = tls_max_version
self.tls_verbose_logging: bool = tls_verbose_logging # unused
self.tls_ciphers: AcceptableCiphers
if tls_ciphers:
@ -84,7 +79,7 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
def from_crawler(
cls,
crawler: Crawler,
method: int = SSL.SSLv23_METHOD, # noqa: S503
method: int | None = SSL.SSLv23_METHOD, # noqa: S503
*args: Any,
**kwargs: Any,
) -> Self:
@ -92,12 +87,21 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
)
tls_ciphers: str | None = crawler.settings["DOWNLOADER_CLIENT_TLS_CIPHERS"]
# DOWNLOADER_CLIENT_TLS_METHOD reading and handling should be also moved here
# when the deprecated load_context_factory_from_settings() is removed
tls_min_ver, tls_max_ver = _get_tls_version_limits(
crawler.settings, _TWISTED_VERSION_MAP.__getitem__
)
if tls_min_ver or tls_max_ver:
method = None
verify_certificates = crawler.settings.getbool("DOWNLOAD_VERIFY_CERTIFICATES")
return cls( # type: ignore[misc]
*args,
method=method,
tls_verbose_logging=tls_verbose_logging,
tls_ciphers=tls_ciphers,
tls_min_version=tls_min_ver,
tls_max_version=tls_max_ver,
verify_certificates=verify_certificates,
**kwargs,
)
@ -107,41 +111,50 @@ class _ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
return self._get_cert_options()
def _get_cert_options(self) -> CertificateOptions:
with _filter_method_warning():
return CertificateOptions(
method=self._ssl_method,
fixBrokenPeers=True,
acceptableCiphers=self.tls_ciphers,
)
return _ScrapyCertificateOptions(**self._get_cert_options_kwargs())
def _get_cert_options_kwargs(self) -> dict[str, Any]:
kwargs: dict[str, Any] = {
"fixBrokenPeers": True,
"acceptableCiphers": self.tls_ciphers,
}
if self.tls_min_version or self.tls_max_version:
kwargs.update(
_get_cert_options_version_kwargs(
self.tls_min_version, self.tls_max_version
)
)
# when ScrapyClientContextFactory is removed self._ssl_method can just be None by default
elif self._ssl_method != SSL.SSLv23_METHOD:
kwargs["method"] = self._ssl_method
return kwargs
# kept for old-style HTTP/1.0 downloader context twisted calls,
# e.g. connectSSL()
# should be removed together with ScrapyClientContextFactory
def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context:
def getContext(
self, hostname: Any = None, port: Any = None
) -> SSL.Context: # pragma: no cover
return self._get_context()
def _get_context(self) -> SSL.Context:
cert_options = self._get_cert_options()
ctx = cert_options.getContext()
ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT
return ctx
return self._get_cert_options().getContext()
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
if not self._verify_certificates:
# _ScrapyClientTLSOptions is needed to skip verification errors
# Our options class is needed to skip verification errors
if TWISTED_TLS_NEW_IMPL:
return _ScrapyClientTLSOptions26(
self._get_cert_options()._makeTLSConnection,
hostname.decode("ascii"),
)
return _ScrapyClientTLSOptions(
hostname.decode("ascii"), self._get_context()
) # type: ignore[no-untyped-call]
# Otherwise use the normal Twisted function.
# Note that this doesn't use self._get_context().
with _filter_method_warning():
return optionsForClientTLS(
hostname=hostname.decode("ascii"),
extraCertificateOptions={
"method": self._ssl_method,
"acceptableCiphers": self.tls_ciphers,
},
hostname.decode("ascii"), # type: ignore[arg-type]
self._get_context(), # type: ignore[arg-type]
)
# Otherwise use the normal Twisted function.
return optionsForClientTLS( # type: ignore[no-any-return]
hostname=hostname.decode("ascii"),
extraCertificateOptions=self._get_cert_options_kwargs(),
)
ScrapyClientContextFactory = create_deprecated_class(
@ -166,12 +179,6 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory):
:meth:`creatorForNetloc` is the same as
:class:`~twisted.web.client.BrowserLikePolicyForHTTPS` except this context
factory allows setting the TLS/SSL method to use.
The default OpenSSL method is ``TLS_METHOD`` (also called
``SSLv23_METHOD``) which allows TLS protocol negotiation.
As this overrides the parent ``creatorForNetloc()`` method, only
``self._ssl_method`` is used from the parent class.
"""
def __init__(self, *args: Any, **kwargs: Any):
@ -185,11 +192,10 @@ class BrowserLikeContextFactory(_ScrapyClientContextFactory):
super().__init__(*args, **kwargs)
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
with _filter_method_warning():
return optionsForClientTLS(
hostname=hostname.decode("ascii"),
extraCertificateOptions={"method": self._ssl_method},
)
return optionsForClientTLS( # type: ignore[no-any-return]
hostname=hostname.decode("ascii"),
extraCertificateOptions=self._get_cert_options_kwargs(),
)
@implementer(IPolicyForHTTPS)
@ -202,8 +208,25 @@ class _AcceptableProtocolsContextFactory:
the acceptable protocols on the :class:`.ClientTLSOptions` instance
returned by it. It's only needed because we support custom factories via
:setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`.
It's a no-op on Twisted 26.4.0+, though using it with custom
factories on those Twisted versions may be not enough for HTTP/2 support.
"""
# Something needs to call set_alpn_protos() for ALPN to work.
#
# Twisted < 26.4.0 does it in OpenSSLCertificateOptions._makeContext()
# (requires passing acceptableProtocols from the factory to
# OpenSSLCertificateOptions) and in TLSMemoryBIOFactory._createConnection()
# based on H2ClientFactory.acceptableProtocols (too late, it seems).
#
# Newer Twisted does it in OpenSSLCertificateOptions._makeContext() as
# well, and in OpenSSLCertificateOptions._makeTLSConnection() based on
# H2ClientFactory.acceptableProtocols (which now works).
#
# When we drop DOWNLOADER_CLIENTCONTEXTFACTORY it looks like we can replace
# all of this with _ScrapyClientContextFactory.acceptableProtocols.
def __init__(self, context_factory: Any, acceptable_protocols: list[bytes]):
verifyObject(IPolicyForHTTPS, context_factory)
self._wrapped_context_factory: Any = context_factory
@ -213,7 +236,12 @@ class _AcceptableProtocolsContextFactory:
options: ClientTLSOptions = self._wrapped_context_factory.creatorForNetloc(
hostname, port
)
_setAcceptableProtocols(options._ctx, self._acceptable_protocols)
if not TWISTED_TLS_NEW_IMPL:
from twisted.internet._sslverify import ( # type: ignore[attr-defined] # noqa: PLC0415 # pylint: disable=no-name-in-module
_setAcceptableProtocols,
)
_setAcceptableProtocols(options._ctx, self._acceptable_protocols) # type: ignore[attr-defined]
return options
@ -225,11 +253,33 @@ AcceptableProtocolsContextFactory = create_deprecated_class(
)
class _ScrapyCertificateOptions(CertificateOptions):
"""A wrapper needed to add flags to the SSL context before it's used."""
def _makeContext(self, skipCiphers: bool = False) -> SSL.Context:
if TWISTED_TLS_NEW_IMPL:
ctx = super()._makeContext(skipCiphers)
else:
ctx = super()._makeContext()
ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT
return ctx
def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS:
"""Create an instance of :setting:`DOWNLOADER_CLIENTCONTEXTFACTORY`.
Also passes values of other relevant settings to the factory class.
"""
tls_method_setting: str = crawler.settings["DOWNLOADER_CLIENT_TLS_METHOD"]
if tls_method_setting != "TLS":
warnings.warn(
"Setting DOWNLOADER_CLIENT_TLS_METHOD to a non-default value is"
" deprecated, please use DOWNLOAD_TLS_MIN_VERSION and/or"
" DOWNLOAD_TLS_MAX_VERSION instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
tls_method = _openssl_methods[tls_method_setting]
if crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL":
context_factory_cls = _ScrapyClientContextFactory
else: # pragma: no cover
@ -241,13 +291,12 @@ def _load_context_factory_from_settings(crawler: Crawler) -> IPolicyForHTTPS:
context_factory_cls = load_object(
crawler.settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]
)
ssl_method = openssl_methods[crawler.settings.get("DOWNLOADER_CLIENT_TLS_METHOD")]
return cast(
"IPolicyForHTTPS",
build_from_crawler(
context_factory_cls,
crawler,
method=ssl_method,
method=tls_method,
),
)

View File

@ -0,0 +1,25 @@
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING
from .base import BaseDownloadHandler
if TYPE_CHECKING:
from scrapy.crawler import Crawler
class BaseHttpDownloadHandler(BaseDownloadHandler, ABC):
"""Base class for built-in HTTP download handlers."""
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE")
self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE")
self._fail_on_dataloss: bool = crawler.settings.getbool(
"DOWNLOAD_FAIL_ON_DATALOSS"
)
self._tls_verbose_logging: bool = crawler.settings.getbool(
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
)
self._fail_on_dataloss_warned: bool = False

View File

@ -0,0 +1,307 @@
from __future__ import annotations
import base64
import logging
import time
from abc import ABC, abstractmethod
from io import BytesIO
from typing import TYPE_CHECKING, Any, ClassVar, Generic, NoReturn, TypedDict, TypeVar
from urllib.parse import quote, urlsplit
from scrapy import Request, signals
from scrapy.exceptions import (
DownloadCancelledError,
NotConfigured,
ResponseDataLossError,
)
from scrapy.utils._download_handlers import (
check_stop_download,
get_dataloss_msg,
get_maxsize_msg,
get_warnsize_msg,
make_response,
normalize_bind_address,
)
from scrapy.utils.asyncio import is_asyncio_available
from scrapy.utils.url import add_http_if_no_scheme
from ._base_http import BaseHttpDownloadHandler
if TYPE_CHECKING:
from collections.abc import AsyncIterable
from contextlib import AbstractAsyncContextManager
from ipaddress import IPv4Address, IPv6Address
from _typeshed import SizedBuffer
# typing.NotRequired requires Python 3.11
from typing_extensions import NotRequired
from scrapy.crawler import Crawler
from scrapy.http import Headers, Response
logger = logging.getLogger(__name__)
_ResponseT = TypeVar("_ResponseT")
class _BaseResponseArgs(TypedDict):
status: int
url: str
headers: Headers
certificate: NotRequired[Any]
ip_address: NotRequired[IPv4Address | IPv6Address | None]
protocol: str | None
class BaseStreamingDownloadHandler(BaseHttpDownloadHandler, ABC, Generic[_ResponseT]):
"""A base class for HTTP download handlers that follow the streaming logic flow."""
_DEFAULT_CONNECT_TIMEOUT: ClassVar[float] = 10
experimental: ClassVar[bool] = False
requires_asyncio: ClassVar[bool] = True
# require subclasses to disable proxies explicitly with an explanation
supports_proxies: ClassVar[bool] = True
supports_per_request_bindaddress: ClassVar[bool] = False
def __init__(self, crawler: Crawler):
if self.requires_asyncio and not is_asyncio_available(): # pragma: no cover
raise NotConfigured(
f"{type(self).__name__} requires the asyncio support. Make"
f" sure that you have either enabled the asyncio Twisted"
f" reactor in the TWISTED_REACTOR setting or disabled the"
f" TWISTED_REACTOR_ENABLED setting. See the asyncio documentation"
f" of Scrapy for more information."
)
self._check_deps_installed()
super().__init__(crawler)
if self.experimental:
logger.warning(
f"{type(self).__name__} is experimental and is not recommended for production use."
)
self._bind_address = normalize_bind_address(
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"
)
@staticmethod
@abstractmethod
def _check_deps_installed() -> None:
"""Raise NotConfigured if the required deps are not installed."""
raise NotImplementedError
@abstractmethod
def _make_request(
self, request: Request, timeout: float
) -> AbstractAsyncContextManager[_ResponseT]:
"""Return an async context manager yielding the library-specific response.
Exceptions raised by the library should be reraised as Scrapy-specific ones.
"""
raise NotImplementedError
@staticmethod
@abstractmethod
def _extract_headers(response: _ResponseT) -> Headers:
"""Convert library-specific response headers to a
:class:`~scrapy.http.headers.Headers` object."""
raise NotImplementedError
@staticmethod
@abstractmethod
def _build_base_response_args(
response: _ResponseT, request: Request, headers: Headers
) -> _BaseResponseArgs:
"""Build kwargs for :func:`scrapy.utils._download_handlers.make_response`."""
raise NotImplementedError
@staticmethod
@abstractmethod
def _iter_body_chunks(response: _ResponseT) -> AsyncIterable[SizedBuffer]:
"""Return an async iterable yielding body chunks from the response."""
raise NotImplementedError
@staticmethod
@abstractmethod
def _is_dataloss_exception(exc: Exception) -> bool:
"""Return True if ``exc`` represents dataloss."""
raise NotImplementedError
def _log_tls_info(self, response: _ResponseT, request: Request) -> None:
"""Log TLS connection details, if possible."""
async def download_request(self, request: Request) -> Response:
if not self.supports_proxies and request.meta.get("proxy"):
raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.")
if not self.supports_per_request_bindaddress and request.meta.get(
"bindaddress"
):
logger.error(
f"The 'bindaddress' request meta key is not supported by"
f" {type(self).__name__} and will be ignored."
)
timeout: float = request.meta.get(
"download_timeout", self._DEFAULT_CONNECT_TIMEOUT
)
start_time = time.monotonic()
async with self._make_request(request, timeout) as response:
request.meta["download_latency"] = time.monotonic() - start_time
return await self._read_response(response, request)
async def _read_response(self, response: _ResponseT, request: Request) -> Response:
maxsize: int = request.meta.get("download_maxsize", self._default_maxsize)
warnsize: int = request.meta.get("download_warnsize", self._default_warnsize)
headers = self._extract_headers(response)
content_length = headers.get("Content-Length")
expected_size = int(content_length) if content_length is not None else None
if maxsize and expected_size and expected_size > maxsize:
self._cancel_maxsize(expected_size, maxsize, request, expected=True)
reached_warnsize = False
if warnsize and expected_size and expected_size > warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(expected_size, warnsize, request, expected=True)
)
make_response_base_args = self._build_base_response_args(
response, request, headers
)
if self._tls_verbose_logging:
self._log_tls_info(response, request)
if stop_download := check_stop_download(
signals.headers_received,
self.crawler,
request,
headers=headers,
body_length=expected_size,
):
return make_response(
**make_response_base_args,
stop_download=stop_download,
)
response_body = BytesIO()
bytes_received = 0
try:
async for chunk in self._iter_body_chunks(response):
response_body.write(chunk)
bytes_received += len(chunk)
if stop_download := check_stop_download(
signals.bytes_received, self.crawler, request, data=chunk
):
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
stop_download=stop_download,
)
if maxsize and bytes_received > maxsize:
response_body.truncate(0)
self._cancel_maxsize(
bytes_received, maxsize, request, expected=False
)
if warnsize and bytes_received > warnsize and not reached_warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(
bytes_received, warnsize, request, expected=False
)
)
except Exception as e:
if not self._is_dataloss_exception(e):
raise
fail_on_dataloss: bool = request.meta.get(
"download_fail_on_dataloss", self._fail_on_dataloss
)
if not fail_on_dataloss:
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
flags=["dataloss"],
)
if not self._fail_on_dataloss_warned:
logger.warning(get_dataloss_msg(request.url))
self._fail_on_dataloss_warned = True
raise ResponseDataLossError(str(e)) from e
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
)
def _get_bind_address_host(self) -> str | None:
"""Return the host portion of the bind address.
Needed for handlers that don't support the bind port.
"""
if self._bind_address is None:
return None
host, port = self._bind_address
if port != 0:
logger.warning(
"DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not "
"support binding to a specific local port. Ignoring the port "
"and binding only to %r.",
port,
type(self).__name__,
host,
)
return host
@staticmethod
def _cancel_maxsize(
size: int, limit: int, request: Request, *, expected: bool
) -> NoReturn:
warning_msg = get_maxsize_msg(size, limit, request, expected=expected)
logger.warning(warning_msg)
raise DownloadCancelledError(warning_msg)
@staticmethod
def _extract_proxy(request: Request) -> tuple[str | None, str | None]:
"""Return a tuple of the proxy URL with a scheme and the value of the
Proxy-Authorization header.
This is useful for handlers that take the proxy headers separately.
"""
proxy: str | None = request.meta.get("proxy")
if not proxy:
return None, None
proxy = add_http_if_no_scheme(proxy)
auth_header: list[bytes] | None = request.headers.pop(
b"Proxy-Authorization", None
)
return proxy, auth_header[0].decode("ascii") if auth_header else None
def _extract_proxy_url_with_creds(self, request: Request) -> str | None:
"""Return the proxy URL with the userinfo added based on the
Proxy-Authorization header.
This is useful for handlers that cannot take the proxy headers
separately.
"""
proxy_url, auth_header = self._extract_proxy(request)
if proxy_url is None or auth_header is None:
return proxy_url
scheme, token = auth_header.split(" ", 1)
if scheme != "Basic":
raise ValueError(
f"Expected Basic auth in Proxy-Authorization, got {scheme}"
)
user, password = (
base64.b64decode(token).decode(self._proxy_auth_encoding).split(":", 1)
)
parts = urlsplit(proxy_url)
netloc = f"{quote(user)}:{quote(password)}@{parts.netloc}"
return parts._replace(netloc=netloc).geturl()

View File

@ -3,136 +3,165 @@
from __future__ import annotations
import ipaddress
import logging
import ssl
import time
from http.cookiejar import Cookie, CookieJar
from io import BytesIO
from typing import TYPE_CHECKING, Any, NoReturn, TypedDict
from contextlib import asynccontextmanager
from socket import gaierror
from typing import TYPE_CHECKING, ClassVar
from scrapy import Request, signals
from scrapy.exceptions import (
CannotResolveHostError,
DownloadCancelledError,
DownloadConnectionRefusedError,
DownloadFailedError,
DownloadTimeoutError,
NotConfigured,
ResponseDataLossError,
UnsupportedURLSchemeError,
)
from scrapy.http import Headers, Response
from scrapy.utils._download_handlers import (
BaseHttpDownloadHandler,
check_stop_download,
get_dataloss_msg,
get_maxsize_msg,
get_warnsize_msg,
make_response,
normalize_bind_address,
from scrapy.http import Headers
from scrapy.utils._download_handlers import NullCookieJar
from scrapy.utils.python import _iter_exc_causes
from scrapy.utils.ssl import (
_log_sslobj_debug_info,
_make_insecure_ssl_ctx,
_make_ssl_context,
)
from scrapy.utils.asyncio import is_asyncio_available
from scrapy.utils.ssl import _log_sslobj_debug_info, _make_ssl_context
from ._base_streaming import BaseStreamingDownloadHandler, _BaseResponseArgs
if TYPE_CHECKING:
from contextlib import AbstractAsyncContextManager
from http.client import HTTPResponse
from ipaddress import IPv4Address, IPv6Address
from urllib.request import Request as ULRequest
from collections.abc import AsyncIterator
from httpcore import AsyncNetworkStream
from scrapy import Request
from scrapy.crawler import Crawler
HAS_SOCKS = HAS_HTTP2 = False
try:
import httpx
except ImportError:
httpx = None # type: ignore[assignment]
else:
# a small hack to avoid importing these optional extras unconditionally
logger = logging.getLogger(__name__)
DOWNLOAD_FAILED_EXCEPTIONS: tuple[type[BaseException], ...] = (
httpx.RequestError,
httpx.InvalidURL,
)
try:
import h2.exceptions
class _BaseResponseArgs(TypedDict):
status: int
url: str
headers: Headers
ip_address: IPv4Address | IPv6Address
protocol: str
# workaround for (and from) https://github.com/encode/httpx/issues/2992
class _NullCookieJar(CookieJar): # pragma: no cover
"""A CookieJar that rejects all cookies."""
def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None:
HAS_HTTP2 = True
DOWNLOAD_FAILED_EXCEPTIONS += (h2.exceptions.InvalidBodyLengthError,)
except ImportError: # pragma: no cover
pass
def set_cookie(self, cookie: Cookie) -> None:
try:
import socksio.exceptions
HAS_SOCKS = True
DOWNLOAD_FAILED_EXCEPTIONS += (socksio.exceptions.ProtocolError,)
except ImportError: # pragma: no cover
pass
class HttpxDownloadHandler(BaseHttpDownloadHandler):
_DEFAULT_CONNECT_TIMEOUT = 10
if TYPE_CHECKING:
_Base = BaseStreamingDownloadHandler[httpx.Response]
else:
_Base = BaseStreamingDownloadHandler
class HttpxDownloadHandler(_Base):
experimental: ClassVar[bool] = True
def __init__(self, crawler: Crawler):
# we skip HttpxDownloadHandler tests with the non-asyncio reactor
if not is_asyncio_available(): # pragma: no cover
super().__init__(crawler)
self._verify_certificates: bool = crawler.settings.getbool(
"DOWNLOAD_VERIFY_CERTIFICATES"
)
self._enable_h2: bool = crawler.settings.getbool("HTTPX_HTTP2_ENABLED")
if self._enable_h2 and not HAS_HTTP2: # pragma: no cover
raise NotConfigured(
f"{type(self).__name__} requires the asyncio support. Make"
f" sure that you have either enabled the asyncio Twisted"
f" reactor in the TWISTED_REACTOR setting or disabled the"
f" TWISTED_REACTOR_ENABLED setting. See the asyncio"
f" documentation of Scrapy for more information."
f"HTTP/2 support in {type(self).__name__} requires the 'httpx[http2]' extra to be installed."
)
self._ssl_context: ssl.SSLContext = _make_ssl_context(crawler.settings)
self._bind_host: str | None = self._get_bind_address_host()
self._limits: httpx.Limits = httpx.Limits(
# hard limit on simultaneous connections
max_connections=self._pool_size_total,
# total number of idle connections in the pool (extra ones are closed)
max_keepalive_connections=self._pool_size_total,
)
self._default_client: httpx.AsyncClient = self._make_client()
# httpx doesn't support per-request proxies: https://github.com/encode/httpx/discussions/3183,
# so we keep a pool of clients per proxy URL. LRU eviction can be added here if needed.
self._proxy_clients: dict[str, httpx.AsyncClient] = {}
@staticmethod
def _check_deps_installed() -> None:
if httpx is None: # pragma: no cover
raise NotConfigured(
f"{type(self).__name__} requires the httpx library to be installed."
"HttpxDownloadHandler requires the httpx library to be installed."
)
super().__init__(crawler)
logger.warning(
"HttpxDownloadHandler is experimental and is not recommended for production use."
)
bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS")
bind_address = normalize_bind_address(bind_address)
self._bind_address: str | None = None
def _make_client(self, proxy_url: str | None = None) -> httpx.AsyncClient:
if proxy_url:
if proxy_url.startswith("https:") and not self._verify_certificates:
proxy_ssl_context = _make_insecure_ssl_ctx()
else:
proxy_ssl_context = None
proxy = httpx.Proxy(proxy_url, ssl_context=proxy_ssl_context)
else:
proxy = None
if bind_address is not None:
host, port = bind_address
if port != 0:
logger.warning(
"DOWNLOAD_BIND_ADDRESS specifies a port (%s), but %s does not "
"support binding to a specific local port. Ignoring the port "
"and binding only to %r.",
port,
type(self).__name__,
host,
)
self._bind_address = host
self._client = httpx.AsyncClient(
cookies=_NullCookieJar(),
client = httpx.AsyncClient(
cookies=NullCookieJar(),
transport=httpx.AsyncHTTPTransport(
verify=_make_ssl_context(crawler.settings),
local_address=self._bind_address,
verify=self._ssl_context,
local_address=self._bind_host,
http2=self._enable_h2,
limits=self._limits,
trust_env=False,
proxy=proxy,
),
)
# https://github.com/encode/httpx/discussions/1566
for header_name in ("accept", "accept-encoding", "user-agent"):
self._client.headers.pop(header_name, None)
client.headers.pop(header_name, None)
return client
async def download_request(self, request: Request) -> Response:
self._warn_unsupported_meta(request.meta)
def _get_client(self, proxy_url: str | None) -> httpx.AsyncClient:
if proxy_url is None:
return self._default_client
if cached := self._proxy_clients.get(proxy_url):
return cached
client = self._make_client(proxy_url)
self._proxy_clients[proxy_url] = client
return client
@asynccontextmanager
async def _make_request(
self, request: Request, timeout: float
) -> AsyncIterator[httpx.Response]:
proxy = self._extract_proxy_url_with_creds(request)
if proxy and proxy.startswith("socks") and not HAS_SOCKS: # pragma: no cover
raise ValueError(
f"SOCKS proxy support in {type(self).__name__} requires the 'httpx[socks]' extra to be installed."
)
client = self._get_client(proxy)
timeout: float = request.meta.get(
"download_timeout", self._DEFAULT_CONNECT_TIMEOUT
)
start_time = time.monotonic()
try:
async with self._get_httpx_response(request, timeout) as httpx_response:
request.meta["download_latency"] = time.monotonic() - start_time
return await self._read_response(httpx_response, request)
async with client.stream(
request.method,
request.url,
content=request.body,
headers=request.headers.to_tuple_list(),
timeout=timeout,
) as response:
yield response
except httpx.TimeoutException as e:
raise DownloadTimeoutError(
f"Getting {request.url} took longer than {timeout} seconds."
@ -140,168 +169,58 @@ class HttpxDownloadHandler(BaseHttpDownloadHandler):
except httpx.UnsupportedProtocol as e:
raise UnsupportedURLSchemeError(str(e)) from e
except httpx.ConnectError as e:
error_message = str(e)
if (
"Name or service not known" in error_message
or "getaddrinfo failed" in error_message
or "nodename nor servname" in error_message
or "Temporary failure in name resolution" in error_message
):
raise CannotResolveHostError(error_message) from e
if any(isinstance(c, gaierror) for c in _iter_exc_causes(e)):
raise CannotResolveHostError(str(e)) from e
raise DownloadConnectionRefusedError(str(e)) from e
except httpx.NetworkError as e:
except httpx.ProxyError as e:
raise DownloadConnectionRefusedError(str(e)) from e
except DOWNLOAD_FAILED_EXCEPTIONS as e:
raise DownloadFailedError(str(e)) from e
except httpx.RemoteProtocolError as e:
raise DownloadFailedError(str(e)) from e
def _warn_unsupported_meta(self, meta: dict[str, Any]) -> None:
if meta.get("bindaddress"):
# configurable only per-client:
# https://github.com/encode/httpx/issues/755#issuecomment-2746121794
logger.error(
f"The 'bindaddress' request meta key is not supported by"
f" {type(self).__name__} and will be ignored."
)
if meta.get("proxy"):
# configurable only per-client:
# https://github.com/encode/httpx/issues/486
logger.error(
f"The 'proxy' request meta key is not supported by"
f" {type(self).__name__} and will be ignored."
)
def _get_httpx_response(
self, request: Request, timeout: float
) -> AbstractAsyncContextManager[httpx.Response]:
return self._client.stream(
request.method,
request.url,
content=request.body,
headers=request.headers.to_tuple_list(),
timeout=timeout,
)
async def _read_response(
self, httpx_response: httpx.Response, request: Request
) -> Response:
maxsize: int = request.meta.get("download_maxsize", self._default_maxsize)
warnsize: int = request.meta.get("download_warnsize", self._default_warnsize)
content_length = httpx_response.headers.get("Content-Length")
expected_size = int(content_length) if content_length is not None else None
if maxsize and expected_size and expected_size > maxsize:
self._cancel_maxsize(expected_size, maxsize, request, expected=True)
reached_warnsize = False
if warnsize and expected_size and expected_size > warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(expected_size, warnsize, request, expected=True)
)
headers = Headers(httpx_response.headers.multi_items())
network_stream: AsyncNetworkStream = httpx_response.extensions["network_stream"]
make_response_base_args: _BaseResponseArgs = {
"status": httpx_response.status_code,
"url": request.url,
"headers": headers,
"ip_address": self._get_server_ip(network_stream),
"protocol": httpx_response.http_version,
}
self._log_tls_info(network_stream)
if stop_download := check_stop_download(
signals.headers_received,
self.crawler,
request,
headers=headers,
body_length=expected_size,
):
return make_response(
**make_response_base_args,
stop_download=stop_download,
)
response_body = BytesIO()
bytes_received = 0
try:
async for chunk in httpx_response.aiter_raw():
response_body.write(chunk)
bytes_received += len(chunk)
if stop_download := check_stop_download(
signals.bytes_received, self.crawler, request, data=chunk
):
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
stop_download=stop_download,
)
if maxsize and bytes_received > maxsize:
response_body.truncate(0)
self._cancel_maxsize(
bytes_received, maxsize, request, expected=False
)
if warnsize and bytes_received > warnsize and not reached_warnsize:
reached_warnsize = True
logger.warning(
get_warnsize_msg(
bytes_received, warnsize, request, expected=False
)
)
except httpx.RemoteProtocolError as e:
# special handling of the dataloss case
if (
"peer closed connection without sending complete message body"
not in str(e)
):
raise
fail_on_dataloss: bool = request.meta.get(
"download_fail_on_dataloss", self._fail_on_dataloss
)
if not fail_on_dataloss:
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
flags=["dataloss"],
)
self._log_dataloss_warning(request.url)
raise ResponseDataLossError(str(e)) from e
return make_response(
**make_response_base_args,
body=response_body.getvalue(),
)
@staticmethod
def _get_server_ip(network_stream: AsyncNetworkStream) -> IPv4Address | IPv6Address:
extra_server_addr = network_stream.get_extra_info("server_addr")
return ipaddress.ip_address(extra_server_addr[0])
def _extract_headers(response: httpx.Response) -> Headers:
return Headers(response.headers.multi_items())
def _log_tls_info(self, network_stream: AsyncNetworkStream) -> None:
if not self._tls_verbose_logging:
return
@staticmethod
def _build_base_response_args(
response: httpx.Response,
request: Request,
headers: Headers,
) -> _BaseResponseArgs:
network_stream: AsyncNetworkStream = response.extensions["network_stream"]
server_addr = network_stream.get_extra_info("server_addr")
ip_address = ipaddress.ip_address(server_addr[0])
ssl_object = network_stream.get_extra_info("ssl_object")
if isinstance(ssl_object, ssl.SSLObject):
cert = ssl_object.getpeercert(binary_form=True)
else:
cert = None
return {
"status": response.status_code,
"url": request.url,
"headers": headers,
"certificate": cert,
"ip_address": ip_address,
"protocol": response.http_version,
}
@staticmethod
def _iter_body_chunks(response: httpx.Response) -> AsyncIterator[bytes]:
return response.aiter_raw()
@staticmethod
def _is_dataloss_exception(exc: Exception) -> bool:
return isinstance(
exc, httpx.RemoteProtocolError
) and "peer closed connection without sending complete message body" in str(exc)
def _log_tls_info(self, response: httpx.Response, request: Request) -> None:
network_stream: AsyncNetworkStream = response.extensions["network_stream"]
extra_ssl_object = network_stream.get_extra_info("ssl_object")
if isinstance(extra_ssl_object, ssl.SSLObject):
_log_sslobj_debug_info(extra_ssl_object)
def _log_dataloss_warning(self, url: str) -> None:
if self._fail_on_dataloss_warned:
return
logger.warning(get_dataloss_msg(url))
self._fail_on_dataloss_warned = True
@staticmethod
def _cancel_maxsize(
size: int, limit: int, request: Request, *, expected: bool
) -> NoReturn:
warning_msg = get_maxsize_msg(size, limit, request, expected=expected)
logger.warning(warning_msg)
raise DownloadCancelledError(warning_msg)
async def close(self) -> None:
await self._client.aclose()
await self._default_client.aclose()
for client in self._proxy_clients.values():
await client.aclose()

View File

@ -1,7 +1,6 @@
# pragma: no file cover
import warnings
from scrapy.core.downloader.handlers.http10 import HTTP10DownloadHandler
from scrapy.core.downloader.handlers.http11 import (
HTTP11DownloadHandler as HTTPDownloadHandler,
)
@ -16,6 +15,5 @@ warnings.warn(
)
__all__ = [
"HTTP10DownloadHandler",
"HTTPDownloadHandler",
]

View File

@ -1,79 +0,0 @@
"""Download handlers for http and https schemes"""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
from scrapy.core.downloader.contextfactory import _ScrapyClientContextFactory
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import to_unicode
if TYPE_CHECKING:
from twisted.internet.interfaces import IConnector
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request
from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.settings import BaseSettings
class HTTP10DownloadHandler:
lazy = False
def __init__(self, settings: BaseSettings, crawler: Crawler):
warnings.warn(
"HTTP10DownloadHandler is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
if not crawler.settings.getbool("TWISTED_REACTOR_ENABLED"): # pragma: no cover
raise NotConfigured(f"{type(self).__name__} requires a Twisted reactor.")
self.HTTPClientFactory: type[ScrapyHTTPClientFactory] = load_object(
settings["DOWNLOADER_HTTPCLIENTFACTORY"]
)
if settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] == "SENTINEL":
self.ClientContextFactory: type[_ScrapyClientContextFactory] = (
_ScrapyClientContextFactory
)
else: # pragma: no cover
warnings.warn(
"The 'DOWNLOADER_CLIENTCONTEXTFACTORY' setting is deprecated.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self.ClientContextFactory = load_object(
settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]
)
self._settings: BaseSettings = settings
self._crawler: Crawler = crawler
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler)
async def download_request(self, request: Request) -> Response:
factory = self.HTTPClientFactory(request)
self._connect(factory)
return await maybe_deferred_to_future(factory.deferred)
def _connect(self, factory: ScrapyHTTPClientFactory) -> IConnector:
from twisted.internet import reactor
host, port = to_unicode(factory.host), factory.port
if factory.scheme == b"https":
client_context_factory = build_from_crawler(
self.ClientContextFactory,
self._crawler,
)
return reactor.connectSSL(host, port, factory, client_context_factory)
return reactor.connectTCP(host, port, factory)
async def close(self) -> None:
pass

View File

@ -6,6 +6,7 @@ import ipaddress
import logging
import re
from contextlib import suppress
from functools import partial
from io import BytesIO
from time import monotonic
from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast
@ -40,7 +41,6 @@ from scrapy.exceptions import (
)
from scrapy.http import Headers, Response
from scrapy.utils._download_handlers import (
BaseHttpDownloadHandler,
check_stop_download,
get_dataloss_msg,
get_maxsize_msg,
@ -56,6 +56,8 @@ from scrapy.utils.python import to_bytes, to_unicode
from scrapy.utils.ssl import _log_ssl_conn_debug_info
from scrapy.utils.url import add_http_if_no_scheme
from ._base_http import BaseHttpDownloadHandler
if TYPE_CHECKING:
from twisted.internet.base import ReactorBase
from twisted.internet.interfaces import IConsumer
@ -110,7 +112,7 @@ class HTTP11DownloadHandler(BaseHttpDownloadHandler):
"download_warnsize", "DOWNLOAD_WARNSIZE"
)
agent = ScrapyAgent(
agent = _ScrapyAgent(
contextFactory=self._contextFactory,
bindAddress=self._bind_address,
pool=self._pool,
@ -160,7 +162,7 @@ class TunnelError(Exception):
"""An HTTP CONNECT tunnel could not be established by the proxy."""
class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
class _TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
"""An endpoint that tunnels through proxies to allow HTTPS downloads. To
accomplish that, this endpoint sends an HTTP CONNECT to the proxy.
The HTTP CONNECT is always sent when using this endpoint, I think this could
@ -196,7 +198,7 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
def requestTunnel(self, protocol: Protocol) -> Protocol:
"""Asks the proxy to open a tunnel."""
assert protocol.transport
tunnelReq = tunnel_request_data(
tunnelReq = _tunnel_request_data(
self._tunneledHost, self._tunneledPort, self._proxyAuthHeader
)
protocol.transport.write(tunnelReq)
@ -220,11 +222,12 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
if b"\r\n\r\n" not in self._connectBuffer:
return
self._protocol.dataReceived = self._protocolDataReceived # type: ignore[method-assign]
respm = TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer)
respm = _TunnelingTCP4ClientEndpoint._responseMatcher.match(self._connectBuffer)
if respm and int(respm.group("status")) == 200:
# set proper Server Name Indication extension
sslOptions = self._contextFactory.creatorForNetloc( # type: ignore[call-arg,misc]
self._tunneledHost, self._tunneledPort
self._tunneledHost, # type: ignore[arg-type]
self._tunneledPort,
)
self._protocol.transport.startTLS(sslOptions, self._protocolFactory)
self._tunnelReadyDeferred.callback(self._protocol)
@ -256,18 +259,18 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
return self._tunnelReadyDeferred
def tunnel_request_data(
def _tunnel_request_data(
host: str, port: int, proxy_auth_header: bytes | None = None
) -> bytes:
r"""
Return binary content of a CONNECT request.
>>> from scrapy.utils.python import to_unicode as s
>>> s(tunnel_request_data("example.com", 8080))
>>> s(_tunnel_request_data("example.com", 8080))
'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\n\r\n'
>>> s(tunnel_request_data("example.com", 8080, b"123"))
>>> s(_tunnel_request_data("example.com", 8080, b"123"))
'CONNECT example.com:8080 HTTP/1.1\r\nHost: example.com:8080\r\nProxy-Authorization: 123\r\n\r\n'
>>> s(tunnel_request_data(b"example.com", "8090"))
>>> s(_tunnel_request_data(b"example.com", "8090"))
'CONNECT example.com:8090 HTTP/1.1\r\nHost: example.com:8090\r\n\r\n'
"""
host_value = to_bytes(host, encoding="ascii") + b":" + to_bytes(str(port))
@ -279,7 +282,7 @@ def tunnel_request_data(
return tunnel_req
class TunnelingAgent(Agent):
class _TunnelingAgent(Agent):
"""An agent that uses a L{TunnelingTCP4ClientEndpoint} to make HTTPS
downloads. It may look strange that we have chosen to subclass Agent and not
ProxyAgent but consider that after the tunnel is opened the proxy is
@ -301,8 +304,8 @@ class TunnelingAgent(Agent):
self._proxyConf: tuple[str, int, bytes | None] = proxyConf
self._contextFactory: IPolicyForHTTPS = contextFactory
def _getEndpoint(self, uri: URI) -> TunnelingTCP4ClientEndpoint:
return TunnelingTCP4ClientEndpoint(
def _getEndpoint(self, uri: URI) -> _TunnelingTCP4ClientEndpoint:
return _TunnelingTCP4ClientEndpoint(
reactor=self._reactor,
host=uri.host,
port=uri.port,
@ -337,17 +340,19 @@ class TunnelingAgent(Agent):
)
class ScrapyProxyAgent(Agent):
class _ScrapyProxyAgent(Agent):
def __init__(
self,
reactor: ReactorBase,
proxyURI: bytes,
contextFactory: IPolicyForHTTPS,
connectTimeout: float | None = None,
bindAddress: tuple[str, int] | None = None,
pool: HTTPConnectionPool | None = None,
):
super().__init__( # type: ignore[no-untyped-call]
reactor=reactor,
contextFactory=contextFactory,
connectTimeout=connectTimeout,
bindAddress=bindAddress,
pool=pool,
@ -377,11 +382,7 @@ class ScrapyProxyAgent(Agent):
)
class ScrapyAgent:
_Agent = Agent
_ProxyAgent = ScrapyProxyAgent
_TunnelingAgent = TunnelingAgent
class _ScrapyAgent:
def __init__(
self,
*,
@ -420,10 +421,14 @@ class ScrapyAgent:
if not proxy_port:
proxy_port = 443 if proxy_parsed.scheme == "https" else 80
if urlparse_cached(request).scheme == "https":
if proxy_parsed.scheme == "https": # pragma: no cover
raise NotImplementedError(
"HTTPS proxies for HTTPS destinations are not supported"
)
assert proxy_host is not None
proxyAuth = request.headers.get(b"Proxy-Authorization", None)
proxyConf = (proxy_host, proxy_port, proxyAuth)
return self._TunnelingAgent(
return _TunnelingAgent(
reactor=reactor,
proxyConf=proxyConf,
contextFactory=self._contextFactory,
@ -431,15 +436,16 @@ class ScrapyAgent:
bindAddress=bindaddress,
pool=self._pool,
)
return self._ProxyAgent(
return _ScrapyProxyAgent(
reactor=reactor,
proxyURI=to_bytes(proxy, encoding="ascii"),
contextFactory=self._contextFactory,
connectTimeout=timeout,
bindAddress=bindaddress,
pool=self._pool,
)
return self._Agent( # type: ignore[no-untyped-call]
return Agent(
reactor=reactor,
contextFactory=self._contextFactory,
connectTimeout=timeout,
@ -457,7 +463,7 @@ class ScrapyAgent:
url = urldefrag(request.url)[0]
method = to_bytes(request.method)
headers = TxHeaders(request.headers)
if isinstance(agent, self._TunnelingAgent):
if isinstance(agent, _TunnelingAgent):
headers.removeHeader(b"Proxy-Authorization")
bodyproducer = _RequestBodyProducer(request.body) if request.body else None
start_time = monotonic()
@ -547,11 +553,7 @@ class ScrapyAgent:
get_warnsize_msg(expected_size, warnsize, request, expected=True)
)
def _cancel(_: Any) -> None:
# Abort connection immediately.
txresponse._transport._producer.abortConnection()
d: Deferred[_ResultT] = Deferred(_cancel)
d: Deferred[_ResultT] = Deferred(partial(self._cancel, txresponse=txresponse))
txresponse.deliverBody(
_ResponseReader(
finished=d,
@ -570,6 +572,11 @@ class ScrapyAgent:
return d
@staticmethod
def _cancel(_: Any, txresponse: TxResponse) -> None:
# Abort connection immediately.
txresponse._transport._producer.abortConnection()
def _cb_bodydone(self, result: _ResultT, url: str) -> Response:
headers = self._headers_from_twisted_response(result["txresponse"])
try:
@ -667,17 +674,17 @@ class _ResponseReader(Protocol):
assert hostname is not None
_log_ssl_conn_debug_info(hostname, connection)
def dataReceived(self, bodyBytes: bytes) -> None:
def dataReceived(self, data: bytes) -> None:
# This maybe called several times after cancel was called with buffered data.
if self._finished.called:
return
assert self.transport
self._bodybuf.write(bodyBytes)
self._bytes_received += len(bodyBytes)
self._bodybuf.write(data)
self._bytes_received += len(data)
if stop_download := check_stop_download(
signals.bytes_received, self._crawler, self._request, data=bodyBytes
signals.bytes_received, self._crawler, self._request, data=data
):
self.transport.stopProducing()
self.transport.loseConnection()

View File

@ -4,19 +4,20 @@ from time import monotonic
from typing import TYPE_CHECKING
from urllib.parse import urldefrag
from twisted.web.client import URI
from scrapy.core.downloader.contextfactory import _load_context_factory_from_settings
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent
from scrapy.exceptions import DownloadTimeoutError, NotConfigured
from scrapy.core.http2.agent import H2Agent, H2ConnectionPool
from scrapy.exceptions import (
DownloadTimeoutError,
NotConfigured,
UnsupportedURLSchemeError,
)
from scrapy.utils._download_handlers import (
normalize_bind_address,
wrap_twisted_exceptions,
)
from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes
if TYPE_CHECKING:
from twisted.internet.base import DelayedCall
@ -44,7 +45,11 @@ class H2DownloadHandler(BaseDownloadHandler):
self._bind_address = crawler.settings.get("DOWNLOAD_BIND_ADDRESS")
async def download_request(self, request: Request) -> Response:
agent = ScrapyH2Agent(
if urlparse_cached(request).scheme == "http": # pragma: no cover
raise UnsupportedURLSchemeError(
f"{type(self).__name__} doesn't support plain HTTP."
)
agent = _ScrapyH2Agent(
context_factory=self._context_factory,
pool=self._pool,
bind_address=self._bind_address,
@ -60,10 +65,7 @@ class H2DownloadHandler(BaseDownloadHandler):
self._pool.close_connections()
class ScrapyH2Agent:
_Agent = H2Agent
_ProxyAgent = ScrapyProxyH2Agent
class _ScrapyH2Agent:
def __init__(
self,
context_factory: IPolicyForHTTPS,
@ -81,25 +83,11 @@ class ScrapyH2Agent:
def _get_agent(self, request: Request, timeout: float | None) -> H2Agent:
from twisted.internet import reactor
if request.meta.get("proxy"): # pragma: no cover
raise NotImplementedError(f"{type(self).__name__} doesn't support proxies.")
bind_address = request.meta.get("bindaddress") or self._bind_address
bind_address = normalize_bind_address(bind_address)
proxy = request.meta.get("proxy")
if proxy:
if urlparse_cached(request).scheme == "https":
# ToDo
raise NotImplementedError(
"Tunneling via CONNECT method using HTTP/2.0 is not yet supported"
)
return self._ProxyAgent(
reactor=reactor,
context_factory=self._context_factory,
proxy_uri=URI.fromBytes(to_bytes(proxy, encoding="ascii")),
connect_timeout=timeout,
bind_address=bind_address,
pool=self._pool,
)
return self._Agent(
return H2Agent(
reactor=reactor,
context_factory=self._context_factory,
connect_timeout=timeout,

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.exceptions import NotConfigured
@ -9,6 +9,8 @@ from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import build_from_crawler, load_object
if TYPE_CHECKING:
from collections.abc import Mapping
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.http import Response
@ -39,7 +41,7 @@ class S3DownloadHandler(BaseDownloadHandler):
)
)
_http_handler = build_from_crawler(
_http_handler: BaseDownloadHandler = build_from_crawler(
load_object(crawler.settings.getwithbase("DOWNLOAD_HANDLERS")["https"]),
crawler,
)
@ -59,7 +61,7 @@ class S3DownloadHandler(BaseDownloadHandler):
awsrequest = botocore.awsrequest.AWSRequest(
method=request.method,
url=f"{scheme}://s3.amazonaws.com/{bucket}{path}",
headers=request.headers.to_unicode_dict(),
headers=cast("Mapping[str, Any]", request.headers.to_unicode_dict()),
data=request.body,
)
assert self._signer

View File

@ -8,7 +8,7 @@ from __future__ import annotations
import warnings
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput
from scrapy.http import Request, Response
@ -75,87 +75,82 @@ class DownloaderMiddlewareManager(MiddlewareManager):
download_func: Callable[[Request], Coroutine[Any, Any, Response]],
request: Request,
) -> Response | Request:
async def process_request(request: Request) -> Response | Request:
for method in self.methods["process_request"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(request=request, spider=self._spider),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request), _warn=global_object_name(method)
)
if response is not None and not isinstance(
response, (Response, Request)
):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {response.__class__.__name__}"
)
if response:
return response
return await download_func(request)
async def process_response(response: Response | Request) -> Response | Request:
if response is None:
raise TypeError("Received None in process_response")
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(request=request, response=response, spider=self._spider),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request, response=response),
_warn=global_object_name(method),
)
if not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return Response or Request, "
f"got {type(response)}"
)
if isinstance(response, Request):
return response
return response
async def process_exception(exception: Exception) -> Response | Request:
for method in self.methods["process_exception"]:
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
response = await ensure_awaitable(
method(
request=request, exception=exception, spider=self._spider
),
_warn=global_object_name(method),
)
else:
response = await ensure_awaitable(
method(request=request, exception=exception),
_warn=global_object_name(method),
)
if response is not None and not isinstance(
response, (Response, Request)
):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {type(response)}"
)
if response:
return response
raise exception
try:
result: Response | Request = await process_request(request)
result: Response | Request = await self._process_request(
request, download_func
)
except Exception as ex:
await _defer_sleep_async()
# either returns a request or response (which we pass to process_response())
# or reraises the exception
result = await process_exception(ex)
return await process_response(result)
result = await self._process_exception(ex, request)
return await self._process_response(result, request)
def _handle_mw_method(self, method: Callable[..., Any], **kwargs: Any) -> Any:
if method in self._mw_methods_requiring_spider:
kwargs["spider"] = self._spider
return method(**kwargs)
async def _process_request(
self,
request: Request,
download_func: Callable[[Request], Coroutine[Any, Any, Response]],
) -> Response | Request:
for method in self.methods["process_request"]:
assert method is not None
response = await ensure_awaitable(
self._handle_mw_method(method, request=request),
_warn=global_object_name(method),
)
if response is not None and not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {response.__class__.__name__}"
)
if response:
return response
return await download_func(request)
async def _process_response(
self, response: Response | Request, request: Request
) -> Response | Request:
if response is None:
raise TypeError("Received None in process_response")
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
assert method is not None
response = await ensure_awaitable(
self._handle_mw_method(method, request=request, response=response),
_warn=global_object_name(method),
)
if not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return Response or Request, "
f"got {type(response)}"
)
if isinstance(response, Request):
return response
return response
async def _process_exception(
self, exception: Exception, request: Request | Response
) -> Response | Request:
for method in self.methods["process_exception"]:
assert method is not None
response = await ensure_awaitable(
self._handle_mw_method(method, request=request, exception=exception),
_warn=global_object_name(method),
)
if response is not None and not isinstance(response, (Response, Request)):
raise _InvalidOutput(
f"Middleware {method.__qualname__} must return None, Response or "
f"Request, got {type(response)}"
)
if response:
return response
raise exception

View File

@ -1,29 +1,70 @@
from __future__ import annotations
import logging
from typing import Any
import warnings
from typing import TYPE_CHECKING, Any
from OpenSSL import SSL
from service_identity import VerificationError
from service_identity.exceptions import CertificateError
from service_identity.pyopenssl import verify_hostname, verify_ip_address
from service_identity.hazmat import (
DNS_ID,
IPAddress_ID,
ServiceID,
verify_service_identity,
)
from service_identity.pyopenssl import (
extract_patterns,
verify_hostname,
verify_ip_address,
)
from twisted.internet._sslverify import ClientTLSOptions
from twisted.internet.ssl import AcceptableCiphers
from twisted.internet.ssl import AcceptableCiphers, TLSVersion
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.deprecate import create_deprecated_class
if TYPE_CHECKING:
from collections.abc import Callable
from OpenSSL.crypto import X509
from twisted.protocols.tls import TLSMemoryBIOProtocol
logger = logging.getLogger(__name__)
METHOD_TLS = "TLS"
METHOD_TLSv10 = "TLSv1.0"
METHOD_TLSv11 = "TLSv1.1"
METHOD_TLSv12 = "TLSv1.2"
_openssl_methods: dict[str, int] = {
"TLS": SSL.SSLv23_METHOD, # protocol negotiation (recommended)
"TLSv1.0": SSL.TLSv1_METHOD, # TLS 1.0 only
"TLSv1.1": SSL.TLSv1_1_METHOD, # TLS 1.1 only
"TLSv1.2": SSL.TLSv1_2_METHOD, # TLS 1.2 only
}
openssl_methods: dict[str, int] = {
METHOD_TLS: SSL.SSLv23_METHOD, # protocol negotiation (recommended)
METHOD_TLSv10: SSL.TLSv1_METHOD, # TLS 1.0 only
METHOD_TLSv11: SSL.TLSv1_1_METHOD, # TLS 1.1 only
METHOD_TLSv12: SSL.TLSv1_2_METHOD, # TLS 1.2 only
def __getattr__(name: str) -> Any:
deprecated = {
"METHOD_TLS": "TLS",
"METHOD_TLSv10": "TLSv1.0",
"METHOD_TLSv11": "TLSv1.1",
"METHOD_TLSv12": "TLSv1.2",
"openssl_methods": _openssl_methods,
}
if name in deprecated:
warnings.warn(
f"scrapy.core.downloader.tls.{name} is deprecated.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return deprecated[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
_TWISTED_VERSION_MAP: dict[str, TLSVersion] = {
"TLSv1.0": TLSVersion.TLSv1_0,
"TLSv1.1": TLSVersion.TLSv1_1,
"TLSv1.2": TLSVersion.TLSv1_2,
"TLSv1.3": TLSVersion.TLSv1_3,
}
@ -39,6 +80,8 @@ class _ScrapyClientTLSOptions(ClientTLSOptions):
Instances of this class are returned from
:class:`._ScrapyClientContextFactory`.
This class is used on Twisted older than 26.4.0.
"""
def _identityVerifyingInfoCallback(
@ -64,7 +107,7 @@ class _ScrapyClientTLSOptions(ClientTLSOptions):
e,
)
else:
super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[no-untyped-call]
super()._identityVerifyingInfoCallback(connection, where, ret) # type: ignore[misc]
ScrapyClientTLSOptions = create_deprecated_class(
@ -75,6 +118,67 @@ ScrapyClientTLSOptions = create_deprecated_class(
)
class _ScrapyClientTLSOptions26(ClientTLSOptions):
"""
SSL Client connection creator ignoring certificate verification errors
(for genuinely invalid certificates or bugs in verification code).
Same as Twisted's private _sslverify.ClientTLSOptions,
except that VerificationError, CertificateError and ValueError
exceptions are caught, so that the connection is not closed, only
logging warnings.
Instances of this class are returned from
:class:`._ScrapyClientContextFactory`.
This class is used on Twisted 26.4.0 and newer.
"""
def clientConnectionForTLS(
self, tlsProtocol: TLSMemoryBIOProtocol
) -> SSL.Connection:
"""This method is needed to override the verify callback."""
conn = super().clientConnectionForTLS(tlsProtocol)
callback = self._verifyCB(self._hostnameIsDnsName, self._hostnameASCII)
conn.set_verify(SSL.VERIFY_PEER | SSL.VERIFY_FAIL_IF_NO_PEER_CERT, callback)
return conn
@staticmethod
def _verifyCB(
hostIsDNS: bool, hostnameASCII: str
) -> Callable[[SSL.Connection, X509, int, int, int], bool]:
svcid: ServiceID = (
DNS_ID(hostnameASCII) if hostIsDNS else IPAddress_ID(hostnameASCII)
)
def verifyCallback(
conn: SSL.Connection, cert: X509, err: int, depth: int, ok: int
) -> bool:
if depth != 0:
# We are only verifying the leaf certificate.
return True
try:
verify_service_identity(extract_patterns(cert), [svcid], [])
except (CertificateError, VerificationError) as e:
logger.warning(
'Remote certificate is not valid for hostname "%s"; %s',
hostnameASCII,
e,
)
except ValueError as e:
logger.warning(
"Ignoring error while verifying certificate "
'from host "%s" (exception: %r)',
hostnameASCII,
e,
)
return True
return verifyCallback
DEFAULT_CIPHERS: AcceptableCiphers = AcceptableCiphers.fromOpenSSLCipherString(
"DEFAULT"
)

View File

@ -1,243 +0,0 @@
"""Deprecated HTTP/1.0 helper classes used by HTTP10DownloadHandler."""
from __future__ import annotations
import warnings
from time import monotonic, time
from typing import TYPE_CHECKING
from urllib.parse import urldefrag, urlparse, urlunparse
from twisted.internet import defer
from twisted.internet.protocol import ClientFactory
from twisted.web.http import HTTPClient
from scrapy.exceptions import DownloadTimeoutError, ScrapyDeprecationWarning
from scrapy.http import Headers, Response
from scrapy.responsetypes import responsetypes
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes, to_unicode
if TYPE_CHECKING:
from scrapy import Request
class ScrapyHTTPPageGetter(HTTPClient):
delimiter = b"\n"
def __init__(self):
warnings.warn(
"ScrapyHTTPPageGetter is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
super().__init__()
def connectionMade(self):
self.headers = Headers() # bucket for response headers
# Method command
self.sendCommand(self.factory.method, self.factory.path)
# Headers
for key, values in self.factory.headers.items():
for value in values:
self.sendHeader(key, value)
self.endHeaders()
# Body
if self.factory.body is not None:
self.transport.write(self.factory.body)
def lineReceived(self, line):
return HTTPClient.lineReceived(self, line.rstrip())
def handleHeader(self, key, value):
self.headers.appendlist(key, value)
def handleStatus(self, version, status, message):
self.factory.gotStatus(version, status, message)
def handleEndHeaders(self):
self.factory.gotHeaders(self.headers)
def connectionLost(self, reason):
self._connection_lost_reason = reason
HTTPClient.connectionLost(self, reason)
self.factory.noPage(reason)
def handleResponse(self, response):
if self.factory.method.upper() == b"HEAD":
self.factory.page(b"")
elif self.length is not None and self.length > 0:
self.factory.noPage(self._connection_lost_reason)
else:
self.factory.page(response)
self.transport.loseConnection()
def timeout(self):
self.transport.loseConnection()
# transport cleanup needed for HTTPS connections
if self.factory.url.startswith(b"https"):
self.transport.stopProducing()
self.factory.noPage(
DownloadTimeoutError(
f"Getting {self.factory.url} took longer "
f"than {self.factory.timeout} seconds."
)
)
# This class used to inherit from Twisteds
# twisted.web.client.HTTPClientFactory. When that class was deprecated in
# Twisted (https://github.com/twisted/twisted/pull/643), we merged its
# non-overridden code into this class.
class ScrapyHTTPClientFactory(ClientFactory):
protocol = ScrapyHTTPPageGetter
waiting = 1
noisy = False
followRedirect = False
afterFoundGet = False
def _build_response(self, body, request):
request.meta["download_latency"] = (
self._headers_time_mono - self._start_time_mono
)
status = int(self.status)
headers = Headers(self.response_headers)
respcls = responsetypes.from_args(headers=headers, url=self._url, body=body)
return respcls(
url=self._url,
status=status,
headers=headers,
body=body,
protocol=to_unicode(self.version),
)
def _set_connection_attributes(self, request):
proxy = request.meta.get("proxy")
if proxy:
proxy_parsed = urlparse(to_bytes(proxy, encoding="ascii"))
self.scheme = proxy_parsed.scheme
self.host = proxy_parsed.hostname
self.port = proxy_parsed.port
self.netloc = proxy_parsed.netloc
if self.port is None:
self.port = 443 if proxy_parsed.scheme == b"https" else 80
self.path = self.url
else:
parsed = urlparse_cached(request)
path_str = urlunparse(
("", "", parsed.path or "/", parsed.params, parsed.query, "")
)
self.path = to_bytes(path_str, encoding="ascii")
assert parsed.hostname is not None
self.host = to_bytes(parsed.hostname, encoding="ascii")
self.port = parsed.port
self.scheme = to_bytes(parsed.scheme, encoding="ascii")
self.netloc = to_bytes(parsed.netloc, encoding="ascii")
if self.port is None:
self.port = 443 if self.scheme == b"https" else 80
def __init__(self, request: Request, timeout: float = 180):
warnings.warn(
"ScrapyHTTPClientFactory is deprecated and will be removed in a future Scrapy version.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self._url: str = urldefrag(request.url)[0]
# converting to bytes to comply to Twisted interface
self.url: bytes = to_bytes(self._url, encoding="ascii")
self.method: bytes = to_bytes(request.method, encoding="ascii")
self.body: bytes | None = request.body or None
self.headers: Headers = Headers(request.headers)
self.response_headers: Headers | None = None
self.timeout: float = request.meta.get("download_timeout") or timeout
self.start_time: float = time()
self._start_time_mono: float = monotonic()
self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback(
self._build_response, request
)
# Fixes Twisted 11.1.0+ support as HTTPClientFactory is expected
# to have _disconnectedDeferred. See Twisted r32329.
# As Scrapy implements it's own logic to handle redirects is not
# needed to add the callback _waitForDisconnect.
# Specifically this avoids the AttributeError exception when
# clientConnectionFailed method is called.
self._disconnectedDeferred: defer.Deferred[None] = defer.Deferred()
self._set_connection_attributes(request)
# set Host header based on url
self.headers.setdefault("Host", self.netloc)
# set Content-Length based len of body
if self.body is not None:
self.headers["Content-Length"] = len(self.body)
# just in case a broken http/1.1 decides to keep connection alive
self.headers.setdefault("Connection", "close")
# Content-Length must be specified in POST method even with no body
elif self.method == b"POST":
self.headers["Content-Length"] = 0
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {self._url}>"
def _cancelTimeout(self, result, timeoutCall):
if timeoutCall.active():
timeoutCall.cancel()
return result
def buildProtocol(self, addr):
p = ClientFactory.buildProtocol(self, addr)
p.followRedirect = self.followRedirect
p.afterFoundGet = self.afterFoundGet
if self.timeout:
from twisted.internet import reactor
timeoutCall = reactor.callLater(self.timeout, p.timeout)
self.deferred.addBoth(self._cancelTimeout, timeoutCall)
return p
def gotHeaders(self, headers):
self.headers_time = time()
self._headers_time_mono = monotonic()
self.response_headers = headers
def gotStatus(self, version, status, message):
"""
Set the status of the request on us.
@param version: The HTTP version.
@type version: L{bytes}
@param status: The HTTP status code, an integer represented as a
bytestring.
@type status: L{bytes}
@param message: The HTTP status message.
@type message: L{bytes}
"""
self.version, self.status, self.message = version, status, message
def page(self, page):
if self.waiting:
self.waiting = 0
self.deferred.callback(page)
def noPage(self, reason):
if self.waiting:
self.waiting = 0
self.deferred.errback(reason)
def clientConnectionFailed(self, _, reason):
"""
When a connection attempt fails, the request cannot be issued. If no
result has yet been provided to the result Deferred, provide the
connection failure reason as an error result.
"""
if self.waiting:
self.waiting = 0
# If the connection attempt failed, there is nothing more to
# disconnect, so just fire that Deferred now.
self._disconnectedDeferred.callback(None)
self.deferred.errback(reason)

View File

@ -11,6 +11,7 @@ import asyncio
import contextlib
import logging
import warnings
from functools import partial
from time import time
from traceback import format_exc
from typing import TYPE_CHECKING, Any
@ -290,7 +291,7 @@ class ExecutionEngine:
"""
assert self._start is not None
try:
item_or_request = await self._start.__anext__()
item_or_request = await anext(self._start)
except StopAsyncIteration:
self._start = None
except Exception as exception:
@ -369,6 +370,10 @@ class ExecutionEngine:
or self.scraper.slot.needs_backout()
)
def _remove_request(self, _: Any, request: Request) -> None:
assert self._slot
self._slot.remove_request(request)
def _start_scheduled_request(self) -> bool:
assert self._slot is not None # typing
assert self.spider is not None # typing
@ -388,11 +393,7 @@ class ExecutionEngine:
)
)
def _remove_request(_: Any) -> None:
assert self._slot
self._slot.remove_request(request)
d2: Deferred[None] = d.addBoth(_remove_request)
d2: Deferred[None] = d.addBoth(partial(self._remove_request, request=request))
d2.addErrback(
lambda f: logger.info(
"Error while removing request from slot",
@ -673,33 +674,36 @@ class ExecutionEngine:
"Closing spider (%(reason)s)", {"reason": reason}, extra={"spider": spider}
)
def log_failure(msg: str) -> None:
logger.error(msg, exc_info=True, extra={"spider": spider}) # noqa: LOG014
if self._stop_mode == "fast":
await self._fast_stop_downloader()
try:
await self._slot.close()
except Exception:
log_failure("Slot close failure")
logger.error("Slot close failure", exc_info=True, extra={"spider": spider})
try:
self.downloader.close()
except Exception:
log_failure("Downloader close failure")
logger.error(
"Downloader close failure", exc_info=True, extra={"spider": spider}
)
try:
await self.scraper.close_spider_async()
except Exception:
log_failure("Scraper close failure")
logger.error(
"Scraper close failure", exc_info=True, extra={"spider": spider}
)
if hasattr(self._slot.scheduler, "close"):
try:
if (d := self._slot.scheduler.close(reason)) is not None:
await maybe_deferred_to_future(d)
except Exception:
log_failure("Scheduler close failure")
logger.error(
"Scheduler close failure", exc_info=True, extra={"spider": spider}
)
try:
await self.signals.send_catch_log_async(
@ -708,7 +712,11 @@ class ExecutionEngine:
reason=reason,
)
except Exception:
log_failure("Error while sending spider_close signal")
logger.error(
"Error while sending spider_close signal",
exc_info=True,
extra={"spider": spider},
)
assert self.crawler.stats
try:
@ -725,7 +733,7 @@ class ExecutionEngine:
else:
self.crawler.stats.close_spider(reason=reason)
except Exception:
log_failure("Stats close failure")
logger.error("Stats close failure")
logger.info(
"Spider closed (%(reason)s)",
@ -739,4 +747,4 @@ class ExecutionEngine:
try:
await ensure_awaitable(self._spider_closed_callback(spider))
except Exception:
log_failure("Error running spider_closed_callback")
logger.error("Error running spider_closed_callback")

View File

@ -143,7 +143,7 @@ class H2Agent:
)
def get_endpoint(self, uri: URI) -> HostnameEndpoint:
return self.endpoint_factory.endpointForURI(uri)
return self.endpoint_factory.endpointForURI(uri) # type: ignore[no-any-return]
def get_key(self, uri: URI) -> ConnectionKeyT:
"""
@ -165,30 +165,3 @@ class H2Agent:
lambda conn: conn.request(request, spider)
)
return d2
class ScrapyProxyH2Agent(H2Agent):
def __init__(
self,
reactor: ReactorBase,
proxy_uri: URI,
pool: H2ConnectionPool,
context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(), # noqa: B008
connect_timeout: float | None = None,
bind_address: tuple[str, int] | None = None,
) -> None:
super().__init__(
reactor=reactor,
pool=pool,
context_factory=context_factory,
connect_timeout=connect_timeout,
bind_address=bind_address,
)
self._proxy_uri = proxy_uri
def get_endpoint(self, uri: URI) -> HostnameEndpoint:
return self.endpoint_factory.endpointForURI(self._proxy_uri)
def get_key(self, uri: URI) -> ConnectionKeyT:
"""We use the proxy uri instead of uri obtained from request url"""
return b"http-proxy", self._proxy_uri.host, self._proxy_uri.port

View File

@ -155,16 +155,16 @@ class Stream:
"status": None,
}
def _cancel(_: Any) -> None:
# Close this stream as gracefully as possible
# If the associated request is initiated we reset this stream
# else we directly call close() method
if self.metadata["request_sent"]:
self.reset_stream(StreamCloseReason.CANCELLED)
else:
self.close(StreamCloseReason.CANCELLED)
self._deferred_response: Deferred[Response] = Deferred(self._cancel)
self._deferred_response: Deferred[Response] = Deferred(_cancel)
def _cancel(self, _: Any) -> None:
# Close this stream as gracefully as possible
# If the associated request is initiated we reset this stream
# else we directly call close() method
if self.metadata["request_sent"]:
self.reset_stream(StreamCloseReason.CANCELLED)
else:
self.close(StreamCloseReason.CANCELLED)
def __repr__(self) -> str:
return f"Stream(id={self.stream_id!r})"

View File

@ -4,17 +4,14 @@ import json
import logging
from abc import abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any
from warnings import warn
from typing import TYPE_CHECKING, Any, cast
# working around https://github.com/sphinx-doc/sphinx/issues/10400
from twisted.internet.defer import Deferred # noqa: TC002
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.spiders import Spider # noqa: TC001
from scrapy.utils.job import job_dir
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import global_object_name
if TYPE_CHECKING:
# requires queuelib >= 1.6.2
@ -337,7 +334,7 @@ class Scheduler(BaseScheduler):
cls = crawler.settings[f"SCHEDULER_START_{queue}_QUEUE"]
if not cls:
return None
return load_object(cls)
return cast("type[BaseQueue]", load_object(cls))
def has_pending_requests(self) -> bool:
return len(self) > 0
@ -450,28 +447,13 @@ class Scheduler(BaseScheduler):
"""Create a new priority queue instance, with in-memory storage"""
assert self.crawler
assert self.pqclass
try:
return build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.mqclass,
key="",
start_queue_cls=self._smqclass,
)
except TypeError: # pragma: no cover
warn(
f"The __init__ method of {global_object_name(self.pqclass)} "
"does not support a `start_queue_cls` keyword-only "
"parameter.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.mqclass,
key="",
)
return build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.mqclass,
key="",
start_queue_cls=self._smqclass,
)
def _dq(self) -> ScrapyPriorityQueue:
"""Create a new priority queue instance, with disk storage"""
@ -479,30 +461,14 @@ class Scheduler(BaseScheduler):
assert self.dqdir
assert self.pqclass
state = self._read_dqs_state(self.dqdir)
try:
q = build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
start_queue_cls=self._sdqclass,
)
except TypeError: # pragma: no cover
warn(
f"The __init__ method of {global_object_name(self.pqclass)} "
"does not support a `start_queue_cls` keyword-only "
"parameter.",
ScrapyDeprecationWarning,
stacklevel=2,
)
q = build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
)
q = build_from_crawler(
self.pqclass,
self.crawler,
downstream_queue_cls=self.dqclass,
key=self.dqdir,
startprios=state,
start_queue_cls=self._sdqclass,
)
if q:
logger.info(
"Resuming crawl (%(queuesize)d requests scheduled)",

View File

@ -23,7 +23,6 @@ from scrapy.exceptions import (
from scrapy.http import Request, Response
from scrapy.pipelines import ItemPipelineManager
from scrapy.utils.asyncio import _parallel_asyncio, is_asyncio_available
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.defer import (
_defer_sleep_async,
_schedule_coro,
@ -126,7 +125,7 @@ class Scraper:
def _check_deprecated_itemproc_method(self, method: str) -> None:
itemproc_cls = type(self.itemproc)
if not hasattr(self.itemproc, "process_item_async"):
if not hasattr(self.itemproc, f"{method}_async"):
warnings.warn(
f"{global_object_name(itemproc_cls)} doesn't define a {method}_async() method,"
f" this is deprecated and the method will be required in future Scrapy versions.",
@ -178,9 +177,7 @@ class Scraper:
self.itemproc.open_spider(self.crawler.spider)
)
def close_spider(
self, spider: Spider | None = None
) -> Deferred[None]: # pragma: no cover
def close_spider(self) -> Deferred[None]: # pragma: no cover
warnings.warn(
"Scraper.close_spider() is deprecated, use close_spider_async() instead",
ScrapyDeprecationWarning,
@ -217,9 +214,8 @@ class Scraper:
self.slot.closing.callback(self.crawler.spider)
@inlineCallbacks
@_warn_spider_arg
def enqueue_scrape(
self, result: Response | Failure, request: Request, spider: Spider | None = None
self, result: Response | Failure, request: Request
) -> Generator[Deferred[Any], Any, None]:
if self.slot is None:
raise RuntimeError("Scraper slot not assigned")
@ -349,13 +345,11 @@ class Scraper:
)
return await ensure_awaitable(iterate_spider_output(output))
@_warn_spider_arg
def handle_spider_error(
self,
_failure: Failure,
request: Request,
response: Response | Failure,
spider: Spider | None = None,
) -> None:
"""Handle an exception raised by a spider callback or errback."""
assert self.crawler.spider
@ -391,7 +385,6 @@ class Scraper:
result: Iterable[_T] | AsyncIterator[_T],
request: Request,
response: Response | Failure,
spider: Spider | None = None,
) -> Deferred[None]: # pragma: no cover
"""Pass items/requests produced by a callback to ``_process_spidermw_output()`` in parallel."""
warnings.warn(

View File

@ -9,31 +9,29 @@ from __future__ import annotations
import logging
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
from functools import wraps
from inspect import isasyncgenfunction, iscoroutine
from inspect import isasyncgenfunction
from itertools import islice
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar
from warnings import warn
from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.python.failure import Failure
from scrapy import Request, Spider
from scrapy.exceptions import ScrapyDeprecationWarning, _InvalidOutput
from scrapy.http import Response
from scrapy.middleware import MiddlewareManager
from scrapy.utils.asyncgen import as_async_generator, collect_asyncgen
from scrapy.utils.asyncgen import as_async_generator
from scrapy.utils.conf import build_component_list
from scrapy.utils.defer import (
_defer_sleep_async,
deferred_from_coro,
maybe_deferred_to_future,
)
from scrapy.utils.python import MutableAsyncChain, MutableChain, global_object_name
from scrapy.utils.python import MutableAsyncChain, global_object_name
if TYPE_CHECKING:
from collections.abc import Generator
from twisted.internet.defer import Deferred
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
@ -47,10 +45,6 @@ ScrapeFunc: TypeAlias = Callable[
]
def _isiterable(o: Any) -> bool:
return isinstance(o, (Iterable, AsyncIterator))
class SpiderMiddlewareManager(MiddlewareManager):
component_name = "spider middleware"
@ -60,85 +54,18 @@ class SpiderMiddlewareManager(MiddlewareManager):
settings.get_component_priority_dict_with_base("SPIDER_MIDDLEWARES")
)
def __init__(self, *middlewares: Any, crawler: Crawler | None = None) -> None:
self._check_deprecated_process_start_requests_use(middlewares)
super().__init__(*middlewares, crawler=crawler)
def _check_deprecated_process_start_requests_use(
self, middlewares: tuple[Any, ...]
) -> None:
deprecated_middlewares = [
middleware
for middleware in middlewares
if hasattr(middleware, "process_start_requests")
and not hasattr(middleware, "process_start")
]
modern_middlewares = [
middleware
for middleware in middlewares
if not hasattr(middleware, "process_start_requests")
and hasattr(middleware, "process_start")
]
if deprecated_middlewares and modern_middlewares:
raise ValueError(
"You are trying to combine spider middlewares that only "
"define the deprecated process_start_requests() method () "
"with spider middlewares that only define the "
"process_start() method (). This is not possible. You must "
"either disable or make universal 1 of those 2 sets of "
"spider middlewares. Making a spider middleware universal "
"means having it define both methods. See the release notes "
"of Scrapy 2.13 for details: "
"https://docs.scrapy.org/en/2.13/news.html"
)
self._use_start_requests = bool(deprecated_middlewares)
if self._use_start_requests:
deprecated_middleware_list = ", ".join(
global_object_name(middleware.__class__)
for middleware in deprecated_middlewares
)
warn(
f"The following enabled spider middlewares, directly or "
f"through their parent classes, define the deprecated "
f"process_start_requests() method: "
f"{deprecated_middleware_list}. process_start_requests() has "
f"been deprecated in favor of a new method, process_start(), "
f"to support asynchronous code execution. "
f"process_start_requests() will stop being called in a future "
f"version of Scrapy. If you use Scrapy 2.13 or higher "
f"only, replace process_start_requests() with "
f"process_start(); note that process_start() is a coroutine "
f"(async def). If you need to maintain compatibility with "
f"lower Scrapy versions, when defining "
f"process_start_requests() in a spider middleware class, "
f"define process_start() as well. See the release notes of "
f"Scrapy 2.13 for details: "
f"https://docs.scrapy.org/en/2.13/news.html",
ScrapyDeprecationWarning,
stacklevel=2,
)
def _add_middleware(self, mw: Any) -> None:
if hasattr(mw, "process_spider_input"):
self.methods["process_spider_input"].append(mw.process_spider_input)
self._check_mw_method_spider_arg(mw.process_spider_input)
if self._use_start_requests:
if hasattr(mw, "process_start_requests"):
self.methods["process_start_requests"].appendleft(
mw.process_start_requests
)
elif hasattr(mw, "process_start"):
if hasattr(mw, "process_start"):
self.methods["process_start"].appendleft(mw.process_start)
process_spider_output = self._get_async_method_pair(mw, "process_spider_output")
process_spider_output = self._get_process_spider_output(mw)
self.methods["process_spider_output"].appendleft(process_spider_output)
if callable(process_spider_output):
if process_spider_output is not None:
self._check_mw_method_spider_arg(process_spider_output)
elif isinstance(process_spider_output, tuple):
for m in process_spider_output:
self._check_mw_method_spider_arg(m)
process_spider_exception = getattr(mw, "process_spider_exception", None)
self.methods["process_spider_exception"].appendleft(process_spider_exception)
@ -152,7 +79,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
request: Request,
) -> Iterable[_T] | AsyncIterator[_T]:
for method in self.methods["process_spider_input"]:
method = cast("Callable", method)
assert method is not None
try:
if method in self._mw_methods_requiring_spider:
result = method(response=response, spider=self._spider)
@ -170,54 +97,28 @@ class SpiderMiddlewareManager(MiddlewareManager):
return await scrape_func(Failure(), request)
return await scrape_func(response, request)
def _evaluate_iterable(
async def _evaluate_iterable(
self,
response: Response,
iterable: Iterable[_T] | AsyncIterator[_T],
iterable: AsyncIterator[_T],
exception_processor_index: int,
recover_to: MutableChain[_T] | MutableAsyncChain[_T],
) -> Iterable[_T] | AsyncIterator[_T]:
def process_sync(iterable: Iterable[_T]) -> Iterable[_T]:
try:
yield from iterable
except Exception as ex:
exception_result = cast(
"Failure | MutableChain[_T]",
self._process_spider_exception(
response, ex, exception_processor_index
),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableChain)
recover_to.extend(exception_result)
async def process_async(iterable: AsyncIterator[_T]) -> AsyncIterator[_T]:
try:
async for r in iterable:
yield r
except Exception as ex:
exception_result = cast(
"Failure | MutableAsyncChain[_T]",
self._process_spider_exception(
response, ex, exception_processor_index
),
)
if isinstance(exception_result, Failure):
raise
assert isinstance(recover_to, MutableAsyncChain)
recover_to.extend(exception_result)
if isinstance(iterable, AsyncIterator):
return process_async(iterable)
return process_sync(iterable)
recover_to: MutableAsyncChain[_T],
) -> AsyncIterator[_T]:
try:
async for r in iterable:
yield r
except Exception as ex:
exception_result: MutableAsyncChain[_T] = self._process_spider_exception(
response, ex, exception_processor_index
)
recover_to.extend(exception_result)
def _process_spider_exception(
self,
response: Response,
exception: Exception,
start_index: int = 0,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
) -> MutableAsyncChain[_T]:
# don't handle _InvalidOutput exception
if isinstance(exception, _InvalidOutput):
raise exception
@ -227,28 +128,18 @@ class SpiderMiddlewareManager(MiddlewareManager):
for method_index, method in enumerate(method_list, start=start_index):
if method is None:
continue
method = cast("Callable", method)
if method in self._mw_methods_requiring_spider:
result = method(
response=response, exception=exception, spider=self._spider
)
else:
result = method(response=response, exception=exception)
if _isiterable(result):
if isinstance(result, (Iterable, AsyncIterator)):
# stop exception handling by handing control over to the
# process_spider_output chain if an iterable has been returned
dfd: Deferred[MutableChain[_T] | MutableAsyncChain[_T]] = (
self._process_spider_output(response, result, method_index + 1)
)
# _process_spider_output() returns a Deferred only because of downgrading so this can be
# simplified when downgrading is removed.
if dfd.called:
# the result is available immediately if _process_spider_output didn't do downgrading
return cast("MutableChain[_T] | MutableAsyncChain[_T]", dfd.result)
# we forbid waiting here because otherwise we would need to return a deferred from
# _process_spider_exception too, which complicates the architecture
msg = f"Async iterable returned from {global_object_name(method)} cannot be downgraded"
raise _InvalidOutput(msg)
if isinstance(result, Iterable):
result = as_async_generator(result)
return self._process_spider_output(response, result, method_index + 1)
if result is None:
continue
msg = (
@ -258,124 +149,35 @@ class SpiderMiddlewareManager(MiddlewareManager):
raise _InvalidOutput(msg)
raise exception
# This method cannot be made async def, as _process_spider_exception relies on the Deferred result
# being available immediately which doesn't work when it's a wrapped coroutine.
# It also needs @inlineCallbacks only because of downgrading so it can be removed when downgrading is removed.
@inlineCallbacks
def _process_spider_output( # noqa: PLR0912
def _process_spider_output(
self,
response: Response,
result: Iterable[_T] | AsyncIterator[_T],
result: AsyncIterator[_T],
start_index: int = 0,
) -> Generator[Deferred[Any], Any, MutableChain[_T] | MutableAsyncChain[_T]]:
) -> MutableAsyncChain[_T]:
# items in this iterable do not need to go through the process_spider_output
# chain, they went through it already from the process_spider_exception method
recovered: MutableChain[_T] | MutableAsyncChain[_T]
last_result_is_async = isinstance(result, AsyncIterator)
recovered = MutableAsyncChain() if last_result_is_async else MutableChain()
# There are three cases for the middleware: def foo, async def foo, def foo + async def foo_async.
# 1. def foo. Sync iterables are passed as is, async ones are downgraded.
# 2. async def foo. Sync iterables are upgraded, async ones are passed as is.
# 3. def foo + async def foo_async. Iterables are passed to the respective method.
# Storing methods and method tuples in the same list is weird but we should be able to roll this back
# when we drop this compatibility feature.
recovered: MutableAsyncChain[_T] = MutableAsyncChain()
method_list = islice(self.methods["process_spider_output"], start_index, None)
for method_index, method_pair in enumerate(method_list, start=start_index):
if method_pair is None:
for method_index, method in enumerate(method_list, start=start_index):
if method is None:
continue
need_upgrade = need_downgrade = False
if isinstance(method_pair, tuple):
# This tuple handling is only needed until _async compatibility methods are removed.
method_sync, method_async = method_pair
method = method_async if last_result_is_async else method_sync
if method in self._mw_methods_requiring_spider:
result = method(response=response, result=result, spider=self._spider)
else:
method = method_pair
if not last_result_is_async and isasyncgenfunction(method):
need_upgrade = True
elif last_result_is_async and not isasyncgenfunction(method):
need_downgrade = True
try:
if need_upgrade:
# Iterable -> AsyncIterator
result = as_async_generator(result)
elif need_downgrade:
logger.warning(
f"Async iterable passed to {global_object_name(method)} was"
f" downgraded to a non-async one. This is deprecated and will"
f" stop working in a future version of Scrapy. Please see"
f" https://docs.scrapy.org/en/latest/topics/coroutines.html#for-middleware-users"
f" for more information."
)
assert isinstance(result, AsyncIterator)
# AsyncIterator -> Iterable
result = yield deferred_from_coro(collect_asyncgen(result))
if isinstance(recovered, AsyncIterator):
recovered_collected = yield deferred_from_coro(
collect_asyncgen(recovered)
)
recovered = MutableChain(recovered_collected)
# might fail directly if the output value is not a generator
if method in self._mw_methods_requiring_spider:
result = method(
response=response, result=result, spider=self._spider
)
else:
result = method(response=response, result=result)
except Exception as ex:
exception_result: Failure | MutableChain[_T] | MutableAsyncChain[_T] = (
self._process_spider_exception(response, ex, method_index + 1)
)
if isinstance(exception_result, Failure):
raise
return exception_result
if _isiterable(result):
result = self._evaluate_iterable(
response, result, method_index + 1, recovered
)
else:
if iscoroutine(result):
result.close() # Silence warning about not awaiting
msg = (
f"{global_object_name(method)} must be an asynchronous "
f"generator (i.e. use yield)"
)
else:
msg = (
f"{global_object_name(method)} must return an iterable, got "
f"{type(result)}"
)
raise _InvalidOutput(msg)
last_result_is_async = isinstance(result, AsyncIterator)
if last_result_is_async:
return MutableAsyncChain(result, recovered)
return MutableChain(result, recovered) # type: ignore[arg-type]
result = method(response=response, result=result)
result = self._evaluate_iterable(
response, result, method_index + 1, recovered
)
return MutableAsyncChain(result, recovered)
async def _process_callback_output(
self,
response: Response,
result: Iterable[_T] | AsyncIterator[_T],
) -> MutableChain[_T] | MutableAsyncChain[_T]:
recovered: MutableChain[_T] | MutableAsyncChain[_T]
if isinstance(result, AsyncIterator):
recovered = MutableAsyncChain()
else:
recovered = MutableChain()
self, response: Response, result: AsyncIterator[_T]
) -> MutableAsyncChain[_T]:
recovered: MutableAsyncChain[_T] = MutableAsyncChain()
result = self._evaluate_iterable(response, result, 0, recovered)
result = await maybe_deferred_to_future(
cast(
"Deferred[Iterable[_T] | AsyncIterator[_T]]",
self._process_spider_output(response, result),
)
)
if isinstance(result, AsyncIterator):
return MutableAsyncChain(result, recovered)
if isinstance(recovered, AsyncIterator):
recovered_collected = await collect_asyncgen(recovered)
recovered = MutableChain(recovered_collected)
return MutableChain(result, recovered)
result = self._process_spider_output(response, result)
return MutableAsyncChain(result, recovered)
def scrape_response(
self,
@ -386,7 +188,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
response: Response,
request: Request,
spider: Spider,
) -> Deferred[MutableChain[_T] | MutableAsyncChain[_T]]: # pragma: no cover
) -> Deferred[MutableAsyncChain[_T]]: # pragma: no cover
warn(
"SpiderMiddlewareManager.scrape_response() is deprecated, use scrape_response_async() instead",
ScrapyDeprecationWarning,
@ -409,31 +211,21 @@ class SpiderMiddlewareManager(MiddlewareManager):
scrape_func: ScrapeFunc[_T],
response: Response,
request: Request,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
) -> MutableAsyncChain[_T]:
if not self.crawler:
raise RuntimeError(
"scrape_response_async() called on a SpiderMiddlewareManager"
" instance created without a crawler."
)
async def process_callback_output(
result: Iterable[_T] | AsyncIterator[_T],
) -> MutableChain[_T] | MutableAsyncChain[_T]:
return await self._process_callback_output(response, result)
def process_spider_exception(
exception: Exception,
) -> MutableChain[_T] | MutableAsyncChain[_T]:
return self._process_spider_exception(response, exception)
try:
it: Iterable[_T] | AsyncIterator[_T] = await self._process_spider_input(
scrape_func, response, request
)
return await process_callback_output(it)
ait = it if isinstance(it, AsyncIterator) else as_async_generator(it)
return await self._process_callback_output(response, ait)
except Exception as ex:
await _defer_sleep_async()
return process_spider_exception(ex)
return self._process_spider_exception(response, ex)
async def process_start(
self, spider: Spider | None = None
@ -451,112 +243,32 @@ class SpiderMiddlewareManager(MiddlewareManager):
)
warn(msg, category=ScrapyDeprecationWarning, stacklevel=2)
self._set_compat_spider(spider)
self._check_deprecated_start_requests_use()
if self._use_start_requests:
sync_start = iter(self._spider.start_requests())
sync_start = await self._process_chain(
"process_start_requests", sync_start, always_add_spider=True
)
start: AsyncIterator[Any] = as_async_generator(sync_start)
else:
start = self._spider.start()
start = await self._process_chain("process_start", start)
return start
def _check_deprecated_start_requests_use(self) -> None:
start_requests_cls = None
start_cls = None
spidercls = self._spider.__class__
mro = spidercls.__mro__
for cls in mro:
cls_dict = cls.__dict__
if start_requests_cls is None and "start_requests" in cls_dict:
start_requests_cls = cls
if start_cls is None and "start" in cls_dict:
start_cls = cls
if start_requests_cls is not None and start_cls is not None:
break
# Spider defines both, start_requests and start.
assert start_requests_cls is not None
assert start_cls is not None
if (
start_requests_cls is not Spider
and start_cls is not start_requests_cls
and mro.index(start_requests_cls) < mro.index(start_cls)
):
src = global_object_name(start_requests_cls)
if start_requests_cls is not spidercls:
src += f" (inherited by {global_object_name(spidercls)})"
warn(
f"{src} defines the deprecated start_requests() method. "
f"start_requests() has been deprecated in favor of a new "
f"method, start(), to support asynchronous code "
f"execution. start_requests() will stop being called in a "
f"future version of Scrapy. If you use Scrapy 2.13 or "
f"higher only, replace start_requests() with start(); "
f"note that start() is a coroutine (async def). If you "
f"need to maintain compatibility with lower Scrapy versions, "
f"when overriding start_requests() in a spider class, "
f"override start() as well; you can use super() to "
f"reuse the inherited start() implementation without "
f"copy-pasting. See the release notes of Scrapy 2.13 for "
f"details: https://docs.scrapy.org/en/2.13/news.html",
ScrapyDeprecationWarning,
stacklevel=2,
)
if (
self._use_start_requests
and start_cls is not Spider
and start_requests_cls is not start_cls
and mro.index(start_cls) < mro.index(start_requests_cls)
):
src = global_object_name(start_cls)
if start_cls is not spidercls:
src += f" (inherited by {global_object_name(spidercls)})"
raise ValueError(
f"{src} does not define the deprecated start_requests() "
f"method. However, one or more of your enabled spider "
f"middlewares (reported in an earlier deprecation warning) "
f"define the process_start_requests() method, and not the "
f"process_start() method, making them only compatible with "
f"(deprecated) spiders that define the start_requests() "
f"method. To solve this issue, disable the offending spider "
f"middlewares, upgrade them as described in that earlier "
f"deprecation warning, or make your spider compatible with "
f"deprecated spider middlewares (and earlier Scrapy versions) "
f"by defining a sync start_requests() method that works "
f"similarly to its existing start() method. See the "
f"release notes of Scrapy 2.13 for details: "
f"https://docs.scrapy.org/en/2.13/news.html"
)
start = self._spider.start()
return await self._process_chain("process_start", start)
# This method is only needed until _async compatibility methods are removed.
@staticmethod
def _get_async_method_pair(
mw: Any, methodname: str
) -> Callable | tuple[Callable, Callable] | None:
normal_method: Callable | None = getattr(mw, methodname, None)
methodname_async = methodname + "_async"
async_method: Callable | None = getattr(mw, methodname_async, None)
def _get_process_spider_output(mw: Any) -> Callable[..., Any] | None:
normal_method: Callable[..., Any] | None = getattr(
mw, "process_spider_output", None
)
async_method: Callable[..., Any] | None = getattr(
mw, "process_spider_output_async", None
)
if not async_method:
if normal_method and not isasyncgenfunction(normal_method):
logger.warning(
raise TypeError(
f"Middleware {global_object_name(mw.__class__)} doesn't support"
f" asynchronous spider output, this is deprecated and will stop"
f" working in a future version of Scrapy. The middleware should"
f" be updated to support it. Please see"
f" https://docs.scrapy.org/en/latest/topics/coroutines.html#for-middleware-users"
f" for more information."
f" asynchronous spider output. Its process_spider_output() method"
f" should be an async generator function or it should additionally"
f" define a process_spider_output_async() method."
)
return normal_method
if not normal_method:
logger.error(
f"Middleware {global_object_name(mw.__class__)} has {methodname_async} "
f"without {methodname}, skipping this method."
f"Middleware {global_object_name(mw.__class__)} has"
f" process_spider_output_async() without process_spider_output(),"
f" skipping this method. Please rename it to process_spider_output()."
)
return None
if not isasyncgenfunction(async_method):
@ -568,8 +280,8 @@ class SpiderMiddlewareManager(MiddlewareManager):
if isasyncgenfunction(normal_method):
logger.error(
f"{global_object_name(normal_method)} is an async "
f"generator function while {methodname_async} exists, "
f"skipping both methods."
f"generator function while process_spider_output_async() exists, "
f"skipping both methods. Please remove process_spider_output_async()."
)
return None
return normal_method, async_method
return async_method

View File

@ -7,6 +7,7 @@ import pprint
import signal
import warnings
from abc import ABC, abstractmethod
from functools import partial
from typing import TYPE_CHECKING, Any, TypeVar
from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks
@ -605,28 +606,30 @@ class AsyncCrawlerRunner(CrawlerRunnerBase):
crawler = self.create_crawler(crawler_or_spidercls)
return self._crawl(crawler, *args, **kwargs)
async def _crawl_and_track(
self, crawler: Crawler, *args: Any, **kwargs: Any
) -> None:
try:
await crawler.crawl_async(*args, **kwargs)
except Exception:
self.bootstrap_failed = True
raise # re-raise so asyncio still logs it to stderr naturally
def _done(self, task: asyncio.Task[None], crawler: Crawler) -> None:
self._active.discard(task)
self.crawlers.discard(crawler)
self.bootstrap_failed |= not getattr(crawler, "spider", None)
def _crawl(self, crawler: Crawler, *args: Any, **kwargs: Any) -> asyncio.Task[None]:
# At this point the asyncio loop has been installed either by the user
# or by AsyncCrawlerProcess (but it isn't running yet, so no asyncio.create_task()).
loop = asyncio.get_event_loop()
self.crawlers.add(crawler)
async def _crawl_and_track() -> None:
try:
await crawler.crawl_async(*args, **kwargs)
except Exception:
self.bootstrap_failed = True
raise # re-raise so asyncio still logs it to stderr naturally
task = loop.create_task(_crawl_and_track())
task = loop.create_task(self._crawl_and_track(crawler, *args, **kwargs))
self._active.add(task)
task.add_done_callback(partial(self._done, crawler=crawler))
def _done(_: asyncio.Task[None]) -> None:
self.crawlers.discard(crawler)
self._active.discard(task)
self.bootstrap_failed |= not getattr(crawler, "spider", None)
task.add_done_callback(_done)
return task
async def stop(self, *, mode: _StopMode = "graceful") -> None:

View File

@ -1,114 +0,0 @@
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING
from warnings import warn
from w3lib import html
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import HtmlResponse, Response
from scrapy.utils.url import escape_ajax
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
logger = logging.getLogger(__name__)
class AjaxCrawlMiddleware:
"""
Handle 'AJAX crawlable' pages marked as crawlable via meta tag.
"""
def __init__(self, settings: BaseSettings):
if not settings.getbool("AJAXCRAWL_ENABLED"):
raise NotConfigured
warn(
"scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware is deprecated"
" and will be removed in a future Scrapy version.",
ScrapyDeprecationWarning,
stacklevel=2,
)
# XXX: Google parses at least first 100k bytes; scrapy's redirect
# middleware parses first 4k. 4k turns out to be insufficient
# for this middleware, and parsing 100k could be slow.
# We use something in between (32K) by default.
self.lookup_bytes: int = settings.getint("AJAXCRAWL_MAXSIZE")
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
def process_response(
self, request: Request, response: Response, spider: Spider
) -> Request | Response:
if not isinstance(response, HtmlResponse) or response.status != 200:
return response
if request.method != "GET":
# other HTTP methods are either not safe or don't have a body
return response
if "ajax_crawlable" in request.meta: # prevent loops
return response
if not self._has_ajax_crawlable_variant(response):
return response
ajax_crawl_request = request.replace(url=escape_ajax(request.url + "#!"))
logger.debug(
"Downloading AJAX crawlable %(ajax_crawl_request)s instead of %(request)s",
{"ajax_crawl_request": ajax_crawl_request, "request": request},
extra={"spider": spider},
)
ajax_crawl_request.meta["ajax_crawlable"] = True
return ajax_crawl_request
def _has_ajax_crawlable_variant(self, response: Response) -> bool:
"""
Return True if a page without hash fragment could be "AJAX crawlable".
"""
body = response.text[: self.lookup_bytes]
return _has_ajaxcrawlable_meta(body)
_ajax_crawlable_re: re.Pattern[str] = re.compile(
r'<meta\s+name=["\']fragment["\']\s+content=["\']!["\']/?>'
)
def _has_ajaxcrawlable_meta(text: str) -> bool:
"""
>>> _has_ajaxcrawlable_meta('<html><head><meta name="fragment" content="!"/></head><body></body></html>')
True
>>> _has_ajaxcrawlable_meta("<html><head><meta name='fragment' content='!'></head></html>")
True
>>> _has_ajaxcrawlable_meta('<html><head><!--<meta name="fragment" content="!"/>--></head><body></body></html>')
False
>>> _has_ajaxcrawlable_meta('<html></html>')
False
"""
# Stripping scripts and comments is slow (about 20x slower than
# just checking if a string is in text); this is a quick fail-fast
# path that should work for most pages.
if "fragment" not in text:
return False
if "content" not in text:
return False
text = html.remove_tags_with_content(text, ("script", "noscript"))
text = html.replace_entities(text)
text = html.remove_comments(text)
return _ajax_crawlable_re.search(text) is not None

View File

@ -6,11 +6,14 @@ See documentation in docs/topics/downloader-middleware.rst
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
from w3lib.http import basic_auth_header
from scrapy import Request, Spider, signals
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.settings import SETTINGS_PRIORITIES
from scrapy.utils.decorators import _warn_spider_arg
from scrapy.utils.url import url_is_from_any_domain
@ -23,12 +26,28 @@ if TYPE_CHECKING:
class HttpAuthMiddleware:
"""Set Basic HTTP Authorization header
(http_user and http_pass spider class attributes)"""
"""Set Basic HTTP Authorization header."""
def __init__(self) -> None:
self._auth: bytes | None = None
self._domain: str | None = None
@classmethod
def from_crawler(cls, crawler: Crawler) -> Self:
o = cls()
usr = crawler.settings.get("HTTPAUTH_USER", "")
pwd = crawler.settings.get("HTTPAUTH_PASS", "")
if usr or pwd:
domain_priority = crawler.settings.getpriority("HTTPAUTH_DOMAIN") or 0
if domain_priority <= SETTINGS_PRIORITIES["default"]:
raise ValueError(
"HTTPAUTH_DOMAIN must be set when HTTPAUTH_USER or HTTPAUTH_PASS "
"is configured. Set it to a domain (e.g. 'example.com') to restrict "
"credentials to that domain, or set it to None to send credentials "
"with all requests."
)
o._auth = basic_auth_header(usr, pwd)
o._domain = crawler.settings.get("HTTPAUTH_DOMAIN")
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
return o
@ -36,18 +55,34 @@ class HttpAuthMiddleware:
usr = getattr(spider, "http_user", "")
pwd = getattr(spider, "http_pass", "")
if usr or pwd:
self.auth = basic_auth_header(usr, pwd)
self.domain = spider.http_auth_domain # type: ignore[attr-defined]
warnings.warn(
"Use the HTTPAUTH_USER, HTTPAUTH_PASS, and HTTPAUTH_DOMAIN settings "
"instead of the http_user, http_pass, and http_auth_domain spider "
"attributes. Support for the spider attributes will be removed in a "
"future version of Scrapy.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
self._auth = basic_auth_header(usr, pwd)
self._domain = spider.http_auth_domain # type: ignore[attr-defined]
@_warn_spider_arg
def process_request(
self, request: Request, spider: Spider | None = None
) -> Request | Response | None:
auth = getattr(self, "auth", None)
if (
auth
and b"Authorization" not in request.headers
and (not self.domain or url_is_from_any_domain(request.url, [self.domain]))
if b"Authorization" in request.headers:
return None
# Per-request meta overrides
usr = request.meta.get("http_user", "")
pwd = request.meta.get("http_pass", "")
if usr or pwd:
domain = request.meta.get("http_auth_domain")
if not domain or url_is_from_any_domain(request.url, [domain]):
request.headers[b"Authorization"] = basic_auth_header(usr, pwd)
return None
# Middleware-level auth
if self._auth and (
not self._domain or url_is_from_any_domain(request.url, [self._domain])
):
request.headers[b"Authorization"] = auth
request.headers[b"Authorization"] = self._auth
return None

View File

@ -12,7 +12,7 @@ once the spider has finished crawling all regular (non-failed) pages.
from __future__ import annotations
from logging import Logger, getLogger
from logging import Logger, getLevelName, getLogger
from typing import TYPE_CHECKING
from scrapy.exceptions import NotConfigured
@ -43,6 +43,7 @@ def get_retry_request(
max_retry_times: int | None = None,
priority_adjust: int | None = None,
logger: Logger = retry_logger,
give_up_log_level: int | str | None = None,
stats_base_key: str = "retry",
) -> Request | None:
"""
@ -51,14 +52,16 @@ def get_retry_request(
exhausted.
For example, in a :class:`~scrapy.Spider` callback, you could use it as
follows::
follows:
.. code-block:: python
def parse(self, response):
if not response.text:
new_request_or_none = get_retry_request(
response.request,
spider=self,
reason='empty',
reason="empty",
)
return new_request_or_none
@ -82,6 +85,10 @@ def get_retry_request(
*logger* is the logging.Logger object to be used when logging messages
*give_up_log_level* is the :ref:`logging level <levels>` used for the
message logged when a request exceeds its retries. See
:setting:`RETRY_GIVE_UP_LOG_LEVEL` for details.
*stats_base_key* is a string to be used as the base key for the
retry-related job stats
"""
@ -114,8 +121,16 @@ def get_retry_request(
stats.inc_value(f"{stats_base_key}/count")
stats.inc_value(f"{stats_base_key}/reason_count/{reason}")
return new_request
if give_up_log_level is None:
give_up_log_level = settings["RETRY_GIVE_UP_LOG_LEVEL"]
if isinstance(give_up_log_level, str):
level = getLevelName(give_up_log_level)
if not isinstance(level, int):
raise ValueError(f"Invalid give-up log level: {give_up_log_level!r}")
give_up_log_level = level
stats.inc_value(f"{stats_base_key}/max_reached")
logger.error(
logger.log(
give_up_log_level,
"Gave up retrying %(request)s (failed %(retry_times)d times): %(reason)s",
{"request": request, "retry_times": retry_times, "reason": reason},
extra={"spider": spider},
@ -132,6 +147,7 @@ class RetryMiddleware:
self.max_retry_times = settings.getint("RETRY_TIMES")
self.retry_http_codes = {int(x) for x in settings.getlist("RETRY_HTTP_CODES")}
self.priority_adjust = settings.getint("RETRY_PRIORITY_ADJUST")
self.give_up_log_level = settings["RETRY_GIVE_UP_LOG_LEVEL"]
self.exceptions_to_retry = tuple(
load_object(x) if isinstance(x, str) else x
for x in settings.getlist("RETRY_EXCEPTIONS")
@ -175,6 +191,9 @@ class RetryMiddleware:
) -> Request | None:
max_retry_times = request.meta.get("max_retry_times", self.max_retry_times)
priority_adjust = request.meta.get("priority_adjust", self.priority_adjust)
give_up_log_level = request.meta.get(
"give_up_log_level", self.give_up_log_level
)
assert self.crawler.spider
return get_retry_request(
request,
@ -182,4 +201,5 @@ class RetryMiddleware:
spider=self.crawler.spider,
max_retry_times=max_retry_times,
priority_adjust=priority_adjust,
give_up_log_level=give_up_log_level,
)

View File

@ -306,12 +306,15 @@ class GCSFeedStorage(BlockingFeedStorage):
def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0)
from google.cloud.storage import Client # noqa: PLC0415
try:
from google.cloud.storage import Client # noqa: PLC0415
client = Client(project=self.project_id)
bucket = client.get_bucket(self.bucket_name)
blob = bucket.blob(self.blob_name)
blob.upload_from_file(file, predefined_acl=self.acl)
client = Client(project=self.project_id)
bucket = client.get_bucket(self.bucket_name)
blob = bucket.blob(self.blob_name)
blob.upload_from_file(file, predefined_acl=self.acl)
finally:
file.close()
class FTPFeedStorage(BlockingFeedStorage):
@ -534,13 +537,15 @@ class FeedExporter:
# Send FEED_EXPORTER_CLOSED signal
await self.crawler.signals.send_catch_log_async(signals.feed_exporter_closed)
@staticmethod
def _get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager):
slot_.file.close()
return slot_.file.file
return slot_.file
async def _close_slot(self, slot: FeedSlot, spider: Spider) -> None:
def get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager):
slot_.file.close()
return slot_.file.file
return slot_.file
if slot.itemcount:
# Normal case
@ -557,7 +562,7 @@ class FeedExporter:
slot_type = type(slot.storage).__name__
assert self.crawler.stats
try:
await ensure_awaitable(slot.storage.store(get_file(slot)))
await ensure_awaitable(slot.storage.store(self._get_file(slot)))
except Exception:
logger.error(
"Error storing %s",

View File

@ -313,7 +313,9 @@ class FilesystemCacheStorage:
self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS")
self.use_gzip: bool = settings.getbool("HTTPCACHE_GZIP")
# https://github.com/python/mypy/issues/10740
self._open: Callable[Concatenate[str | os.PathLike, str, ...], IO[bytes]] = (
self._open: Callable[
Concatenate[str | os.PathLike[str], str, ...], IO[bytes]
] = (
gzip.open if self.use_gzip else open # type: ignore[assignment]
)

View File

@ -5,9 +5,11 @@ Use this module (instead of the more specific ones) when importing Headers,
Request and Response outside this module.
"""
from warnings import catch_warnings, filterwarnings
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http.headers import Headers
from scrapy.http.request import Request
from scrapy.http.request.form import FormRequest
from scrapy.http.request.json_request import JsonRequest
from scrapy.http.request.rpc import XmlRpcRequest
from scrapy.http.response import Response
@ -15,6 +17,19 @@ from scrapy.http.response.html import HtmlResponse
from scrapy.http.response.json import JsonResponse
from scrapy.http.response.text import TextResponse
from scrapy.http.response.xml import XmlResponse
from scrapy.utils.deprecate import create_deprecated_class
with catch_warnings():
filterwarnings("ignore", category=ScrapyDeprecationWarning)
from scrapy.http.request.form import FormRequest as _FormRequest
FormRequest = create_deprecated_class(
name="FormRequest",
new_class=_FormRequest,
subclass_warn_message="{cls} inherits from deprecated class {old}, use the form2request library instead.",
instance_warn_message="{cls} is deprecated, use the form2request library instead.",
)
__all__ = [
"FormRequest",

View File

@ -74,7 +74,7 @@ class CookieJar:
@property
def _cookies(self) -> dict[str, dict[str, dict[str, Cookie]]]:
return self.jar._cookies # type: ignore[attr-defined]
return self.jar._cookies # type: ignore[attr-defined,no-any-return]
def clear_session_cookies(self) -> None:
return self.jar.clear_session_cookies()

View File

@ -138,6 +138,7 @@ class Request(object_ref):
) -> None:
self._encoding: str = encoding # this one has to be set first
self.method: str = str(method).upper()
self._meta: dict[str, Any] | None = dict(meta) if meta else None
self._set_url(url)
self._set_body(body)
if not isinstance(priority, int):
@ -232,7 +233,6 @@ class Request(object_ref):
#: default. See :meth:`~scrapy.Spider.start`.
self.dont_filter: bool = dont_filter
self._meta: dict[str, Any] | None = dict(meta) if meta else None
self._cb_kwargs: dict[str, Any] | None = dict(cb_kwargs) if cb_kwargs else None
self._flags: list[str] | None = list(flags) if flags else None
@ -252,11 +252,17 @@ class Request(object_ref):
def url(self) -> str:
return self._url
def _url_is_verbatim(self) -> bool:
return bool(self._meta and self._meta.get("verbatim_url"))
def _set_url(self, url: str) -> None:
if not isinstance(url, str):
raise TypeError(f"Request url must be str, got {type(url).__name__}")
self._url = safe_url_string(url, self.encoding)
if self._url_is_verbatim():
self._url = url
else:
self._url = safe_url_string(url, self.encoding)
if (
"://" not in self._url

View File

@ -10,10 +10,12 @@ from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, cast
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
from warnings import warn
from parsel.csstranslator import HTMLTranslator
from w3lib.html import strip_html5_whitespace
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http.request import Request
from scrapy.utils.python import is_listlike, to_bytes
@ -30,6 +32,12 @@ if TYPE_CHECKING:
from scrapy.http.response.text import TextResponse
warn(
"The entire scrapy.http.request.form module is deprecated. Use the "
"form2request library instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
FormdataVType: TypeAlias = str | Iterable[str]
FormdataKVType: TypeAlias = tuple[str, FormdataVType]

View File

@ -20,7 +20,6 @@ if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping
from ipaddress import IPv4Address, IPv6Address
from twisted.internet.ssl import Certificate
from twisted.python.failure import Failure
# typing.Self requires Python 3.11
@ -77,7 +76,7 @@ class Response(object_ref):
body: bytes = b"",
flags: list[str] | None = None,
request: Request | None = None,
certificate: Certificate | None = None,
certificate: Any = None,
ip_address: IPv4Address | IPv6Address | None = None,
protocol: str | None = None,
):
@ -87,7 +86,7 @@ class Response(object_ref):
self._set_url(url)
self.request: Request | None = request
self._flags: list[str] | None = list(flags) if flags else None
self.certificate: Certificate | None = certificate
self.certificate: Any = certificate
self.ip_address: IPv4Address | IPv6Address | None = ip_address
self.protocol: str | None = protocol

View File

@ -220,7 +220,7 @@ class TextResponse(Response):
def follow_all(
self,
urls: Iterable[str | Link] | parsel.SelectorList | None = None,
urls: Iterable[str | Link] | parsel.SelectorList[Any] | None = None,
callback: CallbackT | None = None,
method: str = "GET",
headers: Mapping[AnyStr, Any] | Iterable[tuple[AnyStr, Any]] | None = None,

View File

@ -39,7 +39,7 @@ class Link:
def __eq__(self, other: object) -> bool:
if not isinstance(other, Link):
raise NotImplementedError
return NotImplemented
return (
self.url == other.url
and self.text == other.text

View File

@ -27,7 +27,7 @@ class ItemLoader(itemloaders.ItemLoader):
:param item: The item instance to populate using subsequent calls to
:meth:`~ItemLoader.add_xpath`, :meth:`~ItemLoader.add_css`,
or :meth:`~ItemLoader.add_value`.
:type item: scrapy.item.Item
:type item: :ref:`item object <item-types>`
:param selector: The selector to extract data from, when using the
:meth:`add_xpath`, :meth:`add_css`, :meth:`replace_xpath`, or

View File

@ -50,11 +50,8 @@ class MiddlewareManager(ABC):
)
self.middlewares: tuple[Any, ...] = middlewares
# Only process_spider_output and process_spider_exception can be None.
# Only process_spider_output can be a tuple, and only until _async compatibility methods are removed.
self.methods: dict[str, deque[Callable | tuple[Callable, Callable] | None]] = (
defaultdict(deque)
)
self._mw_methods_requiring_spider: set[Callable] = set()
self.methods: dict[str, deque[Callable[..., Any] | None]] = defaultdict(deque)
self._mw_methods_requiring_spider: set[Callable[..., Any]] = set()
for mw in middlewares:
self._add_middleware(mw)
@ -119,7 +116,7 @@ class MiddlewareManager(ABC):
def _add_middleware(self, mw: Any) -> None: # noqa: B027
pass
def _check_mw_method_spider_arg(self, method: Callable) -> None:
def _check_mw_method_spider_arg(self, method: Callable[..., Any]) -> None:
if argument_is_required(method, "spider"):
warnings.warn(
f"{method.__qualname__}() requires a spider argument,"

View File

@ -37,16 +37,16 @@ class ItemPipelineManager(MiddlewareManager):
settings.get_component_priority_dict_with_base("ITEM_PIPELINES")
)
def _add_middleware(self, pipe: Any) -> None:
if hasattr(pipe, "open_spider"):
self.methods["open_spider"].append(pipe.open_spider)
self._check_mw_method_spider_arg(pipe.open_spider)
if hasattr(pipe, "close_spider"):
self.methods["close_spider"].appendleft(pipe.close_spider)
self._check_mw_method_spider_arg(pipe.close_spider)
if hasattr(pipe, "process_item"):
self.methods["process_item"].append(pipe.process_item)
self._check_mw_method_spider_arg(pipe.process_item)
def _add_middleware(self, mw: Any) -> None:
if hasattr(mw, "open_spider"):
self.methods["open_spider"].append(mw.open_spider)
self._check_mw_method_spider_arg(mw.open_spider)
if hasattr(mw, "close_spider"):
self.methods["close_spider"].appendleft(mw.close_spider)
self._check_mw_method_spider_arg(mw.close_spider)
if hasattr(mw, "process_item"):
self.methods["process_item"].append(mw.process_item)
self._check_mw_method_spider_arg(mw.process_item)
def process_item(self, item: Any, spider: Spider) -> Deferred[Any]:
warnings.warn(
@ -62,32 +62,44 @@ class ItemPipelineManager(MiddlewareManager):
"process_item", item, add_spider=True, warn_deferred=True
)
def _get_dfd(
self,
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Deferred[None]:
if method in self._mw_methods_requiring_spider:
return _maybeDeferred_coro(method, True, self._spider)
return _maybeDeferred_coro(method, True)
@staticmethod
def _eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure
def _process_parallel_dfd(self, methodname: str) -> Deferred[list[None]]:
methods = cast(
"Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]",
self.methods[methodname],
)
def get_dfd(
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Deferred[None]:
if method in self._mw_methods_requiring_spider:
return _maybeDeferred_coro(method, True, self._spider)
return _maybeDeferred_coro(method, True)
dfds = [get_dfd(m) for m in methods]
dfds = [self._get_dfd(m) for m in methods]
d: Deferred[list[tuple[bool, None]]] = DeferredList(
dfds, fireOnOneErrback=True, consumeErrors=True
)
d2: Deferred[list[None]] = d.addCallback(lambda r: [x[1] for x in r])
def eb(failure: Failure) -> Failure:
assert isinstance(failure.value, FirstError)
return failure.value.subFailure
d2.addErrback(eb)
d2.addErrback(self._eb)
return d2
def get_awaitable(
self,
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Awaitable[None]:
if method in self._mw_methods_requiring_spider:
result = method(self._spider)
else:
result = method()
return ensure_awaitable(result, _warn=global_object_name(method))
async def _process_parallel_asyncio(self, methodname: str) -> list[None]:
methods = cast(
"Iterable[Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None]]",
@ -96,16 +108,7 @@ class ItemPipelineManager(MiddlewareManager):
if not methods:
return []
def get_awaitable(
method: Callable[..., Coroutine[Any, Any, None] | Deferred[None] | None],
) -> Awaitable[None]:
if method in self._mw_methods_requiring_spider:
result = method(self._spider)
else:
result = method()
return ensure_awaitable(result, _warn=global_object_name(method))
awaitables = [get_awaitable(m) for m in methods]
awaitables = [self.get_awaitable(m) for m in methods]
await asyncio.gather(*awaitables)
return [None for _ in methods]

View File

@ -186,16 +186,18 @@ class S3FilesStore:
raise ValueError(f"Incorrect URI scheme in {uri}, expected 's3'")
self.bucket, self.prefix = uri[5:].split("/", 1)
@staticmethod
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"')
last_modified = boto_key["LastModified"]
modified_stamp = time.mktime(last_modified.timetuple())
return {"checksum": checksum, "last_modified": modified_stamp}
def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]:
def _onsuccess(boto_key: dict[str, Any]) -> StatInfo:
checksum = boto_key["ETag"].strip('"')
last_modified = boto_key["LastModified"]
modified_stamp = time.mktime(last_modified.timetuple())
return {"checksum": checksum, "last_modified": modified_stamp}
return self._get_boto_key(path).addCallback(_onsuccess)
return self._get_boto_key(path).addCallback(self._onsuccess)
def _get_boto_key(self, path: str) -> Deferred[dict[str, Any]]:
key_name = f"{self.prefix}{path}"
@ -308,20 +310,23 @@ class GCSFilesStore:
{"bucket": bucket},
)
@staticmethod
def _onsuccess(blob: Any) -> StatInfo:
if blob:
checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple())
return {"checksum": checksum, "last_modified": last_modified}
return {}
def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]:
def _onsuccess(blob: Any) -> StatInfo:
if blob:
checksum = base64.b64decode(blob.md5_hash).hex()
last_modified = time.mktime(blob.updated.timetuple())
return {"checksum": checksum, "last_modified": last_modified}
return {}
blob_path = self._get_blob_path(path)
return deferred_from_coro(
d: Deferred[Any] = deferred_from_coro(
run_in_thread(self.bucket.get_blob, blob_path)
).addCallback(_onsuccess)
)
return d.addCallback(self._onsuccess)
def _get_content_type(self, headers: dict[str, str] | None) -> str:
if headers and "Content-Type" in headers:
@ -395,26 +400,26 @@ class FTPFilesStore:
)
)
def _stat_file(self, path: str) -> StatInfo:
try:
with FTP() as ftp:
ftp.connect(self.host, self.port)
ftp.login(self.username, self.password)
if self.USE_ACTIVE_MODE:
ftp.set_pasv(False)
file_path = f"{self.basedir}/{path}"
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
m = hashlib.md5() # noqa: S324
ftp.retrbinary(f"RETR {file_path}", m.update)
return {"last_modified": last_modified, "checksum": m.hexdigest()}
# The file doesn't exist
except Exception:
return {}
def stat_file(
self, path: str, info: MediaPipeline.SpiderInfo
) -> Deferred[StatInfo]:
def _stat_file(path: str) -> StatInfo:
try:
with FTP() as ftp:
ftp.connect(self.host, self.port)
ftp.login(self.username, self.password)
if self.USE_ACTIVE_MODE:
ftp.set_pasv(False)
file_path = f"{self.basedir}/{path}"
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
m = hashlib.md5() # noqa: S324
ftp.retrbinary(f"RETR {file_path}", m.update)
return {"last_modified": last_modified, "checksum": m.hexdigest()}
# The file doesn't exist
except Exception:
return {}
return deferred_from_coro(run_in_thread(_stat_file, path))
return deferred_from_coro(run_in_thread(self._stat_file, path))
class FilesPipeline(MediaPipeline):
@ -534,43 +539,51 @@ class FilesPipeline(MediaPipeline):
store_cls = self.STORE_SCHEMES[scheme]
return store_cls(uri)
def _onsuccess(
self,
result: StatInfo,
request: Request,
info: MediaPipeline.SpiderInfo,
path: str,
) -> FileInfo | None:
if not result:
return None # returning None force download
last_modified = result.get("last_modified", None)
if not last_modified:
return None # returning None force download
age_seconds = time.time() - last_modified
age_days = age_seconds / 60 / 60 / 24
if age_days > self.expires:
return None # returning None force download
referer = referer_str(request)
logger.debug(
"File (uptodate): Downloaded %(medianame)s from %(request)s "
"referred in <%(referer)s>",
{"medianame": self.MEDIA_NAME, "request": request, "referer": referer},
extra={"spider": info.spider},
)
self.inc_stats("uptodate")
checksum = result.get("checksum", None)
return {
"url": request.url,
"path": path,
"checksum": checksum,
"status": "uptodate",
}
def media_to_download(
self, request: Request, info: MediaPipeline.SpiderInfo, *, item: Any = None
) -> Deferred[FileInfo | None] | None:
def _onsuccess(result: StatInfo) -> FileInfo | None:
if not result:
return None # returning None force download
last_modified = result.get("last_modified", None)
if not last_modified:
return None # returning None force download
age_seconds = time.time() - last_modified
age_days = age_seconds / 60 / 60 / 24
if age_days > self.expires:
return None # returning None force download
referer = referer_str(request)
logger.debug(
"File (uptodate): Downloaded %(medianame)s from %(request)s "
"referred in <%(referer)s>",
{"medianame": self.MEDIA_NAME, "request": request, "referer": referer},
extra={"spider": info.spider},
)
self.inc_stats("uptodate")
checksum = result.get("checksum", None)
return {
"url": request.url,
"path": path,
"checksum": checksum,
"status": "uptodate",
}
path = self.file_path(request, info=info, item=item)
# maybeDeferred() overloads don't seem to support a Union[_T, Deferred[_T]] return type
dfd: Deferred[StatInfo] = maybeDeferred(self.store.stat_file, path, info) # type: ignore[call-overload]
dfd2: Deferred[FileInfo | None] = dfd.addCallback(_onsuccess)
dfd2: Deferred[FileInfo | None] = dfd.addCallback(
functools.partial(self._onsuccess, request=request, info=info, path=path)
)
dfd2.addErrback(lambda _: None)
dfd2.addErrback(
lambda f: logger.error(

View File

@ -11,14 +11,20 @@ import hashlib
import warnings
from contextlib import suppress
from io import BytesIO
from typing import TYPE_CHECKING, Any, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar, cast
from itemadapter import ItemAdapter
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.http import Request, Response
from scrapy.http.request import NO_CALLBACK
from scrapy.pipelines.files import FileException, FilesPipeline, _md5sum
from scrapy.pipelines.files import (
FileException,
FilesPipeline,
GCSFilesStore,
S3FilesStore,
_md5sum,
)
from scrapy.utils.defer import ensure_awaitable
from scrapy.utils.python import to_bytes
@ -33,6 +39,7 @@ if TYPE_CHECKING:
from scrapy.crawler import Crawler
from scrapy.pipelines.media import FileInfoOrError, MediaPipeline
from scrapy.settings import BaseSettings
class ImageException(FileException):
@ -126,6 +133,20 @@ class ImagesPipeline(FilesPipeline):
) -> str:
return await self.image_downloaded(response, request, info, item=item)
@classmethod
def _update_stores(cls, settings: BaseSettings) -> None:
super()._update_stores(settings)
s3store: type[S3FilesStore] = cast(
"type[S3FilesStore]", cls.STORE_SCHEMES["s3"]
)
s3store.POLICY = settings["IMAGES_STORE_S3_ACL"]
gcs_store: type[GCSFilesStore] = cast(
"type[GCSFilesStore]", cls.STORE_SCHEMES["gs"]
)
gcs_store.POLICY = settings["IMAGES_STORE_GCS_ACL"] or None
async def image_downloaded(
self,
response: Response,

View File

@ -29,9 +29,9 @@ from scrapy.utils.misc import arg_to_iter
from scrapy.utils.python import global_object_name
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from collections.abc import Awaitable
from collections.abc import Awaitable, Callable
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Spider
@ -153,7 +153,7 @@ class MediaPipeline(ABC):
) -> FileInfo:
fp = self._fingerprinter.fingerprint(request)
eb = request.errback
eb: Callable[[Failure], FileInfo] | None = request.errback
request.callback = NO_CALLBACK
request.errback = None

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import logging
import sys
from abc import ABCMeta, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, cast
from urllib.robotparser import RobotFileParser
from protego import Protego
@ -103,7 +103,7 @@ class RerpRobotParser(RobotParser):
def allowed(self, url: str | bytes, user_agent: str | bytes) -> bool:
user_agent = to_unicode(user_agent)
url = to_unicode(url)
return self.rp.is_allowed(user_agent, url)
return cast("bool", self.rp.is_allowed(user_agent, url))
class ProtegoRobotParser(RobotParser):

View File

@ -16,10 +16,6 @@ from scrapy.utils.python import global_object_name
logger = getLogger(__name__)
# The key types are restricted in BaseSettings._get_key() to ones supported by JSON,
# see https://github.com/scrapy/scrapy/issues/5383.
_SettingsKey: TypeAlias = bool | float | int | str | None
if TYPE_CHECKING:
from types import ModuleType
@ -29,7 +25,7 @@ if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
_SettingsInput: TypeAlias = SupportsItems[_SettingsKey, Any] | str | None
_SettingsInput: TypeAlias = SupportsItems[str, Any] | str | None
SETTINGS_PRIORITIES: dict[str, int] = {
@ -80,7 +76,7 @@ class SettingsAttribute:
return f"<SettingsAttribute value={self.value!r} priority={self.priority}>"
class BaseSettings(MutableMapping[_SettingsKey, Any]):
class BaseSettings(MutableMapping[str, Any]):
"""
Instances of this class behave like dictionaries, but store priorities
along with their ``(key, value)`` pairs, and can be frozen (i.e. marked
@ -106,11 +102,11 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
def __init__(self, values: _SettingsInput = None, priority: int | str = "project"):
self.frozen: bool = False
self.attributes: dict[_SettingsKey, SettingsAttribute] = {}
self.attributes: dict[str, SettingsAttribute] = {}
if values:
self.update(values, priority)
def __getitem__(self, opt_name: _SettingsKey) -> Any:
def __getitem__(self, opt_name: str) -> Any:
if opt_name not in self:
return None
return self.attributes[opt_name].value
@ -118,7 +114,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
def __contains__(self, name: Any) -> bool:
return name in self.attributes
def add_to_list(self, name: _SettingsKey, item: Any) -> None:
def add_to_list(self, name: str, item: Any) -> None:
"""Append *item* to the :class:`list` setting with the specified *name*
if *item* is not already in that list.
@ -129,7 +125,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
if item not in value:
self.set(name, [*value, item], self.getpriority(name) or 0)
def remove_from_list(self, name: _SettingsKey, item: Any) -> None:
def remove_from_list(self, name: str, item: Any) -> None:
"""Remove *item* from the :class:`list` setting with the specified
*name*.
@ -143,7 +139,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
raise ValueError(f"{item!r} not found in the {name} setting ({value!r}).")
self.set(name, [v for v in value if v != item], self.getpriority(name) or 0)
def get(self, name: _SettingsKey, default: Any = None) -> Any:
def get(self, name: str, default: Any = None) -> Any: # pylint: disable=arguments-renamed
"""
Get a setting value without affecting its original type.
@ -172,7 +168,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
return self[name] if self[name] is not None else default
def getbool(self, name: _SettingsKey, default: bool = False) -> bool:
def getbool(self, name: str, default: bool = False) -> bool:
"""
Get a setting value as a boolean.
@ -202,7 +198,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
"'True'/'False' and 'true'/'false'"
) from None
def getint(self, name: _SettingsKey, default: int = 0) -> int:
def getint(self, name: str, default: int = 0) -> int:
"""
Get a setting value as an int.
@ -214,7 +210,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
"""
return int(self.get(name, default))
def getfloat(self, name: _SettingsKey, default: float = 0.0) -> float:
def getfloat(self, name: str, default: float = 0.0) -> float:
"""
Get a setting value as a float.
@ -226,9 +222,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
"""
return float(self.get(name, default))
def getlist(
self, name: _SettingsKey, default: list[Any] | None = None
) -> list[Any]:
def getlist(self, name: str, default: list[Any] | None = None) -> list[Any]:
"""
Get a setting value as a list. If the setting original type is a list,
a copy of it will be returned. If it's a string it will be split by
@ -251,7 +245,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
return list(value)
def getdict(
self, name: _SettingsKey, default: dict[Any, Any] | None = None
self, name: str, default: dict[Any, Any] | None = None
) -> dict[Any, Any]:
"""
Get a setting value as a dictionary. If the setting original type is a
@ -275,7 +269,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
def getdictorlist(
self,
name: _SettingsKey,
name: str,
default: dict[Any, Any] | list[Any] | tuple[Any] | None = None,
) -> dict[Any, Any] | list[Any]:
"""Get a setting value as either a :class:`dict` or a :class:`list`.
@ -322,7 +316,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
)
return copy.deepcopy(value)
def getwithbase(self, name: _SettingsKey) -> BaseSettings:
def getwithbase(self, name: str) -> BaseSettings:
"""Get a composition of a dictionary-like setting and its ``_BASE``
counterpart.
@ -341,7 +335,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
compbs.update(self[name])
return compbs
def get_component_priority_dict_with_base(self, name: _SettingsKey) -> BaseSettings:
def get_component_priority_dict_with_base(self, name: str) -> BaseSettings:
"""Get a composition of a component priority dictionary setting and
its ``_BASE`` counterpart.
@ -389,7 +383,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
{restore_key(k): v for k, v in result.items() if v is not None}
)
def getpriority(self, name: _SettingsKey) -> int | None:
def getpriority(self, name: str) -> int | None:
"""
Return the current numerical priority value of a setting, or ``None`` if
the given ``name`` does not exist.
@ -414,7 +408,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
def replace_in_component_priority_dict(
self,
name: _SettingsKey,
name: str,
old_cls: type,
new_cls: type,
priority: int | None = None,
@ -453,12 +447,10 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
)
self.set(name, component_priority_dict, priority=self.getpriority(name) or 0)
def __setitem__(self, name: _SettingsKey, value: Any) -> None:
def __setitem__(self, name: str, value: Any) -> None:
self.set(name, value)
def set(
self, name: _SettingsKey, value: Any, priority: int | str = "project"
) -> None:
def set(self, name: str, value: Any, priority: int | str = "project") -> None:
"""
Store a key/value attribute with a given priority.
@ -487,7 +479,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
self.attributes[name].set(value, priority)
def set_in_component_priority_dict(
self, name: _SettingsKey, cls: type, priority: int | None
self, name: str, cls: type, priority: int | None
) -> None:
"""Set the *cls* component in the *name* :ref:`component priority
dictionary <component-priority-dictionaries>` setting with *priority*.
@ -510,9 +502,9 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
component_priority_dict[cls] = priority
self.set(name, component_priority_dict, self.getpriority(name) or 0)
def setdefault(
def setdefault( # pylint: disable=arguments-renamed
self,
name: _SettingsKey,
name: str,
default: Any = None,
priority: int | str = "project",
) -> Any:
@ -523,7 +515,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
return self.attributes[name].value
def setdefault_in_component_priority_dict(
self, name: _SettingsKey, cls: type, priority: int | None
self, name: str, cls: type, priority: int | None
) -> None:
"""Set the *cls* component in the *name* :ref:`component priority
dictionary <component-priority-dictionaries>` setting with *priority*
@ -592,7 +584,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
"""
self._assert_mutability()
if isinstance(values, str):
values = cast("dict[_SettingsKey, Any]", json.loads(values))
values = cast("dict[str, Any]", json.loads(values))
if values is not None:
if isinstance(values, BaseSettings):
for name, value in values.items():
@ -601,7 +593,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
for name, value in values.items():
self.set(name, value, priority)
def delete(self, name: _SettingsKey, priority: int | str = "project") -> None:
def delete(self, name: str, priority: int | str = "project") -> None:
if name not in self:
raise KeyError(name)
self._assert_mutability()
@ -609,7 +601,7 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
if priority >= cast("int", self.getpriority(name)):
del self.attributes[name]
def __delitem__(self, name: _SettingsKey) -> None:
def __delitem__(self, name: str) -> None:
self._assert_mutability()
del self.attributes[name]
@ -649,26 +641,19 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
copy.freeze()
return copy
def __iter__(self) -> Iterator[_SettingsKey]:
def __iter__(self) -> Iterator[str]:
return iter(self.attributes)
def __len__(self) -> int:
return len(self.attributes)
def _to_dict(self) -> dict[_SettingsKey, Any]:
def _to_dict(self) -> dict[str, Any]:
return {
self._get_key(k): (v._to_dict() if isinstance(v, BaseSettings) else v)
str(k): (v._to_dict() if isinstance(v, BaseSettings) else v)
for k, v in self.items()
}
def _get_key(self, key_value: Any) -> _SettingsKey:
return (
key_value
if isinstance(key_value, (bool, float, int, str, type(None)))
else str(key_value)
)
def copy_to_dict(self) -> dict[_SettingsKey, Any]:
def copy_to_dict(self) -> dict[str, Any]:
"""
Make a copy of current settings and convert to a dict.
@ -691,14 +676,14 @@ class BaseSettings(MutableMapping[_SettingsKey, Any]):
else:
p.text(pformat(self.copy_to_dict()))
def pop(self, name: _SettingsKey, default: Any = __default) -> Any:
def pop(self, name: str, default: Any = __default) -> Any: # pylint: disable=arguments-renamed
try:
value = self.attributes[name].value
except KeyError:
if default is self.__default:
raise
return default
self.__delitem__(name)
del self[name]
return value
@ -735,7 +720,7 @@ def iter_default_settings() -> Iterable[tuple[str, Any]]:
def overridden_settings(
settings: Mapping[_SettingsKey, Any],
settings: Mapping[str, Any],
) -> Iterable[tuple[str, Any]]:
"""Return an iterable of the settings that have been overridden"""
for name, defvalue in iter_default_settings():

View File

@ -16,22 +16,30 @@ 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",
"AJAXCRAWL_ENABLED",
"AJAXCRAWL_MAXSIZE",
"ASYNCIO_EVENT_LOOP",
"AUTOTHROTTLE_DEBUG",
"AUTOTHROTTLE_ENABLED",
"AUTOTHROTTLE_MAX_DELAY",
"AUTOTHROTTLE_START_DELAY",
"AUTOTHROTTLE_TARGET_CONCURRENCY",
"AWS_ACCESS_KEY_ID",
"AWS_ENDPOINT_URL",
"AWS_REGION_NAME",
"AWS_SECRET_ACCESS_KEY",
"AWS_SESSION_TOKEN",
"AWS_USE_SSL",
"AWS_VERIFY",
"BOT_NAME",
"CLOSESPIDER_ERRORCOUNT",
"CLOSESPIDER_ITEMCOUNT",
"CLOSESPIDER_PAGECOUNT",
"CLOSESPIDER_PAGECOUNT_NO_ITEM",
"CLOSESPIDER_TIMEOUT",
"CLOSESPIDER_TIMEOUT_NO_ITEM",
"COMMANDS_MODULE",
"COMPRESSION_ENABLED",
"CONCURRENT_ITEMS",
@ -54,7 +62,6 @@ __all__ = [
"DOWNLOADER_CLIENT_TLS_CIPHERS",
"DOWNLOADER_CLIENT_TLS_METHOD",
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING",
"DOWNLOADER_HTTPCLIENTFACTORY",
"DOWNLOADER_MIDDLEWARES",
"DOWNLOADER_MIDDLEWARES_BASE",
"DOWNLOADER_STATS",
@ -64,9 +71,14 @@ __all__ = [
"DOWNLOAD_HANDLERS",
"DOWNLOAD_HANDLERS_BASE",
"DOWNLOAD_MAXSIZE",
"DOWNLOAD_SLOTS",
"DOWNLOAD_TIMEOUT",
"DOWNLOAD_TLS_MAX_VERSION",
"DOWNLOAD_TLS_MIN_VERSION",
"DOWNLOAD_VERIFY_CERTIFICATES",
"DOWNLOAD_WARNSIZE",
"DUPEFILTER_CLASS",
"DUPEFILTER_DEBUG",
"EDITOR",
"EXTENSIONS",
"EXTENSIONS_BASE",
@ -85,7 +97,9 @@ __all__ = [
"FEED_STORAGE_S3_ACL",
"FEED_STORE_EMPTY",
"FEED_TEMPDIR",
"FEED_URI",
"FEED_URI_PARAMS",
"FILES_STORE",
"FILES_STORE_GCS_ACL",
"FILES_STORE_S3_ACL",
"FORCE_CRAWLER_PROCESS",
@ -93,6 +107,9 @@ __all__ = [
"FTP_PASSWORD",
"FTP_USER",
"GCS_PROJECT_ID",
"HTTPAUTH_DOMAIN",
"HTTPAUTH_PASS",
"HTTPAUTH_USER",
"HTTPCACHE_ALWAYS_STORE",
"HTTPCACHE_DBM_MODULE",
"HTTPCACHE_DIR",
@ -105,8 +122,12 @@ __all__ = [
"HTTPCACHE_IGNORE_SCHEMES",
"HTTPCACHE_POLICY",
"HTTPCACHE_STORAGE",
"HTTPERROR_ALLOWED_CODES",
"HTTPERROR_ALLOW_ALL",
"HTTPPROXY_AUTH_ENCODING",
"HTTPPROXY_ENABLED",
"HTTPX_HTTP2_ENABLED",
"IMAGES_STORE",
"IMAGES_STORE_GCS_ACL",
"IMAGES_STORE_S3_ACL",
"ITEM_PIPELINES",
@ -129,6 +150,8 @@ __all__ = [
"MAIL_HOST",
"MAIL_PASS",
"MAIL_PORT",
"MAIL_SSL",
"MAIL_TLS",
"MAIL_USER",
"MEMDEBUG_ENABLED",
"MEMDEBUG_NOTIFY",
@ -150,10 +173,12 @@ __all__ = [
"REDIRECT_MAX_TIMES",
"REDIRECT_PRIORITY_ADJUST",
"REFERER_ENABLED",
"REFERRER_POLICIES",
"REFERRER_POLICY",
"REQUEST_FINGERPRINTER_CLASS",
"RETRY_ENABLED",
"RETRY_EXCEPTIONS",
"RETRY_GIVE_UP_LOG_LEVEL",
"RETRY_HTTP_CODES",
"RETRY_PRIORITY_ADJUST",
"RETRY_TIMES",
@ -194,9 +219,6 @@ __all__ = [
ADDONS = {}
AJAXCRAWL_ENABLED = False
AJAXCRAWL_MAXSIZE = 32768
ASYNCIO_EVENT_LOOP = None
AUTOTHROTTLE_ENABLED = False
@ -205,12 +227,22 @@ AUTOTHROTTLE_MAX_DELAY = 60.0
AUTOTHROTTLE_START_DELAY = 5.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0
AWS_ACCESS_KEY_ID = None
AWS_SECRET_ACCESS_KEY = None
AWS_ENDPOINT_URL = None
AWS_REGION_NAME = None
AWS_SESSION_TOKEN = None
AWS_USE_SSL = None
AWS_VERIFY = None
BOT_NAME = "scrapybot"
CLOSESPIDER_ERRORCOUNT = 0
CLOSESPIDER_ITEMCOUNT = 0
CLOSESPIDER_PAGECOUNT = 0
CLOSESPIDER_TIMEOUT = 0
CLOSESPIDER_PAGECOUNT_NO_ITEM = 0
CLOSESPIDER_TIMEOUT_NO_ITEM = 0
COMMANDS_MODULE = ""
@ -263,22 +295,22 @@ DOWNLOAD_HANDLERS_BASE = {
DOWNLOAD_MAXSIZE = 1024 * 1024 * 1024 # 1024m
DOWNLOAD_WARNSIZE = 32 * 1024 * 1024 # 32m
DOWNLOAD_SLOTS = {}
DOWNLOAD_TIMEOUT = 180 # 3mins
DOWNLOAD_TLS_MAX_VERSION = None
DOWNLOAD_TLS_MIN_VERSION = None
DOWNLOAD_VERIFY_CERTIFICATES = False
DOWNLOADER = "scrapy.core.downloader.Downloader"
DOWNLOADER_CLIENTCONTEXTFACTORY = "SENTINEL"
DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT"
# Use highest TLS/SSL protocol version supported by the platform, also allowing negotiation:
DOWNLOADER_CLIENT_TLS_METHOD = "TLS"
DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING = False
DOWNLOADER_HTTPCLIENTFACTORY = (
"scrapy.core.downloader.webclient.ScrapyHTTPClientFactory"
)
DOWNLOADER_MIDDLEWARES = {}
DOWNLOADER_MIDDLEWARES_BASE = {
# Engine side
@ -289,7 +321,6 @@ DOWNLOADER_MIDDLEWARES_BASE = {
"scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400,
"scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500,
"scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
"scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560,
"scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
"scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
"scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,
@ -303,6 +334,7 @@ DOWNLOADER_MIDDLEWARES_BASE = {
DOWNLOADER_STATS = True
DUPEFILTER_CLASS = "scrapy.dupefilters.RFPDupeFilter"
DUPEFILTER_DEBUG = False
EDITOR = "vi"
if sys.platform == "win32":
@ -353,8 +385,10 @@ FEED_STORAGE_FTP_ACTIVE = False
FEED_STORAGE_GCS_ACL = ""
FEED_STORAGE_S3_ACL = ""
FEED_TEMPDIR = None
FEED_URI = None
FEED_URI_PARAMS = None # a function to extend uri arguments
FILES_STORE = None
FILES_STORE_GCS_ACL = ""
FILES_STORE_S3_ACL = "private"
@ -366,6 +400,10 @@ FTP_PASSWORD = "guest" # noqa: S105
GCS_PROJECT_ID = None
HTTPAUTH_USER = ""
HTTPAUTH_PASS = ""
HTTPAUTH_DOMAIN = None
HTTPCACHE_ENABLED = False
HTTPCACHE_ALWAYS_STORE = False
HTTPCACHE_DBM_MODULE = "dbm"
@ -379,9 +417,15 @@ HTTPCACHE_IGNORE_SCHEMES = ["file"]
HTTPCACHE_POLICY = "scrapy.extensions.httpcache.DummyPolicy"
HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage"
HTTPERROR_ALLOW_ALL = False
HTTPERROR_ALLOWED_CODES = []
HTTPPROXY_ENABLED = True
HTTPPROXY_AUTH_ENCODING = "latin-1"
HTTPX_HTTP2_ENABLED = False
IMAGES_STORE = None
IMAGES_STORE_GCS_ACL = ""
IMAGES_STORE_S3_ACL = "private"
@ -422,6 +466,8 @@ MAIL_HOST = "localhost"
MAIL_PORT = 25
MAIL_USER = None
MAIL_PASS = None
MAIL_SSL = False
MAIL_TLS = False
MEMDEBUG_ENABLED = False # enable memory debugging
MEMDEBUG_NOTIFY = [] # send memory debugging report by mail at engine shutdown
@ -452,6 +498,7 @@ REDIRECT_PRIORITY_ADJUST = +2
REFERER_ENABLED = True
REFERRER_POLICY = "scrapy.spidermiddlewares.referer.DefaultReferrerPolicy"
REFERRER_POLICIES = {}
REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter"
@ -470,6 +517,7 @@ RETRY_EXCEPTIONS = [
OSError,
"scrapy.core.downloader.handlers.http11.TunnelError",
]
RETRY_GIVE_UP_LOG_LEVEL = "ERROR"
RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429]
RETRY_PRIORITY_ADJUST = -1
RETRY_TIMES = 2 # initial response + 2 retries = 3 requests
@ -538,7 +586,7 @@ USER_AGENT = f"Scrapy/{import_module('scrapy').__version__} (+https://scrapy.org
WARN_ON_GENERATOR_RETURN_VALUE = True
def __getattr__(name: str):
def __getattr__(name: str) -> Any:
if name == "CONCURRENT_REQUESTS_PER_IP":
import warnings # noqa: PLC0415
@ -551,4 +599,4 @@ def __getattr__(name: str):
)
return 0
raise AttributeError
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@ -28,13 +28,8 @@ from scrapy.spiders import Spider
from scrapy.utils.conf import get_config
from scrapy.utils.console import DEFAULT_PYTHON_SHELLS, start_python_console
from scrapy.utils.datatypes import SequenceExclude
from scrapy.utils.defer import (
_schedule_coro,
deferred_f_from_coro_f,
maybe_deferred_to_future,
)
from scrapy.utils.defer import deferred_f_from_coro_f, maybe_deferred_to_future
from scrapy.utils.misc import load_object
from scrapy.utils.reactor import is_asyncio_reactor_installed, set_asyncio_event_loop
from scrapy.utils.response import open_in_browser
if TYPE_CHECKING:
@ -69,8 +64,8 @@ if TYPE_CHECKING:
# 3. When fetch() is called, it prepares a request and calls Shell._schedule()
# in the reactor thread (via threads.blockingCallFromThread()).
# 4. Shell._schedule() calls Shell._open_spider() (on the first call).
# 5. Shell._open_spider() calls engine.open_spider_async(close_if_idle=False)
# and engine._start_request_processing().
# 5. Shell._open_spider() calls
# engine.open_spider_async(close_if_idle=False).
# 6. Shell._schedule() calls engine.crawl(request), scheduling the request.
# 7. Shell._schedule() via _request_deferred() waits until the request callback
# is called. When it's called, the response becomes available.
@ -80,8 +75,8 @@ if TYPE_CHECKING:
# running event loop.
#
# Side note: it should be possible to remove _request_deferred() by using
# engine.download() instead of engine.schedule(), losing the usual stuff like
# spider middlewares (none of which should be important).
# engine.download_async() instead of engine.schedule(), losing the usual stuff
# like spider middlewares (none of which should be important).
#
# Other architecture problems:
# * scrapy.cmdline.execute() creates an AsyncCrawlerProcess instance which
@ -195,10 +190,6 @@ class Shell:
Runs in the reactor thread.
"""
if self._use_reactor and is_asyncio_reactor_installed():
# set the asyncio event loop for the current thread
event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"]
set_asyncio_event_loop(event_loop_path)
if not self.spider:
await self._open_spider(spider)
assert self.crawler.engine is not None
@ -214,7 +205,6 @@ class Shell:
self.crawler.spider = spider
assert self.crawler.engine
await self.crawler.engine.open_spider_async(close_if_idle=False)
_schedule_coro(self.crawler.engine._start_request_processing())
self.spider = spider
def fetch(
@ -241,7 +231,10 @@ class Shell:
with contextlib.suppress(IgnoreRequest):
response = threads.blockingCallFromThread(
reactor, deferred_f_from_coro_f(self._schedule), request, spider
reactor,
deferred_f_from_coro_f(self._schedule), # type: ignore[arg-type]
request,
spider,
)
else:
assert self._loop

View File

@ -41,13 +41,6 @@ class BaseSpiderMiddleware:
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler)
def process_start_requests(
self, start: Iterable[Any], spider: Spider
) -> Iterable[Any]:
for o in start:
if (o := self._get_processed(o, None)) is not None:
yield o
async def process_start(self, start: AsyncIterator[Any]) -> AsyncIterator[Any]:
async for o in start:
if (o := self._get_processed(o, None)) is not None:

View File

@ -7,17 +7,15 @@ See documentation in docs/topics/spiders.rst
from __future__ import annotations
import logging
import warnings
from typing import TYPE_CHECKING, Any, cast
from scrapy import signals
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Request, Response
from scrapy.utils.trackref import object_ref
from scrapy.utils.url import url_is_from_spider
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from collections.abc import AsyncIterator
from twisted.internet.defer import Deferred
@ -26,7 +24,7 @@ if TYPE_CHECKING:
from scrapy.crawler import Crawler
from scrapy.http.request import CallbackT
from scrapy.settings import BaseSettings, _SettingsKey
from scrapy.settings import BaseSettings
from scrapy.utils.log import SpiderLoggerAdapter
@ -39,7 +37,7 @@ class Spider(object_ref):
"""
name: str
custom_settings: dict[_SettingsKey, Any] | None = None
custom_settings: dict[str, Any] | None = None
#: Start URLs. See :meth:`start`.
start_urls: list[str]
@ -127,24 +125,6 @@ class Spider(object_ref):
.. seealso:: :ref:`start-requests`
"""
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=ScrapyDeprecationWarning, module=r"^scrapy\.spiders$"
)
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self) -> Iterable[Any]:
warnings.warn(
(
"The Spider.start_requests() method is deprecated, use "
"Spider.start() instead. If you are calling "
"super().start_requests() from a Spider.start() override, "
"iterate super().start() instead."
),
ScrapyDeprecationWarning,
stacklevel=2,
)
if not self.start_urls and hasattr(self, "start_url"):
raise AttributeError(
"Crawling could not start: 'start_urls' not found "

View File

@ -12,6 +12,7 @@ import warnings
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar, cast
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import HtmlResponse, Request, Response
from scrapy.link import Link
from scrapy.linkextractors import LinkExtractor
@ -46,7 +47,9 @@ def _identity_process_request(request: Request, response: Response) -> Request |
return request
def _get_method(method: Callable | str | None, spider: Spider) -> Callable | None:
def _get_method(
method: Callable[..., Any] | str | None, spider: Spider
) -> Callable[..., Any] | None:
if callable(method):
return method
if isinstance(method, str):
@ -218,4 +221,11 @@ class CrawlSpider(Spider):
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:
spider = super().from_crawler(crawler, *args, **kwargs)
spider._follow_links = crawler.settings.getbool("CRAWLSPIDER_FOLLOW_LINKS")
if not spider._follow_links:
warnings.warn(
"The CRAWLSPIDER_FOLLOW_LINKS setting is deprecated."
" You can set follow=False in your rules to achieve the same effect.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
return spider

View File

@ -1,64 +0,0 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any, cast
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.spiders import Spider
from scrapy.utils.spider import iterate_spider_output
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable
from scrapy import Request
from scrapy.http import Response
class InitSpider(Spider):
"""Base Spider with initialization facilities
.. warning:: This class is deprecated. Copy its code into your project if needed.
It will be removed in a future Scrapy version.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
warnings.warn(
"InitSpider is deprecated. Copy its code from Scrapy's source if needed. "
"Will be removed in a future version.",
ScrapyDeprecationWarning,
stacklevel=2,
)
async def start(self) -> AsyncIterator[Any]:
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", category=ScrapyDeprecationWarning, module=r"^scrapy\.spiders$"
)
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self) -> Iterable[Request]:
self._postinit_reqs: Iterable[Request] = super().start_requests()
return cast("Iterable[Request]", iterate_spider_output(self.init_request()))
def initialized(self, response: Response | None = None) -> Any:
"""This method must be set as the callback of your last initialization
request. See self.init_request() docstring for more info.
"""
return self.__dict__.pop("_postinit_reqs")
def init_request(self) -> Any:
"""This function should return one initialization request, with the
self.initialized method as callback. When the self.initialized method
is called this spider is considered initialized. If you need to perform
several requests for initializing your spider, you can do so by using
different callbacks. The only requirement is that the final callback
(of the last initialization request) must be self.initialized.
The default implementation calls self.initialized immediately, and
means that no initialization is needed. This method should be
overridden only when you need to perform requests to initialize your
spider
"""
return self.initialized()

View File

@ -54,10 +54,6 @@ class SitemapSpider(Spider):
self._follow: list[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow]
async def start(self) -> AsyncIterator[Any]:
for item_or_request in self.start_requests():
yield item_or_request
def start_requests(self) -> Iterable[Request]:
for url in self.sitemap_urls:
yield Request(url, self._parse_sitemap)

View File

@ -26,7 +26,7 @@ if TYPE_CHECKING:
def _with_mkdir(queue_class: type[queue.BaseQueue]) -> type[queue.BaseQueue]:
class DirectoriesCreated(queue_class): # type: ignore[valid-type,misc]
def __init__(self, path: str | PathLike, *args: Any, **kwargs: Any):
def __init__(self, path: str | PathLike[str], *args: Any, **kwargs: Any):
dirname = Path(path).parent
if not dirname.exists():
dirname.mkdir(parents=True, exist_ok=True)

View File

@ -3,10 +3,11 @@
# See documentation in:
# https://docs.scrapy.org/en/latest/topics/items.html
import scrapy
from dataclasses import dataclass
class ${ProjectName}Item(scrapy.Item):
@dataclass
class ${ProjectName}Item:
# define the fields for your item here like:
# name = scrapy.Field()
# name: str | None = None
pass

View File

@ -1,12 +1,28 @@
import sys
from OpenSSL import __version__ as PYOPENSSL_VERSION_STRING
from packaging.version import Version
from twisted import version as TWISTED_VERSION
from twisted.python.versions import Version as TxVersion
from w3lib import __version__ as W3LIB_VERSION_STRING
# improved urllib.robotparser, https://github.com/python/cpython/pull/149374
STDLIB_IMPROVED_ROBOTFILEPARSER = sys.version_info >= (3, 14, 5) or (
(3, 13, 14) <= sys.version_info < (3, 14)
)
TWISTED_FAILURE_HAS_STACK = TWISTED_VERSION < TxVersion("twisted", 24, 10, 0)
# changes to private _sslverify code, https://github.com/twisted/twisted/pull/12506
TWISTED_TLS_NEW_IMPL = TWISTED_VERSION >= TxVersion("twisted", 26, 4, 0)
# lowerMaximumSecurityTo off-by-1, https://github.com/twisted/twisted/issues/10232
TWISTED_TLS_LIMITS_OFFBY1 = TWISTED_VERSION < TxVersion("twisted", 26, 4, 0)
PYOPENSSL_VERSION = Version(PYOPENSSL_VERSION_STRING)
# SSL.Context.use_certificate() wants an X509 object, SSL.Context.use_privatekey() wants a PKey object
PYOPENSSL_WANTS_X509_PKEY = PYOPENSSL_VERSION < Version("24.3.0")
# pyOpenSSL X.509 APIs are deprecated and cryptography-based ones are preferred
PYOPENSSL_X509_DEPRECATED = PYOPENSSL_VERSION >= Version("24.3.0")
# SSL.Context.set_cipher_list() creates a temporary connection, making the context immutable
PYOPENSSL_SET_CIPHER_LIST_TMP_CONN = PYOPENSSL_VERSION < Version("25.2.0")
W3LIB_VERSION = Version(W3LIB_VERSION_STRING)
# safe_url_string() strips the input, https://github.com/scrapy/w3lib/pull/207
W3LIB_STRIPS_URLS = W3LIB_VERSION >= Version("2.1.1")

View File

@ -2,8 +2,8 @@
from __future__ import annotations
from abc import ABC
from contextlib import contextmanager
from http.cookiejar import CookieJar
from typing import TYPE_CHECKING, Any
from twisted.internet.defer import CancelledError
@ -15,7 +15,6 @@ from twisted.web.client import ResponseFailed
from twisted.web.error import SchemeNotSupported
from scrapy import responsetypes
from scrapy.core.downloader.handlers.base import BaseDownloadHandler
from scrapy.exceptions import (
CannotResolveHostError,
DownloadCancelledError,
@ -29,29 +28,24 @@ from scrapy.utils.log import logger
if TYPE_CHECKING:
from collections.abc import Iterator
from http.client import HTTPResponse
from http.cookiejar import Cookie
from ipaddress import IPv4Address, IPv6Address
from twisted.internet.ssl import Certificate
from urllib.request import Request as ULRequest
from scrapy import Request
from scrapy.crawler import Crawler
from scrapy.http import Headers, Response
class BaseHttpDownloadHandler(BaseDownloadHandler, ABC):
"""Base class for built-in HTTP download handlers."""
class NullCookieJar(CookieJar): # pragma: no cover
"""A CookieJar that rejects all cookies."""
def __init__(self, crawler: Crawler):
super().__init__(crawler)
self._default_maxsize: int = crawler.settings.getint("DOWNLOAD_MAXSIZE")
self._default_warnsize: int = crawler.settings.getint("DOWNLOAD_WARNSIZE")
self._fail_on_dataloss: bool = crawler.settings.getbool(
"DOWNLOAD_FAIL_ON_DATALOSS"
)
self._tls_verbose_logging: bool = crawler.settings.getbool(
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
)
self._fail_on_dataloss_warned: bool = False
def extract_cookies(self, response: HTTPResponse, request: ULRequest) -> None:
pass
def set_cookie(self, cookie: Cookie) -> None:
pass
@contextmanager
@ -103,7 +97,7 @@ def make_response(
headers: Headers,
body: bytes = b"",
flags: list[str] | None = None,
certificate: Certificate | None = None,
certificate: Any = None,
ip_address: IPv4Address | IPv6Address | None = None,
protocol: str | None = None,
stop_download: StopDownload | None = None,

View File

@ -148,7 +148,7 @@ class AsyncioLoopingCall:
self._func: Callable[_P, _T] = func
self._args: tuple[Any, ...] = args
self._kwargs: dict[str, Any] = kwargs
self._task: asyncio.Task | None = None
self._task: asyncio.Task[None] | None = None
self.interval: float | None = None
self._start_time: float | None = None

View File

@ -9,7 +9,7 @@ from twisted.web.server import Request, Site
class Root(Resource):
isLeaf = True
def getChild(self, name: str, request: Request) -> Resource:
def getChild(self, path: str, request: Request) -> Resource:
return self
def render(self, request: Request) -> bytes:
@ -30,7 +30,7 @@ class Root(Resource):
def _getarg(
request: Request, name: bytes, default: Any = None, type_: type = str
) -> Any:
return type_(request.args[name][0]) if name in request.args else default # type: ignore[index,operator]
return type_(request.args[name][0]) if name in request.args else default
if __name__ == "__main__":

View File

@ -71,8 +71,8 @@ def arglist_to_dict(arglist: list[str]) -> dict[str, str]:
def closest_scrapy_cfg(
path: str | os.PathLike = ".",
prevpath: str | os.PathLike | None = None,
path: str | os.PathLike[str] = ".",
prevpath: str | os.PathLike[str] | None = None,
) -> str:
"""Return the path to the closest scrapy.cfg file by traversing the current
directory and its parents

View File

@ -13,7 +13,7 @@ import warnings
import weakref
from collections import OrderedDict
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, AnyStr, TypeVar
from typing import TYPE_CHECKING, Any, AnyStr, TypeVar, cast
from scrapy.exceptions import ScrapyDeprecationWarning
@ -28,7 +28,7 @@ _KT = TypeVar("_KT")
_VT = TypeVar("_VT")
class CaselessDict(dict):
class CaselessDict(dict): # type: ignore[type-arg]
__slots__ = ()
def __new__(cls, *args: Any, **kwargs: Any) -> Self:
@ -99,20 +99,20 @@ class CaselessDict(dict):
return dict.pop(self, self.normkey(key), *args)
class CaseInsensitiveDict(collections.UserDict):
class CaseInsensitiveDict(collections.UserDict[str | bytes, Any]):
"""A dict-like structure that accepts strings or bytes
as keys and allows case-insensitive lookups.
"""
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._keys: dict = {}
self._keys: dict[str | bytes, Any] = {}
super().__init__(*args, **kwargs)
def __getitem__(self, key: AnyStr) -> Any:
def __getitem__(self, key: str | bytes) -> Any:
normalized_key = self._normkey(key)
return super().__getitem__(self._keys[normalized_key.lower()])
def __setitem__(self, key: AnyStr, value: Any) -> None:
def __setitem__(self, key: str | bytes, value: Any) -> None:
normalized_key = self._normkey(key)
try:
lower_key = self._keys[normalized_key.lower()]
@ -122,19 +122,19 @@ class CaseInsensitiveDict(collections.UserDict):
super().__setitem__(normalized_key, self._normvalue(value))
self._keys[normalized_key.lower()] = normalized_key
def __delitem__(self, key: AnyStr) -> None:
def __delitem__(self, key: str | bytes) -> None:
normalized_key = self._normkey(key)
stored_key = self._keys.pop(normalized_key.lower())
super().__delitem__(stored_key)
def __contains__(self, key: AnyStr) -> bool: # type: ignore[override]
def __contains__(self, key: str | bytes) -> bool: # type: ignore[override]
normalized_key = self._normkey(key)
return normalized_key.lower() in self._keys
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {super().__repr__()}>"
def _normkey(self, key: AnyStr) -> AnyStr:
def _normkey(self, key: str | bytes) -> str | bytes:
return key
def _normvalue(self, value: Any) -> Any:
@ -158,7 +158,7 @@ class LocalCache(OrderedDict[_KT, _VT]):
super().__setitem__(key, value)
class LocalWeakReferencedCache(weakref.WeakKeyDictionary):
class LocalWeakReferencedCache(weakref.WeakKeyDictionary[_KT, _VT | None]):
"""
A weakref.WeakKeyDictionary implementation that uses LocalCache as its
underlying data structure, making it ordered and capable of being size-limited.
@ -172,16 +172,16 @@ class LocalWeakReferencedCache(weakref.WeakKeyDictionary):
def __init__(self, limit: int | None = None):
super().__init__()
self.data: LocalCache = LocalCache(limit=limit)
self.data: LocalCache[_KT, _VT] = LocalCache(limit=limit)
def __setitem__(self, key: _KT, value: _VT) -> None:
def __setitem__(self, key: _KT, value: _VT | None) -> None:
# if raised, key is not weak-referenceable, skip caching
with contextlib.suppress(TypeError):
super().__setitem__(key, value)
def __getitem__(self, key: _KT) -> _VT | None:
try:
return super().__getitem__(key)
return cast("_VT", super().__getitem__(key))
except (TypeError, KeyError):
return None # key is either not weak-referenceable or not cached

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import inspect
import warnings
from functools import wraps
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast, overload
from twisted.internet.defer import Deferred, maybeDeferred
@ -115,7 +115,7 @@ def _warn_spider_arg(
@wraps(func)
async def async_inner(*args: _P.args, **kwargs: _P.kwargs) -> _T:
check_args(*args, **kwargs)
return await func(*args, **kwargs)
return cast("_T", await func(*args, **kwargs))
return async_inner

View File

@ -173,7 +173,7 @@ def parallel(
return DeferredList([coop.coiterate(work) for _ in range(count)])
class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
class _AsyncCooperatorAdapter(Iterator[Deferred[Any]], Generic[_T]):
"""A class that wraps an async iterable into a normal iterator suitable
for using in Cooperator.coiterate(). As it's only needed for parallel_async(),
it calls the callable directly in the callback, instead of providing a more
@ -262,7 +262,7 @@ class _AsyncCooperatorAdapter(Iterator, Generic[_T]):
def _call_anext(self) -> None:
# This starts waiting for the next result from aiterator.
# If aiterator is exhausted, _errback will be called.
self.anext_deferred = deferred_from_coro(self.aiterator.__anext__())
self.anext_deferred = deferred_from_coro(anext(self.aiterator))
self.anext_deferred.addCallbacks(self._callback, self._errback)
def __next__(self) -> Deferred[Any]:
@ -370,10 +370,10 @@ async def aiter_errback(
"""Wrap an async iterable calling an errback if an error is caught while
iterating it. Similar to :func:`scrapy.utils.defer.iter_errback`.
"""
it = aiterable.__aiter__()
it = aiter(aiterable)
while True:
try:
yield await it.__anext__()
yield await anext(it)
except StopAsyncIteration:
break
except Exception:

Some files were not shown because too many files have changed in this diff Show More