scrapy 实践的一些要素

我对 scrapy 的认识,理解,运用,经过了从不屑到趁手工具的心态变化过程。

最开始自己直接使用 python 和网络请求库、html 解析库和正则等工具,也可以写出一些小爬虫,这些爬虫都是定点抓取某个网站的某些结构化数据。在开发,调试时,全部是自己从零开始编写。后面发现,其实最耗时的部分是网络请求的处理,日志记录,数据库存储,反而分析网站的机构化数据在整个爬虫程序中,占的比重较小,带来的后果就是,我花了大部分的时间,去处理我本来可以不用自己处理的任务。

最开始使用 scrapy 时,是有恐惧和不屑夹杂的情绪的。我写过抓取某个网站的一些数据,还要先装你的库,还要 startproject,还要编写 items,spiders,感觉很复杂;我用 python 配合 requests 库,几行代码就搞定了。这就是不屑。当网站复杂后,或者要抓取的网站增多了后,自己维护非业务核心代码,变得越来越困难,重复的代码也越来越多,急需对业务模型和代码进行抽象,然后复用已有的代码,而这个过程耗时良多,且不一定达到效果。在写了非常多的爬虫脚本后,深刻的理解到了工程化的意义,也对 scrapy 这样的爬虫框架有了更多的需求上的认识,在偶然的工作中,使用 scrapy 来处理大量的网站的复杂内容,对 scrapy 的运用从最开始的官方的手册上的简单例子,一下子就深入进去了,在那次工作中,接触到了对 scrapy 进行二次开发,深度的使用 scrapy 来满足自己的开发需求的代码。我大长见识,原来还可以这么用。一下子就深入进去,更细致的研究学习。

要解决的问题的方法

这里的内容,部分是设计思路上的,部分是工程实践方法上的。

控制下载频率

这里主要是使用 AUTOTHROTTLE 的功能,通过子类化默认的延迟功能,让指定的域名的网站在抓取时,可以自动的变更抓去频率,这样,就达到了避免被对方网站屏蔽的功能。具体的配置和实现如下所示:

1、settings.py 中启用对应的功能,并且配置上对应的网站域名,启用对应的客户化的 extensions

settings.py 文件中要启用 AUTOTHROTTLE_ENABLED

1
2
3
4
5
6
7
8
9
10
AUTOTHROTTLE_ENABLED = True
DELAY_DOMAINS = {
"www.xyz.com": 2,
"www.abc.com": 2,
}
EXTENSIONS = {
'govwebsite.extensions.AutoDelay': 300,
}

上面中 DELAY_DOMAINS 是自定义的字典名,用来记录控制哪些域名的链接需要使用这个延迟规则。EXTENSIONS 中配置的就是自己编写的控制是否启用延迟规则的程序。

2、编写上一步配置的 AutoDelay 类

extensions.py 文件,这个是随便写的名字,但是此处取这个名字,是为了符合 scrapy 的命名规则,也是方便理解这个文件中的代码是做什么的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python
#-*-coding:utf-8-*-
from scrapy.extensions.throttle import AutoThrottle
import re
class AutoDelay(AutoThrottle):
def __init__(self, crawler):
self.delay_domains = crawler.settings.getdict('DELAY_DOMAINS')
print self.delay_domains
super(AutoDelay, self).__init__(crawler)
def _adjust_delay(self, slot, latency, response):
domain = re.search('http://([^/]+).*', response.url).group(1)
if domain in self.delay_domains:
super(AutoDelay, self)._adjust_delay(slot, latency, response)
else:
slot.delay = 0.0

上面的代码中,就是很清楚的展示延迟的具体实现。

数据存储到数据库中

要想将数据存储到数据库中,需要做两件事,一是编写对应的 pipeline,这方面 scrapy 已经有很详细的说明文档,按照说明编写即可。一般来说,要实现以下几个方法,分别是 __init__, from_crawler, open_spider, close_spider, process_item,只要要有的两个方法是 __init__, process_item;另外一个就是要在 settings.py 文件中配置上对应的 pipeline 配置。

1
2
3
ITEM_PIPELINES = {
'govwebsite.pipelines.NewsPipeline': 300,
}

下面分别展示了两个例子,一个是使用 MySQL 作为存储,以 torndb 作为数据库连接库的一个实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# mysql db, use sql
# -*- coding: utf-8 -*-
# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html
import sql_conn
class MySQLPipeline(object):
def __init__(self, host, port, database, user, passwd):
self.host = host
self.port = port
self.database = database
self.user = user
self.passwd = passwd
@classmethod
def from_crawler(cls, crawler):
return cls(
host = crawler.settings.get('DB_HOST'),
port = crawler.settings.get('DB_PORT'),
database = crawler.settings.get('DATABASE'),
user = crawler.settings.get('DB_USER'),
passwd = crawler.settings.get('DB_PASSWD')
)
def open_spider(self, spider):
self.db = sql_conn.Connection(
host = self.host + ':' + str(self.port),
database = self.database,
user = self.user,
password = self.passwd
)
def close_spider(self, spider):
self.db.close()
def process_item(self, item, spider):
table_name = item['dbtable']
self.db.insert_by_dict(table_name, item, True)
return item

在这里,引入的 sql_conn 就是第三方的数据库操作库,直接编写 sql 语句进行数据库的操作。

上面的方式,对于数据库永远是使用 MySQL 的是非常好用,并且没问题的,但是如果数据库要更换,或者运行环境要进行迁移,那么上面的方式就有点麻烦了,一个是要重新建表,另外一个就是可能 sql 语句都不兼容了,这样导致的问题就是,程序失去了灵活性,部署也麻烦。所以,考虑使用 sqlalchemy 和 scrapy 的结合,让 sqlalchemy 来处理具体的数据库操作细节。
要达到这个目标,需要在 settings.py 里面配置上数据库相关内容:

1
2
3
4
5
6
7
8
DATABASE = {
'drivername': 'postgres',
'host': 'localhost',
'port': '5432',
'username': 'axel',
'password': '123456',
'database': 'chinaelites'
}

同时定义数据库表的模型,写在 models.py 中,这里是抓去新闻的一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env python
#-*-coding:utf-8-*-
from sqlalchemy import create_engine, Column, Integer, String, Text
from sqlalchemy.engine.url import URL
from sqlalchemy.ext.declarative import declarative_base
import settings
DeclarativeBase = declarative_base()
def db_connect():
return create_engine(URL(**settings.DATABASE))
def create_news_table(engine):
DeclarativeBase.metadata.create_all(engine)
class News(DeclarativeBase):
__tablename__ = "govweb_raw"
id = Column(Integer, primary_key=True)
city_id = Column(String)
news_url = Column(String)
url_md5 = Column(String, unique=True)
news_location = Column(String)
news_title = Column(String)
publish_time = Column(String)
news_content = Column(Text)
created = Column(Integer)

然后是 pipeline 的编写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# -*- coding: utf-8 -*-
# Define your item pipelines here
#
# Don't forget to add your pipeline to the ITEM_PIPELINES setting
# See: http://doc.scrapy.org/en/latest/topics/item-pipeline.html
from sqlalchemy.orm import sessionmaker
from models import News, db_connect, create_news_table
class NewsPipeline(object):
def __init__(self):
engine = db_connect()
create_news_table(engine)
self.Session = sessionmaker(bind=engine)
def process_item(self, item, spider):
session = self.Session()
news = News(**item)
try:
if self.url_exist(session, News, item):
session.close()
return item
session.add(news)
session.commit()
except Exception as e:
print 'pipelines', e
session.rollback()
raise
finally:
session.close()
return item
def url_exist(self, session, Sobj, item):
url = session.query(Sobj).filter_by(url_md5=item['url_md5']).first()
if url:
return True
else:
return False

通过上面三步,就达到了使用 sqlalchemy 来操作存储数据的目的,这里实用的数据库是 postgresql,如果使用 mysql,也是基本一致。

爬虫定制的一些细节

默认情况下,起始链接是顶一下 start_urls 这个列表中的,但是,好多时候,需要从其他地方加载起始链接,比如从文件中获取起始链接。

这个时候,需要在自己的 spider 里面定义一个 start_requests 方法,即可达到目的,比如下面这样的:

1
2
3
4
def start_requests(self):
urls = self.get_start_urls()
for url in urls:
yield scrapy.Request(url, self.parse)

这里,具体的实现是在自己定义的 get_start_urls 中获取的,返回一个列表即可。

对于 scrapy 来说,需要一个定义一个 parse 方法,这个方法是被 scrapy 在下载了指定链接后,要回调的方法,而编写爬虫时,主要处理的下载数据也是从这里开始的,可以提取指定内容,也可以提取链接,根据新获取的链接,发送更多的网络请求给 scrapy 处理等。

一般来说,链接包含的内容分为两大类:1、列表页和下一页;2、详情内容页;

所以,我们的程序要能够区分出来这两类链接的内容,并且能够根据对应的链接,回调对应的处理方法。同时,为了增强可控性,scrapy 有一个 meta 属性,可以传递额外的信息给请求的链接,方便后续处理。

所以,一般来说,会有如下类似的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def parse(self, response):
url = response.url
next_page_url, detail_url_list = self.get_more_urls(response)
if next_page_url:
req = scrapy.Request(next_page_url, callback=self.parse)
req.meta['something'] = 'something'
yield req
for detail_url in detail_url_list:
req = scrapy.Request(detail_url, callback=self.parse_detail_info)
req.meta['something'] = 'something'
yield req

在上面的代码中,下一页和详情页调用的了不同的回调函数,效果就是,进行了不同的处理。
要想增加更多的控制,就在 meta 信息里面添加,而在对应的回调函数中,获取到了 response 后,可以通过 response.meta[‘key’] 来读取内容。

在程序中运行爬虫

一般情况下,是通过命令行来执行 scrapy 爬虫的,比如 scrapy crawl spider_name,但是很多时候,需要定时执行爬虫程序,那么就需要在程序中调度运行爬虫。下面是一个例子代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env python
#-*-coding:utf-8-*-
import logging
import sys
import argparse
from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings
from scrapy.utils.log import configure_logging
from govwebsite.spiders.news_spider import NewsSpider
configure_logging(install_root_handler=False)
logging.basicConfig(
filename='/tmp/scrapy_spider.log',
format="%(levelname)s %(asctime)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
level=logging.INFO
)
def run(run_mode='full', debug=False):
settings = get_project_settings()
settings.set('RUN_MODE', run_mode, 'project')
settings.set('DEBUG', debug, 'project')
crawler_process = CrawlerProcess(settings)
crawler_process.crawl(NewsSpider)
crawler_process.start()
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument('-d', '--debug', default='N', help=u'设置是否按照 debug 模式运行,debug 模式下,只会将对应网站抓去一条新闻,可选值 y/n')
parser.add_argument('-m', '--mode', default='full', help=u'运行模式,可以选 full 或 update')
args = parser.parse_args()
return args
if __name__ == "__main__":
args = parse_args()
debug = args.debug
if debug.lower() == 'y':
debug = True
else:
debug = False
mode = args.mode
run(mode, debug)

在上面的例子中,有对运行时的配置项进行修改的地方,settings.set('DEBUG', debug, 'project') 这种就是对默认的写在 settings.py 中的覆盖(如果存在),或者新增加一个配置,而在具体的爬虫中,可以使用 self.settings.get('DEBUG') 来读取出来对应的配置,以做程序运行控制。

多个网站的开发实践

当有多个网站/链接需要在一个爬虫中实现时(提取的数据内容可能是一样的),在开发、运行时,要面对的问题主要有:

  1. 提取规则问题。不同的网站结构一般是不同的,需要对每个网站写一套规则;
  2. 因为不同的网站规则不同,所以需要将具体的提取信息的规则单独写,而将运行提取规则的代码进行抽象统一;
  3. 调试问题。一般来说,网站都会有下一页和本页列表页的问题,在调试时,一般抽取下一页成功,抽取详情页的信息成功,这个是调试时需要的,后续才是对这个网站的全部运行,以达到全部抓取的目的;
  4. 不同网站的调试问题。要方便的指定对某个网站的进行调试或者抓取;
  5. 全量、增量抓取问题。第一次运行全部抓取,后续根据网站的更新频率,进行增量抓取,这样即节约了时间和带宽,也减小被对方网站屏蔽的可能性;

以上 5 个问题就是开发同一个爬虫,多个网站数据来源时遇到和需要解决的问题;我通过工作中的实践和自己的摸索,总结了以下的实践方法:

  1. 对不同的网站进行编号,编号可以是数字。比如 1001、1002,也可以是网站域名之类的,比如 baidu、google 等。这样的编号,要求对网站的识别具有唯一性,后续的很多功能都依赖于此。
  2. scrapy 的运行依赖于用户指定的起始链接,根据这个起始链接和后续的抽取规则,就可以控制 scrapy 抓取更多的内容。所以,上面步骤的编号要和对应的起始链接对应起来。这里还要考虑的一个问题是,某个网站的起始链接可能有多个,所以一般使用两种数据结构来保存这个对应的规则。首先使用一个字典保存所有的 编号: 起始链接 规则,如果起始链接只有一个,那么就直接是字符串保存了;如果起始链接有多个,这里我一般采用的是列表进行保存。在具体的抽取起始链接时,对相应的起始链接的类型进行判断,然后采用不同的添加规则。
  3. 假设上一步保存所有的 编号: 起始链接 的文件叫 starturls.py 的,那么这里我们就可以实现只对指定的网站进行抓取了。在运行爬虫时,通过传递的参数,指定对应网站编号;在解析提取起始链接时,检查编号参数,如果有明确指定,就只提取这一个网站的规则;如果没有特别指定,就提取所有的网站规则。通过这里的逻辑,就达到了单个网站爬取的控制。
  4. 调试时,想要只提取下一页和一个详情页的信息,这样可以检查提取规则是否正确。只需要在运行时,指定了调试模式,然后在 parse() 中,根据调试参数,决定是否循环调用以便让 scrapy 不停的运行下去,这样就实现了单个页面的调试规则。
  5. 当有多个网站的多个规则时,在下载了一个页面后,如何确定该使用哪个规则,从哪里导入对应的规则呢?这里就需要做更多的控制,我的方法是,不同的网站的规则写在单独的以网站编号为文件名的文件中,在实际运行时,当获取到了 html 数据时,检查这个 html 数据对应的网站 url 是哪个,根据网站的 url 取寻找对应的网站规则文件名,导入这个规则,进行后续的信息提取。
  6. 全量抓取时无需特别控制,只需要迭代到网站没有下一页或者更多未抓取链接时,程序自己就停止了,增量抓取,我一般采用的是预估网站更新频率,指定全部/对应网站的翻页次数,这样的话,就每次只要你更新的页数多,就可以保证一直是增量更新的。
其他细节

比如增量抓取,全量抓取;提取元素相同,但是不同网站的规则不同,编写对应网站的规则,从文件中载入规则等等,这些都是实际开发中遇到的一些细节问题,这些问题在后面会逐步展开,这也是我喜欢用 scrapy 的原因,可以将自己的精力集中在业务上。