“爬虫”的采集行动

“爬虫”的采集行动

{%}

作者/Ryan Mitchell

数据科学家、软件工程师,目前在波士顿LinkeDrive公司负责开发公司的API和数据分析工具。此前,曾在Abine公司构建网络爬虫和网络机器人。她经常做网络数据采集项目的咨询工作,主要面向金融和零售业。另著有Instant Web Scraping with Java

搜索引擎和网站每时每刻都在采集大量的信息,而用于采集信息的程序一般被称为网络爬虫(Wed crawler)、网络蜘蛛(Web spider)或者网络铲(Web scraper)。之所以有这样的称呼,是因为它可以沿着网络“爬行”,先“爬”到对应的网页上,然后把需要的信息“铲”下来。本质上就是一种递归方式。为了找到 URL 链接,它必须首先获取网页内容,检查这个页面的内容,再寻找另一个 URL,然后获取 URL 对应的网页内容,不断循环这一过程。

不过,值得注意的是:你不必一直这样重复地采集网页。如果你需要的数据都在一个页面上时,爬行一个页面就足以解决问题。使用网络爬虫的时候,必须非常谨慎地考虑需要消耗多少网络流量,还要尽力思考能不能让采集目标的服务器负载更低一些。

遍历单个域名

即使你没听说过“维基百科六度分隔理论”,也很可能听过“凯文 · 贝肯(Kevin Bacon)的六度分隔值游戏”。在这两个游戏中,都是把两个不相干的主题(维基百科里是用词条之间的连接,凯文 · 贝肯的六度分隔值游戏是用出现在同一部电影中的演员来连接)用一个总数不超过六条的主题连接起来(包括原来的两个主题)。

比如,埃里克 · 艾德尔和布兰登 · 弗雷泽都出现在电影《骑警杜德雷》里,布兰登 · 弗雷泽又和凯文 · 贝肯都出现在电影《我呼吸的空气》里。1因此,根据这两个条件,从埃里克· 艾德尔到凯文·贝肯的链条主题长度为 3。

1感谢 The Oracle of Bacon(http://oracleofbacon.org/index.php)的存在,满足了我对这类关系链的好奇心。

接下来,我们将创建一个项目实现“维基百科六度分隔理论”的查找方法。也就是说,实现从埃里克· 艾德尔的词条页面(https://en.wikipedia.org/wiki/Eric_Idle)开始,经过最少的链接点击次数找到凯文· 贝肯的词条页面(https://en.wikipedia.org/wiki/Kevin_Bacon)。

有一定Python编程基础的人应该已经知道如何写一段获取维基百科网站上任何页面并提取页面链接的 Python 代码:

from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen("http://en.wikipedia.org/wiki/Kevin_Bacon")
bsObj = BeautifulSoup(html)
for link in bsObj.findAll("a"):
    if 'href' in link.attrs:
        print(link.attrs['href'])

观察生成的一列链接,就会看到我们想要的所有词条链接都在里面:“Apollo 13”“Philadelphia”和“Primetime Emmy Award”,等等。但是,也有一些我们不需要的链接://wikimediafoundation.org/wiki/Privacy_policy //en.wikipedia.org/wiki/Wikipedia:Contact_us

其实维基百科的每个页面都充满了侧边栏、页眉、页脚链接,以及连接到分类页面、对话页面和其他不包含词条的页面的链接:/wiki/Category:Articles_with_unsourced_statements_from_April_2014 /wiki/Talk:Kevin_Bacon

最近我有个朋友在做一个类似维基百科采集这样的项目,他说为了判断维基百科的内链是否链接到一个词条,他写了一个很大的过滤函数,超过 100 行代码。不幸的是,可能在项目启动的时候,他没有花时间去比较“词条链接”和“其他链接”的差异,也可能他后来发现了那个技巧。如果你仔细观察那些指向词条页面(不是指向其他内容页面)的链接,会发现它们都有的三个共同点:

  • 它们都在idbodyContentdiv标签里

  • URL 链接不包含冒号

  • URL 链接都以 /wiki/ 开头

我们可以利用这些规则稍微调整一下代码来获取词条链接:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

html = urlopen("http://en.wikipedia.org/wiki/Kevin_Bacon")
bsObj = BeautifulSoup(html)
for link in bsObj.find("div", {"id":"bodyContent"}).findAll("a",
                  href=re.compile("^(/wiki/)((?!:).)*$")):
    if 'href' in link.attrs:
        print(link.attrs['href'])

运行代码,就会看到维基百科上凯文·贝肯词条里所有指向其他词条的链接。

当然,写程序来找出这个静态的维基百科词条里所有的词条链接很有趣,不过没什么实际用处。我们需要让这段程序更像下面的形式。

  • 一个函数getLinks,可以用维基百科词条 /wiki/< 词条名称 > 形式的 URL 链接作为参数,然后以同样的形式返回一个列表,里面包含所有的词条 URL 链接。

  • 一个主函数,以某个起始词条为参数调用getLinks,再从返回的 URL 列表里随机选择一个词条链接,再调用getLinks,直到我们主动停止,或者在新的页面上没有词条链接了,程序才停止运行。

完整的代码如下所示:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import datetime
import random
import re

random.seed(datetime.datetime.now())
def getLinks(articleUrl):
    html = urlopen("http://en.wikipedia.org"+articleUrl)
    bsObj = BeautifulSoup(html)
    return bsObj.find("div", {"id":"bodyContent"}).findAll("a",
                 href=re.compile("^(/wiki/)((?!:).)*$"))
links = getLinks("/wiki/Kevin_Bacon")
while len(links) > 0:
    newArticle = links[random.randint(0, len(links)-1)].attrs["href"]
    print(newArticle)
    links = getLinks(newArticle)

导入需要的 Python 库之后,程序首先做的是用系统当前时间生成一个随机数生成器。这样可以保证在每次程序运行的时候,维基百科词条的选择都是一个全新的随机路径。

伪随机数和随机数种子

在前面的示例中,为了能够连续地随机遍历维基百科,我用 Python 的随机数生成器随机选择每一页上的一个词条链接。但是,用随机数的时候需要格外小心。

虽然计算机很擅长做精确计算,但是它们处理随机事件时非常不靠谱。因此,随机数是一个难题。大多数随机数算法都努力创造一种呈均匀分布且难以预测的数据序列,但是在算法初始化阶段都需要提供随机数“种子”(random seed)。而完全相同的种子每次将产生同样的“随机”数序列,因此我用系统时间作为随机数序列生成的起点。这样做会让程序运行的时候更具有随机性。

其实,Python 的伪随机数(pseudorandom number)生成器用的是梅森旋转(Mersenne Twister)算法(https://en.wikipedia.org/wiki/Mersenne_Twister),它产生的随机数很难预测且呈均匀分布,就是有点儿耗费 CPU 资源。真正好的随机数可不便宜!

然后,我们定义getLinks 函数,其参数是维基百科词条页面中 /wiki/< 词条名称 > 形式的 URL 链接,前面加上维基百科的域名,http://en.wikipedia.org,再用域名中的网页获得一个 BeautifulSoup 对象。之后用前面介绍过的参数抽取一列词条链接所在的标签a并返回它们。

程序的主函数首先把起始页面 https://en.wikipedia.org/wiki/Kevin_Bacon 里的词条链接列表(links变量)设置成链接列表。然后用一个循环,从页面中随机找一个词条链接标签并抽取href属性,打印这个页面链接,再把这个链接传入getLinks函数,重新获取新的链接列表。

当然,这里只是简单地构建一个从一个页面到另一个页面的爬虫,要解决“维基百科六度分隔理论”问题还有一点儿工作得做,还应该存储 URL 链接数据并分析数据。

异常处理

为了方便起见,我们在这些示例中忽略了大多数异常处理过程,但是要注意问题随时可能发生。例如,维基百科改变了bodyContent标签的名称怎么办呢?(提示:那时代码就会崩溃。)

因此,这些脚本作为容易演示的示例也许可以运行得很不错,但是要真正成为自动化产品代码,还需要增加更多的异常处理。

采集整个网站

我们实现了在网站上随机地从一个链接跳到另一个链接。但是,如果需要系统地把整个网站按目录分类,或者要搜索网站上的每一个页面,怎么办?那就得采集整个网站。那是一种非常耗费内存资源的过程,尤其是处理大型网站时,最合适的工具就是用一个数据库来储存采集的资源。除了运行数据库,我们还可以掌握这类工具的行为,并不需要大规模地实际运行,就完成整个网站的数据采集。

深网和暗网

你可能听说过深网(deep Web)、暗网(dark Web)或隐藏网络(hidden Web)之类的术语,尤其是在最近的媒体中。它们是什么意思呢?

深网是网络的一部分,与浅网(surface Web)对立。浅网是互联网上搜索引擎可以抓到的那部分网络。据不完全统计,互联网中其实约 90% 的网络都是深网。因为谷歌不能做像表单提交这类的事情,也找不到那些没有直接链接到顶层域名上的网页,或者因为有 robots.txt 禁止而不能查看网站,所以浅网的数量相对深网还是比较少的。

暗网,也被称为 Darknet 或 dark Internet,完全是另一种“怪兽”。它们也建立在已有的网络基础上,但是使用 Tor 客户端,带有运行在 HTTP 之上的新协议,提供了一个信息交换的安全隧道。这类暗网页面也是可以采集的,就像你采集其他网站一样,不过这些内容超出了我所探讨的范围。

和暗网不同,深网是相对容易采集的。实际上,我提供的很多工具都专注于如何采集那些 Google 爬虫机器人不能获取的深网信息。

那么,什么时候采集整个网站是有用的,而什么时候采集整个网站又是有害无益的呢?遍历整个网站的网络数据采集有许多好处。

  • 生成网站地图

    几年前,我曾经遇到过一个问题:一个重要的客户想对一个网站的重新设计方案进行效果评估,但是不想让我们公司进入他们的网站内容管理系统(CMS),也没有一个公开可用的网站地图。我就用爬虫采集了整个网站,收集了所有的链接,再把所有的页面整理成他们网站实际的形式。这让我很快找出了网站上以前不曾留意的部分,并准确地计算出需要重新设计多少网页,以及可能需要移动多少内容。

  • 收集数据

    我的另一个客户为了创建一个专业垂直领域的搜索平台,想收集一些文章(故事、博文、新闻等)。虽然这些网站采集并不费劲,但是它们需要爬虫有足够的深度(我们有意收集数据的网站不多)。于是我就创建了一个爬虫递归地遍历每个网站,只收集那些网站页面上的数据。

一个常用却费时的网站采集方法就是从顶级页面开始(比如主页),搜索页面上的所有链接,然后形成列表。再去采集这些链接的每一个页面,然后把在每个页面上找到的链接形成新的列表,重复执行下一轮采集。

很明显,这是一个复杂度增长很快的情形。假如每个页面有 10 个链接,网站上有 5 个页面深度(一个中等规模网站的主流深度),那么采集整个网站,一共得采集的网页数量就是 105,即 100 000 个页面。不过,虽然“5 个页面深度,每页 10 个链接”是网站的主流配置,但其实很少有网站真的有 100 000 甚至更多的页面。这是因为很大一部分内链都是重复的。

为了避免一个页面被采集两次,链接去重是非常重要的。在代码运行时,把已发现的所有链接都放到一起,并保存在方便查询的列表里(下文示例指 Python 的集合set类型)。只有“新”链接才会被采集,之后再从页面中搜索其他链接:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

pages = set()
def getLinks(pageUrl):
    global pages
    html = urlopen("http://en.wikipedia.org"+pageUrl)
    bsObj = BeautifulSoup(html)
    for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
        if 'href' in link.attrs:
            if link.attrs['href'] not in pages:
                # 我们遇到了新页面
                newPage = link.attrs['href']
                print(newPage)
                pages.add(newPage)
                getLinks(newPage)

getLinks("")

为了全面地展示网络数据采集示例是如何工作的,我降低了在前面例子里使用的“只寻找内链”的标准。不再限制爬虫采集的页面范围,只要遇到页面就查找所有以 /wiki/ 开头的链接,也不考虑链接是不是包含分号。(提示:词条链接不包含冒号,而文档上传页面、讨论页面之类的页面 URL 链接都包含冒号。)

一开始,用getLinks处理一个空 URL,其实是维基百科的主页,因为在函数里空 URL 就是 http://en.wikipedia.org。然后,遍历首页上每个链接,并检查是否已经在全局变量集合pages里面了(已经采集的页面集合)。如果不在,就打印到屏幕上,并把链接加入pages集合,再用getLinks递归地处理这个链接。

关于递归的警告

递归警告在软件开发图书里很少提到,但是我觉得应该注意:如果递归运行的次数非常多,前面的递归程序就很可能崩溃。

Python 默认的递归限制(程序递归地自我调用次数)是 1000 次。因为维基百科的网络链接浩如烟海,所以这个程序达到递归限制后就会停止,除非你设置了一个较大的递归计数器,或用采用其他手段不让它停止。

对于那些链接深度少于 1000 的“普通”网站,这个方法通常可以正常运行,一些奇怪的异常除外。例如,我曾经遇到过一个网站,有一个在生成博文内链的规则。这个规则是“当前页面把 /blog/title_of_blog.php 加到它后面,作为本页面的 URL 链接”。

问题是它们可能会把 /blog/title_of_blog.php 加到一个已经有 /blog/ 的 URL 上面。因此,网站就多了一个 /blog/。最后,我的爬虫找到了这样的 URL 链接:/blog/blog/blog/blog.../blog/title_of_blog.php。

后来,我增加了一些条件,对可能导致无限循环的部分进行检查,确保那些 URL 不是这么荒谬。如果不去检查这些问题,爬虫很快就会崩溃。

收集整个网站数据

如果只是从一个页面跳到另一个页面,网络爬虫是非常无聊的。为了有效地使用它们,在用爬虫的时候我们需要在页面上做些事情。如何创建一个爬虫来收集页面标题、正文的第一个段落,以及编辑页面的链接(如果有的话)等。

和往常一样,决定如何做好这些事情的第一步就是先观察网站上的一些页面,然后拟定一个采集模式。通过观察几个维基百科页面,包括词条和非词条页面,比如隐私策略之类的页面,就会得出下面的规则。

  • 所有的标题(所有页面上,不论是词条页面、编辑历史页面还是其他页面)都是在h1 → span标签里,而且页面上只有一个h1标签。

  • 前面提到过,所有的正文文字都在div#bodyContent标签里。如果想更进一步获取第一段文字,可能用div#mw-content-text → p更好(只选择第一段的标签)。这个规则对所有页面都适用,除了文件页面(例如,https://en.wikipedia.org/wiki/File:Orbit_of_274301_Wikipedia.svg),页面不包含内容文字(content text)的部分内容。

  • 编辑链接只出现在词条页面上。如果有编辑链接,都位于li#ca-edit标签的li#ca-edit → span → a里面。

调整前面的代码,我们就可以建立一个爬虫和数据收集(至少是数据打印)的组合程序:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re

pages = set()
def getLinks(pageUrl):
    global pages
    html = urlopen("http://en.wikipedia.org"+pageUrl)
    bsObj = BeautifulSoup(html)
    try:
        print(bsObj.h1.get_text())
        print(bsObj.find(id="mw-content-text").findAll("p")[0])
        print(bsObj.find(id="ca-edit").find("span").find("a").attrs['href'])
    except AttributeError:
        print("页面缺少一些属性!不过不用担心!")

    for link in bsObj.findAll("a", href=re.compile("^(/wiki/)")):
        if 'href' in link.attrs:
            if link.attrs['href'] not in pages:
                # 我们遇到了新页面
                newPage = link.attrs['href']
                print("----------------\n"+newPage)
                pages.add(newPage)
                getLinks(newPage)
getLinks("")

这个for循环和原来的采集程序基本上是一样的(除了打印一条虚线来分离不同的页面内容之外)。

因为我们不可能确保每一页上都有所有类型的数据,所以每个打印语句都是按照数据在页面上出现的可能性从高到低排列的。也就是说,<h1>标题标签会出现在每一页上(只要能识别,无论哪一页都有),所以我们首先试着获取它的数据。正文内容会出现在大多数页面上(除了文件页面),因此是第二个获取的数据。“编辑”按钮只出现在标题和正文内容都已经获取的页面上,但不是所有这类页面上都有,所以我们最后打印这类数据。

不同模式应对不同需求

在一个异常处理语句中包裹多行语句显然是有点儿危险的。首先,你没法儿识别出究竟是哪行代码出现了异常。其次,如果有个页面没有前面的标题内容,却有“编辑”按钮,那么由于前面已经发生异常,后面的“编辑”按钮链接就不会出现。这种按照网站上信息出现的可能性高低进行排序的方法对许多网站都是可行的,偶而会丢失一点儿数据,只要保存详细的日志就不是什么问题了。

你可能还发现以前出现的所有的例子中,我们都没有“收集”那些“打印”出来的数据。显然,命令行里显示的数据是很难进一步处理的。

通过互联网采集

每次做网络数据采集的演讲时,总有人故意问我:“你怎么建一个谷歌网站?”我的回答通常会包含两点:“首先,你得有几十亿美元能够买得起世界上最大的数据仓库,并把它们隐秘地放在世界各地。其次,你得写一个网络爬虫。”

1994 年,两个斯坦福大学毕业生就是用一个陈旧的服务器和一个 Python 网络爬虫建立的谷歌。现在你应该知道,你已经正式拥有了成为下一个科技亿万富翁需要的工具!

说句实在话,网络爬虫位于许多新式的网络技术领域彼此交叉的中心地带,而且你使用它们也不需要一个大型数据仓库。要实现任何跨站的数据分析,只要构建出可以从互联网上无数网页里解析和储存数据的爬虫就可以了。

就像之前的例子一样,我们后面要建立的网络爬虫也是顺着链接从一个页面跳到另一个页面,描绘出一张网络地图。但是这一次,它们不再忽略外链,而是跟着外链跳转。我们想看看爬虫是不是可以记录我们浏览过的每一个页面上的信息,这将是一个新的挑战。相比我们之前做的单个域名采集,互联网采集要难得多——不同网站的布局迥然不同。这就意味着我们必须在要寻找的信息以及查找方式上都极具灵活性。

不知前方水深浅

如果已经掌握了解决“维基百科六度分隔理论”的方法,那么我们完全有可能从一个像芝麻街 http://www.sesamestreet.org/ 那样的网站开始,经过几跳到达一些非主流网站。

如果读者是小朋友,请在运作代码前咨询一下父母。对那些带有敏感题材或有宗教限制的人来说,某些网站是禁止浏览的,阅读代码示例没问题,但是运行代码的时候请格外小心。

允许爬虫随意跟随外链跳转之前,请问自己几个问题。

  • 我要收集哪些数据?这些数据可以通过采集几个已经确定的网站(永远是最简单的做法)完成吗?或者我的爬虫需要发现哪些我可能不知道的网站吗?

  • 当爬虫到了某个网站,它是立即顺着下一个出站链接跳到一个新网站,还是在网站上呆一会儿,深入采集网站的内容?

  • 有没有我不想采集的一类网站?我对非英文网站的内容感兴趣吗?

  • 如果网络爬虫引起了某个网站网管的怀疑,我如何避免法律责任?

几个灵活的 Python 函数组合起来,用不超过 50 行的代码就可以轻松实现不同类型的网络爬虫:

from urllib.request import urlopen
from urllib.parse import urlparse
from bs4 import BeautifulSoup
import re
import datetime
import random

pages = set()
random.seed(datetime.datetime.now())

# 获取页面所有内链的列表
def getInternalLinks(bsObj, includeUrl):
    includeUrl = urlparse(includeUrl).scheme+"://"+urlparse(includeUrl).netloc
    internalLinks = []
    # 找出所有以"/"开头的链接
    for link in bsObj.findAll("a", href=re.compile("^(/|.*"+includeUrl+")")):
        if link.attrs['href'] is not None:
            if link.attrs['href'] not in internalLinks:
                internalLinks.append(link.attrs['href'])
            else:
                internalLinks.append(link.attrs['href'])
     return internalLinks

# 获取页面所有外链的列表
def getExternalLinks(bsObj, excludeUrl):
    externalLinks = []
    # 找出所有以"http"或"www"开头且不包含当前URL的链接
    for link in bsObj.findAll("a",
                         href=re.compile("^(http|www)((?!"+excludeUrl+").)*$")):
        if link.attrs['href'] is not None:
            if link.attrs['href'] not in externalLinks:
                externalLinks.append(link.attrs['href'])
    return externalLinks

def getRandomExternalLink(startingPage):
    html = urlopen(startingPage)
    bsObj = BeautifulSoup(html)
    externalLinks = getExternalLinks(bsObj, splitAddress(startingPage)[0])
    if len(externalLinks) == 0:
        print("No external links, looking around the site for one")
        domain = urlparse(startingPage).scheme+"://"+urlparse(startingPage).netloc
        internalLinks = getInternalLinks(bsObj, domain)
        internalLinks = getInternalLinks(startingPage)
        return getNextExternalLink(internalLinks[random.randint(0,
                                  len(internalLinks)-1)])
    else:
        return externalLinks[random.randint(0, len(externalLinks)-1)]

def followExternalOnly(startingSite):
    externalLink = getRandomExternalLink(startingSite)
    print("Random external link is: "+externalLink)
    followExternalOnly(externalLink)

followExternalOnly("http://oreilly.com")

上面这个程序从 http://oreilly.com 开始,随机地从一个外链跳到另一个外链。输出的结果如下所示:

随机外链是:http://igniteshow.com/

随机外链是:http://feeds.feedburner.com/oreilly/news

随机外链是:http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=q319

随机外链是:http://makerfaire.com/

网站首页上并不能保证一直能发现外链。为了能够发现外链,就需要用一种类似前面案例中使用的采集方法,即递归地深入一个网站直到找到一个外链才停止。

下图把程序操作可视化成了一个流程图。

{%}

从互联网上不同的网站采集外链的程序流程图

不要把示例程序放进产品代码

我本想把代码写得更完整,但考虑到空间和可读性问题,示例程序没有包含真实产品代码中必须有的检查和异常处理。

例如,如果爬虫遇到一个网站里面一个外链都没有(虽然不太可能,但是如果程序运行的时候够长总会遇到这类情况),这时程序就会一直在这个网站运行跳不出去,直到递归到达 Python 的限制为止。

在以任何正式的目的运行代码之前,请确认你已经在可能出现问题的地方都放置了检查语句。

把任务分解成像“获取页面上所有外链”这样的小函数是不错的做法,以后可以方便地修改代码以满足另一个采集任务的需求。例如,如果我们的目标是采集一个网站所有的外链,并且记录每一个外链,我们可以增加下面的函数:

# 收集网站上发现的所有外链列表
allExtLinks = set()
allIntLinks = set()
def getAllExternalLinks(siteUrl):
html = urlopen(siteUrl)
bsObj = BeautifulSoup(html)
internalLinks = getInternalLinks(bsObj,splitAddress(siteUrl)[0])
externalLinks = getExternalLinks(bsObj,splitAddress(siteUrl)[0])
for link in externalLinks:
    if link not in allExtLinks:
        allExtLinks.add(link)
        print(link)
for link in internalLinks:
    if link not in allIntLinks:
        print("即将获取链接的URL是:"+link)
        allIntLinks.add(link)
        getAllExternalLinks(link)

getAllExternalLinks("http://oreilly.com")

这段代码可以看出两个循环——一个是收集内链,一个是收集外链——然后彼此连接起来工作,程序的流程如下图所示。

{%}

图:收集内链和外链的程序流程图

写代码之前拟个大纲或画个流程图是很好的编程习惯,这么做不仅可以为后期处理节省很多时间,更重要的是,可以防止自己在爬虫变得越来越复杂时乱了分寸。

处理网页重定向

重定向(redirect)允许一个网页在不同的域名下显示。重定向有两种形式:

  • 服务器端重定向,网页在加载之前先改变了 URL;

  • 客户端重定向,有时你会在网页上看到“10 秒钟后页面自动跳转到……”之类的消息,表示在跳转到新 URL 之前网页需要加载内容。

这里涉及服务器端重定向的内容。服务器端重定向通常不用太过担心。如果你在用 Python 3.x 版本的urllib库,它会自动处理重定向。不过要注意,有时候你要采集的页面的 URL 可能并不是你当前所在页面的 URL。

用Scrapy采集

编写网络爬虫的挑战之一是,你经常需要不断地重复一些简单任务:找出页面上的所有链接,区分内链与外链,跳转到新的页面。掌握这些基本模式非常有用,从零开始编写也完全可行,不过有几个工具可以帮你自动处理这些细节。

Scrapy 就是一个可以大幅度降低网页链接查找和识别工作复杂度的 Python 库,它可以让我们轻松地采集一个或多个域名的信息。不过目前 Scrapy 仅支持 Python 2.7,还不支持 Python 3.x。

当然在一台机器上同时使用多个版本的 Python 是没有问题的(比如,同时安装 Python 2.7 和 Python 3.4)。如果你既想用 Scrapy 做项目,又想用 Python 3.4 写程序,完全没问题。

Scrapy 网站提供了最新版工具的下载页面(http://scrapy.org/download/),也可以用 pip 等第三方安装包安装。记住 Python 的版本必须是 2.7(2.6 和 3.x 都不兼容),而且运行所有使用 Scrapy 的程序也必须在 Python 2.7 环境下。

虽然写 Scrapy 爬虫很简单,但完成一个爬虫还是需要一些设置。如果在当前目录下创建新的 Scrapy 项目,就执行下面的代码:

$scrapy startproject wikiSpider

wikiSpider是新项目的名称。在当前目录中会新建一个名称也是 wikiSpider 的项目文件夹。文件夹的目录结构如下所示:

  • scrapy.cfg
    — wikiSpider
    — __init.py__
    — items.py
    — pipelines.py
    — settings.py
    — spiders
    — __init.py__

为了创建一个爬虫,我们需要在 wikiSpider/wikiSpider/spiders/ 文件夹里增加一个 articleSpider.py 文件。另外,在 items.py 文件中,我们需要定义一个Article类。

你的 items.py 文件应该像下面这样(Scrapy 自动生成的注释内容可以保留,当然也可以删除):

# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# http://doc.scrapy.org/en/latest/topics/items.html

from scrapy import Item, Field

class Article(Item):
    # define the fields for your item here like:
    # name = scrapy.Field()
    title = Field()

Scrapy 的每个Item(条目)对象表示网站上的一个页面。当然,你可以根据需要定义不同的条目(比如urlcontentheader image等),但是现在我只演示收集每页的title字段(field)。

在新建的 articleSpider.py 文件里面,写如下代码:

from scrapy.selector import Selector
from scrapy import Spider
from wikiSpider.items import Article


class ArticleSpider(Spider):
    name="article"
    allowed_domains = ["en.wikipedia.org"]
    start_urls = ["http://en.wikipedia.org/wiki/Main_Page",
              "http://en.wikipedia.org/wiki/Python_%28programming_language%29"]

    def parse(self, response):
        item = Article()
        title = response.xpath('//h1/text()')[0].extract()
        print("Title is: "+title)
        item['title'] = title
        return item

这个类的名称(ArticleSpider)与爬虫文件的名称(wikiSpider)是不同的,这个类只是 wikiSpider 目录里的一员,仅仅用于维基词条页面的采集。对一些信息类型较多的大网站,你可能会为每种信息(如博客的博文、图书出版发行信息、专栏文章等)设置独立的 Scrapy 条目,每个条目都有不同的字段,但是所有条目都在同一个 Scrapy 项目里运行。

你可以在 wikiSpider 主目录中用如下命令运行ArticleSpider

$ scrapy crawl article

这行命令会用条目名称article来调用爬虫(不是类名,也不是文件名,而是由ArticleSpidername = "article"决定的)。

陆续出现的调试信息中应该会有两行结果:

Title is: Main Page
Title is: Python (programming language)

这个爬虫先进入start_urls里面的两个页面,收集信息,然后停止。虽然这个爬虫很简单,但是如果你有许多 URL 需要采集,Scrapy 这种用法会非常适合。为了让爬虫更加完善,你需要定义一些规则让 Scrapy 可以在每个页面查找 URL 链接:

from scrapy.contrib.spiders import CrawlSpider, Rule
from wikiSpider.items import Article
from scrapy.contrib.linkextractors.sgml import SgmlLinkExtractor

class ArticleSpider(CrawlSpider):
    name="article"
    allowed_domains = ["en.wikipedia.org"]
    start_urls = ["http://en.wikipedia.org/wiki/Python_
                   %28programming_language%29"]
    rules = [Rule(SgmlLinkExtractor(allow=('(/wiki/)((?!:).)*$'),),
                                       callback="parse_item", follow=True)]

    def parse_item(self, response):
        item = Article()
        title = response.xpath('//h1/text()')[0].extract()
        print("Title is: "+title)
        item['title'] = title
        return item

虽然这个爬虫和前面那个爬虫的启动命令一样,但是如果你不用 Ctrl+C 中止程序,它是不会停止的(很长时间也不会停止)。

Scrapy 日志处理

Scrapy 生成的调试信息非常有用,但是通常太罗嗦。你可以在 Scrapy 项目中的 setting.py 文件中设置日志显示层级:

LOG_LEVEL = 'ERROR'

Scrapy 日志有五种层级,按照范围递增顺序排列如下:

  • CRITICAL

  • ERROR

  • WARNING

  • DEBUG

*INFO

如果日志层级设置为ERROR,那么只有CRITICALERROR日志会显示出来。如果日志层级设置为INFO,那么所有信息都会显示出来,其他同理。

日志不仅可以显示在终端,也可以通过下面命令输出到一个独立的文件中:

$ scrapy crawl article -s LOG_FILE=wiki.log

如果目录中没有 wiki.log,运行程序会创建一个新文件,然后把所有的日志都保存到里面。如果已经存在,会在原文后面加入新的日志内容。

Scrapy 用Item对象决定要从它浏览的页面中提取哪些信息。Scrapy 支持用不同的输出格式来保存这些信息,比如 CSV、JSON 或 XML 文件格式,对应命令如下所示:

$ scrapy crawl article -o articles.csv -t csv
$ scrapy crawl article -o articles.json -t json
$ scrapy crawl article -o articles.xml -t xml

当然,你也可以自定义Item对象,把结果写入你需要的一个文件或数据库中,只要在爬虫的parse部分增加相应的代码即可。

Scrapy 是处理网络数据采集相关问题的利器。它可以自动收集所有 URL,然后和指定的规则进行比较;确保所有的 URL 是唯一的;根据需求对相关的 URL 进行标准化;以及到更深层的页面中递归查找。

尽管这点内容只碰到了 Scrapy 强大功能的一角,但我依然鼓励你去学习 Scrapy 文档(http://doc.scrapy.org/en/latest/)和其他在线的学习资源。Scrapy 的内容非常丰富,具有很多特性。

本文节选自《Python网络数据采集》

 

{%}

《Python网络数据采集》中不仅介绍了网络数据采集的基本原理,还深入探讨了更高级的主题,比如分析原始数据、用网络爬虫测试网站等。此外,书中还提供了详细的代码示例,以帮助你更好地理解书中的内容。