Scrapy 源码分析:框架结构、数据流程、启动流程、核心组件初始化、抓取流程

From:http://kaito-kidd.com/2016/11/09/scrapy-code-analyze-entrance/
Scrapy 源码下载地址:https://github.com/scrapy/scrapy

全局默认配置:scrapy.settings.default_settings
官网文档:https://docs.scrapy.org/en/latest/topics/settings.html

1、scrapy 框架

Scrapy 是基于 Python 编写的一个开源爬虫框架,它可以帮你快速、简单的方式构建爬虫,并从网站上提取你所需要的数据。这里不再介绍Scrapy的安装和使用,主要通过阅读源码讲解Scrapy实现思路。 scrapy 英文文档:https://doc.scrapy.org/en/latest

简单来说构建和运行一个爬虫只需完成以下几步:

  1. 使用 scrapy startproject spider_project 创建爬虫模板或自己编写爬虫脚本
  2. 爬虫类 继承 scrapy.Spider,重写 parse 方法
  3. parse 方法中 yield 或 return "字典RequestItem"
  4. 使用 scrapy crawl <spider_name>scrapy runspider <spider_file.py>运行

经过简单的几行代码,就能采集到某个网站下一些页面的数据,非常方便。但是在这背后到底发生了什么?Scrapy到底是如何帮助我们工作的呢?

架构图

Scrapy 架构图:https://docs.scrapy.org/en/latest/topics/architecture.html

核心组件

Scrapy有以下几大组件:

  1. Scrapy Engine:核心引擎,负责控制和调度各个组件,保证数据流转;
  2. Scheduler:负责管理任务、过滤任务、输出任务的调度器,存储、去重任务都在此控制;
  3. Downloader:下载器,负责在网络上下载网页数据,输入待下载URL,输出下载结果;
  4. Spiders:用户自己编写的爬虫脚本,可自定义抓取意图;
  5. Item Pipeline:负责输出结构化数据,可自定义输出位置;

除此之外,还有两大中间件组件:

  1. Downloader middlewares:介于引擎和下载器之间,可以在网页在下载前后进行逻辑处理;
  2. Spider middlewares:介于引擎和爬虫之间,可以在调用爬虫输入下载结果和输出请求/数据时进行逻辑处理;

数据流转

按照架构图的序号,数据流转大概是这样的:

  1. 引擎从自定义爬虫中获取初始化请求(也叫种子URL);
  2. 引擎把该请求放入调度器中,同时引擎向调度器获取一个待下载的请求(这两部是异步执行的);
  3. 调度器返回给引擎一个待下载的请求;
  4. 引擎发送请求给下载器,中间会经过一系列下载器中间件;
  5. 这个请求通过下载器下载完成后,生成一个响应对象,返回给引擎,这中间会再次经过一系列下载器中间件;
  6. 引擎接收到下载返回的响应对象后,然后发送给爬虫,执行自定义爬虫逻辑,中间会经过一系列爬虫中间件;
  7. 爬虫执行对应的回调方法,处理这个响应,完成用户逻辑后,会生成结果对象或新的请求对象给引擎,再次经过一系列爬虫中间件;
  8. 引擎把爬虫返回的结果对象交由结果处理器处理,把新的请求对象通过引擎再交给调度器;
  9. 从1开始重复执行,直到调度器中没有新的请求处理;

核心组件交互图

读完源码后,整理出一个更详细的架构图,其中展示了更多相关组件的细节:

这里需要说明一下图中的Scrapyer,其实这也是在源码的一个核心类,但官方架构图中没有展示出来,这个类其实是处于EngineSpidersPipeline之间,是连通这3个组件的桥梁。

核心类图

涉及到的一些核心类如下:

其中标没有样式的黑色文字是类的核心属性黄色样式的文字都是核心方法

可以看到,Scrapy的核心类,其实主要包含5大组件、4大中间件管理器、爬虫类和爬虫管理器、请求、响应对象和数据解析类这几大块。

2、启动 流程

当用 scrapy 写好一个爬虫后,使用 scrapy crawl <spider_name> 命令就可以运行这个爬虫。那么scrapy 命令从何而来?实际上,当成功安装 scrapy 后就能找到这个命令:

$ which scrapy
/usr/local/bin/scrapy

使用 vim 或其他编辑器打开它:$ vim /usr/local/bin/scrapy,其实就是一个 python 脚本。

#!/usr/bin/python3

# -*- coding: utf-8 -*-
import re
import sys

from scrapy.cmdline import execute

if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
    sys.exit(execute())

安装好 Scrapy 后,为什么入口点是这里呢?答案就在于 Scrapy 的安装文件 setup.py 中已经声明好了程序的运行入口处:

entry_points 指明了入口是 cmdline.pyexecute 方法,在安装过程中,setuptools 这个包管理工具就会把上述那一段代码生成放在可执行路径下。

这里可以学到一个小技巧,如何用 python 编写一个可执行文件?其实非常简单,只需要以下几步即可完成:

  • 编写一个带有 main 方法的 Python 模块(首行必须注明 Python 执行路径)
  • 去掉.py后缀名
  • 修改权限为可执行(chmod +x 文件名)
  • 直接用文件名就可以执行这个 Python 文件

例如,创建一个文件 mycmd,在这个文件中编写一个 main 方法,这个方法编写我们想要的执行的逻辑,之后执行 chmod +x mycmd 把这个文件权限变成可执行,最后通过 ./mycmd 就可以执行这段代码了,而不再需要通过 python <file.py> 方式就可以执行了

运行入口(execute.py)

知道了 scrapy/cmdline.py 的 execute 方法是入口点,使用 PyCharm 打开Scrapy源码。

下面看下源码

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.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

    from scrapy.settings import BaseSettings, Settings

    _P = ParamSpec("_P")


class ScrapyArgumentParser(argparse.ArgumentParser):
    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

        return super()._parse_optional(arg_string)


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):
        for obj in vars(module).values():
            if (
                inspect.isclass(obj)
                and issubclass(obj, ScrapyCommand)
                and obj.__module__ == module.__name__
                and obj not in (ScrapyCommand, BaseRunSpiderCommand)
            ):
                yield obj


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]
            d[cmdname] = cmd()
    return d


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:
        eps = entry_points().get(group, ())
    for entry_point in eps:
        obj = entry_point.load()
        if inspect.isclass(obj):
            cmds[entry_point.name] = obj()
        else:
            raise Exception(f"Invalid entry point {entry_point.name}")
    return cmds


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"]
    if cmds_module:
        cmds.update(_get_commands_from_module(cmds_module, inproject))
    return cmds


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: BaseSettings, inproject: bool) -> None:
    version = scrapy.__version__
    if inproject:
        print(f"Scrapy {version} - active project: {settings['BOT_NAME']}\n")

    else:
        print(f"Scrapy {version} - no active project\n")


def _print_commands(settings: BaseSettings, inproject: bool) -> None:
    _print_header(settings, inproject)
    print("Usage:")
    print("  scrapy <command> [options] [args]\n")
    print("Available commands:")
    cmds = _get_commands_dict(settings, inproject)
    for cmdname, cmdclass in sorted(cmds.items()):
        print(f"  {cmdname:<13} {cmdclass.short_desc()}")
    if not inproject:
        print()
        print("  [ more ]      More commands available when run from project directory")
    print()
    print('Use "scrapy <command> -h" to see more info about a command')


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: argparse.ArgumentParser,
    func: Callable[_P, None],
    *a: _P.args,
    **kw: _P.kwargs,
) -> None:
    try:
        func(*a, **kw)
    except UsageError as e:
        if str(e):
            parser.error(str(e))
        if e.print_help:
            parser.print_help()
        sys.exit(2)


def execute(
    argv: Optional[List[str]] = None, settings: Optional[Settings] = None
) -> None:
    if argv is None:
        argv = sys.argv

    # 初始化环境、获取项目配置参数 返回settings对象
    if settings is None:
        settings = get_project_settings()
        # set EDITOR from environment if available
        try:
            editor = os.environ["EDITOR"]
        except KeyError:
            pass
        else:
            settings["EDITOR"] = editor

    # 执行环境是否在项目中 主要检查scrapy.cfg配置文件是否存在
    inproject = inside_project()

    # 读取commands文件夹 把所有的命令类转换为{cmd_name: cmd_instance}的字典
    cmds = _get_commands_dict(settings, inproject)
    # 从命令行解析出执行的是哪个命令
    cmdname = _pop_command_name(argv)
    if not cmdname:
        _print_commands(settings, inproject)
        sys.exit(0)
    elif cmdname not in cmds:
        _print_unknown_command(settings, cmdname, inproject)
        sys.exit(2)

    # 根据命令名称找到对应的命令实例
    cmd = cmds[cmdname]
    parser = ScrapyArgumentParser(
        formatter_class=ScrapyHelpFormatter,
        usage=f"scrapy {cmdname} {cmd.syntax()}",
        conflict_handler="resolve",
        description=cmd.long_desc(),
    )
    # 设置项目配置和级别为command
    settings.setdict(cmd.default_settings, priority="command")
    cmd.settings = settings
    # 添加解析规则
    cmd.add_options(parser)
    # 解析命令参数,并交由Scrapy命令实例处理
    opts, args = parser.parse_known_args(args=argv[1:])
    _run_print_help(parser, cmd.process_options, args, opts)

    # 初始化CrawlerProcess实例 并给命令实例添加crawler_process属性
    cmd.crawler_process = CrawlerProcess(settings)
    
    # 执行命令实例的run方法
    _run_print_help(parser, _run_command, cmd, args, opts)
    sys.exit(cmd.exitcode)


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: 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()
    p = cProfile.Profile()
    p.runctx("cmd.run(args, opts)", globals(), loc)
    if opts.profile:
        p.dump_stats(opts.profile)


if __name__ == "__main__":
    try:
        execute()
    finally:
        # Twisted prints errors in DebugInfo.__del__, but PyPy does not run gc.collect() on exit:
        # http://doc.pypy.org/en/latest/cpython_differences.html
        # ?highlight=gc.collect#differences-related-to-garbage-collection-strategies
        garbage_collect()

根据注释就能看到,这里的主要工作包括配置初始化、命令解析、爬虫类加载、运行爬虫这几步。

初始化 项目配置

主要和 "环境变量、scrapy.cfg" 相关,通过调用 get_project_settings 方法,最终生成一个 Settings 实例。get_project_settings 方法如下:

def get_project_settings() -> Settings:
    # 环境变量中是否有SCRAPY_SETTINGS_MODULE配置
    if ENVVAR not in os.environ:
        project = os.environ.get("SCRAPY_PROJECT", "default")
        # 初始化环境 找到用户配置文件settings.py 设置到环境变量SCRAPY_SETTINGS_MODULE中
        init_env(project)
    # 加载默认配置文件default_settings.py 生成settings实例
    settings = Settings()
    # 取得用户配置文件
    settings_module_path = os.environ.get(ENVVAR)
    # 如果有用户配置 则覆盖默认配置
    if settings_module_path:
        settings.setmodule(settings_module_path, priority="project")

    valid_envvars = {
        "CHECK",
        "PROJECT",
        "PYTHON_SHELL",
        "SETTINGS_MODULE",
    }

    scrapy_envvars = {
        k[7:]: v
        for k, v in os.environ.items()
        if k.startswith("SCRAPY_") and k.replace("SCRAPY_", "") in valid_envvars
    }
    # 如果环境变量中有其他scrapy相关配置也覆盖
    settings.setdict(scrapy_envvars, priority="project")

    return settings

在初始配置时,会加载默认的配置文件 default_settings.py,主要逻辑在 Settings 类中。

class Settings(BaseSettings):
    def __init__(self, values: _SettingsInputT = None, priority: Union[int, str] = "project"):
        # 不要传递 kwarg 字典作为参数,因为不希望用户传递自定义字典来更新值
        # 而是希望通过传递确切的值来传递更新参数
        # 调用父类构造初始化
        super().__init__()
        # 把default_settings.py的所有配置set到settings实例中
        self.setmodule(default_settings, "default")
        # 把attributes属性也set到settings实例中
        for name, val in self.items():
            if isinstance(val, dict):
                self.set(name, BaseSettings(val, "default"), "default")
        self.update(values, priority)

可以看到,首先把默认配置文件 default_settings.py 中的所有配置项设置到 Settings 中,而且这个配置是有优先级的。这个默认配置文件 default_settings.py 是非常重要包含了所有默认的配置例如:调度器类、爬虫中间件类、下载器中间件类、下载处理器类 等。

"""
This module contains the default values for all settings used by Scrapy.

For more information about these settings you can read the settings
documentation in docs/topics/settings.rst

Scrapy developers, if you add a setting here remember to:

* add it in alphabetical order
* group similar settings without leaving blank lines
* add its documentation to the available settings documentation
  (docs/topics/settings.rst)

"""

import sys
from importlib import import_module
from pathlib import Path

ADDONS = {}

AJAXCRAWL_ENABLED = False

ASYNCIO_EVENT_LOOP = None

AUTOTHROTTLE_ENABLED = False
AUTOTHROTTLE_DEBUG = False
AUTOTHROTTLE_MAX_DELAY = 60.0
AUTOTHROTTLE_START_DELAY = 5.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 1.0

BOT_NAME = "scrapybot"

CLOSESPIDER_TIMEOUT = 0
CLOSESPIDER_PAGECOUNT = 0
CLOSESPIDER_ITEMCOUNT = 0
CLOSESPIDER_ERRORCOUNT = 0

COMMANDS_MODULE = ""

COMPRESSION_ENABLED = True

CONCURRENT_ITEMS = 100

CONCURRENT_REQUESTS = 16
CONCURRENT_REQUESTS_PER_DOMAIN = 8
CONCURRENT_REQUESTS_PER_IP = 0

COOKIES_ENABLED = True
COOKIES_DEBUG = False

DEFAULT_ITEM_CLASS = "scrapy.item.Item"

DEFAULT_REQUEST_HEADERS = {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en",
}

DEPTH_LIMIT = 0
DEPTH_STATS_VERBOSE = False
DEPTH_PRIORITY = 0

DNSCACHE_ENABLED = True
DNSCACHE_SIZE = 10000
DNS_RESOLVER = "scrapy.resolver.CachingThreadedResolver"
DNS_TIMEOUT = 60

DOWNLOAD_DELAY = 0

DOWNLOAD_HANDLERS = {}
DOWNLOAD_HANDLERS_BASE = {
    "data": "scrapy.core.downloader.handlers.datauri.DataURIDownloadHandler",
    "file": "scrapy.core.downloader.handlers.file.FileDownloadHandler",
    "http": "scrapy.core.downloader.handlers.http.HTTPDownloadHandler",
    "https": "scrapy.core.downloader.handlers.http.HTTPDownloadHandler",
    "s3": "scrapy.core.downloader.handlers.s3.S3DownloadHandler",
    "ftp": "scrapy.core.downloader.handlers.ftp.FTPDownloadHandler",
}

DOWNLOAD_TIMEOUT = 180  # 3mins

DOWNLOAD_MAXSIZE = 1024 * 1024 * 1024  # 1024m
DOWNLOAD_WARNSIZE = 32 * 1024 * 1024  # 32m

DOWNLOAD_FAIL_ON_DATALOSS = True

DOWNLOADER = "scrapy.core.downloader.Downloader"

DOWNLOADER_HTTPCLIENTFACTORY = (
    "scrapy.core.downloader.webclient.ScrapyHTTPClientFactory"
)
DOWNLOADER_CLIENTCONTEXTFACTORY = (
    "scrapy.core.downloader.contextfactory.ScrapyClientContextFactory"
)
DOWNLOADER_CLIENT_TLS_CIPHERS = "DEFAULT"
# Use highest TLS/SSL protocol version supported by the platform, also allowing negotiation:
DOWNLOADER_CLIENT_TLS_METHOD = "TLS"
DOWNLOADER_CLIENT_TLS_VERBOSE_LOGGING = False

DOWNLOADER_MIDDLEWARES = {}

DOWNLOADER_MIDDLEWARES_BASE = {
    # Engine side
    "scrapy.downloadermiddlewares.offsite.OffsiteMiddleware": 50,
    "scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware": 100,
    "scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware": 300,
    "scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware": 350,
    "scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware": 400,
    "scrapy.downloadermiddlewares.useragent.UserAgentMiddleware": 500,
    "scrapy.downloadermiddlewares.retry.RetryMiddleware": 550,
    "scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware": 560,
    "scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware": 580,
    "scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware": 590,
    "scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 600,
    "scrapy.downloadermiddlewares.cookies.CookiesMiddleware": 700,
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 750,
    "scrapy.downloadermiddlewares.stats.DownloaderStats": 850,
    "scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware": 900,
    # Downloader side
}

DOWNLOADER_STATS = True

DUPEFILTER_CLASS = "scrapy.dupefilters.RFPDupeFilter"

EDITOR = "vi"
if sys.platform == "win32":
    EDITOR = "%s -m idlelib.idle"

EXTENSIONS = {}

EXTENSIONS_BASE = {
    "scrapy.extensions.corestats.CoreStats": 0,
    "scrapy.extensions.telnet.TelnetConsole": 0,
    "scrapy.extensions.memusage.MemoryUsage": 0,
    "scrapy.extensions.memdebug.MemoryDebugger": 0,
    "scrapy.extensions.closespider.CloseSpider": 0,
    "scrapy.extensions.feedexport.FeedExporter": 0,
    "scrapy.extensions.logstats.LogStats": 0,
    "scrapy.extensions.spiderstate.SpiderState": 0,
    "scrapy.extensions.throttle.AutoThrottle": 0,
}

FEED_TEMPDIR = None
FEEDS = {}
FEED_URI_PARAMS = None  # a function to extend uri arguments
FEED_STORE_EMPTY = True
FEED_EXPORT_ENCODING = None
FEED_EXPORT_FIELDS = None
FEED_STORAGES = {}
FEED_STORAGES_BASE = {
    "": "scrapy.extensions.feedexport.FileFeedStorage",
    "file": "scrapy.extensions.feedexport.FileFeedStorage",
    "ftp": "scrapy.extensions.feedexport.FTPFeedStorage",
    "gs": "scrapy.extensions.feedexport.GCSFeedStorage",
    "s3": "scrapy.extensions.feedexport.S3FeedStorage",
    "stdout": "scrapy.extensions.feedexport.StdoutFeedStorage",
}
FEED_EXPORT_BATCH_ITEM_COUNT = 0
FEED_EXPORTERS = {}
FEED_EXPORTERS_BASE = {
    "json": "scrapy.exporters.JsonItemExporter",
    "jsonlines": "scrapy.exporters.JsonLinesItemExporter",
    "jsonl": "scrapy.exporters.JsonLinesItemExporter",
    "jl": "scrapy.exporters.JsonLinesItemExporter",
    "csv": "scrapy.exporters.CsvItemExporter",
    "xml": "scrapy.exporters.XmlItemExporter",
    "marshal": "scrapy.exporters.MarshalItemExporter",
    "pickle": "scrapy.exporters.PickleItemExporter",
}
FEED_EXPORT_INDENT = 0

FEED_STORAGE_FTP_ACTIVE = False
FEED_STORAGE_GCS_ACL = ""
FEED_STORAGE_S3_ACL = ""

FILES_STORE_S3_ACL = "private"
FILES_STORE_GCS_ACL = ""

FTP_USER = "anonymous"
FTP_PASSWORD = "guest"  # nosec
FTP_PASSIVE_MODE = True

GCS_PROJECT_ID = None

HTTPCACHE_ENABLED = False
HTTPCACHE_DIR = "httpcache"
HTTPCACHE_IGNORE_MISSING = False
HTTPCACHE_STORAGE = "scrapy.extensions.httpcache.FilesystemCacheStorage"
HTTPCACHE_EXPIRATION_SECS = 0
HTTPCACHE_ALWAYS_STORE = False
HTTPCACHE_IGNORE_HTTP_CODES = []
HTTPCACHE_IGNORE_SCHEMES = ["file"]
HTTPCACHE_IGNORE_RESPONSE_CACHE_CONTROLS = []
HTTPCACHE_DBM_MODULE = "dbm"
HTTPCACHE_POLICY = "scrapy.extensions.httpcache.DummyPolicy"
HTTPCACHE_GZIP = False

HTTPPROXY_ENABLED = True
HTTPPROXY_AUTH_ENCODING = "latin-1"

IMAGES_STORE_S3_ACL = "private"
IMAGES_STORE_GCS_ACL = ""

ITEM_PROCESSOR = "scrapy.pipelines.ItemPipelineManager"

ITEM_PIPELINES = {}
ITEM_PIPELINES_BASE = {}

JOBDIR = None

LOG_ENABLED = True
LOG_ENCODING = "utf-8"
LOG_FORMATTER = "scrapy.logformatter.LogFormatter"
LOG_FORMAT = "%(asctime)s [%(name)s] %(levelname)s: %(message)s"
LOG_DATEFORMAT = "%Y-%m-%d %H:%M:%S"
LOG_STDOUT = False
LOG_LEVEL = "DEBUG"
LOG_FILE = None
LOG_FILE_APPEND = True
LOG_SHORT_NAMES = False

SCHEDULER_DEBUG = False

LOGSTATS_INTERVAL = 60.0

MAIL_HOST = "localhost"
MAIL_PORT = 25
MAIL_FROM = "scrapy@localhost"
MAIL_PASS = None
MAIL_USER = None

MEMDEBUG_ENABLED = False  # enable memory debugging
MEMDEBUG_NOTIFY = []  # send memory debugging report by mail at engine shutdown

MEMUSAGE_CHECK_INTERVAL_SECONDS = 60.0
MEMUSAGE_ENABLED = True
MEMUSAGE_LIMIT_MB = 0
MEMUSAGE_NOTIFY_MAIL = []
MEMUSAGE_WARNING_MB = 0

METAREFRESH_ENABLED = True
METAREFRESH_IGNORE_TAGS = ["noscript"]
METAREFRESH_MAXDELAY = 100

NEWSPIDER_MODULE = ""

PERIODIC_LOG_DELTA = None
PERIODIC_LOG_STATS = None
PERIODIC_LOG_TIMING_ENABLED = False

RANDOMIZE_DOWNLOAD_DELAY = True

REACTOR_THREADPOOL_MAXSIZE = 10

REDIRECT_ENABLED = True
REDIRECT_MAX_TIMES = 20  # uses Firefox default setting
REDIRECT_PRIORITY_ADJUST = +2

REFERER_ENABLED = True
REFERRER_POLICY = "scrapy.spidermiddlewares.referer.DefaultReferrerPolicy"

REQUEST_FINGERPRINTER_CLASS = "scrapy.utils.request.RequestFingerprinter"
REQUEST_FINGERPRINTER_IMPLEMENTATION = "SENTINEL"

RETRY_ENABLED = True
RETRY_TIMES = 2  # initial response + 2 retries = 3 requests
RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429]
RETRY_PRIORITY_ADJUST = -1
RETRY_EXCEPTIONS = [
    "twisted.internet.defer.TimeoutError",
    "twisted.internet.error.TimeoutError",
    "twisted.internet.error.DNSLookupError",
    "twisted.internet.error.ConnectionRefusedError",
    "twisted.internet.error.ConnectionDone",
    "twisted.internet.error.ConnectError",
    "twisted.internet.error.ConnectionLost",
    "twisted.internet.error.TCPTimedOutError",
    "twisted.web.client.ResponseFailed",
    # OSError is raised by the HttpCompression middleware when trying to
    # decompress an empty response
    OSError,
    "scrapy.core.downloader.handlers.http11.TunnelError",
]

ROBOTSTXT_OBEY = False
ROBOTSTXT_PARSER = "scrapy.robotstxt.ProtegoRobotParser"
ROBOTSTXT_USER_AGENT = None

SCHEDULER = "scrapy.core.scheduler.Scheduler"
SCHEDULER_DISK_QUEUE = "scrapy.squeues.PickleLifoDiskQueue"
SCHEDULER_MEMORY_QUEUE = "scrapy.squeues.LifoMemoryQueue"
SCHEDULER_PRIORITY_QUEUE = "scrapy.pqueues.ScrapyPriorityQueue"

SCRAPER_SLOT_MAX_ACTIVE_SIZE = 5000000

SPIDER_LOADER_CLASS = "scrapy.spiderloader.SpiderLoader"
SPIDER_LOADER_WARN_ONLY = False

SPIDER_MIDDLEWARES = {}

SPIDER_MIDDLEWARES_BASE = {
    # Engine side
    "scrapy.spidermiddlewares.httperror.HttpErrorMiddleware": 50,
    "scrapy.spidermiddlewares.referer.RefererMiddleware": 700,
    "scrapy.spidermiddlewares.urllength.UrlLengthMiddleware": 800,
    "scrapy.spidermiddlewares.depth.DepthMiddleware": 900,
    # Spider side
}

SPIDER_MODULES = []

STATS_CLASS = "scrapy.statscollectors.MemoryStatsCollector"
STATS_DUMP = True

STATSMAILER_RCPTS = []

TEMPLATES_DIR = str((Path(__file__).parent / ".." / "templates").resolve())

URLLENGTH_LIMIT = 2083

USER_AGENT = f'Scrapy/{import_module("scrapy").__version__} (+https://scrapy.org)'

TELNETCONSOLE_ENABLED = 1
TELNETCONSOLE_PORT = [6023, 6073]
TELNETCONSOLE_HOST = "127.0.0.1"
TELNETCONSOLE_USERNAME = "scrapy"
TELNETCONSOLE_PASSWORD = None

TWISTED_REACTOR = None

SPIDER_CONTRACTS = {}
SPIDER_CONTRACTS_BASE = {
    "scrapy.contracts.default.UrlContract": 1,
    "scrapy.contracts.default.CallbackKeywordArgumentsContract": 1,
    "scrapy.contracts.default.ReturnsContract": 2,
    "scrapy.contracts.default.ScrapesContract": 3,
}

默认配置中为什么配置了这么多类模块?这其实是 Scrapy 特性之一,它这么做的好处是:scrapy 的架构是非常低耦合的,任何模块都是可替换的。例如,你觉得默认的调度器功能不够用,那么你就可以按照它定义的接口标准,自己实现一个调度器,然后在自己的配置文件中,注册自己的调度器类,那么 Scrapy 运行时就会加载并执行你自定义的调度器,这极大地提高了我们的灵活性。scrapy-redis 就是替换 scrapy 中的模块 来实现分布式。所以,只要在默认配置文件中配置的模块类,都是可替换的。

检查环境是否在项目中

初始化完配置之后,下面一步是检查运行环境是否在爬虫项目中。我们知道,scrapy 命令有的是依赖项目运行的,有的命令则是全局的。这里主要通过就近查找 scrapy.cfg 文件来确定是否在项目环境中,主要逻辑在 inside_project 方法中。

from scrapy.utils.project import get_project_settings, inside_project

运行环境是否在爬虫项目中的依据就是能否找到 scrapy.cfg 文件,如果能找到,则说明是在爬虫项目中,否则就认为是执行的全局命令。

组装 命令实例 集合

再向下看,就到了加载命令的逻辑了。

我们知道 scrapy 包括很多命令,例如 scrapy crawl 、 scrapy fetch 等等,那这些命令是从哪来的?答案就在 _get_commands_dict 方法中。

def _get_commands_dict(settings, inproject):
    # 导入commands文件夹下的所有模块 生成{cmd_name: cmd}的字典集合
    cmds = _get_commands_from_module('scrapy.commands', inproject)
    cmds.update(_get_commands_from_entry_points(inproject))
    # 如果用户自定义配置文件中有COMMANDS_MODULE配置 则加载自定义的命令类
    cmds_module = settings['COMMANDS_MODULE']
    if cmds_module:
        cmds.update(_get_commands_from_module(cmds_module, inproject))
    return cmds

def _get_commands_from_module(module, inproject):
    d = {}
    # 找到这个模块下所有的命令类(ScrapyCommand子类)
    for cmd in _iter_command_classes(module):
        if inproject or not cmd.requires_project:
            # 生成{cmd_name: cmd}字典
            cmdname = cmd.__module__.split('.')[-1]
            d[cmdname] = cmd()
    return d

def _iter_command_classes(module_name):
    # 迭代这个包下的所有模块 找到ScrapyCommand的子类
    for module in walk_modules(module_name):
        for obj in vars(module).values():
            if inspect.isclass(obj) and \
                    issubclass(obj, ScrapyCommand) and \
                    obj.__module__ == module.__name__:
                yield obj

这个过程主要是,导入 commands 文件夹下的所有模块,最终生成一个 {cmd_name: cmd} 字典集合,如果用户在配置文件中也配置了自定义的命令类,也会追加进去。也就是说,我们自己也可以编写自己的命令类,然后追加到配置文件中,之后就可以使用自己定义的命令了。

解析 命令

加载好命令类后,就开始解析我们具体执行的哪个命令了,解析逻辑比较简单:

def _pop_command_name(argv):
    i = 0
    for arg in argv[1:]:
        if not arg.startswith('-'):
            del argv[i]
            return arg
        i += 1

这个过程就是解析命令行,例如执行 scrapy crawl <spider_name>,这个方法会解析出 crawl,通过上面生成好的命令类的字典集合,就能找到 commands 目录下的 crawl.py文件,最终执行的就是它的 Command 类。

解析 命令行 参数

找到对应的命令实例后,调用 cmd.process_options 方法解析我们的参数:

def process_options(self, args, opts):
    # 首先调用了父类的process_options 解析统一固定的参数
    ScrapyCommand.process_options(self, args, opts)
    try:
        # 命令行参数转为字典
        opts.spargs = arglist_to_dict(opts.spargs)
    except ValueError:
        raise UsageError("Invalid -a value, use -a NAME=VALUE", print_help=False)
    if opts.output:
        if opts.output == '-':
            self.settings.set('FEED_URI', 'stdout:', priority='cmdline')
        else:
            self.settings.set('FEED_URI', opts.output, priority='cmdline')
        feed_exporters = without_none_values(
            self.settings.getwithbase('FEED_EXPORTERS'))
        valid_output_formats = feed_exporters.keys()
        if not opts.output_format:
            opts.output_format = os.path.splitext(opts.output)[1].replace(".", "")
        if opts.output_format not in valid_output_formats:
            raise UsageError("Unrecognized output format '%s', set one"
                             " using the '-t' switch or as a file extension"
                             " from the supported list %s" % (opts.output_format,
                                                              		tuple(valid_output_formats)))
        self.settings.set('FEED_FORMAT', opts.output_format, priority='cmdline')

这个过程就是解析命令行其余的参数,固定参数解析交给父类处理,例如输出位置等。其余不同的参数由不同的命令类解析。

初始化 CrawlerProcess

一切准备就绪,最后初始化 CrawlerProcess 实例,然后运行对应命令实例的 run 方法。

cmd.crawler_process = CrawlerProcess(settings)
_run_print_help(parser, _run_command, cmd, args, opts)

运行一个爬虫一般使用的是 scrapy crawl <spider_name>,也就是说最终调用的是 commands/crawl.py 的 run 方法:

def run(self, args, opts):
    if len(args) < 1:
        raise UsageError()
    elif len(args) > 1:
        raise UsageError("running 'scrapy crawl' with more than one spider is no longer supported")
    spname = args[0]

    self.crawler_process.crawl(spname, **opts.spargs)
    self.crawler_process.start()

run 方法中调用了 CrawlerProcess 实例的 crawl 和 start 方法,就这样整个爬虫程序就会运行起来了。先看下 CrawlerProcess 的初始化:

class CrawlerProcess(CrawlerRunner):
    def __init__(self, settings=None):
        # 调用父类初始化
        super(CrawlerProcess, self).__init__(settings)
        # 信号和log初始化
        install_shutdown_handlers(self._signal_shutdown)
        configure_logging(self.settings)
        log_scrapy_info(self.settings)

其中,构造方法中调用了父类 CrawlerRunner 的构造方法:

class CrawlerRunner(object):
    def __init__(self, settings=None):
        if isinstance(settings, dict) or settings is None:
            settings = Settings(settings)
        self.settings = settings
        # 获取爬虫加载器
        self.spider_loader = _get_spider_loader(settings)
        self._crawlers = set()
        self._active = set()

初始化时,调用了 _get_spider_loader方法:

def _get_spider_loader(settings):
    # 读取配置文件中的SPIDER_MANAGER_CLASS配置项
    if settings.get('SPIDER_MANAGER_CLASS'):
        warnings.warn(
            'SPIDER_MANAGER_CLASS option is deprecated. '
            'Please use SPIDER_LOADER_CLASS.',
            category=ScrapyDeprecationWarning, stacklevel=2
        )
    cls_path = settings.get('SPIDER_MANAGER_CLASS',
                            settings.get('SPIDER_LOADER_CLASS'))
    loader_cls = load_object(cls_path)
    try:
        verifyClass(ISpiderLoader, loader_cls)
    except DoesNotImplement:
        warnings.warn(
            'SPIDER_LOADER_CLASS (previously named SPIDER_MANAGER_CLASS) does '
            'not fully implement scrapy.interfaces.ISpiderLoader interface. '
            'Please add all missing methods to avoid unexpected runtime errors.',
            category=ScrapyDeprecationWarning, stacklevel=2
        )
    return loader_cls.from_settings(settings.frozencopy())

这里会读取默认配置文件中的 spider_loader项,默认配置是 spiderloader.SpiderLoader类,从名字我们也能看出来,这个类是用来加载我们编写好的爬虫类的,下面看一下这个类的具体实现。

@implementer(ISpiderLoader)
class SpiderLoader(object):
    def __init__(self, settings):
        # 配置文件获取存放爬虫脚本的路径
        self.spider_modules = settings.getlist('SPIDER_MODULES')
        self._spiders = {}
        # 加载所有爬虫
        self._load_all_spiders()

    def _load_spiders(self, module):
        # 组装成{spider_name: spider_cls}的字典
        for spcls in iter_spider_classes(module):
            self._spiders[spcls.name] = spcls

    def _load_all_spiders(self):
        for name in self.spider_modules:
            for module in walk_modules(name):
                self._load_spiders(module)

可以看到,在这里爬虫加载器会加载所有的爬虫脚本,最后生成一个 {spider_name: spider_cls} 的字典,所以在执行 scarpy crawl <spider_name> 时,Scrapy 就能找到我们编写的爬虫类。

运行 爬虫

CrawlerProcess 初始化完之后,调用它的 crawl 方法:

def crawl(self, crawler_or_spidercls, *args, **kwargs):
    # 创建crawler
    crawler = self.create_crawler(crawler_or_spidercls)
    return self._crawl(crawler, *args, **kwargs)

def _crawl(self, crawler, *args, **kwargs):
    self.crawlers.add(crawler)
    # 调用Crawler的crawl方法
    d = crawler.crawl(*args, **kwargs)
    self._active.add(d)

    def _done(result):
        self.crawlers.discard(crawler)
        self._active.discard(d)
        return result
    return d.addBoth(_done)

def create_crawler(self, crawler_or_spidercls):
    if isinstance(crawler_or_spidercls, Crawler):
        return crawler_or_spidercls
    return self._create_crawler(crawler_or_spidercls)

def _create_crawler(self, spidercls):
    # 如果是字符串 则从spider_loader中加载这个爬虫类
    if isinstance(spidercls, six.string_types):
        spidercls = self.spider_loader.load(spidercls)
    # 否则创建Crawler
    return Crawler(spidercls, self.settings)

这个过程会创建 Cralwer 实例,然后调用它的 crawl 方法:

@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
    assert not self.crawling, "Crawling already taking place"
    self.crawling = True

    try:
        # 到现在 才是实例化一个爬虫实例
        self.spider = self._create_spider(*args, **kwargs)
        # 创建引擎
        self.engine = self._create_engine()
        # 调用爬虫类的start_requests方法
        start_requests = iter(self.spider.start_requests())
        # 执行引擎的open_spider 并传入爬虫实例和初始请求
        yield self.engine.open_spider(self.spider, start_requests)
        yield defer.maybeDeferred(self.engine.start)
    except Exception:
        if six.PY2:
            exc_info = sys.exc_info()

        self.crawling = False
        if self.engine is not None:
            yield self.engine.close()

        if six.PY2:
            six.reraise(*exc_info)
        raise

def _create_spider(self, *args, **kwargs):
    return self.spidercls.from_crawler(self, *args, **kwargs)

到这里,才会对我们的爬虫类创建一个实例对象,然后创建引擎,之后调用爬虫类的 start_requests 方法获取种子 URL,最后交给引擎执行。

最后来看 Cralwer 是如何开始运行的额,也就是它的 start 方法:

def start(self, stop_after_crawl=True):
    if stop_after_crawl:
        d = self.join()
        if d.called:
            return
        d.addBoth(self._stop_reactor)
    reactor.installResolver(self._get_dns_resolver())
    # 配置reactor的池子大小(可修改REACTOR_THREADPOOL_MAXSIZE调整)
    tp = reactor.getThreadPool()
    tp.adjustPoolsize(maxthreads=self.settings.getint('REACTOR_THREADPOOL_MAXSIZE'))
    reactor.addSystemEventTrigger('before', 'shutdown', self.stop)
    # 开始执行
    reactor.run(installSignalHandlers=False)

在这里有一个叫做 reactor 的模块。reactor 是个什么东西呢?它是 Twisted 模块的事件管理器,我们只要把需要执行的事件注册到 reactor 中,然后调用它的 run 方法,它就会帮我们执行注册好的事件,如果遇到网络IO等待,它会自动帮切换到可执行的事件上,非常高效。这里不用深究 reactor 是如何工作的,你可以把它想象成一个线程池,只是采用注册回调的方式来执行事件。

到这里,Scrapy 运行的入口就分析完了,之后爬虫的调度逻辑就交由引擎 ExecuteEngine 处理了,引擎会协调多个组件,相互配合完成整个任务的执行。

总  结

Scrapy 在真正运行前,需要做的工作包括配置环境初始化、命令类的加载、爬虫模块的加载,以及命令类和参数解析,之后运行我们的爬虫类,最终,这个爬虫类的调度交给引擎处理。

3、核心组件 初始化

Scrapy 有哪些核心组件?以及它们主要负责了哪些工作?这些组件为了完成这些功能,内部又是如何实现的?

下面就开始剖析 Scrapy 涉及到的核心的组件,主要包括:引擎、下载器、调度器、爬虫类、输出处理器,以及它们各自都是如何初始化的,在初始化过程中,它们又包含了哪些子模块来辅助完成这些模块的功能。

通过分析可以看到这些组件各司其职,相互协调,共同完成爬虫的抓取任务,而且从代码中也能发现,每个组件类都是定义在配置文件中的,也就是说我们可以实现自己的逻辑,然后替代这些组件,这样的设计模式也非常值得我们学习。

自定义 爬虫类

Scrapy 运行起来后,执行到最后到了 Crawler 的 crawl 方法,看下这个方法:

@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
    assert not self.crawling, "Crawling already taking place"
    self.crawling = True
    try:
        # 从spiderloader中找到爬虫类 并实例化爬虫实例
        self.spider = self._create_spider(*args, **kwargs)
        # 创建引擎
        self.engine = self._create_engine()
        # 调用爬虫类的start_requests方法 拿到种子URL列表
        start_requests = iter(self.spider.start_requests())
        # 执行引擎的open_spider 并传入爬虫实例和初始请求
        yield self.engine.open_spider(self.spider, start_requests)
        yield defer.maybeDeferred(self.engine.start)
    except Exception:
        if six.PY2:
            exc_info = sys.exc_info()
        self.crawling = False
        if self.engine is not None:
            yield self.engine.close()
        if six.PY2:
            six.reraise(*exc_info)
        raise

执行到这里,我可以看到首先创建了爬虫实例,然后创建了引擎,最后把爬虫交给引擎来处理了。

在上一篇文章我们也讲到,在 Crawler 实例化时,会创建 SpiderLoader,它会根据我们定义的配置文件 settings.py 找到存放爬虫的位置,我们写的爬虫代码都在这里。

然后 SpiderLoader 会扫描这些代码文件,并找到父类是 scrapy.Spider 爬虫类,然后根据爬虫类中的 name 属性(在编写爬虫时,这个属性是必填的),生成一个 {spider_name: spider_cls} 的字典,最后根据 scrapy crawl <spider_name> 命令中的 spider_name 找到我们写的爬虫类,然后实例化它,在这里就是调用了_create_spider方法:

def _create_spider(self, *args, **kwargs):
    # 调用类方法from_crawler实例化
    return self.spidercls.from_crawler(self, *args, **kwargs)

实例化爬虫比较有意思,它不是通过普通的构造方法进行初始化,而是调用了类方法 from_crawler 进行的初始化,找到 scrapy.Spider 类:

@classmethod
def from_crawler(cls, crawler, *args, **kwargs):
    spider = cls(*args, **kwargs)
    spider._set_crawler(crawler)
    return spider
    
def _set_crawler(self, crawler):
    self.crawler = crawler
    # 把settings对象赋给spider实例
    self.settings = crawler.settings
    crawler.signals.connect(self.close, signals.spider_closed)

在这里我们可以看到,这个类方法其实也是调用了构造方法,进行实例化,同时也拿到了 settings 配置,来看构造方法干了些什么?

class Spider(object_ref):
    name = None
    custom_settings = None

    def __init__(self, name=None, **kwargs):
        # name必填
        if name is not None:
            self.name = name
        elif not getattr(self, 'name', None):
            raise ValueError("%s must have a name" % type(self).__name__)
        self.__dict__.update(kwargs)
        # 如果没有设置start_urls 默认是[]
        if not hasattr(self, 'start_urls'):
            self.start_urls = []

看到这里是不是很熟悉?这里就是我们平时编写爬虫类时,最常用的几个属性:namestart_urlscustom_settings

  • name:在运行爬虫时通过它找到我们编写的爬虫类;
  • start_urls:抓取入口,也可以叫做种子URL;
  • custom_settings:爬虫自定义配置,会覆盖配置文件中的配置项;

引 擎

分析完爬虫类的初始化后,还是回到 Crawler 的 crawl 方法,紧接着就是创建引擎对象,也就是 _create_engine 方法,看看初始化时都发生了什么?

class ExecutionEngine(object):
    """引擎"""
    def __init__(self, crawler, spider_closed_callback):
        self.crawler = crawler
        # 这里也把settings配置保存到引擎中
        self.settings = crawler.settings
        # 信号
        self.signals = crawler.signals
        # 日志格式
        self.logformatter = crawler.logformatter
        self.slot = None
        self.spider = None
        self.running = False
        self.paused = False
        # 从settings中找到Scheduler调度器,找到Scheduler类
        self.scheduler_cls = load_object(self.settings['SCHEDULER'])
        # 同样,找到Downloader下载器类
        downloader_cls = load_object(self.settings['DOWNLOADER'])
        # 实例化Downloader
        self.downloader = downloader_cls(crawler)
        # 实例化Scraper 它是引擎连接爬虫类的桥梁
        self.scraper = Scraper(crawler)
        self._spider_closed_callback = spider_closed_callback

在这里我们能看到,主要是对其他几个核心组件进行定义和初始化,主要包括包括:SchedulerDownloaderScrapyer,其中 Scheduler 只进行了类定义,没有实例化。

也就是说,引擎是整个 Scrapy 的核心大脑,它负责管理和调度这些组件,让这些组件更好地协调工作。

下面依次来看这几个核心组件都是如何初始化的?

调度器

调度器初始化发生在引擎的 open_spider 方法中,我们提前来看一下调度器的初始化。

class Scheduler(object):
	"""调度器"""
    def __init__(self, dupefilter, jobdir=None, dqclass=None, mqclass=None,
                 logunser=False, stats=None, pqclass=None):
        # 指纹过滤器
        self.df = dupefilter
        # 任务队列文件夹
        self.dqdir = self._dqdir(jobdir)
        # 优先级任务队列类
        self.pqclass = pqclass
        # 磁盘任务队列类
        self.dqclass = dqclass
        # 内存任务队列类
        self.mqclass = mqclass
        # 日志是否序列化
        self.logunser = logunser
        self.stats = stats
        
    @classmethod
    def from_crawler(cls, crawler):
        settings = crawler.settings
        # 从配置文件中获取指纹过滤器类
        dupefilter_cls = load_object(settings['DUPEFILTER_CLASS'])
        # 实例化指纹过滤器
        dupefilter = dupefilter_cls.from_settings(settings)
        # 从配置文件中依次获取优先级任务队列类、磁盘队列类、内存队列类
        pqclass = load_object(settings['SCHEDULER_PRIORITY_QUEUE'])
        dqclass = load_object(settings['SCHEDULER_DISK_QUEUE'])
        mqclass = load_object(settings['SCHEDULER_MEMORY_QUEUE'])
        # 请求日志序列化开关
        logunser = settings.getbool('LOG_UNSERIALIZABLE_REQUESTS', settings.getbool('SCHEDULER_DEBUG'))
        return cls(dupefilter, jobdir=job_dir(settings), logunser=logunser,
                   stats=crawler.stats, pqclass=pqclass, dqclass=dqclass, mqclass=mqclass)

可以看到,调度器的初始化主要做了 2 件事:

  • 实例化请求指纹过滤器:主要用来过滤重复请求;
  • 定义不同类型的任务队列:优先级任务队列、基于磁盘的任务队列、基于内存的任务队列;

请求指纹过滤器又是什么?

在配置文件中,我们可以看到定义的默认指纹过滤器是 RFPDupeFilter

class RFPDupeFilter(BaseDupeFilter):
    """请求指纹过滤器"""
    def __init__(self, path=None, debug=False):
        self.file = None
        # 指纹集合 使用的是Set 基于内存
        self.fingerprints = set()
        self.logdupes = True
        self.debug = debug
        self.logger = logging.getLogger(__name__)
        # 请求指纹可存入磁盘
        if path:
            self.file = open(os.path.join(path, 'requests.seen'), 'a+')
            self.file.seek(0)
            self.fingerprints.update(x.rstrip() for x in self.file)

    @classmethod
    def from_settings(cls, settings):
        debug = settings.getbool('DUPEFILTER_DEBUG')
        return cls(job_dir(settings), debug)

请求指纹过滤器初始化时,定义了指纹集合,这个集合使用内存实现的 Set,而且可以控制这些指纹是否存入磁盘以供下次重复使用。

也就是说,指纹过滤器的主要职责是:过滤重复请求,可自定义过滤规则。

在下篇文章中我们会介绍到,每个请求是根据什么规则生成指纹的,然后是又如何实现重复请求过滤逻辑的,这里我们先知道它的功能即可。

下面来看调度器定义的任务队列都有什么作用?

调度器默认定义了 2 种队列类型:

  • 基于磁盘的任务队列:在配置文件可配置存储路径,每次执行后会把队列任务保存到磁盘上;
  • 基于内存的任务队列:每次都在内存中执行,下次启动则消失;

配置文件默认定义如下:

# 基于磁盘的任务队列(后进先出)
SCHEDULER_DISK_QUEUE = 'scrapy.squeues.PickleLifoDiskQueue'
# 基于内存的任务队列(后进先出)
SCHEDULER_MEMORY_QUEUE = 'scrapy.squeues.LifoMemoryQueue'
# 优先级队列
SCHEDULER_PRIORITY_QUEUE = 'queuelib.PriorityQueue'

如果我们在配置文件中定义了 JOBDIR 配置项,那么每次执行爬虫时,都会把任务队列保存在磁盘中,下次启动爬虫时可以重新加载继续执行我们的任务。

如果没有定义这个配置项,那么默认使用的是内存队列。

细心的你可能会发现,默认定义的这些队列结构都是后进先出的,什么意思呢?

也就是在运行我们的爬虫代码时,如果生成一个抓取任务,放入到任务队列中,那么下次抓取就会从任务队列中先获取到这个任务,优先执行。

这么实现意味什么呢?其实意味着:Scrapy 默认的采集规则是深度优先!

如何改变这种机制,变为广度优先采集呢?这时候我们就要看一下 scrapy.squeues 模块了,在这里定义了很多种队列:

# 先进先出磁盘队列(pickle序列化)
PickleFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, \
    _pickle_serialize, pickle.loads)
# 后进先出磁盘队列(pickle序列化)
PickleLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, \
    _pickle_serialize, pickle.loads)
# 先进先出磁盘队列(marshal序列化)
MarshalFifoDiskQueue = _serializable_queue(queue.FifoDiskQueue, \
    marshal.dumps, marshal.loads)
# 后进先出磁盘队列(marshal序列化)
MarshalLifoDiskQueue = _serializable_queue(queue.LifoDiskQueue, \
    marshal.dumps, marshal.loads)
# 先进先出内存队列
FifoMemoryQueue = queue.FifoMemoryQueue
# 后进先出内存队列
LifoMemoryQueue = queue.LifoMemoryQueue

如果想把抓取任务改为广度优先,只需要在配置文件中把队列类修改为先进先出队列类就可以了!从这里也可以看出,Scrapy 各个组件之间的耦合性非常低,每个模块都是可自定义的。

如果你想探究这些队列是如何实现的,可以参考 Scrapy 作者写的 scrapy/queuelib 项目,在 Github 上就可以找到,在这里有这些队列的具体实现。

下载器

回到引擎的初始化的地方,接下来探究下载器是如何初始化的。在默认的配置文件 default_settings.py 中,下载器配置如下:

DOWNLOADER = 'scrapy.core.downloader.Downloader'

Downloader 类的初始化:

class Downloader(object):
    """下载器"""
    def __init__(self, crawler):
        # 同样的 拿到settings对象
        self.settings = crawler.settings
        self.signals = crawler.signals
        self.slots = {}
        self.active = set()
        # 初始化DownloadHandlers
        self.handlers = DownloadHandlers(crawler)
        # 从配置中获取设置的并发数
        self.total_concurrency = self.settings.getint('CONCURRENT_REQUESTS')
        # 同一域名并发数
        self.domain_concurrency = self.settings.getint('CONCURRENT_REQUESTS_PER_DOMAIN')
        # 同一IP并发数
        self.ip_concurrency = self.settings.getint('CONCURRENT_REQUESTS_PER_IP')
        # 随机延迟下载时间
        self.randomize_delay = self.settings.getbool('RANDOMIZE_DOWNLOAD_DELAY')
        # 初始化下载器中间件
        self.middleware = DownloaderMiddlewareManager.from_crawler(crawler)
        self._slot_gc_loop = task.LoopingCall(self._slot_gc)
        self._slot_gc_loop.start(60)

在这个过程中,主要是初始化了下载处理器下载器中间件管理器以及从配置文件中拿到抓取请求控制的相关参数。那么下载处理器是做什么的?下载器中间件又负责哪些工作?

先来看 DownloadHandlers

class DownloadHandlers(object):
    """下载器处理器"""
    def __init__(self, crawler):
        self._crawler = crawler
        self._schemes = {}	# 存储scheme对应的类路径 后面用于实例化
        self._handlers = {}	# 存储scheme对应的下载器
        self._notconfigured = {}
        # 从配置中找到DOWNLOAD_HANDLERS_BASE 构造下载处理器
        # 注意:这里是调用getwithbase方法  取的是配置中的XXXX_BASE配置
        handlers = without_none_values(
            crawler.settings.getwithbase('DOWNLOAD_HANDLERS'))
        # 存储scheme对应的类路径 后面用于实例化
        for scheme, clspath in six.iteritems(handlers):
            self._schemes[scheme] = clspath

        crawler.signals.connect(self._close, signals.engine_stopped)

下载处理器在默认的配置文件中是这样配置的:

# 用户可自定义的下载处理器
DOWNLOAD_HANDLERS = {}
# 默认的下载处理器
DOWNLOAD_HANDLERS_BASE = {
    'file': 'scrapy.core.downloader.handlers.file.FileDownloadHandler',
    'http': 'scrapy.core.downloader.handlers.http.HTTPDownloadHandler',
    'https': 'scrapy.core.downloader.handlers.http.HTTPDownloadHandler',
    's3': 'scrapy.core.downloader.handlers.s3.S3DownloadHandler',
    'ftp': 'scrapy.core.downloader.handlers.ftp.FTPDownloadHandler',
}

看到这里你应该能明白了,下载处理器会根据下载资源的类型,选择对应的下载器去下载资源。其中我们最常用的就是 http 和 https 对应的处理器。

但是请注意,在这里,这些下载器是没有被实例化的,只有在真正发起网络请求时,才会进行初始化,而且只会初始化一次,后面文章会讲到。

下面我们来看下载器中间件 DownloaderMiddlewareManager 初始化过程,同样地,这里又调用了类方法 from_crawler 进行初始化,而且 DownloaderMiddlewareManager 继承了MiddlewareManager 类,来看它在初始化做了哪些工作:

class MiddlewareManager(object):
    """所有中间件的父类,提供中间件公共的方法"""
    component_name = 'foo middleware'
    @classmethod
    def from_crawler(cls, crawler):
        # 调用from_settings
        return cls.from_settings(crawler.settings, crawler)
    
    @classmethod
    def from_settings(cls, settings, crawler=None):
        # 调用子类_get_mwlist_from_settings得到所有中间件类的模块
        mwlist = cls._get_mwlist_from_settings(settings)
        middlewares = []
        enabled = []
        # 依次实例化
        for clspath in mwlist:
            try:
                # 加载这些中间件模块
                mwcls = load_object(clspath)
                # 如果此中间件类定义了from_crawler 则调用此方法实例化
                if crawler and hasattr(mwcls, 'from_crawler'):
                    mw = mwcls.from_crawler(crawler)
                # 如果此中间件类定义了from_settings 则调用此方法实例化
                elif hasattr(mwcls, 'from_settings'):
                    mw = mwcls.from_settings(settings)
                # 上面2个方法都没有,则直接调用构造实例化
                else:
                    mw = mwcls()
                middlewares.append(mw)
                enabled.append(clspath)
            except NotConfigured as e:
                if e.args:
                    clsname = clspath.split('.')[-1]
                    logger.warning("Disabled %(clsname)s: %(eargs)s",
                                   {'clsname': clsname, 'eargs': e.args[0]},
                                   extra={'crawler': crawler})

        logger.info("Enabled %(componentname)ss:\n%(enabledlist)s",
                    {'componentname': cls.component_name,
                     'enabledlist': pprint.pformat(enabled)},
                    extra={'crawler': crawler})
        # 调用构造方法
        return cls(*middlewares)

    @classmethod
    def _get_mwlist_from_settings(cls, settings):
        # 具体有哪些中间件类,子类定义
        raise NotImplementedError
    
    def __init__(self, *middlewares):
        self.middlewares = middlewares
        # 定义中间件方法
        self.methods = defaultdict(list)
        for mw in middlewares:
            self._add_middleware(mw)
        
	def _add_middleware(self, mw):
        # 默认定义的 子类可覆盖
        # 如果中间件类有定义open_spider 则加入到methods
        if hasattr(mw, 'open_spider'):
            self.methods['open_spider'].append(mw.open_spider)
        # 如果中间件类有定义close_spider 则加入到methods
        # methods就是一串中间件的方法链 后期会依次调用
        if hasattr(mw, 'close_spider'):
            self.methods['close_spider'].insert(0, mw.close_spider)

DownloaderMiddlewareManager 实例化过程:

class DownloaderMiddlewareManager(MiddlewareManager):
	"""下载中间件管理器"""
    component_name = 'downloader middleware'

    @classmethod
    def _get_mwlist_from_settings(cls, settings):
        # 从配置文件DOWNLOADER_MIDDLEWARES_BASE和DOWNLOADER_MIDDLEWARES获得所有下载器中间件
        return build_component_list(
            settings.getwithbase('DOWNLOADER_MIDDLEWARES'))

    def _add_middleware(self, mw):
        # 定义下载器中间件请求、响应、异常一串方法
        if hasattr(mw, 'process_request'):
            self.methods['process_request'].append(mw.process_request)
        if hasattr(mw, 'process_response'):
            self.methods['process_response'].insert(0, mw.process_response)
        if hasattr(mw, 'process_exception'):
            self.methods['process_exception'].insert(0, mw.process_exception)

下载器中间件管理器继承了 MiddlewareManager 类,然后重写了 _add_middleware 方法,为下载行为定义默认的下载前、下载后、异常时对应的处理方法。

这里我们可以想一下,中间件这么做的好处是什么?

从这里能大概看出,从某个组件流向另一个组件时,会经过一系列中间件,每个中间件都定义了自己的处理流程,相当于一个个管道,输入时可以针对数据进行处理,然后送达到另一个组件,另一个组件处理完逻辑后,又经过这一系列中间件,这些中间件可再针对这个响应结果进行处理,最终输出。

Scraper

下载器实例化完了之后,回到引擎的初始化方法中,然后就是实例化 Scraper,这个类没有在架构图中出现,但这个类其实是处于EngineSpidersPipeline 之间,是连通这三个组件的桥梁。

我们来看一下它的初始化过程:

class Scraper(object):

    def __init__(self, crawler):
        self.slot = None
        # 实例化爬虫中间件管理器
        self.spidermw = SpiderMiddlewareManager.from_crawler(crawler)
        # 从配置文件中加载Pipeline处理器类
        itemproc_cls = load_object(crawler.settings['ITEM_PROCESSOR'])
        # 实例化Pipeline处理器
        self.itemproc = itemproc_cls.from_crawler(crawler)
        # 从配置文件中获取同时处理输出的任务个数
        self.concurrent_items = crawler.settings.getint('CONCURRENT_ITEMS')
        self.crawler = crawler
        self.signals = crawler.signals
        self.logformatter = crawler.logformatter

Scraper 创建了 SpiderMiddlewareManager,它的初始化过程:

class SpiderMiddlewareManager(MiddlewareManager):
	"""爬虫中间件管理器"""
    component_name = 'spider middleware'

    @classmethod
    def _get_mwlist_from_settings(cls, settings):
        # 从配置文件中SPIDER_MIDDLEWARES_BASE和SPIDER_MIDDLEWARES获取默认的爬虫中间件类
        return build_component_list(settings.getwithbase('SPIDER_MIDDLEWARES'))

    def _add_middleware(self, mw):
        super(SpiderMiddlewareManager, self)._add_middleware(mw)
        # 定义爬虫中间件处理方法
        if hasattr(mw, 'process_spider_input'):
            self.methods['process_spider_input'].append(mw.process_spider_input)
        if hasattr(mw, 'process_spider_output'):
            self.methods['process_spider_output'].insert(0, mw.process_spider_output)
        if hasattr(mw, 'process_spider_exception'):
            self.methods['process_spider_exception'].insert(0, mw.process_spider_exception)
        if hasattr(mw, 'process_start_requests'):
            self.methods['process_start_requests'].insert(0, mw.process_start_requests)

爬虫中间件管理器初始化与之前的下载器中间件管理器类似,先是从配置文件中加载了默认的爬虫中间件类,然后依次注册爬虫中间件的一系列流程方法。配置文件中定义的默认的爬虫中间件类如下:

SPIDER_MIDDLEWARES_BASE = {
	# 默认的爬虫中间件类
    'scrapy.spidermiddlewares.httperror.HttpErrorMiddleware': 50,
    'scrapy.spidermiddlewares.offsite.OffsiteMiddleware': 500,
    'scrapy.spidermiddlewares.referer.RefererMiddleware': 700,
    'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware': 800,
    'scrapy.spidermiddlewares.depth.DepthMiddleware': 900,
}

这里解释一下,这些默认的爬虫中间件的职责:

  • HttpErrorMiddleware:针对非 200 响应错误进行逻辑处理;
  • OffsiteMiddleware:如果Spider中定义了 allowed_domains,会自动过滤除此之外的域名请求;
  • RefererMiddleware:追加 Referer 头信息;
  • UrlLengthMiddleware:过滤 URL 长度超过限制的请求;
  • DepthMiddleware:过滤超过指定深度的抓取请求;

当然,在这里你也可以定义自己的爬虫中间件,来处理自己所需的逻辑。

爬虫中间件管理器初始化完之后,然后就是 Pipeline 组件的初始化,默认的 Pipeline 组件是 ItemPipelineManager

class ItemPipelineManager(MiddlewareManager):

    component_name = 'item pipeline'

    @classmethod
    def _get_mwlist_from_settings(cls, settings):
        # 从配置文件加载ITEM_PIPELINES_BASE和ITEM_PIPELINES类
        return build_component_list(settings.getwithbase('ITEM_PIPELINES'))

    def _add_middleware(self, pipe):
        super(ItemPipelineManager, self)._add_middleware(pipe)
        # 定义默认的pipeline处理逻辑
        if hasattr(pipe, 'process_item'):
            self.methods['process_item'].append(pipe.process_item)

    def process_item(self, item, spider):
        # 依次调用所有子类的process_item方法
        return self._process_chain('process_item', item, spider)

我们可以看到 ItemPipelineManager 也是中间件管理器的一个子类,由于它的行为非常类似于中间件,但由于功能较为独立,所以属于核心组件之一。

从 Scraper 的初始化过程我们可以看出,它管理着 Spiders 和 Pipeline 相关的数据交互。

4、核心 抓取 流程

Scrapy 最核心的抓取流程是如何运行的?它是如何调度各个组件并完成整个抓取工作的?

运行入口

回到最初的入口,在执行 Scrapy 命令时,主要经过以下几步:

  • 调用 cmdline.py 的 execute 方法
  • 找到对应的 命令实例 解析命令行
  • 构建 CrawlerProcess 实例,调用 crawl 和 start 方法开始抓取

而 crawl 方法最终是调用了 Cralwer 实例的 crawl,这个方法最终把控制权交给了Engine,而 start 方法注册好协程池,就开始异步调度执行了。

我们来看 Cralwer 的 crawl 方法:

@defer.inlineCallbacks
def crawl(self, *args, **kwargs):
    assert not self.crawling, "Crawling already taking place"
    self.crawling = True
    try:
        # 创建爬虫实例
        self.spider = self._create_spider(*args, **kwargs)
        # 创建引擎
        self.engine = self._create_engine()
        # 调用spider的start_requests 获取种子URL
        start_requests = iter(self.spider.start_requests())
        # 调用engine的open_spider 交由引擎调度
        yield self.engine.open_spider(self.spider, start_requests)
        yield defer.maybeDeferred(self.engine.start)
    except Exception:
        if six.PY2:
            exc_info = sys.exc_info()
        self.crawling = False
        if self.engine is not None:
            yield self.engine.close()
        if six.PY2:
            six.reraise(*exc_info)
        raise

这里首先会创建出爬虫实例,然后创建引擎,之后调用了 spider 的 start_requests 方法,这个方法就是我们平时写的最多爬虫类的父类,它在 spiders/__init__.py 中定义:

def start_requests(self):
    # 根据定义好的start_urls属性 生成种子URL对象
    for url in self.start_urls:
        yield self.make_requests_from_url(url)

def make_requests_from_url(self, url):
    # 构建Request对象
    return Request(url, dont_filter=True)

构建 请求

通过上面这段代码,我们能看到,平时我们必须要定义的 start_urls 属性,原来就是在这里用来构建 Request 的,来看 Request 的定义:

class Request(object_ref):

    def __init__(self, url, callback=None, method='GET', headers=None, body=None,
                 cookies=None, meta=None, encoding='utf-8', priority=0,
                 dont_filter=False, errback=None):
        # 编码
        self._encoding = encoding
        # 请求方法
        self.method = str(method).upper()
        # 设置url
        self._set_url(url)
        # 设置body
        self._set_body(body)
        assert isinstance(priority, int), "Request priority not an integer: %r" % priority
        # 优先级
        self.priority = priority
        assert callback or not errback, "Cannot use errback without a callback"
        # 回调函数
        self.callback = callback
        # 异常回调函数
        self.errback = errback
        # cookies
        self.cookies = cookies or {}
        # 构建Header
        self.headers = Headers(headers or {}, encoding=encoding)
        # 是否需要过滤
        self.dont_filter = dont_filter
		# 附加信息
        self._meta = dict(meta) if meta else None

Request 对象比较简单,就是封装了请求参数、请求方法、回调以及可附加的属性信息。当然,你也可以在子类中重写 start_requests 和 make_requests_from_url 这 2 个方法,用来自定义逻辑构建种子请求。

引擎 调度

再回到 crawl 方法,构建好种子请求对象后,调用了 engine 的 open_spider

@defer.inlineCallbacks
def open_spider(self, spider, start_requests=(), close_if_idle=True):
    assert self.has_capacity(), "No free spider slot when opening %r" % \
        spider.name
    logger.info("Spider opened", extra={'spider': spider})
    # 注册_next_request调度方法 循环调度
    nextcall = CallLaterOnce(self._next_request, spider)
    # 初始化scheduler
    scheduler = self.scheduler_cls.from_crawler(self.crawler)
    # 调用爬虫中间件 处理种子请求
    start_requests = yield self.scraper.spidermw.process_start_requests(start_requests, spider)
    # 封装Slot对象
    slot = Slot(start_requests, close_if_idle, nextcall, scheduler)
    self.slot = slot
    self.spider = spider
    # 调用scheduler的open
    yield scheduler.open(spider)
    # 调用scrapyer的open
    yield self.scraper.open_spider(spider)
    # 调用stats的open
    self.crawler.stats.open_spider(spider)
    yield self.signals.send_catch_log_deferred(signals.spider_opened, spider=spider)
    # 发起调度
    slot.nextcall.schedule()
    slot.heartbeat.start(5)

在这里首先构建了一个 CallLaterOnce,之后把 _next_request 方法注册了进去,看此类的实现:

class CallLaterOnce(object):
    # 在twisted的reactor中循环调度一个方法
    def __init__(self, func, *a, **kw):
        self._func = func
        self._a = a
        self._kw = kw
        self._call = None

    def schedule(self, delay=0):
        # 上次发起调度 才可再次继续调度
        if self._call is None:
            # 注册self到callLater中
            self._call = reactor.callLater(delay, self)

    def cancel(self):
        if self._call:
            self._call.cancel()

    def __call__(self):
        # 上面注册的是self 所以会执行__call__
        self._call = None
        return self._func(*self._a, **self._kw)

这里封装了循环执行的方法类,并且注册的方法会在 twisted 的 reactor 中异步执行,以后执行只需调用 schedule,就会注册 self 到 reactor 的 callLater 中,然后它会执行 __call__ 方法,最终执行的就是我们注册的方法。

而这里我们注册的方法就是引擎的 _next_request,也就是说,此方法会循环调度,直到程序退出。

之后调用了爬虫中间件的 process_start_requests 方法,你可以定义多个自己的爬虫中间件,每个类都重写此方法,爬虫在调度之前会分别调用你定义好的爬虫中间件,来处理初始化请求,你可以进行过滤、加工、筛选以及你想做的任何逻辑。

这样做的好处就是,把想做的逻辑拆分成多个中间件,每个中间件功能独立,而且维护起来更加清晰。

调度器

接下来就要开始调度任务了,这里首先调用了 Scheduler 的 open

def open(self, spider):
    self.spider = spider
    # 实例化优先级队列
    self.mqs = self.pqclass(self._newmq)
    # 如果定义了dqdir则实例化基于磁盘的队列
    self.dqs = self._dq() if self.dqdir else None
    # 调用请求指纹过滤器的open方法
    return self.df.open()
    
def _dq(self):
    # 实例化磁盘队列
    activef = join(self.dqdir, 'active.json')
    if exists(activef):
        with open(activef) as f:
            prios = json.load(f)
    else:
        prios = ()
    q = self.pqclass(self._newdq, startprios=prios)
    if q:
        logger.info("Resuming crawl (%(queuesize)d requests scheduled)",
                    {'queuesize': len(q)}, extra={'spider': self.spider})
    return q

在 open 方法中,调度器会实例化出优先级队列,以及根据 dqdir是否配置,决定是否使用磁盘队列,最后调用了请求指纹过滤器的 open 方法,这个方法在父类 BaseDupeFilter 中定义:

class BaseDupeFilter(object):
    # 过滤器基类,子类可重写以下方法
    @classmethod
    def from_settings(cls, settings):
        return cls()

    def request_seen(self, request):
        # 请求过滤
        return False

    def open(self):
        # 可重写 完成过滤器的初始化工作
        pass

    def close(self, reason):
        # 可重写 完成关闭过滤器工作
        pass

    def log(self, request, spider):
        pas

请求过滤器提供了请求过滤的具体实现方式,Scrapy 默认提供了 RFPDupeFilter 过滤器实现过滤重复请求的逻辑,这里先对这个类有个了解,后面会讲具体是如何过滤重复请求的。

Scraper

再之后就调用 Scraper 的 open_spider 方法,在之前的文章中我们提到过,Scraper 类是连接 EngineSpiderItem Pipeline 这 3 个组件的桥梁:

@defer.inlineCallbacks
def open_spider(self, spider):
    self.slot = Slot()
    # 调用所有pipeline的open_spider
    yield self.itemproc.open_spider(spider)

这里的主要逻辑是 Scraper 调用所有 Pipeline 的 open_spider 方法,如果我们定义了多个 Pipeline 输出类,可以重写 open_spider 完成每个 Pipeline 在输出前的初始化工作。

循环 调度

调用了一系列组件的 open 方法后,最后调用了 nextcall.schedule() 开始调度,也就是循环执行在上面注册的 _next_request 方法:

def _next_request(self, spider):
    # 此方法会循环调度
    slot = self.slot
    if not slot:
        return
    # 暂停
    if self.paused:
        return
    # 是否等待
    while not self._needs_backout(spider):
        # 从scheduler中获取request
        # 注意:第一次获取时,是没有的,也就是会break出来
        # 从而执行下面的逻辑
        if not self._next_request_from_scheduler(spider):
            break
    # 如果start_requests有数据且不需要等待
    if slot.start_requests and not self._needs_backout(spider):
        try:
            # 获取下一个种子请求
            request = next(slot.start_requests)
        except StopIteration:
            slot.start_requests = None
        except Exception:
            slot.start_requests = None
            logger.error('Error while obtaining start requests',
                         exc_info=True, extra={'spider': spider})
        else:
            # 调用crawl,实际是把request放入scheduler的队列中
            self.crawl(request, spider)
    # 空闲则关闭spider
    if self.spider_is_idle(spider) and slot.close_if_idle:
        self._spider_idle(spider)
        
def _needs_backout(self, spider):
    # 是否需要等待,取决4个条件
    # 1. Engine是否stop
    # 2. slot是否close
    # 3. downloader下载超过预设
    # 4. scraper处理response超过预设
    slot = self.slot
    return not self.running \
        or slot.closing \
        or self.downloader.needs_backout() \
        or self.scraper.slot.needs_backout()

def _next_request_from_scheduler(self, spider):
    slot = self.slot
    # 从scheduler拿出下个request
    request = slot.scheduler.next_request()
    if not request:
        return
    # 下载
    d = self._download(request, spider)
    # 注册成功、失败、出口回调方法
    d.addBoth(self._handle_downloader_output, request, spider)
    d.addErrback(lambda f: logger.info('Error while handling downloader output',
                                       exc_info=failure_to_exc_info(f),
                                       extra={'spider': spider}))
    d.addBoth(lambda _: slot.remove_request(request))
    d.addErrback(lambda f: logger.info('Error while removing request from slot',
                                       exc_info=failure_to_exc_info(f),
                                       extra={'spider': spider}))
    d.addBoth(lambda _: slot.nextcall.schedule())
    d.addErrback(lambda f: logger.info('Error while scheduling new request',
                                       exc_info=failure_to_exc_info(f),
                                       extra={'spider': spider}))
    return d
    

def crawl(self, request, spider):
    assert spider in self.open_spiders, \
        "Spider %r not opened when crawling: %s" % (spider.name, request)
    # request放入scheduler队列,调用nextcall的schedule
    self.schedule(request, spider)
    self.slot.nextcall.schedule()

def schedule(self, request, spider):
    self.signals.send_catch_log(signal=signals.request_scheduled,
            request=request, spider=spider)
    # 调用scheduler的enqueue_request,把request放入scheduler队列
    if not self.slot.scheduler.enqueue_request(request):
        self.signals.send_catch_log(signal=signals.request_dropped,
                                    request=request, spider=spider)

_next_request 方法首先调用 _needs_backout 检查是否需要等待,等待的条件有以下几种情况:

  • 引擎是否主动关闭
  • Slot是否关闭
  • 下载器在网络下载时是否超过预设参数
  • Scraper处理输出是否超过预设参数

如果不需要等待,则调用 _next_request_from_scheduler,此方法从名字上就能看出,主要是从 Schduler 中获取 Request

这里要注意,在第一次调用此方法时,Scheduler 中是没有放入任何 Request 的,这里会直接break 出来,执行下面的逻辑,而下面就会调用 crawl 方法,实际是把请求放到 Scheduler 的请求队列,放入队列的过程会经过请求过滤器校验是否重复。

下次再调用 _next_request_from_scheduler 时,就能从 Scheduler 中获取到下载请求,然后执行下载动作。

先来看第一次调度,执行 crawl

def crawl(self, request, spider):
    assert spider in self.open_spiders, \
        "Spider %r not opened when crawling: %s" % (spider.name, request)
    # 放入Scheduler队列
    self.schedule(request, spider)
    # 进行下一次调度
    self.slot.nextcall.schedule()
    
def schedule(self, request, spider):
    self.signals.send_catch_log(signal=signals.request_scheduled,
            request=request, spider=spider)
    # 放入Scheduler队列
    if not self.slot.scheduler.enqueue_request(request):
        self.signals.send_catch_log(signal=signals.request_dropped,
                                    request=request, spider=spider)

调用引擎的 crawl 实际就是把请求放入 Scheduler 的队列中,下面看请求是如何入队列的。

请求 入队

Scheduler 请求入队方法:

def enqueue_request(self, request):
    # 请求入队 若请求过滤器验证重复 返回False
    if not request.dont_filter and self.df.request_seen(request):
        self.df.log(request, self.spider)
        return False
    # 磁盘队列是否入队成功
    dqok = self._dqpush(request)
    if dqok:
        self.stats.inc_value('scheduler/enqueued/disk', spider=self.spider)
    else:
        # 没有定义磁盘队列 则使用内存队列
        self._mqpush(request)
        self.stats.inc_value('scheduler/enqueued/memory', spider=self.spider)
    self.stats.inc_value('scheduler/enqueued', spider=self.spider)
    return True
    
def _dqpush(self, request):
    # 是否定义磁盘队列
    if self.dqs is None:
        return
    try:
        # Request对象转dict
        reqd = request_to_dict(request, self.spider)
        # 放入磁盘队列
        self.dqs.push(reqd, -request.priority)
    except ValueError as e:  # non serializable request
        if self.logunser:
            msg = ("Unable to serialize request: %(request)s - reason:"
                   " %(reason)s - no more unserializable requests will be"
                   " logged (stats being collected)")
            logger.warning(msg, {'request': request, 'reason': e},
                           exc_info=True, extra={'spider': self.spider})
            self.logunser = False
        self.stats.inc_value('scheduler/unserializable',
                             spider=self.spider)
        return
    else:
        return True
    
def _mqpush(self, request):
    # 入内存队列
    self.mqs.push(request, -request.priority)

调度器主要定义了 2 种队列:基于磁盘队列、基于内存队列。如果在实例化 Scheduler 时候传入 jobdir,则使用磁盘队列,否则使用内存队列,默认使用内存队列。

指纹 过滤

上面说到,在请求入队之前,首先会通过请求指纹过滤器检查请求是否重复,也就是调用了过滤器的 request_seen

def request_seen(self, request):
    # 生成请求指纹
    fp = self.request_fingerprint(request)
    # 请求指纹如果在指纹集合中 则认为重复
    if fp in self.fingerprints:
        return True
    # 不重复则记录此指纹
    self.fingerprints.add(fp)
    # 实例化如果有path则把指纹写入文件
    if self.file:
        self.file.write(fp + os.linesep)

def request_fingerprint(self, request):
    # 调用utils.request的request_fingerprint
    return request_fingerprint(request)

utils.request 的 request_fingerprint 逻辑如下:

def request_fingerprint(request, include_headers=None):
    """生成请求指纹"""
    # 指纹生成是否包含headers
    if include_headers:
        include_headers = tuple(to_bytes(h.lower())
                                 for h in sorted(include_headers))
    cache = _fingerprint_cache.setdefault(request, {})
    if include_headers not in cache:
        # 使用sha1算法生成指纹
        fp = hashlib.sha1()
        fp.update(to_bytes(request.method))
        fp.update(to_bytes(canonicalize_url(request.url)))
        fp.update(request.body or b'')
        if include_headers:
            for hdr in include_headers:
                if hdr in request.headers:
                    fp.update(hdr)
                    for v in request.headers.getlist(hdr):
                        fp.update(v)
        cache[include_headers] = fp.hexdigest()
    return cache[include_headers]

这个过滤器先是通过 Request 对象生成一个请求指纹,在这里使用 sha1 算法,并记录到指纹集合,每次请求入队前先到这里验证一下指纹集合,如果已存在,则认为请求重复,则不会重复入队列。

不过如果我想不校验重复,也想重复爬取怎么办?看 enqueue_request 的第一行判断,仅需将 Request 实例的 dont_filter 设置为 True 就可以重复抓取此请求,非常灵活。

Scrapy 就是通过此逻辑实现重复请求的过滤,默认情况下,重复请求是不会进行重复抓取的。

下载 请求

请求第一次进来后,肯定是不重复的,那么则会正常进入调度器队列。之后下一次调度,再次调用 _next_request_from_scheduler 方法,此时调用调度器的 next_request 方法,就是从调度器队列中取出一个请求,这次就要开始进行网络下载了,也就是调用 _download

def _download(self, request, spider):
    # 下载请求
    slot = self.slot
    slot.add_request(request)
    def _on_success(response):
        # 成功回调 结果必须是Request或Response
        assert isinstance(response, (Response, Request))
        if isinstance(response, Response):
            # 如果下载后结果为Response 返回Response
            response.request = request
            logkws = self.logformatter.crawled(request, response, spider)
            logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
            self.signals.send_catch_log(signal=signals.response_received, \
                response=response, request=request, spider=spider)
        return response

    def _on_complete(_):
        # 此次下载完成后 继续进行下一次调度
        slot.nextcall.schedule()
        return _

    # 调用Downloader进行下载
    dwld = self.downloader.fetch(request, spider)
    # 注册成功回调
    dwld.addCallbacks(_on_success)
    # 结束回调
    dwld.addBoth(_on_complete)
    return dwld

在进行网络下载时,调用了 Downloader 的 fetch

def fetch(self, request, spider):
    def _deactivate(response):
        # 下载结束后删除此记录
        self.active.remove(request)
        return response
    # 下载前记录处理中的请求
    self.active.add(request)
    # 调用下载器中间件download 并注册下载成功的回调方法是self._enqueue_request
    dfd = self.middleware.download(self._enqueue_request, request, spider)
    # 注册结束回调
    return dfd.addBoth(_deactivate)

这里调用下载器中间件的 download,并注册下载成功的回调方法是 _enqueue_request,来看下载方法:

def download(self, download_func, request, spider):
    @defer.inlineCallbacks
    def process_request(request):
        # 如果下载器中间件有定义process_request 则依次执行
        for method in self.methods['process_request']:
            response = yield method(request=request, spider=spider)
            assert response is None or isinstance(response, (Response, Request)), \
                    'Middleware %s.process_request must return None, Response or Request, got %s' % \
                    (six.get_method_self(method).__class__.__name__, response.__class__.__name__)
            # 如果下载器中间件有返回值 直接返回此结果
            if response:
                defer.returnValue(response)
        # 如果下载器中间件没有返回值,则执行注册进来的方法 也就是Downloader的_enqueue_request
        defer.returnValue((yield download_func(request=request,spider=spider)))

    @defer.inlineCallbacks
    def process_response(response):
        assert response is not None, 'Received None in process_response'
        if isinstance(response, Request):
            defer.returnValue(response)

        # 如果下载器中间件有定义process_response 则依次执行
        for method in self.methods['process_response']:
            response = yield method(request=request, response=response,
                                    spider=spider)
            assert isinstance(response, (Response, Request)), \
                'Middleware %s.process_response must return Response or Request, got %s' % \
                (six.get_method_self(method).__class__.__name__, type(response))
            if isinstance(response, Request):
                defer.returnValue(response)
        defer.returnValue(response)

    @defer.inlineCallbacks
    def process_exception(_failure):
        exception = _failure.value
        # 如果下载器中间件有定义process_exception 则依次执行
        for method in self.methods['process_exception']:
            response = yield method(request=request, exception=exception,
                                    spider=spider)
            assert response is None or isinstance(response, (Response, Request)), \
                'Middleware %s.process_exception must return None, Response or Request, got %s' % \
                (six.get_method_self(method).__class__.__name__, type(response))
            if response:
                defer.returnValue(response)
        defer.returnValue(_failure)

    # 注册执行、错误、回调方法
    deferred = mustbe_deferred(process_request, request)
    deferred.addErrback(process_exception)
    deferred.addCallback(process_response)
    return deferred

在下载过程中,首先找到所有定义好的下载器中间件,包括内置定义好的,也可以自己扩展下载器中间件,下载前先依次执行 process_request,可对 Request 进行加工、处理、校验等操作,然后发起真正的网络下载,也就是第一个参数 download_func,在这里是 Downloader 的 _enqueue_request 方法:

下载成功后回调 Downloader的 _enqueue_request

def _enqueue_request(self, request, spider):
    # 加入下载请求队列
    key, slot = self._get_slot(request, spider)
    request.meta['download_slot'] = key

    def _deactivate(response):
        slot.active.remove(request)
        return response

    slot.active.add(request)
    deferred = defer.Deferred().addBoth(_deactivate)
    # 下载队列
    slot.queue.append((request, deferred))
    # 处理下载队列
    self._process_queue(spider, slot)
    return deferred
    
def _process_queue(self, spider, slot):
    if slot.latercall and slot.latercall.active():
        return

    # 如果延迟下载参数有配置 则延迟处理队列
    now = time()
    delay = slot.download_delay()
    if delay:
        penalty = delay - now + slot.lastseen
        if penalty > 0:
            slot.latercall = reactor.callLater(penalty, self._process_queue, spider, slot)
            return

    # 处理下载队列
    while slot.queue and slot.free_transfer_slots() > 0:
        slot.lastseen = now
        # 从下载队列中取出下载请求
        request, deferred = slot.queue.popleft()
        # 开始下载
        dfd = self._download(slot, request, spider)
        dfd.chainDeferred(deferred)
        # 延迟
        if delay:
            self._process_queue(spider, slot)
            break
            
def _download(self, slot, request, spider):
    # 注册方法 调用handlers的download_request
    dfd = mustbe_deferred(self.handlers.download_request, request, spider)

    # 注册下载完成回调方法
    def _downloaded(response):
        self.signals.send_catch_log(signal=signals.response_downloaded,
                                    response=response,
                                    request=request,
                                    spider=spider)
        return response
    dfd.addCallback(_downloaded)

    slot.transferring.add(request)

    def finish_transferring(_):
        slot.transferring.remove(request)
        # 下载完成后调用_process_queue
        self._process_queue(spider, slot)
        return _

    return dfd.addBoth(finish_transferring)

这里也维护了一个下载队列,可根据配置达到延迟下载的要求。真正发起下载请求是调用了 self.handlers.download_request

def download_request(self, request, spider):
    # 获取请求的scheme
    scheme = urlparse_cached(request).scheme
    # 根据scheeme获取下载处理器
    handler = self._get_handler(scheme)
    if not handler:
        raise NotSupported("Unsupported URL scheme '%s': %s" %
                           (scheme, self._notconfigured[scheme]))
    # 开始下载 并返回结果
    return handler.download_request(request, spider)
    
def _get_handler(self, scheme):
    # 根据scheme获取对应的下载处理器
    # 配置文件中定义好了http、https、ftp等资源的下载处理器
    if scheme in self._handlers:
        return self._handlers[scheme]
    if scheme in self._notconfigured:
        return None
    if scheme not in self._schemes:
        self._notconfigured[scheme] = 'no handler available for that scheme'
        return None

    path = self._schemes[scheme]
    try:
        # 实例化下载处理器
        dhcls = load_object(path)
        dh = dhcls(self._crawler.settings)
    except NotConfigured as ex:
        self._notconfigured[scheme] = str(ex)
        return None
    except Exception as ex:
        logger.error('Loading "%(clspath)s" for scheme "%(scheme)s"',
                     {"clspath": path, "scheme": scheme},
                     exc_info=True,  extra={'crawler': self._crawler})
        self._notconfigured[scheme] = str(ex)
        return None
    else:
        self._handlers[scheme] = dh
    return self._handlers[scheme]

下载前,先通过解析 request 的 scheme 来获取对应的下载处理器,默认配置文件中定义的下载处理器如下:

DOWNLOAD_HANDLERS_BASE = {
    'file': 'scrapy.core.downloader.handlers.file.FileDownloadHandler',
    'http': 'scrapy.core.downloader.handlers.http.HTTPDownloadHandler',
    'https': 'scrapy.core.downloader.handlers.http.HTTPDownloadHandler',
    's3': 'scrapy.core.downloader.handlers.s3.S3DownloadHandler',
    'ftp': 'scrapy.core.downloader.handlers.ftp.FTPDownloadHandler',
}

然后调用 download_request 方法,完成网络下载,这里不再详细讲解每个处理器的实现,简单来说,你可以把它想象成封装好的网络下载库,输入URL,它会给你输出下载结果,这样方便理解。

在下载过程中,如果发生异常情况,则会依次调用下载器中间件的 process_exception 方法,每个中间件只需定义自己的异常处理逻辑即可。

如果下载成功,则会依次执行下载器中间件的 process_response 方法,每个中间件可以进一步处理下载后的结果,最终返回。

这里值得提一下,process_request 方法是每个中间件顺序执行的,而 process_response 和 process_exception 方法是每个中间件倒序执行的,具体可看一下 DownaloderMiddlewareManager 的 _add_middleware 方法,就可以明白是如何注册这个方法链的。

拿到最终的下载结果后,再回到 ExecuteEngine 的 _next_request_from_scheduler 中,会看到调用了 _handle_downloader_output,也就是处理下载结果的逻辑:

def _handle_downloader_output(self, response, request, spider):
    # 下载结果必须是Request、Response、Failure其一
    assert isinstance(response, (Request, Response, Failure)), response
    # 如果是Request 则再次调用crawl 执行Scheduler的入队逻辑
    if isinstance(response, Request):
        self.crawl(response, spider)
        return
    # 如果是Response或Failure 则调用scraper的enqueue_scrape进一步处理
    # 主要是和Spiders和Pipeline交互
    d = self.scraper.enqueue_scrape(response, request, spider)
    d.addErrback(lambda f: logger.error('Error while enqueuing downloader output',
                                        exc_info=failure_to_exc_info(f),
                                        extra={'spider': spider}))
    return d

拿到下载结果后,主要分 2 个逻辑:

  • 如果返回的是 Request 实例,则直接再次放入 Scheduler 请求队列
  • 如果返回的是是 Response 或 Failure 实例,则调用 Scraper 的 enqueue_scrape 方法,做进一步处理

处理 下载结果

请求入队逻辑不用再说,前面已经讲过。现在主要看 Scraper 的 enqueue_scrape,看Scraper 组件是如何处理后续逻辑的:

def enqueue_scrape(self, response, request, spider):
    # 加入Scrape处理队列
    slot = self.slot
    dfd = slot.add_response_request(response, request)
    def finish_scraping(_):
        slot.finish_response(response, request)
        self._check_if_closing(spider, slot)
        self._scrape_next(spider, slot)
        return _
    dfd.addBoth(finish_scraping)
    dfd.addErrback(
        lambda f: logger.error('Scraper bug processing %(request)s',
                               {'request': request},
                               exc_info=failure_to_exc_info(f),
                               extra={'spider': spider}))
    self._scrape_next(spider, slot)
    return dfd

def _scrape_next(self, spider, slot):
    while slot.queue:
        # 从Scraper队列中获取一个待处理的任务
        response, request, deferred = slot.next_response_request_deferred()
        self._scrape(response, request, spider).chainDeferred(deferred)

def _scrape(self, response, request, spider):
    assert isinstance(response, (Response, Failure))
    # 调用_scrape2继续处理
    dfd = self._scrape2(response, request, spider)
    # 注册异常回调
    dfd.addErrback(self.handle_spider_error, request, response, spider)
    # 出口回调
    dfd.addCallback(self.handle_spider_output, request, response, spider)
    return dfd

def _scrape2(self, request_result, request, spider):
    # 如果结果不是Failure实例 则调用爬虫中间件管理器的scrape_response
    if not isinstance(request_result, Failure):
        return self.spidermw.scrape_response(
            self.call_spider, request_result, request, spider)
    else:
        # 直接调用call_spider
        dfd = self.call_spider(request_result, request, spider)
        return dfd.addErrback(
            self._log_download_errors, request_result, request, spider)

首先把请求和响应加入到 Scraper 的处理队列中,然后从队列中获取到任务,如果不是异常结果,则调用爬虫中间件管理器的 scrape_response 方法:

def scrape_response(self, scrape_func, response, request, spider):
    fname = lambda f:'%s.%s' % (
            six.get_method_self(f).__class__.__name__,
            six.get_method_function(f).__name__)

    def process_spider_input(response):
        # 执行一系列爬虫中间件的process_spider_input
        for method in self.methods['process_spider_input']:
            try:
                result = method(response=response, spider=spider)
                assert result is None, \
                        'Middleware %s must returns None or ' \
                        'raise an exception, got %s ' \
                        % (fname(method), type(result))
            except:
                return scrape_func(Failure(), request, spider)
        # 执行完中间件的一系列process_spider_input方法后 执行call_spider
        return scrape_func(response, request, spider)

    def process_spider_exception(_failure):
        # 执行一系列爬虫中间件的process_spider_exception
        exception = _failure.value
        for method in self.methods['process_spider_exception']:
            result = method(response=response, exception=exception, spider=spider)
            assert result is None or _isiterable(result), \
                'Middleware %s must returns None, or an iterable object, got %s ' % \
                (fname(method), type(result))
            if result is not None:
                return result
        return _failure

    def process_spider_output(result):
        # 执行一系列爬虫中间件的process_spider_output
        for method in self.methods['process_spider_output']:
            result = method(response=response, result=result, spider=spider)
            assert _isiterable(result), \
                'Middleware %s must returns an iterable object, got %s ' % \
                (fname(method), type(result))
        return result

    # 执行process_spider_input
    dfd = mustbe_deferred(process_spider_input, response)
    # 注册异常回调
    dfd.addErrback(process_spider_exception)
    # 注册出口回调
    dfd.addCallback(process_spider_output)
    return dfd

有没有感觉套路很熟悉?与上面下载器中间件调用方式非常相似,也调用一系列的前置方法,再执行真正的处理逻辑,最后执行一系列的后置方法。

回调 自定义爬虫

接下来看一下,Scrapy 是如何执行我们写好的爬虫逻辑的,也就是 call_spider 方法,这里回调我们写好的爬虫类:

def call_spider(self, result, request, spider):
    # 回调爬虫模块
    result.request = request
    dfd = defer_result(result)
    # 注册回调方法 取得request.callback 如果未定义则调用爬虫模块的parse方法
    dfd.addCallbacks(request.callback or spider.parse, request.errback)
    return dfd.addCallback(iterate_spider_output)

看到这里,你应该更熟悉,平时我们写的最多的爬虫代码,parse 则是第一个回调方法。之后爬虫类拿到下载结果,就可以定义下载后的 callback 方法,也是在这里进行回调执行的。

处理 输出

在与爬虫类交互完成之后,Scraper 调用了 handle_spider_output 方法处理爬虫的输出结果:

def handle_spider_output(self, result, request, response, spider):
    # 处理爬虫输出结果
    if not result:
        return defer_succeed(None)
    it = iter_errback(result, self.handle_spider_error, request, response, spider)
    # 注册_process_spidermw_output
    dfd = parallel(it, self.concurrent_items,
        self._process_spidermw_output, request, response, spider)
    return dfd

def _process_spidermw_output(self, output, request, response, spider):
    # 处理Spider模块返回的每一个Request/Item
    if isinstance(output, Request):
        # 如果结果是Request 再次入Scheduler的请求队列
        self.crawler.engine.crawl(request=output, spider=spider)
    elif isinstance(output, (BaseItem, dict)):
        # 如果结果是BaseItem/dict
        self.slot.itemproc_size += 1
        # 调用Pipeline的process_item
        dfd = self.itemproc.process_item(output, spider)
        dfd.addBoth(self._itemproc_finished, output, response, spider)
        return dfd
    elif output is None:
        pass
    else:
        typename = type(output).__name__
        logger.error('Spider must return Request, BaseItem, dict or None, '
                     'got %(typename)r in %(request)s',
                     {'request': request, 'typename': typename},
                     extra={'spider': spider})

执行完我们自定义的解析逻辑后,解析方法可返回新的 Request 或 BaseItem 实例。

如果是新的请求,则再次通过 Scheduler 进入请求队列,如果是 BaseItem 实例,则调用 Pipeline 管理器,依次执行 process_item。我们想输出结果时,只需要定义 Pepeline 类,然后重写这个方法就可以了。

ItemPipeManager 处理逻辑:

class ItemPipelineManager(MiddlewareManager):

    component_name = 'item pipeline'

    @classmethod
    def _get_mwlist_from_settings(cls, settings):
        return build_component_list(settings.getwithbase('ITEM_PIPELINES'))

    def _add_middleware(self, pipe):
        super(ItemPipelineManager, self)._add_middleware(pipe)
        if hasattr(pipe, 'process_item'):
            self.methods['process_item'].append(pipe.process_item)

    def process_item(self, item, spider):
        # 依次调用Pipeline的process_item
        return self._process_chain('process_item', item, spider)

可以看到 ItemPipeManager 也是一个中间件,和之前下载器中间件管理器和爬虫中间件管理器类似,如果子类有定义 process_item,则依次执行它。

执行完之后,调用 _itemproc_finished

def _itemproc_finished(self, output, item, response, spider):
    self.slot.itemproc_size -= 1
    if isinstance(output, Failure):
        ex = output.value
        # 如果在Pipeline处理中抛DropItem异常 忽略处理结果
        if isinstance(ex, DropItem):
            logkws = self.logformatter.dropped(item, ex, response, spider)
            logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
            return self.signals.send_catch_log_deferred(
                signal=signals.item_dropped, item=item, response=response,
                spider=spider, exception=output.value)
        else:
            logger.error('Error processing %(item)s', {'item': item},
                         exc_info=failure_to_exc_info(output),
                         extra={'spider': spider})
    else:
        logkws = self.logformatter.scraped(output, response, spider)
        logger.log(*logformatter_adapter(logkws), extra={'spider': spider})
        return self.signals.send_catch_log_deferred(
            signal=signals.item_scraped, item=output, response=response,
            spider=spider)

这里可以看到,如果想在 Pipeline 中丢弃某个结果,直接抛出 DropItem 异常即可,Scrapy 会进行对应的处理。

到这里,抓取结果会根据自定义的输出类,然后输出到指定位置,而新的 Request 则会再次进入请求队列,等待引擎下一次调度,也就是再次调用 ExecutionEngine 的 _next_request,直至请求队列没有新的任务,整个程序退出。

CrawlerSpider

以上,基本上整个核心抓取流程就讲完了。

简单说一下 CrawlerSpider 类,我们平时用的也比较多,它其实就是继承了 Spider 类,然后重写了 parse 方法(这也是继承此类不要重写此方法的原因),并结合 Rule 规则类,来完成 Request 的自动提取逻辑。

Scrapy 提供了这个类方便我们更快速地编写爬虫代码,我们也可以基于此类进行再次封装,让我们的爬虫代码写得更简单。

由此我们也可看出,Scrapy 的每个模块的实现都非常纯粹,每个组件都通过配置文件定义连接起来,如果想要扩展或替换,只需定义并实现自己的处理逻辑即可,其他模块均不受任何影响,所以我们也可以看到,业界有非常多的 Scrapy 插件,都是通过此机制来实现的。

总  结

如果能把这块逻辑搞清楚了,那对 Scrapy 开发新的插件,或者在它的基础上进行二次开发也非常简单了。总结一下整个抓取流程,还是用这两张图表示再清楚不过:

Scrapy 虽然只是个单机版的爬虫框架,但可以非常方便地编写插件,或者自定义组件替换默认的功能,从而定制化自己的爬虫,最终可以实现一个功能强大的爬虫框架,例如分布式、代理调度、并发控制、可视化、监控等功能,它的灵活度非常高。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值