Python-网络爬取第三版-二-

81 阅读32分钟

Python 网络爬取第三版(二)

原文:annas-archive.org/md5/3c359a3a3947ea27259c8eac15f155d2

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:写作网络爬虫

到目前为止,你已经看到了一些带有人为干预的静态页面示例。在本章中,你将开始研究真实世界中的问题,使用爬虫来遍历多个页面,甚至是多个站点。

网络爬虫之所以被称为如此,是因为它们横跨整个网络。它们的核心是递归的元素。它们必须获取 URL 的页面内容,检查该页面中的其他 URL,并递归地获取那些页面。

但要注意:仅仅因为你可以爬取网页,并不意味着你总是应该这样做。在前面的示例中使用的爬虫在需要获取的数据都在单个页面上的情况下效果很好。使用网络爬虫时,你必须非常注意你使用了多少带宽,并且要尽一切努力确定是否有办法减轻目标服务器的负载。

遍历单个域

即使你没有听说过维基百科的六度分隔,你可能听说过它的名字起源,即凯文·贝肯的六度分隔¹。在这两个游戏中,目标是通过包含不超过六个总数的链条(包括两个初始主题)来链接两个不太可能的主题(在第一种情况下,是互相链接的维基百科文章;在第二种情况下,是出演同一部电影的演员)。

例如,埃里克·艾德尔与布伦丹·弗雷泽一起出演了Dudley Do-Right,而布伦丹·弗雷泽又与凯文·贝肯一起出演了我呼吸的空气²。在这种情况下,从埃里克·艾德尔到凯文·贝肯的链只有三个主题。

在本节中,你将开始一个项目,这个项目将成为一个维基百科六度分隔解决方案的发现者:你将能够从埃里克·艾德尔页面出发,找到需要最少的链接点击数将你带到凯文·贝肯页面

你应该已经知道如何编写一个 Python 脚本,用于获取任意维基百科页面并生成该页面上的链接列表:

from urllib.request import urlopen
from bs4 import BeautifulSoup 

html = urlopen('http://en.wikipedia.org/wiki/Kevin_Bacon')
bs = BeautifulSoup(html, 'html.parser')
for link in bs.find_all('a'):
    if 'href' in link.attrs:
        print(link.attrs['href'])

如果你查看生成的链接列表,你会注意到所有你期望的文章都在那里:阿波罗 13 号费城黄金时段艾美奖,以及凯文·贝肯出演的其他电影。然而,也有一些你可能不想要的东西:

//foundation.wikimedia.org/wiki/Privacy_policy
//en.wikipedia.org/wiki/Wikipedia:Contact_us

实际上,维基百科中充满了侧边栏、页脚和页眉链接,这些链接出现在每个页面上,还有链接到分类页面、讨论页面以及不包含不同文章的其他页面:

/wiki/Category:All_articles_with_unsourced_statements
/wiki/Talk:Kevin_Bacon

最近,我的一个朋友在进行类似的维基百科爬取项目时提到,他编写了一个大型的过滤函数,超过 100 行代码,用于确定内部维基百科链接是否是文章页面。不幸的是,他没有花太多时间在前期尝试找出“文章链接”和“其他链接”之间的模式,否则他可能已经发现了窍门。如果你检查指向文章页面的链接,你会发现它们有三个共同点:

  • 它们位于id设置为bodyContentdiv内。

  • 这些 URL 不包含冒号。

  • 这些 URL 以*/wiki/*开头。

你可以使用这些规则稍微修改代码,只检索所需的文章链接,方法是使用正则表达式^(/wiki/)((?!:).)*$

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

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

运行此程序,你应该会看到维基百科关于 Kevin Bacon 的所有文章 URL 列表。

当然,拥有一个在一个硬编码的维基百科文章中找到所有文章链接的脚本,虽然很有趣,但在实践中却相当无用。你需要能够拿着这段代码,将其转换成更像下面这样的东西:

  • 一个单独的函数,getLinks,它接受形式为/wiki/<Article_Name>的维基百科文章 URL,并返回相同形式的所有链接的文章 URL 列表。

  • 一个调用getLinks的主函数,以一个起始文章作为参数,从返回的列表中选择一个随机文章链接,并再次调用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{}'.format(articleUrl))
    bs = BeautifulSoup(html, 'html.parser')
    return bs.find('div', {'id':'bodyContent'}).find_all('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)

程序导入所需的库后,第一件事是使用当前系统时间设置随机数生成器的种子。这实际上确保了每次运行程序时都会得到一个新的有趣的随机路径通过维基百科文章。

接下来,程序定义了getLinks函数,该函数接受形式为/wiki/...的文章 URL,加上维基百科域名http://en.wikipedia.org,并检索该域中 HTML 的BeautifulSoup对象。然后根据前面讨论的参数提取文章链接标签列表,并返回它们。

程序的主体从设置文章链接标签列表(links变量)为初始页面中的链接列表开始:*en.wikipedia.org/wiki/Kevin_… URL 获取一个新的链接列表。

当然,解决维基百科的六度问题比构建一个从页面到页面的爬虫更多一些。你还必须能够存储和分析所得到的数据。要继续解决此问题的解决方案,请参见第九章。

处理你的异常!

尽管这些代码示例为简洁起见省略了大部分异常处理,但请注意可能会出现许多潜在问题。例如,如果维基百科更改了bodyContent标签的名称会怎样?当程序尝试从标签中提取文本时,它会抛出AttributeError

因此,虽然这些脚本可能很适合作为密切观察的例子运行,但自主生产代码需要比本书中所能容纳的异常处理要多得多。查看第四章了解更多信息。

爬取整个站点

在前一节中,你随机地浏览了一个网站,从一个链接跳到另一个链接。但是如果你需要系统地编目或搜索站点上的每一页呢?爬行整个站点,特别是大型站点,是一个占用内存的过程,最适合于那些可以轻松存储爬行结果的应用程序。然而,即使不是全面运行,你也可以探索这些类型应用程序的行为。要了解更多关于使用数据库运行这些应用程序的信息,请参见第九章。

什么时候爬整个网站可能有用,什么时候可能有害?遍历整个站点的网页抓取器适合于许多用途,包括:

生成站点地图

几年前,我面临一个问题:一个重要的客户想要对网站重新设计提供估价,但不愿向我的公司提供其当前内容管理系统的内部访问权限,也没有公开的站点地图。我能够使用爬虫覆盖整个站点,收集所有内部链接,并将页面组织成实际在站点上使用的文件夹结构。这让我能够快速找到我甚至不知道存在的站点部分,并准确计算所需的页面设计数量以及需要迁移的内容量。

收集数据

另一个客户想要收集文章(故事、博客文章、新闻文章等),以创建一个专业搜索平台的工作原型。虽然这些网站爬行不需要详尽,但它们确实需要相当广泛(我们只对从几个站点获取数据感兴趣)。我能够创建爬虫,递归地遍历每个站点,并仅收集在文章页面上找到的数据。

对于详尽的网站爬行,一般的方法是从顶级页面(比如首页)开始,并搜索该页面上的所有内部链接列表。然后对每个链接进行爬行,并在每个链接上找到其他链接列表,触发另一轮爬行。

显然,这是一个可能迅速扩展的情况。如果每页有 10 个内部链接,而网站深度为 5 页(对于中等大小的网站来说是一个相当典型的深度),那么你需要爬行的页面数为 10⁵,即 100,000 页,才能确保你已经详尽地覆盖了整个网站。奇怪的是,虽然“每页 5 个深度和每页 10 个内部链接”是网站的相当典型的维度,但很少有网站拥有 100,000 页或更多页面。当然,原因在于绝大多数的内部链接是重复的。

为了避免两次爬取同一页,非常重要的是,发现的所有内部链接都要一致地格式化,并在程序运行时保持在一个运行集合中进行简单查找。集合 类似于列表,但元素没有特定的顺序,并且只存储唯一的元素,这对我们的需求是理想的。只有“新”的链接应该被爬取,并搜索其他链接:

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

pages = set()
def getLinks(pageUrl):
    html = urlopen('http://en.wikipedia.org{}'.format(pageUrl))
    bs = BeautifulSoup(html, 'html.parser')
    for link in bs.find_all('a', href=re.compile('^(/wiki/)')):
        if 'href' in link.attrs:
            if link.attrs['href'] not in pages:
                #We have encountered a new page
                newPage = link.attrs['href']
                print(newPage)
                pages.add(newPage)
                getLinks(newPage)
getLinks('')

为了向你展示这种网络爬行业务的完整效果,我放宽了什么构成内部链接的标准(来自之前的示例)。不再将爬虫限制于文章页面,而是查找所有以 /wiki/ 开头的链接,无论它们在页面的何处,也无论它们是否包含冒号。请记住:文章页面不包含冒号,但文件上传页面、讨论页面等在 URL 中包含冒号。

最初,使用空 URL 调用 getLinks。这被翻译为“维基百科的首页”,因为空 URL 在函数内部以 http://en.wikipedia.org 开头。然后,对首页上的每个链接进行迭代,并检查它是否在脚本已遇到的页面集合中。如果没有,将其添加到列表中,打印到屏幕上,并递归调用 getLinks

递归的警告

这是在软件书籍中很少见的警告,但我认为你应该知道:如果运行时间足够长,前面的程序几乎肯定会崩溃。

Python 的默认递归限制(程序可以递归调用自身的次数)为 1,000。由于维基百科的链接网络非常庞大,这个程序最终会达到递归限制并停止,除非你在其中加入递归计数器或其他东西以防止这种情况发生。

对于少于 1,000 个链接深度的“平”站点,这种方法通常效果良好,但也有一些特殊情况。例如,我曾经遇到过一个动态生成的 URL 的 bug,它依赖于当前页面的地址来在该页面上写入链接。这导致了无限重复的路径,如 /blogs/blogs.../blogs/blog-post.php

大多数情况下,这种递归技术对于你可能遇到的任何典型网站应该都没问题。

在整个站点收集数据

如果网络爬虫只是从一个页面跳到另一个页面,那么它们会变得相当无聊。为了使它们有用,你需要在访问页面时能够做些事情。让我们看看如何构建一个爬虫,收集标题、内容的第一个段落,并且(如果有的话)编辑页面的链接。

与往常一样,确定最佳方法的第一步是查看站点的几个页面并确定一个模式。通过查看一些维基百科页面(包括文章和非文章页面,如隐私政策页面),应该清楚以下几点:

  • 所有标题(无论其是否作为文章页面、编辑历史页面或任何其他页面存在)都在h1span标签下,这些是页面上唯一的h1标签。

  • 正如之前提到的,所有正文文本都位于div#bodyContent标签下。然而,如果你想更加具体并且只访问文本的第一个段落,那么最好使用div#mw-content-text​p(仅选择第一个段落标签)。这对所有内容页面都适用,但不适用于文件页面(例如,https://en.wikipedia.org/wiki/File:Orbit_of_274301_Wikipedia.svg),因为这些页面没有内容文本的部分。

  • 编辑链接仅出现在文章页面上。如果它们存在,它们将在li#ca-edit标签下找到,位于li#ca-edit →​ span →​ a

通过修改您的基本爬取代码,您可以创建一个组合爬虫/数据收集(或至少是数据打印)程序:

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

pages = set()
def getLinks(pageUrl):
    html = urlopen('http://en.wikipedia.org{}'.format(pageUrl))
    bs = BeautifulSoup(html, 'html.parser')
    try:
        print(bs.h1.get_text())
        print(bs.find(id ='mw-content-text').find_all('p')[0])
        print(bs.find(id='ca-edit').find('span')
             .find('a').attrs['href'])
    except AttributeError:
        print('This page is missing something! Continuing.')

    for link in bs.find_all('a', href=re.compile('^(/wiki/)')):
        if 'href' in link.attrs:
            if link.attrs['href'] not in pages:
                #We have encountered a new page
                newPage = link.attrs['href']
                print('-'*20)
                print(newPage)
                pages.add(newPage)
                getLinks(newPage)
getLinks(''

该程序中的for循环基本上与原始爬虫程序中的相同(增加了打印内容之间的打印破折号以增加清晰度)。

由于你永远不能完全确定每个页面上是否有所有数据,因此每个print语句都按照最有可能出现在页面上的顺序排列。也就是说,h1标题标签似乎出现在每个页面上(至少在我所知的范围内),因此首先尝试获取该数据。文本内容出现在大多数页面上(除了文件页面),因此这是第二个检索到的数据片段。编辑按钮仅出现在已经存在标题和文本内容的页面上,但并非所有这些页面都有该按钮。

不同的需求有不同的模式

显然,将多行包装在异常处理程序中存在一些危险。例如,你无法判断是哪一行抛出了异常。此外,如果某个页面包含编辑按钮但没有标题,编辑按钮将永远不会被记录。然而,在许多实例中,这足以满足页面上物品出现的可能顺序,并且无意中漏掉一些数据点或保留详细日志并不是问题。

你可能会注意到,在此及之前的所有示例中,你不是“收集”数据,而是“打印”它。显然,在终端中操作数据是很困难的。有关存储信息和创建数据库的更多信息,请参阅第九章。

横跨互联网的爬虫

每当我在网页抓取方面发表演讲时,总会有人不可避免地问:“你怎么建造 Google?”我的答案总是双重的:“首先,你需要获得数十亿美元,这样你就能购买世界上最大的数据仓库,并将它们放置在世界各地的隐藏位置。其次,你需要构建一个网络爬虫。”

当 Google 于 1996 年开始时,只是两位斯坦福大学研究生和一台旧服务器以及一个 Python 网络爬虫。现在,你已经知道如何抓取网络,只需一些风险投资,你就可能成为下一个科技亿万富翁!

网络爬虫是许多现代网络技术的核心,不一定需要大型数据仓库来使用它们。要进行跨域数据分析,确实需要构建能够解释和存储互联网上大量页面数据的爬虫。

正如前面的例子一样,你将要构建的网络爬虫将跟随页面到页面的链接,构建出网络的地图。但是这次,它们不会忽略外部链接;它们将会跟随它们。

未知的前方

请记住,下一节中的代码可以放在互联网的任何地方。如果我们从《维基百科的六度》中学到了什么,那就是从http://www.sesamestreet.org/这样的网站到一些不那么愉快的地方只需几个跳转。

孩子们,在运行此代码之前,请征得父母的同意。对于那些有敏感情绪或因宗教限制而禁止查看淫秽网站文本的人,请通过阅读代码示例跟随进行,但在运行时要小心。

在你开始编写任意跟踪所有出站链接的爬虫之前,请先问自己几个问题:

  • 我试图收集哪些数据?这是否可以通过仅抓取几个预定义的网站(几乎总是更简单的选项)来完成,还是我的爬虫需要能够发现我可能不知道的新网站?

  • 当我的爬虫到达特定网站时,它会立即跟随下一个出站链接到一个新的网站,还是会停留一段时间并深入到当前网站?

  • 是否存在任何条件使我不想抓取特定网站?我对非英语内容感兴趣吗?

  • 如果我的网络爬虫引起某个网站管理员的注意,我如何保护自己免受法律行动?(查看第二章获取更多信息。)

可以轻松编写一组灵活的 Python 函数,这些函数可以组合执行各种类型的网络抓取,代码行数少于 60 行。这里,出于简洁考虑,我省略了库导入,并将代码分成多个部分进行讨论。然而,完整的工作版本可以在本书的GitHub 存储库中找到:

#Retrieves a list of all Internal links found on a page
def getInternalLinks(bs, url):
    netloc = urlparse(url).netloc
    scheme = urlparse(url).scheme
    internalLinks = set()
    for link in bs.find_all('a'):
        if not link.attrs.get('href'):
            continue
        parsed = urlparse(link.attrs['href'])
        if parsed.netloc == '':
            l = f'{scheme}://{netloc}/{link.attrs["href"].strip("/")}'
            internalLinks.add(l)
        elif parsed.netloc == internal_netloc:
            internalLinks.add(link.attrs['href'])
    return list(internalLinks)

第一个函数是getInternalLinks。它以 BeautifulSoup 对象和页面的 URL 作为参数。此 URL 仅用于标识内部站点的netloc(网络位置)和scheme(通常为httphttps),因此重要的是要注意,可以在此处使用目标站点的任何内部 URL——不需要是传入的 BeautifulSoup 对象的确切 URL。

这个函数创建了一个名为internalLinks的集合,用于跟踪页面上找到的所有内部链接。它检查所有锚标签的href属性,如果href属性不包含netloc(即像“/careers/”这样的相对 URL)或者具有与传入的 URL 相匹配的netloc,则会检查:

#Retrieves a list of all external links found on a page
def getExternalLinks(bs, url):
    internal_netloc = urlparse(url).netloc
    externalLinks = set()
    for link in bs.find_all('a'):
        if not link.attrs.get('href'):
            continue
        parsed = urlparse(link.attrs['href'])
        if parsed.netloc != '' and parsed.netloc != internal_netloc:
            externalLinks.add(link.attrs['href'])
    return list(externalLinks)

函数getExternalLinks的工作方式与getInternalLinks类似。它检查所有带有href属性的锚标签,并寻找那些具有不与传入的 URL 相匹配的填充netloc的标签:

def getRandomExternalLink(startingPage):
    bs = BeautifulSoup(urlopen(startingPage), 'html.parser')
    externalLinks = getExternalLinks(bs, startingPage)
    if not len(externalLinks):
        print('No external links, looking around the site for one')
        internalLinks = getInternalLinks(bs, startingPage)
        return getRandomExternalLink(random.choice(internalLinks))
    else:
        return random.choice(externalLinks)

函数getRandomExternalLink使用函数getExternalLinks获取页面上所有外部链接的列表。如果找到至少一个链接,则从列表中选择一个随机链接并返回:

def followExternalOnly(startingSite):
    externalLink = getRandomExternalLink(startingSite)
    print(f'Random external link is: {externalLink}')
    followExternalOnly(externalLink)

函数followExternalOnly使用getRandomExternalLink然后递归地遍历整个互联网。你可以这样调用它:

followExternalOnly('https://www.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/

外部链接并不总是能在网站的第一页找到。在这种情况下查找外部链接,会采用类似于前面爬虫示例中使用的方法,递归地深入网站,直到找到外部链接。

图 6-1 以流程图形式说明了其操作。

Alt Text

图 6-1. 爬取互联网站点的脚本流程图

不要将示例程序投入生产

我一直强调这一点,但为了节省空间和提高可读性,本书中的示例程序并不总是包含生产级代码所需的必要检查和异常处理。例如,如果在此爬虫遇到的网站上未找到任何外部链接(这不太可能,但如果你运行足够长的时间,总会发生),这个程序将继续运行,直到达到 Python 的递归限制。

增加此爬虫的健壮性的一种简单方法是将其与第四章中的连接异常处理代码结合起来。这将允许代码在检索页面时遇到 HTTP 错误或服务器异常时选择不同的 URL 进行跳转。

在为任何严肃的目的运行此代码之前,请确保您正在采取措施来处理潜在的陷阱。

将任务分解为简单函数(如“查找此页面上的所有外部链接”)的好处在于,稍后可以重构代码以执行不同的爬虫任务。例如,如果你的目标是爬取整个站点的外部链接并记录每一个,可以添加以下函数:

# Collects a list of all external URLs found on the site
allExtLinks = []
allIntLinks = []

def getAllExternalLinks(url):
    bs = BeautifulSoup(urlopen(url), 'html.parser')
    internalLinks = getInternalLinks(bs, url)
    externalLinks = getExternalLinks(bs, url)
    for link in externalLinks:
        if link not in allExtLinks:
            allExtLinks.append(link)
            print(link)

    for link in internalLinks:
        if link not in allIntLinks:
            allIntLinks.append(link)
            getAllExternalLinks(link)

allIntLinks.append('https://oreilly.com')
getAllExternalLinks('https://www.oreilly.com/')

这段代码可以看作是两个循环的组合——一个收集内部链接,一个收集外部链接。流程图看起来类似于图 6-2。

Alt Text

图 6-2. 收集所有外部链接的网站爬虫流程图

在编写代码之前记下或制作代码应该完成的内容的图表是一个极好的习惯,并且可以在爬虫变得更加复杂时为你节省大量时间和烦恼。

¹ 1990 年代创造的一种流行的客厅游戏,https://en.wikipedia.org/wiki/Six_Degrees_of_Kevin_Bacon

² 感谢培根的神谕 满足了我对这一特定链条的好奇心。

³ 参见“探索谷歌无法理解的‘深网’” 由亚历克斯·赖特撰写。

⁴ 参见“黑客词汇表:什么是暗网?” 由安迪·格林伯格撰写。

第七章:网络爬虫模型

当你控制数据和输入时,编写干净、可扩展的代码已经足够困难了。编写网页爬虫的代码,可能需要从程序员无法控制的各种网站中抓取和存储各种数据,通常会带来独特的组织挑战。

你可能会被要求从各种网站上收集新闻文章或博客文章,每个网站都有不同的模板和布局。一个网站的 h1 标签包含文章的标题,另一个网站的 h1 标签包含网站本身的标题,而文章标题在 <span id="title"> 中。

你可能需要灵活地控制哪些网站被抓取以及它们如何被抓取,并且需要一种快速添加新网站或修改现有网站的方法,而不需要编写多行代码。

你可能被要求从不同网站上抓取产品价格,最终目标是比较相同产品的价格。也许这些价格是以不同的货币计价的,也许你还需要将其与某些其他非网络来源的外部数据结合起来。

虽然网络爬虫的应用几乎是无穷无尽的,但是大型可扩展的爬虫往往会落入几种模式之一。通过学习这些模式,并识别它们适用的情况,你可以极大地提高网络爬虫的可维护性和健壮性。

本章主要关注收集有限数量“类型”数据的网络爬虫(例如餐馆评论、新闻文章、公司简介)从各种网站收集这些数据类型,并将其存储为 Python 对象,从数据库中读写。

规划和定义对象

网页抓取的一个常见陷阱是完全基于眼前可见的内容定义你想要收集的数据。例如,如果你想收集产品数据,你可能首先看一下服装店,决定你要抓取的每个产品都需要有以下字段:

  • 产品名称

  • 价格

  • 描述

  • 尺码

  • 颜色

  • 织物类型

  • 客户评分

当你查看另一个网站时,你发现它在页面上列出了 SKU(用于跟踪和订购商品的库存单位)你肯定也想收集这些数据,即使在第一个网站上看不到它!你添加了这个字段:

  • 商品 SKU

虽然服装可能是一个很好的起点,但你也希望确保你可以将这个爬虫扩展到其他类型的产品上。你开始浏览其他网站的产品部分,并决定你还需要收集以下信息:

  • 精装/平装

  • 亚光/亮光打印

  • 客户评论数量

  • 制造商链接

显然,这种方法是不可持续的。每次在网站上看到新的信息时,简单地将属性添加到产品类型中将导致要跟踪的字段过多。不仅如此,每次抓取新的网站时,你都将被迫对网站具有的字段和到目前为止积累的字段进行详细分析,并可能添加新字段(修改你的 Python 对象类型和数据库结构)。这将导致一个混乱且难以阅读的数据集,可能会导致在使用它时出现问题。

在决定收集哪些数据时,你经常最好的做法是忽略网站。你不会通过查看单个网站并说“存在什么?”来开始一个旨在大规模和可扩展的项目,而是通过说“我需要什么?”然后找到从那里获取所需信息的方法。

也许你真正想做的是比较多家商店的产品价格,并随着时间跟踪这些产品价格。在这种情况下,你需要足够的信息来唯一标识产品,就是这样:

  • 产品标题

  • 制造商

  • 产品 ID 编号(如果可用/相关)

重要的是要注意,所有这些信息都不特定于特定商店。例如,产品评论、评级、价格,甚至描述都特定于特定商店中的该产品的实例。这可以单独存储。

其他信息(产品的颜色、材质)是产品特定的,但可能稀疏——并非适用于每种产品。重要的是要退后一步,对你考虑的每个项目执行一个清单,并问自己这些问题:

  • 这些信息是否有助于项目目标?如果我没有它,是否会成为一个障碍,还是它只是“好有”但最终不会对任何事情产生影响?

  • 如果可能将来有用,但我不确定,那么在以后收集这些数据会有多困难?

  • 这些数据是否与我已经收集的数据重复了?

  • 在这个特定对象中存储数据是否有逻辑意义?(如前所述,如果一个产品的描述在不同网站上发生变化,则在产品中存储描述是没有意义的。)

如果你决定需要收集这些数据,重要的是要提出更多问题,然后决定如何在代码中存储和处理它:

  • 这些数据是稀疏的还是密集的?它在每个列表中是否都是相关且填充的,还是只有一小部分是相关的?

  • 数据有多大?

  • 尤其是在大数据的情况下,我需要每次运行分析时定期检索它,还是只在某些情况下检索它?

  • 这种类型的数据变化多大?我是否经常需要添加新属性,修改类型(如可能经常添加的面料图案),还是它是固定的(鞋码)?

假设您计划围绕产品属性和价格进行一些元分析:例如,一本书的页数,或一件衣服的面料类型,以及未来可能的其他属性,与价格相关。您仔细思考这些问题,并意识到这些数据是稀疏的(相对较少的产品具有任何一个属性),并且您可能经常决定添加或删除属性。在这种情况下,创建一个如下所示的产品类型可能是有意义的:

  • 产品标题

  • 制造商

  • 产品 ID 编号(如适用/相关)

  • 属性(可选列表或字典)

以及以下类似的属性类型:

  • 属性名称

  • 属性值

这使您能够随时间灵活添加新的产品属性,而无需重新设计数据模式或重写代码。在决定如何在数据库中存储这些属性时,您可以将 JSON 写入attribute字段,或者将每个属性存储在一个带有产品 ID 的单独表中。有关实现这些类型数据库模型的更多信息,请参见第九章。

您可以将前述问题应用于您需要存储的其他信息。例如,要跟踪每个产品找到的价格,您可能需要以下信息:

  • 产品 ID

  • 商店 ID

  • 价格

  • 记录价格发现的日期/时间戳

但是,如果产品的属性实际上会改变产品的价格怎么办?例如,商店可能会对大号衬衫收取更高的价格,因为制作大号衬衫需要更多的人工或材料。在这种情况下,您可以考虑将单个衬衫产品拆分为每种尺寸的独立产品列表(以便每件衬衫产品可以独立定价),或者创建一个新的项目类型来存储产品实例信息,包含以下字段:

  • 产品 ID

  • 实例类型(本例中为衬衫尺寸)

每个价格看起来会像这样:

  • 产品实例 ID

  • 商店 ID

  • 价格

  • 记录价格发现的日期/时间戳

虽然“产品和价格”主题可能显得过于具体,但是在设计 Python 对象时,您需要问自己的基本问题和逻辑几乎适用于每种情况。

如果您正在抓取新闻文章,您可能希望获取基本信息,例如:

  • 标题

  • 作者

  • 日期

  • 内容

但是,如果一些文章包含“修订日期”,或“相关文章”,或“社交媒体分享数量”呢?您是否需要这些信息?它们是否与您的项目相关?当不是所有新闻网站都使用所有形式的社交媒体,并且社交媒体站点可能随时间而增长或减少时,您如何高效灵活地存储社交媒体分享数量?

当面对一个新项目时,很容易立即投入到编写 Python 代码以抓取网站的工作中。然而,数据模型往往被第一个抓取的网站的数据的可用性和格式所强烈影响,而被忽视。

然而,数据模型是所有使用它的代码的基础。在模型中做出不好的决定很容易导致以后编写和维护代码时出现问题,或者在提取和高效使用结果数据时出现困难。特别是在处理各种网站(已知和未知的)时,认真考虑和规划你需要收集什么,以及你如何存储它变得至关重要。

处理不同的网页布局

象 Google 这样的搜索引擎最令人印象深刻的一个特性是,它能够从各种网站中提取相关且有用的数据,而不需要预先了解网站的结构。尽管我们作为人类能够立即识别页面的标题和主要内容(除了极端糟糕的网页设计情况),但让机器人做同样的事情要困难得多。

幸运的是,在大多数网络爬虫的情况下,你不需要从你以前从未见过的网站收集数据,而是从少数或几十个由人类预先选择的网站收集数据。这意味着你不需要使用复杂的算法或机器学习来检测页面上“看起来最像标题”的文本或者哪些可能是“主要内容”。你可以手动确定这些元素是什么。

最明显的方法是为每个网站编写单独的网络爬虫或页面解析器。每个解析器可能接受一个 URL、字符串或BeautifulSoup对象,并返回一个被爬取的 Python 对象。

下面是一个Content类的示例(代表网站上的一篇内容,比如新闻文章),以及两个爬虫函数,它们接受一个BeautifulSoup对象并返回一个Content的实例:

from bs4 import BeautifulSoup
from urllib.request import urlopen

class Content:
    def __init__(self, url, title, body):
        self.url = url
        self.title = title
        self.body = body

    def print(self):
        print(f'TITLE: {self.title}')
        print(f'URL: {self.url}')
        print(f'BODY: {self.body}')

def scrapeCNN(url):
    bs = BeautifulSoup(urlopen(url))
    title = bs.find('h1').text
    body = bs.find('div', {'class': 'article__content'}).text
    print('body: ')
    print(body)
    return Content(url, title, body)

def scrapeBrookings(url):
    bs = BeautifulSoup(urlopen(url))
    title = bs.find('h1').text
    body = bs.find('div', {'class': 'post-body'}).text
    return Content(url, title, body)

url = 'https://www.brookings.edu/research/robotic-rulemaking/'
content = scrapeBrookings(url)
content.print()

url = 'https://www.cnn.com/2023/04/03/investing/\
dogecoin-elon-musk-twitter/index.html'
content = scrapeCNN(url)
content.print()

当你开始为额外的新闻网站添加爬虫函数时,你可能会注意到一种模式正在形成。每个网站的解析函数基本上都在做同样的事情:

  • 选择标题元素并提取标题文本

  • 选择文章的主要内容

  • 根据需要选择其他内容项

  • 返回一个通过先前找到的字符串实例化的Content对象

这里唯一真正依赖于网站的变量是用于获取每个信息片段的 CSS 选择器。BeautifulSoup 的findfind_all函数接受两个参数——一个标签字符串和一个键/值属性字典——所以你可以将这些参数作为定义站点结构和目标数据位置的参数传递进去。

更方便的是,你可以使用 BeautifulSoup 的select函数,用一个单一的字符串 CSS 选择器来获取每个想要收集的信息片段,并将所有这些选择器放在一个字典对象中:

class Content:
    """
    Common base class for all articles/pages
    """
    def __init__(self, url, title, body):
        self.url = url
        self.title = title
        self.body = body

    def print(self):
        """
        Flexible printing function controls output
        """
        print('URL: {}'.format(self.url))
        print('TITLE: {}'.format(self.title))
        print('BODY:\n{}'.format(self.body))

class Website:
    """ 
    Contains information about website structure
    """
    def __init__(self, name, url, titleTag, bodyTag):
        self.name = name
        self.url = url
        self.titleTag = titleTag
        self.bodyTag = bodyTag

注意Website类不存储从单个页面收集的信息,而是存储关于如何收集这些数据的说明。它不存储标题“我的页面标题”。它只是存储表示标题位置的字符串标签h1。这就是为什么这个类被称为Website(这里的信息涉及整个网站)而不是Content(它只包含来自单个页面的信息)的原因。

当您编写网络爬虫时,您可能会注意到您经常一遍又一遍地执行许多相同的任务。例如:获取页面内容同时检查错误,获取标签内容,如果找不到则优雅地失败。让我们将这些添加到一个Crawler类中:

class Crawler:
    def getPage(url):
        try:
            html = urlopen(url)
        except Exception:
            return None
        return BeautifulSoup(html, 'html.parser')

    def safeGet(bs, selector):
        """
        Utility function used to get a content string from a Beautiful Soup
        object and a selector. Returns an empty string if no object
        is found for the given selector
        """
        selectedElems = bs.select(selector)
        if selectedElems is not None and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
        return ''

请注意,Crawler类目前没有任何状态。它只是一组静态方法。它的命名似乎也很差劲——它根本不进行任何爬取!你至少可以通过向其添加一个getContent方法稍微增加其实用性,该方法接受一个website对象和一个 URL 作为参数,并返回一个Content对象:

class Crawler:

    ...

    def getContent(website, path):
        """
        Extract content from a given page URL
        """
        url = website.url+path
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, website.titleTag)
            body = Crawler.safeGet(bs, website.bodyTag)
            return Content(url, title, body)
        return Content(url, '', '')

以下显示了如何将这些ContentwebsiteCrawler类一起使用来爬取四个不同的网站:

siteData = [
    ['O\'Reilly', 'https://www.oreilly.com', 'h1', 'div.title-description'],
    ['Reuters', 'https://www.reuters.com', 'h1', 'div.ArticleBodyWrapper'],
    ['Brookings', 'https://www.brookings.edu', 'h1', 'div.post-body'],
    ['CNN', 'https://www.cnn.com', 'h1', 'div.article__content']
]
websites = []
for name, url, title, body in siteData:
    websites.append(Website(name, url, title, body))

Crawler.getContent(
    websites[0], 
    '/library/view/web-scraping-with/9781491910283'
    ).print()
Crawler.getContent(
    websites[1],
    '/article/us-usa-epa-pruitt-idUSKBN19W2D0'
    ).print()
Crawler.getContent(
    websites[2],
    '/blog/techtank/2016/03/01/idea-to-retire-old-methods-of-policy-education/'
    ).print()
Crawler.getContent(
    websites[3], 
    '/2023/04/03/investing/dogecoin-elon-musk-twitter/index.html'
    ).print()

这种新方法乍看起来可能并不比为每个新网站编写新的 Python 函数更简单,但想象一下当你从一个拥有 4 个网站来源的系统转变为一个拥有 20 个或 200 个来源的系统时会发生什么。

定义一个新网站的每个字符串列表都相对容易编写。它不占用太多空间。它可以从数据库或 CSV 文件中加载。它可以从远程源导入或交给具有一点前端经验的非程序员。这个程序员可以填写它并向爬虫添加新的网站,而无需查看一行代码。

当然,缺点是你放弃了一定的灵活性。在第一个示例中,每个网站都有自己的自由形式函数来选择和解析 HTML,以便获得最终结果。在第二个示例中,每个网站需要具有一定的结构,其中字段被保证存在,数据必须在字段出来时保持干净,每个目标字段必须具有唯一且可靠的 CSS 选择器。

但是,我相信这种方法的力量和相对灵活性远远弥补了其实际或被认为的缺点。下一节将涵盖此基本模板的具体应用和扩展,以便您可以处理丢失的字段,收集不同类型的数据,浏览网站的特定部分,并存储关于页面的更复杂的信息。

结构化爬虫

创建灵活和可修改的网站布局类型如果仍然需要手动定位要爬取的每个链接,则不会有太大帮助。第六章展示了通过网站并找到新页面的各种自动化方法。

本节展示了如何将这些方法整合到一个结构良好且可扩展的网站爬虫中,该爬虫可以自动收集链接并发现数据。我在这里仅介绍了三种基本的网页爬虫结构;它们适用于你在野外爬取网站时可能遇到的大多数情况,也许在某些情况下需要进行一些修改。

通过搜索爬取网站

通过与搜索栏相同的方法,爬取网站是最简单的方法之一。尽管在网站上搜索关键词或主题并收集搜索结果列表的过程似乎因网站而异,但几个关键点使其出奇地简单:

  • 大多数网站通过在 URL 参数中将主题作为字符串传递来检索特定主题的搜索结果列表。例如:http://example.com?search=myTopic。这个 URL 的前半部分可以保存为Website对象的属性,主题只需简单附加即可。

  • 搜索后,大多数网站将结果页面呈现为易于识别的链接列表,通常使用方便的包围标签,如<span class="result">,其确切格式也可以作为Website对象的属性存储。

  • 每个结果链接都可以是相对 URL(例如,/articles/page.html)或绝对 URL(例如,*example.com/articles/pa… URL,它都可以作为Website对象的属性存储。

  • 当你定位并规范化搜索页面上的 URL 后,你已成功将问题简化为前一节示例中的情况——在给定网站格式的情况下提取页面数据。

让我们来看一下这个算法在代码中的实现。Content类与之前的示例大致相同。你正在添加 URL 属性来跟踪内容的来源:

class Content:
    """Common base class for all articles/pages"""

    def __init__(self, topic, url, title, body):
        self.topic = topic
        self.title = title
        self.body = body
        self.url = url

    def print(self):
        """
        Flexible printing function controls output
        """
        print('New article found for topic: {}'.format(self.topic))
        print('URL: {}'.format(self.url))
        print('TITLE: {}'.format(self.title))
        print('BODY:\n{}'.format(self.body))    

Website类添加了一些新属性。searchUrl定义了你应该去哪里获取搜索结果,如果你附加你要查找的主题。resultListing定义了包含每个结果信息的“框”,resultUrl定义了这个框内部的标签,将为你提供结果的确切 URL。absoluteUrl属性是一个布尔值,告诉你这些搜索结果是绝对 URL 还是相对 URL。

class Website:
    """Contains information about website structure"""

    def __init__(self, name, url, searchUrl, resultListing,
​    ​    resultUrl, absoluteUrl, titleTag, bodyTag):
        self.name = name
        self.url = url
        self.searchUrl = searchUrl
        self.resultListing = resultListing
        self.resultUrl = resultUrl
        self.absoluteUrl=absoluteUrl
        self.titleTag = titleTag
        self.bodyTag = bodyTag

Crawler类也有所扩展。它现在有一个Website对象,以及一个指向Content对象的 URL 字典,用于跟踪它之前所见过的内容。请注意,getPagesafeGet方法没有更改,这里省略了它们:

class Crawler:
    def __init__(self, website):
        self.site = website
        self.found = {}

    def getContent(self, topic, url):
        """
        Extract content from a given page URL
        """
        bs = Crawler.getPage(url)
        if bs is not None:
            title = Crawler.safeGet(bs, self.site.titleTag)
            body = Crawler.safeGet(bs, self.site.bodyTag)
            return Content(topic, url, title, body)
        return Content(topic, url, '', '')

    def search(self, topic):
        """
        Searches a given website for a given topic and records all pages found
        """
        bs = Crawler.getPage(self.site.searchUrl + topic)
        searchResults = bs.select(self.site.resultListing)
        for result in searchResults:
            url = result.select(self.site.resultUrl)[0].attrs['href']
            # Check to see whether it's a relative or an absolute URL
            url = url if self.site.absoluteUrl else self.site.url + url
            if url not in self.found:
                self.found[url] = self.getContent(topic, url)
            self.found[url].print()

你可以像这样调用你的爬虫:

siteData = [
    ['Reuters', 'http://reuters.com',
     'https://www.reuters.com/search/news?blob=',
     'div.search-result-indiv', 'h3.search-result-title a', 
      False, 'h1', 'div.ArticleBodyWrapper'],
    ['Brookings', 'http://www.brookings.edu',
     'https://www.brookings.edu/search/?s=',
        'div.article-info', 'h4.title a', True, 'h1', 'div.core-block']
]
sites = []
for name, url, search, rListing, rUrl, absUrl, tt, bt in siteData:
    sites.append(Website(name, url, search, rListing, rUrl, absUrl, tt, bt))

crawlers = [Crawler(site) for site in sites]
topics = ['python', 'data%20science']

for topic in topics:
    for crawler in crawlers:
        crawler.search(topic)

就像以前一样,关于每个网站的数据数组被创建:标签的外观、URL 和用于跟踪目的的名称。然后将这些数据加载到Website对象的列表中,并转换为Crawler对象。

然后它会循环遍历crawlers列表中的每个爬虫,并为每个特定主题的每个特定站点爬取信息。每次成功收集有关页面的信息时,它都会将其打印到控制台:

New article found for topic: python
URL: http://reuters.com/article/idUSKCN11S04G
TITLE: Python in India demonstrates huge appetite
BODY:
By 1 Min ReadA 20 feet rock python was caught on camera ...

注意,它首先遍历所有主题,然后在内部循环中遍历所有网站。为什么不反过来做,先从一个网站收集所有主题,然后再从下一个网站收集所有主题呢?首先遍历所有主题可以更均匀地分配对任何一个 Web 服务器的负载。如果你有数百个主题和几十个网站的列表,这一点尤为重要。你不是一次性向一个网站发送成千上万个请求;你发送 10 个请求,等待几分钟,然后再发送 10 个请求,依此类推。

尽管无论哪种方式,请求的数量最终是相同的,但是通常最好尽量合理地分布这些请求的时间。注意如何结构化你的循环是做到这一点的简单方法。

通过链接爬取站点

第六章介绍了在网页上识别内部和外部链接的一些方法,然后利用这些链接来跨站点爬取。在本节中,你将把这些基本方法结合起来,形成一个更灵活的网站爬虫,可以跟随任何匹配特定 URL 模式的链接。

当你想要从一个站点收集所有数据时——而不仅仅是特定搜索结果或页面列表的数据时,这种类型的爬虫效果很好。当站点的页面可能不太有序或广泛分布时,它也能很好地工作。

这些类型的爬虫不需要像在前一节中爬取搜索页面那样定位链接的结构化方法,因此在Website对象中不需要描述搜索页面的属性。但是,由于爬虫没有为其正在查找的链接位置/位置提供具体指令,因此您需要一些规则来告诉它选择哪种类型的页面。您提供一个target​Pat⁠tern(目标 URL 的正则表达式),并留下布尔变量absoluteUrl来完成此操作:

class Website:
    def __init__(self, name, url, targetPattern, absoluteUrl, titleTag, bodyTag):
        self.name = name
        self.url = url
        self.targetPattern = targetPattern
        self.absoluteUrl = absoluteUrl
        self.titleTag = titleTag
        self.bodyTag = bodyTag

class Content:
    def __init__(self, url, title, body):
        self.url = url
        self.title = title
        self.body = body

    def print(self):
        print(f'URL: {self.url}')
        print(f'TITLE: {self.title}')
        print(f'BODY:\n{self.body}')

Content类与第一个爬虫示例中使用的相同。

Crawler类被设计为从每个站点的主页开始,定位内部链接,并解析每个找到的内部链接的内容:

class Crawler:
    def __init__(self, site):
      self.site = site
      self.visited = {}

    def getPage(url):
      try:
            html = urlopen(url)
      except Exception as e:
            print(e)
            return None
      return BeautifulSoup(html, 'html.parser')

    def safeGet(bs, selector):
      selectedElems = bs.select(selector)
      if selectedElems is not None and len(selectedElems) > 0:
            return '\n'.join([elem.get_text() for elem in selectedElems])
      return ''

    def getContent(self, url):
      """
      Extract content from a given page URL
      """
      bs = Crawler.getPage(url)
      if bs is not None:
          title = Crawler.safeGet(bs, self.site.titleTag)
          body = Crawler.safeGet(bs, self.site.bodyTag)
          return Content(url, title, body)
        return Content(url, '', '')

    def crawl(self):
        """
        Get pages from website home page
        """
        bs = Crawler.getPage(self.site.url)
        targetPages = bs.findAll('a', href=re.compile(self.site.targetPattern))
        for targetPage in targetPages:
          url = targetPage.attrs['href']
          url = url if self.site.absoluteUrl else f'{self.site.url}{targetPage}'
          if url not in self.visited:
                self.visited[url] = self.getContent(url)
                self.visited[url].print()

brookings = Website(
    'Brookings', 'https://brookings.edu', '\/(research|blog)\/',
     True, 'h1', 'div.post-body')
crawler = Crawler(brookings)
crawler.crawl()

与先前的示例一样,Website对象是Crawler对象本身的属性。这样可以很好地存储爬虫中访问的页面(visited),但意味着必须为每个网站实例化一个新的爬虫,而不是重复使用同一个爬虫来爬取网站列表。

无论你选择将爬虫设计成与网站无关还是将网站作为爬虫的属性,都是你必须在特定需求背景下权衡的设计决策。两种方法通常都可以接受。

另一个需要注意的是,这个爬虫将从主页获取页面,但在记录了所有这些页面后,它将不会继续爬取。你可能希望编写一个爬虫,采用本章中的模式之一,并让它在访问的每个页面上查找更多目标。甚至可以跟踪每个页面上的所有 URL(不仅限于与目标模式匹配的 URL),以寻找包含目标模式的 URL。

爬取多种页面类型

与通过预定页面集合进行爬取不同,通过网站上所有内部链接进行爬取可能会带来挑战,因为你永远不知道确切的内容。幸运的是,有几种方法可以识别页面类型:

通过 URL

网站上的所有博客文章可能都包含一个 URL(例如*example.com/blog/title-…

通过网站上特定字段的存在或缺失

如果一个页面有日期但没有作者名字,你可能会将其归类为新闻稿。如果它有标题、主图像和价格但没有主要内容,它可能是一个产品页面。

通过页面上特定的标签来识别页面

即使你不收集标签内的数据,你也可以利用标签。例如,你的爬虫可能会查找诸如<div id="related-products">这样的元素来识别页面是否为产品页面,尽管爬虫并不关心相关产品的内容。

要跟踪多种页面类型,你需要在 Python 中拥有多种类型的页面对象。有两种方法可以做到这一点。

如果页面都很相似(它们基本上都具有相同类型的内容),你可能希望为现有的网页对象添加一个pageType属性:

class Website:
    def __init__(self, name, url, titleTag, bodyTag, pageType):
        self.name = name
        self.url = url
        self.titleTag = titleTag
        self.bodyTag = bodyTag
        self.pageType = pageType

如果你将这些页面存储在类似 SQL 的数据库中,这种模式表明所有这些页面可能都存储在同一张表中,并且会添加一个额外的pageType列。

如果你要抓取的页面/内容差异很大(它们包含不同类型的字段),这可能需要为每种页面类型创建新的类。当然,一些东西对所有网页都是通用的——它们都有一个 URL,很可能还有一个名称或页面标题。这是使用子类的理想情况:

class Product(Website):
    """Contains information for scraping a product page"""
    def __init__(self, name, url, titleTag, productNumberTag, priceTag):
        Website.__init__(self, name, url, TitleTag)
        self.productNumberTag = productNumberTag
        self.priceTag = priceTag

class Article(Website):
    """Contains information for scraping an article page"""
    def __init__(self, name, url, titleTag, bodyTag, dateTag):
        Website.__init__(self, name, url, titleTag)
        self.bodyTag = bodyTag
        self.dateTag = dateTag

这个Product页面扩展了Website基类,并添加了仅适用于产品的productNumberprice属性;Article类添加了bodydate属性,这些属性不适用于产品。

你可以使用这两个类来抓取,例如,一个商店网站可能除了产品之外还包含博客文章或新闻稿。

思考网络爬虫模型

从互联网收集信息有如饮水从火龙头喝水。那里有很多东西,而且并不总是清楚您需要什么或者您如何需要它。任何大型网络抓取项目(甚至某些小型项目)的第一步应该是回答这些问题。

在收集多个领域或多个来源的类似数据时,几乎总是应该尝试对其进行规范化。处理具有相同和可比较字段的数据要比处理完全依赖于其原始来源格式的数据容易得多。

在许多情况下,您应该在假设将来将添加更多数据源到抓取器的基础上构建抓取器,目标是最小化添加这些新来源所需的编程开销。即使一个网站乍一看似乎与您的模型不符,也可能有更微妙的方式使其符合。能够看到这些潜在模式可以在长远中为您节省时间、金钱和许多头疼的问题。

数据之间的关联也不应被忽视。您是否在寻找跨数据源具有“类型”、“大小”或“主题”等属性的信息?您如何存储、检索和概念化这些属性?

软件架构是一个广泛而重要的主题,可能需要整个职业生涯来掌握。幸运的是,用于网络抓取的软件架构是一组相对容易获取的有限且可管理的技能。随着您继续抓取数据,您会发现相同的基本模式一次又一次地出现。创建一个良好结构化的网络爬虫并不需要太多神秘的知识,但确实需要花时间退后一步,思考您的项目。

第八章:Scrapy

第七章介绍了构建大型、可扩展且(最重要的!)可维护网络爬虫的一些技术和模式。尽管手动操作很容易做到,但许多库、框架甚至基于 GUI 的工具都可以为您完成这些工作,或者至少试图让您的生活变得更轻松。

自 2008 年发布以来,Scrapy 迅速发展成为 Python 中最大且维护最好的网络爬虫框架。目前由 Zyte(前身为 Scrapinghub)维护。

编写网络爬虫的一个挑战是经常要重复执行相同的任务:查找页面上的所有链接,评估内部和外部链接之间的差异,并浏览新页面。这些基本模式是有用的,也可以从头编写,但 Scrapy 库可以为您处理其中的许多细节。

当然,Scrapy 不能读心。您仍然需要定义页面模板,指定要开始抓取的位置,并为您正在寻找的页面定义 URL 模式。但在这些情况下,它提供了一个清晰的框架来保持代码的组织。

安装 Scrapy

Scrapy 提供了从其网站下载的工具,以及使用 pip 等第三方安装管理器安装 Scrapy 的说明。

由于其相对较大的大小和复杂性,Scrapy 通常不是可以通过传统方式安装的框架:

$ pip install Scrapy

请注意,我说“通常”是因为尽管理论上可能存在,但我通常会遇到一个或多个棘手的依赖问题、版本不匹配和无法解决的错误。

如果您决定从 pip 安装 Scrapy,则强烈建议使用虚拟环境(有关虚拟环境的更多信息,请参阅“使用虚拟环境保持库的整洁”)。

我偏爱的安装方法是通过Anaconda 包管理器。Anaconda 是由 Continuum 公司推出的产品,旨在简化查找和安装流行的 Python 数据科学包的过程。后续章节将使用它管理的许多包,如 NumPy 和 NLTK。

安装完 Anaconda 后,您可以使用以下命令安装 Scrapy:

`conda` `install` `-``c` `conda``-``forge` `scrapy`

如果遇到问题或需要获取最新信息,请查阅Scrapy安装指南获取更多信息。

初始化新的 Spider

安装完 Scrapy 框架后,每个 Spider 需要进行少量设置。Spider是一个 Scrapy 项目,类似于其命名的蜘蛛,专门设计用于爬取网络。在本章中,我使用“Spider”来描述特定的 Scrapy 项目,“crawler”指“使用 Scrapy 或不使用任何通用程序爬取网络”的任何通用程序。

要在当前目录下创建一个新的 Spider,请从命令行运行以下命令:

$ scrapy startproject wikiSpider

这会在项目创建的目录中创建一个新的子目录,名为wikiSpider。在这个目录中,有以下文件结构:

  • scrapy.cfg

  • wikiSpider

    • spiders

      • init.py
    • items.py

    • middlewares.py

    • pipelines.py

    • settings.py

    • init.py

这些 Python 文件以存根代码初始化,以提供创建新爬虫项目的快速方式。本章中的每个部分都与这个wikiSpider项目一起工作。

编写一个简单的爬虫

要创建一个爬虫,您将在wiki​S⁠pider/wikiSpider/spiders/article.py的子spiders目录中添加一个新文件。这是所有爬虫或扩展 scrapy.Spider 的内容的地方。在您新创建的article.py文件中,写入:

from scrapy import Spider, Request

class ArticleSpider(Spider):
    name='article'

    def start_requests(self):
        urls = [
            'http://en.wikipedia.org/wiki/Python_%28programming_language%29',
            'https://en.wikipedia.org/wiki/Functional_programming',
            'https://en.wikipedia.org/wiki/Monty_Python']
        return [Request(url=url, callback=self.parse) for url in urls]

    def parse(self, response):
        url = response.url
        title = response.css('h1::text').extract_first()
        print(f'URL is: {url}')
        print(f'Title is: {title}')

此类的名称(ArticleSpider)根本没有涉及“wiki”或“维基百科”,这表明此类特别负责爬取文章页面,属于更广泛的wikiSpider类别,稍后您可能希望使用它来搜索其他页面类型。

对于内容类型繁多的大型网站,您可能会为每种类型(博客文章、新闻稿、文章等)设置单独的 Scrapy 项目。每个爬虫的名称在项目内必须是唯一的。

关于此爬虫的另外两个关键点是函数start_requestsparse

start_requests

Scrapy 定义的程序入口点用于生成Request对象,Scrapy 用它来爬取网站。

parse

由用户定义的回调函数,并通过Request对象传递给callback=self.parse。稍后,您将看到更多可以使用parse函数完成的功能,但现在只打印页面的标题。

你可以通过转到外部wikiSpider目录并运行以下命令来运行这个article爬虫:

$ scrapy runspider wikiSpider/spiders/article.py

默认的 Scrapy 输出相当冗长。除了调试信息外,这应该会打印出类似以下的行:

2023-02-11 21:43:13 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/robots.txt> (referer: None)
2023-02-11 21:43:14 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (3
01) to <GET https://en.wikipedia.org/wiki/Python_%28programming_language%29> from
 <GET http://en.wikipedia.org/wiki/Python_%28programming_language%29>
2023-02-11 21:43:14 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/wiki/Functional_programming> (referer: None)
2023-02-11 21:43:14 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/wiki/Monty_Python> (referer: None)
URL is: https://en.wikipedia.org/wiki/Functional_programming
Title is: Functional programming
2023-02-11 21:43:14 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/wiki/Python_%28programming_language%29> (referer: None)
URL is: https://en.wikipedia.org/wiki/Monty_Python
Title is: Monty Python
URL is: https://en.wikipedia.org/wiki/Python_%28programming_language%29
Title is: Python (programming language)

爬虫访问列出为 URL 的三个页面,收集信息,然后终止。

使用规则进行爬取

前一节中的爬虫并不是一个真正的爬虫,只能限于仅抓取提供的 URL 列表。它没有能力自行查找新页面。要将其转变为一个完全成熟的爬虫,你需要使用 Scrapy 提供的CrawlSpider类。

在 GitHub 仓库中的代码组织

不幸的是,Scrapy 框架不能在 Jupyter 笔记本中轻松运行,这使得线性编码难以实现。为了在文本中展示所有代码示例的目的,前一节中的爬虫存储在article.py文件中,而下面的示例,创建一个遍历多个页面的 Scrapy 爬虫,存储在articles.py中(注意使用了复数形式)。

后续示例也将存储在单独的文件中,每个部分都会给出新的文件名。运行这些示例时,请确保使用正确的文件名。

此类可以在 GitHub 仓库的 spiders 文件articles.py中找到:

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule

class ArticleSpider(CrawlSpider):
    name = 'articles'
    allowed_domains = ['wikipedia.org']
    start_urls = ['https://en.wikipedia.org/wiki/Benevolent_dictator_for_life']
    rules = [
        Rule(
            LinkExtractor(allow=r'.*'),
​            callback='parse_items',
​            follow=True
​        )
    ]

    def parse_items(self, response):
        url = response.url
        title = response.css('span.mw-page-title-main::text').extract_first()
        text = response.xpath('//div[@id="mw-content-text"]//text()').extract()
        lastUpdated = response.css(
            'li#footer-info-lastmod::text'
        ).extract_first()
        lastUpdated = lastUpdated.replace('This page was last edited on ', '')
        print(f'URL is: {url}')
        print(f'Title is: {title} ')
        print(f'Text is: {text}')
        print(f'Last updated: {lastUpdated}')

这个新的ArticleSpider扩展了CrawlSpider类。它不是提供一个start_requests函数,而是提供一个start_urlsallowed_domains列表。这告诉爬虫从哪里开始爬取,并根据域名是否应跟踪或忽略链接。

还提供了一个rules列表。这提供了进一步的说明,指导哪些链接应该跟踪或忽略(在本例中,允许所有使用正则表达式.*的 URL)。

除了提取每个页面的标题和 URL 之外,还添加了一些新的项目。使用 XPath 选择器提取每个页面的文本内容。XPath 在检索文本内容时经常被使用,包括文本在子标签中的情况(例如,在一段文本中的<a>标签内)。如果您使用 CSS 选择器来做这件事,所有子标签中的文本都将被忽略。

也从页脚解析并存储了最后更新日期字符串到lastUpdated变量中。

您可以通过导航到wikiSpider目录并运行以下命令来运行此示例:

$ scrapy runspider wikiSpider/spiders/articles.py

警告:这将永远运行下去

尽管这个新的爬虫在命令行中的运行方式与前一节中构建的简单爬虫相同,但它不会终止(至少不会很长时间),直到您使用 Ctrl-C 终止执行或关闭终端为止。请注意,对 Wikipedia 服务器负载要友善,不要长时间运行它。

运行此爬虫时,它将遍历wikipedia.org,跟踪* wikipedia.org*域下的所有链接,打印页面标题,并忽略所有外部(站外)链接:

2023-02-11 22:13:34 [scrapy.spidermiddlewares.offsite] DEBUG: Filtered offsite
 request to 'drupal.org': <GET https://drupal.org/node/769>
2023-02-11 22:13:34 [scrapy.spidermiddlewares.offsite] DEBUG: Filtered offsite
 request to 'groups.drupal.org': <GET https://groups.drupal.org/node/5434>
2023-02-11 22:13:34 [scrapy.spidermiddlewares.offsite] DEBUG: Filtered offsite
 request to 'www.techrepublic.com': <GET https://www.techrepublic.com/article/
open-source-shouldnt-mean-anti-commercial-says-drupal-creator-dries-buytaert/>
2023-02-11 22:13:34 [scrapy.spidermiddlewares.offsite] DEBUG: Filtered offsite
 request to 'www.acquia.com': <GET https://www.acquia.com/board-member/dries-b
uytaert>

到目前为止,这是一个相当不错的爬虫,但它可以使用一些限制。它不仅可以访问维基百科上的文章页面,还可以自由漫游到非文章页面,例如:

title is: Wikipedia:General disclaimer

通过使用 Scrapy 的RuleLinkExtractor来仔细查看这一行:

rules = [Rule(LinkExtractor(allow=r'.*'), callback='parse_items',
​    follow=True)]

此行提供了一个 Scrapy Rule对象的列表,该列表定义了所有找到的链接通过的规则。当存在多个规则时,每个链接都会根据规则进行检查,按顺序进行。第一个匹配的规则将用于确定如何处理链接。如果链接不符合任何规则,则会被忽略。

可以为Rule提供四个参数:

link_extractor

唯一必需的参数,一个LinkExtractor对象。

callback

应该用于解析页面内容的函数。

cb_kwargs

要传递给回调函数的参数字典。这个字典格式为{arg_name1: arg_value1, arg_name2: arg_value2},对于稍微不同的任务重用相同的解析函数非常方便。

follow

指示是否希望在未来的爬行中包含在该页面上找到的链接。如果未提供回调函数,则默认为True(毕竟,如果您对页面没有做任何事情,那么至少您可能想要继续通过站点进行爬行)。如果提供了回调函数,则默认为False

LinkExtractor是一个简单的类,专门设计用于识别并返回基于提供的规则在 HTML 内容页中的链接。它具有许多参数,可用于接受或拒绝基于 CSS 和 XPath 选择器、标签(您不仅可以查找锚点标签中的链接!)、域名等的链接。

LinkExtractor类甚至可以扩展,并且可以创建自定义参数。详见 Scrapy 的链接提取器文档获取更多信息。

尽管LinkExtractor类具有灵活的特性,但您最常使用的参数是这些:

allow

允许所有符合提供的正则表达式的链接。

deny

拒绝所有符合提供的正则表达式的链接。

使用两个单独的RuleLinkExtractor类以及一个解析函数,您可以创建一个爬虫,该爬虫可以爬取维基百科,识别所有文章页面,并标记非文章页面(articleMoreRules.py):

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule

class ArticleSpider(CrawlSpider):
    name = 'articles'
    allowed_domains = ['wikipedia.org']
    start_urls = ['https://en.wikipedia.org/wiki/Benevolent_dictator_for_life']
    rules = [
        Rule(
            LinkExtractor(allow='(/wiki/)((?!:).)*$'),
            callback='parse_items',
            follow=True,
            cb_kwargs={'is_article': True}
        ),
        Rule(
            LinkExtractor(allow='.*'),
            callback='parse_items',
            cb_kwargs={'is_article': False}
        )
    ]

    def parse_items(self, response, is_article):
        print(response.url)
        title = response.css('span.mw-page-title-main::text').extract_first()
        if is_article:
            url = response.url
            text = response.xpath(
                '//div[@id="mw-content-text"]//text()'
            ).extract()
            lastUpdated = response.css(
                'li#footer-info-lastmod::text'
            ).extract_first()
            lastUpdated = lastUpdated.replace(
                'This page was last edited on ',
                ''
            )
            print(f'URL is: {url}')
            print(f'Title is: {title}')
            print(f'Text is: {text}')
        else:
            print(f'This is not an article: {title}')

请记住,规则适用于列表中呈现的每个链接。首先将所有文章页面(以*/wiki/*开头且不包含冒号的页面)传递给parse_items函数,并使用默认参数is_article=True。然后将所有其他非文章链接传递给parse_items函数,并使用参数is_article=False

当然,如果您只想收集文章类型页面并忽略所有其他页面,这种方法将不太实际。忽略不匹配文章 URL 模式的页面,并完全省略第二个规则(以及is_article变量),会更加简单。但在 URL 中收集的信息或在爬取过程中收集的信息影响页面解析方式的奇特情况下,这种方法可能会很有用。

创建项目

到目前为止,您已经学习了许多在 Scrapy 中查找、解析和爬取网站的方法,但 Scrapy 还提供了有用的工具,可以将您收集的项目组织并存储在具有明确定义字段的自定义对象中。

为了帮助组织您收集的所有信息,您需要创建一个名为Article的对象。在items.py文件内定义一个新的Article项目。

当您打开items.py文件时,它应该看起来像这样:

# -*- coding: utf-8 -*-

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

import scrapy

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

用一个新的扩展scrapy.ItemArticle类替换此默认的Item存根:

import scrapy

class Article(scrapy.Item):
    url = scrapy.Field()
    title = scrapy.Field()
    text = scrapy.Field()
    lastUpdated = scrapy.Field()

您正在定义将从每个页面收集的四个字段:URL、标题、文本内容和页面最后编辑日期。

如果您正在收集多种页面类型的数据,应将每种单独类型定义为items.py中的一个单独类。如果您的项目很大,或者您开始将更多的解析功能移动到项目对象中,您可能还希望将每个项目提取到自己的文件中。然而,当项目很小时,我喜欢将它们保存在单个文件中。

在文件articleItems.py中,请注意在 ArticleSpider 类中所做的更改,以创建新的 Article 项目:

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from wikiSpider.items import Article

class ArticleSpider(CrawlSpider):
    name = 'articleItems'
    allowed_domains = ['wikipedia.org']
    start_urls = ['https://en.wikipedia.org/wiki/Benevolent'
​    ​    '_dictator_for_life']
    rules = [
        Rule(LinkExtractor(allow='(/wiki/)((?!:).)*$'),
​    ​    ​    callback='parse_items', follow=True),
    ]

    def parse_items(self, response):
        article = Article()
        article['url'] = response.url
        article['title'] = response.css('h1::text').extract_first()
        article['text'] = response.xpath('//div[@id='
​    ​    ​    '"mw-content-text"]//text()').extract()
        lastUpdated = response.css('li#footer-info-lastmod::text'
​    ​    ​    ).extract_first()
        article['lastUpdated'] = lastUpdated.replace('This page was '
​    ​    ​    'last edited on ', '')
        return article

运行此文件时

$ scrapy runspider wikiSpider/spiders/articleItems.py

它将输出通常的 Scrapy 调试数据以及每个文章项目作为 Python 字典:

2023-02-11 22:52:26 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/wiki/Benevolent_dictator_for_life#bodyContent> (referer: https://en.wi
kipedia.org/wiki/Benevolent_dictator_for_life)
2023-02-11 22:52:26 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/wiki/OCaml> (referer: https://en.wikipedia.org/wiki/Benevolent_dictato
r_for_life)
2023-02-11 22:52:26 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://en.wik
ipedia.org/wiki/Xavier_Leroy> (referer: https://en.wikipedia.org/wiki/Benevolent_
dictator_for_life)
2023-02-11 22:52:26 [scrapy.core.scraper] DEBUG: Scraped from <200 https://en.wik
ipedia.org/wiki/Benevolent_dictator_for_life>
{'lastUpdated': ' 7 February 2023, at 01:14',
'text': 'Title given to a small number of open-source software development '
          'leaders',
          ...

使用 Scrapy Items 并不仅仅是为了促进良好的代码组织或以可读的方式布置事物。项目提供了许多工具,用于输出和处理数据,这些将在接下来的部分中详细介绍。

输出项目

Scrapy 使用 Item 对象来确定应从其访问的页面中保存哪些信息。这些信息可以由 Scrapy 以各种方式保存,例如 CSV、JSON 或 XML 文件,使用以下命令:

$ scrapy runspider articleItems.py -o articles.csv -t csv
$ scrapy runspider articleItems.py -o articles.json -t json
$ scrapy runspider articleItems.py -o articles.xml -t xml

每次运行抓取器 articleItems 并将输出按指定格式写入提供的文件。如果不存在,则将创建此文件。

您可能已经注意到,在先前示例中爬虫创建的文章中,文本变量是一个字符串列表,而不是单个字符串。此列表中的每个字符串表示单个 HTML 元素内的文本,而<div id="mw-content-text">内的内容(您正在收集的文本数据)由许多子元素组成。

Scrapy 很好地管理这些更复杂的值。例如,在 CSV 格式中,它将列表转换为字符串,并转义所有逗号,以便文本列表显示在单个 CSV 单元格中。

在 XML 中,此列表的每个元素都被保留在子值标签中:

<items>
<item>
    <url>https://en.wikipedia.org/wiki/Benevolent_dictator_for_life</url>
    <title>Benevolent dictator for life</title>
    <text>
        <value>For the political term, see </value>
        <value>Benevolent dictatorship</value>
        ...
    </text>
    <lastUpdated> 7 February 2023, at 01:14.</lastUpdated>
</item>
....

在 JSON 格式中,列表被保留为列表。

当然,您可以自己使用 Item 对象,并通过将适当的代码添加到爬虫的解析函数中,以任何您想要的方式将它们写入文件或数据库。

项目管道

尽管 Scrapy 是单线程的,但它能够异步地进行许多请求的处理。这使其比本书中迄今编写的爬虫更快,尽管我一直坚信,在涉及网络抓取时,更快并不总是更好。

您正在尝试抓取的网站的网络服务器必须处理这些请求中的每一个,评估这种服务器压力是否合适(甚至对您自己的利益是否明智,因为许多网站有能力和意愿阻止它们可能认为是恶意的抓取活动)。有关网络抓取伦理以及适当地调节抓取程序重要性的更多信息,请参阅[第十九章。

话虽如此,使用 Scrapy 的项目管道可以通过在等待请求返回时执行所有数据处理来进一步提高网络爬虫的速度,而不是等待数据处理完成后再发出另一个请求。当数据处理需要大量时间或处理器密集型计算时,这种优化甚至可能是必需的。

要创建一个项目管道,请重新访问本章开头创建的 settings.py 文件。您应该看到以下被注释的行:

# Configure item pipelines
# See http://scrapy.readthedocs.org/en/latest/topics/item-pipeline.html
#ITEM_PIPELINES = {
#    'wikiSpider.pipelines.WikispiderPipeline': 300,
#}

取消对最后三行的注释,并替换为:

ITEM_PIPELINES = {
    'wikiSpider.pipelines.WikispiderPipeline': 300,
}

这提供了一个 Python 类 wikiSpider.pipelines.WikispiderPipeline,用于处理数据,以及一个表示运行管道顺序的整数。虽然可以使用任何整数,但通常使用 0 到 1000 的数字,并将按升序运行。

现在,您需要添加管道类并重写原始爬虫,以便爬虫收集数据,管道执行数据处理的繁重工作。也许会诱人地在原始爬虫中编写 parse_items 方法以返回响应并让管道创建 Article 对象:

    def parse_items(self, response):
        return response

然而,Scrapy 框架不允许这样做,必须返回一个 Item 对象(例如扩展 ItemArticle)。因此,现在 parse_items 的目标是提取原始数据,尽可能少地进行处理,以便可以传递到管道:

from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from wikiSpider.items import Article

class ArticleSpider(CrawlSpider):
    name = 'articlePipelines'
    allowed_domains = ['wikipedia.org']
    start_urls = ['https://en.wikipedia.org/wiki/Benevolent_dictator_for_life']
    rules = [
        Rule(LinkExtractor(allow='(/wiki/)((?!:).)*$'),
​    ​    ​    callback='parse_items', follow=True),
    ]

    def parse_items(self, response):
        article = Article()
        article['url'] = response.url
        article['title'] = response.css('h1::text').extract_first()
        article['text'] = response.xpath('//div[@id='
​    ​    ​    '"mw-content-text"]//text()').extract()
        article['lastUpdated'] = response.css('li#'
​    ​    ​    'footer-info-lastmod::text').extract_first()
        return article

此文件保存为 articlePipelines.py 在 GitHub 仓库中。

当然,现在您需要通过添加管道将 pipelines.py 文件和更新的爬虫联系在一起。当最初初始化 Scrapy 项目时,会在 wikiSpider/wikiSpider/pipelines.py 创建一个文件:

# -*- 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

class WikispiderPipeline(object):
    def process_item(self, item, spider):
        return item

这个存根类应该用您的新管道代码替换。在之前的章节中,您已经以原始格式收集了两个字段,并且这些字段可能需要额外的处理:lastUpdated(一个糟糕格式化的表示日期的字符串对象)和 text(一个杂乱的字符串片段数组)。

以下应该用于替换 wikiSpider/wikiSpider/pipelines.py 中的存根代码:

from datetime import datetime
from wikiSpider.items import Article
from string import whitespace

class WikispiderPipeline(object):
    def process_item(self, article, spider):
        dateStr = article['lastUpdated']
        article['lastUpdated'] = article['lastUpdated']
​    ​    ​    .replace('This page was last edited on', '')
        article['lastUpdated'] = article['lastUpdated'].strip()
        article['lastUpdated'] = datetime.strptime(
​    ​    ​    article['lastUpdated'], '%d %B %Y, at %H:%M.')
        article['text'] = [line for line in article['text']
​    ​    ​    if line not in whitespace]
        article['text'] = ''.join(article['text'])
        return article

WikispiderPipeline 具有一个 process_item 方法,接受一个 Article 对象,将 lastUpdated 字符串解析为 Python 的 datetime 对象,并从字符串列表中清理和连接文本为单个字符串。

process_item 是每个管道类的必需方法。Scrapy 使用此方法异步传递由爬虫收集的 Items。例如,如果您在上一节中输出到 JSON 或 CSV,这里返回的解析的 Article 对象将由 Scrapy 记录或打印。

现在,在决定数据处理位置时,您有两个选择:在爬虫中的 parse_items 方法或在管道中的 process_items 方法。

可以在 settings.py 文件中声明具有不同任务的多个管道。但是,Scrapy 将所有项目不论类型都传递给每个管道以便处理。在数据传入管道之前,可能更好地在爬虫中处理特定项目的解析。但是,如果此解析需要很长时间,可以考虑将其移到管道中(可以异步处理),并添加一个项目类型检查:

def process_item(self, item, spider):    
    if isinstance(item, Article):
        # Article-specific processing here

在编写 Scrapy 项目时,特别是在处理大型项目时,确定要执行的处理及其执行位置是一个重要考虑因素。

使用 Scrapy 记录日志

Scrapy 生成的调试信息可能很有用,但你可能已经注意到,它通常过于冗长。你可以通过在 Scrapy 项目的 settings.py 文件中添加一行来轻松调整日志级别:

LOG_LEVEL = 'ERROR'

Scrapy 使用标准的日志级别层次结构,如下:

  • CRITICAL

  • ERROR

  • WARNING

  • DEBUG

  • INFO

如果日志级别设置为 ERROR,则只会显示 CRITICALERROR 级别的日志。如果设置为 INFO,则会显示所有日志,依此类推。

除了通过 settings.py 文件控制日志记录外,还可以通过命令行控制日志输出位置。在命令行中运行时,可以定义一个日志文件,将日志输出到该文件而不是终端:

$ scrapy crawl articles -s LOG_FILE=wiki.log

这将在当前目录中创建一个新的日志文件(如果不存在),并将所有日志输出到该文件,使得你的终端只显示手动添加的 Python 打印语句。

更多资源

Scrapy 是一个强大的工具,可以处理与网络爬取相关的许多问题。它会自动收集所有 URL,并将它们与预定义的规则进行比较,确保所有 URL 都是唯一的,在必要时会对相对 URL 进行标准化,并递归深入页面。

鼓励您查阅Scrapy 文档以及Scrapy 的官方教程页面,这些资源详细介绍了该框架。

Scrapy 是一个非常庞大的库,具有许多功能。它的特性可以无缝协同工作,但也存在许多重叠的区域,使用户可以轻松地在其中开发自己的风格。如果你想要使用 Scrapy 做一些这里未提到的事情,很可能有一种(或几种)方法可以实现!

第九章:存储数据

尽管在终端打印输出很有趣,但在数据聚合和分析方面却不是非常有用。要使大多数网络爬虫实用,你需要能够保存它们抓取的信息。

本章涵盖了三种主要的数据管理方法,几乎适用于任何想象得到的应用程序。需要支持网站的后端或创建自己的 API 吗?你可能希望你的爬虫将数据写入数据库。需要快速简便地从互联网上收集文档并将它们存储到硬盘吗?你可能需要为此创建文件流。需要偶尔的提醒或每天的聚合数据吗?给自己发送邮件吧!

除了网页抓取之外,存储和处理大量数据的能力对于几乎任何现代编程应用都非常重要。事实上,本章的信息对于实现书后部分示例中的许多示例是必要的。如果你对自动化数据存储不熟悉,我强烈建议你至少浏览一下本章。

媒体文件

你可以通过两种主要方式存储媒体文件:通过引用和通过下载文件本身。将文件存储为引用只需保存主机服务器上文件所在位置的文本 URL,而不实际下载文件。这有几个优点:

  • 当爬虫无需下载文件时,其运行速度更快,需要的带宽更少。

  • 通过仅存储 URL,你可以节省自己机器上的空间。

  • 编写仅存储 URL 并且不需要处理额外文件下载的代码更容易。

  • 避免下载大文件可以减轻主机服务器的负载。

以下是缺点:

  • 在你自己的网站或应用程序中嵌入这些 URL 被称为热链接,这样做是在互联网上迅速陷入麻烦的一种方式。

  • 你不希望使用别人的服务器周期来为自己的应用程序托管媒体文件。

  • 存储在特定 URL 上的文件可能会发生变化。如果例如你在公共博客上嵌入了热链接图像,博客所有者发现并决定将图像更改为不良内容,可能会导致尴尬的影响。虽然不严重但仍然不便的是,如果你打算稍后使用它们,存储的 URL 可能在以后某个时候消失。

  • 真正的网络浏览器不仅仅请求页面的 HTML 然后离开。它们还下载页面所需的所有资产。下载文件可以使你的爬虫看起来像是人类在浏览网站,这是比仅仅记录链接更具优势的地方。

如果您在考虑是将文件还是文件的 URL 存储到文件中,则应自问您是否有可能多次查看或阅读该文件,或者这个文件的数据库是否将在其生命周期的大部分时间内闲置。如果答案是后者,则最好只存储 URL。如果是前者,请继续阅读!

用于检索网页内容的 urllib 库还包含用于检索文件内容的功能。以下程序使用urllib.request.urlretrieve从远程 URL 下载图像:

from urllib.request import urlretrieve, urlopen
from bs4 import BeautifulSoup

html = urlopen('http://www.pythonscraping.com')
bs = BeautifulSoup(html, 'html.parser')
imageLocation = bs.find('img', {'alt': 'python-logo'})['src']
urlretrieve (imageLocation, 'logo.jpg')

这将从 pythonscraping.com 下载 Python 标志并将其存储为logo.jpg在脚本运行的同一目录中。

如果您只需要下载单个文件并知道如何命名它以及文件扩展名是什么,则此方法效果很好。但大多数爬虫不会只下载一个文件并结束。以下内容将从 pythonscraping.com 的主页下载所有内部文件,这些文件由任何标签的src属性链接到:

import os
from urllib.request import urlretrieve, urlopen
from urllib.parse import urlparse
from bs4 import BeautifulSoup

downloadDir = 'downloaded'
baseUrl = 'https://pythonscraping.com/'
baseNetloc = urlparse(baseUrl).netloc

def getAbsoluteURL(source):
    if urlparse(baseUrl).netloc == '':
        return baseUrl + source
    return source

def getDownloadPath(fileUrl):
    parsed = urlparse(fileUrl)
    netloc = parsed.netloc.strip('/')
    path = parsed.path.strip('/')
    localfile = f'{downloadDir}/{netloc}/{path}'

    # Remove the filename from the path in order to 
    # make the directory structure leading up to it
    localpath = '/'.join(localfile.split('/')[:-1])
    if not os.path.exists(localpath):
        os.makedirs(localpath)
    return localfile

html = urlopen(baseUrl)
bs = BeautifulSoup(html, 'html.parser')
downloadList = bs.findAll(src=True)

for download in downloadList:
    fileUrl = getAbsoluteURL(download['src'])
    if fileUrl is not None:
        try:
            urlretrieve(fileUrl, getDownloadPath(fileUrl))
            print(fileUrl)
        except Exception as e:
            print(f'Could not retrieve {fileUrl} Error: {e}')

谨慎运行

你知道那些关于从互联网下载未知文件的警告吗?这个脚本会将它遇到的一切都下载到您计算机的硬盘上。这包括随机的 bash 脚本、.exe文件和其他可能的恶意软件。

你以为自己是安全的,因为你从未真正执行过发送到你的下载文件夹的任何内容吗?特别是如果您以管理员身份运行此程序,您就在自找麻烦。如果您遇到一个发送自身到*../../../../usr/bin/python*的网站文件会发生什么?下次您从命令行运行 Python 脚本时,您可能会在您的机器上部署恶意软件!

本程序仅供示例目的编写;不应随意部署而没有更广泛的文件名检查,并且只应在具有有限权限的帐户中运行。一如既往,备份您的文件,不要将敏感信息存储在硬盘上,并且运用一些常识会事半功倍。

此脚本使用了一个 lambda 函数(在第五章介绍)来选择首页上具有src属性的所有标签,然后清理和规范化 URL 以获取每个下载的绝对路径(确保丢弃外部链接)。然后,每个文件都将下载到本地文件夹downloaded中的自己的路径上。

注意,Python 的os模块被简要用于检索每个下载的目标目录,并在需要时创建丢失的目录路径。os模块充当 Python 与操作系统之间的接口,允许它操作文件路径,创建目录,获取有关运行进程和环境变量的信息,以及许多其他有用的事情。

将数据存储到 CSV

CSV,或称为逗号分隔值,是存储电子表格数据的最流行的文件格式之一。由于其简单性,它受到 Microsoft Excel 和许多其他应用程序的支持。以下是一个完全有效的 CSV 文件示例:

fruit,cost
apple,1.00
banana,0.30
pear,1.25

与 Python 一样,在这里空白字符很重要:每行由换行符分隔,而行内列则由逗号分隔(因此称为“逗号分隔”)。其他形式的 CSV 文件(有时称为字符分隔值文件)使用制表符或其他字符分隔行,但这些文件格式较不常见且支持较少。

如果您希望直接从网上下载 CSV 文件并将其存储在本地,而无需进行任何解析或修改,您不需要阅读这一部分。使用前一部分描述的方法下载它们,就像下载任何其他文件并使用 CSV 文件格式保存它们一样。

使用 Python 的csv库非常容易修改 CSV 文件,甚至可以从头开始创建一个:

import csv

csvFile = open('test.csv', 'w+')
try:
    writer = csv.writer(csvFile)
    writer.writerow(('number', 'number plus 2', 'number times 2'))
    for i in range(10):
        writer.writerow( (i, i+2, i*2))
finally:
    csvFile.close()

预防性提醒:在 Python 中创建文件是相当防弹的。如果test.csv不存在,Python 将自动创建该文件(但不会创建目录)。如果已存在,则 Python 将用新数据覆盖test.csv

运行后,您应该会看到一个 CSV 文件:

number,number plus 2,number times 2
0,2,0
1,3,2
2,4,4
...

一个常见的网页抓取任务是检索 HTML 表格并将其写入 CSV 文件。维基百科的麦当劳餐厅列表提供了一个包含链接、排序和其他 HTML 垃圾的相当复杂的 HTML 表格,需要在写入 CSV 之前将其丢弃。使用 BeautifulSoup 和get_text()函数,您可以在不到 20 行的代码中完成这一任务:

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

html = urlopen('https://en.wikipedia.org/wiki/
       List_of_countries_with_McDonald%27s_restaurants')
bs = BeautifulSoup(html, 'html.parser')
# The main comparison table is currently the first table on the page
table = bs.find('table',{'class':'wikitable'})
rows = table.findAll('tr')
csvFile = open('countries.csv', 'wt+')
writer = csv.writer(csvFile)
try:
    for row in rows:
        csvRow = []
        for cell in row.findAll(['td', 'th']):
            csvRow.append(cell.get_text().strip())
        writer.writerow(csvRow)
finally:
    csvFile.close()

获取单个表格的更简单方法

如果您经常遇到需要将多个 HTML 表格转换为 CSV 文件,或者需要将多个 HTML 表格收集到单个 CSV 文件中,这段脚本非常适合集成到爬虫中。然而,如果您只需要做一次,还有更好的工具可用:复制和粘贴。选择并复制 HTML 表格的所有内容,然后将其粘贴到 Excel 或 Google 文档中,即可获得所需的 CSV 文件,而无需运行脚本!

结果应该是一个保存在本地的格式良好的 CSV 文件,命名为countries.csv

MySQL

MySQL(正式发音为“my es-kew-el”,尽管许多人说“my sequel”)是当今最流行的开源关系数据库管理系统。与其他大型竞争对手相比,它的流行度在历史上一直与两个其他主要的闭源数据库系统:Microsoft 的 SQL Server 和 Oracle 的 DBMS 齐头并进,这在开源项目中是相当不寻常的。

它的流行不是没有原因的。对于大多数应用程序来说,选择 MySQL 很难出错。它是一个可扩展的、强大的、功能齐全的数据库管理系统,被顶级网站使用:YouTube[¹]、Twitter[²] 和 Facebook[³],以及许多其他网站。

因为 MySQL 的普及性、价格(“免费”是一个非常好的价格)和开箱即用性,它非常适合用于网络抓取项目,并且我们将在本书的余下部分中继续使用它。

安装 MySQL

如果你对 MySQL 还不熟悉,安装数据库可能听起来有点吓人(如果你已经很熟悉了,可以跳过这一部分)。实际上,它和安装其他类型的软件一样简单。在核心层面,MySQL 由一组数据文件驱动,存储在服务器或本地机器上,这些文件包含数据库中存储的所有信息。MySQL 软件层在此基础上提供了通过命令行界面方便地与数据交互的方式。例如,以下命令会浏览数据文件并返回数据库中所有名字为“Ryan”的用户的列表:

SELECT * FROM users WHERE firstname = "Ryan"

如果你使用基于 Debian 的 Linux 发行版(或者任何带有 apt-get 的系统),安装 MySQL 就像这样简单:

$ sudo apt-get install mysql-server

只需关注安装过程,批准内存要求,并在提示时为新的 root 用户输入新密码即可。

对于 macOS 和 Windows,情况会有些棘手。如果还没有,请先创建一个 Oracle 账户然后再下载安装包。

如果你在 macOS 上,首先需要获取安装包

选择 .dmg 包,并使用或创建 Oracle 账户来下载文件。文件打开后,你应该会被引导通过一个相当简单的安装向导(参见图 9-1)。

默认的安装步骤应该足够,对于本书的目的,我假设你已经安装了默认的 MySQL。

在 macOS 上安装 MySQL 后,可以按照以下步骤启动 MySQL 服务器:

$ cd /usr/local/mysql
$ sudo ./bin/mysqld_safe

在 Windows 上,安装和运行 MySQL 稍微复杂一些,但好消息是有一个便捷的安装程序简化了这个过程。一旦下载完成,它将指导你完成所需的步骤(参见图 9-2)。

Alt Text

图 9-1. macOS 上的 MySQL 安装程序

Alt Text

图 9-2. Windows 上的 MySQL 安装程序

你应该能够通过选择默认选项来安装 MySQL,只有一个例外:在设置类型页面上,我建议你选择仅安装服务器,以避免安装大量额外的 Microsoft 软件和库。接下来,你可以使用默认的安装设置并按照提示启动你的 MySQL 服务器。

安装并运行 MySQL 服务器后,您仍然需要能够使用命令行与其进行交互。在 Windows 上,您可以使用MySQL Shell工具。在 Mac 上,我喜欢使用Homebrew package manager安装命令行工具:

$ brew install mysql

安装命令行工具后,您应该能够连接到 MySQL 服务器:

$ mysql -u root -p

这将提示您输入安装过程中创建的根密码。

一些基本命令

在 MySQL 服务器运行后,您有多种选项可以与数据库进行交互。许多软件工具作为中介,使您不必经常处理 MySQL 命令(或至少较少处理)。诸如 phpMyAdmin 和 MySQL Workbench 的工具可以使快速查看、排序和插入数据变得简单。但是,熟悉命令行操作仍然非常重要。

除了变量名外,MySQL 是不区分大小写的;例如,SELECTsElEcT 是相同的。然而,按照惯例,编写 MySQL 语句时所有 MySQL 关键字都应为大写。相反,大多数开发者更喜欢将其表和数据库名称使用小写,尽管这种标准经常被忽略。

当您首次登录 MySQL 时,还没有数据库可以添加数据,但是您可以创建一个:

> CREATE DATABASE scraping;

由于每个 MySQL 实例可以有多个数据库,在您开始与数据库交互之前,您需要告诉 MySQL 您要使用哪个数据库:

> USE scraping;

从此时起(至少直到您关闭 MySQL 连接或切换到另一个数据库),所有输入的命令都将针对新创建的scraping数据库运行。

一切看起来都很简单。在数据库中创建表应该也同样简单吧?让我们试着创建一个用于存储抓取的网页集合的表:

> CREATE TABLE pages;

这将导致错误:

ERROR 1113 (42000): A table must have at least 1 column

与数据库可以不含任何表存在不同,MySQL 中的表不能没有列存在。要在 MySQL 中定义列,必须在CREATE TABLE <tablename>语句后的括号内输入以逗号分隔的列列表:

> CREATE TABLE pages (id BIGINT(7) NOT NULL AUTO_INCREMENT,
title VARCHAR(200), content VARCHAR(10000),
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(id));

每个列定义有三个部分:

  • 名称(idtitlecreated,等等)

  • 变量类型(BIGINT(7)VARCHARTIMESTAMP

  • 可选的任何其他属性(NOT NULL AUTO_INCREMENT

在列列表的末尾,您必须定义表的关键字。MySQL 使用关键字来组织表中的内容,以便进行快速查找。在本章后面,我将描述如何利用这些关键字来加快数据库操作,但现在,通常将表的id列作为关键字是最佳选择。

执行查询后,您可以随时使用DESCRIBE命令查看表的结构:

> DESCRIBE pages;
+---------+----------------+------+-----+-------------------+----------------+
| Field   | Type           | Null | Key | Default           | Extra          |
+---------+----------------+------+-----+-------------------+----------------+
| id      | bigint(7)      | NO   | PRI | NULL              | auto_increment |
| title   | varchar(200)   | YES  |     | NULL              |                |
| content | varchar(10000) | YES  |     | NULL              |                |
| created | timestamp      | NO   |     | CURRENT_TIMESTAMP |                |
+---------+----------------+------+-----+-------------------+----------------+
4 rows in set (0.01 sec)

当然,这仍然是一个空表。您可以使用以下命令将测试数据插入到pages表中:

> INSERT INTO pages (title, content) VALUES ("Test page title",
"This is some test page content. It can be up to 10,000 characters
long.");

注意,尽管表有四列 (id, title, content, created), 但你只需定义其中两列 (titlecontent) 就可以插入一行数据。这是因为 id 列是自动增加的(每次插入新行时 MySQL 自动添加 1),通常可以自行处理。此外,timestamp 列设置为默认包含当前时间。

当然,你可以覆盖这些默认设置:

> INSERT INTO pages (id, title, content, created) VALUES (3, 
"Test page title",
"This is some test page content. It can be up to 10,000 characters
long.", "2014-09-21 10:25:32");

只要你提供给 id 列的整数在数据库中不存在,这个覆盖就可以完美运行。然而,这通常不是一个好的做法;最好让 MySQL 自行处理 idtimestamp 列,除非有充分的理由要做出不同的处理。

现在你的表中有了一些数据,你可以使用多种方法来选择这些数据。以下是几个 SELECT 语句的示例:

> SELECT * FROM pages WHERE id = 2;

这个语句告诉 MySQL,“从 pages 中选择所有 id 等于 2 的行。”星号 (*) 充当通配符,在 where id equals 2 条件为真时返回所有行。它返回表中的第二行,或者如果没有 id 等于 2 的行,则返回空结果。例如,以下不区分大小写的查询返回所有 title 字段包含 “test” 的行(% 符号在 MySQL 字符串中充当通配符):

> SELECT * FROM pages WHERE title LIKE "%test%";

但是如果你有一张有很多列的表,你只想返回特定的一部分数据怎么办?不必选择全部,你可以像这样做:

> SELECT id, title FROM pages WHERE content LIKE "%page content%";

这将只返回包含短语 “page content” 的 idtitle

DELETE 语句与 SELECT 语句的语法基本相同:

> DELETE FROM pages WHERE id = 1;

因此,特别是在处理不能轻易恢复的重要数据库时,最好将任何 DELETE 语句首先编写为 SELECT 语句(在本例中为 SELECT * FROM pages WHERE  id = 1),测试以确保只返回要删除的行,然后用 DELETE 替换 SELECT *。许多程序员有编写 DELETE 语句时错用了条件或更糟糕的是匆忙时完全忽略了它的恐怖故事,导致客户数据丢失。不要让这种情况发生在你身上!

对于 UPDATE 语句也应该采取类似的预防措施:

> UPDATE pages SET title="A new title",
content="Some new content" WHERE id=2;

本书只涉及简单的 MySQL 语句,进行基本的选择、插入和更新。如果你有兴趣学习更多关于这个强大数据库工具的命令和技巧,我推荐 Paul DuBois 的 MySQL Cookbook(O’Reilly)。

与 Python 集成

不幸的是,Python 对于 MySQL 的支持不是内置的。然而,许多开源库允许你与 MySQL 数据库交互。其中最流行的之一是 PyMySQL

截至撰写本文时,PyMySQL 的当前版本是 1.0.3,可以使用 pip 安装:

$ pip install PyMySQL

安装完成后,你应该自动拥有 PyMySQL 包的访问权限。当你的本地 MySQL 服务器运行时,你应该能够成功执行以下脚本(记得为你的数据库添加 root 密码):

import pymysql
conn = pymysql.connect(
    host='127.0.0.1',
​    unix_socket='/tmp/mysql.sock',
​    user='root',
​    passwd=None,
​    db='mysql'
)
cur = conn.cursor()
cur.execute('USE scraping')
cur.execute('SELECT * FROM pages WHERE id=1')
print(cur.fetchone())
cur.close()
conn.close()

在这个示例中,引入了两种新的对象类型:连接对象(conn)和游标对象(cur)。

连接/游标模型在数据库编程中常用,尽管一些用户可能起初会发现区分这两者有些棘手。连接负责连接数据库,当然,还负责发送数据库信息,处理回滚(当需要中止一个查询或一组查询,并且需要将数据库返回到之前的状态时),以及创建新的游标对象。

一个连接可以有多个游标。游标跟踪某些状态信息,比如它正在使用哪个数据库。如果你有多个数据库并且需要在所有数据库中写入信息,你可能需要多个游标来处理这个任务。游标还包含它执行的最新查询的结果。通过调用游标的函数,比如cur.fetchone(),你可以访问这些信息。

在使用完游标和连接后,重要的是要关闭它们。如果不这样做,可能会导致连接泄漏,即未关闭的连接会积累起来,虽然不再使用,但软件无法关闭,因为它认为你可能仍在使用它们。这是经常导致数据库故障的问题之一(我既写过也修复过许多连接泄漏的 bug),所以记得关闭你的连接!

最常见的事情,你可能一开始想做的就是能够将你的爬取结果存储在数据库中。让我们看看如何实现这一点,使用之前的示例:维基百科爬虫。

在网页抓取时处理 Unicode 文本可能会很棘手。默认情况下,MySQL 不处理 Unicode。幸运的是,你可以启用这个功能(只需记住这样做会增加数据库的大小)。因为你可能会在维基百科上遇到各种丰富多彩的字符,现在是告诉你的数据库期望一些 Unicode 字符的好时机:

ALTER DATABASE scraping CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
ALTER TABLE pages CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE pages CHANGE title title VARCHAR(200) CHARACTER SET utf8mb4 COLLATE 
utf8mb4_unicode_ci;
ALTER TABLE pages CHANGE content content VARCHAR(10000) CHARACTER SET utf8mb4 
COLLATE utf8mb4_unicode_ci;

这四行改变了数据库的默认字符集,表格的字符集,以及两列的字符集,从utf8mb4(仍然是 Unicode,但对大多数 Unicode 字符的支持非常糟糕)到utf8mb4_unicode_ci

如果你尝试将几个 umlauts 或者汉字插入数据库中的titlecontent字段,并且成功执行而没有错误,那么你就知道你成功了。

现在数据库已经准备好接受维基百科可能投射给它的各种内容,你可以运行以下操作:

conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
                       user='root', passwd=None, db='mysql', charset='utf8')
cur = conn.cursor()
cur.execute('USE scraping')

random.seed(datetime.datetime.now())

def store(title, content):
    cur.execute('INSERT INTO pages (title, content) VALUES '
        '("%s", "%s")', (title, content))
    cur.connection.commit()

def getLinks(articleUrl):
    html = urlopen('http://en.wikipedia.org'+articleUrl)
    bs = BeautifulSoup(html, 'html.parser')
    title = bs.find('h1').get_text()
    content = bs.find('div', {'id':'mw-content-text'}).find('p')
        .get_text()
    store(title, content)
    return bs.find('div', {'id':'bodyContent'}).find_all('a',
        href=re.compile('^(/wiki/)((?!:).)*$'))

links = getLinks('/wiki/Kevin_Bacon')
try:
    while len(links) > 0:
         newArticle = links[random.randint(0, len(links)-1)].attrs['href']
         print(newArticle)
         links = getLinks(newArticle)
finally:
    cur.close()
    conn.close()

在这里需要注意几点:首先,在数据库连接字符串中添加了"charset='utf8'"。这告诉连接应将所有信息以 UTF-8 发送到数据库(当然,数据库应已配置为处理此信息)。

其次,注意添加了一个store函数。它接收两个字符串变量titlecontent,并将它们添加到一个INSERT语句中,该语句由游标执行,然后由游标的连接提交。这是游标和连接分离的一个很好的例子;虽然游标存储了关于数据库及其自身上下文的信息,但它需要通过连接操作才能将信息发送回数据库并插入信息。

最后,你会发现在程序的主循环底部添加了一个finally语句。这确保了无论程序如何被中断或其执行过程中可能抛出的异常(因为网络是混乱的,你应该始终假设会抛出异常),在程序结束之前光标和连接都会立即关闭。在进行网络爬取并且有开放的数据库连接时,包括像这样的try...finally语句是个好主意。

虽然 PyMySQL 不是一个庞大的包,但这本书无法涵盖大量有用的功能。你可以在 PyMySQL 网站的文档中查看更多信息。

数据库技术和良好实践

有些人将他们整个职业生涯都花在研究、调优和发明数据库上。我不是这些人之一,这本书也不是那种类型的书。然而,就计算机科学中的许多主题而言,你可以快速学到一些技巧,至少能使你的数据库对大多数应用程序足够、而且足够快速。

首先,除了少数例外情况,始终向你的表中添加id列。MySQL 中的所有表都必须至少有一个主键(MySQL 用来排序的关键列),因此 MySQL 需要知道如何对其进行排序,而且通常难以明智地选择这些键。

关于是使用人工创建的id列作为此键还是像username这样的唯一属性的争论已经在数据科学家和软件工程师中持续了多年,虽然我倾向于创建id列。尤其是当你处理网页抓取和存储他人数据时,这一点尤为真实。你根本不知道什么是真正独特的或非独特的,我之前也曾感到惊讶。

你的id列应该是自增的,并且作为所有表的主键使用。

其次,使用智能索引。一个词典(就像书中的那种,而不是 Python 对象)是按字母顺序索引的单词列表。这样,每当你需要一个单词时,只要知道它的拼写方式,就可以快速查找。你还可以想象一个根据单词定义按字母顺序排列的词典。除非你在玩某种奇怪的危险边缘游戏,需要根据定义找出单词,否则这种词典几乎没有用处。但是在数据库查找中,这类情况确实会发生。例如,你的数据库中可能有一个经常需要查询的字段:

>SELECT * FROM dictionary WHERE definition="A small furry animal that says meow";
+------+-------+-------------------------------------+
| id   | word  | definition                          |
+------+-------+-------------------------------------+
|  200 | cat   | A small furry animal that says meow |
+------+-------+-------------------------------------+
1 row in set (0.00 sec)

你很可能想要在这个表中添加一个索引(除了已经存在的id索引),以使对definition列的查找更快速。不过,请记住,添加索引会增加新索引的空间占用,并在插入新行时增加额外的处理时间。特别是当你处理大量数据时,你应仔细考虑索引的权衡以及你需要索引多少的问题。为了使这个“definitions”索引变得更加轻便,你可以告诉 MySQL 仅对列值中的前 16 个字符进行索引。这条命令将在definition字段的前 16 个字符上创建一个索引:

CREATE INDEX definition ON dictionary (id, definition(16));

当你需要通过完整定义来搜索单词时(尤其是如果定义值的前 16 个字符彼此非常不同),这个索引将使你的查找速度大大加快,并且不会显著增加额外的空间和前期处理时间。

关于查询时间与数据库大小之间的平衡(数据库工程中的基本平衡之一),特别是在大量自然文本数据的网络抓取中,常见的一个错误是存储大量重复数据。例如,假设你想要测量出现在多个网站上的某些短语的频率。这些短语可以从给定列表中找到,也可以通过文本分析算法自动生成。也许你会被诱惑将数据存储为这样的格式:

+--------+--------------+------+-----+---------+----------------+
| Field  | Type         | Null | Key | Default | Extra          |
+--------+--------------+------+-----+---------+----------------+
| id     | int(11)      | NO   | PRI | NULL    | auto_increment |
| url    | varchar(200) | YES  |     | NULL    |                |
| phrase | varchar(200) | YES  |     | NULL    |                |
+--------+--------------+------+-----+---------+----------------+

每次在网站上找到一个短语并记录它所在的 URL 时,这样会向数据库中添加一行。然而,通过将数据拆分为三个单独的表,你可以极大地减少数据集:

>DESCRIBE phrases
+--------+--------------+------+-----+---------+----------------+
| Field  | Type         | Null | Key | Default | Extra          |
+--------+--------------+------+-----+---------+----------------+
| id     | int(11)      | NO   | PRI | NULL    | auto_increment |
| phrase | varchar(200) | YES  |     | NULL    |                |
+--------+--------------+------+-----+---------+----------------+

 >DESCRIBE urls
 +-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| url   | varchar(200) | YES  |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+

>DESCRIBE foundInstances
+-------------+---------+------+-----+---------+----------------+
| Field       | Type    | Null | Key | Default | Extra          |
+-------------+---------+------+-----+---------+----------------+
| id          | int(11) | NO   | PRI | NULL    | auto_increment |
| urlId       | int(11) | YES  |     | NULL    |                |
| phraseId    | int(11) | YES  |     | NULL    |                |
| occurrences | int(11) | YES  |     | NULL    |                |
+-------------+---------+------+-----+---------+----------------+

尽管表定义更大,但你可以看到大多数列只是整数id字段,它们占用的空间要少得多。此外,每个 URL 和短语的完整文本仅存储一次。

除非安装第三方包或保持详细的日志记录,否则无法确定数据何时添加、更新或从数据库中删除。根据数据的可用空间、更改的频率以及确定这些更改发生时间的重要性,你可能需要考虑保留多个时间戳:createdupdateddeleted

MySQL 中的“六度”

第六章介绍了维基百科的六度问题,即通过一系列链接找到任意两个维基百科文章之间的连接的目标(即,通过从一个页面点击链接到达下一个页面的方式找到一种方法)。要解决这个问题,不仅需要构建可以爬取网站的机器人(这一点你已经做到了),还需要以建筑上合理的方式存储信息,以便以后轻松进行数据分析。

自增的 id 列,时间戳,和多个表格:它们在这里都发挥作用。为了找出如何最好地存储这些信息,你需要抽象地思考。一个链接简单地是连接页面 A 和页面 B 的东西。它同样可以将页面 B 连接到页面 A,但这将是一个独立的链接。你可以通过说,“在页面 A 上存在一个连接到页面 B 的链接来唯一标识一个链接。也就是说,INSERT INTO links (fromPageId, toPageId) VALUES (A, B);(其中 A 和 B 是两个页面的唯一 ID)。”

一个旨在存储页面和链接、创建日期和唯一 ID 的两表系统可以按以下方式构建:

CREATE DATABASE wikipedia;
USE wikipedia;

CREATE TABLE wikipedia.pages (
  id INT NOT NULL AUTO_INCREMENT,
  url VARCHAR(255) NOT NULL,
  created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
);

CREATE TABLE wikipedia.links (
  id INT NOT NULL AUTO_INCREMENT,
  fromPageId INT NULL,
  toPageId INT NULL,
  created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id)
);

请注意,与之前打印页面标题的爬虫不同,你甚至没有将页面标题存储在 pages 表中。为什么呢?嗯,记录页面标题需要访问页面以检索它。如果你想要构建一个高效的网络爬虫来填充这些表,你希望能够存储页面以及链接到它的链接,即使你还没有必要访问页面。

尽管这并不适用于所有网站,但维基百科链接和页面标题之间的好处在于,一个可以通过简单操作变成另一个。例如,en.wikipedia.org/wiki/Monty_… 表示页面的标题是“蒙提·派森”。

以下将存储在维基百科上具有“Bacon 数”(与凯文·贝肯页面之间的链接数,包括)小于或等于 6 的所有页面:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
import pymysql
from random import shuffle

conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
                       user='root', passwd='password', db='mysql', 
                             charset='utf8')
cur = conn.cursor()
cur.execute('USE wikipedia')

def insertPageIfNotExists(url):
    cur.execute('SELECT id FROM pages WHERE url = %s LIMIT 1', (url))
    page = cur.fetchone()
    if not page:
        cur.execute('INSERT INTO pages (url) VALUES (%s)', (url))
        conn.commit()
        return cur.lastrowid
    else:
        return page[0]

def loadPages():
    cur.execute('SELECT url FROM pages')
    return [row[0] for row in cur.fetchall()]

def insertLink(fromPageId, toPageId):
    cur.execute(
        'SELECT EXISTS(SELECT 1 FROM links WHERE fromPageId = %s\
 AND toPageId = %s)'
        ,(int(fromPageId),
        int(toPageId))
    )
    if not cur.fetchone()[0]:
        cur.execute('INSERT INTO links (fromPageId, toPageId) VALUES (%s, %s)', 
                    (int(fromPageId), int(toPageId)))
        conn.commit()

def pageHasLinks(pageId):
    cur.execute(
        'SELECT EXISTS(SELECT 1 FROM links WHERE fromPageId = %s)'
        , (int(pageId))
    )
    return cur.fetchone()[0]

def getLinks(pageUrl, recursionLevel, pages):
    if recursionLevel > 4:
        return

    pageId = insertPageIfNotExists(pageUrl)
    html = urlopen(f'http://en.wikipedia.org{pageUrl}')
    bs = BeautifulSoup(html, 'html.parser')
    links = bs.findAll('a', href=re.compile('^(/wiki/)((?!:).)*$'))
    links = [link.attrs['href'] for link in links]

    for link in links:
        linkId = insertPageIfNotExists(link)
        insertLink(pageId, linkId)
        if not pageHasLinks(linkId):
            print(f'Getting {link}')
            pages.append(link)
            getLinks(link, recursionLevel+1, pages)
        else:
            print(f'Already fetched {link}')

getLinks('/wiki/Kevin_Bacon', 0, loadPages()) 
cur.close()
conn.close()

这里的三个函数使用 PyMySQL 与数据库交互:

insertPageIfNotExists

正如其名称所示,如果页面记录尚不存在,此函数将插入一个新的页面记录。这与存储在 pages 中的所有收集页面的运行列表一起,确保页面记录不会重复。它还用于查找 pageId 数字以创建新的链接。

insertLink

这在数据库中创建一个新的链接记录。如果该链接已经存在,它将不会创建一个链接。即使页面上存在两个或多个相同的链接,对于我们的目的来说,它们是同一个链接,代表同一个关系,并且应该被计为一个记录。即使在同一页面上运行多次程序,这也有助于保持数据库的完整性。

loadPages

这会将数据库中所有当前页面加载到列表中,以便确定是否应该访问新页面。页面也会在运行时收集,因此如果此爬虫仅运行一次,从空数据库开始理论上不应该需要loadPage。然而,实际上可能会出现问题。网络可能会中断,或者您可能希望在几段时间内收集链接,因此爬虫能够重新加载自身并不会失去任何进展是非常重要的。

您应该注意使用loadPages和它生成的pages列表确定是否访问页面时可能出现的一个潜在问题:一旦加载每个页面,该页面上的所有链接都被存储为页面,即使它们尚未被访问——只是它们的链接已被看到。如果爬虫停止并重新启动,所有这些“已看到但未访问”的页面将永远不会被访问,并且来自它们的链接将不会被记录。可以通过向每个页面记录添加布尔变量visited并仅在该页面已加载并记录其自身的传出链接时将其设置为True来修复此问题。

然而,对于我们的目的来说,目前这个解决方案已经足够了。如果您可以确保相对较长的运行时间(或只需一次运行时间),并且没有必要确保完整的链接集(只需要一个大数据集进行实验),则不需要添加visited变量。

对于解决从凯文·贝肯埃里克·艾多的问题的延续以及最终解决方案,请参阅解决有向图问题的“维基百科的六度:结论”。

电子邮件

就像网页通过 HTTP 发送一样,电子邮件通过 SMTP(简单邮件传输协议)发送。而且就像您使用 Web 服务器客户端处理通过 HTTP 发送网页一样,服务器使用各种电子邮件客户端(如 Sendmail、Postfix 或 Mailman)来发送和接收电子邮件。

尽管使用 Python 发送电子邮件相对简单,但确实需要您可以访问运行 SMTP 的服务器。在服务器或本地计算机上设置 SMTP 客户端很棘手,超出了本书的范围,但许多优秀的资源可以帮助完成此任务,特别是如果您正在运行 Linux 或 macOS。

以下代码示例假设您在本地运行 SMTP 客户端。(要将此代码修改为远程 SMTP 客户端,请将localhost更改为您的远程服务器地址。)

使用 Python 发送电子邮件仅需九行代码:

import smtplib
from email.mime.text import MIMEText

msg = MIMEText('The body of the email is here')

msg['Subject'] = 'An Email Alert'
msg['From'] = 'ryan@pythonscraping.com'
msg['To'] = 'webmaster@pythonscraping.com'

s = smtplib.SMTP('localhost')
s.send_message(msg)
s.quit()

Python 包含两个重要的包用于发送电子邮件:smtplibemail

Python 的 email 模块包含用于创建要发送的电子邮件包的有用格式化函数。这里使用的 MIMEText 对象创建了一个空的电子邮件,格式化为使用低级 MIME(多用途互联网邮件扩展)协议进行传输,高级别的 SMTP 连接是在此基础上建立的。MIMEText 对象 msg 包含了电子邮件的收件人和发件人地址,以及一个主体和一个头部,Python 使用这些信息来创建一个格式正确的电子邮件。

smtplib 包包含了处理与服务器连接的信息。就像连接到 MySQL 服务器一样,这个连接必须在每次创建时关闭,以避免创建过多的连接。

这个基本的电子邮件功能可以通过将其放在一个函数中来扩展并使其更有用:

import smtplib
from email.mime.text import MIMEText
from bs4 import BeautifulSoup
from urllib.request import urlopen
import time

def sendMail(subject, body):
    msg = MIMEText(body)
    msg['Subject'] = subject
    msg['From'] ='christmas_alerts@pythonscraping.com'
    msg['To'] = 'ryan@pythonscraping.com'

    s = smtplib.SMTP('localhost')
    s.send_message(msg)
    s.quit()

bs = BeautifulSoup(urlopen('https://isitchristmas.com/'), 'html.parser')
while(bs.find('a', {'id':'answer'}).attrs['title'] == 'NO'):
    print('It is not Christmas yet.')
    time.sleep(3600)
    bs = BeautifulSoup(urlopen('https://isitchristmas.com/'), 'html.parser')

sendMail('It\'s Christmas!', 
         'According to http://itischristmas.com, it is Christmas!')

这个特定脚本每小时检查一次网站 https://isitchristmas.com(其主要功能是根据一年中的日期显示一个巨大的 YES 或 NO)。如果它看到的不是 NO,它将发送给您一个警报邮件,提醒您现在是圣诞节。

尽管这个特定程序看起来可能比挂在墙上的日历没有多大用处,但它可以稍加调整,做出各种非常有用的事情。它可以在网站停机、测试失败,甚至是你在亚马逊等待的缺货产品出现时向你发送警报邮件——而你的墙挂日历都做不到这些。

¹ Joab Jackson,“YouTube 用 Go 代码扩展 MySQL”PCWorld,2012 年 12 月 15 日。

² Jeremy Cole 和 Davi Arnaut,“Twitter 上的 MySQL”Twitter 工程博客,2012 年 4 月 9 日。

³ “MySQL 和数据库工程:Mark Callaghan”,Facebook 工程师,2012 年 3 月 4 日。