Merge remote-tracking branch 'origin/master' into HassaanNaushahi-BR_fix_issue-1998

This commit is contained in:
Andrey Rakhmatullin 2024-12-30 20:02:54 +05:00
commit 53a38eeb13
330 changed files with 13351 additions and 6683 deletions

View File

@ -1,21 +0,0 @@
skips:
- B101
- B113 # https://github.com/PyCQA/bandit/issues/1010
- B105
- B301
- B303
- B306
- B307
- B311
- B320
- B321
- B324
- B402 # https://github.com/scrapy/scrapy/issues/4180
- B403
- B404
- B406
- B410
- B503
- B603
- B605
exclude_dirs: ['tests']

View File

@ -1,7 +0,0 @@
[bumpversion]
current_version = 2.11.0
commit = True
tag = True
tag_name = {new_version}
[bumpversion:file:scrapy/VERSION]

View File

@ -1,6 +0,0 @@
[run]
branch = true
include = scrapy/*
omit =
tests/*
disable_warnings = include-ignored

22
.flake8
View File

@ -1,22 +0,0 @@
[flake8]
max-line-length = 119
ignore = W503, E203
exclude =
docs/conf.py
per-file-ignores =
# Exclude files that are meant to provide top-level imports
# E402: Module level import not at top of file
# F401: Module imported but unused
scrapy/__init__.py:E402
scrapy/core/downloader/handlers/http.py:F401
scrapy/http/__init__.py:F401
scrapy/linkextractors/__init__.py:E402,F401
scrapy/selector/__init__.py:F401
scrapy/spiders/__init__.py:E402,F401
# Issues pending a review:
scrapy/utils/url.py:F403,F405
tests/test_loader.py:E741

View File

@ -1,7 +1,7 @@
# .git-blame-ignore-revs
# adding black formatter to all the code
e211ec0aa26ecae0da8ae55d064ea60e1efe4d0d
# re applying black to the code with default line length
# reapplying black to the code with default line length
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

View File

@ -12,16 +12,19 @@ jobs:
fail-fast: false
matrix:
include:
- python-version: "3.12"
- python-version: "3.13"
env:
TOXENV: pylint
- python-version: 3.8
- python-version: "3.9"
env:
TOXENV: typing
- python-version: "3.11" # Keep in sync with .readthedocs.yml
- python-version: "3.9"
env:
TOXENV: typing-tests
- python-version: "3.13" # Keep in sync with .readthedocs.yml
env:
TOXENV: docs
- python-version: "3.12"
- python-version: "3.13"
env:
TOXENV: twinecheck
@ -29,7 +32,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
@ -43,4 +46,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pre-commit/action@v3.0.0
- uses: pre-commit/action@v3.0.1

View File

@ -10,16 +10,20 @@ concurrency:
jobs:
publish:
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/Scrapy
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
- uses: actions/setup-python@v5
with:
python-version: 3.12
python-version: "3.13"
- run: |
pip install --upgrade build twine
python -m pip install --upgrade build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@v1.6.4
with:
password: ${{ secrets.PYPI_TOKEN }}
uses: pypa/gh-action-pypi-publish@release/v1

View File

@ -7,17 +7,17 @@ concurrency:
jobs:
tests:
runs-on: macos-11
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

View File

@ -12,7 +12,7 @@ jobs:
fail-fast: false
matrix:
include:
- python-version: 3.9
- python-version: "3.9"
env:
TOXENV: py
- python-version: "3.10"
@ -24,7 +24,10 @@ jobs:
- python-version: "3.12"
env:
TOXENV: py
- python-version: "3.12"
- python-version: "3.13"
env:
TOXENV: py
- python-version: "3.13"
env:
TOXENV: asyncio
- python-version: pypy3.9
@ -35,26 +38,26 @@ jobs:
TOXENV: pypy3
# pinned deps
- python-version: 3.8.17
- python-version: 3.9.19
env:
TOXENV: pinned
- python-version: 3.8.17
- python-version: 3.9.19
env:
TOXENV: asyncio-pinned
- python-version: pypy3.8
- python-version: pypy3.9
env:
TOXENV: pypy3-pinned
- python-version: 3.8.17
- python-version: 3.9.19
env:
TOXENV: extra-deps-pinned
- python-version: 3.8.17
- python-version: 3.9.19
env:
TOXENV: botocore-pinned
- python-version: "3.12"
- python-version: "3.13"
env:
TOXENV: extra-deps
- python-version: "3.12"
- python-version: "3.13"
env:
TOXENV: botocore
@ -62,7 +65,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

View File

@ -12,12 +12,9 @@ jobs:
fail-fast: false
matrix:
include:
- python-version: 3.8
- python-version: "3.9"
env:
TOXENV: windows-pinned
- python-version: 3.9
env:
TOXENV: py
- python-version: "3.10"
env:
TOXENV: py
@ -27,7 +24,10 @@ jobs:
- python-version: "3.12"
env:
TOXENV: py
- python-version: "3.12"
- python-version: "3.13"
env:
TOXENV: py
- python-version: "3.13"
env:
TOXENV: asyncio
@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

View File

@ -1,2 +0,0 @@
[settings]
profile = black

View File

@ -1,24 +1,16 @@
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.7.5
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.4
hooks:
- id: bandit
args: [-r, -c, .bandit.yml]
- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
hooks:
- id: flake8
- id: ruff
args: [ --fix ]
- repo: https://github.com/psf/black.git
rev: 23.9.1
rev: 24.10.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
- repo: https://github.com/adamchainz/blacken-docs
rev: 1.16.0
rev: 1.19.1
hooks:
- id: blacken-docs
additional_dependencies:
- black==23.9.1
- black==24.10.0

View File

@ -9,7 +9,7 @@ build:
tools:
# For available versions, see:
# https://docs.readthedocs.io/en/stable/config-file/v2.html#build-tools-python
python: "3.11" # Keep in sync with .github/workflows/checks.yml
python: "3.13" # Keep in sync with .github/workflows/checks.yml
python:
install:

View File

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

View File

@ -6,11 +6,11 @@ Scrapy
======
.. image:: https://img.shields.io/pypi/v/Scrapy.svg
:target: https://pypi.python.org/pypi/Scrapy
:target: https://pypi.org/pypi/Scrapy
:alt: PyPI Version
.. image:: https://img.shields.io/pypi/pyversions/Scrapy.svg
:target: https://pypi.python.org/pypi/Scrapy
:target: https://pypi.org/pypi/Scrapy
:alt: Supported Python Versions
.. image:: https://github.com/scrapy/scrapy/workflows/Ubuntu/badge.svg
@ -27,7 +27,7 @@ Scrapy
:alt: Windows
.. image:: https://img.shields.io/badge/wheel-yes-brightgreen.svg
:target: https://pypi.python.org/pypi/Scrapy
:target: https://pypi.org/pypi/Scrapy
:alt: Wheel Status
.. image:: https://img.shields.io/codecov/c/github/scrapy/scrapy/master.svg
@ -59,7 +59,7 @@ including a list of features.
Requirements
============
* Python 3.8+
* Python 3.9+
* Works on Linux, Windows, macOS, BSD
Install
@ -111,4 +111,4 @@ See https://scrapy.org/companies/ for a list.
Commercial Support
==================
See https://scrapy.org/support/ for details.
See https://scrapy.org/support/ for details.

12
SECURITY.md Normal file
View File

@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 2.12.x | :white_check_mark: |
| < 2.12.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
import pytest
from twisted import version as twisted_version
from twisted.python.versions import Version
from twisted.web.http import H2_ENABLED
from scrapy.utils.reactor import install_reactor
@ -28,7 +24,9 @@ collect_ignore = [
*_py_files("tests/CrawlerRunner"),
]
with Path("tests/ignores.txt").open(encoding="utf-8") as reader:
base_dir = Path(__file__).parent
ignore_file_path = base_dir / "tests" / "ignores.txt"
with ignore_file_path.open(encoding="utf-8") as reader:
for line in reader:
file_path = line.strip()
if file_path and file_path[0] != "#":
@ -61,7 +59,7 @@ def pytest_addoption(parser):
def reactor_pytest(request):
if not request.cls:
# doctests
return
return None
request.cls.reactor_pytest = request.config.getoption("--reactor")
return request.cls.reactor_pytest
@ -85,14 +83,36 @@ def only_not_asyncio(request, reactor_pytest):
def requires_uvloop(request):
if not request.node.get_closest_marker("requires_uvloop"):
return
if sys.implementation.name == "pypy":
pytest.skip("uvloop does not support pypy properly")
if platform.system() == "Windows":
pytest.skip("uvloop does not support Windows")
if twisted_version == Version("twisted", 21, 2, 0):
pytest.skip("https://twistedmatrix.com/trac/ticket/10106")
if sys.version_info >= (3, 12):
pytest.skip("uvloop doesn't support Python 3.12 yet")
try:
import uvloop
del uvloop
except ImportError:
pytest.skip("uvloop is not installed")
@pytest.fixture(autouse=True)
def requires_botocore(request):
if not request.node.get_closest_marker("requires_botocore"):
return
try:
import botocore
del botocore
except ImportError:
pytest.skip("botocore is not installed")
@pytest.fixture(autouse=True)
def requires_boto3(request):
if not request.node.get_closest_marker("requires_boto3"):
return
try:
import boto3
del boto3
except ImportError:
pytest.skip("boto3 is not installed")
def pytest_configure(config):

View File

@ -273,7 +273,7 @@
Quotes by: <a href="https://www.goodreads.com/quotes">GoodReads.com</a>
</p>
<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>
</div>
</footer>

View File

@ -273,7 +273,7 @@
Quotes by: <a href="https://www.goodreads.com/quotes">GoodReads.com</a>
</p>
<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>
</div>
</footer>

View File

@ -8,9 +8,8 @@
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import os
import sys
from datetime import datetime
from pathlib import Path
# 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.
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
# |version| and |release|, also used in various other places throughout the
@ -187,6 +186,8 @@ html_css_files = [
"custom.css",
]
# Set canonical URL from the Read the Docs Domain
html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "")
# Options for LaTeX output
# ------------------------
@ -227,9 +228,10 @@ latex_documents = [
# A list of regular expressions that match URIs that should not be checked when
# doing a linkcheck build.
linkcheck_ignore = [
"http://localhost:\d+",
r"http://localhost:\d+",
"http://hg.scrapy.org",
"http://directory.google.com/",
r"https://github.com/scrapy/scrapy/issues/\d+",
]

View File

@ -74,18 +74,81 @@ guidelines when you're going to report a new bug.
.. _Minimal, Complete, and Verifiable example: https://stackoverflow.com/help/mcve
.. _find-work:
Finding work
============
If you have decided to make a contribution to Scrapy, but you do not know what
to contribute, you have a few options to find pending work:
- Check out the `contribution GitHub page`_, which lists open issues tagged
as **good first issue**.
.. _contribution GitHub page: https://github.com/scrapy/scrapy/contribute
There are also `help wanted issues`_ but mind that some may require
familiarity with the Scrapy code base. You can also target any other issue
provided it is not tagged as **discuss**.
- If you enjoy writing documentation, there are `documentation issues`_ as
well, but mind that some may require familiarity with the Scrapy code base
as well.
.. _documentation issues: https://github.com/scrapy/scrapy/issues?q=is%3Aissue+is%3Aopen+label%3Adocs+
- If you enjoy :ref:`writing automated tests <write-tests>`, you can work on
increasing our `test coverage`_.
- If you enjoy code cleanup, we welcome fixes for issues detected by our
static analysis tools. See ``pyproject.toml`` for silenced issues that may
need addressing.
Mind that some issues we do not aim to address at all, and usually include
a comment on them explaining the reason; not to confuse with comments that
state what the issue is about, for non-descriptive issue codes.
If you have found an issue, make sure you read the entire issue thread before
you ask questions. That includes related issues and pull requests that show up
in the issue thread when the issue is mentioned elsewhere.
We do not assign issues, and you do not need to announce that you are going to
start working on an issue either. If you want to work on an issue, just go
ahead and :ref:`write a patch for it <writing-patches>`.
Do not discard an issue simply because there is an open pull request for it.
Check if open pull requests are active first. And even if some are active, if
you think you can build a better implementation, feel free to create a pull
request with your approach.
If you decide to work on something without an open issue, please:
- Do not create an issue to work on code coverage or code cleanup, create a
pull request directly.
- Do not create both an issue and a pull request right away. Either open an
issue first to get feedback on whether or not the issue is worth
addressing, and create a pull request later only if the feedback from the
team is positive, or create only a pull request, if you think a discussion
will be easier over your code.
- Do not add docstrings for the sake of adding docstrings, or only to address
silenced Ruff issues. We expect docstrings to exist only when they add
something significant to readers, such as explaining something that is not
easier to understand from reading the corresponding code, summarizing a
long, hard-to-read implementation, providing context about calling code, or
indicating purposely uncaught exceptions from called code.
- Do not add tests that use as much mocking as possible just to touch a given
line of code and hence improve line coverage. While we do aim to maximize
test coverage, tests should be written for real scenarios, with minimum
mocking. We usually prefer end-to-end tests.
.. _writing-patches:
Writing patches
===============
Scrapy has a list of `good first issues`_ and `help wanted issues`_ that you
can work on. These issues are a great way to get started with contributing to
Scrapy. If you're new to the codebase, you may want to focus on documentation
or testing-related issues, as they are always useful and can help you get
more familiar with the project. You can also check Scrapy's `test coverage`_
to see which areas may benefit from more tests.
The better a patch is written, the higher the chances that it'll get accepted and the sooner it will be merged.
Well-written patches should:
@ -131,6 +194,14 @@ Remember to explain what was fixed or the new functionality (what it is, why
it's needed, etc). The more info you include, the easier will be for core
developers to understand and accept your patch.
If your pull request aims to resolve an open issue, `link it accordingly
<https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword>`__,
e.g.:
.. code-block:: none
Resolves #123
You can also discuss the new functionality (or bug fix) before creating the
patch, but it's always good to have a patch ready to illustrate your arguments
and show that you have put some additional thought into the subject. A good
@ -154,7 +225,7 @@ by running ``git fetch upstream pull/$PR_NUMBER/head:$BRANCH_NAME_TO_CREATE``
(replace 'upstream' with a remote name for scrapy repository,
``$PR_NUMBER`` with an ID of the pull request, and ``$BRANCH_NAME_TO_CREATE``
with a name of the branch you want to create locally).
See also: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/checking-out-pull-requests-locally#modifying-an-inactive-pull-request-locally.
See also: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/checking-out-pull-requests-locally#modifying-an-inactive-pull-request-locally.
When writing GitHub pull requests, try to keep titles short but descriptive.
E.g. For bug #411: "Scrapy hangs if an exception raises in start_requests"
@ -178,12 +249,12 @@ Scrapy:
* We use `black <https://black.readthedocs.io/en/stable/>`_ for code formatting.
There is a hook in the pre-commit config
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
metadata to identify author of the code.
See https://help.github.com/en/github/using-git/setting-your-username-in-git for
setup instructions.
See https://docs.github.com/en/get-started/getting-started-with-git/setting-your-username-in-git
for setup instructions.
.. _scrapy-pre-commit:
@ -242,6 +313,7 @@ Documentation about deprecated features must be removed as those features are
deprecated, so that new readers do not run into it. New deprecations and
deprecation removals are documented in the :ref:`release notes <news>`.
.. _write-tests:
Tests
=====
@ -317,9 +389,8 @@ And their unit-tests are in::
.. _AUTHORS: https://github.com/scrapy/scrapy/blob/master/AUTHORS
.. _tests/: https://github.com/scrapy/scrapy/tree/master/tests
.. _open issues: https://github.com/scrapy/scrapy/issues
.. _PEP 257: https://www.python.org/dev/peps/pep-0257/
.. _pull request: https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
.. _PEP 257: https://peps.python.org/pep-0257/
.. _pull request: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request
.. _pytest-xdist: https://github.com/pytest-dev/pytest-xdist
.. _good first issues: https://github.com/scrapy/scrapy/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
.. _help wanted issues: https://github.com/scrapy/scrapy/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22
.. _test coverage: https://app.codecov.io/gh/scrapy/scrapy

View File

@ -23,7 +23,7 @@ comparing `jinja2`_ to `Django`_.
.. _BeautifulSoup: https://www.crummy.com/software/BeautifulSoup/
.. _lxml: https://lxml.de/
.. _jinja2: https://palletsprojects.com/p/jinja/
.. _jinja2: https://palletsprojects.com/projects/jinja/
.. _Django: https://www.djangoproject.com/
Can I use Scrapy with BeautifulSoup?
@ -138,39 +138,36 @@ See previous question.
How can I prevent memory errors due to many allowed domains?
------------------------------------------------------------
If you have a spider with a long list of
:attr:`~scrapy.Spider.allowed_domains` (e.g. 50,000+), consider
replacing the default
:class:`~scrapy.spidermiddlewares.offsite.OffsiteMiddleware` spider middleware
with a :ref:`custom spider middleware <custom-spider-middleware>` that requires
less memory. For example:
If you have a spider with a long list of :attr:`~scrapy.Spider.allowed_domains`
(e.g. 50,000+), consider replacing the default
:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` downloader
middleware with a :ref:`custom downloader middleware
<topics-downloader-middleware-custom>` that requires less memory. For example:
- If your domain names are similar enough, use your own regular expression
instead joining the strings in
:attr:`~scrapy.Spider.allowed_domains` into a complex regular
expression.
instead joining the strings in :attr:`~scrapy.Spider.allowed_domains` into
a complex regular 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
:issue:`1908`.
See also other suggestions at `StackOverflow`_.
See also `other suggestions at StackOverflow
<https://stackoverflow.com/q/36440681>`__.
.. note:: Remember to disable
:class:`scrapy.spidermiddlewares.offsite.OffsiteMiddleware` when you enable
your custom implementation:
:class:`scrapy.downloadermiddlewares.offsite.OffsiteMiddleware` when you
enable your custom implementation:
.. code-block:: python
SPIDER_MIDDLEWARES = {
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None,
"myproject.middlewares.CustomOffsiteMiddleware": 500,
DOWNLOADER_MIDDLEWARES = {
"scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": None,
"myproject.middlewares.CustomOffsiteMiddleware": 50,
}
.. _meet the installation requirements: https://github.com/andreasvc/pyre2#installation
.. _pyre2: https://github.com/andreasvc/pyre2
.. _re: https://docs.python.org/library/re.html
.. _StackOverflow: https://stackoverflow.com/q/36440681/939364
.. _re: https://docs.python.org/3/library/re.html
Can I use Basic HTTP Authentication in my spiders?
--------------------------------------------------
@ -206,12 +203,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
problem, so you may not need to fix them.
Those messages are thrown by the Offsite Spider Middleware, which is a spider
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`.
Those messages are thrown by
:class:`~scrapy.downloadermiddlewares.offsite.OffsiteMiddleware`, which is a
downloader middleware (enabled by default) whose purpose is to filter out
requests to domains outside the ones covered by the spider.
What is the recommended way to deploy a Scrapy crawler in production?
---------------------------------------------------------------------
@ -273,7 +268,7 @@ To dump into a CSV file::
scrapy crawl myspider -O items.csv
To dump into a XML file::
To dump into an XML file::
scrapy crawl myspider -O items.xml
@ -286,7 +281,7 @@ The ``__VIEWSTATE`` parameter is used in sites built with ASP.NET/VB.NET. For
more info on how it works see `this page`_. Also, here's an `example spider`_
which scrapes one of these sites.
.. _this page: https://metacpan.org/pod/release/ECARROLL/HTML-TreeBuilderX-ASP_NET-0.09/lib/HTML/TreeBuilderX/ASP_NET.pm
.. _this page: https://metacpan.org/release/ECARROLL/HTML-TreeBuilderX-ASP_NET-0.09/view/lib/HTML/TreeBuilderX/ASP_NET.pm
.. _example spider: https://github.com/AmbientLighter/rpn-fas/blob/master/fas/spiders/rnp.py
What's the best way to parse big XML/CSV data feeds?
@ -297,9 +292,13 @@ build the DOM of the entire feed in memory, and this can be quite slow and
consume a lot of memory.
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``
module. In fact, this is what the feed spiders (see :ref:`topics-spiders`) use
under the cover.
the :func:`~scrapy.utils.iterators.xmliter_lxml` and
:func:`~scrapy.utils.iterators.csviter` functions. In fact, this is what
:class:`~scrapy.spiders.XMLFeedSpider` uses.
.. autofunction:: scrapy.utils.iterators.xmliter_lxml
.. autofunction:: scrapy.utils.iterators.csviter
Does Scrapy manage cookies automatically?
-----------------------------------------
@ -405,6 +404,23 @@ or :class:`~scrapy.signals.headers_received` signals and raising a
: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
inline within web pages, similar to 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>``
--------------------------------------------------------------------------
@ -415,7 +431,7 @@ See :issue:`2680`.
.. _has been reported: https://github.com/scrapy/scrapy/issues/2905
.. _Python standard library modules: https://docs.python.org/py-modindex.html
.. _Python standard library modules: https://docs.python.org/3/py-modindex.html
.. _Python package: https://pypi.org/
.. _user agents: https://en.wikipedia.org/wiki/User_agent
.. _LIFO: https://en.wikipedia.org/wiki/Stack_(abstract_data_type)

View File

@ -33,7 +33,7 @@ Having trouble? We'd like to help!
.. _StackOverflow using the scrapy tag: https://stackoverflow.com/tags/scrapy
.. _#scrapy IRC channel: irc://irc.freenode.net/scrapy
.. _issue tracker: https://github.com/scrapy/scrapy/issues
.. _Scrapy Discord: https://discord.gg/mv3yErfpvq
.. _Scrapy Discord: https://discord.com/invite/mv3yErfpvq
First steps

View File

@ -9,7 +9,7 @@ Installation guide
Supported Python versions
=========================
Scrapy requires Python 3.8+, either the CPython implementation (default) or
Scrapy requires Python 3.9+, either the CPython implementation (default) or
the PyPy implementation (see :ref:`python:implementations`).
.. _intro-install-scrapy:
@ -37,7 +37,7 @@ Note that sometimes this may require solving compilation issues for some Scrapy
dependencies depending on your operating system, so be sure to check the
:ref:`intro-install-platform-notes`.
For more detailed and platform specifics instructions, as well as
For more detailed and platform-specific instructions, as well as
troubleshooting information, read on.
@ -101,7 +101,7 @@ Windows
-------
Though it's possible to install Scrapy on Windows using pip, we recommend you
to install `Anaconda`_ or `Miniconda`_ and use the package from the
install `Anaconda`_ or `Miniconda`_ and use the package from the
`conda-forge`_ channel, which will avoid most installation issues.
Once you've installed `Anaconda`_ or `Miniconda`_, install Scrapy with::
@ -141,7 +141,7 @@ But it should support older versions of Ubuntu too, like Ubuntu 14.04,
albeit with potential issues with TLS connections.
**Don't** use the ``python-scrapy`` package provided by Ubuntu, they are
typically too old and slow to catch up with latest Scrapy.
typically too old and slow to catch up with the latest Scrapy release.
To install Scrapy on Ubuntu (or Ubuntu-based) systems, you need to install
@ -170,7 +170,7 @@ macOS
Building Scrapy's dependencies requires the presence of a C compiler and
development headers. On macOS this is typically provided by Apples Xcode
development tools. To install the Xcode command line tools open a terminal
development tools. To install the Xcode command-line tools, open a terminal
window and run::
xcode-select --install
@ -200,11 +200,6 @@ solutions:
brew install python
* Latest versions of python have ``pip`` bundled with them so you won't need
to install it separately. If this is not the case, upgrade python::
brew update; brew upgrade python
* *(Optional)* :ref:`Install Scrapy inside a Python virtual environment
<intro-using-virtualenv>`.
@ -272,10 +267,10 @@ For details, see `Issue #2473 <https://github.com/scrapy/scrapy/issues/2473>`_.
.. _lxml: https://lxml.de/index.html
.. _parsel: https://pypi.org/project/parsel/
.. _w3lib: https://pypi.org/project/w3lib/
.. _twisted: https://twistedmatrix.com/trac/
.. _twisted: https://twisted.org/
.. _cryptography: https://cryptography.io/en/latest/
.. _pyOpenSSL: https://pypi.org/project/pyOpenSSL/
.. _setuptools: https://pypi.python.org/pypi/setuptools
.. _setuptools: https://pypi.org/pypi/setuptools
.. _homebrew: https://brew.sh/
.. _zsh: https://www.zsh.org/
.. _Anaconda: https://docs.anaconda.com/anaconda/

View File

@ -44,13 +44,13 @@ https://quotes.toscrape.com, following the pagination:
if next_page is not None:
yield response.follow(next_page, self.parse)
Put this in a text file, name it to something like ``quotes_spider.py``
Put this in a text file, name it something like ``quotes_spider.py``
and run the spider using the :command:`runspider` command::
scrapy runspider quotes_spider.py -o quotes.jsonl
When this finishes you will have in the ``quotes.jsonl`` file a list of the
quotes in JSON Lines format, containing text and author, looking like this::
quotes in JSON Lines format, containing the text and author, which will look like this::
{"author": "Jane Austen", "text": "\u201cThe person, be it gentleman or lady, who has not pleasure in a good novel, must be intolerably stupid.\u201d"}
{"author": "Steve Martin", "text": "\u201cA day without sunshine is like, you know, night.\u201d"}
@ -65,27 +65,27 @@ When you ran the command ``scrapy runspider quotes_spider.py``, Scrapy looked fo
Spider definition inside it and ran it through its crawler engine.
The crawl started by making requests to the URLs defined in the ``start_urls``
attribute (in this case, only the URL for quotes in *humor* category)
attribute (in this case, only the URL for quotes in the *humor* category)
and called the default callback method ``parse``, passing the response object as
an argument. In the ``parse`` callback, we loop through the quote elements
using a CSS Selector, yield a Python dict with the extracted quote text and author,
look for a link to the next page and schedule another request using the same
``parse`` method as callback.
Here you notice one of the main advantages about Scrapy: requests are
Here you will notice one of the main advantages of Scrapy: requests are
:ref:`scheduled and processed asynchronously <topics-architecture>`. This
means that Scrapy doesn't need to wait for a request to be finished and
processed, it can send another request or do other things in the meantime. This
also means that other requests can keep going even if some request fails or an
also means that other requests can keep going even if a request fails or an
error happens while handling it.
While this enables you to do very fast crawls (sending multiple concurrent
requests at the same time, in a fault-tolerant way) Scrapy also gives you
control over the politeness of the crawl through :ref:`a few settings
<topics-settings-ref>`. You can do things like setting a download delay between
each request, limiting amount of concurrent requests per domain or per IP, and
each request, limiting the amount of concurrent requests per domain or per IP, and
even :ref:`using an auto-throttling extension <topics-autothrottle>` that tries
to figure out these automatically.
to figure these settings out automatically.
.. note::
@ -106,10 +106,10 @@ scraping easy and efficient, such as:
* Built-in support for :ref:`selecting and extracting <topics-selectors>` data
from HTML/XML sources using extended CSS selectors and XPath expressions,
with helper methods to extract using regular expressions.
with helper methods for extraction using regular expressions.
* An :ref:`interactive shell console <topics-shell>` (IPython aware) for trying
out the CSS and XPath expressions to scrape data, very useful when writing or
out the CSS and XPath expressions to scrape data, which is very useful when writing or
debugging your spiders.
* Built-in support for :ref:`generating feed exports <topics-feed-exports>` in
@ -124,7 +124,7 @@ scraping easy and efficient, such as:
well-defined API (middlewares, :ref:`extensions <topics-extensions>`, and
:ref:`pipelines <topics-item-pipeline>`).
* Wide range of built-in extensions and middlewares for handling:
* A wide range of built-in extensions and middlewares for handling:
- cookies and session handling
- HTTP features like compression, authentication, caching
@ -152,6 +152,6 @@ interest!
.. _join the community: https://scrapy.org/community/
.. _web scraping: https://en.wikipedia.org/wiki/Web_scraping
.. _Amazon Associates Web Services: https://affiliate-program.amazon.com/gp/advertising/api/detail/main.html
.. _Amazon Associates Web Services: https://affiliate-program.amazon.com/welcome/ecs
.. _Amazon S3: https://aws.amazon.com/s3/
.. _Sitemaps: https://www.sitemaps.org/index.html

View File

@ -18,11 +18,11 @@ This tutorial will walk you through these tasks:
4. Changing spider to recursively follow links
5. Using spider arguments
Scrapy is written in Python_. If you're new to the language you might want to
start by getting an idea of what the language is like, to get the most out of
Scrapy.
Scrapy is written in Python_. The more you learn about Python, the more you
can get out of Scrapy.
If you're already familiar with other languages, and want to learn Python quickly, the `Python Tutorial`_ is a good resource.
If you're already familiar with other languages and want to learn Python quickly, the
`Python Tutorial`_ is a good resource.
If you're new to programming and want to start with Python, the following books
may be useful to you:
@ -76,10 +76,9 @@ This will create a ``tutorial`` directory with the following contents::
Our first Spider
================
Spiders are classes that you define and that Scrapy uses to scrape information
from a website (or a group of websites). They must subclass
:class:`~scrapy.Spider` and define the initial requests to make,
optionally how to follow links in the pages, and how to parse the downloaded
Spiders are classes that you define and that Scrapy uses to scrape information from a website
(or a group of websites). They must subclass :class:`~scrapy.Spider` and define the initial
requests to be made, and optionally, how to follow links in pages and parse the downloaded
page content to extract data.
This is the code for our first Spider. Save it in a file named
@ -138,7 +137,7 @@ To put our spider to work, go to the project's top level directory and run::
scrapy crawl quotes
This command runs the spider with name ``quotes`` that we've just added, that
This command runs the spider named ``quotes`` that we've just added, that
will send some requests for the ``quotes.toscrape.com`` domain. You will get an output
similar to this::
@ -169,7 +168,7 @@ Scrapy schedules the :class:`scrapy.Request <scrapy.Request>` objects
returned by the ``start_requests`` method of the Spider. Upon receiving a
response for each one, it instantiates :class:`~scrapy.http.Response` objects
and calls the callback method associated with the request (in this case, the
``parse`` method) passing the response as argument.
``parse`` method) passing the response as an argument.
A shortcut to the start_requests method
@ -217,8 +216,8 @@ using the :ref:`Scrapy shell <topics-shell>`. Run::
.. note::
Remember to always enclose urls in quotes when running Scrapy shell from
command-line, otherwise urls containing arguments (i.e. ``&`` character)
Remember to always enclose URLs in quotes when running Scrapy shell from the
command line, otherwise URLs containing arguments (i.e. ``&`` character)
will not work.
On Windows, use double quotes instead::
@ -257,7 +256,7 @@ object:
The result of running ``response.css('title')`` is a list-like object called
:class:`~scrapy.selector.SelectorList`, which represents a list of
:class:`~scrapy.Selector` objects that wrap around XML/HTML elements
and allow you to run further queries to fine-grain the selection or extract the
and allow you to run further queries to refine the selection or extract the
data.
To extract the text from the title above, you can do:
@ -354,12 +353,12 @@ Besides `CSS`_, Scrapy selectors also support using `XPath`_ expressions:
XPath expressions are very powerful, and are the foundation of Scrapy
Selectors. In fact, CSS selectors are converted to XPath under-the-hood. You
can see that if you read closely the text representation of the selector
objects in the shell.
can see that if you read the text representation of the selector
objects in the shell closely.
While perhaps not as popular as CSS selectors, XPath expressions offer more
power because besides navigating the structure, it can also look at the
content. Using XPath, you're able to select things like: *select the link
content. Using XPath, you're able to select things like: *the link
that contains the text "Next Page"*. This makes XPath very fitting to the task
of scraping, and we encourage you to learn XPath even if you already know how to
construct CSS selectors, it will make scraping much easier.
@ -370,7 +369,7 @@ recommend `this tutorial to learn XPath through examples
<http://zvon.org/comp/r/tut-XPath_1.html>`_, and `this tutorial to learn "how
to think in XPath" <http://plasmasturm.org/log/xpath101/>`_.
.. _XPath: https://www.w3.org/TR/xpath/all/
.. _XPath: https://www.w3.org/TR/xpath-10/
.. _CSS: https://www.w3.org/TR/selectors
Extracting quotes and authors
@ -422,7 +421,7 @@ variable, so that we can run our CSS selectors directly on a particular quote:
>>> quote = response.css("div.quote")[0]
Now, let's extract ``text``, ``author`` and the ``tags`` from that quote
Now, let's extract the ``text``, ``author`` and ``tags`` from that quote
using the ``quote`` object we just created:
.. code-block:: pycon
@ -448,7 +447,7 @@ to get all of them:
from sys import version_info
Having figured out how to extract each bit, we can now iterate over all the
quotes elements and put them together into a Python dictionary:
quote elements and put them together into a Python dictionary:
.. code-block:: pycon
@ -465,8 +464,8 @@ quotes elements and put them together into a Python dictionary:
Extracting data in our spider
-----------------------------
Let's get back to our spider. Until now, it doesn't extract any data in
particular, just saves the whole HTML page to a local file. Let's integrate the
Let's get back to our spider. Until now, it hasn't extracted any data in
particular, just saving the whole HTML page to a local file. Let's integrate the
extraction logic above into our spider.
A Scrapy spider typically generates many dictionaries containing the data
@ -529,8 +528,8 @@ using a different serialization format, such as `JSON Lines`_::
scrapy crawl quotes -o quotes.jsonl
The `JSON Lines`_ format is useful because it's stream-like, you can easily
append new records to it. It doesn't have the same problem of JSON when you run
The `JSON Lines`_ format is useful because it's stream-like, so you can easily
append new records to it. It doesn't have the same problem as JSON when you run
twice. Also, as each record is a separate line, you can process big files
without having to fit everything in memory, there are tools like `JQ`_ to help
do that at the command-line.
@ -542,7 +541,7 @@ for Item Pipelines has been set up for you when the project is created, in
``tutorial/pipelines.py``. Though you don't need to implement any item
pipelines if you just want to store the scraped items.
.. _JSON Lines: http://jsonlines.org
.. _JSON Lines: https://jsonlines.org
.. _JQ: https://stedolan.github.io/jq
@ -555,7 +554,7 @@ from https://quotes.toscrape.com, you want quotes from all the pages in the webs
Now that you know how to extract data from pages, let's see how to follow links
from them.
First thing is to extract the link to the page we want to follow. Examining
The first thing to do is extract the link to the page we want to follow. Examining
our page, we can see there is a link to the next page with the following
markup:
@ -589,7 +588,7 @@ There is also an ``attrib`` property available
>>> response.css("li.next a").attrib["href"]
'/page/2/'
Let's see now our spider modified to recursively follow the link to the next
Now let's see our spider, modified to recursively follow the link to the next
page, extracting data from it:
.. code-block:: python
@ -756,8 +755,8 @@ Another interesting thing this spider demonstrates is that, even if there are
many quotes from the same author, we don't need to worry about visiting the
same author page multiple times. By default, Scrapy filters out duplicated
requests to URLs already visited, avoiding the problem of hitting servers too
much because of a programming mistake. This can be configured by the setting
:setting:`DUPEFILTER_CLASS`.
much because of a programming mistake. This can be configured in the
:setting:`DUPEFILTER_CLASS` setting.
Hopefully by now you have a good understanding of how to use the mechanism
of following links and callbacks with Scrapy.
@ -824,12 +823,12 @@ Next steps
==========
This tutorial covered only the basics of Scrapy, but there's a lot of other
features not mentioned here. Check the :ref:`topics-whatelse` section in
features not mentioned here. Check the :ref:`topics-whatelse` section in the
:ref:`intro-overview` chapter for a quick overview of the most important ones.
You can continue from the section :ref:`section-basics` to know more about the
command-line tool, spiders, selectors and other things the tutorial hasn't covered like
modeling the scraped data. If you prefer to play with an example project, check
modeling the scraped data. If you'd prefer to play with an example project, check
the :ref:`intro-examples` section.
.. _JSON: https://en.wikipedia.org/wiki/JSON

View File

@ -3,6 +3,767 @@
Release notes
=============
.. _release-2.12.0:
Scrapy 2.12.0 (2024-11-18)
--------------------------
Highlights:
- Dropped support for Python 3.8, added support for Python 3.13
- :meth:`~scrapy.Spider.start_requests` can now yield items
- Added :class:`~scrapy.http.JsonResponse`
- Added :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM`
Modified requirements
~~~~~~~~~~~~~~~~~~~~~
- Dropped support for Python 3.8.
(:issue:`6466`, :issue:`6472`)
- Added support for Python 3.13.
(:issue:`6166`)
- Minimum versions increased for these dependencies:
- Twisted_: 18.9.0 → 21.7.0
- cryptography_: 36.0.0 → 37.0.0
- pyOpenSSL_: 21.0.0 → 22.0.0
- lxml_: 4.4.1 → 4.6.0
- Removed ``setuptools`` from the dependency list.
(:issue:`6487`)
Backward-incompatible changes
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- User-defined cookies for HTTPS requests will have the ``secure`` flag set
to ``True`` unless it's set to ``False`` explictly. This is important when
these cookies are reused in HTTP requests, e.g. after a redirect to an HTTP
URL.
(:issue:`6357`)
- The Reppy-based ``robots.txt`` parser,
``scrapy.robotstxt.ReppyRobotParser``, was removed, as it doesn't support
Python 3.9+.
(:issue:`5230`, :issue:`6099`, :issue:`6499`)
- The initialization API of :class:`scrapy.pipelines.media.MediaPipeline` and
its subclasses was improved and it's possible that some previously working
usage scenarios will no longer work. It can only affect you if you define
custom subclasses of ``MediaPipeline`` or create instances of these
pipelines via ``from_settings()`` or ``__init__()`` calls instead of
``from_crawler()`` calls.
Previously, ``MediaPipeline.from_crawler()`` called the ``from_settings()``
method if it existed or the ``__init__()`` method otherwise, and then did
some additional initialization using the ``crawler`` instance. If the
``from_settings()`` method existed (like in ``FilesPipeline``) it called
``__init__()`` to create the instance. It wasn't possible to override
``from_crawler()`` without calling ``MediaPipeline.from_crawler()`` from it
which, in turn, couldn't be called in some cases (including subclasses of
``FilesPipeline``).
Now, in line with the general usage of ``from_crawler()`` and
``from_settings()`` and the deprecation of the latter the recommended
initialization order is the following one:
- All ``__init__()`` methods should take a ``crawler`` argument. If they
also take a ``settings`` argument they should ignore it, using
``crawler.settings`` instead. When they call ``__init__()`` of the base
class they should pass the ``crawler`` argument to it too.
- A ``from_settings()`` method shouldn't be defined. Class-specific
initialization code should go into either an overriden ``from_crawler()``
method or into ``__init__()``.
- It's now possible to override ``from_crawler()`` and it's not necessary
to call ``MediaPipeline.from_crawler()`` in it if other recommendations
were followed.
- If pipeline instances were created with ``from_settings()`` or
``__init__()`` calls (which wasn't supported even before, as it missed
important initialization code), they should now be created with
``from_crawler()`` calls.
(:issue:`6540`)
- The ``response_body`` argument of :meth:`ImagesPipeline.convert_image
<scrapy.pipelines.images.ImagesPipeline.convert_image>` is now
positional-only, as it was changed from optional to required.
(:issue:`6500`)
- The ``convert`` argument of :func:`scrapy.utils.conf.build_component_list`
is now positional-only, as the preceding argument (``custom``) was removed.
(:issue:`6500`)
- The ``overwrite_output`` argument of
:func:`scrapy.utils.conf.feed_process_params_from_cli` is now
positional-only, as the preceding argument (``output_format``) was removed.
(:issue:`6500`)
Deprecation removals
~~~~~~~~~~~~~~~~~~~~
- Removed the ``scrapy.utils.request.request_fingerprint()`` function,
deprecated in Scrapy 2.7.0.
(:issue:`6212`, :issue:`6213`)
- Removed support for value ``"2.6"`` of setting
``REQUEST_FINGERPRINTER_IMPLEMENTATION``, deprecated in Scrapy 2.7.0.
(:issue:`6212`, :issue:`6213`)
- :class:`~scrapy.dupefilters.RFPDupeFilter` subclasses now require
supporting the ``fingerprinter`` parameter in their ``__init__`` method,
introduced in Scrapy 2.7.0.
(:issue:`6102`, :issue:`6113`)
- Removed the ``scrapy.downloadermiddlewares.decompression`` module,
deprecated in Scrapy 2.7.0.
(:issue:`6100`, :issue:`6113`)
- Removed the ``scrapy.utils.response.response_httprepr()`` function,
deprecated in Scrapy 2.6.0.
(:issue:`6111`, :issue:`6116`)
- Spiders with spider-level HTTP authentication, i.e. with the ``http_user``
or ``http_pass`` attributes, must now define ``http_auth_domain`` as well,
which was introduced in Scrapy 2.5.1.
(:issue:`6103`, :issue:`6113`)
- :ref:`Media pipelines <topics-media-pipeline>` methods ``file_path()``,
``file_downloaded()``, ``get_images()``, ``image_downloaded()``,
``media_downloaded()``, ``media_to_download()``, and ``thumb_path()`` must
now support an ``item`` parameter, added in Scrapy 2.4.0.
(:issue:`6107`, :issue:`6113`)
- The ``__init__()`` and ``from_crawler()`` methods of :ref:`feed storage
backend classes <topics-feed-storage>` must now support the keyword-only
``feed_options`` parameter, introduced in Scrapy 2.4.0.
(:issue:`6105`, :issue:`6113`)
- Removed the ``scrapy.loader.common`` and ``scrapy.loader.processors``
modules, deprecated in Scrapy 2.3.0.
(:issue:`6106`, :issue:`6113`)
- Removed the ``scrapy.utils.misc.extract_regex()`` function, deprecated in
Scrapy 2.3.0.
(:issue:`6106`, :issue:`6113`)
- Removed the ``scrapy.http.JSONRequest`` class, replaced with
``JsonRequest`` in Scrapy 1.8.0.
(:issue:`6110`, :issue:`6113`)
- ``scrapy.utils.log.logformatter_adapter`` no longer supports missing
``args``, ``level``, or ``msg`` parameters, and no longer supports a
``format`` parameter, all scenarios that were deprecated in Scrapy 1.0.0.
(:issue:`6109`, :issue:`6116`)
- A custom class assigned to the :setting:`SPIDER_LOADER_CLASS` setting that
does not implement the :class:`~scrapy.interfaces.ISpiderLoader` interface
will now raise a :exc:`zope.interface.verify.DoesNotImplement` exception at
run time. Non-compliant classes have been triggering a deprecation warning
since Scrapy 1.0.0.
(:issue:`6101`, :issue:`6113`)
- Removed the ``--output-format``/``-t`` command line option, deprecated in
Scrapy 2.1.0. ``-O <URI>:<FORMAT>`` should be used instead.
(:issue:`6500`)
- Running :meth:`~scrapy.crawler.Crawler.crawl` more than once on the same
:class:`~scrapy.crawler.Crawler` instance, deprecated in Scrapy 2.11.0, now
raises an exception.
(:issue:`6500`)
- Subclassing
:class:`~scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware`
without support for the ``crawler`` argument in ``__init__()`` and without
a custom ``from_crawler()`` method, deprecated in Scrapy 2.5.0, is no
longer allowed.
(:issue:`6500`)
- Removed the ``EXCEPTIONS_TO_RETRY`` attribute of
:class:`~scrapy.downloadermiddlewares.retry.RetryMiddleware`, deprecated in
Scrapy 2.10.0.
(:issue:`6500`)
- Removed support for :ref:`S3 feed exports <topics-feed-storage-s3>` without
the boto3_ package installed, deprecated in Scrapy 2.10.0.
(:issue:`6500`)
- Removed the ``scrapy.extensions.feedexport._FeedSlot`` class, deprecated in
Scrapy 2.10.0.
(:issue:`6500`)
- Removed the ``scrapy.pipelines.images.NoimagesDrop`` exception, deprecated
in Scrapy 2.8.0.
(:issue:`6500`)
- The ``response_body`` argument of :meth:`ImagesPipeline.convert_image
<scrapy.pipelines.images.ImagesPipeline.convert_image>` is now required,
not passing it was deprecated in Scrapy 2.8.0.
(:issue:`6500`)
- Removed the ``custom`` argument of
:func:`scrapy.utils.conf.build_component_list`, deprecated in Scrapy
2.10.0.
(:issue:`6500`)
- Removed the ``scrapy.utils.reactor.get_asyncio_event_loop_policy()``
function, deprecated in Scrapy 2.9.0. Use :func:`asyncio.get_event_loop`
and related standard library functions instead.
(:issue:`6500`)
Deprecations
~~~~~~~~~~~~
- The ``from_settings()`` methods of the :ref:`Scrapy components
<topics-components>` that have them are now deprecated. ``from_crawler()``
should now be used instead. Affected components:
- :class:`scrapy.dupefilters.RFPDupeFilter`
- :class:`scrapy.mail.MailSender`
- :class:`scrapy.middleware.MiddlewareManager`
- :class:`scrapy.core.downloader.contextfactory.ScrapyClientContextFactory`
- :class:`scrapy.pipelines.files.FilesPipeline`
- :class:`scrapy.pipelines.images.ImagesPipeline`
- :class:`scrapy.spidermiddlewares.urllength.UrlLengthMiddleware`
(:issue:`6540`)
- It's now deprecated to have a ``from_settings()`` method but no
``from_crawler()`` method in 3rd-party :ref:`Scrapy components
<topics-components>`. You can define a simple ``from_crawler()`` method
that calls ``cls.from_settings(crawler.settings)`` to fix this if you don't
want to refactor the code. Note that if you have a ``from_crawler()``
method Scrapy will not call the ``from_settings()`` method so the latter
can be removed.
(:issue:`6540`)
- The initialization API of :class:`scrapy.pipelines.media.MediaPipeline` and
its subclasses was improved and some old usage scenarios are now deprecated
(see also the "Backward-incompatible changes" section). Specifically:
- It's deprecated to define an ``__init__()`` method that doesn't take a
``crawler`` argument.
- It's deprecated to call an ``__init__()`` method without passing a
``crawler`` argument. If it's passed, it's also deprecated to pass a
``settings`` argument, which will be ignored anyway.
- Calling ``from_settings()`` is deprecated, use ``from_crawler()``
instead.
- Overriding ``from_settings()`` is deprecated, override ``from_crawler()``
instead.
(:issue:`6540`)
- The ``REQUEST_FINGERPRINTER_IMPLEMENTATION`` setting is now deprecated.
(:issue:`6212`, :issue:`6213`)
- The ``scrapy.utils.misc.create_instance()`` function is now deprecated, use
:func:`scrapy.utils.misc.build_from_crawler` instead.
(:issue:`5523`, :issue:`5884`, :issue:`6162`, :issue:`6169`, :issue:`6540`)
- ``scrapy.core.downloader.Downloader._get_slot_key()`` is deprecated, use
:meth:`scrapy.core.downloader.Downloader.get_slot_key` instead.
(:issue:`6340`, :issue:`6352`)
- ``scrapy.utils.defer.process_chain_both()`` is now deprecated.
(:issue:`6397`)
- ``scrapy.twisted_version`` is now deprecated, you should instead use
:attr:`twisted.version` directly (but note that it's an
``incremental.Version`` object, not a tuple).
(:issue:`6509`, :issue:`6512`)
- ``scrapy.utils.python.flatten()`` and ``scrapy.utils.python.iflatten()``
are now deprecated.
(:issue:`6517`, :issue:`6519`)
- ``scrapy.utils.python.equal_attributes()`` is now deprecated.
(:issue:`6517`, :issue:`6519`)
- ``scrapy.utils.request.request_authenticate()`` is now deprecated, you
should instead just set the ``Authorization`` header directly.
(:issue:`6517`, :issue:`6519`)
- ``scrapy.utils.serialize.ScrapyJSONDecoder`` is now deprecated, it didn't
contain any code since Scrapy 1.0.0.
(:issue:`6517`, :issue:`6519`)
- ``scrapy.utils.test.assert_samelines()`` is now deprecated.
(:issue:`6517`, :issue:`6519`)
- ``scrapy.extensions.feedexport.build_storage()`` is now deprecated. You can
instead call the builder callable directly.
(:issue:`6540`)
New features
~~~~~~~~~~~~
- :meth:`~scrapy.Spider.start_requests` can now yield items.
(:issue:`5289`, :issue:`6417`)
- Added a new :class:`~scrapy.http.Response` subclass,
:class:`~scrapy.http.JsonResponse`, for responses with a `JSON MIME type
<https://mimesniff.spec.whatwg.org/#json-mime-type>`_.
(:issue:`6069`, :issue:`6171`, :issue:`6174`)
- The :class:`~scrapy.extensions.logstats.LogStats` extension now adds
``items_per_minute`` and ``responses_per_minute`` to the :ref:`stats
<topics-stats>` when the spider closes.
(:issue:`4110`, :issue:`4111`)
- Added :setting:`CLOSESPIDER_PAGECOUNT_NO_ITEM` which allows closing the
spider if no items were scraped in a set amount of time.
(:issue:`6434`)
- User-defined cookies can now include the ``secure`` field.
(:issue:`6357`)
- Added component getters to :class:`~scrapy.crawler.Crawler`:
:meth:`~scrapy.crawler.Crawler.get_addon`,
:meth:`~scrapy.crawler.Crawler.get_downloader_middleware`,
:meth:`~scrapy.crawler.Crawler.get_extension`,
:meth:`~scrapy.crawler.Crawler.get_item_pipeline`,
:meth:`~scrapy.crawler.Crawler.get_spider_middleware`.
(:issue:`6181`)
- Slot delay updates by the :ref:`AutoThrottle extension
<topics-autothrottle>` based on response latencies can now be disabled for
specific requests via the :reqmeta:`autothrottle_dont_adjust_delay` meta
key.
(:issue:`6246`, :issue:`6527`)
- If :setting:`SPIDER_LOADER_WARN_ONLY` is set to ``True``,
:class:`~scrapy.spiderloader.SpiderLoader` does not raise
:exc:`SyntaxError` but emits a warning instead.
(:issue:`6483`, :issue:`6484`)
- Added support for multiple-compressed responses (ones with several
encodings in the ``Content-Encoding`` header).
(:issue:`5143`, :issue:`5964`, :issue:`6063`)
- Added support for multiple standard values in :setting:`REFERRER_POLICY`.
(:issue:`6381`)
- Added support for brotlicffi_ (previously named brotlipy_). brotli_ is
still recommended but only brotlicffi_ works on PyPy.
(:issue:`6263`, :issue:`6269`)
.. _brotlicffi: https://github.com/python-hyper/brotlicffi
- Added :class:`~scrapy.contracts.default.MetadataContract` that sets the
request meta.
(:issue:`6468`, :issue:`6469`)
Improvements
~~~~~~~~~~~~
- Extended the list of file extensions that
:class:`LinkExtractor <scrapy.linkextractors.lxmlhtml.LxmlLinkExtractor>`
ignores by default.
(:issue:`6074`, :issue:`6125`)
- :func:`scrapy.utils.httpobj.urlparse_cached` is now used in more places
instead of :func:`urllib.parse.urlparse`.
(:issue:`6228`, :issue:`6229`)
Bug fixes
~~~~~~~~~
- :class:`~scrapy.pipelines.media.MediaPipeline` is now an abstract class and
its methods that were expected to be overridden in subclasses are now
abstract methods.
(:issue:`6365`, :issue:`6368`)
- Fixed handling of invalid ``@``-prefixed lines in contract extraction.
(:issue:`6383`, :issue:`6388`)
- Importing ``scrapy.extensions.telnet`` no longer installs the default
reactor.
(:issue:`6432`)
- Reduced log verbosity for dropped requests that was increased in 2.11.2.
(:issue:`6433`, :issue:`6475`)
Documentation
~~~~~~~~~~~~~
- Added ``SECURITY.md`` that documents the security policy.
(:issue:`5364`, :issue:`6051`)
- Example code for :ref:`running Scrapy from a script <run-from-script>` no
longer imports ``twisted.internet.reactor`` at the top level, which caused
problems with non-default reactors when this code was used unmodified.
(:issue:`6361`, :issue:`6374`)
- Documented the :class:`~scrapy.extensions.spiderstate.SpiderState`
extension.
(:issue:`6278`, :issue:`6522`)
- Other documentation improvements and fixes.
(:issue:`5920`,
:issue:`6094`,
:issue:`6177`,
:issue:`6200`,
:issue:`6207`,
:issue:`6216`,
:issue:`6223`,
:issue:`6317`,
:issue:`6328`,
:issue:`6389`,
:issue:`6394`,
:issue:`6402`,
:issue:`6411`,
:issue:`6427`,
:issue:`6429`,
:issue:`6440`,
:issue:`6448`,
:issue:`6449`,
:issue:`6462`,
:issue:`6497`,
:issue:`6506`,
:issue:`6507`,
:issue:`6524`)
Quality assurance
~~~~~~~~~~~~~~~~~
- Added ``py.typed``, in line with `PEP 561
<https://peps.python.org/pep-0561/>`_.
(:issue:`6058`, :issue:`6059`)
- Fully covered the code with type hints (except for the most complicated
parts, mostly related to ``twisted.web.http`` and other Twisted parts
without type hints).
(:issue:`5989`,
:issue:`6097`,
:issue:`6127`,
:issue:`6129`,
:issue:`6130`,
:issue:`6133`,
:issue:`6143`,
:issue:`6191`,
:issue:`6268`,
:issue:`6274`,
:issue:`6275`,
:issue:`6276`,
:issue:`6279`,
:issue:`6325`,
:issue:`6326`,
:issue:`6333`,
:issue:`6335`,
:issue:`6336`,
:issue:`6337`,
:issue:`6341`,
:issue:`6353`,
:issue:`6356`,
:issue:`6370`,
:issue:`6371`,
:issue:`6384`,
:issue:`6385`,
:issue:`6387`,
:issue:`6391`,
:issue:`6395`,
:issue:`6414`,
:issue:`6422`,
:issue:`6460`,
:issue:`6466`,
:issue:`6472`,
:issue:`6494`,
:issue:`6498`,
:issue:`6516`)
- Improved Bandit_ checks.
(:issue:`6260`, :issue:`6264`, :issue:`6265`)
- Added pyupgrade_ to the ``pre-commit`` configuration.
(:issue:`6392`)
.. _pyupgrade: https://github.com/asottile/pyupgrade
- Added ``flake8-bugbear``, ``flake8-comprehensions``, ``flake8-debugger``,
``flake8-docstrings``, ``flake8-string-format`` and
``flake8-type-checking`` to the ``pre-commit`` configuration.
(:issue:`6406`, :issue:`6413`)
- CI and test improvements and fixes.
(:issue:`5285`,
:issue:`5454`,
:issue:`5997`,
:issue:`6078`,
:issue:`6084`,
:issue:`6087`,
:issue:`6132`,
:issue:`6153`,
:issue:`6154`,
:issue:`6201`,
:issue:`6231`,
:issue:`6232`,
:issue:`6235`,
:issue:`6236`,
:issue:`6242`,
:issue:`6245`,
:issue:`6253`,
:issue:`6258`,
:issue:`6259`,
:issue:`6270`,
:issue:`6272`,
:issue:`6286`,
:issue:`6290`,
:issue:`6296`
:issue:`6367`,
:issue:`6372`,
:issue:`6403`,
:issue:`6416`,
:issue:`6435`,
:issue:`6489`,
:issue:`6501`,
:issue:`6504`,
:issue:`6511`,
:issue:`6543`,
:issue:`6545`)
- Code cleanups.
(:issue:`6196`,
:issue:`6197`,
:issue:`6198`,
:issue:`6199`,
:issue:`6254`,
:issue:`6257`,
:issue:`6285`,
:issue:`6305`,
:issue:`6343`,
:issue:`6349`,
:issue:`6386`,
:issue:`6415`,
:issue:`6463`,
:issue:`6470`,
:issue:`6499`,
:issue:`6505`,
:issue:`6510`,
:issue:`6531`,
:issue:`6542`)
Other
~~~~~
- Issue tracker improvements. (:issue:`6066`)
.. _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:
Scrapy 2.11.0 (2023-09-18)
@ -62,6 +823,9 @@ Deprecation removals
1.0.0, use :attr:`CrawlerRunner.spider_loader
<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
~~~~~~~~~~~~
@ -837,7 +1601,7 @@ Documentation
(:issue:`3582`, :issue:`5432`).
.. _Common Crawl: https://commoncrawl.org/
.. _Google cache: http://www.googleguide.com/cached_pages.html
.. _Google cache: https://www.googleguide.com/cached_pages.html
- The new :ref:`topics-components` topic covers enforcing requirements on
Scrapy components, like :ref:`downloader middlewares
@ -1157,6 +1921,9 @@ Deprecations
Instead, call :meth:`~scrapy.core.engine.ExecutionEngine.open_spider`
first to set the :class:`~scrapy.Spider` object.
- :func:`scrapy.utils.response.response_httprepr` is now deprecated.
(:issue:`4972`)
New features
~~~~~~~~~~~~
@ -1191,7 +1958,7 @@ New features
(:setting:`AWS_SESSION_TOKEN`) and endpoint customization
(:setting:`AWS_ENDPOINT_URL`). (:issue:`4998`, :issue:`5210`)
.. _temporary security credentials: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#temporary-access-keys
.. _temporary security credentials: https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html
- New :setting:`LOG_FILE_APPEND` setting to allow truncating the log file.
(:issue:`5279`)
@ -1295,7 +2062,7 @@ Documentation
- Provided better context and instructions to disable the
:setting:`URLLENGTH_LIMIT` setting. (:issue:`5135`, :issue:`5250`)
- Documented that :ref:`reppy-parser` does not support Python 3.9+.
- Documented that Reppy parser does not support Python 3.9+.
(:issue:`5226`, :issue:`5231`)
- Documented :ref:`the scheduler component <topics-scheduler>`.
@ -1337,7 +2104,7 @@ Documentation
- ``quotes.toscrape.com`` references now use HTTPS instead of HTTP.
(:issue:`5395`, :issue:`5396`)
- Added a link to `our Discord server <https://discord.gg/mv3yErfpvq>`_
- Added a link to `our Discord server <https://discord.com/invite/mv3yErfpvq>`_
to :ref:`getting-help`. (:issue:`5421`, :issue:`5422`)
- The pronunciation of the project name is now :ref:`officially
@ -1528,7 +2295,7 @@ Bug fixes
with lower indentation than the following code.
(:issue:`4477`, :issue:`4935`)
- The `Content-Length <https://tools.ietf.org/html/rfc2616#section-14.13>`_
- The `Content-Length <https://datatracker.ietf.org/doc/html/rfc2616#section-14.13>`_
header is no longer omitted from responses when using the default, HTTP/1.1
download handler (see :setting:`DOWNLOAD_HANDLERS`).
(:issue:`5009`, :issue:`5034`, :issue:`5045`, :issue:`5057`, :issue:`5062`)
@ -2028,7 +2795,7 @@ Documentation
* Simplified the code example in :ref:`topics-loaders-dataclass`
(:issue:`4652`)
.. _OpenSSL cipher list format: https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html#CIPHER-LIST-FORMAT
.. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format
Quality assurance
@ -2255,7 +3022,7 @@ Quality assurance
* Added a `Pylint <https://www.pylint.org/>`_ job to Travis CI
(:issue:`3727`)
* Added a `Mypy <http://mypy-lang.org/>`_ job to Travis CI (:issue:`4637`)
* Added a `Mypy <https://mypy-lang.org/>`_ job to Travis CI (:issue:`4637`)
* Made use of set literals in tests (:issue:`4573`)
@ -2762,7 +3529,7 @@ Quality assurance
* Cleaned up code (:issue:`3937`, :issue:`4208`, :issue:`4209`,
:issue:`4210`, :issue:`4212`, :issue:`4369`, :issue:`4376`, :issue:`4378`)
.. _Bandit: https://bandit.readthedocs.io/
.. _Bandit: https://bandit.readthedocs.io/en/latest/
.. _Flake8: https://flake8.pycqa.org/en/latest/
@ -2871,6 +3638,38 @@ affect subclasses:
(: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:
@ -3077,7 +3876,7 @@ New features
* A new :setting:`ROBOTSTXT_PARSER` setting allows choosing which robots.txt_
parser to use. It includes built-in support for
:ref:`RobotFileParser <python-robotfileparser>`,
:ref:`Protego <protego-parser>` (default), :ref:`Reppy <reppy-parser>`, and
:ref:`Protego <protego-parser>` (default), Reppy, and
:ref:`Robotexclusionrulesparser <rerp-parser>`, and allows you to
:ref:`implement support for additional parsers
<support-for-new-robots-parser>` (:issue:`754`, :issue:`2669`,
@ -3905,7 +4704,7 @@ Docs
- Update Contributing docs, document new support channels
(:issue:`2762`, issue:`3038`)
- Include references to Scrapy subreddit in the docs
- Fix broken links; use https:// for external links
- Fix broken links; use ``https://`` for external links
(:issue:`2978`, :issue:`2982`, :issue:`2958`)
- Document CloseSpider extension better (:issue:`2759`)
- Use ``pymongo.collection.Collection.insert_one()`` in MongoDB example
@ -4506,7 +5305,7 @@ This 1.1 release brings a lot of interesting features and bug fixes:
- Don't retry bad requests (HTTP 400) by default (:issue:`1289`).
If you need the old behavior, add ``400`` to :setting:`RETRY_HTTP_CODES`.
- Fix shell files argument handling (:issue:`1710`, :issue:`1550`).
If you try ``scrapy shell index.html`` it will try to load the URL http://index.html,
If you try ``scrapy shell index.html`` it will try to load the URL ``http://index.html``,
use ``scrapy shell ./index.html`` to load a local file.
- Robots.txt compliance is now enabled by default for newly-created projects
(:issue:`1724`). Scrapy will also wait for robots.txt to be downloaded
@ -5182,7 +5981,7 @@ Scrapy 0.24.5 (2015-02-25)
Scrapy 0.24.4 (2014-08-09)
--------------------------
- pem file is used by mockserver and required by scrapy bench (:commit:`5eddc68`)
- pem file is used by mockserver and required by scrapy bench (:commit:`5eddc68b63`)
- scrapy bench needs scrapy.tests* (:commit:`d6cb999`)
Scrapy 0.24.3 (2014-08-09)
@ -5703,7 +6502,7 @@ Scrapy changes:
- nested items now fully supported in JSON and JSONLines exporters
- added :reqmeta:`cookiejar` Request meta key to support multiple cookie sessions per spider
- decoupled encoding detection code to `w3lib.encoding`_, and ported Scrapy code to use that module
- dropped support for Python 2.5. See https://blog.scrapinghub.com/2012/02/27/scrapy-0-15-dropping-support-for-python-2-5/
- dropped support for Python 2.5. See https://www.zyte.com/blog/scrapy-0-15-dropping-support-for-python-2-5/
- dropped support for Twisted 2.5
- added :setting:`REFERER_ENABLED` setting, to control referer middleware
- changed default user agent to: ``Scrapy/VERSION (+http://scrapy.org)``
@ -5781,7 +6580,7 @@ Scrapy 0.14
New features and settings
~~~~~~~~~~~~~~~~~~~~~~~~~
- Support for `AJAX crawlable urls`_
- Support for AJAX crawlable urls
- New persistent scheduler that stores requests on disk, allowing to suspend and resume crawls (:rev:`2737`)
- added ``-o`` option to ``scrapy crawl``, a shortcut for dumping scraped items into a file (or standard output using ``-``)
- Added support for passing custom settings to Scrapyd ``schedule.json`` api (:rev:`2779`, :rev:`2783`)
@ -6052,11 +6851,10 @@ Scrapy 0.7
First release of Scrapy.
.. _AJAX crawlable urls: https://developers.google.com/search/docs/ajax-crawling/docs/getting-started?csw=1
.. _boto3: https://github.com/boto/boto3
.. _botocore: https://github.com/boto/botocore
.. _chunked transfer encoding: https://en.wikipedia.org/wiki/Chunked_transfer_encoding
.. _ClientForm: http://wwwsearch.sourceforge.net/old/ClientForm/
.. _ClientForm: https://pypi.org/project/ClientForm/
.. _Creating a pull request: https://help.github.com/en/articles/creating-a-pull-request
.. _cryptography: https://cryptography.io/en/latest/
.. _docstrings: https://docs.python.org/3/glossary.html#term-docstring
@ -6068,7 +6866,7 @@ First release of Scrapy.
.. _parsel.csstranslator.GenericTranslator: https://parsel.readthedocs.io/en/latest/parsel.html#parsel.csstranslator.GenericTranslator
.. _parsel.csstranslator.HTMLTranslator: https://parsel.readthedocs.io/en/latest/parsel.html#parsel.csstranslator.HTMLTranslator
.. _parsel.csstranslator.XPathExpr: https://parsel.readthedocs.io/en/latest/parsel.html#parsel.csstranslator.XPathExpr
.. _PEP 257: https://www.python.org/dev/peps/pep-0257/
.. _PEP 257: https://peps.python.org/pep-0257/
.. _Pillow: https://python-pillow.org/
.. _pyOpenSSL: https://www.pyopenssl.org/en/stable/
.. _queuelib: https://github.com/scrapy/queuelib
@ -6080,7 +6878,7 @@ First release of Scrapy.
.. _service_identity: https://service-identity.readthedocs.io/en/stable/
.. _six: https://six.readthedocs.io/
.. _tox: https://pypi.org/project/tox/
.. _Twisted: https://twistedmatrix.com/trac/
.. _Twisted: https://twisted.org/
.. _w3lib: https://github.com/scrapy/w3lib
.. _w3lib.encoding: https://github.com/scrapy/w3lib/blob/master/w3lib/encoding.py
.. _What is cacheable: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1

View File

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

View File

@ -150,14 +150,14 @@ Access the crawler instance:
def from_crawler(cls, crawler):
return cls(crawler)
def update_settings(self, settings):
...
def update_settings(self, settings): ...
Use a fallback component:
.. code-block:: python
from scrapy.core.downloader.handlers.http import HTTPDownloadHandler
from scrapy.utils.misc import build_from_crawler
FALLBACK_SETTING = "MY_FALLBACK_DOWNLOAD_HANDLER"
@ -168,11 +168,7 @@ Use a fallback component:
def __init__(self, settings, crawler):
dhcls = load_object(settings.get(FALLBACK_SETTING))
self._fallback_handler = create_instance(
dhcls,
settings=None,
crawler=crawler,
)
self._fallback_handler = build_from_crawler(dhcls, crawler)
def download_request(self, request, spider):
if request.meta.get("my_params"):

View File

@ -26,7 +26,9 @@ contains a dictionary of all available extensions and their order similar to
how you :ref:`configure the downloader middlewares
<topics-downloader-middleware-setting>`.
.. class:: Crawler(spidercls, settings)
.. autoclass:: Crawler
:members: get_addon, get_downloader_middleware, get_extension,
get_item_pipeline, get_spider_middleware
The Crawler object must be instantiated with a
:class:`scrapy.Spider` subclass and a

View File

@ -168,9 +168,7 @@ For more information about asynchronous programming and Twisted see these
links:
* :doc:`twisted:core/howto/defer-intro`
* `Twisted - hello, asynchronous programming`_
* `Twisted Introduction - Krondo`_
.. _Twisted: https://twistedmatrix.com/trac/
.. _Twisted - hello, asynchronous programming: http://jessenoller.com/blog/2009/02/11/twisted-hello-asynchronous-programming/
.. _Twisted Introduction - Krondo: http://krondo.com/an-introduction-to-asynchronous-programming-and-twisted/
.. _Twisted: https://twisted.org/
.. _Twisted Introduction - Krondo: https://krondo.com/an-introduction-to-asynchronous-programming-and-twisted/

View File

@ -21,9 +21,14 @@ Design goals
How it works
============
AutoThrottle extension adjusts download delays dynamically to make spider send
:setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` concurrent requests on average
to each remote website.
Scrapy allows defining the concurrency and delay of different download slots,
e.g. through the :setting:`DOWNLOAD_SLOTS` setting. By default requests are
assigned to slots based on their URL domain, although it is possible to
customize the download slot of any request.
The AutoThrottle extension adjusts the delay of each download slot dynamically,
to make your spider send :setting:`AUTOTHROTTLE_TARGET_CONCURRENCY` concurrent
requests on average to each remote website.
It uses download latency to compute the delays. The main idea is the
following: if a server needs ``latency`` seconds to respond, a client
@ -80,6 +85,33 @@ callback, for example, and unable to attend downloads. However, these latencies
should still give a reasonable estimate of how busy Scrapy (and ultimately, the
server) is, and this extension builds on that premise.
.. reqmeta:: autothrottle_dont_adjust_delay
Prevent specific requests from triggering slot delay adjustments
================================================================
AutoThrottle adjusts the delay of download slots based on the latencies of
responses that belong to that download slot. The only exceptions are non-200
responses, which are only taken into account to increase that delay, but
ignored if they would decrease that delay.
You can also set the ``autothrottle_dont_adjust_delay`` request metadata key to
``True`` in any request to prevent its response latency from impacting the
delay of its download slot:
.. code-block:: python
from scrapy import Request
Request("https://example.com", meta={"autothrottle_dont_adjust_delay": True})
Note, however, that AutoThrottle still determines the starting delay of every
download slot by setting the ``download_delay`` attribute on the running
spider. If you want AutoThrottle not to impact a download slot at all, in
addition to setting this meta key in all requests that use that download slot,
you might want to set a custom value for the ``delay`` attribute of that
download slot, e.g. using :setting:`DOWNLOAD_SLOTS`.
Settings
========
@ -131,7 +163,7 @@ AUTOTHROTTLE_TARGET_CONCURRENCY
Default: ``1.0``
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
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.corestats.CoreStats']
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.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
@ -37,7 +38,6 @@ You should see an output like this::
'scrapy.downloadermiddlewares.stats.DownloaderStats']
2016-12-16 21:18:49 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
'scrapy.spidermiddlewares.referer.RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'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
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
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
fine though.
@ -186,7 +186,7 @@ Enable crawling of "Ajax Crawlable Pages"
=========================================
Some pages (up to 1%, based on empirical data from year 2013) declare
themselves as `ajax crawlable`_. This means they provide plain HTML
themselves as ajax crawlable. This means they provide plain HTML
version of content that is usually available only via AJAX.
Pages can indicate it in two ways:
@ -206,8 +206,6 @@ AjaxCrawlMiddleware helps to crawl them correctly.
It is turned OFF by default because it has some performance overhead,
and enabling it for focused crawls doesn't make much sense.
.. _ajax crawlable: https://developers.google.com/search/docs/ajax-crawling/docs/getting-started
.. _broad-crawls-bfo:
Crawl in BFO order

View File

@ -6,7 +6,7 @@
Command line tool
=================
Scrapy is controlled through the ``scrapy`` command-line tool, to be referred
Scrapy is controlled through the ``scrapy`` command-line tool, to be referred to
here as the "Scrapy tool" to differentiate it from the sub-commands, which we
just call "commands" or "Scrapy commands".
@ -185,8 +185,8 @@ And you can see all available commands with::
There are two kinds of commands, those that only work from inside a Scrapy
project (Project-specific commands) and those that also work without an active
Scrapy project (Global commands), though they may behave slightly different
when running from inside a project (as they would use the project overridden
Scrapy project (Global commands), though they may behave slightly differently
when run from inside a project (as they would use the project overridden
settings).
Global commands:
@ -236,7 +236,7 @@ genspider
.. versionadded:: 2.6.0
The ability to pass a URL instead of a domain.
Create a new spider in the current folder or in the current project's ``spiders`` folder, if called from inside a project. The ``<name>`` parameter is set as the spider's ``name``, while ``<domain or URL>`` is used to generate the ``allowed_domains`` and ``start_urls`` spider's attributes.
Creates a new spider in the current folder or in the current project's ``spiders`` folder, if called from inside a project. The ``<name>`` parameter is set as the spider's ``name``, while ``<domain or URL>`` is used to generate the ``allowed_domains`` and ``start_urls`` spider's attributes.
Usage example::
@ -253,7 +253,7 @@ Usage example::
$ scrapy genspider -t crawl scrapyorg scrapy.org
Created spider 'scrapyorg' using template 'crawl'
This is just a convenience shortcut command for creating spiders based on
This is just a convenient shortcut command for creating spiders based on
pre-defined templates, but certainly not the only way to create spiders. You
can just create the spider source code files yourself, instead of using this
command.
@ -274,11 +274,9 @@ Supported options:
* ``-a NAME=VALUE``: set a spider argument (may be repeated)
* ``--output FILE`` or ``-o FILE``: append scraped items to the end of FILE (use - for stdout), to define format set a colon at the end of the output URI (i.e. ``-o FILE:FORMAT``)
* ``--output FILE`` or ``-o FILE``: append scraped items to the end of FILE (use - for stdout). To define the output format, set a colon at the end of the output URI (i.e. ``-o FILE:FORMAT``)
* ``--overwrite-output FILE`` or ``-O FILE``: dump scraped items into FILE, overwriting any existing file, to define format set a colon at the end of the output URI (i.e. ``-O FILE:FORMAT``)
* ``--output-format FORMAT`` or ``-t FORMAT``: deprecated way to define format to use for dumping items, does not work in combination with ``-O``
* ``--overwrite-output FILE`` or ``-O FILE``: dump scraped items into FILE, overwriting any existing file. To define the output format, set a colon at the end of the output URI (i.e. ``-O FILE:FORMAT``)
Usage examples::
@ -291,9 +289,6 @@ Usage examples::
$ scrapy crawl -O myfile:json myspider
[ ... myspider starts crawling and saves the result in myfile in json format overwriting the original content... ]
$ scrapy crawl -o myfile -t csv myspider
[ ... myspider starts crawling and appends the result to the file myfile in csv format ... ]
.. command:: check
check
@ -353,7 +348,7 @@ edit
Edit the given spider using the editor defined in the ``EDITOR`` environment
variable or (if unset) the :setting:`EDITOR` setting.
This command is provided only as a convenience shortcut for the most common
This command is provided only as a convenient shortcut for the most common
case, the developer is of course free to choose any tool or IDE to write and
debug spiders.
@ -372,7 +367,7 @@ fetch
Downloads the given URL using the Scrapy downloader and writes the contents to
standard output.
The interesting thing about this command is that it fetches the page how the
The interesting thing about this command is that it fetches the page the way the
spider would download it. For example, if the spider has a ``USER_AGENT``
attribute which overrides the User Agent, it will use that one.

View File

@ -4,8 +4,8 @@
Components
==========
A Scrapy component is any class whose objects are created using
:func:`scrapy.utils.misc.create_instance`.
A Scrapy component is any class whose objects are built using
:func:`~scrapy.utils.misc.build_from_crawler`.
That includes the classes that you may assign to the following settings:
@ -84,3 +84,15 @@ If your requirement is a minimum Scrapy version, you may use
f"method of spider middlewares as an asynchronous "
f"generator."
)
API reference
=============
The following function can be used to create an instance of a component class:
.. autofunction:: scrapy.utils.misc.build_from_crawler
The following function can also be useful when implementing a component, to
report the import path of the component class, e.g. when reporting problems:
.. autofunction:: scrapy.utils.python.global_object_name

View File

@ -20,13 +20,13 @@ following example:
This function parses a sample response. Some contracts are mingled
with this docstring.
@url http://www.amazon.com/s?field-keywords=selfish+gene
@url http://www.example.com/s?field-keywords=selfish+gene
@returns items 1 16
@returns requests 0 0
@scrapes Title Author Year Price
"""
This callback is tested using three built-in contracts:
You can use the following contracts:
.. module:: scrapy.contracts.default
@ -46,6 +46,14 @@ This callback is tested using three built-in contracts:
@cb_kwargs {"arg1": "value1", "arg2": "value2", ...}
.. class:: MetadataContract
This contract (``@meta``) sets the :attr:`meta <scrapy.Request.meta>`
attribute for the sample request. It must be a valid JSON dictionary.
::
@meta {"arg1": "value1", "arg2": "value2", ...}
.. class:: ReturnsContract
This contract (``@returns``) sets lower and upper bounds for the items and

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`.
Open in browser
===============
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
you would use it:
can use the :func:`~scrapy.utils.response.open_in_browser` function for that:
.. 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
=======
@ -163,8 +153,6 @@ available in all future runs should they be necessary again:
For more information, check the :ref:`topics-logging` section.
.. _base tag: https://www.w3schools.com/tags/tag_base.asp
.. _debug-vscode:
Visual Studio Code

View File

@ -54,6 +54,6 @@ just like ``scrapyd-deploy``.
.. _scrapyd-client: https://github.com/scrapy/scrapyd-client
.. _scrapyd-deploy documentation: https://scrapyd.readthedocs.io/en/latest/deploy.html
.. _shub: https://shub.readthedocs.io/en/latest/
.. _Zyte: https://zyte.com/
.. _Zyte: https://www.zyte.com/
.. _Zyte Scrapy Cloud: https://www.zyte.com/scrapy-cloud/
.. _Zyte Scrapy Cloud documentation: https://docs.zyte.com/scrapy-cloud.html

View File

@ -278,7 +278,7 @@ into our ``url``.
In more complex websites, it could be difficult to easily reproduce the
requests, as we could need to add ``headers`` or ``cookies`` to make it work.
In those cases you can export the requests in `cURL <https://curl.haxx.se/>`_
In those cases you can export the requests in `cURL <https://curl.se/>`_
format, by right-clicking on each of them in the network tool and using the
:meth:`~scrapy.Request.from_curl()` method to generate an equivalent
request:

View File

@ -763,6 +763,44 @@ HttpProxyMiddleware
Keep in mind this value will take precedence over ``http_proxy``/``https_proxy``
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
------------------
@ -838,7 +876,7 @@ REDIRECT_MAX_TIMES
Default: ``20``
The maximum number of redirections that will be followed for a single request.
After this maximum, the request's response is returned as is.
If maximum redirections are exceeded, the request is aborted and ignored.
MetaRefreshMiddleware
---------------------
@ -882,7 +920,11 @@ Meta tags within these tags are ignored.
.. versionchanged:: 2.0
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"]``.
.. setting:: METAREFRESH_MAXDELAY
@ -1040,7 +1082,6 @@ RobotsTxtMiddleware
* :ref:`Protego <protego-parser>` (default)
* :ref:`RobotFileParser <python-robotfileparser>`
* :ref:`Robotexclusionrulesparser <rerp-parser>`
* :ref:`Reppy <reppy-parser>` (deprecated)
You can change the robots.txt_ parser with the :setting:`ROBOTSTXT_PARSER`
setting. Or you can also :ref:`implement support for a new parser <support-for-new-robots-parser>`.
@ -1060,7 +1101,7 @@ Parsers vary in several aspects:
* Support for wildcard matching
* Usage of `length based rule <https://developers.google.com/search/reference/robots_txt#order-of-precedence-for-group-member-lines>`_:
* Usage of `length based rule <https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt#order-of-precedence-for-rules>`_:
in particular for ``Allow`` and ``Disallow`` directives, where the most
specific rule based on the length of the path trumps the less specific
(shorter) rule
@ -1078,7 +1119,7 @@ Based on `Protego <https://github.com/scrapy/protego>`_:
* implemented in Python
* is compliant with `Google's Robots.txt Specification
<https://developers.google.com/search/reference/robots_txt>`_
<https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt>`_
* supports wildcard matching
@ -1108,43 +1149,12 @@ In order to use this parser, set:
* :setting:`ROBOTSTXT_PARSER` to ``scrapy.robotstxt.PythonRobotParser``
.. _reppy-parser:
Reppy parser
~~~~~~~~~~~~
Based on `Reppy <https://github.com/seomoz/reppy/>`_:
* is a Python wrapper around `Robots Exclusion Protocol Parser for C++
<https://github.com/seomoz/rep-cpp>`_
* is compliant with `Martijn Koster's 1996 draft specification
<https://www.robotstxt.org/norobots-rfc.txt>`_
* supports wildcard matching
* uses the length based rule
Native implementation, provides better speed than Protego.
In order to use this parser:
* Install `Reppy <https://github.com/seomoz/reppy/>`_ by running ``pip install reppy``
.. warning:: `Upstream issue #122
<https://github.com/seomoz/reppy/issues/122>`_ prevents reppy usage in Python 3.9+.
Because of this the Reppy parser is deprecated.
* Set :setting:`ROBOTSTXT_PARSER` setting to
``scrapy.robotstxt.ReppyRobotParser``
.. _rerp-parser:
Robotexclusionrulesparser
~~~~~~~~~~~~~~~~~~~~~~~~~
Based on `Robotexclusionrulesparser <http://nikitathespider.com/python/rerp/>`_:
Based on `Robotexclusionrulesparser <https://pypi.org/project/robotexclusionrulesparser/>`_:
* implemented in Python
@ -1157,7 +1167,7 @@ Based on `Robotexclusionrulesparser <http://nikitathespider.com/python/rerp/>`_:
In order to use this parser:
* Install `Robotexclusionrulesparser <http://nikitathespider.com/python/rerp/>`_ by running
* Install ``Robotexclusionrulesparser`` by running
``pip install robotexclusionrulesparser``
* Set :setting:`ROBOTSTXT_PARSER` setting to
@ -1217,9 +1227,7 @@ AjaxCrawlMiddleware
.. class:: AjaxCrawlMiddleware
Middleware that finds 'AJAX crawlable' page variants based
on meta-fragment html tag. See
https://developers.google.com/search/docs/ajax-crawling/docs/getting-started
for more info.
on meta-fragment html tag.
.. note::

View File

@ -85,9 +85,8 @@ It might be enough to yield a :class:`~scrapy.Request` with the same HTTP
method and URL. However, you may also need to reproduce the body, headers and
form parameters (see :class:`~scrapy.FormRequest`) of that request.
As all major browsers allow to export the requests in `cURL
<https://curl.haxx.se/>`_ format, Scrapy incorporates the method
:meth:`~scrapy.Request.from_curl()` to generate an equivalent
As all major browsers allow to export the requests in curl_ format, Scrapy
incorporates the method :meth:`~scrapy.Request.from_curl()` to generate an equivalent
:class:`~scrapy.Request` from a cURL command. To get more information
visit :ref:`request from curl <requests-from-curl>` inside the network
tool section.
@ -115,15 +114,14 @@ Handling different response formats
Once you have a response with the desired data, how you extract the desired
data from it depends on the type of response:
- If the response is HTML or XML, use :ref:`selectors
- If the response is HTML, XML or JSON, use :ref:`selectors
<topics-selectors>` as usual.
- If the response is JSON, use :func:`json.loads` to load the desired data from
:attr:`response.text <scrapy.http.TextResponse.text>`:
- If the response is JSON, use :func:`response.json()` to load the desired data:
.. code-block:: python
data = json.loads(response.text)
data = response.json()
If the desired data is inside HTML or XML code embedded within JSON data,
you can load that HTML or XML code into a
@ -290,7 +288,7 @@ We recommend using `scrapy-playwright`_ for a better integration.
.. _JavaScript: https://en.wikipedia.org/wiki/JavaScript
.. _Splash: https://github.com/scrapinghub/splash
.. _chompjs: https://github.com/Nykakin/chompjs
.. _curl: https://curl.haxx.se/
.. _curl: https://curl.se/
.. _headless browser: https://en.wikipedia.org/wiki/Headless_browser
.. _js2xml: https://github.com/scrapinghub/js2xml
.. _playwright-python: https://github.com/microsoft/playwright-python

View File

@ -27,13 +27,13 @@ the standard ``__init__`` method:
mailer = MailSender()
Or you can instantiate it passing a Scrapy settings object, which will respect
the :ref:`settings <topics-email-settings>`:
Or you can instantiate it passing a :class:`scrapy.Crawler` instance, which
will respect the :ref:`settings <topics-email-settings>`:
.. skip: start
.. code-block:: python
mailer = MailSender.from_settings(settings)
mailer = MailSender.from_crawler(crawler)
And here is how to use it to send an e-mail (without attachments):
@ -81,13 +81,13 @@ rest of the framework.
:param smtpssl: enforce using a secure SSL connection
:type smtpssl: bool
.. classmethod:: from_settings(settings)
.. classmethod:: from_crawler(crawler)
Instantiate using a Scrapy settings object, which will respect
:ref:`these Scrapy settings <topics-email-settings>`.
Instantiate using a :class:`scrapy.Crawler` instance, which will
respect :ref:`these Scrapy settings <topics-email-settings>`.
:param settings: the e-mail recipients
:type settings: :class:`scrapy.settings.Settings` object
:param crawler: the crawler
:type settings: :class:`scrapy.Crawler` object
.. method:: send(to, subject, body, cc=None, attachs=(), mimetype='text/plain', charset=None)

View File

@ -243,6 +243,32 @@ An extension for debugging memory usage. It collects information about:
To enable this extension, turn on the :setting:`MEMDEBUG_ENABLED` setting. The
info will be stored in the stats.
.. _topics-extensions-ref-spiderstate:
Spider state extension
~~~~~~~~~~~~~~~~~~~~~~
.. module:: scrapy.extensions.spiderstate
:synopsis: Spider state extension
.. class:: SpiderState
Manages spider state data by loading it before a crawl and saving it after.
Give a value to the :setting:`JOBDIR` setting to enable this extension.
When enabled, this extension manages the :attr:`~scrapy.Spider.state`
attribute of your :class:`~scrapy.Spider` instance:
- When your spider closes (:signal:`spider_closed`), the contents of its
:attr:`~scrapy.Spider.state` attribute are serialized into a file named
``spider.state`` in the :setting:`JOBDIR` folder.
- When your spider opens (:signal:`spider_opened`), if a previously-generated
``spider.state`` file exists in the :setting:`JOBDIR` folder, it is loaded
into the :attr:`~scrapy.Spider.state` attribute.
For an example, see :ref:`topics-keeping-persistent-state-between-batches`.
Close spider extension
~~~~~~~~~~~~~~~~~~~~~~
@ -317,6 +343,19 @@ crawls more than that, the spider will be closed with the reason
``closespider_pagecount``. If zero (or non set), spiders won't be closed by
number of crawled responses.
.. setting:: CLOSESPIDER_PAGECOUNT_NO_ITEM
CLOSESPIDER_PAGECOUNT_NO_ITEM
"""""""""""""""""""""""""""""
Default: ``0``
An integer which specifies the maximum number of consecutive responses to crawl
without items scraped. If the spider crawls more consecutive responses than that
and no items are scraped in the meantime, the spider will be closed with the
reason ``closespider_pagecount_no_item``. If zero (or not set), spiders won't be
closed by number of crawled responses with no items.
.. setting:: CLOSESPIDER_ERRORCOUNT
CLOSESPIDER_ERRORCOUNT
@ -507,8 +546,4 @@ Invokes a :doc:`Python debugger <library/pdb>` inside a running Scrapy process w
signal is received. After the debugger is exited, the Scrapy process continues
running normally.
For more info see `Debugging in Python`_.
This extension only works on POSIX-compliant platforms (i.e. not Windows).
.. _Debugging in Python: https://pythonconquerstheuniverse.wordpress.com/2009/09/10/debugging-in-python/

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
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:
Serialization formats
@ -208,7 +213,7 @@ passed through the following settings:
- :setting:`AWS_SECRET_ACCESS_KEY`
- :setting:`AWS_SESSION_TOKEN` (only needed for `temporary security credentials`_)
.. _temporary security credentials: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#temporary-access-keys
.. _temporary security credentials: https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html
You can also define a custom ACL, custom endpoint, and region name for exported
feeds using these settings:
@ -243,7 +248,7 @@ The feeds are stored on `Google Cloud Storage`_.
- Required external libraries: `google-cloud-storage`_.
For more information about authentication, please refer to `Google Cloud documentation <https://cloud.google.com/docs/authentication/production>`_.
For more information about authentication, please refer to `Google Cloud documentation <https://cloud.google.com/docs/authentication>`_.
You can set a *Project ID* and *Access Control List (ACL)* through the following settings:
@ -385,7 +390,13 @@ Each plugin is a class that must implement the following methods:
.. 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
can then access those parameters from the ``__init__`` method of your plugin.
@ -505,8 +516,7 @@ as a fallback value if that key is not provided for a specific feed definition:
.. note:: Some FTP servers may not support appending to files (the
``APPE`` FTP command).
- :ref:`topics-feed-storage-s3`: ``True`` (appending `is not supported
<https://forums.aws.amazon.com/message.jspa?messageID=540395>`_)
- :ref:`topics-feed-storage-s3`: ``True`` (appending is not supported)
- :ref:`topics-feed-storage-gcs`: ``True`` (appending is not supported)
@ -805,5 +815,5 @@ source spider in the feed URI:
.. _URIs: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
.. _Amazon S3: https://aws.amazon.com/s3/
.. _boto3: https://github.com/boto/boto3
.. _Canned ACL: https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl
.. _Canned ACL: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
.. _Google Cloud Storage: https://cloud.google.com/storage/

View File

@ -99,7 +99,7 @@ contain a price:
adapter["price"] = adapter["price"] * self.vat_factor
return item
else:
raise DropItem(f"Missing price in {item}")
raise DropItem("Missing price")
Write items to a JSON lines file
@ -175,7 +175,7 @@ method and how to clean up the resources properly.
return item
.. _MongoDB: https://www.mongodb.com/
.. _pymongo: https://api.mongodb.com/python/current/
.. _pymongo: https://pymongo.readthedocs.io/en/stable/
.. _ScreenshotPipeline:
@ -254,7 +254,7 @@ returns multiples items with the same id:
def process_item(self, item, spider):
adapter = ItemAdapter(item)
if adapter["id"] in self.ids_seen:
raise DropItem(f"Duplicate item found: {item!r}")
raise DropItem(f"Item ID already seen: {adapter['id']}")
else:
self.ids_seen.add(adapter["id"])
return item

View File

@ -42,39 +42,27 @@ Item objects
:class:`Item` provides a :class:`dict`-like API plus additional features that
make it the most feature-complete item type:
.. class:: scrapy.item.Item([arg])
.. class:: scrapy.Item([arg])
.. autoclass:: scrapy.Item
:members: copy, deepcopy, fields
:undoc-members:
:class:`Item` objects replicate the standard :class:`dict` API, including
its ``__init__`` method.
:class:`Item` objects replicate the standard :class:`dict` API, including
its ``__init__`` method.
:class:`Item` allows defining field names, so that:
:class:`Item` allows the defining of field names, so that:
- :class:`KeyError` is raised when using undefined field names (i.e.
prevents typos going unnoticed)
- :class:`KeyError` is raised when using undefined field names (i.e.
prevents typos going unnoticed)
- :ref:`Item exporters <topics-exporters>` can export all fields by
default even if the first scraped object does not have values for all
of them
- :ref:`Item exporters <topics-exporters>` can export all fields by
default even if the first scraped object does not have values for all
of them
:class:`Item` also allows defining field metadata, which can be used to
:ref:`customize serialization <topics-exporters-field-serialization>`.
:class:`Item` also allows the defining of field metadata, which can be used to
:ref:`customize serialization <topics-exporters-field-serialization>`.
:mod:`trackref` tracks :class:`Item` objects to help find memory leaks
(see :ref:`topics-leaks-trackrefs`).
:class:`Item` objects also provide the following additional API members:
.. automethod:: copy
.. automethod:: deepcopy
.. attribute:: fields
A dictionary containing *all declared fields* for this Item, not only
those populated. The keys are the field names and the values are the
:class:`Field` objects used in the :ref:`Item declaration
<topics-items-declaring>`.
:mod:`trackref` tracks :class:`Item` objects to help find memory leaks
(see :ref:`topics-leaks-trackrefs`).
Example:
@ -94,11 +82,11 @@ Dataclass objects
.. versionadded:: 2.2
:func:`~dataclasses.dataclass` allows defining item classes with field names,
:func:`~dataclasses.dataclass` allows the defining of item classes with field names,
so that :ref:`item exporters <topics-exporters>` can export all fields by
default even if the first scraped object does not have values for all of them.
Additionally, ``dataclass`` items also allow to:
Additionally, ``dataclass`` items also allow you to:
* define the type and default value of each defined field.
@ -126,7 +114,7 @@ attr.s objects
.. versionadded:: 2.2
:func:`attr.s` allows defining item classes with field names,
:func:`attr.s` allows the defining of item classes with field names,
so that :ref:`item exporters <topics-exporters>` can export all fields by
default even if the first scraped object does not have values for all of them.
@ -205,10 +193,9 @@ documentation to see which metadata keys are used by each component.
It's important to note that the :class:`Field` objects used to declare the item
do not stay assigned as class attributes. Instead, they can be accessed through
the :attr:`Item.fields` attribute.
the :attr:`~scrapy.Item.fields` attribute.
.. class:: scrapy.item.Field([arg])
.. class:: scrapy.Field([arg])
.. autoclass:: scrapy.Field
The :class:`Field` class is just an alias to the built-in :class:`dict` class and
doesn't provide any extra functionality or attributes. In other words,
@ -221,7 +208,7 @@ the :attr:`Item.fields` attribute.
`attr.ib`_ for additional information.
.. _dataclasses.field: https://docs.python.org/3/library/dataclasses.html#dataclasses.field
.. _attr.ib: https://www.attrs.org/en/stable/api.html#attr.ib
.. _attr.ib: https://www.attrs.org/en/stable/api-attr.html#attr.ib
Working with Item objects
@ -399,12 +386,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
:class:`~itemadapter.ItemAdapter` class and the
:func:`~itemadapter.is_item` function to write code that works for
any :ref:`supported item type <item-types>`:
.. autoclass:: itemadapter.ItemAdapter
.. autofunction:: itemadapter.is_item
any supported item type.
Other classes related to items
==============================

View File

@ -46,7 +46,7 @@ Keeping persistent state between batches
Sometimes you'll want to keep some persistent spider state between pause/resume
batches. You can use the ``spider.state`` attribute for that, which should be a
dict. There's a built-in extension that takes care of serializing, storing and
dict. There's :ref:`a built-in extension <topics-extensions-ref-spiderstate>` that takes care of serializing, storing and
loading that attribute from the job directory, when the spider starts and
stops.

View File

@ -85,7 +85,7 @@ LxmlLinkExtractor
:param restrict_xpaths: is an XPath (or list of XPath's) which defines
regions inside the response where links should be extracted from.
If given, only the text selected by those XPath will be scanned for
links. See examples below.
links.
:type restrict_xpaths: str or list
:param restrict_css: a CSS selector (or list of selectors) which defines

View File

@ -261,7 +261,7 @@ policy:
For more information, see `canned ACLs`_ in the Amazon S3 Developer Guide.
You can also use other S3-like storages. Storages like self-hosted `Minio`_ or
`s3.scality`_. All you need to do is set endpoint option in you Scrapy
`Zenko CloudServer`_. All you need to do is set endpoint option in you Scrapy
settings:
.. code-block:: python
@ -276,9 +276,9 @@ For self-hosting you also might feel the need not to use SSL and not to verify S
AWS_VERIFY = False # or True (None by default)
.. _botocore: https://github.com/boto/botocore
.. _canned ACLs: https://docs.aws.amazon.com/AmazonS3/latest/dev/acl-overview.html#canned-acl
.. _canned ACLs: https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
.. _Minio: https://github.com/minio/minio
.. _s3.scality: https://s3.scality.com/
.. _Zenko CloudServer: https://www.zenko.io/cloudserver/
.. _media-pipeline-gcs:
@ -303,7 +303,7 @@ For example, these are valid :setting:`IMAGES_STORE` and :setting:`GCS_PROJECT_I
For information about authentication, see this `documentation`_.
.. _documentation: https://cloud.google.com/docs/authentication/production
.. _documentation: https://cloud.google.com/docs/authentication
You can modify the Access Control List (ACL) policy used for the stored files,
which is defined by the :setting:`FILES_STORE_GCS_ACL` and
@ -532,14 +532,14 @@ See here the methods that you can override in your custom Files Pipeline:
.. code-block:: python
from pathlib import PurePosixPath
from urllib.parse import urlparse
from scrapy.utils.httpobj import urlparse_cached
from scrapy.pipelines.files import FilesPipeline
class MyFilesPipeline(FilesPipeline):
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
property.
@ -690,14 +690,14 @@ See here the methods that you can override in your custom Images Pipeline:
.. code-block:: python
from pathlib import PurePosixPath
from urllib.parse import urlparse
from scrapy.utils.httpobj import urlparse_cached
from scrapy.pipelines.images import ImagesPipeline
class MyImagesPipeline(ImagesPipeline):
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
property.

View File

@ -92,7 +92,6 @@ reactor after ``MySpider`` has finished running.
.. code-block:: python
from twisted.internet import reactor
import scrapy
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
@ -107,6 +106,37 @@ reactor after ``MySpider`` has finished running.
runner = CrawlerRunner()
d = runner.crawl(MySpider)
from twisted.internet import reactor
d.addBoth(lambda _: reactor.stop())
reactor.run() # the script will block here until the crawling is finished
Same example but using a non-default reactor, it's only necessary call
``install_reactor`` if you are using ``CrawlerRunner`` since ``CrawlerProcess`` already does this automatically.
.. code-block:: python
import scrapy
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
class MySpider(scrapy.Spider):
# Your spider definition
...
configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"})
from scrapy.utils.reactor import install_reactor
install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
runner = CrawlerRunner()
d = runner.crawl(MySpider)
from twisted.internet import reactor
d.addBoth(lambda _: reactor.stop())
reactor.run() # the script will block here until the crawling is finished
@ -151,7 +181,6 @@ Same example using :class:`~scrapy.crawler.CrawlerRunner`:
.. code-block:: python
import scrapy
from twisted.internet import reactor
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.project import get_project_settings
@ -173,6 +202,9 @@ Same example using :class:`~scrapy.crawler.CrawlerRunner`:
runner.crawl(MySpider1)
runner.crawl(MySpider2)
d = runner.join()
from twisted.internet import reactor
d.addBoth(lambda _: reactor.stop())
reactor.run() # the script will block here until all crawling jobs are finished
@ -181,7 +213,7 @@ Same example but running the spiders sequentially by chaining the deferreds:
.. code-block:: python
from twisted.internet import reactor, defer
from twisted.internet import defer
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.project import get_project_settings
@ -209,6 +241,8 @@ Same example but running the spiders sequentially by chaining the deferreds:
reactor.stop()
from twisted.internet import reactor
crawl()
reactor.run() # the script will block here until the last crawl call is finished
@ -289,7 +323,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
super proxy that you can attach your own proxies to.
* 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
`commercial support`_.

View File

@ -94,13 +94,14 @@ Request objects
.. code-block:: python
request_with_cookies = Request(
url="http://www.example.com",
url="https://www.example.com",
cookies=[
{
"name": "currency",
"value": "USD",
"domain": "example.com",
"path": "/currency",
"secure": True,
},
],
)
@ -469,60 +470,6 @@ import path.
.. 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:
Writing your own request fingerprinter
@ -541,7 +488,7 @@ A request fingerprinter is a class that must implement the following method:
:param request: request to fingerprint
:type request: scrapy.http.Request
Additionally, it may also implement the following methods:
Additionally, it may also implement the following method:
.. classmethod:: from_crawler(cls, crawler)
:noindex:
@ -557,13 +504,6 @@ Additionally, it may also implement the following methods:
:param crawler: crawler that uses this request fingerprinter
:type crawler: :class:`~scrapy.crawler.Crawler` object
.. classmethod:: from_settings(cls, settings)
If present, and ``from_crawler`` is not defined, this class method is called
to create a request fingerprinter instance from a
:class:`~scrapy.settings.Settings` object. It must return a new instance of
the request fingerprinter.
.. currentmodule:: scrapy.http
The :meth:`fingerprint` method of the default request fingerprinter,
@ -721,6 +661,7 @@ are some special keys recognized by Scrapy and its built-in extensions.
Those are:
* :reqmeta:`autothrottle_dont_adjust_delay`
* :reqmeta:`bindaddress`
* :reqmeta:`cookiejar`
* :reqmeta:`dont_cache`
@ -731,6 +672,7 @@ Those are:
* :reqmeta:`download_fail_on_dataloss`
* :reqmeta:`download_latency`
* :reqmeta:`download_maxsize`
* :reqmeta:`download_warnsize`
* :reqmeta:`download_timeout`
* ``ftp_password`` (See :setting:`FTP_PASSWORD` for more info)
* ``ftp_user`` (See :setting:`FTP_USER` for more info)
@ -1357,3 +1299,13 @@ XmlResponse objects
line. See :attr:`TextResponse.encoding`.
.. _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

@ -591,7 +591,7 @@ Another common case would be to extract all direct ``<p>`` children:
For more details about relative XPaths see the `Location Paths`_ section in the
XPath specification.
.. _Location Paths: https://www.w3.org/TR/xpath/all/#location-paths
.. _Location Paths: https://www.w3.org/TR/xpath-10/#location-paths
When querying by class, consider using CSS
------------------------------------------
@ -727,7 +727,7 @@ But using the ``.`` to mean the node, works:
>>> sel.xpath("//a[contains(., 'Next Page')]").getall()
['<a href="#">Click here to go to the <strong>Next Page</strong></a>']
.. _`XPath string function`: https://www.w3.org/TR/xpath/all/#section-String-Functions
.. _`XPath string function`: https://www.w3.org/TR/xpath-10/#section-String-Functions
.. _topics-selectors-xpath-variables:
@ -801,8 +801,8 @@ This is how the file starts::
...
You can see several namespace declarations including a default
"http://www.w3.org/2005/Atom" and another one using the "gd:" prefix for
"http://schemas.google.com/g/2005".
``"http://www.w3.org/2005/Atom"`` and another one using the ``gd:`` prefix for
``"http://schemas.google.com/g/2005"``.
.. highlight:: python
@ -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
described with CSS selectors.
Parsel also simplifies adding your own XPath extensions.
.. autofunction:: parsel.xpathfuncs.set_xpathfunc
Parsel also simplifies adding your own XPath extensions with
:func:`~parsel.xpathfuncs.set_xpathfunc`.
.. _topics-selectors-ref:
@ -1062,6 +1060,12 @@ Selector objects
For convenience, this method can be called as ``response.css()``
.. automethod:: jmespath
.. note::
For convenience, this method can be called as ``response.jmespath()``
.. automethod:: get
See also: :ref:`old-extraction-api`
@ -1094,6 +1098,8 @@ SelectorList objects
.. automethod:: css
.. automethod:: jmespath
.. automethod:: getall
See also: :ref:`old-extraction-api`

View File

@ -288,7 +288,7 @@ The AWS security token used by code that requires access to `Amazon Web services
such as the :ref:`S3 feed storage backend <topics-feed-storage-s3>`, when using
`temporary security credentials`_.
.. _temporary security credentials: https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#temporary-access-keys
.. _temporary security credentials: https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds.html
.. setting:: AWS_ENDPOINT_URL
@ -617,7 +617,7 @@ necessary to access certain HTTPS websites: for example, you may need to use
``'DEFAULT:!DH'`` for a website with weak DH parameters or enable a
specific cipher that is not included in ``DEFAULT`` if a website requires it.
.. _OpenSSL cipher list format: https://www.openssl.org/docs/manmaster/man1/openssl-ciphers.html#CIPHER-LIST-FORMAT
.. _OpenSSL cipher list format: https://docs.openssl.org/master/man1/openssl-ciphers/#cipher-list-format
.. setting:: DOWNLOADER_CLIENT_TLS_METHOD
@ -674,6 +674,7 @@ Default:
.. code-block:: python
{
"scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50,
"scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100,
"scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300,
"scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350,
@ -828,14 +829,14 @@ The default HTTPS handler uses HTTP/1.1. To use HTTP/2:
- No support for the :signal:`bytes_received` and
:signal:`headers_received` signals.
.. _frame size: https://tools.ietf.org/html/rfc7540#section-4.2
.. _frame size: https://datatracker.ietf.org/doc/html/rfc7540#section-4.2
.. _http2 faq: https://http2.github.io/faq/#does-http2-require-encryption
.. _server pushes: https://tools.ietf.org/html/rfc7540#section-8.2
.. _server pushes: https://datatracker.ietf.org/doc/html/rfc7540#section-8.2
.. setting:: DOWNLOAD_SLOTS
DOWNLOAD_SLOTS
----------------
--------------
Default: ``{}``
@ -873,40 +874,42 @@ The amount of time (in secs) that the downloader will wait before timing out.
Request.meta key.
.. setting:: DOWNLOAD_MAXSIZE
.. reqmeta:: 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 size can be set per spider using :attr:`download_maxsize`
spider attribute and per-request using :reqmeta:`download_maxsize`
Request.meta key.
This limit can be set per spider using the :attr:`download_maxsize` spider
attribute and per request using the :reqmeta:`download_maxsize` Request.meta
key.
.. setting:: DOWNLOAD_WARNSIZE
.. reqmeta:: 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 size can be set per spider using :attr:`download_warnsize`
spider attribute and per-request using :reqmeta:`download_warnsize`
Request.meta key.
This limit can be set per spider using the :attr:`download_warnsize` spider
attribute and per request using the :reqmeta:`download_warnsize` Request.meta
key.
.. setting:: DOWNLOAD_FAIL_ON_DATALOSS
@ -1063,7 +1066,7 @@ in ``Request`` meta.
some FTP servers explicitly ask for the user's e-mail address
and will not allow login with the "guest" password.
.. _RFC 1635: https://tools.ietf.org/html/rfc1635
.. _RFC 1635: https://datatracker.ietf.org/doc/html/rfc1635
.. reqmeta:: ftp_user
.. setting:: FTP_USER
@ -1225,6 +1228,25 @@ Default: ``False``
If ``True``, the logs will just contain the root path. If it is set to ``False``
then it displays the component responsible for the log output
.. setting:: LOG_VERSIONS
LOG_VERSIONS
------------
Default: ``["lxml", "libxml2", "cssselect", "parsel", "w3lib", "Twisted", "Python", "pyOpenSSL", "cryptography", "Platform"]``
Logs the installed versions of the specified items.
An item can be any installed Python package.
The following special items are also supported:
- ``libxml2``
- ``Platform`` (:func:`platform.platform`)
- ``Python``
.. setting:: LOGSTATS_INTERVAL
LOGSTATS_INTERVAL
@ -1569,7 +1591,7 @@ SPIDER_LOADER_WARN_ONLY
Default: ``False``
By default, when Scrapy tries to import spider classes from :setting:`SPIDER_MODULES`,
it will fail loudly if there is any ``ImportError`` exception.
it will fail loudly if there is any ``ImportError`` or ``SyntaxError`` exception.
But you can choose to silence this exception and turn it into a simple
warning by setting ``SPIDER_LOADER_WARN_ONLY = True``.
@ -1603,7 +1625,6 @@ Default:
{
"scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": 500,
"scrapy.spidermiddlewares.referer.RefererMiddleware": 700,
"scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800,
"scrapy.spidermiddlewares.depth.DepthMiddleware": 900,

View File

@ -159,8 +159,9 @@ item_scraped
:param spider: the spider which scraped the item
:type spider: :class:`~scrapy.Spider` object
:param response: the response from where the item was scraped
:type response: :class:`~scrapy.http.Response` object
:param response: the response from where the item was scraped, or ``None``
if it was yielded from :meth:`~scrapy.Spider.start_requests`.
:type response: :class:`~scrapy.http.Response` | ``None``
item_dropped
~~~~~~~~~~~~
@ -179,8 +180,9 @@ item_dropped
:param spider: the spider which scraped the item
:type spider: :class:`~scrapy.Spider` object
:param response: the response from where the item was dropped
:type response: :class:`~scrapy.http.Response` object
:param response: the response from where the item was dropped, or ``None``
if it was yielded from :meth:`~scrapy.Spider.start_requests`.
:type response: :class:`~scrapy.http.Response` | ``None``
:param exception: the exception (which must be a
:exc:`~scrapy.exceptions.DropItem` subclass) which caused the item
@ -201,8 +203,10 @@ item_error
:param item: the item that caused the error in the :ref:`topics-item-pipeline`
:type item: :ref:`item object <item-types>`
:param response: the response being processed when the exception was raised
:type response: :class:`~scrapy.http.Response` object
:param response: the response being processed when the exception was
raised, or ``None`` if it was yielded from
:meth:`~scrapy.Spider.start_requests`.
:type response: :class:`~scrapy.http.Response` | ``None``
:param spider: the spider which raised the exception
:type spider: :class:`~scrapy.Spider` object
@ -343,11 +347,18 @@ request_scheduled
.. signal:: request_scheduled
.. function:: request_scheduled(request, spider)
Sent when the engine schedules a :class:`~scrapy.Request`, to be
downloaded later.
Sent when the engine is asked to schedule a :class:`~scrapy.Request`, to be
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.
.. versionadded:: 2.11.2
Allow dropping requests with :exc:`~scrapy.exceptions.IgnoreRequest`.
:param request: the request that reached the scheduler
: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
SPIDER_MIDDLEWARES = {
"myproject.middlewares.CustomSpiderMiddleware": 543,
"scrapy.spidermiddlewares.offsite.OffsiteMiddleware": None,
"scrapy.spidermiddlewares.referer.RefererMiddleware": None,
"myproject.middlewares.CustomRefererSpiderMiddleware": 700,
}
Finally, keep in mind that some middlewares may need to be enabled through a
@ -176,7 +176,7 @@ object gives you access, for example, to the :ref:`settings <topics-settings>`.
items).
It receives an iterable (in the ``start_requests`` parameter) and must
return another iterable of :class:`~scrapy.Request` objects.
return another iterable of :class:`~scrapy.Request` objects and/or :ref:`item objects <topics-items>`.
.. note:: When implementing this method in your spider middleware, you
should always return an iterable (that follows the input one) and
@ -313,42 +313,6 @@ Default: ``False``
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
-----------------
@ -394,7 +358,7 @@ Acceptable values for REFERRER_POLICY
- either a path to a ``scrapy.spidermiddlewares.referer.ReferrerPolicy``
subclass — a custom policy or one of the built-in ones (see classes below),
- or one of the standard W3C-defined string values,
- or one or more comma-separated standard W3C-defined string values,
- or the special ``"scrapy-default"``.
======================================= ========================================================================

View File

@ -75,7 +75,8 @@ scrapy.Spider
An optional list of strings containing domains that this spider is
allowed to crawl. Requests for URLs not belonging to the domain names
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``,
then add ``'example.com'`` to the list.
@ -202,7 +203,8 @@ scrapy.Spider
.. method:: start_requests()
This method must return an iterable with the first Requests to crawl for
This method must return an iterable with the first Requests to crawl and/or with :ref:`item objects
<topics-items>` for
this spider. It is called by Scrapy when the spider is opened for
scraping. Scrapy calls it only once, so it is safe to implement
:meth:`start_requests` as a generator.

View File

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

View File

@ -1,6 +1,6 @@
# Run tests, generate coverage report and open it on a browser
#
# Requires: coverage 3.3 or above from https://pypi.python.org/pypi/coverage
# Requires: coverage 3.3 or above from https://pypi.org/pypi/coverage
coverage run --branch $(which trial) --reporter=text tests
coverage html -i

View File

@ -41,7 +41,6 @@ _scrapy() {
(runspider)
local options=(
{'(--output)-o','(-o)--output='}'[dump scraped items into FILE (use - for stdout)]:file:_files'
{'(--output-format)-t','(-t)--output-format='}'[format to use for dumping items with -o]:format:(FORMAT)'
'*-a[set spider argument (may be repeated)]:value pair:(NAME=VALUE)'
'1:spider file:_files -g \*.py'
)
@ -99,7 +98,6 @@ _scrapy() {
(crawl)
local options=(
{'(--output)-o','(-o)--output='}'[dump scraped items into FILE (use - for stdout)]:file:_files'
{'(--output-format)-t','(-t)--output-format='}'[format to use for dumping items with -o]:format:(FORMAT)'
'*-a[set spider argument (may be repeated)]:value pair:(NAME=VALUE)'
'1:spider:_scrapy_spiders'
)

View File

@ -1,99 +0,0 @@
[MASTER]
persistent=no
jobs=1 # >1 hides results
[MESSAGES CONTROL]
disable=abstract-method,
anomalous-backslash-in-string,
arguments-differ,
arguments-renamed,
attribute-defined-outside-init,
bad-classmethod-argument,
bad-mcs-classmethod-argument,
bare-except,
broad-except,
broad-exception-raised,
c-extension-no-member,
catching-non-exception,
cell-var-from-loop,
comparison-with-callable,
consider-using-dict-items,
consider-using-in,
consider-using-with,
cyclic-import,
dangerous-default-value,
disallowed-name,
duplicate-code, # https://github.com/PyCQA/pylint/issues/214
eval-used,
expression-not-assigned,
fixme,
function-redefined,
global-statement,
implicit-str-concat,
import-error,
import-outside-toplevel,
import-self,
inconsistent-return-statements,
inherit-non-class,
invalid-name,
invalid-overridden-method,
isinstance-second-argument-not-valid-type,
keyword-arg-before-vararg,
line-too-long,
logging-format-interpolation,
logging-fstring-interpolation,
logging-not-lazy,
lost-exception,
method-hidden,
missing-docstring,
no-else-raise,
no-else-return,
no-member,
no-method-argument,
no-name-in-module,
no-self-argument,
no-value-for-parameter,
not-callable,
pointless-exception-statement,
pointless-statement,
pointless-string-statement,
protected-access,
raise-missing-from,
redefined-argument-from-local,
redefined-builtin,
redefined-outer-name,
reimported,
signature-differs,
super-init-not-called,
too-few-public-methods,
too-many-ancestors,
too-many-arguments,
too-many-branches,
too-many-format-args,
too-many-function-args,
too-many-instance-attributes,
too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
unbalanced-tuple-unpacking,
undefined-variable,
undefined-loop-variable,
unexpected-special-method-signature,
unnecessary-comprehension,
unnecessary-dunder-call,
unnecessary-pass,
unreachable,
unsubscriptable-object,
unused-argument,
unused-import,
unused-private-member,
unused-variable,
unused-wildcard-import,
use-dict-literal,
used-before-assignment,
useless-object-inheritance, # Required for Python 2 support
useless-return,
useless-super-delegation,
wildcard-import,
wrong-import-position

353
pyproject.toml Normal file
View File

@ -0,0 +1,353 @@
[build-system]
requires = ["setuptools >= 61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "Scrapy"
dynamic = ["version"]
description = "A high-level Web Crawling and Web Scraping framework"
dependencies = [
"Twisted>=21.7.0",
"cryptography>=37.0.0",
"cssselect>=0.9.1",
"itemloaders>=1.0.1",
"parsel>=1.5.0",
"pyOpenSSL>=22.0.0",
"queuelib>=1.4.2",
"service_identity>=18.1.0",
"w3lib>=1.17.0",
"zope.interface>=5.1.0",
"protego>=0.1.15",
"itemadapter>=0.1.0",
"packaging",
"tldextract",
"lxml>=4.6.0",
"defusedxml>=0.7.1",
# Platform-specific dependencies
'PyDispatcher>=2.0.5; platform_python_implementation == "CPython"',
'PyPyDispatcher>=2.1.0; platform_python_implementation == "PyPy"',
]
classifiers = [
"Framework :: Scrapy",
"Development Status :: 5 - Production/Stable",
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Topic :: Software Development :: Libraries :: Python Modules",
]
readme = "README.rst"
requires-python = ">=3.9"
authors = [{ name = "Scrapy developers", email = "pablo@pablohoffman.com" }]
maintainers = [{ name = "Pablo Hoffman", email = "pablo@pablohoffman.com" }]
[project.urls]
Homepage = "https://scrapy.org/"
Documentation = "https://docs.scrapy.org/"
Source = "https://github.com/scrapy/scrapy"
Tracker = "https://github.com/scrapy/scrapy/issues"
Changelog = "https://github.com/scrapy/scrapy/commits/master/"
releasenotes = "https://docs.scrapy.org/en/latest/news.html"
[project.scripts]
scrapy = "scrapy.cmdline:execute"
[tool.setuptools.packages.find]
where = ["."]
include = ["scrapy", "scrapy.*",]
[tool.setuptools.dynamic]
version = {file = "./scrapy/VERSION"}
[tool.mypy]
ignore_missing_imports = true
# Interface classes are hard to support
[[tool.mypy.overrides]]
module = "twisted.internet.interfaces"
follow_imports = "skip"
[[tool.mypy.overrides]]
module = "scrapy.interfaces"
ignore_errors = true
[[tool.mypy.overrides]]
module = "twisted.internet.reactor"
follow_imports = "skip"
# FIXME: remove the following section once the issues are solved
[[tool.mypy.overrides]]
module = "scrapy.settings.default_settings"
ignore_errors = true
[tool.bumpversion]
current_version = "2.12.0"
commit = true
tag = true
tag_name = "{new_version}"
[[tool.bumpversion.files]]
filename = "scrapy/VERSION"
[[tool.bumpversion.files]]
filename = "SECURITY.md"
parse = """(?P<major>0|[1-9]\\d*)\\.(?P<minor>0|[1-9]\\d*)"""
serialize = ["{major}.{minor}"]
[tool.coverage.run]
branch = true
include = ["scrapy/*"]
omit = ["tests/*"]
disable_warnings = ["include-ignored"]
[tool.coverage.report]
# https://github.com/nedbat/coveragepy/issues/831#issuecomment-517778185
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
[tool.pylint.MASTER]
persistent = "no"
jobs = 1 # >1 hides results
extension-pkg-allow-list=[
"lxml",
]
[tool.pylint."MESSAGES CONTROL"]
disable = [
"abstract-method",
"arguments-differ",
"arguments-renamed",
"attribute-defined-outside-init",
"broad-exception-caught",
"consider-using-with",
"cyclic-import",
"dangerous-default-value",
"disallowed-name",
"duplicate-code", # https://github.com/PyCQA/pylint/issues/214
"eval-used",
"fixme",
"import-error",
"import-outside-toplevel",
"inherit-non-class",
"invalid-name",
"invalid-overridden-method",
"isinstance-second-argument-not-valid-type",
"keyword-arg-before-vararg",
"line-too-long",
"logging-format-interpolation",
"logging-fstring-interpolation",
"logging-not-lazy",
"missing-docstring",
"no-member",
"no-method-argument",
"no-name-in-module",
"no-self-argument",
"no-value-for-parameter", # https://github.com/pylint-dev/pylint/issues/3268
"not-callable",
"pointless-statement",
"pointless-string-statement",
"protected-access",
"raise-missing-from",
"redefined-builtin",
"redefined-outer-name",
"signature-differs",
"too-few-public-methods",
"too-many-ancestors",
"too-many-arguments",
"too-many-branches",
"too-many-function-args",
"too-many-instance-attributes",
"too-many-lines",
"too-many-locals",
"too-many-positional-arguments",
"too-many-public-methods",
"too-many-return-statements",
"unbalanced-tuple-unpacking",
"unnecessary-dunder-call",
"unused-argument",
"unused-import",
"unused-variable",
"used-before-assignment",
"useless-return",
"wrong-import-position",
]
[tool.pytest.ini_options]
xfail_strict = true
usefixtures = "chdir"
python_files = ["test_*.py", "__init__.py"]
python_classes = []
addopts = [
"--assert=plain",
"--ignore=docs/_ext",
"--ignore=docs/conf.py",
"--ignore=docs/news.rst",
"--ignore=docs/topics/dynamic-content.rst",
"--ignore=docs/topics/items.rst",
"--ignore=docs/topics/leaks.rst",
"--ignore=docs/topics/loaders.rst",
"--ignore=docs/topics/selectors.rst",
"--ignore=docs/topics/shell.rst",
"--ignore=docs/topics/stats.rst",
"--ignore=docs/topics/telnetconsole.rst",
"--ignore=docs/utils",
]
markers = [
"only_asyncio: marks tests as only enabled when --reactor=asyncio is passed",
"only_not_asyncio: marks tests as only enabled when --reactor=asyncio is not passed",
"requires_uvloop: marks tests as only enabled when uvloop is known to be working",
"requires_botocore: marks tests that need botocore (but not boto3)",
"requires_boto3: marks tests that need botocore and boto3",
]
filterwarnings = []
[tool.ruff.lint]
extend-select = [
# flake8-bugbear
"B",
# flake8-comprehensions
"C4",
# pydocstyle
"D",
# flake8-future-annotations
"FA",
# refurb
"FURB",
# isort
"I",
# flake8-implicit-str-concat
"ISC",
# flake8-logging
"LOG",
# Perflint
"PERF",
# pygrep-hooks
"PGH",
# flake8-pie
"PIE",
# pylint
"PL",
# flake8-pyi
"PYI",
# flake8-quotes
"Q",
# flake8-return
"RET",
# flake8-raise
"RSE",
# flake8-bandit
"S",
# flake8-slots
"SLOT",
# flake8-debugger
"T10",
# flake8-type-checking
"TC",
# pyupgrade
"UP",
# pycodestyle warnings
"W",
# flake8-2020
"YTT",
]
ignore = [
# Assigning to `os.environ` doesn't clear the environment.
"B003",
# Do not use mutable data structures for argument defaults.
"B006",
# Loop control variable not used within the loop body.
"B007",
# Do not perform function calls in argument defaults.
"B008",
# Star-arg unpacking after a keyword argument is strongly discouraged.
"B026",
# Found useless expression.
"B018",
# No explicit stacklevel argument found.
"B028",
# Within an `except` clause, raise exceptions with `raise ... from`
"B904",
# Missing docstring in public module
"D100",
# Missing docstring in public class
"D101",
# Missing docstring in public method
"D102",
# Missing docstring in public function
"D103",
# Missing docstring in public package
"D104",
# Missing docstring in magic method
"D105",
# Missing docstring in public nested class
"D106",
# Missing docstring in __init__
"D107",
# One-line docstring should fit on one line with quotes
"D200",
# No blank lines allowed after function docstring
"D202",
# 1 blank line required between summary line and description
"D205",
# Multi-line docstring closing quotes should be on a separate line
"D209",
# First line should end with a period
"D400",
# First line should be in imperative mood; try rephrasing
"D401",
# First line should not be the function's "signature"
"D402",
# First word of the first line should be properly capitalized
"D403",
# `try`-`except` within a loop incurs performance overhead
"PERF203",
# Too many return statements
"PLR0911",
# Too many branches
"PLR0912",
# Too many arguments in function definition
"PLR0913",
# Too many statements
"PLR0915",
# Magic value used in comparison
"PLR2004",
# `for` loop variable overwritten by assignment target
"PLW2901",
# Use of `assert` detected; needed for mypy
"S101",
# FTP-related functions are being called; https://github.com/scrapy/scrapy/issues/4180
"S321",
# Argument default set to insecure SSL protocol
"S503",
]
[tool.ruff.lint.per-file-ignores]
# Exclude files that are meant to provide top-level imports
"scrapy/__init__.py" = ["E402"]
"scrapy/core/downloader/handlers/http.py" = ["F401"]
"scrapy/http/__init__.py" = ["F401"]
"scrapy/linkextractors/__init__.py" = ["E402", "F401"]
"scrapy/selector/__init__.py" = ["F401"]
"scrapy/spiders/__init__.py" = ["E402", "F401"]
# Skip bandit in tests
"tests/**" = ["S"]
# Issues pending a review:
"docs/conf.py" = ["E402"]
"scrapy/utils/url.py" = ["F403", "F405"]
"tests/test_loader.py" = ["E741"]
[tool.ruff.lint.pydocstyle]
convention = "pep257"

View File

@ -1,28 +0,0 @@
[pytest]
xfail_strict = true
usefixtures = chdir
python_files=test_*.py __init__.py
python_classes=
addopts =
--assert=plain
--ignore=docs/_ext
--ignore=docs/conf.py
--ignore=docs/news.rst
--ignore=docs/topics/dynamic-content.rst
--ignore=docs/topics/items.rst
--ignore=docs/topics/leaks.rst
--ignore=docs/topics/loaders.rst
--ignore=docs/topics/selectors.rst
--ignore=docs/topics/shell.rst
--ignore=docs/topics/stats.rst
--ignore=docs/topics/telnetconsole.rst
--ignore=docs/utils
markers =
only_asyncio: marks tests as only enabled when --reactor=asyncio is passed
only_not_asyncio: marks tests as only enabled when --reactor=asyncio is not passed
requires_uvloop: marks tests as only enabled when uvloop is known to be working
filterwarnings =
ignore:scrapy.downloadermiddlewares.decompression is deprecated
ignore:Module scrapy.utils.reqser is deprecated
ignore:typing.re is deprecated
ignore:typing.io is deprecated

View File

@ -1 +1 @@
2.11.0
2.12.0

View File

@ -6,8 +6,6 @@ import pkgutil
import sys
import warnings
from twisted import version as _txv
# Declare top-level shortcuts
from scrapy.http import FormRequest, Request
from scrapy.item import Field, Item
@ -17,7 +15,6 @@ from scrapy.spiders import Spider
__all__ = [
"__version__",
"version_info",
"twisted_version",
"Spider",
"Request",
"FormRequest",
@ -30,13 +27,23 @@ __all__ = [
# Scrapy and Twisted versions
__version__ = (pkgutil.get_data(__package__, "VERSION") or b"").decode("ascii").strip()
version_info = tuple(int(v) if v.isdigit() else v for v in __version__.split("."))
twisted_version = (_txv.major, _txv.minor, _txv.micro)
# Check minimum required Python version
if sys.version_info < (3, 8):
print(f"Scrapy {__version__} requires Python 3.8+")
sys.exit(1)
def __getattr__(name: str):
if name == "twisted_version":
import warnings # pylint: disable=reimported
from twisted import version as _txv
from scrapy.exceptions import ScrapyDeprecationWarning
warnings.warn(
"The scrapy.twisted_version attribute is deprecated, use twisted.version instead",
ScrapyDeprecationWarning,
)
return _txv.major, _txv.minor, _txv.micro
raise AttributeError
# Ignore noisy twisted deprecation warnings

View File

@ -1,13 +1,16 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, List
from typing import TYPE_CHECKING, Any
from scrapy.exceptions import NotConfigured
from scrapy.settings import Settings
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:
from scrapy.crawler import Crawler
from scrapy.settings import Settings
logger = logging.getLogger(__name__)
@ -15,9 +18,9 @@ logger = logging.getLogger(__name__)
class AddonManager:
"""This class facilitates loading and storing :ref:`topics-addons`."""
def __init__(self, crawler: "Crawler") -> None:
self.crawler: "Crawler" = crawler
self.addons: List[Any] = []
def __init__(self, crawler: Crawler) -> None:
self.crawler: Crawler = crawler
self.addons: list[Any] = []
def load_settings(self, settings: Settings) -> None:
"""Load add-ons and configurations from a settings object and apply them.
@ -32,9 +35,7 @@ class AddonManager:
for clspath in build_component_list(settings["ADDONS"]):
try:
addoncls = load_object(clspath)
addon = create_instance(
addoncls, settings=settings, crawler=self.crawler
)
addon = build_from_crawler(addoncls, self.crawler)
addon.update_settings(settings)
self.addons.append(addon)
except NotConfigured as e:

View File

@ -1,9 +1,12 @@
from __future__ import annotations
import argparse
import cProfile
import inspect
import os
import sys
from importlib.metadata import entry_points
from typing import TYPE_CHECKING
import scrapy
from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter
@ -13,9 +16,21 @@ from scrapy.utils.misc import walk_modules
from scrapy.utils.project import get_project_settings, inside_project
from scrapy.utils.python import garbage_collect
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
# typing.ParamSpec requires Python 3.10
from typing_extensions import ParamSpec
from scrapy.settings import BaseSettings, Settings
_P = ParamSpec("_P")
class ScrapyArgumentParser(argparse.ArgumentParser):
def _parse_optional(self, arg_string):
def _parse_optional(
self, arg_string: str
) -> tuple[argparse.Action | None, str, str | None] | None:
# if starts with -: it means that is a parameter not a argument
if arg_string[:2] == "-:":
return None
@ -23,7 +38,7 @@ class ScrapyArgumentParser(argparse.ArgumentParser):
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
# scrapy.utils.spider.iter_spider_classes
for module in walk_modules(module_name):
@ -37,8 +52,8 @@ def _iter_command_classes(module_name):
yield obj
def _get_commands_from_module(module, inproject):
d = {}
def _get_commands_from_module(module: str, inproject: bool) -> dict[str, ScrapyCommand]:
d: dict[str, ScrapyCommand] = {}
for cmd in _iter_command_classes(module):
if inproject or not cmd.requires_project:
cmdname = cmd.__module__.split(".")[-1]
@ -46,8 +61,10 @@ def _get_commands_from_module(module, inproject):
return d
def _get_commands_from_entry_points(inproject, group="scrapy.commands"):
cmds = {}
def _get_commands_from_entry_points(
inproject: bool, group: str = "scrapy.commands"
) -> dict[str, ScrapyCommand]:
cmds: dict[str, ScrapyCommand] = {}
if sys.version_info >= (3, 10):
eps = entry_points(group=group)
else:
@ -57,11 +74,13 @@ def _get_commands_from_entry_points(inproject, group="scrapy.commands"):
if inspect.isclass(obj):
cmds[entry_point.name] = obj()
else:
raise Exception(f"Invalid entry point {entry_point.name}")
raise ValueError(f"Invalid entry point {entry_point.name}")
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.update(_get_commands_from_entry_points(inproject))
cmds_module = settings["COMMANDS_MODULE"]
@ -70,16 +89,17 @@ def _get_commands_dict(settings, inproject):
return cmds
def _pop_command_name(argv):
def _pop_command_name(argv: list[str]) -> str | None:
i = 0
for arg in argv[1:]:
if not arg.startswith("-"):
del argv[i]
return arg
i += 1
return None
def _print_header(settings, inproject):
def _print_header(settings: BaseSettings, inproject: bool) -> None:
version = scrapy.__version__
if inproject:
print(f"Scrapy {version} - active project: {settings['BOT_NAME']}\n")
@ -88,7 +108,7 @@ def _print_header(settings, inproject):
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("Usage:")
print(" scrapy <command> [options] [args]\n")
@ -103,13 +123,20 @@ def _print_commands(settings, inproject):
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(f"Unknown command: {cmdname}\n")
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:
func(*a, **kw)
except UsageError as e:
@ -120,7 +147,7 @@ def _run_print_help(parser, func, *a, **kw):
sys.exit(2)
def execute(argv=None, settings=None):
def execute(argv: list[str] | None = None, settings: Settings | None = None) -> None:
if argv is None:
argv = sys.argv
@ -162,14 +189,16 @@ def execute(argv=None, settings=None):
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:
_run_command_profiled(cmd, args, opts)
else:
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:
sys.stderr.write(f"scrapy: writing cProfile stats to {opts.profile!r}\n")
loc = locals()

View File

@ -1,62 +1,70 @@
"""
Base class for Scrapy commands
"""
from __future__ import annotations
import argparse
import builtins
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import TYPE_CHECKING, Any
from twisted.python import failure
from scrapy.crawler import CrawlerProcess
from scrapy.exceptions import UsageError
from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli
if TYPE_CHECKING:
from collections.abc import Iterable
from scrapy.crawler import Crawler, CrawlerProcess
class ScrapyCommand:
requires_project = False
crawler_process: Optional[CrawlerProcess] = None
requires_project: bool = False
crawler_process: CrawlerProcess | None = None
# 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:
self.settings: Any = None # set in scrapy.cmdline
def set_crawler(self, crawler):
def set_crawler(self, crawler: Crawler) -> None:
if hasattr(self, "_crawler"):
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.
"""
return ""
def short_desc(self):
def short_desc(self) -> str:
"""
A short description of the command
"""
return ""
def long_desc(self):
def long_desc(self) -> str:
"""A long description of the command. Return short description when not
available. It cannot contain newlines since contents will be formatted
by optparser which removes newlines and wraps text.
"""
return self.short_desc()
def help(self):
def help(self) -> str:
"""An extensive help for the command. It will be shown when using the
"help" command. It can contain newlines since no post-formatting will
be applied to its contents.
"""
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
"""
@ -91,7 +99,7 @@ class ScrapyCommand:
)
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:
self.settings.setdict(arglist_to_dict(opts.set), priority="cmdline")
except ValueError:
@ -116,7 +124,7 @@ class ScrapyCommand:
if opts.pdb:
failure.startDebugMode()
def run(self, args: List[str], opts: argparse.Namespace) -> None:
def run(self, args: list[str], opts: argparse.Namespace) -> None:
"""
Entry point for running commands
"""
@ -128,8 +136,8 @@ class BaseRunSpiderCommand(ScrapyCommand):
Common class used to share functionality between the crawl, parse and runspider commands
"""
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"-a",
dest="spargs",
@ -154,15 +162,9 @@ class BaseRunSpiderCommand(ScrapyCommand):
help="dump scraped items into FILE, overwriting any existing file,"
" to define format set a colon at the end of the output URI (i.e. -O FILE:FORMAT)",
)
parser.add_argument(
"-t",
"--output-format",
metavar="FORMAT",
help="format to use for dumping items",
)
def process_options(self, args, opts):
ScrapyCommand.process_options(self, args, opts)
def process_options(self, args: list[str], opts: argparse.Namespace) -> None:
super().process_options(args, opts)
try:
opts.spargs = arglist_to_dict(opts.spargs)
except ValueError:
@ -171,8 +173,7 @@ class BaseRunSpiderCommand(ScrapyCommand):
feeds = feed_process_params_from_cli(
self.settings,
opts.output,
opts.output_format,
opts.overwrite_output,
overwrite_output=opts.overwrite_output,
)
self.settings.set("FEEDS", feeds, priority="cmdline")
@ -182,7 +183,13 @@ class ScrapyHelpFormatter(argparse.HelpFormatter):
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: int | None = None,
):
super().__init__(
prog,
indent_increment=indent_increment,
@ -190,11 +197,12 @@ class ScrapyHelpFormatter(argparse.HelpFormatter):
width=width,
)
def _join_parts(self, part_strings):
parts = self.format_part_strings(part_strings)
def _join_parts(self, part_strings: Iterable[str]) -> str:
# scrapy.commands.list shadows builtins.list
parts = self.format_part_strings(builtins.list(part_strings))
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.
"""

View File

@ -1,12 +1,22 @@
from __future__ import annotations
import subprocess
import sys
import time
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode
import scrapy
from scrapy.commands import ScrapyCommand
from scrapy.http import Response, TextResponse
from scrapy.linkextractors import LinkExtractor
if TYPE_CHECKING:
import argparse
from collections.abc import Iterable
from scrapy import Request
class Command(ScrapyCommand):
default_settings = {
@ -15,24 +25,28 @@ class Command(ScrapyCommand):
"CLOSESPIDER_TIMEOUT": 10,
}
def short_desc(self):
def short_desc(self) -> str:
return "Run quick benchmark test"
def run(self, args, opts):
def run(self, args: list[str], opts: argparse.Namespace) -> None:
with _BenchServer():
assert self.crawler_process
self.crawler_process.crawl(_BenchSpider, total=100000)
self.crawler_process.start()
class _BenchServer:
def __enter__(self):
def __enter__(self) -> None:
from scrapy.utils.test import get_testenv
pargs = [sys.executable, "-u", "-m", "scrapy.utils.benchserver"]
self.proc = subprocess.Popen(pargs, stdout=subprocess.PIPE, env=get_testenv())
self.proc = subprocess.Popen( # noqa: S603
pargs, stdout=subprocess.PIPE, env=get_testenv()
)
assert self.proc.stdout
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.wait()
time.sleep(0.2)
@ -47,11 +61,12 @@ class _BenchSpider(scrapy.Spider):
baseurl = "http://localhost:8998"
link_extractor = LinkExtractor()
def start_requests(self):
def start_requests(self) -> Iterable[Request]:
qargs = {"total": self.total, "show": self.show}
url = f"{self.baseurl}?{urlencode(qargs, doseq=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):
yield scrapy.Request(link.url, callback=self.parse)

View File

@ -1,3 +1,4 @@
import argparse
import time
from collections import defaultdict
from unittest import TextTestResult as _TextTestResult
@ -10,7 +11,7 @@ from scrapy.utils.misc import load_object, set_environ
class TextTestResult(_TextTestResult):
def printSummary(self, start, stop):
def printSummary(self, start: float, stop: float) -> None:
write = self.stream.write
writeln = self.stream.writeln
@ -42,14 +43,14 @@ class Command(ScrapyCommand):
requires_project = True
default_settings = {"LOG_ENABLED": False}
def syntax(self):
def syntax(self) -> str:
return "[options] <spider>"
def short_desc(self):
def short_desc(self) -> str:
return "Check spider contracts"
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"-l",
"--list",
@ -66,7 +67,7 @@ class Command(ScrapyCommand):
help="print contract tests for all spiders",
)
def run(self, args, opts):
def run(self, args: list[str], opts: argparse.Namespace) -> None:
# load contracts
contracts = build_component_list(self.settings.getwithbase("SPIDER_CONTRACTS"))
conman = ContractsManager(load_object(c) for c in contracts)
@ -76,12 +77,13 @@ class Command(ScrapyCommand):
# contract requests
contract_reqs = defaultdict(list)
assert self.crawler_process
spider_loader = self.crawler_process.spider_loader
with set_environ(SCRAPY_CHECK="true"):
for spidername in args or spider_loader.list():
spidercls = spider_loader.load(spidername)
spidercls.start_requests = lambda s: conman.from_spider(s, result)
spidercls.start_requests = lambda s: conman.from_spider(s, result) # type: ignore[assignment,method-assign,return-value]
tested_methods = conman.tested_methods_from_spidercls(spidercls)
if opts.list:

View File

@ -1,29 +1,39 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from twisted.python.failure import Failure
from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError
if TYPE_CHECKING:
import argparse
class Command(BaseRunSpiderCommand):
requires_project = True
def syntax(self):
def syntax(self) -> str:
return "[options] <spider>"
def short_desc(self):
def short_desc(self) -> str:
return "Run a spider"
def run(self, args, opts):
def run(self, args: list[str], opts: argparse.Namespace) -> None:
if len(args) < 1:
raise UsageError()
elif len(args) > 1:
raise UsageError
if len(args) > 1:
raise UsageError(
"running 'scrapy crawl' with more than one spider is not supported"
)
spname = args[0]
assert self.crawler_process
crawl_defer = self.crawler_process.crawl(spname, **opts.spargs)
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
else:

View File

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

View File

@ -1,34 +1,39 @@
from __future__ import annotations
import sys
from argparse import Namespace
from typing import List, Type
from typing import TYPE_CHECKING
from w3lib.url import is_url
from scrapy import Spider
from scrapy.commands import ScrapyCommand
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.spider import DefaultSpider, spidercls_for_request
if TYPE_CHECKING:
from argparse import ArgumentParser, Namespace
from scrapy import Spider
class Command(ScrapyCommand):
requires_project = False
def syntax(self):
def syntax(self) -> str:
return "[options] <url>"
def short_desc(self):
def short_desc(self) -> str:
return "Fetch a URL using the Scrapy downloader"
def long_desc(self):
def long_desc(self) -> str:
return (
"Fetch a URL using the Scrapy downloader and print its content"
" to stdout. You may want to use --nolog to disable logging"
)
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument("--spider", dest="spider", help="use this spider")
parser.add_argument(
"--headers",
@ -44,25 +49,26 @@ class Command(ScrapyCommand):
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 value in values:
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:
assert response.request
self._print_headers(response.request.headers, b">")
print(">")
self._print_headers(response.headers, b"<")
else:
self._print_bytes(response.body)
def _print_bytes(self, bytes_):
def _print_bytes(self, bytes_: bytes) -> None:
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:
if len(args) != 1 or not is_url(args[0]):
raise UsageError()
raise UsageError
request = Request(
args[0],
callback=self._print_response,
@ -76,7 +82,7 @@ class Command(ScrapyCommand):
else:
request.meta["handle_httpstatus_all"] = True
spidercls: Type[Spider] = DefaultSpider
spidercls: type[Spider] = DefaultSpider
assert self.crawler_process
spider_loader = self.crawler_process.spider_loader
if opts.spider:

View File

@ -1,9 +1,11 @@
from __future__ import annotations
import os
import shutil
import string
from importlib import import_module
from pathlib import Path
from typing import Optional, cast
from typing import TYPE_CHECKING, Any, cast
from urllib.parse import urlparse
import scrapy
@ -11,8 +13,11 @@ from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError
from scrapy.utils.template import render_templatefile, string_camelcase
if TYPE_CHECKING:
import argparse
def sanitize_module_name(module_name):
def sanitize_module_name(module_name: str) -> str:
"""Sanitize the given module name, by replacing dashes and points
with underscores and prefixing it with a letter if it doesn't start
with one
@ -23,7 +28,7 @@ def sanitize_module_name(module_name):
return module_name
def extract_domain(url):
def extract_domain(url: str) -> str:
"""Extract domain name from URL string"""
o = urlparse(url)
if o.scheme == "" and o.netloc == "":
@ -31,7 +36,7 @@ def extract_domain(url):
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."""
parsed = urlparse(url)
if parsed.scheme == "" and parsed.netloc == "":
@ -43,14 +48,14 @@ class Command(ScrapyCommand):
requires_project = False
default_settings = {"LOG_ENABLED": False}
def syntax(self):
def syntax(self) -> str:
return "[options] <name> <domain>"
def short_desc(self):
def short_desc(self) -> str:
return "Generate new spider using pre-defined templates"
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"-l",
"--list",
@ -86,7 +91,7 @@ class Command(ScrapyCommand):
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:
self._list_templates()
return
@ -96,7 +101,7 @@ class Command(ScrapyCommand):
print(template_file.read_text(encoding="utf-8"))
return
if len(args) != 2:
raise UsageError()
raise UsageError
name, url = args[0:2]
url = verify_url_scheme(url)
@ -113,23 +118,39 @@ class Command(ScrapyCommand):
if template_file:
self._genspider(module, name, url, opts.template, template_file)
if opts.edit:
self.exitcode = os.system(f'scrapy edit "{name}"')
self.exitcode = os.system(f'scrapy edit "{name}"') # noqa: S605
def _genspider(self, module, name, url, template_name, template_file):
"""Generate the spider module, based on the given template"""
def _generate_template_variables(
self,
module: str,
name: str,
url: str,
template_name: str,
) -> dict[str, Any]:
capitalized_module = "".join(s.capitalize() for s in module.split("_"))
domain = extract_domain(url)
tvars = {
return {
"project_name": self.settings.get("BOT_NAME"),
"ProjectName": string_camelcase(self.settings.get("BOT_NAME")),
"module": module,
"name": name,
"url": url,
"domain": domain,
"domain": extract_domain(url),
"classname": f"{capitalized_module}Spider",
}
def _genspider(
self,
module: str,
name: str,
url: str,
template_name: str,
template_file: str | os.PathLike,
) -> None:
"""Generate the spider module, based on the given template"""
tvars = self._generate_template_variables(module, name, url, template_name)
if self.settings.get("NEWSPIDER_MODULE"):
spiders_module = import_module(self.settings["NEWSPIDER_MODULE"])
assert spiders_module.__file__
spiders_dir = Path(spiders_module.__file__).parent.resolve()
else:
spiders_module = None
@ -144,7 +165,7 @@ class Command(ScrapyCommand):
if spiders_module:
print(f"in module:\n {spiders_module.__name__}.{module}")
def _find_template(self, template: str) -> Optional[Path]:
def _find_template(self, template: str) -> Path | None:
template_file = Path(self.templates_dir, f"{template}.tmpl")
if template_file.exists():
return template_file
@ -152,7 +173,7 @@ class Command(ScrapyCommand):
print('Use "scrapy genspider --list" to see all available templates.')
return None
def _list_templates(self):
def _list_templates(self) -> None:
print("Available templates:")
for file in sorted(Path(self.templates_dir).iterdir()):
if file.suffix == ".tmpl":

View File

@ -1,13 +1,21 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from scrapy.commands import ScrapyCommand
if TYPE_CHECKING:
import argparse
class Command(ScrapyCommand):
requires_project = True
default_settings = {"LOG_ENABLED": False}
def short_desc(self):
def short_desc(self) -> str:
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()):
print(s)

View File

@ -1,15 +1,18 @@
from __future__ import annotations
import functools
import inspect
import json
import logging
from typing import Dict
from typing import TYPE_CHECKING, Any, TypeVar, overload
from itemadapter import ItemAdapter, is_item
from twisted.internet.defer import maybeDeferred
from twisted.internet.defer import Deferred, maybeDeferred
from w3lib.url import is_url
from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError
from scrapy.http import Request
from scrapy.http import Request, Response
from scrapy.utils import display
from scrapy.utils.asyncgen import collect_asyncgen
from scrapy.utils.defer import aiter_errback, deferred_from_coro
@ -17,26 +20,39 @@ from scrapy.utils.log import failure_to_exc_info
from scrapy.utils.misc import arg_to_iter
from scrapy.utils.spider import spidercls_for_request
if TYPE_CHECKING:
import argparse
from collections.abc import AsyncGenerator, Coroutine, Iterable
from twisted.python.failure import Failure
from scrapy.http.request import CallbackT
from scrapy.spiders import Spider
logger = logging.getLogger(__name__)
_T = TypeVar("_T")
class Command(BaseRunSpiderCommand):
requires_project = True
spider = None
items: Dict[int, list] = {}
requests: Dict[int, list] = {}
spider: Spider | None = None
items: dict[int, list[Any]] = {}
requests: dict[int, list[Request]] = {}
spidercls: type[Spider] | None
first_response = None
def syntax(self):
def syntax(self) -> str:
return "[options] <url>"
def short_desc(self):
def short_desc(self) -> str:
return "Parse URL (using its spider) and print the results"
def add_options(self, parser):
BaseRunSpiderCommand.add_options(self, parser)
def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"--spider",
dest="spider",
@ -105,7 +121,7 @@ class Command(BaseRunSpiderCommand):
)
@property
def max_level(self):
def max_level(self) -> int:
max_items, max_requests = 0, 0
if self.items:
max_items = max(self.items)
@ -113,13 +129,21 @@ class Command(BaseRunSpiderCommand):
max_requests = max(self.requests)
return max(max_items, max_requests)
def handle_exception(self, _failure):
def handle_exception(self, _failure: Failure) -> None:
logger.error(
"An error is caught while iterating the async iterable",
exc_info=failure_to_exc_info(_failure),
)
def iterate_spider_output(self, result):
@overload
def iterate_spider_output(
self, result: AsyncGenerator[_T] | Coroutine[Any, Any, _T]
) -> Deferred[_T]: ...
@overload
def iterate_spider_output(self, result: _T) -> Iterable[Any]: ...
def iterate_spider_output(self, result: Any) -> Iterable[Any] | Deferred[Any]:
if inspect.isasyncgen(result):
d = deferred_from_coro(
collect_asyncgen(aiter_errback(result, self.handle_exception))
@ -132,15 +156,15 @@ class Command(BaseRunSpiderCommand):
return d
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, [])
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, [])
self.requests[lvl] = old_reqs + new_reqs
def print_items(self, lvl=None, colour=True):
def print_items(self, lvl: int | None = None, colour: bool = True) -> None:
if lvl is None:
items = [item for lst in self.items.values() for item in lst]
else:
@ -149,7 +173,7 @@ class Command(BaseRunSpiderCommand):
print("# Scraped Items ", "-" * 60)
display.pprint([ItemAdapter(x).asdict() for x in items], colorize=colour)
def print_requests(self, lvl=None, colour=True):
def print_requests(self, lvl: int | None = None, colour: bool = True) -> None:
if lvl is None:
if self.requests:
requests = self.requests[max(self.requests)]
@ -161,7 +185,7 @@ class Command(BaseRunSpiderCommand):
print("# Requests ", "-" * 65)
display.pprint(requests, colorize=colour)
def print_results(self, opts):
def print_results(self, opts: argparse.Namespace) -> None:
colour = not opts.nocolour
if opts.verbose:
@ -178,7 +202,14 @@ class Command(BaseRunSpiderCommand):
if not opts.nolinks:
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: CallbackT,
) -> tuple[list[Any], list[Request], argparse.Namespace, int, Spider, CallbackT]:
items, requests = [], []
for x in spider_output:
if is_item(x):
@ -187,14 +218,22 @@ class Command(BaseRunSpiderCommand):
requests.append(x)
return items, requests, opts, depth, spider, callback
def run_callback(self, response, callback, cb_kwargs=None):
def run_callback(
self,
response: Response,
callback: CallbackT,
cb_kwargs: dict[str, Any] | None = None,
) -> Deferred[Any]:
cb_kwargs = cb_kwargs or {}
d = maybeDeferred(self.iterate_spider_output, callback(response, **cb_kwargs))
return d
return maybeDeferred(
self.iterate_spider_output, callback(response, **cb_kwargs)
)
def get_callback_from_rules(self, spider, response):
def get_callback_from_rules(
self, spider: Spider, response: Response
) -> CallbackT | str | 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):
return rule.callback or "parse"
else:
@ -203,8 +242,10 @@ class Command(BaseRunSpiderCommand):
"please specify a callback to use for parsing",
{"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
if opts.spider:
try:
@ -218,13 +259,15 @@ class Command(BaseRunSpiderCommand):
if not self.spidercls:
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)
if self.spidercls:
self.spidercls.start_requests = _start_requests
self.spidercls.start_requests = _start_requests # type: ignore[assignment,method-assign]
def start_parsing(self, url, opts):
def start_parsing(self, url: str, opts: argparse.Namespace) -> None:
assert self.crawler_process
assert self.spidercls
self.crawler_process.crawl(self.spidercls, **opts.spargs)
self.pcrawler = list(self.crawler_process.crawlers)[0]
self.crawler_process.start()
@ -232,7 +275,12 @@ class Command(BaseRunSpiderCommand):
if not self.first_response:
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, CallbackT
],
) -> list[Any]:
items, requests, opts, depth, spider, callback = args
if opts.pipelines:
itemproc = self.pcrawler.engine.scraper.itemproc
@ -251,42 +299,53 @@ class Command(BaseRunSpiderCommand):
return scraped_data
def prepare_request(self, spider, request, opts):
def callback(response, **cb_kwargs):
def _get_callback(
self,
*,
spider: Spider,
opts: argparse.Namespace,
response: Response | None = None,
) -> CallbackT:
cb: str | CallbackT | 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[list[Any]]:
# memorize first request
if not self.first_response:
self.first_response = response
# determine real callback
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
cb = self._get_callback(spider=spider, opts=opts, response=response)
# parse items and requests
depth = response.meta["_depth"]
depth: int = response.meta["_depth"]
d = self.run_callback(response, cb, cb_kwargs)
d.addCallback(self._get_items_and_requests, opts, depth, spider, callback)
@ -303,16 +362,19 @@ class Command(BaseRunSpiderCommand):
request.meta["_depth"] = 1
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
return request
def process_options(self, args, opts):
BaseRunSpiderCommand.process_options(self, args, opts)
def process_options(self, args: list[str], opts: argparse.Namespace) -> None:
super().process_options(args, opts)
self.process_request_meta(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:
try:
opts.meta = json.loads(opts.meta)
@ -323,7 +385,7 @@ class Command(BaseRunSpiderCommand):
print_help=False,
)
def process_request_cb_kwargs(self, opts):
def process_request_cb_kwargs(self, opts: argparse.Namespace) -> None:
if opts.cbkwargs:
try:
opts.cbkwargs = json.loads(opts.cbkwargs)
@ -334,12 +396,11 @@ class Command(BaseRunSpiderCommand):
print_help=False,
)
def run(self, args, opts):
def run(self, args: list[str], opts: argparse.Namespace) -> None:
# parse arguments
if not len(args) == 1 or not is_url(args[0]):
raise UsageError()
else:
url = args[0]
raise UsageError
url = args[0]
# prepare spidercls
self.set_spidercls(url, opts)

View File

@ -1,16 +1,21 @@
from __future__ import annotations
import sys
from importlib import import_module
from os import PathLike
from pathlib import Path
from types import ModuleType
from typing import Union
from typing import TYPE_CHECKING
from scrapy.commands import BaseRunSpiderCommand
from scrapy.exceptions import UsageError
from scrapy.utils.spider import iter_spider_classes
if TYPE_CHECKING:
import argparse
from os import PathLike
from types import ModuleType
def _import_file(filepath: Union[str, PathLike]) -> ModuleType:
def _import_file(filepath: str | PathLike[str]) -> ModuleType:
abspath = Path(filepath).resolve()
if abspath.suffix not in (".py", ".pyw"):
raise ValueError(f"Not a Python source file: {abspath}")
@ -27,18 +32,18 @@ class Command(BaseRunSpiderCommand):
requires_project = False
default_settings = {"SPIDER_LOADER_WARN_ONLY": True}
def syntax(self):
def syntax(self) -> str:
return "[options] <spider_file>"
def short_desc(self):
def short_desc(self) -> str:
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"
def run(self, args, opts):
def run(self, args: list[str], opts: argparse.Namespace) -> None:
if len(args) != 1:
raise UsageError()
raise UsageError
filename = Path(args[0])
if not filename.exists():
raise UsageError(f"File not found: {filename}\n")
@ -51,6 +56,7 @@ class Command(BaseRunSpiderCommand):
raise UsageError(f"No spider found in file: {filename}\n")
spidercls = spclasses.pop()
assert self.crawler_process
self.crawler_process.crawl(spidercls, **opts.spargs)
self.crawler_process.start()

View File

@ -1,3 +1,4 @@
import argparse
import json
from scrapy.commands import ScrapyCommand
@ -8,14 +9,14 @@ class Command(ScrapyCommand):
requires_project = False
default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True}
def syntax(self):
def syntax(self) -> str:
return "[options]"
def short_desc(self):
def short_desc(self) -> str:
return "Get settings values"
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"--get", dest="get", metavar="SETTING", help="print raw setting value"
)
@ -44,7 +45,8 @@ class Command(ScrapyCommand):
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
if opts.get:
s = settings.get(opts.get)

View File

@ -3,17 +3,23 @@ Scrapy Shell
See documentation in docs/topics/shell.rst
"""
from argparse import Namespace
from threading import Thread
from typing import List, Type
from scrapy import Spider
from __future__ import annotations
from threading import Thread
from typing import TYPE_CHECKING, Any
from scrapy.commands import ScrapyCommand
from scrapy.http import Request
from scrapy.shell import Shell
from scrapy.utils.spider import DefaultSpider, spidercls_for_request
from scrapy.utils.url import guess_scheme
if TYPE_CHECKING:
from argparse import ArgumentParser, Namespace
from scrapy import Spider
class Command(ScrapyCommand):
requires_project = False
@ -23,20 +29,20 @@ class Command(ScrapyCommand):
"DUPEFILTER_CLASS": "scrapy.dupefilters.BaseDupeFilter",
}
def syntax(self):
def syntax(self) -> str:
return "[url|file]"
def short_desc(self):
def short_desc(self) -> str:
return "Interactive scraping console"
def long_desc(self):
def long_desc(self) -> str:
return (
"Interactive console for scraping the given url or file. "
"Use ./file.html syntax or full path for local file."
)
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"-c",
dest="code",
@ -51,13 +57,12 @@ class Command(ScrapyCommand):
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
available in the shell
"""
pass
def run(self, args: List[str], opts: Namespace) -> None:
def run(self, args: list[str], opts: Namespace) -> None:
url = args[0] if args else None
if url:
# first argument may be a local file
@ -66,7 +71,7 @@ class Command(ScrapyCommand):
assert self.crawler_process
spider_loader = self.crawler_process.spider_loader
spidercls: Type[Spider] = DefaultSpider
spidercls: type[Spider] = DefaultSpider
if opts.spider:
spidercls = spider_loader.load(opts.spider)
elif url:
@ -87,7 +92,8 @@ class Command(ScrapyCommand):
shell = Shell(crawler, update_vars=self.update_vars, code=opts.code)
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(
target=self.crawler_process.start,
kwargs={"stop_after_crawl": False, "install_signal_handlers": False},

View File

@ -1,3 +1,5 @@
from __future__ import annotations
import os
import re
import string
@ -5,13 +7,17 @@ from importlib.util import find_spec
from pathlib import Path
from shutil import copy2, copystat, ignore_patterns, move
from stat import S_IWUSR as OWNER_WRITE_PERMISSION
from typing import TYPE_CHECKING
import scrapy
from scrapy.commands import ScrapyCommand
from scrapy.exceptions import UsageError
from scrapy.utils.template import render_templatefile, string_camelcase
TEMPLATES_TO_RENDER = (
if TYPE_CHECKING:
import argparse
TEMPLATES_TO_RENDER: tuple[tuple[str, ...], ...] = (
("scrapy.cfg",),
("${project_name}", "settings.py.tmpl"),
("${project_name}", "items.py.tmpl"),
@ -22,7 +28,7 @@ TEMPLATES_TO_RENDER = (
IGNORE = ignore_patterns("*.pyc", "__pycache__", ".svn")
def _make_writable(path):
def _make_writable(path: str | os.PathLike) -> None:
current_permissions = os.stat(path).st_mode
os.chmod(path, current_permissions | OWNER_WRITE_PERMISSION)
@ -31,14 +37,14 @@ class Command(ScrapyCommand):
requires_project = False
default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True}
def syntax(self):
def syntax(self) -> str:
return "<project_name> [project_dir]"
def short_desc(self):
def short_desc(self) -> str:
return "Create new project"
def _is_valid_name(self, project_name):
def _module_exists(module_name):
def _is_valid_name(self, project_name: str) -> bool:
def _module_exists(module_name: str) -> bool:
spec = find_spec(module_name)
return spec is not None and spec.loader is not None
@ -53,7 +59,7 @@ class Command(ScrapyCommand):
return True
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
the issue a new function had to be created. It's a simple copy and
@ -84,9 +90,9 @@ class Command(ScrapyCommand):
copystat(src, 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):
raise UsageError()
raise UsageError
project_name = args[0]

View File

@ -1,19 +1,21 @@
import argparse
import scrapy
from scrapy.commands import ScrapyCommand
from scrapy.utils.versions import scrapy_components_versions
from scrapy.utils.versions import get_versions
class Command(ScrapyCommand):
default_settings = {"LOG_ENABLED": False, "SPIDER_LOADER_WARN_ONLY": True}
def syntax(self):
def syntax(self) -> str:
return "[-v]"
def short_desc(self):
def short_desc(self) -> str:
return "Print Scrapy version"
def add_options(self, parser):
ScrapyCommand.add_options(self, parser)
def add_options(self, parser: argparse.ArgumentParser) -> None:
super().add_options(parser)
parser.add_argument(
"--verbose",
"-v",
@ -22,9 +24,9 @@ class Command(ScrapyCommand):
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:
versions = scrapy_components_versions()
versions = get_versions()
width = max(len(n) for (n, _) in versions)
for name, version in versions:
print(f"{name:<{width}} : {version}")

View File

@ -1,21 +1,28 @@
import argparse
import logging
from scrapy.commands import fetch
from scrapy.http import Response, TextResponse
from scrapy.utils.response import open_in_browser
logger = logging.getLogger(__name__)
class Command(fetch.Command):
def short_desc(self):
def short_desc(self) -> str:
return "Open URL in browser, as seen by Scrapy"
def long_desc(self):
def long_desc(self) -> str:
return (
"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)
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)

View File

@ -1,32 +1,44 @@
from __future__ import annotations
import re
import sys
from collections.abc import AsyncGenerator, Iterable
from functools import wraps
from inspect import getmembers
from types import CoroutineType
from typing import AsyncGenerator, Dict, Optional, Type
from unittest import TestCase
from typing import TYPE_CHECKING, Any, cast
from unittest import TestCase, TestResult
from scrapy.http import Request
from scrapy.http import Request, Response
from scrapy.utils.python import get_spec
from scrapy.utils.spider import iterate_spider_output
if TYPE_CHECKING:
from collections.abc import Callable
from twisted.python.failure import Failure
from scrapy import Spider
class Contract:
"""Abstract class for contracts"""
request_cls: Optional[Type[Request]] = None
request_cls: type[Request] | None = None
name: str
def __init__(self, method, *args):
def __init__(self, method: Callable, *args: Any):
self.testcase_pre = _create_testcase(method, f"@{self.name} pre-hook")
self.testcase_post = _create_testcase(method, f"@{self.name} post-hook")
self.args = args
self.args: tuple[Any, ...] = args
def add_pre_hook(self, request, results):
def add_pre_hook(self, request: Request, results: TestResult) -> Request:
if hasattr(self, "pre_process"):
cb = request.callback
assert cb is not None
@wraps(cb)
def wrapper(response, **cb_kwargs):
def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]:
try:
results.startTest(self.testcase_pre)
self.pre_process(response)
@ -37,28 +49,26 @@ class Contract:
results.addError(self.testcase_pre, sys.exc_info())
else:
results.addSuccess(self.testcase_pre)
finally:
cb_result = cb(response, **cb_kwargs)
if isinstance(cb_result, (AsyncGenerator, CoroutineType)):
raise TypeError("Contracts don't support async callbacks")
return list( # pylint: disable=return-in-finally
iterate_spider_output(cb_result)
)
cb_result = cb(response, **cb_kwargs)
if isinstance(cb_result, (AsyncGenerator, CoroutineType)):
raise TypeError("Contracts don't support async callbacks")
return list(cast(Iterable[Any], iterate_spider_output(cb_result)))
request.callback = wrapper
return request
def add_post_hook(self, request, results):
def add_post_hook(self, request: Request, results: TestResult) -> Request:
if hasattr(self, "post_process"):
cb = request.callback
assert cb is not None
@wraps(cb)
def wrapper(response, **cb_kwargs):
def wrapper(response: Response, **cb_kwargs: Any) -> list[Any]:
cb_result = cb(response, **cb_kwargs)
if isinstance(cb_result, (AsyncGenerator, CoroutineType)):
raise TypeError("Contracts don't support async callbacks")
output = list(iterate_spider_output(cb_result))
output = list(cast(Iterable[Any], iterate_spider_output(cb_result)))
try:
results.startTest(self.testcase_post)
self.post_process(output)
@ -69,25 +79,24 @@ class Contract:
results.addError(self.testcase_post, sys.exc_info())
else:
results.addSuccess(self.testcase_post)
finally:
return output # pylint: disable=return-in-finally
return output
request.callback = wrapper
return request
def adjust_request_args(self, args):
def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]:
return args
class ContractsManager:
contracts: Dict[str, Contract] = {}
contracts: dict[str, type[Contract]] = {}
def __init__(self, contracts):
def __init__(self, contracts: Iterable[type[Contract]]):
for contract in contracts:
self.contracts[contract.name] = contract
def tested_methods_from_spidercls(self, spidercls):
def tested_methods_from_spidercls(self, spidercls: type[Spider]) -> list[str]:
is_method = re.compile(r"^\s*@", re.MULTILINE).search
methods = []
for key, value in getmembers(spidercls):
@ -96,21 +105,25 @@ class ContractsManager:
return methods
def extract_contracts(self, method):
contracts = []
def extract_contracts(self, method: Callable) -> list[Contract]:
contracts: list[Contract] = []
assert method.__doc__ is not None
for line in method.__doc__.split("\n"):
line = line.strip()
if line.startswith("@"):
name, args = re.match(r"@(\w+)\s*(.*)", line).groups()
m = re.match(r"@(\w+)\s*(.*)", line)
if m is None:
continue
name, args = m.groups()
args = re.split(r"\s+", args)
contracts.append(self.contracts[name](method, *args))
return contracts
def from_spider(self, spider, results):
requests = []
def from_spider(self, spider: Spider, results: TestResult) -> list[Request | None]:
requests: list[Request | None] = []
for method in self.tested_methods_from_spidercls(type(spider)):
bound_method = spider.__getattribute__(method)
try:
@ -121,7 +134,7 @@ class ContractsManager:
return requests
def from_method(self, method, results):
def from_method(self, method: Callable, results: TestResult) -> Request | None:
contracts = self.extract_contracts(method)
if contracts:
request_cls = Request
@ -154,22 +167,26 @@ class ContractsManager:
self._clean_req(request, method, results)
return request
return None
def _clean_req(self, request, method, results):
def _clean_req(
self, request: Request, method: Callable, results: TestResult
) -> None:
"""stop the request from returning objects and records any errors"""
cb = request.callback
assert cb is not None
@wraps(cb)
def cb_wrapper(response, **cb_kwargs):
def cb_wrapper(response: Response, **cb_kwargs: Any) -> None:
try:
output = cb(response, **cb_kwargs)
output = list(iterate_spider_output(output))
output = list(cast(Iterable[Any], iterate_spider_output(output)))
except Exception:
case = _create_testcase(method, "callback")
results.addError(case, sys.exc_info())
def eb_wrapper(failure):
def eb_wrapper(failure: Failure) -> None:
case = _create_testcase(method, "errback")
exc_info = failure.type, failure.value, failure.getTracebackObject()
results.addError(case, exc_info)
@ -178,11 +195,11 @@ class ContractsManager:
request.errback = eb_wrapper
def _create_testcase(method, desc):
spider = method.__self__.name
def _create_testcase(method: Callable, desc: str) -> TestCase:
spider = method.__self__.name # type: ignore[attr-defined]
class ContractTestCase(TestCase):
def __str__(_self):
def __str__(_self) -> str:
return f"[{spider}] {method.__name__} ({desc})"
name = f"{spider}_{method.__name__}"

View File

@ -1,4 +1,7 @@
from __future__ import annotations
import json
from typing import Any, Callable
from itemadapter import ItemAdapter, is_item
@ -15,7 +18,7 @@ class UrlContract(Contract):
name = "url"
def adjust_request_args(self, args):
def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]:
args["url"] = self.args[0]
return args
@ -29,11 +32,25 @@ class CallbackKeywordArgumentsContract(Contract):
name = "cb_kwargs"
def adjust_request_args(self, args):
def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]:
args["cb_kwargs"] = json.loads(" ".join(self.args))
return args
class MetadataContract(Contract):
"""Contract to set metadata arguments for the request.
The value should be JSON-encoded dictionary, e.g.:
@meta {"arg1": "some value"}
"""
name = "meta"
def adjust_request_args(self, args: dict[str, Any]) -> dict[str, Any]:
args["meta"] = json.loads(" ".join(self.args))
return args
class ReturnsContract(Contract):
"""Contract to check the output of a callback
@ -48,14 +65,14 @@ class ReturnsContract(Contract):
"""
name = "returns"
object_type_verifiers = {
object_type_verifiers: dict[str | None, Callable[[Any], bool]] = {
"request": lambda x: isinstance(x, Request),
"requests": lambda x: isinstance(x, Request),
"item": is_item,
"items": is_item,
}
def __init__(self, *args, **kwargs):
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
if len(self.args) not in [1, 2, 3]:
@ -66,16 +83,16 @@ class ReturnsContract(Contract):
self.obj_type_verifier = self.object_type_verifiers[self.obj_name]
try:
self.min_bound = int(self.args[1])
self.min_bound: float = int(self.args[1])
except IndexError:
self.min_bound = 1
try:
self.max_bound = int(self.args[2])
self.max_bound: float = int(self.args[2])
except IndexError:
self.max_bound = float("inf")
def post_process(self, output):
def post_process(self, output: list[Any]) -> None:
occurrences = 0
for x in output:
if self.obj_type_verifier(x):
@ -85,7 +102,7 @@ class ReturnsContract(Contract):
if not assertion:
if self.min_bound == self.max_bound:
expected = self.min_bound
expected = str(self.min_bound)
else:
expected = f"{self.min_bound}..{self.max_bound}"
@ -101,7 +118,7 @@ class ScrapesContract(Contract):
name = "scrapes"
def post_process(self, output):
def post_process(self, output: list[Any]) -> None:
for x in output:
if is_item(x):
missing = [arg for arg in self.args if arg not in ItemAdapter(x)]

View File

@ -1,8 +1,11 @@
from __future__ import annotations
import random
import warnings
from collections import deque
from datetime import datetime
from time import time
from typing import TYPE_CHECKING, Any, Deque, Dict, Set, Tuple, cast
from typing import TYPE_CHECKING, Any, TypeVar, cast
from twisted.internet import task
from twisted.internet.defer import Deferred
@ -10,28 +13,37 @@ from twisted.internet.defer import Deferred
from scrapy import Request, Spider, signals
from scrapy.core.downloader.handlers import DownloadHandlers
from scrapy.core.downloader.middleware import DownloaderMiddlewareManager
from scrapy.http import Response
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.resolver import dnscache
from scrapy.settings import BaseSettings
from scrapy.signalmanager import SignalManager
from scrapy.utils.defer import mustbe_deferred
from scrapy.utils.httpobj import urlparse_cached
if TYPE_CHECKING:
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.settings import BaseSettings
from scrapy.signalmanager import SignalManager
_T = TypeVar("_T")
class Slot:
"""Downloader slot"""
def __init__(self, concurrency: int, delay: float, randomize_delay: bool):
def __init__(
self,
concurrency: int,
delay: float,
randomize_delay: bool,
):
self.concurrency: int = concurrency
self.delay: float = delay
self.randomize_delay: bool = randomize_delay
self.active: Set[Request] = set()
self.queue: Deque[Tuple[Request, Deferred]] = deque()
self.transferring: Set[Request] = set()
self.active: set[Request] = set()
self.queue: deque[tuple[Request, Deferred[Response]]] = deque()
self.transferring: set[Request] = set()
self.lastseen: float = 0
self.latercall = None
@ -40,7 +52,7 @@ class Slot:
def download_delay(self) -> float:
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) # noqa: S311
return self.delay
def close(self) -> None:
@ -67,7 +79,7 @@ class Slot:
def _get_concurrency_delay(
concurrency: int, spider: Spider, settings: BaseSettings
) -> Tuple[int, float]:
) -> tuple[int, float]:
delay: float = settings.getfloat("DOWNLOAD_DELAY")
if hasattr(spider, "download_delay"):
delay = spider.download_delay
@ -81,11 +93,11 @@ def _get_concurrency_delay(
class Downloader:
DOWNLOAD_SLOT = "download_slot"
def __init__(self, crawler: "Crawler"):
def __init__(self, crawler: Crawler):
self.settings: BaseSettings = crawler.settings
self.signals: SignalManager = crawler.signals
self.slots: Dict[str, Slot] = {}
self.active: Set[Request] = set()
self.slots: dict[str, Slot] = {}
self.active: set[Request] = set()
self.handlers: DownloadHandlers = DownloadHandlers(crawler)
self.total_concurrency: int = self.settings.getint("CONCURRENT_REQUESTS")
self.domain_concurrency: int = self.settings.getint(
@ -98,24 +110,26 @@ class Downloader:
)
self._slot_gc_loop: task.LoopingCall = task.LoopingCall(self._slot_gc)
self._slot_gc_loop.start(60)
self.per_slot_settings: Dict[str, Dict[str, Any]] = self.settings.getdict(
self.per_slot_settings: dict[str, dict[str, Any]] = self.settings.getdict(
"DOWNLOAD_SLOTS", {}
)
def fetch(self, request: Request, spider: Spider) -> Deferred:
def _deactivate(response: Response) -> Response:
def fetch(self, request: Request, spider: Spider) -> Deferred[Response | Request]:
def _deactivate(response: _T) -> _T:
self.active.remove(request)
return response
self.active.add(request)
dfd = self.middleware.download(self._enqueue_request, request, spider)
dfd: Deferred[Response | Request] = self.middleware.download(
self._enqueue_request, request, spider
)
return dfd.addBoth(_deactivate)
def needs_backout(self) -> bool:
return len(self.active) >= self.total_concurrency
def _get_slot(self, request: Request, spider: Spider) -> Tuple[str, Slot]:
key = self._get_slot_key(request, spider)
def _get_slot(self, request: Request, spider: Spider) -> tuple[str, Slot]:
key = self.get_slot_key(request)
if key not in self.slots:
slot_settings = self.per_slot_settings.get(key, {})
conc = (
@ -132,7 +146,7 @@ class Downloader:
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:
return cast(str, request.meta[self.DOWNLOAD_SLOT])
@ -142,7 +156,15 @@ class Downloader:
return key
def _enqueue_request(self, request: Request, spider: Spider) -> Deferred:
def _get_slot_key(self, request: Request, spider: Spider | None) -> 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[Response]:
key, slot = self._get_slot(request, spider)
request.meta[self.DOWNLOAD_SLOT] = key
@ -154,7 +176,7 @@ class Downloader:
self.signals.send_catch_log(
signal=signals.request_reached_downloader, request=request, spider=spider
)
deferred: Deferred = Deferred().addBoth(_deactivate)
deferred: Deferred[Response] = Deferred().addBoth(_deactivate)
slot.queue.append((request, deferred))
self._process_queue(spider, slot)
return deferred
@ -187,11 +209,15 @@ class Downloader:
self._process_queue(spider, slot)
break
def _download(self, slot: Slot, request: Request, spider: Spider) -> Deferred:
def _download(
self, slot: Slot, request: Request, spider: Spider
) -> Deferred[Response]:
# The order is very important for the following deferreds. Do not change!
# 1. Create the download deferred
dfd = mustbe_deferred(self.handlers.download_request, request, spider)
dfd: Deferred[Response] = mustbe_deferred(
self.handlers.download_request, request, spider
)
# 2. Notify response_downloaded listeners about the recent download
# before querying queue for next request
@ -212,7 +238,7 @@ class Downloader:
# middleware itself)
slot.transferring.add(request)
def finish_transferring(_: Any) -> Any:
def finish_transferring(_: _T) -> _T:
slot.transferring.remove(request)
self._process_queue(spider, slot)
self.signals.send_catch_log(

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any, List, Optional
from typing import TYPE_CHECKING, Any
from OpenSSL import SSL
from twisted.internet._sslverify import _setAcceptableProtocols
@ -19,12 +21,18 @@ from scrapy.core.downloader.tls import (
ScrapyClientTLSOptions,
openssl_methods,
)
from scrapy.settings import BaseSettings
from scrapy.utils.misc import create_instance, load_object
from scrapy.exceptions import ScrapyDeprecationWarning
from scrapy.utils.misc import build_from_crawler, load_object
if TYPE_CHECKING:
from twisted.internet._sslverify import ClientTLSOptions
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
@implementer(IPolicyForHTTPS)
class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
@ -42,7 +50,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
self,
method: int = SSL.SSLv23_METHOD,
tls_verbose_logging: bool = False,
tls_ciphers: Optional[str] = None,
tls_ciphers: str | None = None,
*args: Any,
**kwargs: Any,
):
@ -62,11 +70,36 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
method: int = SSL.SSLv23_METHOD,
*args: Any,
**kwargs: Any,
):
) -> Self:
warnings.warn(
f"{cls.__name__}.from_settings() is deprecated, use from_crawler() instead.",
category=ScrapyDeprecationWarning,
stacklevel=2,
)
return cls._from_settings(settings, method, *args, **kwargs)
@classmethod
def from_crawler(
cls,
crawler: Crawler,
method: int = SSL.SSLv23_METHOD,
*args: Any,
**kwargs: Any,
) -> Self:
return cls._from_settings(crawler.settings, method, *args, **kwargs)
@classmethod
def _from_settings(
cls,
settings: BaseSettings,
method: int = SSL.SSLv23_METHOD,
*args: Any,
**kwargs: Any,
) -> Self:
tls_verbose_logging: bool = settings.getbool(
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
)
tls_ciphers: Optional[str] = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"]
tls_ciphers: str | None = settings["DOWNLOADER_CLIENT_TLS_CIPHERS"]
return cls( # type: ignore[misc]
method=method,
tls_verbose_logging=tls_verbose_logging,
@ -78,18 +111,9 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
def getCertificateOptions(self) -> CertificateOptions:
# setting verify=True will require you to provide CAs
# to verify against; in other words: it's not that simple
# backward-compatible SSL/TLS method:
#
# * this will respect `method` attribute in often recommended
# `ScrapyClientContextFactory` subclass
# (https://github.com/scrapy/scrapy/issues/1429#issuecomment-131782133)
#
# * getattr() for `_ssl_method` attribute for context factories
# not calling super().__init__
return CertificateOptions(
verify=False,
method=getattr(self, "method", getattr(self, "_ssl_method", None)),
method=self._ssl_method,
fixBrokenPeers=True,
acceptableCiphers=self.tls_ciphers,
)
@ -97,11 +121,11 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
# kept for old-style HTTP/1.0 downloader context twisted calls,
# e.g. connectSSL()
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
return ctx
def creatorForNetloc(self, hostname: bytes, port: int) -> "ClientTLSOptions":
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
return ScrapyClientTLSOptions(
hostname.decode("ascii"),
self.getContext(),
@ -128,7 +152,7 @@ class BrowserLikeContextFactory(ScrapyClientContextFactory):
``SSLv23_METHOD``) which allows TLS protocol negotiation.
"""
def creatorForNetloc(self, hostname: bytes, port: int) -> "ClientTLSOptions":
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
# trustRoot set to platformTrust() will use the platform's root CAs.
#
# This means that a website like https://www.cacert.org will be rejected
@ -147,36 +171,36 @@ class AcceptableProtocolsContextFactory:
negotiation.
"""
def __init__(self, context_factory: Any, acceptable_protocols: List[bytes]):
def __init__(self, context_factory: Any, acceptable_protocols: list[bytes]):
verifyObject(IPolicyForHTTPS, context_factory)
self._wrapped_context_factory: Any = context_factory
self._acceptable_protocols: List[bytes] = acceptable_protocols
self._acceptable_protocols: list[bytes] = acceptable_protocols
def creatorForNetloc(self, hostname: bytes, port: int) -> "ClientTLSOptions":
options: "ClientTLSOptions" = self._wrapped_context_factory.creatorForNetloc(
def creatorForNetloc(self, hostname: bytes, port: int) -> ClientTLSOptions:
options: ClientTLSOptions = self._wrapped_context_factory.creatorForNetloc(
hostname, port
)
_setAcceptableProtocols(options._ctx, self._acceptable_protocols)
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")]
context_factory_cls = load_object(settings["DOWNLOADER_CLIENTCONTEXTFACTORY"])
# try method-aware context factory
try:
context_factory = create_instance(
objcls=context_factory_cls,
settings=settings,
crawler=crawler,
context_factory = build_from_crawler(
context_factory_cls,
crawler,
method=ssl_method,
)
except TypeError:
# use context factory defaults
context_factory = create_instance(
objcls=context_factory_cls,
settings=settings,
crawler=crawler,
context_factory = build_from_crawler(
context_factory_cls,
crawler,
)
msg = (
f"{settings['DOWNLOADER_CLIENTCONTEXTFACTORY']} does not accept "

View File

@ -1,33 +1,51 @@
"""Download handlers for different schemes"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, Union, cast
from typing import TYPE_CHECKING, Any, Protocol, cast
from twisted.internet import defer
from twisted.internet.defer import Deferred
from scrapy import Request, Spider, signals
from scrapy.exceptions import NotConfigured, NotSupported
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
if TYPE_CHECKING:
from collections.abc import Callable, Generator
from twisted.internet.defer import Deferred
from scrapy.crawler import Crawler
from scrapy.http import Response
logger = logging.getLogger(__name__)
class DownloadHandlerProtocol(Protocol):
def download_request(
self, request: Request, spider: Spider
) -> Deferred[Response]: ...
class DownloadHandlers:
def __init__(self, crawler: "Crawler"):
self._crawler: "Crawler" = crawler
self._schemes: Dict[
str, Union[str, Callable]
] = {} # stores acceptable schemes on instancing
self._handlers: Dict[str, Any] = {} # stores instanced handlers for schemes
self._notconfigured: Dict[str, str] = {} # remembers failed handlers
handlers: Dict[str, Union[str, Callable]] = without_none_values(
crawler.settings.getwithbase("DOWNLOAD_HANDLERS")
def __init__(self, crawler: Crawler):
self._crawler: Crawler = crawler
self._schemes: dict[str, str | Callable[..., Any]] = (
{}
) # stores acceptable schemes on instancing
self._handlers: dict[str, DownloadHandlerProtocol] = (
{}
) # stores instanced handlers for schemes
self._notconfigured: dict[str, str] = {} # remembers failed handlers
handlers: dict[str, str | Callable[..., Any]] = without_none_values(
cast(
"dict[str, str | Callable[..., Any]]",
crawler.settings.getwithbase("DOWNLOAD_HANDLERS"),
)
)
for scheme, clspath in handlers.items():
self._schemes[scheme] = clspath
@ -35,7 +53,7 @@ class DownloadHandlers:
crawler.signals.connect(self._close, signals.engine_stopped)
def _get_handler(self, scheme: str) -> Any:
def _get_handler(self, scheme: str) -> DownloadHandlerProtocol | None:
"""Lazy-load the downloadhandler for a scheme
only on the first request for that scheme.
"""
@ -49,16 +67,17 @@ class DownloadHandlers:
return self._load_handler(scheme)
def _load_handler(self, scheme: str, skip_lazy: bool = False) -> Any:
def _load_handler(
self, scheme: str, skip_lazy: bool = False
) -> DownloadHandlerProtocol | None:
path = self._schemes[scheme]
try:
dhcls = load_object(path)
dhcls: type[DownloadHandlerProtocol] = load_object(path)
if skip_lazy and getattr(dhcls, "lazy", True):
return None
dh = create_instance(
objcls=dhcls,
settings=self._crawler.settings,
crawler=self._crawler,
dh = build_from_crawler(
dhcls,
self._crawler,
)
except NotConfigured as ex:
self._notconfigured[scheme] = str(ex)
@ -72,21 +91,20 @@ class DownloadHandlers:
)
self._notconfigured[scheme] = str(ex)
return None
else:
self._handlers[scheme] = dh
return dh
self._handlers[scheme] = dh
return dh
def download_request(self, request: Request, spider: Spider) -> Deferred:
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
scheme = urlparse_cached(request).scheme
handler = self._get_handler(scheme)
if not handler:
raise NotSupported(
f"Unsupported URL scheme '{scheme}': {self._notconfigured[scheme]}"
)
return cast(Deferred, handler.download_request(request, spider))
return handler.download_request(request, spider)
@defer.inlineCallbacks
def _close(self, *_a: Any, **_kw: Any) -> Generator[Deferred, Any, None]:
def _close(self, *_a: Any, **_kw: Any) -> Generator[Deferred[Any], Any, None]:
for dh in self._handlers.values():
if hasattr(dh, "close"):
yield dh.close()

View File

@ -1,12 +1,16 @@
from typing import Any, Dict
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from w3lib.url import parse_data_uri
from scrapy import Request, Spider
from scrapy.http import Response, TextResponse
from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
if TYPE_CHECKING:
from scrapy import Request, Spider
class DataURIDownloadHandler:
lazy = False
@ -16,7 +20,7 @@ class DataURIDownloadHandler:
uri = parse_data_uri(request.url)
respcls = responsetypes.from_mimetype(uri.media_type)
resp_kwargs: Dict[str, Any] = {}
resp_kwargs: dict[str, Any] = {}
if issubclass(respcls, TextResponse) and uri.media_type.split("/")[0] == "text":
charset = uri.media_type_parameters.get("charset")
resp_kwargs["encoding"] = charset

View File

@ -1,16 +1,23 @@
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from w3lib.url import file_uri_to_path
from scrapy.responsetypes import responsetypes
from scrapy.utils.decorators import defers
if TYPE_CHECKING:
from scrapy import Request, Spider
from scrapy.http import Response
class FileDownloadHandler:
lazy = False
@defers
def download_request(self, request, spider):
def download_request(self, request: Request, spider: Spider) -> Response:
filepath = file_uri_to_path(request.url)
body = Path(filepath).read_bytes()
respcls = responsetypes.from_args(filename=filepath, body=body)

View File

@ -28,8 +28,11 @@ In case of status 200 request, response.headers will come with two keys:
'Size' - with size of the downloaded data
"""
from __future__ import annotations
import re
from io import BytesIO
from typing import TYPE_CHECKING, Any, BinaryIO
from urllib.parse import unquote
from twisted.internet.protocol import ClientCreator, Protocol
@ -40,23 +43,37 @@ from scrapy.responsetypes import responsetypes
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes
if TYPE_CHECKING:
from twisted.internet.defer import Deferred
from twisted.python.failure import Failure
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy.crawler import Crawler
from scrapy.settings import BaseSettings
class ReceivedDataProtocol(Protocol):
def __init__(self, filename=None):
self.__filename = filename
self.body = open(filename, "wb") if filename else BytesIO()
self.size = 0
def __init__(self, filename: str | None = None):
self.__filename: str | None = filename
self.body: BinaryIO = open(filename, "wb") if filename else BytesIO()
self.size: int = 0
def dataReceived(self, data):
def dataReceived(self, data: bytes) -> None:
self.body.write(data)
self.size += len(data)
@property
def filename(self):
def filename(self) -> str | None:
return self.__filename
def close(self):
self.body.close() if self.filename else self.body.seek(0)
def close(self) -> None:
if self.filename:
self.body.close()
else:
self.body.seek(0)
_CODE_RE = re.compile(r"\d+")
@ -65,21 +82,21 @@ _CODE_RE = re.compile(r"\d+")
class FTPDownloadHandler:
lazy = False
CODE_MAPPING = {
CODE_MAPPING: dict[str, int] = {
"550": 404,
"default": 503,
}
def __init__(self, settings):
def __init__(self, settings: BaseSettings):
self.default_user = settings["FTP_USER"]
self.default_password = settings["FTP_PASSWORD"]
self.passive_mode = settings["FTP_PASSIVE_MODE"]
@classmethod
def from_crawler(cls, crawler):
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings)
def download_request(self, request, spider):
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
from twisted.internet import reactor
parsed_url = urlparse_cached(request)
@ -91,28 +108,33 @@ class FTPDownloadHandler:
creator = ClientCreator(
reactor, FTPClient, user, password, passive=passive_mode
)
dfd = creator.connectTCP(parsed_url.hostname, parsed_url.port or 21)
dfd: Deferred[FTPClient] = creator.connectTCP(
parsed_url.hostname, parsed_url.port or 21
)
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[Response]:
self.client = client
protocol = ReceivedDataProtocol(request.meta.get("ftp_local_filename"))
return client.retrieveFile(filepath, protocol).addCallbacks(
callback=self._build_response,
callbackArgs=(request, protocol),
errback=self._failed,
errbackArgs=(request,),
)
d = client.retrieveFile(filepath, protocol)
d.addCallback(self._build_response, request, protocol)
d.addErrback(self._failed, request)
return d
def _build_response(self, result, request, protocol):
def _build_response(
self, result: Any, request: Request, protocol: ReceivedDataProtocol
) -> Response:
self.result = result
protocol.close()
headers = {"local filename": protocol.filename or "", "size": protocol.size}
body = to_bytes(protocol.filename or protocol.body.read())
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()
if result.type == CommandFailed:
m = _CODE_RE.search(message)
@ -122,4 +144,5 @@ class FTPDownloadHandler:
return Response(
url=request.url, status=httpcode, body=to_bytes(message)
)
assert result.type
raise result.type(result.value)

View File

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

View File

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

View File

@ -1,28 +1,33 @@
from __future__ import annotations
from time import time
from typing import Optional, Type, TypeVar
from typing import TYPE_CHECKING
from urllib.parse import urldefrag
from twisted.internet.base import DelayedCall
from twisted.internet.defer import Deferred
from twisted.internet.error import TimeoutError
from twisted.web.client import URI
from scrapy.core.downloader.contextfactory import load_context_factory_from_settings
from scrapy.core.downloader.webclient import _parse
from scrapy.core.http2.agent import H2Agent, H2ConnectionPool, ScrapyProxyH2Agent
from scrapy.crawler import Crawler
from scrapy.http import Request, Response
from scrapy.settings import Settings
from scrapy.spiders import Spider
from scrapy.utils.python import to_bytes
H2DownloadHandlerOrSubclass = TypeVar(
"H2DownloadHandlerOrSubclass", bound="H2DownloadHandler"
)
if TYPE_CHECKING:
from twisted.internet.base import DelayedCall
from twisted.internet.defer import Deferred
from twisted.web.iweb import IPolicyForHTTPS
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy.crawler import Crawler
from scrapy.http import Request, Response
from scrapy.settings import Settings
from scrapy.spiders import Spider
class H2DownloadHandler:
def __init__(self, settings: Settings, crawler: Optional[Crawler] = None):
def __init__(self, settings: Settings, crawler: Crawler):
self._crawler = crawler
from twisted.internet import reactor
@ -31,12 +36,10 @@ class H2DownloadHandler:
self._context_factory = load_context_factory_from_settings(settings, crawler)
@classmethod
def from_crawler(
cls: Type[H2DownloadHandlerOrSubclass], crawler: Crawler
) -> H2DownloadHandlerOrSubclass:
def from_crawler(cls, crawler: Crawler) -> Self:
return cls(crawler.settings, crawler)
def download_request(self, request: Request, spider: Spider) -> Deferred:
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
agent = ScrapyH2Agent(
context_factory=self._context_factory,
pool=self._pool,
@ -54,11 +57,11 @@ class ScrapyH2Agent:
def __init__(
self,
context_factory,
context_factory: IPolicyForHTTPS,
pool: H2ConnectionPool,
connect_timeout: int = 10,
bind_address: Optional[bytes] = None,
crawler: Optional[Crawler] = None,
bind_address: bytes | None = None,
crawler: Crawler | None = None,
) -> None:
self._context_factory = context_factory
self._connect_timeout = connect_timeout
@ -66,7 +69,7 @@ class ScrapyH2Agent:
self._pool = pool
self._crawler = crawler
def _get_agent(self, request: Request, timeout: Optional[float]) -> H2Agent:
def _get_agent(self, request: Request, timeout: float | None) -> H2Agent:
from twisted.internet import reactor
bind_address = request.meta.get("bindaddress") or self._bind_address
@ -97,7 +100,7 @@ class ScrapyH2Agent:
pool=self._pool,
)
def download_request(self, request: Request, spider: Spider) -> Deferred:
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
from twisted.internet import reactor
timeout = request.meta.get("download_timeout") or self._connect_timeout

View File

@ -1,21 +1,36 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from scrapy.core.downloader.handlers.http import HTTPDownloadHandler
from scrapy.exceptions import NotConfigured
from scrapy.utils.boto import is_botocore_available
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:
from twisted.internet.defer import Deferred
# typing.Self requires Python 3.11
from typing_extensions import Self
from scrapy import Request, Spider
from scrapy.crawler import Crawler
from scrapy.http import Response
from scrapy.settings import BaseSettings
class S3DownloadHandler:
def __init__(
self,
settings,
settings: BaseSettings,
*,
crawler=None,
aws_access_key_id=None,
aws_secret_access_key=None,
aws_session_token=None,
httpdownloadhandler=HTTPDownloadHandler,
**kw,
crawler: Crawler,
aws_access_key_id: str | None = None,
aws_secret_access_key: str | None = None,
aws_session_token: str | None = None,
httpdownloadhandler: type[HTTPDownloadHandler] = HTTPDownloadHandler,
**kw: Any,
):
if not is_botocore_available():
raise NotConfigured("missing botocore library")
@ -43,25 +58,27 @@ class S3DownloadHandler:
if kw:
raise TypeError(f"Unexpected keyword arguments: {kw}")
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"]
self._signer = SignerCls(
# botocore.auth.BaseSigner doesn't have an __init__() with args, only subclasses do
self._signer = SignerCls( # type: ignore[call-arg]
botocore.credentials.Credentials(
aws_access_key_id, aws_secret_access_key, aws_session_token
)
)
_http_handler = create_instance(
objcls=httpdownloadhandler,
settings=settings,
crawler=crawler,
_http_handler = build_from_crawler(
httpdownloadhandler,
crawler,
)
self._download_http = _http_handler.download_request
@classmethod
def from_crawler(cls, crawler, **kwargs):
def from_crawler(cls, crawler: Crawler, **kwargs: Any) -> Self:
return cls(crawler.settings, crawler=crawler, **kwargs)
def download_request(self, request, spider):
def download_request(self, request: Request, spider: Spider) -> Deferred[Response]:
p = urlparse_cached(request)
scheme = "https" if request.meta.get("is_secure") else "http"
bucket = p.hostname
@ -78,6 +95,7 @@ class S3DownloadHandler:
headers=request.headers.to_unicode_dict(),
data=request.body,
)
assert self._signer
self._signer.add_auth(awsrequest)
request = request.replace(url=url, headers=awsrequest.headers.items())
return self._download_http(request, spider)

View File

@ -3,25 +3,34 @@ Downloader Middleware manager
See documentation in docs/topics/downloader-middleware.rst
"""
from typing import Any, Callable, Generator, List, Union, cast
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
from twisted.internet.defer import Deferred, inlineCallbacks
from twisted.python.failure import Failure
from scrapy import Spider
from scrapy.exceptions import _InvalidOutput
from scrapy.http import Request, Response
from scrapy.middleware import MiddlewareManager
from scrapy.settings import BaseSettings
from scrapy.utils.conf import build_component_list
from scrapy.utils.defer import deferred_from_coro, mustbe_deferred
if TYPE_CHECKING:
from collections.abc import Generator
from twisted.python.failure import Failure
from scrapy import Spider
from scrapy.settings import BaseSettings
class DownloaderMiddlewareManager(MiddlewareManager):
component_name = "downloader middleware"
@classmethod
def _get_mwlist_from_settings(cls, settings: BaseSettings) -> List[Any]:
def _get_mwlist_from_settings(cls, settings: BaseSettings) -> list[Any]:
return build_component_list(settings.getwithbase("DOWNLOADER_MIDDLEWARES"))
def _add_middleware(self, mw: Any) -> None:
@ -33,10 +42,15 @@ class DownloaderMiddlewareManager(MiddlewareManager):
self.methods["process_exception"].appendleft(mw.process_exception)
def download(
self, download_func: Callable, request: Request, spider: Spider
) -> Deferred:
self,
download_func: Callable[[Request, Spider], Deferred[Response]],
request: Request,
spider: Spider,
) -> Deferred[Response | Request]:
@inlineCallbacks
def process_request(request: Request) -> Generator[Deferred, Any, Any]:
def process_request(
request: Request,
) -> Generator[Deferred[Any], Any, Response | Request]:
for method in self.methods["process_request"]:
method = cast(Callable, method)
response = yield deferred_from_coro(
@ -51,15 +65,15 @@ class DownloaderMiddlewareManager(MiddlewareManager):
)
if response:
return response
return (yield download_func(request=request, spider=spider))
return (yield download_func(request, spider))
@inlineCallbacks
def process_response(
response: Union[Response, Request]
) -> Generator[Deferred, Any, Union[Response, Request]]:
response: Response | Request,
) -> Generator[Deferred[Any], Any, Response | Request]:
if response is None:
raise TypeError("Received None in process_response")
elif isinstance(response, Request):
if isinstance(response, Request):
return response
for method in self.methods["process_response"]:
@ -79,7 +93,7 @@ class DownloaderMiddlewareManager(MiddlewareManager):
@inlineCallbacks
def process_exception(
failure: Failure,
) -> Generator[Deferred, Any, Union[Failure, Response, Request]]:
) -> Generator[Deferred[Any], Any, Failure | Response | Request]:
exception = failure.value
for method in self.methods["process_exception"]:
method = cast(Callable, method)
@ -97,7 +111,9 @@ class DownloaderMiddlewareManager(MiddlewareManager):
return response
return failure
deferred = mustbe_deferred(process_request, request)
deferred: Deferred[Response | Request] = mustbe_deferred(
process_request, request
)
deferred.addErrback(process_exception)
deferred.addCallback(process_response)
return deferred

View File

@ -1,5 +1,5 @@
import logging
from typing import Any, Dict
from typing import Any
from OpenSSL import SSL
from service_identity.exceptions import CertificateError
@ -21,7 +21,7 @@ METHOD_TLSv11 = "TLSv1.1"
METHOD_TLSv12 = "TLSv1.2"
openssl_methods: Dict[str, int] = {
openssl_methods: dict[str, int] = {
METHOD_TLS: SSL.SSLv23_METHOD, # protocol negotiation (recommended)
METHOD_TLSv10: SSL.TLSv1_METHOD, # TLS 1.0 only
METHOD_TLSv11: SSL.TLSv1_1_METHOD, # TLS 1.1 only

View File

@ -1,20 +1,24 @@
from __future__ import annotations
import re
from time import time
from typing import Optional, Tuple
from typing import TYPE_CHECKING
from urllib.parse import ParseResult, urldefrag, urlparse, urlunparse
from twisted.internet import defer
from twisted.internet.protocol import ClientFactory
from twisted.web.http import HTTPClient
from scrapy import Request
from scrapy.http import Headers
from scrapy.http import Headers, Response
from scrapy.responsetypes import responsetypes
from scrapy.utils.httpobj import urlparse_cached
from scrapy.utils.python import to_bytes, to_unicode
if TYPE_CHECKING:
from scrapy import Request
def _parsed_url_args(parsed: ParseResult) -> Tuple[bytes, bytes, bytes, int, bytes]:
def _parsed_url_args(parsed: ParseResult) -> tuple[bytes, bytes, bytes, int, bytes]:
# Assume parsed is urlparse-d from Request.url,
# which was passed via safe_url_string and is ascii-only.
path_str = urlunparse(("", "", parsed.path or "/", parsed.params, parsed.query, ""))
@ -29,7 +33,7 @@ def _parsed_url_args(parsed: ParseResult) -> Tuple[bytes, bytes, bytes, int, byt
return scheme, netloc, host, port, path
def _parse(url: str) -> Tuple[bytes, bytes, bytes, int, bytes]:
def _parse(url: str) -> tuple[bytes, bytes, bytes, int, bytes]:
"""Return tuple of (scheme, netloc, host, port, path),
all in bytes except for port which is int.
Assume url is from Request.url, which was passed via safe_url_string
@ -140,12 +144,12 @@ class ScrapyHTTPClientFactory(ClientFactory):
# converting to bytes to comply to Twisted interface
self.url: bytes = to_bytes(self._url, encoding="ascii")
self.method: bytes = to_bytes(request.method, encoding="ascii")
self.body: Optional[bytes] = request.body or None
self.body: bytes | None = request.body or None
self.headers: Headers = Headers(request.headers)
self.response_headers: Optional[Headers] = None
self.response_headers: Headers | None = None
self.timeout: float = request.meta.get("download_timeout") or timeout
self.start_time: float = time()
self.deferred: defer.Deferred = defer.Deferred().addCallback(
self.deferred: defer.Deferred[Response] = defer.Deferred().addCallback(
self._build_response, request
)
@ -155,7 +159,7 @@ class ScrapyHTTPClientFactory(ClientFactory):
# needed to add the callback _waitForDisconnect.
# Specifically this avoids the AttributeError exception when
# clientConnectionFailed method is called.
self._disconnectedDeferred: defer.Deferred = defer.Deferred()
self._disconnectedDeferred: defer.Deferred[None] = defer.Deferred()
self._set_connection_attributes(request)

View File

@ -4,60 +4,57 @@ This is the Scrapy engine which controls the Scheduler, Downloader and Spider.
For more information see docs/topics/architecture.rst
"""
from __future__ import annotations
import logging
from time import time
from typing import (
TYPE_CHECKING,
Any,
Callable,
Generator,
Iterable,
Iterator,
Optional,
Set,
Type,
Union,
cast,
)
from typing import TYPE_CHECKING, Any, TypeVar, cast
from itemadapter import is_item
from twisted.internet.defer import Deferred, inlineCallbacks, succeed
from twisted.internet.task import LoopingCall
from twisted.python.failure import Failure
from scrapy import signals
from scrapy.core.downloader import Downloader
from scrapy.core.scraper import Scraper
from scrapy.exceptions import CloseSpider, DontCloseSpider
from scrapy.core.scraper import Scraper, _HandleOutputDeferred
from scrapy.exceptions import CloseSpider, DontCloseSpider, IgnoreRequest
from scrapy.http import Request, Response
from scrapy.logformatter import LogFormatter
from scrapy.settings import BaseSettings, Settings
from scrapy.signalmanager import SignalManager
from scrapy.spiders import Spider
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.reactor import CallLaterOnce
if TYPE_CHECKING:
from collections.abc import Callable, Generator, Iterable, Iterator
from scrapy.core.downloader import Downloader
from scrapy.core.scheduler import BaseScheduler
from scrapy.crawler import Crawler
from scrapy.logformatter import LogFormatter
from scrapy.settings import BaseSettings, Settings
from scrapy.signalmanager import SignalManager
from scrapy.spiders import Spider
logger = logging.getLogger(__name__)
_T = TypeVar("_T")
class Slot:
def __init__(
self,
start_requests: Iterable[Request],
close_if_idle: bool,
nextcall: CallLaterOnce,
scheduler: "BaseScheduler",
nextcall: CallLaterOnce[None],
scheduler: BaseScheduler,
) -> None:
self.closing: Optional[Deferred] = None
self.inprogress: Set[Request] = set()
self.start_requests: Optional[Iterator[Request]] = iter(start_requests)
self.closing: Deferred[None] | None = None
self.inprogress: set[Request] = set()
self.start_requests: Iterator[Request] | None = iter(start_requests)
self.close_if_idle: bool = close_if_idle
self.nextcall: CallLaterOnce = nextcall
self.scheduler: "BaseScheduler" = scheduler
self.nextcall: CallLaterOnce[None] = nextcall
self.scheduler: BaseScheduler = scheduler
self.heartbeat: LoopingCall = LoopingCall(nextcall.schedule)
def add_request(self, request: Request) -> None:
@ -67,7 +64,7 @@ class Slot:
self.inprogress.remove(request)
self._maybe_fire_closing()
def close(self) -> Deferred:
def close(self) -> Deferred[None]:
self.closing = Deferred()
self._maybe_fire_closing()
return self.closing
@ -82,29 +79,35 @@ class Slot:
class ExecutionEngine:
def __init__(self, crawler: "Crawler", spider_closed_callback: Callable) -> None:
self.crawler: "Crawler" = crawler
def __init__(
self,
crawler: Crawler,
spider_closed_callback: Callable[[Spider], Deferred[None] | None],
) -> None:
self.crawler: Crawler = crawler
self.settings: Settings = crawler.settings
self.signals: SignalManager = crawler.signals
assert crawler.logformatter
self.logformatter: LogFormatter = crawler.logformatter
self.slot: Optional[Slot] = None
self.spider: Optional[Spider] = None
self.slot: Slot | None = None
self.spider: Spider | None = None
self.running: bool = False
self.paused: bool = False
self.scheduler_cls: Type["BaseScheduler"] = self._get_scheduler_class(
self.scheduler_cls: type[BaseScheduler] = self._get_scheduler_class(
crawler.settings
)
downloader_cls: Type[Downloader] = load_object(self.settings["DOWNLOADER"])
downloader_cls: type[Downloader] = load_object(self.settings["DOWNLOADER"])
self.downloader: Downloader = downloader_cls(crawler)
self.scraper = Scraper(crawler)
self._spider_closed_callback: Callable = spider_closed_callback
self.start_time: Optional[float] = None
self.scraper: Scraper = Scraper(crawler)
self._spider_closed_callback: Callable[[Spider], Deferred[None] | None] = (
spider_closed_callback
)
self.start_time: float | None = None
def _get_scheduler_class(self, settings: BaseSettings) -> Type["BaseScheduler"]:
def _get_scheduler_class(self, settings: BaseSettings) -> type[BaseScheduler]:
from scrapy.core.scheduler import BaseScheduler
scheduler_cls: Type = load_object(settings["SCHEDULER"])
scheduler_cls: type[BaseScheduler] = load_object(settings["SCHEDULER"])
if not issubclass(scheduler_cls, BaseScheduler):
raise TypeError(
f"The provided scheduler class ({settings['SCHEDULER']})"
@ -113,20 +116,20 @@ class ExecutionEngine:
return scheduler_cls
@inlineCallbacks
def start(self) -> Generator[Deferred, Any, None]:
def start(self) -> Generator[Deferred[Any], Any, None]:
if self.running:
raise RuntimeError("Engine already running")
self.start_time = time()
yield self.signals.send_catch_log_deferred(signal=signals.engine_started)
self.running = True
self._closewait: Deferred = Deferred()
self._closewait: Deferred[None] = Deferred()
yield self._closewait
def stop(self) -> Deferred:
def stop(self) -> Deferred[None]:
"""Gracefully stop the execution engine"""
@inlineCallbacks
def _finish_stopping_engine(_: Any) -> Generator[Deferred, Any, None]:
def _finish_stopping_engine(_: Any) -> Generator[Deferred[Any], Any, None]:
yield self.signals.send_catch_log_deferred(signal=signals.engine_stopped)
self._closewait.callback(None)
@ -141,7 +144,7 @@ class ExecutionEngine:
)
return dfd.addBoth(_finish_stopping_engine)
def close(self) -> Deferred:
def close(self) -> Deferred[None]:
"""
Gracefully close the execution engine.
If it has already been started, stop it. In all cases, close the spider and the downloader.
@ -168,7 +171,7 @@ class ExecutionEngine:
assert self.spider is not None # typing
if self.paused:
return None
return
while (
not self._needs_backout()
@ -178,7 +181,7 @@ class ExecutionEngine:
if self.slot.start_requests is not None and not self._needs_backout():
try:
request = next(self.slot.start_requests)
request_or_item = next(self.slot.start_requests)
except StopIteration:
self.slot.start_requests = None
except Exception:
@ -189,7 +192,16 @@ class ExecutionEngine:
extra={"spider": self.spider},
)
else:
self.crawl(request)
if isinstance(request_or_item, Request):
self.crawl(request_or_item)
elif is_item(request_or_item):
self.scraper.start_itemproc(request_or_item, response=None)
else:
logger.error(
f"Got {request_or_item!r} among start requests. Only "
f"requests and items are supported. It will be "
f"ignored."
)
if self.spider_is_idle() and self.slot.close_if_idle:
self._spider_idle()
@ -204,7 +216,7 @@ class ExecutionEngine:
or self.scraper.slot.needs_backout()
)
def _next_request_from_scheduler(self) -> Optional[Deferred]:
def _next_request_from_scheduler(self) -> Deferred[None] | None:
assert self.slot is not None # typing
assert self.spider is not None # typing
@ -212,7 +224,7 @@ class ExecutionEngine:
if request is None:
return None
d = self._download(request)
d: Deferred[Response | Request] = self._download(request)
d.addBoth(self._handle_downloader_output, request)
d.addErrback(
lambda f: logger.info(
@ -226,8 +238,8 @@ class ExecutionEngine:
assert self.slot
self.slot.remove_request(request)
d.addBoth(_remove_request)
d.addErrback(
d2: Deferred[None] = d.addBoth(_remove_request)
d2.addErrback(
lambda f: logger.info(
"Error while removing request from slot",
exc_info=failure_to_exc_info(f),
@ -235,19 +247,19 @@ class ExecutionEngine:
)
)
slot = self.slot
d.addBoth(lambda _: slot.nextcall.schedule())
d.addErrback(
d2.addBoth(lambda _: slot.nextcall.schedule())
d2.addErrback(
lambda f: logger.info(
"Error while scheduling new request",
exc_info=failure_to_exc_info(f),
extra={"spider": self.spider},
)
)
return d
return d2
def _handle_downloader_output(
self, result: Union[Request, Response, Failure], request: Request
) -> Optional[Deferred]:
self, result: Request | Response | Failure, request: Request
) -> _HandleOutputDeferred | None:
assert self.spider is not None # typing
if not isinstance(result, (Request, Response, Failure)):
@ -291,33 +303,42 @@ class ExecutionEngine:
self.slot.nextcall.schedule() # type: ignore[union-attr]
def _schedule_request(self, request: Request, spider: Spider) -> None:
self.signals.send_catch_log(
signals.request_scheduled, request=request, spider=spider
request_scheduled_result = self.signals.send_catch_log(
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):
return
if not self.slot.scheduler.enqueue_request(request): # type: ignore[union-attr]
self.signals.send_catch_log(
signals.request_dropped, request=request, spider=spider
)
def download(self, request: Request) -> Deferred:
def download(self, request: Request) -> Deferred[Response]:
"""Return a Deferred which fires with a Response as result, only downloader middlewares are applied"""
if self.spider is None:
raise RuntimeError(f"No open spider to crawl: {request}")
return self._download(request).addBoth(self._downloaded, request)
d: Deferred[Response | Request] = self._download(request)
# Deferred.addBoth() overloads don't seem to support a Union[_T, Deferred[_T]] return type
d2: Deferred[Response] = d.addBoth(self._downloaded, request) # type: ignore[call-overload]
return d2
def _downloaded(
self, result: Union[Response, Request, Failure], request: Request
) -> Union[Deferred, Response, Failure]:
self, result: Response | Request | Failure, request: Request
) -> Deferred[Response] | Response | Failure:
assert self.slot is not None # typing
self.slot.remove_request(request)
return self.download(result) if isinstance(result, Request) else result
def _download(self, request: Request) -> Deferred:
def _download(self, request: Request) -> Deferred[Response | Request]:
assert self.slot is not None # typing
self.slot.add_request(request)
def _on_success(result: Union[Response, Request]) -> Union[Response, Request]:
def _on_success(result: Response | Request) -> Response | Request:
if not isinstance(result, (Response, Request)):
raise TypeError(
f"Incorrect type: expected Response or Request, got {type(result)}: {result!r}"
@ -339,35 +360,37 @@ class ExecutionEngine:
)
return result
def _on_complete(_: Any) -> Any:
def _on_complete(_: _T) -> _T:
assert self.slot is not None
self.slot.nextcall.schedule()
return _
assert self.spider is not None
dwld = self.downloader.fetch(request, self.spider)
dwld.addCallbacks(_on_success)
dwld: Deferred[Response | Request] = self.downloader.fetch(request, self.spider)
dwld.addCallback(_on_success)
dwld.addBoth(_on_complete)
return dwld
@inlineCallbacks
def open_spider(
self, spider: Spider, start_requests: Iterable = (), close_if_idle: bool = True
) -> Generator[Deferred, Any, None]:
self,
spider: Spider,
start_requests: Iterable[Request] = (),
close_if_idle: bool = True,
) -> Generator[Deferred[Any], Any, None]:
if self.slot is not None:
raise RuntimeError(f"No free spider slot when opening {spider.name!r}")
logger.info("Spider opened", extra={"spider": spider})
nextcall = CallLaterOnce(self._next_request)
scheduler = create_instance(
self.scheduler_cls, settings=None, crawler=self.crawler
)
scheduler = build_from_crawler(self.scheduler_cls, self.crawler)
start_requests = yield self.scraper.spidermw.process_start_requests(
start_requests, spider
)
self.slot = Slot(start_requests, close_if_idle, nextcall, scheduler)
self.spider = spider
if hasattr(scheduler, "open"):
yield scheduler.open(spider)
if d := scheduler.open(spider):
yield d
yield self.scraper.open_spider(spider)
assert self.crawler.stats
self.crawler.stats.open_spider(spider)
@ -394,13 +417,13 @@ class ExecutionEngine:
if isinstance(x, Failure) and isinstance(x.value, ex)
}
if DontCloseSpider in detected_ex:
return None
return
if self.spider_is_idle():
ex = detected_ex.get(CloseSpider, CloseSpider(reason="finished"))
assert isinstance(ex, CloseSpider) # typing
self.close_spider(self.spider, reason=ex.reason)
def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred:
def close_spider(self, spider: Spider, reason: str = "cancelled") -> Deferred[None]:
"""Close (cancel) spider and clear all its outstanding requests"""
if self.slot is None:
raise RuntimeError("Engine slot not assigned")
@ -414,7 +437,7 @@ class ExecutionEngine:
dfd = self.slot.close()
def log_failure(msg: str) -> Callable:
def log_failure(msg: str) -> Callable[[Failure], None]:
def errback(failure: Failure) -> None:
logger.error(
msg, exc_info=failure_to_exc_info(failure), extra={"spider": spider}

View File

@ -1,10 +1,10 @@
from __future__ import annotations
from collections import deque
from typing import Deque, Dict, List, Optional, Tuple
from typing import TYPE_CHECKING
from twisted.internet import defer
from twisted.internet.base import ReactorBase
from twisted.internet.defer import Deferred
from twisted.internet.endpoints import HostnameEndpoint
from twisted.python.failure import Failure
from twisted.web.client import (
URI,
@ -16,9 +16,17 @@ from twisted.web.error import SchemeNotSupported
from scrapy.core.downloader.contextfactory import AcceptableProtocolsContextFactory
from scrapy.core.http2.protocol import H2ClientFactory, H2ClientProtocol
from scrapy.http.request import Request
from scrapy.settings import Settings
from scrapy.spiders import Spider
if TYPE_CHECKING:
from twisted.internet.base import ReactorBase
from twisted.internet.endpoints import HostnameEndpoint
from scrapy.http import Request, Response
from scrapy.settings import Settings
from scrapy.spiders import Spider
ConnectionKeyT = tuple[bytes, bytes, int]
class H2ConnectionPool:
@ -28,19 +36,21 @@ class H2ConnectionPool:
# Store a dictionary which is used to get the respective
# H2ClientProtocolInstance using the key as Tuple(scheme, hostname, port)
self._connections: Dict[Tuple, H2ClientProtocol] = {}
self._connections: dict[ConnectionKeyT, H2ClientProtocol] = {}
# Save all requests that arrive before the connection is established
self._pending_requests: Dict[Tuple, Deque[Deferred]] = {}
self._pending_requests: dict[
ConnectionKeyT, deque[Deferred[H2ClientProtocol]]
] = {}
def get_connection(
self, key: Tuple, uri: URI, endpoint: HostnameEndpoint
) -> Deferred:
self, key: ConnectionKeyT, uri: URI, endpoint: HostnameEndpoint
) -> Deferred[H2ClientProtocol]:
if key in self._pending_requests:
# Received a request while connecting to remote
# Create a deferred which will fire with the H2ClientProtocol
# instance
d: Deferred = Deferred()
d: Deferred[H2ClientProtocol] = Deferred()
self._pending_requests[key].append(d)
return d
@ -54,22 +64,24 @@ class H2ConnectionPool:
return self._new_connection(key, uri, endpoint)
def _new_connection(
self, key: Tuple, uri: URI, endpoint: HostnameEndpoint
) -> Deferred:
self, key: ConnectionKeyT, uri: URI, endpoint: HostnameEndpoint
) -> Deferred[H2ClientProtocol]:
self._pending_requests[key] = deque()
conn_lost_deferred: Deferred = Deferred()
conn_lost_deferred: Deferred[list[BaseException]] = Deferred()
conn_lost_deferred.addCallback(self._remove_connection, key)
factory = H2ClientFactory(uri, self.settings, conn_lost_deferred)
conn_d = endpoint.connect(factory)
conn_d.addCallback(self.put_connection, key)
d: Deferred = Deferred()
d: Deferred[H2ClientProtocol] = Deferred()
self._pending_requests[key].append(d)
return d
def put_connection(self, conn: H2ClientProtocol, key: Tuple) -> H2ClientProtocol:
def put_connection(
self, conn: H2ClientProtocol, key: ConnectionKeyT
) -> H2ClientProtocol:
self._connections[key] = conn
# Now as we have established a proper HTTP/2 connection
@ -81,7 +93,9 @@ class H2ConnectionPool:
return conn
def _remove_connection(self, errors: List[BaseException], key: Tuple) -> None:
def _remove_connection(
self, errors: list[BaseException], key: ConnectionKeyT
) -> None:
self._connections.pop(key)
# Call the errback of all the pending requests for this connection
@ -107,8 +121,8 @@ class H2Agent:
reactor: ReactorBase,
pool: H2ConnectionPool,
context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(),
connect_timeout: Optional[float] = None,
bind_address: Optional[bytes] = None,
connect_timeout: float | None = None,
bind_address: bytes | None = None,
) -> None:
self._reactor = reactor
self._pool = pool
@ -119,17 +133,17 @@ class H2Agent:
self._reactor, self._context_factory, connect_timeout, bind_address
)
def get_endpoint(self, uri: URI):
def get_endpoint(self, uri: URI) -> HostnameEndpoint:
return self.endpoint_factory.endpointForURI(uri)
def get_key(self, uri: URI) -> Tuple:
def get_key(self, uri: URI) -> ConnectionKeyT:
"""
Arguments:
uri - URI obtained directly from request URL
"""
return uri.scheme, uri.host, uri.port
def request(self, request: Request, spider: Spider) -> Deferred:
def request(self, request: Request, spider: Spider) -> Deferred[Response]:
uri = URI.fromBytes(bytes(request.url, encoding="utf-8"))
try:
endpoint = self.get_endpoint(uri)
@ -137,9 +151,11 @@ class H2Agent:
return defer.fail(Failure())
key = self.get_key(uri)
d = self._pool.get_connection(key, uri, endpoint)
d.addCallback(lambda conn: conn.request(request, spider))
return d
d: Deferred[H2ClientProtocol] = self._pool.get_connection(key, uri, endpoint)
d2: Deferred[Response] = d.addCallback(
lambda conn: conn.request(request, spider)
)
return d2
class ScrapyProxyH2Agent(H2Agent):
@ -149,8 +165,8 @@ class ScrapyProxyH2Agent(H2Agent):
proxy_uri: URI,
pool: H2ConnectionPool,
context_factory: BrowserLikePolicyForHTTPS = BrowserLikePolicyForHTTPS(),
connect_timeout: Optional[float] = None,
bind_address: Optional[bytes] = None,
connect_timeout: float | None = None,
bind_address: bytes | None = None,
) -> None:
super().__init__(
reactor=reactor,
@ -161,9 +177,9 @@ class ScrapyProxyH2Agent(H2Agent):
)
self._proxy_uri = proxy_uri
def get_endpoint(self, uri: URI):
def get_endpoint(self, uri: URI) -> HostnameEndpoint:
return self.endpoint_factory.endpointForURI(self._proxy_uri)
def get_key(self, uri: URI) -> Tuple:
def get_key(self, uri: URI) -> ConnectionKeyT:
"""We use the proxy uri instead of uri obtained from request url"""
return "http-proxy", self._proxy_uri.host, self._proxy_uri.port
return b"http-proxy", self._proxy_uri.host, self._proxy_uri.port

View File

@ -1,9 +1,10 @@
from __future__ import annotations
import ipaddress
import itertools
import logging
from collections import deque
from ipaddress import IPv4Address, IPv6Address
from typing import Dict, List, Optional, Union
from typing import TYPE_CHECKING, Any
from h2.config import H2Configuration
from h2.connection import H2Connection
@ -20,20 +21,30 @@ from h2.events import (
WindowUpdated,
)
from h2.exceptions import FrameTooLargeError, H2Error
from twisted.internet.defer import Deferred
from twisted.internet.error import TimeoutError
from twisted.internet.interfaces import IHandshakeListener, IProtocolNegotiationFactory
from twisted.internet.interfaces import (
IAddress,
IHandshakeListener,
IProtocolNegotiationFactory,
)
from twisted.internet.protocol import Factory, Protocol, connectionDone
from twisted.internet.ssl import Certificate
from twisted.protocols.policies import TimeoutMixin
from twisted.python.failure import Failure
from twisted.web.client import URI
from zope.interface import implementer
from scrapy.core.http2.stream import Stream, StreamCloseReason
from scrapy.http import Request
from scrapy.settings import Settings
from scrapy.spiders import Spider
from scrapy.http import Request, Response
if TYPE_CHECKING:
from ipaddress import IPv4Address, IPv6Address
from twisted.internet.defer import Deferred
from twisted.python.failure import Failure
from twisted.web.client import URI
from scrapy.settings import Settings
from scrapy.spiders import Spider
logger = logging.getLogger(__name__)
@ -52,7 +63,7 @@ class InvalidNegotiatedProtocol(H2Error):
class RemoteTerminatedConnection(H2Error):
def __init__(
self,
remote_ip_address: Optional[Union[IPv4Address, IPv6Address]],
remote_ip_address: IPv4Address | IPv6Address | None,
event: ConnectionTerminated,
) -> None:
self.remote_ip_address = remote_ip_address
@ -63,9 +74,7 @@ class RemoteTerminatedConnection(H2Error):
class MethodNotAllowed405(H2Error):
def __init__(
self, remote_ip_address: Optional[Union[IPv4Address, IPv6Address]]
) -> None:
def __init__(self, remote_ip_address: IPv4Address | IPv6Address | None) -> None:
self.remote_ip_address = remote_ip_address
def __str__(self) -> str:
@ -77,7 +86,10 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
IDLE_TIMEOUT = 240
def __init__(
self, uri: URI, settings: Settings, conn_lost_deferred: Deferred
self,
uri: URI,
settings: Settings,
conn_lost_deferred: Deferred[list[BaseException]],
) -> None:
"""
Arguments:
@ -88,7 +100,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
conn_lost_deferred -- Deferred fires with the reason: Failure to notify
that connection was lost
"""
self._conn_lost_deferred = conn_lost_deferred
self._conn_lost_deferred: Deferred[list[BaseException]] = conn_lost_deferred
config = H2Configuration(client_side=True, header_encoding="utf-8")
self.conn = H2Connection(config=config)
@ -99,19 +111,19 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
self._stream_id_generator = itertools.count(start=1, step=2)
# Streams are stored in a dictionary keyed off their stream IDs
self.streams: Dict[int, Stream] = {}
self.streams: dict[int, Stream] = {}
# If requests are received before connection is made we keep
# all requests in a pool and send them as the connection is made
self._pending_request_stream_pool: deque = deque()
self._pending_request_stream_pool: deque[Stream] = deque()
# Save an instance of errors raised which lead to losing the connection
# We pass these instances to the streams ResponseFailed() failure
self._conn_lost_errors: List[BaseException] = []
self._conn_lost_errors: list[BaseException] = []
# Some meta data of this connection
# initialized when connection is successfully made
self.metadata: Dict = {
self.metadata: dict[str, Any] = {
# Peer certificate instance
"certificate": None,
# Address of the server we are connected to which
@ -204,14 +216,14 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
data = self.conn.data_to_send()
self.transport.write(data)
def request(self, request: Request, spider: Spider) -> Deferred:
def request(self, request: Request, spider: Spider) -> Deferred[Response]:
if not isinstance(request, Request):
raise TypeError(
f"Expected scrapy.http.Request, received {request.__class__.__qualname__}"
)
stream = self._new_stream(request, spider)
d = stream.get_response()
d: Deferred[Response] = stream.get_response()
# Add the stream to the request pool
self._pending_request_stream_pool.append(stream)
@ -236,7 +248,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
self.conn.initiate_connection()
self._write_to_transport()
def _lose_connection_with_error(self, errors: List[BaseException]) -> None:
def _lose_connection_with_error(self, errors: list[BaseException]) -> None:
"""Helper function to lose the connection with the error sent as a
reason"""
self._conn_lost_errors += errors
@ -339,7 +351,7 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
self._pending_request_stream_pool.clear()
self.conn.close_connection()
def _handle_events(self, events: List[Event]) -> None:
def _handle_events(self, events: list[Event]) -> None:
"""Private method which acts as a bridge between the events
received from the HTTP/2 data and IH2EventsHandler
@ -425,14 +437,17 @@ class H2ClientProtocol(Protocol, TimeoutMixin):
@implementer(IProtocolNegotiationFactory)
class H2ClientFactory(Factory):
def __init__(
self, uri: URI, settings: Settings, conn_lost_deferred: Deferred
self,
uri: URI,
settings: Settings,
conn_lost_deferred: Deferred[list[BaseException]],
) -> None:
self.uri = uri
self.settings = settings
self.conn_lost_deferred = conn_lost_deferred
def buildProtocol(self, addr) -> H2ClientProtocol:
def buildProtocol(self, addr: IAddress) -> H2ClientProtocol:
return H2ClientProtocol(self.uri, self.settings, self.conn_lost_deferred)
def acceptableProtocols(self) -> List[bytes]:
def acceptableProtocols(self) -> list[bytes]:
return [PROTOCOL_NAME]

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