Merge branch 'master' into py313

This commit is contained in:
Andrey Rakhmatullin 2024-05-22 13:14:59 +05:00
commit 85d7458651
230 changed files with 7881 additions and 2925 deletions

View File

@ -1,21 +1,7 @@
skips: skips:
- B101 - B101 # assert_used, needed for mypy
- B113 # https://github.com/PyCQA/bandit/issues/1010 - B321 # ftplib, https://github.com/scrapy/scrapy/issues/4180
- B105 - B402 # import_ftplib, https://github.com/scrapy/scrapy/issues/4180
- B301 - B411 # import_xmlrpclib, https://github.com/PyCQA/bandit/issues/1082
- B303 - B503 # ssl_with_bad_defaults
- B306
- B307
- B311
- B320
- B321
- B324
- B402 # https://github.com/scrapy/scrapy/issues/4180
- B403
- B404
- B406
- B410
- B503
- B603
- B605
exclude_dirs: ['tests'] exclude_dirs: ['tests']

View File

@ -1,7 +1,11 @@
[bumpversion] [bumpversion]
current_version = 2.11.0 current_version = 2.11.2
commit = True commit = True
tag = True tag = True
tag_name = {new_version} tag_name = {new_version}
[bumpversion:file:scrapy/VERSION] [bumpversion:file:scrapy/VERSION]
[bumpversion:file:SECURITY.md]
parse = (?P<major>\d+)\.(?P<minor>\d+)\.x
serialize = {major}.{minor}.x

View File

@ -4,3 +4,9 @@ include = scrapy/*
omit = omit =
tests/* tests/*
disable_warnings = include-ignored disable_warnings = include-ignored
[report]
# https://github.com/nedbat/coveragepy/issues/831#issuecomment-517778185
exclude_lines =
pragma: no cover
if TYPE_CHECKING:

View File

@ -1,7 +1,7 @@
[flake8] [flake8]
max-line-length = 119 max-line-length = 119
ignore = W503, E203 ignore = E203, E501, E701, E704, W503
exclude = exclude =
docs/conf.py docs/conf.py

View File

@ -1,7 +1,7 @@
# .git-blame-ignore-revs # .git-blame-ignore-revs
# adding black formatter to all the code # adding black formatter to all the code
e211ec0aa26ecae0da8ae55d064ea60e1efe4d0d e211ec0aa26ecae0da8ae55d064ea60e1efe4d0d
# re applying black to the code with default line length # reapplying black to the code with default line length
303f0a70fcf8067adf0a909c2096a5009162383a 303f0a70fcf8067adf0a909c2096a5009162383a
# reaplying black again and removing line length on pre-commit black config # reapplying black again and removing line length on pre-commit black config
c5cdd0d30ceb68ccba04af0e71d1b8e6678e2962 c5cdd0d30ceb68ccba04af0e71d1b8e6678e2962

View File

@ -18,6 +18,9 @@ jobs:
- python-version: 3.8 - python-version: 3.8
env: env:
TOXENV: typing TOXENV: typing
- python-version: 3.8
env:
TOXENV: typing-tests
- python-version: "3.11" # Keep in sync with .readthedocs.yml - python-version: "3.11" # Keep in sync with .readthedocs.yml
env: env:
TOXENV: docs TOXENV: docs

View File

@ -1,19 +1,19 @@
repos: repos:
- repo: https://github.com/PyCQA/bandit - repo: https://github.com/PyCQA/bandit
rev: 1.7.5 rev: 1.7.7
hooks: hooks:
- id: bandit - id: bandit
args: [-r, -c, .bandit.yml] args: [-r, -c, .bandit.yml]
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: 6.1.0 rev: 7.0.0
hooks: hooks:
- id: flake8 - id: flake8
- repo: https://github.com/psf/black.git - repo: https://github.com/psf/black.git
rev: 23.9.1 rev: 24.2.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort
rev: 5.12.0 rev: 5.13.2
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/adamchainz/blacken-docs - repo: https://github.com/adamchainz/blacken-docs
@ -21,4 +21,4 @@ repos:
hooks: hooks:
- id: blacken-docs - id: blacken-docs
additional_dependencies: additional_dependencies:
- black==23.9.1 - black==24.2.0

View File

@ -1,9 +1,8 @@
include README.rst include CODE_OF_CONDUCT.md
include AUTHORS include CONTRIBUTING.md
include INSTALL include INSTALL.md
include LICENSE
include MANIFEST.in
include NEWS include NEWS
include SECURITY.md
include scrapy/VERSION include scrapy/VERSION
include scrapy/mime.types include scrapy/mime.types
@ -12,16 +11,13 @@ include scrapy/py.typed
include codecov.yml include codecov.yml
include conftest.py include conftest.py
include pytest.ini include pytest.ini
include requirements-*.txt
include tox.ini include tox.ini
recursive-include scrapy/templates * recursive-include scrapy/templates *
recursive-include scrapy license.txt
recursive-include docs * recursive-include docs *
prune docs/build prune docs/build
recursive-include extras * recursive-include extras *
recursive-include bin *
recursive-include tests * recursive-include tests *
global-exclude __pycache__ *.py[cod] global-exclude __pycache__ *.py[cod]

12
SECURITY.md Normal file
View File

@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.11.x | :white_check_mark: |
| < 2.11.x | :x: |
## Reporting a Vulnerability
Please report the vulnerability using https://github.com/scrapy/scrapy/security/advisories/new.

View File

@ -1,10 +1,6 @@
import platform
import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from twisted import version as twisted_version
from twisted.python.versions import Version
from twisted.web.http import H2_ENABLED from twisted.web.http import H2_ENABLED
from scrapy.utils.reactor import install_reactor from scrapy.utils.reactor import install_reactor
@ -85,12 +81,12 @@ def only_not_asyncio(request, reactor_pytest):
def requires_uvloop(request): def requires_uvloop(request):
if not request.node.get_closest_marker("requires_uvloop"): if not request.node.get_closest_marker("requires_uvloop"):
return return
if sys.implementation.name == "pypy": try:
pytest.skip("uvloop does not support pypy properly") import uvloop
if platform.system() == "Windows":
pytest.skip("uvloop does not support Windows") del uvloop
if twisted_version == Version("twisted", 21, 2, 0): except ImportError:
pytest.skip("https://twistedmatrix.com/trac/ticket/10106") pytest.skip("uvloop is not installed")
def pytest_configure(config): def pytest_configure(config):

View File

@ -273,7 +273,7 @@
Quotes by: <a href="https://www.goodreads.com/quotes">GoodReads.com</a> Quotes by: <a href="https://www.goodreads.com/quotes">GoodReads.com</a>
</p> </p>
<p class="copyright"> <p class="copyright">
Made with <span class='sh-red'></span> by <a href="https://scrapinghub.com">Scrapinghub</a> Made with <span class='sh-red'></span> by <a href="https://www.zyte.com">Zyte</a>
</p> </p>
</div> </div>
</footer> </footer>

View File

@ -273,7 +273,7 @@
Quotes by: <a href="https://www.goodreads.com/quotes">GoodReads.com</a> Quotes by: <a href="https://www.goodreads.com/quotes">GoodReads.com</a>
</p> </p>
<p class="copyright"> <p class="copyright">
Made with <span class='sh-red'></span> by <a href="https://scrapinghub.com">Scrapinghub</a> Made with <span class='sh-red'></span> by <a href="https://www.zyte.com">Zyte</a>
</p> </p>
</div> </div>
</footer> </footer>

View File

@ -10,7 +10,6 @@
# serve to show the default. # serve to show the default.
import sys import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
# If your extensions are in another directory, add it here. If the directory # If your extensions are in another directory, add it here. If the directory
@ -48,7 +47,7 @@ master_doc = "index"
# General information about the project. # General information about the project.
project = "Scrapy" project = "Scrapy"
copyright = f"2008{datetime.now().year}, Scrapy developers" copyright = "Scrapy developers"
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the
@ -227,7 +226,7 @@ latex_documents = [
# A list of regular expressions that match URIs that should not be checked when # A list of regular expressions that match URIs that should not be checked when
# doing a linkcheck build. # doing a linkcheck build.
linkcheck_ignore = [ linkcheck_ignore = [
"http://localhost:\d+", r"http://localhost:\d+",
"http://hg.scrapy.org", "http://hg.scrapy.org",
"http://directory.google.com/", "http://directory.google.com/",
] ]

View File

@ -178,7 +178,7 @@ Scrapy:
* We use `black <https://black.readthedocs.io/en/stable/>`_ for code formatting. * We use `black <https://black.readthedocs.io/en/stable/>`_ for code formatting.
There is a hook in the pre-commit config There is a hook in the pre-commit config
that will automatically format your code before every commit. You can also that will automatically format your code before every commit. You can also
run black manually with ``tox -e black``. run black manually with ``tox -e pre-commit``.
* Don't put your name in the code you contribute; git provides enough * Don't put your name in the code you contribute; git provides enough
metadata to identify author of the code. metadata to identify author of the code.

View File

@ -138,39 +138,37 @@ See previous question.
How can I prevent memory errors due to many allowed domains? How can I prevent memory errors due to many allowed domains?
------------------------------------------------------------ ------------------------------------------------------------
If you have a spider with a long list of If you have a spider with a long list of :attr:`~scrapy.Spider.allowed_domains`
:attr:`~scrapy.Spider.allowed_domains` (e.g. 50,000+), consider (e.g. 50,000+), consider replacing the default
replacing the default :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` downloader
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` spider middleware middleware with a :ref:`custom downloader middleware
with a :ref:`custom spider middleware <custom-spider-middleware>` that requires <topics-downloader-middleware-custom>` that requires less memory. For example:
less memory. For example:
- If your domain names are similar enough, use your own regular expression - If your domain names are similar enough, use your own regular expression
instead joining the strings in instead joining the strings in :attr:`~scrapy.Spider.allowed_domains` into
:attr:`~scrapy.Spider.allowed_domains` into a complex regular a complex regular expression.
expression.
- If you can `meet the installation requirements`_, use pyre2_ instead of - If you can `meet the installation requirements`_, use pyre2_ instead of
Pythons re_ to compile your URL-filtering regular expression. See Pythons re_ to compile your URL-filtering regular expression. See
:issue:`1908`. :issue:`1908`.
See also other suggestions at `StackOverflow`_. See also `other suggestions at StackOverflow
<https://stackoverflow.com/q/36440681>`__.
.. note:: Remember to disable .. note:: Remember to disable
:class:`scrapy.spidermiddlewares.offsite.OffsiteMiddleware` when you enable :class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` when you
your custom implementation: enable your custom implementation:
.. code-block:: python .. code-block:: python
SPIDER_MIDDLEWARES = { DOWNLOADER_MIDDLEWARES = {
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None, "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": None,
"myproject.middlewares.CustomOffsiteMiddleware": 500, "myproject.middlewares.CustomOffsiteMiddleware": 50,
} }
.. _meet the installation requirements: https://github.com/andreasvc/pyre2#installation .. _meet the installation requirements: https://github.com/andreasvc/pyre2#installation
.. _pyre2: https://github.com/andreasvc/pyre2 .. _pyre2: https://github.com/andreasvc/pyre2
.. _re: https://docs.python.org/library/re.html .. _re: https://docs.python.org/library/re.html
.. _StackOverflow: https://stackoverflow.com/q/36440681/939364
Can I use Basic HTTP Authentication in my spiders? Can I use Basic HTTP Authentication in my spiders?
-------------------------------------------------- --------------------------------------------------
@ -206,12 +204,10 @@ I get "Filtered offsite request" messages. How can I fix them?
Those messages (logged with ``DEBUG`` level) don't necessarily mean there is a Those messages (logged with ``DEBUG`` level) don't necessarily mean there is a
problem, so you may not need to fix them. problem, so you may not need to fix them.
Those messages are thrown by the Offsite Spider Middleware, which is a spider Those messages are thrown by
middleware (enabled by default) whose purpose is to filter out requests to :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, which is a
domains outside the ones covered by the spider. downloader middleware (enabled by default) whose purpose is to filter out
requests to domains outside the ones covered by the spider.
For more info see:
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware`.
What is the recommended way to deploy a Scrapy crawler in production? What is the recommended way to deploy a Scrapy crawler in production?
--------------------------------------------------------------------- ---------------------------------------------------------------------
@ -297,9 +293,13 @@ build the DOM of the entire feed in memory, and this can be quite slow and
consume a lot of memory. consume a lot of memory.
In order to avoid parsing all the entire feed at once in memory, you can use In order to avoid parsing all the entire feed at once in memory, you can use
the functions ``xmliter`` and ``csviter`` from ``scrapy.utils.iterators`` the :func:`~scrapy.utils.iterators.xmliter_lxml` and
module. In fact, this is what the feed spiders (see :ref:`topics-spiders`) use :func:`~scrapy.utils.iterators.csviter` functions. In fact, this is what
under the cover. :class:`~scrapy.spiders.XMLFeedSpider` uses.
.. autofunction:: scrapy.utils.iterators.xmliter_lxml
.. autofunction:: scrapy.utils.iterators.csviter
Does Scrapy manage cookies automatically? Does Scrapy manage cookies automatically?
----------------------------------------- -----------------------------------------
@ -405,6 +405,23 @@ or :class:`~scrapy.signals.headers_received` signals and raising a
:ref:`topics-stop-response-download` topic for additional information and examples. :ref:`topics-stop-response-download` topic for additional information and examples.
.. _faq-blank-request:
How can I make a blank request?
-------------------------------
.. code-block:: python
from scrapy import Request
blank_request = Request("data:,")
In this case, the URL is set to a data URI scheme. Data URLs allow you to include data
in-line in web pages as if they were external resources. The "data:" scheme with an empty
content (",") essentially creates a request to a data URL without any specific content.
Running ``runspider`` I get ``error: No spider found in file: <filename>`` Running ``runspider`` I get ``error: No spider found in file: <filename>``
-------------------------------------------------------------------------- --------------------------------------------------------------------------

View File

@ -3,6 +3,229 @@
Release notes Release notes
============= =============
.. _release-VERSION:
Scrapy VERSION (YYYY-MM-DD)
---------------------------
Deprecations
~~~~~~~~~~~~
- :meth:`scrapy.core.downloader.Downloader._get_slot_key` is deprecated, use
:meth:`scrapy.core.downloader.Downloader.get_slot_key` instead.
(:issue:`6340`)
.. _release-2.11.2:
Scrapy 2.11.2 (2024-05-14)
--------------------------
Security bug fixes
~~~~~~~~~~~~~~~~~~
- Redirects to non-HTTP protocols are no longer followed. Please, see the
`23j4-mw76-5v7h security advisory`_ for more information. (:issue:`457`)
.. _23j4-mw76-5v7h security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-23j4-mw76-5v7h
- The ``Authorization`` header is now dropped on redirects to a different
scheme (``http://`` or ``https://``) or port, even if the domain is the
same. Please, see the `4qqq-9vqf-3h3f security advisory`_ for more
information.
.. _4qqq-9vqf-3h3f security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-4qqq-9vqf-3h3f
- When using system proxy settings that are different for ``http://`` and
``https://``, redirects to a different URL scheme will now also trigger the
corresponding change in proxy settings for the redirected request. Please,
see the `jm3v-qxmh-hxwv security advisory`_ for more information.
(:issue:`767`)
.. _jm3v-qxmh-hxwv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-jm3v-qxmh-hxwv
- :attr:`Spider.allowed_domains <scrapy.Spider.allowed_domains>` is now
enforced for all requests, and not only requests from spider callbacks.
(:issue:`1042`, :issue:`2241`, :issue:`6358`)
- :func:`~scrapy.utils.iterators.xmliter_lxml` no longer resolves XML
entities. (:issue:`6265`)
- defusedxml_ is now used to make
:class:`scrapy.http.request.rpc.XmlRpcRequest` more secure.
(:issue:`6250`, :issue:`6251`)
.. _defusedxml: https://github.com/tiran/defusedxml
Bug fixes
~~~~~~~~~
- Restored support for brotlipy_, which had been dropped in Scrapy 2.11.1 in
favor of brotli_. (:issue:`6261`)
.. _brotli: https://github.com/google/brotli
.. note:: brotlipy is deprecated, both in Scrapy and upstream. Use brotli
instead if you can.
- Make :setting:`METAREFRESH_IGNORE_TAGS` ``["noscript"]`` by default. This
prevents
:class:`~scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware` from
following redirects that would not be followed by web browsers with
JavaScript enabled. (:issue:`6342`, :issue:`6347`)
- During :ref:`feed export <topics-feed-exports>`, do not close the
underlying file from :ref:`built-in post-processing plugins
<builtin-plugins>`.
(:issue:`5932`, :issue:`6178`, :issue:`6239`)
- :class:`LinkExtractor <scrapy.linkextractors.lxmlhtml.LxmlLinkExtractor>`
now properly applies the ``unique`` and ``canonicalize`` parameters.
(:issue:`3273`, :issue:`6221`)
- Do not initialize the scheduler disk queue if :setting:`JOBDIR` is an empty
string. (:issue:`6121`, :issue:`6124`)
- Fix :attr:`Spider.logger <scrapy.Spider.logger>` not logging custom extra
information. (:issue:`6323`, :issue:`6324`)
- ``robots.txt`` files with a non-UTF-8 encoding no longer prevent parsing
the UTF-8-compatible (e.g. ASCII) parts of the document.
(:issue:`6292`, :issue:`6298`)
- :meth:`scrapy.http.cookies.WrappedRequest.get_header` no longer raises an
exception if ``default`` is ``None``.
(:issue:`6308`, :issue:`6310`)
- :class:`~scrapy.selector.Selector` now uses
:func:`scrapy.utils.response.get_base_url` to determine the base URL of a
given :class:`~scrapy.http.Response`. (:issue:`6265`)
- The :meth:`media_to_download` method of :ref:`media pipelines
<topics-media-pipeline>` now logs exceptions before stripping them.
(:issue:`5067`, :issue:`5068`)
- When passing a callback to the :command:`parse` command, build the callback
callable with the right signature.
(:issue:`6182`)
Documentation
~~~~~~~~~~~~~
- Add a FAQ entry about :ref:`creating blank requests <faq-blank-request>`.
(:issue:`6203`, :issue:`6208`)
- Document that :attr:`scrapy.selector.Selector.type` can be ``"json"``.
(:issue:`6328`, :issue:`6334`)
Quality assurance
~~~~~~~~~~~~~~~~~
- Make builds reproducible. (:issue:`5019`, :issue:`6322`)
- Packaging and test fixes.
(:issue:`6286`, :issue:`6290`, :issue:`6312`, :issue:`6316`, :issue:`6344`)
.. _release-2.11.1:
Scrapy 2.11.1 (2024-02-14)
--------------------------
Highlights:
- Security bug fixes.
- Support for Twisted >= 23.8.0.
- Documentation improvements.
Security bug fixes
~~~~~~~~~~~~~~~~~~
- Addressed `ReDoS vulnerabilities`_:
- ``scrapy.utils.iterators.xmliter`` is now deprecated in favor of
:func:`~scrapy.utils.iterators.xmliter_lxml`, which
:class:`~scrapy.spiders.XMLFeedSpider` now uses.
To minimize the impact of this change on existing code,
:func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating
the node namespace with a prefix in the node name, and big files with
highly nested trees when using libxml2 2.7+.
- Fixed regular expressions in the implementation of the
:func:`~scrapy.utils.response.open_in_browser` function.
Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information.
.. _ReDoS vulnerabilities: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
.. _cc65-xxvf-f7r9 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cc65-xxvf-f7r9
- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply
to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security
advisory`_ for more information.
.. _7j7m-v7m3-jqm7 security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-7j7m-v7m3-jqm7
- Also in relation with the `7j7m-v7m3-jqm7 security advisory`_, the
deprecated ``scrapy.downloadermiddlewares.decompression`` module has been
removed.
- The ``Authorization`` header is now dropped on redirects to a different
domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more
information.
.. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv
Modified requirements
~~~~~~~~~~~~~~~~~~~~~
- The Twisted dependency is no longer restricted to < 23.8.0. (:issue:`6024`,
:issue:`6064`, :issue:`6142`)
Bug fixes
~~~~~~~~~
- The OS signal handling code was refactored to no longer use private Twisted
functions. (:issue:`6024`, :issue:`6064`, :issue:`6112`)
Documentation
~~~~~~~~~~~~~
- Improved documentation for :class:`~scrapy.crawler.Crawler` initialization
changes made in the 2.11.0 release. (:issue:`6057`, :issue:`6147`)
- Extended documentation for :attr:`Request.meta <scrapy.http.Request.meta>`.
(:issue:`5565`)
- Fixed the :reqmeta:`dont_merge_cookies` documentation. (:issue:`5936`,
:issue:`6077`)
- Added a link to Zyte's export guides to the :ref:`feed exports
<topics-feed-exports>` documentation. (:issue:`6183`)
- Added a missing note about backward-incompatible changes in
:class:`~scrapy.exporters.PythonItemExporter` to the 2.11.0 release notes.
(:issue:`6060`, :issue:`6081`)
- Added a missing note about removing the deprecated
``scrapy.utils.boto.is_botocore()`` function to the 2.8.0 release notes.
(:issue:`6056`, :issue:`6061`)
- Other documentation improvements. (:issue:`6128`, :issue:`6144`,
:issue:`6163`, :issue:`6190`, :issue:`6192`)
Quality assurance
~~~~~~~~~~~~~~~~~
- Added Python 3.12 to the CI configuration, re-enabled tests that were
disabled when the pre-release support was added. (:issue:`5985`,
:issue:`6083`, :issue:`6098`)
- Fixed a test issue on PyPy 7.3.14. (:issue:`6204`, :issue:`6205`)
.. _release-2.11.0: .. _release-2.11.0:
Scrapy 2.11.0 (2023-09-18) Scrapy 2.11.0 (2023-09-18)
@ -62,6 +285,9 @@ Deprecation removals
1.0.0, use :attr:`CrawlerRunner.spider_loader 1.0.0, use :attr:`CrawlerRunner.spider_loader
<scrapy.crawler.CrawlerRunner.spider_loader>` instead. (:issue:`6010`) <scrapy.crawler.CrawlerRunner.spider_loader>` instead. (:issue:`6010`)
- The :func:`scrapy.utils.response.response_httprepr` function, deprecated in
Scrapy 2.6.0, has now been removed. (:issue:`6111`)
Deprecations Deprecations
~~~~~~~~~~~~ ~~~~~~~~~~~~
@ -1157,6 +1383,9 @@ Deprecations
Instead, call :meth:`~scrapy.core.engine.ExecutionEngine.open_spider` Instead, call :meth:`~scrapy.core.engine.ExecutionEngine.open_spider`
first to set the :class:`~scrapy.Spider` object. first to set the :class:`~scrapy.Spider` object.
- :func:`scrapy.utils.response.response_httprepr` is now deprecated.
(:issue:`4972`)
New features New features
~~~~~~~~~~~~ ~~~~~~~~~~~~
@ -2871,6 +3100,38 @@ affect subclasses:
(:issue:`3884`) (:issue:`3884`)
.. _release-1.8.4:
Scrapy 1.8.4 (2024-02-14)
-------------------------
**Security bug fixes:**
- Due to its `ReDoS vulnerabilities`_, ``scrapy.utils.iterators.xmliter`` is
now deprecated in favor of :func:`~scrapy.utils.iterators.xmliter_lxml`,
which :class:`~scrapy.spiders.XMLFeedSpider` now uses.
To minimize the impact of this change on existing code,
:func:`~scrapy.utils.iterators.xmliter_lxml` now supports indicating
the node namespace as a prefix in the node name, and big files with highly
nested trees when using libxml2 2.7+.
Please, see the `cc65-xxvf-f7r9 security advisory`_ for more information.
- :setting:`DOWNLOAD_MAXSIZE` and :setting:`DOWNLOAD_WARNSIZE` now also apply
to the decompressed response body. Please, see the `7j7m-v7m3-jqm7 security
advisory`_ for more information.
- Also in relation with the `7j7m-v7m3-jqm7 security advisory`_, use of the
``scrapy.downloadermiddlewares.decompression`` module is discouraged and
will trigger a warning.
- The ``Authorization`` header is now dropped on redirects to a different
domain. Please, see the `cw9j-q3vf-hrrv security advisory`_ for more
information.
.. _cw9j-q3vf-hrrv security advisory: https://github.com/scrapy/scrapy/security/advisories/GHSA-cw9j-q3vf-hrrv
.. _release-1.8.3: .. _release-1.8.3:

View File

@ -1,4 +1,4 @@
sphinx==5.0.2 sphinx==6.2.1
sphinx-hoverxref==1.1.1 sphinx-hoverxref==1.3.0
sphinx-notfound-page==0.8 sphinx-notfound-page==1.0.0
sphinx-rtd-theme==1.0.0 sphinx-rtd-theme==2.0.0

View File

@ -150,8 +150,7 @@ Access the crawler instance:
def from_crawler(cls, crawler): def from_crawler(cls, crawler):
return cls(crawler) return cls(crawler)
def update_settings(self, settings): def update_settings(self, settings): ...
...
Use a fallback component: Use a fallback component:

View File

@ -47,6 +47,18 @@ effect, but there are some important differences:
AutoThrottle doesn't have these issues. AutoThrottle doesn't have these issues.
Disabling throttling on a downloader slot
=========================================
It is possible to disable AutoThrottle for a specific download slot at run time
by setting its ``throttle`` attribute to ``False``, e.g. using
:setting:`DOWNLOAD_SLOTS`.
Note, however, that AutoThrottle still determines the starting delay of every
slot by setting the ``download_delay`` attribute on the running spider. You
might want to set a custom value for the ``delay`` attribute of the slot, e.g.
using :setting:`DOWNLOAD_SLOTS`.
Throttling algorithm Throttling algorithm
==================== ====================
@ -131,7 +143,7 @@ AUTOTHROTTLE_TARGET_CONCURRENCY
Default: ``1.0`` Default: ``1.0``
Average number of requests Scrapy should be sending in parallel to remote Average number of requests Scrapy should be sending in parallel to remote
websites. websites. It must be higher than ``0.0``.
By default, AutoThrottle adjusts the delay to send a single By default, AutoThrottle adjusts the delay to send a single
concurrent request to each of the remote websites. Set this option to concurrent request to each of the remote websites. Set this option to

View File

@ -24,7 +24,8 @@ You should see an output like this::
'scrapy.extensions.telnet.TelnetConsole', 'scrapy.extensions.telnet.TelnetConsole',
'scrapy.extensions.corestats.CoreStats'] 'scrapy.extensions.corestats.CoreStats']
2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled downloader middlewares: 2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware', ['scrapy.downloadermiddlewares.offsite.OffsiteMiddleware',
'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware', 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware', 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware', 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
@ -37,7 +38,6 @@ You should see an output like this::
'scrapy.downloadermiddlewares.stats.DownloaderStats'] 'scrapy.downloadermiddlewares.stats.DownloaderStats']
2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled spider middlewares: 2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware', ['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
'scrapy.spidermiddlewares.referer.RefererMiddleware', 'scrapy.spidermiddlewares.referer.RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware', 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'scrapy.spidermiddlewares.depth.DepthMiddleware'] 'scrapy.spidermiddlewares.depth.DepthMiddleware']

View File

@ -116,7 +116,7 @@ Reduce log level
When doing broad crawls you are often only interested in the crawl rates you When doing broad crawls you are often only interested in the crawl rates you
get and any errors found. These stats are reported by Scrapy when using the get and any errors found. These stats are reported by Scrapy when using the
``INFO`` log level. In order to save CPU (and log storage requirements) you ``INFO`` log level. In order to save CPU (and log storage requirements) you
should not use ``DEBUG`` log level when preforming large broad crawls in should not use ``DEBUG`` log level when performing large broad crawls in
production. Using ``DEBUG`` level when developing your (broad) crawler may be production. Using ``DEBUG`` level when developing your (broad) crawler may be
fine though. fine though.

View File

@ -125,25 +125,15 @@ Fortunately, the :command:`shell` is your bread and butter in this case (see
See also: :ref:`topics-shell-inspect-response`. See also: :ref:`topics-shell-inspect-response`.
Open in browser Open in browser
=============== ===============
Sometimes you just want to see how a certain response looks in a browser, you Sometimes you just want to see how a certain response looks in a browser, you
can use the ``open_in_browser`` function for that. Here is an example of how can use the :func:`~scrapy.utils.response.open_in_browser` function for that:
you would use it:
.. code-block:: python .. autofunction:: scrapy.utils.response.open_in_browser
from scrapy.utils.response import open_in_browser
def parse_details(self, response):
if "item name" not in response.body:
open_in_browser(response)
``open_in_browser`` will open a browser with the response received by Scrapy at
that point, adjusting the `base tag`_ so that images and styles are displayed
properly.
Logging Logging
======= =======
@ -163,8 +153,6 @@ available in all future runs should they be necessary again:
For more information, check the :ref:`topics-logging` section. For more information, check the :ref:`topics-logging` section.
.. _base tag: https://www.w3schools.com/tags/tag_base.asp
.. _debug-vscode: .. _debug-vscode:
Visual Studio Code Visual Studio Code

View File

@ -763,6 +763,44 @@ HttpProxyMiddleware
Keep in mind this value will take precedence over ``http_proxy``/``https_proxy`` Keep in mind this value will take precedence over ``http_proxy``/``https_proxy``
environment variables, and it will also ignore ``no_proxy`` environment variable. environment variables, and it will also ignore ``no_proxy`` environment variable.
OffsiteMiddleware
-----------------
.. module:: scrapy.downloadermiddlewares.offsite
:synopsis: Offsite Middleware
.. class:: OffsiteMiddleware
.. versionadded:: 2.11.2
Filters out Requests for URLs outside the domains covered by the spider.
This middleware filters out every request whose host names aren't in the
spider's :attr:`~scrapy.Spider.allowed_domains` attribute.
All subdomains of any domain in the list are also allowed.
E.g. the rule ``www.example.org`` will also allow ``bob.www.example.org``
but not ``www2.example.com`` nor ``example.com``.
When your spider returns a request for a domain not belonging to those
covered by the spider, this middleware will log a debug message similar to
this one::
DEBUG: Filtered offsite request to 'offsite.example': <GET http://offsite.example/some/page.html>
To avoid filling the log with too much noise, it will only print one of
these messages for each new domain filtered. So, for example, if another
request for ``offsite.example`` is filtered, no log message will be
printed. But if a request for ``other.example`` is filtered, a message
will be printed (but only for the first request filtered).
If the spider doesn't define an
:attr:`~scrapy.Spider.allowed_domains` attribute, or the
attribute is empty, the offsite middleware will allow all requests.
If the request has the :attr:`~scrapy.Request.dont_filter` attribute
set, the offsite middleware will allow the request even if its domain is not
listed in allowed domains.
RedirectMiddleware RedirectMiddleware
------------------ ------------------
@ -882,7 +920,15 @@ Meta tags within these tags are ignored.
.. versionchanged:: 2.0 .. versionchanged:: 2.0
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
``['script', 'noscript']`` to ``[]``. ``["script", "noscript"]`` to ``[]``.
.. versionchanged:: 2.11.2
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
``[]`` to ``["noscript"]``.
.. versionchanged:: VERSION
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
``[]`` to ``['noscript']``.
.. setting:: METAREFRESH_MAXDELAY .. setting:: METAREFRESH_MAXDELAY

View File

@ -13,6 +13,11 @@ Scrapy provides this functionality out of the box with the Feed Exports, which
allows you to generate feeds with the scraped items, using multiple allows you to generate feeds with the scraped items, using multiple
serialization formats and storage backends. serialization formats and storage backends.
This page provides detailed documentation for all feed export features. If you
are looking for a step-by-step guide, check out `Zytes export guides`_.
.. _Zytes export guides: https://docs.zyte.com/web-scraping/guides/export/index.html#exporting-scraped-data
.. _topics-feed-format: .. _topics-feed-format:
Serialization formats Serialization formats
@ -385,7 +390,13 @@ Each plugin is a class that must implement the following methods:
.. method:: close(self) .. method:: close(self)
Close the target file object. Clean up the plugin.
For example, you might want to close a file wrapper that you might have
used to compress data written into the file received in the ``__init__``
method.
.. warning:: Do not close the file from the ``__init__`` method.
To pass a parameter to your plugin, use :ref:`feed options <feed-options>`. You To pass a parameter to your plugin, use :ref:`feed options <feed-options>`. You
can then access those parameters from the ``__init__`` method of your plugin. can then access those parameters from the ``__init__`` method of your plugin.

View File

@ -399,12 +399,7 @@ In code that receives an item, such as methods of :ref:`item pipelines
<topics-spider-middleware>`, it is a good practice to use the <topics-spider-middleware>`, it is a good practice to use the
:class:`~itemadapter.ItemAdapter` class and the :class:`~itemadapter.ItemAdapter` class and the
:func:`~itemadapter.is_item` function to write code that works for :func:`~itemadapter.is_item` function to write code that works for
any :ref:`supported item type <item-types>`: any supported item type.
.. autoclass:: itemadapter.ItemAdapter
.. autofunction:: itemadapter.is_item
Other classes related to items Other classes related to items
============================== ==============================

View File

@ -532,14 +532,14 @@ See here the methods that you can override in your custom Files Pipeline:
.. code-block:: python .. code-block:: python
from pathlib import PurePosixPath from pathlib import PurePosixPath
from urllib.parse import urlparse from scrapy.utils.httpobj import urlparse_cached
from scrapy.pipelines.files import FilesPipeline from scrapy.pipelines.files import FilesPipeline
class MyFilesPipeline(FilesPipeline): class MyFilesPipeline(FilesPipeline):
def file_path(self, request, response=None, info=None, *, item=None): def file_path(self, request, response=None, info=None, *, item=None):
return "files/" + PurePosixPath(urlparse(request.url).path).name return "files/" + PurePosixPath(urlparse_cached(request).path).name
Similarly, you can use the ``item`` to determine the file path based on some item Similarly, you can use the ``item`` to determine the file path based on some item
property. property.
@ -690,14 +690,14 @@ See here the methods that you can override in your custom Images Pipeline:
.. code-block:: python .. code-block:: python
from pathlib import PurePosixPath from pathlib import PurePosixPath
from urllib.parse import urlparse from scrapy.utils.httpobj import urlparse_cached
from scrapy.pipelines.images import ImagesPipeline from scrapy.pipelines.images import ImagesPipeline
class MyImagesPipeline(ImagesPipeline): class MyImagesPipeline(ImagesPipeline):
def file_path(self, request, response=None, info=None, *, item=None): def file_path(self, request, response=None, info=None, *, item=None):
return "files/" + PurePosixPath(urlparse(request.url).path).name return "files/" + PurePosixPath(urlparse_cached(request).path).name
Similarly, you can use the ``item`` to determine the file path based on some item Similarly, you can use the ``item`` to determine the file path based on some item
property. property.

View File

@ -289,7 +289,8 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a
super proxy that you can attach your own proxies to. super proxy that you can attach your own proxies to.
* use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy * use a ban avoidance service, such as `Zyte API`_, which provides a `Scrapy
plugin <https://github.com/scrapy-plugins/scrapy-zyte-api>`__ plugin <https://github.com/scrapy-plugins/scrapy-zyte-api>`__ and additional
features, like `AI web scraping <https://www.zyte.com/ai-web-scraping/>`__
If you are still unable to prevent your bot getting banned, consider contacting If you are still unable to prevent your bot getting banned, consider contacting
`commercial support`_. `commercial support`_.

View File

@ -94,13 +94,14 @@ Request objects
.. code-block:: python .. code-block:: python
request_with_cookies = Request( request_with_cookies = Request(
url="http://www.example.com", url="https://www.example.com",
cookies=[ cookies=[
{ {
"name": "currency", "name": "currency",
"value": "USD", "value": "USD",
"domain": "example.com", "domain": "example.com",
"path": "/currency", "path": "/currency",
"secure": True,
}, },
], ],
) )
@ -469,60 +470,6 @@ import path.
.. autoclass:: scrapy.utils.request.RequestFingerprinter .. autoclass:: scrapy.utils.request.RequestFingerprinter
.. setting:: REQUEST_FINGERPRINTER_IMPLEMENTATION
REQUEST_FINGERPRINTER_IMPLEMENTATION
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. versionadded:: 2.7
Default: ``'2.6'``
Determines which request fingerprinting algorithm is used by the default
request fingerprinter class (see :setting:`REQUEST_FINGERPRINTER_CLASS`).
Possible values are:
- ``'2.6'`` (default)
This implementation uses the same request fingerprinting algorithm as
Scrapy 2.6 and earlier versions.
Even though this is the default value for backward compatibility reasons,
it is a deprecated value.
- ``'2.7'``
This implementation was introduced in Scrapy 2.7 to fix an issue of the
previous implementation.
New projects should use this value. The :command:`startproject` command
sets this value in the generated ``settings.py`` file.
If you are using the default value (``'2.6'``) for this setting, and you are
using Scrapy components where changing the request fingerprinting algorithm
would cause undesired results, you need to carefully decide when to change the
value of this setting, or switch the :setting:`REQUEST_FINGERPRINTER_CLASS`
setting to a custom request fingerprinter class that implements the 2.6 request
fingerprinting algorithm and does not log this warning (
:ref:`2.6-request-fingerprinter` includes an example implementation of such a
class).
Scenarios where changing the request fingerprinting algorithm may cause
undesired results include, for example, using the HTTP cache middleware (see
:class:`~scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware`).
Changing the request fingerprinting algorithm would invalidate the current
cache, requiring you to redownload all requests again.
Otherwise, set :setting:`REQUEST_FINGERPRINTER_IMPLEMENTATION` to ``'2.7'`` in
your settings to switch already to the request fingerprinting implementation
that will be the only request fingerprinting implementation available in a
future version of Scrapy, and remove the deprecation warning triggered by using
the default value (``'2.6'``).
.. _2.6-request-fingerprinter:
.. _custom-request-fingerprinter: .. _custom-request-fingerprinter:
Writing your own request fingerprinter Writing your own request fingerprinter
@ -731,6 +678,7 @@ Those are:
* :reqmeta:`download_fail_on_dataloss` * :reqmeta:`download_fail_on_dataloss`
* :reqmeta:`download_latency` * :reqmeta:`download_latency`
* :reqmeta:`download_maxsize` * :reqmeta:`download_maxsize`
* :reqmeta:`download_warnsize`
* :reqmeta:`download_timeout` * :reqmeta:`download_timeout`
* ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info) * ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info)
* ``ftp_user`` (See :setting:`FTP_USER` for more info) * ``ftp_user`` (See :setting:`FTP_USER` for more info)
@ -1357,3 +1305,13 @@ XmlResponse objects
line. See :attr:`TextResponse.encoding`. line. See :attr:`TextResponse.encoding`.
.. _bug in lxml: https://bugs.launchpad.net/lxml/+bug/1665241 .. _bug in lxml: https://bugs.launchpad.net/lxml/+bug/1665241
JsonResponse objects
--------------------
.. class:: JsonResponse(url[, ...])
The :class:`JsonResponse` class is a subclass of :class:`TextResponse`
that is used when the response has a `JSON MIME type
<https://mimesniff.spec.whatwg.org/#json-mime-type>`_ in its `Content-Type`
header.

View File

@ -1032,10 +1032,8 @@ whereas the CSS lookup is translated into XPath and thus runs more efficiently,
so performance-wise its uses are limited to situations that are not easily so performance-wise its uses are limited to situations that are not easily
described with CSS selectors. described with CSS selectors.
Parsel also simplifies adding your own XPath extensions. Parsel also simplifies adding your own XPath extensions with
:func:`~parsel.xpathfuncs.set_xpathfunc`.
.. autofunction:: parsel.xpathfuncs.set_xpathfunc
.. _topics-selectors-ref: .. _topics-selectors-ref:

View File

@ -674,6 +674,7 @@ Default:
.. code-block:: python .. code-block:: python
{ {
"scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50,
"scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100, "scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100,
"scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300, "scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300,
"scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350, "scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350,
@ -835,7 +836,7 @@ The default HTTPS handler uses HTTP/1.1. To use HTTP/2:
.. setting:: DOWNLOAD_SLOTS .. setting:: DOWNLOAD_SLOTS
DOWNLOAD_SLOTS DOWNLOAD_SLOTS
---------------- --------------
Default: ``{}`` Default: ``{}``
@ -844,7 +845,12 @@ Allows to define concurrency/delay parameters on per slot (domain) basis:
.. code-block:: python .. code-block:: python
DOWNLOAD_SLOTS = { DOWNLOAD_SLOTS = {
"quotes.toscrape.com": {"concurrency": 1, "delay": 2, "randomize_delay": False}, "quotes.toscrape.com": {
"concurrency": 1,
"delay": 2,
"randomize_delay": False,
"throttle": False,
},
"books.toscrape.com": {"delay": 3, "randomize_delay": False}, "books.toscrape.com": {"delay": 3, "randomize_delay": False},
} }
@ -856,6 +862,9 @@ Allows to define concurrency/delay parameters on per slot (domain) basis:
- :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`: ``concurrency`` - :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`: ``concurrency``
- :setting:`RANDOMIZE_DOWNLOAD_DELAY`: ``randomize_delay`` - :setting:`RANDOMIZE_DOWNLOAD_DELAY`: ``randomize_delay``
There is no global setting for ``throttle``, whose default value is
``None``.
.. setting:: DOWNLOAD_TIMEOUT .. setting:: DOWNLOAD_TIMEOUT
@ -873,40 +882,42 @@ The amount of time (in secs) that the downloader will wait before timing out.
Request.meta key. Request.meta key.
.. setting:: DOWNLOAD_MAXSIZE .. setting:: DOWNLOAD_MAXSIZE
.. reqmeta:: download_maxsize
DOWNLOAD_MAXSIZE DOWNLOAD_MAXSIZE
---------------- ----------------
Default: ``1073741824`` (1024MB) Default: ``1073741824`` (1 GiB)
The maximum response size (in bytes) that downloader will download. The maximum response body size (in bytes) allowed. Bigger responses are
aborted and ignored.
If you want to disable it set to 0. This applies both before and after compression. If decompressing a response
body would exceed this limit, decompression is aborted and the response is
ignored.
.. reqmeta:: download_maxsize Use ``0`` to disable this limit.
.. note:: This limit can be set per spider using the :attr:`download_maxsize` spider
attribute and per request using the :reqmeta:`download_maxsize` Request.meta
This size can be set per spider using :attr:`download_maxsize` key.
spider attribute and per-request using :reqmeta:`download_maxsize`
Request.meta key.
.. setting:: DOWNLOAD_WARNSIZE .. setting:: DOWNLOAD_WARNSIZE
.. reqmeta:: download_warnsize
DOWNLOAD_WARNSIZE DOWNLOAD_WARNSIZE
----------------- -----------------
Default: ``33554432`` (32MB) Default: ``33554432`` (32 MiB)
The response size (in bytes) that downloader will start to warn. If the size of a response exceeds this value, before or after compression, a
warning will be logged about it.
If you want to disable it set to 0. Use ``0`` to disable this limit.
.. note:: This limit can be set per spider using the :attr:`download_warnsize` spider
attribute and per request using the :reqmeta:`download_warnsize` Request.meta
This size can be set per spider using :attr:`download_warnsize` key.
spider attribute and per-request using :reqmeta:`download_warnsize`
Request.meta key.
.. setting:: DOWNLOAD_FAIL_ON_DATALOSS .. setting:: DOWNLOAD_FAIL_ON_DATALOSS
@ -1603,7 +1614,6 @@ Default:
{ {
"scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50, "scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": 500,
"scrapy.spidermiddlewares.referer.RefererMiddleware": 700, "scrapy.spidermiddlewares.referer.RefererMiddleware": 700,
"scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800, "scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800,
"scrapy.spidermiddlewares.depth.DepthMiddleware": 900, "scrapy.spidermiddlewares.depth.DepthMiddleware": 900,

View File

@ -343,11 +343,18 @@ request_scheduled
.. signal:: request_scheduled .. signal:: request_scheduled
.. function:: request_scheduled(request, spider) .. function:: request_scheduled(request, spider)
Sent when the engine schedules a :class:`~scrapy.Request`, to be Sent when the engine is asked to schedule a :class:`~scrapy.Request`, to be
downloaded later. downloaded later, before the request reaches the :ref:`scheduler
<topics-scheduler>`.
Raise :exc:`~scrapy.exceptions.IgnoreRequest` to drop a request before it
reaches the scheduler.
This signal does not support returning deferreds from its handlers. This signal does not support returning deferreds from its handlers.
.. versionadded:: 2.11.2
Allow dropping requests with :exc:`~scrapy.exceptions.IgnoreRequest`.
:param request: the request that reached the scheduler :param request: the request that reached the scheduler
:type request: :class:`~scrapy.Request` object :type request: :class:`~scrapy.Request` object

View File

@ -51,8 +51,8 @@ value. For example, if you want to disable the off-site middleware:
.. code-block:: python .. code-block:: python
SPIDER_MIDDLEWARES = { SPIDER_MIDDLEWARES = {
"myproject.middlewares.CustomSpiderMiddleware": 543, "scrapy.spidermiddlewares.referer.RefererMiddleware": None,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None, "myproject.middlewares.CustomRefererSpiderMiddleware": 700,
} }
Finally, keep in mind that some middlewares may need to be enabled through a Finally, keep in mind that some middlewares may need to be enabled through a
@ -313,42 +313,6 @@ Default: ``False``
Pass all responses, regardless of its status code. Pass all responses, regardless of its status code.
OffsiteMiddleware
-----------------
.. module:: scrapy.spidermiddlewares.offsite
:synopsis: Offsite Spider Middleware
.. class:: OffsiteMiddleware
Filters out Requests for URLs outside the domains covered by the spider.
This middleware filters out every request whose host names aren't in the
spider's :attr:`~scrapy.Spider.allowed_domains` attribute.
All subdomains of any domain in the list are also allowed.
E.g. the rule ``www.example.org`` will also allow ``bob.www.example.org``
but not ``www2.example.com`` nor ``example.com``.
When your spider returns a request for a domain not belonging to those
covered by the spider, this middleware will log a debug message similar to
this one::
DEBUG: Filtered offsite request to 'www.othersite.com': <GET http://www.othersite.com/some/page.html>
To avoid filling the log with too much noise, it will only print one of
these messages for each new domain filtered. So, for example, if another
request for ``www.othersite.com`` is filtered, no log message will be
printed. But if a request for ``someothersite.com`` is filtered, a message
will be printed (but only for the first request filtered).
If the spider doesn't define an
:attr:`~scrapy.Spider.allowed_domains` attribute, or the
attribute is empty, the offsite middleware will allow all requests.
If the request has the :attr:`~scrapy.Request.dont_filter` attribute
set, the offsite middleware will allow the request even if its domain is not
listed in allowed domains.
RefererMiddleware RefererMiddleware
----------------- -----------------

View File

@ -75,7 +75,8 @@ scrapy.Spider
An optional list of strings containing domains that this spider is An optional list of strings containing domains that this spider is
allowed to crawl. Requests for URLs not belonging to the domain names allowed to crawl. Requests for URLs not belonging to the domain names
specified in this list (or their subdomains) won't be followed if specified in this list (or their subdomains) won't be followed if
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` is enabled. :class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` is
enabled.
Let's say your target url is ``https://www.example.com/1.html``, Let's say your target url is ``https://www.example.com/1.html``,
then add ``'example.com'`` to the list. then add ``'example.com'`` to the list.

View File

@ -172,8 +172,8 @@ TELNETCONSOLE_PORT
Default: ``[6023, 6073]`` Default: ``[6023, 6073]``
The port range to use for the telnet console. If set to ``None`` or ``0``, a The port range to use for the telnet console. If set to ``None``, a dynamically
dynamically assigned port is used. assigned port is used.
.. setting:: TELNETCONSOLE_HOST .. setting:: TELNETCONSOLE_HOST

View File

@ -4,21 +4,14 @@ jobs=1 # >1 hides results
[MESSAGES CONTROL] [MESSAGES CONTROL]
disable=abstract-method, disable=abstract-method,
anomalous-backslash-in-string,
arguments-differ, arguments-differ,
arguments-renamed, arguments-renamed,
attribute-defined-outside-init, attribute-defined-outside-init,
bad-classmethod-argument, bad-classmethod-argument,
bad-mcs-classmethod-argument,
bare-except, bare-except,
broad-except, broad-except,
broad-exception-raised, broad-exception-raised,
c-extension-no-member, c-extension-no-member,
catching-non-exception,
cell-var-from-loop,
comparison-with-callable,
consider-using-dict-items,
consider-using-in,
consider-using-with, consider-using-with,
cyclic-import, cyclic-import,
dangerous-default-value, dangerous-default-value,
@ -32,7 +25,6 @@ disable=abstract-method,
implicit-str-concat, implicit-str-concat,
import-error, import-error,
import-outside-toplevel, import-outside-toplevel,
import-self,
inconsistent-return-statements, inconsistent-return-statements,
inherit-non-class, inherit-non-class,
invalid-name, invalid-name,
@ -44,7 +36,6 @@ disable=abstract-method,
logging-fstring-interpolation, logging-fstring-interpolation,
logging-not-lazy, logging-not-lazy,
lost-exception, lost-exception,
method-hidden,
missing-docstring, missing-docstring,
no-else-raise, no-else-raise,
no-else-return, no-else-return,
@ -52,7 +43,7 @@ disable=abstract-method,
no-method-argument, no-method-argument,
no-name-in-module, no-name-in-module,
no-self-argument, no-self-argument,
no-value-for-parameter, no-value-for-parameter, # https://github.com/pylint-dev/pylint/issues/3268
not-callable, not-callable,
pointless-exception-statement, pointless-exception-statement,
pointless-statement, pointless-statement,
@ -77,23 +68,15 @@ disable=abstract-method,
too-many-public-methods, too-many-public-methods,
too-many-return-statements, too-many-return-statements,
unbalanced-tuple-unpacking, unbalanced-tuple-unpacking,
undefined-variable,
undefined-loop-variable,
unexpected-special-method-signature,
unnecessary-comprehension,
unnecessary-dunder-call, unnecessary-dunder-call,
unnecessary-pass, unnecessary-pass,
unreachable, unreachable,
unsubscriptable-object,
unused-argument, unused-argument,
unused-import, unused-import,
unused-private-member, unused-private-member,
unused-variable, unused-variable,
unused-wildcard-import, unused-wildcard-import,
use-dict-literal,
used-before-assignment, used-before-assignment,
useless-object-inheritance, # Required for Python 2 support
useless-return, useless-return,
useless-super-delegation,
wildcard-import, wildcard-import,
wrong-import-position wrong-import-position

View File

@ -1 +1 @@
2.11.0 2.11.2

View File

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, List
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.settings import Settings from scrapy.settings import Settings
from scrapy.utils.conf import build_component_list from scrapy.utils.conf import build_component_list
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
if TYPE_CHECKING: if TYPE_CHECKING:
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
@ -32,9 +32,7 @@ class AddonManager:
for clspath in build_component_list(settings["ADDONS"]): for clspath in build_component_list(settings["ADDONS"]):
try: try:
addoncls = load_object(clspath) addoncls = load_object(clspath)
addon = create_instance( addon = build_from_crawler(addoncls, self.crawler)
addoncls, settings=settings, crawler=self.crawler
)
addon.update_settings(settings) addon.update_settings(settings)
self.addons.append(addon) self.addons.append(addon)
except NotConfigured as e: except NotConfigured as e:

View File

@ -1,21 +1,33 @@
from __future__ import annotations
import argparse import argparse
import cProfile import cProfile
import inspect import inspect
import os import os
import sys import sys
from importlib.metadata import entry_points from importlib.metadata import entry_points
from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional, Tuple, Type
import scrapy import scrapy
from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter
from scrapy.crawler import CrawlerProcess from scrapy.crawler import CrawlerProcess
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.settings import BaseSettings, Settings
from scrapy.utils.misc import walk_modules from scrapy.utils.misc import walk_modules
from scrapy.utils.project import get_project_settings, inside_project from scrapy.utils.project import get_project_settings, inside_project
from scrapy.utils.python import garbage_collect from scrapy.utils.python import garbage_collect
if TYPE_CHECKING:
# typing.ParamSpec requires Python 3.10
from typing_extensions import ParamSpec
_P = ParamSpec("_P")
class ScrapyArgumentParser(argparse.ArgumentParser): class ScrapyArgumentParser(argparse.ArgumentParser):
def _parse_optional(self, arg_string): def _parse_optional(
self, arg_string: str
) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]:
# if starts with -: it means that is a parameter not a argument # if starts with -: it means that is a parameter not a argument
if arg_string[:2] == "-:": if arg_string[:2] == "-:":
return None return None
@ -23,7 +35,7 @@ class ScrapyArgumentParser(argparse.ArgumentParser):
return super()._parse_optional(arg_string) return super()._parse_optional(arg_string)
def _iter_command_classes(module_name): def _iter_command_classes(module_name: str) -> Iterable[Type[ScrapyCommand]]:
# TODO: add `name` attribute to commands and merge this function with # TODO: add `name` attribute to commands and merge this function with
# scrapy.utils.spider.iter_spider_classes # scrapy.utils.spider.iter_spider_classes
for module in walk_modules(module_name): for module in walk_modules(module_name):
@ -37,8 +49,8 @@ def _iter_command_classes(module_name):
yield obj yield obj
def _get_commands_from_module(module, inproject): def _get_commands_from_module(module: str, inproject: bool) -> Dict[str, ScrapyCommand]:
d = {} d: Dict[str, ScrapyCommand] = {}
for cmd in _iter_command_classes(module): for cmd in _iter_command_classes(module):
if inproject or not cmd.requires_project: if inproject or not cmd.requires_project:
cmdname = cmd.__module__.split(".")[-1] cmdname = cmd.__module__.split(".")[-1]
@ -46,8 +58,10 @@ def _get_commands_from_module(module, inproject):
return d return d
def _get_commands_from_entry_points(inproject, group="scrapy.commands"): def _get_commands_from_entry_points(
cmds = {} inproject: bool, group: str = "scrapy.commands"
) -> Dict[str, ScrapyCommand]:
cmds: Dict[str, ScrapyCommand] = {}
if sys.version_info >= (3, 10): if sys.version_info >= (3, 10):
eps = entry_points(group=group) eps = entry_points(group=group)
else: else:
@ -61,7 +75,9 @@ def _get_commands_from_entry_points(inproject, group="scrapy.commands"):
return cmds return cmds
def _get_commands_dict(settings, inproject): def _get_commands_dict(
settings: BaseSettings, inproject: bool
) -> Dict[str, ScrapyCommand]:
cmds = _get_commands_from_module("scrapy.commands", inproject) cmds = _get_commands_from_module("scrapy.commands", inproject)
cmds.update(_get_commands_from_entry_points(inproject)) cmds.update(_get_commands_from_entry_points(inproject))
cmds_module = settings["COMMANDS_MODULE"] cmds_module = settings["COMMANDS_MODULE"]
@ -70,16 +86,17 @@ def _get_commands_dict(settings, inproject):
return cmds return cmds
def _pop_command_name(argv): def _pop_command_name(argv: List[str]) -> Optional[str]:
i = 0 i = 0
for arg in argv[1:]: for arg in argv[1:]:
if not arg.startswith("-"): if not arg.startswith("-"):
del argv[i] del argv[i]
return arg return arg
i += 1 i += 1
return None
def _print_header(settings, inproject): def _print_header(settings: BaseSettings, inproject: bool) -> None:
version = scrapy.__version__ version = scrapy.__version__
if inproject: if inproject:
print(f"Scrapy {version} - active project: {settings['BOT_NAME']}\n") print(f"Scrapy {version} - active project: {settings['BOT_NAME']}\n")
@ -88,7 +105,7 @@ def _print_header(settings, inproject):
print(f"Scrapy {version} - no active project\n") print(f"Scrapy {version} - no active project\n")
def _print_commands(settings, inproject): def _print_commands(settings: BaseSettings, inproject: bool) -> None:
_print_header(settings, inproject) _print_header(settings, inproject)
print("Usage:") print("Usage:")
print(" scrapy <command> [options] [args]\n") print(" scrapy <command> [options] [args]\n")
@ -103,13 +120,20 @@ def _print_commands(settings, inproject):
print('Use "scrapy <command> -h" to see more info about a command') print('Use "scrapy <command> -h" to see more info about a command')
def _print_unknown_command(settings, cmdname, inproject): def _print_unknown_command(
settings: BaseSettings, cmdname: str, inproject: bool
) -> None:
_print_header(settings, inproject) _print_header(settings, inproject)
print(f"Unknown command: {cmdname}\n") print(f"Unknown command: {cmdname}\n")
print('Use "scrapy" to see available commands') print('Use "scrapy" to see available commands')
def _run_print_help(parser, func, *a, **kw): def _run_print_help(
parser: argparse.ArgumentParser,
func: Callable[_P, None],
*a: _P.args,
**kw: _P.kwargs,
) -> None:
try: try:
func(*a, **kw) func(*a, **kw)
except UsageError as e: except UsageError as e:
@ -120,7 +144,9 @@ def _run_print_help(parser, func, *a, **kw):
sys.exit(2) sys.exit(2)
def execute(argv=None, settings=None): def execute(
argv: Optional[List[str]] = None, settings: Optional[Settings] = None
) -> None:
if argv is None: if argv is None:
argv = sys.argv argv = sys.argv
@ -162,14 +188,16 @@ def execute(argv=None, settings=None):
sys.exit(cmd.exitcode) sys.exit(cmd.exitcode)
def _run_command(cmd, args, opts): def _run_command(cmd: ScrapyCommand, args: List[str], opts: argparse.Namespace) -> None:
if opts.profile: if opts.profile:
_run_command_profiled(cmd, args, opts) _run_command_profiled(cmd, args, opts)
else: else:
cmd.run(args, opts) cmd.run(args, opts)
def _run_command_profiled(cmd, args, opts): def _run_command_profiled(
cmd: ScrapyCommand, args: List[str], opts: argparse.Namespace
) -> None:
if opts.profile: if opts.profile:
sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n") sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n")
loc = locals() loc = locals()

View File

@ -1,62 +1,64 @@
""" """
Base class for Scrapy commands Base class for Scrapy commands
""" """
import argparse import argparse
import builtins
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, Iterable, List, Optional
from twisted.python import failure from twisted.python import failure
from scrapy.crawler import CrawlerProcess from scrapy.crawler import Crawler, CrawlerProcess
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli
class ScrapyCommand: class ScrapyCommand:
requires_project = False requires_project: bool = False
crawler_process: Optional[CrawlerProcess] = None crawler_process: Optional[CrawlerProcess] = None
# default settings to be used for this command instead of global defaults # default settings to be used for this command instead of global defaults
default_settings: Dict[str, Any] = {} default_settings: Dict[str, Any] = {}
exitcode = 0 exitcode: int = 0
def __init__(self) -> None: def __init__(self) -> None:
self.settings: Any = None # set in scrapy.cmdline self.settings: Any = None # set in scrapy.cmdline
def set_crawler(self, crawler): def set_crawler(self, crawler: Crawler) -> None:
if hasattr(self, "_crawler"): if hasattr(self, "_crawler"):
raise RuntimeError("crawler already set") raise RuntimeError("crawler already set")
self._crawler = crawler self._crawler: Crawler = crawler
def syntax(self): def syntax(self) -> str:
""" """
Command syntax (preferably one-line). Do not include command name. Command syntax (preferably one-line). Do not include command name.
""" """
return "" return ""
def short_desc(self): def short_desc(self) -> str:
""" """
A short description of the command A short description of the command
""" """
return "" return ""
def long_desc(self): def long_desc(self) -> str:
"""A long description of the command. Return short description when not """A long description of the command. Return short description when not
available. It cannot contain newlines since contents will be formatted available. It cannot contain newlines since contents will be formatted
by optparser which removes newlines and wraps text. by optparser which removes newlines and wraps text.
""" """
return self.short_desc() return self.short_desc()
def help(self): def help(self) -> str:
"""An extensive help for the command. It will be shown when using the """An extensive help for the command. It will be shown when using the
"help" command. It can contain newlines since no post-formatting will "help" command. It can contain newlines since no post-formatting will
be applied to its contents. be applied to its contents.
""" """
return self.long_desc() return self.long_desc()
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
""" """
Populate option parse with options available for this command Populate option parse with options available for this command
""" """
@ -91,7 +93,7 @@ class ScrapyCommand:
) )
group.add_argument("--pdb", action="store_true", help="enable pdb on failure") group.add_argument("--pdb", action="store_true", help="enable pdb on failure")
def process_options(self, args, opts): def process_options(self, args: List[str], opts: argparse.Namespace) -> None:
try: try:
self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline") self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline")
except ValueError: except ValueError:
@ -128,8 +130,8 @@ class BaseRunSpiderCommand(ScrapyCommand):
Common class used to share functionality between the crawl, parse and runspider commands Common class used to share functionality between the crawl, parse and runspider commands
""" """
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"-a", "-a",
dest="spargs", dest="spargs",
@ -161,8 +163,8 @@ class BaseRunSpiderCommand(ScrapyCommand):
help="format to use for dumping items", help="format to use for dumping items",
) )
def process_options(self, args, opts): def process_options(self, args: List[str], opts: argparse.Namespace) -> None:
ScrapyCommand.process_options(self, args, opts) super().process_options(args, opts)
try: try:
opts.spargs = arglist_to_dict(opts.spargs) opts.spargs = arglist_to_dict(opts.spargs)
except ValueError: except ValueError:
@ -182,7 +184,13 @@ class ScrapyHelpFormatter(argparse.HelpFormatter):
Help Formatter for scrapy command line help messages. Help Formatter for scrapy command line help messages.
""" """
def __init__(self, prog, indent_increment=2, max_help_position=24, width=None): def __init__(
self,
prog: str,
indent_increment: int = 2,
max_help_position: int = 24,
width: Optional[int] = None,
):
super().__init__( super().__init__(
prog, prog,
indent_increment=indent_increment, indent_increment=indent_increment,
@ -190,11 +198,12 @@ class ScrapyHelpFormatter(argparse.HelpFormatter):
width=width, width=width,
) )
def _join_parts(self, part_strings): def _join_parts(self, part_strings: Iterable[str]) -> str:
parts = self.format_part_strings(part_strings) # scrapy.commands.list shadows builtins.list
parts = self.format_part_strings(builtins.list(part_strings))
return super()._join_parts(parts) return super()._join_parts(parts)
def format_part_strings(self, part_strings): def format_part_strings(self, part_strings: List[str]) -> List[str]:
""" """
Underline and title case command line help message headers. Underline and title case command line help message headers.
""" """

View File

@ -1,10 +1,14 @@
import subprocess import argparse
import subprocess # nosec
import sys import sys
import time import time
from typing import Any, Iterable, List
from urllib.parse import urlencode from urllib.parse import urlencode
import scrapy import scrapy
from scrapy import Request
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.http import Response, TextResponse
from scrapy.linkextractors import LinkExtractor from scrapy.linkextractors import LinkExtractor
@ -15,24 +19,28 @@ class Command(ScrapyCommand):
"CLOSESPIDER_TIMEOUT": 10, "CLOSESPIDER_TIMEOUT": 10,
} }
def short_desc(self): def short_desc(self) -> str:
return "Run quick benchmark test" return "Run quick benchmark test"
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
with _BenchServer(): with _BenchServer():
assert self.crawler_process
self.crawler_process.crawl(_BenchSpider, total=100000) self.crawler_process.crawl(_BenchSpider, total=100000)
self.crawler_process.start() self.crawler_process.start()
class _BenchServer: class _BenchServer:
def __enter__(self): def __enter__(self) -> None:
from scrapy.utils.test import get_testenv from scrapy.utils.test import get_testenv
pargs = [sys.executable, "-u", "-m", "scrapy.utils.benchserver"] pargs = [sys.executable, "-u", "-m", "scrapy.utils.benchserver"]
self.proc = subprocess.Popen(pargs, stdout=subprocess.PIPE, env=get_testenv()) self.proc = subprocess.Popen(
pargs, stdout=subprocess.PIPE, env=get_testenv()
) # nosec
assert self.proc.stdout
self.proc.stdout.readline() self.proc.stdout.readline()
def __exit__(self, exc_type, exc_value, traceback): def __exit__(self, exc_type, exc_value, traceback) -> None:
self.proc.kill() self.proc.kill()
self.proc.wait() self.proc.wait()
time.sleep(0.2) time.sleep(0.2)
@ -47,11 +55,12 @@ class _BenchSpider(scrapy.Spider):
baseurl = "http://localhost:8998" baseurl = "http://localhost:8998"
link_extractor = LinkExtractor() link_extractor = LinkExtractor()
def start_requests(self): def start_requests(self) -> Iterable[Request]:
qargs = {"total": self.total, "show": self.show} qargs = {"total": self.total, "show": self.show}
url = f"{self.baseurl}?{urlencode(qargs, doseq=True)}" url = f"{self.baseurl}?{urlencode(qargs, doseq=True)}"
return [scrapy.Request(url, dont_filter=True)] return [scrapy.Request(url, dont_filter=True)]
def parse(self, response): def parse(self, response: Response) -> Any:
assert isinstance(Response, TextResponse)
for link in self.link_extractor.extract_links(response): for link in self.link_extractor.extract_links(response):
yield scrapy.Request(link.url, callback=self.parse) yield scrapy.Request(link.url, callback=self.parse)

View File

@ -1,5 +1,7 @@
import argparse
import time import time
from collections import defaultdict from collections import defaultdict
from typing import List
from unittest import TextTestResult as _TextTestResult from unittest import TextTestResult as _TextTestResult
from unittest import TextTestRunner from unittest import TextTestRunner
@ -10,9 +12,10 @@ from scrapy.utils.misc import load_object, set_environ
class TextTestResult(_TextTestResult): class TextTestResult(_TextTestResult):
def printSummary(self, start, stop): def printSummary(self, start: float, stop: float) -> None:
write = self.stream.write write = self.stream.write
writeln = self.stream.writeln # _WritelnDecorator isn't implemented in typeshed yet
writeln = self.stream.writeln # type: ignore[attr-defined]
run = self.testsRun run = self.testsRun
plural = "s" if run != 1 else "" plural = "s" if run != 1 else ""
@ -42,14 +45,14 @@ class Command(ScrapyCommand):
requires_project = True requires_project = True
default_settings = {"LOG_ENABLED": False} default_settings = {"LOG_ENABLED": False}
def syntax(self): def syntax(self) -> str:
return "[options] <spider>" return "[options] <spider>"
def short_desc(self): def short_desc(self) -> str:
return "Check spider contracts" return "Check spider contracts"
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"-l", "-l",
"--list", "--list",
@ -66,7 +69,7 @@ class Command(ScrapyCommand):
help="print contract tests for all spiders", help="print contract tests for all spiders",
) )
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
# load contracts # load contracts
contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS")) contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS"))
conman = ContractsManager(load_object(c) for c in contracts) conman = ContractsManager(load_object(c) for c in contracts)
@ -76,6 +79,7 @@ class Command(ScrapyCommand):
# contract requests # contract requests
contract_reqs = defaultdict(list) contract_reqs = defaultdict(list)
assert self.crawler_process
spider_loader = self.crawler_process.spider_loader spider_loader = self.crawler_process.spider_loader
with set_environ(SCRAPY_CHECK="true"): with set_environ(SCRAPY_CHECK="true"):

View File

@ -1,3 +1,8 @@
import argparse
from typing import List, cast
from twisted.python.failure import Failure
from scrapy.commands import BaseRunSpiderCommand from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
@ -5,13 +10,13 @@ from scrapy.exceptions import UsageError
class Command(BaseRunSpiderCommand): class Command(BaseRunSpiderCommand):
requires_project = True requires_project = True
def syntax(self): def syntax(self) -> str:
return "[options] <spider>" return "[options] <spider>"
def short_desc(self): def short_desc(self) -> str:
return "Run a spider" return "Run a spider"
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
if len(args) < 1: if len(args) < 1:
raise UsageError() raise UsageError()
elif len(args) > 1: elif len(args) > 1:
@ -20,10 +25,11 @@ class Command(BaseRunSpiderCommand):
) )
spname = args[0] spname = args[0]
assert self.crawler_process
crawl_defer = self.crawler_process.crawl(spname, **opts.spargs) crawl_defer = self.crawler_process.crawl(spname, **opts.spargs)
if getattr(crawl_defer, "result", None) is not None and issubclass( if getattr(crawl_defer, "result", None) is not None and issubclass(
crawl_defer.result.type, Exception cast(Failure, crawl_defer.result).type, Exception
): ):
self.exitcode = 1 self.exitcode = 1
else: else:

View File

@ -1,5 +1,7 @@
import argparse
import os import os
import sys import sys
from typing import List
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
@ -9,32 +11,34 @@ class Command(ScrapyCommand):
requires_project = True requires_project = True
default_settings = {"LOG_ENABLED": False} default_settings = {"LOG_ENABLED": False}
def syntax(self): def syntax(self) -> str:
return "<spider>" return "<spider>"
def short_desc(self): def short_desc(self) -> str:
return "Edit spider" return "Edit spider"
def long_desc(self): def long_desc(self) -> str:
return ( return (
"Edit a spider using the editor defined in the EDITOR environment" "Edit a spider using the editor defined in the EDITOR environment"
" variable or else the EDITOR setting" " variable or else the EDITOR setting"
) )
def _err(self, msg): def _err(self, msg: str) -> None:
sys.stderr.write(msg + os.linesep) sys.stderr.write(msg + os.linesep)
self.exitcode = 1 self.exitcode = 1
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
if len(args) != 1: if len(args) != 1:
raise UsageError() raise UsageError()
editor = self.settings["EDITOR"] editor = self.settings["EDITOR"]
assert self.crawler_process
try: try:
spidercls = self.crawler_process.spider_loader.load(args[0]) spidercls = self.crawler_process.spider_loader.load(args[0])
except KeyError: except KeyError:
return self._err(f"Spider not found: {args[0]}") return self._err(f"Spider not found: {args[0]}")
sfile = sys.modules[spidercls.__module__].__file__ sfile = sys.modules[spidercls.__module__].__file__
assert sfile
sfile = sfile.replace(".pyc", ".py") sfile = sfile.replace(".pyc", ".py")
self.exitcode = os.system(f'{editor} "{sfile}"') self.exitcode = os.system(f'{editor} "{sfile}"') # nosec

View File

@ -1,13 +1,13 @@
import sys import sys
from argparse import Namespace from argparse import ArgumentParser, Namespace
from typing import List, Type from typing import Dict, List, Type
from w3lib.url import is_url from w3lib.url import is_url
from scrapy import Spider from scrapy import Spider
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.http import Request from scrapy.http import Request, Response
from scrapy.utils.datatypes import SequenceExclude from scrapy.utils.datatypes import SequenceExclude
from scrapy.utils.spider import DefaultSpider, spidercls_for_request from scrapy.utils.spider import DefaultSpider, spidercls_for_request
@ -15,20 +15,20 @@ from scrapy.utils.spider import DefaultSpider, spidercls_for_request
class Command(ScrapyCommand): class Command(ScrapyCommand):
requires_project = False requires_project = False
def syntax(self): def syntax(self) -> str:
return "[options] <url>" return "[options] <url>"
def short_desc(self): def short_desc(self) -> str:
return "Fetch a URL using the Scrapy downloader" return "Fetch a URL using the Scrapy downloader"
def long_desc(self): def long_desc(self) -> str:
return ( return (
"Fetch a URL using the Scrapy downloader and print its content" "Fetch a URL using the Scrapy downloader and print its content"
" to stdout. You may want to use --nolog to disable logging" " to stdout. You may want to use --nolog to disable logging"
) )
def add_options(self, parser): def add_options(self, parser: ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument("--spider", dest="spider", help="use this spider") parser.add_argument("--spider", dest="spider", help="use this spider")
parser.add_argument( parser.add_argument(
"--headers", "--headers",
@ -44,20 +44,21 @@ class Command(ScrapyCommand):
help="do not handle HTTP 3xx status codes and print response as-is", help="do not handle HTTP 3xx status codes and print response as-is",
) )
def _print_headers(self, headers, prefix): def _print_headers(self, headers: Dict[bytes, List[bytes]], prefix: bytes) -> None:
for key, values in headers.items(): for key, values in headers.items():
for value in values: for value in values:
self._print_bytes(prefix + b" " + key + b": " + value) self._print_bytes(prefix + b" " + key + b": " + value)
def _print_response(self, response, opts): def _print_response(self, response: Response, opts: Namespace) -> None:
if opts.headers: if opts.headers:
assert response.request
self._print_headers(response.request.headers, b">") self._print_headers(response.request.headers, b">")
print(">") print(">")
self._print_headers(response.headers, b"<") self._print_headers(response.headers, b"<")
else: else:
self._print_bytes(response.body) self._print_bytes(response.body)
def _print_bytes(self, bytes_): def _print_bytes(self, bytes_: bytes) -> None:
sys.stdout.buffer.write(bytes_ + b"\n") sys.stdout.buffer.write(bytes_ + b"\n")
def run(self, args: List[str], opts: Namespace) -> None: def run(self, args: List[str], opts: Namespace) -> None:

View File

@ -1,9 +1,10 @@
import argparse
import os import os
import shutil import shutil
import string import string
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import Optional, cast from typing import List, Optional, Union, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import scrapy import scrapy
@ -12,7 +13,7 @@ from scrapy.exceptions import UsageError
from scrapy.utils.template import render_templatefile, string_camelcase from scrapy.utils.template import render_templatefile, string_camelcase
def sanitize_module_name(module_name): def sanitize_module_name(module_name: str) -> str:
"""Sanitize the given module name, by replacing dashes and points """Sanitize the given module name, by replacing dashes and points
with underscores and prefixing it with a letter if it doesn't start with underscores and prefixing it with a letter if it doesn't start
with one with one
@ -23,7 +24,7 @@ def sanitize_module_name(module_name):
return module_name return module_name
def extract_domain(url): def extract_domain(url: str) -> str:
"""Extract domain name from URL string""" """Extract domain name from URL string"""
o = urlparse(url) o = urlparse(url)
if o.scheme == "" and o.netloc == "": if o.scheme == "" and o.netloc == "":
@ -31,7 +32,7 @@ def extract_domain(url):
return o.netloc return o.netloc
def verify_url_scheme(url): def verify_url_scheme(url: str) -> str:
"""Check url for scheme and insert https if none found.""" """Check url for scheme and insert https if none found."""
parsed = urlparse(url) parsed = urlparse(url)
if parsed.scheme == "" and parsed.netloc == "": if parsed.scheme == "" and parsed.netloc == "":
@ -43,14 +44,14 @@ class Command(ScrapyCommand):
requires_project = False requires_project = False
default_settings = {"LOG_ENABLED": False} default_settings = {"LOG_ENABLED": False}
def syntax(self): def syntax(self) -> str:
return "[options] <name> <domain>" return "[options] <name> <domain>"
def short_desc(self): def short_desc(self) -> str:
return "Generate new spider using pre-defined templates" return "Generate new spider using pre-defined templates"
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"-l", "-l",
"--list", "--list",
@ -86,7 +87,7 @@ class Command(ScrapyCommand):
help="If the spider already exists, overwrite it with the template", help="If the spider already exists, overwrite it with the template",
) )
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
if opts.list: if opts.list:
self._list_templates() self._list_templates()
return return
@ -113,9 +114,16 @@ class Command(ScrapyCommand):
if template_file: if template_file:
self._genspider(module, name, url, opts.template, template_file) self._genspider(module, name, url, opts.template, template_file)
if opts.edit: if opts.edit:
self.exitcode = os.system(f'scrapy edit "{name}"') self.exitcode = os.system(f'scrapy edit "{name}"') # nosec
def _genspider(self, module, name, url, template_name, template_file): def _genspider(
self,
module: str,
name: str,
url: str,
template_name: str,
template_file: Union[str, os.PathLike],
) -> None:
"""Generate the spider module, based on the given template""" """Generate the spider module, based on the given template"""
capitalized_module = "".join(s.capitalize() for s in module.split("_")) capitalized_module = "".join(s.capitalize() for s in module.split("_"))
domain = extract_domain(url) domain = extract_domain(url)
@ -130,6 +138,7 @@ class Command(ScrapyCommand):
} }
if self.settings.get("NEWSPIDER_MODULE"): if self.settings.get("NEWSPIDER_MODULE"):
spiders_module = import_module(self.settings["NEWSPIDER_MODULE"]) spiders_module = import_module(self.settings["NEWSPIDER_MODULE"])
assert spiders_module.__file__
spiders_dir = Path(spiders_module.__file__).parent.resolve() spiders_dir = Path(spiders_module.__file__).parent.resolve()
else: else:
spiders_module = None spiders_module = None
@ -152,7 +161,7 @@ class Command(ScrapyCommand):
print('Use "scrapy genspider --list" to see all available templates.') print('Use "scrapy genspider --list" to see all available templates.')
return None return None
def _list_templates(self): def _list_templates(self) -> None:
print("Available templates:") print("Available templates:")
for file in sorted(Path(self.templates_dir).iterdir()): for file in sorted(Path(self.templates_dir).iterdir()):
if file.suffix == ".tmpl": if file.suffix == ".tmpl":

View File

@ -1,3 +1,6 @@
import argparse
from typing import List
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
@ -5,9 +8,10 @@ class Command(ScrapyCommand):
requires_project = True requires_project = True
default_settings = {"LOG_ENABLED": False} default_settings = {"LOG_ENABLED": False}
def short_desc(self): def short_desc(self) -> str:
return "List available spiders" return "List available spiders"
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
assert self.crawler_process
for s in sorted(self.crawler_process.spider_loader.list()): for s in sorted(self.crawler_process.spider_loader.list()):
print(s) print(s)

View File

@ -1,15 +1,32 @@
import argparse
import functools
import inspect import inspect
import json import json
import logging import logging
from typing import Dict from types import CoroutineType
from typing import (
Any,
AsyncGenerator,
Callable,
Dict,
Iterable,
List,
Optional,
Tuple,
TypeVar,
Union,
overload,
)
from itemadapter import ItemAdapter, is_item from itemadapter import ItemAdapter, is_item
from twisted.internet.defer import maybeDeferred from twisted.internet.defer import Deferred, maybeDeferred
from twisted.python.failure import Failure
from w3lib.url import is_url from w3lib.url import is_url
from scrapy.commands import BaseRunSpiderCommand from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.http import Request from scrapy.http import Request, Response
from scrapy.spiders import Spider
from scrapy.utils import display from scrapy.utils import display
from scrapy.utils.asyncgen import collect_asyncgen from scrapy.utils.asyncgen import collect_asyncgen
from scrapy.utils.defer import aiter_errback, deferred_from_coro from scrapy.utils.defer import aiter_errback, deferred_from_coro
@ -19,24 +36,26 @@ from scrapy.utils.spider import spidercls_for_request
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_T = TypeVar("_T")
class Command(BaseRunSpiderCommand): class Command(BaseRunSpiderCommand):
requires_project = True requires_project = True
spider = None spider = None
items: Dict[int, list] = {} items: Dict[int, List[Any]] = {}
requests: Dict[int, list] = {} requests: Dict[int, List[Request]] = {}
first_response = None first_response = None
def syntax(self): def syntax(self) -> str:
return "[options] <url>" return "[options] <url>"
def short_desc(self): def short_desc(self) -> str:
return "Parse URL (using its spider) and print the results" return "Parse URL (using its spider) and print the results"
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
BaseRunSpiderCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"--spider", "--spider",
dest="spider", dest="spider",
@ -105,7 +124,7 @@ class Command(BaseRunSpiderCommand):
) )
@property @property
def max_level(self): def max_level(self) -> int:
max_items, max_requests = 0, 0 max_items, max_requests = 0, 0
if self.items: if self.items:
max_items = max(self.items) max_items = max(self.items)
@ -113,13 +132,21 @@ class Command(BaseRunSpiderCommand):
max_requests = max(self.requests) max_requests = max(self.requests)
return max(max_items, max_requests) return max(max_items, max_requests)
def handle_exception(self, _failure): def handle_exception(self, _failure: Failure) -> None:
logger.error( logger.error(
"An error is caught while iterating the async iterable", "An error is caught while iterating the async iterable",
exc_info=failure_to_exc_info(_failure), exc_info=failure_to_exc_info(_failure),
) )
def iterate_spider_output(self, result): @overload
def iterate_spider_output(
self, result: Union[AsyncGenerator, CoroutineType]
) -> Deferred: ...
@overload
def iterate_spider_output(self, result: _T) -> Iterable: ...
def iterate_spider_output(self, result: Any) -> Union[Iterable, Deferred]:
if inspect.isasyncgen(result): if inspect.isasyncgen(result):
d = deferred_from_coro( d = deferred_from_coro(
collect_asyncgen(aiter_errback(result, self.handle_exception)) collect_asyncgen(aiter_errback(result, self.handle_exception))
@ -132,15 +159,15 @@ class Command(BaseRunSpiderCommand):
return d return d
return arg_to_iter(deferred_from_coro(result)) return arg_to_iter(deferred_from_coro(result))
def add_items(self, lvl, new_items): def add_items(self, lvl: int, new_items: List[Any]) -> None:
old_items = self.items.get(lvl, []) old_items = self.items.get(lvl, [])
self.items[lvl] = old_items + new_items self.items[lvl] = old_items + new_items
def add_requests(self, lvl, new_reqs): def add_requests(self, lvl: int, new_reqs: List[Request]) -> None:
old_reqs = self.requests.get(lvl, []) old_reqs = self.requests.get(lvl, [])
self.requests[lvl] = old_reqs + new_reqs self.requests[lvl] = old_reqs + new_reqs
def print_items(self, lvl=None, colour=True): def print_items(self, lvl: Optional[int] = None, colour: bool = True) -> None:
if lvl is None: if lvl is None:
items = [item for lst in self.items.values() for item in lst] items = [item for lst in self.items.values() for item in lst]
else: else:
@ -149,7 +176,7 @@ class Command(BaseRunSpiderCommand):
print("# Scraped Items ", "-" * 60) print("# Scraped Items ", "-" * 60)
display.pprint([ItemAdapter(x).asdict() for x in items], colorize=colour) display.pprint([ItemAdapter(x).asdict() for x in items], colorize=colour)
def print_requests(self, lvl=None, colour=True): def print_requests(self, lvl: Optional[int] = None, colour: bool = True) -> None:
if lvl is None: if lvl is None:
if self.requests: if self.requests:
requests = self.requests[max(self.requests)] requests = self.requests[max(self.requests)]
@ -161,7 +188,7 @@ class Command(BaseRunSpiderCommand):
print("# Requests ", "-" * 65) print("# Requests ", "-" * 65)
display.pprint(requests, colorize=colour) display.pprint(requests, colorize=colour)
def print_results(self, opts): def print_results(self, opts: argparse.Namespace) -> None:
colour = not opts.nocolour colour = not opts.nocolour
if opts.verbose: if opts.verbose:
@ -178,7 +205,14 @@ class Command(BaseRunSpiderCommand):
if not opts.nolinks: if not opts.nolinks:
self.print_requests(colour=colour) self.print_requests(colour=colour)
def _get_items_and_requests(self, spider_output, opts, depth, spider, callback): def _get_items_and_requests(
self,
spider_output: Iterable[Any],
opts: argparse.Namespace,
depth: int,
spider: Spider,
callback: Callable,
) -> Tuple[List[Any], List[Request], argparse.Namespace, int, Spider, Callable]:
items, requests = [], [] items, requests = [], []
for x in spider_output: for x in spider_output:
if is_item(x): if is_item(x):
@ -187,14 +221,21 @@ class Command(BaseRunSpiderCommand):
requests.append(x) requests.append(x)
return items, requests, opts, depth, spider, callback return items, requests, opts, depth, spider, callback
def run_callback(self, response, callback, cb_kwargs=None): def run_callback(
self,
response: Response,
callback: Callable,
cb_kwargs: Optional[Dict[str, Any]] = None,
) -> Deferred:
cb_kwargs = cb_kwargs or {} cb_kwargs = cb_kwargs or {}
d = maybeDeferred(self.iterate_spider_output, callback(response, **cb_kwargs)) d = maybeDeferred(self.iterate_spider_output, callback(response, **cb_kwargs))
return d return d
def get_callback_from_rules(self, spider, response): def get_callback_from_rules(
self, spider: Spider, response: Response
) -> Union[Callable, str, None]:
if getattr(spider, "rules", None): if getattr(spider, "rules", None):
for rule in spider.rules: for rule in spider.rules: # type: ignore[attr-defined]
if rule.link_extractor.matches(response.url): if rule.link_extractor.matches(response.url):
return rule.callback or "parse" return rule.callback or "parse"
else: else:
@ -203,8 +244,10 @@ class Command(BaseRunSpiderCommand):
"please specify a callback to use for parsing", "please specify a callback to use for parsing",
{"spider": spider.name}, {"spider": spider.name},
) )
return None
def set_spidercls(self, url, opts): def set_spidercls(self, url: str, opts: argparse.Namespace) -> None:
assert self.crawler_process
spider_loader = self.crawler_process.spider_loader spider_loader = self.crawler_process.spider_loader
if opts.spider: if opts.spider:
try: try:
@ -218,13 +261,14 @@ class Command(BaseRunSpiderCommand):
if not self.spidercls: if not self.spidercls:
logger.error("Unable to find spider for: %(url)s", {"url": url}) logger.error("Unable to find spider for: %(url)s", {"url": url})
def _start_requests(spider): def _start_requests(spider: Spider) -> Iterable[Request]:
yield self.prepare_request(spider, Request(url), opts) yield self.prepare_request(spider, Request(url), opts)
if self.spidercls: if self.spidercls:
self.spidercls.start_requests = _start_requests self.spidercls.start_requests = _start_requests
def start_parsing(self, url, opts): def start_parsing(self, url: str, opts: argparse.Namespace) -> None:
assert self.crawler_process
self.crawler_process.crawl(self.spidercls, **opts.spargs) self.crawler_process.crawl(self.spidercls, **opts.spargs)
self.pcrawler = list(self.crawler_process.crawlers)[0] self.pcrawler = list(self.crawler_process.crawlers)[0]
self.crawler_process.start() self.crawler_process.start()
@ -232,7 +276,12 @@ class Command(BaseRunSpiderCommand):
if not self.first_response: if not self.first_response:
logger.error("No response downloaded for: %(url)s", {"url": url}) logger.error("No response downloaded for: %(url)s", {"url": url})
def scraped_data(self, args): def scraped_data(
self,
args: Tuple[
List[Any], List[Request], argparse.Namespace, int, Spider, Callable
],
) -> List[Any]:
items, requests, opts, depth, spider, callback = args items, requests, opts, depth, spider, callback = args
if opts.pipelines: if opts.pipelines:
itemproc = self.pcrawler.engine.scraper.itemproc itemproc = self.pcrawler.engine.scraper.itemproc
@ -251,42 +300,53 @@ class Command(BaseRunSpiderCommand):
return scraped_data return scraped_data
def prepare_request(self, spider, request, opts): def _get_callback(
def callback(response, **cb_kwargs): self,
*,
spider: Spider,
opts: argparse.Namespace,
response: Optional[Response] = None,
) -> Callable:
cb: Union[str, Callable, None] = None
if response:
cb = response.meta["_callback"]
if not cb:
if opts.callback:
cb = opts.callback
elif response and opts.rules and self.first_response == response:
cb = self.get_callback_from_rules(spider, response)
if not cb:
raise ValueError(
f"Cannot find a rule that matches {response.url!r} in spider: "
f"{spider.name}"
)
else:
cb = "parse"
if not callable(cb):
assert cb is not None
cb_method = getattr(spider, cb, None)
if callable(cb_method):
cb = cb_method
else:
raise ValueError(
f"Cannot find callback {cb!r} in spider: {spider.name}"
)
assert callable(cb)
return cb
def prepare_request(
self, spider: Spider, request: Request, opts: argparse.Namespace
) -> Request:
def callback(response: Response, **cb_kwargs: Any) -> Deferred:
# memorize first request # memorize first request
if not self.first_response: if not self.first_response:
self.first_response = response self.first_response = response
# determine real callback cb = self._get_callback(spider=spider, opts=opts, response=response)
cb = response.meta["_callback"]
if not cb:
if opts.callback:
cb = opts.callback
elif opts.rules and self.first_response == response:
cb = self.get_callback_from_rules(spider, response)
if not cb:
logger.error(
"Cannot find a rule that matches %(url)r in spider: %(spider)s",
{"url": response.url, "spider": spider.name},
)
return
else:
cb = "parse"
if not callable(cb):
cb_method = getattr(spider, cb, None)
if callable(cb_method):
cb = cb_method
else:
logger.error(
"Cannot find callback %(callback)r in spider: %(spider)s",
{"callback": cb, "spider": spider.name},
)
return
# parse items and requests # parse items and requests
depth = response.meta["_depth"] depth: int = response.meta["_depth"]
d = self.run_callback(response, cb, cb_kwargs) d = self.run_callback(response, cb, cb_kwargs)
d.addCallback(self._get_items_and_requests, opts, depth, spider, callback) d.addCallback(self._get_items_and_requests, opts, depth, spider, callback)
@ -303,16 +363,19 @@ class Command(BaseRunSpiderCommand):
request.meta["_depth"] = 1 request.meta["_depth"] = 1
request.meta["_callback"] = request.callback request.meta["_callback"] = request.callback
if not request.callback and not opts.rules:
cb = self._get_callback(spider=spider, opts=opts)
functools.update_wrapper(callback, cb)
request.callback = callback request.callback = callback
return request return request
def process_options(self, args, opts): def process_options(self, args: List[str], opts: argparse.Namespace) -> None:
BaseRunSpiderCommand.process_options(self, args, opts) super().process_options(args, opts)
self.process_request_meta(opts) self.process_request_meta(opts)
self.process_request_cb_kwargs(opts) self.process_request_cb_kwargs(opts)
def process_request_meta(self, opts): def process_request_meta(self, opts: argparse.Namespace) -> None:
if opts.meta: if opts.meta:
try: try:
opts.meta = json.loads(opts.meta) opts.meta = json.loads(opts.meta)
@ -323,7 +386,7 @@ class Command(BaseRunSpiderCommand):
print_help=False, print_help=False,
) )
def process_request_cb_kwargs(self, opts): def process_request_cb_kwargs(self, opts: argparse.Namespace) -> None:
if opts.cbkwargs: if opts.cbkwargs:
try: try:
opts.cbkwargs = json.loads(opts.cbkwargs) opts.cbkwargs = json.loads(opts.cbkwargs)
@ -334,7 +397,7 @@ class Command(BaseRunSpiderCommand):
print_help=False, print_help=False,
) )
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
# parse arguments # parse arguments
if not len(args) == 1 or not is_url(args[0]): if not len(args) == 1 or not is_url(args[0]):
raise UsageError() raise UsageError()

View File

@ -1,9 +1,10 @@
import argparse
import sys import sys
from importlib import import_module from importlib import import_module
from os import PathLike from os import PathLike
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Union from typing import List, Union
from scrapy.commands import BaseRunSpiderCommand from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
@ -27,16 +28,16 @@ class Command(BaseRunSpiderCommand):
requires_project = False requires_project = False
default_settings = {"SPIDER_LOADER_WARN_ONLY": True} default_settings = {"SPIDER_LOADER_WARN_ONLY": True}
def syntax(self): def syntax(self) -> str:
return "[options] <spider_file>" return "[options] <spider_file>"
def short_desc(self): def short_desc(self) -> str:
return "Run a self-contained spider (without creating a project)" return "Run a self-contained spider (without creating a project)"
def long_desc(self): def long_desc(self) -> str:
return "Run the spider defined in the given file" return "Run the spider defined in the given file"
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
if len(args) != 1: if len(args) != 1:
raise UsageError() raise UsageError()
filename = Path(args[0]) filename = Path(args[0])
@ -51,6 +52,7 @@ class Command(BaseRunSpiderCommand):
raise UsageError(f"No spider found in file: {filename}\n") raise UsageError(f"No spider found in file: {filename}\n")
spidercls = spclasses.pop() spidercls = spclasses.pop()
assert self.crawler_process
self.crawler_process.crawl(spidercls, **opts.spargs) self.crawler_process.crawl(spidercls, **opts.spargs)
self.crawler_process.start() self.crawler_process.start()

View File

@ -1,4 +1,6 @@
import argparse
import json import json
from typing import List
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
@ -8,14 +10,14 @@ class Command(ScrapyCommand):
requires_project = False requires_project = False
default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True} default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True}
def syntax(self): def syntax(self) -> str:
return "[options]" return "[options]"
def short_desc(self): def short_desc(self) -> str:
return "Get settings values" return "Get settings values"
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"--get", dest="get", metavar="SETTING", help="print raw setting value" "--get", dest="get", metavar="SETTING", help="print raw setting value"
) )
@ -44,7 +46,8 @@ class Command(ScrapyCommand):
help="print setting value, interpreted as a list", help="print setting value, interpreted as a list",
) )
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
assert self.crawler_process
settings = self.crawler_process.settings settings = self.crawler_process.settings
if opts.get: if opts.get:
s = settings.get(opts.get) s = settings.get(opts.get)

View File

@ -3,9 +3,10 @@ Scrapy Shell
See documentation in docs/topics/shell.rst See documentation in docs/topics/shell.rst
""" """
from argparse import Namespace
from argparse import ArgumentParser, Namespace
from threading import Thread from threading import Thread
from typing import List, Type from typing import Any, Dict, List, Type
from scrapy import Spider from scrapy import Spider
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
@ -23,20 +24,20 @@ class Command(ScrapyCommand):
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter", "DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
} }
def syntax(self): def syntax(self) -> str:
return "[url|file]" return "[url|file]"
def short_desc(self): def short_desc(self) -> str:
return "Interactive scraping console" return "Interactive scraping console"
def long_desc(self): def long_desc(self) -> str:
return ( return (
"Interactive console for scraping the given url or file. " "Interactive console for scraping the given url or file. "
"Use ./file.html syntax or full path for local file." "Use ./file.html syntax or full path for local file."
) )
def add_options(self, parser): def add_options(self, parser: ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"-c", "-c",
dest="code", dest="code",
@ -51,7 +52,7 @@ class Command(ScrapyCommand):
help="do not handle HTTP 3xx status codes and print response as-is", help="do not handle HTTP 3xx status codes and print response as-is",
) )
def update_vars(self, vars): def update_vars(self, vars: Dict[str, Any]) -> None:
"""You can use this function to update the Scrapy objects that will be """You can use this function to update the Scrapy objects that will be
available in the shell available in the shell
""" """
@ -87,7 +88,8 @@ class Command(ScrapyCommand):
shell = Shell(crawler, update_vars=self.update_vars, code=opts.code) shell = Shell(crawler, update_vars=self.update_vars, code=opts.code)
shell.start(url=url, redirect=not opts.no_redirect) shell.start(url=url, redirect=not opts.no_redirect)
def _start_crawler_thread(self): def _start_crawler_thread(self) -> None:
assert self.crawler_process
t = Thread( t = Thread(
target=self.crawler_process.start, target=self.crawler_process.start,
kwargs={"stop_after_crawl": False, "install_signal_handlers": False}, kwargs={"stop_after_crawl": False, "install_signal_handlers": False},

View File

@ -1,3 +1,4 @@
import argparse
import os import os
import re import re
import string import string
@ -5,13 +6,14 @@ from importlib.util import find_spec
from pathlib import Path from pathlib import Path
from shutil import copy2, copystat, ignore_patterns, move from shutil import copy2, copystat, ignore_patterns, move
from stat import S_IWUSR as OWNER_WRITE_PERMISSION from stat import S_IWUSR as OWNER_WRITE_PERMISSION
from typing import List, Tuple, Union
import scrapy import scrapy
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError from scrapy.exceptions import UsageError
from scrapy.utils.template import render_templatefile, string_camelcase from scrapy.utils.template import render_templatefile, string_camelcase
TEMPLATES_TO_RENDER = ( TEMPLATES_TO_RENDER: Tuple[Tuple[str, ...], ...] = (
("scrapy.cfg",), ("scrapy.cfg",),
("${project_name}", "settings.py.tmpl"), ("${project_name}", "settings.py.tmpl"),
("${project_name}", "items.py.tmpl"), ("${project_name}", "items.py.tmpl"),
@ -22,7 +24,7 @@ TEMPLATES_TO_RENDER = (
IGNORE = ignore_patterns("*.pyc", "__pycache__", ".svn") IGNORE = ignore_patterns("*.pyc", "__pycache__", ".svn")
def _make_writable(path): def _make_writable(path: Union[str, os.PathLike]) -> None:
current_permissions = os.stat(path).st_mode current_permissions = os.stat(path).st_mode
os.chmod(path, current_permissions | OWNER_WRITE_PERMISSION) os.chmod(path, current_permissions | OWNER_WRITE_PERMISSION)
@ -31,14 +33,14 @@ class Command(ScrapyCommand):
requires_project = False requires_project = False
default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True} default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True}
def syntax(self): def syntax(self) -> str:
return "<project_name> [project_dir]" return "<project_name> [project_dir]"
def short_desc(self): def short_desc(self) -> str:
return "Create new project" return "Create new project"
def _is_valid_name(self, project_name): def _is_valid_name(self, project_name: str) -> bool:
def _module_exists(module_name): def _module_exists(module_name: str) -> bool:
spec = find_spec(module_name) spec = find_spec(module_name)
return spec is not None and spec.loader is not None return spec is not None and spec.loader is not None
@ -53,7 +55,7 @@ class Command(ScrapyCommand):
return True return True
return False return False
def _copytree(self, src: Path, dst: Path): def _copytree(self, src: Path, dst: Path) -> None:
""" """
Since the original function always creates the directory, to resolve Since the original function always creates the directory, to resolve
the issue a new function had to be created. It's a simple copy and the issue a new function had to be created. It's a simple copy and
@ -84,7 +86,7 @@ class Command(ScrapyCommand):
copystat(src, dst) copystat(src, dst)
_make_writable(dst) _make_writable(dst)
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
if len(args) not in (1, 2): if len(args) not in (1, 2):
raise UsageError() raise UsageError()
@ -105,7 +107,9 @@ class Command(ScrapyCommand):
return return
self._copytree(Path(self.templates_dir), project_dir.resolve()) self._copytree(Path(self.templates_dir), project_dir.resolve())
move(project_dir / "module", project_dir / project_name) # On 3.8 shutil.move doesn't fully support Path args, but it supports our use case
# See https://bugs.python.org/issue32689
move(project_dir / "module", project_dir / project_name) # type: ignore[arg-type]
for paths in TEMPLATES_TO_RENDER: for paths in TEMPLATES_TO_RENDER:
tplfile = Path( tplfile = Path(
project_dir, project_dir,

View File

@ -1,3 +1,6 @@
import argparse
from typing import List
import scrapy import scrapy
from scrapy.commands import ScrapyCommand from scrapy.commands import ScrapyCommand
from scrapy.utils.versions import scrapy_components_versions from scrapy.utils.versions import scrapy_components_versions
@ -6,14 +9,14 @@ from scrapy.utils.versions import scrapy_components_versions
class Command(ScrapyCommand): class Command(ScrapyCommand):
default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True} default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True}
def syntax(self): def syntax(self) -> str:
return "[-v]" return "[-v]"
def short_desc(self): def short_desc(self) -> str:
return "Print Scrapy version" return "Print Scrapy version"
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
ScrapyCommand.add_options(self, parser) super().add_options(parser)
parser.add_argument( parser.add_argument(
"--verbose", "--verbose",
"-v", "-v",
@ -22,7 +25,7 @@ class Command(ScrapyCommand):
help="also display twisted/python/platform info (useful for bug reports)", help="also display twisted/python/platform info (useful for bug reports)",
) )
def run(self, args, opts): def run(self, args: List[str], opts: argparse.Namespace) -> None:
if opts.verbose: if opts.verbose:
versions = scrapy_components_versions() versions = scrapy_components_versions()
width = max(len(n) for (n, _) in versions) width = max(len(n) for (n, _) in versions)

View File

@ -1,21 +1,28 @@
import argparse import argparse
import logging
from scrapy.commands import fetch from scrapy.commands import fetch
from scrapy.http import Response, TextResponse
from scrapy.utils.response import open_in_browser from scrapy.utils.response import open_in_browser
logger = logging.getLogger(__name__)
class Command(fetch.Command): class Command(fetch.Command):
def short_desc(self): def short_desc(self) -> str:
return "Open URL in browser, as seen by Scrapy" return "Open URL in browser, as seen by Scrapy"
def long_desc(self): def long_desc(self) -> str:
return ( return (
"Fetch a URL using the Scrapy downloader and show its contents in a browser" "Fetch a URL using the Scrapy downloader and show its contents in a browser"
) )
def add_options(self, parser): def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser) super().add_options(parser)
parser.add_argument("--headers", help=argparse.SUPPRESS) parser.add_argument("--headers", help=argparse.SUPPRESS)
def _print_response(self, response, opts): def _print_response(self, response: Response, opts: argparse.Namespace) -> None:
if not isinstance(response, TextResponse):
logger.error("Cannot view a non-text response.")
return
open_in_browser(response) open_in_browser(response)

View File

@ -1,8 +1,9 @@
import random import random
import warnings
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
from time import time from time import time
from typing import TYPE_CHECKING, Any, Deque, Dict, Set, Tuple, cast from typing import TYPE_CHECKING, Any, Deque, Dict, Optional, Set, Tuple, cast
from twisted.internet import task from twisted.internet import task
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
@ -10,6 +11,7 @@ from twisted.internet.defer import Deferred
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
from scrapy.core.downloader.handlers import DownloadHandlers from scrapy.core.downloader.handlers import DownloadHandlers
from scrapy.core.downloader.middleware import DownloaderMiddlewareManager from scrapy.core.downloader.middleware import DownloaderMiddlewareManager
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.http import Response from scrapy.http import Response
from scrapy.resolver import dnscache from scrapy.resolver import dnscache
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
@ -24,10 +26,18 @@ if TYPE_CHECKING:
class Slot: class Slot:
"""Downloader slot""" """Downloader slot"""
def __init__(self, concurrency: int, delay: float, randomize_delay: bool): def __init__(
self,
concurrency: int,
delay: float,
randomize_delay: bool,
*,
throttle: Optional[bool] = None,
):
self.concurrency: int = concurrency self.concurrency: int = concurrency
self.delay: float = delay self.delay: float = delay
self.randomize_delay: bool = randomize_delay self.randomize_delay: bool = randomize_delay
self.throttle = throttle
self.active: Set[Request] = set() self.active: Set[Request] = set()
self.queue: Deque[Tuple[Request, Deferred]] = deque() self.queue: Deque[Tuple[Request, Deferred]] = deque()
@ -40,7 +50,7 @@ class Slot:
def download_delay(self) -> float: def download_delay(self) -> float:
if self.randomize_delay: if self.randomize_delay:
return random.uniform(0.5 * self.delay, 1.5 * self.delay) return random.uniform(0.5 * self.delay, 1.5 * self.delay) # nosec
return self.delay return self.delay
def close(self) -> None: def close(self) -> None:
@ -52,13 +62,15 @@ class Slot:
return ( return (
f"{cls_name}(concurrency={self.concurrency!r}, " f"{cls_name}(concurrency={self.concurrency!r}, "
f"delay={self.delay:.2f}, " f"delay={self.delay:.2f}, "
f"randomize_delay={self.randomize_delay!r})" f"randomize_delay={self.randomize_delay!r}, "
f"throttle={self.throttle!r})"
) )
def __str__(self) -> str: def __str__(self) -> str:
return ( return (
f"<downloader.Slot concurrency={self.concurrency!r} " f"<downloader.Slot concurrency={self.concurrency!r} "
f"delay={self.delay:.2f} randomize_delay={self.randomize_delay!r} " f"delay={self.delay:.2f} randomize_delay={self.randomize_delay!r} "
f"throttle={self.throttle!r} "
f"len(active)={len(self.active)} len(queue)={len(self.queue)} " f"len(active)={len(self.active)} len(queue)={len(self.queue)} "
f"len(transferring)={len(self.transferring)} " f"len(transferring)={len(self.transferring)} "
f"lastseen={datetime.fromtimestamp(self.lastseen).isoformat()}>" f"lastseen={datetime.fromtimestamp(self.lastseen).isoformat()}>"
@ -115,7 +127,7 @@ class Downloader:
return len(self.active) >= self.total_concurrency return len(self.active) >= self.total_concurrency
def _get_slot(self, request: Request, spider: Spider) -> Tuple[str, Slot]: def _get_slot(self, request: Request, spider: Spider) -> Tuple[str, Slot]:
key = self._get_slot_key(request, spider) key = self.get_slot_key(request)
if key not in self.slots: if key not in self.slots:
slot_settings = self.per_slot_settings.get(key, {}) slot_settings = self.per_slot_settings.get(key, {})
conc = ( conc = (
@ -127,12 +139,13 @@ class Downloader:
slot_settings.get("delay", delay), slot_settings.get("delay", delay),
) )
randomize_delay = slot_settings.get("randomize_delay", self.randomize_delay) randomize_delay = slot_settings.get("randomize_delay", self.randomize_delay)
new_slot = Slot(conc, delay, randomize_delay) throttle = slot_settings.get("throttle", None)
new_slot = Slot(conc, delay, randomize_delay, throttle=throttle)
self.slots[key] = new_slot self.slots[key] = new_slot
return key, self.slots[key] return key, self.slots[key]
def _get_slot_key(self, request: Request, spider: Spider) -> str: def get_slot_key(self, request: Request) -> str:
if self.DOWNLOAD_SLOT in request.meta: if self.DOWNLOAD_SLOT in request.meta:
return cast(str, request.meta[self.DOWNLOAD_SLOT]) return cast(str, request.meta[self.DOWNLOAD_SLOT])
@ -142,6 +155,14 @@ class Downloader:
return key return key
def _get_slot_key(self, request: Request, spider: Optional[Spider]) -> str:
warnings.warn(
"Use of this protected method is deprecated. Consider using its corresponding public method get_slot_key() instead.",
ScrapyDeprecationWarning,
stacklevel=2,
)
return self.get_slot_key(request)
def _enqueue_request(self, request: Request, spider: Spider) -> Deferred: def _enqueue_request(self, request: Request, spider: Spider) -> Deferred:
key, slot = self._get_slot(request, spider) key, slot = self._get_slot(request, spider)
request.meta[self.DOWNLOAD_SLOT] = key request.meta[self.DOWNLOAD_SLOT] = key

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import warnings import warnings
from typing import TYPE_CHECKING, Any, List, Optional from typing import TYPE_CHECKING, Any, List, Optional
@ -19,12 +21,16 @@ from scrapy.core.downloader.tls import (
ScrapyClientTLSOptions, ScrapyClientTLSOptions,
openssl_methods, openssl_methods,
) )
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings from scrapy.settings import BaseSettings
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
if TYPE_CHECKING: if TYPE_CHECKING:
from twisted.internet._sslverify import ClientTLSOptions from twisted.internet._sslverify import ClientTLSOptions
# typing.Self requires Python 3.11
from typing_extensions import Self
@implementer(IPolicyForHTTPS) @implementer(IPolicyForHTTPS)
class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS): class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
@ -62,7 +68,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
method: int = SSL.SSLv23_METHOD, method: int = SSL.SSLv23_METHOD,
*args: Any, *args: Any,
**kwargs: Any, **kwargs: Any,
): ) -> Self:
tls_verbose_logging: bool = settings.getbool( tls_verbose_logging: bool = settings.getbool(
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING" "DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
) )
@ -97,7 +103,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
# kept for old-style HTTP/1.0 downloader context twisted calls, # kept for old-style HTTP/1.0 downloader context twisted calls,
# e.g. connectSSL() # e.g. connectSSL()
def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context: def getContext(self, hostname: Any = None, port: Any = None) -> SSL.Context:
ctx = self.getCertificateOptions().getContext() ctx: SSL.Context = self.getCertificateOptions().getContext()
ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT ctx.set_options(0x4) # OP_LEGACY_SERVER_CONNECT
return ctx return ctx
@ -160,23 +166,23 @@ class AcceptableProtocolsContextFactory:
return options return options
def load_context_factory_from_settings(settings, crawler): def load_context_factory_from_settings(
settings: BaseSettings, crawler: Crawler
) -> IPolicyForHTTPS:
ssl_method = openssl_methods[settings.get("DOWNLOADER_CLIENT_TLS_METHOD")] ssl_method = openssl_methods[settings.get("DOWNLOADER_CLIENT_TLS_METHOD")]
context_factory_cls = load_object(settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]) context_factory_cls = load_object(settings["DOWNLOADER_CLIENTCONTEXTFACTORY"])
# try method-aware context factory # try method-aware context factory
try: try:
context_factory = create_instance( context_factory = build_from_crawler(
objcls=context_factory_cls, context_factory_cls,
settings=settings, crawler,
crawler=crawler,
method=ssl_method, method=ssl_method,
) )
except TypeError: except TypeError:
# use context factory defaults # use context factory defaults
context_factory = create_instance( context_factory = build_from_crawler(
objcls=context_factory_cls, context_factory_cls,
settings=settings, crawler,
crawler=crawler,
) )
msg = ( msg = (
f"{settings['DOWNLOADER_CLIENTCONTEXTFACTORY']} does not accept " f"{settings['DOWNLOADER_CLIENTCONTEXTFACTORY']} does not accept "

View File

@ -9,7 +9,7 @@ from twisted.internet.defer import Deferred
from scrapy import Request, Spider, signals from scrapy import Request, Spider, signals
from scrapy.exceptions import NotConfigured, NotSupported from scrapy.exceptions import NotConfigured, NotSupported
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import without_none_values from scrapy.utils.python import without_none_values
if TYPE_CHECKING: if TYPE_CHECKING:
@ -21,9 +21,9 @@ logger = logging.getLogger(__name__)
class DownloadHandlers: class DownloadHandlers:
def __init__(self, crawler: "Crawler"): def __init__(self, crawler: "Crawler"):
self._crawler: "Crawler" = crawler self._crawler: "Crawler" = crawler
self._schemes: Dict[ self._schemes: Dict[str, Union[str, Callable]] = (
str, Union[str, Callable] {}
] = {} # stores acceptable schemes on instancing ) # stores acceptable schemes on instancing
self._handlers: Dict[str, Any] = {} # stores instanced handlers for schemes self._handlers: Dict[str, Any] = {} # stores instanced handlers for schemes
self._notconfigured: Dict[str, str] = {} # remembers failed handlers self._notconfigured: Dict[str, str] = {} # remembers failed handlers
handlers: Dict[str, Union[str, Callable]] = without_none_values( handlers: Dict[str, Union[str, Callable]] = without_none_values(
@ -55,10 +55,9 @@ class DownloadHandlers:
dhcls = load_object(path) dhcls = load_object(path)
if skip_lazy and getattr(dhcls, "lazy", True): if skip_lazy and getattr(dhcls, "lazy", True):
return None return None
dh = create_instance( dh = build_from_crawler(
objcls=dhcls, dhcls,
settings=self._crawler.settings, self._crawler,
crawler=self._crawler,
) )
except NotConfigured as ex: except NotConfigured as ex:
self._notconfigured[scheme] = str(ex) self._notconfigured[scheme] = str(ex)

View File

@ -2,6 +2,8 @@ from pathlib import Path
from w3lib.url import file_uri_to_path from w3lib.url import file_uri_to_path
from scrapy import Request, Spider
from scrapy.http import Response
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers from scrapy.utils.decorators import defers
@ -10,7 +12,7 @@ class FileDownloadHandler:
lazy = False lazy = False
@defers @defers
def download_request(self, request, spider): def download_request(self, request: Request, spider: Spider) -> Response:
filepath = file_uri_to_path(request.url) filepath = file_uri_to_path(request.url)
body = Path(filepath).read_bytes() body = Path(filepath).read_bytes()
respcls = responsetypes.from_args(filename=filepath, body=body) respcls = responsetypes.from_args(filename=filepath, body=body)

View File

@ -28,34 +28,46 @@ In case of status 200 request, response.headers will come with two keys:
'Size' - with size of the downloaded data 'Size' - with size of the downloaded data
""" """
from __future__ import annotations
import re import re
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, Optional
from urllib.parse import unquote from urllib.parse import unquote
from twisted.internet.defer import Deferred
from twisted.internet.protocol import ClientCreator, Protocol from twisted.internet.protocol import ClientCreator, Protocol
from twisted.protocols.ftp import CommandFailed, FTPClient from twisted.protocols.ftp import CommandFailed, FTPClient
from twisted.python.failure import Failure
from scrapy import Request, Spider
from scrapy.crawler import Crawler
from scrapy.http import Response from scrapy.http import Response
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.settings import BaseSettings
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes from scrapy.utils.python import to_bytes
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
class ReceivedDataProtocol(Protocol): class ReceivedDataProtocol(Protocol):
def __init__(self, filename=None): def __init__(self, filename: Optional[str] = None):
self.__filename = filename self.__filename: Optional[str] = filename
self.body = open(filename, "wb") if filename else BytesIO() self.body: BinaryIO = open(filename, "wb") if filename else BytesIO()
self.size = 0 self.size: int = 0
def dataReceived(self, data): def dataReceived(self, data: bytes) -> None:
self.body.write(data) self.body.write(data)
self.size += len(data) self.size += len(data)
@property @property
def filename(self): def filename(self) -> Optional[str]:
return self.__filename return self.__filename
def close(self): def close(self) -> None:
self.body.close() if self.filename else self.body.seek(0) self.body.close() if self.filename else self.body.seek(0)
@ -65,21 +77,21 @@ _CODE_RE = re.compile(r"\d+")
class FTPDownloadHandler: class FTPDownloadHandler:
lazy = False lazy = False
CODE_MAPPING = { CODE_MAPPING: Dict[str, int] = {
"550": 404, "550": 404,
"default": 503, "default": 503,
} }
def __init__(self, settings): def __init__(self, settings: BaseSettings):
self.default_user = settings["FTP_USER"] self.default_user = settings["FTP_USER"]
self.default_password = settings["FTP_PASSWORD"] self.default_password = settings["FTP_PASSWORD"]
self.passive_mode = settings["FTP_PASSIVE_MODE"] self.passive_mode = settings["FTP_PASSIVE_MODE"]
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings) return cls(crawler.settings)
def download_request(self, request, spider): def download_request(self, request: Request, spider: Spider) -> Deferred:
from twisted.internet import reactor from twisted.internet import reactor
parsed_url = urlparse_cached(request) parsed_url = urlparse_cached(request)
@ -91,28 +103,29 @@ class FTPDownloadHandler:
creator = ClientCreator( creator = ClientCreator(
reactor, FTPClient, user, password, passive=passive_mode reactor, FTPClient, user, password, passive=passive_mode
) )
dfd = creator.connectTCP(parsed_url.hostname, parsed_url.port or 21) dfd: Deferred = creator.connectTCP(parsed_url.hostname, parsed_url.port or 21)
return dfd.addCallback(self.gotClient, request, unquote(parsed_url.path)) return dfd.addCallback(self.gotClient, request, unquote(parsed_url.path))
def gotClient(self, client, request, filepath): def gotClient(self, client: FTPClient, request: Request, filepath: str) -> Deferred:
self.client = client self.client = client
protocol = ReceivedDataProtocol(request.meta.get("ftp_local_filename")) protocol = ReceivedDataProtocol(request.meta.get("ftp_local_filename"))
return client.retrieveFile(filepath, protocol).addCallbacks( d = client.retrieveFile(filepath, protocol)
callback=self._build_response, d.addCallback(self._build_response, request, protocol)
callbackArgs=(request, protocol), d.addErrback(self._failed, request)
errback=self._failed, return d
errbackArgs=(request,),
)
def _build_response(self, result, request, protocol): def _build_response(
self, result: Any, request: Request, protocol: ReceivedDataProtocol
) -> Response:
self.result = result self.result = result
protocol.close() protocol.close()
headers = {"local filename": protocol.filename or "", "size": protocol.size} headers = {"local filename": protocol.filename or "", "size": protocol.size}
body = to_bytes(protocol.filename or protocol.body.read()) body = to_bytes(protocol.filename or protocol.body.read())
respcls = responsetypes.from_args(url=request.url, body=body) respcls = responsetypes.from_args(url=request.url, body=body)
return respcls(url=request.url, status=200, body=body, headers=headers) # hints for Headers-related types may need to be fixed to not use AnyStr
return respcls(url=request.url, status=200, body=body, headers=headers) # type: ignore[arg-type]
def _failed(self, result, request): def _failed(self, result: Failure, request: Request) -> Response:
message = result.getErrorMessage() message = result.getErrorMessage()
if result.type == CommandFailed: if result.type == CommandFailed:
m = _CODE_RE.search(message) m = _CODE_RE.search(message)
@ -122,4 +135,5 @@ class FTPDownloadHandler:
return Response( return Response(
url=request.url, status=httpcode, body=to_bytes(message) url=request.url, status=httpcode, body=to_bytes(message)
) )
assert result.type
raise result.type(result.value) raise result.type(result.value)

View File

@ -1,39 +1,57 @@
"""Download handlers for http and https schemes """Download handlers for http and https schemes
""" """
from scrapy.utils.misc import create_instance, load_object
from __future__ import annotations
from typing import TYPE_CHECKING, Type
from twisted.internet.defer import Deferred
from scrapy import Request, Spider
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import to_unicode from scrapy.utils.python import to_unicode
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory
from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory
class HTTP10DownloadHandler: class HTTP10DownloadHandler:
lazy = False lazy = False
def __init__(self, settings, crawler=None): def __init__(self, settings: BaseSettings, crawler: Crawler):
self.HTTPClientFactory = load_object(settings["DOWNLOADER_HTTPCLIENTFACTORY"]) self.HTTPClientFactory: Type[ScrapyHTTPClientFactory] = load_object(
self.ClientContextFactory = load_object( settings["DOWNLOADER_HTTPCLIENTFACTORY"]
)
self.ClientContextFactory: Type[ScrapyClientContextFactory] = load_object(
settings["DOWNLOADER_CLIENTCONTEXTFACTORY"] settings["DOWNLOADER_CLIENTCONTEXTFACTORY"]
) )
self._settings = settings self._settings: BaseSettings = settings
self._crawler = crawler self._crawler: Crawler = crawler
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler) return cls(crawler.settings, crawler)
def download_request(self, request, spider): def download_request(self, request: Request, spider: Spider) -> Deferred:
"""Return a deferred for the HTTP download""" """Return a deferred for the HTTP download"""
factory = self.HTTPClientFactory(request) factory = self.HTTPClientFactory(request)
self._connect(factory) self._connect(factory)
return factory.deferred return factory.deferred
def _connect(self, factory): def _connect(self, factory: ScrapyHTTPClientFactory) -> Deferred:
from twisted.internet import reactor from twisted.internet import reactor
host, port = to_unicode(factory.host), factory.port host, port = to_unicode(factory.host), factory.port
if factory.scheme == b"https": if factory.scheme == b"https":
client_context_factory = create_instance( client_context_factory = build_from_crawler(
objcls=self.ClientContextFactory, self.ClientContextFactory,
settings=self._settings, self._crawler,
crawler=self._crawler,
) )
return reactor.connectSSL(host, port, factory, client_context_factory) return reactor.connectSSL(host, port, factory, client_context_factory)
return reactor.connectTCP(host, port, factory) return reactor.connectTCP(host, port, factory)

View File

@ -1,65 +1,77 @@
"""Download handlers for http and https schemes""" """Download handlers for http and https schemes"""
from __future__ import annotations
import ipaddress import ipaddress
import logging import logging
import re import re
from contextlib import suppress from contextlib import suppress
from io import BytesIO from io import BytesIO
from time import time from time import time
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
from urllib.parse import urldefrag, urlunparse from urllib.parse import urldefrag, urlunparse
from twisted.internet import defer, protocol, ssl from twisted.internet import ssl
from twisted.internet.base import ReactorBase
from twisted.internet.defer import CancelledError, Deferred, succeed
from twisted.internet.endpoints import TCP4ClientEndpoint from twisted.internet.endpoints import TCP4ClientEndpoint
from twisted.internet.error import TimeoutError from twisted.internet.error import TimeoutError
from twisted.internet.interfaces import IConsumer
from twisted.internet.protocol import Factory, Protocol, connectionDone
from twisted.python.failure import Failure from twisted.python.failure import Failure
from twisted.web.client import ( from twisted.web.client import URI, Agent, HTTPConnectionPool
URI, from twisted.web.client import Response as TxResponse
Agent, from twisted.web.client import ResponseDone, ResponseFailed
HTTPConnectionPool,
ResponseDone,
ResponseFailed,
)
from twisted.web.http import PotentialDataLoss, _DataLoss from twisted.web.http import PotentialDataLoss, _DataLoss
from twisted.web.http_headers import Headers as TxHeaders from twisted.web.http_headers import Headers as TxHeaders
from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer from twisted.web.iweb import UNKNOWN_LENGTH, IBodyProducer, IPolicyForHTTPS
from zope.interface import implementer from zope.interface import implementer
from scrapy import signals from scrapy import Request, Spider, signals
from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.contextfactory import load_context_factory_from_settings
from scrapy.core.downloader.webclient import _parse from scrapy.core.downloader.webclient import _parse
from scrapy.crawler import Crawler
from scrapy.exceptions import StopDownload from scrapy.exceptions import StopDownload
from scrapy.http import Headers from scrapy.http import Headers, Response
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.settings import BaseSettings
from scrapy.utils.python import to_bytes, to_unicode from scrapy.utils.python import to_bytes, to_unicode
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class HTTP11DownloadHandler: class HTTP11DownloadHandler:
lazy = False lazy = False
def __init__(self, settings, crawler=None): def __init__(self, settings: BaseSettings, crawler: Crawler):
self._crawler = crawler self._crawler = crawler
from twisted.internet import reactor from twisted.internet import reactor
self._pool = HTTPConnectionPool(reactor, persistent=True) self._pool: HTTPConnectionPool = HTTPConnectionPool(reactor, persistent=True)
self._pool.maxPersistentPerHost = settings.getint( self._pool.maxPersistentPerHost = settings.getint(
"CONCURRENT_REQUESTS_PER_DOMAIN" "CONCURRENT_REQUESTS_PER_DOMAIN"
) )
self._pool._factory.noisy = False self._pool._factory.noisy = False
self._contextFactory = load_context_factory_from_settings(settings, crawler) self._contextFactory: IPolicyForHTTPS = load_context_factory_from_settings(
self._default_maxsize = settings.getint("DOWNLOAD_MAXSIZE") settings, crawler
self._default_warnsize = settings.getint("DOWNLOAD_WARNSIZE") )
self._fail_on_dataloss = settings.getbool("DOWNLOAD_FAIL_ON_DATALOSS") self._default_maxsize: int = settings.getint("DOWNLOAD_MAXSIZE")
self._disconnect_timeout = 1 self._default_warnsize: int = settings.getint("DOWNLOAD_WARNSIZE")
self._fail_on_dataloss: bool = settings.getbool("DOWNLOAD_FAIL_ON_DATALOSS")
self._disconnect_timeout: int = 1
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler) return cls(crawler.settings, crawler)
def download_request(self, request, spider): def download_request(self, request: Request, spider: Spider) -> Deferred:
"""Return a deferred for the HTTP download""" """Return a deferred for the HTTP download"""
agent = ScrapyAgent( agent = ScrapyAgent(
contextFactory=self._contextFactory, contextFactory=self._contextFactory,
@ -71,10 +83,10 @@ class HTTP11DownloadHandler:
) )
return agent.download_request(request) return agent.download_request(request)
def close(self): def close(self) -> Deferred:
from twisted.internet import reactor from twisted.internet import reactor
d = self._pool.closeCachedConnections() d: Deferred = self._pool.closeCachedConnections()
# closeCachedConnections will hang on network or server issues, so # closeCachedConnections will hang on network or server issues, so
# we'll manually timeout the deferred. # we'll manually timeout the deferred.
# #
@ -85,7 +97,7 @@ class HTTP11DownloadHandler:
# issue a callback after `_disconnect_timeout` seconds. # issue a callback after `_disconnect_timeout` seconds.
delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, []) delayed_call = reactor.callLater(self._disconnect_timeout, d.callback, [])
def cancel_delayed_call(result): def cancel_delayed_call(result: Any) -> Any:
if delayed_call.active(): if delayed_call.active():
delayed_call.cancel() delayed_call.cancel()
return result return result
@ -115,39 +127,41 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
def __init__( def __init__(
self, self,
reactor, reactor: ReactorBase,
host, host: str,
port, port: int,
proxyConf, proxyConf: Tuple[str, int, Optional[bytes]],
contextFactory, contextFactory: IPolicyForHTTPS,
timeout=30, timeout: float = 30,
bindAddress=None, bindAddress: Optional[Tuple[str, int]] = None,
): ):
proxyHost, proxyPort, self._proxyAuthHeader = proxyConf proxyHost, proxyPort, self._proxyAuthHeader = proxyConf
super().__init__(reactor, proxyHost, proxyPort, timeout, bindAddress) super().__init__(reactor, proxyHost, proxyPort, timeout, bindAddress)
self._tunnelReadyDeferred = defer.Deferred() self._tunnelReadyDeferred: Deferred = Deferred()
self._tunneledHost = host self._tunneledHost: str = host
self._tunneledPort = port self._tunneledPort: int = port
self._contextFactory = contextFactory self._contextFactory: IPolicyForHTTPS = contextFactory
self._connectBuffer = bytearray() self._connectBuffer: bytearray = bytearray()
def requestTunnel(self, protocol): def requestTunnel(self, protocol: Protocol) -> Protocol:
"""Asks the proxy to open a tunnel.""" """Asks the proxy to open a tunnel."""
assert protocol.transport
tunnelReq = tunnel_request_data( tunnelReq = tunnel_request_data(
self._tunneledHost, self._tunneledPort, self._proxyAuthHeader self._tunneledHost, self._tunneledPort, self._proxyAuthHeader
) )
protocol.transport.write(tunnelReq) protocol.transport.write(tunnelReq)
self._protocolDataReceived = protocol.dataReceived self._protocolDataReceived = protocol.dataReceived
protocol.dataReceived = self.processProxyResponse protocol.dataReceived = self.processProxyResponse # type: ignore[method-assign]
self._protocol = protocol self._protocol = protocol
return protocol return protocol
def processProxyResponse(self, rcvd_bytes): def processProxyResponse(self, data: bytes) -> None:
"""Processes the response from the proxy. If the tunnel is successfully """Processes the response from the proxy. If the tunnel is successfully
created, notifies the client that we are ready to send requests. If not created, notifies the client that we are ready to send requests. If not
raises a TunnelError. raises a TunnelError.
""" """
self._connectBuffer += rcvd_bytes assert self._protocol.transport
self._connectBuffer += data
# make sure that enough (all) bytes are consumed # make sure that enough (all) bytes are consumed
# and that we've got all HTTP headers (ending with a blank line) # and that we've got all HTTP headers (ending with a blank line)
# from the proxy so that we don't send those bytes to the TLS layer # from the proxy so that we don't send those bytes to the TLS layer
@ -155,23 +169,24 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
# see https://github.com/scrapy/scrapy/issues/2491 # see https://github.com/scrapy/scrapy/issues/2491
if b"\r\n\r\n" not in self._connectBuffer: if b"\r\n\r\n" not in self._connectBuffer:
return return
self._protocol.dataReceived = self._protocolDataReceived 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: if respm and int(respm.group("status")) == 200:
# set proper Server Name Indication extension # set proper Server Name Indication extension
sslOptions = self._contextFactory.creatorForNetloc( sslOptions = self._contextFactory.creatorForNetloc( # type: ignore[call-arg,misc]
self._tunneledHost, self._tunneledPort self._tunneledHost, self._tunneledPort
) )
self._protocol.transport.startTLS(sslOptions, self._protocolFactory) self._protocol.transport.startTLS(sslOptions, self._protocolFactory)
self._tunnelReadyDeferred.callback(self._protocol) self._tunnelReadyDeferred.callback(self._protocol)
else: else:
extra: Any
if respm: if respm:
extra = { extra = {
"status": int(respm.group("status")), "status": int(respm.group("status")),
"reason": respm.group("reason").strip(), "reason": respm.group("reason").strip(),
} }
else: else:
extra = rcvd_bytes[: self._truncatedLength] extra = data[: self._truncatedLength]
self._tunnelReadyDeferred.errback( self._tunnelReadyDeferred.errback(
TunnelError( TunnelError(
"Could not open CONNECT tunnel with proxy " "Could not open CONNECT tunnel with proxy "
@ -179,11 +194,11 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
) )
) )
def connectFailed(self, reason): def connectFailed(self, reason: Failure) -> None:
"""Propagates the errback to the appropriate deferred.""" """Propagates the errback to the appropriate deferred."""
self._tunnelReadyDeferred.errback(reason) self._tunnelReadyDeferred.errback(reason)
def connect(self, protocolFactory): def connect(self, protocolFactory: Factory) -> Deferred:
self._protocolFactory = protocolFactory self._protocolFactory = protocolFactory
connectDeferred = super().connect(protocolFactory) connectDeferred = super().connect(protocolFactory)
connectDeferred.addCallback(self.requestTunnel) connectDeferred.addCallback(self.requestTunnel)
@ -191,7 +206,9 @@ class TunnelingTCP4ClientEndpoint(TCP4ClientEndpoint):
return self._tunnelReadyDeferred return self._tunnelReadyDeferred
def tunnel_request_data(host, port, proxy_auth_header=None): def tunnel_request_data(
host: str, port: int, proxy_auth_header: Optional[bytes] = None
) -> bytes:
r""" r"""
Return binary content of a CONNECT request. Return binary content of a CONNECT request.
@ -222,18 +239,19 @@ class TunnelingAgent(Agent):
def __init__( def __init__(
self, self,
reactor, *,
proxyConf, reactor: ReactorBase,
contextFactory=None, proxyConf: Tuple[str, int, Optional[bytes]],
connectTimeout=None, contextFactory: IPolicyForHTTPS,
bindAddress=None, connectTimeout: Optional[float] = None,
pool=None, bindAddress: Optional[bytes] = None,
pool: Optional[HTTPConnectionPool] = None,
): ):
super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool) super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool)
self._proxyConf = proxyConf self._proxyConf: Tuple[str, int, Optional[bytes]] = proxyConf
self._contextFactory = contextFactory self._contextFactory: IPolicyForHTTPS = contextFactory
def _getEndpoint(self, uri): def _getEndpoint(self, uri: URI) -> TunnelingTCP4ClientEndpoint:
return TunnelingTCP4ClientEndpoint( return TunnelingTCP4ClientEndpoint(
reactor=self._reactor, reactor=self._reactor,
host=uri.host, host=uri.host,
@ -245,8 +263,15 @@ class TunnelingAgent(Agent):
) )
def _requestWithEndpoint( def _requestWithEndpoint(
self, key, endpoint, method, parsedURI, headers, bodyProducer, requestPath self,
): key: Any,
endpoint: TCP4ClientEndpoint,
method: bytes,
parsedURI: bytes,
headers: Optional[TxHeaders],
bodyProducer: Optional[IBodyProducer],
requestPath: bytes,
) -> Deferred:
# proxy host and port are required for HTTP pool `key` # proxy host and port are required for HTTP pool `key`
# otherwise, same remote host connection request could reuse # otherwise, same remote host connection request could reuse
# a cached tunneled connection to a different proxy # a cached tunneled connection to a different proxy
@ -264,7 +289,12 @@ class TunnelingAgent(Agent):
class ScrapyProxyAgent(Agent): class ScrapyProxyAgent(Agent):
def __init__( def __init__(
self, reactor, proxyURI, connectTimeout=None, bindAddress=None, pool=None self,
reactor: ReactorBase,
proxyURI: bytes,
connectTimeout: Optional[float] = None,
bindAddress: Optional[bytes] = None,
pool: Optional[HTTPConnectionPool] = None,
): ):
super().__init__( super().__init__(
reactor=reactor, reactor=reactor,
@ -272,9 +302,15 @@ class ScrapyProxyAgent(Agent):
bindAddress=bindAddress, bindAddress=bindAddress,
pool=pool, pool=pool,
) )
self._proxyURI = URI.fromBytes(proxyURI) self._proxyURI: URI = URI.fromBytes(proxyURI)
def request(self, method, uri, headers=None, bodyProducer=None): def request(
self,
method: bytes,
uri: bytes,
headers: Optional[TxHeaders] = None,
bodyProducer: Optional[IBodyProducer] = None,
) -> Deferred:
""" """
Issue a new request via the configured proxy. Issue a new request via the configured proxy.
""" """
@ -298,26 +334,27 @@ class ScrapyAgent:
def __init__( def __init__(
self, self,
contextFactory=None, *,
connectTimeout=10, contextFactory: IPolicyForHTTPS,
bindAddress=None, connectTimeout: float = 10,
pool=None, bindAddress: Optional[bytes] = None,
maxsize=0, pool: Optional[HTTPConnectionPool] = None,
warnsize=0, maxsize: int = 0,
fail_on_dataloss=True, warnsize: int = 0,
crawler=None, fail_on_dataloss: bool = True,
crawler: Crawler,
): ):
self._contextFactory = contextFactory self._contextFactory: IPolicyForHTTPS = contextFactory
self._connectTimeout = connectTimeout self._connectTimeout: float = connectTimeout
self._bindAddress = bindAddress self._bindAddress: Optional[bytes] = bindAddress
self._pool = pool self._pool: Optional[HTTPConnectionPool] = pool
self._maxsize = maxsize self._maxsize: int = maxsize
self._warnsize = warnsize self._warnsize: int = warnsize
self._fail_on_dataloss = fail_on_dataloss self._fail_on_dataloss: bool = fail_on_dataloss
self._txresponse = None self._txresponse: Optional[TxResponse] = None
self._crawler = crawler self._crawler: Crawler = crawler
def _get_agent(self, request, timeout): def _get_agent(self, request: Request, timeout: float) -> Agent:
from twisted.internet import reactor from twisted.internet import reactor
bindaddress = request.meta.get("bindaddress") or self._bindAddress bindaddress = request.meta.get("bindaddress") or self._bindAddress
@ -325,10 +362,10 @@ class ScrapyAgent:
if proxy: if proxy:
proxyScheme, proxyNetloc, proxyHost, proxyPort, proxyParams = _parse(proxy) proxyScheme, proxyNetloc, proxyHost, proxyPort, proxyParams = _parse(proxy)
scheme = _parse(request.url)[0] scheme = _parse(request.url)[0]
proxyHost = to_unicode(proxyHost) proxyHost_str = to_unicode(proxyHost)
if scheme == b"https": if scheme == b"https":
proxyAuth = request.headers.get(b"Proxy-Authorization", None) proxyAuth = request.headers.get(b"Proxy-Authorization", None)
proxyConf = (proxyHost, proxyPort, proxyAuth) proxyConf = (proxyHost_str, proxyPort, proxyAuth)
return self._TunnelingAgent( return self._TunnelingAgent(
reactor=reactor, reactor=reactor,
proxyConf=proxyConf, proxyConf=proxyConf,
@ -338,7 +375,9 @@ class ScrapyAgent:
pool=self._pool, pool=self._pool,
) )
proxyScheme = proxyScheme or b"http" proxyScheme = proxyScheme or b"http"
proxyURI = urlunparse((proxyScheme, proxyNetloc, proxyParams, "", "", "")) proxyURI = urlunparse(
(proxyScheme, proxyNetloc, proxyParams, b"", b"", b"")
)
return self._ProxyAgent( return self._ProxyAgent(
reactor=reactor, reactor=reactor,
proxyURI=to_bytes(proxyURI, encoding="ascii"), proxyURI=to_bytes(proxyURI, encoding="ascii"),
@ -355,7 +394,7 @@ class ScrapyAgent:
pool=self._pool, pool=self._pool,
) )
def download_request(self, request): def download_request(self, request: Request) -> Deferred:
from twisted.internet import reactor from twisted.internet import reactor
timeout = request.meta.get("download_timeout") or self._connectTimeout timeout = request.meta.get("download_timeout") or self._connectTimeout
@ -372,7 +411,7 @@ class ScrapyAgent:
else: else:
bodyproducer = None bodyproducer = None
start_time = time() start_time = time()
d = agent.request( d: Deferred = agent.request(
method, to_bytes(url, encoding="ascii"), headers, bodyproducer method, to_bytes(url, encoding="ascii"), headers, bodyproducer
) )
# set download latency # set download latency
@ -385,7 +424,9 @@ class ScrapyAgent:
d.addBoth(self._cb_timeout, request, url, timeout) d.addBoth(self._cb_timeout, request, url, timeout)
return d return d
def _cb_timeout(self, result, request, url, timeout): def _cb_timeout(
self, result: Any, request: Request, url: str, timeout: float
) -> Any:
if self._timeout_cl.active(): if self._timeout_cl.active():
self._timeout_cl.cancel() self._timeout_cl.cancel()
return result return result
@ -396,19 +437,21 @@ class ScrapyAgent:
raise TimeoutError(f"Getting {url} took longer than {timeout} seconds.") raise TimeoutError(f"Getting {url} took longer than {timeout} seconds.")
def _cb_latency(self, result, request, start_time): def _cb_latency(self, result: Any, request: Request, start_time: float) -> Any:
request.meta["download_latency"] = time() - start_time request.meta["download_latency"] = time() - start_time
return result return result
@staticmethod @staticmethod
def _headers_from_twisted_response(response): def _headers_from_twisted_response(response: TxResponse) -> Headers:
headers = Headers() headers = Headers()
if response.length != UNKNOWN_LENGTH: if response.length != UNKNOWN_LENGTH:
headers[b"Content-Length"] = str(response.length).encode() headers[b"Content-Length"] = str(response.length).encode()
headers.update(response.headers.getAllRawHeaders()) headers.update(response.headers.getAllRawHeaders())
return headers return headers
def _cb_bodyready(self, txresponse, request): def _cb_bodyready(
self, txresponse: TxResponse, request: Request
) -> Union[Dict[str, Any], Deferred]:
headers_received_result = self._crawler.signals.send_catch_log( headers_received_result = self._crawler.signals.send_catch_log(
signal=signals.headers_received, signal=signals.headers_received,
headers=self._headers_from_twisted_response(txresponse), headers=self._headers_from_twisted_response(txresponse),
@ -464,7 +507,7 @@ class ScrapyAgent:
logger.warning(warning_msg, warning_args) logger.warning(warning_msg, warning_args)
txresponse._transport.loseConnection() txresponse._transport.loseConnection()
raise defer.CancelledError(warning_msg % warning_args) raise CancelledError(warning_msg % warning_args)
if warnsize and expected_size > warnsize: if warnsize and expected_size > warnsize:
logger.warning( logger.warning(
@ -473,11 +516,11 @@ class ScrapyAgent:
{"size": expected_size, "warnsize": warnsize, "request": request}, {"size": expected_size, "warnsize": warnsize, "request": request},
) )
def _cancel(_): def _cancel(_: Any) -> None:
# Abort connection immediately. # Abort connection immediately.
txresponse._transport._producer.abortConnection() txresponse._transport._producer.abortConnection()
d = defer.Deferred(_cancel) d: Deferred = Deferred(_cancel)
txresponse.deliverBody( txresponse.deliverBody(
_ResponseReader( _ResponseReader(
finished=d, finished=d,
@ -495,7 +538,9 @@ class ScrapyAgent:
return d return d
def _cb_bodydone(self, result, request, url): def _cb_bodydone(
self, result: Dict[str, Any], request: Request, url: str
) -> Union[Response, Failure]:
headers = self._headers_from_twisted_response(result["txresponse"]) headers = self._headers_from_twisted_response(result["txresponse"])
respcls = responsetypes.from_args(headers=headers, url=url, body=result["body"]) respcls = responsetypes.from_args(headers=headers, url=url, body=result["body"])
try: try:
@ -515,53 +560,57 @@ class ScrapyAgent:
) )
if result.get("failure"): if result.get("failure"):
result["failure"].value.response = response result["failure"].value.response = response
return result["failure"] return cast(Failure, result["failure"])
return response return response
@implementer(IBodyProducer) @implementer(IBodyProducer)
class _RequestBodyProducer: class _RequestBodyProducer:
def __init__(self, body): def __init__(self, body: bytes):
self.body = body self.body = body
self.length = len(body) self.length = len(body)
def startProducing(self, consumer): def startProducing(self, consumer: IConsumer) -> Deferred:
consumer.write(self.body) consumer.write(self.body)
return defer.succeed(None) return succeed(None)
def pauseProducing(self): def pauseProducing(self) -> None:
pass pass
def stopProducing(self): def stopProducing(self) -> None:
pass pass
class _ResponseReader(protocol.Protocol): class _ResponseReader(Protocol):
def __init__( def __init__(
self, self,
finished, finished: Deferred,
txresponse, txresponse: TxResponse,
request, request: Request,
maxsize, maxsize: int,
warnsize, warnsize: int,
fail_on_dataloss, fail_on_dataloss: bool,
crawler, crawler: Crawler,
): ):
self._finished = finished self._finished: Deferred = finished
self._txresponse = txresponse self._txresponse: TxResponse = txresponse
self._request = request self._request: Request = request
self._bodybuf = BytesIO() self._bodybuf: BytesIO = BytesIO()
self._maxsize = maxsize self._maxsize: int = maxsize
self._warnsize = warnsize self._warnsize: int = warnsize
self._fail_on_dataloss = fail_on_dataloss self._fail_on_dataloss: bool = fail_on_dataloss
self._fail_on_dataloss_warned = False self._fail_on_dataloss_warned: bool = False
self._reached_warnsize = False self._reached_warnsize: bool = False
self._bytes_received = 0 self._bytes_received: int = 0
self._certificate = None self._certificate: Optional[ssl.Certificate] = None
self._ip_address = None self._ip_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address, None] = (
self._crawler = crawler None
)
self._crawler: Crawler = crawler
def _finish_response(self, flags=None, failure=None): def _finish_response(
self, flags: Optional[List[str]] = None, failure: Optional[Failure] = None
) -> None:
self._finished.callback( self._finished.callback(
{ {
"txresponse": self._txresponse, "txresponse": self._txresponse,
@ -573,7 +622,8 @@ class _ResponseReader(protocol.Protocol):
} }
) )
def connectionMade(self): def connectionMade(self) -> None:
assert self.transport
if self._certificate is None: if self._certificate is None:
with suppress(AttributeError): with suppress(AttributeError):
self._certificate = ssl.Certificate( self._certificate = ssl.Certificate(
@ -585,11 +635,12 @@ class _ResponseReader(protocol.Protocol):
self.transport._producer.getPeer().host self.transport._producer.getPeer().host
) )
def dataReceived(self, bodyBytes): def dataReceived(self, bodyBytes: bytes) -> None:
# This maybe called several times after cancel was called with buffered data. # This maybe called several times after cancel was called with buffered data.
if self._finished.called: if self._finished.called:
return return
assert self.transport
self._bodybuf.write(bodyBytes) self._bodybuf.write(bodyBytes)
self._bytes_received += len(bodyBytes) self._bytes_received += len(bodyBytes)
@ -636,7 +687,7 @@ class _ResponseReader(protocol.Protocol):
{"warnsize": self._warnsize, "request": self._request}, {"warnsize": self._warnsize, "request": self._request},
) )
def connectionLost(self, reason): def connectionLost(self, reason: Failure = connectionDone) -> None:
if self._finished.called: if self._finished.called:
return return

View File

@ -1,11 +1,14 @@
from __future__ import annotations
from time import time from time import time
from typing import Optional, Type, TypeVar from typing import TYPE_CHECKING, Optional
from urllib.parse import urldefrag from urllib.parse import urldefrag
from twisted.internet.base import DelayedCall from twisted.internet.base import DelayedCall
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from twisted.internet.error import TimeoutError from twisted.internet.error import TimeoutError
from twisted.web.client import URI from twisted.web.client import URI
from twisted.web.iweb import IPolicyForHTTPS
from scrapy.core.downloader.contextfactory import load_context_factory_from_settings from scrapy.core.downloader.contextfactory import load_context_factory_from_settings
from scrapy.core.downloader.webclient import _parse from scrapy.core.downloader.webclient import _parse
@ -16,13 +19,13 @@ from scrapy.settings import Settings
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.python import to_bytes from scrapy.utils.python import to_bytes
H2DownloadHandlerOrSubclass = TypeVar( if TYPE_CHECKING:
"H2DownloadHandlerOrSubclass", bound="H2DownloadHandler" # typing.Self requires Python 3.11
) from typing_extensions import Self
class H2DownloadHandler: class H2DownloadHandler:
def __init__(self, settings: Settings, crawler: Optional[Crawler] = None): def __init__(self, settings: Settings, crawler: Crawler):
self._crawler = crawler self._crawler = crawler
from twisted.internet import reactor from twisted.internet import reactor
@ -31,9 +34,7 @@ class H2DownloadHandler:
self._context_factory = load_context_factory_from_settings(settings, crawler) self._context_factory = load_context_factory_from_settings(settings, crawler)
@classmethod @classmethod
def from_crawler( def from_crawler(cls, crawler: Crawler) -> Self:
cls: Type[H2DownloadHandlerOrSubclass], crawler: Crawler
) -> H2DownloadHandlerOrSubclass:
return cls(crawler.settings, crawler) return cls(crawler.settings, crawler)
def download_request(self, request: Request, spider: Spider) -> Deferred: def download_request(self, request: Request, spider: Spider) -> Deferred:
@ -54,7 +55,7 @@ class ScrapyH2Agent:
def __init__( def __init__(
self, self,
context_factory, context_factory: IPolicyForHTTPS,
pool: H2ConnectionPool, pool: H2ConnectionPool,
connect_timeout: int = 10, connect_timeout: int = 10,
bind_address: Optional[bytes] = None, bind_address: Optional[bytes] = None,

View File

@ -1,21 +1,34 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional, Type
from twisted.internet.defer import Deferred
from scrapy import Request, Spider
from scrapy.core.downloader.handlers.http import HTTPDownloadHandler from scrapy.core.downloader.handlers.http import HTTPDownloadHandler
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.settings import BaseSettings
from scrapy.utils.boto import is_botocore_available from scrapy.utils.boto import is_botocore_available
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.misc import create_instance from scrapy.utils.misc import build_from_crawler
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
class S3DownloadHandler: class S3DownloadHandler:
def __init__( def __init__(
self, self,
settings, settings: BaseSettings,
*, *,
crawler=None, crawler: Crawler,
aws_access_key_id=None, aws_access_key_id: Optional[str] = None,
aws_secret_access_key=None, aws_secret_access_key: Optional[str] = None,
aws_session_token=None, aws_session_token: Optional[str] = None,
httpdownloadhandler=HTTPDownloadHandler, httpdownloadhandler: Type[HTTPDownloadHandler] = HTTPDownloadHandler,
**kw, **kw: Any,
): ):
if not is_botocore_available(): if not is_botocore_available():
raise NotConfigured("missing botocore library") raise NotConfigured("missing botocore library")
@ -43,6 +56,8 @@ class S3DownloadHandler:
if kw: if kw:
raise TypeError(f"Unexpected keyword arguments: {kw}") raise TypeError(f"Unexpected keyword arguments: {kw}")
if not self.anon: if not self.anon:
assert aws_access_key_id is not None
assert aws_secret_access_key is not None
SignerCls = botocore.auth.AUTH_TYPE_MAPS["s3"] SignerCls = botocore.auth.AUTH_TYPE_MAPS["s3"]
self._signer = SignerCls( self._signer = SignerCls(
botocore.credentials.Credentials( botocore.credentials.Credentials(
@ -50,18 +65,17 @@ class S3DownloadHandler:
) )
) )
_http_handler = create_instance( _http_handler = build_from_crawler(
objcls=httpdownloadhandler, httpdownloadhandler,
settings=settings, crawler,
crawler=crawler,
) )
self._download_http = _http_handler.download_request self._download_http = _http_handler.download_request
@classmethod @classmethod
def from_crawler(cls, crawler, **kwargs): def from_crawler(cls, crawler: Crawler, **kwargs: Any) -> Self:
return cls(crawler.settings, crawler=crawler, **kwargs) return cls(crawler.settings, crawler=crawler, **kwargs)
def download_request(self, request, spider): def download_request(self, request: Request, spider: Spider) -> Deferred:
p = urlparse_cached(request) p = urlparse_cached(request)
scheme = "https" if request.meta.get("is_secure") else "http" scheme = "https" if request.meta.get("is_secure") else "http"
bucket = p.hostname bucket = p.hostname
@ -78,6 +92,7 @@ class S3DownloadHandler:
headers=request.headers.to_unicode_dict(), headers=request.headers.to_unicode_dict(),
data=request.body, data=request.body,
) )
assert self._signer
self._signer.add_auth(awsrequest) self._signer.add_auth(awsrequest)
request = request.replace(url=url, headers=awsrequest.headers.items()) request = request.replace(url=url, headers=awsrequest.headers.items())
return self._download_http(request, spider) return self._download_http(request, spider)

View File

@ -3,6 +3,7 @@ Downloader Middleware manager
See documentation in docs/topics/downloader-middleware.rst See documentation in docs/topics/downloader-middleware.rst
""" """
from typing import Any, Callable, Generator, List, Union, cast from typing import Any, Callable, Generator, List, Union, cast
from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.defer import Deferred, inlineCallbacks

View File

@ -4,6 +4,7 @@ This is the Scrapy engine which controls the Scheduler, Downloader and Spider.
For more information see docs/topics/architecture.rst For more information see docs/topics/architecture.rst
""" """
import logging import logging
from time import time from time import time
from typing import ( from typing import (
@ -27,14 +28,15 @@ from twisted.python.failure import Failure
from scrapy import signals from scrapy import signals
from scrapy.core.downloader import Downloader from scrapy.core.downloader import Downloader
from scrapy.core.scraper import Scraper from scrapy.core.scraper import Scraper
from scrapy.exceptions import CloseSpider, DontCloseSpider from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest
from scrapy.http import Request, Response from scrapy.http import Request, Response
from scrapy.logformatter import LogFormatter from scrapy.logformatter import LogFormatter
from scrapy.settings import BaseSettings, Settings from scrapy.settings import BaseSettings, Settings
from scrapy.signalmanager import SignalManager from scrapy.signalmanager import SignalManager
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.log import failure_to_exc_info, logformatter_adapter from scrapy.utils.log import failure_to_exc_info, logformatter_adapter
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import global_object_name
from scrapy.utils.reactor import CallLaterOnce from scrapy.utils.reactor import CallLaterOnce
if TYPE_CHECKING: if TYPE_CHECKING:
@ -291,9 +293,19 @@ class ExecutionEngine:
self.slot.nextcall.schedule() # type: ignore[union-attr] self.slot.nextcall.schedule() # type: ignore[union-attr]
def _schedule_request(self, request: Request, spider: Spider) -> None: def _schedule_request(self, request: Request, spider: Spider) -> None:
self.signals.send_catch_log( request_scheduled_result = self.signals.send_catch_log(
signals.request_scheduled, request=request, spider=spider signals.request_scheduled,
request=request,
spider=spider,
dont_log=IgnoreRequest,
) )
for handler, result in request_scheduled_result:
if isinstance(result, Failure) and isinstance(result.value, IgnoreRequest):
logger.debug(
f"Signal handler {global_object_name(handler)} dropped "
f"request {request} before it reached the scheduler."
)
return
if not self.slot.scheduler.enqueue_request(request): # type: ignore[union-attr] if not self.slot.scheduler.enqueue_request(request): # type: ignore[union-attr]
self.signals.send_catch_log( self.signals.send_catch_log(
signals.request_dropped, request=request, spider=spider signals.request_dropped, request=request, spider=spider
@ -346,7 +358,7 @@ class ExecutionEngine:
assert self.spider is not None assert self.spider is not None
dwld = self.downloader.fetch(request, self.spider) dwld = self.downloader.fetch(request, self.spider)
dwld.addCallbacks(_on_success) dwld.addCallback(_on_success)
dwld.addBoth(_on_complete) dwld.addBoth(_on_complete)
return dwld return dwld
@ -358,16 +370,15 @@ class ExecutionEngine:
raise RuntimeError(f"No free spider slot when opening {spider.name!r}") raise RuntimeError(f"No free spider slot when opening {spider.name!r}")
logger.info("Spider opened", extra={"spider": spider}) logger.info("Spider opened", extra={"spider": spider})
nextcall = CallLaterOnce(self._next_request) nextcall = CallLaterOnce(self._next_request)
scheduler = create_instance( scheduler = build_from_crawler(self.scheduler_cls, self.crawler)
self.scheduler_cls, settings=None, crawler=self.crawler
)
start_requests = yield self.scraper.spidermw.process_start_requests( start_requests = yield self.scraper.spidermw.process_start_requests(
start_requests, spider start_requests, spider
) )
self.slot = Slot(start_requests, close_if_idle, nextcall, scheduler) self.slot = Slot(start_requests, close_if_idle, nextcall, scheduler)
self.spider = spider self.spider = spider
if hasattr(scheduler, "open"): if hasattr(scheduler, "open"):
yield scheduler.open(spider) if d := scheduler.open(spider):
yield d
yield self.scraper.open_spider(spider) yield self.scraper.open_spider(spider)
assert self.crawler.stats assert self.crawler.stats
self.crawler.stats.open_spider(spider) self.crawler.stats.open_spider(spider)

View File

@ -2,7 +2,6 @@ import logging
from enum import Enum from enum import Enum
from io import BytesIO from io import BytesIO
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from urllib.parse import urlparse
from h2.errors import ErrorCodes from h2.errors import ErrorCodes
from h2.exceptions import H2Error, ProtocolError, StreamClosedError from h2.exceptions import H2Error, ProtocolError, StreamClosedError
@ -15,6 +14,7 @@ from twisted.web.client import ResponseFailed
from scrapy.http import Request from scrapy.http import Request
from scrapy.http.headers import Headers from scrapy.http.headers import Headers
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.utils.httpobj import urlparse_cached
if TYPE_CHECKING: if TYPE_CHECKING:
from scrapy.core.http2.protocol import H2ClientProtocol from scrapy.core.http2.protocol import H2ClientProtocol
@ -111,17 +111,17 @@ class Stream:
# Metadata of an HTTP/2 connection stream # Metadata of an HTTP/2 connection stream
# initialized when stream is instantiated # initialized when stream is instantiated
self.metadata: Dict = { self.metadata: Dict = {
"request_content_length": 0 "request_content_length": (
if self._request.body is None 0 if self._request.body is None else len(self._request.body)
else len(self._request.body), ),
# Flag to keep track whether the stream has initiated the request # Flag to keep track whether the stream has initiated the request
"request_sent": False, "request_sent": False,
# Flag to track whether we have logged about exceeding download warnsize # Flag to track whether we have logged about exceeding download warnsize
"reached_warnsize": False, "reached_warnsize": False,
# Each time we send a data frame, we will decrease value by the amount send. # Each time we send a data frame, we will decrease value by the amount send.
"remaining_content_length": 0 "remaining_content_length": (
if self._request.body is None 0 if self._request.body is None else len(self._request.body)
else len(self._request.body), ),
# Flag to keep track whether client (self) have closed this stream # Flag to keep track whether client (self) have closed this stream
"stream_closed_local": False, "stream_closed_local": False,
# Flag to keep track whether the server has closed the stream # Flag to keep track whether the server has closed the stream
@ -185,7 +185,7 @@ class Stream:
def check_request_url(self) -> bool: def check_request_url(self) -> bool:
# Make sure that we are sending the request to the correct URL # Make sure that we are sending the request to the correct URL
url = urlparse(self._request.url) url = urlparse_cached(self._request)
return ( return (
url.netloc == str(self._protocol.metadata["uri"].host, "utf-8") url.netloc == str(self._protocol.metadata["uri"].host, "utf-8")
or url.netloc == str(self._protocol.metadata["uri"].netloc, "utf-8") or url.netloc == str(self._protocol.metadata["uri"].netloc, "utf-8")
@ -194,7 +194,7 @@ class Stream:
) )
def _get_request_headers(self) -> List[Tuple[str, str]]: def _get_request_headers(self) -> List[Tuple[str, str]]:
url = urlparse(self._request.url) url = urlparse_cached(self._request)
path = url.path path = url.path
if url.query: if url.query:

View File

@ -4,19 +4,23 @@ import json
import logging import logging
from abc import abstractmethod from abc import abstractmethod
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar, cast from typing import TYPE_CHECKING, Any, Optional, Type, cast
from twisted.internet.defer import Deferred from twisted.internet.defer import Deferred
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.dupefilters import BaseDupeFilter from scrapy.dupefilters import BaseDupeFilter
from scrapy.http.request import Request from scrapy.http.request import Request
from scrapy.pqueues import ScrapyPriorityQueue
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.statscollectors import StatsCollector from scrapy.statscollectors import StatsCollector
from scrapy.utils.job import job_dir from scrapy.utils.job import job_dir
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
if TYPE_CHECKING: if TYPE_CHECKING:
# requires queuelib >= 1.6.2
from queuelib.queue import BaseQueue
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Self from typing_extensions import Self
@ -121,9 +125,6 @@ class BaseScheduler(metaclass=BaseSchedulerMeta):
raise NotImplementedError() raise NotImplementedError()
SchedulerTV = TypeVar("SchedulerTV", bound="Scheduler")
class Scheduler(BaseScheduler): class Scheduler(BaseScheduler):
""" """
Default Scrapy scheduler. This implementation also handles duplication Default Scrapy scheduler. This implementation also handles duplication
@ -179,30 +180,30 @@ class Scheduler(BaseScheduler):
self, self,
dupefilter: BaseDupeFilter, dupefilter: BaseDupeFilter,
jobdir: Optional[str] = None, jobdir: Optional[str] = None,
dqclass=None, dqclass: Optional[Type[BaseQueue]] = None,
mqclass=None, mqclass: Optional[Type[BaseQueue]] = None,
logunser: bool = False, logunser: bool = False,
stats: Optional[StatsCollector] = None, stats: Optional[StatsCollector] = None,
pqclass=None, pqclass: Optional[Type[ScrapyPriorityQueue]] = None,
crawler: Optional[Crawler] = None, crawler: Optional[Crawler] = None,
): ):
self.df: BaseDupeFilter = dupefilter self.df: BaseDupeFilter = dupefilter
self.dqdir: Optional[str] = self._dqdir(jobdir) self.dqdir: Optional[str] = self._dqdir(jobdir)
self.pqclass = pqclass self.pqclass: Optional[Type[ScrapyPriorityQueue]] = pqclass
self.dqclass = dqclass self.dqclass: Optional[Type[BaseQueue]] = dqclass
self.mqclass = mqclass self.mqclass: Optional[Type[BaseQueue]] = mqclass
self.logunser: bool = logunser self.logunser: bool = logunser
self.stats: Optional[StatsCollector] = stats self.stats: Optional[StatsCollector] = stats
self.crawler: Optional[Crawler] = crawler self.crawler: Optional[Crawler] = crawler
@classmethod @classmethod
def from_crawler(cls: Type[SchedulerTV], crawler: Crawler) -> SchedulerTV: def from_crawler(cls, crawler: Crawler) -> Self:
""" """
Factory method, initializes the scheduler with arguments taken from the crawl settings Factory method, initializes the scheduler with arguments taken from the crawl settings
""" """
dupefilter_cls = load_object(crawler.settings["DUPEFILTER_CLASS"]) dupefilter_cls = load_object(crawler.settings["DUPEFILTER_CLASS"])
return cls( return cls(
dupefilter=create_instance(dupefilter_cls, crawler.settings, crawler), dupefilter=build_from_crawler(dupefilter_cls, crawler),
jobdir=job_dir(crawler.settings), jobdir=job_dir(crawler.settings),
dqclass=load_object(crawler.settings["SCHEDULER_DISK_QUEUE"]), dqclass=load_object(crawler.settings["SCHEDULER_DISK_QUEUE"]),
mqclass=load_object(crawler.settings["SCHEDULER_MEMORY_QUEUE"]), mqclass=load_object(crawler.settings["SCHEDULER_MEMORY_QUEUE"]),
@ -221,9 +222,9 @@ class Scheduler(BaseScheduler):
(2) initialize the disk queue if the ``jobdir`` attribute is a valid directory (2) initialize the disk queue if the ``jobdir`` attribute is a valid directory
(3) return the result of the dupefilter's ``open`` method (3) return the result of the dupefilter's ``open`` method
""" """
self.spider = spider self.spider: Spider = spider
self.mqs = self._mq() self.mqs: ScrapyPriorityQueue = self._mq()
self.dqs = self._dq() if self.dqdir else None self.dqs: Optional[ScrapyPriorityQueue] = self._dq() if self.dqdir else None
return self.df.open() return self.df.open()
def close(self, reason: str) -> Optional[Deferred]: def close(self, reason: str) -> Optional[Deferred]:
@ -320,24 +321,26 @@ class Scheduler(BaseScheduler):
return self.dqs.pop() return self.dqs.pop()
return None return None
def _mq(self): def _mq(self) -> ScrapyPriorityQueue:
"""Create a new priority queue instance, with in-memory storage""" """Create a new priority queue instance, with in-memory storage"""
return create_instance( assert self.crawler
assert self.pqclass
return build_from_crawler(
self.pqclass, self.pqclass,
settings=None, self.crawler,
crawler=self.crawler,
downstream_queue_cls=self.mqclass, downstream_queue_cls=self.mqclass,
key="", key="",
) )
def _dq(self): def _dq(self) -> ScrapyPriorityQueue:
"""Create a new priority queue instance, with disk storage""" """Create a new priority queue instance, with disk storage"""
assert self.crawler
assert self.dqdir assert self.dqdir
assert self.pqclass
state = self._read_dqs_state(self.dqdir) state = self._read_dqs_state(self.dqdir)
q = create_instance( q = build_from_crawler(
self.pqclass, self.pqclass,
settings=None, self.crawler,
crawler=self.crawler,
downstream_queue_cls=self.dqclass, downstream_queue_cls=self.dqclass,
key=self.dqdir, key=self.dqdir,
startprios=state, startprios=state,

View File

@ -1,5 +1,6 @@
"""This module implements the Scraper component which parses responses and """This module implements the Scraper component which parses responses and
extracts information from them""" extracts information from them"""
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -7,7 +8,6 @@ from collections import deque
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
AsyncGenerator,
AsyncIterable, AsyncIterable,
Deque, Deque,
Generator, Generator,
@ -17,6 +17,7 @@ from typing import (
Tuple, Tuple,
Type, Type,
Union, Union,
cast,
) )
from itemadapter import is_item from itemadapter import is_item
@ -183,7 +184,9 @@ class Scraper:
result, request, spider result, request, spider
) # returns spider's processed output ) # returns spider's processed output
dfd.addErrback(self.handle_spider_error, request, result, spider) dfd.addErrback(self.handle_spider_error, request, result, spider)
dfd.addCallback(self.handle_spider_output, request, result, spider) dfd.addCallback(
self.handle_spider_output, request, cast(Response, result), spider
)
return dfd return dfd
def _scrape2( def _scrape2(
@ -255,12 +258,12 @@ class Scraper:
self, self,
result: Union[Iterable, AsyncIterable], result: Union[Iterable, AsyncIterable],
request: Request, request: Request,
response: Union[Response, Failure], response: Response,
spider: Spider, spider: Spider,
) -> Deferred: ) -> Deferred:
if not result: if not result:
return defer_succeed(None) return defer_succeed(None)
it: Union[Generator, AsyncGenerator] it: Union[Iterable, AsyncIterable]
if isinstance(result, AsyncIterable): if isinstance(result, AsyncIterable):
it = aiter_errback( it = aiter_errback(
result, self.handle_spider_error, request, response, spider result, self.handle_spider_error, request, response, spider

View File

@ -3,6 +3,7 @@ Spider Middleware manager
See documentation in docs/topics/spider-middleware.rst See documentation in docs/topics/spider-middleware.rst
""" """
import logging import logging
from inspect import isasyncgenfunction, iscoroutine from inspect import isasyncgenfunction, iscoroutine
from itertools import islice from itertools import islice
@ -103,8 +104,7 @@ class SpiderMiddlewareManager(MiddlewareManager):
) -> Union[Generator, AsyncGenerator]: ) -> Union[Generator, AsyncGenerator]:
def process_sync(iterable: Iterable) -> Generator: def process_sync(iterable: Iterable) -> Generator:
try: try:
for r in iterable: yield from iterable
yield r
except Exception as ex: except Exception as ex:
exception_result = self._process_spider_exception( exception_result = self._process_spider_exception(
response, spider, Failure(ex), exception_processor_index response, spider, Failure(ex), exception_processor_index
@ -303,10 +303,8 @@ class SpiderMiddlewareManager(MiddlewareManager):
dfd = mustbe_deferred( dfd = mustbe_deferred(
self._process_spider_input, scrape_func, response, request, spider self._process_spider_input, scrape_func, response, request, spider
) )
dfd.addCallbacks( dfd.addCallback(deferred_f_from_coro_f(process_callback_output))
callback=deferred_f_from_coro_f(process_callback_output), dfd.addErrback(process_spider_exception)
errback=process_spider_exception,
)
return dfd return dfd
def process_start_requests( def process_start_requests(

View File

@ -39,7 +39,7 @@ from scrapy.utils.log import (
log_reactor_info, log_reactor_info,
log_scrapy_info, log_scrapy_info,
) )
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.ossignal import install_shutdown_handlers, signal_names from scrapy.utils.ossignal import install_shutdown_handlers, signal_names
from scrapy.utils.reactor import ( from scrapy.utils.reactor import (
install_reactor, install_reactor,
@ -109,10 +109,9 @@ class Crawler:
lf_cls: Type[LogFormatter] = load_object(self.settings["LOG_FORMATTER"]) lf_cls: Type[LogFormatter] = load_object(self.settings["LOG_FORMATTER"])
self.logformatter = lf_cls.from_crawler(self) self.logformatter = lf_cls.from_crawler(self)
self.request_fingerprinter = create_instance( self.request_fingerprinter = build_from_crawler(
load_object(self.settings["REQUEST_FINGERPRINTER_CLASS"]), load_object(self.settings["REQUEST_FINGERPRINTER_CLASS"]),
settings=self.settings, self,
crawler=self,
) )
reactor_class: str = self.settings["TWISTED_REACTOR"] reactor_class: str = self.settings["TWISTED_REACTOR"]
@ -179,6 +178,48 @@ class Crawler:
assert self.engine assert self.engine
yield maybeDeferred(self.engine.stop) yield maybeDeferred(self.engine.stop)
@staticmethod
def _get_component(component_class, components):
for component in components:
if isinstance(component, component_class):
return component
return None
def get_addon(self, cls):
return self._get_component(cls, self.addons.addons)
def get_downloader_middleware(self, cls):
if not self.engine:
raise RuntimeError(
"Crawler.get_downloader_middleware() can only be called after "
"the crawl engine has been created."
)
return self._get_component(cls, self.engine.downloader.middleware.middlewares)
def get_extension(self, cls):
if not self.extensions:
raise RuntimeError(
"Crawler.get_extension() can only be called after the "
"extension manager has been created."
)
return self._get_component(cls, self.extensions.middlewares)
def get_item_pipeline(self, cls):
if not self.engine:
raise RuntimeError(
"Crawler.get_item_pipeline() can only be called after the "
"crawl engine has been created."
)
return self._get_component(cls, self.engine.scraper.itemproc.middlewares)
def get_spider_middleware(self, cls):
if not self.engine:
raise RuntimeError(
"Crawler.get_spider_middleware() can only be called after the "
"crawl engine has been created."
)
return self._get_component(cls, self.engine.scraper.spidermw.middlewares)
class CrawlerRunner: class CrawlerRunner:
""" """
@ -404,7 +445,9 @@ class CrawlerProcess(CrawlerRunner):
d.addBoth(self._stop_reactor) d.addBoth(self._stop_reactor)
resolver_class = load_object(self.settings["DNS_RESOLVER"]) resolver_class = load_object(self.settings["DNS_RESOLVER"])
resolver = create_instance(resolver_class, self.settings, self, reactor=reactor) # We pass self, which is CrawlerProcess, instead of Crawler here,
# which works because the default resolvers only use crawler.settings.
resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[arg-type]
resolver.install_on_reactor() resolver.install_on_reactor()
tp = reactor.getThreadPool() tp = reactor.getThreadPool()
tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE")) tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE"))

View File

@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
_split_domain = TLDExtract(include_psl_private_domains=True) _split_domain = TLDExtract(include_psl_private_domains=True)
_UNSET = object()
def _is_public_domain(domain: str) -> bool: def _is_public_domain(domain: str) -> bool:
@ -133,6 +134,7 @@ class CookiesMiddleware:
Decode from bytes if necessary. Decode from bytes if necessary.
""" """
decoded = {} decoded = {}
flags = set()
for key in ("name", "value", "path", "domain"): for key in ("name", "value", "path", "domain"):
if cookie.get(key) is None: if cookie.get(key) is None:
if key in ("name", "value"): if key in ("name", "value"):
@ -152,10 +154,16 @@ class CookiesMiddleware:
cookie, cookie,
) )
decoded[key] = cookie[key].decode("latin1", errors="replace") decoded[key] = cookie[key].decode("latin1", errors="replace")
for flag in ("secure",):
value = cookie.get(flag, _UNSET)
if value is _UNSET or not value:
continue
flags.add(flag)
cookie_str = f"{decoded.pop('name')}={decoded.pop('value')}" cookie_str = f"{decoded.pop('name')}={decoded.pop('value')}"
for key, value in decoded.items(): # path, domain for key, value in decoded.items(): # path, domain
cookie_str += f"; {key.capitalize()}={value}" cookie_str += f"; {key.capitalize()}={value}"
for flag in flags: # secure
cookie_str += f"; {flag.capitalize()}"
return cookie_str return cookie_str
def _get_request_cookies( def _get_request_cookies(
@ -168,9 +176,11 @@ class CookiesMiddleware:
return [] return []
cookies: Iterable[Dict[str, Any]] cookies: Iterable[Dict[str, Any]]
if isinstance(request.cookies, dict): if isinstance(request.cookies, dict):
cookies = ({"name": k, "value": v} for k, v in request.cookies.items()) cookies = tuple({"name": k, "value": v} for k, v in request.cookies.items())
else: else:
cookies = request.cookies cookies = request.cookies
for cookie in cookies:
cookie.setdefault("secure", urlparse_cached(request).scheme == "https")
formatted = filter(None, (self._format_cookie(c, request) for c in cookies)) formatted = filter(None, (self._format_cookie(c, request) for c in cookies))
response = Response(request.url, headers={"Set-Cookie": formatted}) response = Response(request.url, headers={"Set-Cookie": formatted})
return jar.make_cookies(response, request) return jar.make_cookies(response, request)

View File

@ -3,6 +3,7 @@ DefaultHeaders downloader middleware
See documentation in docs/topics/downloader-middleware.rst See documentation in docs/topics/downloader-middleware.rst
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Iterable, Tuple, Union from typing import TYPE_CHECKING, Iterable, Tuple, Union

View File

@ -3,6 +3,7 @@ Download timeout middleware
See documentation in docs/topics/downloader-middleware.rst See documentation in docs/topics/downloader-middleware.rst
""" """
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Union from typing import TYPE_CHECKING, Union

View File

@ -1,50 +1,96 @@
from __future__ import annotations from __future__ import annotations
import io import warnings
import zlib from itertools import chain
from logging import getLogger
from typing import TYPE_CHECKING, List, Optional, Union from typing import TYPE_CHECKING, List, Optional, Union
from scrapy import Request, Spider from scrapy import Request, Spider, signals
from scrapy.crawler import Crawler from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import IgnoreRequest, NotConfigured
from scrapy.http import Response, TextResponse from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.statscollectors import StatsCollector from scrapy.statscollectors import StatsCollector
from scrapy.utils._compression import (
_DecompressionMaxSizeExceeded,
_inflate,
_unbrotli,
_unzstd,
)
from scrapy.utils.deprecate import ScrapyDeprecationWarning
from scrapy.utils.gz import gunzip from scrapy.utils.gz import gunzip
if TYPE_CHECKING: if TYPE_CHECKING:
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
from typing_extensions import Self from typing_extensions import Self
logger = getLogger(__name__)
ACCEPTED_ENCODINGS: List[bytes] = [b"gzip", b"deflate"] ACCEPTED_ENCODINGS: List[bytes] = [b"gzip", b"deflate"]
try: try:
import brotli try:
import brotli # noqa: F401
ACCEPTED_ENCODINGS.append(b"br") except ImportError:
import brotlicffi # noqa: F401
except ImportError: except ImportError:
pass pass
else:
ACCEPTED_ENCODINGS.append(b"br")
try: try:
import zstandard import zstandard # noqa: F401
ACCEPTED_ENCODINGS.append(b"zstd")
except ImportError: except ImportError:
pass pass
else:
ACCEPTED_ENCODINGS.append(b"zstd")
class HttpCompressionMiddleware: class HttpCompressionMiddleware:
"""This middleware allows compressed (gzip, deflate) traffic to be """This middleware allows compressed (gzip, deflate) traffic to be
sent/received from web sites""" sent/received from web sites"""
def __init__(self, stats: Optional[StatsCollector] = None): def __init__(
self.stats = stats self,
stats: Optional[StatsCollector] = None,
*,
crawler: Optional[Crawler] = None,
):
if not crawler:
self.stats = stats
self._max_size = 1073741824
self._warn_size = 33554432
return
self.stats = crawler.stats
self._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE")
self._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE")
crawler.signals.connect(self.open_spider, signals.spider_opened)
@classmethod @classmethod
def from_crawler(cls, crawler: Crawler) -> Self: def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("COMPRESSION_ENABLED"): if not crawler.settings.getbool("COMPRESSION_ENABLED"):
raise NotConfigured raise NotConfigured
return cls(stats=crawler.stats) try:
return cls(crawler=crawler)
except TypeError:
warnings.warn(
"HttpCompressionMiddleware subclasses must either modify "
"their '__init__' method to support a 'crawler' parameter or "
"reimplement their 'from_crawler' method.",
ScrapyDeprecationWarning,
)
mw = cls()
mw.stats = crawler.stats
mw._max_size = crawler.settings.getint("DOWNLOAD_MAXSIZE")
mw._warn_size = crawler.settings.getint("DOWNLOAD_WARNSIZE")
crawler.signals.connect(mw.open_spider, signals.spider_opened)
return mw
def open_spider(self, spider):
if hasattr(spider, "download_maxsize"):
self._max_size = spider.download_maxsize
if hasattr(spider, "download_warnsize"):
self._warn_size = spider.download_warnsize
def process_request( def process_request(
self, request: Request, spider: Spider self, request: Request, spider: Spider
@ -60,8 +106,26 @@ class HttpCompressionMiddleware:
if isinstance(response, Response): if isinstance(response, Response):
content_encoding = response.headers.getlist("Content-Encoding") content_encoding = response.headers.getlist("Content-Encoding")
if content_encoding: if content_encoding:
encoding = content_encoding.pop() max_size = request.meta.get("download_maxsize", self._max_size)
decoded_body = self._decode(response.body, encoding.lower()) warn_size = request.meta.get("download_warnsize", self._warn_size)
try:
decoded_body, content_encoding = self._handle_encoding(
response.body, content_encoding, max_size
)
except _DecompressionMaxSizeExceeded:
raise IgnoreRequest(
f"Ignored response {response} because its body "
f"({len(response.body)} B compressed) exceeded "
f"DOWNLOAD_MAXSIZE ({max_size} B) during "
f"decompression."
)
if len(response.body) < warn_size <= len(decoded_body):
logger.warning(
f"{response} body size after decompression "
f"({len(decoded_body)} B) is larger than the "
f"download warning size ({warn_size} B)."
)
response.headers["Content-Encoding"] = content_encoding
if self.stats: if self.stats:
self.stats.inc_value( self.stats.inc_value(
"httpcompression/response_bytes", "httpcompression/response_bytes",
@ -74,7 +138,7 @@ class HttpCompressionMiddleware:
respcls = responsetypes.from_args( respcls = responsetypes.from_args(
headers=response.headers, url=response.url, body=decoded_body headers=response.headers, url=response.url, body=decoded_body
) )
kwargs = dict(cls=respcls, body=decoded_body) kwargs = {"cls": respcls, "body": decoded_body}
if issubclass(respcls, TextResponse): if issubclass(respcls, TextResponse):
# force recalculating the encoding until we make sure the # force recalculating the encoding until we make sure the
# responsetypes guessing is reliable # responsetypes guessing is reliable
@ -85,25 +149,35 @@ class HttpCompressionMiddleware:
return response return response
def _decode(self, body: bytes, encoding: bytes) -> bytes: def _handle_encoding(self, body, content_encoding, max_size):
if encoding == b"gzip" or encoding == b"x-gzip": to_decode, to_keep = self._split_encodings(content_encoding)
body = gunzip(body) for encoding in to_decode:
body = self._decode(body, encoding, max_size)
return body, to_keep
def _split_encodings(self, content_encoding):
to_keep = [
encoding.strip().lower()
for encoding in chain.from_iterable(
encodings.split(b",") for encodings in content_encoding
)
]
to_decode = []
while to_keep:
encoding = to_keep.pop()
if encoding not in ACCEPTED_ENCODINGS:
to_keep.append(encoding)
return to_decode, to_keep
to_decode.append(encoding)
return to_decode, to_keep
def _decode(self, body: bytes, encoding: bytes, max_size: int) -> bytes:
if encoding in {b"gzip", b"x-gzip"}:
return gunzip(body, max_size=max_size)
if encoding == b"deflate": if encoding == b"deflate":
try: return _inflate(body, max_size=max_size)
body = zlib.decompress(body)
except zlib.error:
# ugly hack to work with raw deflate content that may
# be sent by microsoft servers. For more information, see:
# http://carsten.codimi.de/gzip.yaws/
# http://www.port80software.com/200ok/archive/2005/10/31/868.aspx
# http://www.gzip.org/zlib/zlib_faq.html#faq38
body = zlib.decompress(body, -15)
if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS: if encoding == b"br" and b"br" in ACCEPTED_ENCODINGS:
body = brotli.decompress(body) return _unbrotli(body, max_size=max_size)
if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS: if encoding == b"zstd" and b"zstd" in ACCEPTED_ENCODINGS:
# Using its streaming API since its simple API could handle only cases return _unzstd(body, max_size=max_size)
# where there is content size data embedded in the frame
reader = zstandard.ZstdDecompressor().stream_reader(io.BytesIO(body))
body = reader.read()
return body return body

View File

@ -60,26 +60,33 @@ class HttpProxyMiddleware:
def process_request( def process_request(
self, request: Request, spider: Spider self, request: Request, spider: Spider
) -> Union[Request, Response, None]: ) -> Union[Request, Response, None]:
creds, proxy_url = None, None creds, proxy_url, scheme = None, None, None
if "proxy" in request.meta: if "proxy" in request.meta:
if request.meta["proxy"] is not None: if request.meta["proxy"] is not None:
creds, proxy_url = self._get_proxy(request.meta["proxy"], "") creds, proxy_url = self._get_proxy(request.meta["proxy"], "")
elif self.proxies: elif self.proxies:
parsed = urlparse_cached(request) parsed = urlparse_cached(request)
scheme = parsed.scheme _scheme = parsed.scheme
if ( if (
# 'no_proxy' is only supported by http schemes # 'no_proxy' is only supported by http schemes
scheme not in ("http", "https") _scheme not in ("http", "https")
or (parsed.hostname and not proxy_bypass(parsed.hostname)) or (parsed.hostname and not proxy_bypass(parsed.hostname))
) and scheme in self.proxies: ) and _scheme in self.proxies:
scheme = _scheme
creds, proxy_url = self.proxies[scheme] creds, proxy_url = self.proxies[scheme]
self._set_proxy_and_creds(request, proxy_url, creds) self._set_proxy_and_creds(request, proxy_url, creds, scheme)
return None return None
def _set_proxy_and_creds( def _set_proxy_and_creds(
self, request: Request, proxy_url: Optional[str], creds: Optional[bytes] self,
request: Request,
proxy_url: Optional[str],
creds: Optional[bytes],
scheme: Optional[str],
) -> None: ) -> None:
if scheme:
request.meta["_scheme_proxy"] = True
if proxy_url: if proxy_url:
request.meta["proxy"] = proxy_url request.meta["proxy"] = proxy_url
elif request.meta.get("proxy") is not None: elif request.meta.get("proxy") is not None:

View File

@ -0,0 +1,77 @@
import logging
import re
import warnings
from scrapy import signals
from scrapy.exceptions import IgnoreRequest
from scrapy.utils.httpobj import urlparse_cached
logger = logging.getLogger(__name__)
class OffsiteMiddleware:
@classmethod
def from_crawler(cls, crawler):
o = cls(crawler.stats)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
crawler.signals.connect(o.request_scheduled, signal=signals.request_scheduled)
return o
def __init__(self, stats):
self.stats = stats
self.domains_seen = set()
def spider_opened(self, spider):
self.host_regex = self.get_host_regex(spider)
def request_scheduled(self, request, spider):
self.process_request(request, spider)
def process_request(self, request, spider):
if request.dont_filter or self.should_follow(request, spider):
return None
domain = urlparse_cached(request).hostname
if domain and domain not in self.domains_seen:
self.domains_seen.add(domain)
logger.debug(
"Filtered offsite request to %(domain)r: %(request)s",
{"domain": domain, "request": request},
extra={"spider": spider},
)
self.stats.inc_value("offsite/domains", spider=spider)
self.stats.inc_value("offsite/filtered", spider=spider)
raise IgnoreRequest
def should_follow(self, request, spider):
regex = self.host_regex
# hostname can be None for wrong urls (like javascript links)
host = urlparse_cached(request).hostname or ""
return bool(regex.search(host))
def get_host_regex(self, spider):
"""Override this method to implement a different offsite policy"""
allowed_domains = getattr(spider, "allowed_domains", None)
if not allowed_domains:
return re.compile("") # allow all by default
url_pattern = re.compile(r"^https?://.*$")
port_pattern = re.compile(r":\d+$")
domains = []
for domain in allowed_domains:
if domain is None:
continue
if url_pattern.match(domain):
message = (
"allowed_domains accepts only domains, not URLs. "
f"Ignoring URL entry {domain} in allowed_domains."
)
warnings.warn(message)
elif port_pattern.search(domain):
message = (
"allowed_domains accepts only domains without ports. "
f"Ignoring entry {domain} in allowed_domains."
)
warnings.warn(message)
else:
domains.append(re.escape(domain))
regex = rf'^(.*\.)?({"|".join(domains)})$'
return re.compile(regex)

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any, List, Union, cast from typing import TYPE_CHECKING, Any, List, Union, cast
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin
from w3lib.url import safe_url_string from w3lib.url import safe_url_string
@ -29,11 +29,49 @@ def _build_redirect_request(
**kwargs, **kwargs,
cookies=None, cookies=None,
) )
if "Cookie" in redirect_request.headers: if "_scheme_proxy" in redirect_request.meta:
source_request_netloc = urlparse_cached(source_request).netloc source_request_scheme = urlparse_cached(source_request).scheme
redirect_request_netloc = urlparse_cached(redirect_request).netloc redirect_request_scheme = urlparse_cached(redirect_request).scheme
if source_request_netloc != redirect_request_netloc: if source_request_scheme != redirect_request_scheme:
redirect_request.meta.pop("_scheme_proxy")
redirect_request.meta.pop("proxy", None)
redirect_request.meta.pop("_auth_proxy", None)
redirect_request.headers.pop(b"Proxy-Authorization", None)
has_cookie_header = "Cookie" in redirect_request.headers
has_authorization_header = "Authorization" in redirect_request.headers
if has_cookie_header or has_authorization_header:
default_ports = {"http": 80, "https": 443}
parsed_source_request = urlparse_cached(source_request)
source_scheme, source_host, source_port = (
parsed_source_request.scheme,
parsed_source_request.hostname,
parsed_source_request.port
or default_ports.get(parsed_source_request.scheme),
)
parsed_redirect_request = urlparse_cached(redirect_request)
redirect_scheme, redirect_host, redirect_port = (
parsed_redirect_request.scheme,
parsed_redirect_request.hostname,
parsed_redirect_request.port
or default_ports.get(parsed_redirect_request.scheme),
)
if has_cookie_header and (
redirect_scheme not in {source_scheme, "https"}
or source_host != redirect_host
):
del redirect_request.headers["Cookie"] del redirect_request.headers["Cookie"]
# https://fetch.spec.whatwg.org/#ref-for-cors-non-wildcard-request-header-name
if has_authorization_header and (
source_scheme != redirect_scheme
or source_host != redirect_host
or source_port != redirect_port
):
del redirect_request.headers["Authorization"]
return redirect_request return redirect_request
@ -119,13 +157,15 @@ class RedirectMiddleware(BaseRedirectMiddleware):
assert response.headers["Location"] is not None assert response.headers["Location"] is not None
location = safe_url_string(response.headers["Location"]) location = safe_url_string(response.headers["Location"])
if response.headers["Location"].startswith(b"//"): if response.headers["Location"].startswith(b"//"):
request_scheme = urlparse(request.url).scheme request_scheme = urlparse_cached(request).scheme
location = request_scheme + "://" + location.lstrip("/") location = request_scheme + "://" + location.lstrip("/")
redirected_url = urljoin(request.url, location) redirected_url = urljoin(request.url, location)
redirected = _build_redirect_request(request, url=redirected_url)
if urlparse_cached(redirected).scheme not in {"http", "https"}:
return response
if response.status in (301, 307, 308) or request.method == "HEAD": if response.status in (301, 307, 308) or request.method == "HEAD":
redirected = _build_redirect_request(request, url=redirected_url)
return self._redirect(redirected, request, spider, response.status) return self._redirect(redirected, request, spider, response.status)
redirected = self._redirect_request_using_get(request, redirected_url) redirected = self._redirect_request_using_get(request, redirected_url)
@ -147,12 +187,16 @@ class MetaRefreshMiddleware(BaseRedirectMiddleware):
request.meta.get("dont_redirect", False) request.meta.get("dont_redirect", False)
or request.method == "HEAD" or request.method == "HEAD"
or not isinstance(response, HtmlResponse) or not isinstance(response, HtmlResponse)
or urlparse_cached(request).scheme not in {"http", "https"}
): ):
return response return response
interval, url = get_meta_refresh(response, ignore_tags=self._ignore_tags) interval, url = get_meta_refresh(response, ignore_tags=self._ignore_tags)
if url and cast(float, interval) < self._maxdelay: if not url:
redirected = self._redirect_request_using_get(request, url) return response
redirected = self._redirect_request_using_get(request, url)
if urlparse_cached(redirected).scheme not in {"http", "https"}:
return response
if cast(float, interval) < self._maxdelay:
return self._redirect(redirected, request, spider, "meta refresh") return self._redirect(redirected, request, spider, "meta refresh")
return response return response

View File

@ -9,6 +9,7 @@ RETRY_HTTP_CODES - which HTTP response codes to retry
Failed pages are collected on the scraping process and rescheduled at the end, Failed pages are collected on the scraping process and rescheduled at the end,
once the spider has finished crawling all regular (non failed) pages. once the spider has finished crawling all regular (non failed) pages.
""" """
from __future__ import annotations from __future__ import annotations
import warnings import warnings

View File

@ -4,6 +4,7 @@ Scrapy core exceptions
These exceptions are documented in docs/topics/exceptions.rst. Please don't add These exceptions are documented in docs/topics/exceptions.rst. Please don't add
new exceptions here without documenting them there. new exceptions here without documenting them there.
""" """
from typing import Any from typing import Any
# Internal # Internal

View File

@ -3,16 +3,18 @@ Item Exporters are used to export/serialize items into different formats.
""" """
import csv import csv
import io
import marshal import marshal
import pickle import pickle # nosec
import pprint import pprint
from collections.abc import Mapping from io import BytesIO, TextIOWrapper
from xml.sax.saxutils import XMLGenerator from json import JSONEncoder
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple, Union
from xml.sax.saxutils import XMLGenerator # nosec
from xml.sax.xmlreader import AttributesImpl # nosec
from itemadapter import ItemAdapter, is_item from itemadapter import ItemAdapter, is_item
from scrapy.item import Item from scrapy.item import Field, Item
from scrapy.utils.python import is_listlike, to_bytes, to_unicode from scrapy.utils.python import is_listlike, to_bytes, to_unicode
from scrapy.utils.serialize import ScrapyJSONEncoder from scrapy.utils.serialize import ScrapyJSONEncoder
@ -29,36 +31,42 @@ __all__ = [
class BaseItemExporter: class BaseItemExporter:
def __init__(self, *, dont_fail=False, **kwargs): def __init__(self, *, dont_fail: bool = False, **kwargs: Any):
self._kwargs = kwargs self._kwargs: Dict[str, Any] = kwargs
self._configure(kwargs, dont_fail=dont_fail) self._configure(kwargs, dont_fail=dont_fail)
def _configure(self, options, dont_fail=False): def _configure(self, options: Dict[str, Any], dont_fail: bool = False) -> None:
"""Configure the exporter by popping options from the ``options`` dict. """Configure the exporter by popping options from the ``options`` dict.
If dont_fail is set, it won't raise an exception on unexpected options If dont_fail is set, it won't raise an exception on unexpected options
(useful for using with keyword arguments in subclasses ``__init__`` methods) (useful for using with keyword arguments in subclasses ``__init__`` methods)
""" """
self.encoding = options.pop("encoding", None) self.encoding: Optional[str] = options.pop("encoding", None)
self.fields_to_export = options.pop("fields_to_export", None) self.fields_to_export: Union[Mapping[str, str], Iterable[str], None] = (
self.export_empty_fields = options.pop("export_empty_fields", False) options.pop("fields_to_export", None)
self.indent = options.pop("indent", None) )
self.export_empty_fields: bool = options.pop("export_empty_fields", False)
self.indent: Optional[int] = options.pop("indent", None)
if not dont_fail and options: if not dont_fail and options:
raise TypeError(f"Unexpected options: {', '.join(options.keys())}") raise TypeError(f"Unexpected options: {', '.join(options.keys())}")
def export_item(self, item): def export_item(self, item: Any) -> None:
raise NotImplementedError raise NotImplementedError
def serialize_field(self, field, name, value): def serialize_field(
serializer = field.get("serializer", lambda x: x) self, field: Union[Mapping[str, Any], Field], name: str, value: Any
) -> Any:
serializer: Callable[[Any], Any] = field.get("serializer", lambda x: x)
return serializer(value) return serializer(value)
def start_exporting(self): def start_exporting(self) -> None:
pass pass
def finish_exporting(self): def finish_exporting(self) -> None:
pass pass
def _get_serialized_fields(self, item, default_value=None, include_empty=None): def _get_serialized_fields(
self, item: Any, default_value: Any = None, include_empty: Optional[bool] = None
) -> Iterable[Tuple[str, Any]]:
"""Return the fields to export as an iterable of tuples """Return the fields to export as an iterable of tuples
(name, serialized_value) (name, serialized_value)
""" """
@ -100,22 +108,22 @@ class BaseItemExporter:
class JsonLinesItemExporter(BaseItemExporter): class JsonLinesItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs): def __init__(self, file: BytesIO, **kwargs: Any):
super().__init__(dont_fail=True, **kwargs) super().__init__(dont_fail=True, **kwargs)
self.file = file self.file: BytesIO = file
self._kwargs.setdefault("ensure_ascii", not self.encoding) self._kwargs.setdefault("ensure_ascii", not self.encoding)
self.encoder = ScrapyJSONEncoder(**self._kwargs) self.encoder: JSONEncoder = ScrapyJSONEncoder(**self._kwargs)
def export_item(self, item): def export_item(self, item: Any) -> None:
itemdict = dict(self._get_serialized_fields(item)) itemdict = dict(self._get_serialized_fields(item))
data = self.encoder.encode(itemdict) + "\n" data = self.encoder.encode(itemdict) + "\n"
self.file.write(to_bytes(data, self.encoding)) self.file.write(to_bytes(data, self.encoding))
class JsonItemExporter(BaseItemExporter): class JsonItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs): def __init__(self, file: BytesIO, **kwargs: Any):
super().__init__(dont_fail=True, **kwargs) super().__init__(dont_fail=True, **kwargs)
self.file = file self.file: BytesIO = file
# there is a small difference between the behaviour or JsonItemExporter.indent # there is a small difference between the behaviour or JsonItemExporter.indent
# and ScrapyJSONEncoder.indent. ScrapyJSONEncoder.indent=None is needed to prevent # and ScrapyJSONEncoder.indent. ScrapyJSONEncoder.indent=None is needed to prevent
# the addition of newlines everywhere # the addition of newlines everywhere
@ -127,26 +135,26 @@ class JsonItemExporter(BaseItemExporter):
self.encoder = ScrapyJSONEncoder(**self._kwargs) self.encoder = ScrapyJSONEncoder(**self._kwargs)
self.first_item = True self.first_item = True
def _beautify_newline(self): def _beautify_newline(self) -> None:
if self.indent is not None: if self.indent is not None:
self.file.write(b"\n") self.file.write(b"\n")
def _add_comma_after_first(self): def _add_comma_after_first(self) -> None:
if self.first_item: if self.first_item:
self.first_item = False self.first_item = False
else: else:
self.file.write(b",") self.file.write(b",")
self._beautify_newline() self._beautify_newline()
def start_exporting(self): def start_exporting(self) -> None:
self.file.write(b"[") self.file.write(b"[")
self._beautify_newline() self._beautify_newline()
def finish_exporting(self): def finish_exporting(self) -> None:
self._beautify_newline() self._beautify_newline()
self.file.write(b"]") self.file.write(b"]")
def export_item(self, item): def export_item(self, item: Any) -> None:
itemdict = dict(self._get_serialized_fields(item)) itemdict = dict(self._get_serialized_fields(item))
data = to_bytes(self.encoder.encode(itemdict), self.encoding) data = to_bytes(self.encoder.encode(itemdict), self.encoding)
self._add_comma_after_first() self._add_comma_after_first()
@ -154,7 +162,7 @@ class JsonItemExporter(BaseItemExporter):
class XmlItemExporter(BaseItemExporter): class XmlItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs): def __init__(self, file: BytesIO, **kwargs: Any):
self.item_element = kwargs.pop("item_element", "item") self.item_element = kwargs.pop("item_element", "item")
self.root_element = kwargs.pop("root_element", "items") self.root_element = kwargs.pop("root_element", "items")
super().__init__(**kwargs) super().__init__(**kwargs)
@ -162,22 +170,22 @@ class XmlItemExporter(BaseItemExporter):
self.encoding = "utf-8" self.encoding = "utf-8"
self.xg = XMLGenerator(file, encoding=self.encoding) self.xg = XMLGenerator(file, encoding=self.encoding)
def _beautify_newline(self, new_item=False): def _beautify_newline(self, new_item: bool = False) -> None:
if self.indent is not None and (self.indent > 0 or new_item): if self.indent is not None and (self.indent > 0 or new_item):
self.xg.characters("\n") self.xg.characters("\n")
def _beautify_indent(self, depth=1): def _beautify_indent(self, depth: int = 1) -> None:
if self.indent: if self.indent:
self.xg.characters(" " * self.indent * depth) self.xg.characters(" " * self.indent * depth)
def start_exporting(self): def start_exporting(self) -> None:
self.xg.startDocument() self.xg.startDocument()
self.xg.startElement(self.root_element, {}) self.xg.startElement(self.root_element, AttributesImpl({}))
self._beautify_newline(new_item=True) self._beautify_newline(new_item=True)
def export_item(self, item): def export_item(self, item: Any) -> None:
self._beautify_indent(depth=1) self._beautify_indent(depth=1)
self.xg.startElement(self.item_element, {}) self.xg.startElement(self.item_element, AttributesImpl({}))
self._beautify_newline() self._beautify_newline()
for name, value in self._get_serialized_fields(item, default_value=""): for name, value in self._get_serialized_fields(item, default_value=""):
self._export_xml_field(name, value, depth=2) self._export_xml_field(name, value, depth=2)
@ -185,13 +193,13 @@ class XmlItemExporter(BaseItemExporter):
self.xg.endElement(self.item_element) self.xg.endElement(self.item_element)
self._beautify_newline(new_item=True) self._beautify_newline(new_item=True)
def finish_exporting(self): def finish_exporting(self) -> None:
self.xg.endElement(self.root_element) self.xg.endElement(self.root_element)
self.xg.endDocument() self.xg.endDocument()
def _export_xml_field(self, name, serialized_value, depth): def _export_xml_field(self, name: str, serialized_value: Any, depth: int) -> None:
self._beautify_indent(depth=depth) self._beautify_indent(depth=depth)
self.xg.startElement(name, {}) self.xg.startElement(name, AttributesImpl({}))
if hasattr(serialized_value, "items"): if hasattr(serialized_value, "items"):
self._beautify_newline() self._beautify_newline()
for subname, value in serialized_value.items(): for subname, value in serialized_value.items():
@ -213,17 +221,17 @@ class XmlItemExporter(BaseItemExporter):
class CsvItemExporter(BaseItemExporter): class CsvItemExporter(BaseItemExporter):
def __init__( def __init__(
self, self,
file, file: BytesIO,
include_headers_line=True, include_headers_line: bool = True,
join_multivalued=",", join_multivalued: str = ",",
errors=None, errors: Optional[str] = None,
**kwargs, **kwargs: Any,
): ):
super().__init__(dont_fail=True, **kwargs) super().__init__(dont_fail=True, **kwargs)
if not self.encoding: if not self.encoding:
self.encoding = "utf-8" self.encoding = "utf-8"
self.include_headers_line = include_headers_line self.include_headers_line = include_headers_line
self.stream = io.TextIOWrapper( self.stream = TextIOWrapper(
file, file,
line_buffering=False, line_buffering=False,
write_through=True, write_through=True,
@ -235,11 +243,13 @@ class CsvItemExporter(BaseItemExporter):
self._headers_not_written = True self._headers_not_written = True
self._join_multivalued = join_multivalued self._join_multivalued = join_multivalued
def serialize_field(self, field, name, value): def serialize_field(
serializer = field.get("serializer", self._join_if_needed) self, field: Union[Mapping[str, Any], Field], name: str, value: Any
) -> Any:
serializer: Callable[[Any], Any] = field.get("serializer", self._join_if_needed)
return serializer(value) return serializer(value)
def _join_if_needed(self, value): def _join_if_needed(self, value: Any) -> Any:
if isinstance(value, (list, tuple)): if isinstance(value, (list, tuple)):
try: try:
return self._join_multivalued.join(value) return self._join_multivalued.join(value)
@ -247,7 +257,7 @@ class CsvItemExporter(BaseItemExporter):
pass pass
return value return value
def export_item(self, item): def export_item(self, item: Any) -> None:
if self._headers_not_written: if self._headers_not_written:
self._headers_not_written = False self._headers_not_written = False
self._write_headers_and_set_fields_to_export(item) self._write_headers_and_set_fields_to_export(item)
@ -256,36 +266,38 @@ class CsvItemExporter(BaseItemExporter):
values = list(self._build_row(x for _, x in fields)) values = list(self._build_row(x for _, x in fields))
self.csv_writer.writerow(values) self.csv_writer.writerow(values)
def finish_exporting(self): def finish_exporting(self) -> None:
self.stream.detach() # Avoid closing the wrapped file. self.stream.detach() # Avoid closing the wrapped file.
def _build_row(self, values): def _build_row(self, values: Iterable[Any]) -> Iterable[Any]:
for s in values: for s in values:
try: try:
yield to_unicode(s, self.encoding) yield to_unicode(s, self.encoding)
except TypeError: except TypeError:
yield s yield s
def _write_headers_and_set_fields_to_export(self, item): def _write_headers_and_set_fields_to_export(self, item: Any) -> None:
if self.include_headers_line: if self.include_headers_line:
if not self.fields_to_export: if not self.fields_to_export:
# use declared field names, or keys if the item is a dict # use declared field names, or keys if the item is a dict
self.fields_to_export = ItemAdapter(item).field_names() self.fields_to_export = ItemAdapter(item).field_names()
fields: Iterable[str]
if isinstance(self.fields_to_export, Mapping): if isinstance(self.fields_to_export, Mapping):
fields = self.fields_to_export.values() fields = self.fields_to_export.values()
else: else:
assert self.fields_to_export
fields = self.fields_to_export fields = self.fields_to_export
row = list(self._build_row(fields)) row = list(self._build_row(fields))
self.csv_writer.writerow(row) self.csv_writer.writerow(row)
class PickleItemExporter(BaseItemExporter): class PickleItemExporter(BaseItemExporter):
def __init__(self, file, protocol=4, **kwargs): def __init__(self, file: BytesIO, protocol: int = 4, **kwargs: Any):
super().__init__(**kwargs) super().__init__(**kwargs)
self.file = file self.file: BytesIO = file
self.protocol = protocol self.protocol: int = protocol
def export_item(self, item): def export_item(self, item: Any) -> None:
d = dict(self._get_serialized_fields(item)) d = dict(self._get_serialized_fields(item))
pickle.dump(d, self.file, self.protocol) pickle.dump(d, self.file, self.protocol)
@ -299,20 +311,20 @@ class MarshalItemExporter(BaseItemExporter):
opened in binary mode, a :class:`~io.BytesIO` object, etc) opened in binary mode, a :class:`~io.BytesIO` object, etc)
""" """
def __init__(self, file, **kwargs): def __init__(self, file: BytesIO, **kwargs: Any):
super().__init__(**kwargs) super().__init__(**kwargs)
self.file = file self.file: BytesIO = file
def export_item(self, item): def export_item(self, item: Any) -> None:
marshal.dump(dict(self._get_serialized_fields(item)), self.file) marshal.dump(dict(self._get_serialized_fields(item)), self.file)
class PprintItemExporter(BaseItemExporter): class PprintItemExporter(BaseItemExporter):
def __init__(self, file, **kwargs): def __init__(self, file: BytesIO, **kwargs: Any):
super().__init__(**kwargs) super().__init__(**kwargs)
self.file = file self.file: BytesIO = file
def export_item(self, item): def export_item(self, item: Any) -> None:
itemdict = dict(self._get_serialized_fields(item)) itemdict = dict(self._get_serialized_fields(item))
self.file.write(to_bytes(pprint.pformat(itemdict) + "\n")) self.file.write(to_bytes(pprint.pformat(itemdict) + "\n"))
@ -327,16 +339,20 @@ class PythonItemExporter(BaseItemExporter):
.. _msgpack: https://pypi.org/project/msgpack/ .. _msgpack: https://pypi.org/project/msgpack/
""" """
def _configure(self, options, dont_fail=False): def _configure(self, options: Dict[str, Any], dont_fail: bool = False) -> None:
super()._configure(options, dont_fail) super()._configure(options, dont_fail)
if not self.encoding: if not self.encoding:
self.encoding = "utf-8" self.encoding = "utf-8"
def serialize_field(self, field, name, value): def serialize_field(
serializer = field.get("serializer", self._serialize_value) self, field: Union[Mapping[str, Any], Field], name: str, value: Any
) -> Any:
serializer: Callable[[Any], Any] = field.get(
"serializer", self._serialize_value
)
return serializer(value) return serializer(value)
def _serialize_value(self, value): def _serialize_value(self, value: Any) -> Any:
if isinstance(value, Item): if isinstance(value, Item):
return self.export_item(value) return self.export_item(value)
if is_item(value): if is_item(value):
@ -347,10 +363,10 @@ class PythonItemExporter(BaseItemExporter):
return to_unicode(value, encoding=self.encoding) return to_unicode(value, encoding=self.encoding)
return value return value
def _serialize_item(self, item): def _serialize_item(self, item: Any) -> Iterable[Tuple[Union[str, bytes], Any]]:
for key, value in ItemAdapter(item).items(): for key, value in ItemAdapter(item).items():
yield key, self._serialize_value(value) yield key, self._serialize_value(value)
def export_item(self, item): def export_item(self, item: Any) -> Dict[Union[str, bytes], Any]: # type: ignore[override]
result = dict(self._get_serialized_fields(item)) result: Dict[Union[str, bytes], Any] = dict(self._get_serialized_fields(item))
return result return result

View File

@ -3,7 +3,11 @@ The Extension Manager
See documentation in docs/topics/extensions.rst See documentation in docs/topics/extensions.rst
""" """
from typing import Any, List
from scrapy.middleware import MiddlewareManager from scrapy.middleware import MiddlewareManager
from scrapy.settings import Settings
from scrapy.utils.conf import build_component_list from scrapy.utils.conf import build_component_list
@ -11,5 +15,5 @@ class ExtensionManager(MiddlewareManager):
component_name = "extension" component_name = "extension"
@classmethod @classmethod
def _get_mwlist_from_settings(cls, settings): def _get_mwlist_from_settings(cls, settings: Settings) -> List[Any]:
return build_component_list(settings.getwithbase("EXTENSIONS")) return build_component_list(settings.getwithbase("EXTENSIONS"))

View File

@ -4,20 +4,31 @@ conditions are met.
See documentation in docs/topics/extensions.rst See documentation in docs/topics/extensions.rst
""" """
from __future__ import annotations
import logging import logging
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING, Any, DefaultDict, Dict
from scrapy import signals from twisted.python.failure import Failure
from scrapy import Request, Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.http import Response
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CloseSpider: class CloseSpider:
def __init__(self, crawler): def __init__(self, crawler: Crawler):
self.crawler = crawler self.crawler: Crawler = crawler
self.close_on = { self.close_on: Dict[str, Any] = {
"timeout": crawler.settings.getfloat("CLOSESPIDER_TIMEOUT"), "timeout": crawler.settings.getfloat("CLOSESPIDER_TIMEOUT"),
"itemcount": crawler.settings.getint("CLOSESPIDER_ITEMCOUNT"), "itemcount": crawler.settings.getint("CLOSESPIDER_ITEMCOUNT"),
"pagecount": crawler.settings.getint("CLOSESPIDER_PAGECOUNT"), "pagecount": crawler.settings.getint("CLOSESPIDER_PAGECOUNT"),
@ -28,7 +39,7 @@ class CloseSpider:
if not any(self.close_on.values()): if not any(self.close_on.values()):
raise NotConfigured raise NotConfigured
self.counter = defaultdict(int) self.counter: DefaultDict[str, int] = defaultdict(int)
if self.close_on.get("errorcount"): if self.close_on.get("errorcount"):
crawler.signals.connect(self.error_count, signal=signals.spider_error) crawler.signals.connect(self.error_count, signal=signals.spider_error)
@ -39,8 +50,8 @@ class CloseSpider:
if self.close_on.get("itemcount"): if self.close_on.get("itemcount"):
crawler.signals.connect(self.item_scraped, signal=signals.item_scraped) crawler.signals.connect(self.item_scraped, signal=signals.item_scraped)
if self.close_on.get("timeout_no_item"): if self.close_on.get("timeout_no_item"):
self.timeout_no_item = self.close_on["timeout_no_item"] self.timeout_no_item: int = self.close_on["timeout_no_item"]
self.items_in_period = 0 self.items_in_period: int = 0
crawler.signals.connect( crawler.signals.connect(
self.spider_opened_no_item, signal=signals.spider_opened self.spider_opened_no_item, signal=signals.spider_opened
) )
@ -50,22 +61,25 @@ class CloseSpider:
crawler.signals.connect(self.spider_closed, signal=signals.spider_closed) crawler.signals.connect(self.spider_closed, signal=signals.spider_closed)
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler) return cls(crawler)
def error_count(self, failure, response, spider): def error_count(self, failure: Failure, response: Response, spider: Spider) -> None:
self.counter["errorcount"] += 1 self.counter["errorcount"] += 1
if self.counter["errorcount"] == self.close_on["errorcount"]: if self.counter["errorcount"] == self.close_on["errorcount"]:
assert self.crawler.engine
self.crawler.engine.close_spider(spider, "closespider_errorcount") self.crawler.engine.close_spider(spider, "closespider_errorcount")
def page_count(self, response, request, spider): def page_count(self, response: Response, request: Request, spider: Spider) -> None:
self.counter["pagecount"] += 1 self.counter["pagecount"] += 1
if self.counter["pagecount"] == self.close_on["pagecount"]: if self.counter["pagecount"] == self.close_on["pagecount"]:
assert self.crawler.engine
self.crawler.engine.close_spider(spider, "closespider_pagecount") self.crawler.engine.close_spider(spider, "closespider_pagecount")
def spider_opened(self, spider): def spider_opened(self, spider: Spider) -> None:
from twisted.internet import reactor from twisted.internet import reactor
assert self.crawler.engine
self.task = reactor.callLater( self.task = reactor.callLater(
self.close_on["timeout"], self.close_on["timeout"],
self.crawler.engine.close_spider, self.crawler.engine.close_spider,
@ -73,21 +87,22 @@ class CloseSpider:
reason="closespider_timeout", reason="closespider_timeout",
) )
def item_scraped(self, item, spider): def item_scraped(self, item: Any, spider: Spider) -> None:
self.counter["itemcount"] += 1 self.counter["itemcount"] += 1
if self.counter["itemcount"] == self.close_on["itemcount"]: if self.counter["itemcount"] == self.close_on["itemcount"]:
assert self.crawler.engine
self.crawler.engine.close_spider(spider, "closespider_itemcount") self.crawler.engine.close_spider(spider, "closespider_itemcount")
def spider_closed(self, spider): def spider_closed(self, spider: Spider) -> None:
task = getattr(self, "task", False) task = getattr(self, "task", None)
if task and task.active(): if task and task.active():
task.cancel() task.cancel()
task_no_item = getattr(self, "task_no_item", False) task_no_item = getattr(self, "task_no_item", None)
if task_no_item and task_no_item.running: if task_no_item and task_no_item.running:
task_no_item.stop() task_no_item.stop()
def spider_opened_no_item(self, spider): def spider_opened_no_item(self, spider: Spider) -> None:
from twisted.internet import task from twisted.internet import task
self.task_no_item = task.LoopingCall(self._count_items_produced, spider) self.task_no_item = task.LoopingCall(self._count_items_produced, spider)
@ -98,10 +113,10 @@ class CloseSpider:
f"{self.timeout_no_item} seconds." f"{self.timeout_no_item} seconds."
) )
def item_scraped_no_item(self, item, spider): def item_scraped_no_item(self, item: Any, spider: Spider) -> None:
self.items_in_period += 1 self.items_in_period += 1
def _count_items_produced(self, spider): def _count_items_produced(self, spider: Spider) -> None:
if self.items_in_period >= 1: if self.items_in_period >= 1:
self.items_in_period = 0 self.items_in_period = 0
else: else:
@ -109,4 +124,5 @@ class CloseSpider:
f"Closing spider since no items were produced in the last " f"Closing spider since no items were produced in the last "
f"{self.timeout_no_item} seconds." f"{self.timeout_no_item} seconds."
) )
assert self.crawler.engine
self.crawler.engine.close_spider(spider, "closespider_timeout_no_item") self.crawler.engine.close_spider(spider, "closespider_timeout_no_item")

View File

@ -1,18 +1,29 @@
""" """
Extension for collecting core stats like items scraped and start/finish times Extension for collecting core stats like items scraped and start/finish times
""" """
from datetime import datetime, timezone
from scrapy import signals from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, Optional
from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.statscollectors import StatsCollector
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
class CoreStats: class CoreStats:
def __init__(self, stats): def __init__(self, stats: StatsCollector):
self.stats = stats self.stats: StatsCollector = stats
self.start_time = None self.start_time: Optional[datetime] = None
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
assert crawler.stats
o = cls(crawler.stats) o = cls(crawler.stats)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
@ -21,11 +32,12 @@ class CoreStats:
crawler.signals.connect(o.response_received, signal=signals.response_received) crawler.signals.connect(o.response_received, signal=signals.response_received)
return o return o
def spider_opened(self, spider): def spider_opened(self, spider: Spider) -> None:
self.start_time = datetime.now(tz=timezone.utc) self.start_time = datetime.now(tz=timezone.utc)
self.stats.set_value("start_time", self.start_time, spider=spider) self.stats.set_value("start_time", self.start_time, spider=spider)
def spider_closed(self, spider, reason): def spider_closed(self, spider: Spider, reason: str) -> None:
assert self.start_time is not None
finish_time = datetime.now(tz=timezone.utc) finish_time = datetime.now(tz=timezone.utc)
elapsed_time = finish_time - self.start_time elapsed_time = finish_time - self.start_time
elapsed_time_seconds = elapsed_time.total_seconds() elapsed_time_seconds = elapsed_time.total_seconds()
@ -35,13 +47,13 @@ class CoreStats:
self.stats.set_value("finish_time", finish_time, spider=spider) self.stats.set_value("finish_time", finish_time, spider=spider)
self.stats.set_value("finish_reason", reason, spider=spider) self.stats.set_value("finish_reason", reason, spider=spider)
def item_scraped(self, item, spider): def item_scraped(self, item: Any, spider: Spider) -> None:
self.stats.inc_value("item_scraped_count", spider=spider) self.stats.inc_value("item_scraped_count", spider=spider)
def response_received(self, spider): def response_received(self, spider: Spider) -> None:
self.stats.inc_value("response_received_count", spider=spider) self.stats.inc_value("response_received_count", spider=spider)
def item_dropped(self, item, spider, exception): def item_dropped(self, item: Any, spider: Spider, exception: BaseException) -> None:
reason = exception.__class__.__name__ reason = exception.__class__.__name__
self.stats.inc_value("item_dropped_count", spider=spider) self.stats.inc_value("item_dropped_count", spider=spider)
self.stats.inc_value(f"item_dropped_reasons_count/{reason}", spider=spider) self.stats.inc_value(f"item_dropped_reasons_count/{reason}", spider=spider)

View File

@ -4,22 +4,31 @@ Extensions for debugging Scrapy
See documentation in docs/topics/extensions.rst See documentation in docs/topics/extensions.rst
""" """
from __future__ import annotations
import logging import logging
import signal import signal
import sys import sys
import threading import threading
import traceback import traceback
from pdb import Pdb from pdb import Pdb
from types import FrameType
from typing import TYPE_CHECKING, Optional
from scrapy.crawler import Crawler
from scrapy.utils.engine import format_engine_status from scrapy.utils.engine import format_engine_status
from scrapy.utils.trackref import format_live_refs from scrapy.utils.trackref import format_live_refs
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class StackTraceDump: class StackTraceDump:
def __init__(self, crawler=None): def __init__(self, crawler: Crawler):
self.crawler = crawler self.crawler: Crawler = crawler
try: try:
signal.signal(signal.SIGUSR2, self.dump_stacktrace) signal.signal(signal.SIGUSR2, self.dump_stacktrace)
signal.signal(signal.SIGQUIT, self.dump_stacktrace) signal.signal(signal.SIGQUIT, self.dump_stacktrace)
@ -28,10 +37,11 @@ class StackTraceDump:
pass pass
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler) return cls(crawler)
def dump_stacktrace(self, signum, frame): def dump_stacktrace(self, signum: int, frame: Optional[FrameType]) -> None:
assert self.crawler.engine
log_args = { log_args = {
"stackdumps": self._thread_stacks(), "stackdumps": self._thread_stacks(),
"enginestatus": format_engine_status(self.crawler.engine), "enginestatus": format_engine_status(self.crawler.engine),
@ -44,7 +54,7 @@ class StackTraceDump:
extra={"crawler": self.crawler}, extra={"crawler": self.crawler},
) )
def _thread_stacks(self): def _thread_stacks(self) -> str:
id2name = dict((th.ident, th.name) for th in threading.enumerate()) id2name = dict((th.ident, th.name) for th in threading.enumerate())
dumps = "" dumps = ""
for id_, frame in sys._current_frames().items(): for id_, frame in sys._current_frames().items():
@ -55,12 +65,13 @@ class StackTraceDump:
class Debugger: class Debugger:
def __init__(self): def __init__(self) -> None:
try: try:
signal.signal(signal.SIGUSR2, self._enter_debugger) signal.signal(signal.SIGUSR2, self._enter_debugger)
except AttributeError: except AttributeError:
# win32 platforms don't support SIGUSR signals # win32 platforms don't support SIGUSR signals
pass pass
def _enter_debugger(self, signum, frame): def _enter_debugger(self, signum: int, frame: Optional[FrameType]) -> None:
assert frame
Pdb().set_trace(frame.f_back) Pdb().set_trace(frame.f_back)

View File

@ -4,6 +4,8 @@ Feed Exports extension
See documentation in docs/topics/feed-exports.rst See documentation in docs/topics/feed-exports.rst
""" """
from __future__ import annotations
import logging import logging
import re import re
import sys import sys
@ -11,26 +13,51 @@ import warnings
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path, PureWindowsPath from pathlib import Path, PureWindowsPath
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from typing import IO, Any, Callable, Dict, List, Optional, Tuple, Union from typing import (
IO,
TYPE_CHECKING,
Any,
Callable,
Dict,
Iterable,
List,
Optional,
Protocol,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from twisted.internet import defer, threads from twisted.internet import threads
from twisted.internet.defer import DeferredList from twisted.internet.defer import Deferred, DeferredList, maybeDeferred
from twisted.python.failure import Failure
from w3lib.url import file_uri_to_path from w3lib.url import file_uri_to_path
from zope.interface import Interface, implementer from zope.interface import Interface, implementer
from scrapy import Spider, signals from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
from scrapy.exporters import BaseItemExporter
from scrapy.extensions.postprocessing import PostProcessingManager from scrapy.extensions.postprocessing import PostProcessingManager
from scrapy.settings import BaseSettings, Settings
from scrapy.utils.boto import is_botocore_available from scrapy.utils.boto import is_botocore_available
from scrapy.utils.conf import feed_complete_default_values_from_settings from scrapy.utils.conf import feed_complete_default_values_from_settings
from scrapy.utils.defer import maybe_deferred_to_future from scrapy.utils.defer import maybe_deferred_to_future
from scrapy.utils.deprecate import create_deprecated_class from scrapy.utils.deprecate import create_deprecated_class
from scrapy.utils.ftp import ftp_store_file from scrapy.utils.ftp import ftp_store_file
from scrapy.utils.log import failure_to_exc_info from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.misc import create_instance, load_object from scrapy.utils.misc import build_from_crawler, load_object
from scrapy.utils.python import without_none_values from scrapy.utils.python import without_none_values
if TYPE_CHECKING:
from _typeshed import OpenBinaryMode
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
@ -40,8 +67,19 @@ try:
except ImportError: except ImportError:
IS_BOTO3_AVAILABLE = False IS_BOTO3_AVAILABLE = False
UriParamsCallableT = Callable[[Dict[str, Any], Spider], Optional[Dict[str, Any]]]
def build_storage(builder, uri, *args, feed_options=None, preargs=(), **kwargs): _StorageT = TypeVar("_StorageT", bound="FeedStorageProtocol")
def build_storage(
builder: Callable[..., _StorageT],
uri: str,
*args: Any,
feed_options: Optional[Dict[str, Any]] = None,
preargs: Iterable[Any] = (),
**kwargs: Any,
) -> _StorageT:
kwargs["feed_options"] = feed_options kwargs["feed_options"] = feed_options
return builder(*preargs, uri, *args, **kwargs) return builder(*preargs, uri, *args, **kwargs)
@ -55,10 +93,10 @@ class ItemFilter:
:type feed_options: dict :type feed_options: dict
""" """
feed_options: Optional[dict] feed_options: Optional[Dict[str, Any]]
item_classes: Tuple item_classes: Tuple[type, ...]
def __init__(self, feed_options: Optional[dict]) -> None: def __init__(self, feed_options: Optional[Dict[str, Any]]) -> None:
self.feed_options = feed_options self.feed_options = feed_options
if feed_options is not None: if feed_options is not None:
self.item_classes = tuple( self.item_classes = tuple(
@ -97,28 +135,49 @@ class IFeedStorage(Interface):
"""Store the given file stream""" """Store the given file stream"""
class FeedStorageProtocol(Protocol):
"""Reimplementation of ``IFeedStorage`` that can be used in type hints."""
def __init__(self, uri: str, *, feed_options: Optional[Dict[str, Any]] = None):
"""Initialize the storage with the parameters given in the URI and the
feed-specific options (see :setting:`FEEDS`)"""
def open(self, spider: Spider) -> IO[bytes]:
"""Open the storage for the given spider. It must return a file-like
object that will be used for the exporters"""
def store(self, file: IO[bytes]) -> Optional[Deferred]:
"""Store the given file stream"""
@implementer(IFeedStorage) @implementer(IFeedStorage)
class BlockingFeedStorage: class BlockingFeedStorage:
def open(self, spider): def open(self, spider: Spider) -> IO[bytes]:
path = spider.crawler.settings["FEED_TEMPDIR"] path = spider.crawler.settings["FEED_TEMPDIR"]
if path and not Path(path).is_dir(): if path and not Path(path).is_dir():
raise OSError("Not a Directory: " + str(path)) raise OSError("Not a Directory: " + str(path))
return NamedTemporaryFile(prefix="feed-", dir=path) return NamedTemporaryFile(prefix="feed-", dir=path)
def store(self, file): def store(self, file: IO[bytes]) -> Optional[Deferred]:
return threads.deferToThread(self._store_in_thread, file) return threads.deferToThread(self._store_in_thread, file)
def _store_in_thread(self, file): def _store_in_thread(self, file: IO[bytes]) -> None:
raise NotImplementedError raise NotImplementedError
@implementer(IFeedStorage) @implementer(IFeedStorage)
class StdoutFeedStorage: class StdoutFeedStorage:
def __init__(self, uri, _stdout=None, *, feed_options=None): def __init__(
self,
uri: str,
_stdout: Optional[IO[bytes]] = None,
*,
feed_options: Optional[Dict[str, Any]] = None,
):
if not _stdout: if not _stdout:
_stdout = sys.stdout.buffer _stdout = sys.stdout.buffer
self._stdout = _stdout self._stdout: IO[bytes] = _stdout
if feed_options and feed_options.get("overwrite", False) is True: if feed_options and feed_options.get("overwrite", False) is True:
logger.warning( logger.warning(
"Standard output (stdout) storage does not support " "Standard output (stdout) storage does not support "
@ -127,54 +186,58 @@ class StdoutFeedStorage:
"it to False." "it to False."
) )
def open(self, spider): def open(self, spider: Spider) -> IO[bytes]:
return self._stdout return self._stdout
def store(self, file): def store(self, file: IO[bytes]) -> Optional[Deferred]:
pass pass
@implementer(IFeedStorage) @implementer(IFeedStorage)
class FileFeedStorage: class FileFeedStorage:
def __init__(self, uri, *, feed_options=None): def __init__(self, uri: str, *, feed_options: Optional[Dict[str, Any]] = None):
self.path = file_uri_to_path(uri) self.path: str = file_uri_to_path(uri)
feed_options = feed_options or {} feed_options = feed_options or {}
self.write_mode = "wb" if feed_options.get("overwrite", False) else "ab" self.write_mode: OpenBinaryMode = (
"wb" if feed_options.get("overwrite", False) else "ab"
)
def open(self, spider) -> IO[Any]: def open(self, spider: Spider) -> IO[bytes]:
dirname = Path(self.path).parent dirname = Path(self.path).parent
if dirname and not dirname.exists(): if dirname and not dirname.exists():
dirname.mkdir(parents=True) dirname.mkdir(parents=True)
return Path(self.path).open(self.write_mode) return Path(self.path).open(self.write_mode)
def store(self, file): def store(self, file: IO[bytes]) -> Optional[Deferred]:
file.close() file.close()
return None
class S3FeedStorage(BlockingFeedStorage): class S3FeedStorage(BlockingFeedStorage):
def __init__( def __init__(
self, self,
uri, uri: str,
access_key=None, access_key: Optional[str] = None,
secret_key=None, secret_key: Optional[str] = None,
acl=None, acl: Optional[str] = None,
endpoint_url=None, endpoint_url: Optional[str] = None,
*, *,
feed_options=None, feed_options: Optional[Dict[str, Any]] = None,
session_token=None, session_token: Optional[str] = None,
region_name=None, region_name: Optional[str] = None,
): ):
if not is_botocore_available(): if not is_botocore_available():
raise NotConfigured("missing botocore library") raise NotConfigured("missing botocore library")
u = urlparse(uri) u = urlparse(uri)
self.bucketname = u.hostname assert u.hostname
self.access_key = u.username or access_key self.bucketname: str = u.hostname
self.secret_key = u.password or secret_key self.access_key: Optional[str] = u.username or access_key
self.session_token = session_token self.secret_key: Optional[str] = u.password or secret_key
self.keyname = u.path[1:] # remove first "/" self.session_token: Optional[str] = session_token
self.acl = acl self.keyname: str = u.path[1:] # remove first "/"
self.endpoint_url = endpoint_url self.acl: Optional[str] = acl
self.region_name = region_name self.endpoint_url: Optional[str] = endpoint_url
self.region_name: Optional[str] = region_name
if IS_BOTO3_AVAILABLE: if IS_BOTO3_AVAILABLE:
import boto3.session import boto3.session
@ -217,7 +280,13 @@ class S3FeedStorage(BlockingFeedStorage):
) )
@classmethod @classmethod
def from_crawler(cls, crawler, uri, *, feed_options=None): def from_crawler(
cls,
crawler: Crawler,
uri: str,
*,
feed_options: Optional[Dict[str, Any]] = None,
) -> Self:
return build_storage( return build_storage(
cls, cls,
uri, uri,
@ -230,8 +299,9 @@ class S3FeedStorage(BlockingFeedStorage):
feed_options=feed_options, feed_options=feed_options,
) )
def _store_in_thread(self, file): def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0) file.seek(0)
kwargs: Dict[str, Any]
if IS_BOTO3_AVAILABLE: if IS_BOTO3_AVAILABLE:
kwargs = {"ExtraArgs": {"ACL": self.acl}} if self.acl else {} kwargs = {"ExtraArgs": {"ACL": self.acl}} if self.acl else {}
self.s3_client.upload_fileobj( self.s3_client.upload_fileobj(
@ -246,22 +316,23 @@ class S3FeedStorage(BlockingFeedStorage):
class GCSFeedStorage(BlockingFeedStorage): class GCSFeedStorage(BlockingFeedStorage):
def __init__(self, uri, project_id, acl): def __init__(self, uri: str, project_id: Optional[str], acl: Optional[str]):
self.project_id = project_id self.project_id: Optional[str] = project_id
self.acl = acl self.acl: Optional[str] = acl
u = urlparse(uri) u = urlparse(uri)
self.bucket_name = u.hostname assert u.hostname
self.blob_name = u.path[1:] # remove first "/" self.bucket_name: str = u.hostname
self.blob_name: str = u.path[1:] # remove first "/"
@classmethod @classmethod
def from_crawler(cls, crawler, uri): def from_crawler(cls, crawler: Crawler, uri: str) -> Self:
return cls( return cls(
uri, uri,
crawler.settings["GCS_PROJECT_ID"], crawler.settings["GCS_PROJECT_ID"],
crawler.settings["FEED_STORAGE_GCS_ACL"] or None, crawler.settings["FEED_STORAGE_GCS_ACL"] or None,
) )
def _store_in_thread(self, file): def _store_in_thread(self, file: IO[bytes]) -> None:
file.seek(0) file.seek(0)
from google.cloud.storage import Client from google.cloud.storage import Client
@ -291,7 +362,13 @@ class FTPFeedStorage(BlockingFeedStorage):
self.overwrite: bool = not feed_options or feed_options.get("overwrite", True) self.overwrite: bool = not feed_options or feed_options.get("overwrite", True)
@classmethod @classmethod
def from_crawler(cls, crawler, uri, *, feed_options=None): def from_crawler(
cls,
crawler: Crawler,
uri: str,
*,
feed_options: Optional[Dict[str, Any]] = None,
) -> Self:
return build_storage( return build_storage(
cls, cls,
uri, uri,
@ -299,7 +376,7 @@ class FTPFeedStorage(BlockingFeedStorage):
feed_options=feed_options, feed_options=feed_options,
) )
def _store_in_thread(self, file): def _store_in_thread(self, file: IO[bytes]) -> None:
ftp_store_file( ftp_store_file(
path=self.path, path=self.path,
file=file, file=file,
@ -315,46 +392,51 @@ class FTPFeedStorage(BlockingFeedStorage):
class FeedSlot: class FeedSlot:
def __init__( def __init__(
self, self,
storage, storage: FeedStorageProtocol,
uri, uri: str,
format, format: str,
store_empty, store_empty: bool,
batch_id, batch_id: int,
uri_template, uri_template: str,
filter, filter: ItemFilter,
feed_options, feed_options: Dict[str, Any],
spider, spider: Spider,
exporters, exporters: Dict[str, Type[BaseItemExporter]],
settings, settings: BaseSettings,
crawler, crawler: Crawler,
): ):
self.file = None self.file: Optional[IO[bytes]] = None
self.exporter = None self.exporter: Optional[BaseItemExporter] = None
self.storage = storage self.storage: FeedStorageProtocol = storage
# feed params # feed params
self.batch_id = batch_id self.batch_id: int = batch_id
self.format = format self.format: str = format
self.store_empty = store_empty self.store_empty: bool = store_empty
self.uri_template = uri_template self.uri_template: str = uri_template
self.uri = uri self.uri: str = uri
self.filter = filter self.filter: ItemFilter = filter
# exporter params # exporter params
self.feed_options = feed_options self.feed_options: Dict[str, Any] = feed_options
self.spider = spider self.spider: Spider = spider
self.exporters = exporters self.exporters: Dict[str, Type[BaseItemExporter]] = exporters
self.settings = settings self.settings: BaseSettings = settings
self.crawler = crawler self.crawler: Crawler = crawler
# flags # flags
self.itemcount = 0 self.itemcount: int = 0
self._exporting = False self._exporting: bool = False
self._fileloaded = False self._fileloaded: bool = False
def start_exporting(self): def start_exporting(self) -> None:
if not self._fileloaded: if not self._fileloaded:
self.file = self.storage.open(self.spider) self.file = self.storage.open(self.spider)
if "postprocessing" in self.feed_options: if "postprocessing" in self.feed_options:
self.file = PostProcessingManager( self.file = cast(
self.feed_options["postprocessing"], self.file, self.feed_options IO[bytes],
PostProcessingManager(
self.feed_options["postprocessing"],
self.file,
self.feed_options,
),
) )
self.exporter = self._get_exporter( self.exporter = self._get_exporter(
file=self.file, file=self.file,
@ -367,17 +449,23 @@ class FeedSlot:
self._fileloaded = True self._fileloaded = True
if not self._exporting: if not self._exporting:
assert self.exporter
self.exporter.start_exporting() self.exporter.start_exporting()
self._exporting = True self._exporting = True
def _get_instance(self, objcls, *args, **kwargs): def _get_instance(
return create_instance(objcls, self.settings, self.crawler, *args, **kwargs) self, objcls: Type[BaseItemExporter], *args: Any, **kwargs: Any
) -> BaseItemExporter:
return build_from_crawler(objcls, self.crawler, *args, **kwargs)
def _get_exporter(self, file, format, *args, **kwargs): def _get_exporter(
self, file: IO[bytes], format: str, *args: Any, **kwargs: Any
) -> BaseItemExporter:
return self._get_instance(self.exporters[format], file, *args, **kwargs) return self._get_instance(self.exporters[format], file, *args, **kwargs)
def finish_exporting(self): def finish_exporting(self) -> None:
if self._exporting: if self._exporting:
assert self.exporter
self.exporter.finish_exporting() self.exporter.finish_exporting()
self._exporting = False self._exporting = False
@ -389,22 +477,22 @@ _FeedSlot = create_deprecated_class(
class FeedExporter: class FeedExporter:
_pending_deferreds: List[defer.Deferred] = [] _pending_deferreds: List[Deferred] = []
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
exporter = cls(crawler) exporter = cls(crawler)
crawler.signals.connect(exporter.open_spider, signals.spider_opened) crawler.signals.connect(exporter.open_spider, signals.spider_opened)
crawler.signals.connect(exporter.close_spider, signals.spider_closed) crawler.signals.connect(exporter.close_spider, signals.spider_closed)
crawler.signals.connect(exporter.item_scraped, signals.item_scraped) crawler.signals.connect(exporter.item_scraped, signals.item_scraped)
return exporter return exporter
def __init__(self, crawler): def __init__(self, crawler: Crawler):
self.crawler = crawler self.crawler: Crawler = crawler
self.settings = crawler.settings self.settings: Settings = crawler.settings
self.feeds = {} self.feeds = {}
self.slots = [] self.slots: List[FeedSlot] = []
self.filters = {} self.filters: Dict[str, ItemFilter] = {}
if not self.settings["FEEDS"] and not self.settings["FEED_URI"]: if not self.settings["FEEDS"] and not self.settings["FEED_URI"]:
raise NotConfigured raise NotConfigured
@ -436,8 +524,12 @@ class FeedExporter:
) )
self.filters[uri] = self._load_filter(feed_options) self.filters[uri] = self._load_filter(feed_options)
self.storages = self._load_components("FEED_STORAGES") self.storages: Dict[str, Type[FeedStorageProtocol]] = self._load_components(
self.exporters = self._load_components("FEED_EXPORTERS") "FEED_STORAGES"
)
self.exporters: Dict[str, Type[BaseItemExporter]] = self._load_components(
"FEED_EXPORTERS"
)
for uri, feed_options in self.feeds.items(): for uri, feed_options in self.feeds.items():
if not self._storage_supported(uri, feed_options): if not self._storage_supported(uri, feed_options):
raise NotConfigured raise NotConfigured
@ -446,7 +538,7 @@ class FeedExporter:
if not self._exporter_supported(feed_options["format"]): if not self._exporter_supported(feed_options["format"]):
raise NotConfigured raise NotConfigured
def open_spider(self, spider): def open_spider(self, spider: Spider) -> None:
for uri, feed_options in self.feeds.items(): for uri, feed_options in self.feeds.items():
uri_params = self._get_uri_params(spider, feed_options["uri_params"]) uri_params = self._get_uri_params(spider, feed_options["uri_params"])
self.slots.append( self.slots.append(
@ -459,7 +551,7 @@ class FeedExporter:
) )
) )
async def close_spider(self, spider): async def close_spider(self, spider: Spider) -> None:
for slot in self.slots: for slot in self.slots:
self._close_slot(slot, spider) self._close_slot(slot, spider)
@ -472,8 +564,9 @@ class FeedExporter:
self.crawler.signals.send_catch_log_deferred(signals.feed_exporter_closed) self.crawler.signals.send_catch_log_deferred(signals.feed_exporter_closed)
) )
def _close_slot(self, slot, spider): def _close_slot(self, slot: FeedSlot, spider: Spider) -> Optional[Deferred]:
def get_file(slot_): def get_file(slot_: FeedSlot) -> IO[bytes]:
assert slot_.file
if isinstance(slot_.file, PostProcessingManager): if isinstance(slot_.file, PostProcessingManager):
slot_.file.close() slot_.file.close()
return slot_.file.file return slot_.file.file
@ -491,7 +584,7 @@ class FeedExporter:
return None return None
logmsg = f"{slot.format} feed ({slot.itemcount} items) in: {slot.uri}" logmsg = f"{slot.format} feed ({slot.itemcount} items) in: {slot.uri}"
d = defer.maybeDeferred(slot.storage.store, get_file(slot)) d: Deferred = maybeDeferred(slot.storage.store, get_file(slot))
d.addCallback( d.addCallback(
self._handle_store_success, logmsg, spider, type(slot.storage).__name__ self._handle_store_success, logmsg, spider, type(slot.storage).__name__
@ -509,20 +602,33 @@ class FeedExporter:
return d return d
def _handle_store_error(self, f, logmsg, spider, slot_type): def _handle_store_error(
self, f: Failure, logmsg: str, spider: Spider, slot_type: str
) -> None:
logger.error( logger.error(
"Error storing %s", "Error storing %s",
logmsg, logmsg,
exc_info=failure_to_exc_info(f), exc_info=failure_to_exc_info(f),
extra={"spider": spider}, extra={"spider": spider},
) )
assert self.crawler.stats
self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}") self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}")
def _handle_store_success(self, f, logmsg, spider, slot_type): def _handle_store_success(
self, f: Failure, logmsg: str, spider: Spider, slot_type: str
) -> None:
logger.info("Stored %s", logmsg, extra={"spider": spider}) logger.info("Stored %s", logmsg, extra={"spider": spider})
assert self.crawler.stats
self.crawler.stats.inc_value(f"feedexport/success_count/{slot_type}") self.crawler.stats.inc_value(f"feedexport/success_count/{slot_type}")
def _start_new_batch(self, batch_id, uri, feed_options, spider, uri_template): def _start_new_batch(
self,
batch_id: int,
uri: str,
feed_options: Dict[str, Any],
spider: Spider,
uri_template: str,
) -> FeedSlot:
""" """
Redirect the output data stream to a new file. Redirect the output data stream to a new file.
Execute multiple times if FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified Execute multiple times if FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified
@ -545,11 +651,11 @@ class FeedExporter:
spider=spider, spider=spider,
exporters=self.exporters, exporters=self.exporters,
settings=self.settings, settings=self.settings,
crawler=getattr(self, "crawler", None), crawler=self.crawler,
) )
return slot return slot
def item_scraped(self, item, spider): def item_scraped(self, item: Any, spider: Spider) -> None:
slots = [] slots = []
for slot in self.slots: for slot in self.slots:
if not slot.filter.accepts(item): if not slot.filter.accepts(item):
@ -559,6 +665,7 @@ class FeedExporter:
continue continue
slot.start_exporting() slot.start_exporting()
assert slot.exporter
slot.exporter.export_item(item) slot.exporter.export_item(item)
slot.itemcount += 1 slot.itemcount += 1
# create new slot for each slot with itemcount == FEED_EXPORT_BATCH_ITEM_COUNT and close the old one # create new slot for each slot with itemcount == FEED_EXPORT_BATCH_ITEM_COUNT and close the old one
@ -583,7 +690,7 @@ class FeedExporter:
slots.append(slot) slots.append(slot)
self.slots = slots self.slots = slots
def _load_components(self, setting_prefix): def _load_components(self, setting_prefix: str) -> Dict[str, Any]:
conf = without_none_values(self.settings.getwithbase(setting_prefix)) conf = without_none_values(self.settings.getwithbase(setting_prefix))
d = {} d = {}
for k, v in conf.items(): for k, v in conf.items():
@ -593,12 +700,13 @@ class FeedExporter:
pass pass
return d return d
def _exporter_supported(self, format): def _exporter_supported(self, format: str) -> bool:
if format in self.exporters: if format in self.exporters:
return True return True
logger.error("Unknown feed format: %(format)s", {"format": format}) logger.error("Unknown feed format: %(format)s", {"format": format})
return False
def _settings_are_valid(self): def _settings_are_valid(self) -> bool:
""" """
If FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified uri has to contain If FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified uri has to contain
%(batch_time)s or %(batch_id)d to distinguish different files of partial output %(batch_time)s or %(batch_id)d to distinguish different files of partial output
@ -616,7 +724,7 @@ class FeedExporter:
return False return False
return True return True
def _storage_supported(self, uri, feed_options): def _storage_supported(self, uri: str, feed_options: Dict[str, Any]) -> bool:
scheme = urlparse(uri).scheme scheme = urlparse(uri).scheme
if scheme in self.storages or PureWindowsPath(uri).drive: if scheme in self.storages or PureWindowsPath(uri).drive:
try: try:
@ -629,8 +737,11 @@ class FeedExporter:
) )
else: else:
logger.error("Unknown feed storage scheme: %(scheme)s", {"scheme": scheme}) logger.error("Unknown feed storage scheme: %(scheme)s", {"scheme": scheme})
return False
def _get_storage(self, uri, feed_options): def _get_storage(
self, uri: str, feed_options: Dict[str, Any]
) -> FeedStorageProtocol:
"""Fork of create_instance specific to feed storage classes """Fork of create_instance specific to feed storage classes
It supports not passing the *feed_options* parameters to classes that It supports not passing the *feed_options* parameters to classes that
@ -639,11 +750,14 @@ class FeedExporter:
feedcls = self.storages.get(urlparse(uri).scheme, self.storages["file"]) feedcls = self.storages.get(urlparse(uri).scheme, self.storages["file"])
crawler = getattr(self, "crawler", None) crawler = getattr(self, "crawler", None)
def build_instance(builder, *preargs): def build_instance(
builder: Type[FeedStorageProtocol], *preargs: Any
) -> FeedStorageProtocol:
return build_storage( return build_storage(
builder, uri, feed_options=feed_options, preargs=preargs builder, uri, feed_options=feed_options, preargs=preargs
) )
instance: FeedStorageProtocol
if crawler and hasattr(feedcls, "from_crawler"): if crawler and hasattr(feedcls, "from_crawler"):
instance = build_instance(feedcls.from_crawler, crawler) instance = build_instance(feedcls.from_crawler, crawler)
method_name = "from_crawler" method_name = "from_crawler"
@ -660,9 +774,9 @@ class FeedExporter:
def _get_uri_params( def _get_uri_params(
self, self,
spider: Spider, spider: Spider,
uri_params_function: Optional[Union[str, Callable[[dict, Spider], dict]]], uri_params_function: Union[str, UriParamsCallableT, None],
slot: Optional[FeedSlot] = None, slot: Optional[FeedSlot] = None,
) -> dict: ) -> Dict[str, Any]:
params = {} params = {}
for k in dir(spider): for k in dir(spider):
params[k] = getattr(spider, k) params[k] = getattr(spider, k)
@ -670,7 +784,7 @@ class FeedExporter:
params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-") params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-")
params["batch_time"] = utc_now.isoformat().replace(":", "-") params["batch_time"] = utc_now.isoformat().replace(":", "-")
params["batch_id"] = slot.batch_id + 1 if slot is not None else 1 params["batch_id"] = slot.batch_id + 1 if slot is not None else 1
uripar_function = ( uripar_function: UriParamsCallableT = (
load_object(uri_params_function) load_object(uri_params_function)
if uri_params_function if uri_params_function
else lambda params, _: params else lambda params, _: params
@ -678,7 +792,9 @@ class FeedExporter:
new_params = uripar_function(params, spider) new_params = uripar_function(params, spider)
return new_params if new_params is not None else params return new_params if new_params is not None else params
def _load_filter(self, feed_options): def _load_filter(self, feed_options: Dict[str, Any]) -> ItemFilter:
# load the item filter if declared else load the default filter class # load the item filter if declared else load the default filter class
item_filter_class = load_object(feed_options.get("item_filter", ItemFilter)) item_filter_class: Type[ItemFilter] = load_object(
feed_options.get("item_filter", ItemFilter)
)
return item_filter_class(feed_options) return item_filter_class(feed_options)

View File

@ -1,10 +1,13 @@
import gzip import gzip
import logging import logging
import pickle import os
import pickle # nosec
from email.utils import mktime_tz, parsedate_tz from email.utils import mktime_tz, parsedate_tz
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from time import time from time import time
from types import ModuleType
from typing import IO, TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union, cast
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
from w3lib.http import headers_dict_to_raw, headers_raw_to_dict from w3lib.http import headers_dict_to_raw, headers_raw_to_dict
@ -12,49 +15,65 @@ from w3lib.http import headers_dict_to_raw, headers_raw_to_dict
from scrapy.http import Headers, Response from scrapy.http import Headers, Response
from scrapy.http.request import Request from scrapy.http.request import Request
from scrapy.responsetypes import responsetypes from scrapy.responsetypes import responsetypes
from scrapy.settings import BaseSettings
from scrapy.spiders import Spider from scrapy.spiders import Spider
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.project import data_path from scrapy.utils.project import data_path
from scrapy.utils.python import to_bytes, to_unicode from scrapy.utils.python import to_bytes, to_unicode
from scrapy.utils.request import RequestFingerprinter
if TYPE_CHECKING:
# typing.Concatenate requires Python 3.10
from typing_extensions import Concatenate
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DummyPolicy: class DummyPolicy:
def __init__(self, settings): def __init__(self, settings: BaseSettings):
self.ignore_schemes = settings.getlist("HTTPCACHE_IGNORE_SCHEMES") self.ignore_schemes: List[str] = settings.getlist("HTTPCACHE_IGNORE_SCHEMES")
self.ignore_http_codes = [ self.ignore_http_codes: List[int] = [
int(x) for x in settings.getlist("HTTPCACHE_IGNORE_HTTP_CODES") int(x) for x in settings.getlist("HTTPCACHE_IGNORE_HTTP_CODES")
] ]
def should_cache_request(self, request): def should_cache_request(self, request: Request) -> bool:
return urlparse_cached(request).scheme not in self.ignore_schemes return urlparse_cached(request).scheme not in self.ignore_schemes
def should_cache_response(self, response, request): def should_cache_response(self, response: Response, request: Request) -> bool:
return response.status not in self.ignore_http_codes return response.status not in self.ignore_http_codes
def is_cached_response_fresh(self, cachedresponse, request): def is_cached_response_fresh(
self, cachedresponse: Response, request: Request
) -> bool:
return True return True
def is_cached_response_valid(self, cachedresponse, response, request): def is_cached_response_valid(
self, cachedresponse: Response, response: Response, request: Request
) -> bool:
return True return True
class RFC2616Policy: class RFC2616Policy:
MAXAGE = 3600 * 24 * 365 # one year MAXAGE = 3600 * 24 * 365 # one year
def __init__(self, settings): def __init__(self, settings: BaseSettings):
self.always_store = settings.getbool("HTTPCACHE_ALWAYS_STORE") self.always_store: bool = settings.getbool("HTTPCACHE_ALWAYS_STORE")
self.ignore_schemes = settings.getlist("HTTPCACHE_IGNORE_SCHEMES") self.ignore_schemes: List[str] = settings.getlist("HTTPCACHE_IGNORE_SCHEMES")
self._cc_parsed = WeakKeyDictionary() self._cc_parsed: WeakKeyDictionary[
self.ignore_response_cache_controls = [ Union[Request, Response], Dict[bytes, Optional[bytes]]
] = WeakKeyDictionary()
self.ignore_response_cache_controls: List[bytes] = [
to_bytes(cc) to_bytes(cc)
for cc in settings.getlist("HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS") for cc in settings.getlist("HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS")
] ]
def _parse_cachecontrol(self, r): def _parse_cachecontrol(
self, r: Union[Request, Response]
) -> Dict[bytes, Optional[bytes]]:
if r not in self._cc_parsed: if r not in self._cc_parsed:
cch = r.headers.get(b"Cache-Control", b"") cch = r.headers.get(b"Cache-Control", b"")
assert cch is not None
parsed = parse_cachecontrol(cch) parsed = parse_cachecontrol(cch)
if isinstance(r, Response): if isinstance(r, Response):
for key in self.ignore_response_cache_controls: for key in self.ignore_response_cache_controls:
@ -62,7 +81,7 @@ class RFC2616Policy:
self._cc_parsed[r] = parsed self._cc_parsed[r] = parsed
return self._cc_parsed[r] return self._cc_parsed[r]
def should_cache_request(self, request): def should_cache_request(self, request: Request) -> bool:
if urlparse_cached(request).scheme in self.ignore_schemes: if urlparse_cached(request).scheme in self.ignore_schemes:
return False return False
cc = self._parse_cachecontrol(request) cc = self._parse_cachecontrol(request)
@ -72,7 +91,7 @@ class RFC2616Policy:
# Any other is eligible for caching # Any other is eligible for caching
return True return True
def should_cache_response(self, response, request): def should_cache_response(self, response: Response, request: Request) -> bool:
# What is cacheable - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1 # What is cacheable - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1
# Response cacheability - https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4 # Response cacheability - https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4
# Status code 206 is not included because cache can not deal with partial contents # Status code 206 is not included because cache can not deal with partial contents
@ -100,7 +119,9 @@ class RFC2616Policy:
# info and can not be revalidated # info and can not be revalidated
return False return False
def is_cached_response_fresh(self, cachedresponse, request): def is_cached_response_fresh(
self, cachedresponse: Response, request: Request
) -> bool:
cc = self._parse_cachecontrol(cachedresponse) cc = self._parse_cachecontrol(cachedresponse)
ccreq = self._parse_cachecontrol(request) ccreq = self._parse_cachecontrol(request)
if b"no-cache" in cc or b"no-cache" in ccreq: if b"no-cache" in cc or b"no-cache" in ccreq:
@ -141,7 +162,9 @@ class RFC2616Policy:
self._set_conditional_validators(request, cachedresponse) self._set_conditional_validators(request, cachedresponse)
return False return False
def is_cached_response_valid(self, cachedresponse, response, request): def is_cached_response_valid(
self, cachedresponse: Response, response: Response, request: Request
) -> bool:
# Use the cached response if the new response is a server error, # Use the cached response if the new response is a server error,
# as long as the old response didn't specify must-revalidate. # as long as the old response didn't specify must-revalidate.
if response.status >= 500: if response.status >= 500:
@ -152,7 +175,9 @@ class RFC2616Policy:
# Use the cached response if the server says it hasn't changed. # Use the cached response if the server says it hasn't changed.
return response.status == 304 return response.status == 304
def _set_conditional_validators(self, request, cachedresponse): def _set_conditional_validators(
self, request: Request, cachedresponse: Response
) -> None:
if b"Last-Modified" in cachedresponse.headers: if b"Last-Modified" in cachedresponse.headers:
request.headers[b"If-Modified-Since"] = cachedresponse.headers[ request.headers[b"If-Modified-Since"] = cachedresponse.headers[
b"Last-Modified" b"Last-Modified"
@ -161,13 +186,15 @@ class RFC2616Policy:
if b"ETag" in cachedresponse.headers: if b"ETag" in cachedresponse.headers:
request.headers[b"If-None-Match"] = cachedresponse.headers[b"ETag"] request.headers[b"If-None-Match"] = cachedresponse.headers[b"ETag"]
def _get_max_age(self, cc): def _get_max_age(self, cc: Dict[bytes, Optional[bytes]]) -> Optional[int]:
try: try:
return max(0, int(cc[b"max-age"])) return max(0, int(cc[b"max-age"])) # type: ignore[arg-type]
except (KeyError, ValueError): except (KeyError, ValueError):
return None return None
def _compute_freshness_lifetime(self, response, request, now): def _compute_freshness_lifetime(
self, response: Response, request: Request, now: float
) -> float:
# Reference nsHttpResponseHead::ComputeFreshnessLifetime # Reference nsHttpResponseHead::ComputeFreshnessLifetime
# https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#706 # https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#706
cc = self._parse_cachecontrol(response) cc = self._parse_cachecontrol(response)
@ -198,10 +225,12 @@ class RFC2616Policy:
# Insufficient information to compute freshness lifetime # Insufficient information to compute freshness lifetime
return 0 return 0
def _compute_current_age(self, response, request, now): def _compute_current_age(
self, response: Response, request: Request, now: float
) -> float:
# Reference nsHttpResponseHead::ComputeCurrentAge # Reference nsHttpResponseHead::ComputeCurrentAge
# https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#658 # https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#658
currentage = 0 currentage: float = 0
# If Date header is not set we assume it is a fast connection, and # If Date header is not set we assume it is a fast connection, and
# clock is in sync with the server # clock is in sync with the server
date = rfc1123_to_epoch(response.headers.get(b"Date")) or now date = rfc1123_to_epoch(response.headers.get(b"Date")) or now
@ -210,7 +239,7 @@ class RFC2616Policy:
if b"Age" in response.headers: if b"Age" in response.headers:
try: try:
age = int(response.headers[b"Age"]) age = int(response.headers[b"Age"]) # type: ignore[arg-type]
currentage = max(currentage, age) currentage = max(currentage, age)
except ValueError: except ValueError:
pass pass
@ -219,13 +248,13 @@ class RFC2616Policy:
class DbmCacheStorage: class DbmCacheStorage:
def __init__(self, settings): def __init__(self, settings: BaseSettings):
self.cachedir = data_path(settings["HTTPCACHE_DIR"], createdir=True) self.cachedir: str = data_path(settings["HTTPCACHE_DIR"], createdir=True)
self.expiration_secs = settings.getint("HTTPCACHE_EXPIRATION_SECS") self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS")
self.dbmodule = import_module(settings["HTTPCACHE_DBM_MODULE"]) self.dbmodule: ModuleType = import_module(settings["HTTPCACHE_DBM_MODULE"])
self.db = None self.db: Any = None # the real type is private
def open_spider(self, spider: Spider): def open_spider(self, spider: Spider) -> None:
dbpath = Path(self.cachedir, f"{spider.name}.db") dbpath = Path(self.cachedir, f"{spider.name}.db")
self.db = self.dbmodule.open(str(dbpath), "c") self.db = self.dbmodule.open(str(dbpath), "c")
@ -235,15 +264,16 @@ class DbmCacheStorage:
extra={"spider": spider}, extra={"spider": spider},
) )
self._fingerprinter = spider.crawler.request_fingerprinter assert spider.crawler.request_fingerprinter
self._fingerprinter: RequestFingerprinter = spider.crawler.request_fingerprinter
def close_spider(self, spider): def close_spider(self, spider: Spider) -> None:
self.db.close() self.db.close()
def retrieve_response(self, spider, request): def retrieve_response(self, spider: Spider, request: Request) -> Optional[Response]:
data = self._read_data(spider, request) data = self._read_data(spider, request)
if data is None: if data is None:
return # not cached return None # not cached
url = data["url"] url = data["url"]
status = data["status"] status = data["status"]
headers = Headers(data["headers"]) headers = Headers(data["headers"])
@ -252,7 +282,9 @@ class DbmCacheStorage:
response = respcls(url=url, headers=headers, status=status, body=body) response = respcls(url=url, headers=headers, status=status, body=body)
return response return response
def store_response(self, spider, request, response): def store_response(
self, spider: Spider, request: Request, response: Response
) -> None:
key = self._fingerprinter.fingerprint(request).hex() key = self._fingerprinter.fingerprint(request).hex()
data = { data = {
"status": response.status, "status": response.status,
@ -263,28 +295,31 @@ class DbmCacheStorage:
self.db[f"{key}_data"] = pickle.dumps(data, protocol=4) self.db[f"{key}_data"] = pickle.dumps(data, protocol=4)
self.db[f"{key}_time"] = str(time()) self.db[f"{key}_time"] = str(time())
def _read_data(self, spider, request): def _read_data(self, spider: Spider, request: Request) -> Optional[Dict[str, Any]]:
key = self._fingerprinter.fingerprint(request).hex() key = self._fingerprinter.fingerprint(request).hex()
db = self.db db = self.db
tkey = f"{key}_time" tkey = f"{key}_time"
if tkey not in db: if tkey not in db:
return # not found return None # not found
ts = db[tkey] ts = db[tkey]
if 0 < self.expiration_secs < time() - float(ts): if 0 < self.expiration_secs < time() - float(ts):
return # expired return None # expired
return pickle.loads(db[f"{key}_data"]) return cast(Dict[str, Any], pickle.loads(db[f"{key}_data"])) # nosec
class FilesystemCacheStorage: class FilesystemCacheStorage:
def __init__(self, settings): def __init__(self, settings: BaseSettings):
self.cachedir = data_path(settings["HTTPCACHE_DIR"]) self.cachedir: str = data_path(settings["HTTPCACHE_DIR"])
self.expiration_secs = settings.getint("HTTPCACHE_EXPIRATION_SECS") self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS")
self.use_gzip = settings.getbool("HTTPCACHE_GZIP") self.use_gzip: bool = settings.getbool("HTTPCACHE_GZIP")
self._open = gzip.open if self.use_gzip else open # https://github.com/python/mypy/issues/10740
self._open: Callable[Concatenate[Union[str, os.PathLike], str, ...], IO] = (
gzip.open if self.use_gzip else open # type: ignore[assignment]
)
def open_spider(self, spider: Spider): def open_spider(self, spider: Spider) -> None:
logger.debug( logger.debug(
"Using filesystem cache storage in %(cachedir)s", "Using filesystem cache storage in %(cachedir)s",
{"cachedir": self.cachedir}, {"cachedir": self.cachedir},
@ -294,27 +329,29 @@ class FilesystemCacheStorage:
assert spider.crawler.request_fingerprinter assert spider.crawler.request_fingerprinter
self._fingerprinter = spider.crawler.request_fingerprinter self._fingerprinter = spider.crawler.request_fingerprinter
def close_spider(self, spider): def close_spider(self, spider: Spider) -> None:
pass pass
def retrieve_response(self, spider: Spider, request: Request): def retrieve_response(self, spider: Spider, request: Request) -> Optional[Response]:
"""Return response if present in cache, or None otherwise.""" """Return response if present in cache, or None otherwise."""
metadata = self._read_meta(spider, request) metadata = self._read_meta(spider, request)
if metadata is None: if metadata is None:
return # not cached return None # not cached
rpath = Path(self._get_request_path(spider, request)) rpath = Path(self._get_request_path(spider, request))
with self._open(rpath / "response_body", "rb") as f: with self._open(rpath / "response_body", "rb") as f:
body = f.read() body = f.read()
with self._open(rpath / "response_headers", "rb") as f: with self._open(rpath / "response_headers", "rb") as f:
rawheaders = f.read() rawheaders = f.read()
url = metadata.get("response_url") url = metadata["response_url"]
status = metadata["status"] status = metadata["status"]
headers = Headers(headers_raw_to_dict(rawheaders)) headers = Headers(headers_raw_to_dict(rawheaders))
respcls = responsetypes.from_args(headers=headers, url=url, body=body) respcls = responsetypes.from_args(headers=headers, url=url, body=body)
response = respcls(url=url, headers=headers, status=status, body=body) response = respcls(url=url, headers=headers, status=status, body=body)
return response return response
def store_response(self, spider: Spider, request: Request, response): def store_response(
self, spider: Spider, request: Request, response: Response
) -> None:
"""Store the given response in the cache.""" """Store the given response in the cache."""
rpath = Path(self._get_request_path(spider, request)) rpath = Path(self._get_request_path(spider, request))
if not rpath.exists(): if not rpath.exists():
@ -343,19 +380,19 @@ class FilesystemCacheStorage:
key = self._fingerprinter.fingerprint(request).hex() key = self._fingerprinter.fingerprint(request).hex()
return str(Path(self.cachedir, spider.name, key[0:2], key)) return str(Path(self.cachedir, spider.name, key[0:2], key))
def _read_meta(self, spider: Spider, request: Request): def _read_meta(self, spider: Spider, request: Request) -> Optional[Dict[str, Any]]:
rpath = Path(self._get_request_path(spider, request)) rpath = Path(self._get_request_path(spider, request))
metapath = rpath / "pickled_meta" metapath = rpath / "pickled_meta"
if not metapath.exists(): if not metapath.exists():
return # not found return None # not found
mtime = metapath.stat().st_mtime mtime = metapath.stat().st_mtime
if 0 < self.expiration_secs < time() - mtime: if 0 < self.expiration_secs < time() - mtime:
return # expired return None # expired
with self._open(metapath, "rb") as f: with self._open(metapath, "rb") as f:
return pickle.load(f) return cast(Dict[str, Any], pickle.load(f)) # nosec
def parse_cachecontrol(header): def parse_cachecontrol(header: bytes) -> Dict[bytes, Optional[bytes]]:
"""Parse Cache-Control header """Parse Cache-Control header
https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9 https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9
@ -375,9 +412,9 @@ def parse_cachecontrol(header):
return directives return directives
def rfc1123_to_epoch(date_str): def rfc1123_to_epoch(date_str: Union[str, bytes, None]) -> Optional[int]:
try: try:
date_str = to_unicode(date_str, encoding="ascii") date_str = to_unicode(date_str, encoding="ascii") # type: ignore[arg-type]
return mktime_tz(parsedate_tz(date_str)) return mktime_tz(parsedate_tz(date_str)) # type: ignore[arg-type]
except Exception: except Exception:
return None return None

View File

@ -1,58 +1,94 @@
from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Optional, Tuple, Union
from twisted.internet import task from twisted.internet import task
from scrapy import signals from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.statscollectors import StatsCollector
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LogStats: class LogStats:
"""Log basic scraping stats periodically""" """Log basic scraping stats periodically like:
* RPM - Requests per Minute
* IPM - Items per Minute
"""
def __init__(self, stats, interval=60.0): def __init__(self, stats: StatsCollector, interval: float = 60.0):
self.stats = stats self.stats: StatsCollector = stats
self.interval = interval self.interval: float = interval
self.multiplier = 60.0 / self.interval self.multiplier: float = 60.0 / self.interval
self.task = None self.task: Optional[task.LoopingCall] = None
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
interval = crawler.settings.getfloat("LOGSTATS_INTERVAL") interval: float = crawler.settings.getfloat("LOGSTATS_INTERVAL")
if not interval: if not interval:
raise NotConfigured raise NotConfigured
assert crawler.stats
o = cls(crawler.stats, interval) o = cls(crawler.stats, interval)
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
return o return o
def spider_opened(self, spider): def spider_opened(self, spider: Spider) -> None:
self.pagesprev = 0 self.pagesprev: int = 0
self.itemsprev = 0 self.itemsprev: int = 0
self.task = task.LoopingCall(self.log, spider) self.task = task.LoopingCall(self.log, spider)
self.task.start(self.interval) self.task.start(self.interval)
def log(self, spider): def log(self, spider: Spider) -> None:
items = self.stats.get_value("item_scraped_count", 0) self.calculate_stats()
pages = self.stats.get_value("response_received_count", 0)
irate = (items - self.itemsprev) * self.multiplier
prate = (pages - self.pagesprev) * self.multiplier
self.pagesprev, self.itemsprev = pages, items
msg = ( msg = (
"Crawled %(pages)d pages (at %(pagerate)d pages/min), " "Crawled %(pages)d pages (at %(pagerate)d pages/min), "
"scraped %(items)d items (at %(itemrate)d items/min)" "scraped %(items)d items (at %(itemrate)d items/min)"
) )
log_args = { log_args = {
"pages": pages, "pages": self.pages,
"pagerate": prate, "pagerate": self.prate,
"items": items, "items": self.items,
"itemrate": irate, "itemrate": self.irate,
} }
logger.info(msg, log_args, extra={"spider": spider}) logger.info(msg, log_args, extra={"spider": spider})
def spider_closed(self, spider, reason): def calculate_stats(self) -> None:
self.items: int = self.stats.get_value("item_scraped_count", 0)
self.pages: int = self.stats.get_value("response_received_count", 0)
self.irate: float = (self.items - self.itemsprev) * self.multiplier
self.prate: float = (self.pages - self.pagesprev) * self.multiplier
self.pagesprev, self.itemsprev = self.pages, self.items
def spider_closed(self, spider: Spider, reason: str) -> None:
if self.task and self.task.running: if self.task and self.task.running:
self.task.stop() self.task.stop()
rpm_final, ipm_final = self.calculate_final_stats(spider)
self.stats.set_value("responses_per_minute", rpm_final)
self.stats.set_value("items_per_minute", ipm_final)
def calculate_final_stats(
self, spider: Spider
) -> Union[Tuple[None, None], Tuple[float, float]]:
start_time = self.stats.get_value("start_time")
finished_time = self.stats.get_value("finished_time")
if not start_time or not finished_time:
return None, None
mins_elapsed = (finished_time - start_time).seconds / 60
items = self.stats.get_value("item_scraped_count", 0)
pages = self.stats.get_value("response_received_count", 0)
return (pages / mins_elapsed), (items / mins_elapsed)

View File

@ -4,26 +4,36 @@ MemoryDebugger extension
See documentation in docs/topics/extensions.rst See documentation in docs/topics/extensions.rst
""" """
import gc from __future__ import annotations
from scrapy import signals import gc
from typing import TYPE_CHECKING
from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.statscollectors import StatsCollector
from scrapy.utils.trackref import live_refs from scrapy.utils.trackref import live_refs
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
class MemoryDebugger: class MemoryDebugger:
def __init__(self, stats): def __init__(self, stats: StatsCollector):
self.stats = stats self.stats: StatsCollector = stats
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
if not crawler.settings.getbool("MEMDEBUG_ENABLED"): if not crawler.settings.getbool("MEMDEBUG_ENABLED"):
raise NotConfigured raise NotConfigured
assert crawler.stats
o = cls(crawler.stats) o = cls(crawler.stats)
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
return o return o
def spider_closed(self, spider, reason): def spider_closed(self, spider: Spider, reason: str) -> None:
gc.collect() gc.collect()
self.stats.set_value( self.stats.set_value(
"memdebug/gc_garbage_count", len(gc.garbage), spider=spider "memdebug/gc_garbage_count", len(gc.garbage), spider=spider

View File

@ -3,24 +3,33 @@ MemoryUsage extension
See documentation in docs/topics/extensions.rst See documentation in docs/topics/extensions.rst
""" """
from __future__ import annotations
import logging import logging
import socket import socket
import sys import sys
from importlib import import_module from importlib import import_module
from pprint import pformat from pprint import pformat
from typing import TYPE_CHECKING, List
from twisted.internet import task from twisted.internet import task
from scrapy import signals from scrapy import signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.mail import MailSender from scrapy.mail import MailSender
from scrapy.utils.engine import get_engine_status from scrapy.utils.engine import get_engine_status
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class MemoryUsage: class MemoryUsage:
def __init__(self, crawler): def __init__(self, crawler: Crawler):
if not crawler.settings.getbool("MEMUSAGE_ENABLED"): if not crawler.settings.getbool("MEMUSAGE_ENABLED"):
raise NotConfigured raise NotConfigured
try: try:
@ -29,32 +38,33 @@ class MemoryUsage:
except ImportError: except ImportError:
raise NotConfigured raise NotConfigured
self.crawler = crawler self.crawler: Crawler = crawler
self.warned = False self.warned: bool = False
self.notify_mails = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL") self.notify_mails: List[str] = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL")
self.limit = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024 self.limit: int = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024
self.warning = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024 self.warning: int = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024
self.check_interval = crawler.settings.getfloat( self.check_interval: float = crawler.settings.getfloat(
"MEMUSAGE_CHECK_INTERVAL_SECONDS" "MEMUSAGE_CHECK_INTERVAL_SECONDS"
) )
self.mail = MailSender.from_settings(crawler.settings) self.mail: MailSender = MailSender.from_settings(crawler.settings)
crawler.signals.connect(self.engine_started, signal=signals.engine_started) crawler.signals.connect(self.engine_started, signal=signals.engine_started)
crawler.signals.connect(self.engine_stopped, signal=signals.engine_stopped) crawler.signals.connect(self.engine_stopped, signal=signals.engine_stopped)
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler) return cls(crawler)
def get_virtual_size(self): def get_virtual_size(self) -> int:
size = self.resource.getrusage(self.resource.RUSAGE_SELF).ru_maxrss size: int = self.resource.getrusage(self.resource.RUSAGE_SELF).ru_maxrss
if sys.platform != "darwin": if sys.platform != "darwin":
# on macOS ru_maxrss is in bytes, on Linux it is in KB # on macOS ru_maxrss is in bytes, on Linux it is in KB
size *= 1024 size *= 1024
return size return size
def engine_started(self): def engine_started(self) -> None:
assert self.crawler.stats
self.crawler.stats.set_value("memusage/startup", self.get_virtual_size()) self.crawler.stats.set_value("memusage/startup", self.get_virtual_size())
self.tasks = [] self.tasks: List[task.LoopingCall] = []
tsk = task.LoopingCall(self.update) tsk = task.LoopingCall(self.update)
self.tasks.append(tsk) self.tasks.append(tsk)
tsk.start(self.check_interval, now=True) tsk.start(self.check_interval, now=True)
@ -67,15 +77,18 @@ class MemoryUsage:
self.tasks.append(tsk) self.tasks.append(tsk)
tsk.start(self.check_interval, now=True) tsk.start(self.check_interval, now=True)
def engine_stopped(self): def engine_stopped(self) -> None:
for tsk in self.tasks: for tsk in self.tasks:
if tsk.running: if tsk.running:
tsk.stop() tsk.stop()
def update(self): def update(self) -> None:
assert self.crawler.stats
self.crawler.stats.max_value("memusage/max", self.get_virtual_size()) self.crawler.stats.max_value("memusage/max", self.get_virtual_size())
def _check_limit(self): def _check_limit(self) -> None:
assert self.crawler.engine
assert self.crawler.stats
peak_mem_usage = self.get_virtual_size() peak_mem_usage = self.get_virtual_size()
if peak_mem_usage > self.limit: if peak_mem_usage > self.limit:
self.crawler.stats.set_value("memusage/limit_reached", 1) self.crawler.stats.set_value("memusage/limit_reached", 1)
@ -105,9 +118,10 @@ class MemoryUsage:
{"virtualsize": peak_mem_usage / 1024 / 1024}, {"virtualsize": peak_mem_usage / 1024 / 1024},
) )
def _check_warning(self): def _check_warning(self) -> None:
if self.warned: # warn only once if self.warned: # warn only once
return return
assert self.crawler.stats
if self.get_virtual_size() > self.warning: if self.get_virtual_size() > self.warning:
self.crawler.stats.set_value("memusage/warning_reached", 1) self.crawler.stats.set_value("memusage/warning_reached", 1)
mem = self.warning / 1024 / 1024 mem = self.warning / 1024 / 1024
@ -125,12 +139,14 @@ class MemoryUsage:
self.crawler.stats.set_value("memusage/warning_notified", 1) self.crawler.stats.set_value("memusage/warning_notified", 1)
self.warned = True self.warned = True
def _send_report(self, rcpts, subject): def _send_report(self, rcpts: List[str], subject: str) -> None:
"""send notification mail with some additional useful info""" """send notification mail with some additional useful info"""
assert self.crawler.engine
assert self.crawler.stats
stats = self.crawler.stats stats = self.crawler.stats
s = f"Memory usage at engine startup : {stats.get_value('memusage/startup')/1024/1024}M\r\n" s = f"Memory usage at engine startup : {stats.get_value('memusage/startup') / 1024 / 1024}M\r\n"
s += f"Maximum memory usage : {stats.get_value('memusage/max')/1024/1024}M\r\n" s += f"Maximum memory usage : {stats.get_value('memusage/max') / 1024 / 1024}M\r\n"
s += f"Current memory usage : {self.get_virtual_size()/1024/1024}M\r\n" s += f"Current memory usage : {self.get_virtual_size() / 1024 / 1024}M\r\n"
s += ( s += (
"ENGINE STATUS ------------------------------------------------------- \r\n" "ENGINE STATUS ------------------------------------------------------- \r\n"

View File

@ -1,12 +1,22 @@
from __future__ import annotations
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from json import JSONEncoder
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from twisted.internet import task from twisted.internet import task
from scrapy import signals from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.statscollectors import StatsCollector
from scrapy.utils.serialize import ScrapyJSONEncoder from scrapy.utils.serialize import ScrapyJSONEncoder
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -15,32 +25,34 @@ class PeriodicLog:
def __init__( def __init__(
self, self,
stats, stats: StatsCollector,
interval=60.0, interval: float = 60.0,
ext_stats={}, ext_stats: Dict[str, Any] = {},
ext_delta={}, ext_delta: Dict[str, Any] = {},
ext_timing_enabled=False, ext_timing_enabled: bool = False,
): ):
self.stats = stats self.stats: StatsCollector = stats
self.interval = interval self.interval: float = interval
self.multiplier = 60.0 / self.interval self.multiplier: float = 60.0 / self.interval
self.task = None self.task: Optional[task.LoopingCall] = None
self.encoder = ScrapyJSONEncoder(sort_keys=True, indent=4) self.encoder: JSONEncoder = ScrapyJSONEncoder(sort_keys=True, indent=4)
self.ext_stats_enabled = bool(ext_stats) self.ext_stats_enabled: bool = bool(ext_stats)
self.ext_stats_include = ext_stats.get("include", []) self.ext_stats_include: List[str] = ext_stats.get("include", [])
self.ext_stats_exclude = ext_stats.get("exclude", []) self.ext_stats_exclude: List[str] = ext_stats.get("exclude", [])
self.ext_delta_enabled = bool(ext_delta) self.ext_delta_enabled: bool = bool(ext_delta)
self.ext_delta_include = ext_delta.get("include", []) self.ext_delta_include: List[str] = ext_delta.get("include", [])
self.ext_delta_exclude = ext_delta.get("exclude", []) self.ext_delta_exclude: List[str] = ext_delta.get("exclude", [])
self.ext_timing_enabled = ext_timing_enabled self.ext_timing_enabled: bool = ext_timing_enabled
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
interval = crawler.settings.getfloat("LOGSTATS_INTERVAL") interval: float = crawler.settings.getfloat("LOGSTATS_INTERVAL")
if not interval: if not interval:
raise NotConfigured raise NotConfigured
try: try:
ext_stats = crawler.settings.getdict("PERIODIC_LOG_STATS") ext_stats: Optional[Dict[str, Any]] = crawler.settings.getdict(
"PERIODIC_LOG_STATS"
)
except (TypeError, ValueError): except (TypeError, ValueError):
ext_stats = ( ext_stats = (
{"enabled": True} {"enabled": True}
@ -48,7 +60,9 @@ class PeriodicLog:
else None else None
) )
try: try:
ext_delta = crawler.settings.getdict("PERIODIC_LOG_DELTA") ext_delta: Optional[Dict[str, Any]] = crawler.settings.getdict(
"PERIODIC_LOG_DELTA"
)
except (TypeError, ValueError): except (TypeError, ValueError):
ext_delta = ( ext_delta = (
{"enabled": True} {"enabled": True}
@ -56,11 +70,14 @@ class PeriodicLog:
else None else None
) )
ext_timing_enabled = crawler.settings.getbool( ext_timing_enabled: bool = crawler.settings.getbool(
"PERIODIC_LOG_TIMING_ENABLED", False "PERIODIC_LOG_TIMING_ENABLED", False
) )
if not (ext_stats or ext_delta or ext_timing_enabled): if not (ext_stats or ext_delta or ext_timing_enabled):
raise NotConfigured raise NotConfigured
assert crawler.stats
assert ext_stats is not None
assert ext_delta is not None
o = cls( o = cls(
crawler.stats, crawler.stats,
interval, interval,
@ -72,16 +89,16 @@ class PeriodicLog:
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
return o return o
def spider_opened(self, spider): def spider_opened(self, spider: Spider) -> None:
self.time_prev = datetime.now(tz=timezone.utc) self.time_prev: datetime = datetime.now(tz=timezone.utc)
self.delta_prev = {} self.delta_prev: Dict[str, Union[int, float]] = {}
self.stats_prev = {} self.stats_prev: Dict[str, Union[int, float]] = {}
self.task = task.LoopingCall(self.log) self.task = task.LoopingCall(self.log)
self.task.start(self.interval) self.task.start(self.interval)
def log(self): def log(self) -> None:
data = {} data: Dict[str, Any] = {}
if self.ext_timing_enabled: if self.ext_timing_enabled:
data.update(self.log_timing()) data.update(self.log_timing())
if self.ext_delta_enabled: if self.ext_delta_enabled:
@ -90,8 +107,8 @@ class PeriodicLog:
data.update(self.log_crawler_stats()) data.update(self.log_crawler_stats())
logger.info(self.encoder.encode(data)) logger.info(self.encoder.encode(data))
def log_delta(self): def log_delta(self) -> Dict[str, Any]:
num_stats = { num_stats: Dict[str, Union[int, float]] = {
k: v k: v
for k, v in self.stats._stats.items() for k, v in self.stats._stats.items()
if isinstance(v, (int, float)) if isinstance(v, (int, float))
@ -101,7 +118,7 @@ class PeriodicLog:
self.delta_prev = num_stats self.delta_prev = num_stats
return {"delta": delta} return {"delta": delta}
def log_timing(self): def log_timing(self) -> Dict[str, Any]:
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
time = { time = {
"log_interval": self.interval, "log_interval": self.interval,
@ -113,7 +130,7 @@ class PeriodicLog:
self.time_prev = now self.time_prev = now
return {"time": time} return {"time": time}
def log_crawler_stats(self): def log_crawler_stats(self) -> Dict[str, Any]:
stats = { stats = {
k: v k: v
for k, v in self.stats._stats.items() for k, v in self.stats._stats.items()
@ -121,7 +138,9 @@ class PeriodicLog:
} }
return {"stats": stats} return {"stats": stats}
def param_allowed(self, stat_name, include, exclude): def param_allowed(
self, stat_name: str, include: List[str], exclude: List[str]
) -> bool:
if not include and not exclude: if not include and not exclude:
return True return True
for p in exclude: for p in exclude:
@ -134,7 +153,7 @@ class PeriodicLog:
return True return True
return False return False
def spider_closed(self, spider, reason): def spider_closed(self, spider: Spider, reason: str) -> None:
self.log() self.log()
if self.task and self.task.running: if self.task and self.task.running:
self.task.stop() self.task.stop()

View File

@ -1,11 +1,12 @@
""" """
Extension for processing data before they are exported to feeds. Extension for processing data before they are exported to feeds.
""" """
from bz2 import BZ2File from bz2 import BZ2File
from gzip import GzipFile from gzip import GzipFile
from io import IOBase from io import IOBase
from lzma import LZMAFile from lzma import LZMAFile
from typing import Any, BinaryIO, Dict, List from typing import IO, Any, BinaryIO, Dict, List, cast
from scrapy.utils.misc import load_object from scrapy.utils.misc import load_object
@ -42,7 +43,6 @@ class GzipPlugin:
def close(self) -> None: def close(self) -> None:
self.gzipfile.close() self.gzipfile.close()
self.file.close()
class Bz2Plugin: class Bz2Plugin:
@ -69,7 +69,6 @@ class Bz2Plugin:
def close(self) -> None: def close(self) -> None:
self.bz2file.close() self.bz2file.close()
self.file.close()
class LZMAPlugin: class LZMAPlugin:
@ -111,7 +110,6 @@ class LZMAPlugin:
def close(self) -> None: def close(self) -> None:
self.lzmafile.close() self.lzmafile.close()
self.file.close()
# io.IOBase is subclassed here, so that exporters can use the PostProcessingManager # io.IOBase is subclassed here, so that exporters can use the PostProcessingManager
@ -128,7 +126,7 @@ class PostProcessingManager(IOBase):
""" """
def __init__( def __init__(
self, plugins: List[Any], file: BinaryIO, feed_options: Dict[str, Any] self, plugins: List[Any], file: IO[bytes], feed_options: Dict[str, Any]
) -> None: ) -> None:
self.plugins = self._load_plugins(plugins) self.plugins = self._load_plugins(plugins)
self.file = file self.file = file
@ -144,7 +142,7 @@ class PostProcessingManager(IOBase):
:return: returns number of bytes written :return: returns number of bytes written
:rtype: int :rtype: int
""" """
return self.head_plugin.write(data) return cast(int, self.head_plugin.write(data))
def tell(self) -> int: def tell(self) -> int:
return self.file.tell() return self.file.tell()

View File

@ -1,19 +1,27 @@
import pickle from __future__ import annotations
from pathlib import Path
from scrapy import signals import pickle # nosec
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.utils.job import job_dir from scrapy.utils.job import job_dir
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
class SpiderState: class SpiderState:
"""Store and load spider state during a scraping job""" """Store and load spider state during a scraping job"""
def __init__(self, jobdir=None): def __init__(self, jobdir: Optional[str] = None):
self.jobdir = jobdir self.jobdir: Optional[str] = jobdir
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
jobdir = job_dir(crawler.settings) jobdir = job_dir(crawler.settings)
if not jobdir: if not jobdir:
raise NotConfigured raise NotConfigured
@ -23,18 +31,20 @@ class SpiderState:
crawler.signals.connect(obj.spider_opened, signal=signals.spider_opened) crawler.signals.connect(obj.spider_opened, signal=signals.spider_opened)
return obj return obj
def spider_closed(self, spider): def spider_closed(self, spider: Spider) -> None:
if self.jobdir: if self.jobdir:
with Path(self.statefn).open("wb") as f: with Path(self.statefn).open("wb") as f:
assert hasattr(spider, "state") # set in spider_opened
pickle.dump(spider.state, f, protocol=4) pickle.dump(spider.state, f, protocol=4)
def spider_opened(self, spider): def spider_opened(self, spider: Spider) -> None:
if self.jobdir and Path(self.statefn).exists(): if self.jobdir and Path(self.statefn).exists():
with Path(self.statefn).open("rb") as f: with Path(self.statefn).open("rb") as f:
spider.state = pickle.load(f) spider.state = pickle.load(f) # type: ignore[attr-defined] # nosec
else: else:
spider.state = {} spider.state = {} # type: ignore[attr-defined]
@property @property
def statefn(self) -> str: def statefn(self) -> str:
assert self.jobdir
return str(Path(self.jobdir, "spider.state")) return str(Path(self.jobdir, "spider.state"))

View File

@ -4,28 +4,41 @@ StatsMailer extension sends an email when a spider finishes scraping.
Use STATSMAILER_RCPTS setting to enable and give the recipient mail address Use STATSMAILER_RCPTS setting to enable and give the recipient mail address
""" """
from scrapy import signals from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional
from twisted.internet.defer import Deferred
from scrapy import Spider, signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.mail import MailSender from scrapy.mail import MailSender
from scrapy.statscollectors import StatsCollector
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
class StatsMailer: class StatsMailer:
def __init__(self, stats, recipients, mail): def __init__(self, stats: StatsCollector, recipients: List[str], mail: MailSender):
self.stats = stats self.stats: StatsCollector = stats
self.recipients = recipients self.recipients: List[str] = recipients
self.mail = mail self.mail: MailSender = mail
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
recipients = crawler.settings.getlist("STATSMAILER_RCPTS") recipients: List[str] = crawler.settings.getlist("STATSMAILER_RCPTS")
if not recipients: if not recipients:
raise NotConfigured raise NotConfigured
mail = MailSender.from_settings(crawler.settings) mail: MailSender = MailSender.from_settings(crawler.settings)
assert crawler.stats
o = cls(crawler.stats, recipients, mail) o = cls(crawler.stats, recipients, mail)
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed) crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
return o return o
def spider_closed(self, spider): def spider_closed(self, spider: Spider) -> Optional[Deferred]:
spider_stats = self.stats.get_stats(spider) spider_stats = self.stats.get_stats(spider)
body = "Global stats\n\n" body = "Global stats\n\n"
body += "\n".join(f"{k:<50} : {v}" for k, v in self.stats.get_stats().items()) body += "\n".join(f"{k:<50} : {v}" for k, v in self.stats.get_stats().items())

View File

@ -4,13 +4,17 @@ Scrapy Telnet Console extension
See documentation in docs/topics/telnetconsole.rst See documentation in docs/topics/telnetconsole.rst
""" """
from __future__ import annotations
import binascii import binascii
import logging import logging
import os import os
import pprint import pprint
import traceback import traceback
from typing import TYPE_CHECKING, Any, Dict, List
from twisted.internet import protocol from twisted.internet import protocol
from twisted.internet.tcp import Port
try: try:
from twisted.conch import manhole, telnet from twisted.conch import manhole, telnet
@ -22,12 +26,16 @@ except (ImportError, SyntaxError):
TWISTED_CONCH_AVAILABLE = False TWISTED_CONCH_AVAILABLE = False
from scrapy import signals from scrapy import signals
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.utils.decorators import defers from scrapy.utils.decorators import defers
from scrapy.utils.engine import print_engine_status from scrapy.utils.engine import print_engine_status
from scrapy.utils.reactor import listen_tcp from scrapy.utils.reactor import listen_tcp
from scrapy.utils.trackref import print_live_refs from scrapy.utils.trackref import print_live_refs
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# signal to update telnet variables # signal to update telnet variables
@ -36,7 +44,7 @@ update_telnet_vars = object()
class TelnetConsole(protocol.ServerFactory): class TelnetConsole(protocol.ServerFactory):
def __init__(self, crawler): def __init__(self, crawler: Crawler):
if not crawler.settings.getbool("TELNETCONSOLE_ENABLED"): if not crawler.settings.getbool("TELNETCONSOLE_ENABLED"):
raise NotConfigured raise NotConfigured
if not TWISTED_CONCH_AVAILABLE: if not TWISTED_CONCH_AVAILABLE:
@ -44,14 +52,14 @@ class TelnetConsole(protocol.ServerFactory):
"TELNETCONSOLE_ENABLED setting is True but required twisted " "TELNETCONSOLE_ENABLED setting is True but required twisted "
"modules failed to import:\n" + _TWISTED_CONCH_TRACEBACK "modules failed to import:\n" + _TWISTED_CONCH_TRACEBACK
) )
self.crawler = crawler self.crawler: Crawler = crawler
self.noisy = False self.noisy: bool = False
self.portrange = [ self.portrange: List[int] = [
int(x) for x in crawler.settings.getlist("TELNETCONSOLE_PORT") int(x) for x in crawler.settings.getlist("TELNETCONSOLE_PORT")
] ]
self.host = crawler.settings["TELNETCONSOLE_HOST"] self.host: str = crawler.settings["TELNETCONSOLE_HOST"]
self.username = crawler.settings["TELNETCONSOLE_USERNAME"] self.username: str = crawler.settings["TELNETCONSOLE_USERNAME"]
self.password = crawler.settings["TELNETCONSOLE_PASSWORD"] self.password: str = crawler.settings["TELNETCONSOLE_PASSWORD"]
if not self.password: if not self.password:
self.password = binascii.hexlify(os.urandom(8)).decode("utf8") self.password = binascii.hexlify(os.urandom(8)).decode("utf8")
@ -61,11 +69,11 @@ class TelnetConsole(protocol.ServerFactory):
self.crawler.signals.connect(self.stop_listening, signals.engine_stopped) self.crawler.signals.connect(self.stop_listening, signals.engine_stopped)
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler) return cls(crawler)
def start_listening(self): def start_listening(self) -> None:
self.port = listen_tcp(self.portrange, self.host, self) self.port: Port = listen_tcp(self.portrange, self.host, self)
h = self.port.getHost() h = self.port.getHost()
logger.info( logger.info(
"Telnet console listening on %(host)s:%(port)d", "Telnet console listening on %(host)s:%(port)d",
@ -73,10 +81,10 @@ class TelnetConsole(protocol.ServerFactory):
extra={"crawler": self.crawler}, extra={"crawler": self.crawler},
) )
def stop_listening(self): def stop_listening(self) -> None:
self.port.stopListening() self.port.stopListening()
def protocol(self): def protocol(self) -> telnet.TelnetTransport: # type: ignore[override]
class Portal: class Portal:
"""An implementation of IPortal""" """An implementation of IPortal"""
@ -95,9 +103,10 @@ class TelnetConsole(protocol.ServerFactory):
return telnet.TelnetTransport(telnet.AuthenticatingTelnetProtocol, Portal()) return telnet.TelnetTransport(telnet.AuthenticatingTelnetProtocol, Portal())
def _get_telnet_vars(self): def _get_telnet_vars(self) -> Dict[str, Any]:
# Note: if you add entries here also update topics/telnetconsole.rst # Note: if you add entries here also update topics/telnetconsole.rst
telnet_vars = { assert self.crawler.engine
telnet_vars: Dict[str, Any] = {
"engine": self.crawler.engine, "engine": self.crawler.engine,
"spider": self.crawler.engine.spider, "spider": self.crawler.engine.spider,
"slot": self.crawler.engine.slot, "slot": self.crawler.engine.slot,

View File

@ -1,51 +1,68 @@
import logging from __future__ import annotations
from scrapy import signals import logging
from typing import TYPE_CHECKING, Optional, Tuple
from scrapy import Request, Spider, signals
from scrapy.core.downloader import Slot
from scrapy.crawler import Crawler
from scrapy.exceptions import NotConfigured from scrapy.exceptions import NotConfigured
from scrapy.http import Response
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AutoThrottle: class AutoThrottle:
def __init__(self, crawler): def __init__(self, crawler: Crawler):
self.crawler = crawler self.crawler: Crawler = crawler
if not crawler.settings.getbool("AUTOTHROTTLE_ENABLED"): if not crawler.settings.getbool("AUTOTHROTTLE_ENABLED"):
raise NotConfigured raise NotConfigured
self.debug = crawler.settings.getbool("AUTOTHROTTLE_DEBUG") self.debug: bool = crawler.settings.getbool("AUTOTHROTTLE_DEBUG")
self.target_concurrency = crawler.settings.getfloat( self.target_concurrency: float = crawler.settings.getfloat(
"AUTOTHROTTLE_TARGET_CONCURRENCY" "AUTOTHROTTLE_TARGET_CONCURRENCY"
) )
if self.target_concurrency <= 0.0:
raise NotConfigured(
f"AUTOTHROTTLE_TARGET_CONCURRENCY "
f"({self.target_concurrency!r}) must be higher than 0."
)
crawler.signals.connect(self._spider_opened, signal=signals.spider_opened) crawler.signals.connect(self._spider_opened, signal=signals.spider_opened)
crawler.signals.connect( crawler.signals.connect(
self._response_downloaded, signal=signals.response_downloaded self._response_downloaded, signal=signals.response_downloaded
) )
@classmethod @classmethod
def from_crawler(cls, crawler): def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler) return cls(crawler)
def _spider_opened(self, spider): def _spider_opened(self, spider: Spider) -> None:
self.mindelay = self._min_delay(spider) self.mindelay = self._min_delay(spider)
self.maxdelay = self._max_delay(spider) self.maxdelay = self._max_delay(spider)
spider.download_delay = self._start_delay(spider) spider.download_delay = self._start_delay(spider) # type: ignore[attr-defined]
def _min_delay(self, spider): def _min_delay(self, spider: Spider) -> float:
s = self.crawler.settings s = self.crawler.settings
return getattr(spider, "download_delay", s.getfloat("DOWNLOAD_DELAY")) return getattr(spider, "download_delay", s.getfloat("DOWNLOAD_DELAY"))
def _max_delay(self, spider): def _max_delay(self, spider: Spider) -> float:
return self.crawler.settings.getfloat("AUTOTHROTTLE_MAX_DELAY") return self.crawler.settings.getfloat("AUTOTHROTTLE_MAX_DELAY")
def _start_delay(self, spider): def _start_delay(self, spider: Spider) -> float:
return max( return max(
self.mindelay, self.crawler.settings.getfloat("AUTOTHROTTLE_START_DELAY") self.mindelay, self.crawler.settings.getfloat("AUTOTHROTTLE_START_DELAY")
) )
def _response_downloaded(self, response, request, spider): def _response_downloaded(
self, response: Response, request: Request, spider: Spider
) -> None:
key, slot = self._get_slot(request, spider) key, slot = self._get_slot(request, spider)
latency = request.meta.get("download_latency") latency = request.meta.get("download_latency")
if latency is None or slot is None: if latency is None or slot is None or slot.throttle is False:
return return
olddelay = slot.delay olddelay = slot.delay
@ -69,11 +86,16 @@ class AutoThrottle:
extra={"spider": spider}, extra={"spider": spider},
) )
def _get_slot(self, request, spider): def _get_slot(
key = request.meta.get("download_slot") self, request: Request, spider: Spider
) -> Tuple[Optional[str], Optional[Slot]]:
key: Optional[str] = request.meta.get("download_slot")
if key is None:
return None, None
assert self.crawler.engine
return key, self.crawler.engine.downloader.slots.get(key) return key, self.crawler.engine.downloader.slots.get(key)
def _adjust_delay(self, slot, latency, response): def _adjust_delay(self, slot: Slot, latency: float, response: Response) -> None:
"""Define delay adjustment policy""" """Define delay adjustment policy"""
# If a server needs `latency` seconds to respond then # If a server needs `latency` seconds to respond then

View File

@ -12,5 +12,6 @@ from scrapy.http.request.json_request import JsonRequest
from scrapy.http.request.rpc import XmlRpcRequest from scrapy.http.request.rpc import XmlRpcRequest
from scrapy.http.response import Response from scrapy.http.response import Response
from scrapy.http.response.html import HtmlResponse 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.text import TextResponse
from scrapy.http.response.xml import XmlResponse from scrapy.http.response.xml import XmlResponse

View File

@ -1,36 +1,56 @@
from __future__ import annotations
import re import re
import time import time
from http.cookiejar import Cookie from http.cookiejar import Cookie
from http.cookiejar import CookieJar as _CookieJar from http.cookiejar import CookieJar as _CookieJar
from http.cookiejar import DefaultCookiePolicy from http.cookiejar import CookiePolicy, DefaultCookiePolicy
from typing import Sequence from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterator,
List,
Optional,
Sequence,
Tuple,
cast,
)
from scrapy import Request from scrapy import Request
from scrapy.http import Response from scrapy.http import Response
from scrapy.utils.httpobj import urlparse_cached from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_unicode from scrapy.utils.python import to_unicode
if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
# Defined in the http.cookiejar module, but undocumented: # Defined in the http.cookiejar module, but undocumented:
# https://github.com/python/cpython/blob/v3.9.0/Lib/http/cookiejar.py#L527 # https://github.com/python/cpython/blob/v3.9.0/Lib/http/cookiejar.py#L527
IPV4_RE = re.compile(r"\.\d+$", re.ASCII) IPV4_RE = re.compile(r"\.\d+$", re.ASCII)
class CookieJar: class CookieJar:
def __init__(self, policy=None, check_expired_frequency=10000): def __init__(
self.policy = policy or DefaultCookiePolicy() self,
self.jar = _CookieJar(self.policy) policy: Optional[CookiePolicy] = None,
self.jar._cookies_lock = _DummyLock() check_expired_frequency: int = 10000,
self.check_expired_frequency = check_expired_frequency ):
self.processed = 0 self.policy: CookiePolicy = policy or DefaultCookiePolicy()
self.jar: _CookieJar = _CookieJar(self.policy)
self.jar._cookies_lock = _DummyLock() # type: ignore[attr-defined]
self.check_expired_frequency: int = check_expired_frequency
self.processed: int = 0
def extract_cookies(self, response, request): def extract_cookies(self, response: Response, request: Request) -> None:
wreq = WrappedRequest(request) wreq = WrappedRequest(request)
wrsp = WrappedResponse(response) wrsp = WrappedResponse(response)
return self.jar.extract_cookies(wrsp, wreq) self.jar.extract_cookies(wrsp, wreq) # type: ignore[arg-type]
def add_cookie_header(self, request: Request) -> None: def add_cookie_header(self, request: Request) -> None:
wreq = WrappedRequest(request) wreq = WrappedRequest(request)
self.policy._now = self.jar._now = int(time.time()) self.policy._now = self.jar._now = int(time.time()) # type: ignore[attr-defined]
# the cookiejar implementation iterates through all domains # the cookiejar implementation iterates through all domains
# instead we restrict to potential matches on the domain # instead we restrict to potential matches on the domain
@ -47,10 +67,10 @@ class CookieJar:
cookies = [] cookies = []
for host in hosts: for host in hosts:
if host in self.jar._cookies: if host in self.jar._cookies: # type: ignore[attr-defined]
cookies += self.jar._cookies_for_domain(host, wreq) cookies += self.jar._cookies_for_domain(host, wreq) # type: ignore[attr-defined]
attrs = self.jar._cookie_attrs(cookies) attrs = self.jar._cookie_attrs(cookies) # type: ignore[attr-defined]
if attrs: if attrs:
if not wreq.has_header("Cookie"): if not wreq.has_header("Cookie"):
wreq.add_unredirected_header("Cookie", "; ".join(attrs)) wreq.add_unredirected_header("Cookie", "; ".join(attrs))
@ -61,37 +81,42 @@ class CookieJar:
self.jar.clear_expired_cookies() self.jar.clear_expired_cookies()
@property @property
def _cookies(self): def _cookies(self) -> Dict[str, Dict[str, Dict[str, Cookie]]]:
return self.jar._cookies return self.jar._cookies # type: ignore[attr-defined,no-any-return]
def clear_session_cookies(self, *args, **kwargs): def clear_session_cookies(self) -> None:
return self.jar.clear_session_cookies(*args, **kwargs) return self.jar.clear_session_cookies()
def clear(self, domain=None, path=None, name=None): def clear(
return self.jar.clear(domain, path, name) self,
domain: Optional[str] = None,
path: Optional[str] = None,
name: Optional[str] = None,
) -> None:
self.jar.clear(domain, path, name)
def __iter__(self): def __iter__(self) -> Iterator[Cookie]:
return iter(self.jar) return iter(self.jar)
def __len__(self): def __len__(self) -> int:
return len(self.jar) return len(self.jar)
def set_policy(self, pol): def set_policy(self, pol: CookiePolicy) -> None:
return self.jar.set_policy(pol) self.jar.set_policy(pol)
def make_cookies(self, response: Response, request: Request) -> Sequence[Cookie]: def make_cookies(self, response: Response, request: Request) -> Sequence[Cookie]:
wreq = WrappedRequest(request) wreq = WrappedRequest(request)
wrsp = WrappedResponse(response) wrsp = WrappedResponse(response)
return self.jar.make_cookies(wrsp, wreq) return self.jar.make_cookies(wrsp, wreq) # type: ignore[arg-type]
def set_cookie(self, cookie): def set_cookie(self, cookie: Cookie) -> None:
self.jar.set_cookie(cookie) self.jar.set_cookie(cookie)
def set_cookie_if_ok(self, cookie: Cookie, request: Request) -> None: def set_cookie_if_ok(self, cookie: Cookie, request: Request) -> None:
self.jar.set_cookie_if_ok(cookie, WrappedRequest(request)) self.jar.set_cookie_if_ok(cookie, WrappedRequest(request)) # type: ignore[arg-type]
def potential_domain_matches(domain): def potential_domain_matches(domain: str) -> List[str]:
"""Potential domain matches for a cookie """Potential domain matches for a cookie
>>> potential_domain_matches('www.example.com') >>> potential_domain_matches('www.example.com')
@ -111,10 +136,10 @@ def potential_domain_matches(domain):
class _DummyLock: class _DummyLock:
def acquire(self): def acquire(self) -> None:
pass pass
def release(self): def release(self) -> None:
pass pass
@ -124,19 +149,19 @@ class WrappedRequest:
see http://docs.python.org/library/urllib2.html#urllib2.Request see http://docs.python.org/library/urllib2.html#urllib2.Request
""" """
def __init__(self, request): def __init__(self, request: Request):
self.request = request self.request = request
def get_full_url(self): def get_full_url(self) -> str:
return self.request.url return self.request.url
def get_host(self): def get_host(self) -> str:
return urlparse_cached(self.request).netloc return urlparse_cached(self.request).netloc
def get_type(self): def get_type(self) -> str:
return urlparse_cached(self.request).scheme return urlparse_cached(self.request).scheme
def is_unverifiable(self): def is_unverifiable(self) -> bool:
"""Unverifiable should indicate whether the request is unverifiable, as defined by RFC 2965. """Unverifiable should indicate whether the request is unverifiable, as defined by RFC 2965.
It defaults to False. An unverifiable request is one whose URL the user did not have the It defaults to False. An unverifiable request is one whose URL the user did not have the
@ -144,35 +169,36 @@ class WrappedRequest:
HTML document, and the user had no option to approve the automatic HTML document, and the user had no option to approve the automatic
fetching of the image, this should be true. fetching of the image, this should be true.
""" """
return self.request.meta.get("is_unverifiable", False) return cast(bool, self.request.meta.get("is_unverifiable", False))
@property @property
def full_url(self): def full_url(self) -> str:
return self.get_full_url() return self.get_full_url()
@property @property
def host(self): def host(self) -> str:
return self.get_host() return self.get_host()
@property @property
def type(self): def type(self) -> str:
return self.get_type() return self.get_type()
@property @property
def unverifiable(self): def unverifiable(self) -> bool:
return self.is_unverifiable() return self.is_unverifiable()
@property @property
def origin_req_host(self): def origin_req_host(self) -> str:
return urlparse_cached(self.request).hostname return cast(str, urlparse_cached(self.request).hostname)
def has_header(self, name): def has_header(self, name: str) -> bool:
return name in self.request.headers return name in self.request.headers
def get_header(self, name, default=None): def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
return to_unicode(self.request.headers.get(name, default), errors="replace") value = self.request.headers.get(name, default)
return to_unicode(value, errors="replace") if value is not None else None
def header_items(self): def header_items(self) -> List[Tuple[str, List[str]]]:
return [ return [
( (
to_unicode(k, errors="replace"), to_unicode(k, errors="replace"),
@ -181,18 +207,18 @@ class WrappedRequest:
for k, v in self.request.headers.items() for k, v in self.request.headers.items()
] ]
def add_unredirected_header(self, name, value): def add_unredirected_header(self, name: str, value: str) -> None:
self.request.headers.appendlist(name, value) self.request.headers.appendlist(name, value)
class WrappedResponse: class WrappedResponse:
def __init__(self, response): def __init__(self, response: Response):
self.response = response self.response = response
def info(self): def info(self) -> Self:
return self return self
def get_all(self, name, default=None): def get_all(self, name: str, default: Any = None) -> List[str]:
return [ return [
to_unicode(v, errors="replace") for v in self.response.headers.getlist(name) to_unicode(v, errors="replace") for v in self.response.headers.getlist(name)
] ]

View File

@ -113,7 +113,9 @@ class Headers(CaselessDict):
return ((k, self.getlist(k)) for k in self.keys()) return ((k, self.getlist(k)) for k in self.keys())
def values(self) -> List[Optional[bytes]]: # type: ignore[override] def values(self) -> List[Optional[bytes]]: # type: ignore[override]
return [self[k] for k in self.keys()] return [
self[k] for k in self.keys() # pylint: disable=consider-using-dict-items
]
def to_string(self) -> bytes: def to_string(self) -> bytes:
# cast() can be removed if the headers_dict_to_raw() hint is improved # cast() can be removed if the headers_dict_to_raw() hint is improved

View File

@ -4,8 +4,12 @@ requests in Scrapy.
See documentation in docs/topics/request-response.rst See documentation in docs/topics/request-response.rst
""" """
from __future__ import annotations
import inspect import inspect
from typing import ( from typing import (
TYPE_CHECKING,
Any, Any,
AnyStr, AnyStr,
Callable, Callable,
@ -16,8 +20,6 @@ from typing import (
NoReturn, NoReturn,
Optional, Optional,
Tuple, Tuple,
Type,
TypeVar,
Union, Union,
cast, cast,
) )
@ -31,7 +33,9 @@ from scrapy.utils.python import to_bytes
from scrapy.utils.trackref import object_ref from scrapy.utils.trackref import object_ref
from scrapy.utils.url import escape_ajax from scrapy.utils.url import escape_ajax
RequestTypeVar = TypeVar("RequestTypeVar", bound="Request") if TYPE_CHECKING:
# typing.Self requires Python 3.11
from typing_extensions import Self
def NO_CALLBACK(*args: Any, **kwargs: Any) -> NoReturn: def NO_CALLBACK(*args: Any, **kwargs: Any) -> NoReturn:
@ -185,11 +189,11 @@ class Request(object_ref):
@classmethod @classmethod
def from_curl( def from_curl(
cls: Type[RequestTypeVar], cls,
curl_command: str, curl_command: str,
ignore_unknown_options: bool = True, ignore_unknown_options: bool = True,
**kwargs: Any, **kwargs: Any,
) -> RequestTypeVar: ) -> Self:
"""Create a Request object from a string containing a `cURL """Create a Request object from a string containing a `cURL
<https://curl.haxx.se/>`_ command. It populates the HTTP method, the <https://curl.haxx.se/>`_ command. It populates the HTTP method, the
URL, the headers, the cookies and the body. It accepts the same URL, the headers, the cookies and the body. It accepts the same
@ -231,12 +235,16 @@ class Request(object_ref):
""" """
d = { d = {
"url": self.url, # urls are safe (safe_string_url) "url": self.url, # urls are safe (safe_string_url)
"callback": _find_method(spider, self.callback) "callback": (
if callable(self.callback) _find_method(spider, self.callback)
else self.callback, if callable(self.callback)
"errback": _find_method(spider, self.errback) else self.callback
if callable(self.errback) ),
else self.errback, "errback": (
_find_method(spider, self.errback)
if callable(self.errback)
else self.errback
),
"headers": dict(self.headers), "headers": dict(self.headers),
} }
for attr in self.attributes: for attr in self.attributes:

View File

@ -10,21 +10,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Tuple, Union, cast from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Tuple, Union, cast
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
from lxml.html import ( from lxml.html import FormElement # nosec
FormElement, from lxml.html import InputElement # nosec
HTMLParser, from lxml.html import MultipleSelectOptions # nosec
InputElement, from lxml.html import SelectElement # nosec
MultipleSelectOptions, from lxml.html import TextareaElement # nosec
SelectElement,
TextareaElement,
)
from parsel.selector import create_root_node
from w3lib.html import strip_html5_whitespace from w3lib.html import strip_html5_whitespace
from scrapy.http.request import Request from scrapy.http.request import Request
from scrapy.http.response.text import TextResponse from scrapy.http.response.text import TextResponse
from scrapy.utils.python import is_listlike, to_bytes from scrapy.utils.python import is_listlike, to_bytes
from scrapy.utils.response import get_base_url
if TYPE_CHECKING: if TYPE_CHECKING:
# typing.Self requires Python 3.11 # typing.Self requires Python 3.11
@ -120,7 +115,7 @@ def _get_form(
formxpath: Optional[str], formxpath: Optional[str],
) -> FormElement: ) -> FormElement:
"""Find the wanted form element within the given response.""" """Find the wanted form element within the given response."""
root = create_root_node(response.text, HTMLParser, base_url=get_base_url(response)) root = response.selector.root
forms = root.xpath("//form") forms = root.xpath("//form")
if not forms: if not forms:
raise ValueError(f"No <form> element found in {response}") raise ValueError(f"No <form> element found in {response}")

View File

@ -4,12 +4,17 @@ This module implements the XmlRpcRequest class which is a more convenient class
See documentation in docs/topics/request-response.rst See documentation in docs/topics/request-response.rst
""" """
import xmlrpc.client as xmlrpclib import xmlrpc.client as xmlrpclib
from typing import Any, Optional from typing import Any, Optional
import defusedxml.xmlrpc
from scrapy.http.request import Request from scrapy.http.request import Request
from scrapy.utils.python import get_func_args from scrapy.utils.python import get_func_args
defusedxml.xmlrpc.monkey_patch()
DUMPS_ARGS = get_func_args(xmlrpclib.dumps) DUMPS_ARGS = get_func_args(xmlrpclib.dumps)

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