mirror of https://github.com/scrapy/scrapy.git
Merge branch 'master' into typing-request-response-cls
This commit is contained in:
commit
d97d32c48e
14
.bandit.yml
14
.bandit.yml
|
|
@ -1,19 +1,7 @@
|
|||
skips:
|
||||
- B101 # assert_used
|
||||
- B105 # hardcoded_password_string
|
||||
- B301 # pickle
|
||||
- B307 # eval
|
||||
- B311 # random
|
||||
- B320 # xml_bad_etree
|
||||
- B101 # assert_used, needed for mypy
|
||||
- B321 # ftplib, https://github.com/scrapy/scrapy/issues/4180
|
||||
- B324 # hashlib "Use of weak SHA1 hash for security"
|
||||
- B402 # import_ftplib, https://github.com/scrapy/scrapy/issues/4180
|
||||
- B403 # import_pickle
|
||||
- B404 # import_subprocess
|
||||
- B406 # import_xml_sax
|
||||
- B410 # import_lxml
|
||||
- B411 # import_xmlrpclib, https://github.com/PyCQA/bandit/issues/1082
|
||||
- B503 # ssl_with_bad_defaults
|
||||
- B603 # subprocess_without_shell_equals_true
|
||||
- B605 # start_process_with_a_shell
|
||||
exclude_dirs: ['tests']
|
||||
|
|
|
|||
|
|
@ -4,3 +4,9 @@ include = scrapy/*
|
|||
omit =
|
||||
tests/*
|
||||
disable_warnings = include-ignored
|
||||
|
||||
[report]
|
||||
# https://github.com/nedbat/coveragepy/issues/831#issuecomment-517778185
|
||||
exclude_lines =
|
||||
pragma: no cover
|
||||
if TYPE_CHECKING:
|
||||
|
|
|
|||
|
|
@ -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
MANIFEST.in
12
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
|
||||
|
|
@ -12,16 +11,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]
|
||||
|
|
|
|||
18
conftest.py
18
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
|
||||
|
|
@ -85,14 +81,12 @@ 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")
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
# serve to show the default.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@
|
|||
Release notes
|
||||
=============
|
||||
|
||||
|
||||
.. _release-VERSION:
|
||||
|
||||
Scrapy VERSION (YYYY-MM-DD)
|
||||
---------------------------
|
||||
|
||||
Deprecations
|
||||
~~~~~~~~~~~~
|
||||
|
||||
- :func:`scrapy.core.downloader.Downloader._get_slot_key` is now deprecated.
|
||||
Consider using its corresponding public method get_slot_key() instead.
|
||||
(:issue:`6340`)
|
||||
|
||||
|
||||
.. _release-2.11.1:
|
||||
|
||||
Scrapy 2.11.1 (2024-02-14)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,18 @@ effect, but there are some important differences:
|
|||
|
||||
AutoThrottle doesn't have these issues.
|
||||
|
||||
Disabling throttling on a downloader slot
|
||||
=========================================
|
||||
|
||||
It is possible to disable AutoThrottle for a specific download slot at run time
|
||||
by setting its ``throttle`` attribute to ``False``, e.g. using
|
||||
:setting:`DOWNLOAD_SLOTS`.
|
||||
|
||||
Note, however, that AutoThrottle still determines the starting delay of every
|
||||
slot by setting the ``download_delay`` attribute on the running spider. You
|
||||
might want to set a custom value for the ``delay`` attribute of the slot, e.g.
|
||||
using :setting:`DOWNLOAD_SLOTS`.
|
||||
|
||||
Throttling algorithm
|
||||
====================
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -884,6 +884,10 @@ Meta tags within these tags are ignored.
|
|||
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
|
||||
``['script', 'noscript']`` to ``[]``.
|
||||
|
||||
.. versionchanged:: VERSION
|
||||
The default value of :setting:`METAREFRESH_IGNORE_TAGS` changed from
|
||||
``[]`` to ``['noscript']``.
|
||||
|
||||
.. setting:: METAREFRESH_MAXDELAY
|
||||
|
||||
METAREFRESH_MAXDELAY
|
||||
|
|
|
|||
|
|
@ -289,7 +289,8 @@ Here are some tips to keep in mind when dealing with these kinds of sites:
|
|||
services like `ProxyMesh`_. An open source alternative is `scrapoxy`_, a
|
||||
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`_.
|
||||
|
|
|
|||
|
|
@ -835,7 +835,7 @@ The default HTTPS handler uses HTTP/1.1. To use HTTP/2:
|
|||
.. setting:: DOWNLOAD_SLOTS
|
||||
|
||||
DOWNLOAD_SLOTS
|
||||
----------------
|
||||
--------------
|
||||
|
||||
Default: ``{}``
|
||||
|
||||
|
|
@ -844,7 +844,12 @@ Allows to define concurrency/delay parameters on per slot (domain) basis:
|
|||
.. code-block:: python
|
||||
|
||||
DOWNLOAD_SLOTS = {
|
||||
"quotes.toscrape.com": {"concurrency": 1, "delay": 2, "randomize_delay": False},
|
||||
"quotes.toscrape.com": {
|
||||
"concurrency": 1,
|
||||
"delay": 2,
|
||||
"randomize_delay": False,
|
||||
"throttle": False,
|
||||
},
|
||||
"books.toscrape.com": {"delay": 3, "randomize_delay": False},
|
||||
}
|
||||
|
||||
|
|
@ -856,6 +861,9 @@ Allows to define concurrency/delay parameters on per slot (domain) basis:
|
|||
- :setting:`CONCURRENT_REQUESTS_PER_DOMAIN`: ``concurrency``
|
||||
- :setting:`RANDOMIZE_DOWNLOAD_DELAY`: ``randomize_delay``
|
||||
|
||||
There is no global setting for ``throttle``, whose default value is
|
||||
``None``.
|
||||
|
||||
|
||||
.. setting:: DOWNLOAD_TIMEOUT
|
||||
|
||||
|
|
|
|||
|
|
@ -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,21 +1,33 @@
|
|||
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, Callable, Dict, Iterable, List, Optional, Tuple, Type
|
||||
|
||||
import scrapy
|
||||
from scrapy.commands import BaseRunSpiderCommand, ScrapyCommand, ScrapyHelpFormatter
|
||||
from scrapy.crawler import CrawlerProcess
|
||||
from scrapy.exceptions import UsageError
|
||||
from scrapy.settings import BaseSettings, Settings
|
||||
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:
|
||||
# typing.ParamSpec requires Python 3.10
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
class ScrapyArgumentParser(argparse.ArgumentParser):
|
||||
def _parse_optional(self, arg_string):
|
||||
def _parse_optional(
|
||||
self, arg_string: str
|
||||
) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]:
|
||||
# if starts with -: it means that is a parameter not a argument
|
||||
if arg_string[:2] == "-:":
|
||||
return None
|
||||
|
|
@ -23,7 +35,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 +49,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 +58,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:
|
||||
|
|
@ -61,7 +75,9 @@ def _get_commands_from_entry_points(inproject, group="scrapy.commands"):
|
|||
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 +86,17 @@ def _get_commands_dict(settings, inproject):
|
|||
return cmds
|
||||
|
||||
|
||||
def _pop_command_name(argv):
|
||||
def _pop_command_name(argv: List[str]) -> Optional[str]:
|
||||
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 +105,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 +120,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 +144,9 @@ def _run_print_help(parser, func, *a, **kw):
|
|||
sys.exit(2)
|
||||
|
||||
|
||||
def execute(argv=None, settings=None):
|
||||
def execute(
|
||||
argv: Optional[List[str]] = None, settings: Optional[Settings] = None
|
||||
) -> None:
|
||||
if argv is None:
|
||||
argv = sys.argv
|
||||
|
||||
|
|
@ -162,14 +188,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()
|
||||
|
|
|
|||
|
|
@ -3,61 +3,62 @@ Base class for Scrapy commands
|
|||
"""
|
||||
|
||||
import argparse
|
||||
import builtins
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from twisted.python import failure
|
||||
|
||||
from scrapy.crawler import CrawlerProcess
|
||||
from scrapy.crawler import Crawler, CrawlerProcess
|
||||
from scrapy.exceptions import UsageError
|
||||
from scrapy.utils.conf import arglist_to_dict, feed_process_params_from_cli
|
||||
|
||||
|
||||
class ScrapyCommand:
|
||||
requires_project = False
|
||||
requires_project: bool = False
|
||||
crawler_process: Optional[CrawlerProcess] = None
|
||||
|
||||
# default settings to be used for this command instead of global defaults
|
||||
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
|
||||
"""
|
||||
|
|
@ -92,7 +93,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:
|
||||
|
|
@ -129,8 +130,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",
|
||||
|
|
@ -162,8 +163,8 @@ class BaseRunSpiderCommand(ScrapyCommand):
|
|||
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:
|
||||
|
|
@ -183,7 +184,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: Optional[int] = None,
|
||||
):
|
||||
super().__init__(
|
||||
prog,
|
||||
indent_increment=indent_increment,
|
||||
|
|
@ -191,11 +198,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,10 +1,14 @@
|
|||
import subprocess
|
||||
import argparse
|
||||
import subprocess # nosec
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Iterable, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import scrapy
|
||||
from scrapy import Request
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.http import Response, TextResponse
|
||||
from scrapy.linkextractors import LinkExtractor
|
||||
|
||||
|
||||
|
|
@ -15,24 +19,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(
|
||||
pargs, stdout=subprocess.PIPE, env=get_testenv()
|
||||
) # nosec
|
||||
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 +55,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,5 +1,7 @@
|
|||
import argparse
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import List
|
||||
from unittest import TextTestResult as _TextTestResult
|
||||
from unittest import TextTestRunner
|
||||
|
||||
|
|
@ -10,9 +12,10 @@ 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
|
||||
# _WritelnDecorator isn't implemented in typeshed yet
|
||||
writeln = self.stream.writeln # type: ignore[attr-defined]
|
||||
|
||||
run = self.testsRun
|
||||
plural = "s" if run != 1 else ""
|
||||
|
|
@ -42,14 +45,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 +69,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,6 +79,7 @@ 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"):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import argparse
|
||||
from typing import List, cast
|
||||
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from scrapy.commands import BaseRunSpiderCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
|
||||
|
|
@ -5,13 +10,13 @@ from scrapy.exceptions import UsageError
|
|||
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:
|
||||
|
|
@ -20,10 +25,11 @@ class Command(BaseRunSpiderCommand):
|
|||
)
|
||||
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,5 +1,7 @@
|
|||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
|
|
@ -9,32 +11,34 @@ 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()
|
||||
|
||||
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]}")
|
||||
|
||||
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}"') # nosec
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import sys
|
||||
from argparse import Namespace
|
||||
from typing import List, Type
|
||||
from argparse import ArgumentParser, Namespace
|
||||
from typing import Dict, List, Type
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -15,20 +15,20 @@ from scrapy.utils.spider import DefaultSpider, spidercls_for_request
|
|||
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,20 +44,21 @@ 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:
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import string
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast
|
||||
from typing import List, Optional, Union, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import scrapy
|
||||
|
|
@ -12,7 +13,7 @@ from scrapy.exceptions import UsageError
|
|||
from scrapy.utils.template import render_templatefile, string_camelcase
|
||||
|
||||
|
||||
def sanitize_module_name(module_name):
|
||||
def sanitize_module_name(module_name: str) -> str:
|
||||
"""Sanitize the given module name, by replacing dashes and points
|
||||
with underscores and prefixing it with a letter if it doesn't start
|
||||
with one
|
||||
|
|
@ -23,7 +24,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 +32,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 +44,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 +87,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
|
||||
|
|
@ -113,9 +114,16 @@ 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}"') # nosec
|
||||
|
||||
def _genspider(self, module, name, url, template_name, template_file):
|
||||
def _genspider(
|
||||
self,
|
||||
module: str,
|
||||
name: str,
|
||||
url: str,
|
||||
template_name: str,
|
||||
template_file: Union[str, os.PathLike],
|
||||
) -> None:
|
||||
"""Generate the spider module, based on the given template"""
|
||||
capitalized_module = "".join(s.capitalize() for s in module.split("_"))
|
||||
domain = extract_domain(url)
|
||||
|
|
@ -130,6 +138,7 @@ class Command(ScrapyCommand):
|
|||
}
|
||||
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
|
||||
|
|
@ -152,7 +161,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,3 +1,6 @@
|
|||
import argparse
|
||||
from typing import List
|
||||
|
||||
from scrapy.commands import ScrapyCommand
|
||||
|
||||
|
||||
|
|
@ -5,9 +8,10 @@ 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,16 +1,32 @@
|
|||
import argparse
|
||||
import functools
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict
|
||||
from types import CoroutineType
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
from itemadapter import ItemAdapter, is_item
|
||||
from twisted.internet.defer import maybeDeferred
|
||||
from twisted.internet.defer import Deferred, maybeDeferred
|
||||
from twisted.python.failure import Failure
|
||||
from w3lib.url import is_url
|
||||
|
||||
from scrapy.commands import BaseRunSpiderCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
from scrapy.http import Request
|
||||
from scrapy.http import Request, Response
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.utils import display
|
||||
from scrapy.utils.asyncgen import collect_asyncgen
|
||||
from scrapy.utils.defer import aiter_errback, deferred_from_coro
|
||||
|
|
@ -20,24 +36,26 @@ from scrapy.utils.spider import spidercls_for_request
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class Command(BaseRunSpiderCommand):
|
||||
requires_project = True
|
||||
|
||||
spider = None
|
||||
items: Dict[int, list] = {}
|
||||
requests: Dict[int, list] = {}
|
||||
items: Dict[int, List[Any]] = {}
|
||||
requests: Dict[int, List[Request]] = {}
|
||||
|
||||
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",
|
||||
|
|
@ -106,7 +124,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)
|
||||
|
|
@ -114,13 +132,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: Union[AsyncGenerator, CoroutineType]
|
||||
) -> Deferred: ...
|
||||
|
||||
@overload
|
||||
def iterate_spider_output(self, result: _T) -> Iterable: ...
|
||||
|
||||
def iterate_spider_output(self, result: Any) -> Union[Iterable, Deferred]:
|
||||
if inspect.isasyncgen(result):
|
||||
d = deferred_from_coro(
|
||||
collect_asyncgen(aiter_errback(result, self.handle_exception))
|
||||
|
|
@ -133,15 +159,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: Optional[int] = None, colour: bool = True) -> None:
|
||||
if lvl is None:
|
||||
items = [item for lst in self.items.values() for item in lst]
|
||||
else:
|
||||
|
|
@ -150,7 +176,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: Optional[int] = None, colour: bool = True) -> None:
|
||||
if lvl is None:
|
||||
if self.requests:
|
||||
requests = self.requests[max(self.requests)]
|
||||
|
|
@ -162,7 +188,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:
|
||||
|
|
@ -179,7 +205,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: Callable,
|
||||
) -> Tuple[List[Any], List[Request], argparse.Namespace, int, Spider, Callable]:
|
||||
items, requests = [], []
|
||||
for x in spider_output:
|
||||
if is_item(x):
|
||||
|
|
@ -188,14 +221,21 @@ 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: Callable,
|
||||
cb_kwargs: Optional[Dict[str, Any]] = None,
|
||||
) -> Deferred:
|
||||
cb_kwargs = cb_kwargs or {}
|
||||
d = maybeDeferred(self.iterate_spider_output, callback(response, **cb_kwargs))
|
||||
return d
|
||||
|
||||
def get_callback_from_rules(self, spider, response):
|
||||
def get_callback_from_rules(
|
||||
self, spider: Spider, response: Response
|
||||
) -> Union[Callable, str, None]:
|
||||
if getattr(spider, "rules", None):
|
||||
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:
|
||||
|
|
@ -204,8 +244,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:
|
||||
|
|
@ -219,13 +261,14 @@ 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
|
||||
|
||||
def start_parsing(self, url, opts):
|
||||
def start_parsing(self, url: str, opts: argparse.Namespace) -> None:
|
||||
assert self.crawler_process
|
||||
self.crawler_process.crawl(self.spidercls, **opts.spargs)
|
||||
self.pcrawler = list(self.crawler_process.crawlers)[0]
|
||||
self.crawler_process.start()
|
||||
|
|
@ -233,7 +276,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, Callable
|
||||
],
|
||||
) -> List[Any]:
|
||||
items, requests, opts, depth, spider, callback = args
|
||||
if opts.pipelines:
|
||||
itemproc = self.pcrawler.engine.scraper.itemproc
|
||||
|
|
@ -252,8 +300,14 @@ class Command(BaseRunSpiderCommand):
|
|||
|
||||
return scraped_data
|
||||
|
||||
def _get_callback(self, *, spider, opts, response=None):
|
||||
cb = None
|
||||
def _get_callback(
|
||||
self,
|
||||
*,
|
||||
spider: Spider,
|
||||
opts: argparse.Namespace,
|
||||
response: Optional[Response] = None,
|
||||
) -> Callable:
|
||||
cb: Union[str, Callable, None] = None
|
||||
if response:
|
||||
cb = response.meta["_callback"]
|
||||
if not cb:
|
||||
|
|
@ -270,6 +324,7 @@ class Command(BaseRunSpiderCommand):
|
|||
cb = "parse"
|
||||
|
||||
if not callable(cb):
|
||||
assert cb is not None
|
||||
cb_method = getattr(spider, cb, None)
|
||||
if callable(cb_method):
|
||||
cb = cb_method
|
||||
|
|
@ -277,10 +332,13 @@ class Command(BaseRunSpiderCommand):
|
|||
raise ValueError(
|
||||
f"Cannot find callback {cb!r} in spider: {spider.name}"
|
||||
)
|
||||
assert callable(cb)
|
||||
return cb
|
||||
|
||||
def prepare_request(self, spider, request, opts):
|
||||
def callback(response, **cb_kwargs):
|
||||
def prepare_request(
|
||||
self, spider: Spider, request: Request, opts: argparse.Namespace
|
||||
) -> Request:
|
||||
def callback(response: Response, **cb_kwargs: Any) -> Deferred:
|
||||
# memorize first request
|
||||
if not self.first_response:
|
||||
self.first_response = response
|
||||
|
|
@ -288,7 +346,7 @@ class Command(BaseRunSpiderCommand):
|
|||
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)
|
||||
|
|
@ -311,13 +369,13 @@ class Command(BaseRunSpiderCommand):
|
|||
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)
|
||||
|
|
@ -328,7 +386,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)
|
||||
|
|
@ -339,7 +397,7 @@ 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()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import argparse
|
||||
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 List, Union
|
||||
|
||||
from scrapy.commands import BaseRunSpiderCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
|
|
@ -27,16 +28,16 @@ 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()
|
||||
filename = Path(args[0])
|
||||
|
|
@ -51,6 +52,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,4 +1,6 @@
|
|||
import argparse
|
||||
import json
|
||||
from typing import List
|
||||
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.settings import BaseSettings
|
||||
|
|
@ -8,14 +10,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 +46,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)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ Scrapy Shell
|
|||
See documentation in docs/topics/shell.rst
|
||||
"""
|
||||
|
||||
from argparse import Namespace
|
||||
from argparse import ArgumentParser, Namespace
|
||||
from threading import Thread
|
||||
from typing import List, Type
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
from scrapy import Spider
|
||||
from scrapy.commands import ScrapyCommand
|
||||
|
|
@ -24,20 +24,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",
|
||||
|
|
@ -52,7 +52,7 @@ 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
|
||||
"""
|
||||
|
|
@ -88,7 +88,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,4 @@
|
|||
import argparse
|
||||
import os
|
||||
import re
|
||||
import string
|
||||
|
|
@ -5,13 +6,14 @@ 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 List, Tuple, Union
|
||||
|
||||
import scrapy
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.exceptions import UsageError
|
||||
from scrapy.utils.template import render_templatefile, string_camelcase
|
||||
|
||||
TEMPLATES_TO_RENDER = (
|
||||
TEMPLATES_TO_RENDER: Tuple[Tuple[str, ...], ...] = (
|
||||
("scrapy.cfg",),
|
||||
("${project_name}", "settings.py.tmpl"),
|
||||
("${project_name}", "items.py.tmpl"),
|
||||
|
|
@ -22,7 +24,7 @@ TEMPLATES_TO_RENDER = (
|
|||
IGNORE = ignore_patterns("*.pyc", "__pycache__", ".svn")
|
||||
|
||||
|
||||
def _make_writable(path):
|
||||
def _make_writable(path: Union[str, os.PathLike]) -> None:
|
||||
current_permissions = os.stat(path).st_mode
|
||||
os.chmod(path, current_permissions | OWNER_WRITE_PERMISSION)
|
||||
|
||||
|
|
@ -31,14 +33,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 +55,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,7 +86,7 @@ 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()
|
||||
|
||||
|
|
@ -105,7 +107,9 @@ class Command(ScrapyCommand):
|
|||
return
|
||||
|
||||
self._copytree(Path(self.templates_dir), project_dir.resolve())
|
||||
move(project_dir / "module", project_dir / project_name)
|
||||
# On 3.8 shutil.move doesn't fully support Path args, but it supports our use case
|
||||
# See https://bugs.python.org/issue32689
|
||||
move(project_dir / "module", project_dir / project_name) # type: ignore[arg-type]
|
||||
for paths in TEMPLATES_TO_RENDER:
|
||||
tplfile = Path(
|
||||
project_dir,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import argparse
|
||||
from typing import List
|
||||
|
||||
import scrapy
|
||||
from scrapy.commands import ScrapyCommand
|
||||
from scrapy.utils.versions import scrapy_components_versions
|
||||
|
|
@ -6,14 +9,14 @@ from scrapy.utils.versions import scrapy_components_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,7 +25,7 @@ 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()
|
||||
width = max(len(n) for (n, _) in versions)
|
||||
|
|
|
|||
|
|
@ -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,8 +1,9 @@
|
|||
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, Deque, Dict, Optional, Set, Tuple, cast
|
||||
|
||||
from twisted.internet import task
|
||||
from twisted.internet.defer import Deferred
|
||||
|
|
@ -10,6 +11,7 @@ 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.exceptions import ScrapyDeprecationWarning
|
||||
from scrapy.http import Response
|
||||
from scrapy.resolver import dnscache
|
||||
from scrapy.settings import BaseSettings
|
||||
|
|
@ -24,10 +26,18 @@ if TYPE_CHECKING:
|
|||
class Slot:
|
||||
"""Downloader slot"""
|
||||
|
||||
def __init__(self, concurrency: int, delay: float, randomize_delay: bool):
|
||||
def __init__(
|
||||
self,
|
||||
concurrency: int,
|
||||
delay: float,
|
||||
randomize_delay: bool,
|
||||
*,
|
||||
throttle: Optional[bool] = None,
|
||||
):
|
||||
self.concurrency: int = concurrency
|
||||
self.delay: float = delay
|
||||
self.randomize_delay: bool = randomize_delay
|
||||
self.throttle = throttle
|
||||
|
||||
self.active: Set[Request] = set()
|
||||
self.queue: Deque[Tuple[Request, Deferred]] = deque()
|
||||
|
|
@ -40,7 +50,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) # nosec
|
||||
return self.delay
|
||||
|
||||
def close(self) -> None:
|
||||
|
|
@ -52,13 +62,15 @@ class Slot:
|
|||
return (
|
||||
f"{cls_name}(concurrency={self.concurrency!r}, "
|
||||
f"delay={self.delay:.2f}, "
|
||||
f"randomize_delay={self.randomize_delay!r})"
|
||||
f"randomize_delay={self.randomize_delay!r}, "
|
||||
f"throttle={self.throttle!r})"
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"<downloader.Slot concurrency={self.concurrency!r} "
|
||||
f"delay={self.delay:.2f} randomize_delay={self.randomize_delay!r} "
|
||||
f"throttle={self.throttle!r} "
|
||||
f"len(active)={len(self.active)} len(queue)={len(self.queue)} "
|
||||
f"len(transferring)={len(self.transferring)} "
|
||||
f"lastseen={datetime.fromtimestamp(self.lastseen).isoformat()}>"
|
||||
|
|
@ -115,7 +127,7 @@ class Downloader:
|
|||
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)
|
||||
key = self.get_slot_key(request)
|
||||
if key not in self.slots:
|
||||
slot_settings = self.per_slot_settings.get(key, {})
|
||||
conc = (
|
||||
|
|
@ -127,12 +139,13 @@ class Downloader:
|
|||
slot_settings.get("delay", delay),
|
||||
)
|
||||
randomize_delay = slot_settings.get("randomize_delay", self.randomize_delay)
|
||||
new_slot = Slot(conc, delay, randomize_delay)
|
||||
throttle = slot_settings.get("throttle", None)
|
||||
new_slot = Slot(conc, delay, randomize_delay, throttle=throttle)
|
||||
self.slots[key] = new_slot
|
||||
|
||||
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,6 +155,14 @@ class Downloader:
|
|||
|
||||
return key
|
||||
|
||||
def _get_slot_key(self, request: Request, spider: Optional[Spider]) -> str:
|
||||
warnings.warn(
|
||||
"Use of this protected method is deprecated. Consider using its corresponding public method get_slot_key() instead.",
|
||||
ScrapyDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.get_slot_key(request)
|
||||
|
||||
def _enqueue_request(self, request: Request, spider: Spider) -> Deferred:
|
||||
key, slot = self._get_slot(request, spider)
|
||||
request.meta[self.DOWNLOAD_SLOT] = key
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING, Any, List, Optional
|
||||
|
||||
|
|
@ -19,12 +21,16 @@ from scrapy.core.downloader.tls import (
|
|||
ScrapyClientTLSOptions,
|
||||
openssl_methods,
|
||||
)
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.settings import BaseSettings
|
||||
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
|
||||
|
||||
|
||||
@implementer(IPolicyForHTTPS)
|
||||
class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
|
||||
|
|
@ -62,7 +68,7 @@ class ScrapyClientContextFactory(BrowserLikePolicyForHTTPS):
|
|||
method: int = SSL.SSLv23_METHOD,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
):
|
||||
) -> Self:
|
||||
tls_verbose_logging: bool = settings.getbool(
|
||||
"DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING"
|
||||
)
|
||||
|
|
@ -97,7 +103,7 @@ 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
|
||||
|
||||
|
|
@ -160,7 +166,9 @@ class AcceptableProtocolsContextFactory:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from pathlib import Path
|
|||
|
||||
from w3lib.url import file_uri_to_path
|
||||
|
||||
from scrapy import Request, Spider
|
||||
from scrapy.http import Response
|
||||
from scrapy.responsetypes import responsetypes
|
||||
from scrapy.utils.decorators import defers
|
||||
|
||||
|
|
@ -10,7 +12,7 @@ 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,34 +28,46 @@ 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, Dict, Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
from twisted.internet.defer import Deferred
|
||||
from twisted.internet.protocol import ClientCreator, Protocol
|
||||
from twisted.protocols.ftp import CommandFailed, FTPClient
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from scrapy import Request, Spider
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.http import Response
|
||||
from scrapy.responsetypes import responsetypes
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.utils.httpobj import urlparse_cached
|
||||
from scrapy.utils.python import to_bytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
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: Optional[str] = None):
|
||||
self.__filename: Optional[str] = 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) -> Optional[str]:
|
||||
return self.__filename
|
||||
|
||||
def close(self):
|
||||
def close(self) -> None:
|
||||
self.body.close() if self.filename else self.body.seek(0)
|
||||
|
||||
|
||||
|
|
@ -65,21 +77,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:
|
||||
from twisted.internet import reactor
|
||||
|
||||
parsed_url = urlparse_cached(request)
|
||||
|
|
@ -91,28 +103,29 @@ class FTPDownloadHandler:
|
|||
creator = ClientCreator(
|
||||
reactor, FTPClient, user, password, passive=passive_mode
|
||||
)
|
||||
dfd = creator.connectTCP(parsed_url.hostname, parsed_url.port or 21)
|
||||
dfd: Deferred = creator.connectTCP(parsed_url.hostname, parsed_url.port or 21)
|
||||
return dfd.addCallback(self.gotClient, request, unquote(parsed_url.path))
|
||||
|
||||
def gotClient(self, client, request, filepath):
|
||||
def gotClient(self, client: FTPClient, request: Request, filepath: str) -> Deferred:
|
||||
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 +135,5 @@ class FTPDownloadHandler:
|
|||
return Response(
|
||||
url=request.url, status=httpcode, body=to_bytes(message)
|
||||
)
|
||||
assert result.type
|
||||
raise result.type(result.value)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,50 @@
|
|||
"""Download handlers for http and https schemes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Type
|
||||
|
||||
from twisted.internet.defer import Deferred
|
||||
|
||||
from scrapy import Request, Spider
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.utils.misc import build_from_crawler, load_object
|
||||
from scrapy.utils.python import to_unicode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
from scrapy.core.downloader.contextfactory import ScrapyClientContextFactory
|
||||
from scrapy.core.downloader.webclient import ScrapyHTTPClientFactory
|
||||
|
||||
|
||||
class HTTP10DownloadHandler:
|
||||
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:
|
||||
"""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) -> Deferred:
|
||||
from twisted.internet import reactor
|
||||
|
||||
host, port = to_unicode(factory.host), factory.port
|
||||
|
|
|
|||
|
|
@ -1,65 +1,77 @@
|
|||
"""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, Dict, List, Optional, Tuple, Union, cast
|
||||
from urllib.parse import urldefrag, urlunparse
|
||||
|
||||
from twisted.internet import defer, protocol, ssl
|
||||
from twisted.internet import ssl
|
||||
from twisted.internet.base import ReactorBase
|
||||
from twisted.internet.defer import CancelledError, Deferred, succeed
|
||||
from twisted.internet.endpoints import TCP4ClientEndpoint
|
||||
from twisted.internet.error import TimeoutError
|
||||
from twisted.internet.interfaces import IConsumer
|
||||
from twisted.internet.protocol import Factory, Protocol, connectionDone
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.web.client import (
|
||||
URI,
|
||||
Agent,
|
||||
HTTPConnectionPool,
|
||||
ResponseDone,
|
||||
ResponseFailed,
|
||||
)
|
||||
from twisted.web.client import URI, Agent, HTTPConnectionPool
|
||||
from twisted.web.client import Response as TxResponse
|
||||
from twisted.web.client import ResponseDone, ResponseFailed
|
||||
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.crawler import Crawler
|
||||
from scrapy.exceptions import StopDownload
|
||||
from scrapy.http import Headers
|
||||
from scrapy.http import Headers, Response
|
||||
from scrapy.responsetypes import responsetypes
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.utils.python import to_bytes, to_unicode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
"""Return a deferred for the HTTP download"""
|
||||
agent = ScrapyAgent(
|
||||
contextFactory=self._contextFactory,
|
||||
|
|
@ -71,10 +83,10 @@ class HTTP11DownloadHandler:
|
|||
)
|
||||
return agent.download_request(request)
|
||||
|
||||
def close(self):
|
||||
def close(self) -> Deferred:
|
||||
from twisted.internet import reactor
|
||||
|
||||
d = self._pool.closeCachedConnections()
|
||||
d: Deferred = self._pool.closeCachedConnections()
|
||||
# closeCachedConnections will hang on network or server issues, so
|
||||
# we'll manually timeout the deferred.
|
||||
#
|
||||
|
|
@ -85,7 +97,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: Any) -> Any:
|
||||
if delayed_call.active():
|
||||
delayed_call.cancel()
|
||||
return result
|
||||
|
|
@ -115,39 +127,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, Optional[bytes]],
|
||||
contextFactory: IPolicyForHTTPS,
|
||||
timeout: float = 30,
|
||||
bindAddress: Optional[Tuple[str, int]] = 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 = 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 +169,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 +194,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:
|
||||
self._protocolFactory = protocolFactory
|
||||
connectDeferred = super().connect(protocolFactory)
|
||||
connectDeferred.addCallback(self.requestTunnel)
|
||||
|
|
@ -191,7 +206,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: Optional[bytes] = None
|
||||
) -> bytes:
|
||||
r"""
|
||||
Return binary content of a CONNECT request.
|
||||
|
||||
|
|
@ -222,18 +239,19 @@ class TunnelingAgent(Agent):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
reactor,
|
||||
proxyConf,
|
||||
contextFactory=None,
|
||||
connectTimeout=None,
|
||||
bindAddress=None,
|
||||
pool=None,
|
||||
*,
|
||||
reactor: ReactorBase,
|
||||
proxyConf: Tuple[str, int, Optional[bytes]],
|
||||
contextFactory: IPolicyForHTTPS,
|
||||
connectTimeout: Optional[float] = None,
|
||||
bindAddress: Optional[bytes] = None,
|
||||
pool: Optional[HTTPConnectionPool] = None,
|
||||
):
|
||||
super().__init__(reactor, contextFactory, connectTimeout, bindAddress, pool)
|
||||
self._proxyConf = proxyConf
|
||||
self._contextFactory = contextFactory
|
||||
self._proxyConf: Tuple[str, int, Optional[bytes]] = 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 +263,15 @@ class TunnelingAgent(Agent):
|
|||
)
|
||||
|
||||
def _requestWithEndpoint(
|
||||
self, key, endpoint, method, parsedURI, headers, bodyProducer, requestPath
|
||||
):
|
||||
self,
|
||||
key: Any,
|
||||
endpoint: TCP4ClientEndpoint,
|
||||
method: bytes,
|
||||
parsedURI: bytes,
|
||||
headers: Optional[TxHeaders],
|
||||
bodyProducer: Optional[IBodyProducer],
|
||||
requestPath: bytes,
|
||||
) -> Deferred:
|
||||
# proxy host and port are required for HTTP pool `key`
|
||||
# otherwise, same remote host connection request could reuse
|
||||
# a cached tunneled connection to a different proxy
|
||||
|
|
@ -264,7 +289,12 @@ class TunnelingAgent(Agent):
|
|||
|
||||
class ScrapyProxyAgent(Agent):
|
||||
def __init__(
|
||||
self, reactor, proxyURI, connectTimeout=None, bindAddress=None, pool=None
|
||||
self,
|
||||
reactor: ReactorBase,
|
||||
proxyURI: bytes,
|
||||
connectTimeout: Optional[float] = None,
|
||||
bindAddress: Optional[bytes] = None,
|
||||
pool: Optional[HTTPConnectionPool] = None,
|
||||
):
|
||||
super().__init__(
|
||||
reactor=reactor,
|
||||
|
|
@ -272,9 +302,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: Optional[TxHeaders] = None,
|
||||
bodyProducer: Optional[IBodyProducer] = None,
|
||||
) -> Deferred:
|
||||
"""
|
||||
Issue a new request via the configured proxy.
|
||||
"""
|
||||
|
|
@ -298,26 +334,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: Optional[bytes] = None,
|
||||
pool: Optional[HTTPConnectionPool] = 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: Optional[bytes] = bindAddress
|
||||
self._pool: Optional[HTTPConnectionPool] = pool
|
||||
self._maxsize: int = maxsize
|
||||
self._warnsize: int = warnsize
|
||||
self._fail_on_dataloss: bool = fail_on_dataloss
|
||||
self._txresponse: Optional[TxResponse] = 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 +362,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 +375,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 +394,7 @@ class ScrapyAgent:
|
|||
pool=self._pool,
|
||||
)
|
||||
|
||||
def download_request(self, request):
|
||||
def download_request(self, request: Request) -> Deferred:
|
||||
from twisted.internet import reactor
|
||||
|
||||
timeout = request.meta.get("download_timeout") or self._connectTimeout
|
||||
|
|
@ -372,7 +411,7 @@ class ScrapyAgent:
|
|||
else:
|
||||
bodyproducer = None
|
||||
start_time = time()
|
||||
d = agent.request(
|
||||
d: Deferred = agent.request(
|
||||
method, to_bytes(url, encoding="ascii"), headers, bodyproducer
|
||||
)
|
||||
# set download latency
|
||||
|
|
@ -385,7 +424,9 @@ class ScrapyAgent:
|
|||
d.addBoth(self._cb_timeout, request, url, timeout)
|
||||
return d
|
||||
|
||||
def _cb_timeout(self, result, request, url, timeout):
|
||||
def _cb_timeout(
|
||||
self, result: Any, request: Request, url: str, timeout: float
|
||||
) -> Any:
|
||||
if self._timeout_cl.active():
|
||||
self._timeout_cl.cancel()
|
||||
return result
|
||||
|
|
@ -396,19 +437,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: Any, request: Request, start_time: float) -> Any:
|
||||
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
|
||||
) -> Union[Dict[str, Any], Deferred]:
|
||||
headers_received_result = self._crawler.signals.send_catch_log(
|
||||
signal=signals.headers_received,
|
||||
headers=self._headers_from_twisted_response(txresponse),
|
||||
|
|
@ -464,7 +507,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 +516,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 = Deferred(_cancel)
|
||||
txresponse.deliverBody(
|
||||
_ResponseReader(
|
||||
finished=d,
|
||||
|
|
@ -495,7 +538,9 @@ class ScrapyAgent:
|
|||
|
||||
return d
|
||||
|
||||
def _cb_bodydone(self, result, request, url):
|
||||
def _cb_bodydone(
|
||||
self, result: Dict[str, Any], request: Request, url: str
|
||||
) -> Union[Response, Failure]:
|
||||
headers = self._headers_from_twisted_response(result["txresponse"])
|
||||
respcls = responsetypes.from_args(headers=headers, url=url, body=result["body"])
|
||||
try:
|
||||
|
|
@ -515,53 +560,57 @@ class ScrapyAgent:
|
|||
)
|
||||
if result.get("failure"):
|
||||
result["failure"].value.response = response
|
||||
return result["failure"]
|
||||
return cast(Failure, result["failure"])
|
||||
return response
|
||||
|
||||
|
||||
@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:
|
||||
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,
|
||||
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 = 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: Optional[ssl.Certificate] = None
|
||||
self._ip_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address, None] = (
|
||||
None
|
||||
)
|
||||
self._crawler: Crawler = crawler
|
||||
|
||||
def _finish_response(self, flags=None, failure=None):
|
||||
def _finish_response(
|
||||
self, flags: Optional[List[str]] = None, failure: Optional[Failure] = None
|
||||
) -> None:
|
||||
self._finished.callback(
|
||||
{
|
||||
"txresponse": self._txresponse,
|
||||
|
|
@ -573,7 +622,8 @@ class _ResponseReader(protocol.Protocol):
|
|||
}
|
||||
)
|
||||
|
||||
def connectionMade(self):
|
||||
def connectionMade(self) -> None:
|
||||
assert self.transport
|
||||
if self._certificate is None:
|
||||
with suppress(AttributeError):
|
||||
self._certificate = ssl.Certificate(
|
||||
|
|
@ -585,11 +635,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 +687,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,11 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from time import time
|
||||
from typing import Optional, Type, TypeVar
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
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 twisted.web.iweb import IPolicyForHTTPS
|
||||
|
||||
from scrapy.core.downloader.contextfactory import load_context_factory_from_settings
|
||||
from scrapy.core.downloader.webclient import _parse
|
||||
|
|
@ -16,13 +19,13 @@ 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:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
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,9 +34,7 @@ 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:
|
||||
|
|
@ -54,7 +55,7 @@ class ScrapyH2Agent:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
context_factory,
|
||||
context_factory: IPolicyForHTTPS,
|
||||
pool: H2ConnectionPool,
|
||||
connect_timeout: int = 10,
|
||||
bind_address: Optional[bytes] = None,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type
|
||||
|
||||
from twisted.internet.defer import Deferred
|
||||
|
||||
from scrapy import Request, Spider
|
||||
from scrapy.core.downloader.handlers.http import HTTPDownloadHandler
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.utils.boto import is_botocore_available
|
||||
from scrapy.utils.httpobj import urlparse_cached
|
||||
from scrapy.utils.misc import build_from_crawler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
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: Optional[str] = None,
|
||||
aws_secret_access_key: Optional[str] = None,
|
||||
aws_session_token: Optional[str] = None,
|
||||
httpdownloadhandler: Type[HTTPDownloadHandler] = HTTPDownloadHandler,
|
||||
**kw: Any,
|
||||
):
|
||||
if not is_botocore_available():
|
||||
raise NotConfigured("missing botocore library")
|
||||
|
|
@ -43,6 +56,8 @@ 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.credentials.Credentials(
|
||||
|
|
@ -57,10 +72,10 @@ class S3DownloadHandler:
|
|||
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:
|
||||
p = urlparse_cached(request)
|
||||
scheme = "https" if request.meta.get("is_secure") else "http"
|
||||
bucket = p.hostname
|
||||
|
|
@ -77,6 +92,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)
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ class ExecutionEngine:
|
|||
|
||||
assert self.spider is not None
|
||||
dwld = self.downloader.fetch(request, self.spider)
|
||||
dwld.addCallbacks(_on_success)
|
||||
dwld.addCallback(_on_success)
|
||||
dwld.addBoth(_on_complete)
|
||||
return dwld
|
||||
|
||||
|
|
@ -366,7 +366,8 @@ class ExecutionEngine:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -4,19 +4,23 @@ import json
|
|||
import logging
|
||||
from abc import abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Optional, Type, cast
|
||||
|
||||
from twisted.internet.defer import Deferred
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.dupefilters import BaseDupeFilter
|
||||
from scrapy.http.request import Request
|
||||
from scrapy.pqueues import ScrapyPriorityQueue
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.statscollectors import StatsCollector
|
||||
from scrapy.utils.job import job_dir
|
||||
from scrapy.utils.misc import build_from_crawler, load_object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# requires queuelib >= 1.6.2
|
||||
from queuelib.queue import BaseQueue
|
||||
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
|
@ -121,9 +125,6 @@ class BaseScheduler(metaclass=BaseSchedulerMeta):
|
|||
raise NotImplementedError()
|
||||
|
||||
|
||||
SchedulerTV = TypeVar("SchedulerTV", bound="Scheduler")
|
||||
|
||||
|
||||
class Scheduler(BaseScheduler):
|
||||
"""
|
||||
Default Scrapy scheduler. This implementation also handles duplication
|
||||
|
|
@ -179,24 +180,24 @@ class Scheduler(BaseScheduler):
|
|||
self,
|
||||
dupefilter: BaseDupeFilter,
|
||||
jobdir: Optional[str] = None,
|
||||
dqclass=None,
|
||||
mqclass=None,
|
||||
dqclass: Optional[Type[BaseQueue]] = None,
|
||||
mqclass: Optional[Type[BaseQueue]] = None,
|
||||
logunser: bool = False,
|
||||
stats: Optional[StatsCollector] = None,
|
||||
pqclass=None,
|
||||
pqclass: Optional[Type[ScrapyPriorityQueue]] = None,
|
||||
crawler: Optional[Crawler] = None,
|
||||
):
|
||||
self.df: BaseDupeFilter = dupefilter
|
||||
self.dqdir: Optional[str] = self._dqdir(jobdir)
|
||||
self.pqclass = pqclass
|
||||
self.dqclass = dqclass
|
||||
self.mqclass = mqclass
|
||||
self.pqclass: Optional[Type[ScrapyPriorityQueue]] = pqclass
|
||||
self.dqclass: Optional[Type[BaseQueue]] = dqclass
|
||||
self.mqclass: Optional[Type[BaseQueue]] = mqclass
|
||||
self.logunser: bool = logunser
|
||||
self.stats: Optional[StatsCollector] = stats
|
||||
self.crawler: Optional[Crawler] = crawler
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls: Type[SchedulerTV], crawler: Crawler) -> SchedulerTV:
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
"""
|
||||
Factory method, initializes the scheduler with arguments taken from the crawl settings
|
||||
"""
|
||||
|
|
@ -221,9 +222,9 @@ class Scheduler(BaseScheduler):
|
|||
(2) initialize the disk queue if the ``jobdir`` attribute is a valid directory
|
||||
(3) return the result of the dupefilter's ``open`` method
|
||||
"""
|
||||
self.spider = spider
|
||||
self.mqs = self._mq()
|
||||
self.dqs = self._dq() if self.dqdir else None
|
||||
self.spider: Spider = spider
|
||||
self.mqs: ScrapyPriorityQueue = self._mq()
|
||||
self.dqs: Optional[ScrapyPriorityQueue] = self._dq() if self.dqdir else None
|
||||
return self.df.open()
|
||||
|
||||
def close(self, reason: str) -> Optional[Deferred]:
|
||||
|
|
@ -320,8 +321,10 @@ class Scheduler(BaseScheduler):
|
|||
return self.dqs.pop()
|
||||
return None
|
||||
|
||||
def _mq(self):
|
||||
def _mq(self) -> ScrapyPriorityQueue:
|
||||
"""Create a new priority queue instance, with in-memory storage"""
|
||||
assert self.crawler
|
||||
assert self.pqclass
|
||||
return build_from_crawler(
|
||||
self.pqclass,
|
||||
self.crawler,
|
||||
|
|
@ -329,9 +332,11 @@ class Scheduler(BaseScheduler):
|
|||
key="",
|
||||
)
|
||||
|
||||
def _dq(self):
|
||||
def _dq(self) -> ScrapyPriorityQueue:
|
||||
"""Create a new priority queue instance, with disk storage"""
|
||||
assert self.crawler
|
||||
assert self.dqdir
|
||||
assert self.pqclass
|
||||
state = self._read_dqs_state(self.dqdir)
|
||||
q = build_from_crawler(
|
||||
self.pqclass,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from collections import deque
|
|||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
AsyncIterable,
|
||||
Deque,
|
||||
Generator,
|
||||
|
|
@ -18,6 +17,7 @@ from typing import (
|
|||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from itemadapter import is_item
|
||||
|
|
@ -184,7 +184,9 @@ class Scraper:
|
|||
result, request, spider
|
||||
) # returns spider's processed output
|
||||
dfd.addErrback(self.handle_spider_error, request, result, spider)
|
||||
dfd.addCallback(self.handle_spider_output, request, result, spider)
|
||||
dfd.addCallback(
|
||||
self.handle_spider_output, request, cast(Response, result), spider
|
||||
)
|
||||
return dfd
|
||||
|
||||
def _scrape2(
|
||||
|
|
@ -256,12 +258,12 @@ class Scraper:
|
|||
self,
|
||||
result: Union[Iterable, AsyncIterable],
|
||||
request: Request,
|
||||
response: Union[Response, Failure],
|
||||
response: Response,
|
||||
spider: Spider,
|
||||
) -> Deferred:
|
||||
if not result:
|
||||
return defer_succeed(None)
|
||||
it: Union[Generator, AsyncGenerator]
|
||||
it: Union[Iterable, AsyncIterable]
|
||||
if isinstance(result, AsyncIterable):
|
||||
it = aiter_errback(
|
||||
result, self.handle_spider_error, request, response, spider
|
||||
|
|
|
|||
|
|
@ -303,10 +303,8 @@ class SpiderMiddlewareManager(MiddlewareManager):
|
|||
dfd = mustbe_deferred(
|
||||
self._process_spider_input, scrape_func, response, request, spider
|
||||
)
|
||||
dfd.addCallbacks(
|
||||
callback=deferred_f_from_coro_f(process_callback_output),
|
||||
errback=process_spider_exception,
|
||||
)
|
||||
dfd.addCallback(deferred_f_from_coro_f(process_callback_output))
|
||||
dfd.addErrback(process_spider_exception)
|
||||
return dfd
|
||||
|
||||
def process_start_requests(
|
||||
|
|
|
|||
|
|
@ -445,7 +445,9 @@ class CrawlerProcess(CrawlerRunner):
|
|||
d.addBoth(self._stop_reactor)
|
||||
|
||||
resolver_class = load_object(self.settings["DNS_RESOLVER"])
|
||||
resolver = build_from_crawler(resolver_class, self, reactor=reactor)
|
||||
# We pass self, which is CrawlerProcess, instead of Crawler here,
|
||||
# which works because the default resolvers only use crawler.settings.
|
||||
resolver = build_from_crawler(resolver_class, self, reactor=reactor) # type: ignore[arg-type]
|
||||
resolver.install_on_reactor()
|
||||
tp = reactor.getThreadPool()
|
||||
tp.adjustPoolsize(maxthreads=self.settings.getint("REACTOR_THREADPOOL_MAXSIZE"))
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ logger = getLogger(__name__)
|
|||
ACCEPTED_ENCODINGS: List[bytes] = [b"gzip", b"deflate"]
|
||||
|
||||
try:
|
||||
import brotli # noqa: F401
|
||||
try:
|
||||
import brotli # noqa: F401
|
||||
except ImportError:
|
||||
import brotlicffi # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@ Item Exporters are used to export/serialize items into different formats.
|
|||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import marshal
|
||||
import pickle
|
||||
import pickle # nosec
|
||||
import pprint
|
||||
from collections.abc import Mapping
|
||||
from xml.sax.saxutils import XMLGenerator
|
||||
from io import BytesIO, TextIOWrapper
|
||||
from json import JSONEncoder
|
||||
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple, Union
|
||||
from xml.sax.saxutils import XMLGenerator # nosec
|
||||
from xml.sax.xmlreader import AttributesImpl # nosec
|
||||
|
||||
from itemadapter import ItemAdapter, is_item
|
||||
|
||||
from scrapy.item import Item
|
||||
from scrapy.item import Field, Item
|
||||
from scrapy.utils.python import is_listlike, to_bytes, to_unicode
|
||||
from scrapy.utils.serialize import ScrapyJSONEncoder
|
||||
|
||||
|
|
@ -29,36 +31,42 @@ __all__ = [
|
|||
|
||||
|
||||
class BaseItemExporter:
|
||||
def __init__(self, *, dont_fail=False, **kwargs):
|
||||
self._kwargs = kwargs
|
||||
def __init__(self, *, dont_fail: bool = False, **kwargs: Any):
|
||||
self._kwargs: Dict[str, Any] = kwargs
|
||||
self._configure(kwargs, dont_fail=dont_fail)
|
||||
|
||||
def _configure(self, options, dont_fail=False):
|
||||
def _configure(self, options: Dict[str, Any], dont_fail: bool = False) -> None:
|
||||
"""Configure the exporter by popping options from the ``options`` dict.
|
||||
If dont_fail is set, it won't raise an exception on unexpected options
|
||||
(useful for using with keyword arguments in subclasses ``__init__`` methods)
|
||||
"""
|
||||
self.encoding = options.pop("encoding", None)
|
||||
self.fields_to_export = options.pop("fields_to_export", None)
|
||||
self.export_empty_fields = options.pop("export_empty_fields", False)
|
||||
self.indent = options.pop("indent", None)
|
||||
self.encoding: Optional[str] = options.pop("encoding", None)
|
||||
self.fields_to_export: Union[Mapping[str, str], Iterable[str], None] = (
|
||||
options.pop("fields_to_export", None)
|
||||
)
|
||||
self.export_empty_fields: bool = options.pop("export_empty_fields", False)
|
||||
self.indent: Optional[int] = options.pop("indent", None)
|
||||
if not dont_fail and options:
|
||||
raise TypeError(f"Unexpected options: {', '.join(options.keys())}")
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def serialize_field(self, field, name, value):
|
||||
serializer = field.get("serializer", lambda x: x)
|
||||
def serialize_field(
|
||||
self, field: Union[Mapping[str, Any], Field], name: str, value: Any
|
||||
) -> Any:
|
||||
serializer: Callable[[Any], Any] = field.get("serializer", lambda x: x)
|
||||
return serializer(value)
|
||||
|
||||
def start_exporting(self):
|
||||
def start_exporting(self) -> None:
|
||||
pass
|
||||
|
||||
def finish_exporting(self):
|
||||
def finish_exporting(self) -> None:
|
||||
pass
|
||||
|
||||
def _get_serialized_fields(self, item, default_value=None, include_empty=None):
|
||||
def _get_serialized_fields(
|
||||
self, item: Any, default_value: Any = None, include_empty: Optional[bool] = None
|
||||
) -> Iterable[Tuple[str, Any]]:
|
||||
"""Return the fields to export as an iterable of tuples
|
||||
(name, serialized_value)
|
||||
"""
|
||||
|
|
@ -100,22 +108,22 @@ class BaseItemExporter:
|
|||
|
||||
|
||||
class JsonLinesItemExporter(BaseItemExporter):
|
||||
def __init__(self, file, **kwargs):
|
||||
def __init__(self, file: BytesIO, **kwargs: Any):
|
||||
super().__init__(dont_fail=True, **kwargs)
|
||||
self.file = file
|
||||
self.file: BytesIO = file
|
||||
self._kwargs.setdefault("ensure_ascii", not self.encoding)
|
||||
self.encoder = ScrapyJSONEncoder(**self._kwargs)
|
||||
self.encoder: JSONEncoder = ScrapyJSONEncoder(**self._kwargs)
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
itemdict = dict(self._get_serialized_fields(item))
|
||||
data = self.encoder.encode(itemdict) + "\n"
|
||||
self.file.write(to_bytes(data, self.encoding))
|
||||
|
||||
|
||||
class JsonItemExporter(BaseItemExporter):
|
||||
def __init__(self, file, **kwargs):
|
||||
def __init__(self, file: BytesIO, **kwargs: Any):
|
||||
super().__init__(dont_fail=True, **kwargs)
|
||||
self.file = file
|
||||
self.file: BytesIO = file
|
||||
# there is a small difference between the behaviour or JsonItemExporter.indent
|
||||
# and ScrapyJSONEncoder.indent. ScrapyJSONEncoder.indent=None is needed to prevent
|
||||
# the addition of newlines everywhere
|
||||
|
|
@ -127,26 +135,26 @@ class JsonItemExporter(BaseItemExporter):
|
|||
self.encoder = ScrapyJSONEncoder(**self._kwargs)
|
||||
self.first_item = True
|
||||
|
||||
def _beautify_newline(self):
|
||||
def _beautify_newline(self) -> None:
|
||||
if self.indent is not None:
|
||||
self.file.write(b"\n")
|
||||
|
||||
def _add_comma_after_first(self):
|
||||
def _add_comma_after_first(self) -> None:
|
||||
if self.first_item:
|
||||
self.first_item = False
|
||||
else:
|
||||
self.file.write(b",")
|
||||
self._beautify_newline()
|
||||
|
||||
def start_exporting(self):
|
||||
def start_exporting(self) -> None:
|
||||
self.file.write(b"[")
|
||||
self._beautify_newline()
|
||||
|
||||
def finish_exporting(self):
|
||||
def finish_exporting(self) -> None:
|
||||
self._beautify_newline()
|
||||
self.file.write(b"]")
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
itemdict = dict(self._get_serialized_fields(item))
|
||||
data = to_bytes(self.encoder.encode(itemdict), self.encoding)
|
||||
self._add_comma_after_first()
|
||||
|
|
@ -154,7 +162,7 @@ class JsonItemExporter(BaseItemExporter):
|
|||
|
||||
|
||||
class XmlItemExporter(BaseItemExporter):
|
||||
def __init__(self, file, **kwargs):
|
||||
def __init__(self, file: BytesIO, **kwargs: Any):
|
||||
self.item_element = kwargs.pop("item_element", "item")
|
||||
self.root_element = kwargs.pop("root_element", "items")
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -162,22 +170,22 @@ class XmlItemExporter(BaseItemExporter):
|
|||
self.encoding = "utf-8"
|
||||
self.xg = XMLGenerator(file, encoding=self.encoding)
|
||||
|
||||
def _beautify_newline(self, new_item=False):
|
||||
def _beautify_newline(self, new_item: bool = False) -> None:
|
||||
if self.indent is not None and (self.indent > 0 or new_item):
|
||||
self.xg.characters("\n")
|
||||
|
||||
def _beautify_indent(self, depth=1):
|
||||
def _beautify_indent(self, depth: int = 1) -> None:
|
||||
if self.indent:
|
||||
self.xg.characters(" " * self.indent * depth)
|
||||
|
||||
def start_exporting(self):
|
||||
def start_exporting(self) -> None:
|
||||
self.xg.startDocument()
|
||||
self.xg.startElement(self.root_element, {})
|
||||
self.xg.startElement(self.root_element, AttributesImpl({}))
|
||||
self._beautify_newline(new_item=True)
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
self._beautify_indent(depth=1)
|
||||
self.xg.startElement(self.item_element, {})
|
||||
self.xg.startElement(self.item_element, AttributesImpl({}))
|
||||
self._beautify_newline()
|
||||
for name, value in self._get_serialized_fields(item, default_value=""):
|
||||
self._export_xml_field(name, value, depth=2)
|
||||
|
|
@ -185,13 +193,13 @@ class XmlItemExporter(BaseItemExporter):
|
|||
self.xg.endElement(self.item_element)
|
||||
self._beautify_newline(new_item=True)
|
||||
|
||||
def finish_exporting(self):
|
||||
def finish_exporting(self) -> None:
|
||||
self.xg.endElement(self.root_element)
|
||||
self.xg.endDocument()
|
||||
|
||||
def _export_xml_field(self, name, serialized_value, depth):
|
||||
def _export_xml_field(self, name: str, serialized_value: Any, depth: int) -> None:
|
||||
self._beautify_indent(depth=depth)
|
||||
self.xg.startElement(name, {})
|
||||
self.xg.startElement(name, AttributesImpl({}))
|
||||
if hasattr(serialized_value, "items"):
|
||||
self._beautify_newline()
|
||||
for subname, value in serialized_value.items():
|
||||
|
|
@ -213,17 +221,17 @@ class XmlItemExporter(BaseItemExporter):
|
|||
class CsvItemExporter(BaseItemExporter):
|
||||
def __init__(
|
||||
self,
|
||||
file,
|
||||
include_headers_line=True,
|
||||
join_multivalued=",",
|
||||
errors=None,
|
||||
**kwargs,
|
||||
file: BytesIO,
|
||||
include_headers_line: bool = True,
|
||||
join_multivalued: str = ",",
|
||||
errors: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(dont_fail=True, **kwargs)
|
||||
if not self.encoding:
|
||||
self.encoding = "utf-8"
|
||||
self.include_headers_line = include_headers_line
|
||||
self.stream = io.TextIOWrapper(
|
||||
self.stream = TextIOWrapper(
|
||||
file,
|
||||
line_buffering=False,
|
||||
write_through=True,
|
||||
|
|
@ -235,11 +243,13 @@ class CsvItemExporter(BaseItemExporter):
|
|||
self._headers_not_written = True
|
||||
self._join_multivalued = join_multivalued
|
||||
|
||||
def serialize_field(self, field, name, value):
|
||||
serializer = field.get("serializer", self._join_if_needed)
|
||||
def serialize_field(
|
||||
self, field: Union[Mapping[str, Any], Field], name: str, value: Any
|
||||
) -> Any:
|
||||
serializer: Callable[[Any], Any] = field.get("serializer", self._join_if_needed)
|
||||
return serializer(value)
|
||||
|
||||
def _join_if_needed(self, value):
|
||||
def _join_if_needed(self, value: Any) -> Any:
|
||||
if isinstance(value, (list, tuple)):
|
||||
try:
|
||||
return self._join_multivalued.join(value)
|
||||
|
|
@ -247,7 +257,7 @@ class CsvItemExporter(BaseItemExporter):
|
|||
pass
|
||||
return value
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
if self._headers_not_written:
|
||||
self._headers_not_written = False
|
||||
self._write_headers_and_set_fields_to_export(item)
|
||||
|
|
@ -256,36 +266,38 @@ class CsvItemExporter(BaseItemExporter):
|
|||
values = list(self._build_row(x for _, x in fields))
|
||||
self.csv_writer.writerow(values)
|
||||
|
||||
def finish_exporting(self):
|
||||
def finish_exporting(self) -> None:
|
||||
self.stream.detach() # Avoid closing the wrapped file.
|
||||
|
||||
def _build_row(self, values):
|
||||
def _build_row(self, values: Iterable[Any]) -> Iterable[Any]:
|
||||
for s in values:
|
||||
try:
|
||||
yield to_unicode(s, self.encoding)
|
||||
except TypeError:
|
||||
yield s
|
||||
|
||||
def _write_headers_and_set_fields_to_export(self, item):
|
||||
def _write_headers_and_set_fields_to_export(self, item: Any) -> None:
|
||||
if self.include_headers_line:
|
||||
if not self.fields_to_export:
|
||||
# use declared field names, or keys if the item is a dict
|
||||
self.fields_to_export = ItemAdapter(item).field_names()
|
||||
fields: Iterable[str]
|
||||
if isinstance(self.fields_to_export, Mapping):
|
||||
fields = self.fields_to_export.values()
|
||||
else:
|
||||
assert self.fields_to_export
|
||||
fields = self.fields_to_export
|
||||
row = list(self._build_row(fields))
|
||||
self.csv_writer.writerow(row)
|
||||
|
||||
|
||||
class PickleItemExporter(BaseItemExporter):
|
||||
def __init__(self, file, protocol=4, **kwargs):
|
||||
def __init__(self, file: BytesIO, protocol: int = 4, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
self.file = file
|
||||
self.protocol = protocol
|
||||
self.file: BytesIO = file
|
||||
self.protocol: int = protocol
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
d = dict(self._get_serialized_fields(item))
|
||||
pickle.dump(d, self.file, self.protocol)
|
||||
|
||||
|
|
@ -299,20 +311,20 @@ class MarshalItemExporter(BaseItemExporter):
|
|||
opened in binary mode, a :class:`~io.BytesIO` object, etc)
|
||||
"""
|
||||
|
||||
def __init__(self, file, **kwargs):
|
||||
def __init__(self, file: BytesIO, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
self.file = file
|
||||
self.file: BytesIO = file
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
marshal.dump(dict(self._get_serialized_fields(item)), self.file)
|
||||
|
||||
|
||||
class PprintItemExporter(BaseItemExporter):
|
||||
def __init__(self, file, **kwargs):
|
||||
def __init__(self, file: BytesIO, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
self.file = file
|
||||
self.file: BytesIO = file
|
||||
|
||||
def export_item(self, item):
|
||||
def export_item(self, item: Any) -> None:
|
||||
itemdict = dict(self._get_serialized_fields(item))
|
||||
self.file.write(to_bytes(pprint.pformat(itemdict) + "\n"))
|
||||
|
||||
|
|
@ -327,16 +339,20 @@ class PythonItemExporter(BaseItemExporter):
|
|||
.. _msgpack: https://pypi.org/project/msgpack/
|
||||
"""
|
||||
|
||||
def _configure(self, options, dont_fail=False):
|
||||
def _configure(self, options: Dict[str, Any], dont_fail: bool = False) -> None:
|
||||
super()._configure(options, dont_fail)
|
||||
if not self.encoding:
|
||||
self.encoding = "utf-8"
|
||||
|
||||
def serialize_field(self, field, name, value):
|
||||
serializer = field.get("serializer", self._serialize_value)
|
||||
def serialize_field(
|
||||
self, field: Union[Mapping[str, Any], Field], name: str, value: Any
|
||||
) -> Any:
|
||||
serializer: Callable[[Any], Any] = field.get(
|
||||
"serializer", self._serialize_value
|
||||
)
|
||||
return serializer(value)
|
||||
|
||||
def _serialize_value(self, value):
|
||||
def _serialize_value(self, value: Any) -> Any:
|
||||
if isinstance(value, Item):
|
||||
return self.export_item(value)
|
||||
if is_item(value):
|
||||
|
|
@ -347,10 +363,10 @@ class PythonItemExporter(BaseItemExporter):
|
|||
return to_unicode(value, encoding=self.encoding)
|
||||
return value
|
||||
|
||||
def _serialize_item(self, item):
|
||||
def _serialize_item(self, item: Any) -> Iterable[Tuple[Union[str, bytes], Any]]:
|
||||
for key, value in ItemAdapter(item).items():
|
||||
yield key, self._serialize_value(value)
|
||||
|
||||
def export_item(self, item):
|
||||
result = dict(self._get_serialized_fields(item))
|
||||
def export_item(self, item: Any) -> Dict[Union[str, bytes], Any]: # type: ignore[override]
|
||||
result: Dict[Union[str, bytes], Any] = dict(self._get_serialized_fields(item))
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ The Extension Manager
|
|||
See documentation in docs/topics/extensions.rst
|
||||
"""
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from scrapy.middleware import MiddlewareManager
|
||||
from scrapy.settings import Settings
|
||||
from scrapy.utils.conf import build_component_list
|
||||
|
||||
|
||||
|
|
@ -12,5 +15,5 @@ class ExtensionManager(MiddlewareManager):
|
|||
component_name = "extension"
|
||||
|
||||
@classmethod
|
||||
def _get_mwlist_from_settings(cls, settings):
|
||||
def _get_mwlist_from_settings(cls, settings: Settings) -> List[Any]:
|
||||
return build_component_list(settings.getwithbase("EXTENSIONS"))
|
||||
|
|
|
|||
|
|
@ -4,20 +4,31 @@ conditions are met.
|
|||
See documentation in docs/topics/extensions.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING, Any, DefaultDict, Dict
|
||||
|
||||
from scrapy import signals
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from scrapy import Request, Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.http import Response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloseSpider:
|
||||
def __init__(self, crawler):
|
||||
self.crawler = crawler
|
||||
def __init__(self, crawler: Crawler):
|
||||
self.crawler: Crawler = crawler
|
||||
|
||||
self.close_on = {
|
||||
self.close_on: Dict[str, Any] = {
|
||||
"timeout": crawler.settings.getfloat("CLOSESPIDER_TIMEOUT"),
|
||||
"itemcount": crawler.settings.getint("CLOSESPIDER_ITEMCOUNT"),
|
||||
"pagecount": crawler.settings.getint("CLOSESPIDER_PAGECOUNT"),
|
||||
|
|
@ -28,7 +39,7 @@ class CloseSpider:
|
|||
if not any(self.close_on.values()):
|
||||
raise NotConfigured
|
||||
|
||||
self.counter = defaultdict(int)
|
||||
self.counter: DefaultDict[str, int] = defaultdict(int)
|
||||
|
||||
if self.close_on.get("errorcount"):
|
||||
crawler.signals.connect(self.error_count, signal=signals.spider_error)
|
||||
|
|
@ -39,8 +50,8 @@ class CloseSpider:
|
|||
if self.close_on.get("itemcount"):
|
||||
crawler.signals.connect(self.item_scraped, signal=signals.item_scraped)
|
||||
if self.close_on.get("timeout_no_item"):
|
||||
self.timeout_no_item = self.close_on["timeout_no_item"]
|
||||
self.items_in_period = 0
|
||||
self.timeout_no_item: int = self.close_on["timeout_no_item"]
|
||||
self.items_in_period: int = 0
|
||||
crawler.signals.connect(
|
||||
self.spider_opened_no_item, signal=signals.spider_opened
|
||||
)
|
||||
|
|
@ -50,22 +61,25 @@ class CloseSpider:
|
|||
crawler.signals.connect(self.spider_closed, signal=signals.spider_closed)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
return cls(crawler)
|
||||
|
||||
def error_count(self, failure, response, spider):
|
||||
def error_count(self, failure: Failure, response: Response, spider: Spider) -> None:
|
||||
self.counter["errorcount"] += 1
|
||||
if self.counter["errorcount"] == self.close_on["errorcount"]:
|
||||
assert self.crawler.engine
|
||||
self.crawler.engine.close_spider(spider, "closespider_errorcount")
|
||||
|
||||
def page_count(self, response, request, spider):
|
||||
def page_count(self, response: Response, request: Request, spider: Spider) -> None:
|
||||
self.counter["pagecount"] += 1
|
||||
if self.counter["pagecount"] == self.close_on["pagecount"]:
|
||||
assert self.crawler.engine
|
||||
self.crawler.engine.close_spider(spider, "closespider_pagecount")
|
||||
|
||||
def spider_opened(self, spider):
|
||||
def spider_opened(self, spider: Spider) -> None:
|
||||
from twisted.internet import reactor
|
||||
|
||||
assert self.crawler.engine
|
||||
self.task = reactor.callLater(
|
||||
self.close_on["timeout"],
|
||||
self.crawler.engine.close_spider,
|
||||
|
|
@ -73,21 +87,22 @@ class CloseSpider:
|
|||
reason="closespider_timeout",
|
||||
)
|
||||
|
||||
def item_scraped(self, item, spider):
|
||||
def item_scraped(self, item: Any, spider: Spider) -> None:
|
||||
self.counter["itemcount"] += 1
|
||||
if self.counter["itemcount"] == self.close_on["itemcount"]:
|
||||
assert self.crawler.engine
|
||||
self.crawler.engine.close_spider(spider, "closespider_itemcount")
|
||||
|
||||
def spider_closed(self, spider):
|
||||
task = getattr(self, "task", False)
|
||||
def spider_closed(self, spider: Spider) -> None:
|
||||
task = getattr(self, "task", None)
|
||||
if task and task.active():
|
||||
task.cancel()
|
||||
|
||||
task_no_item = getattr(self, "task_no_item", False)
|
||||
task_no_item = getattr(self, "task_no_item", None)
|
||||
if task_no_item and task_no_item.running:
|
||||
task_no_item.stop()
|
||||
|
||||
def spider_opened_no_item(self, spider):
|
||||
def spider_opened_no_item(self, spider: Spider) -> None:
|
||||
from twisted.internet import task
|
||||
|
||||
self.task_no_item = task.LoopingCall(self._count_items_produced, spider)
|
||||
|
|
@ -98,10 +113,10 @@ class CloseSpider:
|
|||
f"{self.timeout_no_item} seconds."
|
||||
)
|
||||
|
||||
def item_scraped_no_item(self, item, spider):
|
||||
def item_scraped_no_item(self, item: Any, spider: Spider) -> None:
|
||||
self.items_in_period += 1
|
||||
|
||||
def _count_items_produced(self, spider):
|
||||
def _count_items_produced(self, spider: Spider) -> None:
|
||||
if self.items_in_period >= 1:
|
||||
self.items_in_period = 0
|
||||
else:
|
||||
|
|
@ -109,4 +124,5 @@ class CloseSpider:
|
|||
f"Closing spider since no items were produced in the last "
|
||||
f"{self.timeout_no_item} seconds."
|
||||
)
|
||||
assert self.crawler.engine
|
||||
self.crawler.engine.close_spider(spider, "closespider_timeout_no_item")
|
||||
|
|
|
|||
|
|
@ -2,18 +2,28 @@
|
|||
Extension for collecting core stats like items scraped and start/finish times
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from __future__ import annotations
|
||||
|
||||
from scrapy import signals
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.statscollectors import StatsCollector
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class CoreStats:
|
||||
def __init__(self, stats):
|
||||
self.stats = stats
|
||||
self.start_time = None
|
||||
def __init__(self, stats: StatsCollector):
|
||||
self.stats: StatsCollector = stats
|
||||
self.start_time: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
assert crawler.stats
|
||||
o = cls(crawler.stats)
|
||||
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
|
||||
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
|
||||
|
|
@ -22,11 +32,12 @@ class CoreStats:
|
|||
crawler.signals.connect(o.response_received, signal=signals.response_received)
|
||||
return o
|
||||
|
||||
def spider_opened(self, spider):
|
||||
def spider_opened(self, spider: Spider) -> None:
|
||||
self.start_time = datetime.now(tz=timezone.utc)
|
||||
self.stats.set_value("start_time", self.start_time, spider=spider)
|
||||
|
||||
def spider_closed(self, spider, reason):
|
||||
def spider_closed(self, spider: Spider, reason: str) -> None:
|
||||
assert self.start_time is not None
|
||||
finish_time = datetime.now(tz=timezone.utc)
|
||||
elapsed_time = finish_time - self.start_time
|
||||
elapsed_time_seconds = elapsed_time.total_seconds()
|
||||
|
|
@ -36,13 +47,13 @@ class CoreStats:
|
|||
self.stats.set_value("finish_time", finish_time, spider=spider)
|
||||
self.stats.set_value("finish_reason", reason, spider=spider)
|
||||
|
||||
def item_scraped(self, item, spider):
|
||||
def item_scraped(self, item: Any, spider: Spider) -> None:
|
||||
self.stats.inc_value("item_scraped_count", spider=spider)
|
||||
|
||||
def response_received(self, spider):
|
||||
def response_received(self, spider: Spider) -> None:
|
||||
self.stats.inc_value("response_received_count", spider=spider)
|
||||
|
||||
def item_dropped(self, item, spider, exception):
|
||||
def item_dropped(self, item: Any, spider: Spider, exception: BaseException) -> None:
|
||||
reason = exception.__class__.__name__
|
||||
self.stats.inc_value("item_dropped_count", spider=spider)
|
||||
self.stats.inc_value(f"item_dropped_reasons_count/{reason}", spider=spider)
|
||||
|
|
|
|||
|
|
@ -4,22 +4,31 @@ Extensions for debugging Scrapy
|
|||
See documentation in docs/topics/extensions.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from pdb import Pdb
|
||||
from types import FrameType
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.utils.engine import format_engine_status
|
||||
from scrapy.utils.trackref import format_live_refs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StackTraceDump:
|
||||
def __init__(self, crawler=None):
|
||||
self.crawler = crawler
|
||||
def __init__(self, crawler: Crawler):
|
||||
self.crawler: Crawler = crawler
|
||||
try:
|
||||
signal.signal(signal.SIGUSR2, self.dump_stacktrace)
|
||||
signal.signal(signal.SIGQUIT, self.dump_stacktrace)
|
||||
|
|
@ -28,10 +37,11 @@ class StackTraceDump:
|
|||
pass
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
return cls(crawler)
|
||||
|
||||
def dump_stacktrace(self, signum, frame):
|
||||
def dump_stacktrace(self, signum: int, frame: Optional[FrameType]) -> None:
|
||||
assert self.crawler.engine
|
||||
log_args = {
|
||||
"stackdumps": self._thread_stacks(),
|
||||
"enginestatus": format_engine_status(self.crawler.engine),
|
||||
|
|
@ -44,7 +54,7 @@ class StackTraceDump:
|
|||
extra={"crawler": self.crawler},
|
||||
)
|
||||
|
||||
def _thread_stacks(self):
|
||||
def _thread_stacks(self) -> str:
|
||||
id2name = dict((th.ident, th.name) for th in threading.enumerate())
|
||||
dumps = ""
|
||||
for id_, frame in sys._current_frames().items():
|
||||
|
|
@ -55,12 +65,13 @@ class StackTraceDump:
|
|||
|
||||
|
||||
class Debugger:
|
||||
def __init__(self):
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
signal.signal(signal.SIGUSR2, self._enter_debugger)
|
||||
except AttributeError:
|
||||
# win32 platforms don't support SIGUSR signals
|
||||
pass
|
||||
|
||||
def _enter_debugger(self, signum, frame):
|
||||
def _enter_debugger(self, signum: int, frame: Optional[FrameType]) -> None:
|
||||
assert frame
|
||||
Pdb().set_trace(frame.f_back)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Feed Exports extension
|
|||
See documentation in docs/topics/feed-exports.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
|
@ -11,17 +13,36 @@ import warnings
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import IO, Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from twisted.internet import defer, threads
|
||||
from twisted.internet.defer import DeferredList
|
||||
from twisted.internet import threads
|
||||
from twisted.internet.defer import Deferred, DeferredList, maybeDeferred
|
||||
from twisted.python.failure import Failure
|
||||
from w3lib.url import file_uri_to_path
|
||||
from zope.interface import Interface, implementer
|
||||
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured, ScrapyDeprecationWarning
|
||||
from scrapy.exporters import BaseItemExporter
|
||||
from scrapy.extensions.postprocessing import PostProcessingManager
|
||||
from scrapy.settings import BaseSettings, Settings
|
||||
from scrapy.utils.boto import is_botocore_available
|
||||
from scrapy.utils.conf import feed_complete_default_values_from_settings
|
||||
from scrapy.utils.defer import maybe_deferred_to_future
|
||||
|
|
@ -31,6 +52,12 @@ from scrapy.utils.log import failure_to_exc_info
|
|||
from scrapy.utils.misc import build_from_crawler, load_object
|
||||
from scrapy.utils.python import without_none_values
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _typeshed import OpenBinaryMode
|
||||
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
|
|
@ -40,8 +67,19 @@ try:
|
|||
except ImportError:
|
||||
IS_BOTO3_AVAILABLE = False
|
||||
|
||||
UriParamsCallableT = Callable[[Dict[str, Any], Spider], Optional[Dict[str, Any]]]
|
||||
|
||||
def build_storage(builder, uri, *args, feed_options=None, preargs=(), **kwargs):
|
||||
_StorageT = TypeVar("_StorageT", bound="FeedStorageProtocol")
|
||||
|
||||
|
||||
def build_storage(
|
||||
builder: Callable[..., _StorageT],
|
||||
uri: str,
|
||||
*args: Any,
|
||||
feed_options: Optional[Dict[str, Any]] = None,
|
||||
preargs: Iterable[Any] = (),
|
||||
**kwargs: Any,
|
||||
) -> _StorageT:
|
||||
kwargs["feed_options"] = feed_options
|
||||
return builder(*preargs, uri, *args, **kwargs)
|
||||
|
||||
|
|
@ -55,10 +93,10 @@ class ItemFilter:
|
|||
:type feed_options: dict
|
||||
"""
|
||||
|
||||
feed_options: Optional[dict]
|
||||
item_classes: Tuple
|
||||
feed_options: Optional[Dict[str, Any]]
|
||||
item_classes: Tuple[type, ...]
|
||||
|
||||
def __init__(self, feed_options: Optional[dict]) -> None:
|
||||
def __init__(self, feed_options: Optional[Dict[str, Any]]) -> None:
|
||||
self.feed_options = feed_options
|
||||
if feed_options is not None:
|
||||
self.item_classes = tuple(
|
||||
|
|
@ -97,28 +135,49 @@ class IFeedStorage(Interface):
|
|||
"""Store the given file stream"""
|
||||
|
||||
|
||||
class FeedStorageProtocol(Protocol):
|
||||
"""Reimplementation of ``IFeedStorage`` that can be used in type hints."""
|
||||
|
||||
def __init__(self, uri: str, *, feed_options: Optional[Dict[str, Any]] = None):
|
||||
"""Initialize the storage with the parameters given in the URI and the
|
||||
feed-specific options (see :setting:`FEEDS`)"""
|
||||
|
||||
def open(self, spider: Spider) -> IO[bytes]:
|
||||
"""Open the storage for the given spider. It must return a file-like
|
||||
object that will be used for the exporters"""
|
||||
|
||||
def store(self, file: IO[bytes]) -> Optional[Deferred]:
|
||||
"""Store the given file stream"""
|
||||
|
||||
|
||||
@implementer(IFeedStorage)
|
||||
class BlockingFeedStorage:
|
||||
def open(self, spider):
|
||||
def open(self, spider: Spider) -> IO[bytes]:
|
||||
path = spider.crawler.settings["FEED_TEMPDIR"]
|
||||
if path and not Path(path).is_dir():
|
||||
raise OSError("Not a Directory: " + str(path))
|
||||
|
||||
return NamedTemporaryFile(prefix="feed-", dir=path)
|
||||
|
||||
def store(self, file):
|
||||
def store(self, file: IO[bytes]) -> Optional[Deferred]:
|
||||
return threads.deferToThread(self._store_in_thread, file)
|
||||
|
||||
def _store_in_thread(self, file):
|
||||
def _store_in_thread(self, file: IO[bytes]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@implementer(IFeedStorage)
|
||||
class StdoutFeedStorage:
|
||||
def __init__(self, uri, _stdout=None, *, feed_options=None):
|
||||
def __init__(
|
||||
self,
|
||||
uri: str,
|
||||
_stdout: Optional[IO[bytes]] = None,
|
||||
*,
|
||||
feed_options: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if not _stdout:
|
||||
_stdout = sys.stdout.buffer
|
||||
self._stdout = _stdout
|
||||
self._stdout: IO[bytes] = _stdout
|
||||
if feed_options and feed_options.get("overwrite", False) is True:
|
||||
logger.warning(
|
||||
"Standard output (stdout) storage does not support "
|
||||
|
|
@ -127,54 +186,58 @@ class StdoutFeedStorage:
|
|||
"it to False."
|
||||
)
|
||||
|
||||
def open(self, spider):
|
||||
def open(self, spider: Spider) -> IO[bytes]:
|
||||
return self._stdout
|
||||
|
||||
def store(self, file):
|
||||
def store(self, file: IO[bytes]) -> Optional[Deferred]:
|
||||
pass
|
||||
|
||||
|
||||
@implementer(IFeedStorage)
|
||||
class FileFeedStorage:
|
||||
def __init__(self, uri, *, feed_options=None):
|
||||
self.path = file_uri_to_path(uri)
|
||||
def __init__(self, uri: str, *, feed_options: Optional[Dict[str, Any]] = None):
|
||||
self.path: str = file_uri_to_path(uri)
|
||||
feed_options = feed_options or {}
|
||||
self.write_mode = "wb" if feed_options.get("overwrite", False) else "ab"
|
||||
self.write_mode: OpenBinaryMode = (
|
||||
"wb" if feed_options.get("overwrite", False) else "ab"
|
||||
)
|
||||
|
||||
def open(self, spider) -> IO[Any]:
|
||||
def open(self, spider: Spider) -> IO[bytes]:
|
||||
dirname = Path(self.path).parent
|
||||
if dirname and not dirname.exists():
|
||||
dirname.mkdir(parents=True)
|
||||
return Path(self.path).open(self.write_mode)
|
||||
|
||||
def store(self, file):
|
||||
def store(self, file: IO[bytes]) -> Optional[Deferred]:
|
||||
file.close()
|
||||
return None
|
||||
|
||||
|
||||
class S3FeedStorage(BlockingFeedStorage):
|
||||
def __init__(
|
||||
self,
|
||||
uri,
|
||||
access_key=None,
|
||||
secret_key=None,
|
||||
acl=None,
|
||||
endpoint_url=None,
|
||||
uri: str,
|
||||
access_key: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
acl: Optional[str] = None,
|
||||
endpoint_url: Optional[str] = None,
|
||||
*,
|
||||
feed_options=None,
|
||||
session_token=None,
|
||||
region_name=None,
|
||||
feed_options: Optional[Dict[str, Any]] = None,
|
||||
session_token: Optional[str] = None,
|
||||
region_name: Optional[str] = None,
|
||||
):
|
||||
if not is_botocore_available():
|
||||
raise NotConfigured("missing botocore library")
|
||||
u = urlparse(uri)
|
||||
self.bucketname = u.hostname
|
||||
self.access_key = u.username or access_key
|
||||
self.secret_key = u.password or secret_key
|
||||
self.session_token = session_token
|
||||
self.keyname = u.path[1:] # remove first "/"
|
||||
self.acl = acl
|
||||
self.endpoint_url = endpoint_url
|
||||
self.region_name = region_name
|
||||
assert u.hostname
|
||||
self.bucketname: str = u.hostname
|
||||
self.access_key: Optional[str] = u.username or access_key
|
||||
self.secret_key: Optional[str] = u.password or secret_key
|
||||
self.session_token: Optional[str] = session_token
|
||||
self.keyname: str = u.path[1:] # remove first "/"
|
||||
self.acl: Optional[str] = acl
|
||||
self.endpoint_url: Optional[str] = endpoint_url
|
||||
self.region_name: Optional[str] = region_name
|
||||
|
||||
if IS_BOTO3_AVAILABLE:
|
||||
import boto3.session
|
||||
|
|
@ -217,7 +280,13 @@ class S3FeedStorage(BlockingFeedStorage):
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, uri, *, feed_options=None):
|
||||
def from_crawler(
|
||||
cls,
|
||||
crawler: Crawler,
|
||||
uri: str,
|
||||
*,
|
||||
feed_options: Optional[Dict[str, Any]] = None,
|
||||
) -> Self:
|
||||
return build_storage(
|
||||
cls,
|
||||
uri,
|
||||
|
|
@ -230,8 +299,9 @@ class S3FeedStorage(BlockingFeedStorage):
|
|||
feed_options=feed_options,
|
||||
)
|
||||
|
||||
def _store_in_thread(self, file):
|
||||
def _store_in_thread(self, file: IO[bytes]) -> None:
|
||||
file.seek(0)
|
||||
kwargs: Dict[str, Any]
|
||||
if IS_BOTO3_AVAILABLE:
|
||||
kwargs = {"ExtraArgs": {"ACL": self.acl}} if self.acl else {}
|
||||
self.s3_client.upload_fileobj(
|
||||
|
|
@ -246,22 +316,23 @@ class S3FeedStorage(BlockingFeedStorage):
|
|||
|
||||
|
||||
class GCSFeedStorage(BlockingFeedStorage):
|
||||
def __init__(self, uri, project_id, acl):
|
||||
self.project_id = project_id
|
||||
self.acl = acl
|
||||
def __init__(self, uri: str, project_id: Optional[str], acl: Optional[str]):
|
||||
self.project_id: Optional[str] = project_id
|
||||
self.acl: Optional[str] = acl
|
||||
u = urlparse(uri)
|
||||
self.bucket_name = u.hostname
|
||||
self.blob_name = u.path[1:] # remove first "/"
|
||||
assert u.hostname
|
||||
self.bucket_name: str = u.hostname
|
||||
self.blob_name: str = u.path[1:] # remove first "/"
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, uri):
|
||||
def from_crawler(cls, crawler: Crawler, uri: str) -> Self:
|
||||
return cls(
|
||||
uri,
|
||||
crawler.settings["GCS_PROJECT_ID"],
|
||||
crawler.settings["FEED_STORAGE_GCS_ACL"] or None,
|
||||
)
|
||||
|
||||
def _store_in_thread(self, file):
|
||||
def _store_in_thread(self, file: IO[bytes]) -> None:
|
||||
file.seek(0)
|
||||
from google.cloud.storage import Client
|
||||
|
||||
|
|
@ -291,7 +362,13 @@ class FTPFeedStorage(BlockingFeedStorage):
|
|||
self.overwrite: bool = not feed_options or feed_options.get("overwrite", True)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, uri, *, feed_options=None):
|
||||
def from_crawler(
|
||||
cls,
|
||||
crawler: Crawler,
|
||||
uri: str,
|
||||
*,
|
||||
feed_options: Optional[Dict[str, Any]] = None,
|
||||
) -> Self:
|
||||
return build_storage(
|
||||
cls,
|
||||
uri,
|
||||
|
|
@ -299,7 +376,7 @@ class FTPFeedStorage(BlockingFeedStorage):
|
|||
feed_options=feed_options,
|
||||
)
|
||||
|
||||
def _store_in_thread(self, file):
|
||||
def _store_in_thread(self, file: IO[bytes]) -> None:
|
||||
ftp_store_file(
|
||||
path=self.path,
|
||||
file=file,
|
||||
|
|
@ -315,46 +392,51 @@ class FTPFeedStorage(BlockingFeedStorage):
|
|||
class FeedSlot:
|
||||
def __init__(
|
||||
self,
|
||||
storage,
|
||||
uri,
|
||||
format,
|
||||
store_empty,
|
||||
batch_id,
|
||||
uri_template,
|
||||
filter,
|
||||
feed_options,
|
||||
spider,
|
||||
exporters,
|
||||
settings,
|
||||
crawler,
|
||||
storage: FeedStorageProtocol,
|
||||
uri: str,
|
||||
format: str,
|
||||
store_empty: bool,
|
||||
batch_id: int,
|
||||
uri_template: str,
|
||||
filter: ItemFilter,
|
||||
feed_options: Dict[str, Any],
|
||||
spider: Spider,
|
||||
exporters: Dict[str, Type[BaseItemExporter]],
|
||||
settings: BaseSettings,
|
||||
crawler: Crawler,
|
||||
):
|
||||
self.file = None
|
||||
self.exporter = None
|
||||
self.storage = storage
|
||||
self.file: Optional[IO[bytes]] = None
|
||||
self.exporter: Optional[BaseItemExporter] = None
|
||||
self.storage: FeedStorageProtocol = storage
|
||||
# feed params
|
||||
self.batch_id = batch_id
|
||||
self.format = format
|
||||
self.store_empty = store_empty
|
||||
self.uri_template = uri_template
|
||||
self.uri = uri
|
||||
self.filter = filter
|
||||
self.batch_id: int = batch_id
|
||||
self.format: str = format
|
||||
self.store_empty: bool = store_empty
|
||||
self.uri_template: str = uri_template
|
||||
self.uri: str = uri
|
||||
self.filter: ItemFilter = filter
|
||||
# exporter params
|
||||
self.feed_options = feed_options
|
||||
self.spider = spider
|
||||
self.exporters = exporters
|
||||
self.settings = settings
|
||||
self.crawler = crawler
|
||||
self.feed_options: Dict[str, Any] = feed_options
|
||||
self.spider: Spider = spider
|
||||
self.exporters: Dict[str, Type[BaseItemExporter]] = exporters
|
||||
self.settings: BaseSettings = settings
|
||||
self.crawler: Crawler = crawler
|
||||
# flags
|
||||
self.itemcount = 0
|
||||
self._exporting = False
|
||||
self._fileloaded = False
|
||||
self.itemcount: int = 0
|
||||
self._exporting: bool = False
|
||||
self._fileloaded: bool = False
|
||||
|
||||
def start_exporting(self):
|
||||
def start_exporting(self) -> None:
|
||||
if not self._fileloaded:
|
||||
self.file = self.storage.open(self.spider)
|
||||
if "postprocessing" in self.feed_options:
|
||||
self.file = PostProcessingManager(
|
||||
self.feed_options["postprocessing"], self.file, self.feed_options
|
||||
self.file = cast(
|
||||
IO[bytes],
|
||||
PostProcessingManager(
|
||||
self.feed_options["postprocessing"],
|
||||
self.file,
|
||||
self.feed_options,
|
||||
),
|
||||
)
|
||||
self.exporter = self._get_exporter(
|
||||
file=self.file,
|
||||
|
|
@ -367,17 +449,23 @@ class FeedSlot:
|
|||
self._fileloaded = True
|
||||
|
||||
if not self._exporting:
|
||||
assert self.exporter
|
||||
self.exporter.start_exporting()
|
||||
self._exporting = True
|
||||
|
||||
def _get_instance(self, objcls, *args, **kwargs):
|
||||
def _get_instance(
|
||||
self, objcls: Type[BaseItemExporter], *args: Any, **kwargs: Any
|
||||
) -> BaseItemExporter:
|
||||
return build_from_crawler(objcls, self.crawler, *args, **kwargs)
|
||||
|
||||
def _get_exporter(self, file, format, *args, **kwargs):
|
||||
def _get_exporter(
|
||||
self, file: IO[bytes], format: str, *args: Any, **kwargs: Any
|
||||
) -> BaseItemExporter:
|
||||
return self._get_instance(self.exporters[format], file, *args, **kwargs)
|
||||
|
||||
def finish_exporting(self):
|
||||
def finish_exporting(self) -> None:
|
||||
if self._exporting:
|
||||
assert self.exporter
|
||||
self.exporter.finish_exporting()
|
||||
self._exporting = False
|
||||
|
||||
|
|
@ -389,22 +477,22 @@ _FeedSlot = create_deprecated_class(
|
|||
|
||||
|
||||
class FeedExporter:
|
||||
_pending_deferreds: List[defer.Deferred] = []
|
||||
_pending_deferreds: List[Deferred] = []
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
exporter = cls(crawler)
|
||||
crawler.signals.connect(exporter.open_spider, signals.spider_opened)
|
||||
crawler.signals.connect(exporter.close_spider, signals.spider_closed)
|
||||
crawler.signals.connect(exporter.item_scraped, signals.item_scraped)
|
||||
return exporter
|
||||
|
||||
def __init__(self, crawler):
|
||||
self.crawler = crawler
|
||||
self.settings = crawler.settings
|
||||
def __init__(self, crawler: Crawler):
|
||||
self.crawler: Crawler = crawler
|
||||
self.settings: Settings = crawler.settings
|
||||
self.feeds = {}
|
||||
self.slots = []
|
||||
self.filters = {}
|
||||
self.slots: List[FeedSlot] = []
|
||||
self.filters: Dict[str, ItemFilter] = {}
|
||||
|
||||
if not self.settings["FEEDS"] and not self.settings["FEED_URI"]:
|
||||
raise NotConfigured
|
||||
|
|
@ -436,8 +524,12 @@ class FeedExporter:
|
|||
)
|
||||
self.filters[uri] = self._load_filter(feed_options)
|
||||
|
||||
self.storages = self._load_components("FEED_STORAGES")
|
||||
self.exporters = self._load_components("FEED_EXPORTERS")
|
||||
self.storages: Dict[str, Type[FeedStorageProtocol]] = self._load_components(
|
||||
"FEED_STORAGES"
|
||||
)
|
||||
self.exporters: Dict[str, Type[BaseItemExporter]] = self._load_components(
|
||||
"FEED_EXPORTERS"
|
||||
)
|
||||
for uri, feed_options in self.feeds.items():
|
||||
if not self._storage_supported(uri, feed_options):
|
||||
raise NotConfigured
|
||||
|
|
@ -446,7 +538,7 @@ class FeedExporter:
|
|||
if not self._exporter_supported(feed_options["format"]):
|
||||
raise NotConfigured
|
||||
|
||||
def open_spider(self, spider):
|
||||
def open_spider(self, spider: Spider) -> None:
|
||||
for uri, feed_options in self.feeds.items():
|
||||
uri_params = self._get_uri_params(spider, feed_options["uri_params"])
|
||||
self.slots.append(
|
||||
|
|
@ -459,7 +551,7 @@ class FeedExporter:
|
|||
)
|
||||
)
|
||||
|
||||
async def close_spider(self, spider):
|
||||
async def close_spider(self, spider: Spider) -> None:
|
||||
for slot in self.slots:
|
||||
self._close_slot(slot, spider)
|
||||
|
||||
|
|
@ -472,8 +564,9 @@ class FeedExporter:
|
|||
self.crawler.signals.send_catch_log_deferred(signals.feed_exporter_closed)
|
||||
)
|
||||
|
||||
def _close_slot(self, slot, spider):
|
||||
def get_file(slot_):
|
||||
def _close_slot(self, slot: FeedSlot, spider: Spider) -> Optional[Deferred]:
|
||||
def get_file(slot_: FeedSlot) -> IO[bytes]:
|
||||
assert slot_.file
|
||||
if isinstance(slot_.file, PostProcessingManager):
|
||||
slot_.file.close()
|
||||
return slot_.file.file
|
||||
|
|
@ -491,7 +584,7 @@ class FeedExporter:
|
|||
return None
|
||||
|
||||
logmsg = f"{slot.format} feed ({slot.itemcount} items) in: {slot.uri}"
|
||||
d = defer.maybeDeferred(slot.storage.store, get_file(slot))
|
||||
d: Deferred = maybeDeferred(slot.storage.store, get_file(slot))
|
||||
|
||||
d.addCallback(
|
||||
self._handle_store_success, logmsg, spider, type(slot.storage).__name__
|
||||
|
|
@ -509,20 +602,33 @@ class FeedExporter:
|
|||
|
||||
return d
|
||||
|
||||
def _handle_store_error(self, f, logmsg, spider, slot_type):
|
||||
def _handle_store_error(
|
||||
self, f: Failure, logmsg: str, spider: Spider, slot_type: str
|
||||
) -> None:
|
||||
logger.error(
|
||||
"Error storing %s",
|
||||
logmsg,
|
||||
exc_info=failure_to_exc_info(f),
|
||||
extra={"spider": spider},
|
||||
)
|
||||
assert self.crawler.stats
|
||||
self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}")
|
||||
|
||||
def _handle_store_success(self, f, logmsg, spider, slot_type):
|
||||
def _handle_store_success(
|
||||
self, f: Failure, logmsg: str, spider: Spider, slot_type: str
|
||||
) -> None:
|
||||
logger.info("Stored %s", logmsg, extra={"spider": spider})
|
||||
assert self.crawler.stats
|
||||
self.crawler.stats.inc_value(f"feedexport/success_count/{slot_type}")
|
||||
|
||||
def _start_new_batch(self, batch_id, uri, feed_options, spider, uri_template):
|
||||
def _start_new_batch(
|
||||
self,
|
||||
batch_id: int,
|
||||
uri: str,
|
||||
feed_options: Dict[str, Any],
|
||||
spider: Spider,
|
||||
uri_template: str,
|
||||
) -> FeedSlot:
|
||||
"""
|
||||
Redirect the output data stream to a new file.
|
||||
Execute multiple times if FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified
|
||||
|
|
@ -545,11 +651,11 @@ class FeedExporter:
|
|||
spider=spider,
|
||||
exporters=self.exporters,
|
||||
settings=self.settings,
|
||||
crawler=getattr(self, "crawler", None),
|
||||
crawler=self.crawler,
|
||||
)
|
||||
return slot
|
||||
|
||||
def item_scraped(self, item, spider):
|
||||
def item_scraped(self, item: Any, spider: Spider) -> None:
|
||||
slots = []
|
||||
for slot in self.slots:
|
||||
if not slot.filter.accepts(item):
|
||||
|
|
@ -559,6 +665,7 @@ class FeedExporter:
|
|||
continue
|
||||
|
||||
slot.start_exporting()
|
||||
assert slot.exporter
|
||||
slot.exporter.export_item(item)
|
||||
slot.itemcount += 1
|
||||
# create new slot for each slot with itemcount == FEED_EXPORT_BATCH_ITEM_COUNT and close the old one
|
||||
|
|
@ -583,7 +690,7 @@ class FeedExporter:
|
|||
slots.append(slot)
|
||||
self.slots = slots
|
||||
|
||||
def _load_components(self, setting_prefix):
|
||||
def _load_components(self, setting_prefix: str) -> Dict[str, Any]:
|
||||
conf = without_none_values(self.settings.getwithbase(setting_prefix))
|
||||
d = {}
|
||||
for k, v in conf.items():
|
||||
|
|
@ -593,12 +700,13 @@ class FeedExporter:
|
|||
pass
|
||||
return d
|
||||
|
||||
def _exporter_supported(self, format):
|
||||
def _exporter_supported(self, format: str) -> bool:
|
||||
if format in self.exporters:
|
||||
return True
|
||||
logger.error("Unknown feed format: %(format)s", {"format": format})
|
||||
return False
|
||||
|
||||
def _settings_are_valid(self):
|
||||
def _settings_are_valid(self) -> bool:
|
||||
"""
|
||||
If FEED_EXPORT_BATCH_ITEM_COUNT setting or FEEDS.batch_item_count is specified uri has to contain
|
||||
%(batch_time)s or %(batch_id)d to distinguish different files of partial output
|
||||
|
|
@ -616,7 +724,7 @@ class FeedExporter:
|
|||
return False
|
||||
return True
|
||||
|
||||
def _storage_supported(self, uri, feed_options):
|
||||
def _storage_supported(self, uri: str, feed_options: Dict[str, Any]) -> bool:
|
||||
scheme = urlparse(uri).scheme
|
||||
if scheme in self.storages or PureWindowsPath(uri).drive:
|
||||
try:
|
||||
|
|
@ -629,8 +737,11 @@ class FeedExporter:
|
|||
)
|
||||
else:
|
||||
logger.error("Unknown feed storage scheme: %(scheme)s", {"scheme": scheme})
|
||||
return False
|
||||
|
||||
def _get_storage(self, uri, feed_options):
|
||||
def _get_storage(
|
||||
self, uri: str, feed_options: Dict[str, Any]
|
||||
) -> FeedStorageProtocol:
|
||||
"""Fork of create_instance specific to feed storage classes
|
||||
|
||||
It supports not passing the *feed_options* parameters to classes that
|
||||
|
|
@ -639,11 +750,14 @@ class FeedExporter:
|
|||
feedcls = self.storages.get(urlparse(uri).scheme, self.storages["file"])
|
||||
crawler = getattr(self, "crawler", None)
|
||||
|
||||
def build_instance(builder, *preargs):
|
||||
def build_instance(
|
||||
builder: Type[FeedStorageProtocol], *preargs: Any
|
||||
) -> FeedStorageProtocol:
|
||||
return build_storage(
|
||||
builder, uri, feed_options=feed_options, preargs=preargs
|
||||
)
|
||||
|
||||
instance: FeedStorageProtocol
|
||||
if crawler and hasattr(feedcls, "from_crawler"):
|
||||
instance = build_instance(feedcls.from_crawler, crawler)
|
||||
method_name = "from_crawler"
|
||||
|
|
@ -660,9 +774,9 @@ class FeedExporter:
|
|||
def _get_uri_params(
|
||||
self,
|
||||
spider: Spider,
|
||||
uri_params_function: Optional[Union[str, Callable[[dict, Spider], dict]]],
|
||||
uri_params_function: Union[str, UriParamsCallableT, None],
|
||||
slot: Optional[FeedSlot] = None,
|
||||
) -> dict:
|
||||
) -> Dict[str, Any]:
|
||||
params = {}
|
||||
for k in dir(spider):
|
||||
params[k] = getattr(spider, k)
|
||||
|
|
@ -670,7 +784,7 @@ class FeedExporter:
|
|||
params["time"] = utc_now.replace(microsecond=0).isoformat().replace(":", "-")
|
||||
params["batch_time"] = utc_now.isoformat().replace(":", "-")
|
||||
params["batch_id"] = slot.batch_id + 1 if slot is not None else 1
|
||||
uripar_function = (
|
||||
uripar_function: UriParamsCallableT = (
|
||||
load_object(uri_params_function)
|
||||
if uri_params_function
|
||||
else lambda params, _: params
|
||||
|
|
@ -678,7 +792,9 @@ class FeedExporter:
|
|||
new_params = uripar_function(params, spider)
|
||||
return new_params if new_params is not None else params
|
||||
|
||||
def _load_filter(self, feed_options):
|
||||
def _load_filter(self, feed_options: Dict[str, Any]) -> ItemFilter:
|
||||
# load the item filter if declared else load the default filter class
|
||||
item_filter_class = load_object(feed_options.get("item_filter", ItemFilter))
|
||||
item_filter_class: Type[ItemFilter] = load_object(
|
||||
feed_options.get("item_filter", ItemFilter)
|
||||
)
|
||||
return item_filter_class(feed_options)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import gzip
|
||||
import logging
|
||||
import pickle
|
||||
import os
|
||||
import pickle # nosec
|
||||
from email.utils import mktime_tz, parsedate_tz
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from types import ModuleType
|
||||
from typing import IO, TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union, cast
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from w3lib.http import headers_dict_to_raw, headers_raw_to_dict
|
||||
|
|
@ -12,49 +15,65 @@ from w3lib.http import headers_dict_to_raw, headers_raw_to_dict
|
|||
from scrapy.http import Headers, Response
|
||||
from scrapy.http.request import Request
|
||||
from scrapy.responsetypes import responsetypes
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.utils.httpobj import urlparse_cached
|
||||
from scrapy.utils.project import data_path
|
||||
from scrapy.utils.python import to_bytes, to_unicode
|
||||
from scrapy.utils.request import RequestFingerprinter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Concatenate requires Python 3.10
|
||||
from typing_extensions import Concatenate
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DummyPolicy:
|
||||
def __init__(self, settings):
|
||||
self.ignore_schemes = settings.getlist("HTTPCACHE_IGNORE_SCHEMES")
|
||||
self.ignore_http_codes = [
|
||||
def __init__(self, settings: BaseSettings):
|
||||
self.ignore_schemes: List[str] = settings.getlist("HTTPCACHE_IGNORE_SCHEMES")
|
||||
self.ignore_http_codes: List[int] = [
|
||||
int(x) for x in settings.getlist("HTTPCACHE_IGNORE_HTTP_CODES")
|
||||
]
|
||||
|
||||
def should_cache_request(self, request):
|
||||
def should_cache_request(self, request: Request) -> bool:
|
||||
return urlparse_cached(request).scheme not in self.ignore_schemes
|
||||
|
||||
def should_cache_response(self, response, request):
|
||||
def should_cache_response(self, response: Response, request: Request) -> bool:
|
||||
return response.status not in self.ignore_http_codes
|
||||
|
||||
def is_cached_response_fresh(self, cachedresponse, request):
|
||||
def is_cached_response_fresh(
|
||||
self, cachedresponse: Response, request: Request
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
def is_cached_response_valid(self, cachedresponse, response, request):
|
||||
def is_cached_response_valid(
|
||||
self, cachedresponse: Response, response: Response, request: Request
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class RFC2616Policy:
|
||||
MAXAGE = 3600 * 24 * 365 # one year
|
||||
|
||||
def __init__(self, settings):
|
||||
self.always_store = settings.getbool("HTTPCACHE_ALWAYS_STORE")
|
||||
self.ignore_schemes = settings.getlist("HTTPCACHE_IGNORE_SCHEMES")
|
||||
self._cc_parsed = WeakKeyDictionary()
|
||||
self.ignore_response_cache_controls = [
|
||||
def __init__(self, settings: BaseSettings):
|
||||
self.always_store: bool = settings.getbool("HTTPCACHE_ALWAYS_STORE")
|
||||
self.ignore_schemes: List[str] = settings.getlist("HTTPCACHE_IGNORE_SCHEMES")
|
||||
self._cc_parsed: WeakKeyDictionary[
|
||||
Union[Request, Response], Dict[bytes, Optional[bytes]]
|
||||
] = WeakKeyDictionary()
|
||||
self.ignore_response_cache_controls: List[bytes] = [
|
||||
to_bytes(cc)
|
||||
for cc in settings.getlist("HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS")
|
||||
]
|
||||
|
||||
def _parse_cachecontrol(self, r):
|
||||
def _parse_cachecontrol(
|
||||
self, r: Union[Request, Response]
|
||||
) -> Dict[bytes, Optional[bytes]]:
|
||||
if r not in self._cc_parsed:
|
||||
cch = r.headers.get(b"Cache-Control", b"")
|
||||
assert cch is not None
|
||||
parsed = parse_cachecontrol(cch)
|
||||
if isinstance(r, Response):
|
||||
for key in self.ignore_response_cache_controls:
|
||||
|
|
@ -62,7 +81,7 @@ class RFC2616Policy:
|
|||
self._cc_parsed[r] = parsed
|
||||
return self._cc_parsed[r]
|
||||
|
||||
def should_cache_request(self, request):
|
||||
def should_cache_request(self, request: Request) -> bool:
|
||||
if urlparse_cached(request).scheme in self.ignore_schemes:
|
||||
return False
|
||||
cc = self._parse_cachecontrol(request)
|
||||
|
|
@ -72,7 +91,7 @@ class RFC2616Policy:
|
|||
# Any other is eligible for caching
|
||||
return True
|
||||
|
||||
def should_cache_response(self, response, request):
|
||||
def should_cache_response(self, response: Response, request: Request) -> bool:
|
||||
# What is cacheable - https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.1
|
||||
# Response cacheability - https://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.4
|
||||
# Status code 206 is not included because cache can not deal with partial contents
|
||||
|
|
@ -100,7 +119,9 @@ class RFC2616Policy:
|
|||
# info and can not be revalidated
|
||||
return False
|
||||
|
||||
def is_cached_response_fresh(self, cachedresponse, request):
|
||||
def is_cached_response_fresh(
|
||||
self, cachedresponse: Response, request: Request
|
||||
) -> bool:
|
||||
cc = self._parse_cachecontrol(cachedresponse)
|
||||
ccreq = self._parse_cachecontrol(request)
|
||||
if b"no-cache" in cc or b"no-cache" in ccreq:
|
||||
|
|
@ -141,7 +162,9 @@ class RFC2616Policy:
|
|||
self._set_conditional_validators(request, cachedresponse)
|
||||
return False
|
||||
|
||||
def is_cached_response_valid(self, cachedresponse, response, request):
|
||||
def is_cached_response_valid(
|
||||
self, cachedresponse: Response, response: Response, request: Request
|
||||
) -> bool:
|
||||
# Use the cached response if the new response is a server error,
|
||||
# as long as the old response didn't specify must-revalidate.
|
||||
if response.status >= 500:
|
||||
|
|
@ -152,7 +175,9 @@ class RFC2616Policy:
|
|||
# Use the cached response if the server says it hasn't changed.
|
||||
return response.status == 304
|
||||
|
||||
def _set_conditional_validators(self, request, cachedresponse):
|
||||
def _set_conditional_validators(
|
||||
self, request: Request, cachedresponse: Response
|
||||
) -> None:
|
||||
if b"Last-Modified" in cachedresponse.headers:
|
||||
request.headers[b"If-Modified-Since"] = cachedresponse.headers[
|
||||
b"Last-Modified"
|
||||
|
|
@ -161,13 +186,15 @@ class RFC2616Policy:
|
|||
if b"ETag" in cachedresponse.headers:
|
||||
request.headers[b"If-None-Match"] = cachedresponse.headers[b"ETag"]
|
||||
|
||||
def _get_max_age(self, cc):
|
||||
def _get_max_age(self, cc: Dict[bytes, Optional[bytes]]) -> Optional[int]:
|
||||
try:
|
||||
return max(0, int(cc[b"max-age"]))
|
||||
return max(0, int(cc[b"max-age"])) # type: ignore[arg-type]
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
def _compute_freshness_lifetime(self, response, request, now):
|
||||
def _compute_freshness_lifetime(
|
||||
self, response: Response, request: Request, now: float
|
||||
) -> float:
|
||||
# Reference nsHttpResponseHead::ComputeFreshnessLifetime
|
||||
# https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#706
|
||||
cc = self._parse_cachecontrol(response)
|
||||
|
|
@ -198,10 +225,12 @@ class RFC2616Policy:
|
|||
# Insufficient information to compute freshness lifetime
|
||||
return 0
|
||||
|
||||
def _compute_current_age(self, response, request, now):
|
||||
def _compute_current_age(
|
||||
self, response: Response, request: Request, now: float
|
||||
) -> float:
|
||||
# Reference nsHttpResponseHead::ComputeCurrentAge
|
||||
# https://dxr.mozilla.org/mozilla-central/source/netwerk/protocol/http/nsHttpResponseHead.cpp#658
|
||||
currentage = 0
|
||||
currentage: float = 0
|
||||
# If Date header is not set we assume it is a fast connection, and
|
||||
# clock is in sync with the server
|
||||
date = rfc1123_to_epoch(response.headers.get(b"Date")) or now
|
||||
|
|
@ -210,7 +239,7 @@ class RFC2616Policy:
|
|||
|
||||
if b"Age" in response.headers:
|
||||
try:
|
||||
age = int(response.headers[b"Age"])
|
||||
age = int(response.headers[b"Age"]) # type: ignore[arg-type]
|
||||
currentage = max(currentage, age)
|
||||
except ValueError:
|
||||
pass
|
||||
|
|
@ -219,13 +248,13 @@ class RFC2616Policy:
|
|||
|
||||
|
||||
class DbmCacheStorage:
|
||||
def __init__(self, settings):
|
||||
self.cachedir = data_path(settings["HTTPCACHE_DIR"], createdir=True)
|
||||
self.expiration_secs = settings.getint("HTTPCACHE_EXPIRATION_SECS")
|
||||
self.dbmodule = import_module(settings["HTTPCACHE_DBM_MODULE"])
|
||||
self.db = None
|
||||
def __init__(self, settings: BaseSettings):
|
||||
self.cachedir: str = data_path(settings["HTTPCACHE_DIR"], createdir=True)
|
||||
self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS")
|
||||
self.dbmodule: ModuleType = import_module(settings["HTTPCACHE_DBM_MODULE"])
|
||||
self.db: Any = None # the real type is private
|
||||
|
||||
def open_spider(self, spider: Spider):
|
||||
def open_spider(self, spider: Spider) -> None:
|
||||
dbpath = Path(self.cachedir, f"{spider.name}.db")
|
||||
self.db = self.dbmodule.open(str(dbpath), "c")
|
||||
|
||||
|
|
@ -235,15 +264,16 @@ class DbmCacheStorage:
|
|||
extra={"spider": spider},
|
||||
)
|
||||
|
||||
self._fingerprinter = spider.crawler.request_fingerprinter
|
||||
assert spider.crawler.request_fingerprinter
|
||||
self._fingerprinter: RequestFingerprinter = spider.crawler.request_fingerprinter
|
||||
|
||||
def close_spider(self, spider):
|
||||
def close_spider(self, spider: Spider) -> None:
|
||||
self.db.close()
|
||||
|
||||
def retrieve_response(self, spider, request):
|
||||
def retrieve_response(self, spider: Spider, request: Request) -> Optional[Response]:
|
||||
data = self._read_data(spider, request)
|
||||
if data is None:
|
||||
return # not cached
|
||||
return None # not cached
|
||||
url = data["url"]
|
||||
status = data["status"]
|
||||
headers = Headers(data["headers"])
|
||||
|
|
@ -252,7 +282,9 @@ class DbmCacheStorage:
|
|||
response = respcls(url=url, headers=headers, status=status, body=body)
|
||||
return response
|
||||
|
||||
def store_response(self, spider, request, response):
|
||||
def store_response(
|
||||
self, spider: Spider, request: Request, response: Response
|
||||
) -> None:
|
||||
key = self._fingerprinter.fingerprint(request).hex()
|
||||
data = {
|
||||
"status": response.status,
|
||||
|
|
@ -263,28 +295,31 @@ class DbmCacheStorage:
|
|||
self.db[f"{key}_data"] = pickle.dumps(data, protocol=4)
|
||||
self.db[f"{key}_time"] = str(time())
|
||||
|
||||
def _read_data(self, spider, request):
|
||||
def _read_data(self, spider: Spider, request: Request) -> Optional[Dict[str, Any]]:
|
||||
key = self._fingerprinter.fingerprint(request).hex()
|
||||
db = self.db
|
||||
tkey = f"{key}_time"
|
||||
if tkey not in db:
|
||||
return # not found
|
||||
return None # not found
|
||||
|
||||
ts = db[tkey]
|
||||
if 0 < self.expiration_secs < time() - float(ts):
|
||||
return # expired
|
||||
return None # expired
|
||||
|
||||
return pickle.loads(db[f"{key}_data"])
|
||||
return cast(Dict[str, Any], pickle.loads(db[f"{key}_data"])) # nosec
|
||||
|
||||
|
||||
class FilesystemCacheStorage:
|
||||
def __init__(self, settings):
|
||||
self.cachedir = data_path(settings["HTTPCACHE_DIR"])
|
||||
self.expiration_secs = settings.getint("HTTPCACHE_EXPIRATION_SECS")
|
||||
self.use_gzip = settings.getbool("HTTPCACHE_GZIP")
|
||||
self._open = gzip.open if self.use_gzip else open
|
||||
def __init__(self, settings: BaseSettings):
|
||||
self.cachedir: str = data_path(settings["HTTPCACHE_DIR"])
|
||||
self.expiration_secs: int = settings.getint("HTTPCACHE_EXPIRATION_SECS")
|
||||
self.use_gzip: bool = settings.getbool("HTTPCACHE_GZIP")
|
||||
# https://github.com/python/mypy/issues/10740
|
||||
self._open: Callable[Concatenate[Union[str, os.PathLike], str, ...], IO] = (
|
||||
gzip.open if self.use_gzip else open # type: ignore[assignment]
|
||||
)
|
||||
|
||||
def open_spider(self, spider: Spider):
|
||||
def open_spider(self, spider: Spider) -> None:
|
||||
logger.debug(
|
||||
"Using filesystem cache storage in %(cachedir)s",
|
||||
{"cachedir": self.cachedir},
|
||||
|
|
@ -294,27 +329,29 @@ class FilesystemCacheStorage:
|
|||
assert spider.crawler.request_fingerprinter
|
||||
self._fingerprinter = spider.crawler.request_fingerprinter
|
||||
|
||||
def close_spider(self, spider):
|
||||
def close_spider(self, spider: Spider) -> None:
|
||||
pass
|
||||
|
||||
def retrieve_response(self, spider: Spider, request: Request):
|
||||
def retrieve_response(self, spider: Spider, request: Request) -> Optional[Response]:
|
||||
"""Return response if present in cache, or None otherwise."""
|
||||
metadata = self._read_meta(spider, request)
|
||||
if metadata is None:
|
||||
return # not cached
|
||||
return None # not cached
|
||||
rpath = Path(self._get_request_path(spider, request))
|
||||
with self._open(rpath / "response_body", "rb") as f:
|
||||
body = f.read()
|
||||
with self._open(rpath / "response_headers", "rb") as f:
|
||||
rawheaders = f.read()
|
||||
url = metadata.get("response_url")
|
||||
url = metadata["response_url"]
|
||||
status = metadata["status"]
|
||||
headers = Headers(headers_raw_to_dict(rawheaders))
|
||||
respcls = responsetypes.from_args(headers=headers, url=url, body=body)
|
||||
response = respcls(url=url, headers=headers, status=status, body=body)
|
||||
return response
|
||||
|
||||
def store_response(self, spider: Spider, request: Request, response):
|
||||
def store_response(
|
||||
self, spider: Spider, request: Request, response: Response
|
||||
) -> None:
|
||||
"""Store the given response in the cache."""
|
||||
rpath = Path(self._get_request_path(spider, request))
|
||||
if not rpath.exists():
|
||||
|
|
@ -343,19 +380,19 @@ class FilesystemCacheStorage:
|
|||
key = self._fingerprinter.fingerprint(request).hex()
|
||||
return str(Path(self.cachedir, spider.name, key[0:2], key))
|
||||
|
||||
def _read_meta(self, spider: Spider, request: Request):
|
||||
def _read_meta(self, spider: Spider, request: Request) -> Optional[Dict[str, Any]]:
|
||||
rpath = Path(self._get_request_path(spider, request))
|
||||
metapath = rpath / "pickled_meta"
|
||||
if not metapath.exists():
|
||||
return # not found
|
||||
return None # not found
|
||||
mtime = metapath.stat().st_mtime
|
||||
if 0 < self.expiration_secs < time() - mtime:
|
||||
return # expired
|
||||
return None # expired
|
||||
with self._open(metapath, "rb") as f:
|
||||
return pickle.load(f)
|
||||
return cast(Dict[str, Any], pickle.load(f)) # nosec
|
||||
|
||||
|
||||
def parse_cachecontrol(header):
|
||||
def parse_cachecontrol(header: bytes) -> Dict[bytes, Optional[bytes]]:
|
||||
"""Parse Cache-Control header
|
||||
|
||||
https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9
|
||||
|
|
@ -375,9 +412,9 @@ def parse_cachecontrol(header):
|
|||
return directives
|
||||
|
||||
|
||||
def rfc1123_to_epoch(date_str):
|
||||
def rfc1123_to_epoch(date_str: Union[str, bytes, None]) -> Optional[int]:
|
||||
try:
|
||||
date_str = to_unicode(date_str, encoding="ascii")
|
||||
return mktime_tz(parsedate_tz(date_str))
|
||||
date_str = to_unicode(date_str, encoding="ascii") # type: ignore[arg-type]
|
||||
return mktime_tz(parsedate_tz(date_str)) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional, Tuple, Union
|
||||
|
||||
from twisted.internet import task
|
||||
|
||||
from scrapy import signals
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.statscollectors import StatsCollector
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -14,30 +23,31 @@ class LogStats:
|
|||
* IPM - Items per Minute
|
||||
"""
|
||||
|
||||
def __init__(self, stats, interval=60.0):
|
||||
self.stats = stats
|
||||
self.interval = interval
|
||||
self.multiplier = 60.0 / self.interval
|
||||
self.task = None
|
||||
def __init__(self, stats: StatsCollector, interval: float = 60.0):
|
||||
self.stats: StatsCollector = stats
|
||||
self.interval: float = interval
|
||||
self.multiplier: float = 60.0 / self.interval
|
||||
self.task: Optional[task.LoopingCall] = None
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
interval = crawler.settings.getfloat("LOGSTATS_INTERVAL")
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
interval: float = crawler.settings.getfloat("LOGSTATS_INTERVAL")
|
||||
if not interval:
|
||||
raise NotConfigured
|
||||
assert crawler.stats
|
||||
o = cls(crawler.stats, interval)
|
||||
crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
|
||||
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
|
||||
return o
|
||||
|
||||
def spider_opened(self, spider):
|
||||
self.pagesprev = 0
|
||||
self.itemsprev = 0
|
||||
def spider_opened(self, spider: Spider) -> None:
|
||||
self.pagesprev: int = 0
|
||||
self.itemsprev: int = 0
|
||||
|
||||
self.task = task.LoopingCall(self.log, spider)
|
||||
self.task.start(self.interval)
|
||||
|
||||
def log(self, spider):
|
||||
def log(self, spider: Spider) -> None:
|
||||
self.calculate_stats()
|
||||
|
||||
msg = (
|
||||
|
|
@ -52,14 +62,14 @@ class LogStats:
|
|||
}
|
||||
logger.info(msg, log_args, extra={"spider": spider})
|
||||
|
||||
def calculate_stats(self):
|
||||
self.items = self.stats.get_value("item_scraped_count", 0)
|
||||
self.pages = self.stats.get_value("response_received_count", 0)
|
||||
self.irate = (self.items - self.itemsprev) * self.multiplier
|
||||
self.prate = (self.pages - self.pagesprev) * self.multiplier
|
||||
def calculate_stats(self) -> None:
|
||||
self.items: int = self.stats.get_value("item_scraped_count", 0)
|
||||
self.pages: int = self.stats.get_value("response_received_count", 0)
|
||||
self.irate: float = (self.items - self.itemsprev) * self.multiplier
|
||||
self.prate: float = (self.pages - self.pagesprev) * self.multiplier
|
||||
self.pagesprev, self.itemsprev = self.pages, self.items
|
||||
|
||||
def spider_closed(self, spider, reason):
|
||||
def spider_closed(self, spider: Spider, reason: str) -> None:
|
||||
if self.task and self.task.running:
|
||||
self.task.stop()
|
||||
|
||||
|
|
@ -67,7 +77,9 @@ class LogStats:
|
|||
self.stats.set_value("responses_per_minute", rpm_final)
|
||||
self.stats.set_value("items_per_minute", ipm_final)
|
||||
|
||||
def calculate_final_stats(self, spider):
|
||||
def calculate_final_stats(
|
||||
self, spider: Spider
|
||||
) -> Union[Tuple[None, None], Tuple[float, float]]:
|
||||
start_time = self.stats.get_value("start_time")
|
||||
finished_time = self.stats.get_value("finished_time")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,26 +4,36 @@ MemoryDebugger extension
|
|||
See documentation in docs/topics/extensions.rst
|
||||
"""
|
||||
|
||||
import gc
|
||||
from __future__ import annotations
|
||||
|
||||
from scrapy import signals
|
||||
import gc
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.statscollectors import StatsCollector
|
||||
from scrapy.utils.trackref import live_refs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class MemoryDebugger:
|
||||
def __init__(self, stats):
|
||||
self.stats = stats
|
||||
def __init__(self, stats: StatsCollector):
|
||||
self.stats: StatsCollector = stats
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
if not crawler.settings.getbool("MEMDEBUG_ENABLED"):
|
||||
raise NotConfigured
|
||||
assert crawler.stats
|
||||
o = cls(crawler.stats)
|
||||
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
|
||||
return o
|
||||
|
||||
def spider_closed(self, spider, reason):
|
||||
def spider_closed(self, spider: Spider, reason: str) -> None:
|
||||
gc.collect()
|
||||
self.stats.set_value(
|
||||
"memdebug/gc_garbage_count", len(gc.garbage), spider=spider
|
||||
|
|
|
|||
|
|
@ -4,24 +4,32 @@ MemoryUsage extension
|
|||
See documentation in docs/topics/extensions.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
from importlib import import_module
|
||||
from pprint import pformat
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from twisted.internet import task
|
||||
|
||||
from scrapy import signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.mail import MailSender
|
||||
from scrapy.utils.engine import get_engine_status
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoryUsage:
|
||||
def __init__(self, crawler):
|
||||
def __init__(self, crawler: Crawler):
|
||||
if not crawler.settings.getbool("MEMUSAGE_ENABLED"):
|
||||
raise NotConfigured
|
||||
try:
|
||||
|
|
@ -30,32 +38,33 @@ class MemoryUsage:
|
|||
except ImportError:
|
||||
raise NotConfigured
|
||||
|
||||
self.crawler = crawler
|
||||
self.warned = False
|
||||
self.notify_mails = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL")
|
||||
self.limit = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024
|
||||
self.warning = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024
|
||||
self.check_interval = crawler.settings.getfloat(
|
||||
self.crawler: Crawler = crawler
|
||||
self.warned: bool = False
|
||||
self.notify_mails: List[str] = crawler.settings.getlist("MEMUSAGE_NOTIFY_MAIL")
|
||||
self.limit: int = crawler.settings.getint("MEMUSAGE_LIMIT_MB") * 1024 * 1024
|
||||
self.warning: int = crawler.settings.getint("MEMUSAGE_WARNING_MB") * 1024 * 1024
|
||||
self.check_interval: float = crawler.settings.getfloat(
|
||||
"MEMUSAGE_CHECK_INTERVAL_SECONDS"
|
||||
)
|
||||
self.mail = MailSender.from_settings(crawler.settings)
|
||||
self.mail: MailSender = MailSender.from_settings(crawler.settings)
|
||||
crawler.signals.connect(self.engine_started, signal=signals.engine_started)
|
||||
crawler.signals.connect(self.engine_stopped, signal=signals.engine_stopped)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
return cls(crawler)
|
||||
|
||||
def get_virtual_size(self):
|
||||
size = self.resource.getrusage(self.resource.RUSAGE_SELF).ru_maxrss
|
||||
def get_virtual_size(self) -> int:
|
||||
size: int = self.resource.getrusage(self.resource.RUSAGE_SELF).ru_maxrss
|
||||
if sys.platform != "darwin":
|
||||
# on macOS ru_maxrss is in bytes, on Linux it is in KB
|
||||
size *= 1024
|
||||
return size
|
||||
|
||||
def engine_started(self):
|
||||
def engine_started(self) -> None:
|
||||
assert self.crawler.stats
|
||||
self.crawler.stats.set_value("memusage/startup", self.get_virtual_size())
|
||||
self.tasks = []
|
||||
self.tasks: List[task.LoopingCall] = []
|
||||
tsk = task.LoopingCall(self.update)
|
||||
self.tasks.append(tsk)
|
||||
tsk.start(self.check_interval, now=True)
|
||||
|
|
@ -68,15 +77,18 @@ class MemoryUsage:
|
|||
self.tasks.append(tsk)
|
||||
tsk.start(self.check_interval, now=True)
|
||||
|
||||
def engine_stopped(self):
|
||||
def engine_stopped(self) -> None:
|
||||
for tsk in self.tasks:
|
||||
if tsk.running:
|
||||
tsk.stop()
|
||||
|
||||
def update(self):
|
||||
def update(self) -> None:
|
||||
assert self.crawler.stats
|
||||
self.crawler.stats.max_value("memusage/max", self.get_virtual_size())
|
||||
|
||||
def _check_limit(self):
|
||||
def _check_limit(self) -> None:
|
||||
assert self.crawler.engine
|
||||
assert self.crawler.stats
|
||||
peak_mem_usage = self.get_virtual_size()
|
||||
if peak_mem_usage > self.limit:
|
||||
self.crawler.stats.set_value("memusage/limit_reached", 1)
|
||||
|
|
@ -106,9 +118,10 @@ class MemoryUsage:
|
|||
{"virtualsize": peak_mem_usage / 1024 / 1024},
|
||||
)
|
||||
|
||||
def _check_warning(self):
|
||||
def _check_warning(self) -> None:
|
||||
if self.warned: # warn only once
|
||||
return
|
||||
assert self.crawler.stats
|
||||
if self.get_virtual_size() > self.warning:
|
||||
self.crawler.stats.set_value("memusage/warning_reached", 1)
|
||||
mem = self.warning / 1024 / 1024
|
||||
|
|
@ -126,8 +139,10 @@ class MemoryUsage:
|
|||
self.crawler.stats.set_value("memusage/warning_notified", 1)
|
||||
self.warned = True
|
||||
|
||||
def _send_report(self, rcpts, subject):
|
||||
def _send_report(self, rcpts: List[str], subject: str) -> None:
|
||||
"""send notification mail with some additional useful info"""
|
||||
assert self.crawler.engine
|
||||
assert self.crawler.stats
|
||||
stats = self.crawler.stats
|
||||
s = f"Memory usage at engine startup : {stats.get_value('memusage/startup') / 1024 / 1024}M\r\n"
|
||||
s += f"Maximum memory usage : {stats.get_value('memusage/max') / 1024 / 1024}M\r\n"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from json import JSONEncoder
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
|
||||
|
||||
from twisted.internet import task
|
||||
|
||||
from scrapy import signals
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.statscollectors import StatsCollector
|
||||
from scrapy.utils.serialize import ScrapyJSONEncoder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -15,32 +25,34 @@ class PeriodicLog:
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
stats,
|
||||
interval=60.0,
|
||||
ext_stats={},
|
||||
ext_delta={},
|
||||
ext_timing_enabled=False,
|
||||
stats: StatsCollector,
|
||||
interval: float = 60.0,
|
||||
ext_stats: Dict[str, Any] = {},
|
||||
ext_delta: Dict[str, Any] = {},
|
||||
ext_timing_enabled: bool = False,
|
||||
):
|
||||
self.stats = stats
|
||||
self.interval = interval
|
||||
self.multiplier = 60.0 / self.interval
|
||||
self.task = None
|
||||
self.encoder = ScrapyJSONEncoder(sort_keys=True, indent=4)
|
||||
self.ext_stats_enabled = bool(ext_stats)
|
||||
self.ext_stats_include = ext_stats.get("include", [])
|
||||
self.ext_stats_exclude = ext_stats.get("exclude", [])
|
||||
self.ext_delta_enabled = bool(ext_delta)
|
||||
self.ext_delta_include = ext_delta.get("include", [])
|
||||
self.ext_delta_exclude = ext_delta.get("exclude", [])
|
||||
self.ext_timing_enabled = ext_timing_enabled
|
||||
self.stats: StatsCollector = stats
|
||||
self.interval: float = interval
|
||||
self.multiplier: float = 60.0 / self.interval
|
||||
self.task: Optional[task.LoopingCall] = None
|
||||
self.encoder: JSONEncoder = ScrapyJSONEncoder(sort_keys=True, indent=4)
|
||||
self.ext_stats_enabled: bool = bool(ext_stats)
|
||||
self.ext_stats_include: List[str] = ext_stats.get("include", [])
|
||||
self.ext_stats_exclude: List[str] = ext_stats.get("exclude", [])
|
||||
self.ext_delta_enabled: bool = bool(ext_delta)
|
||||
self.ext_delta_include: List[str] = ext_delta.get("include", [])
|
||||
self.ext_delta_exclude: List[str] = ext_delta.get("exclude", [])
|
||||
self.ext_timing_enabled: bool = ext_timing_enabled
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
interval = crawler.settings.getfloat("LOGSTATS_INTERVAL")
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
interval: float = crawler.settings.getfloat("LOGSTATS_INTERVAL")
|
||||
if not interval:
|
||||
raise NotConfigured
|
||||
try:
|
||||
ext_stats = crawler.settings.getdict("PERIODIC_LOG_STATS")
|
||||
ext_stats: Optional[Dict[str, Any]] = crawler.settings.getdict(
|
||||
"PERIODIC_LOG_STATS"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
ext_stats = (
|
||||
{"enabled": True}
|
||||
|
|
@ -48,7 +60,9 @@ class PeriodicLog:
|
|||
else None
|
||||
)
|
||||
try:
|
||||
ext_delta = crawler.settings.getdict("PERIODIC_LOG_DELTA")
|
||||
ext_delta: Optional[Dict[str, Any]] = crawler.settings.getdict(
|
||||
"PERIODIC_LOG_DELTA"
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
ext_delta = (
|
||||
{"enabled": True}
|
||||
|
|
@ -56,11 +70,14 @@ class PeriodicLog:
|
|||
else None
|
||||
)
|
||||
|
||||
ext_timing_enabled = crawler.settings.getbool(
|
||||
ext_timing_enabled: bool = crawler.settings.getbool(
|
||||
"PERIODIC_LOG_TIMING_ENABLED", False
|
||||
)
|
||||
if not (ext_stats or ext_delta or ext_timing_enabled):
|
||||
raise NotConfigured
|
||||
assert crawler.stats
|
||||
assert ext_stats is not None
|
||||
assert ext_delta is not None
|
||||
o = cls(
|
||||
crawler.stats,
|
||||
interval,
|
||||
|
|
@ -72,16 +89,16 @@ class PeriodicLog:
|
|||
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
|
||||
return o
|
||||
|
||||
def spider_opened(self, spider):
|
||||
self.time_prev = datetime.now(tz=timezone.utc)
|
||||
self.delta_prev = {}
|
||||
self.stats_prev = {}
|
||||
def spider_opened(self, spider: Spider) -> None:
|
||||
self.time_prev: datetime = datetime.now(tz=timezone.utc)
|
||||
self.delta_prev: Dict[str, Union[int, float]] = {}
|
||||
self.stats_prev: Dict[str, Union[int, float]] = {}
|
||||
|
||||
self.task = task.LoopingCall(self.log)
|
||||
self.task.start(self.interval)
|
||||
|
||||
def log(self):
|
||||
data = {}
|
||||
def log(self) -> None:
|
||||
data: Dict[str, Any] = {}
|
||||
if self.ext_timing_enabled:
|
||||
data.update(self.log_timing())
|
||||
if self.ext_delta_enabled:
|
||||
|
|
@ -90,8 +107,8 @@ class PeriodicLog:
|
|||
data.update(self.log_crawler_stats())
|
||||
logger.info(self.encoder.encode(data))
|
||||
|
||||
def log_delta(self):
|
||||
num_stats = {
|
||||
def log_delta(self) -> Dict[str, Any]:
|
||||
num_stats: Dict[str, Union[int, float]] = {
|
||||
k: v
|
||||
for k, v in self.stats._stats.items()
|
||||
if isinstance(v, (int, float))
|
||||
|
|
@ -101,7 +118,7 @@ class PeriodicLog:
|
|||
self.delta_prev = num_stats
|
||||
return {"delta": delta}
|
||||
|
||||
def log_timing(self):
|
||||
def log_timing(self) -> Dict[str, Any]:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
time = {
|
||||
"log_interval": self.interval,
|
||||
|
|
@ -113,7 +130,7 @@ class PeriodicLog:
|
|||
self.time_prev = now
|
||||
return {"time": time}
|
||||
|
||||
def log_crawler_stats(self):
|
||||
def log_crawler_stats(self) -> Dict[str, Any]:
|
||||
stats = {
|
||||
k: v
|
||||
for k, v in self.stats._stats.items()
|
||||
|
|
@ -121,7 +138,9 @@ class PeriodicLog:
|
|||
}
|
||||
return {"stats": stats}
|
||||
|
||||
def param_allowed(self, stat_name, include, exclude):
|
||||
def param_allowed(
|
||||
self, stat_name: str, include: List[str], exclude: List[str]
|
||||
) -> bool:
|
||||
if not include and not exclude:
|
||||
return True
|
||||
for p in exclude:
|
||||
|
|
@ -134,7 +153,7 @@ class PeriodicLog:
|
|||
return True
|
||||
return False
|
||||
|
||||
def spider_closed(self, spider, reason):
|
||||
def spider_closed(self, spider: Spider, reason: str) -> None:
|
||||
self.log()
|
||||
if self.task and self.task.running:
|
||||
self.task.stop()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from bz2 import BZ2File
|
|||
from gzip import GzipFile
|
||||
from io import IOBase
|
||||
from lzma import LZMAFile
|
||||
from typing import Any, BinaryIO, Dict, List
|
||||
from typing import IO, Any, BinaryIO, Dict, List, cast
|
||||
|
||||
from scrapy.utils.misc import load_object
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ class PostProcessingManager(IOBase):
|
|||
"""
|
||||
|
||||
def __init__(
|
||||
self, plugins: List[Any], file: BinaryIO, feed_options: Dict[str, Any]
|
||||
self, plugins: List[Any], file: IO[bytes], feed_options: Dict[str, Any]
|
||||
) -> None:
|
||||
self.plugins = self._load_plugins(plugins)
|
||||
self.file = file
|
||||
|
|
@ -142,7 +142,7 @@ class PostProcessingManager(IOBase):
|
|||
:return: returns number of bytes written
|
||||
:rtype: int
|
||||
"""
|
||||
return self.head_plugin.write(data)
|
||||
return cast(int, self.head_plugin.write(data))
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.file.tell()
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
import pickle
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
from scrapy import signals
|
||||
import pickle # nosec
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.utils.job import job_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class SpiderState:
|
||||
"""Store and load spider state during a scraping job"""
|
||||
|
||||
def __init__(self, jobdir=None):
|
||||
self.jobdir = jobdir
|
||||
def __init__(self, jobdir: Optional[str] = None):
|
||||
self.jobdir: Optional[str] = jobdir
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
jobdir = job_dir(crawler.settings)
|
||||
if not jobdir:
|
||||
raise NotConfigured
|
||||
|
|
@ -23,18 +31,20 @@ class SpiderState:
|
|||
crawler.signals.connect(obj.spider_opened, signal=signals.spider_opened)
|
||||
return obj
|
||||
|
||||
def spider_closed(self, spider):
|
||||
def spider_closed(self, spider: Spider) -> None:
|
||||
if self.jobdir:
|
||||
with Path(self.statefn).open("wb") as f:
|
||||
assert hasattr(spider, "state") # set in spider_opened
|
||||
pickle.dump(spider.state, f, protocol=4)
|
||||
|
||||
def spider_opened(self, spider):
|
||||
def spider_opened(self, spider: Spider) -> None:
|
||||
if self.jobdir and Path(self.statefn).exists():
|
||||
with Path(self.statefn).open("rb") as f:
|
||||
spider.state = pickle.load(f)
|
||||
spider.state = pickle.load(f) # type: ignore[attr-defined] # nosec
|
||||
else:
|
||||
spider.state = {}
|
||||
spider.state = {} # type: ignore[attr-defined]
|
||||
|
||||
@property
|
||||
def statefn(self) -> str:
|
||||
assert self.jobdir
|
||||
return str(Path(self.jobdir, "spider.state"))
|
||||
|
|
|
|||
|
|
@ -4,28 +4,41 @@ StatsMailer extension sends an email when a spider finishes scraping.
|
|||
Use STATSMAILER_RCPTS setting to enable and give the recipient mail address
|
||||
"""
|
||||
|
||||
from scrapy import signals
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from twisted.internet.defer import Deferred
|
||||
|
||||
from scrapy import Spider, signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.mail import MailSender
|
||||
from scrapy.statscollectors import StatsCollector
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class StatsMailer:
|
||||
def __init__(self, stats, recipients, mail):
|
||||
self.stats = stats
|
||||
self.recipients = recipients
|
||||
self.mail = mail
|
||||
def __init__(self, stats: StatsCollector, recipients: List[str], mail: MailSender):
|
||||
self.stats: StatsCollector = stats
|
||||
self.recipients: List[str] = recipients
|
||||
self.mail: MailSender = mail
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
recipients = crawler.settings.getlist("STATSMAILER_RCPTS")
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
recipients: List[str] = crawler.settings.getlist("STATSMAILER_RCPTS")
|
||||
if not recipients:
|
||||
raise NotConfigured
|
||||
mail = MailSender.from_settings(crawler.settings)
|
||||
mail: MailSender = MailSender.from_settings(crawler.settings)
|
||||
assert crawler.stats
|
||||
o = cls(crawler.stats, recipients, mail)
|
||||
crawler.signals.connect(o.spider_closed, signal=signals.spider_closed)
|
||||
return o
|
||||
|
||||
def spider_closed(self, spider):
|
||||
def spider_closed(self, spider: Spider) -> Optional[Deferred]:
|
||||
spider_stats = self.stats.get_stats(spider)
|
||||
body = "Global stats\n\n"
|
||||
body += "\n".join(f"{k:<50} : {v}" for k, v in self.stats.get_stats().items())
|
||||
|
|
|
|||
|
|
@ -4,13 +4,17 @@ Scrapy Telnet Console extension
|
|||
See documentation in docs/topics/telnetconsole.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import pprint
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING, Any, Dict, List
|
||||
|
||||
from twisted.internet import protocol
|
||||
from twisted.internet.tcp import Port
|
||||
|
||||
try:
|
||||
from twisted.conch import manhole, telnet
|
||||
|
|
@ -22,12 +26,16 @@ except (ImportError, SyntaxError):
|
|||
TWISTED_CONCH_AVAILABLE = False
|
||||
|
||||
from scrapy import signals
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.utils.decorators import defers
|
||||
from scrapy.utils.engine import print_engine_status
|
||||
from scrapy.utils.reactor import listen_tcp
|
||||
from scrapy.utils.trackref import print_live_refs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# signal to update telnet variables
|
||||
|
|
@ -36,7 +44,7 @@ update_telnet_vars = object()
|
|||
|
||||
|
||||
class TelnetConsole(protocol.ServerFactory):
|
||||
def __init__(self, crawler):
|
||||
def __init__(self, crawler: Crawler):
|
||||
if not crawler.settings.getbool("TELNETCONSOLE_ENABLED"):
|
||||
raise NotConfigured
|
||||
if not TWISTED_CONCH_AVAILABLE:
|
||||
|
|
@ -44,14 +52,14 @@ class TelnetConsole(protocol.ServerFactory):
|
|||
"TELNETCONSOLE_ENABLED setting is True but required twisted "
|
||||
"modules failed to import:\n" + _TWISTED_CONCH_TRACEBACK
|
||||
)
|
||||
self.crawler = crawler
|
||||
self.noisy = False
|
||||
self.portrange = [
|
||||
self.crawler: Crawler = crawler
|
||||
self.noisy: bool = False
|
||||
self.portrange: List[int] = [
|
||||
int(x) for x in crawler.settings.getlist("TELNETCONSOLE_PORT")
|
||||
]
|
||||
self.host = crawler.settings["TELNETCONSOLE_HOST"]
|
||||
self.username = crawler.settings["TELNETCONSOLE_USERNAME"]
|
||||
self.password = crawler.settings["TELNETCONSOLE_PASSWORD"]
|
||||
self.host: str = crawler.settings["TELNETCONSOLE_HOST"]
|
||||
self.username: str = crawler.settings["TELNETCONSOLE_USERNAME"]
|
||||
self.password: str = crawler.settings["TELNETCONSOLE_PASSWORD"]
|
||||
|
||||
if not self.password:
|
||||
self.password = binascii.hexlify(os.urandom(8)).decode("utf8")
|
||||
|
|
@ -61,11 +69,11 @@ class TelnetConsole(protocol.ServerFactory):
|
|||
self.crawler.signals.connect(self.stop_listening, signals.engine_stopped)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
return cls(crawler)
|
||||
|
||||
def start_listening(self):
|
||||
self.port = listen_tcp(self.portrange, self.host, self)
|
||||
def start_listening(self) -> None:
|
||||
self.port: Port = listen_tcp(self.portrange, self.host, self)
|
||||
h = self.port.getHost()
|
||||
logger.info(
|
||||
"Telnet console listening on %(host)s:%(port)d",
|
||||
|
|
@ -73,10 +81,10 @@ class TelnetConsole(protocol.ServerFactory):
|
|||
extra={"crawler": self.crawler},
|
||||
)
|
||||
|
||||
def stop_listening(self):
|
||||
def stop_listening(self) -> None:
|
||||
self.port.stopListening()
|
||||
|
||||
def protocol(self):
|
||||
def protocol(self) -> telnet.TelnetTransport: # type: ignore[override]
|
||||
class Portal:
|
||||
"""An implementation of IPortal"""
|
||||
|
||||
|
|
@ -95,9 +103,10 @@ class TelnetConsole(protocol.ServerFactory):
|
|||
|
||||
return telnet.TelnetTransport(telnet.AuthenticatingTelnetProtocol, Portal())
|
||||
|
||||
def _get_telnet_vars(self):
|
||||
def _get_telnet_vars(self) -> Dict[str, Any]:
|
||||
# Note: if you add entries here also update topics/telnetconsole.rst
|
||||
telnet_vars = {
|
||||
assert self.crawler.engine
|
||||
telnet_vars: Dict[str, Any] = {
|
||||
"engine": self.crawler.engine,
|
||||
"spider": self.crawler.engine.spider,
|
||||
"slot": self.crawler.engine.slot,
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
import logging
|
||||
from __future__ import annotations
|
||||
|
||||
from scrapy import signals
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional, Tuple
|
||||
|
||||
from scrapy import Request, Spider, signals
|
||||
from scrapy.core.downloader import Slot
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.exceptions import NotConfigured
|
||||
from scrapy.http import Response
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoThrottle:
|
||||
def __init__(self, crawler):
|
||||
self.crawler = crawler
|
||||
def __init__(self, crawler: Crawler):
|
||||
self.crawler: Crawler = crawler
|
||||
if not crawler.settings.getbool("AUTOTHROTTLE_ENABLED"):
|
||||
raise NotConfigured
|
||||
|
||||
self.debug = crawler.settings.getbool("AUTOTHROTTLE_DEBUG")
|
||||
self.target_concurrency = crawler.settings.getfloat(
|
||||
self.debug: bool = crawler.settings.getbool("AUTOTHROTTLE_DEBUG")
|
||||
self.target_concurrency: float = crawler.settings.getfloat(
|
||||
"AUTOTHROTTLE_TARGET_CONCURRENCY"
|
||||
)
|
||||
if self.target_concurrency <= 0.0:
|
||||
|
|
@ -27,30 +37,32 @@ class AutoThrottle:
|
|||
)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler: Crawler) -> Self:
|
||||
return cls(crawler)
|
||||
|
||||
def _spider_opened(self, spider):
|
||||
def _spider_opened(self, spider: Spider) -> None:
|
||||
self.mindelay = self._min_delay(spider)
|
||||
self.maxdelay = self._max_delay(spider)
|
||||
spider.download_delay = self._start_delay(spider)
|
||||
spider.download_delay = self._start_delay(spider) # type: ignore[attr-defined]
|
||||
|
||||
def _min_delay(self, spider):
|
||||
def _min_delay(self, spider: Spider) -> float:
|
||||
s = self.crawler.settings
|
||||
return getattr(spider, "download_delay", s.getfloat("DOWNLOAD_DELAY"))
|
||||
|
||||
def _max_delay(self, spider):
|
||||
def _max_delay(self, spider: Spider) -> float:
|
||||
return self.crawler.settings.getfloat("AUTOTHROTTLE_MAX_DELAY")
|
||||
|
||||
def _start_delay(self, spider):
|
||||
def _start_delay(self, spider: Spider) -> float:
|
||||
return max(
|
||||
self.mindelay, self.crawler.settings.getfloat("AUTOTHROTTLE_START_DELAY")
|
||||
)
|
||||
|
||||
def _response_downloaded(self, response, request, spider):
|
||||
def _response_downloaded(
|
||||
self, response: Response, request: Request, spider: Spider
|
||||
) -> None:
|
||||
key, slot = self._get_slot(request, spider)
|
||||
latency = request.meta.get("download_latency")
|
||||
if latency is None or slot is None:
|
||||
if latency is None or slot is None or slot.throttle is False:
|
||||
return
|
||||
|
||||
olddelay = slot.delay
|
||||
|
|
@ -74,11 +86,16 @@ class AutoThrottle:
|
|||
extra={"spider": spider},
|
||||
)
|
||||
|
||||
def _get_slot(self, request, spider):
|
||||
key = request.meta.get("download_slot")
|
||||
def _get_slot(
|
||||
self, request: Request, spider: Spider
|
||||
) -> Tuple[Optional[str], Optional[Slot]]:
|
||||
key: Optional[str] = request.meta.get("download_slot")
|
||||
if key is None:
|
||||
return None, None
|
||||
assert self.crawler.engine
|
||||
return key, self.crawler.engine.downloader.slots.get(key)
|
||||
|
||||
def _adjust_delay(self, slot, latency, response):
|
||||
def _adjust_delay(self, slot: Slot, latency: float, response: Response) -> None:
|
||||
"""Define delay adjustment policy"""
|
||||
|
||||
# If a server needs `latency` seconds to respond then
|
||||
|
|
|
|||
|
|
@ -1,36 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from http.cookiejar import Cookie
|
||||
from http.cookiejar import CookieJar as _CookieJar
|
||||
from http.cookiejar import DefaultCookiePolicy
|
||||
from typing import Sequence
|
||||
from http.cookiejar import CookiePolicy, DefaultCookiePolicy
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
cast,
|
||||
)
|
||||
|
||||
from scrapy import Request
|
||||
from scrapy.http import Response
|
||||
from scrapy.utils.httpobj import urlparse_cached
|
||||
from scrapy.utils.python import to_unicode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
# Defined in the http.cookiejar module, but undocumented:
|
||||
# https://github.com/python/cpython/blob/v3.9.0/Lib/http/cookiejar.py#L527
|
||||
IPV4_RE = re.compile(r"\.\d+$", re.ASCII)
|
||||
|
||||
|
||||
class CookieJar:
|
||||
def __init__(self, policy=None, check_expired_frequency=10000):
|
||||
self.policy = policy or DefaultCookiePolicy()
|
||||
self.jar = _CookieJar(self.policy)
|
||||
self.jar._cookies_lock = _DummyLock()
|
||||
self.check_expired_frequency = check_expired_frequency
|
||||
self.processed = 0
|
||||
def __init__(
|
||||
self,
|
||||
policy: Optional[CookiePolicy] = None,
|
||||
check_expired_frequency: int = 10000,
|
||||
):
|
||||
self.policy: CookiePolicy = policy or DefaultCookiePolicy()
|
||||
self.jar: _CookieJar = _CookieJar(self.policy)
|
||||
self.jar._cookies_lock = _DummyLock() # type: ignore[attr-defined]
|
||||
self.check_expired_frequency: int = check_expired_frequency
|
||||
self.processed: int = 0
|
||||
|
||||
def extract_cookies(self, response, request):
|
||||
def extract_cookies(self, response: Response, request: Request) -> None:
|
||||
wreq = WrappedRequest(request)
|
||||
wrsp = WrappedResponse(response)
|
||||
return self.jar.extract_cookies(wrsp, wreq)
|
||||
self.jar.extract_cookies(wrsp, wreq) # type: ignore[arg-type]
|
||||
|
||||
def add_cookie_header(self, request: Request) -> None:
|
||||
wreq = WrappedRequest(request)
|
||||
self.policy._now = self.jar._now = int(time.time())
|
||||
self.policy._now = self.jar._now = int(time.time()) # type: ignore[attr-defined]
|
||||
|
||||
# the cookiejar implementation iterates through all domains
|
||||
# instead we restrict to potential matches on the domain
|
||||
|
|
@ -47,10 +67,10 @@ class CookieJar:
|
|||
|
||||
cookies = []
|
||||
for host in hosts:
|
||||
if host in self.jar._cookies:
|
||||
cookies += self.jar._cookies_for_domain(host, wreq)
|
||||
if host in self.jar._cookies: # type: ignore[attr-defined]
|
||||
cookies += self.jar._cookies_for_domain(host, wreq) # type: ignore[attr-defined]
|
||||
|
||||
attrs = self.jar._cookie_attrs(cookies)
|
||||
attrs = self.jar._cookie_attrs(cookies) # type: ignore[attr-defined]
|
||||
if attrs:
|
||||
if not wreq.has_header("Cookie"):
|
||||
wreq.add_unredirected_header("Cookie", "; ".join(attrs))
|
||||
|
|
@ -61,37 +81,42 @@ class CookieJar:
|
|||
self.jar.clear_expired_cookies()
|
||||
|
||||
@property
|
||||
def _cookies(self):
|
||||
return self.jar._cookies
|
||||
def _cookies(self) -> Dict[str, Dict[str, Dict[str, Cookie]]]:
|
||||
return self.jar._cookies # type: ignore[attr-defined,no-any-return]
|
||||
|
||||
def clear_session_cookies(self, *args, **kwargs):
|
||||
return self.jar.clear_session_cookies(*args, **kwargs)
|
||||
def clear_session_cookies(self) -> None:
|
||||
return self.jar.clear_session_cookies()
|
||||
|
||||
def clear(self, domain=None, path=None, name=None):
|
||||
return self.jar.clear(domain, path, name)
|
||||
def clear(
|
||||
self,
|
||||
domain: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
) -> None:
|
||||
self.jar.clear(domain, path, name)
|
||||
|
||||
def __iter__(self):
|
||||
def __iter__(self) -> Iterator[Cookie]:
|
||||
return iter(self.jar)
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return len(self.jar)
|
||||
|
||||
def set_policy(self, pol):
|
||||
return self.jar.set_policy(pol)
|
||||
def set_policy(self, pol: CookiePolicy) -> None:
|
||||
self.jar.set_policy(pol)
|
||||
|
||||
def make_cookies(self, response: Response, request: Request) -> Sequence[Cookie]:
|
||||
wreq = WrappedRequest(request)
|
||||
wrsp = WrappedResponse(response)
|
||||
return self.jar.make_cookies(wrsp, wreq)
|
||||
return self.jar.make_cookies(wrsp, wreq) # type: ignore[arg-type]
|
||||
|
||||
def set_cookie(self, cookie):
|
||||
def set_cookie(self, cookie: Cookie) -> None:
|
||||
self.jar.set_cookie(cookie)
|
||||
|
||||
def set_cookie_if_ok(self, cookie: Cookie, request: Request) -> None:
|
||||
self.jar.set_cookie_if_ok(cookie, WrappedRequest(request))
|
||||
self.jar.set_cookie_if_ok(cookie, WrappedRequest(request)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def potential_domain_matches(domain):
|
||||
def potential_domain_matches(domain: str) -> List[str]:
|
||||
"""Potential domain matches for a cookie
|
||||
|
||||
>>> potential_domain_matches('www.example.com')
|
||||
|
|
@ -111,10 +136,10 @@ def potential_domain_matches(domain):
|
|||
|
||||
|
||||
class _DummyLock:
|
||||
def acquire(self):
|
||||
def acquire(self) -> None:
|
||||
pass
|
||||
|
||||
def release(self):
|
||||
def release(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -124,19 +149,19 @@ class WrappedRequest:
|
|||
see http://docs.python.org/library/urllib2.html#urllib2.Request
|
||||
"""
|
||||
|
||||
def __init__(self, request):
|
||||
def __init__(self, request: Request):
|
||||
self.request = request
|
||||
|
||||
def get_full_url(self):
|
||||
def get_full_url(self) -> str:
|
||||
return self.request.url
|
||||
|
||||
def get_host(self):
|
||||
def get_host(self) -> str:
|
||||
return urlparse_cached(self.request).netloc
|
||||
|
||||
def get_type(self):
|
||||
def get_type(self) -> str:
|
||||
return urlparse_cached(self.request).scheme
|
||||
|
||||
def is_unverifiable(self):
|
||||
def is_unverifiable(self) -> bool:
|
||||
"""Unverifiable should indicate whether the request is unverifiable, as defined by RFC 2965.
|
||||
|
||||
It defaults to False. An unverifiable request is one whose URL the user did not have the
|
||||
|
|
@ -144,35 +169,36 @@ class WrappedRequest:
|
|||
HTML document, and the user had no option to approve the automatic
|
||||
fetching of the image, this should be true.
|
||||
"""
|
||||
return self.request.meta.get("is_unverifiable", False)
|
||||
return cast(bool, self.request.meta.get("is_unverifiable", False))
|
||||
|
||||
@property
|
||||
def full_url(self):
|
||||
def full_url(self) -> str:
|
||||
return self.get_full_url()
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
def host(self) -> str:
|
||||
return self.get_host()
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
def type(self) -> str:
|
||||
return self.get_type()
|
||||
|
||||
@property
|
||||
def unverifiable(self):
|
||||
def unverifiable(self) -> bool:
|
||||
return self.is_unverifiable()
|
||||
|
||||
@property
|
||||
def origin_req_host(self):
|
||||
return urlparse_cached(self.request).hostname
|
||||
def origin_req_host(self) -> str:
|
||||
return cast(str, urlparse_cached(self.request).hostname)
|
||||
|
||||
def has_header(self, name):
|
||||
def has_header(self, name: str) -> bool:
|
||||
return name in self.request.headers
|
||||
|
||||
def get_header(self, name, default=None):
|
||||
return to_unicode(self.request.headers.get(name, default), errors="replace")
|
||||
def get_header(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
value = self.request.headers.get(name, default)
|
||||
return to_unicode(value, errors="replace") if value is not None else None
|
||||
|
||||
def header_items(self):
|
||||
def header_items(self) -> List[Tuple[str, List[str]]]:
|
||||
return [
|
||||
(
|
||||
to_unicode(k, errors="replace"),
|
||||
|
|
@ -181,18 +207,18 @@ class WrappedRequest:
|
|||
for k, v in self.request.headers.items()
|
||||
]
|
||||
|
||||
def add_unredirected_header(self, name, value):
|
||||
def add_unredirected_header(self, name: str, value: str) -> None:
|
||||
self.request.headers.appendlist(name, value)
|
||||
|
||||
|
||||
class WrappedResponse:
|
||||
def __init__(self, response):
|
||||
def __init__(self, response: Response):
|
||||
self.response = response
|
||||
|
||||
def info(self):
|
||||
def info(self) -> Self:
|
||||
return self
|
||||
|
||||
def get_all(self, name, default=None):
|
||||
def get_all(self, name: str, default: Any = None) -> List[str]:
|
||||
return [
|
||||
to_unicode(v, errors="replace") for v in self.response.headers.getlist(name)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,21 +10,16 @@ from __future__ import annotations
|
|||
from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Tuple, Union, cast
|
||||
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
|
||||
|
||||
from lxml.html import (
|
||||
FormElement,
|
||||
HTMLParser,
|
||||
InputElement,
|
||||
MultipleSelectOptions,
|
||||
SelectElement,
|
||||
TextareaElement,
|
||||
)
|
||||
from parsel.selector import create_root_node
|
||||
from lxml.html import FormElement # nosec
|
||||
from lxml.html import InputElement # nosec
|
||||
from lxml.html import MultipleSelectOptions # nosec
|
||||
from lxml.html import SelectElement # nosec
|
||||
from lxml.html import TextareaElement # nosec
|
||||
from w3lib.html import strip_html5_whitespace
|
||||
|
||||
from scrapy.http.request import Request
|
||||
from scrapy.http.response.text import TextResponse
|
||||
from scrapy.utils.python import is_listlike, to_bytes
|
||||
from scrapy.utils.response import get_base_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
|
|
@ -120,7 +115,7 @@ def _get_form(
|
|||
formxpath: Optional[str],
|
||||
) -> FormElement:
|
||||
"""Find the wanted form element within the given response."""
|
||||
root = create_root_node(response.text, HTMLParser, base_url=get_base_url(response))
|
||||
root = response.selector.root
|
||||
forms = root.xpath("//form")
|
||||
if not forms:
|
||||
raise ValueError(f"No <form> element found in {response}")
|
||||
|
|
|
|||
|
|
@ -159,12 +159,12 @@ class TextResponse(Response):
|
|||
def jmespath(self, query: str, **kwargs: Any) -> SelectorList:
|
||||
from scrapy.selector import SelectorList
|
||||
|
||||
if not hasattr(self.selector, "jmespath"): # type: ignore[attr-defined]
|
||||
if not hasattr(self.selector, "jmespath"):
|
||||
raise AttributeError(
|
||||
"Please install parsel >= 1.8.1 to get jmespath support"
|
||||
)
|
||||
|
||||
return cast(SelectorList, self.selector.jmespath(query, **kwargs)) # type: ignore[attr-defined]
|
||||
return cast(SelectorList, self.selector.jmespath(query, **kwargs))
|
||||
|
||||
def xpath(self, query: str, **kwargs: Any) -> SelectorList:
|
||||
from scrapy.selector import SelectorList
|
||||
|
|
|
|||
|
|
@ -4,14 +4,28 @@ Scrapy Item
|
|||
See documentation in docs/topics/item.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABCMeta
|
||||
from collections.abc import MutableMapping
|
||||
from copy import deepcopy
|
||||
from pprint import pformat
|
||||
from typing import Dict
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterator,
|
||||
KeysView,
|
||||
MutableMapping,
|
||||
NoReturn,
|
||||
Tuple,
|
||||
)
|
||||
|
||||
from scrapy.utils.trackref import object_ref
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class Field(dict):
|
||||
"""Container of field metadata"""
|
||||
|
|
@ -23,7 +37,9 @@ class ItemMeta(ABCMeta):
|
|||
.. _metaclass: https://realpython.com/python-metaclasses
|
||||
"""
|
||||
|
||||
def __new__(mcs, class_name, bases, attrs):
|
||||
def __new__(
|
||||
mcs, class_name: str, bases: Tuple[type, ...], attrs: Dict[str, Any]
|
||||
) -> ItemMeta:
|
||||
classcell = attrs.pop("__classcell__", None)
|
||||
new_bases = tuple(base._class for base in bases if hasattr(base, "_class"))
|
||||
_class = super().__new__(mcs, "x_" + class_name, new_bases, attrs)
|
||||
|
|
@ -44,7 +60,7 @@ class ItemMeta(ABCMeta):
|
|||
return super().__new__(mcs, class_name, bases, new_attrs)
|
||||
|
||||
|
||||
class Item(MutableMapping, object_ref, metaclass=ItemMeta):
|
||||
class Item(MutableMapping[str, Any], object_ref, metaclass=ItemMeta):
|
||||
"""
|
||||
Base class for scraped items.
|
||||
|
||||
|
|
@ -69,51 +85,51 @@ class Item(MutableMapping, object_ref, metaclass=ItemMeta):
|
|||
|
||||
fields: Dict[str, Field]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._values = {}
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
self._values: Dict[str, Any] = {}
|
||||
if args or kwargs: # avoid creating dict for most common case
|
||||
for k, v in dict(*args, **kwargs).items():
|
||||
self[k] = v
|
||||
|
||||
def __getitem__(self, key):
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._values[key]
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
if key in self.fields:
|
||||
self._values[key] = value
|
||||
else:
|
||||
raise KeyError(f"{self.__class__.__name__} does not support field: {key}")
|
||||
|
||||
def __delitem__(self, key):
|
||||
def __delitem__(self, key: str) -> None:
|
||||
del self._values[key]
|
||||
|
||||
def __getattr__(self, name):
|
||||
def __getattr__(self, name: str) -> NoReturn:
|
||||
if name in self.fields:
|
||||
raise AttributeError(f"Use item[{name!r}] to get field value")
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if not name.startswith("_"):
|
||||
raise AttributeError(f"Use item[{name!r}] = {value!r} to set field value")
|
||||
super().__setattr__(name, value)
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return len(self._values)
|
||||
|
||||
def __iter__(self):
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
return iter(self._values)
|
||||
|
||||
__hash__ = object_ref.__hash__
|
||||
|
||||
def keys(self):
|
||||
def keys(self) -> KeysView[str]:
|
||||
return self._values.keys()
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return pformat(dict(self))
|
||||
|
||||
def copy(self):
|
||||
def copy(self) -> Self:
|
||||
return self.__class__(self)
|
||||
|
||||
def deepcopy(self):
|
||||
def deepcopy(self) -> Self:
|
||||
"""Return a :func:`~copy.deepcopy` of this item."""
|
||||
return deepcopy(self)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ For more info see docs/topics/link-extractors.rst
|
|||
"""
|
||||
|
||||
import re
|
||||
from typing import Iterable, Pattern
|
||||
|
||||
# common file extensions that are not followed if they occur in links
|
||||
IGNORED_EXTENSIONS = [
|
||||
|
|
@ -110,14 +111,11 @@ IGNORED_EXTENSIONS = [
|
|||
]
|
||||
|
||||
|
||||
_re_type = type(re.compile("", 0))
|
||||
|
||||
|
||||
def _matches(url, regexs):
|
||||
def _matches(url: str, regexs: Iterable[Pattern[str]]) -> bool:
|
||||
return any(r.search(url) for r in regexs)
|
||||
|
||||
|
||||
def _is_valid_url(url):
|
||||
def _is_valid_url(url: str) -> bool:
|
||||
return url.split("://", 1)[0] in {"http", "https", "file", "ftp"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,21 +5,30 @@ Link extractor based on lxml.html
|
|||
import logging
|
||||
import operator
|
||||
from functools import partial
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Pattern,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from lxml import etree
|
||||
from lxml import etree # nosec
|
||||
from lxml.html import HtmlElement # nosec
|
||||
from parsel.csstranslator import HTMLTranslator
|
||||
from w3lib.html import strip_html5_whitespace
|
||||
from w3lib.url import canonicalize_url, safe_url_string
|
||||
|
||||
from scrapy import Selector
|
||||
from scrapy.http import TextResponse
|
||||
from scrapy.link import Link
|
||||
from scrapy.linkextractors import (
|
||||
IGNORED_EXTENSIONS,
|
||||
_is_valid_url,
|
||||
_matches,
|
||||
_re_type,
|
||||
re,
|
||||
)
|
||||
from scrapy.linkextractors import IGNORED_EXTENSIONS, _is_valid_url, _matches, re
|
||||
from scrapy.utils.misc import arg_to_iter, rel_has_nofollow
|
||||
from scrapy.utils.python import unique as unique_list
|
||||
from scrapy.utils.response import get_base_url
|
||||
|
|
@ -33,41 +42,56 @@ XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"
|
|||
_collect_string_content = etree.XPath("string()")
|
||||
|
||||
|
||||
def _nons(tag):
|
||||
def _nons(tag: Any) -> Any:
|
||||
if isinstance(tag, str):
|
||||
if tag[0] == "{" and tag[1 : len(XHTML_NAMESPACE) + 1] == XHTML_NAMESPACE:
|
||||
return tag.split("}")[-1]
|
||||
return tag
|
||||
|
||||
|
||||
def _identity(x):
|
||||
def _identity(x: Any) -> Any:
|
||||
return x
|
||||
|
||||
|
||||
def _canonicalize_link_url(link):
|
||||
def _canonicalize_link_url(link: Link) -> str:
|
||||
return canonicalize_url(link.url, keep_fragments=True)
|
||||
|
||||
|
||||
class LxmlParserLinkExtractor:
|
||||
def __init__(
|
||||
self,
|
||||
tag="a",
|
||||
attr="href",
|
||||
process=None,
|
||||
unique=False,
|
||||
strip=True,
|
||||
canonicalized=False,
|
||||
tag: Union[str, Callable[[str], bool]] = "a",
|
||||
attr: Union[str, Callable[[str], bool]] = "href",
|
||||
process: Optional[Callable[[Any], Any]] = None,
|
||||
unique: bool = False,
|
||||
strip: bool = True,
|
||||
canonicalized: bool = False,
|
||||
):
|
||||
self.scan_tag = tag if callable(tag) else partial(operator.eq, tag)
|
||||
self.scan_attr = attr if callable(attr) else partial(operator.eq, attr)
|
||||
self.process_attr = process if callable(process) else _identity
|
||||
self.unique = unique
|
||||
self.strip = strip
|
||||
self.link_key = (
|
||||
operator.attrgetter("url") if canonicalized else _canonicalize_link_url
|
||||
# mypy doesn't infer types for operator.* and also for partial()
|
||||
self.scan_tag: Callable[[str], bool] = (
|
||||
tag
|
||||
if callable(tag)
|
||||
else cast(Callable[[str], bool], partial(operator.eq, tag))
|
||||
)
|
||||
self.scan_attr: Callable[[str], bool] = (
|
||||
attr
|
||||
if callable(attr)
|
||||
else cast(Callable[[str], bool], partial(operator.eq, attr))
|
||||
)
|
||||
self.process_attr: Callable[[Any], Any] = (
|
||||
process if callable(process) else _identity
|
||||
)
|
||||
self.unique: bool = unique
|
||||
self.strip: bool = strip
|
||||
self.link_key: Callable[[Link], str] = (
|
||||
cast(Callable[[Link], str], operator.attrgetter("url"))
|
||||
if canonicalized
|
||||
else _canonicalize_link_url
|
||||
)
|
||||
|
||||
def _iter_links(self, document):
|
||||
def _iter_links(
|
||||
self, document: HtmlElement
|
||||
) -> Iterable[Tuple[HtmlElement, str, str]]:
|
||||
for el in document.iter(etree.Element):
|
||||
if not self.scan_tag(_nons(el.tag)):
|
||||
continue
|
||||
|
|
@ -75,10 +99,16 @@ class LxmlParserLinkExtractor:
|
|||
for attrib in attribs:
|
||||
if not self.scan_attr(attrib):
|
||||
continue
|
||||
yield (el, attrib, attribs[attrib])
|
||||
yield el, attrib, attribs[attrib]
|
||||
|
||||
def _extract_links(self, selector, response_url, response_encoding, base_url):
|
||||
links = []
|
||||
def _extract_links(
|
||||
self,
|
||||
selector: Selector,
|
||||
response_url: str,
|
||||
response_encoding: str,
|
||||
base_url: str,
|
||||
) -> List[Link]:
|
||||
links: List[Link] = []
|
||||
# hacky way to get the underlying lxml parsed document
|
||||
for el, attr, attr_val in self._iter_links(selector.root):
|
||||
# pseudo lxml.html.HtmlElement.make_links_absolute(base_url)
|
||||
|
|
@ -108,44 +138,48 @@ class LxmlParserLinkExtractor:
|
|||
links.append(link)
|
||||
return self._deduplicate_if_needed(links)
|
||||
|
||||
def extract_links(self, response):
|
||||
def extract_links(self, response: TextResponse) -> List[Link]:
|
||||
base_url = get_base_url(response)
|
||||
return self._extract_links(
|
||||
response.selector, response.url, response.encoding, base_url
|
||||
)
|
||||
|
||||
def _process_links(self, links):
|
||||
def _process_links(self, links: List[Link]) -> List[Link]:
|
||||
"""Normalize and filter extracted links
|
||||
|
||||
The subclass should override it if necessary
|
||||
"""
|
||||
return self._deduplicate_if_needed(links)
|
||||
|
||||
def _deduplicate_if_needed(self, links):
|
||||
def _deduplicate_if_needed(self, links: List[Link]) -> List[Link]:
|
||||
if self.unique:
|
||||
return unique_list(links, key=self.link_key)
|
||||
return links
|
||||
|
||||
|
||||
_RegexT = Union[str, Pattern[str]]
|
||||
_RegexOrSeveralT = Union[_RegexT, Iterable[_RegexT]]
|
||||
|
||||
|
||||
class LxmlLinkExtractor:
|
||||
_csstranslator = HTMLTranslator()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allow=(),
|
||||
deny=(),
|
||||
allow_domains=(),
|
||||
deny_domains=(),
|
||||
restrict_xpaths=(),
|
||||
tags=("a", "area"),
|
||||
attrs=("href",),
|
||||
canonicalize=False,
|
||||
unique=True,
|
||||
process_value=None,
|
||||
deny_extensions=None,
|
||||
restrict_css=(),
|
||||
strip=True,
|
||||
restrict_text=None,
|
||||
allow: _RegexOrSeveralT = (),
|
||||
deny: _RegexOrSeveralT = (),
|
||||
allow_domains: Union[str, Iterable[str]] = (),
|
||||
deny_domains: Union[str, Iterable[str]] = (),
|
||||
restrict_xpaths: Union[str, Iterable[str]] = (),
|
||||
tags: Union[str, Iterable[str]] = ("a", "area"),
|
||||
attrs: Union[str, Iterable[str]] = ("href",),
|
||||
canonicalize: bool = False,
|
||||
unique: bool = True,
|
||||
process_value: Optional[Callable[[Any], Any]] = None,
|
||||
deny_extensions: Union[str, Iterable[str], None] = None,
|
||||
restrict_css: Union[str, Iterable[str]] = (),
|
||||
strip: bool = True,
|
||||
restrict_text: Optional[_RegexOrSeveralT] = None,
|
||||
):
|
||||
tags, attrs = set(arg_to_iter(tags)), set(arg_to_iter(attrs))
|
||||
self.link_extractor = LxmlParserLinkExtractor(
|
||||
|
|
@ -154,33 +188,33 @@ class LxmlLinkExtractor:
|
|||
unique=unique,
|
||||
process=process_value,
|
||||
strip=strip,
|
||||
canonicalized=canonicalize,
|
||||
canonicalized=not canonicalize,
|
||||
)
|
||||
self.allow_res = [
|
||||
x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(allow)
|
||||
]
|
||||
self.deny_res = [
|
||||
x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(deny)
|
||||
]
|
||||
self.allow_res: List[Pattern[str]] = self._compile_regexes(allow)
|
||||
self.deny_res: List[Pattern[str]] = self._compile_regexes(deny)
|
||||
|
||||
self.allow_domains = set(arg_to_iter(allow_domains))
|
||||
self.deny_domains = set(arg_to_iter(deny_domains))
|
||||
self.allow_domains: Set[str] = set(arg_to_iter(allow_domains))
|
||||
self.deny_domains: Set[str] = set(arg_to_iter(deny_domains))
|
||||
|
||||
self.restrict_xpaths = tuple(arg_to_iter(restrict_xpaths))
|
||||
self.restrict_xpaths: Tuple[str, ...] = tuple(arg_to_iter(restrict_xpaths))
|
||||
self.restrict_xpaths += tuple(
|
||||
map(self._csstranslator.css_to_xpath, arg_to_iter(restrict_css))
|
||||
)
|
||||
|
||||
if deny_extensions is None:
|
||||
deny_extensions = IGNORED_EXTENSIONS
|
||||
self.canonicalize = canonicalize
|
||||
self.deny_extensions = {"." + e for e in arg_to_iter(deny_extensions)}
|
||||
self.restrict_text = [
|
||||
x if isinstance(x, _re_type) else re.compile(x)
|
||||
for x in arg_to_iter(restrict_text)
|
||||
self.canonicalize: bool = canonicalize
|
||||
self.deny_extensions: Set[str] = {"." + e for e in arg_to_iter(deny_extensions)}
|
||||
self.restrict_text: List[Pattern[str]] = self._compile_regexes(restrict_text)
|
||||
|
||||
@staticmethod
|
||||
def _compile_regexes(value: Optional[_RegexOrSeveralT]) -> List[Pattern[str]]:
|
||||
return [
|
||||
x if isinstance(x, re.Pattern) else re.compile(x)
|
||||
for x in arg_to_iter(value)
|
||||
]
|
||||
|
||||
def _link_allowed(self, link):
|
||||
def _link_allowed(self, link: Link) -> bool:
|
||||
if not _is_valid_url(link.url):
|
||||
return False
|
||||
if self.allow_res and not _matches(link.url, self.allow_res):
|
||||
|
|
@ -202,7 +236,7 @@ class LxmlLinkExtractor:
|
|||
return False
|
||||
return True
|
||||
|
||||
def matches(self, url):
|
||||
def matches(self, url: str) -> bool:
|
||||
if self.allow_domains and not url_is_from_any_domain(url, self.allow_domains):
|
||||
return False
|
||||
if self.deny_domains and url_is_from_any_domain(url, self.deny_domains):
|
||||
|
|
@ -216,7 +250,7 @@ class LxmlLinkExtractor:
|
|||
denied = (regex.search(url) for regex in self.deny_res) if self.deny_res else []
|
||||
return any(allowed) and not any(denied)
|
||||
|
||||
def _process_links(self, links):
|
||||
def _process_links(self, links: List[Link]) -> List[Link]:
|
||||
links = [x for x in links if self._link_allowed(x)]
|
||||
if self.canonicalize:
|
||||
for link in links:
|
||||
|
|
@ -224,10 +258,10 @@ class LxmlLinkExtractor:
|
|||
links = self.link_extractor._process_links(links)
|
||||
return links
|
||||
|
||||
def _extract_links(self, *args, **kwargs):
|
||||
def _extract_links(self, *args: Any, **kwargs: Any) -> List[Link]:
|
||||
return self.link_extractor._extract_links(*args, **kwargs)
|
||||
|
||||
def extract_links(self, response):
|
||||
def extract_links(self, response: TextResponse) -> List[Link]:
|
||||
"""Returns a list of :class:`~scrapy.link.Link` objects from the
|
||||
specified :class:`response <scrapy.http.Response>`.
|
||||
|
||||
|
|
@ -249,5 +283,5 @@ class LxmlLinkExtractor:
|
|||
links = self._extract_links(doc, response.url, response.encoding, base_url)
|
||||
all_links.extend(self._process_links(links))
|
||||
if self.link_extractor.unique:
|
||||
return unique_list(all_links)
|
||||
return unique_list(all_links, key=self.link_extractor.link_key)
|
||||
return all_links
|
||||
|
|
|
|||
120
scrapy/mail.py
120
scrapy/mail.py
|
|
@ -4,6 +4,8 @@ Mail sending helpers
|
|||
See documentation in docs/topics/email.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from email import encoders as Encoders
|
||||
from email.mime.base import MIMEBase
|
||||
|
|
@ -12,14 +14,36 @@ from email.mime.nonmultipart import MIMENonMultipart
|
|||
from email.mime.text import MIMEText
|
||||
from email.utils import formatdate
|
||||
from io import BytesIO
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from twisted import version as twisted_version
|
||||
from twisted.internet import defer, ssl
|
||||
from twisted.internet import ssl
|
||||
from twisted.internet.defer import Deferred
|
||||
from twisted.python.failure import Failure
|
||||
from twisted.python.versions import Version
|
||||
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.utils.misc import arg_to_iter
|
||||
from scrapy.utils.python import to_bytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# imports twisted.internet.reactor
|
||||
from twisted.mail.smtp import ESMTPSenderFactory
|
||||
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -28,7 +52,7 @@ logger = logging.getLogger(__name__)
|
|||
COMMASPACE = ", "
|
||||
|
||||
|
||||
def _to_bytes_or_none(text):
|
||||
def _to_bytes_or_none(text: Union[str, bytes, None]) -> Optional[bytes]:
|
||||
if text is None:
|
||||
return None
|
||||
return to_bytes(text)
|
||||
|
|
@ -37,26 +61,26 @@ def _to_bytes_or_none(text):
|
|||
class MailSender:
|
||||
def __init__(
|
||||
self,
|
||||
smtphost="localhost",
|
||||
mailfrom="scrapy@localhost",
|
||||
smtpuser=None,
|
||||
smtppass=None,
|
||||
smtpport=25,
|
||||
smtptls=False,
|
||||
smtpssl=False,
|
||||
debug=False,
|
||||
smtphost: str = "localhost",
|
||||
mailfrom: str = "scrapy@localhost",
|
||||
smtpuser: Optional[str] = None,
|
||||
smtppass: Optional[str] = None,
|
||||
smtpport: int = 25,
|
||||
smtptls: bool = False,
|
||||
smtpssl: bool = False,
|
||||
debug: bool = False,
|
||||
):
|
||||
self.smtphost = smtphost
|
||||
self.smtpport = smtpport
|
||||
self.smtpuser = _to_bytes_or_none(smtpuser)
|
||||
self.smtppass = _to_bytes_or_none(smtppass)
|
||||
self.smtptls = smtptls
|
||||
self.smtpssl = smtpssl
|
||||
self.mailfrom = mailfrom
|
||||
self.debug = debug
|
||||
self.smtphost: str = smtphost
|
||||
self.smtpport: int = smtpport
|
||||
self.smtpuser: Optional[bytes] = _to_bytes_or_none(smtpuser)
|
||||
self.smtppass: Optional[bytes] = _to_bytes_or_none(smtppass)
|
||||
self.smtptls: bool = smtptls
|
||||
self.smtpssl: bool = smtpssl
|
||||
self.mailfrom: str = mailfrom
|
||||
self.debug: bool = debug
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings):
|
||||
def from_settings(cls, settings: BaseSettings) -> Self:
|
||||
return cls(
|
||||
smtphost=settings["MAIL_HOST"],
|
||||
mailfrom=settings["MAIL_FROM"],
|
||||
|
|
@ -69,17 +93,18 @@ class MailSender:
|
|||
|
||||
def send(
|
||||
self,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc=None,
|
||||
attachs=(),
|
||||
mimetype="text/plain",
|
||||
charset=None,
|
||||
_callback=None,
|
||||
):
|
||||
to: Union[str, List[str]],
|
||||
subject: str,
|
||||
body: str,
|
||||
cc: Union[str, List[str], None] = None,
|
||||
attachs: Sequence[Tuple[str, str, IO]] = (),
|
||||
mimetype: str = "text/plain",
|
||||
charset: Optional[str] = None,
|
||||
_callback: Optional[Callable[..., None]] = None,
|
||||
) -> Optional[Deferred]:
|
||||
from twisted.internet import reactor
|
||||
|
||||
msg: MIMEBase
|
||||
if attachs:
|
||||
msg = MIMEMultipart()
|
||||
else:
|
||||
|
|
@ -126,19 +151,17 @@ class MailSender:
|
|||
"mailattachs": len(attachs),
|
||||
},
|
||||
)
|
||||
return
|
||||
return None
|
||||
|
||||
dfd = self._sendmail(rcpts, msg.as_string().encode(charset or "utf-8"))
|
||||
dfd.addCallbacks(
|
||||
callback=self._sent_ok,
|
||||
errback=self._sent_failed,
|
||||
callbackArgs=[to, cc, subject, len(attachs)],
|
||||
errbackArgs=[to, cc, subject, len(attachs)],
|
||||
)
|
||||
dfd.addCallback(self._sent_ok, to, cc, subject, len(attachs))
|
||||
dfd.addErrback(self._sent_failed, to, cc, subject, len(attachs))
|
||||
reactor.addSystemEventTrigger("before", "shutdown", lambda: dfd)
|
||||
return dfd
|
||||
|
||||
def _sent_ok(self, result, to, cc, subject, nattachs):
|
||||
def _sent_ok(
|
||||
self, result: Any, to: List[str], cc: List[str], subject: str, nattachs: int
|
||||
) -> None:
|
||||
logger.info(
|
||||
"Mail sent OK: To=%(mailto)s Cc=%(mailcc)s "
|
||||
'Subject="%(mailsubject)s" Attachs=%(mailattachs)d',
|
||||
|
|
@ -150,7 +173,14 @@ class MailSender:
|
|||
},
|
||||
)
|
||||
|
||||
def _sent_failed(self, failure, to, cc, subject, nattachs):
|
||||
def _sent_failed(
|
||||
self,
|
||||
failure: Failure,
|
||||
to: List[str],
|
||||
cc: List[str],
|
||||
subject: str,
|
||||
nattachs: int,
|
||||
) -> Failure:
|
||||
errstr = str(failure.value)
|
||||
logger.error(
|
||||
"Unable to send mail: To=%(mailto)s Cc=%(mailcc)s "
|
||||
|
|
@ -166,13 +196,13 @@ class MailSender:
|
|||
)
|
||||
return failure
|
||||
|
||||
def _sendmail(self, to_addrs, msg):
|
||||
def _sendmail(self, to_addrs: List[str], msg: bytes) -> Deferred:
|
||||
from twisted.internet import reactor
|
||||
|
||||
msg = BytesIO(msg)
|
||||
d = defer.Deferred()
|
||||
msg_io = BytesIO(msg)
|
||||
d: Deferred = Deferred()
|
||||
|
||||
factory = self._create_sender_factory(to_addrs, msg, d)
|
||||
factory = self._create_sender_factory(to_addrs, msg_io, d)
|
||||
|
||||
if self.smtpssl:
|
||||
reactor.connectSSL(
|
||||
|
|
@ -183,10 +213,12 @@ class MailSender:
|
|||
|
||||
return d
|
||||
|
||||
def _create_sender_factory(self, to_addrs, msg, d):
|
||||
def _create_sender_factory(
|
||||
self, to_addrs: List[str], msg: IO, d: Deferred
|
||||
) -> ESMTPSenderFactory:
|
||||
from twisted.mail.smtp import ESMTPSenderFactory
|
||||
|
||||
factory_keywords = {
|
||||
factory_keywords: Dict[str, Any] = {
|
||||
"heloFallback": True,
|
||||
"requireAuthentication": False,
|
||||
"requireTransportSecurity": self.smtptls,
|
||||
|
|
@ -203,7 +235,7 @@ class MailSender:
|
|||
to_addrs,
|
||||
msg,
|
||||
d,
|
||||
**factory_keywords
|
||||
**factory_keywords,
|
||||
)
|
||||
factory.noisy = False
|
||||
return factory
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Files Pipeline
|
|||
See documentation in topics/media-pipeline.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
|
|
@ -16,7 +18,7 @@ from ftplib import FTP
|
|||
from io import BytesIO
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import DefaultDict, Optional, Set, Union
|
||||
from typing import IO, TYPE_CHECKING, DefaultDict, Optional, Set, Type, Union, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from itemadapter import ItemAdapter
|
||||
|
|
@ -31,10 +33,13 @@ from scrapy.utils.boto import is_botocore_available
|
|||
from scrapy.utils.datatypes import CaseInsensitiveDict
|
||||
from scrapy.utils.ftp import ftp_store_file
|
||||
from scrapy.utils.log import failure_to_exc_info
|
||||
from scrapy.utils.misc import md5sum
|
||||
from scrapy.utils.python import to_bytes
|
||||
from scrapy.utils.request import referer_str
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -42,6 +47,23 @@ def _to_string(path: Union[str, PathLike]) -> str:
|
|||
return str(path) # convert a Path object to string
|
||||
|
||||
|
||||
def _md5sum(file: IO) -> str:
|
||||
"""Calculate the md5 checksum of a file-like object without reading its
|
||||
whole content in memory.
|
||||
|
||||
>>> from io import BytesIO
|
||||
>>> _md5sum(BytesIO(b'file content to hash'))
|
||||
'784406af91dd5a54fbb9c84c2236595a'
|
||||
"""
|
||||
m = hashlib.md5() # nosec
|
||||
while True:
|
||||
d = file.read(8096)
|
||||
if not d:
|
||||
break
|
||||
m.update(d)
|
||||
return m.hexdigest()
|
||||
|
||||
|
||||
class FileException(Exception):
|
||||
"""General media error exception"""
|
||||
|
||||
|
|
@ -70,7 +92,7 @@ class FSFilesStore:
|
|||
return {}
|
||||
|
||||
with absolute_path.open("rb") as f:
|
||||
checksum = md5sum(f)
|
||||
checksum = _md5sum(f)
|
||||
|
||||
return {"last_modified": last_modified, "checksum": checksum}
|
||||
|
||||
|
|
@ -299,7 +321,7 @@ class FTPFilesStore:
|
|||
ftp.set_pasv(False)
|
||||
file_path = f"{self.basedir}/{path}"
|
||||
last_modified = float(ftp.voidcmd(f"MDTM {file_path}")[4:].strip())
|
||||
m = hashlib.md5()
|
||||
m = hashlib.md5() # nosec
|
||||
ftp.retrbinary(f"RETR {file_path}", m.update)
|
||||
return {"last_modified": last_modified, "checksum": m.hexdigest()}
|
||||
# The file doesn't exist
|
||||
|
|
@ -369,8 +391,8 @@ class FilesPipeline(MediaPipeline):
|
|||
super().__init__(download_func=download_func, settings=settings)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings):
|
||||
s3store = cls.STORE_SCHEMES["s3"]
|
||||
def from_settings(cls, settings) -> Self:
|
||||
s3store: Type[S3FilesStore] = cast(Type[S3FilesStore], cls.STORE_SCHEMES["s3"])
|
||||
s3store.AWS_ACCESS_KEY_ID = settings["AWS_ACCESS_KEY_ID"]
|
||||
s3store.AWS_SECRET_ACCESS_KEY = settings["AWS_SECRET_ACCESS_KEY"]
|
||||
s3store.AWS_SESSION_TOKEN = settings["AWS_SESSION_TOKEN"]
|
||||
|
|
@ -380,11 +402,15 @@ class FilesPipeline(MediaPipeline):
|
|||
s3store.AWS_VERIFY = settings["AWS_VERIFY"]
|
||||
s3store.POLICY = settings["FILES_STORE_S3_ACL"]
|
||||
|
||||
gcs_store = cls.STORE_SCHEMES["gs"]
|
||||
gcs_store: Type[GCSFilesStore] = cast(
|
||||
Type[GCSFilesStore], cls.STORE_SCHEMES["gs"]
|
||||
)
|
||||
gcs_store.GCS_PROJECT_ID = settings["GCS_PROJECT_ID"]
|
||||
gcs_store.POLICY = settings["FILES_STORE_GCS_ACL"] or None
|
||||
|
||||
ftp_store = cls.STORE_SCHEMES["ftp"]
|
||||
ftp_store: Type[FTPFilesStore] = cast(
|
||||
Type[FTPFilesStore], cls.STORE_SCHEMES["ftp"]
|
||||
)
|
||||
ftp_store.FTP_USERNAME = settings["FTP_USER"]
|
||||
ftp_store.FTP_PASSWORD = settings["FTP_PASSWORD"]
|
||||
ftp_store.USE_ACTIVE_MODE = settings.getbool("FEED_STORAGE_FTP_ACTIVE")
|
||||
|
|
@ -433,7 +459,8 @@ class FilesPipeline(MediaPipeline):
|
|||
|
||||
path = self.file_path(request, info=info, item=item)
|
||||
dfd = defer.maybeDeferred(self.store.stat_file, path, info)
|
||||
dfd.addCallbacks(_onsuccess, lambda _: None)
|
||||
dfd.addCallback(_onsuccess)
|
||||
dfd.addErrback(lambda _: None)
|
||||
dfd.addErrback(
|
||||
lambda f: logger.error(
|
||||
self.__class__.__name__ + ".store.stat_file",
|
||||
|
|
@ -531,7 +558,7 @@ class FilesPipeline(MediaPipeline):
|
|||
def file_downloaded(self, response, request, info, *, item=None):
|
||||
path = self.file_path(request, response=response, info=info, item=item)
|
||||
buf = BytesIO(response.body)
|
||||
checksum = md5sum(buf)
|
||||
checksum = _md5sum(buf)
|
||||
buf.seek(0)
|
||||
self.store.persist_file(path, buf, info)
|
||||
return checksum
|
||||
|
|
@ -542,7 +569,7 @@ class FilesPipeline(MediaPipeline):
|
|||
return item
|
||||
|
||||
def file_path(self, request, response=None, info=None, *, item=None):
|
||||
media_guid = hashlib.sha1(to_bytes(request.url)).hexdigest()
|
||||
media_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # nosec
|
||||
media_ext = Path(request.url).suffix
|
||||
# Handles empty and wild extensions by trying to guess the
|
||||
# mime type then extension or default to empty string otherwise
|
||||
|
|
|
|||
|
|
@ -4,26 +4,38 @@ Images Pipeline
|
|||
See documentation in topics/media-pipeline.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import warnings
|
||||
from contextlib import suppress
|
||||
from io import BytesIO
|
||||
from os import PathLike
|
||||
from typing import Dict, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Dict, Tuple, Type, Union, cast
|
||||
|
||||
from itemadapter import ItemAdapter
|
||||
|
||||
from scrapy.exceptions import DropItem, NotConfigured, ScrapyDeprecationWarning
|
||||
from scrapy.http import Request
|
||||
from scrapy.http.request import NO_CALLBACK
|
||||
from scrapy.pipelines.files import FileException, FilesPipeline
|
||||
from scrapy.pipelines.files import (
|
||||
FileException,
|
||||
FilesPipeline,
|
||||
FTPFilesStore,
|
||||
GCSFilesStore,
|
||||
S3FilesStore,
|
||||
_md5sum,
|
||||
)
|
||||
|
||||
# TODO: from scrapy.pipelines.media import MediaPipeline
|
||||
from scrapy.settings import Settings
|
||||
from scrapy.utils.misc import md5sum
|
||||
from scrapy.utils.python import get_func_args, to_bytes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class NoimagesDrop(DropItem):
|
||||
"""Product with no images exception"""
|
||||
|
|
@ -97,8 +109,8 @@ class ImagesPipeline(FilesPipeline):
|
|||
self._deprecated_convert_image = None
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings):
|
||||
s3store = cls.STORE_SCHEMES["s3"]
|
||||
def from_settings(cls, settings) -> Self:
|
||||
s3store: Type[S3FilesStore] = cast(Type[S3FilesStore], cls.STORE_SCHEMES["s3"])
|
||||
s3store.AWS_ACCESS_KEY_ID = settings["AWS_ACCESS_KEY_ID"]
|
||||
s3store.AWS_SECRET_ACCESS_KEY = settings["AWS_SECRET_ACCESS_KEY"]
|
||||
s3store.AWS_SESSION_TOKEN = settings["AWS_SESSION_TOKEN"]
|
||||
|
|
@ -108,11 +120,15 @@ class ImagesPipeline(FilesPipeline):
|
|||
s3store.AWS_VERIFY = settings["AWS_VERIFY"]
|
||||
s3store.POLICY = settings["IMAGES_STORE_S3_ACL"]
|
||||
|
||||
gcs_store = cls.STORE_SCHEMES["gs"]
|
||||
gcs_store: Type[GCSFilesStore] = cast(
|
||||
Type[GCSFilesStore], cls.STORE_SCHEMES["gs"]
|
||||
)
|
||||
gcs_store.GCS_PROJECT_ID = settings["GCS_PROJECT_ID"]
|
||||
gcs_store.POLICY = settings["IMAGES_STORE_GCS_ACL"] or None
|
||||
|
||||
ftp_store = cls.STORE_SCHEMES["ftp"]
|
||||
ftp_store: Type[FTPFilesStore] = cast(
|
||||
Type[FTPFilesStore], cls.STORE_SCHEMES["ftp"]
|
||||
)
|
||||
ftp_store.FTP_USERNAME = settings["FTP_USER"]
|
||||
ftp_store.FTP_PASSWORD = settings["FTP_PASSWORD"]
|
||||
ftp_store.USE_ACTIVE_MODE = settings.getbool("FEED_STORAGE_FTP_ACTIVE")
|
||||
|
|
@ -128,7 +144,7 @@ class ImagesPipeline(FilesPipeline):
|
|||
for path, image, buf in self.get_images(response, request, info, item=item):
|
||||
if checksum is None:
|
||||
buf.seek(0)
|
||||
checksum = md5sum(buf)
|
||||
checksum = _md5sum(buf)
|
||||
width, height = image.size
|
||||
self.store.persist_file(
|
||||
path,
|
||||
|
|
@ -228,9 +244,9 @@ class ImagesPipeline(FilesPipeline):
|
|||
return item
|
||||
|
||||
def file_path(self, request, response=None, info=None, *, item=None):
|
||||
image_guid = hashlib.sha1(to_bytes(request.url)).hexdigest()
|
||||
image_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # nosec
|
||||
return f"full/{image_guid}.jpg"
|
||||
|
||||
def thumb_path(self, request, thumb_id, response=None, info=None, *, item=None):
|
||||
thumb_guid = hashlib.sha1(to_bytes(request.url)).hexdigest()
|
||||
thumb_guid = hashlib.sha1(to_bytes(request.url)).hexdigest() # nosec
|
||||
return f"thumbs/{thumb_id}/{thumb_guid}.jpg"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from twisted.internet.defer import Deferred, DeferredList
|
||||
from twisted.python.failure import Failure
|
||||
|
|
@ -12,6 +15,11 @@ from scrapy.utils.defer import defer_result, mustbe_deferred
|
|||
from scrapy.utils.log import failure_to_exc_info
|
||||
from scrapy.utils.misc import arg_to_iter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -67,9 +75,9 @@ class MediaPipeline:
|
|||
return formatted_key
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler) -> Self:
|
||||
try:
|
||||
pipe = cls.from_settings(crawler.settings)
|
||||
pipe = cls.from_settings(crawler.settings) # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
pipe = cls()
|
||||
pipe.crawler = crawler
|
||||
|
|
@ -98,10 +106,17 @@ class MediaPipeline:
|
|||
|
||||
# Return cached result if request was already seen
|
||||
if fp in info.downloaded:
|
||||
return defer_result(info.downloaded[fp]).addCallbacks(cb, eb)
|
||||
d = defer_result(info.downloaded[fp])
|
||||
d.addCallback(cb)
|
||||
if eb:
|
||||
d.addErrback(eb)
|
||||
return d
|
||||
|
||||
# Otherwise, wait for result
|
||||
wad = Deferred().addCallbacks(cb, eb)
|
||||
wad = Deferred()
|
||||
wad.addCallback(cb)
|
||||
if eb:
|
||||
wad.addErrback(eb)
|
||||
info.waiting[fp].append(wad)
|
||||
|
||||
# Check if request is downloading right now to avoid doing it twice
|
||||
|
|
@ -132,23 +147,11 @@ class MediaPipeline:
|
|||
if self.download_func:
|
||||
# this ugly code was left only to support tests. TODO: remove
|
||||
dfd = mustbe_deferred(self.download_func, request, info.spider)
|
||||
dfd.addCallbacks(
|
||||
callback=self.media_downloaded,
|
||||
callbackArgs=(request, info),
|
||||
callbackKeywords={"item": item},
|
||||
errback=self.media_failed,
|
||||
errbackArgs=(request, info),
|
||||
)
|
||||
else:
|
||||
self._modify_media_request(request)
|
||||
dfd = self.crawler.engine.download(request)
|
||||
dfd.addCallbacks(
|
||||
callback=self.media_downloaded,
|
||||
callbackArgs=(request, info),
|
||||
callbackKeywords={"item": item},
|
||||
errback=self.media_failed,
|
||||
errbackArgs=(request, info),
|
||||
)
|
||||
dfd.addCallback(self.media_downloaded, request, info, item=item)
|
||||
dfd.addErrback(self.media_failed, request, info)
|
||||
return dfd
|
||||
|
||||
def _cache_result_and_execute_waiters(self, result, fp, info):
|
||||
|
|
|
|||
|
|
@ -1,12 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Type,
|
||||
cast,
|
||||
)
|
||||
|
||||
from scrapy import Request
|
||||
from scrapy.core.downloader import Downloader
|
||||
from scrapy.utils.misc import build_from_crawler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _path_safe(text):
|
||||
def _path_safe(text: str) -> str:
|
||||
"""
|
||||
Return a filesystem-safe version of a string ``text``
|
||||
|
||||
|
|
@ -20,10 +41,22 @@ def _path_safe(text):
|
|||
pathable_slot = "".join([c if c.isalnum() or c in "-._" else "_" for c in text])
|
||||
# as we replace some letters we can get collision for different slots
|
||||
# add we add unique part
|
||||
unique_slot = hashlib.md5(text.encode("utf8")).hexdigest()
|
||||
unique_slot = hashlib.md5(text.encode("utf8")).hexdigest() # nosec
|
||||
return "-".join([pathable_slot, unique_slot])
|
||||
|
||||
|
||||
class QueueProtocol(Protocol):
|
||||
"""Protocol for downstream queues of ``ScrapyPriorityQueue``."""
|
||||
|
||||
def push(self, request: Request) -> None: ...
|
||||
|
||||
def pop(self) -> Optional[Request]: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
def __len__(self) -> int: ...
|
||||
|
||||
|
||||
class ScrapyPriorityQueue:
|
||||
"""A priority queue implemented using multiple internal queues (typically,
|
||||
FIFO queues). It uses one internal queue for each priority value. The internal
|
||||
|
|
@ -51,18 +84,30 @@ class ScrapyPriorityQueue:
|
|||
"""
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, downstream_queue_cls, key, startprios=()):
|
||||
def from_crawler(
|
||||
cls,
|
||||
crawler: Crawler,
|
||||
downstream_queue_cls: Type[QueueProtocol],
|
||||
key: str,
|
||||
startprios: Iterable[int] = (),
|
||||
) -> Self:
|
||||
return cls(crawler, downstream_queue_cls, key, startprios)
|
||||
|
||||
def __init__(self, crawler, downstream_queue_cls, key, startprios=()):
|
||||
self.crawler = crawler
|
||||
self.downstream_queue_cls = downstream_queue_cls
|
||||
self.key = key
|
||||
self.queues = {}
|
||||
self.curprio = None
|
||||
def __init__(
|
||||
self,
|
||||
crawler: Crawler,
|
||||
downstream_queue_cls: Type[QueueProtocol],
|
||||
key: str,
|
||||
startprios: Iterable[int] = (),
|
||||
):
|
||||
self.crawler: Crawler = crawler
|
||||
self.downstream_queue_cls: Type[QueueProtocol] = downstream_queue_cls
|
||||
self.key: str = key
|
||||
self.queues: Dict[int, QueueProtocol] = {}
|
||||
self.curprio: Optional[int] = None
|
||||
self.init_prios(startprios)
|
||||
|
||||
def init_prios(self, startprios):
|
||||
def init_prios(self, startprios: Iterable[int]) -> None:
|
||||
if not startprios:
|
||||
return
|
||||
|
||||
|
|
@ -71,17 +116,17 @@ class ScrapyPriorityQueue:
|
|||
|
||||
self.curprio = min(startprios)
|
||||
|
||||
def qfactory(self, key):
|
||||
def qfactory(self, key: int) -> QueueProtocol:
|
||||
return build_from_crawler(
|
||||
self.downstream_queue_cls,
|
||||
self.crawler,
|
||||
self.key + "/" + str(key),
|
||||
)
|
||||
|
||||
def priority(self, request):
|
||||
def priority(self, request: Request) -> int:
|
||||
return -request.priority
|
||||
|
||||
def push(self, request):
|
||||
def push(self, request: Request) -> None:
|
||||
priority = self.priority(request)
|
||||
if priority not in self.queues:
|
||||
self.queues[priority] = self.qfactory(priority)
|
||||
|
|
@ -90,9 +135,9 @@ class ScrapyPriorityQueue:
|
|||
if self.curprio is None or priority < self.curprio:
|
||||
self.curprio = priority
|
||||
|
||||
def pop(self):
|
||||
def pop(self) -> Optional[Request]:
|
||||
if self.curprio is None:
|
||||
return
|
||||
return None
|
||||
q = self.queues[self.curprio]
|
||||
m = q.pop()
|
||||
if not q:
|
||||
|
|
@ -102,7 +147,7 @@ class ScrapyPriorityQueue:
|
|||
self.curprio = min(prios) if prios else None
|
||||
return m
|
||||
|
||||
def peek(self):
|
||||
def peek(self) -> Optional[Request]:
|
||||
"""Returns the next object to be returned by :meth:`pop`,
|
||||
but without removing it from the queue.
|
||||
|
||||
|
|
@ -112,30 +157,32 @@ class ScrapyPriorityQueue:
|
|||
if self.curprio is None:
|
||||
return None
|
||||
queue = self.queues[self.curprio]
|
||||
return queue.peek()
|
||||
# Protocols can't declare optional members
|
||||
return cast(Request, queue.peek()) # type: ignore[attr-defined]
|
||||
|
||||
def close(self):
|
||||
active = []
|
||||
def close(self) -> List[int]:
|
||||
active: List[int] = []
|
||||
for p, q in self.queues.items():
|
||||
active.append(p)
|
||||
q.close()
|
||||
return active
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return sum(len(x) for x in self.queues.values()) if self.queues else 0
|
||||
|
||||
|
||||
class DownloaderInterface:
|
||||
def __init__(self, crawler):
|
||||
self.downloader = crawler.engine.downloader
|
||||
def __init__(self, crawler: Crawler):
|
||||
assert crawler.engine
|
||||
self.downloader: Downloader = crawler.engine.downloader
|
||||
|
||||
def stats(self, possible_slots):
|
||||
def stats(self, possible_slots: Iterable[str]) -> List[Tuple[int, str]]:
|
||||
return [(self._active_downloads(slot), slot) for slot in possible_slots]
|
||||
|
||||
def get_slot_key(self, request):
|
||||
return self.downloader._get_slot_key(request, None)
|
||||
def get_slot_key(self, request: Request) -> str:
|
||||
return self.downloader.get_slot_key(request)
|
||||
|
||||
def _active_downloads(self, slot):
|
||||
def _active_downloads(self, slot: str) -> int:
|
||||
"""Return a number of requests in a Downloader for a given slot"""
|
||||
if slot not in self.downloader.slots:
|
||||
return 0
|
||||
|
|
@ -149,10 +196,22 @@ class DownloaderAwarePriorityQueue:
|
|||
"""
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, downstream_queue_cls, key, startprios=()):
|
||||
def from_crawler(
|
||||
cls,
|
||||
crawler: Crawler,
|
||||
downstream_queue_cls: Type[QueueProtocol],
|
||||
key: str,
|
||||
startprios: Optional[Dict[str, Iterable[int]]] = None,
|
||||
) -> Self:
|
||||
return cls(crawler, downstream_queue_cls, key, startprios)
|
||||
|
||||
def __init__(self, crawler, downstream_queue_cls, key, slot_startprios=()):
|
||||
def __init__(
|
||||
self,
|
||||
crawler: Crawler,
|
||||
downstream_queue_cls: Type[QueueProtocol],
|
||||
key: str,
|
||||
slot_startprios: Optional[Dict[str, Iterable[int]]] = None,
|
||||
):
|
||||
if crawler.settings.getint("CONCURRENT_REQUESTS_PER_IP") != 0:
|
||||
raise ValueError(
|
||||
f'"{self.__class__}" does not support CONCURRENT_REQUESTS_PER_IP'
|
||||
|
|
@ -169,16 +228,18 @@ class DownloaderAwarePriorityQueue:
|
|||
"queue class can be resumed."
|
||||
)
|
||||
|
||||
self._downloader_interface = DownloaderInterface(crawler)
|
||||
self.downstream_queue_cls = downstream_queue_cls
|
||||
self.key = key
|
||||
self.crawler = crawler
|
||||
self._downloader_interface: DownloaderInterface = DownloaderInterface(crawler)
|
||||
self.downstream_queue_cls: Type[QueueProtocol] = downstream_queue_cls
|
||||
self.key: str = key
|
||||
self.crawler: Crawler = crawler
|
||||
|
||||
self.pqueues = {} # slot -> priority queue
|
||||
self.pqueues: Dict[str, ScrapyPriorityQueue] = {} # slot -> priority queue
|
||||
for slot, startprios in (slot_startprios or {}).items():
|
||||
self.pqueues[slot] = self.pqfactory(slot, startprios)
|
||||
|
||||
def pqfactory(self, slot, startprios=()):
|
||||
def pqfactory(
|
||||
self, slot: str, startprios: Iterable[int] = ()
|
||||
) -> ScrapyPriorityQueue:
|
||||
return ScrapyPriorityQueue(
|
||||
self.crawler,
|
||||
self.downstream_queue_cls,
|
||||
|
|
@ -186,11 +247,11 @@ class DownloaderAwarePriorityQueue:
|
|||
startprios,
|
||||
)
|
||||
|
||||
def pop(self):
|
||||
def pop(self) -> Optional[Request]:
|
||||
stats = self._downloader_interface.stats(self.pqueues)
|
||||
|
||||
if not stats:
|
||||
return
|
||||
return None
|
||||
|
||||
slot = min(stats)[1]
|
||||
queue = self.pqueues[slot]
|
||||
|
|
@ -199,14 +260,14 @@ class DownloaderAwarePriorityQueue:
|
|||
del self.pqueues[slot]
|
||||
return request
|
||||
|
||||
def push(self, request):
|
||||
def push(self, request: Request) -> None:
|
||||
slot = self._downloader_interface.get_slot_key(request)
|
||||
if slot not in self.pqueues:
|
||||
self.pqueues[slot] = self.pqfactory(slot)
|
||||
queue = self.pqueues[slot]
|
||||
queue.push(request)
|
||||
|
||||
def peek(self):
|
||||
def peek(self) -> Optional[Request]:
|
||||
"""Returns the next object to be returned by :meth:`pop`,
|
||||
but without removing it from the queue.
|
||||
|
||||
|
|
@ -220,13 +281,13 @@ class DownloaderAwarePriorityQueue:
|
|||
queue = self.pqueues[slot]
|
||||
return queue.peek()
|
||||
|
||||
def close(self):
|
||||
def close(self) -> Dict[str, List[int]]:
|
||||
active = {slot: queue.close() for slot, queue in self.pqueues.items()}
|
||||
self.pqueues.clear()
|
||||
return active
|
||||
|
||||
def __len__(self):
|
||||
def __len__(self) -> int:
|
||||
return sum(len(x) for x in self.pqueues.values()) if self.pqueues else 0
|
||||
|
||||
def __contains__(self, slot):
|
||||
def __contains__(self, slot: str) -> bool:
|
||||
return slot in self.pqueues
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from typing import Any
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Type
|
||||
|
||||
from twisted.internet import defer
|
||||
from twisted.internet.base import ThreadedResolver
|
||||
from twisted.internet.base import ReactorBase, ThreadedResolver
|
||||
from twisted.internet.defer import Deferred
|
||||
from twisted.internet.interfaces import (
|
||||
IAddress,
|
||||
IHostnameResolver,
|
||||
IHostResolution,
|
||||
IResolutionReceiver,
|
||||
|
|
@ -12,6 +16,12 @@ from zope.interface.declarations import implementer, provider
|
|||
|
||||
from scrapy.utils.datatypes import LocalCache
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
|
||||
# TODO: cache misses
|
||||
dnscache: LocalCache[str, Any] = LocalCache(10000)
|
||||
|
||||
|
|
@ -22,65 +32,66 @@ class CachingThreadedResolver(ThreadedResolver):
|
|||
Default caching resolver. IPv4 only, supports setting a timeout value for DNS requests.
|
||||
"""
|
||||
|
||||
def __init__(self, reactor, cache_size, timeout):
|
||||
def __init__(self, reactor: ReactorBase, cache_size: int, timeout: float):
|
||||
super().__init__(reactor)
|
||||
dnscache.limit = cache_size
|
||||
self.timeout = timeout
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, reactor):
|
||||
def from_crawler(cls, crawler: Crawler, reactor: ReactorBase) -> Self:
|
||||
if crawler.settings.getbool("DNSCACHE_ENABLED"):
|
||||
cache_size = crawler.settings.getint("DNSCACHE_SIZE")
|
||||
else:
|
||||
cache_size = 0
|
||||
return cls(reactor, cache_size, crawler.settings.getfloat("DNS_TIMEOUT"))
|
||||
|
||||
def install_on_reactor(self):
|
||||
def install_on_reactor(self) -> None:
|
||||
self.reactor.installResolver(self)
|
||||
|
||||
def getHostByName(self, name: str, timeout=None):
|
||||
def getHostByName(self, name: str, timeout: Sequence[int] = ()) -> Deferred[str]:
|
||||
if name in dnscache:
|
||||
return defer.succeed(dnscache[name])
|
||||
# in Twisted<=16.6, getHostByName() is always called with
|
||||
# a default timeout of 60s (actually passed as (1, 3, 11, 45) tuple),
|
||||
# so the input argument above is simply overridden
|
||||
# to enforce Scrapy's DNS_TIMEOUT setting's value
|
||||
timeout = (self.timeout,)
|
||||
# The timeout arg is typed as Sequence[int] but supports floats.
|
||||
timeout = (self.timeout,) # type: ignore[assignment]
|
||||
d = super().getHostByName(name, timeout)
|
||||
if dnscache.limit:
|
||||
d.addCallback(self._cache_result, name)
|
||||
return d
|
||||
|
||||
def _cache_result(self, result, name):
|
||||
def _cache_result(self, result: Any, name: str) -> Any:
|
||||
dnscache[name] = result
|
||||
return result
|
||||
|
||||
|
||||
@implementer(IHostResolution)
|
||||
class HostResolution:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
def __init__(self, name: str):
|
||||
self.name: str = name
|
||||
|
||||
def cancel(self):
|
||||
def cancel(self) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@provider(IResolutionReceiver)
|
||||
class _CachingResolutionReceiver:
|
||||
def __init__(self, resolutionReceiver, hostName):
|
||||
self.resolutionReceiver = resolutionReceiver
|
||||
self.hostName = hostName
|
||||
self.addresses = []
|
||||
def __init__(self, resolutionReceiver: IResolutionReceiver, hostName: str):
|
||||
self.resolutionReceiver: IResolutionReceiver = resolutionReceiver
|
||||
self.hostName: str = hostName
|
||||
self.addresses: List[IAddress] = []
|
||||
|
||||
def resolutionBegan(self, resolution):
|
||||
def resolutionBegan(self, resolution: IHostResolution) -> None:
|
||||
self.resolutionReceiver.resolutionBegan(resolution)
|
||||
self.resolution = resolution
|
||||
|
||||
def addressResolved(self, address):
|
||||
def addressResolved(self, address: IAddress) -> None:
|
||||
self.resolutionReceiver.addressResolved(address)
|
||||
self.addresses.append(address)
|
||||
|
||||
def resolutionComplete(self):
|
||||
def resolutionComplete(self) -> None:
|
||||
self.resolutionReceiver.resolutionComplete()
|
||||
if self.addresses:
|
||||
dnscache[self.hostName] = self.addresses
|
||||
|
|
@ -93,30 +104,30 @@ class CachingHostnameResolver:
|
|||
does not support setting a timeout value for DNS requests.
|
||||
"""
|
||||
|
||||
def __init__(self, reactor, cache_size):
|
||||
self.reactor = reactor
|
||||
self.original_resolver = reactor.nameResolver
|
||||
def __init__(self, reactor: ReactorBase, cache_size: int):
|
||||
self.reactor: ReactorBase = reactor
|
||||
self.original_resolver: IHostnameResolver = reactor.nameResolver
|
||||
dnscache.limit = cache_size
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, reactor):
|
||||
def from_crawler(cls, crawler: Crawler, reactor: ReactorBase) -> Self:
|
||||
if crawler.settings.getbool("DNSCACHE_ENABLED"):
|
||||
cache_size = crawler.settings.getint("DNSCACHE_SIZE")
|
||||
else:
|
||||
cache_size = 0
|
||||
return cls(reactor, cache_size)
|
||||
|
||||
def install_on_reactor(self):
|
||||
def install_on_reactor(self) -> None:
|
||||
self.reactor.installNameResolver(self)
|
||||
|
||||
def resolveHostName(
|
||||
self,
|
||||
resolutionReceiver,
|
||||
resolutionReceiver: IResolutionReceiver,
|
||||
hostName: str,
|
||||
portNumber=0,
|
||||
addressTypes=None,
|
||||
transportSemantics="TCP",
|
||||
):
|
||||
portNumber: int = 0,
|
||||
addressTypes: Optional[Sequence[Type[IAddress]]] = None,
|
||||
transportSemantics: str = "TCP",
|
||||
) -> IHostResolution:
|
||||
try:
|
||||
addresses = dnscache[hostName]
|
||||
except KeyError:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@ from __future__ import annotations
|
|||
import logging
|
||||
import sys
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import TYPE_CHECKING, Optional, Union
|
||||
from warnings import warn
|
||||
|
||||
from scrapy import Spider
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
from scrapy.utils.python import to_unicode
|
||||
|
||||
|
|
@ -18,12 +19,14 @@ if TYPE_CHECKING:
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decode_robotstxt(robotstxt_body, spider, to_native_str_type=False):
|
||||
def decode_robotstxt(
|
||||
robotstxt_body: bytes, spider: Optional[Spider], to_native_str_type: bool = False
|
||||
) -> str:
|
||||
try:
|
||||
if to_native_str_type:
|
||||
robotstxt_body = to_unicode(robotstxt_body)
|
||||
body_decoded = to_unicode(robotstxt_body)
|
||||
else:
|
||||
robotstxt_body = robotstxt_body.decode("utf-8")
|
||||
body_decoded = robotstxt_body.decode("utf-8", errors="ignore")
|
||||
except UnicodeDecodeError:
|
||||
# If we found garbage or robots.txt in an encoding other than UTF-8, disregard it.
|
||||
# Switch to 'allow all' state.
|
||||
|
|
@ -33,8 +36,8 @@ def decode_robotstxt(robotstxt_body, spider, to_native_str_type=False):
|
|||
exc_info=sys.exc_info(),
|
||||
extra={"spider": spider},
|
||||
)
|
||||
robotstxt_body = ""
|
||||
return robotstxt_body
|
||||
body_decoded = ""
|
||||
return body_decoded
|
||||
|
||||
|
||||
class RobotParser(metaclass=ABCMeta):
|
||||
|
|
@ -66,82 +69,80 @@ class RobotParser(metaclass=ABCMeta):
|
|||
|
||||
|
||||
class PythonRobotParser(RobotParser):
|
||||
def __init__(self, robotstxt_body, spider):
|
||||
def __init__(self, robotstxt_body: bytes, spider: Optional[Spider]):
|
||||
from urllib.robotparser import RobotFileParser
|
||||
|
||||
self.spider = spider
|
||||
robotstxt_body = decode_robotstxt(
|
||||
robotstxt_body, spider, to_native_str_type=True
|
||||
)
|
||||
self.rp = RobotFileParser()
|
||||
self.rp.parse(robotstxt_body.splitlines())
|
||||
self.spider: Optional[Spider] = spider
|
||||
body_decoded = decode_robotstxt(robotstxt_body, spider, to_native_str_type=True)
|
||||
self.rp: RobotFileParser = RobotFileParser()
|
||||
self.rp.parse(body_decoded.splitlines())
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, robotstxt_body):
|
||||
def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self:
|
||||
spider = None if not crawler else crawler.spider
|
||||
o = cls(robotstxt_body, spider)
|
||||
return o
|
||||
|
||||
def allowed(self, url, user_agent):
|
||||
def allowed(self, url: Union[str, bytes], user_agent: Union[str, bytes]) -> bool:
|
||||
user_agent = to_unicode(user_agent)
|
||||
url = to_unicode(url)
|
||||
return self.rp.can_fetch(user_agent, url)
|
||||
|
||||
|
||||
class ReppyRobotParser(RobotParser):
|
||||
def __init__(self, robotstxt_body, spider):
|
||||
def __init__(self, robotstxt_body: bytes, spider: Optional[Spider]):
|
||||
warn("ReppyRobotParser is deprecated.", ScrapyDeprecationWarning, stacklevel=2)
|
||||
from reppy.robots import Robots
|
||||
|
||||
self.spider = spider
|
||||
self.spider: Optional[Spider] = spider
|
||||
self.rp = Robots.parse("", robotstxt_body)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, robotstxt_body):
|
||||
def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self:
|
||||
spider = None if not crawler else crawler.spider
|
||||
o = cls(robotstxt_body, spider)
|
||||
return o
|
||||
|
||||
def allowed(self, url, user_agent):
|
||||
def allowed(self, url: Union[str, bytes], user_agent: Union[str, bytes]) -> bool:
|
||||
return self.rp.allowed(url, user_agent)
|
||||
|
||||
|
||||
class RerpRobotParser(RobotParser):
|
||||
def __init__(self, robotstxt_body, spider):
|
||||
def __init__(self, robotstxt_body: bytes, spider: Optional[Spider]):
|
||||
from robotexclusionrulesparser import RobotExclusionRulesParser
|
||||
|
||||
self.spider = spider
|
||||
self.rp = RobotExclusionRulesParser()
|
||||
robotstxt_body = decode_robotstxt(robotstxt_body, spider)
|
||||
self.rp.parse(robotstxt_body)
|
||||
self.spider: Optional[Spider] = spider
|
||||
self.rp: RobotExclusionRulesParser = RobotExclusionRulesParser()
|
||||
body_decoded = decode_robotstxt(robotstxt_body, spider)
|
||||
self.rp.parse(body_decoded)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, robotstxt_body):
|
||||
def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self:
|
||||
spider = None if not crawler else crawler.spider
|
||||
o = cls(robotstxt_body, spider)
|
||||
return o
|
||||
|
||||
def allowed(self, url, user_agent):
|
||||
def allowed(self, url: Union[str, bytes], user_agent: Union[str, bytes]) -> bool:
|
||||
user_agent = to_unicode(user_agent)
|
||||
url = to_unicode(url)
|
||||
return self.rp.is_allowed(user_agent, url)
|
||||
|
||||
|
||||
class ProtegoRobotParser(RobotParser):
|
||||
def __init__(self, robotstxt_body, spider):
|
||||
def __init__(self, robotstxt_body: bytes, spider: Optional[Spider]):
|
||||
from protego import Protego
|
||||
|
||||
self.spider = spider
|
||||
robotstxt_body = decode_robotstxt(robotstxt_body, spider)
|
||||
self.rp = Protego.parse(robotstxt_body)
|
||||
self.spider: Optional[Spider] = spider
|
||||
body_decoded = decode_robotstxt(robotstxt_body, spider)
|
||||
self.rp = Protego.parse(body_decoded)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, robotstxt_body):
|
||||
def from_crawler(cls, crawler: Crawler, robotstxt_body: bytes) -> Self:
|
||||
spider = None if not crawler else crawler.spider
|
||||
o = cls(robotstxt_body, spider)
|
||||
return o
|
||||
|
||||
def allowed(self, url, user_agent):
|
||||
def allowed(self, url: Union[str, bytes], user_agent: Union[str, bytes]) -> bool:
|
||||
user_agent = to_unicode(user_agent)
|
||||
url = to_unicode(url)
|
||||
return self.rp.can_fetch(url, user_agent)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from parsel import Selector as _ParselSelector
|
|||
|
||||
from scrapy.http import HtmlResponse, TextResponse, XmlResponse
|
||||
from scrapy.utils.python import to_bytes
|
||||
from scrapy.utils.response import get_base_url
|
||||
from scrapy.utils.trackref import object_ref
|
||||
|
||||
__all__ = ["Selector", "SelectorList"]
|
||||
|
|
@ -46,7 +47,7 @@ class Selector(_ParselSelector, object_ref):
|
|||
``response`` isn't available. Using ``text`` and ``response`` together is
|
||||
undefined behavior.
|
||||
|
||||
``type`` defines the selector type, it can be ``"html"``, ``"xml"``
|
||||
``type`` defines the selector type, it can be ``"html"``, ``"xml"``, ``"json"``
|
||||
or ``None`` (default).
|
||||
|
||||
If ``type`` is ``None``, the selector automatically chooses the best type
|
||||
|
|
@ -88,7 +89,7 @@ class Selector(_ParselSelector, object_ref):
|
|||
|
||||
if response is not None:
|
||||
text = response.text
|
||||
kwargs.setdefault("base_url", response.url)
|
||||
kwargs.setdefault("base_url", get_base_url(response))
|
||||
|
||||
self.response = response
|
||||
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ FILES_STORE_S3_ACL = "private"
|
|||
FILES_STORE_GCS_ACL = ""
|
||||
|
||||
FTP_USER = "anonymous"
|
||||
FTP_PASSWORD = "guest"
|
||||
FTP_PASSWORD = "guest" # nosec
|
||||
FTP_PASSIVE_MODE = True
|
||||
|
||||
GCS_PROJECT_ID = None
|
||||
|
|
@ -239,7 +239,7 @@ MEMUSAGE_NOTIFY_MAIL = []
|
|||
MEMUSAGE_WARNING_MB = 0
|
||||
|
||||
METAREFRESH_ENABLED = True
|
||||
METAREFRESH_IGNORE_TAGS = []
|
||||
METAREFRESH_IGNORE_TAGS = ["noscript"]
|
||||
METAREFRESH_MAXDELAY = 100
|
||||
|
||||
NEWSPIDER_MODULE = ""
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ See documentation in docs/topics/shell.rst
|
|||
|
||||
import os
|
||||
import signal
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
from itemadapter import is_item
|
||||
from twisted.internet import defer, threads
|
||||
|
|
@ -26,18 +27,32 @@ from scrapy.utils.response import open_in_browser
|
|||
|
||||
|
||||
class Shell:
|
||||
relevant_classes = (Crawler, Spider, Request, Response, Settings)
|
||||
relevant_classes: Tuple[type, ...] = (Crawler, Spider, Request, Response, Settings)
|
||||
|
||||
def __init__(self, crawler, update_vars=None, code=None):
|
||||
self.crawler = crawler
|
||||
self.update_vars = update_vars or (lambda x: None)
|
||||
self.item_class = load_object(crawler.settings["DEFAULT_ITEM_CLASS"])
|
||||
self.spider = None
|
||||
self.inthread = not threadable.isInIOThread()
|
||||
self.code = code
|
||||
self.vars = {}
|
||||
def __init__(
|
||||
self,
|
||||
crawler: Crawler,
|
||||
update_vars: Optional[Callable[[Dict[str, Any]], None]] = None,
|
||||
code: Optional[str] = None,
|
||||
):
|
||||
self.crawler: Crawler = crawler
|
||||
self.update_vars: Callable[[Dict[str, Any]], None] = update_vars or (
|
||||
lambda x: None
|
||||
)
|
||||
self.item_class: type = load_object(crawler.settings["DEFAULT_ITEM_CLASS"])
|
||||
self.spider: Optional[Spider] = None
|
||||
self.inthread: bool = not threadable.isInIOThread()
|
||||
self.code: Optional[str] = code
|
||||
self.vars: Dict[str, Any] = {}
|
||||
|
||||
def start(self, url=None, request=None, response=None, spider=None, redirect=True):
|
||||
def start(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
request: Optional[Request] = None,
|
||||
response: Optional[Response] = None,
|
||||
spider: Optional[Spider] = None,
|
||||
redirect: bool = True,
|
||||
) -> None:
|
||||
# disable accidental Ctrl-C key press from shutting down the engine
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
if url:
|
||||
|
|
@ -50,7 +65,7 @@ class Shell:
|
|||
else:
|
||||
self.populate_vars()
|
||||
if self.code:
|
||||
print(eval(self.code, globals(), self.vars))
|
||||
print(eval(self.code, globals(), self.vars)) # nosec
|
||||
else:
|
||||
"""
|
||||
Detect interactive shell setting in scrapy.cfg
|
||||
|
|
@ -77,7 +92,7 @@ class Shell:
|
|||
self.vars, shells=shells, banner=self.vars.pop("banner", "")
|
||||
)
|
||||
|
||||
def _schedule(self, request, spider):
|
||||
def _schedule(self, request: Request, spider: Optional[Spider]) -> defer.Deferred:
|
||||
if is_asyncio_reactor_installed():
|
||||
# set the asyncio event loop for the current thread
|
||||
event_loop_path = self.crawler.settings["ASYNCIO_EVENT_LOOP"]
|
||||
|
|
@ -85,10 +100,11 @@ class Shell:
|
|||
spider = self._open_spider(request, spider)
|
||||
d = _request_deferred(request)
|
||||
d.addCallback(lambda x: (x, spider))
|
||||
assert self.crawler.engine
|
||||
self.crawler.engine.crawl(request)
|
||||
return d
|
||||
|
||||
def _open_spider(self, request, spider):
|
||||
def _open_spider(self, request: Request, spider: Optional[Spider]) -> Spider:
|
||||
if self.spider:
|
||||
return self.spider
|
||||
|
||||
|
|
@ -96,11 +112,18 @@ class Shell:
|
|||
spider = self.crawler.spider or self.crawler._create_spider()
|
||||
|
||||
self.crawler.spider = spider
|
||||
assert self.crawler.engine
|
||||
self.crawler.engine.open_spider(spider, close_if_idle=False)
|
||||
self.spider = spider
|
||||
return spider
|
||||
|
||||
def fetch(self, request_or_url, spider=None, redirect=True, **kwargs):
|
||||
def fetch(
|
||||
self,
|
||||
request_or_url: Union[Request, str],
|
||||
spider: Optional[Spider] = None,
|
||||
redirect: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
from twisted.internet import reactor
|
||||
|
||||
if isinstance(request_or_url, Request):
|
||||
|
|
@ -123,7 +146,12 @@ class Shell:
|
|||
pass
|
||||
self.populate_vars(response, request, spider)
|
||||
|
||||
def populate_vars(self, response=None, request=None, spider=None):
|
||||
def populate_vars(
|
||||
self,
|
||||
response: Optional[Response] = None,
|
||||
request: Optional[Request] = None,
|
||||
spider: Optional[Spider] = None,
|
||||
) -> None:
|
||||
import scrapy
|
||||
|
||||
self.vars["scrapy"] = scrapy
|
||||
|
|
@ -141,10 +169,10 @@ class Shell:
|
|||
if not self.code:
|
||||
self.vars["banner"] = self.get_help()
|
||||
|
||||
def print_help(self):
|
||||
def print_help(self) -> None:
|
||||
print(self.get_help())
|
||||
|
||||
def get_help(self):
|
||||
def get_help(self) -> str:
|
||||
b = []
|
||||
b.append("Available Scrapy objects:")
|
||||
b.append(
|
||||
|
|
@ -168,11 +196,11 @@ class Shell:
|
|||
|
||||
return "\n".join(f"[s] {line}" for line in b)
|
||||
|
||||
def _is_relevant(self, value):
|
||||
def _is_relevant(self, value: Any) -> bool:
|
||||
return isinstance(value, self.relevant_classes) or is_item(value)
|
||||
|
||||
|
||||
def inspect_response(response, spider):
|
||||
def inspect_response(response: Response, spider: Spider) -> None:
|
||||
"""Open a shell to inspect the given response"""
|
||||
# Shell.start removes the SIGINT handler, so save it and re-add it after
|
||||
# the shell has closed
|
||||
|
|
@ -181,7 +209,7 @@ def inspect_response(response, spider):
|
|||
signal.signal(signal.SIGINT, sigint_handler)
|
||||
|
||||
|
||||
def _request_deferred(request):
|
||||
def _request_deferred(request: Request) -> defer.Deferred:
|
||||
"""Wrap a request inside a Deferred.
|
||||
|
||||
This function is harmful, do not use it until you know what you are doing.
|
||||
|
|
@ -195,15 +223,17 @@ def _request_deferred(request):
|
|||
request_callback = request.callback
|
||||
request_errback = request.errback
|
||||
|
||||
def _restore_callbacks(result):
|
||||
def _restore_callbacks(result: Any) -> Any:
|
||||
request.callback = request_callback
|
||||
request.errback = request_errback
|
||||
return result
|
||||
|
||||
d = defer.Deferred()
|
||||
d: defer.Deferred = defer.Deferred()
|
||||
d.addBoth(_restore_callbacks)
|
||||
if request.callback:
|
||||
d.addCallbacks(request.callback, request.errback)
|
||||
d.addCallback(request.callback)
|
||||
if request.errback:
|
||||
d.addErrback(request.errback)
|
||||
|
||||
request.callback, request.errback = d.callback, d.errback
|
||||
return d
|
||||
|
|
|
|||
|
|
@ -17,11 +17,17 @@ from scrapy.utils.trackref import object_ref
|
|||
from scrapy.utils.url import url_is_from_spider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
# typing.Concatenate requires Python 3.10
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
from typing_extensions import Concatenate, Self
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.settings import BaseSettings
|
||||
from scrapy.utils.log import SpiderLoggerAdapter
|
||||
|
||||
CallbackT = Callable[Concatenate[Response, ...], Any]
|
||||
|
||||
|
||||
class Spider(object_ref):
|
||||
|
|
@ -34,7 +40,7 @@ class Spider(object_ref):
|
|||
|
||||
def __init__(self, name: Optional[str] = None, **kwargs: Any):
|
||||
if name is not None:
|
||||
self.name = name
|
||||
self.name: str = name
|
||||
elif not getattr(self, "name", None):
|
||||
raise ValueError(f"{type(self).__name__} must have a name")
|
||||
self.__dict__.update(kwargs)
|
||||
|
|
@ -42,9 +48,11 @@ class Spider(object_ref):
|
|||
self.start_urls: List[str] = []
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.LoggerAdapter:
|
||||
def logger(self) -> SpiderLoggerAdapter:
|
||||
from scrapy.utils.log import SpiderLoggerAdapter
|
||||
|
||||
logger = logging.getLogger(self.name)
|
||||
return logging.LoggerAdapter(logger, {"spider": self})
|
||||
return SpiderLoggerAdapter(logger, {"spider": self})
|
||||
|
||||
def log(self, message: Any, level: int = logging.DEBUG, **kw: Any) -> None:
|
||||
"""Log the given message at the given log level
|
||||
|
|
@ -62,8 +70,8 @@ class Spider(object_ref):
|
|||
return spider
|
||||
|
||||
def _set_crawler(self, crawler: Crawler) -> None:
|
||||
self.crawler = crawler
|
||||
self.settings = crawler.settings
|
||||
self.crawler: Crawler = crawler
|
||||
self.settings: BaseSettings = crawler.settings
|
||||
crawler.signals.connect(self.close, signals.spider_closed)
|
||||
|
||||
def start_requests(self) -> Iterable[Request]:
|
||||
|
|
@ -79,10 +87,14 @@ class Spider(object_ref):
|
|||
def _parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
return self.parse(response, **kwargs)
|
||||
|
||||
def parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__}.parse callback is not defined"
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
parse: CallbackT
|
||||
else:
|
||||
|
||||
def parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
raise NotImplementedError(
|
||||
f"{self.__class__.__name__}.parse callback is not defined"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def update_settings(cls, settings: BaseSettings) -> None:
|
||||
|
|
|
|||
|
|
@ -5,29 +5,65 @@ for scraping typical web sites that requires crawling pages.
|
|||
See documentation in docs/topics/spiders.rst
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import AsyncIterable, Awaitable, Sequence
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncIterable,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from twisted.python.failure import Failure
|
||||
|
||||
from scrapy.http import HtmlResponse, Request, Response
|
||||
from scrapy.link import Link
|
||||
from scrapy.linkextractors import LinkExtractor
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.utils.asyncgen import collect_asyncgen
|
||||
from scrapy.utils.spider import iterate_spider_output
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
def _identity(x):
|
||||
from scrapy.crawler import Crawler
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
ProcessLinksT = Callable[[List[Link]], List[Link]]
|
||||
ProcessRequestT = Callable[[Request, Response], Optional[Request]]
|
||||
|
||||
|
||||
def _identity(x: _T) -> _T:
|
||||
return x
|
||||
|
||||
|
||||
def _identity_process_request(request, response):
|
||||
def _identity_process_request(
|
||||
request: Request, response: Response
|
||||
) -> Optional[Request]:
|
||||
return request
|
||||
|
||||
|
||||
def _get_method(method, spider):
|
||||
def _get_method(
|
||||
method: Union[Callable, str, None], spider: Spider
|
||||
) -> Optional[Callable]:
|
||||
if callable(method):
|
||||
return method
|
||||
if isinstance(method, str):
|
||||
return getattr(spider, method, None)
|
||||
return None
|
||||
|
||||
|
||||
_default_link_extractor = LinkExtractor()
|
||||
|
|
@ -36,37 +72,46 @@ _default_link_extractor = LinkExtractor()
|
|||
class Rule:
|
||||
def __init__(
|
||||
self,
|
||||
link_extractor=None,
|
||||
callback=None,
|
||||
cb_kwargs=None,
|
||||
follow=None,
|
||||
process_links=None,
|
||||
process_request=None,
|
||||
errback=None,
|
||||
link_extractor: Optional[LinkExtractor] = None,
|
||||
callback: Union[Callable, str, None] = None,
|
||||
cb_kwargs: Optional[Dict[str, Any]] = None,
|
||||
follow: Optional[bool] = None,
|
||||
process_links: Union[ProcessLinksT, str, None] = None,
|
||||
process_request: Union[ProcessRequestT, str, None] = None,
|
||||
errback: Union[Callable[[Failure], Any], str, None] = None,
|
||||
):
|
||||
self.link_extractor = link_extractor or _default_link_extractor
|
||||
self.callback = callback
|
||||
self.errback = errback
|
||||
self.cb_kwargs = cb_kwargs or {}
|
||||
self.process_links = process_links or _identity
|
||||
self.process_request = process_request or _identity_process_request
|
||||
self.follow = follow if follow is not None else not callback
|
||||
self.link_extractor: LinkExtractor = link_extractor or _default_link_extractor
|
||||
self.callback: Union[Callable, str, None] = callback
|
||||
self.errback: Union[Callable[[Failure], Any], str, None] = errback
|
||||
self.cb_kwargs: Dict[str, Any] = cb_kwargs or {}
|
||||
self.process_links: Union[ProcessLinksT, str] = process_links or _identity
|
||||
self.process_request: Union[ProcessRequestT, str] = (
|
||||
process_request or _identity_process_request
|
||||
)
|
||||
self.follow: bool = follow if follow is not None else not callback
|
||||
|
||||
def _compile(self, spider):
|
||||
def _compile(self, spider: Spider) -> None:
|
||||
# this replaces method names with methods and we can't express this in type hints
|
||||
self.callback = _get_method(self.callback, spider)
|
||||
self.errback = _get_method(self.errback, spider)
|
||||
self.process_links = _get_method(self.process_links, spider)
|
||||
self.process_request = _get_method(self.process_request, spider)
|
||||
self.errback = cast(Callable[[Failure], Any], _get_method(self.errback, spider))
|
||||
self.process_links = cast(
|
||||
ProcessLinksT, _get_method(self.process_links, spider)
|
||||
)
|
||||
self.process_request = cast(
|
||||
ProcessRequestT, _get_method(self.process_request, spider)
|
||||
)
|
||||
|
||||
|
||||
class CrawlSpider(Spider):
|
||||
rules: Sequence[Rule] = ()
|
||||
_rules: List[Rule]
|
||||
_follow_links: bool
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
def __init__(self, *a: Any, **kw: Any):
|
||||
super().__init__(*a, **kw)
|
||||
self._compile_rules()
|
||||
|
||||
def _parse(self, response, **kwargs):
|
||||
def _parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
return self._parse_response(
|
||||
response=response,
|
||||
callback=self.parse_start_url,
|
||||
|
|
@ -74,13 +119,13 @@ class CrawlSpider(Spider):
|
|||
follow=True,
|
||||
)
|
||||
|
||||
def parse_start_url(self, response, **kwargs):
|
||||
def parse_start_url(self, response: Response, **kwargs: Any) -> Any:
|
||||
return []
|
||||
|
||||
def process_results(self, response: Response, results: list):
|
||||
def process_results(self, response: Response, results: Any) -> Any:
|
||||
return results
|
||||
|
||||
def _build_request(self, rule_index, link):
|
||||
def _build_request(self, rule_index: int, link: Link) -> Request:
|
||||
return Request(
|
||||
url=link.url,
|
||||
callback=self._callback,
|
||||
|
|
@ -88,32 +133,43 @@ class CrawlSpider(Spider):
|
|||
meta={"rule": rule_index, "link_text": link.text},
|
||||
)
|
||||
|
||||
def _requests_to_follow(self, response):
|
||||
def _requests_to_follow(self, response: Response) -> Iterable[Optional[Request]]:
|
||||
if not isinstance(response, HtmlResponse):
|
||||
return
|
||||
seen = set()
|
||||
seen: Set[Link] = set()
|
||||
for rule_index, rule in enumerate(self._rules):
|
||||
links = [
|
||||
links: List[Link] = [
|
||||
lnk
|
||||
for lnk in rule.link_extractor.extract_links(response)
|
||||
if lnk not in seen
|
||||
]
|
||||
for link in rule.process_links(links):
|
||||
for link in cast(ProcessLinksT, rule.process_links)(links):
|
||||
seen.add(link)
|
||||
request = self._build_request(rule_index, link)
|
||||
yield rule.process_request(request, response)
|
||||
yield cast(ProcessRequestT, rule.process_request)(request, response)
|
||||
|
||||
def _callback(self, response, **cb_kwargs):
|
||||
rule = self._rules[response.meta["rule"]]
|
||||
def _callback(self, response: Response, **cb_kwargs: Any) -> Any:
|
||||
rule = self._rules[cast(int, response.meta["rule"])]
|
||||
return self._parse_response(
|
||||
response, rule.callback, {**rule.cb_kwargs, **cb_kwargs}, rule.follow
|
||||
response,
|
||||
cast(Callable, rule.callback),
|
||||
{**rule.cb_kwargs, **cb_kwargs},
|
||||
rule.follow,
|
||||
)
|
||||
|
||||
def _errback(self, failure):
|
||||
rule = self._rules[failure.request.meta["rule"]]
|
||||
return self._handle_failure(failure, rule.errback)
|
||||
def _errback(self, failure: Failure) -> Iterable[Any]:
|
||||
rule = self._rules[cast(int, failure.request.meta["rule"])] # type: ignore[attr-defined]
|
||||
return self._handle_failure(
|
||||
failure, cast(Callable[[Failure], Any], rule.errback)
|
||||
)
|
||||
|
||||
async def _parse_response(self, response, callback, cb_kwargs, follow=True):
|
||||
async def _parse_response(
|
||||
self,
|
||||
response: Response,
|
||||
callback: Optional[Callable],
|
||||
cb_kwargs: Dict[str, Any],
|
||||
follow: bool = True,
|
||||
) -> AsyncIterable[Any]:
|
||||
if callback:
|
||||
cb_res = callback(response, **cb_kwargs) or ()
|
||||
if isinstance(cb_res, AsyncIterable):
|
||||
|
|
@ -128,19 +184,21 @@ class CrawlSpider(Spider):
|
|||
for request_or_item in self._requests_to_follow(response):
|
||||
yield request_or_item
|
||||
|
||||
def _handle_failure(self, failure, errback):
|
||||
def _handle_failure(
|
||||
self, failure: Failure, errback: Optional[Callable[[Failure], Any]]
|
||||
) -> Iterable[Any]:
|
||||
if errback:
|
||||
results = errback(failure) or ()
|
||||
yield from iterate_spider_output(results)
|
||||
|
||||
def _compile_rules(self):
|
||||
def _compile_rules(self) -> None:
|
||||
self._rules = []
|
||||
for rule in self.rules:
|
||||
self._rules.append(copy.copy(rule))
|
||||
self._rules[-1]._compile(self)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, *args, **kwargs):
|
||||
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:
|
||||
spider = super().from_crawler(crawler, *args, **kwargs)
|
||||
spider._follow_links = crawler.settings.getbool(
|
||||
"CRAWLSPIDER_FOLLOW_LINKS", True
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ for scraping from an XML feed.
|
|||
See documentation in docs/topics/spiders.rst
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
from scrapy.exceptions import NotConfigured, NotSupported
|
||||
from scrapy.http import Response, TextResponse
|
||||
from scrapy.selector import Selector
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.utils.iterators import csviter, xmliter_lxml
|
||||
|
|
@ -22,11 +25,13 @@ class XMLFeedSpider(Spider):
|
|||
use iternodes, since it's a faster and cleaner.
|
||||
"""
|
||||
|
||||
iterator = "iternodes"
|
||||
itertag = "item"
|
||||
namespaces = ()
|
||||
iterator: str = "iternodes"
|
||||
itertag: str = "item"
|
||||
namespaces: Sequence[Tuple[str, str]] = ()
|
||||
|
||||
def process_results(self, response, results):
|
||||
def process_results(
|
||||
self, response: Response, results: Iterable[Any]
|
||||
) -> Iterable[Any]:
|
||||
"""This overridable method is called for each result (item or request)
|
||||
returned by the spider, and it's intended to perform any last time
|
||||
processing required before returning the results to the framework core,
|
||||
|
|
@ -36,20 +41,20 @@ class XMLFeedSpider(Spider):
|
|||
"""
|
||||
return results
|
||||
|
||||
def adapt_response(self, response):
|
||||
def adapt_response(self, response: Response) -> Response:
|
||||
"""You can override this function in order to make any changes you want
|
||||
to into the feed before parsing it. This function must return a
|
||||
response.
|
||||
"""
|
||||
return response
|
||||
|
||||
def parse_node(self, response, selector):
|
||||
def parse_node(self, response: Response, selector: Selector) -> Any:
|
||||
"""This method must be overridden with your custom spider functionality"""
|
||||
if hasattr(self, "parse_item"): # backward compatibility
|
||||
return self.parse_item(response, selector)
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_nodes(self, response, nodes):
|
||||
def parse_nodes(self, response: Response, nodes: Iterable[Selector]) -> Any:
|
||||
"""This method is called for the nodes matching the provided tag name
|
||||
(itertag). Receives the response and an Selector for each node.
|
||||
Overriding this method is mandatory. Otherwise, you spider won't work.
|
||||
|
|
@ -61,20 +66,25 @@ class XMLFeedSpider(Spider):
|
|||
ret = iterate_spider_output(self.parse_node(response, selector))
|
||||
yield from self.process_results(response, ret)
|
||||
|
||||
def _parse(self, response, **kwargs):
|
||||
def _parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
if not hasattr(self, "parse_node"):
|
||||
raise NotConfigured(
|
||||
"You must define parse_node method in order to scrape this XML feed"
|
||||
)
|
||||
|
||||
response = self.adapt_response(response)
|
||||
nodes: Iterable[Selector]
|
||||
if self.iterator == "iternodes":
|
||||
nodes = self._iternodes(response)
|
||||
elif self.iterator == "xml":
|
||||
if not isinstance(response, TextResponse):
|
||||
raise ValueError("Response content isn't text")
|
||||
selector = Selector(response, type="xml")
|
||||
self._register_namespaces(selector)
|
||||
nodes = selector.xpath(f"//{self.itertag}")
|
||||
elif self.iterator == "html":
|
||||
if not isinstance(response, TextResponse):
|
||||
raise ValueError("Response content isn't text")
|
||||
selector = Selector(response, type="html")
|
||||
self._register_namespaces(selector)
|
||||
nodes = selector.xpath(f"//{self.itertag}")
|
||||
|
|
@ -83,12 +93,12 @@ class XMLFeedSpider(Spider):
|
|||
|
||||
return self.parse_nodes(response, nodes)
|
||||
|
||||
def _iternodes(self, response):
|
||||
def _iternodes(self, response: Response) -> Iterable[Selector]:
|
||||
for node in xmliter_lxml(response, self.itertag):
|
||||
self._register_namespaces(node)
|
||||
yield node
|
||||
|
||||
def _register_namespaces(self, selector):
|
||||
def _register_namespaces(self, selector: Selector) -> None:
|
||||
for prefix, uri in self.namespaces:
|
||||
selector.register_namespace(prefix, uri)
|
||||
|
||||
|
|
@ -102,27 +112,29 @@ class CSVFeedSpider(Spider):
|
|||
and the file's headers.
|
||||
"""
|
||||
|
||||
delimiter = (
|
||||
delimiter: Optional[str] = (
|
||||
None # When this is None, python's csv module's default delimiter is used
|
||||
)
|
||||
quotechar = (
|
||||
quotechar: Optional[str] = (
|
||||
None # When this is None, python's csv module's default quotechar is used
|
||||
)
|
||||
headers = None
|
||||
headers: Optional[List[str]] = None
|
||||
|
||||
def process_results(self, response, results):
|
||||
def process_results(
|
||||
self, response: Response, results: Iterable[Any]
|
||||
) -> Iterable[Any]:
|
||||
"""This method has the same purpose as the one in XMLFeedSpider"""
|
||||
return results
|
||||
|
||||
def adapt_response(self, response):
|
||||
def adapt_response(self, response: Response) -> Response:
|
||||
"""This method has the same purpose as the one in XMLFeedSpider"""
|
||||
return response
|
||||
|
||||
def parse_row(self, response, row):
|
||||
def parse_row(self, response: Response, row: Dict[str, str]) -> Any:
|
||||
"""This method must be overridden with your custom spider functionality"""
|
||||
raise NotImplementedError
|
||||
|
||||
def parse_rows(self, response):
|
||||
def parse_rows(self, response: Response) -> Any:
|
||||
"""Receives a response and a dict (representing each row) with a key for
|
||||
each provided (or detected) header of the CSV file. This spider also
|
||||
gives the opportunity to override adapt_response and
|
||||
|
|
@ -135,7 +147,7 @@ class CSVFeedSpider(Spider):
|
|||
ret = iterate_spider_output(self.parse_row(response, row))
|
||||
yield from self.process_results(response, ret)
|
||||
|
||||
def _parse(self, response, **kwargs):
|
||||
def _parse(self, response: Response, **kwargs: Any) -> Any:
|
||||
if not hasattr(self, "parse_row"):
|
||||
raise NotConfigured(
|
||||
"You must define parse_row method in order to scrape this CSV feed"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
from typing import Any, Iterable, Optional, cast
|
||||
|
||||
from scrapy import Request
|
||||
from scrapy.http import Response
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.utils.spider import iterate_spider_output
|
||||
|
||||
|
|
@ -5,17 +9,17 @@ from scrapy.utils.spider import iterate_spider_output
|
|||
class InitSpider(Spider):
|
||||
"""Base Spider with initialization facilities"""
|
||||
|
||||
def start_requests(self):
|
||||
self._postinit_reqs = super().start_requests()
|
||||
return iterate_spider_output(self.init_request())
|
||||
def start_requests(self) -> Iterable[Request]:
|
||||
self._postinit_reqs: Iterable[Request] = super().start_requests()
|
||||
return cast(Iterable[Request], iterate_spider_output(self.init_request()))
|
||||
|
||||
def initialized(self, response=None):
|
||||
def initialized(self, response: Optional[Response] = None) -> Any:
|
||||
"""This method must be set as the callback of your last initialization
|
||||
request. See self.init_request() docstring for more info.
|
||||
"""
|
||||
return self.__dict__.pop("_postinit_reqs")
|
||||
|
||||
def init_request(self):
|
||||
def init_request(self) -> Any:
|
||||
"""This function should return one initialization request, with the
|
||||
self.initialized method as callback. When the self.initialized method
|
||||
is called this spider is considered initialized. If you need to perform
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from scrapy.http import Request, XmlResponse
|
||||
from scrapy.http import Request, Response, XmlResponse
|
||||
from scrapy.spiders import Spider
|
||||
from scrapy.utils._compression import _DecompressionMaxSizeExceeded
|
||||
from scrapy.utils.gz import gunzip, gzip_magic_number
|
||||
|
|
@ -18,15 +32,17 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class SitemapSpider(Spider):
|
||||
sitemap_urls = ()
|
||||
sitemap_rules = [("", "parse")]
|
||||
sitemap_follow = [""]
|
||||
sitemap_alternate_links = False
|
||||
sitemap_urls: Sequence[str] = ()
|
||||
sitemap_rules: Sequence[
|
||||
Tuple[Union[re.Pattern[str], str], Union[str, Callable]]
|
||||
] = [("", "parse")]
|
||||
sitemap_follow: Sequence[Union[re.Pattern[str], str]] = [""]
|
||||
sitemap_alternate_links: bool = False
|
||||
_max_size: int
|
||||
_warn_size: int
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler: "Crawler", *args: Any, **kwargs: Any) -> "Self":
|
||||
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:
|
||||
spider = super().from_crawler(crawler, *args, **kwargs)
|
||||
spider._max_size = getattr(
|
||||
spider, "download_maxsize", spider.settings.getint("DOWNLOAD_MAXSIZE")
|
||||
|
|
@ -36,27 +52,29 @@ class SitemapSpider(Spider):
|
|||
)
|
||||
return spider
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
def __init__(self, *a: Any, **kw: Any):
|
||||
super().__init__(*a, **kw)
|
||||
self._cbs = []
|
||||
self._cbs: List[Tuple[re.Pattern[str], Callable]] = []
|
||||
for r, c in self.sitemap_rules:
|
||||
if isinstance(c, str):
|
||||
c = getattr(self, c)
|
||||
c = cast(Callable, getattr(self, c))
|
||||
self._cbs.append((regex(r), c))
|
||||
self._follow = [regex(x) for x in self.sitemap_follow]
|
||||
self._follow: List[re.Pattern[str]] = [regex(x) for x in self.sitemap_follow]
|
||||
|
||||
def start_requests(self):
|
||||
def start_requests(self) -> Iterable[Request]:
|
||||
for url in self.sitemap_urls:
|
||||
yield Request(url, self._parse_sitemap)
|
||||
|
||||
def sitemap_filter(self, entries):
|
||||
def sitemap_filter(
|
||||
self, entries: Iterable[Dict[str, Any]]
|
||||
) -> Iterable[Dict[str, Any]]:
|
||||
"""This method can be used to filter sitemap entries by their
|
||||
attributes, for example, you can filter locs with lastmod greater
|
||||
than a given date (see docs).
|
||||
"""
|
||||
yield from entries
|
||||
|
||||
def _parse_sitemap(self, response):
|
||||
def _parse_sitemap(self, response: Response) -> Iterable[Request]:
|
||||
if response.url.endswith("/robots.txt"):
|
||||
for url in sitemap_urls_from_robots(response.text, base_url=response.url):
|
||||
yield Request(url, callback=self._parse_sitemap)
|
||||
|
|
@ -84,7 +102,7 @@ class SitemapSpider(Spider):
|
|||
yield Request(loc, callback=c)
|
||||
break
|
||||
|
||||
def _get_sitemap_body(self, response):
|
||||
def _get_sitemap_body(self, response: Response) -> Optional[bytes]:
|
||||
"""Return the sitemap body contained in the given response,
|
||||
or None if the response is not a sitemap.
|
||||
"""
|
||||
|
|
@ -115,15 +133,16 @@ class SitemapSpider(Spider):
|
|||
# in other word, here, we have plain XML
|
||||
if response.url.endswith(".xml") or response.url.endswith(".xml.gz"):
|
||||
return response.body
|
||||
return None
|
||||
|
||||
|
||||
def regex(x):
|
||||
def regex(x: Union[re.Pattern[str], str]) -> re.Pattern[str]:
|
||||
if isinstance(x, str):
|
||||
return re.compile(x)
|
||||
return x
|
||||
|
||||
|
||||
def iterloc(it, alt=False):
|
||||
def iterloc(it: Iterable[Dict[str, Any]], alt: bool = False) -> Iterable[str]:
|
||||
for d in it:
|
||||
yield d["loc"]
|
||||
|
||||
|
|
|
|||
|
|
@ -2,20 +2,28 @@
|
|||
Scheduler queues
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import marshal
|
||||
import pickle
|
||||
import pickle # nosec
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional, Type, Union
|
||||
|
||||
from queuelib import queue
|
||||
|
||||
from scrapy import Request
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.utils.request import request_from_dict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
def _with_mkdir(queue_class):
|
||||
class DirectoriesCreated(queue_class):
|
||||
def __init__(self, path: Union[str, PathLike], *args, **kwargs):
|
||||
|
||||
def _with_mkdir(queue_class: Type[queue.BaseQueue]) -> Type[queue.BaseQueue]:
|
||||
class DirectoriesCreated(queue_class): # type: ignore[valid-type,misc]
|
||||
def __init__(self, path: Union[str, PathLike], *args: Any, **kwargs: Any):
|
||||
dirname = Path(path).parent
|
||||
if not dirname.exists():
|
||||
dirname.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -24,18 +32,23 @@ def _with_mkdir(queue_class):
|
|||
return DirectoriesCreated
|
||||
|
||||
|
||||
def _serializable_queue(queue_class, serialize, deserialize):
|
||||
class SerializableQueue(queue_class):
|
||||
def push(self, obj):
|
||||
def _serializable_queue(
|
||||
queue_class: Type[queue.BaseQueue],
|
||||
serialize: Callable[[Any], bytes],
|
||||
deserialize: Callable[[bytes], Any],
|
||||
) -> Type[queue.BaseQueue]:
|
||||
class SerializableQueue(queue_class): # type: ignore[valid-type,misc]
|
||||
def push(self, obj: Any) -> None:
|
||||
s = serialize(obj)
|
||||
super().push(s)
|
||||
|
||||
def pop(self):
|
||||
def pop(self) -> Optional[Any]:
|
||||
s = super().pop()
|
||||
if s:
|
||||
return deserialize(s)
|
||||
return None
|
||||
|
||||
def peek(self):
|
||||
def peek(self) -> Optional[Any]:
|
||||
"""Returns the next object to be returned by :meth:`pop`,
|
||||
but without removing it from the queue.
|
||||
|
||||
|
|
@ -50,31 +63,36 @@ def _serializable_queue(queue_class, serialize, deserialize):
|
|||
) from ex
|
||||
if s:
|
||||
return deserialize(s)
|
||||
return None
|
||||
|
||||
return SerializableQueue
|
||||
|
||||
|
||||
def _scrapy_serialization_queue(queue_class):
|
||||
class ScrapyRequestQueue(queue_class):
|
||||
def __init__(self, crawler, key):
|
||||
def _scrapy_serialization_queue(
|
||||
queue_class: Type[queue.BaseQueue],
|
||||
) -> Type[queue.BaseQueue]:
|
||||
class ScrapyRequestQueue(queue_class): # type: ignore[valid-type,misc]
|
||||
def __init__(self, crawler: Crawler, key: str):
|
||||
self.spider = crawler.spider
|
||||
super().__init__(key)
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, key, *args, **kwargs):
|
||||
def from_crawler(
|
||||
cls, crawler: Crawler, key: str, *args: Any, **kwargs: Any
|
||||
) -> Self:
|
||||
return cls(crawler, key)
|
||||
|
||||
def push(self, request):
|
||||
request = request.to_dict(spider=self.spider)
|
||||
return super().push(request)
|
||||
def push(self, request: Request) -> None:
|
||||
request_dict = request.to_dict(spider=self.spider)
|
||||
super().push(request_dict)
|
||||
|
||||
def pop(self):
|
||||
def pop(self) -> Optional[Request]:
|
||||
request = super().pop()
|
||||
if not request:
|
||||
return None
|
||||
return request_from_dict(request, spider=self.spider)
|
||||
|
||||
def peek(self):
|
||||
def peek(self) -> Optional[Request]:
|
||||
"""Returns the next object to be returned by :meth:`pop`,
|
||||
but without removing it from the queue.
|
||||
|
||||
|
|
@ -89,13 +107,15 @@ def _scrapy_serialization_queue(queue_class):
|
|||
return ScrapyRequestQueue
|
||||
|
||||
|
||||
def _scrapy_non_serialization_queue(queue_class):
|
||||
class ScrapyRequestQueue(queue_class):
|
||||
def _scrapy_non_serialization_queue(
|
||||
queue_class: Type[queue.BaseQueue],
|
||||
) -> Type[queue.BaseQueue]:
|
||||
class ScrapyRequestQueue(queue_class): # type: ignore[valid-type,misc]
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler, *args, **kwargs):
|
||||
def from_crawler(cls, crawler: Crawler, *args: Any, **kwargs: Any) -> Self:
|
||||
return cls()
|
||||
|
||||
def peek(self):
|
||||
def peek(self) -> Optional[Any]:
|
||||
"""Returns the next object to be returned by :meth:`pop`,
|
||||
but without removing it from the queue.
|
||||
|
||||
|
|
@ -113,7 +133,7 @@ def _scrapy_non_serialization_queue(queue_class):
|
|||
return ScrapyRequestQueue
|
||||
|
||||
|
||||
def _pickle_serialize(obj):
|
||||
def _pickle_serialize(obj: Any) -> bytes:
|
||||
try:
|
||||
return pickle.dumps(obj, protocol=4)
|
||||
# Both pickle.PicklingError and AttributeError can be raised by pickle.dump(s)
|
||||
|
|
@ -122,17 +142,18 @@ def _pickle_serialize(obj):
|
|||
raise ValueError(str(e)) from e
|
||||
|
||||
|
||||
# queue.*Queue aren't subclasses of queue.BaseQueue
|
||||
_PickleFifoSerializationDiskQueue = _serializable_queue(
|
||||
_with_mkdir(queue.FifoDiskQueue), _pickle_serialize, pickle.loads
|
||||
_with_mkdir(queue.FifoDiskQueue), _pickle_serialize, pickle.loads # type: ignore[arg-type]
|
||||
)
|
||||
_PickleLifoSerializationDiskQueue = _serializable_queue(
|
||||
_with_mkdir(queue.LifoDiskQueue), _pickle_serialize, pickle.loads
|
||||
_with_mkdir(queue.LifoDiskQueue), _pickle_serialize, pickle.loads # type: ignore[arg-type]
|
||||
)
|
||||
_MarshalFifoSerializationDiskQueue = _serializable_queue(
|
||||
_with_mkdir(queue.FifoDiskQueue), marshal.dumps, marshal.loads
|
||||
_with_mkdir(queue.FifoDiskQueue), marshal.dumps, marshal.loads # type: ignore[arg-type]
|
||||
)
|
||||
_MarshalLifoSerializationDiskQueue = _serializable_queue(
|
||||
_with_mkdir(queue.LifoDiskQueue), marshal.dumps, marshal.loads
|
||||
_with_mkdir(queue.LifoDiskQueue), marshal.dumps, marshal.loads # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# public queue classes
|
||||
|
|
@ -140,5 +161,5 @@ PickleFifoDiskQueue = _scrapy_serialization_queue(_PickleFifoSerializationDiskQu
|
|||
PickleLifoDiskQueue = _scrapy_serialization_queue(_PickleLifoSerializationDiskQueue)
|
||||
MarshalFifoDiskQueue = _scrapy_serialization_queue(_MarshalFifoSerializationDiskQueue)
|
||||
MarshalLifoDiskQueue = _scrapy_serialization_queue(_MarshalLifoSerializationDiskQueue)
|
||||
FifoMemoryQueue = _scrapy_non_serialization_queue(queue.FifoMemoryQueue)
|
||||
LifoMemoryQueue = _scrapy_non_serialization_queue(queue.LifoMemoryQueue)
|
||||
FifoMemoryQueue = _scrapy_non_serialization_queue(queue.FifoMemoryQueue) # type: ignore[arg-type]
|
||||
LifoMemoryQueue = _scrapy_non_serialization_queue(queue.LifoMemoryQueue) # type: ignore[arg-type]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,42 @@
|
|||
import zlib
|
||||
from io import BytesIO
|
||||
from warnings import warn
|
||||
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
|
||||
try:
|
||||
import brotli
|
||||
try:
|
||||
import brotli
|
||||
except ImportError:
|
||||
import brotlicffi as brotli
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
brotli.Decompressor.process
|
||||
except AttributeError:
|
||||
warn(
|
||||
(
|
||||
"You have brotlipy installed, and Scrapy will use it, but "
|
||||
"Scrapy support for brotlipy is deprecated and will stop "
|
||||
"working in a future version of Scrapy. brotlipy itself is "
|
||||
"deprecated, it has been superseded by brotlicffi. "
|
||||
"Please, uninstall brotlipy "
|
||||
"and install brotli or brotlicffi instead. brotlipy has the same import "
|
||||
"name as brotli, so keeping both installed is strongly "
|
||||
"discouraged."
|
||||
),
|
||||
ScrapyDeprecationWarning,
|
||||
)
|
||||
|
||||
def _brotli_decompress(decompressor, data):
|
||||
return decompressor.decompress(data)
|
||||
|
||||
else:
|
||||
|
||||
def _brotli_decompress(decompressor, data):
|
||||
return decompressor.process(data)
|
||||
|
||||
|
||||
try:
|
||||
import zstandard
|
||||
|
|
@ -61,7 +93,7 @@ def _unbrotli(data: bytes, *, max_size: int = 0) -> bytes:
|
|||
decompressed_size = 0
|
||||
while output_chunk:
|
||||
input_chunk = input_stream.read(_CHUNK_SIZE)
|
||||
output_chunk = decompressor.process(input_chunk)
|
||||
output_chunk = _brotli_decompress(decompressor, input_chunk)
|
||||
decompressed_size += len(output_chunk)
|
||||
if max_size and decompressed_size > max_size:
|
||||
raise _DecompressionMaxSizeExceeded(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class Root(Resource):
|
|||
def render(self, request):
|
||||
total = _getarg(request, b"total", 100, int)
|
||||
show = _getarg(request, b"show", 10, int)
|
||||
nlist = [random.randint(1, total) for _ in range(show)]
|
||||
nlist = [random.randint(1, total) for _ in range(show)] # nosec
|
||||
request.write(b"<html><head></head><body>")
|
||||
args = request.args.copy()
|
||||
for nl in nlist:
|
||||
|
|
|
|||
|
|
@ -1,17 +1,27 @@
|
|||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, Iterable, Optional
|
||||
|
||||
EmbedFuncT = Callable[..., None]
|
||||
KnownShellsT = Dict[str, Callable[..., EmbedFuncT]]
|
||||
|
||||
|
||||
def _embed_ipython_shell(namespace={}, banner=""):
|
||||
def _embed_ipython_shell(
|
||||
namespace: Dict[str, Any] = {}, banner: str = ""
|
||||
) -> EmbedFuncT:
|
||||
"""Start an IPython Shell"""
|
||||
try:
|
||||
from IPython.terminal.embed import InteractiveShellEmbed
|
||||
from IPython.terminal.ipapp import load_default_config
|
||||
except ImportError:
|
||||
from IPython.frontend.terminal.embed import InteractiveShellEmbed
|
||||
from IPython.frontend.terminal.ipapp import load_default_config
|
||||
from IPython.frontend.terminal.embed import ( # type: ignore[no-redef]
|
||||
InteractiveShellEmbed,
|
||||
)
|
||||
from IPython.frontend.terminal.ipapp import ( # type: ignore[no-redef]
|
||||
load_default_config,
|
||||
)
|
||||
|
||||
@wraps(_embed_ipython_shell)
|
||||
def wrapper(namespace=namespace, banner=""):
|
||||
def wrapper(namespace: Dict[str, Any] = namespace, banner: str = "") -> None:
|
||||
config = load_default_config()
|
||||
# Always use .instance() to ensure _instance propagation to all parents
|
||||
# this is needed for <TAB> completion works well for new imports
|
||||
|
|
@ -26,30 +36,36 @@ def _embed_ipython_shell(namespace={}, banner=""):
|
|||
return wrapper
|
||||
|
||||
|
||||
def _embed_bpython_shell(namespace={}, banner=""):
|
||||
def _embed_bpython_shell(
|
||||
namespace: Dict[str, Any] = {}, banner: str = ""
|
||||
) -> EmbedFuncT:
|
||||
"""Start a bpython shell"""
|
||||
import bpython
|
||||
|
||||
@wraps(_embed_bpython_shell)
|
||||
def wrapper(namespace=namespace, banner=""):
|
||||
def wrapper(namespace: Dict[str, Any] = namespace, banner: str = "") -> None:
|
||||
bpython.embed(locals_=namespace, banner=banner)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _embed_ptpython_shell(namespace={}, banner=""):
|
||||
def _embed_ptpython_shell(
|
||||
namespace: Dict[str, Any] = {}, banner: str = ""
|
||||
) -> EmbedFuncT:
|
||||
"""Start a ptpython shell"""
|
||||
import ptpython.repl
|
||||
|
||||
@wraps(_embed_ptpython_shell)
|
||||
def wrapper(namespace=namespace, banner=""):
|
||||
def wrapper(namespace: Dict[str, Any] = namespace, banner: str = "") -> None:
|
||||
print(banner)
|
||||
ptpython.repl.embed(locals=namespace)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _embed_standard_shell(namespace={}, banner=""):
|
||||
def _embed_standard_shell(
|
||||
namespace: Dict[str, Any] = {}, banner: str = ""
|
||||
) -> EmbedFuncT:
|
||||
"""Start a standard python shell"""
|
||||
import code
|
||||
|
||||
|
|
@ -63,13 +79,13 @@ def _embed_standard_shell(namespace={}, banner=""):
|
|||
readline.parse_and_bind("tab:complete")
|
||||
|
||||
@wraps(_embed_standard_shell)
|
||||
def wrapper(namespace=namespace, banner=""):
|
||||
def wrapper(namespace: Dict[str, Any] = namespace, banner: str = "") -> None:
|
||||
code.interact(banner=banner, local=namespace)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
DEFAULT_PYTHON_SHELLS = {
|
||||
DEFAULT_PYTHON_SHELLS: KnownShellsT = {
|
||||
"ptpython": _embed_ptpython_shell,
|
||||
"ipython": _embed_ipython_shell,
|
||||
"bpython": _embed_bpython_shell,
|
||||
|
|
@ -77,7 +93,9 @@ DEFAULT_PYTHON_SHELLS = {
|
|||
}
|
||||
|
||||
|
||||
def get_shell_embed_func(shells=None, known_shells=None):
|
||||
def get_shell_embed_func(
|
||||
shells: Optional[Iterable[str]] = None, known_shells: Optional[KnownShellsT] = None
|
||||
) -> Any:
|
||||
"""Return the first acceptable shell-embed function
|
||||
from a given list of shell names.
|
||||
"""
|
||||
|
|
@ -95,7 +113,11 @@ def get_shell_embed_func(shells=None, known_shells=None):
|
|||
continue
|
||||
|
||||
|
||||
def start_python_console(namespace=None, banner="", shells=None):
|
||||
def start_python_console(
|
||||
namespace: Optional[Dict[str, Any]] = None,
|
||||
banner: str = "",
|
||||
shells: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
"""Start Python console bound to the given namespace.
|
||||
Readline support and tab completion will be used on Unix, if available.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
from typing import TYPE_CHECKING, Any, Callable, TypeVar
|
||||
|
||||
from twisted.internet import defer, threads
|
||||
from twisted.internet.defer import Deferred
|
||||
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.ParamSpec requires Python 3.10
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
def deprecated(use_instead: Any = None) -> Callable:
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def deprecated(
|
||||
use_instead: Any = None,
|
||||
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
|
||||
"""This is a decorator which can be used to mark functions
|
||||
as deprecated. It will result in a warning being emitted
|
||||
when the function is used."""
|
||||
|
||||
def deco(func: Callable) -> Callable:
|
||||
def deco(func: Callable[_P, _T]) -> Callable[_P, _T]:
|
||||
@wraps(func)
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
def wrapped(*args: _P.args, **kwargs: _P.kwargs) -> Any:
|
||||
message = f"Call to deprecated function {func.__name__}."
|
||||
if use_instead:
|
||||
message += f" Use {use_instead} instead."
|
||||
|
|
@ -30,23 +43,23 @@ def deprecated(use_instead: Any = None) -> Callable:
|
|||
return deco
|
||||
|
||||
|
||||
def defers(func: Callable) -> Callable[..., Deferred]:
|
||||
def defers(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]:
|
||||
"""Decorator to make sure a function always returns a deferred"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapped(*a: Any, **kw: Any) -> Deferred:
|
||||
def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]:
|
||||
return defer.maybeDeferred(func, *a, **kw)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def inthread(func: Callable) -> Callable[..., Deferred]:
|
||||
def inthread(func: Callable[_P, _T]) -> Callable[_P, Deferred[_T]]:
|
||||
"""Decorator to call a function in a thread and return a deferred with the
|
||||
result
|
||||
"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapped(*a: Any, **kw: Any) -> Deferred:
|
||||
def wrapped(*a: _P.args, **kw: _P.kwargs) -> Deferred[_T]:
|
||||
return threads.deferToThread(func, *a, **kw)
|
||||
|
||||
return wrapped
|
||||
|
|
|
|||
|
|
@ -2,21 +2,22 @@
|
|||
Helper functions for dealing with Twisted deferreds
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from asyncio import Future
|
||||
from functools import wraps
|
||||
from types import CoroutineType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
AsyncIterable,
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
Generator,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
|
|
@ -37,6 +38,14 @@ from twisted.python.failure import Failure
|
|||
from scrapy.exceptions import IgnoreRequest
|
||||
from scrapy.utils.reactor import _get_asyncio_event_loop, is_asyncio_reactor_installed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Concatenate and typing.ParamSpec require Python 3.10
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def defer_fail(_failure: Failure) -> Deferred:
|
||||
"""Same as twisted.internet.defer.fail but delay calling errback until
|
||||
|
|
@ -74,7 +83,31 @@ def defer_result(result: Any) -> Deferred:
|
|||
return defer_succeed(result)
|
||||
|
||||
|
||||
def mustbe_deferred(f: Callable, *args: Any, **kw: Any) -> Deferred:
|
||||
@overload
|
||||
def mustbe_deferred(
|
||||
f: Callable[_P, Deferred[_T]], *args: _P.args, **kw: _P.kwargs
|
||||
) -> Deferred[_T]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def mustbe_deferred(
|
||||
f: Callable[_P, Coroutine[Deferred[Any], Any, _T]],
|
||||
*args: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> Deferred[_T]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def mustbe_deferred(
|
||||
f: Callable[_P, _T], *args: _P.args, **kw: _P.kwargs
|
||||
) -> Deferred[_T]: ...
|
||||
|
||||
|
||||
def mustbe_deferred(
|
||||
f: Callable[_P, Union[Deferred[_T], Coroutine[Deferred[Any], Any, _T], _T]],
|
||||
*args: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> Deferred[_T]:
|
||||
"""Same as twisted.internet.defer.maybeDeferred, but delay calling
|
||||
callback/errback to next reactor loop
|
||||
"""
|
||||
|
|
@ -92,7 +125,11 @@ def mustbe_deferred(f: Callable, *args: Any, **kw: Any) -> Deferred:
|
|||
|
||||
|
||||
def parallel(
|
||||
iterable: Iterable, count: int, callable: Callable, *args: Any, **named: Any
|
||||
iterable: Iterable[_T],
|
||||
count: int,
|
||||
callable: Callable[Concatenate[_T, _P], Any],
|
||||
*args: _P.args,
|
||||
**named: _P.kwargs,
|
||||
) -> Deferred:
|
||||
"""Execute a callable over the objects in the given iterable, in parallel,
|
||||
using no more than ``count`` concurrent calls.
|
||||
|
|
@ -104,7 +141,7 @@ def parallel(
|
|||
return DeferredList([coop.coiterate(work) for _ in range(count)])
|
||||
|
||||
|
||||
class _AsyncCooperatorAdapter(Iterator):
|
||||
class _AsyncCooperatorAdapter(Iterator[Deferred]):
|
||||
"""A class that wraps an async iterable into a normal iterator suitable
|
||||
for using in Cooperator.coiterate(). As it's only needed for parallel_async(),
|
||||
it calls the callable directly in the callback, instead of providing a more
|
||||
|
|
@ -152,28 +189,30 @@ class _AsyncCooperatorAdapter(Iterator):
|
|||
|
||||
def __init__(
|
||||
self,
|
||||
aiterable: AsyncIterable,
|
||||
callable: Callable,
|
||||
*callable_args: Any,
|
||||
**callable_kwargs: Any,
|
||||
aiterable: AsyncIterable[_T],
|
||||
callable: Callable[Concatenate[_T, _P], Any],
|
||||
*callable_args: _P.args,
|
||||
**callable_kwargs: _P.kwargs,
|
||||
):
|
||||
self.aiterator: AsyncIterator = aiterable.__aiter__()
|
||||
self.callable: Callable = callable
|
||||
self.aiterator: AsyncIterator[_T] = aiterable.__aiter__()
|
||||
self.callable: Callable[Concatenate[_T, _P], Any] = callable
|
||||
self.callable_args: Tuple[Any, ...] = callable_args
|
||||
self.callable_kwargs: Dict[str, Any] = callable_kwargs
|
||||
self.finished: bool = False
|
||||
self.waiting_deferreds: List[Deferred] = []
|
||||
self.anext_deferred: Optional[Deferred] = None
|
||||
self.anext_deferred: Optional[Deferred[_T]] = None
|
||||
|
||||
def _callback(self, result: Any) -> None:
|
||||
def _callback(self, result: _T) -> None:
|
||||
# This gets called when the result from aiterator.__anext__() is available.
|
||||
# It calls the callable on it and sends the result to the oldest waiting Deferred
|
||||
# (by chaining if the result is a Deferred too or by firing if not).
|
||||
self.anext_deferred = None
|
||||
result = self.callable(result, *self.callable_args, **self.callable_kwargs)
|
||||
callable_result = self.callable(
|
||||
result, *self.callable_args, **self.callable_kwargs
|
||||
)
|
||||
d = self.waiting_deferreds.pop(0)
|
||||
if isinstance(result, Deferred):
|
||||
result.chainDeferred(d)
|
||||
if isinstance(callable_result, Deferred):
|
||||
callable_result.chainDeferred(d)
|
||||
else:
|
||||
d.callback(None)
|
||||
if self.waiting_deferreds:
|
||||
|
|
@ -207,11 +246,11 @@ class _AsyncCooperatorAdapter(Iterator):
|
|||
|
||||
|
||||
def parallel_async(
|
||||
async_iterable: AsyncIterable,
|
||||
async_iterable: AsyncIterable[_T],
|
||||
count: int,
|
||||
callable: Callable,
|
||||
*args: Any,
|
||||
**named: Any,
|
||||
callable: Callable[Concatenate[_T, _P], Any],
|
||||
*args: _P.args,
|
||||
**named: _P.kwargs,
|
||||
) -> Deferred:
|
||||
"""Like parallel but for async iterators"""
|
||||
coop = Cooperator()
|
||||
|
|
@ -221,7 +260,10 @@ def parallel_async(
|
|||
|
||||
|
||||
def process_chain(
|
||||
callbacks: Iterable[Callable], input: Any, *a: Any, **kw: Any
|
||||
callbacks: Iterable[Callable[Concatenate[_T, _P], Any]],
|
||||
input: Any,
|
||||
*a: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> Deferred:
|
||||
"""Return a Deferred built by chaining the given callbacks"""
|
||||
d: Deferred = Deferred()
|
||||
|
|
@ -232,23 +274,17 @@ def process_chain(
|
|||
|
||||
|
||||
def process_chain_both(
|
||||
callbacks: Iterable[Callable],
|
||||
errbacks: Iterable[Callable],
|
||||
callbacks: Iterable[Callable[Concatenate[_T, _P], Any]],
|
||||
errbacks: Iterable[Callable[Concatenate[Failure, _P], Any]],
|
||||
input: Any,
|
||||
*a: Any,
|
||||
**kw: Any,
|
||||
*a: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> Deferred:
|
||||
"""Return a Deferred built by chaining the given callbacks and errbacks"""
|
||||
d: Deferred = Deferred()
|
||||
for cb, eb in zip(callbacks, errbacks):
|
||||
d.addCallbacks(
|
||||
callback=cb,
|
||||
errback=eb,
|
||||
callbackArgs=a,
|
||||
callbackKeywords=kw,
|
||||
errbackArgs=a,
|
||||
errbackKeywords=kw,
|
||||
)
|
||||
d.addCallback(cb, *a, **kw)
|
||||
d.addErrback(eb, *a, **kw)
|
||||
if isinstance(input, failure.Failure):
|
||||
d.errback(input)
|
||||
else:
|
||||
|
|
@ -257,20 +293,27 @@ def process_chain_both(
|
|||
|
||||
|
||||
def process_parallel(
|
||||
callbacks: Iterable[Callable], input: Any, *a: Any, **kw: Any
|
||||
callbacks: Iterable[Callable[Concatenate[_T, _P], Any]],
|
||||
input: Any,
|
||||
*a: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> Deferred:
|
||||
"""Return a Deferred with the output of all successful calls to the given
|
||||
callbacks
|
||||
"""
|
||||
dfds = [defer.succeed(input).addCallback(x, *a, **kw) for x in callbacks]
|
||||
d: Deferred = DeferredList(dfds, fireOnOneErrback=True, consumeErrors=True)
|
||||
d.addCallbacks(lambda r: [x[1] for x in r], lambda f: f.value.subFailure)
|
||||
d.addCallback(lambda r: [x[1] for x in r])
|
||||
d.addErrback(lambda f: f.value.subFailure)
|
||||
return d
|
||||
|
||||
|
||||
def iter_errback(
|
||||
iterable: Iterable, errback: Callable, *a: Any, **kw: Any
|
||||
) -> Generator:
|
||||
iterable: Iterable[_T],
|
||||
errback: Callable[Concatenate[Failure, _P], Any],
|
||||
*a: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> Iterable[_T]:
|
||||
"""Wraps an iterable calling an errback if an error is caught while
|
||||
iterating it.
|
||||
"""
|
||||
|
|
@ -285,8 +328,11 @@ def iter_errback(
|
|||
|
||||
|
||||
async def aiter_errback(
|
||||
aiterable: AsyncIterable, errback: Callable, *a: Any, **kw: Any
|
||||
) -> AsyncGenerator:
|
||||
aiterable: AsyncIterable[_T],
|
||||
errback: Callable[Concatenate[Failure, _P], Any],
|
||||
*a: _P.args,
|
||||
**kw: _P.kwargs,
|
||||
) -> AsyncIterable[_T]:
|
||||
"""Wraps an async iterable calling an errback if an error is caught while
|
||||
iterating it. Similar to scrapy.utils.defer.iter_errback()
|
||||
"""
|
||||
|
|
@ -301,7 +347,6 @@ async def aiter_errback(
|
|||
|
||||
|
||||
_CT = TypeVar("_CT", bound=Union[Awaitable, CoroutineType, Future])
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
@overload
|
||||
|
|
@ -327,7 +372,9 @@ def deferred_from_coro(o: _T) -> Union[Deferred, _T]:
|
|||
return o
|
||||
|
||||
|
||||
def deferred_f_from_coro_f(coro_f: Callable[..., Coroutine]) -> Callable:
|
||||
def deferred_f_from_coro_f(
|
||||
coro_f: Callable[_P, Coroutine[Any, Any, _T]]
|
||||
) -> Callable[_P, Deferred[_T]]:
|
||||
"""Converts a coroutine function into a function that returns a Deferred.
|
||||
|
||||
The coroutine function will be called at the time when the wrapper is called. Wrapper args will be passed to it.
|
||||
|
|
@ -335,13 +382,15 @@ def deferred_f_from_coro_f(coro_f: Callable[..., Coroutine]) -> Callable:
|
|||
"""
|
||||
|
||||
@wraps(coro_f)
|
||||
def f(*coro_args: Any, **coro_kwargs: Any) -> Any:
|
||||
def f(*coro_args: _P.args, **coro_kwargs: _P.kwargs) -> Any:
|
||||
return deferred_from_coro(coro_f(*coro_args, **coro_kwargs))
|
||||
|
||||
return f
|
||||
|
||||
|
||||
def maybeDeferred_coro(f: Callable, *args: Any, **kw: Any) -> Deferred:
|
||||
def maybeDeferred_coro(
|
||||
f: Callable[_P, Any], *args: _P.args, **kw: _P.kwargs
|
||||
) -> Deferred:
|
||||
"""Copy of defer.maybeDeferred that also converts coroutines to Deferreds."""
|
||||
try:
|
||||
result = f(*args, **kw)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
"""Some debugging functions for working with the Scrapy engine"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# used in global tests code
|
||||
from time import time # noqa: F401
|
||||
from typing import TYPE_CHECKING, Any, List, Tuple
|
||||
from typing import Any, List, Tuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from scrapy.core.engine import ExecutionEngine
|
||||
from scrapy.core.engine import ExecutionEngine
|
||||
|
||||
|
||||
def get_engine_status(engine: "ExecutionEngine") -> List[Tuple[str, Any]]:
|
||||
def get_engine_status(engine: ExecutionEngine) -> List[Tuple[str, Any]]:
|
||||
"""Return a report of the current engine status"""
|
||||
tests = [
|
||||
"time()-engine.start_time",
|
||||
|
|
@ -30,14 +31,14 @@ def get_engine_status(engine: "ExecutionEngine") -> List[Tuple[str, Any]]:
|
|||
checks: List[Tuple[str, Any]] = []
|
||||
for test in tests:
|
||||
try:
|
||||
checks += [(test, eval(test))]
|
||||
checks += [(test, eval(test))] # nosec
|
||||
except Exception as e:
|
||||
checks += [(test, f"{type(e).__name__} (exception)")]
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
def format_engine_status(engine: "ExecutionEngine") -> str:
|
||||
def format_engine_status(engine: ExecutionEngine) -> str:
|
||||
checks = get_engine_status(engine)
|
||||
s = "Execution engine status\n\n"
|
||||
for test, result in checks:
|
||||
|
|
@ -47,5 +48,5 @@ def format_engine_status(engine: "ExecutionEngine") -> str:
|
|||
return s
|
||||
|
||||
|
||||
def print_engine_status(engine: "ExecutionEngine") -> None:
|
||||
def print_engine_status(engine: ExecutionEngine) -> None:
|
||||
print(format_engine_status(engine))
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import logging
|
|||
import re
|
||||
from io import StringIO
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
|
|
@ -18,16 +17,13 @@ from typing import (
|
|||
)
|
||||
from warnings import warn
|
||||
|
||||
from lxml import etree
|
||||
from lxml import etree # nosec
|
||||
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
from scrapy.http import Response, TextResponse
|
||||
from scrapy.selector import Selector
|
||||
from scrapy.utils.python import re_rsearch, to_unicode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from lxml._types import SupportsReadClose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -98,9 +94,10 @@ def xmliter_lxml(
|
|||
reader = _StreamReader(obj)
|
||||
tag = f"{{{namespace}}}{nodename}" if namespace else nodename
|
||||
iterable = etree.iterparse(
|
||||
cast("SupportsReadClose[bytes]", reader),
|
||||
reader,
|
||||
encoding=reader.encoding,
|
||||
events=("end", "start-ns"),
|
||||
resolve_entities=False,
|
||||
huge_tree=True,
|
||||
)
|
||||
selxpath = "//" + (f"{prefix}:{nodename}" if namespace else nodename)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,17 @@ import logging
|
|||
import sys
|
||||
from logging.config import dictConfig
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, Union, cast
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
List,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from twisted.python import log as twisted_log
|
||||
from twisted.python.failure import Failure
|
||||
|
|
@ -238,3 +248,16 @@ def logformatter_adapter(logkws: dict) -> Tuple[int, str, dict]:
|
|||
args = logkws if not logkws.get("args") else logkws["args"]
|
||||
|
||||
return (level, message, args)
|
||||
|
||||
|
||||
class SpiderLoggerAdapter(logging.LoggerAdapter):
|
||||
def process(
|
||||
self, msg: str, kwargs: MutableMapping[str, Any]
|
||||
) -> Tuple[str, MutableMapping[str, Any]]:
|
||||
"""Method that augments logging with additional 'extra' data"""
|
||||
if isinstance(kwargs.get("extra"), MutableMapping):
|
||||
kwargs["extra"].update(self.extra)
|
||||
else:
|
||||
kwargs["extra"] = self.extra
|
||||
|
||||
return msg, kwargs
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"""Helper functions which don't fit anywhere else"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
import inspect
|
||||
|
|
@ -22,6 +24,8 @@ from typing import (
|
|||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
|
@ -32,9 +36,11 @@ from scrapy.utils.datatypes import LocalWeakReferencedCache
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from scrapy import Spider
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
from scrapy.settings import BaseSettings
|
||||
|
||||
_ITERABLE_SINGLE_VALUES = dict, Item, str, bytes
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def arg_to_iter(arg: Any) -> Iterable[Any]:
|
||||
|
|
@ -113,7 +119,15 @@ def md5sum(file: IO) -> str:
|
|||
>>> md5sum(BytesIO(b'file content to hash'))
|
||||
'784406af91dd5a54fbb9c84c2236595a'
|
||||
"""
|
||||
m = hashlib.md5()
|
||||
warnings.warn(
|
||||
(
|
||||
"The scrapy.utils.misc.md5sum function is deprecated, and will be "
|
||||
"removed in a future version of Scrapy."
|
||||
),
|
||||
ScrapyDeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
m = hashlib.md5() # nosec
|
||||
while True:
|
||||
d = file.read(8096)
|
||||
if not d:
|
||||
|
|
@ -169,7 +183,9 @@ def create_instance(objcls, settings, crawler, *args, **kwargs):
|
|||
return instance
|
||||
|
||||
|
||||
def build_from_crawler(objcls, crawler, /, *args, **kwargs):
|
||||
def build_from_crawler(
|
||||
objcls: Type[T], crawler: Crawler, /, *args: Any, **kwargs: Any
|
||||
) -> T:
|
||||
"""Construct a class instance using its ``from_crawler`` constructor.
|
||||
|
||||
``*args`` and ``**kwargs`` are forwarded to the constructor.
|
||||
|
|
@ -177,20 +193,22 @@ def build_from_crawler(objcls, crawler, /, *args, **kwargs):
|
|||
Raises ``TypeError`` if the resulting instance is ``None``.
|
||||
"""
|
||||
if hasattr(objcls, "from_crawler"):
|
||||
instance = objcls.from_crawler(crawler, *args, **kwargs)
|
||||
instance = objcls.from_crawler(crawler, *args, **kwargs) # type: ignore[attr-defined]
|
||||
method_name = "from_crawler"
|
||||
elif hasattr(objcls, "from_settings"):
|
||||
instance = objcls.from_settings(crawler.settings, *args, **kwargs)
|
||||
instance = objcls.from_settings(crawler.settings, *args, **kwargs) # type: ignore[attr-defined]
|
||||
method_name = "from_settings"
|
||||
else:
|
||||
instance = objcls(*args, **kwargs)
|
||||
method_name = "__new__"
|
||||
if instance is None:
|
||||
raise TypeError(f"{objcls.__qualname__}.{method_name} returned None")
|
||||
return instance
|
||||
return cast(T, instance)
|
||||
|
||||
|
||||
def build_from_settings(objcls, settings, /, *args, **kwargs):
|
||||
def build_from_settings(
|
||||
objcls: Type[T], settings: BaseSettings, /, *args: Any, **kwargs: Any
|
||||
) -> T:
|
||||
"""Construct a class instance using its ``from_settings`` constructor.
|
||||
|
||||
``*args`` and ``**kwargs`` are forwarded to the constructor.
|
||||
|
|
@ -198,14 +216,14 @@ def build_from_settings(objcls, settings, /, *args, **kwargs):
|
|||
Raises ``TypeError`` if the resulting instance is ``None``.
|
||||
"""
|
||||
if hasattr(objcls, "from_settings"):
|
||||
instance = objcls.from_settings(settings, *args, **kwargs)
|
||||
instance = objcls.from_settings(settings, *args, **kwargs) # type: ignore[attr-defined]
|
||||
method_name = "from_settings"
|
||||
else:
|
||||
instance = objcls(*args, **kwargs)
|
||||
method_name = "__new__"
|
||||
if instance is None:
|
||||
raise TypeError(f"{objcls.__qualname__}.{method_name} returned None")
|
||||
return instance
|
||||
return cast(T, instance)
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
|
@ -282,7 +300,7 @@ def is_generator_with_return_value(callable: Callable) -> bool:
|
|||
return bool(_generator_callbacks_cache[callable])
|
||||
|
||||
|
||||
def warn_on_generator_with_return_value(spider: "Spider", callable: Callable) -> None:
|
||||
def warn_on_generator_with_return_value(spider: Spider, callable: Callable) -> None:
|
||||
"""
|
||||
Logs a warning if a callable is a generator function and includes
|
||||
a 'return' statement with a value different than None
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
This module contains essential stuff that should've come with Python itself ;)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections.abc
|
||||
import gc
|
||||
import inspect
|
||||
|
|
@ -11,6 +13,7 @@ import weakref
|
|||
from functools import partial, wraps
|
||||
from itertools import chain
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
AsyncIterable,
|
||||
|
|
@ -25,12 +28,21 @@ from typing import (
|
|||
Optional,
|
||||
Pattern,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
from scrapy.utils.asyncgen import as_async_generator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Concatenate and typing.ParamSpec require Python 3.10
|
||||
from typing_extensions import Concatenate, ParamSpec
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def flatten(x: Iterable) -> list:
|
||||
"""flatten(sequence) -> list
|
||||
|
|
@ -169,14 +181,19 @@ def re_rsearch(
|
|||
return None
|
||||
|
||||
|
||||
def memoizemethod_noargs(method: Callable) -> Callable:
|
||||
_SelfT = TypeVar("_SelfT")
|
||||
|
||||
|
||||
def memoizemethod_noargs(
|
||||
method: Callable[Concatenate[_SelfT, _P], _T]
|
||||
) -> Callable[Concatenate[_SelfT, _P], _T]:
|
||||
"""Decorator to cache the result of a method (without arguments) using a
|
||||
weak reference to its object
|
||||
"""
|
||||
cache: weakref.WeakKeyDictionary[Any, Any] = weakref.WeakKeyDictionary()
|
||||
cache: weakref.WeakKeyDictionary[_SelfT, _T] = weakref.WeakKeyDictionary()
|
||||
|
||||
@wraps(method)
|
||||
def new_method(self: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
def new_method(self: _SelfT, *args: _P.args, **kwargs: _P.kwargs) -> _T:
|
||||
if self not in cache:
|
||||
cache[self] = method(self, *args, **kwargs)
|
||||
return cache[self]
|
||||
|
|
|
|||
|
|
@ -1,18 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from asyncio import AbstractEventLoop, AbstractEventLoopPolicy
|
||||
from contextlib import suppress
|
||||
from typing import Any, Callable, Dict, Optional, Sequence, Type
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Generic,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
)
|
||||
from warnings import catch_warnings, filterwarnings, warn
|
||||
|
||||
from twisted.internet import asyncioreactor, error
|
||||
from twisted.internet.base import DelayedCall
|
||||
from twisted.internet.protocol import ServerFactory
|
||||
from twisted.internet.tcp import Port
|
||||
|
||||
from scrapy.exceptions import ScrapyDeprecationWarning
|
||||
from scrapy.utils.misc import load_object
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.ParamSpec requires Python 3.10
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
def listen_tcp(portrange, host, factory):
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def listen_tcp(portrange: List[int], host: str, factory: ServerFactory) -> Port: # type: ignore[return]
|
||||
"""Like reactor.listenTCP but tries different ports in a range."""
|
||||
from twisted.internet import reactor
|
||||
|
||||
|
|
@ -20,8 +43,6 @@ def listen_tcp(portrange, host, factory):
|
|||
raise ValueError(f"invalid portrange: {portrange}")
|
||||
if not portrange:
|
||||
return reactor.listenTCP(0, factory, interface=host)
|
||||
if not hasattr(portrange, "__iter__"):
|
||||
return reactor.listenTCP(portrange, factory, interface=host)
|
||||
if len(portrange) == 1:
|
||||
return reactor.listenTCP(portrange[0], factory, interface=host)
|
||||
for x in range(portrange[0], portrange[1] + 1):
|
||||
|
|
@ -32,14 +53,14 @@ def listen_tcp(portrange, host, factory):
|
|||
raise
|
||||
|
||||
|
||||
class CallLaterOnce:
|
||||
class CallLaterOnce(Generic[_T]):
|
||||
"""Schedule a function to be called in the next reactor loop, but only if
|
||||
it hasn't been already scheduled since the last time it ran.
|
||||
"""
|
||||
|
||||
def __init__(self, func: Callable, *a: Any, **kw: Any):
|
||||
self._func: Callable = func
|
||||
self._a: Sequence[Any] = a
|
||||
def __init__(self, func: Callable[_P, _T], *a: _P.args, **kw: _P.kwargs):
|
||||
self._func: Callable[_P, _T] = func
|
||||
self._a: Tuple[Any, ...] = a
|
||||
self._kw: Dict[str, Any] = kw
|
||||
self._call: Optional[DelayedCall] = None
|
||||
|
||||
|
|
@ -53,7 +74,7 @@ class CallLaterOnce:
|
|||
if self._call:
|
||||
self._call.cancel()
|
||||
|
||||
def __call__(self) -> Any:
|
||||
def __call__(self) -> _T:
|
||||
self._call = None
|
||||
return self._func(*self._a, **self._kw)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ This module provides some useful functions for working with
|
|||
scrapy.http.Request objects
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import warnings
|
||||
|
|
@ -32,6 +34,9 @@ from scrapy.utils.misc import load_object
|
|||
from scrapy.utils.python import to_bytes, to_unicode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# typing.Self requires Python 3.11
|
||||
from typing_extensions import Self
|
||||
|
||||
from scrapy.crawler import Crawler
|
||||
|
||||
|
||||
|
|
@ -111,7 +116,7 @@ def fingerprint(
|
|||
"headers": headers,
|
||||
}
|
||||
fingerprint_json = json.dumps(fingerprint_data, sort_keys=True)
|
||||
cache[cache_key] = hashlib.sha1(fingerprint_json.encode()).digest()
|
||||
cache[cache_key] = hashlib.sha1(fingerprint_json.encode()).digest() # nosec
|
||||
return cache[cache_key]
|
||||
|
||||
|
||||
|
|
@ -133,10 +138,10 @@ class RequestFingerprinter:
|
|||
"""
|
||||
|
||||
@classmethod
|
||||
def from_crawler(cls, crawler):
|
||||
def from_crawler(cls, crawler) -> Self:
|
||||
return cls(crawler)
|
||||
|
||||
def __init__(self, crawler: Optional["Crawler"] = None):
|
||||
def __init__(self, crawler: Optional[Crawler] = None):
|
||||
if crawler:
|
||||
implementation = crawler.settings.get(
|
||||
"REQUEST_FINGERPRINTER_IMPLEMENTATION"
|
||||
|
|
|
|||
|
|
@ -3,24 +3,27 @@ This module provides some useful functions for working with
|
|||
scrapy.http.Response objects
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import webbrowser
|
||||
from typing import Any, Callable, Iterable, Tuple, Union
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable, Tuple, Union
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from twisted.web import http
|
||||
from w3lib import html
|
||||
|
||||
import scrapy
|
||||
from scrapy.http.response import Response
|
||||
from scrapy.utils.python import to_bytes, to_unicode
|
||||
|
||||
_baseurl_cache: "WeakKeyDictionary[Response, str]" = WeakKeyDictionary()
|
||||
if TYPE_CHECKING:
|
||||
from scrapy.http import Response, TextResponse
|
||||
|
||||
_baseurl_cache: WeakKeyDictionary[Response, str] = WeakKeyDictionary()
|
||||
|
||||
|
||||
def get_base_url(response: "scrapy.http.response.text.TextResponse") -> str:
|
||||
def get_base_url(response: TextResponse) -> str:
|
||||
"""Return the base url of the given response, joined with the response url"""
|
||||
if response not in _baseurl_cache:
|
||||
text = response.text[0:4096]
|
||||
|
|
@ -30,13 +33,13 @@ def get_base_url(response: "scrapy.http.response.text.TextResponse") -> str:
|
|||
return _baseurl_cache[response]
|
||||
|
||||
|
||||
_metaref_cache: (
|
||||
"WeakKeyDictionary[Response, Union[Tuple[None, None], Tuple[float, str]]]"
|
||||
) = WeakKeyDictionary()
|
||||
_metaref_cache: WeakKeyDictionary[
|
||||
Response, Union[Tuple[None, None], Tuple[float, str]]
|
||||
] = WeakKeyDictionary()
|
||||
|
||||
|
||||
def get_meta_refresh(
|
||||
response: "scrapy.http.response.text.TextResponse",
|
||||
response: TextResponse,
|
||||
ignore_tags: Iterable[str] = ("script", "noscript"),
|
||||
) -> Union[Tuple[None, None], Tuple[float, str]]:
|
||||
"""Parse the http-equiv refresh parameter from the given response"""
|
||||
|
|
@ -68,10 +71,7 @@ def _remove_html_comments(body):
|
|||
|
||||
|
||||
def open_in_browser(
|
||||
response: Union[
|
||||
"scrapy.http.response.html.HtmlResponse",
|
||||
"scrapy.http.response.text.TextResponse",
|
||||
],
|
||||
response: TextResponse,
|
||||
_openfunc: Callable[[str], Any] = webbrowser.open,
|
||||
) -> Any:
|
||||
"""Open *response* in a local web browser, adjusting the `base tag`_ for
|
||||
|
|
|
|||
|
|
@ -5,21 +5,21 @@ Note: The main purpose of this module is to provide support for the
|
|||
SitemapSpider, its API is subject to change without notice.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Generator, Iterator, Optional
|
||||
from typing import Any, Dict, Generator, Iterator, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import lxml.etree
|
||||
import lxml.etree # nosec
|
||||
|
||||
|
||||
class Sitemap:
|
||||
"""Class to parse Sitemap (type=urlset) and Sitemap Index
|
||||
(type=sitemapindex) files"""
|
||||
|
||||
def __init__(self, xmltext: str):
|
||||
def __init__(self, xmltext: Union[str, bytes]):
|
||||
xmlp = lxml.etree.XMLParser(
|
||||
recover=True, remove_comments=True, resolve_entities=False
|
||||
)
|
||||
self._root = lxml.etree.fromstring(xmltext, parser=xmlp)
|
||||
self._root = lxml.etree.fromstring(xmltext, parser=xmlp) # nosec
|
||||
rt = self._root.tag
|
||||
self.type = self._root.tag.split("}", 1)[1] if "}" in rt else rt
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import List, Tuple
|
|||
|
||||
import cryptography
|
||||
import cssselect
|
||||
import lxml.etree
|
||||
import lxml.etree # nosec
|
||||
import parsel
|
||||
import twisted
|
||||
import w3lib
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ Example:
|
|||
|
||||
The data flow with Spider Middleware v2 is as follows:
|
||||
|
||||
1. When a response arrives from the engine, it it passed through all the spider
|
||||
1. When a response arrives from the engine, it is passed through all the spider
|
||||
middlewares (in descending order). The result of each middleware
|
||||
``process_response`` is kept and then returned along with the spider
|
||||
callback result
|
||||
|
|
|
|||
|
|
@ -1,50 +1,47 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCYp6U4G9YWITYB
|
||||
/JlZ+Hd08c/9a157WVl03hbR2DSK8FnK+D8cp2dGzuTfC08w8M/yvVYPcbb7ZDiT
|
||||
NUsVwboFvmr/6mN6M9uQioCRStrP6Rkm2Wuagyj+GjqLwogTJlPiPwEPhlMgz1BJ
|
||||
u6jQQSgiMsxKWMkVz3pCYERUMRX0DEgYST9rjYUAwD4rPv8XXtLLSPs0VniIggUH
|
||||
JrngDUrtoK5Wuf098NJPIwW8uE2ev+DXH2Iuwn2fNKt5lSYypJdUZjyamwuE6HFB
|
||||
eIBAIIKijMz/8UV1+H8Q0OcU2Sva2FglHREQtA/S5FlpcuTZt/77Vnxv75y/0zls
|
||||
90iyQ3E/AgMBAAECggEBAJA1dyAdM85uC04vKVNUJM1GDp0xS+0syBReJaKRI3nJ
|
||||
epoCj+RqxGag1pdaYLI0G84NTPqECz9LOyLdqpPgEfKRIxWlf9oWmSnfnXskArd8
|
||||
VfVcWYl6tEPv1TToTZIBmCbYLBFVbLxG/GrbK6uokdhUsqbdXwEKok2IEaSTRlDn
|
||||
v8BVXte00d9VEKKpmI6EY3f45uPQPHuJNcitP2HGW1mT/C6XoZR6wj+VvoRgUGQT
|
||||
I7PuktbYpQlLV+oX0uZz9frPGhjydUq0Jti5v3QAJEb+7D0cKrkZW+7fYDx4YkRU
|
||||
oDiuWEyO2kfpff52Qxs+xUXMiAyw6/8+TamKoAi1TIECgYEAyAzoztW6W4CjL2au
|
||||
/hN5VmbAvuBxq1m1G5KgXM1myX9V2CgH6OKwzJQNSCEfKMNOjqxB99T7C3tMCjgG
|
||||
gmbUzylTeciQFF+crrl2Rn/6qZS9dCo1hagb3K5eXMhLXoP425Y4sypNPPqULhPn
|
||||
YrUDFNAf89rRLqP1KMPLZ+uO7EECgYEAw1lWPxGV+X85iQxYN9xoX85htfJSBXTf
|
||||
dLirQ4bkykOxSA6ZzFuhDO/G373Q1rze4tmEO790uOCeaiXGgeWC1A+2PMO957i5
|
||||
9FqhDIkmerfdIttdEUMM9rQwuTcLnixGZkT5GHDzjtNinaIVB+pv7twRAESqN9dC
|
||||
QXh7IF7g/X8CgYBMhQOX+hCqZ24D95cAAJrs/ajEWj2geVPZFCDa3oZulJJVeBpu
|
||||
bieKWScra9/rS6mE0Ub6cTEFl0fisMNspcDI7NnNP3Y9FMVt3+rp1JIgw5AkGvEW
|
||||
CtN9egUGIGcT5A8Qj0lo3slkhcSgS2S6UNq431MZh51z5askyJ/JREULAQKBgFrR
|
||||
OatwfYzUfOcd+hVePpfr1rlDwqYOw6P8BoMKP2tZNR4Oy6maH7Fn98kk8eYjQGuu
|
||||
PC+avqUEqCEpFrRlAwGbnFl7ltoXozvatmyhhmYe/Iur+ASCa5B2DQDOenQ6mTAK
|
||||
eNPIDzMjSwGFzMk1UHx3it/ZDFmRlZfibzuJYIf5AoGBAIaPHk4qadK/XpcD4Wwx
|
||||
BOsDEIz27DGWdwWfd5r3EcV4zX/wNzH0G1Z8eydNjUqKzufMZgFwpcTu0Evesl1/
|
||||
B8kC8sLHxQoG5SvBu4dBxMwKIU9O9uFnX5SUYZUDpCtUYyZ+GtGom41Jwg5ENrwy
|
||||
HzPh2taMnCA0h1fNLFFBkw88
|
||||
-----END PRIVATE KEY-----
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEowIBAAKCAQEAuq7ujTpHoSwQn9/hFJT837jU/T7xLuyXjkAEfL7uVDuPWSdF
|
||||
AJy+QJsuL6INMKMlxLUb1RRzxQgAmtYN1dIEbTPplffCNbfYm3FXg0mZlxg1UBg9
|
||||
rE1bPwuz/B+M76S35EIiKQlpaCFErLQi5oyhw9FIBvYLZxxgfeDfDPiXQlBvtHix
|
||||
n9TFqNoLNZkAX+auh2Wj2SSjM74pBQWsuVZLkF5CAwYuMQkpEplCV/QHNX3ZeNdQ
|
||||
YNFvpA7CxENa3sTZyHpeTmWoOdJXgJFveAWL4ZhSvkSd0HDuPPJp4JckETNESa9B
|
||||
qOwFfj36SM+5dRiCwiuzwAQ+oaFjisMXuRbVFQIDAQABAoIBABqFaJmCupNgnboA
|
||||
xcq1QdmMuiGCNCRs8zj/ykNoopYv4fUR+aEVI4gtI5obxRDwVJjF+/7BCZNnyCI3
|
||||
H78NN5jGA7zM9nfINwsaRor9xUasZ0KKNxTH5pslz/uVBeIzvfY9GPpIfoOPGmEI
|
||||
tF6Zgw+9JyTqBoOvCdxIOpfupxqB8TQ0z4UbFUuBiEkGuJ+o8C1rX8Wze0JUl0qG
|
||||
BOwhQtaCn/yrm+dTXZ5XaelJY5mcwgFy22Jiynmm6TbLhyZlACd2Q/MGak7o1TJL
|
||||
QgvvGMlcVrK7MZ3TJN+wzwWfwAAjXnT3Xvd5pD5yunZJoNe8YyFOCMlh0swNG5Zt
|
||||
0tGeX78CgYEA+m6gYGKTNWFnqlqMZRfGTqiqVZeVQKftcLdA1dkscnffRP9bvKOW
|
||||
9TbgzoGHiyZnjZBDFTUuy67El8RXIMsxYy3GYuGRdUSLS63Fr2af4pBQIYvW6OmG
|
||||
UZlcAP6ZAhUzn409XGlXaac3F30hFeKdC62+V5ZMnfPlVhHRCoKaaqsCgYEAvtV4
|
||||
FuU5sFKyhKPPV3rzaNZtL0swvtBIuODH1oAWhPNySQvCu+45W0EOOAPPpsYP4wGX
|
||||
G+otOSp4RLdlVXNhkh1rpJzeK77KZ5ZY+ShkuHD+uL/iRARwl/gh2Ve1aqUrm1LE
|
||||
9ldchmQGvLalN9HalzeW//xHA3X9SF4Vo16Dvz8CgYABeZlUOABp9hLoO/RLvCIc
|
||||
4H1wV543bUXGvi2RlN/gJLiZ7W8a41PGSfZ1AOpNdYJyoQDkJRYLeRILWsqwlMHL
|
||||
tb9PYci7ihXP8kwRxmb2rKbsK6iuYoG6BU83akh4bKuLKwfLfYtYQfXfG4uQV29Z
|
||||
XEKcvXPiEkethBlZGH/UVQKBgCa9Pvum3OcmYob6mgSwOOl3XgLTyLlzns+pEehB
|
||||
aFDk+rZJZOaxnYMg2boVS/oXCvKSSBKqnzOTo4aPlEqceZonzspD7fYDbSNKKhWq
|
||||
VYf7qDno+g3EuPagsH5mh7V2gjutub4oTegaNiPpD/Ec8Lrx1f1xQRk6wogGUW4w
|
||||
qZ4RAoGBAPg1LezV8mlesF5mhj+KubYP4l1Zf9geAeQprjDbFsA0BEAS2KsWgmwR
|
||||
Ye1fmek7jDjCPLQ4Amq030mLJuQGEM3cZPqjKX2sBZ8fQcgw7pWJWMvKMTBA4Aah
|
||||
zQx1KXwHJANMWq/0QSFDq/LGJ2OYMlV2F0tH3P5Kp7ZASTyc78ux
|
||||
-----END RSA PRIVATE KEY-----
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDoTCCAomgAwIBAgIGDodLQx9+MA0GCSqGSIb3DQEBCwUAMCgxEjAQBgNVBAMM
|
||||
CW1pdG1wcm94eTESMBAGA1UECgwJbWl0bXByb3h5MB4XDTIwMDgxMjE3MDMyNloX
|
||||
DTIzMDgxNDE3MDMyNlowKDESMBAGA1UEAwwJbWl0bXByb3h5MRIwEAYDVQQKDAlt
|
||||
aXRtcHJveHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYp6U4G9YW
|
||||
ITYB/JlZ+Hd08c/9a157WVl03hbR2DSK8FnK+D8cp2dGzuTfC08w8M/yvVYPcbb7
|
||||
ZDiTNUsVwboFvmr/6mN6M9uQioCRStrP6Rkm2Wuagyj+GjqLwogTJlPiPwEPhlMg
|
||||
z1BJu6jQQSgiMsxKWMkVz3pCYERUMRX0DEgYST9rjYUAwD4rPv8XXtLLSPs0VniI
|
||||
ggUHJrngDUrtoK5Wuf098NJPIwW8uE2ev+DXH2Iuwn2fNKt5lSYypJdUZjyamwuE
|
||||
6HFBeIBAIIKijMz/8UV1+H8Q0OcU2Sva2FglHREQtA/S5FlpcuTZt/77Vnxv75y/
|
||||
0zls90iyQ3E/AgMBAAGjgdAwgc0wDwYDVR0TAQH/BAUwAwEB/zARBglghkgBhvhC
|
||||
AQEEBAMCAgQweAYDVR0lBHEwbwYIKwYBBQUHAwEGCCsGAQUFBwMCBggrBgEFBQcD
|
||||
BAYIKwYBBQUHAwgGCisGAQQBgjcCARUGCisGAQQBgjcCARYGCisGAQQBgjcKAwEG
|
||||
CisGAQQBgjcKAwMGCisGAQQBgjcKAwQGCWCGSAGG+EIEATAOBgNVHQ8BAf8EBAMC
|
||||
AQYwHQYDVR0OBBYEFBCsLPpFz3l9rOOfGmfs+VRc3jhJMA0GCSqGSIb3DQEBCwUA
|
||||
A4IBAQADTpA15na6U5qqDCe0rr39fkS1/dY804Xnz7g/L3AsxPE1KOMijuJa8sKd
|
||||
kKwba1173FwMupfK39zY8jUxL8Qprdi92RO6CpoFUsL/icpA///lYhzUSqt32qwe
|
||||
gRNW3mtYBimOk6KH1NOfQnJolWpJh+g1OEsitQKEeKwIn5Hz+8/yS5tbwLgdnMlY
|
||||
1/it1H70JSdE7nfJueqN4cFfBsm6XaHZzacJJmN7WP88fd+zztnSQsBFbLlnjnqj
|
||||
envCDIwCrMywKNMqEBMwmBEGSAF47fVNYj6KzDAtMvBdDkYaHWpBf4tnFfk6v0wj
|
||||
wiKjdLjCmJgjGAQjRw5VYJ8JI0XO
|
||||
MIIDNTCCAh2gAwIBAgIUcGDiCmOuhfxMGFS/otcGGFkOSAEwDQYJKoZIhvcNAQEL
|
||||
BQAwKDESMBAGA1UEAwwJbWl0bXByb3h5MRIwEAYDVQQKDAltaXRtcHJveHkwHhcN
|
||||
MjQwMjI3MTMwNTQ4WhcNMzQwMjI2MTMwNTQ4WjAoMRIwEAYDVQQDDAltaXRtcHJv
|
||||
eHkxEjAQBgNVBAoMCW1pdG1wcm94eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
|
||||
AQoCggEBALqu7o06R6EsEJ/f4RSU/N+41P0+8S7sl45ABHy+7lQ7j1knRQCcvkCb
|
||||
Li+iDTCjJcS1G9UUc8UIAJrWDdXSBG0z6ZX3wjW32JtxV4NJmZcYNVAYPaxNWz8L
|
||||
s/wfjO+kt+RCIikJaWghRKy0IuaMocPRSAb2C2ccYH3g3wz4l0JQb7R4sZ/Uxaja
|
||||
CzWZAF/mrodlo9kkozO+KQUFrLlWS5BeQgMGLjEJKRKZQlf0BzV92XjXUGDRb6QO
|
||||
wsRDWt7E2ch6Xk5lqDnSV4CRb3gFi+GYUr5EndBw7jzyaeCXJBEzREmvQajsBX49
|
||||
+kjPuXUYgsIrs8AEPqGhY4rDF7kW1RUCAwEAAaNXMFUwDwYDVR0TAQH/BAUwAwEB
|
||||
/zATBgNVHSUEDDAKBggrBgEFBQcDATAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE
|
||||
FOjFT0G7itqsrCij2InhRSfB0sEkMA0GCSqGSIb3DQEBCwUAA4IBAQCVMa5/xlH4
|
||||
GUbrWNMdxr9LL7Dh+vK0wYCfAsc/kO2zCq8iVt/MaqVLel/bKcQhvE5RZHvyep13
|
||||
x7378OfCqqHkDDDNroWIvij84ZtMUaM53tF13G/ZGOlNsoLNynWs9IVVvqGKsH7o
|
||||
/buJ1RNArI/0irF0UD7qrMmo1p6SYanZhqdh2PphNy9NS3FsfrfnuWvf+/TRp9Ts
|
||||
L8058B0p/LIL0OB5trYFircC3iKSOuRl0ERD2ufgSqsSVEYm1mc6UIxv+d1iFD+Q
|
||||
8CRUF88icQXrec1TCbhh0CfdDxz+FYSTnW0DR0L75coa/CBmRxAjnrkLoXRr3Y1d
|
||||
sTjU4zDdBcBw
|
||||
-----END CERTIFICATE-----
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
# Tests requirements
|
||||
attrs
|
||||
pexpect >= 4.8.0
|
||||
pyftpdlib >= 1.5.8
|
||||
pytest
|
||||
pytest-cov==4.0.0
|
||||
pytest-xdist
|
||||
sybil >= 1.3.0 # https://github.com/cjw296/sybil/issues/20#issuecomment-605433422
|
||||
testfixtures
|
||||
uvloop; platform_system != "Windows"
|
||||
|
||||
bpython # optional for shell wrapper tests
|
||||
brotli; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests
|
||||
# 1.1.0 is broken on PyPy: https://github.com/google/brotli/issues/1072
|
||||
brotli==1.0.9; implementation_name == 'pypy' # optional for HTTP compress downloader middleware tests
|
||||
zstandard; implementation_name != 'pypy' # optional for HTTP compress downloader middleware tests
|
||||
ipython
|
||||
pywin32; sys_platform == "win32"
|
||||
|
|
@ -4,6 +4,7 @@ Some spiders used for testing and benchmarking
|
|||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from twisted.internet import defer
|
||||
|
|
@ -78,6 +79,28 @@ class DelaySpider(MetaSpider):
|
|||
self.t2_err = time.time()
|
||||
|
||||
|
||||
class LogSpider(MetaSpider):
|
||||
name = "log_spider"
|
||||
|
||||
def log_debug(self, message: str, extra: Optional[dict] = None):
|
||||
self.logger.debug(message, extra=extra)
|
||||
|
||||
def log_info(self, message: str, extra: Optional[dict] = None):
|
||||
self.logger.info(message, extra=extra)
|
||||
|
||||
def log_warning(self, message: str, extra: Optional[dict] = None):
|
||||
self.logger.warning(message, extra=extra)
|
||||
|
||||
def log_error(self, message: str, extra: Optional[dict] = None):
|
||||
self.logger.error(message, extra=extra)
|
||||
|
||||
def log_critical(self, message: str, extra: Optional[dict] = None):
|
||||
self.logger.critical(message, extra=extra)
|
||||
|
||||
def parse(self, response):
|
||||
pass
|
||||
|
||||
|
||||
class SlowSpider(DelaySpider):
|
||||
name = "slow"
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue