mirror of https://github.com/scrapy/scrapy.git
Merge remote-tracking branch 'origin/master' into faster-shutdown-2
This commit is contained in:
commit
f2a3f19aa6
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
51
conftest.py
51
conftest.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 #}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ scrapy_intersphinx_enable = [
|
|||
"coverage",
|
||||
"cryptography",
|
||||
"cssselect",
|
||||
"form2request",
|
||||
"itemloaders",
|
||||
"parsel",
|
||||
"pytest",
|
||||
|
|
|
|||
17
docs/faq.rst
17
docs/faq.rst
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
305
docs/news.rst
305
docs/news.rst
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
--------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/>`__
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
=============================
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
2.15.2
|
||||
2.16.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, [])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 Twisted’s
|
||||
# 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue