当前位置: 首页 > 新闻 > 信息荟萃
编号:2716
用Python写网络爬虫.pdf
http://www.100md.com 2020年2月7日
第1页
第6页
第13页
第23页
第43页
第195页

    参见附件(7403KB,261页)。

     用Python写网络爬虫是作家Richard Lawson写的关于python的书籍,讲述了怎样通过pthon进行简单地编程语言,写出网络爬虫,爬取网络数据,并有案例分析。

    用Python写网络爬虫内容简介

    作为一种便捷地收集网上信息并从中抽取出可用信息的方式,网络爬虫技术变得越来越有用。使用Python这样的简单编程语言,你可以使用少量编程技能就可以爬取复杂的网站。 《用Python写网络爬虫》作为使用Python来爬取网络数据的杰出指南,讲解了从静态页面爬取数据的方法以及使用缓存来管理服务器负载的方法。此外,本书还介绍了如何使用AJAX URL和Firebug扩展来爬取数据,以及有关爬取技术的更多真相,比如使用浏览器渲染、管理cookie、通过提交表单从受验证码保护的复杂网站中抽取数据等。本书使用Scrapy创建了一个高级网络爬虫,并对一些真实的网站进行了爬取。

    用Python写网络爬虫作者资料

    Richard Lawson来自澳大利亚,毕业于墨尔本大学计算机科学专业。毕业后,他创办了一家专注于网络爬虫的公司,为超过50个国家的业务提供远程工作。他精通于世界语,可以使用汉语和韩语对话,并且积极投身于开源软件。他目前在牛津大学攻读研究生学位,并利用业余时间研发自主无人机。

    用Python写网络爬虫精彩内容

    无论如何,当你抓取某个网站的数据时,请记住自己是该网站的访客,应当约束自己的抓取行为,否则他们可能会封禁你的IP,甚至采取更进一步的法律行动。这就要求请求的速度需要限定在一个合理值之内,并且还需要设定一个专属的用户代理来标识自己。在下面的小节中我们将会对这些实践进行具体介绍。

    用Python写网络爬虫截图

    书名:用Python写网络爬虫

    ISBN:978-7-115-43179-0

    本书由人民邮电出版社发行数字版。版权所

    有,侵权必究。

    您购买的人民邮电出版社电子书仅供您个人使

    用,未经授权,不得以任何方式复制和传播本书内

    容。

    我们愿意相信读者具有这样的良知和觉悟,与

    我们共同保护知识产权。

    如果购买者有侵权行为,我们可能对该用户实

    施包括但不限于关闭该帐号等维权措施,并可能追

    究法律责任。

    著 [澳] Richard Lawson

    译 李 斌 责任编辑 傅道坤

    人民邮电出版社出版发行 北京市丰台

    区成寿寺路11号

    邮编 100164 电子邮件

    315@ptpress.com.cn

    网址 http:www.ptpress.com.cn

    读者服务热线:(010)81055410

    反盗版热线:(010)81055315版权声明

    Copyright ? 2015 Packt Publishing. First

    published in the English language under the title Web

    Scraping with Python.

    All Rights Reserved.

    本书由英国Packt Publishing公司授权人民邮电

    出版社出版。未经出版者书面许可,对本书的任何

    部分不得以任何方式或任何手段复制和传播。

    版权所有,侵权必究。内容提要

    本书讲解了如何使用Python来编写网络爬虫程

    序,内容包括网络爬虫简介,从页面中抓取数据的

    三种方法,提取缓存中的数据,使用多个线程和进

    程来进行并发抓取,如何抓取动态页面中的内容,与表单进行交互,处理页面中的验证码问题,以及

    使用Scarpy和Portia来进行数据抓取,并在最后使用

    本书介绍的数据抓取技术对几个真实的网站进行了

    抓取,旨在帮助读者活学活用书中介绍的技术。

    本书适合有一定Python编程经验,而且对爬虫

    技术感兴趣的读者阅读。关于作者

    Richard Lawson来自澳大利亚,毕业于墨尔本

    大学计算机科学专业。毕业后,他创办了一家专注

    于网络爬虫的公司,为超过50个国家的业务提供远

    程工作。他精通于世界语,可以使用汉语和韩语对

    话,并且积极投身于开源软件。他目前在牛津大学

    攻读研究生学位,并利用业余时间研发自主无人

    机。

    我要感谢Timothy Baldwin教授将我引入这

    个令人兴奋的领域,以及本书编写时在巴黎招

    待我的Tharavy Douc。关于审稿人

    Martin Burch是一名常驻纽约的数据记者,其

    工作是为华尔街日报绘制交互式图表。他在新墨西

    哥州立大学获得了新闻学和信息系统专业的学士学

    位,然后在纽约城市大学新闻学研究院获得了新闻

    学专业硕士学位。

    我要感谢我的妻子Lisa鼓励我协助本书的

    创作,我的叔叔Michael耐心解答我的编程问

    题,以及我的父亲Richard激发了我对新闻学和

    写作的热爱。

    William Sankey是一位数据专业人士,也是一

    位业余开发人员,生活在马里兰州科利奇帕克市。

    他于2012年毕业于约翰?霍普金斯大学,获得了公

    共政策硕士学位,专业方向为定量分析。他目前在

    LM政策研究有限责任公司担任健康服务研究员,从事与美国医疗保险和医疗补助服务中心(CMS)

    相关的项目。这些项目包括责任医疗机构评估以及

    精神病院住院患者预付费系统监测。我要感谢我深爱的妻子Julia和顽皮的小猫

    Ruby,给予我全部的爱和支持。

    Ayush Tiwari是一名Python开发者,本科就读

    于印度理工学院罗克分校。他自2013年起工作于印

    度理工学院罗克分校信息管理小组,并活跃于网络

    开发领域。对他而言,审阅本书是一个非常棒的经

    历。他不仅是一名审稿人,也是一名狂热的网络爬

    虫学习者。他向所有Python爱好者推荐本书,以便

    享受爬虫的益处。 他热衷于Python网络爬虫,曾参

    与体育直播订阅、通用Python电子商务网络爬虫

    (在Miranj)等相关项目。 他还使用Django应用开

    发了就业门户,帮助改善印度理工学院罗克分校的

    就业流程。 除了后端开发之外,他还喜欢使用诸如

    NumPy、SciPy等Python库进行科学计算和数据分

    析,目前他从事计算流体力学领域的研究。你可以

    在GitHub上访问到他的项目,他的用户名是

    tiwariayush。 他喜欢徒步穿越喜马拉雅山谷,每年

    会参加多次徒步行走活动。此外,他还喜欢弹吉

    他。他的成就还包括参加国际知名的Super 30小

    组,并在其中成为排名保持者。他在高中时,还参

    加了国际奥林匹克数学竞赛。我的家庭成员(我的姐姐Aditi、我的父母以及

    Anand先生)、我在VI和IMG的朋友以及我的教授

    都为我提供了很大的帮助。我要感谢他们所有人对

    我的支持。

    最后,感谢尊敬的作者和Packt出版社团队出版

    了这些非常好的技术书籍。我要对他们在编写这些

    书籍时的所有辛苦工作表示赞赏。前言

    互联网包含了迄今为止最有用的数据集,并且

    大部分可以免费公开访问。但是,这些数据难以复

    用。它们被嵌入在网站的结构和样式当中,需要抽

    取出来才能使用。从网页中抽取数据的过程又被称

    为网络爬虫。随着越来越多的信息被发布到网络

    上,网络爬虫也变得越来越有用。阅读本书的前提

    本书中所有的代码都已经在Python 2.7环境中

    进行过测试,并且可以从

    http:bitbucket.orgwswpcode下载到这些源代

    码。理想情况下,本书未来的版本会将示例代码移

    植到Python 3当中。不过,现在依赖的很多库(比

    如ScrapyTwisted、Mechanize和Ghost)还只支持

    Python 2。为了帮助阐明爬取示例,我们创建了一

    个示例网站,其网址为http:example.

    webscraping.com。由于该网站限制了下载内容的

    速度,因此如果你希望自行搭建示例网站,可以从

    http:bitbucket.orgwswpplaces获取网站源代

    码和安装说明。

    我们决定为本书中使用的大部分示例搭建一个

    定制网站,而不是抓取活跃网站,这样我们就对环

    境拥有了完全控制。这种方式为我们提供了稳定

    性,因为活跃网站要比书中的定制网站更新更加频

    繁,并且当你尝试运行爬虫示例时,代码可能已经

    无法工作。另外,定制网站允许我们自定义示例,用于阐释特定技巧并避免其他干扰。最后,活跃网

    站可能并不欢迎我们使用它作为学习网络爬虫的对

    象,并且可能会尝试封禁我们的爬虫。使用我们自己定制的网站可以规避这些风险,不过在这些例子

    中学到的技巧确实也可以应用到这些活跃网站当

    中。本书读者

    阅读本书需要有一定的编程经验,并且不适用

    于绝对的初学者。在实践中,我们将会首先实现我

    们自己的网络爬虫技术版本,然后才会介绍现有的

    流行模块,这样可以让你更好地理解这些技术是如

    何工作的。本书中的这些示例将假设你已经拥有

    Python语言以及使用pip安装模块的能力。如果你

    想复习一下这些知识,有一本非常好的免费在线书

    籍可以使用,其作者为Mark Pilgrim,书籍网址

    是http:www.diveintopython.net。这本书也是

    我初学Python时所使用的资源。

    此外,这些例子还假设你已经了解网页是如何

    使用HTML进行构建并通过JavaScript更新的知识。

    关于HTTP、CSS、AJAX、WebKit以及MongoDB的

    既有知识也很有用,不过它们不是必需的,这些技

    术会在需要使用时进行介绍。上述很多主题的详细

    参考资料可以从http:www.w3schools.com获取

    到。第1章 网络爬虫简介

    本章中,我们将会介绍如下主题:

    网络爬虫领域简介;

    解释合法性质疑;

    对目标网站进行背景调研;

    逐步完善一个高级网络爬虫。1.1 网络爬虫何时有用

    假设我有一个鞋店,并且想要及时了解竞争对

    手的价格。我可以每天访问他们的网站,与我店铺

    中鞋子的价格进行对比。但是,如果我店铺中的鞋

    类品种繁多,或是希望能够更加频繁地查看价格变

    化的话,就需要花费大量的时间,甚至难以实现。

    再举一个例子,我看中了一双鞋,想等它促销时再

    购买。我可能需要每天访问这家鞋店的网站来查看

    这双鞋是否降价,也许需要等待几个月的时间,我

    才能如愿盼到这双鞋促销。上述这 两个重复性的手

    工流程,都可以利用本书介绍的网络爬虫技术实现

    自动化处理。

    理想状态下,网络爬虫并不是必须品,每个网

    站都应该提供API,以结构化的格式共享它们的数

    据。然而现实情况中,虽然一些网站已经提供了这

    种API,但是它们通常会限制可以抓取的数据,以

    及访问这些数据的频率。另外,对于网站的开发者

    而言,维护前端界面比维护后端API接口优先级更

    高。总之,我们不能仅仅依赖于API去访问我们所

    需的在线数据,而是应该学习一些网络爬虫技术的

    相关知识。1.2 网络爬虫是否合法

    网络爬虫目前还处于早期的蛮荒阶段,“允许

    哪些行为”这种基本秩序还处于建设之中。从目前

    的实践来看,如果抓取数据的行为用于个人使用,则不存在问题;而如果数据用于转载,那么抓取的

    数据类型就非常关键了。

    世界各地法院的一些案件可以帮助我们确定哪

    些网络爬虫行为是允许的。在Feist Publications,Inc.起诉Rural Telephone Service Co.的案件中,美国

    联邦最高法院裁定抓取并转载真实数据(比如,电

    话清单)是允许的。而在澳大利亚,Telstra

    Corporation Limited起诉Phone Directories Company

    Pty Ltd这一类似案件中,则裁定只有拥有明确作者

    的数据,才可以获得版权。此外,在欧盟的ofir.dk

    起诉home.dk一案中,最终裁定定期抓取和深度链

    接是允许的。

    这些案件告诉我们,当抓取的数据是现实生活

    中的真实数据(比如,营业地址、电话清单)时,是允许转载的。但是,如果是原创数据(比如,意

    见和评论),通常就会受到版权限制,而不能转

    载。无论如何,当你抓取某个网站的数据时,请记

    住自己是该网站的访客,应当约束自己的抓取行

    为,否则他们可能会封禁你的IP,甚至采取更进一

    步的法律行动。这就要求下载请求的速度需要限定

    在一个合理值之内,并且还需要设定一个专属的用

    户代理来标识自己。在下面的小节中我们将会对这

    些实践进行具体介绍。

    关于上述几个法律案件的更多信息可以参考下述地址:

    http:caselaw.lp.findlaw.comscriptsgetcase. pl?

    court=USvol=499invol=340

    http:www.austlii.edu.auaucasescthFCA201044.html

    http:www.bvhd.dkuploadstx_mocarticlesS_og_Handelsrettens_afg_relse_i_Ofir-

    sagen.pdf1.3 背景调研

    在深入讨论爬取一个网站之前,我们首先需要

    对目标站点的规模和结构进行一定程度的了解。网

    站自身的robots.txt和Sitemap文件都可以为我们

    提供一定的帮助,此外还有一些能提供更详细信息

    的外部工具,比如Google搜索和WHOIS。

    1.3.1 检查robots.txt

    大多数网站都会定义robots.txt文件,这样可

    以让爬虫了解爬取该网站时存在哪些限制。这些限

    制虽然仅仅作为建议给出,但是良好的网络公民都

    应当遵守这些限制。在爬取之前,检查robots.txt

    文件这一宝贵资源可以最小化爬虫被封禁的可能,而且还能发现和网站结构相关的线索。关于

    robots.txt协议的更多信息可以参见

    http:www.robotstxt.org。下面的代码是我们的

    示例文件robots.txt中的内容,可以访问

    http:example.webscraping.comrobots.txt获

    取。

    section 1

    User-agent: BadCrawler

    Disallow: section 2

    User-agent:

    Crawl-delay: 5

    Disallow: trap

    section 3

    Sitemap: http:example.webscraping.comsitemap.xml

    在section 1中,robots.txt文件禁止用户代

    理为BadCrawler的爬虫爬取该网站,不过这种写法

    可能无法起到应有的作用,因为恶意爬虫根本不会

    遵从robots.txt的要求。本章后面的一个例子将会

    展示如何让爬虫自动遵守robots.txt的要求。

    section 2规定,无论使用哪种用户代理,都

    应该在两次下载请求之间给出5秒的抓取延迟,我

    们需要遵从该建议以避免服务器过载。这里还有一

    个trap链接,用于封禁那些爬取了不允许链接的

    恶意爬虫。如果你访问了这个链接,服务器就会封

    禁你的IP一分钟!一个真实的网站可能会对你的IP

    封禁更长时间,甚至是永久封禁。不过如果这样设

    置的话,我们就无法继续这个例子了。

    section 3定义了一个Sitemap文件,我们将在

    下一节中了解如何检查该文件。

    1.3.2 检查网站地图

    网站提供的Sitemap文件(即网站地图)可以帮助爬虫定位网站最新的内容,而无须爬取每一个

    网页。如果想要了解更多信息,可以从

    http:www.sitemaps.orgprotocol.html获取网站

    地图标准的定义。下面是在robots.txt文件中发现

    的Sitemap文件的内容。

    

    

    http:example.webscraping.comviewAfghanistan-1

    http:example.webscraping.comviewAland-Islands-2

    http:example.webscraping.comviewAlbania-3...

    网站地图提供了所有网页的链接,我们会在后

    面的小节中使用这些信息,用于创建我们的第一个

    爬虫。虽然Sitemap文件提供了一种爬取网站的有

    效方式,但是我们仍需对其谨慎处理,因为该文件

    经常存在缺失、过期或不完整的问题。

    1.3.3 估算网站大小

    目标网站的大小会影响我们如何进行爬取。如

    果是像我们的示例站点这样只有几百个URL的网

    站,效率并没有那么重要;但如果是拥有数百万个

    网页的站点,使用串行下载可能需要持续数月才能

    完成,这时就需要使用第4章中介绍的分布式下载来解决了。

    估算网站大小的一个简便方法是检查Google爬

    虫的结果,因为Google很可能已经爬取过我们感兴

    趣的网站。我们可以通过Google搜索的site关键词

    过滤域名结果,从而获取该信息。我们可以从

    http:www.google.comadvanced_search了解到该

    接口及其他高级搜索参数的用法。

    图1.1所示为使用site关键词对我们的示例网站进行

    搜索的结果,即在Google中搜

    索site:example.webscraping.com。

    从图1.1中可以看出,此时Google估算该网站拥

    有202个网页,这和实际情况差不多。不过对于更

    大型的网站,我们会发现Google的估算并不十分准

    确。

    在域名后面添加URL路径,可以对结果进行过

    滤,仅显示网站的某些部分。图1.2所示为搜

    索site:example.webscraping.comview的结果。该

    搜索条件会限制Google只搜索国家页面。图1.1图1.2

    这种附加的过滤条件非常有用,因为在理想情

    况下,你只希望爬取网站中包含有用数据的部分,而不是爬取网站的每个页面。

    1.3.4 识别网站所用技术

    构建网站所使用的技术类型也会对我们如何爬

    取产生影响。有一个十分有用的工具可以检查网站

    构建的技术类型——builtwith模块。该模块的安

    装方法如下。 pip install builtwith

    该模块将URL作为参数,下载该URL并对其进

    行分析,然后返回该网站使用的技术。下面是使用

    该模块的一个例子。

    >>> import builtwith

    >>> builtwith.parse('http:example.webscraping.com')

    {u'javascript-frameworks': [u'jQuery', u'Modernizr', u'jQuery UI'],u'programming-languages': [u'Python'],u'web-frameworks': [u'Web2py', u'Twitter Bootstrap'],u'web-servers': [u'Nginx']}

    从上面的返回结果中可以看出,示例网站使用

    了Python的Web2py框架,另外还使用了一些通用的

    JavaScript库,因此该网站的内容很有可能是嵌入在

    HTML中的,相对而言比较容易抓取。而如果改用

    AngularJS构建该网站,此时的网站内容就很可能是

    动态加载的。另外,如果网站使用了ASP.NET,那

    么在爬取网页时,就必须要用到会话管理和表单提

    交了。对于这些更加复杂的情况,我们会在第5章

    和第6章中进行介绍。

    1.3.5 寻找网站所有者

    对于一些网站,我们可能会关心其所有者是

    谁。比如,我们已知网站的所有者会封禁网络爬

    虫,那么我们最好把下载速度控制得更加保守一些。为了找到网站的所有者,我们可以使用WHOIS

    协议查询域名的注册者是谁。Python中有一个针对

    该协议的封装库,其文档地址

    为https:pypi.python.orgpypipython-whois,我们可以通过pip进行安装。

    pip install python-whois

    下面是使用该模块对appspot.com这个域名进

    行WHOIS查询时的返回结果。

    >>> import whois

    >>> print whois.whois('appspot.com')

    {...

    name_servers: [

    NS1.GOOGLE.COM,NS2.GOOGLE.COM,NS3.GOOGLE.COM,NS4.GOOGLE.COM,ns4.google.com,ns2.google.com,ns1.google.com,ns3.google.com

    ],org: Google Inc.,emails: [

    abusecomplaints@markmonitor.com,dns-admin@google.com

    ]

    }

    从结果中可以看出该域名归属于Google,实际

    上也确实如此。该域名是用于Google App Engine服务的。当我们爬取该域名时就需要十分小心,因为

    Google经常会阻断网络爬虫,尽管实际上其自身就

    是一个网络爬虫业务。1.4 编写第一个网络爬虫

    为了抓取网站,我们首先需要下载包含有感兴

    趣数据的网页,该过程一般被称为爬取

    (crawling)。爬取一个网站有很多种方法,而选

    用哪种方法更加合适,则取决于目标网站的结构。

    本章中,首先会探讨如何安全地下载网页,然后会

    介绍如下3种爬取网站的常见方法:

    爬取网站地图;

    遍历每个网页的数据库ID;

    跟踪网页链接。

    1.4.1 下载网页

    要想爬取网页,我们首先需要将其下载下来。

    下面的示例脚本使用Python的urllib2模块下载

    URL。

    import urllib2

    def download(url):

    return urllib2.urlopen(url).read

    当传入URL参数时,该函数将会下载网页并返

    回其HTML。不过,这个代码片段存在一个问题,即当下载网页时,我们可能会遇到一些无法控制的

    错误,比如请求的页面可能不存在。此

    时,urllib2会抛出异常,然后退出脚本。安全起

    见,下面再给出一个更健壮的版本,可以捕获这些

    异常。

    import urllib2

    def download(url):

    print 'Downloading:', url

    try:

    html = urllib2.urlopen(url).read

    except urllib2.URLError as e:

    print 'Download error:', e.reason

    html = None

    return html

    现在,当出现下载错误时,该函数能够捕获到

    异常,然后返回None。

    1.重试下载

    下载时遇到的错误经常是临时性的,比如服务

    器过载时返回的503 Service Unavailable错误。

    对于此类错误,我们可以尝试重新下载,因为这个

    服务器问题现在可能已解决。不过,我们不需要对

    所有错误都尝试重新下载。如果服务器返回的

    是404 Not Found这种错误,则说明该网页目前并

    不存在,再次尝试同样的请求一般也不会出现不同

    的结果。互联网工程任务组(Internet Engineering Task

    Force)定义了HTTP错误的完整列表,详情可参

    考https:tools.ietf.orghtmlrfc7231section-

    6。从该文档中,我们可以了解到4xx错误发生在请求存在问

    题时,而5xx错误则发生在服务端存在问题时。所以,我们只

    需要确保download函数在发生5xx`错误时重试下载即

    可。下面是支持重试下载功能的新版本 代码。

    def download(url, num_retries=2):

    print 'Downloading:', url

    try:

    html = urllib2.urlopen(url).read

    except urllib2.URLError as e:

    print 'Download error:', e.reason

    html = None

    if num_retries > 0:

    if hasattr(e, 'code') and 500 <= e.code < 600:

    recursively retry 5xx HTTP errors

    return download(url, num_retries-1)

    return html

    现在,当download函数遇到5xx错误码时,将

    会递归调用函数自身进行重试。此外,该函数还增

    加了一个参数,用于设定重试下载的次数,其默认

    值为两次。我们在这里限制网页下载的尝试次数,是因为服务器错误可能暂时还没有解决。想要测试

    该函数,可以尝试下载http:httpstat.us500,该网址会始终返回500错误码。

    >>> download('http:httpstat.us500')

    Downloading: http:httpstat.us500

    Download error: Internal Server ErrorDownloading: http:httpstat.us500

    Download error: Internal Server Error

    Downloading: http:httpstat.us500

    Download error: Internal Server Error

    从上面的返回结果可以看出,download函数的

    行为和预期一致,先尝试下载网页,在接收到500

    错误后,又进行了两次重试才放弃。

    2.设置用户代理

    默认情况下,urllib2使用Python-urllib2.7

    作为用户代理下载网页内容,其中2.7是Python的

    版本号。如果能使用可辨识的用户代理则更好,这

    样可以避免我们的网络爬虫碰到一些问题。此外,也许是因为曾经历过质量不佳的Python网络爬虫造

    成的服务器过载,一些网站还会封禁这个默认的用

    户代理。比如,在使用Python默认用户代理的情况

    下,访问http:www.meetup.com``,目前会返回

    如图1.3所示的访问拒绝提示。图1.3

    因此,为了下载更加可靠,我们需要控制用户

    代理的设定。下面的代码对download函数进行了修

    改,设定了一个默认的用户代理“wswp”(即Web

    Scraping with Python的首字母缩写)。

    def download(url, user_agent='wswp', num_retries=2):

    print 'Downloading:', url

    headers = {'User-agent': user_agent}

    request = urllib2.Request(url, headers=headers)

    try:

    html = urllib2.urlopen(request).read

    except urllib2.URLError as e:

    print 'Download error:', e.reason

    html = None

    if num_retries > 0:

    if hasattr(e, 'code') and 500 <= e.code < 600:

    retry 5XX HTTP errors

    return download(url, user_agent, num_retries-1)

    return html

    现在,我们拥有了一个灵活的下载函数,可以在后续示例中得到复用。该函数能够捕获异常、重

    试下载并设置用户代理。

    1.4.2 网站地图爬虫

    在第一个简单的爬虫中,我们将使用示例网站

    robots.txt文件中发现的网站地图来下载所有网

    页。为了解析网站地图,我们将会使用一个简单的

    正则表达式,从标签中提取出URL。

    而在下一章中,我们将会介绍一种更加健壮的解析

    方法——CSS选择器。下面是该示例爬虫的代码。

    def crawl_sitemap(url):

    download the sitemap file

    sitemap = download(url)

    extract the sitemap links

    links = re.findall('(.?)', sitemap)

    download each link

    for link in links:

    html = download(link)

    scrape html here

    ...

    现在,运行网站地图爬虫,从示例网站中下载

    所有国家页面。

    >>> crawl_sitemap('http:example.webscraping.comsitemap.xml')

    Downloading: http:example.webscraping.comsitemap.xml

    Downloading: http:example.webscraping.comviewAfghanistan-1

    Downloading: http:example.webscraping.comviewAland-Islands-2

    Downloading: http:example.webscraping.comviewAlbania-3...可以看出,上述运行结果和我们的预期一致,不过正如前文所述,我们无法依靠Sitemap文件提

    供每个网页的链接。下一节中,我们将会介绍另一

    个简单的爬虫,该爬虫不再依赖于Sitemap文件。

    1.4.3 ID遍历爬虫

    本节中,我们将利用网站结构的弱点,更加轻

    松地访问所有内容。下面是一些示例国家的URL。

    http:example.webscraping.comviewAfghanistan-

    1

    http:example.webscraping.comviewAustralia-

    2

    http:example.webscraping.comviewBrazil-

    3

    可以看出,这些URL只在结尾处有所区别,包

    括国家名(作为页面别名)和ID。在URL中包含页

    面别名是非常普遍的做法,可以对搜索引擎优化起

    到帮助作用。一般情况下,Web服务器会忽略这个

    字符串,只使用ID来匹配数据库中的相关记录。下

    面我们将其移除,加载http:example.``

    ``webscraping.comview1,测试示例网站中的链

    接是否仍然可用。测试结果如图1.4所示。图1.4

    从图1.4中可以看出,网页依然可以加载成功,也就是说该方法是有用的。现在,我们就可以忽略页面别名,只遍历ID来下载所有国家的页面。下面

    是使用了该技巧的代码片段。

    import itertools

    for page in itertools.count(1):

    url = 'http:example.webscraping.comview-%d' % page

    html = download(url)

    if html is None:

    break

    else:

    success - can scrape the result

    pass

    在这段代码中,我们对ID进行遍历,直到出现

    下载错误时停止,我们假设此时已到达最后一个国

    家的页面。不过,这种实现方式存在一个缺陷,那

    就是某些记录可能已被删除,数据库ID之间并不是

    连续的。此时,只要访问到某个间隔点,爬虫就会

    立即退出。下面是这段代码的改进版本,在该版本

    中连续发生多次下载错误后才会退出程序。

    maximum number of consecutive download errors allowed

    max_errors = 5

    current number of consecutive download errors

    num_errors = 0

    for page in itertools.count(1):

    url = 'http:example.webscraping.comview-%d' % page

    html = download(url)

    if html is None:

    received an error trying to download this webpage

    num_errors += 1

    if num_errors == max_errors:

    reached maximum number of

    consecutive errors so exit

    break

    else: success - can scrape the result

    ...

    num_errors = 0

    上面代码中实现的爬虫需要连续5次下载错误

    才会停止遍历,这样就很大程度上降低了遇到被删

    除记录时过早停止遍历的风险。

    在爬取网站时,遍历ID是一个很便捷的方法,但是和网站地图爬虫一样,这种方法也无法保证始

    终可用。比如,一些网站会检查页面别名是否满足

    预期,如果不是,则会返回404 Not Found错误。

    而另一些网站则会使用非连续大数作为ID,或是不

    使用数值作为ID,此时遍历就难以发挥其作用了。

    例如,Amazon使用ISBN作为图书ID,这种编码包

    含至少10位数字。使用ID对Amazon的图书进行遍

    历需要测试数十亿次,因此这种方法肯定不是抓取

    该站内容最高效的方法。

    1.4.4 链接爬虫

    到目前为止,我们已经利用示例网站的结构特

    点实现了两个简单爬虫,用于下载所有的国家页

    面。只要这两种技术可用,就应当使用其进行爬

    取,因为这两种方法最小化了需要下载的网页数

    量。不过,对于另一些网站,我们需要让爬虫表现

    得更像普通用户,跟踪链接,访问感兴趣的内容。通过跟踪所有链接的方式,我们可以很容易地

    下载整个网站的页面。但是,这种方法会下载大量

    我们并不需要的网页。例如,我们想要从一个在线

    论坛中抓取用户账号详情页,那么此时我们只需要

    下载账号页,而不需要下载讨论贴的页面。本节中

    的链接爬虫将使用正则表达式来确定需要下载哪些

    页面。下面是这段代码的初始版本。

    import re

    def link_crawler(seed_url, link_regex):

    Crawl from the given seed URL following links matched by link_regex

    crawl_queue = [seed_url]

    while crawl_queue:

    url = crawl_queue.pop

    html = download(url)

    filter for links matching our regular expression

    for link in get_links(html):

    if re.match(link_regex, link):

    crawl_queue.append(link)

    def get_links(html):

    Return a list of links from html

    a regular expression to extract all links from the webpage

    webpage_regex = re.compile(']+href=[\'](.?)[\']',re.IGNORECASE)

    list of all links from the webpage

    return webpage_regex.findall(html)

    要运行这段代码,只需要调用link_crawler函

    数,并传入两个参数:要爬取的网站URL和用于跟

    踪链接的正则表达式。对于示例网站,我们想要爬

    取的是国家列表索引页和国家页面。其中,索引页链接格式如下。

    http:example.webscraping.comindex1

    http:example.webscraping.comindex2

    国家页链接格式如下。

    http:example.webscraping.comviewAfghanistan-

    1

    http:example.webscraping.comviewAland-

    Islands-2

    因此,我们可以用(index|view)这个简单的

    正则表达式来匹配这两类网页。当爬虫使用这些输

    入参数运行时会发生什么呢?你会发现我们得到了

    如下的下载错误。

    >>> link_crawler('http:example.webscraping.com','(index|view)')

    Downloading: http:example.webscraping.com

    Downloading: index1

    Traceback (most recent call last):...

    ValueError: unknown url type: index1

    可以看出,问题出在下载index1时,该链接

    只有网页的路径部分,而没有协议和服务器部分,也就是说这是一个相对链接。由于浏览器知道你正

    在浏览哪个网页,所以在浏览器浏览时,相对链接是能够正常工作的。但是,urllib2是无法获知上

    下文的。为了让urllib2能够定位网页,我们需要

    将链接转换为绝对链接的形式,以便包含定位网页

    的所有细节。如你所愿,Python中确实有用来实现

    这一功能的模块,该模块称为urlparse。下面

    是link_crawler的改进版本,使用了urlparse模块

    来创建绝对路径。

    import urlparse

    def link_crawler(seed_url, link_regex):

    Crawl from the given seed URL following links matched by link_regex

    crawl_queue = [seed_url]

    while crawl_queue:

    url = crawl_queue.pop

    html = download(url)

    for link in get_links(html):

    if re.match(link_regex, link):

    link = urlparse.urljoin(seed_url, link)

    crawl_queue.append(link)

    当你运行这段代码时,会发现虽然网页下载没

    有出现错误,但是同样的地点总是会被不断下载

    到。这是因为这些地点相互之间存在链接。比如,澳大利亚链接到了南极洲,而南极洲也存在到澳大

    利亚的链接,此时爬虫就会在它们之间不断循环下

    去。要想避免重复爬取相同的链接,我们需要记录

    哪些链接已经被爬取过。下面是修改后的

    link_crawler函数,已具备存储已发现URL的功

    能,可以避免重复下载。 def link_crawler(seed_url, link_regex):

    crawl_queue = [seed_url]

    keep track which URL's have seen before

    seen = set(crawl_queue)

    while crawl_queue:

    url = crawl_queue.pop

    html = download(url)

    for link in get_links(html):

    check if link matches expected regex

    if re.match(link_regex, link):

    form absolute link

    link = urlparse.urljoin(seed_url, link)

    check if have already seen this link

    if link not in seen:

    seen.add(link)

    crawl_queue.append(link)

    当运行该脚本时,它会爬取所有地点,并且能

    够如期停止。最终,我们得到了一个可用的爬虫!

    高级功能

    现在,让我们为链接爬虫添加一些功能,使其

    在爬取其他网站时更加有用。

    解析robots.txt

    首先,我们需要解析robots.txt文件,以避免

    下载禁止爬取的URL。使用Python自带的

    robotparser模块,就可以轻松完成这项工作,如

    下面的代码所示。

    >>> import robotparser

    >>> rp = robotparser.RobotFileParser>>> rp.set_url('http:example.webscraping.comrobots.txt')

    >>> rp.read

    >>> url = 'http:example.webscraping.com'

    >>> user_agent = 'BadCrawler'

    >>> rp.can_fetch(user_agent, url)

    False

    >>> user_agent = 'GoodCrawler'

    >>> rp.can_fetch(user_agent, url)

    True

    robotparser模块首先加载robots.txt文件,然后通过can_fetch函数确定指定的用户代理是

    否允许访问网页。在本例中,当用户代理设置为

    'BadCrawler' 时,robotparser模块会返回结果表明

    无法获取网页,这和示例网站robots.txt``的定义

    一样。

    为了将该功能集成到爬虫中,我们需要

    在crawl循环中添加该检查。...

    while crawl_queue:

    url = crawl_queue.pop

    check url passes robots.txt restrictions

    if rp.can_fetch(user_agent, url):...

    else:

    print 'Blocked by robots.txt:', url

    支持代理

    有时我们需要使用代理访问某个网站。比如,Netflix屏蔽了美国以外的大多数国家。使用urllib2支持代理并没有想象中那么容易(可以尝试使用更

    友好的Python HTTP模块requests来实现该功能,其文档地址为http:docs.python-

    requests.org)。下面是使用urllib2支持代理的

    代码。

    proxy = ...

    opener = urllib2.build_opener

    proxy_params = {urlparse.urlparse(url).scheme: proxy}

    opener.add_handler(urllib2.ProxyHandler(proxy_params))

    response = opener.open(request)

    下面是集成了该功能的新版本download函数。

    def download(url, user_agent='wswp', proxy=None, num_retries=2):

    print 'Downloading:', url

    headers = {'User-agent': user_agent}

    request = urllib2.Request(url, headers=headers)

    opener = urllib2.build_opener

    if proxy:

    proxy_params = {urlparse.urlparse(url).scheme: proxy}

    opener.add_handler(urllib2.ProxyHandler(proxy_params))

    try:

    html = opener.open(request).read

    except urllib2.URLError as e:

    print 'Download error:', e.reason

    html = None

    if num_retries > 0:

    if hasattr(e, 'code') and 500 <= e.code < 600:

    retry 5XX HTTP errors

    html = download(url, user_agent, proxy,num_retries-1)

    return html

    下载限速如果我们爬取网站的速度过快,就会面临被封

    禁或是造成服务器过载的风险。为了降低这些风

    险,我们可以在两次下载之间添加延时,从而对爬

    虫限速。下面是实现了该功能的类的代码。

    class Throttle:

    Add a delay between downloads to the same domain

    def __init__(self, delay):

    amount of delay between downloads for each domain

    self.delay = delay

    timestamp of when a domain was last accessed

    self.domains = {}

    def wait(self, url):

    domain = urlparse.urlparse(url).netloc

    last_accessed = self.domains.get(domain)

    if self.delay > 0 and last_accessed is not None:

    sleep_secs = self.delay - (datetime.datetime.now -

    last_accessed).seconds

    if sleep_secs > 0:

    domain has been accessed recently

    so need to sleep

    time.sleep(sleep_secs)

    update the last accessed time

    self.domains[domain] = datetime.datetime.now

    Throttle类记录了每个域名上次访问的时间,如果当前时间距离上次访问时间小于指定延时,则

    执行睡眠操作。我们可以在每次下载之前调

    用Throttle对爬虫进行限速。

    throttle = Throttle(delay)...

    throttle.wait(url)

    result = download(url, headers, proxy=proxy, num_retries=num_retries)

    避免爬虫陷阱

    目前,我们的爬虫会跟踪所有之前没有访问过

    的链接。但是,一些网站会动态生成页面内容,这

    样就会出现无限多的网页。比如,网站有一个在线

    日历功能,提供了可以访问下个月和下一年的链

    接,那么下个月的页面中同样会包含访问再下个月

    的链接,这样页面就会无止境地链接下去。这种情

    况被称为爬虫陷阱。

    想要避免陷入爬虫陷阱,一个简单的方法是记

    录到达当前网页经过了多少个链接,也就是深度。

    当到达最大深度时,爬虫就不再向队列中添加该网

    页中的链接了。要实现这一功能,我们需要修

    改seen变量。该变量原先只记录访问过的网页链

    接,现在修改为一个字典,增加了页面深度的记

    录。

    def link_crawler(..., max_depth=2):

    max_depth = 2

    seen = {}...

    depth = seen[url]

    if depth != max_depth:

    for link in links:

    if link not in seen:

    seen[link] = depth + 1

    crawl_queue.append(link)现在有了这一功能,我们就有信心爬虫最终一

    定能够完成。如果想要禁用该功能,只需

    将max_depth设为一个负数即可,此时当前深度永

    远不会与之相等。

    最终版本

    这个高级链接爬虫的完整源代码可以

    在https:bitbucket.org

    wswpcodesrctipchapter01link_crawler3.py

    下载得到。要测试这段代码,我们可以将用户代理

    设置为BadCrawler,也就是本章前文所述的被

    robots.txt屏蔽了的那个用户代理。从下面的运行

    结果中可以看出,爬虫果然被屏蔽了,代码启动后

    马上就会结束。

    >>> seed_url = 'http:example.webscraping.comindex'

    >>> link_regex = '(index|view)'

    >>> link_crawler(seed_url, link_regex, user_agent='BadCrawler')

    Blocked by robots.txt: http:example.webscraping.com

    现在,让我们使用默认的用户代理,并将最大

    深度设置为1,这样只有主页上的链接才会被下

    载。

    >>> link_crawler(seed_url, link_regex, max_depth=1)

    Downloading: http:example.webscraping.comindex

    Downloading: http:example.webscraping.comindex1

    Downloading: http:example.webscraping.comviewAntigua-and-Barbuda-10

    Downloading: http:example.webscraping.comviewAntarctica-9Downloading: http:example.webscraping.comviewAnguilla-8

    Downloading: http:example.webscraping.comviewAngola-7

    Downloading: http:example.webscraping.comviewAndorra-6

    Downloading: http:example.webscraping.comviewAmerican-Samoa-5

    Downloading: http:example.webscraping.comviewAlgeria-4

    Downloading: http:example.webscraping.comviewAlbania-3

    Downloading: http:example.webscraping.comviewAland-Islands-2

    Downloading: http:example.webscraping.comviewAfghanistan-1

    和预期一样,爬虫在下载完国家列表的第一页

    之后就停止了。1.5 本章小结

    本章介绍了网络爬虫,然后开发了一个能够在

    后续章节中复用的成熟爬虫。此外,我们还介绍了

    一些外部工具和模块的使用方法,用于了解网站、用户代理、网站地图、爬取延时以及各种爬取策

    略。

    下一章中,我们将讨论如何从已爬取到的网页

    中获取数据。第2章 数据抓取

    在上一章中,我们构建了一个爬虫,可以通过

    跟踪链接的方式下载我们所需的网页。虽然这个例

    子很有意思,却不够实用,因为爬虫在下载网页之

    后又将结果丢弃掉了。现在,我们需要让这个爬虫

    从每个网页中抽取一些数据,然后实现某些事情,这种做法也被称为抓取(scraping)。

    首先,我们会介绍一个叫做Firebug Lite的浏

    览器扩展,用于检查网页内容,如果你有一些网络

    开发背景的话,可能已经对该扩展十分熟悉了。然

    后,我们会介绍三种抽取网页数据的方法,分别是

    正则表达式、Beautiful Soup和lxml。最后,我们将

    对比这三种数据抓取方法。2.1 分析网页

    想要了解一个网页的结构如何,可以使用查看

    源代码的方法。在大多数浏览器中,都可以在页面

    上右键单击选择View page source选项,获取网页

    的源代码,如图2.1所示。

    我们可以在HTML的下述代码中找到我们感兴

    趣的数据。

    ...

    for=places_national_flag

    id=places_national_flag__label>National Flag:

    src=placesstaticimagesflagsgb.png >

    class=w2p_fc>

    for=places_neighbours

    id=places_neighbours__label>Neighbours:

    class=w2p_fw>

    class=w2p_fc>
图2.1

    对于浏览器解析而言,缺失空白符和格式并无

    大碍,但在我们阅读时则会造成一定困难。要想更

    好地理解该表格,我们将使用Firebug Lite扩展。该

    扩展适用于所有浏览器,我们可以通过

    https:getfirebug.comfirebuglite页面获取到

    该扩展。如果愿意的话,Firefox用户可以安装完整

    版的Firebug扩展,不过Lite版本已经包含了我们在

    本章和第6章中所用到的功能。

    Firebug Lite安装完成后,可以右键单击我们在

    抓取中感兴趣的网页部分,然后在菜单中选择

    Inspect with Firebug Lite,如图2.2所示。图2.2此时,浏览器就会打开如图2.3所示的Firebug

    面板,并显示选中元素周围的HTML层次结构。

    如图2.3所示,当选择国家面积这一属性时,我

    们可以从Firebug面板中清晰地看到,该值包含在

    class为w2p_fw的元素的子元素。现在,我们就获取到需要抓取的面积数据的所有信息了。

    图2.32.2 三种网页抓取方法

    现在我们已经了解了该网页的结构,下面将要

    介绍三种抓取其中数据的方法。首先是正则表达

    式,然后是流行的BeautifulSoup模块,最后是强

    大的lxml模块。

    2.2.1 正则表达式

    如果你对正则表达式还不熟悉,或是需要一些

    提示时,可以查

    阅https:docs.python.org2howtoregex.html

    获得完整介绍。

    当我们使用正则表达式抓取面积数据时,首先

    需要尝试匹配也加入进

    来。由于该元素具有ID属性,所以应该是唯一的。

    >>> re.findall('
    class=w2p_fl>
.?
    class=w2p_fw>(.?)
元素中,而元素又是ID

    为places_area__row的
元素中的内容,如下所示。

    >>> import re

    >>> url = 'http:example.webscraping.comviewUnited

    -Kingdom-239'

    >>> html = download(url)

    >>> re.findall('
(.?)', html)

    ['','244,820 square kilometres','62,348,447','GB','United Kingdom','London','EU','.uk', 'GBP','Pound','44','@ @@|@ @@|@@ @@|@@ @@|@@ @@|@@@ @@|GIR0AA','^(([A-Z]\\d{2}[A-Z]{2})|([A-Z]\\d{3}[A-Z]{2})|([A-Z]{2}\\d{2}

    [A-Z]{2})|([A-Z]{2}\\d{3}[A-Z]{2})|([A-Z]\\d[A-Z]\\d[A-Z]{2})

    |([A-Z]{2}\\d[A-Z]\\d[A-Z]{2})|(GIR0AA))','en-GB,cy-GB,gd','
标签。要想分离出面积属

    性,我们可以只选择其中的第二个元素,如下所

    示。

    >>> re.findall('
(.?)', html)[1]

    '244,820 square kilometres'

    虽然现在可以使用这个方案,但是如果网页发

    生变化,该方案很可能就会失效。比如表格发生了

    变化,去除了第二行中的国土面积数据。如果我们

    只在现在抓取数据,就可以忽略这种未来可能发生

    的变化。但是,如果我们希望未来还能再次抓取该

    数据,就需要给出更加健壮的解决方案,从而尽可

    能避免这种布局变化所带来的影响。想要该正则表

    达式更加健壮,我们可以将其父元素

    class=w2p_fw>(.?)
', html) ['244,820 square kilometres']

    这个迭代版本看起来更好一些,但是网页更新

    还有很多其他方式,同样可以让该正则表达式无法

    满足。比如,将双引号变为单引号,
标签之间

    添加多余的空格,或是变更area_label等。下面是

    尝试支持这些可能性的改进版本。

    >>> re.findall('
    id=places_area__row>.?(.?)
', html)

    ['244,820 square kilometres']

    虽然该正则表达式更容易适应未来变化,但又

    存在难以构造、可读性差的问题。此外,还有一些

    微小的布局变化也会使该正则表达式无法满足,比

    如在
标签里添加title属性。

    从本例中可以看出,正则表达式为我们提供了

    抓取数据的快捷方式,但是该方法过于脆弱,容易

    在网页更新后出现问题。幸好,还有一些更好的解

    决方案,我们会在接下来的小节中继续介绍。

    2.2.2 Beautiful Soup

    Beautiful Soup是一个非常流行的Python模块。

    该模块可以解析网页,并提供定位内容的便捷接

    口。如果你还没有安装该模块,可以使用下面的命令安装其最新版本:

    pip install beautifulsoup4

    使用Beautiful Soup的第一步是将已下载的

    HTML内容解析为soup文档。由于大多数网页都不

    具备良好的HTML格式,因此Beautiful Soup需要对

    其实际格式进行确定。例如,在下面这个简单网页

    的列表中,存在属性值两侧引号缺失和标签未闭合

    的问题。

    
' % field, html).groups[0]

    return results

    from bs4 import BeautifulSoup

    def bs_scraper(html):

    soup = BeautifulSoup(html, 'html.parser')

    results = {}

    for field in FIELDS:

    results[field] = soup.find('table').find('tr',id='places_%s__row' % field).find('td',class_='w2p_fw').text

    return results

    import lxml.html

    def lxml_scraper(html):

    tree = lxml.html.fromstring(html)

    results = {}

    for field in FIELDS:

    results[field] = tree.cssselect('table > trplaces_%s__row

    > td.w2p_fw' % field)[0].text_content

    return results

    抓取结果

    现在,我们已经完成了所有爬虫的代码实现,接下来将通过如下代码片段,测试这三种方法的相

    对性能。

    import time

    NUM_ITERATIONS = 1000 number of times to test each scraper

    html = download('http:example.webscraping.complacesview

    United-Kingdom-239')

    for name, scraper in [('Regular expressions', re_scraper),('BeautifulSoup', bs_scraper),('Lxml', lxml_scraper)]:

    record start time of scrape start = time.time

    for i in range(NUM_ITERATIONS):

    if scraper == re_scraper:

    re.purge

    result = scraper(html)

    check scraped result is as expected

    assert(result['area'] == '244,820 square kilometres')

    record end time of scrape and output the total

    end = time.time

    print '%s: %.2f seconds' % (name, end – start)

    在这段代码中,每个爬虫都会执行1000次,每

    次执行都会检查抓取结果是否正确,然后打印总用

    时。这里使用的download函数依然是上一章中定义

    的那个函数。请注意,我们在加粗的代码行中调用

    了re.purge方法。默认情况下,正则表达式模块

    会缓存搜索结果,为了与其他爬虫的对比更加公

    平,我们需要使用该方法清除缓存。

    下面是在我的电脑中运行该脚本的结果。

    python performance.py

    Regular expressions: 5.50 seconds

    BeautifulSoup: 42.84 seconds

    Lxml: 7.06 seconds

    由于硬件条件的区别,不同电脑的执行结果也

    会存在一定差异。不过,每种方法之间的相对差异

    应当是相当的。从结果中可以看出,在抓取我们的

    示例网页时,Beautiful Soup比其他两种方法慢了超

    过6倍之多。实际上这一结果是符合预期的,因

    为lxml和正则表达式模块都是C语言编写的,而BeautifulSoup``则是纯Python编写的。一个有趣的

    事实是,lxml表现得和正则表达式差不多好。由于

    lxml在搜索元素之前,必须将输入解析为内部格

    式,因此会产生额外的开销。而当抓取同一网页的

    多个特征时,这种初始化解析产生的开销就会降

    低,lxml也就更具竞争力。这真是一个令人惊叹的

    模块!

    2.2.5 结论

    表2.1总结了每种抓取方法的优缺点。

    表2.1

    抓取方法 性能 使用难度 安装难度

    正则表达式 快 困难 简单(内置模块)

    Beautiful Soup 慢 简单 简单(纯Python)

    Lxml 快 简单 相对困难

    如果你的爬虫瓶颈是下载网页,而不是抽取数

    据的话,那么使用较慢的方法(如Beautiful Soup)

    也不成问题。如果只需抓取少量数据,并且想要避

    免额外依赖的话,那么正则表达式可能更加适合。

    不过,通常情况下,lxml是抓取数据的最好选择,这是因为该方法既快速又健壮,而正则表达式和

    Beautiful Soup只在某些特定场景下有用。

    2.2.6 为链接爬虫添加抓取回调

    前面我们已经了解了如何抓取国家数据,接下

    来我们需要将其集成到上一章的链接爬虫当中。要

    想复用这段爬虫代码抓取其他网站,我们需要添加

    一个callback参数处理抓取行为。callback是一个

    函数,在发生某个特定事件之后会调用该函数(在

    本例中,会在网页下载完成后调用)。该抓取

    callback函数包含url和html两个参数,并且可以

    返回一个待爬取的URL列表。下面是其实现代码,可以看出在Python中实现该功能非常简单。

    def link_crawler(..., scrape_callback=None):...

    links = []

    if scrape_callback:

    links.extend(scrape_callback(url, html) or [])...

    在上面的代码片段中,我们加粗显示了新增加

    的抓取callback函数代码。如果想要获取该版本链

    接爬虫的完整代码,可以访问https:bitbucket.

    orgwswpcodesrctipchapter02link_crawler.py

    现在,我们只需对传入的scrape_callback函数定制化处理,就能使用该爬虫抓取其他网站了。

    下面对lxml抓取示例的代码进行了修改,使其能够

    在callback函数中使用。

    def scrape_callback(url, html):

    if re.search('view', url):

    tree = lxml.html.fromstring(html)

    row = [tree.cssselect('table > trplaces_%s__row >

    td.w2p_fw' % field)[0].text_content for field in

    FIELDS]

    print url, row

    上面这个callback函数会去抓取国家数据,然

    后将其显示出来。不过通常情况下,在抓取网站

    时,我们更希望能够复用这些数据,因此下面我们

    对其功能进行扩展,把得到的结果数据保存到CSV

    表格中,其代码如下所示。

    import csv

    class ScrapeCallback:

    def __init__(self):

    self.writer = csv.writer(open('countries.csv', 'w'))

    self.fields = ('area', 'population', 'iso', 'country','capital', 'continent', 'tld', 'currency_code','currency_name', 'phone', 'postal_code_format','postal_code_regex', 'languages','neighbours')

    self.writer.writerow(self.fields)

    def __call__(self, url, html):

    if re.search('view', url):

    tree = lxml.html.fromstring(html)

    row = []

    for field in self.fields:

    row.append(tree.cssselect('table >

    trplaces_{}__row >

    td.w2p_fw'.format(field)) [0].text_content)

    self.writer.writerow(row)

    为了实现该callback,我们使用了回调类,而

    不再是回调函数,以便保持csv中writer属性的状

    态。csv的writer属性在构造方法中进行了实例化

    处理,然后在__call__方法中执行了多次写操作。

    请注意,__call__是一个特殊方法,在对象作为函

    数被调用时会调用该方法,这也是链接爬虫中

    cache_callback的调用方法。也就是

    说,scrape_callback(url, html)和调

    用scrape_callback.__call__(url, html)是等价

    的。如果想要了解更多有关Python特殊类方法的知

    识,可以参

    考https:docs.python.org2referencedatamodel.htmlspecial-

    method-names。

    下面是向链接爬虫传入回调的代码写法。

    link_crawler('http:example.webscraping.com', '(index|view)',max_depth=-1, scrape_callback=ScrapeCallback)

    现在,当我们运行这个使用了callback的爬虫

    时,程序就会将结果写入一个CSV文件中,我们可

    以使用类似Excel或者LibreOffice的应用查看该文

    件,如图2.5所示。图2.5

    成功了!我们完成了第一个可以工作的数据抓

    取爬虫。2.3 本章小结

    在本章中,我们介绍了几种抓取网页数据的方

    法。正则表达式在一次性数据抓取中非常有用,此

    外还可以避免解析整个网页带来的开

    销;BeautifulSoup提供了更高层次的接口,同时

    还能避免过多麻烦的依赖。不过,通常情况

    下,lxml是我们的最佳选择,因为它速度更快,功

    能更加丰富,因此在接下来的例子中我们将会使

    用lxml模块进行数据抓取。

    下一章,我们会介绍缓存技术,这样就能把网

    页保存下来,只在爬虫第一次运行时才会下载网

    页。第3章 下载缓存

    在上一章中,我们学习了如何从已爬取到的网

    页中抓取数据,以及将抓取结果保存到表格中。如

    果我们还想抓取另外一个字段,比如国旗图片的

    URL,那么又该怎么做呢?要想抓取这些新增的字

    段,我们需要重新下载整个网站。对于我们这个小

    型的示例网站而言,这可能不算特别大的问题。但

    是,对于那些拥有数百万个网页的网站而言,重新

    爬取可能需要耗费几个星期的时间。因此,本章提

    出了对已爬取网页进行缓存的方案,可以让每个网

    页只下载一次。3.1 为链接爬虫添加缓存支持

    要想支持缓存,我们需要修改第1章中编写的

    download函数,使其在URL下载之前进行缓存检

    查。另外,我们还需要把限速功能移至函数内部,只有在真正发生下载时才会触发限速,而在加载缓

    存时不会触发。为了避免每次下载都要传入多个参

    数,我们借此机会将download函数重构为一个类,这样参数只需在构造方法中设置一次,就能在后续

    下载时多次复用。下面是支持了缓存功能的代码实

    现。

    class Downloader:

    def __init__(self, delay=5,user_agent='wswp', proxies=None,num_retries=1, cache=None):

    self.throttle = Throttle(delay)

    self.user_agent = user_agent

    self.proxies = proxies

    self.num_retries = num_retries

    self.cache = cache

    def __call__(self, url):

    result = None

    if self.cache:

    try:

    result = self.cache[url]

    except KeyError:

    url is not available in cache

    pass

    else:

    if self.num_retries > 0 and 500 <= result['code'] < 600: server error so ignore result from cache

    and re-download

    result = None

    if result is None:

    result was not loaded from cache

    so still need to download

    self.throttle.wait(url)

    proxy = random.choice(self.proxies) if self.proxies

    else None

    headers = {'User-agent': self.user_agent}

    result = self.download(url, headers, proxy,self.num_retries)

    if self.cache:

    save result to cache

    self.cache[url] = result

    return result['html']

    def download(self, url, headers, proxy, num_retries,data=None):...

    return {'html': html, 'code': code}

    下载类的完整源码可以从`https:bitbucket.org`

    `wswpcodesrctipchapter03downloader.py`获取。

    前面代码中的Download类有一个比较有意思的

    部分,那就是__call__特殊方法,在该方法中我们

    实现了下载前检查缓存的功能。该方法首先会检查

    缓存是否已经定义。如果已经定义,则检查之前是

    否已经缓存了该URL。如果该URL已被缓存,则检

    查之前的下载中是否遇到了服务端错误。最后,如

    果也没有发生过服务端错误,则表明该缓存结果可

    用。如果上述检查中的任何一项失败,都需要正常下载该URL,然后将得到的结果添加到缓存中。这

    里的download方法和之前的download函数基本一

    样,只是在返回下载的HTML时额外返回了HTTP

    状态码,以便在缓存中存储错误码。当然,如果你

    只需要一个简单的下载功能,而不需要限速或缓存

    的话,可以直接调用该方法,这样就不会通过

    __call__方法调用了。

    而对于cache类,我们可以通过调用result =

    cache[url]从cache中加载数据,并通过

    cache[url] = result向cache中保存结果。大家应

    该很熟悉这种便捷的接口写法,因为这也是Python

    内建字典数据类型的使用方式。为了支持该接口,我们的cache类需要定义__getitem__和

    __setitem__这两个特殊的类方法。

    此外,为了支持缓存功能,链接爬虫的代码也

    需要进行一些微调,包括添加cache参数、移除限

    速以及将download函数替换为新的类等,如下面的

    代码所示。

    def link_crawler(..., cache=None):

    crawl_queue = [seed_url]

    seen = {seed_url: 0}

    num_urls = 0

    rp = get_robots(seed_url)

    D = Downloader(delay=delay, user_agent=user_agent,proxies=proxies, num_retries=num_retries, cache=cache) while crawl_queue:

    url = crawl_queue.pop

    depth = seen[url]

    check url passes robots.txt restrictions

    if rp.can_fetch(user_agent, url):

    html = D(url)

    links = []...

    到目前为止,这个网络爬虫的基本架构已经准

    备好了,下面就要开始构建实际的缓存了。3.2 磁盘缓存

    要想缓存下载结果,我们先来尝试最容易想到

    的方案,将下载到的网页存储到文件系统中。为了

    实现该功能,我们需要将URL安全地映射为跨平台

    的文件名。表3.1所示为几大主流文件系统的限制。

    表3.1

    操作系统 文件系统 非法文件名字符 文件名最大长度

    Linux Ext3Ext4 和 \0 255字节

    OS X HFS Plus : 和 \0 255个UTF-16编码单元

    Windows NTFS \、、?、:、、、>、< 和 | 255个字符

    为了保证在不同文件系统中,我们的文件路径

    都是安全的,就需要限制其只能包含数字、字母和

    基本符号,并将其他字符替换为下划线,其实现代

    码如下所示。

    >>> import re

    >>> url = 'http:example.webscraping.comdefaultview

    Australia-1'

    >>> re.sub('[^0-9a-zA-Z\-.,;_ ]', '_', url)

    'http_example.webscraping.comdefaultviewAustralia-1'此外,文件名及其父目录的长度需要限制在

    255个字符以内(实现代码如下),以满足表3.1中

    给出的长度限制。

    >>> filename = ''.join(segment[:255] for segment in

    filename.split(''))

    还有一种边界情况需要考虑,那就是URL路径

    可能会以斜杠()结尾,此时斜杠后面的空字符串

    就会成为一个非法的文件名。但是,如果移除这个

    斜杠,使用其父字符串作为文件名,又会造成无法

    保存其他URL的问题。考虑下面这两个URL:

    http:example.webscraping.comindex

    http:example.webscraping.comindex1

    如果我们希望这两个URL都能保存下来,就需

    要以index作为目录名,以1作为子路径。对于像第

    一个URL路径这样以斜杠结尾的情况,这里使用的

    解决方案是添加index.html作为其文件名。同样

    地,当URL路径为空时也进行相同的操作。为了解

    析URL,我们需要使用urlparse.urlsplit函

    数,将URL分割成几个部分。

    >>> import urlparse

    >>> components =

    urlparse.urlsplit('http:example.webscraping.comindex')

    >>> print components

    SplitResult(scheme='http', netloc='example.webscraping.com', path='index', query='', fragment='')

    >>> print components.path

    'index'

    该函数提供了解析和处理URL的便捷接口。下

    面是使用该模块对上述边界情况添加index.html的

    示例代码。

    >>> path = components.path

    >>> if not path:

    >>> path = 'index.html'

    >>> elif path.endswith(''):

    >>> path += 'index.html'

    >>> filename = components.netloc + path + components.query

    >>> filename

    'example.webscraping.comindexindex.html'

    3.2.1 实现

    上一节中,我们介绍了创建基于磁盘的缓存时

    需要考虑的文件系统限制,包括允许使用哪些字

    符、文件名长度限制,以及确保文件和目录的创建

    位置不同。把URL到文件名的这些映射逻辑结合起

    来,就形成了磁盘缓存的主要部分。下面是

    DiskCache类的初始实现代码。

    import os

    import re

    import urlparse

    class DiskCache:

    def __init__(self, cache_dir='cache'):

    self.cache_dir = cache_dir self.max_length = max_length

    def url_to_path(self, url):

    Create file system path for this URL

    components = urlparse.urlsplit(url)

    append index.html to empty paths

    path = components.path

    if not path:

    path = 'index.html'

    elif path.endswith(''):

    path += 'index.html'

    filename = components.netloc + path + components.query

    replace invalid characters

    filename = re.sub('[^0-9a-zA-Z\-.,;_ ]', '_', filename)

    restrict maximum number of characters

    filename = ''.join(segment[:255] for segment in

    filename.split(''))

    return os.path.join(self.cache_dir, filename)

    在上面的代码中,构造方法传入了一个用于设

    定缓存位置的参数,然后在url_to_path方法中应

    用了前面讨论的文件名限制。现在,我们还缺少根

    据文件名存取数据的方法,下面的代码实现了这两

    个缺失的方法。

    import pickle

    class DiskCache:...

    def __getitem__(self, url):

    Load data from disk for this URL

    path = self.url_to_path(url)

    if os.path.exists(path):

    with open(path, 'rb') as fp:

    return pickle.load(fp)

    else:

    URL has not yet been cached

    raise KeyError(url + ' does not exist') def __setitem__(self, url, result):

    Save data to disk for this url

    path = self.url_to_path(url)

    folder = os.path.dirname(path)

    if not os.path.exists(folder):

    os.makedirs(folder)

    with open(path, 'wb') as fp:

    fp.write(pickle.dumps(result))

    在__setitem__中,我们使用url_to_path

    方法将URL映射为安全文件名,在必要情况下还需

    要创建父目录。这里使用的pickle模块会把输入转

    化为字符串,然后保存到磁盘中。而

    在__getitem__方法中,首先将URL映射为安全

    文件名。然后,如果文件存在,则加载其内容,并

    执行反序列化,恢复其原始数据类型;如果文件不

    存在,则说明缓存中还没有该URL的数据,此时会

    抛出KeyError异常。

    3.2.2 缓存测试

    现在,我们通过向爬虫传递cache回调,来检

    验DiskCache类。该类的完整源代码可以从 https:bitbucket.orgwswpcodesrctip

    chapter03disk_cache.py获取。我们可以通过执

    行如下脚本,使用链接爬虫测试磁盘缓存。

    time python disk_cache.py

    Downloading: http:example.webscraping.com

    Downloading: http:example.webscraping.comviewAfghanistan-1...

    Downloading: http:example.webscraping.comviewZimbabwe-252

    23m38.289s

    第一次执行该命令时,由于缓存为空,因此网

    页会被正常下载。但当我们第二次执行该脚本时,网页加载自缓存中,爬虫应该更快完成执行,其执

    行结果如下所示。

    time python disk_cache.py

    0m0.186s

    和上面的预期一样,爬取操作很快就完成了。

    当缓存为空时,我的计算机中的爬虫下载耗时超过

    23分钟;而在第二次全部使用缓存时,该耗时只有

    0.186秒(比第一次爬取快了超过7000倍!)。由于

    硬件的差异,在不同的计算机中的准确执行时间也

    会有所区别。不过毋庸置疑的是,磁盘缓存速度更

    快。

    3.2.3 节省磁盘空间

    为了最小化缓存所需的磁盘空间,我们可以对

    下载得到的HTML文件进行压缩处理。处理的实现

    方法很简单,只需在保存到磁盘之前使用zlib``压

    缩序列化字符串即可,如下面的代码所示。

    fp.write(zlib.compress(pickle.dumps(result)))而从磁盘加载后解压的代码如下所示。

    return pickle.loads(zlib.decompress(fp.read))

    `压缩完所有网页之后,缓存大小从4.4MB下降

    到2.3MB,而在我的计算机上爬取缓存示例网站的

    时间是0.212秒,和未压缩时的0.186秒相比只是略

    有增加。当然,如果你的项目对速度十分敏感的

    话,也可以禁用压缩功能。

    3.2.4 清理过期数据

    当前版本的磁盘缓存使用键值对的形式在磁盘

    上保存缓存,未来无论何时请求都会返回结果。对

    于缓存网页而言,该功能可能不太理想,因为网页

    内容随时都有可能发生变化,存储在缓存中的数据

    存在过期风险。本节中,我们将为缓存数据添加过

    期时间,以便爬虫知道何时需要重新下载网页。在

    缓存网页时支持存储时间戳的功能也很简单,如下

    面的代码所示。

    from datetime import datetime, timedelta

    class DiskCacke:

    def _init_(self, ..., expires=timedelta(days=30)):...

    self.expires = expires

    def _getitem_(self, url):

    Load data from disk for this URL ...

    with open(path, 'rb') as fp:

    result, timestamp =

    pickle.loads(zlib.decompress(fp.read))

    if self.has_expired(timestamp):

    raise KeyError(url + ' has expired')

    return result

    else:

    URL has not yet been cached

    raise KeyError(url + ' does not exist')

    def _setitem_(self, url, result):

    Save data to disk for this url

    ...

    timestamp = datetime.utcnow

    data = pickle.dumps((result, timestamp))

    with open(path, 'wb') as fp:

    fp.write(zlib.compress(data))

    def has_expired(self, timestamp):

    Return whether this timestamp has expired

    return datetime.utcnow > timestamp + self.expires

    在构造方法中,我们使用timedelta对象将默认

    过期时间设置为30天。然后,在__set__方法中,把当前时间戳保存到序列化数据中;而在__get__

    方法中,对比当前时间和缓存时间,检查是否过

    期。为了测试过期时间功能,我们可以将其缩短为

    5秒,如下所示。

    >>> cache = DiskCache(expires=timedelta(seconds=5))

    >>> url = 'http:example.webscraping.com'

    >>> result = {'html': '...'}

    >>> cache[url] = result

    >>> cache[url]

    {'html': '...'} >>> import time; time.sleep(5)

    >>> cache[url]

    Traceback (most recent call last):...

    KeyError: 'http:example.webscraping.com has expired'

    和预期一样,缓存结果最初是可用的,经过5

    秒的睡眠之后,再次调用同一URL,则会抛出

    KeyError异常,也就是说缓存下载失效了。

    3.2.5 缺点

    基于磁盘的缓存系统比较容易实现,无须安装

    其他模块,并且在文件管理器中就能查看结果。但

    是,该方法存在一个缺点,即受制于本地文件系统

    的限制。本章早些时候,为了将URL映射为安全文

    件名,我们应用了多种限制,然而这又会引发另一

    个问题,那就是一些URL会被映射为相同的文件

    名。比如,在对如下几个URL进行字符替换之后就

    会得到相同的文件名。

    http:example.com?a+b

    http:example.com?ab

    http:example.com?a=b

    http:example.com?a!b

    这就意味着,如果其中一个URL生成了缓存,其他3个URL也会被认为已经生成缓存,因为它们映射到了同一个文件名。另外,如果一些长URL只

    在255个字符之后存在区别,截断后的版本也会被

    映射为相同的文件名。这个问题非常重要,因为

    URL的最大长度并没有明确限制。尽管在实践中

    URL很少会超过2000个字符,并且早期版本的IE浏

    览器也不支持超过2083个字符的URL。

    避免这些限制的一种解决方案是使用URL的哈

    希值作为文件名。尽管该方法可以带来一定改善,但是最终还是会面临许多文件系统具有的一个关键

    问题,那就是每个卷和每个目录下的文件数量是有

    限制的。如果缓存存储在FAT32文件系统中,每个

    目录的最大文件数是65535。该限制可以通过将缓

    存分割到不同目录来避免,但是文件系统可存储的

    文件总数也是有限制的。我使用的ext4分区目前支

    持略多于1500万个文件,而一个大型网站往往拥有

    超过1亿个网页。很遗憾,DiskCache方法想要通用

    的话存在太多限制。要想避免这些问题,我们需要

    把多个缓存网页合并到一个文件中,并使用类

    似B+树的算法进行索引。我们并不会自己实现这种

    算法,而是在下一节中介绍已实现这类算法的数据

    库。3.3 数据库缓存

    为了避免磁盘缓存方案的已知限制,下面我们

    会在现有数据库系统之上创建缓存。爬取时,我们

    可能需要缓存大量数据,但又无须任何复杂的连接

    操作,因此我们将选用NoSQL数据库,这种数据库

    比传统的关系型数据库更易于扩展。在本节中,我

    们将会选用目前非常流行的MongoDB作为缓存数据

    库。

    3.3.1 NoSQL是什么

    NoSQL全称为Not Only SQL,是一种相对较

    新的数据库设计方式。传统的关系模型使用的是固

    定模式,并将数据分割到各个表中。然而,对于大

    数据集的情况,数据量太大使其难以存放在单一服

    务器中,此时就需要扩展到多台服务器。不过,关

    系模型对于这种扩展的支持并不够好,因为在查询

    多个表时,数据可能在不同的服务器中。相反,NoSQL数据库通常是无模式的,从设计之初就考虑

    了跨服务器无缝分片的问题。在NoSQL中,有多种

    方式可以实现该目标,分别是列数据存储(如

    HBase)、键值对存储(如Redis)、面向文档的数

    据库(如MongoDB)以及图形数据库(如Neo4j)。

    3.3.2 安装MongoDB

    MongoDB可以从

    https:www.mongodb.orgdownloads下载得到。然

    后,我们需要使用如下命令额外安装其Python封装

    库。

    pip install pymongo

    要想检测安装是否成功,可以使用如下命令在

    本地启动MongoDB。

    mongod -dbpath .

    然后,在Python中,使用MongoDB的默认端口

    尝试连接MongoDB。

    >>> from pymongo import MongoClient

    >>> client = MongoClient('localhost', 27017)

    3.3.3 MongoDB概述

    下面是通过MongoDB存取数据的示例代码。

    >>> url = 'http:example.webscraping.comviewUnited-Kingdom-239'

    >>> html = '...' >>> db = client.cache

    >>> db.webpage.insert({'url': url, 'html': html})

    ObjectId('5518c0644e0c87444c12a577')

    >>> db.webpage.find_one(url=url)

    {u'_id': ObjectId('5518c0644e0c87444c12a577'),u'html': u'...',u'url': u'http:example.webscraping.comviewUnited-Kingdom-239'}

    上面的例子存在一个问题,那就是如果我们对

    相同的URL插入另一条不同的文档时,MongoDB会

    欣然接受并执行这次插入操作,其执行过程如下所

    示。

    >>> db.webpage.insert({'url': url, 'html': html})

    >>> db.webpage.find(url=url).count

    2

    此时,同一URL下出现了多条记录,但我们只

    关心最新存储的那条数据。为了避免重复,我们将

    ID设置为URL,并执行upsert操作。该操作表示当

    记录存在时更新记录,否则插入新记录,其代码如

    下所示。

    >>> db.webpage.update({'_id': url}, {'set': {'html': html}},upsert=True)

    >>> db.webpage.find_one({'_id': url})

    {u'_id': u'http:example.webscraping.comview

    United-Kingdom-239', u'html': u'...'}

    现在,当我们尝试向同一URL插入记录时,将

    会更新其内容,而不是创建冗余的数据,如下面的

    代码所示。 >>> new_html = ''

    >>> db.webpage.update({'_id': url}, {'set': {'html': new_

    html}}, upsert=True)

    >>> db.webpage.find_one({'_id': url})

    {u'_id': u'http:example.webscraping.comviewUnited-Kingdom-239',u'html': u''}

    >>> db.webpage.find({'_id': url}).count

    1

    可以看出,在添加了这条记录之后,虽然

    HTML的内容更新了,但该URL的记录数仍然是

    1。

    MongoDB官方文档可参考`http:docs.mongodb.orgmanual`,在该文档

    中可以找到上述功能及一些其他功能更详细的介绍。

    3.3.4 MongoDB缓存实现

    现在我们已经准备好创建基于MongoDB的缓

    存了,这里使用了和之前的DiskCache类相同的类

    接口。

    from datetime import datetime, timedelta

    from pymongo import MongoClient

    class MongoCache:

    def __init__(self, client=None, expires=timedelta(days=30)):

    if a client object is not passed then try

    connecting to mongodb at the default localhost port

    self.client = MongoClient('localhost', 27017)

    if client is None else client

    create collection to store cached webpages, which is the equivalent of a table

    in a relational database

    self.db = client.cache

    create index to expire cached webpages

    self.db.webpage.create_index('timestamp',expireAfterSeconds=expires.total_seconds)

    def __getitem__(self, url):

    Load value at this URL

    record = self.db.webpage.find_one({'_id': url})

    if record:

    return record['result']

    else:

    raise KeyError(url + ' does not exist')

    def __setitem__(self, url, result):

    Save value for this URL

    record = {'result': result, 'timestamp':

    datetime.utcnow}

    self.db.webpage.update({'_id': url}, {'set': record},upsert=True)

    在上一节讨论如何避免冗余时,你已经见过这

    里的__getitem__和__setitem__方法的实现了。此

    外,我们在构造方法中创建了timestamp索引。在

    达到给定时间戳一定秒数之后,MongoDB的这一便

    捷功能可以自动删除记录。这样我们就无须再像

    DiskCache类那样,手工检查记录是否仍然有效

    了。下面我们使用空的timedelta对象进行测试,此时记录在创建后就会被立即删除。

    >>> cache = MongoCache(expires=timedelta)

    >>> result = {'html': '…'}

    >>> cache[url] = result

    >>> cache[url]

    {'html': '…'}记录还在这里,看起来好像我们的缓存过期机

    制没能正常运行。但实际上这是MongoDB的运行机

    制造成的。MongoDB运行了一个后台任务,每分钟

    检查一次过期记录,所以此时该记录还没有被删

    除。让我们再等1分钟,就会发现缓存过期机制已

    经运行成功了。

    >>> import time; time.sleep(60)

    >>> cache[url]

    Traceback (most recent call last):...

    KeyError: 'http:example.webscraping.comviewUnited-Kingdom-239

    does not exist'

    这种机制下,MongoDB缓存无法按照给定时

    间精确清理过期记录,会存在至多1分钟的延时。

    不过,由于缓存过期时间通常设定为几周或是几个

    月,所以这个相对较小的延时不会存在太大问题。

    3.3.5 压缩

    为了使数据库缓存与之前的磁盘缓存功能一

    致,我们最后还要添加一个功能:压缩。其实现方

    法和磁盘缓存相类似,即序列化数据后使用zlib库

    进行压缩,如下面的代码所示。

    import pickle

    import zlib

    from bson.binary import Binary class MongoCache:

    def __getitem__(self, url):

    record = self.db.webpage.find_one({'_id': url})

    if record:

    return pickle.loads(zlib.decompress(record['result']))

    else:

    raise KeyError(url + ' does not exist')

    def __setitem__(self, url, result):

    record = {

    'result': Binary(zlib.compress(pickle.dumps(result))),'timestamp': datetime.utcnow

    }

    self.db.webpage.update(

    {'_id': url}, {'set': record}, upsert=True)

    3.3.6 缓存测试

    MongoCache类的源码可以从 https:bitbucket.orgwswpcode

    srctipchapter03mongo_cache.py获取,和

    DiskCache一样,这里我们依然通过执行该脚本测

    试链接爬虫。

    time python mongo_cache.py

    http:example.webscraping.com

    http:example.webscraping.comviewAfghanistan-1...

    http:example.webscraping.comviewZimbabwe-252

    23m40.302s

    time python mongo_cache.py

    0.378s

    可以看出,加载数据库缓存的时间几乎是加载

    磁盘缓存的两倍。不过,MongoDB可以让我们免受文件系统的各种限制,还能在下一章介绍的并发爬

    虫处理中更加高效。3.4 本章小结

    本章中,我们了解到缓存已下载的网页可以节

    省时间,并能最小化重新爬取网站所耗费的带宽。

    缓存的主要缺点是会占用磁盘空间,不过我们可以

    使用压缩的方式减少空间占用。此外,在类似

    MongoDB等现有数据库的基础之上创建缓存,可以

    避免文件系统的各种限制。

    下一章,我们会为爬虫添加并发下载多个网页

    的功能,从而使爬虫运行得更快。第4章 并发下载

    在之前的章节中,我们的爬虫都是串行下载网

    页的,只有前一次下载完成之后才会启动新下载。

    在爬取规模较小的示例网站时,串行下载尚可应

    对,但面对大型网站时就会显得捉襟见肘了。在爬

    取拥有100万网页的大型网站时,假设我们以每秒

    一个网页的速度昼夜不停地下载,耗时也要超过11

    天。如果我们可以同时下载多个网页,那么下载时

    间将会得到显著改善。

    本章将介绍使用多线程和多进程这两种下载网

    页的方式,并将它们与串行下载的性能进行比较。4.1 100万个网页

    想要测试并发下载的性能,最好要有一个大型

    的目标网站。为此,本章将使用Alexa提供的最受

    欢迎的100万个网站列表,该列表的排名根据安装

    了Alexa工具栏的用户得出。尽管只有少数用户使

    用了这个浏览器插件,其数据并不权威,但对于我

    们这个测试来说已经足够了。

    我们可以通过浏览Alexa网站获取该数据,其

    网址为http:www. alexa.comtopsites。此外,我们也可以通过http:s3.amazonaws. comalexa-

    statictop-1m.csv.zip直接下载这一列表的压缩

    文件,这样就不用再去抓取Alexa网站的数据了。

    4.1.1 解析Alexa列表

    Alexa网站列表是以电子表格的形式提供的,表格中包含两列内容,分别是排名和域名,如图4.1

    所示。图4.1

    抽取数据包含如下4个步骤。

    1.下载.zip文件。

    2.从.zip文件中提取出CSV文件。

    3.解析CSV文件。

    4.遍历CSV文件中的每一行,从中抽取出域名

    数据。下面是实现上述功能的代码。

    import csv

    from zipfile import ZipFile

    from StringIO import StringIO

    from downloader import Downloader

    D = Downloader

    zipped_data = D('http:s3.amazonaws.comalexa-statictop-1m.csv.zip')

    urls = [] top 1 million URL's will be stored in this list

    with ZipFile(StringIO(zipped_data)) as zf:

    csv_filename = zf.namelist[0]

    for _, website in csv.reader(zf.open(csv_filename)):

    urls.append('http:' + website)

    你可能已经注意到,下载得到的压缩数据是在

    使用StringIO封装之后,才传给ZipFile的。这是

    因为ZipFile需要一个类似文件的接口,而不是字

    符串。接下来,我们从文件名列表中提取出CSV文

    件的名称。由于这个.zip文件中只包含一个文件,所以我们直接选择第一个文件名即可。然后遍历该

    CSV文件,将第二列中的域名数据添加到URL列表

    中。为了使URL合法,我们还会在每个域名前添

    加http:协议。

    要想在之前开发的爬虫中复用上述功能,还需

    要修改scrape_callback接口。

    class AlexaCallback:

    def __init__(self, max_urls=1000):

    self.max_urls = max_urls

    self.seed_url = 'http:s3.amazonaws.comalexa-static

    top-1m.csv.zip' def __call__(self, url, html):

    if url == self.seed_url:

    urls = []

    with ZipFile(StringIO(html)) as zf:

    csv_filename = zf.namelist[0]

    for _, website in

    csv.reader(zf.open(csv_filename)):

    urls.append('http:' + website)

    if len(urls) == self.max_urls:

    break

    return urls

    这里添加了一个新的输入参数max_urls,用于

    设定从Alexa文件中提取的URL数量。默认情况

    下,该值被设置为1000个URL,这是因为下载100

    万个网页的耗时过长(正如本章开始时提到的,串

    行下载需要花费超过11天的时间)。4.2 串行爬虫

    下面是串行下载时,之前开发的链接爬虫使用

    AlexaCallback``回调的代码。

    scrape_callback = AlexaCallback

    link_crawler(seed_url=scrape_callback.seed_url,cache_callback=MongoCache,scrape_callback=scrape_callback)

    完整源码可以从 https:bitbucket.orgwswpcodesrctip

    chapter04sequential_test.py获取,我们可以在

    命令行中执行如下命令运行该脚本。

    time python sequential_test.py...

    26m41.141s

    根据该执行结果估算,串行下载时平均每个

    URL需要花费1.6秒。4.3 多线程爬虫

    现在,我们将串行下载网页的爬虫扩展成并行

    下载。需要注意的是,如果滥用这一功能,多线程

    爬虫请求内容速度过快,可能会造成服务器过载,或是IP地址被封禁。为了避免这一问题,我们的爬

    虫将会设置一个delay标识,用于设定请求同一域

    名时的最小时间间隔。

    作为本章示例的Alexa网站列表由于包含了100

    万个不同的域名,因而不会出现上述问题。但是,当你以后爬取同一域名下的不同网页时,就需要注

    意两次下载之间至少需要1秒钟的延时。

    4.3.1 线程和进程如何工作

    图4.2所示为一个包含有多个线程的进程的执行

    过程。图4.2

    当运行Python脚本或其他计算机程序时,就会

    创建包含有代码和状态的进程。这些进程通过计算

    机的一个或多个CPU来执行。不过,同一时刻每个

    CPU只会执行一个进程,然后在不同进程间快速切

    换,这样就给人以多个程序同时运行的感觉。同

    理,在一个进程中,程序的执行也是在不同线程间

    进行切换的,每个线程执行程序的不同部分。这就

    意味着当一个线程等待网页下载时,进程可以切换到其他线程执行,避免浪费CPU时间。因此,为了

    充分利用计算机中的所有资源尽可能快地下载数

    据,我们需要将下载分发到多个进程和线程中。

    4.3.2 实现

    幸运的是,在Python中实现多线程编程相对来

    说比较简单。我们可以保留与第1章开发的链接爬

    虫类似的队列结构,只是改为在多个线程中启动爬

    虫循环,以便并行下载这些链接。下面的代码是修

    改后的链接爬虫起始部分,这里把crawl循环移到

    了函数内部。

    import time

    import threading

    from downloader import Downloader

    SLEEP_TIME = 1

    def threaded_crawler(..., max_threads=10):

    the queue of URL's that still need to be crawled

    crawl_queue = [seed_url]

    the URL's that have been seen

    seen = set([seed_url])

    D = Downloader(cache=cache, delay=delay,user_agent=user_agent, proxies=proxies,num_retries=num_retries, timeout=timeout)

    def process_queue:

    while True:

    try:

    url = crawl_queue.pop

    except IndexError:

    crawl queue is empty

    break

    else: html = D(url)...

    下面是threaded_crawler函数的剩余部分,这

    里在多个线程中启动了process_queue,并等待其

    完成。

    threads = []

    while threads or crawl_queue:

    the crawl is still active

    for thread in threads:

    if not thread.is_alive:

    remove the stopped threads

    threads.remove(thread)

    while len(threads) < max_threads and crawl_queue:

    can start some more threads

    thread = threading.Thread(target=process_queue)

    set daemon so main thread can exit when receives ctrl-c

    thread.setDaemon(True)

    thread.start

    threads.append(thread)

    all threads have been processed

    sleep temporarily so CPU can focus execution elsewhere

    time.sleep(SLEEP_TIME))

    当有URL可爬取时,上面代码中的循环会不断

    创建线程,直到达到线程池的最大值。在爬取过程

    中,如果当前队列中没有更多可以爬取的URL时,线程会提前停止。假设我们有2个线程以及2个待下

    载的URL。当第一个线程完成下载时,待爬取队列

    为空,因此该线程退出。第二个线程稍后也完成了

    下载,但又发现了另一个待下载的URL。此时

    thread循环注意到还有URL需要下载,并且线程数未达到最大值,因此又会创建一个新的下载线程。

    对threaded_crawler接口的测试代码可以从 https:bitbucket.

    orgwswpcodesrctipchapter04threaded_test.py

    获取。现在,让我们使用如下命令,测试多线程版

    本链接爬虫的性能。

    time python threaded_test.py 5...

    4m50.465s

    由于我们使用了5个线程,因此下载速度几乎

    是串行版本的5倍。在4.4节中会对多线程性能进行

    更进一步的分析。

    4.3.3 多进程爬虫

    为了进一步改善性能,我们对多线程示例再度

    扩展,使其支持多进程。目前,爬虫队列都是存储

    在本地内存当中,其他进程都无法处理这一爬虫。

    为了解决该问题,需要把爬虫队列转移到MongoDB

    当中。单独存储队列,意味着即使是不同服务器上

    的爬虫也能够协同处理同一个爬虫任务。

    请注意,如果想要拥有更加健壮的队列,则需

    要考虑使用专用的消息传输工具,比如Celery。不过,为了尽量减少本书中介绍的技术种类,我们在

    这里选择复用MongoDB。下面是基于MongoDB实

    现的队列代码。

    from datetime import datetime, timedelta

    from pymongo import MongoClient, errors

    class MongoQueue:

    possible states of a download

    OUTSTANDING, PROCESSING, COMPLETE = range(3)

    def __init__(self, client=None, timeout=300):

    self.client = MongoClient if client is None else client

    self.db = self.client.cache

    self.timeout = timeout

    def __nonzero__(self):

    Returns True if there are more jobs to process

    record = self.db.crawl_queue.find_one(

    {'status': {'ne': self.COMPLETE}})

    return True if record else False

    def push(self, url):

    Add new URL to queue if does not exist

    try:

    self.db.crawl_queue.insert({'_id': url, 'status':

    self.OUTSTANDING})

    except errors.DuplicateKeyError as e:

    pass this is already in the queue

    def pop(self):

    Get an outstanding URL from the queue and set its

    status to processing. If the queue is empty a KeyError

    exception is raised.

    record = self.db.crawl_queue.find_and_modify(

    query={'status': self.OUTSTANDING},update={'set': {'status': self.PROCESSING,'timestamp': datetime.now}} )

    if record:

    return record['_id']

    else:

    self.repair

    raise KeyError

    def complete(self, url):

    self.db.crawl_queue.update({'_id': url}, {'set':

    {'status': self.COMPLETE}})

    def repair(self):

    Release stalled jobs

    record = self.db.crawl_queue.find_and_modify(

    query={

    'timestamp': {'lt': datetime.now -

    timedelta(seconds=self.timeout)},'status': {'ne': self.COMPLETE}

    },update={'set': {'status': self.OUTSTANDING}})

    if record:

    print 'Released:', record['_id']

    上面代码中的队列定义了3种状

    态:OUTSTANDING、PROCESSING和COMPLETE。当添

    加一个新URL时,其状态为OUTSTANDING;当URL

    从队列中取出准备下载时,其状态为PROCESSING;

    当下载结束后,其状态为COMPLETE。该实现中,大

    部分代码都在关注从队列中取出的URL无法正常完

    成时的处理,比如处理URL的进程被终止的情况。

    为了避免丢失这些URL的结果,该类使用了一

    个timeout参数,其默认值为300秒。在repair方

    法中,如果某个URL的处理时间超过了这

    个timeout值,我们就认定处理过程出现了错误,URL的状态将被重新设为OUTSTANDING,以便再次

    处理。

    为了支持这个新的队列类型,还需要对多线程

    爬虫的代码进行少量修改,下面的代码中已经对修

    改部分进行了加粗处理。

    def threaded_crawler(...):...

    the queue of URL's that still need to be crawled

    crawl_queue = MongoQueue

    crawl_queue.push(seed_url)

    def process_queue:

    while True:

    keep track that are processing url

    try:

    url = crawl_queue.pop

    except KeyError:

    currently no urls to process

    break

    else:...

    crawl_queue.complete(url)

    第一个改动是将Python内建队列替换成基于

    MongoDB的新队列,这里将其命名为MongoQueue。

    由于该队列会在内部实现中处理重复URL的问题,因此不再需要seen变量。最后,在URL处理结束后

    调用complete方法,用于记录该URL已经被成功

    解析。

    更新后的多线程爬虫还可以启动多个进程,如下面的代码所示。

    import multiprocessing

    def process_link_crawler(args, kwargs):

    num_cpus = multiprocessing.cpu_count

    print 'Starting {} processes'.format(num_cpus)

    processes = []

    for i in range(num_cpus):

    p = multiprocessing.Process(target=threaded_crawler,args=[args], kwargs=kwargs)

    p.start

    processes.append(p)

    wait for processes to complete

    for p in processes:

    p.join

    这段代码的结构看起来十分熟悉,因为多进程

    模块和之前使用的多线程模块接口相似。这段代码

    中首先获取可用CPU的个数,在每个新进程中启动

    多线程爬虫,然后等待所有进程完成执行。

    现在,让我们使用如下命令,测试多进程版本

    链接爬虫的性能。测试process_link_crawler的接

    口和之前测试多线程爬虫时一样,可以从 https:bitbucket.orgwswpcodesrctipchapter04

    process_test.py获取。

    time python process_test.py 5

    Starting 2 processes...

    2m5.405s通过脚本检测,测试服务器包含2个CPU,运

    行时间大约是之前使用单一进程执行多线程爬虫时

    的一半。在下一节中,我们将进一步研究这三种方

    式的相对性能。4.4 性能

    为了进一步了解增加线程和进程的数量会如何

    影响下载时间,我们对爬取1000个网页时的结果进

    行了对比,如表4.1所示。

    表4.1

    脚本 线程数 进程数 时间 相对串行的时间比

    串行 1 1 28分59.966秒 1

    多线程 5 1 7分11.634秒 4.03

    多线程 10 1 3分50.455秒 7.55

    多线程 20 1 2分45.412秒 10.52

    多进程 5 2 4分2.624秒 7.17

    多进程 10 2 2分1.445秒 14.33

    多进程 20 2 1分47.663秒 16.16

    表格的最后一列给出的是相对于串行下载的时

    间比。可以看出,性能的增长与线程和进程的数量

    并不是成线性比例的,而是趋于对数。比如,使用1个进程和5个线程时,性能大约为串行时的4倍,而使用20个线程时性能只达到了串行下载时的10

    倍。虽然新增的线程能够加快下载速度,但是起到

    的效果相比于之前添加的线程会越来越小。其实这

    是可以预见到的现象,因为此时进程需要在更多线

    程之间进行切换,专门用于每一个线程的时间就会

    变少。此外,下载的带宽是有限的,最终添加新线

    程将无法带来更快的下载速度。因此,要想获得更

    好的性能,就需要在多台服务器上分布式部署爬

    虫,并且所有服务器都要指向同一个MongoDB队列

    实例。4.5 本章小结

    本章中,我们介绍了串行下载存在瓶颈的原

    因,然后给出了通过多线程和多进程高效下载大量

    网页的方法。

    下一章中,我们将介绍如何抓取使用JavaScript

    动态加载内容的网页。第5章 动态内容

    根据联合国全球网站可访问性审计报告,73%

    的主流网站都在其重要功能中依赖JavaScript(参

    考http:www.un.orgesasocdevenable

    documentsexecsumnomensa.doc)。和单页面应用

    的简单表单事件不同,使用JavaScript时,不再是加

    载后立即下载所有页面内容。这样就会造成许多网

    页在浏览器中展示的内容不会出现在HTML源代码

    中,本书前面介绍的抓取技术也就无法正常运转

    了。对于这种依赖JavaScript的动态网站,本章将会

    介绍两种抓取其数据的方法,分别是:

    JavaScript逆向工程;

    渲染JavaScript。5.1 动态网页示例

    让我们来看一个动态网页的例子。示例网站有

    一个搜索表单,可以通过

    http:example.webscraping.comsearch进行访

    问,该页面用于查询国家。比如说,我们想要查找

    所有起始字母为A的国家,其搜索结果页面如图5.1

    所示。图5.1

    如果我们右键单击结果部分,使用Firebug查看

    元素(参见第2章),可以发现结果被存储在ID为“result”的div元素中,如图5.2所示。

    让我们尝试使用lxml模块抽取这些结果,这里

    用到的知识在第2章和第3章的Downloader类中都已

    经介绍过了。

    >>> import lxml.html

    >>> ......

您现在查看是摘要介绍页, 详见PDF附件(7403KB,261页)