mirror of https://github.com/scrapy/scrapy.git
Merge remote-tracking branch 'origin/master' into HassaanNaushahi-BR_fix_issue-1998
This commit is contained in:
commit
53a38eeb13
21
.bandit.yml
21
.bandit.yml
|
|
@ -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']
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
[bumpversion]
|
||||
current_version = 2.11.0
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = {new_version}
|
||||
|
||||
[bumpversion:file:scrapy/VERSION]
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
[run]
|
||||
branch = true
|
||||
include = scrapy/*
|
||||
omit =
|
||||
tests/*
|
||||
disable_warnings = include-ignored
|
||||
22
.flake8
22
.flake8
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
[settings]
|
||||
profile = black
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
13
MANIFEST.in
13
MANIFEST.in
|
|
@ -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]
|
||||
|
|
|
|||
10
README.rst
10
README.rst
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
48
conftest.py
48
conftest.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
10
docs/conf.py
10
docs/conf.py
|
|
@ -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+",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
80
docs/faq.rst
80
docs/faq.rst
|
|
@ -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
|
||||
Python’s 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Apple’s 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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
834
docs/news.rst
834
docs/news.rst
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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::
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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 `Zyte’s export guides`_.
|
||||
|
||||
.. _Zyte’s 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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
==============================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`_.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"``.
|
||||
|
||||
======================================= ========================================================================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
99
pylintrc
99
pylintrc
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
28
pytest.ini
28
pytest.ini
|
|
@ -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
|
||||
|
|
@ -1 +1 @@
|
|||
2.11.0
|
||||
2.12.0
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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__}"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue