Scrapy-实用的命令行工具实现方法

其实这篇文章是scrapy源码学习的(一),加载器那篇才是(二)

scrapy的命令行工具

本文环境:

  • wind7 64bits
  • python 3.7
  • scrapy 1.5.1

scrapy拥有非常灵活的低耦合的命令行工具,如果自己想要重新实现覆盖掉scrapy自带的命令也是可以的。
使用它的命令行工具可以大致分为两种情况:

  • 在创建的project路径下
  • 不在project路径下

先看下不在scrapy项目路径下的命令行有哪些:

Scrapy 1.5.1 - no active project

Usage:
  scrapy <command> [options] [args]

Available commands:
  bench         Run quick benchmark test
  fetch         Fetch a URL using the Scrapy downloader
  genspider     Generate new spider using pre-defined templates
  runspider     Run a self-contained spider (without creating a project)
  settings      Get settings values
  shell         Interactive scraping console
  startproject  Create new project
  version       Print Scrapy version
  view          Open URL in browser, as seen by Scrapy

  [ more ]      More commands available when run from project directory

Use "scrapy <command> -h" to see more info about a command

在项目路径下的命令行新增了check、crawl、edit、list、parse这些命令,具体:

Scrapy 1.5.1 - project: myspider01

Usage:
  scrapy <command> [options] [args]

Available commands:
  bench         Run quick benchmark test
  check         Check spider contracts
  crawl         Run a spider
  edit          Edit spider
  fetch         Fetch a URL using the Scrapy downloader
  genspider     Generate new spider using pre-defined templates
  list          List available spiders
  parse         Parse URL (using its spider) and print the results
  runspider     Run a self-contained spider (without creating a project)
  settings      Get settings values
  shell         Interactive scraping console
  startproject  Create new project
  version       Print Scrapy version
  view          Open URL in browser, as seen by Scrapy

Use "scrapy <command> -h" to see more info about a command

也即是说scrapy可以根据当前路径是否是scrapy项目路径来判断提供可用的命令给用户。

创建一个scrapy项目

在当前路径下创建一个scrapy项目,DOS下输入:

scrapy startproject myproject

可以查看刚刚创建的项目myproject的目录结构:

├── scrapy.cfg                         //scrapy项目配置文件
├── myproject
    ├── spiders                     // 爬虫脚本目录
        ├── __init__.py   
    ├── __init__.py 
    ├── items.py 
    ├── middlewares.py 
    ├── pipelines.py 
    ├── settings.py                  // 项目设置

可以断定,在我们使用"startproject"这个scrapy命令时,scrapy会把一些项目默认模板拷贝到我们创建项目的路径下,从而生成我们看到的类似上面的目录结构。我们可以打开scrapy的包,看看这些模板在哪个地方。切换至scrapy的安装路径(比如:..Python37Libsite-packagesscrapy),可以看到路径下有templates文件夹,而此文件夹下的project文件夹便是创建项目时拷贝的默认模板存放目录。
那么scrapy是怎么实现类似“startproject”这样的命令的呢?

打开scrapy源码

找到入口

scrapy是使用命令行来启动脚本的(当然也可以调用入口函数来启动),查看其命令行实现流程必须先找到命令行实行的入口点,这个从其安装文件setup.py中找到。
打开setup.py 找到entry_points:

...
  entry_points={
        'console_scripts': ['scrapy = scrapy.cmdline:execute']
    },
...

可以看到scrapy开头的命令皆由模块scrapy.cmdline的execute函数作为入口函数。

分析入口函数

先浏览一下execute函数源码,这里只贴主要部分:

def execute(argv=None, settings=None):
    if argv is None:
        argv = sys.argv

    ...

    #主要部分:获取当前项目的设置
    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

    #检查提醒已不被支持的设置项目
    check_deprecated_settings(settings)

    ...

    #主要部分:判断是否在项目路径下,加载可见命令,解析命令参数
    inproject = inside_project()
    cmds = _get_commands_dict(settings, inproject)
    cmdname = _pop_command_name(argv)
    parser = optparse.OptionParser(formatter=optparse.TitledHelpFormatter(), \
        conflict_handler='resolve')
    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.usage = "scrapy %s %s" % (cmdname, cmd.syntax())
    parser.description = cmd.long_desc()
    settings.setdict(cmd.default_settings, priority='command')
    cmd.settings = settings
    cmd.add_options(parser)
    opts, args = parser.parse_args(args=argv[1:])
    _run_print_help(parser, cmd.process_options, args, opts)
    cmd.crawler_process = CrawlerProcess(settings)
    _run_print_help(parser, _run_command, cmd, args, opts)
    sys.exit(cmd.exitcode)

阅读cmdline.py的execute函数,大概了解了命令行实现的基本流程:
Scrapy-实用的命令行工具实现方法

1.获取命令参数

命令参数的获取可以通过两种方式传递:
第一种是调用execute,比如:

from scrapy.cmdline import execute
execute(argv=['scrapy','startproject','myproject','-a','xxxx'])

这样就相当于第二种方式:命令控制台执行

scrapy startproject myproject -a xxxx

传递的参数都是

['scrapy','startproject','myproject','-a','xxxx']
2.获取scrapy项目配置

如果当前不是调用的方式传递settings给execute入口,而是一般的命令控制台启动scrapy,那么scrapy会在当前路径下搜索加载可能存在的项目配置文件。主要是通过函数get_project_settings执行。

ENVVAR = 'SCRAPY_SETTINGS_MODULE'

def get_project_settings():
    #获取配置
    if ENVVAR not in os.environ:
        #初始化获取项目的default级配置,即是scrapy生成的默认配置
        project = os.environ.get('SCRAPY_PROJECT', 'default')
        #初始化项目环境,设置系统环境变量SCRAPY_SETTINGS_MODULE的值为配置模块路径
        init_env(project)

    settings = Settings()
    settings_module_path = os.environ.get(ENVVAR)
    if settings_module_path:
        settings.setmodule(settings_module_path, priority='project')

    ...

    return settings

获取的配置文件主要是scrapy.cfg,我们可以看下他的内容:

[settings]
default = myproject.settings
[deploy]
#url = http://localhost:6800/
project = myproject

在生成项目myproject的时候,这个配置文件就已经指定了项目设置模块的路径"myproject.settings",所以上面的get_project_settings函数获取便是配置文件settings字段中的default键值,然后导入该设置模块来生成配置。具体实现在init_env函数中。

def init_env(project='default', set_syspath=True):
    """在当前项目路径下初始化项目环境. 并且通过配置系统环境来让python能够定位配置模块
    """
    #在项目路径下进入命令行,才能准确获取配置
    #获取可能存在scrapy.cfg配置文件的模块路径
    cfg = get_config()
    #获取到配置文件后设置系统环境变量SCRAPY_SETTINGS_MODULE为配置模块路径,
    #如: myproject.settings,默认项目级别均为default,即是配置文件字段settings中的键
    if cfg.has_option('settings', project):
        os.environ['SCRAPY_SETTINGS_MODULE'] = cfg.get('settings', project)
    #将最近的scrapy.cfg模块路径放入系统路径使Python能够找到该模块导入
    closest = closest_scrapy_cfg()
    if closest:
        projdir = os.path.dirname(closest)
        if set_syspath and projdir not in sys.path:
        #加入项目设置模块路径到系统路径让Python能够定位到
            sys.path.append(projdir)

def get_config(use_closest=True):
    """
    SafeConfigParser.read(filenames)
    尝试解析文件列表,如果解析成功返回文件列表。如果filenames是string或Unicode string,
    将会按单个文件来解析。如果在filenames中的文件不能打开,该文件将被忽略。这样设计的目的是,
    让你能指定本地有可能是配置文件的列表(例如,当前文件夹,用户的根目录,及一些全系统目录),
    所以在列表中存在的配置文件都会被读取。"""
    sources = get_sources(use_closest)
    cfg = SafeConfigParser()
    cfg.read(sources)
    return cfg

def get_sources(use_closest=True):
    '''先获取用户的根目录,及一些全系统目录下的有scrapy.cfg的路径加入sources
    最后如果使用最靠近当前路径的scrapy.cfg的标志use_closest为True时加入该scrapy.cfg路径'''
    xdg_config_home = os.environ.get('XDG_CONFIG_HOME') or \
        os.path.expanduser('~/.config')
    sources = ['/etc/scrapy.cfg', r'c:\scrapy\scrapy.cfg',
               xdg_config_home + '/scrapy.cfg',
               os.path.expanduser('~/.scrapy.cfg')]
    if use_closest:
        sources.append(closest_scrapy_cfg())
    return sources

def closest_scrapy_cfg(path='.', prevpath=None):
    """
    搜索最靠近当前当前路径的scrapy.cfg配置文件并返回其路径。
    搜索会按照当前路径-->父路径的递归方式进行,到达顶层没有结果则返回‘’
    """
    if path == prevpath:
        return ''
    path = os.path.abspath(path)
    cfgfile = os.path.join(path, 'scrapy.cfg')
    if os.path.exists(cfgfile):
        return cfgfile
    return closest_scrapy_cfg(os.path.dirname(path), path)

通过init_env来设置os.environ['SCRAPY_SETTINGS_MODULE']的值,这样的话

#将项目配置模块路径设置进系统环境变量
os.environ['SCRAPY_SETTINGS_MODULE'] = 'myproject.settings'

初始化后返回到原先的get_project_settings,生成一个设置类Settings实例,然后再将设置模块加载进实例中完成项目配置的获取这一动作。

3.判断是否在scrapy项目路径下

判断当前路径是否是scrapy项目路径,其实很简单,因为前面已经初始化过settings,如果在项目路径下,那么
os.environ['SCRAPY_SETTINGS_MODULE']的值就已经被设置了,现在只需要判断这个值是否存在便可以判断是否在项目路径下。具体实现在inside_project函数中实现:

def inside_project():
    scrapy_module = os.environ.get('SCRAPY_SETTINGS_MODULE')
    if scrapy_module is not None:
        try:
            import_module(scrapy_module)
        except ImportError as exc:
            warnings.warn("Cannot import scrapy settings module %s: %s" % (scrapy_module, exc))
        else:
            return True
    return bool(closest_scrapy_cfg())
4.获取命令集合,命令解析

知道了当前是否在项目路径下,还有初始化了项目配置,这个时候就可以获取到在当前路径下能够使用的命令行有哪些了。
获取当前可用命令集合比较简单,直接加载模块scrapy.commands下的所有命令行类,判断是否需要在项目路径下才能使用该命令,是的话直接实例化加入一个字典(格式:<命令名称>:<命令实例>)返回,具体实现通过_get_commands_dict:

def _get_commands_dict(settings, inproject):
    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 _get_commands_from_module(module, inproject):
    d = {}
    for cmd in _iter_command_classes(module):
        #判断是否需要先创建一个项目才能使用该命令,
        #即目前是否位于项目路径下(inproject)的可用命令有哪些,不是的有哪些
        if inproject or not cmd.requires_project:
            cmdname = cmd.__module__.split('.')[-1]
            #获取该命令名称并实例化 加入返回字典
            #返回{<命令名称>:<命令实例>}
            d[cmdname] = cmd()
    return d 

def _iter_command_classes(module_name):
    #获取scrapy.commands下所有模块文件中属于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__ and \
                    not obj == ScrapyCommand:
                yield obj

其中判断是否是命令类的关键在于该命令模块中的命令类是否继承了命令基类ScrapyCommand,只要继承了该基类就可以被检测到。这有点类似接口的作用,ScrapyCommand基类其实就是一个标识类(该类比较简单,可以查看基类代码)。而该基类中有一个requires_project标识,标识是否需要在scrapy项目路径下才能使用该命令,判断该值就可以获得当前可用命令。
获取到了可用命令集合,接下来会加载Python自带的命令行解析模块optparser.OptionParser的命令行参数解析器,通过实例化获取该parser,传入当前命令实例的add_options属性方法中来加载当前命令实例附加的解析命令,如:-a xxx, -p xxx, --dir xxx 之类的类似Unix命令行的命令。这些都是通过parser来实现解析。

5.判断当前命令是否可用

其实在加载解析器之前,会去判断当前的用户输入命令是否是合法的,是不是可用的,如果可用会接下去解析执行该命令,不可用便打印出相关的帮助提示。比如:

Usage
=====
  scrapy startproject <project_name> [project_dir]

Create new project

Options
=======
--help, -h              show this help message and exit

Global Options
--------------
--logfile=FILE          log file. if omitted stderr will be used
--loglevel=LEVEL, -L LEVEL
                        log level (default: DEBUG)
--nolog                 disable logging completely
--profile=FILE          write python cProfile stats to FILE
--pidfile=FILE          write process ID to FILE
--set=NAME=VALUE, -s NAME=VALUE
                        set/override setting (may be repeated)
--pdb                   enable pdb on failure

至此,scrapy命令行工具的实现流程基本结束。

学习点

scrapy的命令行工具实现了低耦合,需要删减增加哪个命令行只需要在scrapy.commands模块中修改增删就可以实现。但是实现的关键在于该模块下的每一个命令行类都得继承ScrapyCommand这个基类,这样在导入的时候才能有所判断,所以我说ScrapyCommand是个标识类。基于标识类来实现模块的低耦合。

下一篇将会记录根据借鉴scrapy命令行工具实现方法来实现自己的命令行

相关推荐