爬虫的两部分,一是下载 Web 页面,有许多问题须要考虑,如何最大程度地利用本地带宽,如何调度针对不一样站点的 Web 请求以减轻对方服务器的负担等。一个高性能的 Web Crawler 系统里,DNS 查询也会成为急需优化的瓶颈,另外,还有一些“行规”须要遵循(例如 robots.txt)。而获取了网页以后的分析过程也是很是复杂的,Internet 上的东西千奇百怪,各类错误百出的 HTML 页面都有,要想所有分析清楚几乎是不可能的事;另外,随着 AJAX 的流行,如何获取由 Javascript 动态生成的内容成了一大难题;除此以外,Internet 上还有有各类有意或无心出现的Spider Trap ,若是盲目的跟踪超连接的话,就会陷入 Trap 中万劫不复了,例如这个网站,听说是以前 Google 宣称 Internet 上的 Unique URL 数目已经达到了 1 trillion 个,所以这我的 is proud to announce the second trillion 。 html
不过,其实并无多少人须要作像 Google 那样通用的 Crawler ,一般咱们作一个 Crawler 就是为了去爬特定的某个或者某一类网站,所谓知己知彼,百战不殆,咱们能够事先对须要爬的网站结构作一些分析,事情就变得容易多了。经过分析,选出有价值的连接进行跟踪,就能够避免不少没必要要的连接或者 Spider Trap ,若是网站的结构容许选择一个合适的路径的话,咱们能够按照必定顺序把感兴趣的东西爬一遍,这样以来,连 URL 重复的判断也能够省去。python
举个例子,假如咱们想把 pongba 的 blog mindhacks.cn 里面的 blog 文字爬下来,经过观察,很容易发现咱们对其中的两种页面感兴趣:linux
- 文章列表页面,例如首页,或者 URL 是
/page/\d+/
这样的页面,经过 Firebug 能够看到到每篇文章的连接都是在一个h1
下的a
标签里的(须要注意的是,在 Firebug 的 HTML 面板里看到的 HTML 代码和 View Source 所看到的也许会有些出入,若是网页中有 Javascript 动态修改 DOM 树的话,前者是被修改过的版本,而且通过 Firebug 规则化的,例如 attribute 都有引号扩起来等,然后者一般才是你的 spider 爬到的原始内容。若是是使用正则表达式对页面进行分析或者所用的 HTML Parser 和 Firefox 的有些出入的话,须要特别注意),另外,在一个 class 为wp-pagenavi
的div
里有到不一样列表页面的连接。 - 文章内容页面,每篇 blog 有这样一个页面,例如 /2008/09/11/machine-learning-and-ai-resources/ ,包含了完整的文章内容,这是咱们感兴趣的内容。
所以,咱们从首页开始,经过 wp-pagenavi
里的连接来获得其余的文章列表页面,特别地,咱们定义一个路径:只 follow Next Page 的连接,这样就能够从头至尾按顺序走一遍,免去了须要判断重复抓取的烦恼。另外,文章列表页面的那些到具体文章的连接所对应的页面就是咱们真正要保存的数据页面了。正则表达式
这样以来,其实用脚本语言写一个 ad hoc 的 Crawler 来完成这个任务也并不难,不过今天的主角是 Scrapy ,这是一个用 Python 写的 Crawler Framework ,简单轻巧,而且很是方便,而且官网上说已经在实际生产中在使用了,所以并非一个玩具级别的东西。不过如今尚未 Release 版本,能够直接使用他们的 Mercurial 仓库里抓取源码进行安装。不过,这个东西也能够不安装直接使用,这样还方便随时更新,文档里说得很详细,我就不重复了。sql
Scrapy 使用 Twisted 这个异步网络库来处理网络通信,架构清晰,而且包含了各类中间件接口,能够灵活的完成各类需求。总体架构如下图所示:shell
绿线是数据流向,首先从初始 URL 开始,Scheduler 会将其交给 Downloader 进行下载,下载以后会交给 Spider 进行分析,Spider 分析出来的结果有两种:一种是须要进一步抓取的连接,例如以前分析的“下一页”的连接,这些东西会被传回 Scheduler ;另外一种是须要保存的数据,它们则被送到 Item Pipeline 那里,那是对数据进行后期处理(详细分析、过滤、存储等)的地方。另外,在数据流动的通道里还能够安装各类中间件,进行必要的处理。数据库
看起来好像很复杂,其实用起来很简单,就如同 Rails 同样,首先新建一个工程:服务器
scrapy-admin.py startproject blog_crawl |
会建立一个 blog_crawl
目录,里面有个 scrapy-ctl.py
是整个项目的控制脚本,而代码全都放在子目录 blog_crawl
里面。为了能抓取 mindhacks.cn ,咱们在 spiders
目录里新建一个mindhacks_spider.py
,定义咱们的 Spider 以下:网络
from scrapy.spider import BaseSpider class MindhacksSpider(BaseSpider): domain_name = "mindhacks.cn" start_urls = ["http://mindhacks.cn/"] def parse(self, response): return [] SPIDER = MindhacksSpider() |
咱们的 MindhacksSpider
继承自 BaseSpider
(一般直接继承自功能更丰富的scrapy.contrib.spiders.CrawlSpider
要方便一些,不过为了展现数据是如何 parse 的,这里仍是使用 BaseSpider
了),变量 domain_name
和 start_urls
都很容易明白是什么意思,而 parse
方法是咱们须要定义的回调函数,默认的 request 获得 response 以后会调用这个回调函数,咱们须要在这里对页面进行解析,返回两种结果(须要进一步 crawl 的连接和须要保存的数据),让我感受有些奇怪的是,它的接口定义里这两种结果居然是混杂在一个 list 里返回的,不太清楚这里为什么这样设计,难道最后不仍是要费力把它们分开?总之这里咱们先写一个空函数,只返回一个空列表。另外,定义一个“全局”变量 SPIDER
,它会在 Scrapy 导入这个 module 的时候实例化,并自动被 Scrapy 的引擎找到。这样就能够先运行一下 crawler 试试了:架构
./scrapy-ctl.py crawl mindhacks.cn |
会有一堆输出,能够看到抓取了 http://mindhacks.cn
,由于这是初始 URL ,可是因为咱们在 parse
函数里没有返回须要进一步抓取的 URL ,所以整个 crawl 过程只抓取了主页便结束了。接下来即是要对页面进行分析,Scrapy 提供了一个很方便的 Shell (须要 IPython )可让咱们作实验,用以下命令启动 Shell :
./scrapy-ctl.py shell http://mindhacks.cn |
它会启动 crawler ,把命令行指定的这个页面抓取下来,而后进入 shell ,根据提示,咱们有许多现成的变量能够用,其中一个就是 hxs
,它是一个 HtmlXPathSelector
,mindhacks 的 HTML 页面比较规范,能够很方便的直接用 XPath 进行分析。经过 Firebug 能够看到,到每篇 blog 文章的连接都是在 h1
下的,所以在 Shell 中使用这样的 XPath 表达式测试:
In [1]: hxs.x('//h1/a/@href').extract() Out[1]: [u'http://mindhacks.cn/2009/07/06/why-you-should-do-it-yourself/', u'http://mindhacks.cn/2009/05/17/seven-years-in-nju/', u'http://mindhacks.cn/2009/03/28/effective-learning-and-memorization/', u'http://mindhacks.cn/2009/03/15/preconception-explained/', u'http://mindhacks.cn/2009/03/09/first-principles-of-programming/', u'http://mindhacks.cn/2009/02/15/why-you-should-start-blogging-now/', u'http://mindhacks.cn/2009/02/09/writing-is-better-thinking/', u'http://mindhacks.cn/2009/02/07/better-explained-conflicts-in-intimate-relationship/', u'http://mindhacks.cn/2009/02/07/independence-day/', u'http://mindhacks.cn/2009/01/18/escape-from-your-shawshank-part1/'] |
这正是咱们须要的 URL ,另外,还能够找到“下一页”的连接所在,连同其余几个页面的连接一同在一个 div
里,不过“下一页”的连接没有 title
属性,所以 XPath 写做
//div[@class="wp-pagenavi"]/a[not(@title)] |
不过若是向后翻一页的话,会发现其实“上一页”也是这样的,所以还须要判断该连接上的文字是那个下一页的箭头 u'\xbb'
,原本也能够写到 XPath 里面去,可是好像这个自己是 unicode escape 字符,因为编码缘由理不清楚,直接放到外面判断了,最终 parse
函数以下:
def parse(self, response): items = [] hxs = HtmlXPathSelector(response) posts = hxs.x('//h1/a/@href').extract() items.extend([self.make_requests_from_url(url).replace(callback=self.parse_post) for url in posts]) page_links = hxs.x('//div[@class="wp-pagenavi"]/a[not(@title)]') for link in page_links: if link.x('text()').extract()[0] == u'\xbb': url = link.x('@href').extract()[0] items.append(self.make_requests_from_url(url)) return items |
前半部分是解析须要抓取的 blog 正文的连接,后半部分则是给出“下一页”的连接。须要注意的是,这里返回的列表里并非一个个的字符串格式的 URL 就完了,Scrapy 但愿获得的是Request
对象,这比一个字符串格式的 URL 能携带更多的东西,诸如 Cookie 或者回调函数之类的。能够看到咱们在建立 blog 正文的 Request
的时候替换掉了回调函数,由于默认的这个回调函数 parse
是专门用来解析文章列表这样的页面的,而 parse_post
定义以下:
def parse_post(self, response): item = BlogCrawlItem() item.url = unicode(response.url) item.raw = response.body_as_unicode() return [item] |
很简单,返回一个 BlogCrawlItem
,把抓到的数据放在里面,原本能够在这里作一点解析,例如,经过 XPath 把正文和标题等解析出来,可是我倾向于后面再来作这些事情,例如 Item Pipeline 或者更后面的 Offline 阶段。BlogCrawlItem
是 Scrapy 自动帮咱们定义好的一个继承自ScrapedItem
的空类,在 items.py
中,这里我加了一点东西:
from scrapy.item import ScrapedItem class BlogCrawlItem(ScrapedItem): def __init__(self): ScrapedItem.__init__(self) self.url = '' def __str__(self): return 'BlogCrawlItem(url: %s)' % self.url |
定义了 __str__
函数,只给出 URL ,由于默认的 __str__
函数会把全部的数据都显示出来,所以会看到 crawl 的时候控制台 log 狂输出东西,那是把抓取到的网页内容输出出来了。-.-bb
这样一来,数据就取到了,最后只剩下存储数据的功能,咱们经过添加一个 Pipeline 来实现,因为 Python 在标准库里自带了 Sqlite3 的支持,因此我使用 Sqlite 数据库来存储数据。用以下代码替换 pipelines.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 29 30 31 32 33 34 35 36 37 |
import sqlite3 from os import path from scrapy.core import signals from scrapy.xlib.pydispatch import dispatcher class SQLiteStorePipeline(object): filename = 'data.sqlite' def __init__(self): self.conn = None dispatcher.connect(self.initialize, signals.engine_started) dispatcher.connect(self.finalize, signals.engine_stopped) def process_item(self, domain, item): self.conn.execute('insert into blog values(?,?,?)', (item.url, item.raw, unicode(domain))) return item def initialize(self): if path.exists(self.filename): self.conn = sqlite3.connect(self.filename) else: self.conn = self.create_table(self.filename) def finalize(self): if self.conn is not None: self.conn.commit() self.conn.close() self.conn = None def create_table(self, filename): conn = sqlite3.connect(filename) conn.execute("""create table blog (url text primary key, raw text, domain text)""") conn.commit() return conn |
在 __init__
函数中,使用 dispatcher 将两个信号链接到指定的函数上,分别用于初始化和关闭数据库链接(在 close
以前记得 commit
,彷佛是不会自动 commit
的,直接 close
的话好像全部的数据都丢失了 dd-.-)。当有数据通过 pipeline 的时候,process_item
函数会被调用,在这里咱们直接讲原始数据存储到数据库中,不做任何处理。若是须要的话,能够添加额外的 pipeline ,对数据进行提取、过滤等,这里就不细说了。
最后,在 settings.py
里列出咱们的 pipeline :
ITEM_PIPELINES = ['blog_crawl.pipelines.SQLiteStorePipeline'] |
再跑一下 crawler ,就 OK 啦! 最后,总结一下:一个高质量的 crawler 是极其复杂的工程,可是若是有好的工具的话,作一个专用的 crawler 仍是比较容易的。Scrapy 是一个很轻便的爬虫框架,极大地简化了 crawler 开发的过程。另外,Scrapy 的文档也是十分详细的,若是以为个人介绍省略了一些东西不太清楚的话,推荐看他的 Tutorial 。