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

300 阅读1小时+

Python 网络爬取第三版(四)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第十五章:通过 API 爬行

JavaScript 一直是网络爬虫的克星。在互联网古老历史的某个时刻,你可以确保向 Web 服务器请求一个 HTML 页面,所得到的内容与在浏览器中看到的完全相同。

随着 JavaScript 和 Ajax 内容的生成和加载变得更加普遍,这种情况变得不那么常见了。在第十四章中,你看到了解决这个问题的一种方法:使用 Selenium 自动化浏览器并获取数据。这是一件容易的事情。它几乎总是有效的。

问题在于,当你拥有像 Selenium 这样强大和高效的“锤子”时,每个网络抓取问题开始看起来都很像一个钉子。

在本章中,你将完全消除 JavaScript 的影响(甚至无需执行它或加载它!),直接访问数据源:生成数据的 API。

API 简介

尽管关于 REST、GraphQL、JSON 和 XML API 的复杂性有无数书籍、演讲和指南,但它们的核心基于一个简单的概念。一个API,或者应用程序编程接口,定义了一种标准化的语法,允许一个软件件与另一个软件件通信,即使它们可能是用不同的语言编写或以其他方式不同结构化的。

本节专注于 Web API(特别是允许 Web 服务器与浏览器通信的 API),并使用术语API特指这种类型。但是你可能想记住,在其他情境中,API也是一个通用术语,可以用来允许例如 Java 程序与同一台机器上运行的 Python 程序通信。API 并不总是“通过互联网”并且不一定涉及任何 Web 技术。

Web API 最常被使用的是那些使用良好宣传和文档化的公共服务的开发人员。例如,美国国家气象局提供了一个weather API,可以获取任何地点的当前天气数据和预报。Google 在其开发者部分提供了几十种 API,用于语言翻译、分析和地理位置。

这些 API 的文档通常描述路由或端点,即你可以请求的 URL,带有可变参数,可以是 URL 路径中的一部分,也可以是GET参数。

例如,以下示例将pathparam作为路由路径的参数提供:

http://example.com/the-api-route/pathparam

而这个示例将pathparam作为参数param1的值提供:

http://example.com/the-api-route?param1=pathparam

尽管像许多计算机科学中的主题一样,有关何时以及在哪里通过路径或参数传递变量的哲学辩论一直在进行,但两种通过 API 传递变量数据的方法经常被使用。

API 的响应通常以 JSON 或 XML 格式返回。在现代,JSON 比 XML 更流行,但您仍然可能会看到一些 XML 响应。许多 API 允许您更改响应类型,使用另一个参数来定义您想要的响应类型。

这里是一个 JSON 格式的 API 响应的示例:

{"user":{"id": 123, "name": "Ryan Mitchell", "city": "Boston"}}

这里是一个 XML 格式的 API 响应的示例:

<user><id>123</id><name>Ryan Mitchell</name><city>Boston</city></user>

ip-api.com 提供了一个易于使用且简单的 API,将 IP 地址转换为实际物理地址。您可以尝试在浏览器中输入以下内容进行简单的 API 请求:¹

http://ip-api.com/json/50.78.253.58

这应该产生类似以下的响应:

{"as": "AS7922 Comcast Cable Communications, LLC","city": "Boston",
"country": "United States","countryCode": "US",
"isp": "Comcast Cable Communications","lat": 42.3584,"lon": -71.0598,
"org": "Boston Park Plaza Hotel","query": "50.78.253.58",
"region": "MA","regionName": "Massachusetts","status": "success",
"timezone": "America/New_York","zip": "02116"}

注意,请求路径中包含json参数。您可以通过相应地更改此参数来请求 XML 或 CSV 响应:

http://ip-api.com/xml/50.78.253.58
http://ip-api.com/csv/50.78.253.58

HTTP 方法和 API

在前面的部分中,您看到了通过GET请求从服务器获取信息的 API。通过 HTTP,有四种主要的方式(或方法)来请求从 Web 服务器获取信息:

  • `GET`

  • `POST`

  • `PUT`

  • `DELETE`

从技术上讲,还存在更多的方法(如HEADOPTIONSCONNECT),但它们在 API 中很少使用,您几乎不太可能看到它们。绝大多数 API 限制自己使用这四种方法或这四种方法的子集。常见的是只看到使用GET,或者只看到使用GETPOST的 API。

当您通过浏览器地址栏访问网站时,使用的是GET。当您像这样调用http://ip-api.com/json/50.78.253.58时,您可以将GET视为说:“嘿,Web 服务器,请检索/获取我这些信息。”

根据定义,GET请求不会对服务器数据库中的信息进行任何更改。不会存储任何内容;不会修改任何内容。只是读取信息。

POST 在您填写表单或提交信息时使用。每次登录网站时,您都在使用POST请求与您的用户名和(希望的话)加密密码。如果您使用 API 发送POST请求,则表示:“请将此信息存储在您的数据库中。”

PUT 在与网站交互时使用较少,但在 API 中有时会用到。PUT 请求用于更新对象或信息。例如,一个 API 可能需要用POST请求创建新用户,但如果要更新用户的电子邮件地址,则可能需要用PUT请求。²

DELETE 被用来删除对象,正如您可以想象的那样。例如,如果您向 example.com/user/23 发送一个 DELETE 请求,它将删除 ID 为 23 的用户。DELETE 方法在公共 API 中并不常见,这些 API 主要用于传播信息或允许用户创建或发布信息,而不是允许用户从数据库中删除信息。

GET 请求不同,POSTPUTDELETE 请求允许您在请求的主体中发送信息,除了 URL 或路由外。

就像您从 Web 服务器收到的响应一样,这些数据通常被格式化为 JSON 或者更少见的 XML,这些数据的格式由 API 的语法定义。例如,如果您正在使用一个用于在博客文章上创建评论的 API,您可能会发出 PUT 请求到:

http://example.com/comments?post=123

使用以下请求主体:

{"title": "Great post about APIs!", "body": "Very informative. Really helped me 
out with a tricky technical challenge I was facing. Thanks for taking the time 
to write such a detailed blog post about PUT requests!", "author": {"name":"Ryan 
Mitchell", "website": "http://pythonscraping.com", "company": "O'Reilly Media"}}

请注意,博客文章的 ID(123)作为 URL 的参数传递,您正在创建的新评论的内容则在请求的主体中传递。参数和数据可以同时传递到参数和主体中。哪些参数是必需的以及它们被传递的位置,再次由 API 的语法确定。

更多关于 API 响应的信息

正如您在本章开头看到的 ip-api.com 示例中所见,API 的一个重要特性是它们具有良好格式化的响应。最常见的响应格式包括 可扩展标记语言(XML)和 JavaScript 对象表示法(JSON)。

近年来,JSON 比 XML 更受欢迎的原因有几个主要因素。首先,良好设计的 XML 文件通常比 JSON 文件更小。例如,比较以下这段包含 98 个字符的 XML 数据:

<user><firstname>Ryan</firstname><lastname>Mitchell</lastname><username>Kludgist
</username></user>

现在看看相同的 JSON 数据:

{"user":{"firstname":"Ryan","lastname":"Mitchell","username":"Kludgist"}}

这仅有 73 个字符,比等效的 XML 小了整整 36%。

当然,有人可能会争辩说 XML 可以这样格式化:

<user firstname="ryan" lastname="mitchell" username="Kludgist"></user>

但这被认为是一种不良做法,因为它不支持数据的深度嵌套。尽管如此,它仍然需要 71 个字符,大约与等效的 JSON 一样长。

JSON 之所以比 XML 更受欢迎的另一个原因是由于 Web 技术的变化。过去,接收 API 的一端通常是服务器端脚本,如 PHP 或 .NET。如今,像 Angular 或 Backbone 这样的框架可能会发送和接收 API 调用。服务器端技术对数据的形式有些中立。但像 Backbone 这样的 JavaScript 库更容易处理 JSON。

尽管 API 通常被认为具有 XML 响应或 JSON 响应,但任何事情都有可能。API 的响应类型仅受其创建者想象力的限制。CSV 是另一种典型的响应输出(如在 ip-api.com 示例中看到的)。某些 API 甚至可能设计用于生成文件。可以向服务器发送请求以生成带有特定文本覆盖的图像或请求特定的 XLSX 或 PDF 文件。

有些 API 根本不返回任何响应。例如,如果您正在向服务器发出请求以创建新的博客文章评论,则它可能仅返回 HTTP 响应代码 200,意味着“我发布了评论;一切都很好!”其他可能返回类似这样的最小响应:

{"success": true}

如果发生错误,可能会得到这样的响应:

{"error": {"message": "Something super bad happened"}}

或者,如果 API 配置不是特别好,您可能会得到一个无法解析的堆栈跟踪或一些简单的英文文本。在向 API 发出请求时,通常最明智的做法是首先检查您收到的响应是否实际上是 JSON(或 XML、CSV 或您期望返回的任何其他格式)。

解析 JSON

在本章中,您已经看过各种类型的 API 及其功能,并查看了这些 API 的示例 JSON 响应。现在让我们看看如何解析和使用这些信息。

在本章开头,您看到了 ip-api.com API 的示例,该 API 将 IP 地址解析为物理地址:

http://ip-api.com/json/50.78.253.58

您可以使用 Python 的 JSON 解析函数解码此请求的输出:

import json
from urllib.request import urlopen

def getCountry(ipAddress):
    response = urlopen('http://ip-api.com/json/'+ipAddress).read()
    ​    .decode('utf-8')
    responseJson = json.loads(response)
    return responseJson.get('countryCode')

print(getCountry('50.78.253.58'))

这将打印 IP 地址50.78.253.58的国家代码。

Python 使用的 JSON 解析库是 Python 核心库的一部分。只需在顶部输入import json,一切就搞定了!与许多语言不同,可能将 JSON 解析为特殊的 JSON 对象或 JSON 节点,Python 使用了一种更灵活的方法,将 JSON 对象转换为字典,将 JSON 数组转换为列表,将 JSON 字符串转换为字符串等等。这样一来,访问和操作存储在 JSON 中的值就变得非常容易了。

以下快速演示了 Python 的 JSON 库如何处理可能在 JSON 字符串中遇到的值:

import json

jsonString = '{"arrayOfNums":[{"number":0},{"number":1},{"number":2}],
               "arrayOfFruits":[{"fruit":"apple"},{"fruit":"banana"},
                               {"fruit":"pear"}]}'
jsonObj = json.loads(jsonString)

print(jsonObj.get('arrayOfNums'))
print(jsonObj.get('arrayOfNums')[1])
print(jsonObj.get('arrayOfNums')[1].get('number') +
      jsonObj.get('arrayOfNums')[2].get('number'))
print(jsonObj.get('arrayOfFruits')[2].get('fruit'))

这是输出:

[{'number': 0}, {'number': 1}, {'number': 2}]
{'number': 1}
3
pear

第 1 行是字典对象的列表,第 2 行是字典对象,第 3 行是整数(访问字典中的整数的总和),第 4 行是字符串。

未记录的 API

到目前为止,在本章中,我们只讨论了已记录的 API。它们的开发人员打算让它们被公众使用,发布有关它们的信息,并假设 API 将被其他开发人员使用。但是绝大多数 API 根本没有任何已发布的文档。

但为什么要创建没有任何公共文档的 API 呢?正如本章开头提到的,这一切都与 JavaScript 有关。

传统上,动态网站的 Web 服务器在用户请求页面时有几个任务:

  • 处理用户请求网站页面的GET请求

  • 从出现在该页面上的数据库中检索数据

  • 为页面的 HTML 模板格式化数据

  • 将格式化后的 HTML 发送给用户

随着 JavaScript 框架的普及,许多由服务器处理的 HTML 创建任务移至了浏览器端。服务器可能会向用户的浏览器发送一个硬编码的 HTML 模板,但是会发起单独的 Ajax 请求来加载内容,并将其放置在 HTML 模板的正确位置。所有这些操作都会在浏览器/客户端上进行。

这最初对 Web 爬虫构成了问题。他们习惯于请求一个 HTML 页面,并返回完全相同的内容已就绪的 HTML 页面。但是,现在他们得到的是一个 HTML 模板,没有任何内容。

Selenium 用于解决这个问题。现在程序员的网络爬虫可以变成浏览器,请求 HTML 模板,执行任何 JavaScript,允许所有数据加载到位,然后仅然后再爬取页面数据。因为 HTML 已经全部加载,它基本上变成了一个先前解决过的问题——解析和格式化现有的 HTML 问题。

然而,由于整个内容管理系统(原本只存在于 Web 服务器中)基本上已经移至浏览器客户端,即使是最简单的网站也可能膨胀到数兆字节的内容和十几个 HTTP 请求。

此外,当使用 Selenium 时,所有用户可能不关心的“额外内容”都会被加载:调用跟踪程序,加载侧边栏广告,调用侧边栏广告的跟踪程序。图像、CSS、第三方字体数据——所有这些都需要加载。当您使用浏览器浏览网页时,这可能看起来很好,但是如果您正在编写一个需要快速移动、收集特定数据并尽可能减少对 Web 服务器负载的网络爬虫,则可能加载的数据量要比您需要的数据多 100 倍。

但是,所有这些 JavaScript、Ajax 和 Web 现代化都有其积极的一面:因为服务器不再将数据格式化为 HTML,它们通常只是作为数据库本身的薄包装。这个薄包装只是从数据库中提取数据,并通过 API 返回到页面中。

当然,这些 API 并不打算被除了网页本身之外的任何人或任何东西使用,因此开发人员将它们未记录,并假设(或希望)没有人会注意到它们。但是它们确实存在。

例如,美国零售巨头 target.com 通过 JSON 加载其所有搜索结果。您可以通过访问https://www.target.com/s?searchTerm=web%20scraping%20with%20python在他们的网站上搜索产品。

如果您使用 urllib 或 Requests 库来爬取此页面,您将找不到任何搜索结果。这些结果是通过对 URL 的 API 调用单独加载的:

https://redsky.target.com/redsky_aggregations/v1/web/plp_search_v2

因为 Target 的 API 每个请求都需要一个密钥,并且这些 API 密钥会超时,我建议你自己试一试并查看 JSON 结果。

当然,你可以使用 Selenium 加载所有搜索结果并解析生成的 HTML。不过,每次搜索将需要进行约 260 次请求并传输数兆字节的数据。直接使用 API,你只需发出一次请求并传输大约只有你所需的 10 KB 精美格式的数据。

查找未记录的 API

在之前的章节中,你使用 Chrome 检查器来检查 HTML 页面的内容,但现在你将以稍微不同的目的使用它:来检查用于构建页面的调用的请求和响应。

要做到这一点,请打开 Chrome 检查器窗口,点击 Network 选项卡,如图 15-1 所示。

图 15-1。Chrome 网络检查工具提供了浏览器正在进行和接收的所有调用的视图。

注意,在页面加载之前需要打开此窗口。关闭窗口时不会跟踪网络调用。

在页面加载时,每当浏览器向 Web 服务器发出调用以获取渲染页面所需的附加信息时,你将实时看到一条线。这可能包括 API 调用。

找到未记录的 API 可能需要一些侦探工作(为了避免这种侦探工作,请参见“记录未记录的 API”),特别是在具有大量网络调用的大型站点中。不过,一般来说,一旦看到它,你就会知道。

API 调用往往具有几个有助于在网络调用列表中定位它们的特征:

  • 它们通常包含 JSON 或 XML。你可以使用搜索/过滤字段来过滤请求列表。

  • 使用GET请求,URL 将包含传递给它们的参数值。例如,如果你正在寻找一个 API 调用,返回搜索结果或加载特定页面的数据,那么这将非常有用。只需用你使用的搜索词、页面 ID 或其他标识信息来过滤结果。

  • 它们通常是 XHR 类型。

API 可能并不总是显而易见,特别是在具有大量功能的大型网站中,可能在加载单个页面时会进行数百次调用。然而,通过一点实践,就能更轻松地找到草堆中的比喻性针。

记录未记录的 API

当你发现正在进行的 API 调用时,通常有必要至少部分记录它,特别是如果你的爬虫将严重依赖该调用。你可能希望在网站上加载多个页面,在检查器控制台的网络选项卡中过滤目标 API 调用。通过这样做,你可以看到调用在不同页面之间的变化,并确定它接受和返回的字段。

每个 API 调用都可以通过关注以下字段来识别和记录:

  • 使用的 HTTP 方法

  • 输入

    • 路径参数

    • 标头(包括 cookie)

    • 正文内容(用于PUTPOST调用)

  • 输出

    • 响应头(包括设置的 cookie)

    • 响应体类型

    • 响应体字段

将 API 与其他数据源结合使用

尽管许多现代 Web 应用程序的存在理由是获取现有数据并以更吸引人的方式格式化它,但我认为在大多数情况下这并不是一个有趣的事情。如果你只使用 API 作为数据源,那么你所能做的就是简单地复制已经存在并基本上已经发布的别人的数据库。更有趣的可能是以新颖的方式结合两个或更多数据源,或者使用 API 作为一个工具来从新的角度查看抓取的数据。

让我们来看一个例子,说明如何将 API 中的数据与网络抓取结合使用,以查看哪些地区对维基百科贡献最多。

如果你在维基百科上花了很多时间,你可能已经见过文章的修订历史页面,显示了最近的编辑列表。如果用户在编辑时已登录维基百科,会显示他们的用户名。如果未登录,则记录其 IP 地址,如图 15-2 所示。

Alt Text

图 15-2. 维基百科 Python 条目修订历史页面上匿名编辑者的 IP 地址

在历史页面上提供的 IP 地址是 121.97.110.145。根据目前的信息,使用 ip-api.com API,该 IP 地址来自菲律宾卡尼洛,菲律宾(IP 地址有时会在地理位置上略微变动)。

这些信息单独来看并不那么有趣,但是如果你能够收集关于维基百科编辑及其发生地点的许多地理数据,会怎么样呢?几年前,我确实做到了,并使用Google 的 GeoChart 库创建了一个有趣的图表,展示了对英语维基百科以及其他语言维基百科的编辑来源(图 15-3)。

Alt Text

图 15-3. 使用 Google 的 GeoChart 库创建的维基百科编辑可视化

创建一个基本的脚本来爬取维基百科,查找修订历史页面,然后在这些修订历史页面上查找 IP 地址并不难。使用修改自第六章的代码,以下脚本正是如此:

def getLinks(articleUrl):
    html = urlopen(f'http://en.wikipedia.org{articleUrl}')
    bs = BeautifulSoup(html, 'html.parser')
    return bs.find('div', {'id':'bodyContent'}).findAll('a', 
        href=re.compile('^(/wiki/)((?!:).)*$'))

def getHistoryIPs(pageUrl):
    #Format of revision history pages is: 
    #http://en.wikipedia.org/w/index.php?title=Title_in_URL&action=history
    pageUrl = pageUrl.replace('/wiki/', '')
    historyUrl = f'http://en.wikipedia.org/w/index.php?title={pageUrl}\
    &action=history'
    print(f'history url is: {historyUrl}')
    bs = BeautifulSoup(urlopen(historyUrl), 'html.parser')
    #finds only the links with class "mw-anonuserlink" which has IP addresses 
    #instead of usernames
    ipAddresses = bs.findAll('a', {'class':'mw-anonuserlink'})
    return set([ip.get_text() for ip in ipAddresses])

links = getLinks('/wiki/Python_(programming_language)')

while(len(links) > 0):
    for link in links:
        print('-'*20) 
        historyIPs = getHistoryIPs(link.attrs['href'])
        for historyIP in historyIPs:
            print(historyIP)

    newLink = links[random.randint(0, len(links)-1)].attrs['href']
    links = getLinks(newLink)

该程序使用两个主要函数:getLinks(在第六章中也使用过),以及新的getHistoryIPs,后者搜索所有带有类mw-anonuserlink的链接内容(指示使用 IP 地址而不是用户名的匿名用户)并将其作为集合返回。

这段代码还使用了一个有些任意的(但对于本例来说很有效)搜索模式来查找要检索修订历史记录的文章。它首先检索由起始页面链接到的所有维基百科文章的历史记录(在本例中是关于 Python 编程语言的文章)。之后,它会随机选择一个新的起始页面,并检索该页面链接到的所有文章的修订历史页面。它会一直持续下去,直到碰到没有链接的页面。

现在您已经有了检索 IP 地址作为字符串的代码,您可以将其与上一节中的getCountry函数结合使用,将这些 IP 地址解析为国家。您将稍微修改getCountry以处理导致 404 Not Found 错误的无效或格式不正确的 IP 地址:

def getCountry(ipAddress):
    try:
      response = urlopen(f'https://ipwho.is/{ipAddress}').read().decode('utf-8')
    except HTTPError:
      return None
    responseJson = json.loads(response)
    return responseJson.get('country_code')

links = getLinks('/wiki/Python_(programming_language)')

while(len(links) > 0):
    for link in links:
      print('-'*20) 
      historyIPs = getHistoryIPs(link.attrs["href"])
      for historyIP in historyIPs:
          print(f'{historyIP} is from {getCountry(historyIP)}')

    newLink = links[random.randint(0, len(links)-1)].attrs['href']
    links = getLinks(newLink)

这是示例输出:

--------------------
history url is: http://en.wikipedia.org/w/index.php?title=Programming_paradigm&a
ction=history
2405:201:2009:80b0:41bc:366f:a49c:52f2 is from IN
115.186.189.53 is from PK
103.252.145.68 is from IN
2405:201:400b:7058:b128:89fd:5248:f249 is from IN
172.115.220.47 is from US
2806:1016:d:54b6:8950:4501:c00b:507a is from MX
36.255.87.160 is from IN
2603:6011:1100:a1d0:31bd:8a11:a0c8:e4c3 is from US
2806:108e:d:bd2c:a577:db4f:2867:2b5c is from MX
2409:4042:e8f:8d39:b50c:f4ca:91b8:eb9d is from IN
107.190.108.84 is from CA
--------------------
history url is: http://en.wikipedia.org/w/index.php?title=Multi-paradigm_program
ming_language&action=history
98.197.198.46 is from US
75.139.254.117 is from US

关于 API 的更多信息

本章展示了现代 API 通常用于访问网络数据的几种方式,以及这些 API 如何用于构建更快更强大的网络爬虫。如果您想构建 API 而不仅仅是使用它们,或者如果您想进一步了解它们的构建和语法理论,我推荐阅读《RESTful Web APIs》,作者是 Leonard Richardson、Mike Amundsen 和 Sam Ruby(O’Reilly)。这本书提供了关于在网络上使用 API 的理论和实践的强大概述。此外,Mike Amundsen 还有一系列有趣的视频,《Designing APIs for the Web》(O’Reilly),教你如何创建自己的 API——如果您决定以方便的格式向公众提供您的抓取数据,这是一个有用的技能。

虽然有些人可能对 JavaScript 和动态网站的无处不在感到惋惜,使得传统的“抓取和解析 HTML 页面”的做法已经过时,但我对我们的新机器人统治者表示欢迎。随着动态网站对 HTML 页面的人类消费依赖程度降低,更多地依赖于严格格式化的 JSON 文件进行 HTML 消费,这为每个试图获取干净、格式良好的数据的人提供了便利。

网络已不再是偶尔带有多媒体和 CSS 装饰的 HTML 页面集合。它是数百种文件类型和数据格式的集合,一次传输数百个,形成您通过浏览器消耗的页面。真正的诀窍通常是超越你面前的页面,抓取其源头的数据。

¹ 这个 API 将 IP 地址解析为地理位置,您稍后在本章中也将使用它。

² 实际上,许多 API 在更新信息时使用POST请求代替PUT请求。新实体是创建还是仅更新一个旧实体通常取决于 API 请求本身的结构。但是,了解区别仍然是很重要的,您经常会在常用 API 中遇到PUT请求。

第十六章:图像处理与文本识别

从谷歌的自动驾驶汽车到能够识别假钞的自动售货机,机器视觉是一个具有深远目标和影响的广阔领域。本章专注于该领域的一个小方面:文本识别——具体而言,如何利用各种 Python 库识别和使用在线找到的基于文本的图像。

当你不希望文本被机器人发现和阅读时,使用图像代替文本是一种常见的技术。这在联系表单上经常见到,当电子邮件地址部分或完全呈现为图像时。取决于执行的技巧如何,这甚至可能对人类观众不可察觉,但机器人很难读取这些图像,这种技术足以阻止大多数垃圾邮件发送者获取您的电子邮件地址。

当然,CAPTCHA 利用了用户能够阅读安全图像而大多数机器人不能的事实。一些 CAPTCHA 比其他更难,这是我们将在本书后面解决的问题。

但 CAPTCHA 并不是网络上唯一需要图像转文本翻译帮助的地方。甚至很多文档都是从硬拷贝扫描并放在网络上,这使得这些文档对大部分互联网用户而言是无法访问的,尽管它们就在“人们的视线之中”。没有图像转文本的能力,唯一的方法是让人类手动输入它们,但谁有时间做这件事呢。

将图像转换为文本称为光学字符识别(OCR)。一些主要的库可以执行 OCR,许多其他库支持它们或构建在它们之上。这些库体系相当复杂,因此建议您在尝试本章中的任何练习之前先阅读下一节。

本章中使用的所有示例图像都可以在 GitHub 仓库文件夹Chapter16_ImageProcessingFiles中找到。为简洁起见,所有文中代码示例将简称为files目录。

库概述

Python 是处理图像和阅读、基于图像的机器学习甚至图像创建的绝佳语言。虽然有许多库可用于图像处理,但我将专注于两个:Pillow 和 Tesseract。

当处理并对来自网络的图像进行 OCR 时,这两个库组成了一个强大的互补二重奏。Pillow执行第一次清理和过滤图像,Tesseract则尝试将这些图像中找到的形状与其已知文本库进行匹配。

本章涵盖了它们的安装和基本使用,以及这两个库一起工作的几个示例。我还将介绍一些高级的 Tesseract 训练,以便您可以训练 Tesseract 识别您可能在网络上遇到的额外字体和语言(甚至是 CAPTCHA)。

Pillow

尽管 Pillow 可能不是最全功能的图像处理库,但它具有您可能需要的所有功能,甚至更多——除非您计划用 Python 重写 Photoshop,否则您读的不是这本书!Pillow 还有一个优点,即是其中一些更好文档化的第三方库之一,并且非常容易上手使用。

Forked off the Python Imaging Library (PIL) for Python 2.x, Pillow adds support for Python 3.x. Like its predecessor, Pillow allows you to easily import and manipulate images with a variety of filters, masks, and even pixel-specific transformations:

from PIL import Image, ImageFilter

kitten = Image.open('kitten.jpg')
blurryKitten = kitten.filter(ImageFilter.GaussianBlur)
blurryKitten.save('kitten_blurred.jpg')
blurryKitten.show()

在上面的例子中,kitten.jpg 图像将在您默认的图像查看器中打开,并添加模糊效果,同时也会保存为同一目录下更模糊的 kitten_blurred.jpg

您将使用 Pillow 对图像执行预处理,使其更易于机器读取,但正如前面提到的,您也可以使用该库进行许多其他操作,而不仅仅是这些简单的滤镜应用。欲了解更多信息,请查看 Pillow 文档

Tesseract

Tesseract 是一个 OCR 库。由 Google 赞助(一个显然以其 OCR 和机器学习技术而闻名的公司),Tesseract 被普遍认为是目前最好、最准确的开源 OCR 系统。

除了准确外,它还非常灵活。它可以被训练来识别任何数量的字体(只要这些字体在自身内部相对一致,您很快就会看到)。它还可以扩展到识别任何 Unicode 字符。

本章同时使用命令行程序 Tesseract 及其第三方 Python 包装 pytesseract。这两者将明确命名为其中的一个,所以当您看到 Tesseract 时,我指的是命令行软件,当您看到 pytesseract 时,我特指它的第三方 Python 包装。

安装 Tesseract

对于 Windows 用户,有一个方便的 可执行安装程序。截至目前,当前版本是 3.02,尽管新版本也应该是可以的。

Linux 用户可以使用 apt-get 安装 Tesseract:

$ sudo apt-get tesseract-ocr

在 Mac 上安装 Tesseract 稍微复杂一些,但可以通过许多第三方安装程序轻松完成,例如 Homebrew,它在 第九章 中用于安装 MySQL。例如,您可以安装 Homebrew 并使用它在两行命令中安装 Tesseract:

$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/\
HEAD/install.sh)"
$ brew install tesseract

Tesseract 也可以从源代码安装,在项目的下载页面

要将图像转换为文本,Tesseract 使用在各种语言(或字符集)的大型数据集上训练过的机器学习模型。要查看安装的可用模型,请使用以下命令:

$ tesseract --list-langs

这将打印存储模型的目录(在 Linux 上是*/usr/local/share*,在使用 HomeBrew 安装的 Mac 上是*/opt/homebrew/share/tessdata/*),以及可用的模型。

安装了 Tesseract 后,您可以准备安装 Python 包装库 pytesseract,它使用您现有的 Tesseract 安装来读取图像文件并输出可在 Python 脚本中使用的字符串和对象。

如往常一样,您可以通过 pip 安装 pytesseract:

$ pip install pytesseract

Pytesseract 可以与 PIL 结合使用从图像中读取文本:

from PIL import Image
import pytesseract

print(pytesseract.image_to_string(Image.open('files/test.png')))

如果 pytesseract 无法识别您是否已安装了 Tesseract,则可以使用以下命令获取您的 Tesseract 安装位置:

$ which tesseract

并且在 Python 中,通过包含这一行来指定 pytesseract 的位置:

pytesseract.pytesseract.tesseract_cmd = '/path/to/tesseract'

Pytesseract 除了像上面代码示例中返回图像 OCR 结果之外,还有几个有用的功能。它可以估算框文件(每个字符边界的像素位置):

print(pytesseract.image_to_boxes(Image.open('files/test.png')))

它还可以返回所有数据的完整输出,如置信度分数、页数和行数、框数据以及其他信息:

print(pytesseract.image_to_data(Image.open('files/test.png')))

这两个文件的默认输出为以空格或制表符分隔的字符串文件,但您也可以将输出作为字典或(如果 UTF-8 解码不够用)字节字符串获取:

from PIL import Image
import pytesseract
from pytesseract import Output

print(pytesseract.image_to_data(Image.open('files/test.png'),
    output_type=Output.DICT))
print(pytesseract.image_to_string(Image.open('files/test.png'),
    output_type=Output.BYTES))

本章节同时使用了 pytesseract 库和通过subprocess库从 Python 触发 Tesseract 的命令行 Tesseract。虽然 pytesseract 库很有用且方便,但它无法完成一些 Tesseract 函数,因此熟悉所有方法是很好的。

NumPy

虽然 NumPy 对于简单的 OCR 并非必需,但如果您想要在本章后面介绍的训练 Tesseract 识别额外字符集或字体,您将需要它。您还将在本章后面的某些代码示例中使用它进行简单的数学任务(如加权平均数)。

NumPy 是用于线性代数和其他大规模数学应用的强大库。NumPy 与 Tesseract 配合良好,因为它能够将图像数学地表示为大型像素数组并进行操作。

NumPy 可以通过任何第三方 Python 安装器如 pip 来安装,或者通过下载软件包并使用$ python setup.py install进行安装。

即使您不打算运行使用它的代码示例,我强烈建议您安装它或将其添加到 Python 工具库中。它有助于完善 Python 的内置数学库,并具有许多有用的特性,特别是对于操作数字列表。

按照惯例,NumPy 作为np导入,并且可以如下使用:

import numpy as np

numbers = [100, 102, 98, 97, 103]
print(np.std(numbers))
print(np.mean(numbers))

此示例打印了提供给它的数字集的标准差和均值。

处理格式良好的文本

幸运的话,大多数需要处理的文本应该相对干净且格式良好。格式良好的文本通常符合几个要求,尽管“混乱”和“格式良好”之间的界限可能是主观的。

总的来说,格式良好的文本

  • 以一种标准字体书写(不包括手写字体、草书字体或过度装饰的字体)

  • 如果复制或拍摄,具有极其清晰的线条,没有复制伪影或黑斑

  • 对齐良好,没有倾斜的字母

  • 不会跑到图像之外,也没有截断的文本或图像边缘的边距

其中一些问题可以在预处理中修复。例如,图像可以转换为灰度,亮度和对比度可以调整,根据需要可以裁剪和旋转图像。但是,某些基本限制可能需要更广泛的训练。请参阅“阅读 CAPTCHA 和训练 Tesseract”。

图 16-1 是格式良好文本的理想示例。

Alt Text

图 16-1. 保存为.tiff 文件以供 Tesseract 读取的示例文本

files目录中,您可以从命令行运行 Tesseract 来读取此文件并将结果写入文本文件:

$ tesseract text.png textoutput
$ cat textoutput.txt

输出包含新创建的textoutput.txt文件的内容:

This is some text, written in Arial, that will be read by
Tesseract. Here are some symbols: !|@#$%&*()

您可以看到结果大多是准确的,尽管它在!@之间添加了额外的竖线字符。总体而言,这使您能够相当舒适地阅读文本。

在模糊图像文本、创建一些 JPG 压缩伪影和添加轻微背景渐变后,Tesseract 的结果变得更糟(见图 16-2)。

Alt Text

图 16-2. 不幸的是,您在互联网上遇到的许多文档更像是这种情况,而不是前面的例子

而不是将结果写入文件,您还可以在文件名通常出现的地方传递一个破折号(-),Tesseract 将结果回显到终端:

$ tesseract text_bad.png -

Tesseract 由于背景渐变的原因无法处理此图像,因此产生了以下输出:

This is some text, written In Arlal, that"
Tesseract. Here are some symbols: _

请注意,一旦背景渐变使文本更难以区分,文本就会被截断,并且每行的最后一个字符都是错误的,因为 Tesseract 试图徒劳地理解它。此外,JPG 伪影和模糊使得 Tesseract 难以区分小写字母i和大写字母I以及数字1

在这里,使用 Python 脚本首先清理图像非常方便。使用 Pillow 库,您可以创建一个阈值滤镜来去除背景中的灰色,突出文本,并使图像更清晰,以便 Tesseract 读取。

此外,您可以使用 pytesseract 库而不是从命令行使用 Tesseract 来运行 Tesseract 命令并读取生成的文件:

from PIL import Image
import pytesseract

def cleanFile(filePath, newFilePath):
    image = Image.open(filePath)

    #Set a threshold value for the image, and save
    image = image.point(lambda x: 0 if x < 143 else 255)
    image.save(newFilePath)
    return image

image = cleanFile('files/textBad.png', 'files/textCleaned.png')

#call tesseract to do OCR on the newly created image
print(pytesseract.image_to_string(image))

结果图像被自动创建为 text_cleaned.png,如 图 16-3 所示。

Alt Text

图 16-3。通过将图像的前一“混乱”版本通过阈值过滤器进行处理而创建的图像

除了一些几乎难以辨认或缺失的标点符号外,文本是可读的,至少对我们来说是这样。Tesseract 尽力而为:

This is some text, written In Anal, that will be read by 
Tesseract Here are some symbols: !@#$%"&'()

逗号和句号非常小,是图像整理的首要受害者,几乎从我们的视野和 Tesseract 的视野中消失。还有不幸的是,Tesseract 将“Arial”误解为“Anal”,这是 Tesseract 将 ri 解释为单个字符 n 的结果。

尽管如此,它仍然比之前的版本有所改进,其中近一半的文本被切掉。

Tesseract 最大的弱点似乎是背景亮度不均。Tesseract 的算法在读取文本之前尝试自动调整图像的对比度,但使用类似 Pillow 库这样的工具可能会获得更好的结果。

提交给 Tesseract 之前绝对需要修复的图像包括倾斜的图像、有大量非文本区域或其他问题的图像。

自动调整图像

在前面的例子中,值 143 被实验性地选择为将所有图像像素调整为黑色或白色以便 Tesseract 读取图像的“理想”阈值。但是,如果您有许多图像,所有图像都有稍有不同的灰度问题,并且无法合理地手动调整所有图像,那该怎么办?

找到最佳解决方案(或至少是相当不错的解决方案)的一种方法是对一系列调整到不同值的图像运行 Tesseract,并通过某种组合来选择最佳结果,这些组合包括 Tesseract 能够读取的字符和/或字符串的数量以及它读取这些字符的“置信度”。

您使用的确切算法可能因应用程序而异,但以下是通过图像处理阈值进行迭代以找到“最佳”设置的一个示例:

import pytesseract
from pytesseract import Output
from PIL import Image
import numpy as np

def cleanFile(filePath, threshold):
    image = Image.open(filePath)
    #Set a threshold value for the image, and save
    image = image.point(lambda x: 0 if x < threshold else 255)
    return image

def getConfidence(image):
    data = pytesseract.image_to_data(image, output_type=Output.DICT)
    text = data['text']
    confidences = []
    numChars = []

    for i in range(len(text)):
        if data['conf'][i] > -1:
            confidences.append(data['conf'][i])
            numChars.append(len(text[i]))

    return np.average(confidences, weights=numChars), sum(numChars)

filePath = 'files/textBad.png'

start = 80
step = 5
end = 200

for threshold in range(start, end, step):
    image = cleanFile(filePath, threshold)
    scores = getConfidence(image)
    print("threshold: " + str(threshold) + ", confidence: "
        + str(scores[0]) + " numChars " + str(scores[1]))

该脚本有两个功能:

cleanFile

接收原始的“坏”文件和一个阈值变量以运行 PIL 阈值工具。它处理文件并返回 PIL 图像对象。

getConfidence

接收清理后的 PIL 图像对象并将其传递给 Tesseract。它计算每个识别字符串的平均置信度(按该字符串中的字符数加权),以及识别字符的数量。

通过改变阈值并在每个值上获取识别字符的置信度和数量,您可以得到输出:

threshold: 80, confidence: 61.8333333333 numChars 18
threshold: 85, confidence: 64.9130434783 numChars 23
threshold: 90, confidence: 62.2564102564 numChars 39
threshold: 95, confidence: 64.5135135135 numChars 37
threshold: 100, confidence: 60.7878787879 numChars 66
threshold: 105, confidence: 61.9078947368 numChars 76
threshold: 110, confidence: 64.6329113924 numChars 79
threshold: 115, confidence: 69.7397260274 numChars 73
threshold: 120, confidence: 72.9078947368 numChars 76
threshold: 125, confidence: 73.582278481 numChars 79
threshold: 130, confidence: 75.6708860759 numChars 79
threshold: 135, confidence: 76.8292682927 numChars 82
threshold: 140, confidence: 72.1686746988 numChars 83
threshold: 145, confidence: 75.5662650602 numChars 83
threshold: 150, confidence: 77.5443037975 numChars 79
threshold: 155, confidence: 79.1066666667 numChars 75
threshold: 160, confidence: 78.4666666667 numChars 75
threshold: 165, confidence: 80.1428571429 numChars 70
threshold: 170, confidence: 78.4285714286 numChars 70
threshold: 175, confidence: 76.3731343284 numChars 67
threshold: 180, confidence: 76.7575757576 numChars 66
threshold: 185, confidence: 79.4920634921 numChars 63
threshold: 190, confidence: 76.0793650794 numChars 63
threshold: 195, confidence: 70.6153846154 numChars 65

无论是结果中的平均置信度还是识别字符的数量,都显示出明显的趋势。两者都倾向于在阈值约为 145 时达到峰值,这接近手动找到的“理想”结果 143。

140 和 145 的阈值都给出了最大数量的识别字符(83 个),但是 145 的阈值为这些找到的字符提供了最高的置信度,因此您可能希望选择该结果,并返回在该阈值下被识别为图像包含的文本的“最佳猜测”。

当然,仅仅找到“最多”字符并不一定意味着所有这些字符都是真实的。在某些阈值下,Tesseract 可能会将单个字符拆分为多个字符,或者将图像中的随机噪声解释为实际不存在的文本字符。在这种情况下,您可能更倾向于更重视每个评分的平均置信度。

例如,如果你找到的结果读取(部分):

threshold: 145, confidence: 75.5662650602 numChars 83
threshold: 150, confidence: 97.1234567890 numChars 82

如果结果让您的置信度增加超过 20%,仅丢失一个字符,并假设 145 的阈值结果仅仅是不正确的,或者可能分割一个字符或者找到了不存在的东西,那么选择该结果可能是个明智的选择。

这是某些前期实验用于完善您的阈值选择算法可能会派上用场的部分。例如,您可能希望选择其置信度和字符数的乘积最大化的得分(在本例中,145 仍以 6272 的产品获胜,在我们的想象例子中,阈值 150 以 7964 的产品获胜),或者其他某种度量。

请注意,此类选择算法除了仅限于threshold之外,还适用于任意 PIL 工具值。您还可以通过改变每个值的值来选择两个或多个值,并以类似的方式选择最佳结果分数。

显然,这种选择算法在计算上是非常密集的。您在每张图片上都要运行 PIL 和 Tesseract 多次,而如果您事先知道“理想”的阈值值,您只需运行它们一次。

请记住,当您开始处理的图像时,您可能会开始注意到找到的“理想”值中的模式。而不是尝试从 80 到 200 的每个阈值,您可能实际上只需要尝试从 130 到 180 的阈值。

您甚至可以采用另一种方法,并选择首次通过时间间隔为 20 的阈值,然后使用贪心算法在前一次迭代中找到的“最佳”解决方案之间减小您的阈值步长,以获得最佳结果。当您处理多个变量时,这种方法可能也是最佳的。

从网站上的图像中抓取文本

使用 Tesseract 从硬盘上的图像中读取文本可能并不那么令人兴奋,但是当与网页抓取器一起使用时,它可以成为一个强大的工具。图片在网站上可能会无意中混淆文本(例如在本地餐馆网站上的菜单的 JPG 副本),但它们也可以有意地隐藏文本,正如我将在下一个例子中展示的那样。

尽管亚马逊的 robots.txt 文件允许爬取其产品页面,但书籍预览通常不会被通过的爬虫所捕捉到。这是因为书籍预览是通过用户触发的 Ajax 脚本加载的,图像被精心隐藏在多层的 div 和 iframe 中。当然,即使你能访问这些图像,还有一个不小的问题是将它们作为文本进行阅读。

以下脚本就实现了这一壮举:它导航到托尔斯泰的大字版《伊凡·伊里奇之死》,打开阅读器,收集图像网址,然后系统地从每一个图像中下载、阅读和打印文本。

选择一个测试主题

当涉及到处理它未经训练的字体时,Tesseract 在处理大格式的书籍版本时表现得更好,特别是如果图像较小。下一节将介绍如何训练 Tesseract 以识别不同字体,这可以帮助它读取包括非大字版书籍预览在内的更小字号!

请注意,此代码依赖于亚马逊上的实时列表以及亚马逊网站的几个架构特性才能正确运行。如果此列表下架或更换,请随时用另一本具有预览功能的书籍 URL 进行替换(我发现大字版和无衬线字体效果良好)。

因为这是一个相对复杂的代码,整合了前几章的多个概念,我在整个过程中添加了注释,以便更容易理解正在进行的操作:

# Retrieve and image URL and read the image as text
def image_to_text(image):
    urlretrieve(image, 'page.jpg')
    imageList.append(image)
    print(pytesseract.image_to_string(Image.open('page.jpg')))

# Create new Selenium driver
driver = webdriver.Chrome(service=Service(CHROMEDRIVER_PATH))

driver.get(
    'https://www.amazon.com/Death-Ivan-Ilyich-Nikolayevich-Tolstoy/\
dp/1427027277')

# Click on the book preview button
driver.find_element(By.ID, 'litb-canvas-click-wrapper').click()
try:
    # Wait for iframe to load
    WebDriverWait(driver, 600).until(
        EC.presence_of_element_located((By.ID, 'litb-read-frame'))
    )
except TimeoutException:
    print('Did not find the iframe')

# Switch to iframe
frame = driver.find_element(By.ID, 'litb-read-frame')
driver.switch_to.frame(frame)

try:
    Wait for preview reader to load
    WebDriverWait(driver, 600).until(
        EC.presence_of_element_located((By.ID, 'kr-renderer'))
    )
except TimeoutException:
    print('Did not find the images')

# Collect all images inside divs with the "data-page" attribute
images = driver.find_elements(By.XPATH, '//div[@data-page]/img')
for image in images:
    image_url = image.get_attribute('src')
    image_to_text(image_url)

driver.quit()

尽管理论上这个脚本可以使用任何类型的 Selenium webdriver 运行,但我发现它目前与 Chrome 一起工作最为可靠。

正如你之前使用 Tesseract 阅读器时所经历的那样,它能够基本上清晰地打印出书籍的许多长段落,就像在第一章的预览中所看到的那样:

Chapter I

During an interval in the Melvinski trial in the large
building of the Law Courts the members and public
prosecutor met in Ivan Egorovich Shebek's private
room, where the conversation turned on the celebrated
Krasovski case. Fedor Vasilievich warmly maintained
that it was not subject to their jurisdiction, Ivan
Egorovich maintained the contrary, while Peter
Ivanovich, not having entered into the discussion at
the start, took no part in it but looked through the
Gazette which had just been handed in.

“Gentlemen,” he said, “Ivan Ilych has died!”

大字版和无衬线字体确保了图像的无误转录。在转录中可能出现错误的情况下,可以通过基于字典单词列表的猜测进行修正(也许还可以根据相关专有名词如“Melvinski”进行补充)。

有时候,错误可能会涵盖整个单词,比如文本第三页上的情况:

it is he who is dead and not 1.

在这种情况下,“I” 这个单词被字符 “1” 替换了。在这里,马尔科夫链分析可能会有所帮助,除了一个单词字典之外。如果文本的任何部分包含一个极不常见的短语(“and not 1”),则可以假设该文本实际上是更常见的短语(“and not I”)。

当然,这些字符替换遵循可预测的模式是有帮助的:“vi” 变成了 “w”,“I” 变成了 “1”。如果你的文本中这些替换经常发生,你可以创建一个列表,用来“尝试”新词和短语,选择最合理的解决方案。一种方法可能是替换频繁混淆的字符,并使用与字典中的词匹配的解决方案,或者是一个被认可的(或最常见的)n-gram。

如果您选择这种方法,请务必阅读第十二章以获取有关处理文本和自然语言处理的更多信息。

尽管此示例中的文本是常见的无衬线字体,Tesseract 应该能够相对容易地识别它,有时稍微重新训练也有助于提高准确性。下一节将讨论另一种解决错乱文本问题的方法,需事先投入一些时间。

通过为 Tesseract 提供大量具有已知值的文本图像集合,Tesseract 可以“学习”以便在将来更精确和准确地识别相同字体,即使文本中偶尔存在背景和位置问题。

阅读 CAPTCHA 并训练 Tesseract

尽管大多数人熟悉 CAPTCHA 这个词,但很少有人知道它代表什么:Completely Automated Public Turing Test to Tell Computers and Humans Apart。它笨重的首字母缩略语暗示了它在阻碍本应完全可用的网络界面中的作用,因为人类和非人类机器人经常难以解决 CAPTCHA 测试。

图灵测试是由艾伦·图灵在其 1950 年的论文《计算机机器与智能》中首次描述的。在这篇论文中,他描述了一个理论情景,其中一个人可以通过计算机终端与人类和人工智能程序进行交流。如果在随意对话中,人类无法区分人类和 AI 程序,那么 AI 程序被认为通过了图灵测试。图灵推理认为,从所有意图和目的来看,人工智能会真正地“思考”。

在图灵测试理论提出 70 年后,如今 CAPTCHA 主要用于激怒人类而不是机器。2017 年,Google 关闭了其标志性的 reCAPTCHA,这在很大程度上是因为它倾向于阻止合法的网站用户。¹(参见图 16-4 的例子。)许多其他公司也效仿,用替代性的防机器人程序替换传统的基于文本的 CAPTCHA。

图 16-4. Google reCAPTCHA 的文本,2017 年之前

尽管 CAPTCHA 的流行度有所下降,但它们仍然常用,尤其是在较小的网站上。它们还可以作为计算机阅读样本“困难”文本的来源。也许您的目标不是解决 CAPTCHA,而是阅读扫描不良的 PDF 或手写笔记。但原则是相同的。

鉴于此,我创建了一个表单,机器人“被阻止”提交,因为它需要解决一个 CAPTCHA:https://pythonscraping.com/humans-only/。在这一部分中,您将训练 Tesseract 库以识别其特定字体和文本变化,以便高可靠性地解决此 CAPTCHA。

如果您是机器人并且难以阅读此图像,“U8DG” 是图 16-5 中 CAPTCHA 的解决方案。作为机器人的 Tesseract 当然难以解决它。

图片

图 16-5. 防机器人验证码位于https://pythonscraping.com/humans-only/
$ tesseract U8DG.png -

u& DS

在这种情况下,Tesseract 返回五个字符(包括一个空格),并且只正确识别了一个字符,大写的 D。

问题不在于 Tesseract 读取文本的能力差,或者这个验证码对计算机来说过于复杂——而是这种手写字体与 Tesseract "开箱即用" 的常规英文字体不同。幸运的是,可以训练它识别额外的字体、字符和语言。

训练 Tesseract

无论您是为验证码还是任何其他文本进行训练,都有几个因素需要考虑,这些因素会极大地影响 Tesseract 的性能以及您训练的方法:

  • 字符是否在图像中重叠,或者您是否可以在每个字符周围画出整齐的矩形而不会有其他字符的部分侵犯这个矩形?

  • 文本中是否存在多种字体或书写风格的变体,还是仅使用单一字体?

  • 图像中是否有任何背景图像、线条或其他分散注意力的垃圾?

  • 字符之间是否有高对比度,字符与背景之间是否有清晰的边界?

  • 字体是否是比较标准的有衬线或无衬线字体,还是具有随机元素和“手写”风格的不寻常字体?

如果某些文本样本中字符有重叠,您可以考虑仅使用没有重叠的文本样本。如果每个文本样本都有重叠,则考虑在训练之前进行预处理以分离字符。

爬取和准备图像

预处理有助于去除任何背景垃圾,并改善图像中字符的颜色、对比度和分离度。

需要多少图像?

您应该获取多少图像?我建议每个字符大约有 10 个示例,如果您的文本有高变异性或随机性,则更多。Tesseract 偶尔会丢弃文件,例如由于重叠的框或其他神秘的原因,因此您可能希望有一些额外的缓冲空间。如果发现您的 OCR 结果不如预期,或者 Tesseract 在某些字符上出现问题,创建额外的训练数据并再次尝试是一个良好的调试步骤。

此外,如果同一文本样本中存在多种字体变体,或者涉及其他变体(随机倾斜或混淆文本),您可能需要更多的训练数据。

如果字体比较标准且没有其他严重的复杂因素,请确保先尝试使用 Tesseract 而不需额外训练!没有训练的情况下,性能可能已经满足您的需求,而训练可能是非常耗时的过程。

训练需要向 Tesseract 提供至少每个您希望其能够识别的字符的几个示例。以下内容下载了包含四个字符的每个样本 CAPTCHA 图像的 100 个示例,共计 400 个字符样本:

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

if not os.path.exists('captchas'):
    os.mkdir('captchas')

for i in range(0, 100):
    bs = BeautifulSoup(urlopen('https://pythonscraping.com/humans-only/'))
    imgUrl = bs.find('img', {'class': 'wpcf7-captchac'})['src']
    urlretrieve(imgUrl, f'captchas/{imgUrl.split("/")[-1]}')    

在审查下载的训练图像之后,现在是决定是否需要进行任何预处理的时候了。这些 CAPTCHA 图像中的文本为灰色,背景为黑色。您可以编写一个cleanImage函数,将其转换为白色背景上的黑色文本,并添加白色边框,以确保每个字符与图像边缘分离:

def cleanImage(imagePath):
    image = Image.open(imagePath)
    image = image.point(lambda x: 255 if x<143 else 0)
    image = ImageOps.expand(image,border=20,fill='white')
    image.save(imagePath)

for filename in os.listdir('captchas'):
    if '.png' in filename:
        cleanImage(f'captchas/{filename}')

使用 Tesseract 训练项目创建框文件

接下来,您需要使用这些清理过的图像来创建框文件。框文件包含图像中每个字符占据一行,后跟该字符的边界框坐标。例如,包含字符“AK6F”的 CAPTCHA 图像可能具有相应的框文件:

A 32 34 54 58
K 66 32 91 56
6 101 34 117 57
F 135 32 156 57

我在https://github.com/REMitchell/tesseract-trainer创建了一个项目,其中包括一个 Web 应用程序,帮助创建这些框文件。要使用此项目创建框文件,请按照以下步骤操作:

  1. 将每个 CAPTCHA 图像重命名为其解决方案。例如,包含“AK6F”的图像将被重命名为“AK6F.png.”

  2. 在 Tesseract 训练项目中,打开名为createBoxes.html的文件,使用您选择的 Web 浏览器。

  3. 单击“添加新文件”链接,并选择在第一步中重命名的多个图像文件。

  4. Web 应用程序将基于图像名称自动生成框。将这些框拖动到其对应字符周围,如图 16-6 所示。

  5. 当您满意框的放置位置时,请单击“下载.box”以下载框文件,接下来的图像应该会出现。

图 16-6。使用 Tesseract 训练器工具创建框文件

作为可选步骤,我建议您播放一些好的播客或电视节目,因为这将是几个小时的乏味工作。确切的时间取决于您需要绘制多少个框。

创建框文件后的下一步是向 Tesseract 展示您所有的辛勤工作,并让它进行训练。该过程的最终目标是创建一个traineddata文件,您可以将其添加到您的 Tesseract 语言目录中。

在 Tesseract 训练项目中,https://github.com/REMitchell/tesseract-trainer,我包含了一个名为 trainer.py 的文件。此脚本期望项目根目录下有一个data目录,并在其下有cleanedbox目录:

  • data

    • cleaned

      • 具有任何预处理和清理完成的 CAPTCHA 图像,文件名与框文件匹配
    • box

      • 从 Web 应用程序下载的框文件

在创建您的*.box文件和图像文件夹之后,请将这些数据复制到备份文件夹中,然后再进行任何进一步的操作。尽管运行数据训练脚本不太可能删除任何内容,但当涉及到花费数小时来创建.box*文件时,最好还是小心为好。

从 box 文件训练 Tesseract

执行数据分析并创建 Tesseract 所需的训练文件涉及许多步骤。trainer.py文件会为您完成所有这些工作。

该程序采取的初始设置和步骤可以在该类的__init__runAll方法中看到:

CLEANED_DIR = 'cleaned'
BOX_DIR = 'box'
EXP_DIR = 'exp'
class TesseractTrainer():
    def __init__(self, languageName, fontName, directory='data'):
        self.languageName = languageName
        self.fontName = fontName
        self.directory = directory

    def runAll(self):
        os.chdir(self.directory)
        self.createDirectories()
        self.createFontProperties()
        prefixes = self.renameFiles()
        self.createTrainingFiles(prefixes)
        self.extractUnicode()
        self.runShapeClustering()
        self.runMfTraining()
        self.runCnTraining()
        self.createTessData()

trainer.py的底部创建了一个新的TesseractTrainer实例,并调用了 runAll 方法:

trainer = TesseractTrainer('captcha', 'captchaFont')
trainer.runAll()

将传递给TesseractTrainer对象的三个属性是:

languageName

Tesseract 用来跟踪语言的三个字母语言代码。对于特定的训练场景,我更喜欢创建一个新语言,而不是合并它或使用它来替换 Tesseract 预训练的英文数据。

fontName

您选择的字体名称。这可以是任何东西,但必须是一个没有空格的单词。在实践中,这仅用于训练过程中的内部目的,您不太可能看到它或需要引用它。

directory

包含清理图像和 box 文件的目录名。默认情况下,这是 data。如果您有多个项目,您可以为每个项目传入一个唯一的数据目录名称,以保持所有内容的组织。

让我们来看一些使用的个别方法。

createDirectories会进行一些初始的清理工作,并创建子目录,如稍后将存储训练文件的exp目录。

createFontProperties会创建一个必需的文件font_properties,让 Tesseract 知道您正在创建的新字体:

captchaFont 0 0 0 0 0

该文件包含字体名称,后面跟着 1 和 0,表示是否考虑斜体、粗体或字体的其他版本。训练具有这些属性的字体是一个有趣的练习,但不幸的是超出了本书的范围。

renameFiles会重命名所有*.box*文件及其相应的图像文件,名称需符合 Tesseract 所需(这里的文件编号是顺序数字,以保持多个文件分开):

  • ..exp.box

  • ..exp.tiff

extractUnicode会查看所有已创建的*.box*文件,并确定可以训练的总字符集。生成的 Unicode 文件将告诉您找到了多少不同的字符,这可能是快速查看是否缺少任何内容的好方法。

下面的三个函数,runShapeClusteringrunMfTrainingrunCtTraining,分别创建文件shapetablepfftablenormproto。它们都提供关于每个字符的几何和形状的信息,以及提供 Tesseract 用于计算给定字符是哪种类型的概率的统计信息。

最后,Tesseract 将每个编译的数据文件夹重命名为所需语言名称的前缀(例如,shapetable重命名为cap.shapetable),并将所有这些文件编译成最终的训练数据文件cap.traineddata

使用 Tesseract 的 traineddata 文件

traineddata文件是整个过程的主要输出。该文件告诉 Tesseract 如何在你提供的训练数据集中识别字符。要使用该文件,你需要将其移动到你的tessdata根目录。

你可以使用以下命令找到这个文件夹:

$ tesseract --list-langs

这将提供类似以下的输出:

List of available languages in "/opt/homebrew/share/tessdata/" (3):
eng
osd
snum

然后将TESSDATA_PREFIX环境变量设置为此目录:

$ export TESSDATA_PREFIX=/opt/homebrew/share/tessdata/

最后,将你的新traineddata文件移动到languages目录:

$ cp data/exp/cap.traineddata $TESSDATA_PREFIX/cap.traineddata

安装新的traineddata文件后,Tesseract 应该会自动识别它作为新语言,并能够解决其遇到的新 CAPTCHA:

$ tesseract -l captcha U8DG.png -

U8DG

成功!显著改进了之前将图像解释为u& DS的情况。

这只是对 Tesseract 字体训练和识别能力的简要概述。如果你对深入训练 Tesseract 感兴趣,也许开始自己的 CAPTCHA 训练文件库,或者与世界分享新的字体识别能力,我建议查看文档

检索 CAPTCHA 并提交解决方案

许多流行的内容管理系统经常会被预先编程的机器人注册,这些机器人知道这些用户注册页面的著名位置。例如,在http://pythonscraping.com上,即使有 CAPTCHA(诚然,不够强大),也无法阻止注册量的增加。

那么这些机器人是如何做到的呢?你已经成功解决了硬盘上围绕的图像中的 CAPTCHA,但是如何制作一个完全功能的机器人呢?本节综合了前几章涵盖的许多技术。如果你还没有,建议至少浏览第十三章。

大多数基于图像的 CAPTCHA 具有几个特性:

  • 它们是由服务器端程序动态生成的图像。它们可能具有看起来不像传统图像的图像源,例如<img src="WebForm.aspx?id=8AP85CQKE9TJ">,但可以像任何其他图像一样下载和操作。

  • 图像的解决方案存储在服务器端数据库中。

  • 如果您花费的时间过长,许多 CAPTCHA 会超时。对于机器人来说,这通常不是问题,但是排队 CAPTCHA 解决方案以供以后使用,或者可能延迟 CAPTCHA 请求和提交解决方案之间时间的其他做法,可能不会成功。

处理这个问题的一般方法是将 CAPTCHA 图像文件下载到您的硬盘上,清理它,使用 Tesseract 解析图像,并在适当的表单参数下返回解决方案。

我创建了一个页面,位于http://pythonscraping.com/humans-only,带有一个 CAPTCHA 保护的评论表单,用于编写一个击败其的机器人。该机器人使用命令行的 Tesseract 库,而不是 pytesseract 包装器,尽管可以使用任一包。

要开始,加载页面并找到需要与其余表单数据一起 POST 的隐藏令牌的位置:

html = urlopen('https://www.pythonscraping.com/humans-only')
bs = BeautifulSoup(html, 'html.parser')
#Gather prepopulated form values
hiddenToken = bs.find(
    'input',
    {'name':'_wpcf7_captcha_challenge_captcha-170'}
)['value']

这个隐藏令牌恰好也是在页面上呈现的 CAPTCHA 图像的文件名,这使得编写getCaptchaSolution函数相对简单:

def getCaptchaSolution(hiddenToken):
    imageLocation = f'https://pythonscraping.com/wp-content/\
uploads/wpcf7_captcha/{hiddenToken}.png'
    urlretrieve(imageLocation, 'captcha.png')
    cleanImage('captcha.png')
    p = subprocess.Popen(
        ['tesseract','-l', 'captcha', 'captcha.png', 'output'],
        stdout=subprocess.PIPE,stderr=subprocess.PIPE
    )
    p.wait()
    f = open('output.txt', 'r')

    #Clean any whitespace characters
    captchaResponse = f.read().replace(' ', '').replace('\n', '')
    print('Captcha solution attempt: '+captchaResponse)
    return captchaResponse

请注意,此脚本将在两种情况下失败:如果 Tesseract 未从图像中精确提取出四个字符(因为我们知道这个 CAPTCHA 的所有有效解决方案必须有四个字符),或者如果它提交了表单但 CAPTCHA 解决方案错误。

在第一种情况下,您可以重新加载页面并重试,可能不会受到 Web 服务器的任何惩罚。在第二种情况下,服务器可能会注意到您错误地解决了 CAPTCHA,并对您进行惩罚。许多服务器在多次失败的 CAPTCHA 尝试后,会阻止用户或对其进行更严格的筛选。

当然,作为这个特定服务器的所有者,我可以证明它非常宽容,不太可能阻止您!

表单数据本身相对较长,您可以在 GitHub 存储库或在自己提交表单时的浏览器网络检查工具中完整查看。检查 CAPTCHA 解决方案的长度并使用 Requests 库提交它是相当简单的,但是:

if len(captcha_solution) == 4:
    formSubmissionUrl = 'https://pythonscraping.com/wp-json/contact-form-7/v1/\
contact-forms/93/feedback'
    headers = {'Content-Type': 'multipart/form-data;boundary=----WebKitFormBou\
ndaryBFvsPGsghJe0Esco'}
    r = requests.post(formSubmissionUrl, data=form_data, headers=headers)
    print(r.text)
else:
    print('There was a problem reading the CAPTCHA correctly!')

如果 CAPTCHA 解决方案是正确的(通常是这样),您应该期望看到类似以下内容的打印输出:

Captcha solution attempt: X9SU
{"contact_form_id":93,"status":"mail_sent","message":
"Thank you for your message. It has been sent.",
"posted_data_hash":"2bc8d1e0345bbfc281eac0410fc7b80d",
"into":"#wpcf7-f93-o1","invalid_fields":[],"captcha":
{"captcha-170":
"https:\/\/pythonscraping.com\/wp-content\/uploads
\/wpcf7_captcha\/3551342528.png"}}

尽管 CAPTCHA 不像 10 或 20 年前那样普遍,但许多站点仍在使用它们,了解如何处理它们非常重要。此外,通过处理 CAPTCHA 解决方案而获得的技能,可以轻松转化为您可能遇到的其他图像到文本场景。

¹ 参见 Rhett Jones,“Google 终于杀死了 CAPTCHA”,Gizmodo,2017 年 3 月 11 日,https://gizmodo.com/google-has-finally-killed-the-captcha-1793190374

第十七章:避免爬虫陷阱

很少有什么比爬取一个网站、查看输出,并没有看到浏览器中清晰可见的数据更令人沮丧的了。或者提交一个应该完全正常但被网络服务器拒绝的表单。或者因未知原因被一个网站阻止 IP 地址。

这些是一些最难解决的 bug,不仅因为它们可能是如此意外(在一个网站上运行良好的脚本在另一个看似相同的网站上可能根本不起作用),而且因为它们故意不提供任何显眼的错误消息或堆栈跟踪供使用。你被识别为机器人,被拒绝了,而你却不知道为什么。

在本书中,我写了很多在网站上执行棘手任务的方法,包括提交表单、提取和清理困难数据以及执行 JavaScript。这一章有点像一个杂项章节,因为这些技术来自各种各样的学科。然而,它们都有一个共同点:它们旨在克服一个唯一目的是防止自动爬取网站的障碍。

无论这些信息对你目前有多么重要,我强烈建议你至少浏览一下这一章。你永远不知道它何时会帮助你解决一个难题或完全防止一个问题的发生。

关于伦理的一点说明

在本书的前几章中,我讨论了网络爬虫所处的法律灰色地带,以及一些伦理和法律指南。说实话,对我来说,这一章在伦理上可能是最难写的一章。我的网站一直受到机器人、垃圾邮件发送者、网络爬虫和各种不受欢迎的虚拟访客的困扰,也许你的网站也是如此。那么为什么要教人们如何构建更好的机器人呢?

我认为包括这一章有几个重要原因:

  • 有一些完全符合道德和法律的理由可以爬取一些不希望被爬取的网站。在我以前的一份工作中,我作为一个网络爬虫,自动收集网站上发布客户姓名、地址、电话号码和其他个人信息的信息,而这些客户并未同意发布这些信息到互联网上。我使用爬取的信息向网站提出合法请求,要求其删除这些信息。为了避免竞争,这些网站密切关注着这些信息不被爬取。然而,我的工作确保了我公司客户(其中一些人有骚扰者,是家庭暴力的受害者,或者有其他非常充分的理由希望保持低调)的匿名性,这为网络爬取提供了一个令人信服的理由,我很感激我有必要的技能来完成这项工作。

  • 尽管几乎不可能建立一个“抓取器免疫”的网站(或者至少是一个仍然容易被合法用户访问的网站),但我希望本章的信息能帮助那些希望捍卫其网站免受恶意攻击的人。在整本书中,我将指出每种网页抓取技术的一些弱点,你可以用来保护自己的网站。请记住,今天网络上的大多数机器人仅仅是在进行广泛的信息和漏洞扫描;即使使用本章描述的几种简单技术,也很可能会阻挡其中 99%。然而,它们每个月都在变得更加复杂,最好做好准备。

  • 像大多数程序员一样,我不认为隐瞒任何教育信息是一件积极的事情。

在阅读本章节时,请记住,许多这些脚本和描述的技术不应该在你能找到的每个网站上运行。这不仅仅是不好的做法,而且你可能最终会收到停止和停止信函或者更糟糕的后果(如果你收到这样的信函,详细信息请参阅第二章)。但我不会每次讨论新技术时都敲打你的头。所以,在本章剩余部分,就像哲学家 Gump 曾经说过的:“这就是我要说的一切。”

仿人行为

对于不想被抓取的网站而言,基本挑战在于如何区分机器人和人类。虽然许多网站使用的技术(如 CAPTCHA)很难欺骗,但你可以通过一些相对简单的方法使你的机器人看起来更像人类。

调整你的标头

在整本书中,你已经使用 Python Requests 库来创建、发送和接收 HTTP 请求,比如在第十三章处理网站上的表单。Requests 库也非常适合设置标头。HTTP 标头是每次向 Web 服务器发出请求时由你发送的属性或偏好列表。HTTP 定义了几十种晦涩的标头类型,其中大多数不常用。然而,大多数主流浏览器在发起任何连接时一直使用以下七个字段(显示了来自我的浏览器示例数据):

Hostwww.google.com/
Connectionkeep-alive
Accepttext/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7
User-AgentMozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36
Referrerwww.google.com/
Accept-Encodinggzip, deflate, sdch
Accept-Languageen-US,en;q=0.8

这里是一个使用默认的 urllib 库进行典型 Python 网页抓取的标头:

Accept-Encodingidentity
User-AgentPython-urllib/3.9

如果你是一个试图阻止爬虫的网站管理员,你更可能放行哪一个?

幸运的是,使用 Requests 库可以完全自定义头部。https://www.whatismybrowser.com这个网站非常适合测试服务器可见的浏览器属性。你将使用以下脚本爬取这个网站以验证你的 Cookie 设置。

import requests
from bs4 import BeautifulSoup

session = requests.Session()

headers = {'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36',
           'Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,\
image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;\
q=0.7'}
url = 'https://www.whatismybrowser.com/\
developers/what-http-headers-is-my-browser-sending'
req = session.get(url, headers=headers)

bs = BeautifulSoup(req.text, 'html.parser')
print(bs.find('table', {'class':'table-striped'}).get_text)

输出应显示标题现在与代码中的headers字典对象中设置的相同。

尽管网站可以基于 HTTP 头部的任何属性检查“人类性”,但我发现通常唯一重要的设置是User-Agent。无论你在做什么项目,将其设置为比Python-urllib/3.9更不引人注意的内容都是个好主意。此外,如果你遇到一个极为可疑的网站,填充诸如Accept-Language等常用但很少检查的头部之一可能是说服它你是人类的关键。

使用 JavaScript 处理 Cookie

正确处理 Cookie 可以缓解许多爬取问题,尽管 Cookie 也可能是双刃剑。使用 Cookie 跟踪你在网站上的活动进度的网站可能会试图阻止显示异常行为的爬虫,如过快地完成表单或访问过多页面。尽管这些行为可以通过关闭和重新打开与网站的连接,甚至更改你的 IP 地址来掩饰,但如果你的 Cookie 暴露了你的身份,你的伪装努力可能会徒劳无功(详见第二十章了解更多关于如何做到这一点的信息)。

Cookies 有时也是爬取网站必需的。如第十三章所示,在网站上保持登录状态需要能够保存并呈现页面到页面的 Cookie。有些网站甚至不要求你真正登录并获得新版本的 Cookie,只需持有一个旧的“已登录”Cookie 并访问网站即可。

如果你正在爬取一个或少数几个特定的网站,我建议检查这些网站生成的 Cookie,并考虑你的爬虫可能需要处理哪些 Cookie。各种浏览器插件可以在你访问和浏览网站时显示 Cookie 的设置方式。EditThisCookie,一款 Chrome 扩展,是我喜欢的工具之一。

若要了解有关使用 Requests 库处理 cookies 的更多信息,请查看“处理登录和 cookies”中的代码示例在第十三章。当然,由于它无法执行 JavaScript,因此 Requests 库将无法处理许多现代跟踪软件生成的 cookies,例如 Google Analytics,这些 cookies 仅在客户端脚本执行后(或有时基于页面事件,如按钮点击,在浏览页面时发生)设置。为了处理这些问题,您需要使用 Selenium 和 Chrome WebDriver 包(我在第十四章中介绍了它们的安装和基本用法)。

您可以通过访问任何站点(http://pythonscraping.com,例如)并在 webdriver 上调用 get_cookies() 查看 cookies:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
chrome_options = Options()
chrome_options.add_argument('--headless')
driver = webdriver.Chrome(
    executable_path='drivers/chromedriver', 
    options=chrome_options)
driver.get('http://pythonscraping.com')
driver.implicitly_wait(1)
print(driver.get_cookies())

这提供了相当典型的 Google Analytics cookies 数组:

[{'domain': '.pythonscraping.com', 'expiry': 1722996491, 'httpOnly': False,
'name': '_ga', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 
'GA1.1.285394841.1688436491'}, {'domain': '.pythonscraping.com', 'expiry': 
1722996491, 'httpOnly': False, 'name': '_ga_G60J5CGY1N', 'path': '/', 
'sameSite': 'Lax', 'secure': False, 'value': 
'GS1.1.1688436491.1.0.1688436491.0.0.0'}]

要操作 cookies,您可以调用 delete_cookie()add_cookie()delete_all_cookies() 函数。此外,您可以保存和存储 cookies 以供其他网络爬虫使用。以下示例让您了解这些函数如何协同工作:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

chrome_options = Options()
chrome_options.add_argument("--headless")
driver = webdriver.Chrome(
    service=Service(CHROMEDRIVER_PATH),
    options=chrome_options
)

driver.get('http://pythonscraping.com')
driver.implicitly_wait(1)

savedCookies = driver.get_cookies()
print(savedCookies)

driver2 = webdriver.Chrome(
    service=Service(CHROMEDRIVER_PATH),
    options=chrome_options
)

driver2.get('http://pythonscraping.com')
driver2.delete_all_cookies()
for cookie in savedCookies:
    driver2.add_cookie(cookie)

driver2.get('http://pythonscraping.com')
driver.implicitly_wait(1)
print(driver2.get_cookies())

在这个示例中,第一个 webdriver 检索一个网站,打印 cookies,然后将它们存储在变量 savedCookies 中。第二个 webdriver 加载同一个网站,删除自己的 cookies,并添加第一个 webdriver 的 cookies。

注意第二个 webdriver 必须先加载网站,然后再添加 cookies。这样 Selenium 才知道 cookies 属于哪个域,即使加载网站本身对爬虫没有实际用处。

完成后,第二个 webdriver 应该有与第一个相同的 cookies。根据 Google Analytics 的说法,这第二个 webdriver 现在与第一个完全相同,并且它们将以相同的方式被跟踪。如果第一个 webdriver 已登录到一个站点,第二个 webdriver 也将是如此。

TLS 指纹识别

在 2000 年代初,许多大型科技公司喜欢在面试程序员时提出经典谜题。当招聘经理意识到两件事情时,这种做法大多数已经淡出:候选人共享和记忆谜题解决方案,以及“解决谜题的能力”与工作表现之间的关联并不紧密。

然而,这些经典的面试谜题之一仍然作为传输层安全协议的隐喻是有价值的。它是这样的:

您需要通过危险的路线向朋友发送一条绝密消息,如果解锁的包含消息的盒子被间谍拦截(但是,如果间谍没有钥匙,则带锁的消息盒是安全的)。您将消息放入可以用多个挂锁锁定的盒子中。虽然您有相应钥匙的挂锁和您的朋友也有自己的挂锁及其相应的钥匙,但是您的朋友的钥匙不适用于您的挂锁,反之亦然。如何确保您的朋友能够在其端解锁盒子并安全地接收消息?

请注意,即使作为单独的运输发送解锁您的挂锁的密钥也不起作用。间谍会拦截并复制这些钥匙并保存以备将来使用。此外,稍后发送钥匙也不起作用(尽管这是“谜题作为隐喻”有点崩溃的地方),因为间谍可以复制盒子本身,如果稍后发送一个钥匙,则可以解锁他们的盒子副本。

一个解决方案是这样的:你把你的挂锁放在盒子上并将其寄给你的朋友。你的朋友收到了锁上的盒子,把他们自己的挂锁放在上面(这样盒子上就有两个挂锁),然后把它寄回来。你移走你的挂锁,只剩下他们的挂锁寄给你的朋友。你的朋友收到盒子并解锁它。

本质上,这是如何在不可信网络上建立安全通信的方法。在像 HTTPS 这样的安全通信协议上,所有消息都使用密钥进行加密和解密。如果攻击者获得了密钥(谜题中的秘密消息表示的),则能够读取发送的任何消息。

那么如何将您将用于加密和解密未来消息的密钥发送给朋友,而不会被攻击者拦截和使用?用您自己的“挂锁”加密它,发送给朋友,朋友添加他们自己的“挂锁”,您移除您的“挂锁”,然后发送回来供朋友“解锁”。通过这种方式,秘密密钥安全地交换。

这个“锁定”、发送、添加另一个“锁定”等整个过程由传输层安全协议(TLS)处理。安全地建立双方共知的密钥的这一过程称为TLS 握手

除了建立一个双方共知的密钥或主秘密外,握手期间还建立了许多其他事情:

  • 双方支持的 TLS 协议的最高版本(在握手的其余部分中将使用的版本)

  • 将使用的加密库是哪个

  • 将使用的压缩方法

  • 服务器的身份,由其公共证书表示

  • 验证主秘密对双方都有效并且通信现在是安全的

每次与新的 Web 服务器联系和建立新的 HTTP 会话时,都会执行整个 TLS 握手过程(有关会话的更多信息,请参见第一章)。由你的计算机发送的确切 TLS 握手消息由进行连接的应用程序确定。例如,Chrome 可能支持略有不同的 TLS 版本或加密库,因此在 TLS 握手中发送的消息将不同。

由于 TLS 握手过程非常复杂,并涉及的变量很多,聪明的服务器管理员意识到,在 TLS 握手期间由客户端发送的消息在某种程度上对每个应用程序都是独一无二的。这些消息形成了一种TLS 指纹,可以显示出消息是由 Chrome、Microsoft Edge、Safari 甚至 Python Requests 库生成的。

您可以通过访问(或爬取)https://tools.scrapfly.io/api/fp/ja3?extended=1来查看由您的 TLS 握手生成的一些信息。为了使 TLS 指纹更易于管理和比较,通常会使用称为 JA3 的哈希方法,其结果显示在此 API 响应中。JA3 哈希指纹被编入大型数据库,并在以后需要识别应用程序时进行查找。

TLS 指纹有点像用户代理 cookie,它是一个长字符串,用于标识您用于发送数据的应用程序。但与用户代理 cookie 不同的是,它不容易修改。在 Python 中,TLS 由SSL 库控制。理论上,也许你可以重写 SSL 库。通过努力和奉献,也许你能够修改 Python 发送的 TLS 指纹,使其足够不同,以致于服务器无法识别 JA3 哈希以阻止 Python 机器人。通过更加努力和奉献,你甚至可能冒充一个无害的浏览器!一些项目,如https://github.com/lwthiker/curl-impersonate,正在试图做到这一点。

不幸的是,TLS 指纹问题的本质意味着任何仿冒库都需要由志愿者进行频繁维护,并且容易快速退化。在这些项目获得更广泛的关注和可靠性之前,有一种更简单的方法可以规避 TLS 指纹识别和阻断:Selenium。

在整本书中,我都警告过不要在存在替代解决方案时使用自动化浏览器来解决问题。浏览器使用大量内存,经常加载不必要的页面,并需要额外的依赖项来保持您的网络爬虫运行。但是,当涉及到 TLS 指纹时,避免麻烦并使用浏览器是很合理的选择。

请记住,无论您使用浏览器的无头版本还是非无头版本,您的 TLS 指纹都将是相同的。因此,可以关闭图形并使用最佳实践仅加载您需要的数据——目标服务器不会知道(至少根据您的 TLS 数据)!

时间至关重要

一些受到良好保护的网站可能会阻止您提交表单或与网站进行交互,如果您操作得太快的话。即使这些安全功能没有启用,从网站下载大量信息比正常人类快得多也是一个被注意并被封锁的好方法。

因此,虽然多线程编程可能是加快页面加载速度的好方法——允许您在一个线程中处理数据,同时在另一个线程中反复加载页面——但对于编写良好的爬虫来说却是一种糟糕的策略。您应该始终尽量减少单个页面加载和数据请求。如果可能的话,尽量将它们间隔几秒钟,即使您必须添加额外的:

import time

time.sleep(3)

是否需要在页面加载之间增加这几秒钟的额外时间通常需要通过实验来确定。我曾多次为了从网站中抓取数据而苦苦挣扎,每隔几分钟就要证明自己“不是机器人”(手动解决 CAPTCHA,将新获取的 cookie 粘贴回到爬虫中,以便网站将爬虫本身视为“已证明其人类性”),但添加了 time.sleep 解决了我的问题,并使我可以无限期地进行抓取。

有时候你必须放慢脚步才能更快地前进!

常见的安全特性

多年来一直使用并继续使用许多试金石测试,以不同程度的成功将网络爬虫与使用浏览器的人类分开。虽然如果机器人下载了一些对公众可用的文章和博客文章并不是什么大事,但如果机器人创建了数千个用户帐户并开始向您网站的所有成员发送垃圾邮件,则这是一个大问题。网络表单,特别是处理帐户创建和登录的表单,如果容易受到机器人的不加区分使用,那么对于安全和计算开销来说,它们对许多站点所有者的最大利益(或至少他们认为是)是限制对站点的访问。

这些以表单和登录为中心的反机器人安全措施可能对网络爬虫构成重大挑战。

请记住,这只是创建这些表单的自动化机器人时可能遇到的一些安全措施的部分概述。请参阅第十六章,有关处理 CAPTCHA 和图像处理的内容,以及第十三章,有关处理标头和 IP 地址的内容,获取有关处理受良好保护的表单的更多信息。

隐藏的输入字段值

HTML 表单中的“隐藏”字段允许浏览器查看字段中包含的值,但用户看不到它们(除非他们查看站点的源代码)。随着使用 cookie 在网站上存储变量并在之间传递它们的增加,隐藏字段在一段时间内不再受欢迎,然后发现了它们的另一个出色用途:防止网页抓取程序提交表单。

图 17-1 显示了 LinkedIn 登录页面上这些隐藏字段的示例。虽然表单只有三个可见字段(用户名、密码和提交按钮),但它向服务器传递了大量信息。

Alt Text

图 17-1. LinkedIn 登录表单有几个隐藏字段。

隐藏字段主要用于防止 Web 抓取程序的两种主要方式:字段可以在表单页面上使用随机生成的变量填充,服务器期望在处理页面上提交该变量。如果表单中没有这个值,服务器可以合理地认为提交不是源自表单页面的有机操作,而是直接由机器人发布到处理页面。绕过此措施的最佳方法是首先抓取表单页面,收集随机生成的变量,然后从那里发布到处理页面。

第二种方法有点像“蜜罐”。如果表单包含一个隐藏字段,其名称看似无害,比如用户名或电子邮件地址,那么一个编写不良的机器人可能会填写该字段并尝试提交它,而不管它对用户是否隐藏。任何带有实际值的隐藏字段(或者在表单提交页面上与其默认值不同的值)都应被忽略,甚至可能会阻止用户访问该站点。

简而言之:有时需要检查表单所在的页面,看看服务器可能期望您漏掉的任何内容。如果看到几个隐藏字段,通常带有大量随机生成的字符串变量,那么 Web 服务器可能会在表单提交时检查它们的存在。此外,可能还会有其他检查,以确保表单变量仅被使用一次,是最近生成的(这消除了仅仅将它们存储在脚本中并随时使用的可能性),或两者兼而有之。

避免蜜罐

尽管 CSS 在区分有用信息和无用信息(例如通过读取idclass标签)方面大多数情况下使生活变得极为简单,但在 Web 抓取程序方面有时可能会出现问题。如果通过 CSS 从用户隐藏网页上的字段,可以合理地假设平均访问该网站的用户将无法填写它,因为它在浏览器中不显示。如果表单被填充,很可能是有机器人在操作,并且该帖子将被丢弃。

这不仅适用于表单,还适用于链接、图像、文件和站点上的任何其他项目,这些项目可以被机器人读取,但对于通过浏览器访问站点的普通用户而言是隐藏的。访问站点上的“隐藏”链接可能很容易触发一个服务器端脚本,该脚本将阻止用户的 IP 地址,将该用户从站点注销,或者采取其他措施防止进一步访问。事实上,许多商业模型都是基于这个概念的。

例如,位于http://pythonscraping.com/pages/itsatrap.html的页面。这个页面包含两个链接,一个被 CSS 隐藏,另一个可见。此外,它包含一个带有两个隐藏字段的表单:

<html>
<head> 
    <title>A bot-proof form</title>
</head>
<style>
    body { 
        overflow-x:hidden;
    }
    .customHidden { 
        position:absolute; 
        right:50000px;
    }
</style>
<body> 
    <h2>A bot-proof form</h2>
    <a href=
     "http://pythonscraping.com/dontgohere" style="display:none;">Go here!</a>
    <a href="http://pythonscraping.com">Click me!</a>
    <form>
        <input type="hidden" name="phone" value="valueShouldNotBeModified"/><p/>
        <input type="text" name="email" class="customHidden" 
                  value="intentionallyBlank"/><p/>
        <input type="text" name="firstName"/><p/>
        <input type="text" name="lastName"/><p/>
        <input type="submit" value="Submit"/><p/>
   </form>
</body>
</html>

这三个元素以三种方式对用户隐藏:

  • 第一个链接使用简单的 CSS display:none属性隐藏。

  • 电话字段是一个隐藏的输入字段。

  • 电子邮件字段通过将其向右移动 50,000 像素(可能超出所有人的显示器屏幕)并隐藏显眼的滚动条来隐藏它。

幸运的是,因为 Selenium 呈现它访问的页面,它能够区分页面上视觉上存在的元素和不存在的元素。元素是否存在于页面上可以通过is_displayed()函数确定。

例如,以下代码检索先前描述的页面,并查找隐藏链接和表单输入字段:

from selenium import webdriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By

driver = webdriver.Chrome(service=Service(CHROMEDRIVER_PATH))

driver.get('http://pythonscraping.com/pages/itsatrap.html')
links = driver.find_elements(By.TAG_NAME, 'a')
for link in links:
    if not link.is_displayed():
        print(f'The link {link.get_attribute("href")} is a trap')

fields = driver.find_elements(By.TAG_NAME, 'input')
for field in fields:
    if not field.is_displayed():
        print(f'Do not change value of {field.get_attribute("name")}')

Selenium 捕获每个隐藏字段,产生以下输出:

The link http://pythonscraping.com/dontgohere is a trap
Do not change value of phone
Do not change value of email

虽然你可能不想访问你发现的任何隐藏链接,但你会想确保你提交了任何预填充的隐藏表单值(或者让 Selenium 为你提交),并与其他表单一起提交。总之,简单忽略隐藏字段是危险的,尽管在与它们交互时必须小心。

人类检查清单

这一章节以及这本书中有很多关于如何构建一个看起来不像爬虫而更像人类的爬虫的信息。如果你不断被网站阻止而又不知道原因,这里有一个你可以用来解决问题的检查清单:

  • 首先,如果你从 Web 服务器接收的页面是空白的、缺少信息的,或者与你期望的(或者在你自己的浏览器中看到的)不同,很可能是由于 JavaScript 在站点上执行以创建页面。查看第十四章。

  • 如果你正在向网站提交表单或进行POST请求,请检查页面以确保网站期望你提交的一切都被提交并且格式正确。使用诸如 Chrome 的 Inspector 面板之类的工具查看发送到网站的实际POST请求,确保你拥有一切,并且“有机”的请求看起来与你的机器人发送的请求相同。

  • 如果您尝试登录网站但无法保持登录状态,或者网站出现其他奇怪的“状态”行为,请检查您的 cookies。确保 cookies 在每次页面加载之间都被正确保存,并且您的 cookies 被发送到该网站以处理每个请求。

  • 如果您从客户端收到 HTTP 错误,特别是 403 Forbidden 错误,这可能表示网站已将您的 IP 地址识别为机器人,不愿再接受任何请求。您需要等待直到您的 IP 地址从列表中移除,或者获取一个新的 IP 地址(要么去星巴克,要么参考第二十章)。为了确保您不会再次被阻止,请尝试以下方法:

    • 确保您的抓取器不要过快地浏览网站。快速抓取是一种不良实践,会给网站管理员的服务器带来沉重负担,可能会让您陷入法律麻烦,并且是抓取器被列入黑名单的头号原因。为您的抓取器添加延迟,并让它们在夜间运行。记住:匆忙编写程序或收集数据是糟糕项目管理的表现;提前计划以避免出现这种混乱。

    • 最明显的一种:更改您的 headers!一些网站会阻止任何宣称自己是抓取器的内容。如果您对一些合理的 header 值感到不确定,请复制您自己浏览器的 headers。

    • 确保您不要点击或访问任何人类通常无法访问的内容(更多信息请参考“避免蜜罐”)。

    • 如果您发现自己需要跨越许多困难障碍才能获得访问权限,请考虑联系网站管理员,让他们知道您的操作。尝试发送电子邮件至webmaster@admin@,请求使用您的抓取器。管理员也是人,您可能会惊讶地发现他们对分享数据的态度是多么的乐意。

第十八章:使用抓取器测试您的网站

在使用具有大型开发堆栈的 Web 项目时,通常只有“堆栈”的“后端”部分会定期进行测试。今天大多数编程语言(包括 Python)都有某种类型的测试框架,但网站前端通常被排除在这些自动化测试之外,尽管它们可能是项目中唯一面向客户的部分。

问题的一部分是,网站经常是许多标记语言和编程语言的混合物。您可以为 JavaScript 的某些部分编写单元测试,但如果它与其交互的 HTML 已更改以使 JavaScript 在页面上没有预期的操作,则此单元测试是无用的,即使它正常工作。

前端网站测试的问题经常被放在后面或者委托给只有最多一个清单和一个 bug 跟踪器的低级程序员。然而,只需稍微付出更多的努力,你就可以用一系列单元测试替换这个清单,并用网页抓取器代替人眼。

想象一下:为 Web 开发进行测试驱动开发。每天测试以确保 Web 界面的所有部分都正常运行。一套测试在有人添加新的网站功能或更改元素位置时运行。本章介绍了测试的基础知识以及如何使用基于 Python 的 Web 抓取器测试各种网站,从简单到复杂。

测试简介

如果您以前从未为代码编写过测试,那么现在没有比现在更好的时间开始了。拥有一套可以运行以确保代码按预期执行(至少是您为其编写了测试的范围)的测试集合会节省您时间和担忧,并使发布新更新变得容易。

什么是单元测试?

测试单元测试 这两个词通常可以互换使用。通常,当程序员提到“编写测试”时,他们真正的意思是“编写单元测试”。另一方面,当一些程序员提到编写单元测试时,他们实际上在编写其他类型的测试。

尽管定义和实践往往因公司而异,但一个单元测试通常具有以下特征:

  • 每个单元测试测试组件功能的一个方面。例如,它可能确保从银行账户中提取负数美元时会抛出适当的错误消息。

    单元测试通常根据它们所测试的组件分组在同一个类中。你可能会有一个测试,测试从银行账户中提取负美元值,然后是一个测试过度支出的银行账户行为的单元测试。

  • 每个单元测试可以完全独立运行,单元测试所需的任何设置或拆卸必须由单元测试本身处理。同样,单元测试不得干扰其他测试的成功或失败,并且它们必须能够以任何顺序成功运行。

  • 每个单元测试通常至少包含一个断言。例如,一个单元测试可能会断言 2 + 2 的答案是 4。偶尔,一个单元测试可能只包含一个失败状态。例如,如果抛出异常,则可能失败,但如果一切顺利,则默认通过。

  • 单元测试与大部分代码分离。虽然它们必须导入和使用它们正在测试的代码,但它们通常保存在单独的类和目录中。

尽管可以编写许多其他类型的测试—例如集成测试和验证测试—但本章主要关注单元测试。不仅单元测试在最近推动的面向测试驱动开发中变得极为流行,而且它们的长度和灵活性使它们易于作为示例进行操作,并且 Python 具有一些内置的单元测试功能,你将在下一节中看到。

Python 单元测试

Python 的单元测试模块unittest已包含在所有标准 Python 安装中。只需导入并扩展unittest.TestCase,它将:

  • 提供setUptearDown函数,分别在每个单元测试之前和之后运行

  • 提供几种类型的“assert”语句,以允许测试通过或失败

  • 运行所有以test_开头的函数作为单元测试,并忽略未以测试形式开头的函数

下面提供了一个简单的单元测试,用于确保 2 + 2 = 4,根据 Python 的定义:

import unittest

class TestAddition(unittest.TestCase):
    def setUp(self):
        print('Setting up the test')

    def tearDown(self):
        print('Tearing down the test')

    def test_twoPlusTwo(self):
        total = 2+2
        self.assertEqual(4, total);

if __name__ == '__main__':
    unittest.main()

尽管在这里setUptearDown不提供任何有用的功能,但它们被包含在内以进行说明。请注意,这些函数在每个单独的测试之前和之后运行,而不是在类的所有测试之前和之后运行。

当从命令行运行测试函数的输出应如下所示:

Setting up the test
Tearing down the test
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

这表明测试已成功运行,2 + 2 确实等于 4。

测试维基百科

测试网站的前端(不包括我们将在下一节中介绍的 JavaScript)只需将 Pythonunittest库与 Web 爬虫结合使用即可:

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

class TestWikipedia(unittest.TestCase):
    bs = None
    def setUpClass():
        url = 'http://en.wikipedia.org/wiki/Monty_Python'
        TestWikipedia.bs = BeautifulSoup(urlopen(url), 'html.parser')

    def test_titleText(self):
        pageTitle = TestWikipedia.bs.find('h1').get_text()
        self.assertEqual('Monty Python', pageTitle);

    def test_contentExists(self):
        content = TestWikipedia.bs.find('div',{'id':'mw-content-text'})
        self.assertIsNotNone(content)

if __name__ == '__main__':
    unittest.main()

这次有两个测试:第一个测试页面的标题是否是预期的“Monty Python”,第二个确保页面具有内容div

请注意,页面内容仅加载一次,并且全局对象bs在测试之间共享。这是通过使用unittest指定的setUpClass函数实现的,该函数在类开始时只运行一次(不像setUp,它在每个单独的测试之前运行)。使用setUpClass而不是setUp可以节省不必要的页面加载;您可以一次获取内容并对其运行多个测试。

setUpClasssetUp之间的一个主要架构差异,除了它们何时以及多频繁地运行之外,是setUpClass是一个静态方法,它“属于”类本身并具有全局类变量,而setUp是一个属于类的特定实例的实例函数。这就是为什么setUp可以在self上设置属性——该类的特定实例——而setUpClass只能访问类TestWikipedia上的静态类属性。

虽然一次只测试一个页面可能看起来并不那么强大或有趣,正如您可能从第六章中记得的那样,构建可以迭代地移动通过网站所有页面的网络爬虫相对容易。当您将一个网络爬虫与对每个页面进行断言的单元测试结合在一起时会发生什么?

有许多方法可以重复运行测试,但是你必须小心地每次加载每个页面,以及你还必须避免一次在内存中持有大量信息。以下设置正好做到了这一点:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import unittest
import re
import random
from urllib.parse import unquote

class TestWikipedia(unittest.TestCase):

    def test_PageProperties(self):
        self.url = 'http://en.wikipedia.org/wiki/Monty_Python'
        #Test the first 10 pages we encounter
        for i in range(1, 10):
            self.bs = BeautifulSoup(urlopen(self.url), 'html.parser')
            titles = self.titleMatchesURL()
            self.assertEquals(titles[0], titles[1])
            self.assertTrue(self.contentExists())
            self.url = self.getNextLink()
        print('Done!')

    def titleMatchesURL(self):
        pageTitle = self.bs.find('h1').get_text()
        urlTitle = self.url[(self.url.index('/wiki/')+6):]
        urlTitle = urlTitle.replace('_', ' ')
        urlTitle = unquote(urlTitle)
        return [pageTitle.lower(), urlTitle.lower()]

    def contentExists(self):
        content = self.bs.find('div',{'id':'mw-content-text'})
        if content is not None:
            return True
        return False

    def getNextLink(self):
        #Returns random link on page, using technique from Chapter 3
        links = self.bs.find('div', {'id':'bodyContent'}).find_all(
            'a', href=re.compile('^(/wiki/)((?!:).)*$'))
        randomLink = random.SystemRandom().choice(links)
        return 'https://wikipedia.org{}'.format(randomLink.attrs['href'])

if __name__ == '__main__':
    unittest.main()

有几件事情要注意。首先,这个类中只有一个实际的测试。其他函数技术上只是辅助函数,尽管它们完成了大部分计算工作来确定测试是否通过。因为测试函数执行断言语句,测试结果被传回测试函数,在那里断言发生。

另外,虽然contentExists返回一个布尔值,但titleMatchesURL返回用于评估的值本身。要了解为什么你希望传递值而不仅仅是布尔值,请比较布尔断言的结果:

======================================================================
FAIL: test_PageProperties (__main__.TestWikipedia)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "15-3.py", line 22, in test_PageProperties
    self.assertTrue(self.titleMatchesURL())
AssertionError: False is not true

assertEquals语句的结果一样:

======================================================================
FAIL: test_PageProperties (__main__.TestWikipedia)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "15-3.py", line 23, in test_PageProperties
    self.assertEquals(titles[0], titles[1])
AssertionError: 'lockheed u-2' != 'u-2 spy plane'

哪一个更容易调试?(在这种情况下,错误是由于重定向导致的,当文章 wikipedia.org/wiki/u-2%20… 重定向到一个名为“Lockheed U-2”的文章时。)

使用 Selenium 进行测试

就像在第十四章中的 Ajax 抓取一样,当进行网站测试时,JavaScript 在处理特定的网站时会出现特殊的挑战。幸运的是,Selenium 已经有了一个处理特别复杂网站的优秀框架;事实上,这个库最初就是为网站测试而设计的!

尽管显然使用相同的语言编写,Python 单元测试和 Selenium 单元测试的语法却惊人地不相似。Selenium 不要求其单元测试被包含在类中的函数中;它的assert语句不需要括号;测试在通过时静默通过,仅在失败时产生某种消息:

driver = webdriver.Chrome()
driver.get('http://en.wikipedia.org/wiki/Monty_Python')
assert 'Monty Python' in driver.title
driver.close()

当运行时,这个测试应该不会产生任何输出。

以这种方式,Selenium 测试可以比 Python 单元测试更加随意地编写,并且assert语句甚至可以集成到常规代码中,当代码执行希望在未满足某些条件时终止时。

与站点互动

最近,我想通过一个本地小企业的网站联系他们的联系表单,但发现 HTML 表单已损坏;当我点击提交按钮时什么也没有发生。经过一番调查,我发现他们使用了一个简单的 mailto 表单,旨在用表单内容发送电子邮件给他们。幸运的是,我能够利用这些信息发送电子邮件给他们,解释表单的问题,并雇佣他们,尽管有技术问题。

如果我要编写一个传统的爬虫来使用或测试这个表单,我的爬虫很可能只会复制表单的布局并直接发送电子邮件,完全绕过表单。我该如何测试表单的功能性,并确保它通过浏览器正常工作?

虽然前几章已经讨论了导航链接、提交表单和其他类型的交互活动,但我们所做的一切本质上是为了 绕过 浏览器界面,而不是使用它。另一方面,Selenium 可以通过浏览器(在这种情况下是无头 Chrome 浏览器)直接输入文本、点击按钮以及执行所有操作,并检测到诸如损坏的表单、糟糕编码的 JavaScript、HTML 拼写错误以及其他可能困扰实际客户的问题。

这种测试的关键在于 Selenium 元素的概念。这个对象在 第十四章 简要提到,并且可以通过如下调用返回:

usernameField = driver.find_element_by_name('username')

正如您可以在浏览器中对网站的各个元素执行多种操作一样,Selenium 可以对任何给定元素执行许多操作。其中包括:

myElement.click()
myElement.click_and_hold()
myElement.release()
myElement.double_click()
myElement.send_keys_to_element('content to enter')

除了对元素执行一次性操作外,动作串也可以组合成 动作链,可以在程序中存储并执行一次或多次。动作链之所以有用,是因为它们可以方便地串接多个动作,但在功能上与显式调用元素上的动作完全相同,就像前面的例子一样。

要了解这种差异,请查看 http://pythonscraping.com/pages/files/form.html 上的表单页面(这在 第十三章 中曾作为示例使用)。我们可以以这种方式填写表单并提交:

from selenium import webdriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.common.keys import Keys
from selenium.webdriver import ActionChains
from selenium.webdriver.chrome.options import Options

chrome_options = Options()
chrome_options.add_argument('--headless')

driver = webdriver.Chrome(
    executable_path='drivers/chromedriver', options=chrome_options)
driver.get('http://pythonscraping.com/pages/files/form.html')

firstnameField = driver.find_element_by_name('firstname')
lastnameField = driver.find_element_by_name('lastname')
submitButton = driver.find_element_by_id('submit')

### METHOD 1 ###
#firstnameField.send_keys('Ryan')
lastnameField.send_keys('Mitchell')
submitButton.click()
################

### METHOD 2 ###
actions = ActionChains(driver).click(firstnameField)
    .send_keys('Ryan')
    .click(lastnameField)
    .send_keys('Mitchell')
    .send_keys(Keys.RETURN)
actions.perform()
################

print(driver.find_element_by_tag_name('body').text)

driver.close()

方法 1 在两个字段上调用 send_keys,然后点击提交按钮。方法 2 使用单个动作链在调用 perform 方法后依次点击和输入每个字段的文本。无论使用第一种方法还是第二种方法,此脚本的操作方式都相同,并打印此行:

Hello there, Ryan Mitchell!

两种方法之间还有另一种变化,除了它们用于处理命令的对象之外:请注意,第一种方法点击“提交”按钮,而第二种方法在提交表单时使用回车键。因为完成相同动作的事件序列有很多思考方式,使用 Selenium 可以完成相同的动作的方法也有很多。

拖放

点击按钮和输入文本是一回事,但是 Selenium 真正发光的地方在于它处理相对新颖的 Web 交互形式的能力。Selenium 允许轻松操作拖放接口。使用其拖放功能需要指定一个元素(要拖动的元素)和要拖动到的目标元素或偏移量。

该演示页面位于http://pythonscraping.com/pages/javascript/draggableDemo.html,展示了这种类型界面的一个示例:

from selenium import webdriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver import ActionChains
from selenium.webdriver.chrome.options import Options
import unittest

class TestDragAndDrop(unittest.TestCase):
    driver = None

    def setUp(self):
        chrome_options = Options()
        chrome_options.add_argument('--headless')
        self.driver = webdriver.Chrome(
            executable_path='drivers/chromedriver', options=chrome_options)
        url = 'http://pythonscraping.com/pages/javascript/draggableDemo.html'
        self.driver.get(url)

    def tearDown(self):
        driver.close()

    def test_drag(self):
        element = self.driver.find_element_by_id('draggable')
        target = self.driver.find_element_by_id('div2')
        actions = ActionChains(self.driver)
        actions.drag_and_drop(element, target).perform()
        self.assertEqual('You are definitely not a bot!',
                         self.driver.find_element_by_id('message').text)

从演示页面的message div中打印出两条消息。第一条消息是:

Prove you are not a bot, by dragging the square from the blue area to the red 
area!

然后,在任务完成后,内容再次打印出来,现在读取:

You are definitely not a bot!

正如演示页面所示,将元素拖动以证明你不是机器人是许多验证码的共同主题。尽管机器人早就能够拖动物体(只需点击、按住和移动),但“拖动此物”作为验证人类的想法似乎无法消亡。

此外,这些可拖动的验证码库很少使用任何对机器人困难的任务,例如“将小猫的图片拖到牛的图片上”(这需要你识别图片为“小猫”和“牛”,并解析指令);相反,它们通常涉及数字排序或类似前面示例中的其他相当琐碎的任务。

当然,它们的强大之处在于其变化如此之多,而且使用频率如此之低;可能没有人会费力去制作一个能够击败所有验证码的机器人。无论如何,这个例子足以说明为什么你不应该在大型网站上使用这种技术。

拍摄截图

除了通常的测试功能外,Selenium 还有一个有趣的技巧,可能会使你的测试(或者让你的老板印象深刻)更加轻松:截图。是的,可以从运行的单元测试中创建照片证据,而无需实际按下 PrtScn 键:

driver = webdriver.Chrome()
driver.get('http://www.pythonscraping.com/')
driver.get_screenshot_as_file('tmp/pythonscraping.png')

此脚本导航到http://pythonscraping.com,然后将首页的截图存储在本地的tmp文件夹中(此文件夹必须已经存在才能正确存储)。截图可以保存为多种图像格式。

第十九章:并行网络爬虫

网络爬虫速度快。至少,通常比雇佣十几个实习生手工复制互联网数据要快得多!当然,科技的进步和享乐主义跑步机要求在某一点上甚至这都不够“快”。这就是人们通常开始寻求分布式计算的时候。

与大多数其他技术领域不同,网络爬虫通常不能简单地通过“将更多的周期投入到问题中”来改进。运行一个进程是快速的;运行两个进程不一定是两倍快。运行三个进程可能会让你被禁止访问你正在猛击的远程服务器!

但是,在某些情况下,并行网络爬虫或运行并行线程或进程仍然有益:

  • 从多个来源(多个远程服务器)收集数据而不仅仅是单个来源

  • 在收集的数据上执行长时间或复杂的操作(例如进行图像分析或 OCR),这些操作可以与获取数据并行进行。

  • 从一个大型网络服务中收集数据,在这里你需要为每个查询付费,或者在你的使用协议范围内创建多个连接到服务。

进程与线程

线程和进程不是 Python 特有的概念。虽然确切的实现细节在操作系统之间不同,并且依赖于操作系统,但计算机科学界的一般共识是,进程更大并且有自己的内存,而线程更小并且在包含它们的进程内共享内存。

通常,当你运行一个简单的 Python 程序时,你在自己的进程中运行它,该进程包含一个线程。但是 Python 支持多进程和多线程。多进程和多线程都实现了同样的最终目标:以并行方式执行两个编程任务,而不是以更传统的线性方式一个接一个地运行函数。

但是,你必须仔细考虑每个方案的利弊。例如,每个进程都有自己由操作系统分配的内存。这意味着内存不在进程之间共享。虽然多个线程可以愉快地写入相同的共享 Python 队列、列表和其他对象,但进程不能,必须更显式地传递这些信息。

使用多线程编程在单独的线程中执行具有共享内存的任务通常被认为比多进程编程更容易。但是这种方便性是有代价的。

Python 的全局解释器锁(GIL)用于防止多个线程同时执行同一行代码。GIL 确保所有进程共享的通用内存不会变得损坏(例如,内存中的字节一半被写入一个值,另一半被写入另一个值)。这种锁定使得编写多线程程序并在同一行内知道你得到什么成为可能,但它也有可能造成瓶颈。

多线程爬取

以下示例说明了使用多线程执行任务:

import threading
import time

def print_time(threadName, delay, iterations):
    start = int(time.time())
    for i in range(0,iterations):
        time.sleep(delay)
        print(f'{int(time.time() - start)} - {threadName}')

threads = [
    threading.Thread(target=print_time, args=('Fizz', 3, 33)),
    threading.Thread(target=print_time, args=('Buzz', 5, 20)),
    threading.Thread(target=print_time, args=('Counter', 1, 100))
]

[t.start() for t in threads]
[t.join() for t in threads]

这是对经典的FizzBuzz 编程测试的参考,输出略显冗长:

1 Counter
2 Counter
3 Fizz
3 Counter
4 Counter
5 Buzz
5 Counter
6 Fizz
6 Counter

脚本启动三个线程,一个每三秒打印一次“Fizz”,另一个每五秒打印一次“Buzz”,第三个每秒打印一次“Counter”。

你可以在线程中执行有用的任务,如爬取网站,而不是打印 Fizz 和 Buzz:

from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
import random
import threading
import time

# Recursively find links on a Wikipedia page, 
# then follow a random link, with artificial 5 sec delay
def scrape_article(thread_name, path):
    time.sleep(5)
    print(f'{thread_name}: Scraping {path}')
    html = urlopen('http://en.wikipedia.org{}'.format(path))
    bs = BeautifulSoup(html, 'html.parser')
    title = bs.find('h1').get_text()
    links = bs.find('div', {'id':'bodyContent'}).find_all('a',
        href=re.compile('^(/wiki/)((?!:).)*$'))
    if len(links) > 0:
        newArticle = links[random.randint(0, len(links)-1)].attrs['href']
        scrape_article(thread_name, newArticle)

threads = [
    threading.Thread(
        target=scrape_article,
        args=('Thread 1', '/wiki/Kevin_Bacon',)
    ),
    threading.Thread(
        target=scrape_article,
        args=('Thread 2', '/wiki/Monty_Python',)
    ),
]
[t.start() for t in threads]
[t.join() for t in threads]

注意包含这一行:

time.sleep(5)

因为你几乎比单线程快了将近一倍的速度爬取维基百科,所以包含这一行可以防止脚本给维基百科的服务器造成过大负载。在实践中,在针对请求数量不是问题的服务器上运行时,应该删除这一行。

如果你想要稍微改写这个例子,以便追踪线程迄今为止共同看到的文章,以便不会重复访问任何文章,你可以在多线程环境中使用列表的方式与在单线程环境中使用它一样:

visited = []
def get_links(thread_name, bs):
    print('Getting links in {}'.format(thread_name))
    links = bs.find('div', {'id':'bodyContent'}).find_all('a',
        href=re.compile('^(/wiki/)((?!:).)*$')
    )
    return [link for link in links if link not in visited]

def scrape_article(thread_name, path):
    visited.append(path)
    ...
    links = get_links(thread_name, bs)
    ...

注意,将路径附加到已访问路径列表的操作是scrape_article执行的第一个动作。这减少了但并没有完全消除它被重复爬取的机会。

如果你运气不好,两个线程仍然可能在同一瞬间偶然遇到相同的路径,两者都会看到它不在已访问列表中,然后都会将其添加到列表并同时进行爬取。但是,实际上由于执行速度和维基百科包含的页面数量,这种情况不太可能发生。

这是一个竞争条件的例子。竞争条件对于有经验的程序员来说可能很难调试,因此评估代码中这些潜在情况,估计它们发生的可能性,并预测其影响的严重性是很重要的。

在这种特定的竞争条件下,爬虫两次访问同一页的情况可能不值得去解决。

竞争条件和队列

虽然你可以使用列表在线程之间通信,但列表并非专门设计用于线程之间通信,它们的错误使用很容易导致程序执行缓慢甚至由竞争条件导致的错误。

列表适用于追加或读取,但不太适合从任意点删除项目,尤其是从列表开头删除。使用如下语句:

myList.pop(0)

实际上需要 Python 重新编写整个列表,从而减慢程序执行速度。

更危险的是,列表还使得在不是线程安全的情况下方便地写入一行。例如:

myList[len(myList)-1]

在多线程环境中,这可能实际上并不会获取列表中的最后一个项目,或者如果计算 len(myList)-1 的值恰好在另一个操作修改列表之前立即进行,则可能会引发异常。

有人可能会认为前面的陈述可以更“Python 化”地写成 myList[-1],当然,像我这样的前 Java 开发者,在弱点时刻从未不小心写过非 Python 风格的代码(尤其是回想起像 myList[myList.length-1] 这样的模式的日子)!但即使您的代码毫无可指摘,也请考虑以下涉及列表的其他非线程安全代码形式:

my_list[i] = my_list[i] + 1
my_list.append(my_list[-1])

这两者都可能导致竞态条件,从而导致意外结果。您可能会尝试另一种方法,并使用除列表之外的其他变量类型。例如:

# Read the message in from the global list
my_message = global_message
# Write a message back
global_message = 'I've retrieved the message'
# do something with my_message

这似乎是一个很好的解决方案,直到您意识到在第一行和第二行之间的瞬间,您可能已经无意中覆盖了来自另一个线程的另一条消息,其文本为“我已检索到消息”。因此,现在您只需为每个线程构建一系列复杂的个人消息对象,并添加一些逻辑来确定谁获取什么……或者您可以使用专为此目的构建的 Queue 模块。

队列是类似列表的对象,可以采用先进先出(FIFO)或后进先出(LIFO)方法。队列通过 queue.put('My message') 从任何线程接收消息,并可以将消息传输给调用 queue.get() 的任何线程。

队列不是设计用来存储静态数据的,而是以线程安全的方式传输数据。从队列检索数据后,它应该仅存在于检索它的线程中。因此,它们通常用于委派任务或发送临时通知。

这在网页抓取中可能很有用。例如,假设您希望将爬取器收集到的数据持久化到数据库中,并且希望每个线程能够快速持久化其数据。一个共享的单一连接可能会导致问题(单一连接无法并行处理请求),但每个爬取线程都分配自己的数据库连接也是没有意义的。随着爬取器的规模扩大(最终您可能从一百个不同的网站中收集数据,每个网站一个线程),这可能会转化为大量大多数空闲的数据库连接,仅在加载页面后偶尔进行写入。

相反,您可以拥有少量数据库线程,每个线程都有自己的连接,等待从队列中接收并存储项目。这提供了一组更易管理的数据库连接:

def storage(queue):
    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')
    while 1:
        if not queue.empty():
            path = queue.get()
            cur.execute('SELECT * FROM pages WHERE url = %s', (path))
            if cur.rowcount == 0:
                print(f'Storing article {path}')
                cur.execute('INSERT INTO pages (url) VALUES (%s)', (path))
                conn.commit()
            else:
                print("Article already exists: {}".format(path))

visited = set()
def get_links(thread_name, bs):
    print('Getting links in {}'.format(thread_name))
    links = bs.find('div', {'id':'bodyContent'}).find_all(
        'a',
        href=re.compile('^(/wiki/)((?!:).)*$')
    )
    links = [link.get('href') for link in links]
    return [link for link in links if link and link not in visited]

def scrape_article(thread_name, path):
    time.sleep(5)
    visited.add(path)
    print(f'{thread_name}: Scraping {path}')
    bs = BeautifulSoup(
        urlopen('http://en.wikipedia.org{}'.format(path)),
        'html.parser'
    )
    links = get_links(thread_name, bs)
    if len(links) > 0:
        [queue.put(link) for link in links]
        newArticle = links[random.randint(0, len(links)-1)].attrs['href']
        scrape_article(thread_name, newArticle)

queue = Queue()

threads = [
    threading.Thread(
    ​    target=scrape_article,
​    ​    args=('Thread 1', '/wiki/Kevin_Bacon',)
​    ),
    threading.Thread(
​    ​    target=scrape_article,
​    ​    args=('Thread 2', '/wiki/Monty_Python',)
​    ),
    threading.Thread(
​    ​    target=storage,
​    ​    args=(queue,)
​    )
]
[t.start() for t in threads]
[t.join() for t in threads]

此脚本创建三个线程:两个线程在维基百科上进行随机遍历页面,第三个线程将收集的数据存储在 MySQL 数据库中。有关 MySQL 和数据存储的更多信息,请参见第九章。

此爬虫也比之前的版本简化了一些。它不再处理页面的标题和 URL,而是只关注 URL。此外,鉴于两个线程可能同时尝试将相同的 URL 添加到visited列表中,我已将此列表转换为集合。虽然它不严格地线程安全,但冗余设计确保任何重复不会对最终结果产生影响。

线程模块的更多特性

Python 的threading模块是在低级别_thread模块之上构建的高级接口。虽然_thread可以完全独立使用,但它需要更多的工作,而且不提供让生活变得如此愉快的小东西——例如便捷函数和巧妙功能。

例如,您可以使用像enumerate这样的静态函数来获取通过threading模块初始化的所有活动线程列表,而无需自己跟踪它们。类似地,activeCount函数提供线程的总数。来自_thread的许多函数都有更方便或更易记的名称,例如currentThread而不是get_ident来获取当前线程的名称。

关于线程模块的一个很好的特点是,可以轻松创建本地线程数据,这些数据对其他线程不可用。如果您有多个线程,每个线程分别从不同的网站抓取数据,并且每个线程跟踪其自己的本地已访问页面列表,这可能是一个不错的功能。

此本地数据可以在线程函数内的任何点上通过调用threading.local()来创建:

import threading

def crawler(url):
    data = threading.local()
    data.visited = []
    # Crawl site

threading.Thread(target=crawler, args=('http://brookings.edu')).start()

这解决了在线程共享对象之间发生竞争条件的问题。每当一个对象不需要被共享时,它就不应该被共享,并且应该保留在本地线程内存中。为了安全地在线程之间共享对象,仍然可以使用前一节中的Queue

线程模块充当一种线程保姆,并且可以高度定制以定义这种保姆的具体任务。isAlive函数默认查看线程是否仍然活动。直到线程完成(或崩溃)时,该函数将返回True

通常,爬虫设计为长时间运行。isAlive方法可以确保如果线程崩溃,它将重新启动:

threading.Thread(target=crawler)
t.start()

while True:
    time.sleep(1)
    if not t.isAlive():
        t = threading.Thread(target=crawler)
        t.start()

另外,可以通过扩展threading.Thread对象来添加其他监控方法:

import threading
import time

class Crawler(threading.Thread):
    def __init__(self):
        threading.Thread.__init__(self)
        self.done = False

    def isDone(self):
        return self.done

    def run(self):
        time.sleep(5)
        self.done = True
        raise Exception('Something bad happened!')

t = Crawler()
t.start()

while True:
    time.sleep(1)
    if t.isDone():
        print('Done')
        break
    if not t.isAlive():
        t = Crawler()
        t.start()

这个新的Crawler类包含一个isDone方法,可以用来检查爬虫是否完成了爬取工作。如果还有一些需要完成的额外记录方法,以便线程不能关闭,但大部分爬取工作已经完成,这可能会很有用。通常情况下,isDone可以替换为某种状态或进度度量 - 例如已记录的页面数或当前页面。

任何由Crawler.run引发的异常都会导致类重新启动,直到isDoneTrue并退出程序。

在您的爬虫类中扩展threading.Thread可以提高它们的健壮性和灵活性,以及您同时监视许多爬虫的任何属性的能力。

多进程

Python 的Processing模块创建了可以从主进程启动和加入的新进程对象。以下代码使用了线程进程部分中的 FizzBuzz 示例来演示:

from multiprocessing import Process
import time

def print_time(threadName, delay, iterations):
    start = int(time.time())
    for i in range(0,iterations):
        time.sleep(delay)
        seconds_elapsed = str(int(time.time()) - start)
        print (threadName if threadName else seconds_elapsed)

processes = [
    Process(target=print_time, args=('Counter', 1, 100)),
    Process(target=print_time, args=('Fizz', 3, 33)),
    Process(target=print_time, args=('Buzz', 5, 20)) 
]

[p.start() for p in processes]
[p.join() for p in processes]

请记住,每个进程都被操作系统视为独立的个体程序。如果通过操作系统的活动监视器或任务管理器查看进程,您应该能看到这一点,就像在图 19-1 中所示。

图 19-1. 在运行 FizzBuzz 时运行的五个 Python 进程

第四个进程的 PID 为 76154,是运行中的 Jupyter 笔记本实例,如果您是从 IPython 笔记本中运行此程序,应该会看到它。第五个进程 83560 是执行的主线程,在程序首次执行时启动。PID 是由操作系统顺序分配的。除非在 FizzBuzz 脚本运行时有另一个程序快速分配 PID,否则您应该会看到另外三个顺序 PID - 在本例中为 83561、83562 和 83563。

这些 PID 也可以通过使用os模块在代码中找到:

import os
...
`# prints the child PID`
`os``.``getpid``(``)`
`# prints the parent PID`
os.getppid()

您程序中的每个进程应该为os.getpid()行打印不同的 PID,但在os.getppid()上将打印相同的父 PID。

从技术上讲,对于这个特定程序,不需要几行代码。如果不包括结束的join语句:

[p.join() for p in processes]

父进程仍将结束并自动终止子进程。但是,如果希望在这些子进程完成后执行任何代码,则需要这种连接。

例如:

[p.start() for p in processes]
print('Program complete')

如果不包括join语句,输出将如下所示:

Program complete
1
2

如果包括join语句,程序将等待每个进程完成后再继续:

[p.start() for p in processes]
[p.join() for p in processes]
print('Program complete')

...
Fizz
99
Buzz
100
Program complete

如果您想要提前停止程序执行,您当然可以使用 Ctrl-C 来终止父进程。父进程的终止也将终止已生成的任何子进程,因此可以放心使用 Ctrl-C,不用担心意外地使进程在后台运行。

多进程爬取

多线程维基百科爬取示例可以修改为使用单独的进程而不是单独的线程:

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

from multiprocessing import Process
import os
import time

visited = []
def get_links(bs):
    links = bs.find('div', {'id':'bodyContent'})
        .find_all('a', href=re.compile('^(/wiki/)((?!:).)*$'))
    return [link for link in links if link not in visited]

def scrape_article(path):
    visited.append(path)
    html = urlopen('http://en.wikipedia.org{}'.format(path))
    time.sleep(5)
    bs = BeautifulSoup(html, 'html.parser')
    print(f'Scraping {bs.find("h1").get_text()} in process {os.getpid()}')
    links = get_links(bs)
    if len(links) > 0:
        scrape_article(links[random.randint(0, len(links)-1)].attrs['href'])

processes = [
    Process(target=scrape_article, args=('/wiki/Kevin_Bacon',)),
    Process(target=scrape_article, args=('/wiki/Monty_Python',)) 
]
[p.start() for p in processes]

再次,您通过包含time.sleep(5)来人为地减慢爬虫的过程,以便可以在不对维基百科服务器施加不合理负载的情况下用于示例目的。

在这里,您正在用os.getpid()替换传递为参数的用户定义的thread_name,这不需要作为参数传递,并且可以在任何时候访问。

这将产生如下的输出:

Scraping Kevin Bacon in process 4067
Scraping Monty Python in process 4068
Scraping Ewan McGregor in process 4067
Scraping Charisma Records in process 4068
Scraping Renée Zellweger in process 4067
Scraping Genesis (band) in process 4068
Scraping Alana Haim in process 4067
Scraping Maroon 5 in process 4068

理论上,与分开线程爬行相比,单独进程爬行略快,有两个主要原因:

  • 进程不受 GIL 锁定的限制,可以同时执行相同的代码行并修改同一个(实际上是同一对象的不同实例化)对象。

  • 进程可以在多个 CPU 核心上运行,这可能会提供速度优势,如果您的每个进程或线程都是处理器密集型的。

然而,这些优势伴随着一个主要的缺点。在前述程序中,所有找到的 URL 都存储在全局的visited列表中。当您使用多个线程时,此列表在所有线程之间共享;除非存在罕见的竞争条件,否则一个线程无法访问已被另一个线程访问过的页面。然而,现在每个进程都获得其自己独立版本的 visited 列表,并且可以自由地访问其他进程已经访问过的页面。

在进程之间通信

进程在其自己独立的内存中运行,如果您希望它们共享信息,这可能会导致问题。

将前面的示例修改为打印 visited 列表的当前输出,您可以看到这个原理的实际应用:

def scrape_article(path): 
    visited.append(path)
    print("Process {} list is now: {}".format(os.getpid(), visited))

这导致输出如下所示:

Process 84552 list is now: ['/wiki/Kevin_Bacon']
Process 84553 list is now: ['/wiki/Monty_Python']
Scraping Kevin Bacon in process 84552
/wiki/Desert_Storm
Process 84552 list is now: ['/wiki/Kevin_Bacon', '/wiki/Desert_Storm']
Scraping Monty Python in process 84553
/wiki/David_Jason
Process 84553 list is now: ['/wiki/Monty_Python', '/wiki/David_Jason']

但是通过两种类型的 Python 对象:队列和管道,可以在同一台机器上的进程之间共享信息。

一个队列与先前看到的线程队列类似。信息可以由一个进程放入队列,由另一个进程移除。一旦这些信息被移除,它就从队列中消失了。因为队列被设计为“临时数据传输”的一种方法,所以不适合保存静态引用,例如“已经访问过的网页列表”。

但是,如果这个静态的网页列表被某种爬取委托替代怎么办?爬虫可以从一个队列中弹出一个路径来爬取(例如*/wiki/Monty_Python*),并且返回一个包含“找到的 URL”列表的独立队列,该队列将由爬取委托处理,以便只有新的 URL 被添加到第一个任务队列中:

def task_delegator(taskQueue, urlsQueue):
    #Initialize with a task for each process
    visited = ['/wiki/Kevin_Bacon', '/wiki/Monty_Python']
    taskQueue.put('/wiki/Kevin_Bacon')
    taskQueue.put('/wiki/Monty_Python')

    while 1:
        # Check to see if there are new links in the urlsQueue
        # for processing
        if not urlsQueue.empty():
            links = [link for link in urlsQueue.get() if link not in visited]
            for link in links:
                #Add new link to the taskQueue
                taskQueue.put(link)

def get_links(bs):
    links = bs.find('div', {'id':'bodyContent'}).find_all('a',
        href=re.compile('^(/wiki/)((?!:).)*$'))
    return [link.attrs['href'] for link in links]

def scrape_article(taskQueue, urlsQueue):
    while 1:
        while taskQueue.empty():
            #Sleep 100 ms while waiting for the task queue
            #This should be rare
            time.sleep(.1)
        path = taskQueue.get()
        html = urlopen('http://en.wikipedia.org{}'.format(path))
        time.sleep(5)
        bs = BeautifulSoup(html, 'html.parser')
        title = bs.find('h1').get_text()
        print(f'Scraping {bs.find('h1').get_text()} in process {os.getpid()}')
        links = get_links(bs)
        #Send these to the delegator for processing
        urlsQueue.put(links)

processes = []
taskQueue = Queue()
urlsQueue = Queue()
processes.append(Process(target=task_delegator, args=(taskQueue, urlsQueue,)))
processes.append(Process(target=scrape_article, args=(taskQueue, urlsQueue,)))
processes.append(Process(target=scrape_article, args=(taskQueue, urlsQueue,)))

for p in processes:
    p.start()

这个爬虫与最初创建的爬虫之间存在一些结构上的差异。不同于每个进程或线程按照其分配的起始点进行自己的随机漫步,它们共同努力完成对网站的完整覆盖爬行。每个进程可以从队列中获取任何“任务”,而不仅仅是它们自己找到的链接。

你可以看到它的实际效果,例如进程 97024 同时爬取蒙提·派森费城(凯文·贝肯的电影):

Scraping Kevin Bacon in process 97023
Scraping Monty Python in process 97024
Scraping Kevin Bacon (disambiguation) in process 97023
Scraping Philadelphia in process 97024
Scraping Kevin Bacon filmography in process 97023
Scraping Kyra Sedgwick in process 97024
Scraping Sosie Bacon in process 97023
Scraping Edmund Bacon (architect) in process 97024
Scraping Michael Bacon (musician) in process 97023
Scraping Holly Near in process 97024
Scraping Leading actor in process 97023

多进程爬虫——另一种方法

所有讨论的多线程和多进程爬取方法都假设你需要对子线程和子进程进行某种形式的“家长监护”。你可以同时启动它们,你可以同时结束它们,你可以在它们之间发送消息或共享内存。

但是,如果你的爬虫设计得不需要任何指导或通信呢?现在还没有理由着急使用import _thread

例如,假设你想并行爬取两个类似的网站。你编写了一个爬虫,可以通过一个小的配置更改或者命令行参数来爬取这两个网站中的任何一个。你完全可以简单地做以下操作:

$ python my_crawler.py website1
$ python my_crawler.py website2

然后,你就启动了一个多进程网络爬虫,同时又节省了 CPU 因为要保留一个父进程而产生的开销!

当然,这种方法也有缺点。如果你想以这种方式在同一个网站上运行两个网络爬虫,你需要某种方式来确保它们不会意外地开始爬取相同的页面。解决方案可能是创建一个 URL 规则(“爬虫 1 爬取博客页面,爬虫 2 爬取产品页面”)或者以某种方式划分网站。

或者,你可以通过某种中间数据库来处理这种协调,比如Redis。在前往新链接之前,爬虫可以向数据库发出请求:“这个页面已经被爬取过了吗?”爬虫将数据库用作进程间通信系统。当然,如果没有仔细考虑,这种方法可能会导致竞态条件或者如果数据库连接慢的话会有延迟(这可能只在连接到远程数据库时才会出现问题)。

你可能还会发现,这种方法不太具有可扩展性。使用Process模块可以动态增加或减少爬取网站或存储数据的进程数量。通过手动启动它们,要么需要一个人物理上运行脚本,要么需要一个单独的管理脚本(无论是 bash 脚本、cron 作业还是其他方式)来完成这个任务。

然而,我过去曾非常成功地使用过这种方法。对于小型的一次性项目来说,这是一种快速获取大量信息的好方法,尤其是跨多个网站。