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

Python 网络爬取第三版(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

对于那些尚未掌握这种技能的人来说,计算机编程可能看起来像是一种魔法。如果编程是魔法,网页抓取就是巫术:这种魔法的应用,尽管印象深刻且有用,却令人惊讶地轻松。

在我作为软件工程师的多年经历中,我发现很少有编程实践能像网页抓取一样,能够同时激发程序员和普通人的兴奋。能够编写一个简单的机器人来收集数据并将其流到终端或存储在数据库中,虽然不难,但每次都能带来某种特定的兴奋感和可能性感,无论你以前做过多少次。

不幸的是,当我与其他程序员谈论网页抓取时,关于这种实践存在许多误解和混淆。有些人不确定它是否合法(它是合法的),或者如何处理类似于 JavaScript 密集页面或需要登录的问题。许多人对如何启动一个大型网页抓取项目甚至是如何找到他们正在寻找的数据感到困惑。本书旨在解答关于网页抓取的许多常见问题和误解,同时提供关于大多数常见网页抓取任务的全面指南。

网页抓取是一个多样化且快速变化的领域,我尝试提供高层次的概念和具体示例,以涵盖您可能会遇到的任何数据收集项目。在整本书中,提供了代码示例来演示这些概念,并允许您尝试它们。这些代码示例可以在GitHub上查看和下载。

什么是网页抓取?

从互联网自动收集数据的行为几乎与互联网本身同样古老。尽管网页抓取不是一个新术语,但在过去的几年中,这种做法更常被称为屏幕抓取数据挖掘网页采集或类似变体。今天的普遍共识似乎更偏向于网页抓取,因此这是我在整本书中使用的术语,尽管我还会将特定遍历多个页面的程序称为网络爬虫,或称网页抓取程序本身为机器人

理论上,网页抓取是通过除了程序与 API 交互之外的任何手段来收集数据的实践(或者显然地,通过人类使用网络浏览器)。这通常通过编写一个自动化程序来实现,该程序查询 Web 服务器,请求数据(通常以 HTML 和其他组成网页的文件形式),然后解析该数据以提取所需信息。

在实践中,网络爬取涵盖了各种编程技术和技术,如数据分析、自然语言解析和信息安全。由于这个领域的范围非常广泛,本书在第 I 部分涵盖了网络爬取和爬行的基础知识,并在第 II 部分深入探讨了高级主题。我建议所有读者仔细研究第一部分,并根据需要深入研究第二部分中更具体的内容。

为什么要进行网络爬取?

如果你的唯一上网方式是通过浏览器,那么你将错过许多可能性。虽然浏览器很方便执行 JavaScript、显示图像和以更具人类可读格式排列对象(等等),但网络爬虫在快速收集和处理大量数据方面表现出色。与通过监视器的狭窄窗口一次查看一页不同,你可以一次查看跨越数千甚至数百万页的数据库。

此外,网络爬虫可以访问传统搜索引擎无法到达的地方。在谷歌搜索“到波士顿的最便宜航班”将会得到大量广告和热门航班搜索网站。谷歌只知道这些网站在其内容页面上说了些什么,而不知道各种查询输入到航班搜索应用程序中的确切结果。然而,一个成熟的网络爬虫可以跟踪波士顿航班的价格变化,跨越多个网站,并告诉你购买机票的最佳时间。

你可能会问:“难道数据收集不是 API 的用武之地吗?”(如果你对 API 不熟悉,请参见第十五章。)嗯,如果你找到一个适合你目的的 API,API 确实很棒。它们旨在提供一种方便的方式,从一个计算机程序向另一个计算机程序传送格式良好的数据流。你可以找到许多类型的数据的 API,比如 Twitter 帖子或维基百科页面。总体而言,最好使用 API(如果存在),而不是构建一个获取相同数据的机器人。然而,由于多种原因,API 可能不存在或对你的目的无用:

  • 你正在收集跨多个网站的相对小、有限数据集,而没有一个统一的 API。

  • 你需要的数据相对较少或不常见,创建者认为不值得提供一个 API。

  • 源没有建立 API 所需的基础设施或技术能力。

  • 数据是有价值和/或受保护的,不打算广泛传播。

即使 API 存在,它提供的请求量和速率限制、数据类型或数据格式可能不足以满足你的目的。

这就是网络爬取的用武之地。除了少数例外,如果你能在浏览器中查看数据,你就可以通过 Python 脚本访问它。如果你可以在脚本中访问它,你就可以将它存储到数据库中。如果你可以将其存储到数据库中,你几乎可以对这些数据进行任何操作。

显然,几乎无限数据访问的许多极其实用的应用程序存在:市场预测,机器语言翻译,甚至医学诊断都极大地受益于能够从新闻网站,翻译文本和健康论坛中检索和分析数据的能力。

即使在艺术世界中,网络抓取也为创作开辟了新的领域。2006 年的项目 “We Feel Fine” 由 Jonathan Harris 和 Sep Kamvar 对许多英语博客网站进行了抓取,以寻找以 “I feel” 或 “I am feeling” 开头的短语。这导致了一种流行的数据可视化,描述了世界每天和每分钟的感受。

无论你从事哪个领域,几乎总能通过网络抓取更有效地指导业务实践,提高生产力,甚至拓展到全新的领域。

关于本书

本书旨在不仅作为网络抓取的介绍,还作为收集、转换和使用来自不合作来源的数据的全面指南。尽管它使用 Python 编程语言并涵盖了许多 Python 基础知识,但不应作为语言入门书籍使用。

如果你完全不懂 Python,这本书可能有些难度,请不要将其作为 Python 入门书籍。话虽如此,我尽力将所有概念和代码样例保持在初中级 Python 编程水平,以使内容对广大读者都具有可访问性。为此,在适当的情况下,偶尔解释更高级的 Python 编程和通用计算机科学主题。如果你是一个更高级的读者,可以随意略过这些部分!

如果你正在寻找更全面的 Python 资源,介绍 Python 由 Bill Lubanovic(O’Reilly)编写的这本指南是一个不错的选择,尽管有些冗长。对于注意力较短的人来说,Jessica McKellar(O’Reilly)的视频系列 Python 入门 是一个极好的资源。我也很喜欢前任教授 Allen Downey(O’Reilly)的 Think Python。尤其是最后一本书非常适合初学者,它教授 Python 语言以及计算机科学和软件工程概念。

技术书籍通常专注于单一的语言或技术,但网络抓取是一个相对分散的主题,其实践要求使用数据库,Web 服务器,HTTP,HTML,Internet 安全,图像处理,数据科学和其他工具。本书试图从“数据收集”的角度覆盖所有这些内容和其他主题。尽管它不应作为任何这些主题的完整处理,但我相信它们的详细覆盖足以帮助你开始编写网络抓取器!

第一部分深入讨论了网页抓取和网络爬虫的主题,并且重点关注本书中使用的少数几个库。第一部分可以作为这些库和技术的全面参考(某些例外情况下,将提供额外参考)。第一部分教授的技能对于所有编写网页抓取器的人都可能很有用,无论他们的特定目标或应用程序如何。

第二部分涵盖了读者在编写网络爬虫时可能会发现有用的其他主题,但这些主题并非始终对所有爬虫都有用。遗憾的是,这些主题过于广泛,无法完全在单一章节中详细讨论。因此,经常会引用其他资源以获取更多信息。

本书的结构使您能够轻松跳转到章节中寻找您寻找的网页抓取技术或信息。当一个概念或代码片段建立在先前章节提到的另一个上时,我明确引用了它所在的章节。

本书使用的约定

本书使用以下排版约定:

Italic

指示新术语、URL、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序清单,以及段落中用于引用程序元素(如变量或函数名称、数据库、数据类型、环境变量、语句和关键字)。

Constant width bold

显示用户应输入的命令或其他文本。

Constant width italic

显示应由用户提供的值或由上下文确定的值。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可在https://github.com/REMitchell/python-scraping下载。

本书旨在帮助您完成工作。如果本书中的示例代码对您有用,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需联系我们寻求许可。例如,编写一个使用本书中几个代码块的程序不需要许可。出售或分发包含 O’Reilly 书籍示例的 CD-ROM 需要许可。通过引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码合并到您产品的文档中需要许可。

我们感激但不要求署名。通常,署名包括标题、作者、出版商和 ISBN。例如:“Python 网络抓取, 第三版, 作者 Ryan Mitchell (O’Reilly). 版权 2024 Ryan Mitchell, 978-1-098-14535-4.”

如果您认为您对代码示例的使用超出了公平使用或此处授予的权限,请随时通过permissions@oreilly.com联系我们。

不幸的是,印刷书籍很难保持最新。通过网络抓取,这增加了一个挑战,因为书中引用的许多库和网站以及代码常常依赖的内容可能会偶尔发生更改,导致代码示例可能失败或产生意外结果。如果您选择运行代码示例,请从 GitHub 仓库运行而不是直接从书本复制。我和选择贡献的本书读者(包括您也可能是其中之一!)将努力保持仓库与所需的修改同步更新。

除了代码示例外,通常还提供终端命令以说明如何安装和运行软件。总体而言,这些命令是针对基于 Linux 的操作系统设计的,但通常也适用于具有正确配置的 Python 环境和 pip 安装的 Windows 用户。当情况不同时,我提供了所有主要操作系统的说明,或者为 Windows 用户提供了外部参考来完成任务。

O’Reilly Online Learning

注意

超过 40 年来,O’Reilly Media为技术和业务培训提供了知识和洞察,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章以及我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供即时访问的培训课程、深入的学习路径、交互式编码环境以及来自 O’Reilly 和 200 多家其他出版商的大量文本和视频。更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题寄给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969 (在美国或加拿大)

  • 707-829-7019 (国际或本地)

  • 707-829-0104 (传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.html

我们为本书设有网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/web_scraping_with_python

关于我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上观看我们:https://youtube.com/oreillymedia

致谢

正如一些最好的产品是源自用户反馈的大海中一样,这本书没有多少有用的形式是没有许多合作者、助威者和编辑的帮助而存在的。感谢 O’Reilly 的员工和他们对这个有些不寻常主题的惊人支持;感谢我的朋友和家人提供的建议并容忍我突然的读书;感谢我在 Gerson Lehrman Group 的同事们,我现在可能欠他们很多工作时间。

感谢我的编辑们:Sara Hunter、John Obelenus 和 Tracey Larvenz。他们的反馈、指导和偶尔的严厉批评是无价的。正是由于他们的建议,有很多章节和代码示例是直接写出来的。

第一章和第二章的灵感,以及第三版中许多新内容的包含,要感谢 Bryan Specht。他留下的遗产比他自己所知的还要广泛深远,但他留下的空缺需要那份遗产填补得更加广大。

最后,感谢 Jim Waldo,他多年前启动了整个项目,当时他邮寄了一台 Linux 机和 Eric Roberts(Addison-Wesley 出版社)的《C 语言的艺术与科学》给一个年轻而受影响的青少年。

第一部分:构建爬虫

本书的第一部分侧重于网络爬虫的基本原理:如何使用 Python 从 Web 服务器请求信息,如何对服务器的响应进行基本处理,以及如何开始以自动化方式与网站进行交互。到最后,你将轻松地在互联网上巡游,构建可以从一个域跳转到另一个域,收集信息并将信息存储以供以后使用的爬虫。

说实话,如果你想以相对较少的前期投入获得巨额回报,网络爬虫是一个很棒的领域。很可能你会遇到的 90%的网络爬虫项目都会在接下来的 6 章中使用的技术。本节涵盖了当一般(尽管技术精湛)公众想到“网络爬虫”时通常会考虑的内容:

  • 从域名检索 HTML 数据

  • 解析该数据以获取目标信息

  • 存储目标信息

  • 可选地,转到另一页以重复该过程

在转向第二部分中更复杂的项目之前,这将为你打下坚实的基础。不要被误导,认为这一部分不像第二部分中的一些更高级的项目那样重要。在编写网络爬虫时,你几乎每天都会使用本书前半部分的所有信息!

第一章:如何运作互联网

在我生命中,我很少遇到真正了解互联网运作原理的人,而我肯定不是其中之一。

我们大多数人都在利用一套精神抽象来使用互联网,这些抽象使我们能够如需求一般使用它。即使对于程序员来说,这些抽象可能只涉及到解决一次特别棘手问题所需的内容。

由于页数限制和作者知识的局限性,这一章也必须依赖这些抽象。它描述了互联网和 Web 应用程序的运作机制,以足够的深度来进行网页抓取(也许还要多一点)。

这一章在某种程度上描述了网络爬虫运行的世界:本书中将反复讨论的习俗、惯例、协议和标准。

当你在网页浏览器的地址栏中输入 URL 并按下回车时,互动文本、图像和媒体如魔法般地出现。每天数十亿人都在经历同样的魔法。他们访问相同的网站,使用相同的应用程序,通常获取专门为他们定制的媒体和文本内容。

而这数十亿人使用不同类型的设备和软件应用程序,这些设备和应用程序由不同公司的不同开发者编写(通常是竞争对手!)。

令人惊讶的是,没有一个全能的管理机构以法律强制力协调互联网的发展。相反,互联网的不同部分由不同的组织管理,这些组织随着时间的推移以一种相对特别的和自愿的方式进化。

当然,选择参与这些组织发布的标准可能会导致您在互联网上的贡献根本无法使用。如果您的网站无法在流行的网页浏览器中显示,人们可能就不会访问它。如果您的路由器发送的数据无法被其他路由器解释,那么这些数据将被忽略。

网页抓取本质上是将一个自己设计的应用程序替换为网络浏览器的实践。因此,了解构建在 Web 浏览器之上的标准和框架至关重要。作为一个网络爬虫,你必须模仿并有时颠覆预期的互联网习俗和惯例。

网络

在电话系统的早期,每部电话通过一根物理电线连接到中央交换机。如果你想给附近的朋友打电话,你拿起电话,请求接线员连接你,接线员会通过插座和插孔物理地为你的电话和朋友的电话创建一个专用连接。

远程通话费用昂贵且可能需要几分钟才能连接。从波士顿打电话到西雅图将导致美国各地交换机操作员协调创建一个直接连接您的电话到接收者的巨大电缆。

如今,我们不再通过临时的专用连接打电话,而是可以从我们家通过持久的电缆网络向世界任何地方进行视频通话。电线不告诉数据要去哪里,数据自己引导,这个过程称为分组交换。尽管多年来的许多技术都为我们所谓的“互联网”做出了贡献,但分组交换确实是唯一一种开启这一切的技术。

在分组交换网络中,要发送的消息被分成离散的有序分组,每个分组都有自己的发送者和目的地地址。这些分组根据该地址动态路由到网络上的任何目标。与被迫盲目穿越从接收者到发送者的单一专用连接不同,分组可以沿着网络选择的任何路径传输。事实上,同一消息传输中的分组可能会在网络中采取不同的路径,并在到达接收计算机时被重新排序。

如果旧的电话网络就像滑索——从山顶的单一目的地到山脚的单一目的地运送乘客——那么分组交换网络就像是高速公路系统,多个目的地的汽车都能使用相同的道路。

现代分组交换网络通常使用开放系统互联模型(OSI 模型)来描述,该模型由七层路由、编码和错误处理组成:

  1. 物理层

  2. 数据链路层

  3. 网络层

  4. 传输层

  5. 会话层

  6. 展示层

  7. 应用层

大多数 Web 应用程序开发者在第 7 层,即应用层中度过他们的一天。这也是本书中花费最多时间的层次。然而,当进行网络爬取时,了解其他层至少具有概念性知识是很重要的。例如,传输层中讨论的 TLS 指纹识别是一种网络爬取检测方法。

此外,了解数据封装和传输的所有层可以帮助排查网络应用程序和网络爬虫中的错误。

物理层

物理层 指定了信息如何通过你家的以太网电线(或任何局域网)以电流物理传输。它定义了诸如编码 1 和 0 的电压级别,以及这些电压可以脉冲的速度。它还定义了如何解释蓝牙和 WiFi 上的无线电波。

这一层不涉及任何编程或数字指令,而完全基于物理和电气标准。

数据链路层

数据链路层 指定了在本地网络中两个节点之间如何传输信息,例如,在你的计算机和路由器之间。它定义了单个传输的开始和结束,并提供了如果传输丢失或混乱时的纠错功能。

在这一层,数据包被包裹在一个额外的“数字信封”中,其中包含路由信息,并被称为 。当帧中的信息不再需要时,它会被解开并作为数据包发送到网络上。

值得注意的是,在数据链路层,网络上的所有设备始终接收相同的数据—没有实际的“交换”或对数据流向的控制。但是,数据未被寻址到的设备通常会忽略数据,并等待直到它们收到发送给它们的数据为止。

网络层

网络层 是数据包交换和因此“互联网”发生的地方。这一层允许你计算机上的数据包被路由器转发并达到其直接网络之外的设备。

网络层涉及传输控制协议/互联网协议(TCP/IP)的互联网协议(IP)部分。IP 是我们获取 IP 地址的地方。例如,我在全球互联网上的 IP 地址目前是 173.48.178.92。这允许世界上的任何计算机向我发送数据,并且我可以从自己的地址发送数据到任何其他地址。

传输层

第 4 层,传输层,关注的是将运行在一台计算机上的特定服务或应用程序连接到另一台计算机上运行的特定应用程序,而不仅仅是连接计算机本身。它还负责在数据流中进行任何必要的错误校正或重试。

例如,TCP 非常挑剔,会一直请求任何丢失的数据包,直到所有数据包都正确接收。TCP 常用于文件传输,其中所有数据包必须以正确的顺序正确接收才能使文件正常工作。

相比之下,用户数据报协议(UDP)将乐意跳过丢失的数据包以保持数据流畅进行。它通常用于视频会议或音频会议,其中临时的传输质量下降比对话延迟更可取。

因为你计算机上的不同应用程序可以同时具有不同的数据可靠性需求(例如,在下载文件的同时进行电话呼叫),所以传输层也是端口号的用武之地。操作系统将运行在计算机上的每个应用程序或服务分配给一个特定的端口,从这个端口发送和接收数据。

这个端口通常在 IP 地址之后用冒号分隔的数字来表示。例如,71.245.238.173:8080 表示由操作系统分配给 IP 地址 71.245.238.173 的网络上端口 8080 的应用程序。

会话层

会话层负责在两个应用程序之间打开和关闭会话。此会话允许关于已发送和未发送数据的有状态信息,并且明确计算机正在与谁通信。会话通常保持打开状态,直到完成数据请求,然后关闭。

会话层允许在短暂崩溃或断开连接的情况下重试传输。

会话与会话

OSI 模型的会话层中的会话与 Web 开发人员通常谈论的会话和会话数据是不同的。Web 应用程序中的会话变量是应用程序层的概念,由 Web 浏览器软件实现。

会话变量在应用程序层中会在浏览器中保留,直到它们不再需要或者用户关闭浏览器窗口。在 OSI 模型的会话层中,会话通常只持续足以传输单个文件的时间!

演示层

演示层将输入数据从字符字符串转换为应用程序可以理解和使用的格式。它还负责字符编码和数据压缩。演示层关心应用程序接收到的输入数据是否表示 PNG 文件还是 HTML 文件,并相应地将此文件传递给应用程序层。

应用程序层

应用程序层解释由演示层编码的数据并适当地使用它。我认为演示层负责转换和识别事物,而应用程序层负责“做”事情。例如,HTTP 及其方法和状态是应用程序层协议。更乏味的 JSON 和 HTML(因为它们是定义数据编码方式的文件类型)是演示层协议。

HTML

Web 浏览器的主要功能是显示 HTML(超文本标记语言)文档。HTML 文档是以.html或者较少见的.htm结尾的文件。

像文本文件一样,HTML 文件使用纯文本字符编码,通常是 ASCII(参见“文本编码和全球互联网”)。这意味着它们可以用任何文本编辑器打开和阅读。

这是一个简单 HTML 文件的例子:

<html>
  <head>
    <title>A Simple Webpage</title>
  </head>
  <body>
    <!-- This comment text is not displayed in the browser -->
    <h1>Hello, World!</h1>
  </body>
</html> 

HTML 文件是一种特殊类型的 XML(可扩展标记语言)文件。每个以<开头且以>结尾的字符串称为标签

XML 标准定义了开放或起始标签(如<html>)和以</开头的结束标签,如</html>的概念。在起始和结束标签之间是标签的内容

在不需要标签有任何内容的情况下,您可能会看到一个标签,它充当自己的闭合标签。这称为空元素标签或自闭合标签,看起来像:

<p />

标签也可以具有attributeKey="attribute value"形式的属性,例如:

<div class="content">
  Lorem ipsum dolor sit amet, consectetur adipiscing elit
</div>

在这里,div标签具有class属性,其值为main-content

HTML 元素具有起始标记和一些可选属性、一些内容以及结束标记。元素还可以包含多个其他元素,此时它们是嵌套元素。

虽然 XML 定义了标签、内容、属性和值的基本概念,但 HTML 定义了这些标签可以和不能是什么,它们可以和不能包含什么,以及它们必须如何由浏览器解释和显示。

例如,HTML 标准定义了class 属性id 属性的使用,这些属性通常用于组织和控制 HTML 元素的显示:

<h1 id="main-title">Some Title</h1>
<div class="content">
  Lorem ipsum dolor sit amet, consectetur adipiscing elit
</div>

作为规则,页面上的多个元素可以包含相同的class值;然而,id字段中的任何值在该页面上必须是唯一的。因此,多个元素可以具有class content,但只能有一个元素具有id main-title

HTML 文档中元素在 Web 浏览器中的显示完全取决于作为软件的 Web 浏览器如何编程。如果一个 Web 浏览器被编程为以不同于另一个 Web 浏览器的方式显示元素,这将导致不同 Web 浏览器用户的不一致体验。

因此,协调 HTML 标签的确切功能并将其编码为单一标准非常重要。HTML 标准目前由万维网联盟(W3C)控制。所有 HTML 标签的当前规范可以在https://html.spec.whatwg.org/multipage/找到。

然而,正式的 W3C HTML 标准可能不是学习 HTML 的最佳地方,如果你以前从未遇到过它。网页抓取的一个重要部分涉及阅读和解释在 Web 上找到的原始 HTML 文件。如果你以前没有处理过 HTML,我强烈推荐像HTML & CSS: The Good Parts这样的书籍来熟悉一些更常见的 HTML 标签。

CSS

层叠样式表(CSS)定义了 Web 页面上 HTML 元素的外观。CSS 定义了布局、颜色、位置、大小以及其他属性,这些属性将无聊的具有浏览器定义默认样式的 HTML 页面转变为对现代 Web 观众更具吸引力的内容。

使用之前的 HTML 示例:

<html>
  <head>
    <title>A Simple Webpage</title>
  </head>
  <body>
    <!-- This comment text is not displayed in the browser -->
    <h1>Hello, World!</h1>
  </body>
</html> 

一些相应的 CSS 可能是:

h1 {
  font-size: 20px;
  color: green;
}

此 CSS 将设置h1标签的内容字体大小为 20 像素,并以绿色文本显示。

此 CSS 中的h1部分称为选择器或 CSS 选择器。此 CSS 选择器指示大括号内的 CSS 将应用于任何h1标签的内容。

CSS 选择器也可以写成仅适用于具有特定classid属性的元素。例如,使用以下 HTML:

<h1 id="main-title">Some Title</h1>
<div class="content">
  Lorem ipsum dolor sit amet, consectetur adipiscing elit
</div>

可能是相应的 CSS:

h1#main-title {
  font-size: 20px;
}

div.content {
  color: green;
}

#用于指示id属性的值,.用于指示class属性的值。

如果标记的值不重要,可以完全省略标记名称。例如,这个 CSS 将使具有类 content 的任何元素的内容变绿:

.content {
  color: green;
}

CSS 数据可以包含在 HTML 本身中,也可以包含在具有 .css 文件扩展名的单独 CSS 文件中。HTML 文件中的 CSS 放置在 HTML 文档的 head 中的<style>标记内:

<html>
  <head>
    <style>
      .content {
        color: green;
      }
    </style>
...

更常见的是,在文档的 head 中使用link标记导入 CSS:

<html>
  <head>
  <link rel="stylesheet" href="mystyle.css">
...

作为网络爬虫,您不太可能经常编写样式表来使 HTML 美观。但是,重要的是要能够阅读并识别 HTML 页面如何被 CSS 转换,以便将您在 Web 浏览器中看到的内容与代码中看到的内容相关联。

例如,当一个 HTML 元素不出现在页面上时,您可能会感到困惑。当您阅读元素的应用 CSS 时,您会看到:

.mystery-element {
    display: none;
}

这将元素的display属性设置为none,从页面中隐藏它。

如果您以前从未遇到过 CSS,则可能不需要深入研究它以便进行 Web 抓取,但是您应该熟悉其语法并注意本书中提到的 CSS 规则。

JavaScript

当客户端请求 Web 服务器的特定 Web 页面时,Web 服务器会执行一些代码以创建发送回的 Web 页面。这些代码称为服务器端代码,可以简单到检索静态 HTML 文件并将其发送出去。或者,它可以是用 Python(最好的语言)、Java、PHP 或任何常见的服务器端编程语言编写的复杂应用程序。

最终,这些服务器端代码会创建一些数据流,该数据流被发送到浏览器并显示出来。但是,如果您希望某种类型的交互或行为(例如文本更改或拖放元素)发生而无需返回服务器运行更多代码,该怎么办?为此,您使用客户端代码

客户端代码是由 Web 服务器发送的任何代码,但实际上由客户端的浏览器执行。在互联网的早期(2000 年中期之前),客户端代码是用多种语言编写的。例如,您可能还记得 Java 小程序和 Flash 应用程序。但 JavaScript 出现为客户端代码提供了唯一的选择,原因很简单:它是浏览器本身支持的唯一语言,无需下载和更新单独的软件(如 Adobe Flash Player)即可运行程序。

JavaScript 在 90 年代中期作为 Netscape Navigator 的新功能而起源。它很快被 Internet Explorer 采纳,使其成为当时两个主要 Web 浏览器的标准。

尽管名字一样,JavaScript 几乎与服务器端编程语言 Java 毫无关系。除了一小部分表面上的语法相似之外,它们是极不相似的语言。

1996 年,Netscape(JavaScript 的创建者)和 Sun Microsystems(Java 的创建者)达成了许可协议,允许 Netscape 使用名称“JavaScript”,预计会有进一步的合作(两种语言之间)。然而,这种合作从未发生过,自那时以来一直是一个令人困惑的误称。

尽管它作为一个现在已不复存在的网页浏览器的脚本语言有一个不确定的开始,但 JavaScript 现在是世界上最流行的编程语言。这种流行程度得益于它还可以在服务器端使用,使用 Node.js。但它的流行无疑是因为它是唯一的客户端编程语言。

JavaScript 被嵌入到 HTML 页面中使用 <script> 标签。JavaScript 代码可以作为内容插入:

<script>
  alert('Hello, world!');
</script>

或者可以使用 src 属性在单独的文件中引用:

<script src="someprogram.js"></script>

与 HTML 和 CSS 不同,您在抓取网页时可能不需要阅读或编写 JavaScript,但至少了解其外观是很有用的。有时它可能包含有用的数据。例如:

<script>
  const data = '{"some": 1, "data": 2, "here": 3}';
</script>

在这里,一个 JavaScript 变量正在用关键字 const(代表“常量”)声明,并被设置为包含一些数据的 JSON 格式字符串,这些数据可以直接由网页抓取器解析。

JSON(JavaScript 对象表示法)是一种文本格式,包含易于解析的人类可读数据,在网页上无处不在。我将在第十五章进一步讨论它。

您可能也会看到 JavaScript 向完全不同的源请求数据:

<script>
  fetch('http://example.com/data.json')
    .then((response) => {
      console.log(response.json());
    });
</script>

在这里,JavaScript 正在创建一个请求到 http://example.com/data.json,并在接收到响应后将其记录到控制台中(有关“控制台”的更多信息,请参阅下一节)。

JavaScript 最初被创建为在否则静态的网页中提供动态互动和动画。然而,今天,并不是所有的动态行为都是由 JavaScript 创建的。HTML 和 CSS 也具有一些功能,允许它们在页面上改变内容。

例如,CSS 关键帧动画可以使元素在用户点击或悬停在元素上时移动、改变颜色、改变大小或进行其他转换。

理解网站(通常字面上的)如何组合起来可以帮助您在尝试定位数据时避免走了弯路。

使用开发者工具观察网站

像珠宝商的放大镜或心脏病医生的听诊器一样,您的浏览器的开发者工具对于网页抓取至关重要。要从网站收集数据,您必须了解它的构建方式。开发者工具正是为您展示这一切的。

在本书中,我将展示在 Google Chrome 中使用开发者工具。然而,Firefox、Microsoft Edge 和其他浏览器中的开发者工具都非常相似。

要访问浏览器菜单中的开发者工具,请按照以下说明进行操作:

Chrome

视图 → 开发者工具 → 开发者工具

Safari

Safari → 首选项 → 高级 → 勾选“在菜单栏中显示开发”菜单

接下来,使用开发菜单:开发 → 显示网页检查器

Microsoft Edge

使用菜单:工具 → 开发者 → 开发者工具

Firefox

工具 → 浏览器工具 → Web 开发者工具

在所有浏览器中,打开开发者工具的键盘快捷键都是相同的,具体取决于您的操作系统。

Mac

Option + Command + I

Windows

CTRL + Shift + I

在进行网页抓取时,你可能会大部分时间都在网络选项卡(如图 1-1 所示)和“Elements”选项卡中度过。

图 1-1. Chrome 开发者工具显示从维基百科加载的页面

网络选项卡显示页面加载过程中发出的所有请求。如果你以前没有使用过,可能会感到惊讶!复杂页面加载时常常会发出数十甚至数百个资源请求。有时,页面甚至会在您访问期间持续发送请求。例如,它们可能会向行动追踪软件发送数据,或者轮询更新。

在网络选项卡中看不到任何内容吗?

注意,开发者工具必须在页面发出请求时打开才能捕获这些请求。如果您加载页面时未打开开发者选项卡,然后决定通过打开开发者工具进行检查,可能需要刷新页面以重新加载并查看它正在发出的请求。

如果你在网络选项卡中点击单个网络请求,你会看到与该请求相关的所有数据。这个网络请求检查工具的布局在不同的浏览器中略有不同,但通常允许你查看:

  • 请求发送的 URL

  • 使用的 HTTP 方法

  • 响应状态

  • 与请求相关的所有标头和 Cookie

  • 负载

  • 响应

这些信息对于编写网页抓取器以复制这些请求以获取相同数据是有用的。

“Elements”选项卡(参见图 1-2 和 图 1-3)用于检查 HTML 文件的结构和内容。它非常方便,可以检查页面上特定数据的 HTML 标签,以便编写网页抓取器抓取这些数据。

当你在“Elements”选项卡上悬停在每个 HTML 元素的文本上时,你会在浏览器中看到相应的页面元素被视觉高亮显示。使用这个工具是探索页面并深入理解页面构建方式的好方法(参见图 1-3)。

图 1-2. 右键单击任何文本或数据,并选择“检查”以查看“Elements”选项卡中围绕该数据的元素

图 1-3。在 HTML 中悬停在元素上将会突出显示页面上对应的结构。

你不需要成为互联网、网络甚至编程方面的专家就能开始网络爬虫。然而,对于这些组件如何组合以及你的浏览器的开发者工具如何显示这些组件有基本的了解是必不可少的。

第二章:网络抓取的法律和伦理问题

2010 年,软件工程师 Pete Warden 构建了一个网络爬虫,从 Facebook 收集数据。他从大约 2 亿名 Facebook 用户那里收集了数据——姓名、位置信息、朋友和兴趣。当然,Facebook 注意到了并向他发送了停止和弃权信,他遵守了。当被问及为什么遵守停止和弃权时,他说:“大数据?便宜。律师?不便宜。”

在本章中,您将了解与网络抓取相关的美国法律(以及一些国际法律),并学习如何分析给定网络抓取情况的合法性和伦理性。

在您阅读以下章节之前,请考虑显而易见的事实:我是一名软件工程师,而不是律师。不要将您在本书的任何章节中或其他地方阅读到的内容解释为专业法律建议或据此采取行动。尽管我相信我能够就网络抓取的法律和伦理问题进行讨论,但在进行任何法律上含糊不清的网络抓取项目之前,您应咨询律师(而不是软件工程师)。

本章的目标是为您提供一个框架,以便能够理解和讨论网络抓取法律问题的各个方面,如知识产权、未经授权的计算机访问和服务器使用,但这不应替代实际的法律建议。

商标、版权、专利,哦,我的天啊!

是时候进行一次知识产权的速成课程了!知识产权基本上分为三种类型:商标(由™或®符号指示)、版权(普遍的©)和专利(有时由注明发明受专利保护的文本或专利号表示,但通常什么也没有)。

专利仅用于宣告对发明的所有权。您不能为图像、文字或任何信息本身申请专利。尽管某些专利,例如软件专利,比我们所想的“发明”更不具体,但请记住,专利的是东西(或技术)而不是组成软件的数据本身。除非您从抓取的图表中构建事物,或者有人对网络抓取方法申请专利,否则您不太可能通过抓取网络无意中侵犯专利。

商标也不太可能成为问题,但仍然必须考虑。根据美国专利商标局:

商标是指一个词语、短语、符号和/或设计,用于识别和区分一方商品的来源与其他方的商品。服务商标是指一个词语、短语、符号和/或设计,用于识别和区分服务的来源而不是商品。术语“商标”通常用来指代商标和服务商标。

除了商标常见的文字和符号之外,其他描述性属性也可以注册商标。例如,容器的形状(如可口可乐瓶)甚至一种颜色(最著名的是欧文康宁公司的粉红色豹王玻璃纤维隔热材料)。

与专利不同,商标的所有权在很大程度上取决于其使用的上下文。例如,如果我希望发布一篇博客文章,并附带一张带有可口可乐标志的图片,只要我不暗示我的博客文章是由可口可乐赞助或发布的,我是可以这样做的。如果我想要生产一种新的软饮料,并在包装上展示相同的可口可乐标志,那显然就是商标侵权。同样地,虽然我可以将我的新软饮料包装成粉红色,但我不能使用相同的颜色来制造家用隔热材料。

这将我们带到了“公平使用”这个话题,这通常是在版权法的背景下讨论的,但也适用于商标。存储或展示商标以参考其代表的品牌是可以的。以可能误导消费者的方式使用商标是不行的。“公平使用”的概念不适用于专利。例如,一个行业中的专利发明不能在没有与专利持有人达成协议的情况下应用到另一个行业。

版权法

商标和专利有一个共同点,那就是它们必须经过正式注册才能被认可。与普遍认为的不同,版权材料并不需要这样做。什么使图像、文本、音乐等成为版权材料呢?并不是页面底部的“保留所有权利”警告或者关于“已出版”与“未出版”材料的任何特殊性质。你创作的每一件材料一旦存在,都会自动受到版权法的保护。

《关于文学和艺术作品保护的伯尔尼公约》以瑞士伯尔尼为名,于 1886 年首次通过,是版权的国际标准。该公约本质上规定,所有成员国必须承认其他成员国公民的作品的版权保护,就像它们是自己国家的公民一样。实际上,这意味着,作为美国公民,你在美国可以因侵犯来自法国等其他国家某人的版权而受到起诉(反之亦然)。

版权登记

尽管版权保护自动适用且无需任何注册,但也可以向美国政府正式注册版权。这通常用于有价值的创意作品,如主要电影,以便稍后进行诉讼时更容易,并为所有权创建强大的书面记录。然而,请不要让版权登记的存在使您困惑——除非特别属于公共领域,否则所有创意作品均受版权保护!

显然,版权对网络爬虫的关注要大于商标或专利。如果我从某人的博客中抓取内容并发布到我的博客上,我很可能会面临诉讼。幸运的是,我有几层保护措施,这可能使我的博客抓取项目具有防御性,具体取决于其功能。

首先,版权保护仅适用于创意作品。它不涵盖统计数据或事实。幸运的是,网络爬虫所追求的大部分内容确实是统计数据和事实。

从网络中收集诗歌并在您自己的网站上显示的网络爬虫可能会侵犯版权法;然而,从时间上看收集诗歌发布频率信息的网络爬虫则不会。诗歌本身是创造性作品。按月发布在网站上的诗歌的平均字数是事实数据,而不是创造性作品。

发布原文(而不是从原始抓取数据的聚合/计算内容)可能不违反版权法,如果数据是价格、公司高管姓名或其他一些事实信息。

即使版权内容也可以在合理范围内根据 1988 年数字千禧年版权法直接使用。 DMCA 概述了有关自动处理版权材料的一些规则。 DMCA 很长,具有许多具体规则,涵盖从电子书到电话的一切。但是,两个主要点可能特别与网络爬虫相关:

  • 根据“安全港”保护,如果您从一个您被引导相信只包含免版权材料的来源中抓取材料,但用户已提交版权材料,则只要在收到通知后删除版权材料,您就受到保护。

  • 您不能规避安全措施(如密码保护)以收集内容。

此外,DMCA 还承认 17 U.S. Code § 107 下的合理使用,并且如果使用版权材料符合合理使用,则根据安全港保护不会发出撤销通知。

简而言之,你绝不应直接发布未经原作者或版权持有人许可的受版权保护的材料。如果你正在将你可以自由访问的受版权保护材料存储在自己的非公开数据库中以进行分析,则可以。如果你将该数据库发布到你的网站以供查看或下载,则不行。如果你正在分析该数据库并发布有关字数统计、作者按创作产量排序的列表或其他数据的元分析,则可以。如果你将该元分析与少量选择的引用或数据简短样本配合使用以阐明观点,则可能也可以,但你可能需要查阅美国法典中的公平使用条款以确认。

版权与人工智能

基于现有创意作品语料库生成新的“创意”作品的生成人工智能程序,给版权法带来独特的挑战。

如果生成 AI 程序的输出类似于现有作品,则可能存在版权问题。已有许多案例作为先例,以指导“类似”在这里的定义,但根据国会研究服务[¹]:

实质相似性测试很难定义,并且在美国法院中各不相同。法院曾经以不同方式描述该测试,例如要求作品具有“基本相似的总体概念和感觉”或“总体外观和感觉”,或者要求“普通合理人无法区分两个作品”。

现代复杂算法的问题在于,很难自动确定你的 AI 是否产生了令人兴奋且新颖的混搭,还是更直接的衍生物。AI 可能无法将其输出标记为与特定输入“基本相似”,甚至无法识别其用于生成创作的哪些输入!表明任何事情出错的第一个迹象可能是一封停止和停止信函或法庭传票。

除了关于生成 AI 输出的版权侵权问题外,即将进行的法院案件还测试了训练过程本身是否可能侵犯版权持有人的权利。

为了训练这些系统,几乎总是需要下载、存储和复制受版权保护的作品。虽然下载受版权保护的图像或文本似乎并不是什么大事,但这与下载受版权保护的电影并无多少区别——你不会下载电影,对吧?

有些人声称这构成了公平使用,并且他们并未以可能影响其市场的方式发布或使用内容。

我们撰写本文时,OpenAI 正在美国专利商标局争论其使用大量受版权保护的材料是否构成合理使用。² 虽然这一论点主要是在 AI 生成算法的背景下,我认为其结果将适用于各种目的构建的网络爬虫。

进入个人财产

侵入个人财产 与我们所认为的“侵入法”根本不同,因为它适用于不动产或土地,而适用于法律术语中的可移动财产或个人财产。当您的财产受到某种方式的干扰,无法访问或使用时,它适用。

在这个云计算时代,很容易不把网络服务器看作是真实的、有形的资源。然而,服务器不仅由昂贵的组件组成,而且需要存储、监控、冷却、清洁,并提供大量电力。据估计,全球电力使用量的 10%用于计算机。³ 如果您的电子设备调查不能说服您,考虑一下谷歌的大型服务器农场,所有这些都需要连接到大型发电站。

虽然服务器是昂贵的资源,但从法律的角度来看,它们很有趣,因为网站管理员通常希望人们使用他们的资源(即访问他们的网站);他们只是不希望他们过度使用他们的资源。通过浏览器查看网站是可以的;发起全面的分布式拒绝服务(DDOS)攻击显然是不可以的。

网络爬虫要违反侵入个人财产,需要满足三个标准:

缺乏同意

因为网络服务器对每个人开放,它们通常也“允许”网络爬虫。然而,许多网站的服务条款明确禁止使用爬虫。此外,任何发给您的停止和停止通知可能会撤销这种同意。

实际损害

服务器成本高昂。除了服务器成本外,如果您的爬虫使网站崩溃或限制其为其他用户提供服务的能力,这可能会增加您造成的“损害”。

故意性

如果你在写代码,你知道它的功能!在辩护你的网络爬虫时,争辩没有意图可能不会顺利。

您必须满足这三个标准,才能适用于个人财产的侵入。然而,如果您违反了服务条款协议,但并未造成实际损害,请不要认为自己免于法律行动。您很可能侵犯了版权法、DMCA、计算机欺诈与滥用法案(本章后面将详细介绍),或适用于网络爬虫的其他众多法律之一。

计算机欺诈与滥用法案

在 20 世纪 80 年代初,计算机开始从学术界进入商业界。这是第一次,病毒和蠕虫不再只被视为一种不便(甚至是一种有趣的爱好),而是作为可能造成经济损失的严重刑事问题。1983 年,由马修·布罗德里克主演的电影《战争游戏》也把这个问题带到了公众和总统罗纳德·里根的眼前。⁴ 作为对此的回应,1986 年制定了《计算机欺诈和滥用法》(CFAA)。

尽管你可能认为 CFAA 仅适用于典型的恶意黑客释放病毒的情况,但该法案对网络爬虫也有重要影响。想象一下一个扫描网页以寻找登录表单和易于猜测密码,或者收集意外泄露在隐藏但公共位置的政府机密的爬虫。所有这些活动在 CFAA 下都是非法的(而且理所当然)。

该法案定义了七种主要的刑事罪行,总结如下:

  • 未经授权访问美国政府拥有的计算机并从这些计算机获取信息。

  • 未经授权访问计算机并获取财务信息。

  • 未经授权访问美国政府拥有的计算机,影响政府对该计算机的使用。

  • 未经授权访问任何受保护计算机并企图欺诈。

  • 未经授权访问计算机并造成损害。

  • 共享或交易用于美国政府使用或影响跨州或国际贸易的计算机的密码或授权信息。

  • 通过造成损害或威胁造成损害来敲诈钱财或“任何有价物品”。

简而言之:远离受保护的计算机,不要访问未授权访问的计算机(包括 Web 服务器),尤其是远离政府或金融计算机。

robots.txt 和服务条款

从法律角度来看,网站的服务条款和 robots.txt 文件是一个有趣的领域。如果一个网站是公开访问的,网站管理员声明哪些软件可以访问它,哪些不能访问,这是一个有争议的问题。说“如果你使用浏览器查看该网站是可以的,但如果你使用自己编写的程序查看就不行”,这是一个棘手的问题。

大多数网站在每一页的页脚都有链接指向其服务条款(TOS)。TOS 不仅包含网络爬虫和自动访问的规则,通常还包括网站收集信息的类型及其处理方式,以及通常的免责声明,即网站提供的服务不带任何明示或暗示的保证。

如果您对搜索引擎优化(SEO)或搜索引擎技术感兴趣,您可能已经听说过robots.txt文件。如果您只需访问任何大型网站并查找其robots.txt文件,您将在根 Web 文件夹中找到它:http://website.com/robots.txt

robots.txt文件的语法是在 1994 年 Web 搜索引擎技术初期的繁荣期间开发的。当像 AltaVista 和 DogPile 这样的搜索引擎开始竞争整个互联网的搜索时,它们开始真正有意义地与由主题组织的简单网站列表竞争,比如由 Yahoo!策划的那种。互联网搜索的这种增长不仅意味着网络爬虫数量的激增,还意味着这些网络爬虫收集的信息对普通公民的可用性激增。

尽管我们今天可能认为这种可用性理所当然,但是当一些网站管理员发现他们发布在网站文件结构深处的信息出现在主要搜索引擎的搜索结果首页时,他们感到震惊。作为回应,开发了robots.txt文件的语法,称为机器人排除协议。

与服务条款不同,通常以广义且非常人性化的语言讨论网络爬虫,robots.txt可以非常容易地被自动化程序解析和使用。虽然它看起来可能是解决不受欢迎的机器人问题的完美系统,但请记住:

  • robots.txt的语法没有官方的管理机构。它是一个普遍使用且通常遵循的惯例,但没有任何东西能阻止任何人创建他们自己的robots.txt文件的版本(除非没有机器人认可或遵守它,直到它变得流行)。尽管如此,它是一个被广泛接受的惯例,主要是因为它相对简单,并且公司没有动力去发明自己的标准或试图改进它。

  • 没有法律或技术手段来强制执行robots.txt文件。它仅仅是一个标志,表示“请不要访问站点的这些部分”。许多网络爬虫库都能遵守robots.txt,尽管这通常是可以覆盖的默认设置。撇开库的默认设置,编写一个遵守robots.txt的网络爬虫实际上比完全忽略它更具技术挑战性。毕竟,您需要读取、解析和应用robots.txt的内容到您的代码逻辑中。

机器人排除协议的语法非常简单。与 Python(以及许多其他语言)一样,注释以#符号开头,以换行符结尾,并且可以在文件的任何位置使用。

除了任何注释之外,文件的第一行以User-agent:开头,指定规则适用于哪个用户。然后是一组规则,要么是Allow:要么是Disallow:,具体取决于该部分是否允许爬虫访问。星号(*)表示通配符,可用于描述User-agent或 URL。

如果一个规则遵循一个看似矛盾的规则,则最后的规则优先。例如:

#Welcome to my robots.txt file!
User-agent: *
Disallow: *

User-agent: Googlebot
Allow: *
Disallow: /private

在这种情况下,所有机器人都被禁止访问网站的任何地方,除了 Googlebot,该机器人可以访问除了/private目录之外的任何地方。

Twitter 的robots.txt文件(也被称为“X”)对 Google、Yahoo!、Yandex(一家知名的俄罗斯搜索引擎)、Microsoft 和其他未涵盖在前述类别中的机器人或搜索引擎的机器人都有明确的指示。谷歌部分(看起来与允许所有其他类别机器人的权限相同)如下所示:

#Google Search Engine Robot
User-agent: Googlebot
Allow: /?_escaped_fragment_

Allow: /?lang=
Allow: /hashtag/*?src=
Allow: /search?q=%23
Disallow: /search/realtime
Disallow: /search/users
Disallow: /search/*/grid

Disallow: /*?
Disallow: /*/followers
Disallow: /*/following

注意,Twitter 限制了其网站中具有 API 的部分的访问。因为 Twitter 拥有一个良好监管的 API(并且可以通过许可收费),所以不允许任何独立爬取其网站信息的“自制 API”是公司的最佳利益。

虽然一开始告诉你的爬虫不能访问哪里的文件可能看起来很限制,但对于网络爬虫的发展来说,这实际上是一种福音。如果你找到了一个robots.txt文件,禁止在网站的特定部分进行爬行,那么网站管理员基本上是在表明他们允许在网站的所有其他部分进行爬虫。毕竟,如果他们不允许,他们在首次编写robots.txt时就会限制访问。

例如,维基百科robots.txt文件中适用于一般网络爬取器(而不是搜索引擎)的部分非常宽松。它甚至包含了可供欢迎机器人(我们就是!)的人类可读文本,并且只阻止对少数页面的访问,例如登录页面、搜索页面和“随机文章”页面:

#
# Friendly, low-speed bots are welcome viewing article pages, but not 
# dynamically generated pages please.
#
# Inktomi's "Slurp" can read a minimum delay between hits; if your bot supports
# such a thing using the 'Crawl-delay' or another instruction, please let us 
# know.
#
# There is a special exception for API mobileview to allow dynamic mobile web &
# app views to load section content.
# These views aren't HTTP-cached but use parser cache aggressively and don't 
# expose special: pages etc.
#
User-agent: *
Allow: /w/api.php?action=mobileview&
Disallow: /w/
Disallow: /trap/
Disallow: /wiki/Especial:Search
Disallow: /wiki/Especial%3ASearch
Disallow: /wiki/Special:Collection
Disallow: /wiki/Spezial:Sammlung
Disallow: /wiki/Special:Random
Disallow: /wiki/Special%3ARandom
Disallow: /wiki/Special:Search
Disallow: /wiki/Special%3ASearch
Disallow: /wiki/Spesial:Search
Disallow: /wiki/Spesial%3ASearch
Disallow: /wiki/Spezial:Search
Disallow: /wiki/Spezial%3ASearch
Disallow: /wiki/Specjalna:Search
Disallow: /wiki/Specjalna%3ASearch
Disallow: /wiki/Speciaal:Search
Disallow: /wiki/Speciaal%3ASearch
Disallow: /wiki/Speciaal:Random
Disallow: /wiki/Speciaal%3ARandom
Disallow: /wiki/Speciel:Search
Disallow: /wiki/Speciel%3ASearch
Disallow: /wiki/Speciale:Search
Disallow: /wiki/Speciale%3ASearch
Disallow: /wiki/Istimewa:Search
Disallow: /wiki/Istimewa%3ASearch
Disallow: /wiki/Toiminnot:Search
Disallow: /wiki/Toiminnot%3ASearch

是否选择编写遵守robots.txt的网络爬虫取决于你自己,但我强烈推荐这样做,特别是如果你的爬虫不加区分地爬行网络。

三个网络爬虫

因为网络抓取是一个无限可能的领域,有很多方法可能会让你陷入法律纠纷。本节介绍了三种涉及一些普遍适用于网络爬虫的法律形式的案例,以及它是如何在这些案例中使用的。

eBay v. Bidder’s Edge 和动产侵权

1997 年,Beanie Baby 市场蓬勃发展,科技行业蓬勃发展,网络拍卖公司成为互联网上的新热门事物。一个名叫 Bidder’s Edge 的公司成立并创建了一种新型元拍卖网站。它不是强迫你从一个拍卖网站到另一个拍卖网站,比较价格,而是汇总了所有当前产品(比如热门的 Furby 娃娃或Spice World的副本)的拍卖数据,并指引你前往价格最低的网站。

Bidder’s Edge 通过一支由网络爬虫组成的部队实现了这一点,不断向各种拍卖网站的 Web 服务器发送请求以获取价格和产品信息。在所有的拍卖网站中,eBay 是最大的,Bidder’s Edge 每天向 eBay 的服务器发出约 10 万次请求。即使按照今天的标准,这也是相当多的流量。据 eBay 称,这占当时其总互联网流量的 1.53%,它肯定对此感到不满。

eBay 发出了一封停止侵权信,并提出许可其数据的提议。然而,这次许可的谈判失败了,Bidder’s Edge 继续爬取 eBay 的网站。

eBay 曾试图封锁 Bidder’s Edge 使用的 IP 地址,封锁了 169 个 IP 地址——尽管 Bidder’s Edge 通过使用代理服务器绕过了这一限制(代理服务器是指代表另一台机器转发请求,但使用代理服务器自己的 IP 地址)。我相信你可以想象到,这对双方来说都是一种令人沮丧且不可持续的解决方案——Bidder’s Edge 不断尝试寻找新的代理服务器并购买新的 IP 地址,而旧的 IP 地址被封锁后,eBay 则被迫维护大型防火墙列表(并且在每个数据包检查中增加了计算昂贵的 IP 地址比较开销)。

最后,1999 年 12 月,eBay 以财产侵权起诉了 Bidder’s Edge。

因为 eBay 的服务器是真实的、有形的资源,是它拥有的,而且它不喜欢 Bidder’s Edge 对它们的异常使用,因此财产侵权似乎是最理想的法律。事实上,在现代,财产侵权与网络爬虫诉讼密切相关,并且最常被视为一种 IT 法律。

法院裁定,为了让 eBay 在使用财产侵权赢得案件,eBay 必须证明两件事:

  • Bidder’s Edge 知道被明确禁止使用 eBay 的资源。

  • eBay 因 Bidder’s Edge 的行为而遭受了财务损失。

鉴于 eBay 的停止侵权信记录,以及 IT 记录显示的服务器使用情况和与服务器相关的实际成本,这对 eBay 来说相对容易。当然,没有大型法庭战斗会轻松结束:提起反诉,支付了许多律师费,这件事最终于 2001 年 3 月以保密金额在庭外和解。

那么这是否意味着未经授权使用他人服务器就自动违反财产侵入侵权?未必。Bidder’s Edge 是一个极端案例;它使用了 eBay 的很多资源,使公司不得不购买额外的服务器,支付更多的电费,并可能雇佣额外的人员。尽管 1.53%的增加看似微不足道,但在大公司中,这可能累积成显著的数额。

2003 年,加州最高法院就另一起案件 Intel Corp 对 Hamidi 作出裁决,前 Intel 员工 Hamidi 发送了 Intel 不喜欢的电子邮件,通过 Intel 的服务器发送给 Intel 员工。法院表示:

Intel 的主张失败并不是因为通过互联网传输的电子邮件享有独特的免责权,而是因为侵入财产的侵害行为——与刚提到的原因不同——在加利福尼亚州,可能没有证据证明对原告的个人财产或法律利益的损害。

本质上,Intel 未能证明 Hamidi 发送给所有员工的六封电子邮件的传输成本(有趣的是,每封邮件都有一个选项可以从 Hamidi 的邮件列表中移除!)对 Intel 造成任何财务损失。它没有剥夺 Intel 任何财产或其财产的使用。

美国诉 Auernheimer 和计算机欺诈与滥用法案

如果信息可以通过网络浏览器轻松访问到,那么以自动化方式访问相同的信息不太可能让你与联邦政府陷入麻烦。然而,当自动化爬虫介入时,一个看似小的安全漏洞很快就可能变成一个更大更危险的问题。

2010 年,Andrew Auernheimer 和 Daniel Spitler 注意到 iPad 的一个很好的特性:当你在它们上访问 AT&T 的网站时,AT&T 会将你重定向到一个包含你 iPad 唯一 ID 号的 URL:

https://dcp2.att.com/OEPClient/openPage?ICCID=<idNumber>&IMEI=

此页面将包含一个登录表单,其中包含 URL 中 ID 号的用户的电子邮件地址。这允许用户只需输入其密码即可访问其帐户。

尽管有大量潜在的 iPad ID 号码,但可以使用网络爬虫迭代可能的号码,一路收集电子邮件地址。通过提供用户这种便利的登录功能,AT&T 本质上将其客户电子邮件地址公开到了网络上。

Auernheimer 和 Spitler 创建了一个爬虫程序,收集了 114,000 个这些电子邮件地址,其中包括名人、CEO 和政府官员的私人电子邮件地址。Auernheimer(但不包括 Spitler)随后将列表及其获取方式的信息发送给了 Gawker Media,后者在标题为“Apple 最严重的安全漏洞:114,000 名 iPad 用户暴露”的报道中发布了这个故事(但未发布列表)。

2011 年 6 月,奥尔哈伊默的住所被 FBI 突袭,涉及电子邮件地址收集,尽管他们最终因毒品指控逮捕了他。2012 年 11 月,他因身份欺诈和未经授权访问计算机而被判有罪,并被判处 41 个月联邦监狱和支付 73,000 美元赔偿金。

他的案件引起了民权律师奥林·科尔的关注,后者加入了他的法律团队,并向第三巡回上诉法院上诉该案。2014 年 4 月 11 日(这些法律程序可能需要相当长的时间),他们提出了以下论点:

奥尔哈伊默对第一项指控的定罪必须被推翻,因为访问公开可用的网站并不构成《计算机欺诈和滥用法案》第 18 U.S.C. § 1030(a)(2)(C)条项下的未经授权访问。AT&T 选择不使用密码或任何其他保护措施来控制对其客户电子邮件地址的访问。AT&T 主观上希望外部人士不会偶然发现这些数据,或者奥尔哈伊默夸大地将访问描述为“窃取”是无关紧要的。公司配置其服务器以使信息对所有人可见,从而授权公众查看信息。通过 AT&T 的公共网站访问电子邮件地址是根据 CFAA 授权的,因此并不构成犯罪。

尽管奥尔哈伊默的定罪仅因地点不符而在上诉中被推翻,但第三巡回上诉法院在他们的决定中似乎对这一论点持开放态度,他们在脚注中写道:

尽管我们无需解决奥尔哈伊默的行为是否涉及此类违规,但在审判中并未提出证据表明账户抓手曾违反任何密码门或其他基于代码的障碍物。账户抓手只是访问了登陆屏幕的公开部分,并爬取了 AT&T 无意间发布的信息。

尽管奥尔哈伊默最终未因《计算机欺诈和滥用法案》而被定罪,但他的住所被 FBI 突袭,花费了数千美元的法律费用,并在法庭和监狱中度过了三年。

作为网络爬虫,我们能从中汲取哪些教训以避免类似情况?也许一个很好的开始是:不要表现得像个混蛋。

爬取任何敏感信息,无论是个人数据(在这种情况下是电子邮件地址)、商业秘密还是政府机密,可能并不是你想要在没有速拨律师的情况下做的事情。即使它是公开可用的,也要考虑:“普通计算机用户是否能够轻松访问这些信息?”或者“这是公司希望用户看到的内容吗?”

我曾多次致电公司报告其 Web 应用程序中的安全漏洞。这句话非常有效:“您好,我是一名安全专业人士,在您的网站上发现了一个潜在的漏洞。您能指引我找到相关负责人以便我报告并解决这个问题吗?”除了立即得到对你(白帽)黑客天才的承认的满足感外,你还可能获得免费订阅、现金奖励和其他好东西!

此外,奥伦海默将信息发布给 Gawker Media(在通知 AT&T 之前),并围绕漏洞利用炫耀,也使他成为 AT&T 律师特别青睐的目标。

如果您发现一个网站存在安全漏洞,最好的做法是通知网站的所有者,而不是媒体。你可能会想要写一篇博客文章并向全世界宣布,尤其是在问题没有得到立即解决的情况下。然而,你需要记住,这是公司的责任,而不是你的责任。你能做的最好的事情就是把你的网络爬虫(如果适用,你的业务)从该网站撤离!

菲尔德诉谷歌:版权和 robots.txt

布雷克·菲尔德,一位律师,基于谷歌网站缓存功能侵犯了版权法的基础,他认为谷歌的缓存(在他从网站上移除之后)剥夺了他对其分发的控制权。版权法允许原创作品的创作者控制其分发。菲尔德的论点是,谷歌的缓存(在他从网站上移除之后)剥夺了他对其分发的控制权。

谷歌网页缓存

当谷歌的网络爬虫(也称为Googlebots)抓取网站时,它们会复制该网站并将其托管在互联网上。任何人都可以访问这个缓存,使用以下 URL 格式:

如果您正在搜索或爬取的网站不可用,您可能想检查是否存在可用的副本!

知道谷歌的缓存功能但不采取行动对菲尔德的案件没有帮助。毕竟,他本可以通过简单添加robots.txt文件来阻止 Googlebots 缓存他的网站,简单指示哪些页面应该被抓取,哪些不应该被抓取。

更重要的是,法院认为 DMCA 安全港条款允许谷歌合法缓存和显示像菲尔德的网站:“[a]服务提供商不应因在由或为服务提供商控制或操作的系统或网络上的材料的中间和临时存储而对版权侵权要求提供金钱补偿......”

¹ 详细分析请参见“生成人工智能与版权法”,法律侧边栏,国会研究服务,2023 年 9 月 29 日。

² 见“关于知识产权保护对人工智能创新的评论”。Docket No. PTO-C-2019-0038,美国专利和商标局。

³ 布莱恩·沃尔什,“数字经济的惊人能源足迹 [更新]”,TIME.com,2013 年 8 月 14 日。

⁴ 见“‘WarGames’和网络安全对好莱坞黑客的债务”,https://oreil.ly/nBCMT,以及“不忠电脑使用和计算机欺诈和滥用法案的范围缩小”,https://oreil.ly/6TWJq

第三章:应用网页抓取

尽管网页抓取器几乎可以帮助任何业务,但真正的诀窍通常是找出如何。就像人工智能或者说实际上是编程一样,您不能只是挥动魔术棒,期望它能改善您的底线。

将网页抓取实践应用于您的业务需要真正的策略和仔细的规划,以便有效地使用它。您需要识别特定问题,弄清楚您需要哪些数据来解决这些问题,然后概述输入、输出和算法,使您的网页抓取器能够生成这些数据。

项目分类

在计划网页抓取项目时,您应考虑它如何适应以下几类之一。

您的网页抓取器是“广泛的”还是“有针对性的”?您可以编写模板来指导有针对性的网页抓取器,但需要不同的技术来处理广泛的抓取器:

  • 您是否会抓取单个网站或甚至该网站内的固定页面集?如果是这样,这是一个非常有针对性的网页抓取项目。

  • 您是否需要抓取已知数量的网站?这仍然是一个相当有针对性的抓取器,但您可能需要为每个网站编写少量的自定义代码,并投入更多时间来规划您的网页抓取器的架构。

  • 您是否正在抓取大量未知的网站并动态发现新目标?您将构建一个必须自动检测并对网站结构做出假设的爬虫吗?您可能正在编写一个广泛或非定向的抓取器。

您是否需要运行抓取器仅一次,还是这将是一个持续的工作,重新获取数据或不断寻找新的页面进行抓取?

  • 一次性的网页抓取项目可以很快且便宜地编写。代码不必漂亮!这个项目的最终结果是数据本身——您可能会将 Excel 或 CSV 文件交给业务部门,他们会很高兴。项目完成后,代码就可以丢弃了。

  • 任何涉及监视、重新扫描新数据或更新数据的项目都需要更强大的代码来进行维护。它可能还需要自己的监控基础设施,以便在遇到错误、无法运行或使用的时间或资源超出预期时进行检测。

收集的数据是否是您的最终产品,还是需要更深入的分析或操作?

  • 在简单数据收集的情况下,网页抓取器将数据存入数据库,就像它发现的那样,或者可能会进行简单的清理(例如从产品价格中去掉美元符号)。

  • 当需要更高级的分析时,您可能甚至不知道哪些数据是重要的。在这种情况下,您必须更多地考虑您的抓取器的架构。

我鼓励您考虑每个项目可能属于哪些类别,以及如何修改该项目的范围以适应您业务的需求。

电子商务

尽管我编写过从网络收集各种有趣数据的网络爬虫,但我收到的最受欢迎的请求之一是从电子商务网站收集产品和定价数据。

通常,这些请求来自拥有竞争电子商务网站的人或者正在进行研究、计划推出新产品或市场的人。在电子商务中,您可能首先想到的指标是“定价”。您希望了解您的价格与竞争对手相比如何。但是,您可能希望收集的其他可能性和数据有很多。

许多产品并非所有产品都有各种尺寸、颜色和款式可选。这些变化可能与不同的成本和供应情况相关联。可能有助于跟踪每种产品的每种变体,以及每个主要产品清单。请注意,对于每种变体,您可能会找到一个独特的 SKU(库存保持单位)识别代码,该代码对于单个产品变体和电子商务网站是唯一的(如果您稍后再查看,目标网站的 SKU 与沃尔玛的 SKU 将不同,但 SKU 将保持不变)。即使在网站上看不到 SKU,您可能会在页面的 HTML 某处或填充网站产品数据的 JavaScript API 中找到它。

在抓取电子商务网站时,记录产品的可用单位数量可能也很重要。与 SKU 一样,单位可能不会立即显示在网站上。您可能会发现这些信息隐藏在网站使用的 HTML 或 API 中。确保还要跟踪产品缺货的情况!这对于评估市场需求可能很有用,甚至可以影响您自己产品的定价(如果有库存)。

当产品打折销售时,您通常会在网站上清晰标出销售价格和原始价格。确保分别记录这两个价格。通过跟踪销售情况,您可以分析竞争对手的促销和折扣策略。

产品评论和评级是另一个有用的信息片段。当然,您不能直接在自己的网站上显示竞争对手网站上产品评论的文本。但是,分析这些评论的原始数据可以帮助您了解哪些产品受欢迎或趋势。

市场营销

在线品牌管理和营销通常涉及大量数据的聚合。与其在社交媒体上滚动或花费数小时搜索公司名称,不如让网络爬虫完成所有繁重的工作!

网络爬虫可以被恶意攻击者用来实质性“复制”网站,以销售假冒商品或欺诈潜在客户。幸运的是,网络爬虫也可以通过扫描搜索引擎结果来帮助防范,以寻找公司商标和其他知识产权的欺诈或不当使用。一些公司,如 MarqVision,还将这些网络爬虫作为服务出售,允许品牌外包网络扒取过程,检测欺诈并发布下架通知。

另一方面,不是所有使用品牌商标的行为都构成侵权。如果您的公司被提及是为了评论或审查的目的,您可能希望知道!网络爬虫可以汇总和跟踪公众对公司及其品牌的情感和认知。

在网络上跟踪您的品牌时,不要忘记您的竞争对手!您可以考虑扒取那些评论竞争产品或讨论竞争对手品牌的人的信息,以便向他们提供折扣或推广促销活动。

当涉及到营销和互联网时,人们常常首先想到的是“社交媒体”。扒取社交媒体的好处在于通常只有少数几个大型网站允许您编写有针对性的爬虫。这些网站包含数以百万计的格式良好的帖子,具有类似的数据和属性(如点赞、分享和评论),可以轻松在不同网站间进行比较。

社交媒体的不利之处在于获取数据可能会遇到障碍。像 Twitter 这样的网站提供 API,可以免费或收费使用。其他社交媒体网站则通过技术和法律保护其数据。我建议在扒取 Facebook 和 LinkedIn 等网站数据之前,先咨询公司的法律顾问。

跟踪与您品牌相关的话题的指标(点赞、分享和评论)可以帮助识别热门话题或参与的机会。跟踪流行度与内容长度、包含的图像/媒体以及语言使用等属性的关系,还可以确定哪些内容更容易与您的目标受众产生共鸣。

如果通过与拥有数亿粉丝的人合作赞助您的产品超出了公司的预算范围,您可以考虑“微影响者”或“纳米影响者”——拥有较小社交媒体影响力的用户,他们甚至可能不认为自己是影响者!构建一个网络爬虫来查找并针对那些经常发布与您品牌相关话题的账户将非常有帮助。

学术研究

虽然本章的大部分示例最终都是为了推动资本主义的发展,但网络爬虫也用于追求知识。网络爬虫在医学、社会学和心理学研究等许多领域中都被广泛应用。

例如,罗格斯大学开设了一门名为“计算社会科学”的课程,教授学生使用 Web 爬虫收集研究项目所需的数据。一些大学课程,如奥斯陆大学的“收集和分析大数据”,甚至将这本书列入了课程表!

2017 年,美国国家卫生研究院支持的一个项目从美国监狱的监犯记录中抓取数据,以估计感染 HIV 的监犯数量。¹ 这个项目引发了一场广泛的伦理分析,权衡了这项研究的好处与监犯群体隐私的风险。最终,研究继续进行,但我建议在使用 Web 爬虫进行研究时,特别是在医学领域,先考虑项目的伦理问题。

另一项健康研究对卫报关于肥胖问题的数百条评论进行了抓取,并分析了这些评论的修辞。² 虽然规模比其他研究项目小,但值得考虑的是,Web 爬虫也可以用于需要“小数据”和定性分析的项目。

这里有另一个利用 Web 爬虫的小众研究项目的例子。2016 年,进行了一项全面研究,对加拿大每所社区学院的营销材料进行了爬取和定性分析。³ 研究人员确定了现代设施和“非传统组织符号”是最受欢迎的推广内容。

在经济研究中,日本银行发表了一篇论文⁴,介绍了他们利用 Web 爬虫获取“替代数据”的情况。也就是说,除了银行通常使用的数据外,还包括 GDP 统计和公司财务报告之外的数据。在这篇论文中,他们透露了替代数据的一种来源是 Web 爬虫,他们使用 Web 爬虫来调整价格指数。

产品构建

您是否有一个商业想法,只需一个相对公开的、常识性的信息数据库就能启动它?看起来周围并没有一个价格合理、方便的信息来源?您可能需要一个 Web 爬虫。

Web 爬虫可以快速提供数据,使您能够获得推出的最低可行产品。以下是 Web 爬虫可能是最佳解决方案的几种情况:

一个列出热门旅游目的地和活动的旅行网站

在这种情况下,一个简单的地理信息数据库是不够的。你想知道人们会去看 Cristo Redentor,而不仅仅是访问巴西里约热内卢。企业目录也不太适用。虽然人们可能对大英博物馆非常感兴趣,但街对面的 Sainsbury's 并没有同样的吸引力。然而,已经有许多旅游评论网站包含了有关热门旅游目的地的信息。

产品评论博客

爬取产品名称和关键词或描述列表,然后使用你喜欢的生成式聊天 AI 填充其余部分。

谈到人工智能,这些模型需要数据——通常是大量的数据!无论您是想要预测趋势还是生成逼真的自然语言,网络爬取通常是获取产品训练数据集的最佳途径之一。

许多商业服务产品需要具有密切保护的行业知识,可能是昂贵或难以获得的,比如工业材料供应商列表、小众领域专家的联系信息或公司的空缺职位。网络爬虫可以聚合在网上各个地方找到的这些信息片段,让您可以以相对较低的前期成本建立一个全面的数据库。

旅行

无论您是想要开始一个基于旅行的业务,还是对在下次度假中节省资金充满热情,旅行业都值得特别认可,因为它提供了许多网络爬取应用程序。

酒店、航空公司和租车服务在各自的市场内产品差异很小,竞争者众多。这意味着价格通常非常相似,并且随着市场条件的变化频繁波动。

虽然像 Kayak 和 Trivago 这样的网站现在可能已经足够庞大和强大,可以支付或提供 API,但所有公司都必须从某个地方开始。网络爬虫可以是开始一个新的旅行聚合网站的绝佳方式,从整个网络中找到用户最佳的优惠。

即使你不打算开始新的业务,你是否坐过飞机或计划未来要坐飞机?如果你正在寻找本书中测试技能的想法,我强烈推荐编写一个旅游网站爬虫作为一个很好的第一个项目。庞大的数据量和数据的时间变化使得这些都是一些有趣的工程挑战。

旅游网站在抵御爬虫攻击时,也是一个很好的折中点。他们希望被搜索引擎爬取和索引,也希望使他们的数据用户友好且易于访问。然而,他们与其他旅游网站竞争激烈,可能需要使用本书后面介绍的一些更高级的技术。注意你的浏览器标头和 cookie 是一个很好的第一步。

如果你发现自己被某个特定的旅游网站阻止,并且不确定如何通过 Python 访问它的内容,可以放心,很可能有另一个具有完全相同数据的旅游网站可以尝试。

销售

网页抓取器是获取销售线索的理想工具。如果您知道一个网站上有您目标市场联系信息的来源,其余的事情就很简单了。无论您的领域有多么小众,在与销售客户的工作中,我已经抓取了青少年运动队教练、健身房业主、皮肤护理供应商以及许多其他类型的目标受众的名单。

招聘行业(我认为它是销售的一个子集)经常利用网页抓取器的两面。候选人简介和职位列表都被抓取。由于 LinkedIn 有强有力的反抓取政策,插件(如 Instant Data Scraper 或 Dux-Soup)通常用于在手动浏览器访问时抓取候选人的简介。这使招聘人员能够在抓取页面之前快速浏览候选人,以确保他们适合工作描述。

类似 Yelp 的目录可以帮助在“价格昂贵性”、是否接受信用卡、提供送货或宴会服务、是否提供酒精饮料等属性上定制搜索。虽然 Yelp 主要以其餐厅评论而闻名,但它也提供关于本地木工、零售店、会计师、汽车修理店等详细信息。

像 Yelp 这样的网站不仅仅是向顾客宣传企业——联系信息也可以用来进行销售介绍。再次强调,详细的筛选工具将帮助您精准定位您的目标市场。

抓取员工目录或职业网站也可以是获取员工姓名和联系信息的宝贵来源,这将有助于进行更个人化的销售介绍。检查 Google 的结构化数据标签(见下一节,“SERP 抓取”)是构建广泛网络抓取器的一个良策,可以针对许多网站进行抓取可靠、格式良好的联系信息。

本书中几乎所有的例子都是关于抓取网站的“内容”——它们呈现的可读信息。然而,甚至网站的底层代码也可以显示出一些信息。它使用了什么内容管理系统?有关服务器端堆栈的任何线索?有什么样的客户聊天机器人或分析系统(如果有)存在?

知道潜在客户可能已经拥有或可能需要的技术,对于销售和市场营销都是有价值的。

SERP 抓取

SERP,或称搜索引擎结果页面抓取,是直接从搜索引擎结果中获取有用数据的做法,而无需访问链接页面本身。搜索引擎结果具有已知且一致的格式优势。搜索引擎链接的页面具有各种各样且未知的格式——处理这些问题是一件混乱的事情,如果可能的话最好避免。

搜索引擎公司有专门的员工,他们的工作是使用元数据分析、巧妙的编程和 AI 工具从网站中提取页面摘要、统计数据和关键词。通过使用他们的结果,而不是在内部尝试复制它们,你可以节省大量时间和金钱。

例如,如果你想了解过去 40 年美国各大主要体育联赛的排名,你可能会找到各种信息源。http://nhl.com 提供一种格式的冰球排名,而http://nfl.com 则提供另一种格式的排名。然而,通过在谷歌上搜索“nba standings 2008”或“mlb standings 2004”,你将得到一致格式的结果,可以深入查看每个赛季的比赛得分和球员信息。

你可能还想了解搜索结果本身的存在和位置,例如,追踪特定搜索词出现的网站及其顺序。这有助于监控你的品牌并关注竞争对手。

如果你正在运营搜索引擎广告活动,或有意启动一项广告活动,你可以仅监控广告而非所有搜索结果。同样,你可以跟踪哪些广告出现,以及它们的顺序,也许还可以观察这些结果随时间的变化。

确保不要将自己局限于主要的搜索结果页面。例如,谷歌拥有谷歌地图、谷歌图片、谷歌购物、谷歌航班、谷歌新闻等。所有这些本质上都是针对不同类型内容的搜索引擎,这些内容可能与你的项目相关。

即使你不从搜索引擎本身抓取数据,了解搜索引擎如何找到和标记它们在特殊搜索结果功能和增强中显示的数据可能会有所帮助。搜索引擎并不通过猜测来决定如何显示数据;它们要求网页开发者专门为像它们这样的第三方显示格式化内容。

谷歌结构化数据的文档可以在这里找到。如果在抓取网页时遇到这些数据,现在你将知道如何使用它。

¹ Stuart Rennie, Mara Buchbinder, and Eric Juengst, “Scraping the Web for Public Health Gains: Ethical Considerations from a ‘Big Data’ Research Project on HIV and Incarceration,” National Library of Medicine 13(1): April 2020, https://www.ncbi.nlm.nih.gov/pmc/articles/PMC7392638/.

² Philip Brooker et al., “Doing Stigma: Online Commentary Around Weight-Related News Media.” New Media & Society 20(9): 1—22, December 2017.

³ Roger Pizarro Milian,《现代校园、本地联系与非传统符号:加拿大社区学院部门的推广实践》,《高等教育与管理》22:218-30,2016 年 9 月,https://link.springer.com/article/10.1080/13583883.2016.1193764

⁴ 亀田誠作,《日本银行研究活动中的替代数据使用》,《日本银行综合评价》2022-E-1,2022 年 1 月,https://www.boj.or.jp/en/research/wps_rev/rev_2022/data/rev22e01.pdf

第四章:编写您的第一个 Web 爬虫

一旦您开始网页抓取,您会开始感激浏览器为您完成的所有小事情。初始阶段,没有 HTML 格式化、CSS 样式、JavaScript 执行和图像渲染的 Web 看起来可能有些令人望而却步。在本章中,我们将开始探讨如何在没有 Web 浏览器帮助的情况下格式化和解释这些裸数据。

本章从发送 GET 请求的基础开始(请求获取或“获取” Web 页面的内容),从 Web 服务器获取特定页面的 HTML 输出,并进行一些简单的数据提取,以便分离您寻找的内容。

安装并使用 Jupyter

此课程的代码可以在 https://github.com/REMitchell/python-scraping 找到。在大多数情况下,代码示例以 Jupyter Notebook 文件的形式展示,并使用 .ipynb 扩展名。

如果您还没有使用过,Jupyter Notebooks 是组织和处理许多小但相关 Python 代码片段的绝佳方式,如 图 4-1 所示。

图 4-1:Jupyter Notebook 在浏览器中运行

每个代码片段都包含在称为 cell 的框中。可以通过键入 Shift + Enter 或单击页面顶部的“运行”按钮来运行每个单元格中的代码。

项目 Jupyter 在 2014 年从 IPython(交互式 Python)项目分支出来。这些笔记本设计用于在浏览器中以可访问和交互的方式运行 Python 代码,非常适合教学和演示。

安装 Jupyter Notebooks:

$ pip install notebook

安装后,您应该可以访问 jupyter 命令,该命令将允许您启动 Web 服务器。导航到包含本书下载的练习文件的目录,并运行:

$ jupyter notebook

这将在端口 8888 上启动 Web 服务器。如果您已经运行了 Web 浏览器,新标签页应该会自动打开。如果没有,请将终端中显示的带有提供的令牌的 URL 复制到您的 Web 浏览器中。

连接

在本书的第一部分中,我们深入探讨了互联网如何通过电缆将数据包从浏览器发送到 Web 服务器,然后再发送回来。当您打开浏览器,输入 **google.com**,然后按 Enter 键时,正是发生了这种情况——数据以 HTTP 请求的形式从您的计算机传输,Google 的 Web 服务器则以 HTML 文件的形式响应表示 google.com 的数据。

但是,在数据包和帧的交换中,Web 浏览器到底如何发挥作用?完全没有。事实上,ARPANET(第一个公共分组交换网络)比第一个 Web 浏览器 Nexus 至少早 20 年。

是的,网络浏览器是一个有用的应用程序,用于创建这些信息包,告诉您的操作系统将它们发送出去,并解释您收到的数据为漂亮的图片、声音、视频和文本。但是,网络浏览器只是代码,代码可以被拆解、分解为其基本组件、重写、重复使用,并使其执行任何您想要的操作。网络浏览器可以告诉处理您的无线(或有线)接口的应用程序发送数据到处理器,但您可以在 Python 中只用三行代码做同样的事情:

from urllib.request import urlopen

html = urlopen('http://pythonscraping.com/pages/page1.html')
print(html.read())

要运行此命令,您可以在 GitHub 存储库的 第一章 中使用 IPython 笔记本,或者您可以将其本地保存为 scrapetest.py 并在终端中使用以下命令运行它:

$ python scrapetest.py

请注意,如果您的计算机上同时安装了 Python 2.x 并且并排运行了两个版本的 Python,则可能需要通过以下方式显式调用 Python 3.x 来运行命令:

$ python3 scrapetest.py

此命令输出位于 URL http://pythonscraping.com/pages/page1.htmlpage1 的完整 HTML 代码。更准确地说,这输出位于域名 http://pythonscraping.com 的服务器上的目录 /pages 中的 HTML 文件 page1.html

为什么将这些地址视为“文件”而不是“页面”很重要?大多数现代网页都有许多与其关联的资源文件。这些可以是图像文件、JavaScript 文件、CSS 文件或您正在请求的页面链接到的任何其他内容。当网络浏览器命中诸如 <img src="cute​Kit⁠ten.jpg"> 的标签时,浏览器知道它需要向服务器发出另一个请求,以获取位于位置 cuteKitten.jpg 的数据,以便为用户完全渲染页面。

当然,您的 Python 脚本目前还没有逻辑去回去请求多个文件;它只能读取您直接请求的单个 HTML 文件。

from urllib.request import urlopen

这意味着看起来就像它的字面意思一样:它查看 Python 模块 request(在 urllib 库中找到)并仅导入函数 urlopen

urllib 是一个标准的 Python 库(这意味着您不必安装任何额外的内容来运行此示例),包含了用于在网络上请求数据、处理 cookies 甚至更改元数据(如标头和用户代理)的函数。在本书中,我们将广泛使用 urllib,因此我建议您阅读该库的 Python 文档

urlopen 用于打开跨网络的远程对象并读取它。因为它是一个相当通用的函数(可以轻松读取 HTML 文件、图像文件或任何其他文件流),所以我们将在本书中经常使用它。

BeautifulSoup 简介

美丽的汤,如此丰富而绿色,

在一个热的碗里等待!

为了这样的佳肴,谁不愿俯身?

傍晚的浓汤,美丽的浓汤!

BeautifulSoup 库以同名的刘易斯·卡罗尔诗歌《爱丽丝漫游奇境记》中的一首诗命名。在故事中,这首诗是由一个叫做模拟海龟的角色唱的(这个名字本身就是对维多利亚时期流行的模拟海龟汤的一个双关)。

与其奇境中的同名者一样,BeautifulSoup 尝试理解荒谬之物;它通过修复糟糕的 HTML 并为我们提供易于遍历的 Python 对象来帮助格式化和组织混乱的网络。

安装 BeautifulSoup

因为 BeautifulSoup 库不是默认的 Python 库,所以必须安装它。如果你已经有经验安装 Python 库,请使用你喜欢的安装程序并跳到下一节,“运行 BeautifulSoup”。

对于那些尚未安装 Python 库(或需要复习的人),这个通用方法将用于在整本书中安装多个库,因此你可能想在未来参考这一部分。

我们将在整本书中使用 BeautifulSoup 4 库(也称为 BS4)。你可以在Crummy.com找到完整的文档和 BeautifulSoup 4 的安装说明。

如果你花了很多时间写 Python,你可能已经使用过 Python 的包安装程序(pip)。如果还没有,我强烈建议你安装 pip,以便安装 BeautifulSoup 和本书中使用的其他 Python 包。

根据你使用的 Python 安装程序,pip 可能已经安装在你的计算机上。要检查,请尝试:

$ pip

这个命令应该导致在你的终端打印出 pip 帮助文本。如果命令未被识别,你可能需要安装 pip。可以使用多种方式安装 pip,例如在 Linux 上使用apt-get或在 macOS 上使用brew。无论你的操作系统如何,你也可以在https://bootstrap.pypa.io/get-pip.py下载 pip 引导文件,并将其保存为get-pip.py,然后用 Python 运行它:

$ python get-pip.py

再次注意,如果你的机器上同时安装了 Python 2.x 和 3.x,你可能需要显式地调用python3

$ python3 get-pip.py

最后,使用 pip 安装 BeautifulSoup:

$ pip install bs4

如果你同时拥有两个版本的 Python 和两个版本的 pip,则可能需要调用pip3来安装 Python 3.x 版本的包:

$ pip3 install bs4

这就是全部啦!BeautifulSoup 现在将被识别为你机器上的 Python 库。你可以通过打开 Python 终端并导入它来测试:

$ python
> from bs4 import BeautifulSoup

导入应该完成而没有错误。

运行 BeautifulSoup

BeautifulSoup 库中最常用的对象是适当地称为BeautifulSoup对象。让我们看看它在本章开头示例中的应用:

from urllib.request import urlopen
from bs4 import BeautifulSoup

html = urlopen('http://www.pythonscraping.com/pages/page1.html')
bs = BeautifulSoup(html.read(), 'html.parser')
print(bs.h1)

输出如下:

<h1>An Interesting Title</h1>

请注意,这仅返回页面上找到的第一个 h1 标签实例。按照惯例,单个页面上应仅使用一个 h1 标签,但通常会在网络上违反惯例,因此您应注意,这仅检索标签的第一个实例,并不一定是您要查找的实例。

与之前的网页抓取示例类似,您正在导入 urlopen 函数并调用 html.read() 获取页面的 HTML 内容。除了文本字符串外,BeautifulSoup 还可以直接使用由 urlopen 返回的文件对象,而不需要先调用 .read()

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

然后将此 HTML 内容转换为 BeautifulSoup 对象,其结构如下:

  • html......

    • headA Useful Page<title></head></em></p> <ul> <li><strong>title</strong> → <em><title>A Useful Page</t**itle></em></li> </ul> </li> <li> <p><strong>body</strong> → <em><body><h1>An Int...</h1><div>Lorem ip...</div></body></em></p> <ul> <li> <p><strong>h1</strong> → <em><h1>An Interesting Title</h1></em></p> </li> <li> <p><strong>div</strong> → <em><div>Lorem Ipsum dolor...</div></em></p> </li> </ul> </li> </ul> </li> </ul> <p>请注意,从页面中提取的 <code>h1</code> 标签嵌套在您的 <code>BeautifulSoup</code> 对象结构的两层深度内(<code>html</code> → <code>body</code> → <code>h1</code>)。但是,当您实际从对象中获取它时,直接调用 <code>h1</code> 标签:</p> <pre><code class="language-py">bs.h1 </code></pre> <p>实际上,以下任何一个函数调用都会产生相同的输出:</p> <pre><code class="language-py">bs.html.body.h1 bs.body.h1 bs.html.h1 </code></pre> <p>创建 <code>BeautifulSoup</code> 对象时,会传入两个参数:</p> <pre><code class="language-py">bs = BeautifulSoup(html.read(), 'html.parser') </code></pre> <p>第一个是基于对象的 HTML 字符串,第二个指定您希望 BeautifulSoup 使用的解析器来创建该对象。在大多数情况下,选择哪个解析器并不重要。</p> <p><code>html.parser</code> 是 Python 3 中附带的解析器,无需额外安装即可使用。除非另有要求,我们将在整本书中使用此解析器。</p> <p>另一个流行的解析器是 <a href="http://lxml.de/parsing.html" target="_blank"><code>lxml</code></a>。可以通过 pip 安装它:</p> <pre><code class="language-py">$ pip install lxml </code></pre> <p>可通过更改提供的解析器字符串在 BeautifulSoup 中使用 <code>lxml</code> 解析器:</p> <pre><code class="language-py">bs = BeautifulSoup(html.read(), 'lxml') </code></pre> <p><code>lxml</code> 在解析“混乱”或格式不正确的 HTML 代码方面比 <code>html.parser</code> 有一些优势。它是宽容的,并修复诸如未关闭标签、不正确嵌套的标签以及缺少 head 或 body 标签等问题。</p> <p><code>lxml</code> 在速度上也比 <code>html.parser</code> 稍快,尽管在网页抓取中,速度并不一定是优势,因为网络速度几乎总是最大的瓶颈。</p> <h1 id="避免过度优化网页抓取代码">避免过度优化网页抓取代码</h1> <p>优雅的算法很令人愉悦,但在网页抓取中,它们可能没有实际影响。处理时间的几微秒可能会被网络请求的秒级延迟所淹没。</p> <p>良好的网页抓取代码通常侧重于健壮且易于阅读的实现,而不是巧妙的处理优化。</p> <p><code>lxml</code>的一个缺点是需要单独安装,并且依赖第三方 C 库来运行。与<code>html.parser</code>相比,这可能会导致可移植性和易用性问题。</p> <p>另一个流行的 HTML 解析器是<code>html5lib</code>。与<code>lxml</code>类似,<code>html5lib</code>是一个非常宽容的解析器,甚至更主动地修复损坏的 HTML。它也依赖于外部依赖项,比<code>lxml</code>和<code>html.parser</code>都慢。尽管如此,如果您要处理混乱或手写的 HTML 网站,它可能是一个不错的选择。</p> <p>它可以通过安装并将字符串<code>html5lib</code>传递给 BeautifulSoup 对象来使用:</p> <pre><code class="language-py">bs = BeautifulSoup(html.read(), 'html5lib') </code></pre> <p>我希望这个 BeautifulSoup 的简介让您对这个库的功能和简易性有了一些了解。几乎可以从任何 HTML(或 XML)文件中提取任何信息,只要它有一个标识性标签围绕它或靠近它。第五章更深入地探讨了更复杂的 BeautifulSoup 函数调用,并介绍了正则表达式及其如何与 BeautifulSoup 一起用于从网站提取信息。</p> <h2 id="可靠连接和异常处理">可靠连接和异常处理</h2> <p>网络是混乱的。数据格式糟糕,网站宕机,闭合标签丢失。在网络爬取中最令人沮丧的经历之一是,当您在睡觉时让爬虫运行,梦想着第二天数据库中将拥有的所有数据,却发现爬虫在某些意外数据格式上出现错误,并在您停止查看屏幕后不久停止执行。</p> <p>在这些情况下,您可能会被诱使诅咒创建网站的开发者的名字(以及奇怪的数据格式),但实际上应该踢的人是您自己,因为您没有预料到这个异常!</p> <p>让我们看看我们的爬虫的第一行,在导入语句之后,找出如何处理可能引发的任何异常:</p> <pre><code class="language-py">html = urlopen('http://www.pythonscraping.com/pages/page1.html') </code></pre> <p>这行代码可能会出现两个主要问题:</p> <ul> <li> <p>服务器上找不到页面(或者在检索时出错)。</p> </li> <li> <p>根本找不到服务器。</p> </li> </ul> <p>在第一种情况下,将返回一个 HTTP 错误。这个 HTTP 错误可能是“404 页面未找到”,“500 内部服务器错误”等。在所有这些情况下,<code>urlopen</code>函数将抛出通用异常<code>HTTPError</code>。您可以通过以下方式处理此异常:</p> <pre><code class="language-py">from urllib.request import urlopen from urllib.error import HTTPError try:     html = urlopen('http://www.pythonscraping.com/pages/page1.html') except HTTPError as e:     print(e)   # return null, break, or do some other "Plan B" else:     # program continues. Note: If you return or break in the   # exception catch, you do not need to use the "else" statement </code></pre> <p>如果返回了 HTTP 错误代码,程序现在会打印错误,并且不会在<code>else</code>语句下执行程序的其余部分。</p> <p>如果根本找不到服务器(例如,<em><a href="http://www.pythonscraping.com" target="_blank">http://www.pythonscraping.com</a></em> 不存在,或者 URL 输错了),<code>urlopen</code> 将抛出一个<code>URLError</code>。这表示根本无法连接到任何服务器,因为远程服务器负责返回 HTTP 状态代码,所以不会抛出<code>HTTPError</code>,而是必须捕获更严重的<code>URLError</code>。您可以添加检查以查看是否出现了这种情况:</p> <pre><code class="language-py">from urllib.request import urlopen from urllib.error import HTTPError from urllib.error import URLError try: html = urlopen('https://pythonscrapingthisurldoesnotexist.com') except HTTPError as e: print(e) except URLError as e: print('The server could not be found!') else: print('It Worked!') </code></pre> <p>当然,如果成功从服务器检索到页面,仍然存在页面内容不完全符合预期的问题。每次访问 <code>BeautifulSoup</code> 对象中的标签时,都应该智能地添加检查以确保标签确实存在。如果尝试访问不存在的标签,BeautifulSoup 将返回一个 <code>None</code> 对象。问题在于,尝试在 <code>None</code> 对象上访问标签本身将导致抛出 <code>AttributeError</code>。</p> <p>下面这行代码(其中 <code>nonExistentTag</code> 是一个虚构的标签,不是 BeautifulSoup 中真实的函数名):</p> <pre><code class="language-py">print(bs.nonExistentTag) </code></pre> <p>返回一个 <code>None</code> 对象。这个对象完全可以处理和检查。问题出现在如果您没有检查它,而是继续尝试在这个 <code>None</code> 对象上调用另一个函数,正如这里所示:</p> <pre><code class="language-py">print(bs.nonExistentTag.someTag) </code></pre> <p>这会导致异常抛出:</p> <pre><code class="language-py">AttributeError: 'NoneType' object has no attribute 'someTag' </code></pre> <p>那么如何防范这两种情况?最简单的方法是显式检查这两种情况:</p> <pre><code class="language-py">try: badContent = bs.nonExistingTag.anotherTag except AttributeError as e: print('Tag was not found') else: if badContent == None:      print ('Tag was not found')     else:         print(badContent) </code></pre> <p>虽然最初每次错误的检查和处理都显得有些费力,但通过对代码稍作重新组织,可以使其更易于编写(更重要的是,更易于阅读)。例如,以下代码是稍微不同方式编写的同一个爬虫:</p> <pre><code class="language-py">from urllib.request import urlopen from urllib.error import HTTPError from bs4 import BeautifulSoup def getTitle(url):     try:         html = urlopen(url)     except HTTPError as e:         return None     try:         bs = BeautifulSoup(html.read(), 'html.parser')         title = bs.body.h1     except AttributeError as e:         return None     return title title = getTitle('http://www.pythonscraping.com/pages/page1.html') if title == None:     print('Title could not be found') else:     print(title) </code></pre> <p>在这个例子中,您正在创建一个名为 <code>getTitle</code> 的函数,它返回页面的标题或者如果检索时出现问题则返回一个 <code>None</code> 对象。在 <code>getTitle</code> 中,您检查了 <code>HTTPError</code>,就像前面的例子中一样,并将两行 BeautifulSoup 代码封装在一个 <code>try</code> 语句中。任何这些行都可能引发 <code>AttributeError</code>(如果服务器不存在,<code>html</code> 将是一个 <code>None</code> 对象,<code>html.read()</code> 将引发 <code>AttributeError</code>)。实际上,您可以在一个 <code>try</code> 语句中包含尽可能多的行或调用另一个可能在任何时候引发 <code>AttributeError</code> 的函数。</p> <p>当编写爬虫时,重要的是要考虑代码的整体结构,以便处理异常并同时保持可读性。您可能还希望大量重用代码。具有通用函数如 <code>getSiteHTML</code> 和 <code>getTitle</code>(完备的异常处理)使得能够快速且可靠地进行网页抓取。</p> <h1 id="第五章高级-html-解析">第五章:高级 HTML 解析</h1> <p>当有人问到米开朗基罗如何雕刻出像他的<em>大卫</em>那样精湛的艺术品时,据说他曾经说过:“很简单。你只需凿掉那些不像大卫的石头。”</p> <p>尽管 Web 抓取在大多数其他方面不像大理石雕刻,但当你试图从复杂的网页中提取你想要的信息时,你必须采取类似的态度。在本章中,我们将探讨各种技术,逐步剔除任何不符合你想要的内容的内容,直到你找到你想要的信息。复杂的 HTML 页面可能一开始看起来令人生畏,但只要不断地削减!</p> <h1 id="beautifulsoup-再次登场">BeautifulSoup 再次登场</h1> <p>在第四章中,您快速了解了安装和运行 BeautifulSoup 以及逐个选择对象的方法。在本节中,我们将讨论通过属性搜索标记、处理标记列表以及导航解析树。</p> <p>您遇到的几乎每个网站都包含样式表。样式表被创建为 Web 浏览器可以将 HTML 渲染成丰富多彩和美观的设计。您可能认为这个样式层对于网络爬虫来说至少是可以完全忽略的,但别那么快!实际上,CSS 对于网络爬虫来说是一个巨大的助益,因为它要求对 HTML 元素进行区分,以便以不同的方式对其进行样式设置。</p> <p>CSS 为 Web 开发人员提供了一个激励,让他们为 HTML 元素添加标签,否则他们可能会使用完全相同的标记。有些标签可能是这样的:</p> <pre><code class="language-py"><span class="green"></span> </code></pre> <p>其他人可能是这样的:</p> <pre><code class="language-py"><span class="red"></span> </code></pre> <p>Web 爬虫可以根据它们的类轻松区分这两个标记;例如,它们可以使用 BeautifulSoup 抓取所有红色文本,但不包括任何绿色文本。由于 CSS 依赖这些标识属性来适当地设计网站的样式,几乎可以肯定,这些类和 id 属性在大多数现代网站上都是丰富的。</p> <p>让我们创建一个示例网络爬虫,用于爬取位于<a href="http://www.pythonscraping.com/pages/warandpeace.html" target="_blank"><em>http://www.pythonscraping.com/pages/warandpeace.html</em></a>的页面。</p> <p>在此页面上,故事中人物说的台词是红色的,而人物的名字是绿色的。您可以在以下页面源代码示例中看到引用适当 CSS 类的<code>span</code>标记:</p> <pre><code class="language-py"><span class="red">Heavens! what a virulent attack!</span> replied <span class="green">the prince</span>, not in the least disconcerted by this reception. </code></pre> <p>您可以抓取整个页面并使用它创建一个<code>BeautifulSoup</code>对象,方法与第四章中使用的程序类似:</p> <pre><code class="language-py">from urllib.request import urlopen from bs4 import BeautifulSoup html = urlopen('http://www.pythonscraping.com/pages/warandpeace.html') bs = BeautifulSoup(html.read(), 'html.parser') </code></pre> <p>使用这个<code>BeautifulSoup</code>对象,您可以使用<code>find_all</code>函数提取一个 Python 列表,其中包含仅选择<code><span class="green"></span></code>标记内文本的专有名词(<code>find_all</code>是一个非常灵活的函数,您将在本书后面经常使用它):</p> <pre><code class="language-py">nameList = bs.find_all('span', {'class':'green'}) for name in nameList:     print(name.get_text()) </code></pre> <p>当运行时,它应该按照 <em>战争与和平</em> 中的顺序列出文本中的所有专有名词。它是如何工作的?之前,你调用过 <code>bs.tagName</code> 来获取页面上该标签的第一个出现。现在,你调用 <code>bs.find_all(tagName, tagAttributes)</code> 来获取页面上所有该标签的列表,而不仅仅是第一个。</p> <p>获取名称列表后,程序遍历列表中的所有名称,并打印 <code>name.get_text()</code> 以便将内容与标签分开。</p> <h1 id="何时使用-get_text-和何时保留标签">何时使用 get_text() 和何时保留标签</h1> <p><code>.get_text()</code> 从你正在处理的文档中去除所有标签,并返回一个只包含文本的 Unicode 字符串。例如,如果你正在处理一个包含许多超链接、段落和其他标签的大块文本,那么所有这些内容都将被去除,你将得到一个没有标签的文本块。</p> <p>请记住,在 BeautifulSoup 对象中查找你要找的内容比在一块文本中要容易得多。调用 <code>.get_text()</code> 应该总是在最后一步操作之前,即在打印、存储或操作最终数据之前。一般来说,你应该尽量保持文档的标签结构尽可能长时间地存在。</p> <h2 id="使用-beautifulsoup-的-find-和-find_all">使用 BeautifulSoup 的 find() 和 find_all()</h2> <p>BeautifulSoup 的 <code>find()</code> 和 <code>find_all()</code> 是你可能会经常使用的两个函数。使用它们可以轻松过滤 HTML 页面,找到所需标签的列表,或者根据它们的不同属性找到单个标签。</p> <p>这两个函数在 BeautifulSoup 文档中的定义非常相似:</p> <pre><code class="language-py">find_all(tag, attrs, recursive, text, limit, **kwargs) find(tag, attrs, recursive, text, **kwargs) </code></pre> <p>很可能,95% 的情况下你只需要使用前两个参数:<code>tag</code> 和 <code>attrs</code>。然而,我们来更详细地看一下所有参数。</p> <p><code>tag</code> 参数是你之前见过的;你可以传递一个标签的字符串名称,甚至是一个 Python 列表,其中包含多个标签名。例如,以下代码返回文档中所有标题标签的列表:¹</p> <pre><code class="language-py">.find_all(['h1','h2','h3','h4','h5','h6']) </code></pre> <p>与 <code>tag</code> 参数不同,<code>attrs</code> 参数必须是一个 Python 字典,包含属性和值。它匹配包含任何一个这些属性的标签。例如,以下函数将返回 HTML 文档中的 <em>绿色</em> 和 <em>红色</em> <code>span</code> 标签:</p> <pre><code class="language-py">.find_all('span', {'class': ['green', 'red']}) </code></pre> <p><code>recursive</code> 参数是一个布尔值。你想要深入文档多深?如果将 <code>recursive</code> 设置为 <code>True</code>,<code>find_all</code> 函数将深入到子级、子级的子级等,查找符合参数的标签。如果设置为 <code>False</code>,它将仅查找文档中的顶级标签。默认情况下,<code>find_all</code> 递归地工作(<code>recursive</code> 设置为 <code>True</code>)。通常情况下,除非你确切知道需要做什么并且性能是个问题,否则最好保持这个设置不变。</p> <p><code>text</code>参数不同之处在于它基于标签的文本内容进行匹配,而不是标签本身的属性。例如,如果您想要找到“王子”被标签包围的次数,在示例页面上,您可以用以下代码替换之前示例中的<code>.find_all()</code>函数:</p> <pre><code class="language-py">nameList = bs.find_all(text='the prince') print(len(nameList)) </code></pre> <p>输出结果为 7。</p> <p>当然,<code>limit</code>参数仅在<code>find_all</code>方法中使用;<code>find</code>等同于使用限制为 1 的相同<code>find_all</code>调用。如果您只对从页面中检索的前<em>x</em>个项目感兴趣,则可以设置这个参数。请注意,这会按照文档中它们出现的顺序返回页面上的第一个项目,而不一定是您想要的第一个项目。</p> <p>附加的<code>kwargs</code>参数允许您将任何其他命名参数传递给方法。<code>find</code>或<code>find_all</code>不识别的任何额外参数将用作标签属性匹配器。例如:</p> <pre><code class="language-py">title = bs.find_all(id='title', class_='text') </code></pre> <p>返回第一个标签,其<code>class</code>属性中包含“text”和<code>id</code>属性中包含“title”。请注意,按照惯例,每个<code>id</code>值在页面上应该只使用一次。因此,在实践中,像这样的一行可能并不特别有用,并且应等同于使用<code>find</code>函数:</p> <pre><code class="language-py">title = bs.find(id='title') </code></pre> <p>您可能已经注意到,BeautifulSoup 已经有了一种根据它们的属性和值查找标签的方法:<code>attr</code>参数。实际上,以下两行是相同的:</p> <pre><code class="language-py">bs.find(id='text') bs.find(attrs={'id':'text'}) </code></pre> <p>然而,第一行的语法更短,且在需要按特定属性获取标签的快速过滤器时更易于使用。当过滤器变得更复杂,或者当您需要将属性值选项作为参数列表传递时,可能需要使用<code>attrs</code>参数:</p> <pre><code class="language-py">bs.find(attrs={'class':['red', 'blue', 'green']}) </code></pre> <h2 id="其他-beautifulsoup-对象">其他 BeautifulSoup 对象</h2> <p>到目前为止,在这本书中,您已经看到 BeautifulSoup 库中的两种对象类型:</p> <p><code>BeautifulSoup</code>对象</p> <p>在之前的代码示例中作为变量<code>bs</code>出现的实例</p> <p><code>Tag</code>对象</p> <p>通过在<code>BeautifulSoup</code>对象上调用<code>find</code>和<code>find_all</code>或深入挖掘,以列表形式检索,或单独检索:</p> <pre><code class="language-py">bs.div.h1 </code></pre> <p>然而,库中还有两个对象,虽然使用较少,但仍然很重要:</p> <p><code>NavigableString</code>对象</p> <p>用于表示标签内的文本,而不是标签本身(某些函数操作并生成<code>NavigableStrings</code>,而不是标签对象)。</p> <p><code>Comment</code>对象</p> <p>用于查找 HTML 注释中的注释标签,<code><!--like this one--></code>。</p> <p>在撰写本文时,这些是 BeautifulSoup 包中唯一的四个对象。当 BeautifulSoup 包于 2004 年发布时,这些也是唯一的四个对象,因此在不久的将来可用对象的数量不太可能改变。</p> <h2 id="导航树">导航树</h2> <p><code>find_all</code>函数负责根据名称和属性查找标签。但是,如果你需要根据文档中的位置查找标签呢?这就是树导航派上用场的地方。在第四章中,你看过如何在 BeautifulSoup 树中单向导航:</p> <pre><code class="language-py">bs.tag.subTag.anotherSubTag </code></pre> <p>现在让我们看看如何在 HTML 树中向上、横向和斜向导航。你将使用我们极具争议性的在线购物网站<a href="http://www.pythonscraping.com/pages/page3.html" target="_blank"><em>http://www.pythonscraping.com/pages/page3.html</em></a>作为抓取的示例页面,如图 5-1 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_0501.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-5-1-来自httpwwwpythonscrapingcompagespage3html的屏幕截图">图 5-1. 来自<a href="http://www.pythonscraping.com/pages/page3.html" target="_blank"><em>http://www.pythonscraping.com/pages/page3.html</em></a>的屏幕截图</h6> <p>此页面的 HTML,作为树形结构映射出来(为简洁起见,某些标签被省略),看起来像这样:</p> <ul> <li> <p>HTML</p> <ul> <li> <p><code>body</code></p> <ul> <li> <p><code>div.wrapper</code></p> <ul> <li> <p><code>h1</code></p> </li> <li> <p><code>div.content</code></p> </li> <li> <p><code>table#giftList</code></p> <ul> <li> <p><code>tr</code></p> <ul> <li> <p><code>th</code></p> </li> <li> <p><code>th</code></p> </li> <li> <p><code>th</code></p> </li> <li> <p><code>th</code></p> </li> </ul> </li> <li> <p><code>tr.gift#gift1</code></p> <ul> <li> <p><code>td</code></p> </li> <li> <p><code>td</code></p> <ul> <li><code>span.excitingNote</code></li> </ul> </li> <li> <p><code>td</code></p> </li> <li> <p><code>td</code></p> <ul> <li><code>img</code></li> </ul> </li> </ul> </li> <li> <p>...表行继续...</p> </li> </ul> </li> <li> <p><code>div.footer</code></p> </li> </ul> </li> </ul> </li> </ul> </li> </ul> <p>你将会在接下来的几节中使用这个相同的 HTML 结构作为示例。</p> <h3 id="处理儿童和其他后代">处理儿童和其他后代</h3> <p>在计算机科学和某些数学分支中,你经常会听说对子代进行的可怕操作:移动它们、存储它们、移除它们,甚至杀死它们。幸运的是,本节仅专注于选择它们!</p> <p>在 BeautifulSoup 库以及许多其他库中,都区分<em>子代</em>和<em>后代</em>:就像人类家谱中的情况一样,子代始终位于父代的正下方,而后代可以位于树中父代的任何级别。例如,<code>tr</code>标签是<code>table</code>标签的子代,而<code>tr</code>、<code>th</code>、<code>td</code>、<code>img</code>和<code>span</code>都是<code>table</code>标签的后代(至少在我们的示例页面中是这样)。所有子代都是后代,但并非所有后代都是子代。</p> <p>一般来说,BeautifulSoup 函数总是处理当前标签选定的后代。例如,<code>bs.body.h1</code>选择的是<code>body</code>标签的第一个<code>h1</code>标签后代。它不会找到位于 body 之外的标签。</p> <p>同样地,<code>bs.div.find_all('img')</code>将会找到文档中第一个<code>div</code>标签,然后检索该<code>div</code>标签的所有后代<code>img</code>标签列表。</p> <p>如果你只想找到作为子代的后代,你可以使用<code>.children</code>标签:</p> <pre><code class="language-py">from urllib.request import urlopen from bs4 import BeautifulSoup html = urlopen('http://www.pythonscraping.com/pages/page3.html') bs = BeautifulSoup(html, 'html.parser') for child in bs.find('table',{'id':'giftList'}).children:     print(child) </code></pre> <p>这段代码打印出<code>giftList</code>表中的产品行列表,包括列标签的初始行。如果你使用<code>descendants()</code>函数而不是<code>children()</code>函数来编写它,将会在表中找到并打印大约二十多个标签,包括<code>img</code>标签、<code>span</code>标签和单独的<code>td</code>标签。区分子代和后代显然非常重要!</p> <h3 id="处理兄弟节点">处理兄弟节点</h3> <p>BeautifulSoup 的<code>next_siblings()</code>函数使得从表格中收集数据变得轻松,特别是带有标题行的表格:</p> <pre><code class="language-py">from urllib.request import urlopen from bs4 import BeautifulSoup html = urlopen('http://www.pythonscraping.com/pages/page3.html') bs = BeautifulSoup(html, 'html.parser') for sibling in bs.find('table', {'id':'giftList'}).tr.next_siblings:     print(sibling) </code></pre> <p>此代码的输出是打印产品表中除第一行标题行之外的所有产品行。为什么标题行被跳过了呢?对象不能与自身为兄弟。每当您获取对象的兄弟时,该对象本身不会包含在列表中。正如函数名称所示,它仅调用 <em>next</em> 兄弟。例如,如果您选择列表中间的一行,并在其上调用 <code>next_siblings</code>,那么只会返回随后的兄弟。因此,通过选择标题行并调用 <code>next_siblings</code>,您可以选择表中的所有行,而不选择标题行本身。</p> <h1 id="使选择更加具体">使选择更加具体</h1> <p>如果要选择表的第一行,上述代码同样有效,无论是选择 <code>bs.table.tr</code> 还是仅选择 <code>bs.tr</code>。但在代码中,我费力地以更长的形式写出了所有内容:</p> <pre><code class="language-py">bs.find('table',{'id':'giftList'}).tr </code></pre> <p>即使看起来页面上只有一个表(或其他目标标签),也很容易忽略事物。此外,页面布局随时都在变化。曾经是页面上第一个的东西,有一天可能成为页面上第二或第三种类型的标签。为了使您的爬虫更加健壮,最好在进行标签选择时尽可能具体。利用标签属性时要尽量具体。</p> <p>作为 <code>next_siblings</code> 的补充,<code>previous_siblings</code> 函数通常在您希望获取兄弟标签列表末尾的易于选择的标签时非常有用。</p> <p>当然,还有<code>next_sibling</code>和<code>previous_sibling</code>函数,它们执行的功能几乎与<code>next_siblings</code>和<code>previous_siblings</code>相同,只是它们返回单个标签而不是列表。</p> <h3 id="处理父元素">处理父元素</h3> <p>在抓取页面时,您可能会发现需要查找标签的父标签的频率比需要查找它们的子标签或兄弟标签的频率要少。通常,当您查看具有爬行目标的 HTML 页面时,您首先查看顶层标签,然后想办法深入到您想要的确切数据部分。然而,偶尔可能会遇到需要 BeautifulSoup 的父查找函数 <code>.parent</code> 和 <code>.parents</code> 的奇怪情况。例如:</p> <pre><code class="language-py">from urllib.request import urlopen from bs4 import BeautifulSoup html = urlopen('http://www.pythonscraping.com/pages/page3.html') bs = BeautifulSoup(html, 'html.parser') print(bs.find('img',               {'src':'../img/gifts/img1.jpg'})       .parent.previous_sibling.get_text()) </code></pre> <p>此代码将打印由位置 <em>../img/gifts/img1.jpg</em> 表示的图像所代表的对象的价格(在本例中,价格为 $15.00)。</p> <p>这是如何工作的?以下图示表示您正在处理的 HTML 页面部分的树形结构,带有编号步骤:</p> <ul> <li> <tr> <ul> <li> <p><code>td</code></p> </li> <li> <p><code>td</code></p> </li> <li> <p><code>td</code> <img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#c03" alt="3" loading="lazy"></p> <ul> <li><code>"$15.00"</code> <strong><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#c04" alt="4" loading="lazy"></strong></li> </ul> </li> <li> <p><code>td</code> <img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#c02" alt="2" loading="lazy"></p> <ul> <li><code><img src="../img/gifts/img1.jpg"></code> <img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#c01" alt="1" loading="lazy"></li> </ul> </li> </ul> </li> </ul> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#comarker1" alt="1" loading="lazy"></p> <p>第一个选择了 <code>src="../img/gifts/img1.jpg"</code> 的图像标签。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#comarker2" alt="2" loading="lazy"></p> <p>您选择了该标签的父标签(在本例中为 <code>td</code> 标签)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#comarker3" alt="3" loading="lazy"></p> <p>你选择<code>td</code>标签的<code>previous_sibling</code>(在这种情况下,包含产品价格的<code>td</code>标签)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/#comarker4" alt="4" loading="lazy"></p> <p>你选择标签内的文本 <code>"$15.00"</code>。</p> <h1 id="正则表达式">正则表达式</h1> <p>正如一个老的计算机科学笑话所说:“假设你有一个问题,并且你决定用正则表达式来解决它。好吧,现在你有两个问题。”</p> <p>不幸的是,正则表达式(通常缩写为<em>regex</em>)通常是用大量随机符号组成的大表格来教授的,看起来像是一堆无意义的东西。这种方法往往会让人望而却步,后来他们进入职场时会编写不必要复杂的搜索和过滤函数以避免使用正则表达式。</p> <p>当涉及到网页抓取时,正则表达式是一个非常宝贵的工具。幸运的是,你不必花费太多时间就能迅速掌握并且可以通过几个简单的例子来学习它们。</p> <p><em>正则表达式</em>之所以被这样称呼,是因为它们用于识别属于<em>正则语言</em>的字符串。这里的“语言”并不是指编程语言或者自然语言(比如英语或法语),而是数学上的意义,意味着“一组遵循某些规则的字符串”。</p> <p>正则语言是一组可以通过一组线性规则生成的字符串,简单地沿着候选字符串移动并匹配规则。² 例如:</p> <ol> <li> <p>至少写下字母<em>a</em>。</p> </li> <li> <p>在末尾添加字母<em>b</em>正好五次。</p> </li> <li> <p>在末尾追加字母<em>c</em>,偶数次。</p> </li> <li> <p>在末尾写下字母<em>d</em>或<em>e</em>。</p> </li> </ol> <p>正则表达式可以明确地确定:“是的,这个字符串符合规则”,或者“这个字符串不符合规则”。这对于快速扫描大文档以查找看起来像电话号码或电子邮件地址的字符串非常方便。</p> <p>符合上述规则的字符串,例如<em>aaaabbbbbccccd</em>,<em>aabbbbbcce</em>等等,数学上来说,有无限多个符合这种模式的字符串。</p> <p>正则表达式只是一种简写方式来表达这些规则集合。例如,这里就是刚才描述的一系列步骤的正则表达式:</p> <pre><code class="language-py">aa*bbbbb(cc)*(d|e) </code></pre> <p>虽然这个字符串一开始可能看起来有点吓人,但当你把它分解成其组成部分时就变得更清晰了:</p> <p><em><code>aa*</code></em></p> <p>首先写下字母<em>a</em>,然后是<em>a**(读作星号</em>),意思是“任意数量的 a,包括 0 个”。通过这种方式,你可以确保至少写下字母<em>a</em>。</p> <p><em><code>bbbbb</code></em></p> <p>这里没有特效,只是连续五个 b。</p> <p><em><code>(cc)*</code></em></p> <p>任何偶数个事物都可以成对分组,因此为了强制执行这个关于偶数事物的规则,你可以写两个 c,用括号括起来,并在其后写一个星号,表示你可以有任意数量的<em>c</em>对(注意这也可以意味着零对)。</p> <p><em><code>(d|e)</code></em></p> <p>在两个表达式中间添加一条竖线意味着它可以是“这个东西<em>或者</em>那个东西”。在这种情况下,你是在说“添加一个<em>d</em>或者一个<em>e</em>”。通过这种方式,你可以确保这两个字符中确切地有一个。</p> <h1 id="试验正则表达式">试验正则表达式</h1> <p>当学习如何编写正则表达式时,重要的是要尝试并了解它们的工作原理。如果你不想启动代码编辑器、写几行代码并运行你的程序来查看正则表达式是否按预期工作,你可以去<a href="http://regexpal.com/" target="_blank">RegEx Pal</a>等网站上实时测试你的正则表达式。</p> <p>表 5-1 列出了常用的正则表达式符号及简要解释和示例。这个列表并不完整,正如前面提到的,你可能会在不同的编程语言中遇到略有不同。然而,这 12 个符号是 Python 中最常用的正则表达式,几乎可以用来查找和收集任何类型的字符串。</p> <p>表 5-1. 常用正则表达式符号</p> <table> <thead> <tr> <th>符号</th> <th>含义</th> <th>示例</th> <th>示例匹配</th> </tr> </thead> <tbody> <tr> <td>*</td> <td>匹配前一个字符、子表达式或括号中的字符,0 次或更多次。</td> <td>a<em>b</em></td> <td>aaaaaaaa, aaabbbbb, bbbbbb</td> </tr> <tr> <td>+</td> <td>匹配前一个字符、子表达式或括号中的字符,1 次或更多次。</td> <td>a+b+</td> <td>aaaaaaaab, aaabbbbb, abbbbbb</td> </tr> </tbody> </table> <p>| [] | 匹配括号中的任何字符(即“从这些内容中挑选任意一个”)。 | [A-Z]* | APPLE, CAPITALS,</p> <p>QWERTY |</p> <table> <thead> <tr> <th>()</th> <th>分组的子表达式(这些按照正则表达式的“运算顺序”首先进行计算)。</th> <th>(a<em>b)</em></th> <th>aaabaab, abaaab, ababaaaaab</th> </tr> </thead> <tbody> <tr> <td></td> <td>匹配前一个字符、子表达式或括号中的字符<em>m</em>到<em>n</em>次(包括<em>m</em>和<em>n</em>)。</td> <td 2,3="">a{2,3}b</td> <td>aabbb, aaabbb, aabb</td> </tr> </tbody> </table> <p>| [^] | 匹配不在括号中的任何单个字符。 | [^A-Z]* | apple, lowercase,</p> <p>qwerty |</p> <table> <thead> <tr> <th>|</th> <th>匹配任何字符、字符串或由<code>I</code>(注意这是一条竖线,或称为<em>管道</em>,而不是大写字母 i)分隔的子表达式。</th> <th>b(a|i|e)d</th> <th>bad, bid, bed</th> </tr> </thead> <tbody> <tr> <td>.</td> <td>匹配任何单个字符(包括符号、数字、空格等)。</td> <td>b.d</td> <td>bad, bzd, b$d, b d</td> </tr> <tr> <td>^</td> <td>表示字符或子表达式出现在字符串的开头。</td> <td>^a</td> <td>apple, asdf, a</td> </tr> <tr> <td>\</td> <td>转义字符(这允许你将特殊字符用作它们的字面意义)。</td> <td>^ &#124; \</td> <td>^ | \</td> </tr> <tr> <td>$</td> <td>常用于正则表达式的末尾,表示“匹配直到字符串末尾”。如果没有它,每个正则表达式默认会以“.*”结尾,接受只匹配字符串第一部分的字符串。这可以类比为 ^ 符号的作用。</td> <td>[A-Z]<em>[a-z]</em>$</td> <td>ABCabc, zzzyx, Bob</td> </tr> <tr> <td>?!</td> <td>“不包含”。这对奇怪的符号,紧跟在一个字符(或正则表达式)之前,表示在更大的字符串中,该特定位置不应出现该字符。这可能会有些棘手;毕竟,该字符可能出现在字符串的其他部分。如果想要完全排除某个字符,可以与开头的 ^ 和末尾的 $ 结合使用。</td> <td>^((?![A-Z]).)*$</td> <td>no-caps-here, $ymb0ls a4e f!ne</td> </tr> </tbody> </table> <p>正则表达式的一个经典示例是识别电子邮件地址的实践。尽管确切的电子邮件地址规则略有不同,但我们可以制定几个通用规则。每个规则对应的正则表达式如下所示:</p> <p>|</p> <p>规则 1</p> <p>电子邮件地址的第一部分至少包含以下内容之一:大写字母、小写字母、数字 0–9、句点(.)、加号(+)或下划线(_)。</p> <p>|</p> <p><strong>[A-Za-z0-9._+]+</strong></p> <p>正则表达式的简写是非常聪明的。例如,它知道“A-Z”意味着“从 A 到 Z 的任何大写字母”。通过将所有可能的序列和符号放在方括号中(而不是圆括号中),您在说:“这个符号可以是我们在方括号中列出的任何一个。”还要注意,加号(+)号表示“这些字符可以出现任意次数,但必须至少出现一次。”</p> <p>|</p> <p>|</p> <p>规则 2</p> <p>在此之后,电子邮件地址包含 @ 符号。</p> <p>|</p> <p><strong>@</strong></p> <p>这相当简单:@ 符号必须出现在中间,并且必须恰好出现一次。</p> <p>|</p> <p>|</p> <p>规则 3</p> <p>邮件地址必须包含至少一个大写或小写字母。</p> <p>|</p> <p><strong>[A-Za-z]+</strong></p> <p>在 @ 符号后的域名的第一部分中只能使用字母,并且必须至少有一个字符。</p> <p>|</p> <p>|</p> <p>规则 4</p> <p>这之后跟着一个句点(.)。</p> <p>|</p> <p><strong>.</strong></p> <p>在顶级域名前必须包含一个句点(.)。</p> <p>|</p> <p>|</p> <p>规则 5</p> <p>最后,电子邮件地址以 <em>com</em>, <em>org</em>, <em>edu</em> 或 <em>net</em> 结尾(实际上,顶级域名有很多可能,但这四个例子足够说明问题)。</p> <p>|</p> <p><strong>(com|org|edu|net)</strong></p> <p>这列出了电子邮件地址第二部分中句点后可能出现的字母序列。</p> <p>|</p> <p>将所有规则连接起来,可以得到这个正则表达式:</p> <pre><code class="language-py">[A-Za-z0-9._+]+@[A-Za-z]+.(com|org|edu|net) </code></pre> <p>当试图从头开始编写任何正则表达式时,最好首先列出一系列具体步骤,明确说明你的目标字符串的样子。注意边缘情况。例如,如果你正在识别电话号码,你是否考虑了国家代码和分机号码?</p> <h1 id="正则表达式并非总是规则的">正则表达式:并非总是规则的!</h1> <p>正则表达式的标准版本(本书涵盖的版本并由 Python 和 BeautifulSoup 使用)基于 Perl 使用的语法。大多数现代编程语言使用这个或类似的语法。然而,请注意,如果您在另一种语言中使用正则表达式,可能会遇到问题。甚至一些现代语言,如 Java,在处理正则表达式时也有细微差异。如果有疑问,请阅读文档!</p> <h1 id="正则表达式和-beautifulsoup">正则表达式和 BeautifulSoup</h1> <p>如果前一节关于正则表达式的内容似乎与本书的任务有些脱节,那么这就是它们之间的联系。BeautifulSoup 和正则表达式在网页抓取方面是密不可分的。事实上,大多数接受字符串参数的函数(例如 <code>find(id="aTagIdHere")</code>)同样也可以接受正则表达式。</p> <p>让我们来看一些例子,抓取<a href="http://www.pythonscraping.com/pages/page3.html" target="_blank"><em>http://www.pythonscraping.com/pages/page3.html</em></a> 所找到的页面。</p> <p>请注意,该网站有许多产品图片,其形式如下:</p> <pre><code class="language-py"><img src="../img/gifts/img3.jpg"> </code></pre> <p>如果你想要抓取所有产品图片的 URL,一开始似乎相当简单:只需使用 <code>.find_all("img")</code> 来抓取所有图像标签,对吗?但是有一个问题。除了明显的“额外”图像(例如标志),现代网站经常还有隐藏的图像,用于间隔和对齐元素的空白图像,以及其他您可能不知道的随机图像标签。当然,你不能指望页面上唯一的图像是产品图像。</p> <p>假设页面的布局可能会发生变化,或者出于某种原因,你不想依赖于图像在页面中的<em>位置</em>来找到正确的标签。当你试图抓取分散在网站各处的特定元素或数据片段时,情况可能是这样的。例如,一个特色产品图片可能会出现在某些页面顶部的特殊布局中,但在其他页面中则不会。</p> <p>解决方案是查找标签本身的某些特征。在这种情况下,你可以查看产品图片的文件路径:</p> <pre><code class="language-py">from urllib.request import urlopen from bs4 import BeautifulSoup import re html = urlopen('http://www.pythonscraping.com/pages/page3.html') bs = BeautifulSoup(html, 'html.parser') images = bs.find_all('img',   {'src':re.compile('..\/img\/gifts/img.*.jpg')}) for image in images:      print(image['src']) </code></pre> <p>这将仅打印以<em>../img/gifts/img</em>开头并以<em>.jpg</em>结尾的相对图像路径,其输出为:</p> <pre><code class="language-py">../img/gifts/img1.jpg ../img/gifts/img2.jpg ../img/gifts/img3.jpg ../img/gifts/img4.jpg ../img/gifts/img6.jpg </code></pre> <p>正则表达式可以作为 BeautifulSoup 表达式中的任何参数插入,从而允许你以很大的灵活性来找到目标元素。</p> <h1 id="访问属性">访问属性</h1> <p>到目前为止,你已经学会了如何访问和过滤标签以及访问其中的内容。然而,在网页抓取中,通常你不是在寻找标签的内容,而是在寻找它们的属性。这对于诸如<code>a</code>标签特别有用,它指向的 URL 包含在<code>href</code>属性中;或者<code>img</code>标签,其中目标图像包含在<code>src</code>属性中。</p> <p>对于标签对象,可以通过调用这个来自动访问 Python 列表的属性:</p> <pre><code class="language-py">myTag.attrs </code></pre> <p>请记住,这实际上返回一个 Python 字典对象,这使得检索和操作这些属性变得轻而易举。例如,可以使用以下行找到图像的源位置:</p> <pre><code class="language-py">myImgTag.attrs['src'] </code></pre> <h1 id="lambda-表达式">Lambda 表达式</h1> <p><em>Lambda</em>是一个花哨的学术术语,在编程中,它简单地意味着“一种编写函数的简便方法”。在 Python 中,我们可以编写一个返回一个数的平方的函数如下:</p> <pre><code class="language-py">def square(n):   return n**2 </code></pre> <p>我们可以使用 lambda 表达式在一行内完成同样的事情:</p> <pre><code class="language-py">square = lambda n: n**2 </code></pre> <p>此处将变量<code>square</code>直接分配给一个函数,该函数接受一个参数<code>n</code>并返回<code>n**2</code>。但并没有规定函数必须“命名”或者根本不分配给变量。我们可以将它们作为值来编写:</p> <pre><code class="language-py">>>> lambda r: r**2 <function <lambda> at 0x7f8f88223a60> </code></pre> <p>本质上,<em>lambda 表达式</em>是一个独立存在的函数,没有被命名或分配给变量。在 Python 中,lambda 函数不能超过一行代码(这是 Python 风格和良好品味的问题,而不是计算机科学的一项基本规则)。</p> <p>lambda 表达式最常见的用途是作为传递给其他函数的参数。BeautifulSoup 允许你将某些类型的函数作为参数传递到<code>find_all</code>函数中。</p> <p>唯一的限制是这些函数必须接受一个标签对象作为参数,并返回一个布尔值。BeautifulSoup 遇到的每个标签对象都在此函数中评估,评估为<code>True</code>的标签被返回,其余被丢弃。</p> <p>例如,以下代码检索所有具有恰好两个属性的标签:</p> <pre><code class="language-py">bs.find_all(lambda tag: len(tag.attrs) == 2) </code></pre> <p>在这里,作为参数传递的函数是<code>len(tag.attrs) == 2</code>。当这个条件成立时,<code>find_all</code>函数将返回该标签。也就是说,它会找到具有两个属性的标签,例如:</p> <pre><code class="language-py"><div class="body" id="content"></div> <span style="color:red" class="title"></span> </code></pre> <p>Lambda 函数非常有用,你甚至可以使用它们来替换现有的 BeautifulSoup 函数:</p> <pre><code class="language-py">bs.find_all(lambda tag: tag.get_text() ==   'Or maybe he\'s only resting?') </code></pre> <p>这也可以不使用 lambda 函数来完成:</p> <pre><code class="language-py">bs.find_all('', text='Or maybe he\'s only resting?') </code></pre> <p>然而,如果你记得 lambda 函数的语法以及如何访问标签属性,你可能再也不需要记住任何其他 BeautifulSoup 语法了!</p> <p>因为提供的 lambda 函数可以是返回<code>True</code>或<code>False</code>值的任何函数,你甚至可以将它们与正则表达式结合起来,以找到具有匹配特定字符串模式的属性的标签。</p> <h1 id="你不总是需要一把锤子">你不总是需要一把锤子</h1> <p>当面对一团复杂的标签时,直接潜入并使用多行语句来提取信息是很诱人的。然而,请记住,放任这一章节中使用的技术层叠可能会导致难以调试或脆弱的代码。让我们看看一些可以避免需要高级 HTML 解析的方法。</p> <p>假设你有一些目标内容。也许是一个名字、统计数据或者一段文字。也许它被深深埋在 HTML 混乱中,没有有用的标签或者 HTML 属性。你可能会决定冒险一试,写下像以下这样的代码尝试提取:</p> <pre><code class="language-py">bs.find_all('table')[4].find_all('tr')[2].find('td').find_all('div')[1].find('a') </code></pre> <p>看起来不太好。除了行的美观度之外,即使网站管理员对网站进行了微小的更改,也可能会完全破坏你的网络爬虫。如果站点的 Web 开发人员决定添加另一个表或另一列数据,该怎么办?如果开发人员在页面顶部添加了另一个组件(带有几个<code>div</code>标签),前面的行就会变得岌岌可危,依赖于网站结构永远不会改变。</p> <p>所以,你有哪些选择?</p> <ul> <li> <p>寻找任何可以用来直接跳到文档中间位置的“地标”,更接近你实际想要的内容。方便的 CSS 属性是一个明显的地标,但你也可以创造性地通过<code>.find_all(text='some tag content')</code>来获取标签的内容。</p> </li> <li> <p>如果没有简单的方法来分离你想要的标签或其任何父级,你能找到一个兄弟节点吗?使用<code>.parent</code>方法,然后再深入到目标标签。</p> </li> <li> <p>完全放弃这个文档,寻找“打印本页”链接,或者可能有更好格式的移动版网站(关于如何呈现自己为移动设备,并接收移动网站版本,请参见第十七章)。</p> </li> <li> <p>不要忽视<code><script></code>标签中的内容或单独加载的 JavaScript 文件。JavaScript 通常包含你正在寻找的数据,并且格式更好!例如,我曾通过检查嵌入的 Google 地图应用程序的 JavaScript,从一个网站收集了格式良好的街道地址。有关此技术的更多信息,请参阅第十一章。</p> </li> <li> <p>信息可能可以在页面的 URL 中找到。例如,页面标题和产品 ID 通常可以在那里找到。</p> </li> <li> <p>如果你正在寻找的信息由于某种原因只在这个网站上唯一,那你就没戏了。如果不是,请考虑其他可能来源的信息。是否有另一个网站有相同的数据?这个网站是否展示了从另一个网站抓取或聚合的数据?</p> </li> </ul> <p>尤其是当面对埋藏或格式混乱的数据时,重要的是不要只是开始挖掘并把自己写入一个可能无法走出的困境中。深呼吸,考虑一些替代方案。</p> <p>当正确使用时,这里介绍的技术将在很大程度上有助于编写更稳定可靠的网络爬虫。</p> <p>¹ 如果你想要获取文档中所有<code>h<*some_level*></code>标签的列表,有更简洁的编写这段代码以完成相同任务的方法。我们将在“正则表达式和 BeautifulSoup”一节中看看其他解决这类问题的方法。</p> <p>² 也许你会问自己,“有‘不规则’的语言和不规则表达式吗?” 非正则表达式超出了本书的范围,但它们包括诸如“写下一串 a 的质数,然后是正好两倍于该数的 b”的字符串或“写下一个回文”。 使用正则表达式无法识别这种类型的字符串。 幸运的是,我从未遇到过我的网络爬虫需要识别这种字符串的情况。</p> <h1 id="第六章写作网络爬虫">第六章:写作网络爬虫</h1> <p>到目前为止,你已经看到了一些带有人为干预的静态页面示例。在本章中,你将开始研究真实世界中的问题,使用爬虫来遍历多个页面,甚至是多个站点。</p> <p><em>网络爬虫</em>之所以被称为如此,是因为它们横跨整个网络。它们的核心是递归的元素。它们必须获取 URL 的页面内容,检查该页面中的其他 URL,并递归地获取<em>那些</em>页面。</p> <p>但要注意:仅仅因为你可以爬取网页,并不意味着你总是应该这样做。在前面的示例中使用的爬虫在需要获取的数据都在单个页面上的情况下效果很好。使用网络爬虫时,你必须非常注意你使用了多少带宽,并且要尽一切努力确定是否有办法减轻目标服务器的负载。</p> <h1 id="遍历单个域">遍历单个域</h1> <p>即使你没有听说过维基百科的六度分隔,你可能听说过它的名字起源,即凯文·贝肯的六度分隔¹。在这两个游戏中,目标是通过包含不超过六个总数的链条(包括两个初始主题)来链接两个不太可能的主题(在第一种情况下,是互相链接的维基百科文章;在第二种情况下,是出演同一部电影的演员)。</p> <p>例如,埃里克·艾德尔与布伦丹·弗雷泽一起出演了<em>Dudley Do-Right</em>,而布伦丹·弗雷泽又与凯文·贝肯一起出演了<em>我呼吸的空气</em>²。在这种情况下,从埃里克·艾德尔到凯文·贝肯的链只有三个主题。</p> <p>在本节中,你将开始一个项目,这个项目将成为一个维基百科六度分隔解决方案的发现者:你将能够从<a href="https://en.wikipedia.org/wiki/Eric_Idle" target="_blank">埃里克·艾德尔页面</a>出发,找到需要最少的链接点击数将你带到<a href="https://en.wikipedia.org/wiki/Kevin_Bacon" target="_blank">凯文·贝肯页面</a>。</p> <p>你应该已经知道如何编写一个 Python 脚本,用于获取任意维基百科页面并生成该页面上的链接列表:</p> <pre><code class="language-py">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']) </code></pre> <p>如果你查看生成的链接列表,你会注意到所有你期望的文章都在那里:<em>阿波罗 13 号</em>、<em>费城</em>、<em>黄金时段艾美奖</em>,以及凯文·贝肯出演的其他电影。然而,也有一些你可能不想要的东西:</p> <pre><code class="language-py">//foundation.wikimedia.org/wiki/Privacy_policy //en.wikipedia.org/wiki/Wikipedia:Contact_us </code></pre> <p>实际上,维基百科中充满了侧边栏、页脚和页眉链接,这些链接出现在每个页面上,还有链接到分类页面、讨论页面以及不包含不同文章的其他页面:</p> <pre><code class="language-py">/wiki/Category:All_articles_with_unsourced_statements /wiki/Talk:Kevin_Bacon </code></pre> <p>最近,我的一个朋友在进行类似的维基百科爬取项目时提到,他编写了一个大型的过滤函数,超过 100 行代码,用于确定内部维基百科链接是否是文章页面。不幸的是,他没有花太多时间在前期尝试找出“文章链接”和“其他链接”之间的模式,否则他可能已经发现了窍门。如果你检查指向文章页面的链接,你会发现它们有三个共同点:</p> <ul> <li> <p>它们位于<code>id</code>设置为<code>bodyContent</code>的<code>div</code>内。</p> </li> <li> <p>这些 URL 不包含冒号。</p> </li> <li> <p>这些 URL 以<em>/wiki/</em>开头。</p> </li> </ul> <p>你可以使用这些规则稍微修改代码,只检索所需的文章链接,方法是使用正则表达式<code>^(/wiki/)((?!:).)*$</code>:</p> <pre><code class="language-py">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']) </code></pre> <p>运行此程序,你应该会看到维基百科关于 Kevin Bacon 的所有文章 URL 列表。</p> <p>当然,拥有一个在一个硬编码的维基百科文章中找到所有文章链接的脚本,虽然很有趣,但在实践中却相当无用。你需要能够拿着这段代码,将其转换成更像下面这样的东西:</p> <ul> <li> <p>一个单独的函数,<code>getLinks</code>,它接受形式为<code>/wiki/<Article_Name></code>的维基百科文章 URL,并返回相同形式的所有链接的文章 URL 列表。</p> </li> <li> <p>一个调用<code>getLinks</code>的主函数,以一个起始文章作为参数,从返回的列表中选择一个随机文章链接,并再次调用<code>getLinks</code>,直到停止程序或者在新页面上找不到文章链接为止。</p> </li> </ul> <p>这是完成此操作的完整代码:</p> <pre><code class="language-py">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) </code></pre> <p>程序导入所需的库后,第一件事是使用当前系统时间设置随机数生成器的种子。这实际上确保了每次运行程序时都会得到一个新的有趣的随机路径通过维基百科文章。</p> <p>接下来,程序定义了<code>getLinks</code>函数,该函数接受形式为<code>/wiki/...</code>的文章 URL,加上维基百科域名<code>http://en.wikipedia.org</code>,并检索该域中 HTML 的<code>BeautifulSoup</code>对象。然后根据前面讨论的参数提取文章链接标签列表,并返回它们。</p> <p>程序的主体从设置文章链接标签列表(<code>links</code>变量)为初始页面中的链接列表开始:<em><a href="https://en.wikipedia.org/wiki/Kevin_Bacon" target="_blank">https://en.wikipedia.org/wiki/Kevin_Bacon</a></em>。然后进入循环,找到页面中的一个随机文章链接标签,提取其中的<code>href</code>属性,打印页面,并从提取的 URL 获取一个新的链接列表。</p> <p>当然,解决维基百科的六度问题比构建一个从页面到页面的爬虫更多一些。你还必须能够存储和分析所得到的数据。要继续解决此问题的解决方案,请参见第九章。</p> <h1 id="处理你的异常">处理你的异常!</h1> <p>尽管这些代码示例为简洁起见省略了大部分异常处理,但请注意可能会出现许多潜在问题。例如,如果维基百科更改了<code>bodyContent</code>标签的名称会怎样?当程序尝试从标签中提取文本时,它会抛出<code>AttributeError</code>。</p> <p>因此,虽然这些脚本可能很适合作为密切观察的例子运行,但自主生产代码需要比本书中所能容纳的异常处理要多得多。查看第四章了解更多信息。</p> <h1 id="爬取整个站点">爬取整个站点</h1> <p>在前一节中,你随机地浏览了一个网站,从一个链接跳到另一个链接。但是如果你需要系统地编目或搜索站点上的每一页呢?爬行整个站点,特别是大型站点,是一个占用内存的过程,最适合于那些可以轻松存储爬行结果的应用程序。然而,即使不是全面运行,你也可以探索这些类型应用程序的行为。要了解更多关于使用数据库运行这些应用程序的信息,请参见第九章。</p> <p>什么时候爬整个网站可能有用,什么时候可能有害?遍历整个站点的网页抓取器适合于许多用途,包括:</p> <p><em>生成站点地图</em></p> <p>几年前,我面临一个问题:一个重要的客户想要对网站重新设计提供估价,但不愿向我的公司提供其当前内容管理系统的内部访问权限,也没有公开的站点地图。我能够使用爬虫覆盖整个站点,收集所有内部链接,并将页面组织成实际在站点上使用的文件夹结构。这让我能够快速找到我甚至不知道存在的站点部分,并准确计算所需的页面设计数量以及需要迁移的内容量。</p> <p><em>收集数据</em></p> <p>另一个客户想要收集文章(故事、博客文章、新闻文章等),以创建一个专业搜索平台的工作原型。虽然这些网站爬行不需要详尽,但它们确实需要相当广泛(我们只对从几个站点获取数据感兴趣)。我能够创建爬虫,递归地遍历每个站点,并仅收集在文章页面上找到的数据。</p> <p>对于详尽的网站爬行,一般的方法是从顶级页面(比如首页)开始,并搜索该页面上的所有内部链接列表。然后对每个链接进行爬行,并在每个链接上找到其他链接列表,触发另一轮爬行。</p> <p>显然,这是一个可能迅速扩展的情况。如果每页有 10 个内部链接,而网站深度为 5 页(对于中等大小的网站来说是一个相当典型的深度),那么你需要爬行的页面数为 10⁵,即 100,000 页,才能确保你已经详尽地覆盖了整个网站。奇怪的是,虽然“每页 5 个深度和每页 10 个内部链接”是网站的相当典型的维度,但很少有网站拥有 100,000 页或更多页面。当然,原因在于绝大多数的内部链接是重复的。</p> <p>为了避免两次爬取同一页,非常重要的是,发现的所有内部链接都要一致地格式化,并在程序运行时保持在一个运行集合中进行简单查找。<em>集合</em> 类似于列表,但元素没有特定的顺序,并且只存储唯一的元素,这对我们的需求是理想的。只有“新”的链接应该被爬取,并搜索其他链接:</p> <pre><code class="language-py">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('') </code></pre> <p>为了向你展示这种网络爬行业务的完整效果,我放宽了什么构成内部链接的标准(来自之前的示例)。不再将爬虫限制于文章页面,而是查找所有以 <em>/wiki/</em> 开头的链接,无论它们在页面的何处,也无论它们是否包含冒号。请记住:文章页面不包含冒号,但文件上传页面、讨论页面等在 URL 中包含冒号。</p> <p>最初,使用空 URL 调用 <code>getLinks</code>。这被翻译为“维基百科的首页”,因为空 URL 在函数内部以 <code>http://en.wikipedia.org</code> 开头。然后,对首页上的每个链接进行迭代,并检查它是否在脚本已遇到的页面集合中。如果没有,将其添加到列表中,打印到屏幕上,并递归调用 <code>getLinks</code>。</p> <h1 id="递归的警告">递归的警告</h1> <p>这是在软件书籍中很少见的警告,但我认为你应该知道:如果运行时间足够长,前面的程序几乎肯定会崩溃。</p> <p>Python 的默认递归限制(程序可以递归调用自身的次数)为 1,000。由于维基百科的链接网络非常庞大,这个程序最终会达到递归限制并停止,除非你在其中加入递归计数器或其他东西以防止这种情况发生。</p> <p>对于少于 1,000 个链接深度的“平”站点,这种方法通常效果良好,但也有一些特殊情况。例如,我曾经遇到过一个动态生成的 URL 的 bug,它依赖于当前页面的地址来在该页面上写入链接。这导致了无限重复的路径,如 <em>/blogs/blogs.../blogs/blog-post.php</em>。</p> <p>大多数情况下,这种递归技术对于你可能遇到的任何典型网站应该都没问题。</p> <h2 id="在整个站点收集数据">在整个站点收集数据</h2> <p>如果网络爬虫只是从一个页面跳到另一个页面,那么它们会变得相当无聊。为了使它们有用,你需要在访问页面时能够做些事情。让我们看看如何构建一个爬虫,收集标题、内容的第一个段落,并且(如果有的话)编辑页面的链接。</p> <p>与往常一样,确定最佳方法的第一步是查看站点的几个页面并确定一个模式。通过查看一些维基百科页面(包括文章和非文章页面,如隐私政策页面),应该清楚以下几点:</p> <ul> <li> <p>所有标题(无论其是否作为文章页面、编辑历史页面或任何其他页面存在)都在<code>h1</code> → <code>span</code>标签下,这些是页面上唯一的<code>h1</code>标签。</p> </li> <li> <p>正如之前提到的,所有正文文本都位于<code>div#bodyContent</code>标签下。然而,如果你想更加具体并且只访问文本的第一个段落,那么最好使用<code>div#mw-content-text</code> → <code>​p</code>(仅选择第一个段落标签)。这对所有内容页面都适用,但不适用于文件页面(例如,<a href="https://en.wikipedia.org/wiki/File:Orbit_of_274301_Wikipedia.svg" target="_blank"><em>https://en.wikipedia.org/wiki/File:Orbit_of_274301_Wikipedia.svg</em></a>),因为这些页面没有内容文本的部分。</p> </li> <li> <p>编辑链接仅出现在文章页面上。如果它们存在,它们将在<code>li#ca-edit</code>标签下找到,位于<code>li#ca-edit</code> →​ <code>span</code> →​ <code>a</code>。</p> </li> </ul> <p>通过修改您的基本爬取代码,您可以创建一个组合爬虫/数据收集(或至少是数据打印)程序:</p> <pre><code class="language-py">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('')  </code></pre> <p>该程序中的<code>for</code>循环基本上与原始爬虫程序中的相同(增加了打印内容之间的打印破折号以增加清晰度)。</p> <p>由于你永远不能完全确定每个页面上是否有所有数据,因此每个<code>print</code>语句都按照最有可能出现在页面上的顺序排列。也就是说,<code>h1</code>标题标签似乎出现在每个页面上(至少在我所知的范围内),因此首先尝试获取该数据。文本内容出现在大多数页面上(除了文件页面),因此这是第二个检索到的数据片段。编辑按钮仅出现在已经存在标题和文本内容的页面上,但并非所有这些页面都有该按钮。</p> <h1 id="不同的需求有不同的模式">不同的需求有不同的模式</h1> <p>显然,将多行包装在异常处理程序中存在一些危险。例如,你无法判断是哪一行抛出了异常。此外,如果某个页面包含编辑按钮但没有标题,编辑按钮将永远不会被记录。然而,在许多实例中,这足以满足页面上物品出现的可能顺序,并且无意中漏掉一些数据点或保留详细日志并不是问题。</p> <p>你可能会注意到,在此及之前的所有示例中,你不是“收集”数据,而是“打印”它。显然,在终端中操作数据是很困难的。有关存储信息和创建数据库的更多信息,请参阅第九章。</p> <h1 id="横跨互联网的爬虫">横跨互联网的爬虫</h1> <p>每当我在网页抓取方面发表演讲时,总会有人不可避免地问:“你怎么建造 Google?”我的答案总是双重的:“首先,你需要获得数十亿美元,这样你就能购买世界上最大的数据仓库,并将它们放置在世界各地的隐藏位置。其次,你需要构建一个网络爬虫。”</p> <p>当 Google 于 1996 年开始时,只是两位斯坦福大学研究生和一台旧服务器以及一个 Python 网络爬虫。现在,你已经知道如何抓取网络,只需一些风险投资,你就可能成为下一个科技亿万富翁!</p> <p>网络爬虫是许多现代网络技术的核心,不一定需要大型数据仓库来使用它们。要进行跨域数据分析,确实需要构建能够解释和存储互联网上大量页面数据的爬虫。</p> <p>正如前面的例子一样,你将要构建的网络爬虫将跟随页面到页面的链接,构建出网络的地图。但是这次,它们不会忽略外部链接;它们将会跟随它们。</p> <h1 id="未知的前方">未知的前方</h1> <p>请记住,下一节中的代码可以放在互联网的<em>任何地方</em>。如果我们从《维基百科的六度》中学到了什么,那就是从<a href="http://www.sesamestreet.org/" target="_blank"><em>http://www.sesamestreet.org/</em></a>这样的网站到一些不那么愉快的地方只需几个跳转。</p> <p>孩子们,在运行此代码之前,请征得父母的同意。对于那些有敏感情绪或因宗教限制而禁止查看淫秽网站文本的人,请通过阅读代码示例跟随进行,但在运行时要小心。</p> <p>在你开始编写任意跟踪所有出站链接的爬虫之前,请先问自己几个问题:</p> <ul> <li> <p>我试图收集哪些数据?这是否可以通过仅抓取几个预定义的网站(几乎总是更简单的选项)来完成,还是我的爬虫需要能够发现我可能不知道的新网站?</p> </li> <li> <p>当我的爬虫到达特定网站时,它会立即跟随下一个出站链接到一个新的网站,还是会停留一段时间并深入到当前网站?</p> </li> <li> <p>是否存在任何条件使我不想抓取特定网站?我对非英语内容感兴趣吗?</p> </li> <li> <p>如果我的网络爬虫引起某个网站管理员的注意,我如何保护自己免受法律行动?(查看第二章获取更多信息。)</p> </li> </ul> <p>可以轻松编写一组灵活的 Python 函数,这些函数可以组合执行各种类型的网络抓取,代码行数少于 60 行。这里,出于简洁考虑,我省略了库导入,并将代码分成多个部分进行讨论。然而,完整的工作版本可以在本书的<a href="https://github.com/REMitchell/python-scraping" target="_blank">GitHub 存储库</a>中找到:</p> <pre><code class="language-py">#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) </code></pre> <p>第一个函数是<code>getInternalLinks</code>。它以 BeautifulSoup 对象和页面的 URL 作为参数。此 URL 仅用于标识内部站点的<code>netloc</code>(网络位置)和<code>scheme</code>(通常为<code>http</code>或<code>https</code>),因此重要的是要注意,可以在此处使用目标站点的任何内部 URL——不需要是传入的 BeautifulSoup 对象的确切 URL。</p> <p>这个函数创建了一个名为<code>internalLinks</code>的集合,用于跟踪页面上找到的所有内部链接。它检查所有锚标签的<code>href</code>属性,如果<code>href</code>属性不包含<code>netloc</code>(即像“/careers/”这样的相对 URL)或者具有与传入的 URL 相匹配的<code>netloc</code>,则会检查:</p> <pre><code class="language-py">#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) </code></pre> <p>函数<code>getExternalLinks</code>的工作方式与<code>getInternalLinks</code>类似。它检查所有带有<code>href</code>属性的锚标签,并寻找那些具有不与传入的 URL 相匹配的填充<code>netloc</code>的标签:</p> <pre><code class="language-py">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) </code></pre> <p>函数<code>getRandomExternalLink</code>使用函数<code>getExternalLinks</code>获取页面上所有外部链接的列表。如果找到至少一个链接,则从列表中选择一个随机链接并返回:</p> <pre><code class="language-py">def followExternalOnly(startingSite):     externalLink = getRandomExternalLink(startingSite)     print(f'Random external link is: {externalLink}')     followExternalOnly(externalLink) </code></pre> <p>函数<code>followExternalOnly</code>使用<code>getRandomExternalLink</code>然后递归地遍历整个互联网。你可以这样调用它:</p> <pre><code class="language-py">followExternalOnly('https://www.oreilly.com/') </code></pre> <p>这个程序从<a href="http://oreilly.com" target="_blank"><em>http://oreilly.com</em></a>开始,然后随机跳转到外部链接。以下是它产生的输出示例:</p> <pre><code class="language-py">http://igniteshow.com/ http://feeds.feedburner.com/oreilly/news http://hire.jobvite.com/CompanyJobs/Careers.aspx?c=q319 http://makerfaire.com/ </code></pre> <p>外部链接并不总是能在网站的第一页找到。在这种情况下查找外部链接,会采用类似于前面爬虫示例中使用的方法,递归地深入网站,直到找到外部链接。</p> <p>图 6-1 以流程图形式说明了其操作。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_0601.png" alt="Alt Text" loading="lazy"></p> <h6 id="图6-1-爬取互联网站点的脚本流程图">图 6-1. 爬取互联网站点的脚本流程图</h6> <h1 id="不要将示例程序投入生产">不要将示例程序投入生产</h1> <p>我一直强调这一点,但为了节省空间和提高可读性,本书中的示例程序并不总是包含生产级代码所需的必要检查和异常处理。例如,如果在此爬虫遇到的网站上未找到任何外部链接(这不太可能,但如果你运行足够长的时间,总会发生),这个程序将继续运行,直到达到 Python 的递归限制。</p> <p>增加此爬虫的健壮性的一种简单方法是将其与第四章中的连接异常处理代码结合起来。这将允许代码在检索页面时遇到 HTTP 错误或服务器异常时选择不同的 URL 进行跳转。</p> <p>在为任何严肃的目的运行此代码之前,请确保您正在采取措施来处理潜在的陷阱。</p> <p>将任务分解为简单函数(如“查找此页面上的所有外部链接”)的好处在于,稍后可以重构代码以执行不同的爬虫任务。例如,如果你的目标是爬取整个站点的外部链接并记录每一个,可以添加以下函数:</p> <pre><code class="language-py"># 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/') </code></pre> <p>这段代码可以看作是两个循环的组合——一个收集内部链接,一个收集外部链接。流程图看起来类似于图 6-2。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_0602.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-6-2-收集所有外部链接的网站爬虫流程图">图 6-2. 收集所有外部链接的网站爬虫流程图</h6> <p>在编写代码之前记下或制作代码应该完成的内容的图表是一个极好的习惯,并且可以在爬虫变得更加复杂时为你节省大量时间和烦恼。</p> <p>¹ 1990 年代创造的一种流行的客厅游戏,<a href="https://en.wikipedia.org/wiki/Six_Degrees_of_Kevin_Bacon" target="_blank"><em>https://en.wikipedia.org/wiki/Six_Degrees_of_Kevin_Bacon</em></a>。</p> <p>² 感谢<a href="http://oracleofbacon.org" target="_blank">培根的神谕</a> 满足了我对这一特定链条的好奇心。</p> <p>³ 参见<a href="http://nyti.ms/2pohZmu" target="_blank">“探索谷歌无法理解的‘深网’”</a> 由亚历克斯·赖特撰写。</p> <p>⁴ 参见<a href="http://bit.ly/2psIw2M" target="_blank">“黑客词汇表:什么是暗网?”</a> 由安迪·格林伯格撰写。</p> <h1 id="第七章网络爬虫模型">第七章:网络爬虫模型</h1> <p>当你控制数据和输入时,编写干净、可扩展的代码已经足够困难了。编写网页爬虫的代码,可能需要从程序员无法控制的各种网站中抓取和存储各种数据,通常会带来独特的组织挑战。</p> <p>你可能会被要求从各种网站上收集新闻文章或博客文章,每个网站都有不同的模板和布局。一个网站的 <code>h1</code> 标签包含文章的标题,另一个网站的 <code>h1</code> 标签包含网站本身的标题,而文章标题在 <code><span id="title"></code> 中。</p> <p>你可能需要灵活地控制哪些网站被抓取以及它们如何被抓取,并且需要一种快速添加新网站或修改现有网站的方法,而不需要编写多行代码。</p> <p>你可能被要求从不同网站上抓取产品价格,最终目标是比较相同产品的价格。也许这些价格是以不同的货币计价的,也许你还需要将其与某些其他非网络来源的外部数据结合起来。</p> <p>虽然网络爬虫的应用几乎是无穷无尽的,但是大型可扩展的爬虫往往会落入几种模式之一。通过学习这些模式,并识别它们适用的情况,你可以极大地提高网络爬虫的可维护性和健壮性。</p> <p>本章主要关注收集有限数量“类型”数据的网络爬虫(例如餐馆评论、新闻文章、公司简介)从各种网站收集这些数据类型,并将其存储为 Python 对象,从数据库中读写。</p> <h1 id="规划和定义对象">规划和定义对象</h1> <p>网页抓取的一个常见陷阱是完全基于眼前可见的内容定义你想要收集的数据。例如,如果你想收集产品数据,你可能首先看一下服装店,决定你要抓取的每个产品都需要有以下字段:</p> <ul> <li> <p>产品名称</p> </li> <li> <p>价格</p> </li> <li> <p>描述</p> </li> <li> <p>尺码</p> </li> <li> <p>颜色</p> </li> <li> <p>织物类型</p> </li> <li> <p>客户评分</p> </li> </ul> <p>当你查看另一个网站时,你发现它在页面上列出了 SKU(用于跟踪和订购商品的库存单位)你肯定也想收集这些数据,即使在第一个网站上看不到它!你添加了这个字段:</p> <ul> <li>商品 SKU</li> </ul> <p>虽然服装可能是一个很好的起点,但你也希望确保你可以将这个爬虫扩展到其他类型的产品上。你开始浏览其他网站的产品部分,并决定你还需要收集以下信息:</p> <ul> <li> <p>精装/平装</p> </li> <li> <p>亚光/亮光打印</p> </li> <li> <p>客户评论数量</p> </li> <li> <p>制造商链接</p> </li> </ul> <p>显然,这种方法是不可持续的。每次在网站上看到新的信息时,简单地将属性添加到产品类型中将导致要跟踪的字段过多。不仅如此,每次抓取新的网站时,你都将被迫对网站具有的字段和到目前为止积累的字段进行详细分析,并可能添加新字段(修改你的 Python 对象类型和数据库结构)。这将导致一个混乱且难以阅读的数据集,可能会导致在使用它时出现问题。</p> <p>在决定收集哪些数据时,你经常最好的做法是忽略网站。你不会通过查看单个网站并说“存在什么?”来开始一个旨在大规模和可扩展的项目,而是通过说“我需要什么?”然后找到从那里获取所需信息的方法。</p> <p>也许你真正想做的是比较多家商店的产品价格,并随着时间跟踪这些产品价格。在这种情况下,你需要足够的信息来唯一标识产品,就是这样:</p> <ul> <li> <p>产品标题</p> </li> <li> <p>制造商</p> </li> <li> <p>产品 ID 编号(如果可用/相关)</p> </li> </ul> <p>重要的是要注意,所有这些信息都不特定于特定商店。例如,产品评论、评级、价格,甚至描述都特定于特定商店中的该产品的实例。这可以单独存储。</p> <p>其他信息(产品的颜色、材质)是产品特定的,但可能稀疏——并非适用于每种产品。重要的是要退后一步,对你考虑的每个项目执行一个清单,并问自己这些问题:</p> <ul> <li> <p>这些信息是否有助于项目目标?如果我没有它,是否会成为一个障碍,还是它只是“好有”但最终不会对任何事情产生影响?</p> </li> <li> <p>如果<em>可能</em>将来有用,但我不确定,那么在以后收集这些数据会有多困难?</p> </li> <li> <p>这些数据是否与我已经收集的数据重复了?</p> </li> <li> <p>在这个特定对象中存储数据是否有逻辑意义?(如前所述,如果一个产品的描述在不同网站上发生变化,则在产品中存储描述是没有意义的。)</p> </li> </ul> <p>如果你决定需要收集这些数据,重要的是要提出更多问题,然后决定如何在代码中存储和处理它:</p> <ul> <li> <p>这些数据是稀疏的还是密集的?它在每个列表中是否都是相关且填充的,还是只有一小部分是相关的?</p> </li> <li> <p>数据有多大?</p> </li> <li> <p>尤其是在大数据的情况下,我需要每次运行分析时定期检索它,还是只在某些情况下检索它?</p> </li> <li> <p>这种类型的数据变化多大?我是否经常需要添加新属性,修改类型(如可能经常添加的面料图案),还是它是固定的(鞋码)?</p> </li> </ul> <p>假设您计划围绕产品属性和价格进行一些元分析:例如,一本书的页数,或一件衣服的面料类型,以及未来可能的其他属性,与价格相关。您仔细思考这些问题,并意识到这些数据是稀疏的(相对较少的产品具有任何一个属性),并且您可能经常决定添加或删除属性。在这种情况下,创建一个如下所示的产品类型可能是有意义的:</p> <ul> <li> <p>产品标题</p> </li> <li> <p>制造商</p> </li> <li> <p>产品 ID 编号(如适用/相关)</p> </li> <li> <p>属性(可选列表或字典)</p> </li> </ul> <p>以及以下类似的属性类型:</p> <ul> <li> <p>属性名称</p> </li> <li> <p>属性值</p> </li> </ul> <p>这使您能够随时间灵活添加新的产品属性,而无需重新设计数据模式或重写代码。在决定如何在数据库中存储这些属性时,您可以将 JSON 写入<code>attribute</code>字段,或者将每个属性存储在一个带有产品 ID 的单独表中。有关实现这些类型数据库模型的更多信息,请参见第九章。</p> <p>您可以将前述问题应用于您需要存储的其他信息。例如,要跟踪每个产品找到的价格,您可能需要以下信息:</p> <ul> <li> <p>产品 ID</p> </li> <li> <p>商店 ID</p> </li> <li> <p>价格</p> </li> <li> <p>记录价格发现的日期/时间戳</p> </li> </ul> <p>但是,如果产品的属性实际上会改变产品的价格怎么办?例如,商店可能会对大号衬衫收取更高的价格,因为制作大号衬衫需要更多的人工或材料。在这种情况下,您可以考虑将单个衬衫产品拆分为每种尺寸的独立产品列表(以便每件衬衫产品可以独立定价),或者创建一个新的项目类型来存储产品实例信息,包含以下字段:</p> <ul> <li> <p>产品 ID</p> </li> <li> <p>实例类型(本例中为衬衫尺寸)</p> </li> </ul> <p>每个价格看起来会像这样:</p> <ul> <li> <p>产品实例 ID</p> </li> <li> <p>商店 ID</p> </li> <li> <p>价格</p> </li> <li> <p>记录价格发现的日期/时间戳</p> </li> </ul> <p>虽然“产品和价格”主题可能显得过于具体,但是在设计 Python 对象时,您需要问自己的基本问题和逻辑几乎适用于每种情况。</p> <p>如果您正在抓取新闻文章,您可能希望获取基本信息,例如:</p> <ul> <li> <p>标题</p> </li> <li> <p>作者</p> </li> <li> <p>日期</p> </li> <li> <p>内容</p> </li> </ul> <p>但是,如果一些文章包含“修订日期”,或“相关文章”,或“社交媒体分享数量”呢?您是否需要这些信息?它们是否与您的项目相关?当不是所有新闻网站都使用所有形式的社交媒体,并且社交媒体站点可能随时间而增长或减少时,您如何高效灵活地存储社交媒体分享数量?</p> <p>当面对一个新项目时,很容易立即投入到编写 Python 代码以抓取网站的工作中。然而,数据模型往往被第一个抓取的网站的数据的可用性和格式所强烈影响,而被忽视。</p> <p>然而,数据模型是所有使用它的代码的基础。在模型中做出不好的决定很容易导致以后编写和维护代码时出现问题,或者在提取和高效使用结果数据时出现困难。特别是在处理各种网站(已知和未知的)时,认真考虑和规划你需要收集什么,以及你如何存储它变得至关重要。</p> <h1 id="处理不同的网页布局">处理不同的网页布局</h1> <p>象 Google 这样的搜索引擎最令人印象深刻的一个特性是,它能够从各种网站中提取相关且有用的数据,而不需要预先了解网站的结构。尽管我们作为人类能够立即识别页面的标题和主要内容(除了极端糟糕的网页设计情况),但让机器人做同样的事情要困难得多。</p> <p>幸运的是,在大多数网络爬虫的情况下,你不需要从你以前从未见过的网站收集数据,而是从少数或几十个由人类预先选择的网站收集数据。这意味着你不需要使用复杂的算法或机器学习来检测页面上“看起来最像标题”的文本或者哪些可能是“主要内容”。你可以手动确定这些元素是什么。</p> <p>最明显的方法是为每个网站编写单独的网络爬虫或页面解析器。每个解析器可能接受一个 URL、字符串或<code>BeautifulSoup</code>对象,并返回一个被爬取的 Python 对象。</p> <p>下面是一个<code>Content</code>类的示例(代表网站上的一篇内容,比如新闻文章),以及两个爬虫函数,它们接受一个<code>BeautifulSoup</code>对象并返回一个<code>Content</code>的实例:</p> <pre><code class="language-py">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() </code></pre> <p>当你开始为额外的新闻网站添加爬虫函数时,你可能会注意到一种模式正在形成。每个网站的解析函数基本上都在做同样的事情:</p> <ul> <li> <p>选择标题元素并提取标题文本</p> </li> <li> <p>选择文章的主要内容</p> </li> <li> <p>根据需要选择其他内容项</p> </li> <li> <p>返回一个通过先前找到的字符串实例化的<code>Content</code>对象</p> </li> </ul> <p>这里唯一真正依赖于网站的变量是用于获取每个信息片段的 CSS 选择器。BeautifulSoup 的<code>find</code>和<code>find_all</code>函数接受两个参数——一个标签字符串和一个键/值属性字典——所以你可以将这些参数作为定义站点结构和目标数据位置的参数传递进去。</p> <p>更方便的是,你可以使用 BeautifulSoup 的<code>select</code>函数,用一个单一的字符串 CSS 选择器来获取每个想要收集的信息片段,并将所有这些选择器放在一个字典对象中:</p> <pre><code class="language-py">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 </code></pre> <p>注意<code>Website</code>类不存储从单个页面收集的信息,而是存储关于<em>如何</em>收集这些数据的说明。它不存储标题“我的页面标题”。它只是存储表示标题位置的字符串标签<code>h1</code>。这就是为什么这个类被称为<code>Website</code>(这里的信息涉及整个网站)而不是<code>Content</code>(它只包含来自单个页面的信息)的原因。</p> <p>当您编写网络爬虫时,您可能会注意到您经常一遍又一遍地执行许多相同的任务。例如:获取页面内容同时检查错误,获取标签内容,如果找不到则优雅地失败。让我们将这些添加到一个<code>Crawler</code>类中:</p> <pre><code class="language-py">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 '' </code></pre> <p>请注意,<code>Crawler</code>类目前没有任何状态。它只是一组静态方法。它的命名似乎也很差劲——它根本不进行任何爬取!你至少可以通过向其添加一个<code>getContent</code>方法稍微增加其实用性,该方法接受一个<code>website</code>对象和一个 URL 作为参数,并返回一个<code>Content</code>对象:</p> <pre><code class="language-py">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, '', '') </code></pre> <p>以下显示了如何将这些<code>Content</code>、<code>website</code>和<code>Crawler</code>类一起使用来爬取四个不同的网站:</p> <pre><code class="language-py">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() </code></pre> <p>这种新方法乍看起来可能并不比为每个新网站编写新的 Python 函数更简单,但想象一下当你从一个拥有 4 个网站来源的系统转变为一个拥有 20 个或 200 个来源的系统时会发生什么。</p> <p>定义一个新网站的每个字符串列表都相对容易编写。它不占用太多空间。它可以从数据库或 CSV 文件中加载。它可以从远程源导入或交给具有一点前端经验的非程序员。这个程序员可以填写它并向爬虫添加新的网站,而无需查看一行代码。</p> <p>当然,缺点是你放弃了一定的灵活性。在第一个示例中,每个网站都有自己的自由形式函数来选择和解析 HTML,以便获得最终结果。在第二个示例中,每个网站需要具有一定的结构,其中字段被保证存在,数据必须在字段出来时保持干净,每个目标字段必须具有唯一且可靠的 CSS 选择器。</p> <p>但是,我相信这种方法的力量和相对灵活性远远弥补了其实际或被认为的缺点。下一节将涵盖此基本模板的具体应用和扩展,以便您可以处理丢失的字段,收集不同类型的数据,浏览网站的特定部分,并存储关于页面的更复杂的信息。</p> <h1 id="结构化爬虫">结构化爬虫</h1> <p>创建灵活和可修改的网站布局类型如果仍然需要手动定位要爬取的每个链接,则不会有太大帮助。第六章展示了通过网站并找到新页面的各种自动化方法。</p> <p>本节展示了如何将这些方法整合到一个结构良好且可扩展的网站爬虫中,该爬虫可以自动收集链接并发现数据。我在这里仅介绍了三种基本的网页爬虫结构;它们适用于你在野外爬取网站时可能遇到的大多数情况,也许在某些情况下需要进行一些修改。</p> <h2 id="通过搜索爬取网站">通过搜索爬取网站</h2> <p>通过与搜索栏相同的方法,爬取网站是最简单的方法之一。尽管在网站上搜索关键词或主题并收集搜索结果列表的过程似乎因网站而异,但几个关键点使其出奇地简单:</p> <ul> <li> <p>大多数网站通过在 URL 参数中将主题作为字符串传递来检索特定主题的搜索结果列表。例如:<code>http://example.com?search=myTopic</code>。这个 URL 的前半部分可以保存为<code>Website</code>对象的属性,主题只需简单附加即可。</p> </li> <li> <p>搜索后,大多数网站将结果页面呈现为易于识别的链接列表,通常使用方便的包围标签,如<code><span class="result"></code>,其确切格式也可以作为<code>Website</code>对象的属性存储。</p> </li> <li> <p>每个<em>结果链接</em>都可以是相对 URL(例如,<em>/articles/page.html</em>)或绝对 URL(例如,<em><a href="http://example.com/articles/page.html" target="_blank">http://example.com/articles/page.html</a></em>)。无论你期望绝对还是相对 URL,它都可以作为<code>Website</code>对象的属性存储。</p> </li> <li> <p>当你定位并规范化搜索页面上的 URL 后,你已成功将问题简化为前一节示例中的情况——在给定网站格式的情况下提取页面数据。</p> </li> </ul> <p>让我们来看一下这个算法在代码中的实现。<code>Content</code>类与之前的示例大致相同。你正在添加 URL 属性来跟踪内容的来源:</p> <pre><code class="language-py">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))     </code></pre> <p><code>Website</code>类添加了一些新属性。<code>searchUrl</code>定义了你应该去哪里获取搜索结果,如果你附加你要查找的主题。<code>resultListing</code>定义了包含每个结果信息的“框”,<code>resultUrl</code>定义了这个框内部的标签,将为你提供结果的确切 URL。<code>absoluteUrl</code>属性是一个布尔值,告诉你这些搜索结果是绝对 URL 还是相对 URL。</p> <pre><code class="language-py">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 </code></pre> <p><code>Crawler</code>类也有所扩展。它现在有一个<code>Website</code>对象,以及一个指向<code>Content</code>对象的 URL 字典,用于跟踪它之前所见过的内容。请注意,<code>getPage</code>和<code>safeGet</code>方法没有更改,这里省略了它们:</p> <pre><code class="language-py">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() </code></pre> <p>你可以像这样调用你的爬虫:</p> <pre><code class="language-py">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) </code></pre> <p>就像以前一样,关于每个网站的数据数组被创建:标签的外观、URL 和用于跟踪目的的名称。然后将这些数据加载到<code>Website</code>对象的列表中,并转换为<code>Crawler</code>对象。</p> <p>然后它会循环遍历<code>crawlers</code>列表中的每个爬虫,并为每个特定主题的每个特定站点爬取信息。每次成功收集有关页面的信息时,它都会将其打印到控制台:</p> <pre><code class="language-py">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 ... </code></pre> <p>注意,它首先遍历所有主题,然后在内部循环中遍历所有网站。为什么不反过来做,先从一个网站收集所有主题,然后再从下一个网站收集所有主题呢?首先遍历所有主题可以更均匀地分配对任何一个 Web 服务器的负载。如果你有数百个主题和几十个网站的列表,这一点尤为重要。你不是一次性向一个网站发送成千上万个请求;你发送 10 个请求,等待几分钟,然后再发送 10 个请求,依此类推。</p> <p>尽管无论哪种方式,请求的数量最终是相同的,但是通常最好尽量合理地分布这些请求的时间。注意如何结构化你的循环是做到这一点的简单方法。</p> <h2 id="通过链接爬取站点">通过链接爬取站点</h2> <p>第六章介绍了在网页上识别内部和外部链接的一些方法,然后利用这些链接来跨站点爬取。在本节中,你将把这些基本方法结合起来,形成一个更灵活的网站爬虫,可以跟随任何匹配特定 URL 模式的链接。</p> <p>当你想要从一个站点收集所有数据时——而不仅仅是特定搜索结果或页面列表的数据时,这种类型的爬虫效果很好。当站点的页面可能不太有序或广泛分布时,它也能很好地工作。</p> <p>这些类型的爬虫不需要像在前一节中爬取搜索页面那样定位链接的结构化方法,因此在<code>Website</code>对象中不需要描述搜索页面的属性。但是,由于爬虫没有为其正在查找的链接位置/位置提供具体指令,因此您需要一些规则来告诉它选择哪种类型的页面。您提供一个<code>target​Pat⁠tern</code>(目标 URL 的正则表达式),并留下布尔变量<code>absoluteUrl</code>来完成此操作:</p> <pre><code class="language-py">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}') </code></pre> <p><code>Content</code>类与第一个爬虫示例中使用的相同。</p> <p><code>Crawler</code>类被设计为从每个站点的主页开始,定位内部链接,并解析每个找到的内部链接的内容:</p> <pre><code class="language-py">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() </code></pre> <p>与先前的示例一样,<code>Website</code>对象是<code>Crawler</code>对象本身的属性。这样可以很好地存储爬虫中访问的页面(<code>visited</code>),但意味着必须为每个网站实例化一个新的爬虫,而不是重复使用同一个爬虫来爬取网站列表。</p> <p>无论你选择将爬虫设计成与网站无关还是将网站作为爬虫的属性,都是你必须在特定需求背景下权衡的设计决策。两种方法通常都可以接受。</p> <p>另一个需要注意的是,这个爬虫将从主页获取页面,但在记录了所有这些页面后,它将不会继续爬取。你可能希望编写一个爬虫,采用本章中的模式之一,并让它在访问的每个页面上查找更多目标。甚至可以跟踪每个页面上的所有 URL(不仅限于与目标模式匹配的 URL),以寻找包含目标模式的 URL。</p> <h2 id="爬取多种页面类型">爬取多种页面类型</h2> <p>与通过预定页面集合进行爬取不同,通过网站上所有内部链接进行爬取可能会带来挑战,因为你永远不知道确切的内容。幸运的是,有几种方法可以识别页面类型:</p> <p>通过 URL</p> <p>网站上的所有博客文章可能都包含一个 URL(例如<em><a href="http://example.com/blog/title-of-post" target="_blank">http://example.com/blog/title-of-post</a></em>)。</p> <p>通过网站上特定字段的存在或缺失</p> <p>如果一个页面有日期但没有作者名字,你可能会将其归类为新闻稿。如果它有标题、主图像和价格但没有主要内容,它可能是一个产品页面。</p> <p>通过页面上特定的标签来识别页面</p> <p>即使你不收集标签内的数据,你也可以利用标签。例如,你的爬虫可能会查找诸如<code><div id="related-products"></code>这样的元素来识别页面是否为产品页面,尽管爬虫并不关心相关产品的内容。</p> <p>要跟踪多种页面类型,你需要在 Python 中拥有多种类型的页面对象。有两种方法可以做到这一点。</p> <p>如果页面都很相似(它们基本上都具有相同类型的内容),你可能希望为现有的网页对象添加一个<code>pageType</code>属性:</p> <pre><code class="language-py">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 </code></pre> <p>如果你将这些页面存储在类似 SQL 的数据库中,这种模式表明所有这些页面可能都存储在同一张表中,并且会添加一个额外的<code>pageType</code>列。</p> <p>如果你要抓取的页面/内容差异很大(它们包含不同类型的字段),这可能需要为每种页面类型创建新的类。当然,一些东西对所有网页都是通用的——它们都有一个 URL,很可能还有一个名称或页面标题。这是使用子类的理想情况:</p> <pre><code class="language-py">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 </code></pre> <p>这个<code>Product</code>页面扩展了<code>Website</code>基类,并添加了仅适用于产品的<code>productNumber</code>和<code>price</code>属性;<code>Article</code>类添加了<code>body</code>和<code>date</code>属性,这些属性不适用于产品。</p> <p>你可以使用这两个类来抓取,例如,一个商店网站可能除了产品之外还包含博客文章或新闻稿。</p> <h1 id="思考网络爬虫模型">思考网络爬虫模型</h1> <p>从互联网收集信息有如饮水从火龙头喝水。那里有很多东西,而且并不总是清楚您需要什么或者您如何需要它。任何大型网络抓取项目(甚至某些小型项目)的第一步应该是回答这些问题。</p> <p>在收集多个领域或多个来源的类似数据时,几乎总是应该尝试对其进行规范化。处理具有相同和可比较字段的数据要比处理完全依赖于其原始来源格式的数据容易得多。</p> <p>在许多情况下,您应该在假设将来将添加更多数据源到抓取器的基础上构建抓取器,目标是最小化添加这些新来源所需的编程开销。即使一个网站乍一看似乎与您的模型不符,也可能有更微妙的方式使其符合。能够看到这些潜在模式可以在长远中为您节省时间、金钱和许多头疼的问题。</p> <p>数据之间的关联也不应被忽视。您是否在寻找跨数据源具有“类型”、“大小”或“主题”等属性的信息?您如何存储、检索和概念化这些属性?</p> <p>软件架构是一个广泛而重要的主题,可能需要整个职业生涯来掌握。幸运的是,用于网络抓取的软件架构是一组相对容易获取的有限且可管理的技能。随着您继续抓取数据,您会发现相同的基本模式一次又一次地出现。创建一个良好结构化的网络爬虫并不需要太多神秘的知识,但确实需要花时间退后一步,思考您的项目。</p> <h1 id="第八章scrapy">第八章:Scrapy</h1> <p>第七章介绍了构建大型、可扩展且(最重要的!)可维护网络爬虫的一些技术和模式。尽管手动操作很容易做到,但许多库、框架甚至基于 GUI 的工具都可以为您完成这些工作,或者至少试图让您的生活变得更轻松。</p> <p>自 2008 年发布以来,Scrapy 迅速发展成为 Python 中最大且维护最好的网络爬虫框架。目前由 Zyte(前身为 Scrapinghub)维护。</p> <p>编写网络爬虫的一个挑战是经常要重复执行相同的任务:查找页面上的所有链接,评估内部和外部链接之间的差异,并浏览新页面。这些基本模式是有用的,也可以从头编写,但 Scrapy 库可以为您处理其中的许多细节。</p> <p>当然,Scrapy 不能读心。您仍然需要定义页面模板,指定要开始抓取的位置,并为您正在寻找的页面定义 URL 模式。但在这些情况下,它提供了一个清晰的框架来保持代码的组织。</p> <h1 id="安装-scrapy">安装 Scrapy</h1> <p>Scrapy 提供了从其网站<a href="http://scrapy.org/download/" target="_blank">下载</a>的工具,以及使用 pip 等第三方安装管理器安装 Scrapy 的说明。</p> <p>由于其相对较大的大小和复杂性,Scrapy 通常不是可以通过传统方式安装的框架:</p> <pre><code class="language-py">$ pip install Scrapy </code></pre> <p>请注意,我说“通常”是因为尽管理论上可能存在,但我通常会遇到一个或多个棘手的依赖问题、版本不匹配和无法解决的错误。</p> <p>如果您决定从 pip 安装 Scrapy,则强烈建议使用虚拟环境(有关虚拟环境的更多信息,请参阅“使用虚拟环境保持库的整洁”)。</p> <p>我偏爱的安装方法是通过<a href="https://docs.continuum.io/anaconda/" target="_blank">Anaconda 包管理器</a>。Anaconda 是由 Continuum 公司推出的产品,旨在简化查找和安装流行的 Python 数据科学包的过程。后续章节将使用它管理的许多包,如 NumPy 和 NLTK。</p> <p>安装完 Anaconda 后,您可以使用以下命令安装 Scrapy:</p> <pre><code class="language-py">`conda` `install` `-``c` `conda``-``forge` `scrapy` </code></pre> <p>如果遇到问题或需要获取最新信息,请查阅<a href="https://doc.scrapy.org/en/latest/intro/install.html" target="_blank">Scrapy</a>安装指南获取更多信息。</p> <h2 id="初始化新的-spider">初始化新的 Spider</h2> <p>安装完 Scrapy 框架后,每个 Spider 需要进行少量设置。<em>Spider</em>是一个 Scrapy 项目,类似于其命名的蜘蛛,专门设计用于爬取网络。在本章中,我使用“Spider”来描述特定的 Scrapy 项目,“crawler”指“使用 Scrapy 或不使用任何通用程序爬取网络”的任何通用程序。</p> <p>要在当前目录下创建一个新的 Spider,请从命令行运行以下命令:</p> <pre><code class="language-py">$ scrapy startproject wikiSpider </code></pre> <p>这会在项目创建的目录中创建一个新的子目录,名为<em>wikiSpider</em>。在这个目录中,有以下文件结构:</p> <ul> <li> <p><em>scrapy.cfg</em></p> </li> <li> <p><em>wikiSpider</em></p> <ul> <li> <p><em>spiders</em></p> <ul> <li><em><strong>init.py</strong></em></li> </ul> </li> <li> <p><em>items.py</em></p> </li> <li> <p><em>middlewares.py</em></p> </li> <li> <p><em>pipelines.py</em></p> </li> <li> <p><em>settings.py</em></p> </li> <li> <p><em><strong>init.py</strong></em></p> </li> </ul> </li> </ul> <p>这些 Python 文件以存根代码初始化,以提供创建新爬虫项目的快速方式。本章中的每个部分都与这个<em>wikiSpider</em>项目一起工作。</p> <h1 id="编写一个简单的爬虫">编写一个简单的爬虫</h1> <p>要创建一个爬虫,您将在<em>wiki​S⁠pider/wikiSpider/spiders/article.py</em>的子<em>spiders</em>目录中添加一个新文件。这是所有爬虫或扩展 scrapy.Spider 的内容的地方。在您新创建的<em>article.py</em>文件中,写入:</p> <pre><code class="language-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}') </code></pre> <p>此类的名称(<code>ArticleSpider</code>)根本没有涉及“wiki”或“维基百科”,这表明此类特别负责爬取文章页面,属于更广泛的<em>wikiSpider</em>类别,稍后您可能希望使用它来搜索其他页面类型。</p> <p>对于内容类型繁多的大型网站,您可能会为每种类型(博客文章、新闻稿、文章等)设置单独的 Scrapy 项目。每个爬虫的名称在项目内必须是唯一的。</p> <p>关于此爬虫的另外两个关键点是函数<code>start_requests</code>和<code>parse</code>:</p> <p><code>start_requests</code></p> <p>Scrapy 定义的程序入口点用于生成<code>Request</code>对象,Scrapy 用它来爬取网站。</p> <p><code>parse</code></p> <p>由用户定义的回调函数,并通过<code>Request</code>对象传递给<code>callback=self.parse</code>。稍后,您将看到更多可以使用<code>parse</code>函数完成的功能,但现在只打印页面的标题。</p> <p>你可以通过转到外部<em>wikiSpider</em>目录并运行以下命令来运行这个<code>article</code>爬虫:</p> <pre><code class="language-py">$ scrapy runspider wikiSpider/spiders/article.py </code></pre> <p>默认的 Scrapy 输出相当冗长。除了调试信息外,这应该会打印出类似以下的行:</p> <pre><code class="language-py">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) </code></pre> <p>爬虫访问列出为 URL 的三个页面,收集信息,然后终止。</p> <h1 id="使用规则进行爬取">使用规则进行爬取</h1> <p>前一节中的爬虫并不是一个真正的爬虫,只能限于仅抓取提供的 URL 列表。它没有能力自行查找新页面。要将其转变为一个完全成熟的爬虫,你需要使用 Scrapy 提供的<code>CrawlSpider</code>类。</p> <h1 id="在-github-仓库中的代码组织">在 GitHub 仓库中的代码组织</h1> <p>不幸的是,Scrapy 框架不能在 Jupyter 笔记本中轻松运行,这使得线性编码难以实现。为了在文本中展示所有代码示例的目的,前一节中的爬虫存储在<em>article.py</em>文件中,而下面的示例,创建一个遍历多个页面的 Scrapy 爬虫,存储在<em>articles.py</em>中(注意使用了复数形式)。</p> <p>后续示例也将存储在单独的文件中,每个部分都会给出新的文件名。运行这些示例时,请确保使用正确的文件名。</p> <p>此类可以在 GitHub 仓库的 spiders 文件<em>articles.py</em>中找到:</p> <pre><code class="language-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}') </code></pre> <p>这个新的<code>ArticleSpider</code>扩展了<code>CrawlSpider</code>类。它不是提供一个<code>start_requests</code>函数,而是提供一个<code>start_urls</code>和<code>allowed_domains</code>列表。这告诉爬虫从哪里开始爬取,并根据域名是否应跟踪或忽略链接。</p> <p>还提供了一个<code>rules</code>列表。这提供了进一步的说明,指导哪些链接应该跟踪或忽略(在本例中,允许所有使用正则表达式<code>.*</code>的 URL)。</p> <p>除了提取每个页面的标题和 URL 之外,还添加了一些新的项目。使用 XPath 选择器提取每个页面的文本内容。XPath 在检索文本内容时经常被使用,包括文本在子标签中的情况(例如,在一段文本中的<code><a></code>标签内)。如果您使用 CSS 选择器来做这件事,所有子标签中的文本都将被忽略。</p> <p>也从页脚解析并存储了最后更新日期字符串到<code>lastUpdated</code>变量中。</p> <p>您可以通过导航到<em>wikiSpider</em>目录并运行以下命令来运行此示例:</p> <pre><code class="language-py">$ scrapy runspider wikiSpider/spiders/articles.py </code></pre> <h1 id="警告这将永远运行下去">警告:这将永远运行下去</h1> <p>尽管这个新的爬虫在命令行中的运行方式与前一节中构建的简单爬虫相同,但它不会终止(至少不会很长时间),直到您使用 Ctrl-C 终止执行或关闭终端为止。请注意,对 Wikipedia 服务器负载要友善,不要长时间运行它。</p> <p>运行此爬虫时,它将遍历<em>wikipedia.org</em>,跟踪* wikipedia.org*域下的所有链接,打印页面标题,并忽略所有外部(站外)链接:</p> <pre><code class="language-py">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> </code></pre> <p>到目前为止,这是一个相当不错的爬虫,但它可以使用一些限制。它不仅可以访问维基百科上的文章页面,还可以自由漫游到非文章页面,例如:</p> <pre><code class="language-py">title is: Wikipedia:General disclaimer </code></pre> <p>通过使用 Scrapy 的<code>Rule</code>和<code>LinkExtractor</code>来仔细查看这一行:</p> <pre><code class="language-py">rules = [Rule(LinkExtractor(allow=r'.*'), callback='parse_items', ​    follow=True)] </code></pre> <p>此行提供了一个 Scrapy <code>Rule</code>对象的列表,该列表定义了所有找到的链接通过的规则。当存在多个规则时,每个链接都会根据规则进行检查,按顺序进行。第一个匹配的规则将用于确定如何处理链接。如果链接不符合任何规则,则会被忽略。</p> <p>可以为<code>Rule</code>提供四个参数:</p> <p><code>link_extractor</code></p> <p>唯一必需的参数,一个<code>LinkExtractor</code>对象。</p> <p><code>callback</code></p> <p>应该用于解析页面内容的函数。</p> <p><code>cb_kwargs</code></p> <p>要传递给回调函数的参数字典。这个字典格式为<code>{arg_name1: arg_value1, arg_name2: arg_value2}</code>,对于稍微不同的任务重用相同的解析函数非常方便。</p> <p><code>follow</code></p> <p>指示是否希望在未来的爬行中包含在该页面上找到的链接。如果未提供回调函数,则默认为<code>True</code>(毕竟,如果您对页面没有做任何事情,那么至少您可能想要继续通过站点进行爬行)。如果提供了回调函数,则默认为<code>False</code>。</p> <p><code>LinkExtractor</code>是一个简单的类,专门设计用于识别并返回基于提供的规则在 HTML 内容页中的链接。它具有许多参数,可用于接受或拒绝基于 CSS 和 XPath 选择器、标签(您不仅可以查找锚点标签中的链接!)、域名等的链接。</p> <p><code>LinkExtractor</code>类甚至可以扩展,并且可以创建自定义参数。详见 Scrapy 的<a href="https://doc.scrapy.org/en/latest/topics/link-extractors.html" target="_blank">链接提取器文档</a>获取更多信息。</p> <p>尽管<code>LinkExtractor</code>类具有灵活的特性,但您最常使用的参数是这些:</p> <p><code>allow</code></p> <p>允许所有符合提供的正则表达式的链接。</p> <p><code>deny</code></p> <p>拒绝所有符合提供的正则表达式的链接。</p> <p>使用两个单独的<code>Rule</code>和<code>LinkExtractor</code>类以及一个解析函数,您可以创建一个爬虫,该爬虫可以爬取维基百科,识别所有文章页面,并标记非文章页面(<em>articleMoreRules.py</em>):</p> <pre><code class="language-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}') </code></pre> <p>请记住,规则适用于列表中呈现的每个链接。首先将所有文章页面(以<em>/wiki/</em>开头且不包含冒号的页面)传递给<code>parse_items</code>函数,并使用默认参数<code>is_article=True</code>。然后将所有其他非文章链接传递给<code>parse_items</code>函数,并使用参数<code>is_article=False</code>。</p> <p>当然,如果您只想收集文章类型页面并忽略所有其他页面,这种方法将不太实际。忽略不匹配文章 URL 模式的页面,并完全省略第二个规则(以及<code>is_article</code>变量),会更加简单。但在 URL 中收集的信息或在爬取过程中收集的信息影响页面解析方式的奇特情况下,这种方法可能会很有用。</p> <h1 id="创建项目">创建项目</h1> <p>到目前为止,您已经学习了许多在 Scrapy 中查找、解析和爬取网站的方法,但 Scrapy 还提供了有用的工具,可以将您收集的项目组织并存储在具有明确定义字段的自定义对象中。</p> <p>为了帮助组织您收集的所有信息,您需要创建一个名为<code>Article</code>的对象。在<em>items.py</em>文件内定义一个新的<code>Article</code>项目。</p> <p>当您打开<em>items.py</em>文件时,它应该看起来像这样:</p> <pre><code class="language-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 </code></pre> <p>用一个新的扩展<code>scrapy.Item</code>的<code>Article</code>类替换此默认的<code>Item</code>存根:</p> <pre><code class="language-py">import scrapy class Article(scrapy.Item):     url = scrapy.Field()     title = scrapy.Field()     text = scrapy.Field()     lastUpdated = scrapy.Field() </code></pre> <p>您正在定义将从每个页面收集的四个字段:URL、标题、文本内容和页面最后编辑日期。</p> <p>如果您正在收集多种页面类型的数据,应将每种单独类型定义为<em>items.py</em>中的一个单独类。如果您的项目很大,或者您开始将更多的解析功能移动到项目对象中,您可能还希望将每个项目提取到自己的文件中。然而,当项目很小时,我喜欢将它们保存在单个文件中。</p> <p>在文件<em>articleItems.py</em>中,请注意在 <code>ArticleSpider</code> 类中所做的更改,以创建新的 <code>Article</code> 项目:</p> <pre><code class="language-py">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 </code></pre> <p>运行此文件时</p> <pre><code class="language-py">$ scrapy runspider wikiSpider/spiders/articleItems.py </code></pre> <p>它将输出通常的 Scrapy 调试数据以及每个文章项目作为 Python 字典:</p> <pre><code class="language-py">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',           ... </code></pre> <p>使用 Scrapy <code>Items</code> 并不仅仅是为了促进良好的代码组织或以可读的方式布置事物。项目提供了许多工具,用于输出和处理数据,这些将在接下来的部分中详细介绍。</p> <h1 id="输出项目">输出项目</h1> <p>Scrapy 使用 <code>Item</code> 对象来确定应从其访问的页面中保存哪些信息。这些信息可以由 Scrapy 以各种方式保存,例如 CSV、JSON 或 XML 文件,使用以下命令:</p> <pre><code class="language-py">$ 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 </code></pre> <p>每次运行抓取器 <code>articleItems</code> 并将输出按指定格式写入提供的文件。如果不存在,则将创建此文件。</p> <p>您可能已经注意到,在先前示例中爬虫创建的文章中,文本变量是一个字符串列表,而不是单个字符串。此列表中的每个字符串表示单个 HTML 元素内的文本,而<code><div id="mw-content-text"></code>内的内容(您正在收集的文本数据)由许多子元素组成。</p> <p>Scrapy 很好地管理这些更复杂的值。例如,在 CSV 格式中,它将列表转换为字符串,并转义所有逗号,以便文本列表显示在单个 CSV 单元格中。</p> <p>在 XML 中,此列表的每个元素都被保留在子值标签中:</p> <pre><code class="language-py"><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> .... </code></pre> <p>在 JSON 格式中,列表被保留为列表。</p> <p>当然,您可以自己使用 <code>Item</code> 对象,并通过将适当的代码添加到爬虫的解析函数中,以任何您想要的方式将它们写入文件或数据库。</p> <h1 id="项目管道">项目管道</h1> <p>尽管 Scrapy 是单线程的,但它能够异步地进行许多请求的处理。这使其比本书中迄今编写的爬虫更快,尽管我一直坚信,在涉及网络抓取时,更快并不总是更好。</p> <p>您正在尝试抓取的网站的网络服务器必须处理这些请求中的每一个,评估这种服务器压力是否合适(甚至对您自己的利益是否明智,因为许多网站有能力和意愿阻止它们可能认为是恶意的抓取活动)。有关网络抓取伦理以及适当地调节抓取程序重要性的更多信息,请参阅[第十九章。</p> <p>话虽如此,使用 Scrapy 的项目管道可以通过在等待请求返回时执行所有数据处理来进一步提高网络爬虫的速度,而不是等待数据处理完成后再发出另一个请求。当数据处理需要大量时间或处理器密集型计算时,这种优化甚至可能是必需的。</p> <p>要创建一个项目管道,请重新访问本章开头创建的 <em>settings.py</em> 文件。您应该看到以下被注释的行:</p> <pre><code class="language-py"># Configure item pipelines # See http://scrapy.readthedocs.org/en/latest/topics/item-pipeline.html #ITEM_PIPELINES = { #    'wikiSpider.pipelines.WikispiderPipeline': 300, #} </code></pre> <p>取消对最后三行的注释,并替换为:</p> <pre><code class="language-py">ITEM_PIPELINES = {     'wikiSpider.pipelines.WikispiderPipeline': 300, } </code></pre> <p>这提供了一个 Python 类 <code>wikiSpider.pipelines.WikispiderPipeline</code>,用于处理数据,以及一个表示运行管道顺序的整数。虽然可以使用任何整数,但通常使用 0 到 1000 的数字,并将按升序运行。</p> <p>现在,您需要添加管道类并重写原始爬虫,以便爬虫收集数据,管道执行数据处理的繁重工作。也许会诱人地在原始爬虫中编写 <code>parse_items</code> 方法以返回响应并让管道创建 <code>Article</code> 对象:</p> <pre><code class="language-py">    def parse_items(self, response):         return response </code></pre> <p>然而,Scrapy 框架不允许这样做,必须返回一个 <code>Item</code> 对象(例如扩展 <code>Item</code> 的 <code>Article</code>)。因此,现在 <code>parse_items</code> 的目标是提取原始数据,尽可能少地进行处理,以便可以传递到管道:</p> <pre><code class="language-py">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 </code></pre> <p>此文件保存为 <em>articlePipelines.py</em> 在 GitHub 仓库中。</p> <p>当然,现在您需要通过添加管道将 <em>pipelines.py</em> 文件和更新的爬虫联系在一起。当最初初始化 Scrapy 项目时,会在 <em>wikiSpider/wikiSpider/pipelines.py</em> 创建一个文件:</p> <pre><code class="language-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 </code></pre> <p>这个存根类应该用您的新管道代码替换。在之前的章节中,您已经以原始格式收集了两个字段,并且这些字段可能需要额外的处理:<code>lastUpdated</code>(一个糟糕格式化的表示日期的字符串对象)和 <code>text</code>(一个杂乱的字符串片段数组)。</p> <p>以下应该用于替换 <em>wikiSpider/wikiSpider/pipelines.py</em> 中的存根代码:</p> <pre><code class="language-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 </code></pre> <p>类 <code>WikispiderPipeline</code> 具有一个 <code>process_item</code> 方法,接受一个 <code>Article</code> 对象,将 <code>lastUpdated</code> 字符串解析为 Python 的 <code>datetime</code> 对象,并从字符串列表中清理和连接文本为单个字符串。</p> <p><code>process_item</code> 是每个管道类的必需方法。Scrapy 使用此方法异步传递由爬虫收集的 <code>Items</code>。例如,如果您在上一节中输出到 JSON 或 CSV,这里返回的解析的 <code>Article</code> 对象将由 Scrapy 记录或打印。</p> <p>现在,在决定数据处理位置时,您有两个选择:在爬虫中的 <code>parse_items</code> 方法或在管道中的 <code>process_items</code> 方法。</p> <p>可以在 <em>settings.py</em> 文件中声明具有不同任务的多个管道。但是,Scrapy 将所有项目不论类型都传递给每个管道以便处理。在数据传入管道之前,可能更好地在爬虫中处理特定项目的解析。但是,如果此解析需要很长时间,可以考虑将其移到管道中(可以异步处理),并添加一个项目类型检查:</p> <pre><code class="language-py">def process_item(self, item, spider):   if isinstance(item, Article):   # Article-specific processing here </code></pre> <p>在编写 Scrapy 项目时,特别是在处理大型项目时,确定要执行的处理及其执行位置是一个重要考虑因素。</p> <h1 id="使用-scrapy-记录日志">使用 Scrapy 记录日志</h1> <p>Scrapy 生成的调试信息可能很有用,但你可能已经注意到,它通常过于冗长。你可以通过在 Scrapy 项目的 <em>settings.py</em> 文件中添加一行来轻松调整日志级别:</p> <pre><code class="language-py">LOG_LEVEL = 'ERROR' </code></pre> <p>Scrapy 使用标准的日志级别层次结构,如下:</p> <ul> <li> <p><code>CRITICAL</code></p> </li> <li> <p><code>ERROR</code></p> </li> <li> <p><code>WARNING</code></p> </li> <li> <p><code>DEBUG</code></p> </li> <li> <p><code>INFO</code></p> </li> </ul> <p>如果日志级别设置为 <code>ERROR</code>,则只会显示 <code>CRITICAL</code> 和 <code>ERROR</code> 级别的日志。如果设置为 <code>INFO</code>,则会显示所有日志,依此类推。</p> <p>除了通过 <em>settings.py</em> 文件控制日志记录外,还可以通过命令行控制日志输出位置。在命令行中运行时,可以定义一个日志文件,将日志输出到该文件而不是终端:</p> <pre><code class="language-py">$ scrapy crawl articles -s LOG_FILE=wiki.log </code></pre> <p>这将在当前目录中创建一个新的日志文件(如果不存在),并将所有日志输出到该文件,使得你的终端只显示手动添加的 Python 打印语句。</p> <h1 id="更多资源">更多资源</h1> <p>Scrapy 是一个强大的工具,可以处理与网络爬取相关的许多问题。它会自动收集所有 URL,并将它们与预定义的规则进行比较,确保所有 URL 都是唯一的,在必要时会对相对 URL 进行标准化,并递归深入页面。</p> <p>鼓励您查阅<a href="https://doc.scrapy.org/en/latest/news.html" target="_blank">Scrapy 文档</a>以及<a href="https://docs.scrapy.org/en/latest/intro/tutorial.html" target="_blank">Scrapy 的官方教程页面</a>,这些资源详细介绍了该框架。</p> <p>Scrapy 是一个非常庞大的库,具有许多功能。它的特性可以无缝协同工作,但也存在许多重叠的区域,使用户可以轻松地在其中开发自己的风格。如果你想要使用 Scrapy 做一些这里未提到的事情,很可能有一种(或几种)方法可以实现!</p> <h1 id="第九章存储数据">第九章:存储数据</h1> <p>尽管在终端打印输出很有趣,但在数据聚合和分析方面却不是非常有用。要使大多数网络爬虫实用,你需要能够保存它们抓取的信息。</p> <p>本章涵盖了三种主要的数据管理方法,几乎适用于任何想象得到的应用程序。需要支持网站的后端或创建自己的 API 吗?你可能希望你的爬虫将数据写入数据库。需要快速简便地从互联网上收集文档并将它们存储到硬盘吗?你可能需要为此创建文件流。需要偶尔的提醒或每天的聚合数据吗?给自己发送邮件吧!</p> <p>除了网页抓取之外,存储和处理大量数据的能力对于几乎任何现代编程应用都非常重要。事实上,本章的信息对于实现书后部分示例中的许多示例是必要的。如果你对自动化数据存储不熟悉,我强烈建议你至少浏览一下本章。</p> <h1 id="媒体文件">媒体文件</h1> <p>你可以通过两种主要方式存储媒体文件:通过引用和通过下载文件本身。将文件存储为引用只需保存主机服务器上文件所在位置的文本 URL,而不实际下载文件。这有几个优点:</p> <ul> <li> <p>当爬虫无需下载文件时,其运行速度更快,需要的带宽更少。</p> </li> <li> <p>通过仅存储 URL,你可以节省自己机器上的空间。</p> </li> <li> <p>编写仅存储 URL 并且不需要处理额外文件下载的代码更容易。</p> </li> <li> <p>避免下载大文件可以减轻主机服务器的负载。</p> </li> </ul> <p>以下是缺点:</p> <ul> <li> <p>在你自己的网站或应用程序中嵌入这些 URL 被称为<em>热链接</em>,这样做是在互联网上迅速陷入麻烦的一种方式。</p> </li> <li> <p>你不希望使用别人的服务器周期来为自己的应用程序托管媒体文件。</p> </li> <li> <p>存储在特定 URL 上的文件可能会发生变化。如果例如你在公共博客上嵌入了热链接图像,博客所有者发现并决定将图像更改为不良内容,可能会导致尴尬的影响。虽然不严重但仍然不便的是,如果你打算稍后使用它们,存储的 URL 可能在以后某个时候消失。</p> </li> <li> <p>真正的网络浏览器不仅仅请求页面的 HTML 然后离开。它们还下载页面所需的所有资产。下载文件可以使你的爬虫看起来像是人类在浏览网站,这是比仅仅记录链接更具优势的地方。</p> </li> </ul> <p>如果您在考虑是将文件还是文件的 URL 存储到文件中,则应自问您是否有可能多次查看或阅读该文件,或者这个文件的数据库是否将在其生命周期的大部分时间内闲置。如果答案是后者,则最好只存储 URL。如果是前者,请继续阅读!</p> <p>用于检索网页内容的 urllib 库还包含用于检索文件内容的功能。以下程序使用<code>urllib.request.urlretrieve</code>从远程 URL 下载图像:</p> <pre><code class="language-py">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') </code></pre> <p>这将从 <em><a href="http://pythonscraping.com" target="_blank">http://pythonscraping.com</a></em> 下载 Python 标志并将其存储为<em>logo.jpg</em>在脚本运行的同一目录中。</p> <p>如果您只需要下载单个文件并知道如何命名它以及文件扩展名是什么,则此方法效果很好。但大多数爬虫不会只下载一个文件并结束。以下内容将从 <em><a href="http://pythonscraping.com" target="_blank">http://pythonscraping.com</a></em> 的主页下载所有内部文件,这些文件由任何标签的<code>src</code>属性链接到:</p> <pre><code class="language-py">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}') </code></pre> <h1 id="谨慎运行">谨慎运行</h1> <p>你知道那些关于从互联网下载未知文件的警告吗?这个脚本会将它遇到的一切都下载到您计算机的硬盘上。这包括随机的 bash 脚本、<em>.exe</em>文件和其他可能的恶意软件。</p> <p>你以为自己是安全的,因为你从未真正执行过发送到你的下载文件夹的任何内容吗?特别是如果您以管理员身份运行此程序,您就在自找麻烦。如果您遇到一个发送自身到<em>../../../../usr/bin/python</em>的网站文件会发生什么?下次您从命令行运行 Python 脚本时,您可能会在您的机器上部署恶意软件!</p> <p>本程序仅供示例目的编写;不应随意部署而没有更广泛的文件名检查,并且只应在具有有限权限的帐户中运行。一如既往,备份您的文件,不要将敏感信息存储在硬盘上,并且运用一些常识会事半功倍。</p> <p>此脚本使用了一个 lambda 函数(在第五章介绍)来选择首页上具有<code>src</code>属性的所有标签,然后清理和规范化 URL 以获取每个下载的绝对路径(确保丢弃外部链接)。然后,每个文件都将下载到本地文件夹<em>downloaded</em>中的自己的路径上。</p> <p>注意,Python 的<code>os</code>模块被简要用于检索每个下载的目标目录,并在需要时创建丢失的目录路径。<code>os</code>模块充当 Python 与操作系统之间的接口,允许它操作文件路径,创建目录,获取有关运行进程和环境变量的信息,以及许多其他有用的事情。</p> <h1 id="将数据存储到-csv">将数据存储到 CSV</h1> <p><em>CSV</em>,或称为<em>逗号分隔值</em>,是存储电子表格数据的最流行的文件格式之一。由于其简单性,它受到 Microsoft Excel 和许多其他应用程序的支持。以下是一个完全有效的 CSV 文件示例:</p> <pre><code class="language-py">fruit,cost apple,1.00 banana,0.30 pear,1.25 </code></pre> <p>与 Python 一样,在这里空白字符很重要:每行由换行符分隔,而行内列则由逗号分隔(因此称为“逗号分隔”)。其他形式的 CSV 文件(有时称为<em>字符分隔值</em>文件)使用制表符或其他字符分隔行,但这些文件格式较不常见且支持较少。</p> <p>如果您希望直接从网上下载 CSV 文件并将其存储在本地,而无需进行任何解析或修改,您不需要阅读这一部分。使用前一部分描述的方法下载它们,就像下载任何其他文件并使用 CSV 文件格式保存它们一样。</p> <p>使用 Python 的<em>csv</em>库非常容易修改 CSV 文件,甚至可以从头开始创建一个:</p> <pre><code class="language-py">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() </code></pre> <p>预防性提醒:在 Python 中创建文件是相当防弹的。如果<em>test.csv</em>不存在,Python 将自动创建该文件(但不会创建目录)。如果已存在,则 Python 将用新数据覆盖<em>test.csv</em>。</p> <p>运行后,您应该会看到一个 CSV 文件:</p> <pre><code class="language-py">number,number plus 2,number times 2 0,2,0 1,3,2 2,4,4 ... </code></pre> <p>一个常见的网页抓取任务是检索 HTML 表格并将其写入 CSV 文件。<a href="https://en.wikipedia.org/wiki/List_of_countries_with_McDonald%27s_restaurants" target="_blank">维基百科的麦当劳餐厅列表</a>提供了一个包含链接、排序和其他 HTML 垃圾的相当复杂的 HTML 表格,需要在写入 CSV 之前将其丢弃。使用 BeautifulSoup 和<code>get_text()</code>函数,您可以在不到 20 行的代码中完成这一任务:</p> <pre><code class="language-py">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() </code></pre> <h1 id="获取单个表格的更简单方法">获取单个表格的更简单方法</h1> <p>如果您经常遇到需要将多个 HTML 表格转换为 CSV 文件,或者需要将多个 HTML 表格收集到单个 CSV 文件中,这段脚本非常适合集成到爬虫中。然而,如果您只需要做一次,还有更好的工具可用:复制和粘贴。选择并复制 HTML 表格的所有内容,然后将其粘贴到 Excel 或 Google 文档中,即可获得所需的 CSV 文件,而无需运行脚本!</p> <p>结果应该是一个保存在本地的格式良好的 CSV 文件,命名为<em>countries.csv</em>。</p> <h1 id="mysql">MySQL</h1> <p><em>MySQL</em>(正式发音为“my es-kew-el”,尽管许多人说“my sequel”)是当今最流行的开源关系数据库管理系统。与其他大型竞争对手相比,它的流行度在历史上一直与两个其他主要的闭源数据库系统:Microsoft 的 SQL Server 和 Oracle 的 DBMS 齐头并进,这在开源项目中是相当不寻常的。</p> <p>它的流行不是没有原因的。对于大多数应用程序来说,选择 MySQL 很难出错。它是一个可扩展的、强大的、功能齐全的数据库管理系统,被顶级网站使用:YouTube[¹]、Twitter[²] 和 Facebook[³],以及许多其他网站。</p> <p>因为 MySQL 的普及性、价格(“免费”是一个非常好的价格)和开箱即用性,它非常适合用于网络抓取项目,并且我们将在本书的余下部分中继续使用它。</p> <h2 id="安装-mysql">安装 MySQL</h2> <p>如果你对 MySQL 还不熟悉,安装数据库可能听起来有点吓人(如果你已经很熟悉了,可以跳过这一部分)。实际上,它和安装其他类型的软件一样简单。在核心层面,MySQL 由一组数据文件驱动,存储在服务器或本地机器上,这些文件包含数据库中存储的所有信息。MySQL 软件层在此基础上提供了通过命令行界面方便地与数据交互的方式。例如,以下命令会浏览数据文件并返回数据库中所有名字为“Ryan”的用户的列表:</p> <pre><code class="language-py">SELECT * FROM users WHERE firstname = "Ryan" </code></pre> <p>如果你使用基于 Debian 的 Linux 发行版(或者任何带有 <code>apt-get</code> 的系统),安装 MySQL 就像这样简单:</p> <pre><code class="language-py">$ sudo apt-get install mysql-server </code></pre> <p>只需关注安装过程,批准内存要求,并在提示时为新的 root 用户输入新密码即可。</p> <p>对于 macOS 和 Windows,情况会有些棘手。如果还没有,请先创建一个 Oracle 账户然后再下载安装包。</p> <p>如果你在 macOS 上,首先需要获取<a href="http://dev.mysql.com/downloads/mysql/" target="_blank">安装包</a>。</p> <p>选择 <em>.dmg</em> 包,并使用或创建 Oracle 账户来下载文件。文件打开后,你应该会被引导通过一个相当简单的安装向导(参见图 9-1)。</p> <p>默认的安装步骤应该足够,对于本书的目的,我假设你已经安装了默认的 MySQL。</p> <p>在 macOS 上安装 MySQL 后,可以按照以下步骤启动 MySQL 服务器:</p> <pre><code class="language-py">$ cd /usr/local/mysql $ sudo ./bin/mysqld_safe </code></pre> <p>在 Windows 上,安装和运行 MySQL 稍微复杂一些,但好消息是有<a href="http://dev.mysql.com/downloads/windows/installer/" target="_blank">一个便捷的安装程序</a>简化了这个过程。一旦下载完成,它将指导你完成所需的步骤(参见图 9-2)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_0901.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-9-1-macos-上的-mysql-安装程序">图 9-1. macOS 上的 MySQL 安装程序</h6> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_0902.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-9-2-windows-上的-mysql-安装程序">图 9-2. Windows 上的 MySQL 安装程序</h6> <p>你应该能够通过选择默认选项来安装 MySQL,只有一个例外:在设置类型页面上,我建议你选择仅安装服务器,以避免安装大量额外的 Microsoft 软件和库。接下来,你可以使用默认的安装设置并按照提示启动你的 MySQL 服务器。</p> <p>安装并运行 MySQL 服务器后,您仍然需要能够使用命令行与其进行交互。在 Windows 上,您可以使用<a href="https://dev.mysql.com/downloads/shell/" target="_blank">MySQL Shell</a>工具。在 Mac 上,我喜欢使用<a href="https://brew.sh" target="_blank">Homebrew package manager</a>安装命令行工具:</p> <pre><code class="language-py">$ brew install mysql </code></pre> <p>安装命令行工具后,您应该能够连接到 MySQL 服务器:</p> <pre><code class="language-py">$ mysql -u root -p </code></pre> <p>这将提示您输入安装过程中创建的根密码。</p> <h2 id="一些基本命令">一些基本命令</h2> <p>在 MySQL 服务器运行后,您有多种选项可以与数据库进行交互。许多软件工具作为中介,使您不必经常处理 MySQL 命令(或至少较少处理)。诸如 phpMyAdmin 和 MySQL Workbench 的工具可以使快速查看、排序和插入数据变得简单。但是,熟悉命令行操作仍然非常重要。</p> <p>除了变量名外,MySQL 是不区分大小写的;例如,<code>SELECT</code> 和 <code>sElEcT</code> 是相同的。然而,按照惯例,编写 MySQL 语句时所有 MySQL 关键字都应为大写。相反,大多数开发者更喜欢将其表和数据库名称使用小写,尽管这种标准经常被忽略。</p> <p>当您首次登录 MySQL 时,还没有数据库可以添加数据,但是您可以创建一个:</p> <pre><code class="language-py">> CREATE DATABASE scraping; </code></pre> <p>由于每个 MySQL 实例可以有多个数据库,在您开始与数据库交互之前,您需要告诉 MySQL 您要使用哪个数据库:</p> <pre><code class="language-py">> USE scraping; </code></pre> <p>从此时起(至少直到您关闭 MySQL 连接或切换到另一个数据库),所有输入的命令都将针对新创建的<code>scraping</code>数据库运行。</p> <p>一切看起来都很简单。在数据库中创建表应该也同样简单吧?让我们试着创建一个用于存储抓取的网页集合的表:</p> <pre><code class="language-py">> CREATE TABLE pages; </code></pre> <p>这将导致错误:</p> <pre><code class="language-py">ERROR 1113 (42000): A table must have at least 1 column </code></pre> <p>与数据库可以不含任何表存在不同,MySQL 中的表不能没有列存在。要在 MySQL 中定义列,必须在<code>CREATE TABLE <tablename></code>语句后的括号内输入以逗号分隔的列列表:</p> <pre><code class="language-py">> CREATE TABLE pages (id BIGINT(7) NOT NULL AUTO_INCREMENT, title VARCHAR(200), content VARCHAR(10000), created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY(id)); </code></pre> <p>每个列定义有三个部分:</p> <ul> <li> <p>名称(<code>id</code>,<code>title</code>,<code>created</code>,等等)</p> </li> <li> <p>变量类型(<code>BIGINT(7)</code>,<code>VARCHAR</code>,<code>TIMESTAMP</code>)</p> </li> <li> <p>可选的任何其他属性(<code>NOT NULL AUTO_INCREMENT</code>)</p> </li> </ul> <p>在列列表的末尾,您必须定义表的<em>关键字</em>。MySQL 使用关键字来组织表中的内容,以便进行快速查找。在本章后面,我将描述如何利用这些关键字来加快数据库操作,但现在,通常将表的<code>id</code>列作为关键字是最佳选择。</p> <p>执行查询后,您可以随时使用<code>DESCRIBE</code>命令查看表的结构:</p> <pre><code class="language-py">> 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) </code></pre> <p>当然,这仍然是一个空表。您可以使用以下命令将测试数据插入到<em>pages</em>表中:</p> <pre><code class="language-py">> INSERT INTO pages (title, content) VALUES ("Test page title", "This is some test page content. It can be up to 10,000 characters long."); </code></pre> <p>注意,尽管表有四列 (<code>id</code>, <code>title</code>, <code>content</code>, <code>created</code>), 但你只需定义其中两列 (<code>title</code> 和 <code>content</code>) 就可以插入一行数据。这是因为 <code>id</code> 列是自动增加的(每次插入新行时 MySQL 自动添加 1),通常可以自行处理。此外,<code>timestamp</code> 列设置为默认包含当前时间。</p> <p>当然,你可以覆盖这些默认设置:</p> <pre><code class="language-py">> 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"); </code></pre> <p>只要你提供给 <code>id</code> 列的整数在数据库中不存在,这个覆盖就可以完美运行。然而,这通常不是一个好的做法;最好让 MySQL 自行处理 <code>id</code> 和 <code>timestamp</code> 列,除非有充分的理由要做出不同的处理。</p> <p>现在你的表中有了一些数据,你可以使用多种方法来选择这些数据。以下是几个 <code>SELECT</code> 语句的示例:</p> <pre><code class="language-py">> SELECT * FROM pages WHERE id = 2; </code></pre> <p>这个语句告诉 MySQL,“从 <code>pages</code> 中选择所有 <code>id</code> 等于 2 的行。”星号 (*) 充当通配符,在 <code>where id equals 2</code> 条件为真时返回所有行。它返回表中的第二行,或者如果没有 <code>id</code> 等于 2 的行,则返回空结果。例如,以下不区分大小写的查询返回所有 <code>title</code> 字段包含 “test” 的行(% 符号在 MySQL 字符串中充当通配符):</p> <pre><code class="language-py">> SELECT * FROM pages WHERE title LIKE "%test%"; </code></pre> <p>但是如果你有一张有很多列的表,你只想返回特定的一部分数据怎么办?不必选择全部,你可以像这样做:</p> <pre><code class="language-py">> SELECT id, title FROM pages WHERE content LIKE "%page content%"; </code></pre> <p>这将只返回包含短语 “page content” 的 <code>id</code> 和 <code>title</code>。</p> <p><code>DELETE</code> 语句与 <code>SELECT</code> 语句的语法基本相同:</p> <pre><code class="language-py">> DELETE FROM pages WHERE id = 1; </code></pre> <p>因此,特别是在处理不能轻易恢复的重要数据库时,最好将任何 <code>DELETE</code> 语句首先编写为 <code>SELECT</code> 语句(在本例中为 <code>SELECT * FROM pages WHERE  id = 1</code>),测试以确保只返回要删除的行,然后用 <code>DELETE</code> 替换 <code>SELECT *</code>。许多程序员有编写 <code>DELETE</code> 语句时错用了条件或更糟糕的是匆忙时完全忽略了它的恐怖故事,导致客户数据丢失。不要让这种情况发生在你身上!</p> <p>对于 <code>UPDATE</code> 语句也应该采取类似的预防措施:</p> <pre><code class="language-py">> UPDATE pages SET title="A new title", content="Some new content" WHERE id=2; </code></pre> <p>本书只涉及简单的 MySQL 语句,进行基本的选择、插入和更新。如果你有兴趣学习更多关于这个强大数据库工具的命令和技巧,我推荐 Paul DuBois 的 <a href="http://shop.oreilly.com/product/0636920032274.do" target="_blank"><em>MySQL Cookbook</em></a>(O’Reilly)。</p> <h2 id="与-python-集成">与 Python 集成</h2> <p>不幸的是,Python 对于 MySQL 的支持不是内置的。然而,许多开源库允许你与 MySQL 数据库交互。其中最流行的之一是 <a href="https://pypi.python.org/pypi/PyMySQL" target="_blank">PyMySQL</a>。</p> <p>截至撰写本文时,PyMySQL 的当前版本是 1.0.3,可以使用 pip 安装:</p> <pre><code class="language-py">$ pip install PyMySQL </code></pre> <p>安装完成后,你应该自动拥有 PyMySQL 包的访问权限。当你的本地 MySQL 服务器运行时,你应该能够成功执行以下脚本(记得为你的数据库添加 root 密码):</p> <pre><code class="language-py">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() </code></pre> <p>在这个示例中,引入了两种新的对象类型:连接对象(<code>conn</code>)和游标对象(<code>cur</code>)。</p> <p>连接/游标模型在数据库编程中常用,尽管一些用户可能起初会发现区分这两者有些棘手。连接负责连接数据库,当然,还负责发送数据库信息,处理回滚(当需要中止一个查询或一组查询,并且需要将数据库返回到之前的状态时),以及创建新的游标对象。</p> <p>一个连接可以有多个游标。游标跟踪某些<em>状态</em>信息,比如它正在使用哪个数据库。如果你有多个数据库并且需要在所有数据库中写入信息,你可能需要多个游标来处理这个任务。游标还包含它执行的最新查询的结果。通过调用游标的函数,比如<code>cur.fetchone()</code>,你可以访问这些信息。</p> <p>在使用完游标和连接后,重要的是要关闭它们。如果不这样做,可能会导致<em>连接泄漏</em>,即未关闭的连接会积累起来,虽然不再使用,但软件无法关闭,因为它认为你可能仍在使用它们。这是经常导致数据库故障的问题之一(我既写过也修复过许多连接泄漏的 bug),所以记得关闭你的连接!</p> <p>最常见的事情,你可能一开始想做的就是能够将你的爬取结果存储在数据库中。让我们看看如何实现这一点,使用之前的示例:维基百科爬虫。</p> <p>在网页抓取时处理 Unicode 文本可能会很棘手。默认情况下,MySQL 不处理 Unicode。幸运的是,你可以启用这个功能(只需记住这样做会增加数据库的大小)。因为你可能会在维基百科上遇到各种丰富多彩的字符,现在是告诉你的数据库期望一些 Unicode 字符的好时机:</p> <pre><code class="language-py">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; </code></pre> <p>这四行改变了数据库的默认字符集,表格的字符集,以及两列的字符集,从<code>utf8mb4</code>(仍然是 Unicode,但对大多数 Unicode 字符的支持非常糟糕)到<code>utf8mb4_unicode_ci</code>。</p> <p>如果你尝试将几个 umlauts 或者汉字插入数据库中的<code>title</code>或<code>content</code>字段,并且成功执行而没有错误,那么你就知道你成功了。</p> <p>现在数据库已经准备好接受维基百科可能投射给它的各种内容,你可以运行以下操作:</p> <pre><code class="language-py">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() </code></pre> <p>在这里需要注意几点:首先,在数据库连接字符串中添加了<code>"charset='utf8'"</code>。这告诉连接应将所有信息以 UTF-8 发送到数据库(当然,数据库应已配置为处理此信息)。</p> <p>其次,注意添加了一个<code>store</code>函数。它接收两个字符串变量<code>title</code>和<code>content</code>,并将它们添加到一个<code>INSERT</code>语句中,该语句由游标执行,然后由游标的连接提交。这是游标和连接分离的一个很好的例子;虽然游标存储了关于数据库及其自身上下文的信息,但它需要通过连接操作才能将信息发送回数据库并插入信息。</p> <p>最后,你会发现在程序的主循环底部添加了一个<code>finally</code>语句。这确保了无论程序如何被中断或其执行过程中可能抛出的异常(因为网络是混乱的,你应该始终假设会抛出异常),在程序结束之前光标和连接都会立即关闭。在进行网络爬取并且有开放的数据库连接时,包括像这样的<code>try...finally</code>语句是个好主意。</p> <p>虽然 PyMySQL 不是一个庞大的包,但这本书无法涵盖大量有用的功能。你可以在 PyMySQL 网站的<a href="https://pymysql.readthedocs.io/en/latest/" target="_blank">文档</a>中查看更多信息。</p> <h2 id="数据库技术和良好实践">数据库技术和良好实践</h2> <p>有些人将他们整个职业生涯都花在研究、调优和发明数据库上。我不是这些人之一,这本书也不是那种类型的书。然而,就计算机科学中的许多主题而言,你可以快速学到一些技巧,至少能使你的数据库对大多数应用程序足够、而且足够快速。</p> <p>首先,除了少数例外情况,始终向你的表中添加<code>id</code>列。MySQL 中的所有表都必须至少有一个主键(MySQL 用来排序的关键列),因此 MySQL 需要知道如何对其进行排序,而且通常难以明智地选择这些键。</p> <p>关于是使用人工创建的<code>id</code>列作为此键还是像<code>username</code>这样的唯一属性的争论已经在数据科学家和软件工程师中持续了多年,虽然我倾向于创建<code>id</code>列。尤其是当你处理网页抓取和存储他人数据时,这一点尤为真实。你根本不知道什么是真正独特的或非独特的,我之前也曾感到惊讶。</p> <p>你的<code>id</code>列应该是自增的,并且作为所有表的主键使用。</p> <p>其次,使用智能索引。一个词典(就像书中的那种,而不是 Python 对象)是按字母顺序索引的单词列表。这样,每当你需要一个单词时,只要知道它的拼写方式,就可以快速查找。你还可以想象一个根据单词定义按字母顺序排列的词典。除非你在玩某种奇怪的<em>危险边缘</em>游戏,需要根据定义找出单词,否则这种词典几乎没有用处。但是在数据库查找中,这类情况确实会发生。例如,你的数据库中可能有一个经常需要查询的字段:</p> <pre><code class="language-py">>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) </code></pre> <p>你很可能想要在这个表中添加一个索引(除了已经存在的<code>id</code>索引),以使对<code>definition</code>列的查找更快速。不过,请记住,添加索引会增加新索引的空间占用,并在插入新行时增加额外的处理时间。特别是当你处理大量数据时,你应仔细考虑索引的权衡以及你需要索引多少的问题。为了使这个“definitions”索引变得更加轻便,你可以告诉 MySQL 仅对列值中的前 16 个字符进行索引。这条命令将在<code>definition</code>字段的前 16 个字符上创建一个索引:</p> <pre><code class="language-py">CREATE INDEX definition ON dictionary (id, definition(16)); </code></pre> <p>当你需要通过完整定义来搜索单词时(尤其是如果定义值的前 16 个字符彼此非常不同),这个索引将使你的查找速度大大加快,并且不会显著增加额外的空间和前期处理时间。</p> <p>关于查询时间与数据库大小之间的平衡(数据库工程中的基本平衡之一),特别是在大量自然文本数据的网络抓取中,常见的一个错误是存储大量重复数据。例如,假设你想要测量出现在多个网站上的某些短语的频率。这些短语可以从给定列表中找到,也可以通过文本分析算法自动生成。也许你会被诱惑将数据存储为这样的格式:</p> <pre><code class="language-py">+--------+--------------+------+-----+---------+----------------+ | Field  | Type         | Null | Key | Default | Extra          | +--------+--------------+------+-----+---------+----------------+ | id     | int(11)      | NO   | PRI | NULL    | auto_increment | | url    | varchar(200) | YES  |     | NULL    |                | | phrase | varchar(200) | YES  |     | NULL    |                | +--------+--------------+------+-----+---------+----------------+ </code></pre> <p>每次在网站上找到一个短语并记录它所在的 URL 时,这样会向数据库中添加一行。然而,通过将数据拆分为三个单独的表,你可以极大地减少数据集:</p> <pre><code class="language-py">>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    |                | +-------------+---------+------+-----+---------+----------------+ </code></pre> <p>尽管表定义更大,但你可以看到大多数列只是整数<code>id</code>字段,它们占用的空间要少得多。此外,每个 URL 和短语的完整文本仅存储一次。</p> <p>除非安装第三方包或保持详细的日志记录,否则无法确定数据何时添加、更新或从数据库中删除。根据数据的可用空间、更改的频率以及确定这些更改发生时间的重要性,你可能需要考虑保留多个时间戳:<code>created</code>、<code>updated</code>和<code>deleted</code>。</p> <h2 id="mysql-中的六度">MySQL 中的“六度”</h2> <p>第六章介绍了维基百科的六度问题,即通过一系列链接找到任意两个维基百科文章之间的连接的目标(即,通过从一个页面点击链接到达下一个页面的方式找到一种方法)。要解决这个问题,不仅需要构建可以爬取网站的机器人(这一点你已经做到了),还需要以建筑上合理的方式存储信息,以便以后轻松进行数据分析。</p> <p>自增的 <code>id</code> 列,时间戳,和多个表格:它们在这里都发挥作用。为了找出如何最好地存储这些信息,你需要抽象地思考。一个链接简单地是连接页面 A 和页面 B 的东西。它同样可以将页面 B 连接到页面 A,但这将是一个独立的链接。你可以通过说,“在页面 A 上存在一个连接到页面 B 的链接来唯一标识一个链接。也就是说,<code>INSERT INTO</code> links (<code>fromPageId</code>, <code>toPageId</code>) VALUES (A, B);(其中 A 和 B 是两个页面的唯一 ID)。”</p> <p>一个旨在存储页面和链接、创建日期和唯一 ID 的两表系统可以按以下方式构建:</p> <pre><code class="language-py">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) ); </code></pre> <p>请注意,与之前打印页面标题的爬虫不同,你甚至没有将页面标题存储在 <code>pages</code> 表中。为什么呢?嗯,记录页面标题需要访问页面以检索它。如果你想要构建一个高效的网络爬虫来填充这些表,你希望能够存储页面以及链接到它的链接,即使你还没有必要访问页面。</p> <p>尽管这并不适用于所有网站,但维基百科链接和页面标题之间的好处在于,一个可以通过简单操作变成另一个。例如,<em><a href="http://en.wikipedia.org/wiki/Monty_Python" target="_blank">http://en.wikipedia.org/wiki/Monty_Python</a></em> 表示页面的标题是“蒙提·派森”。</p> <p>以下将存储在维基百科上具有“Bacon 数”(与凯文·贝肯页面之间的链接数,包括)小于或等于 6 的所有页面:</p> <pre><code class="language-py">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() </code></pre> <p>这里的三个函数使用 PyMySQL 与数据库交互:</p> <p><code>insertPageIfNotExists</code></p> <p>正如其名称所示,如果页面记录尚不存在,此函数将插入一个新的页面记录。这与存储在 <code>pages</code> 中的所有收集页面的运行列表一起,确保页面记录不会重复。它还用于查找 <code>pageId</code> 数字以创建新的链接。</p> <p><code>insertLink</code></p> <p>这在数据库中创建一个新的链接记录。如果该链接已经存在,它将不会创建一个链接。即使页面上存在两个或多个相同的链接,对于我们的目的来说,它们是同一个链接,代表同一个关系,并且应该被计为一个记录。即使在同一页面上运行多次程序,这也有助于保持数据库的完整性。</p> <p><code>loadPages</code></p> <p>这会将数据库中所有当前页面加载到列表中,以便确定是否应该访问新页面。页面也会在运行时收集,因此如果此爬虫仅运行一次,从空数据库开始理论上不应该需要<code>loadPage</code>。然而,实际上可能会出现问题。网络可能会中断,或者您可能希望在几段时间内收集链接,因此爬虫能够重新加载自身并不会失去任何进展是非常重要的。</p> <p>您应该注意使用<code>loadPages</code>和它生成的<code>pages</code>列表确定是否访问页面时可能出现的一个潜在问题:一旦加载每个页面,该页面上的所有链接都被存储为页面,即使它们尚未被访问——只是它们的链接已被看到。如果爬虫停止并重新启动,所有这些“已看到但未访问”的页面将永远不会被访问,并且来自它们的链接将不会被记录。可以通过向每个页面记录添加布尔变量<code>visited</code>并仅在该页面已加载并记录其自身的传出链接时将其设置为<code>True</code>来修复此问题。</p> <p>然而,对于我们的目的来说,目前这个解决方案已经足够了。如果您可以确保相对较长的运行时间(或只需一次运行时间),并且没有必要确保完整的链接集(只需要一个大数据集进行实验),则不需要添加<code>visited</code>变量。</p> <p>对于解决从<a href="https://en.wikipedia.org/wiki/Kevin_Bacon" target="_blank">凯文·贝肯</a>到<a href="https://en.wikipedia.org/wiki/Eric_Idle" target="_blank">埃里克·艾多</a>的问题的延续以及最终解决方案,请参阅解决有向图问题的“维基百科的六度:结论”。</p> <h1 id="电子邮件">电子邮件</h1> <p>就像网页通过 HTTP 发送一样,电子邮件通过 SMTP(简单邮件传输协议)发送。而且就像您使用 Web 服务器客户端处理通过 HTTP 发送网页一样,服务器使用各种电子邮件客户端(如 Sendmail、Postfix 或 Mailman)来发送和接收电子邮件。</p> <p>尽管使用 Python 发送电子邮件相对简单,但确实需要您可以访问运行 SMTP 的服务器。在服务器或本地计算机上设置 SMTP 客户端很棘手,超出了本书的范围,但许多优秀的资源可以帮助完成此任务,特别是如果您正在运行 Linux 或 macOS。</p> <p>以下代码示例假设您在本地运行 SMTP 客户端。(要将此代码修改为远程 SMTP 客户端,请将<code>localhost</code>更改为您的远程服务器地址。)</p> <p>使用 Python 发送电子邮件仅需九行代码:</p> <pre><code class="language-py">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() </code></pre> <p>Python 包含两个重要的包用于发送电子邮件:<em>smtplib</em> 和 <em>email</em>。</p> <p>Python 的 email 模块包含用于创建要发送的电子邮件包的有用格式化函数。这里使用的 <code>MIMEText</code> 对象创建了一个空的电子邮件,格式化为使用低级 MIME(多用途互联网邮件扩展)协议进行传输,高级别的 SMTP 连接是在此基础上建立的。<code>MIMEText</code> 对象 <code>msg</code> 包含了电子邮件的收件人和发件人地址,以及一个主体和一个头部,Python 使用这些信息来创建一个格式正确的电子邮件。</p> <p>smtplib 包包含了处理与服务器连接的信息。就像连接到 MySQL 服务器一样,这个连接必须在每次创建时关闭,以避免创建过多的连接。</p> <p>这个基本的电子邮件功能可以通过将其放在一个函数中来扩展并使其更有用:</p> <pre><code class="language-py">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!') </code></pre> <p>这个特定脚本每小时检查一次网站 <a href="https://isitchristmas.com" target="_blank"><em>https://isitchristmas.com</em></a>(其主要功能是根据一年中的日期显示一个巨大的 YES 或 NO)。如果它看到的不是 NO,它将发送给您一个警报邮件,提醒您现在是圣诞节。</p> <p>尽管这个特定程序看起来可能比挂在墙上的日历没有多大用处,但它可以稍加调整,做出各种非常有用的事情。它可以在网站停机、测试失败,甚至是你在亚马逊等待的缺货产品出现时向你发送警报邮件——而你的墙挂日历都做不到这些。</p> <p>¹ Joab Jackson,<a href="http://bit.ly/1LWVmc8" target="_blank">“YouTube 用 Go 代码扩展 MySQL”</a>,<em>PCWorld</em>,2012 年 12 月 15 日。</p> <p>² Jeremy Cole 和 Davi Arnaut,<a href="http://bit.ly/1KHDKns" target="_blank">“Twitter 上的 MySQL”</a>,<em>Twitter 工程博客</em>,2012 年 4 月 9 日。</p> <p>³ <a href="http://on.fb.me/1RFMqvw" target="_blank">“MySQL 和数据库工程:Mark Callaghan”</a>,Facebook 工程师,2012 年 3 月 4 日。</p> <h1 id="第二部分高级爬虫">第二部分:高级爬虫</h1> <p>您已经打下了一些网络爬虫的基础;现在是有趣的部分。直到此时,您的网络爬虫相对而言都比较“笨拙”。除非服务器直接以良好格式呈现信息,否则它们无法检索信息。它们接受所有信息的表面价值,并将其存储而不进行任何分析。它们会被表单、网站交互甚至 JavaScript 所困扰。简言之,它们在除非信息确实想要被检索,否则无法有效地检索信息。</p> <p>本书的这一部分将帮助您分析原始数据,以获取数据背后的故事——这些故事通常被网站隐藏在 JavaScript、登录表单和反爬措施的层层之下。您将学习如何使用网络爬虫测试您的网站、自动化流程,并大规模访问互联网。通过本节的学习,您将掌握工具,可以收集和操作几乎任何形式、任何部分互联网上的数据。</p> <h1 id="第十章读取文档">第十章:读取文档</h1> <p>现在很容易把互联网主要看作是由基于文本的网站和新型 Web 2.0 多媒体内容构成的集合,而这些内容大部分可以忽略不计以便进行网络抓取。然而,这忽略了互联网最根本的本质:作为传输文件的内容不可知的载体。</p> <p>尽管互联网在上世纪 60 年代末已经存在,但 HTML 直到 1992 年才首次亮相。在此之前,互联网主要由电子邮件和文件传输组成;我们今天所知的网页概念并不存在。换句话说,互联网不是 HTML 文件的集合。它是许多类型文档的集合,其中 HTML 文件通常用作展示它们的框架。如果不能阅读各种类型的文档,包括文本、PDF、图像、视频、电子邮件等,我们将错过大量可用数据的一部分。</p> <p>本章涵盖了处理文档的内容,无论是将它们下载到本地文件夹还是阅读它们并提取数据。您还将了解处理各种文本编码,这使得即使是阅读外语 HTML 页面也变得可能。</p> <h1 id="文档编码">文档编码</h1> <p>文档的编码告诉应用程序——无论是您计算机的操作系统还是您自己的 Python 代码——如何读取它。这种编码通常可以从其文件扩展名中推断出来,尽管文件扩展名并不一定反映其编码。例如,我可以将<em>myImage.jpg</em>保存为<em>myImage.txt</em>而不会出现问题——至少直到我的文本编辑器试图打开它。幸运的是,这种情况很少见,通常文件扩展名就足够了解它以正确地阅读。</p> <p>从根本上讲,所有文档都是用 0 和 1 编码的。此外,编码算法定义了诸如“每个字符多少位”或“每个像素颜色用多少位表示”(对于图像文件)之类的内容。此外,您可能还有一层压缩或某种空间减少算法,如 PNG 文件的情况。</p> <p>尽管一开始处理非 HTML 文件可能看起来令人畏惧,但请放心,通过正确的库,Python 将能够适应处理任何格式的信息。文本文件、视频文件和图像文件之间唯一的区别在于它们的 0 和 1 的解释方式。本章涵盖了几种常见的文件类型:文本、CSV、PDF 和 Word 文档。</p> <p>注意,这些从根本上讲都是存储文本的文件。关于处理图像的信息,请建议您阅读本章以熟悉处理和存储不同类型文件,然后转到第十六章获取更多有关图像处理的信息!</p> <h1 id="文本">文本</h1> <p>在线存储纯文本文件有些不寻常,但它在极简或老式网站中很受欢迎,用于存储大量文本文件。例如,互联网工程任务组(IETF)将其所有已发布文档存储为 HTML、PDF 和文本文件(参见<a href="https://www.ietf.org/rfc/rfc1149.txt" target="_blank"><em>https://www.ietf.org/rfc/rfc1149.txt</em></a> 作为示例)。大多数浏览器将正常显示这些文本文件,你应该能够毫无问题地进行抓取。</p> <p>对于大多数基本文本文档,比如位于<a href="http://www.pythonscraping.com/pages/warandpeace/chapter1.txt" target="_blank"><em>http://www.pythonscraping.com/pages/warandpeace/chapter1.txt</em></a> 的练习文件,你可以使用以下方法:</p> <pre><code class="language-py">from urllib.request import urlopen textPage = urlopen('http://www.pythonscraping.com/'\ 'pages/warandpeace/chapter1.txt') print(textPage.read()) </code></pre> <p>通常,当你使用<code>urlopen</code>检索页面时,你会将其转换为<code>BeautifulSoup</code>对象以解析 HTML。在这种情况下,你可以直接读取页面。将其转换为 BeautifulSoup 对象,虽然完全可行,但却是适得其反的——没有 HTML 可解析,因此该库将变得无用。一旦将文本文件读取为字符串,你只需像处理其他读入 Python 的字符串一样分析它即可。当然,这里的缺点是你无法使用 HTML 标签作为上下文线索,指导你找到实际需要的文本,而非你不想要的文本。当你尝试从文本文件中提取特定信息时,这可能会带来挑战。</p> <h2 id="文本编码与全球互联网">文本编码与全球互联网</h2> <p>大多数情况下,文件扩展名就足以告诉你如何正确读取文件。然而,最基本的所有文档——<em>.txt</em> 文件,奇怪的是,这个规则不适用。</p> <p>使用上述描述的方法读取文本通常会很好地运行,成功率达到 10 次中的 9 次。然而,在处理互联网文本时可能会遇到一些棘手的问题。接下来,我们将介绍从 ASCII 到 Unicode 到 ISO 的英语和外语编码基础,以及如何处理它们。</p> <h3 id="文本编码的历史">文本编码的历史</h3> <p>ASCII 最早在 1960 年代开发,当时比特昂贵,除了拉丁字母和少数标点符号外,没有理由编码其他内容。因此,只使用 7 位来编码 128 个大写字母、小写字母和标点符号。即使有了所有这些创意,他们仍然有 33 个非打印字符,随着技术的变化,其中一些被使用、替换或变为过时。对每个人来说,都有足够的空间,对吧?</p> <p>正如任何程序员所知道的那样,7 是一个奇怪的数字。它不是一个好看的 2 的幂,但它非常接近。20 世纪 60 年代的计算机科学家们争论是否应该添加一个额外的位,以便获得一个好看的圆整数,而不是为了减少文件占用的空间。最终,7 位赢得了。然而,在现代计算中,每个 7 位序列在开头填充了一个额外的 0¹,留下了我们两全其美的结果——文件增大了 14%,同时只有 128 个字符的灵活性。</p> <p>在 1990 年代初期,人们意识到不仅仅是英语存在更多的语言,如果计算机能够显示它们将是非常好的。一个名为 Unicode 联盟的非营利组织尝试通过为每个需要在任何文本文档中使用的字符建立编码来实现一种通用的文本编码器。目标是包括从本书所写的拉丁字母到西里尔字母(кириллица)、汉字象形文字、数学和逻辑符号(⨊、≥),甚至是表情符号和其他符号,如生化危险标志(☣)和和平符号(☮)的所有内容。</p> <p>最终产生的编码器,你可能已经知道,被称为<em>UTF-8</em>,这个名称令人困惑地指的是“通用字符集—转换格式 8 位”。这里的<em>8 位</em>并不是每个字符的大小,而是一个字符需要显示的最小大小。</p> <p>UTF-8 字符的实际大小是灵活的。它可以从 1 字节到 4 字节不等,这取决于它在可能字符列表中的位置(更常见的字符用较少的字节编码;更晦涩的字符需要更多字节)。</p> <p>这种灵活的编码是如何实现的?起初,使用 7 位和最终无用的前导 0 在 ASCII 中看起来像是一个设计缺陷,但证明对 UTF-8 是一个巨大的优势。因为 ASCII 如此流行,Unicode 决定利用这个前导 0 位,声明所有以 0 开头的字节表示该字符只使用一个字节,并使 ASCII 和 UTF-8 的两种编码方案相同。因此,以下字符在 UTF-8 和 ASCII 中都是有效的:</p> <pre><code class="language-py">01000001 - A 01000010 - B 01000011 - C </code></pre> <p>以下字符仅在 UTF-8 中有效,如果将文档解释为 ASCII 文档,则将呈现为不可打印字符。</p> <pre><code class="language-py">11000011 10000000 - À 11000011 10011111 - ß 11000011 10100111 - ç </code></pre> <p>除了 UTF-8 之外,还存在其他 UTF 标准,如 UTF-16、UTF-24 和 UTF-32,尽管在正常情况下很少遇到使用这些格式编码的文档,这超出了本书的范围。</p> <p>尽管 ASCII 的这一原始“设计缺陷”对 UTF-8 有重大优势,但劣势并未完全消失。每个字符中的前 8 位信息仍然只能编码 128 个字符,而不是完整的 256 个。在需要多个字节的 UTF-8 字符中,额外的前导位被花费在校验位上,而不是字符编码上。在 4 字节字符的 32 位中,仅使用 21 位用于字符编码,共计 2,097,152 个可能的字符,其中目前已分配 1,114,112 个。</p> <p>当然,所有通用语言编码标准的问题在于,任何单一外语编写的文档可能比其实际需要的要大得多。尽管您的语言可能只包含大约 100 个字符,但每个字符需要 16 位,而不像英语专用的 ASCII 那样只需 8 位。这使得 UTF-8 中的外语文本文档大约是英语文本文档的两倍大小,至少对于不使用拉丁字符集的外语而言。</p> <p>ISO 通过为每种语言创建特定编码来解决这个问题。与 Unicode 类似,它使用与 ASCII 相同的编码,但在每个字符的开头使用填充 0 位,以便为所有需要的语言创建 128 个特殊字符。这对于欧洲语言尤其有利,这些语言也严重依赖拉丁字母表(保留在编码的位置 0-127),但需要额外的特殊字符。这使得 ISO-8859-1(为拉丁字母表设计)可以拥有分数(如½)或版权符号(©)等符号。</p> <p>其他 ISO 字符集,比如 ISO-8859-9(土耳其语)、ISO-8859-2(德语等多种语言)和 ISO-8859-15(法语等多种语言),在互联网上也很常见。</p> <p>尽管 ISO 编码文档的流行度近年来有所下降,但约有 9% 的互联网网站仍使用某种 ISO 格式²,因此在抓取网站前了解和检查编码是至关重要的。</p> <h3 id="编码的实际应用">编码的实际应用</h3> <p>在前一节中,您使用了 <code>urlopen</code> 的默认设置来读取可能在互联网上遇到的文本文档。这对大多数英文文本效果很好。然而,一旦遇到俄语、阿拉伯语,或者甚至像“résumé”这样的单词,可能会遇到问题。</p> <p>例如,看下面的代码:</p> <pre><code class="language-py">from urllib.request import urlopen textPage = urlopen('http://www.pythonscraping.com/'\ 'pages/warandpeace/chapter1-ru.txt') print(textPage.read()) </code></pre> <p>这读取了原版《战争与和平》的第一章(用俄语和法语写成),并将其打印到屏幕上。屏幕文本部分内容如下:</p> <pre><code class="language-py">b"\xd0\xa7\xd0\x90\xd0\xa1\xd0\xa2\xd0\xac \xd0\x9f\xd0\x95\xd0\xa0\xd0\x92\xd0\ x90\xd0\xaf\n\nI\n\n\xe2\x80\x94 Eh bien, mon prince. </code></pre> <p>此外,使用大多数浏览器访问该页面会导致乱码(见 Figure 10-1)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1001.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-10-1-用-iso-8859-1-编码的法语和西里尔文本许多浏览器中的默认文本文档编码">图 10-1. 用 ISO-8859-1 编码的法语和西里尔文本,许多浏览器中的默认文本文档编码</h6> <p>即使对于母语为俄语的人来说,这可能会有些难以理解。问题在于 Python 试图将文档读取为 ASCII 文档,而浏览器试图将其读取为 ISO-8859-1 编码的文档。当然,两者都没有意识到它实际上是一个 UTF-8 文档。</p> <p>您可以显式地定义字符串为 UTF-8,这样正确地将输出格式化为西里尔字符:</p> <pre><code class="language-py">from urllib.request import urlopen textPage = urlopen('http://www.pythonscraping.com/'\     'pages/warandpeace/chapter1-ru.txt') print(str(textPage.read(), 'utf-8')) </code></pre> <p>使用 BeautifulSoup 实现这一概念如下:</p> <pre><code class="language-py">html = urlopen('http://en.wikipedia.org/wiki/Python_(programming_language)') bs = BeautifulSoup(html, 'html.parser') content = bs.find('div', {'id':'mw-content-text'}).get_text() content = bytes(content, 'UTF-8') content = content.decode('UTF-8') </code></pre> <p>Python 默认将所有字符编码为 UTF-8。您可能会倾向于不作更改,并为编写的每个网络抓取器使用 UTF-8 编码。毕竟,UTF-8 也能顺利处理 ASCII 字符以及外语字符。然而,重要的是要记住,有 9% 的网站使用某种 ISO 编码版本,因此您无法完全避免这个问题。</p> <p>不幸的是,在处理文本文档时,无法确定文档具体使用的编码。一些库可以检查文档并做出最佳猜测(使用一些逻辑来认识到“рассказє可能不是一个单词),但很多时候它们会出错。</p> <p>幸运的是,在 HTML 页面的情况下,编码通常包含在网站 <code><head></code> 部分的标签中。大多数网站,特别是英语网站,都有这样的标签:</p> <pre><code class="language-py"><meta charset="utf-8" /> </code></pre> <p>而 <a href="http://www.ecma-international.org" target="_blank">ECMA International 的网站</a> 就有这个标签:³</p> <pre><code class="language-py"><META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1"> </code></pre> <p>如果您计划大量进行网页抓取,特别是国际网站,最好查找这个元标签,并在读取页面内容时使用它推荐的编码方式。</p> <h1 id="csv">CSV</h1> <p>在进行网页抓取时,您可能会遇到 CSV 文件或喜欢以这种方式格式化数据的同事。幸运的是,Python 有一个 <a href="https://docs.python.org/3.4/library/csv.html" target="_blank">出色的库</a> 既可以读取也可以写入 CSV 文件。虽然该库能处理多种 CSV 变体,但本节主要关注标准格式。如果您有特殊情况需要处理,请参考文档!</p> <h2 id="读取-csv-文件">读取 CSV 文件</h2> <p>Python 的 <em>csv</em> 库主要用于处理本地文件,假设需要处理的 CSV 数据存储在您的计算机上。不幸的是,情况并非总是如此,特别是在进行网页抓取时。有几种方法可以解决这个问题:</p> <ul> <li> <p>手动下载文件并将 Python 指向本地文件位置。</p> </li> <li> <p>编写一个 Python 脚本来下载文件,读取文件,并在检索后(可选)删除文件。</p> </li> <li> <p>从网页中检索文件字符串,并将字符串包装在 <code>StringIO</code> 对象中,以便其像文件一样运行。</p> </li> </ul> <p>尽管前两种选项可行,但将文件保存在硬盘上会占用空间,而你完全可以将它们保存在内存中,这是不良实践。最好的做法是将文件作为字符串读入,并将其包装在一个对象中,使 Python 能够将其视为文件,而无需保存文件。以下脚本从互联网获取 CSV 文件(在本例中,是<a href="http://pythonscraping.com/files/MontyPythonAlbums.csv" target="_blank"><em>http://pythonscraping.com/files/MontyPythonAlbums.csv</em></a>上的 Monty Python 专辑列表),并逐行将其打印到终端:</p> <pre><code class="language-py">from urllib.request import urlopen from io import StringIO import csv data = urlopen('http://pythonscraping.com/files/MontyPythonAlbums.csv')   .read().decode('ascii', 'ignore') dataFile = StringIO(data) csvReader = csv.reader(dataFile) for row in csvReader: print(row) </code></pre> <p>输出看起来像这样:</p> <pre><code class="language-py">['Name', 'Year'] ["Monty Python's Flying Circus", '1970'] ['Another Monty Python Record', '1971'] ["Monty Python's Previous Record", '1972'] ... </code></pre> <p>正如你从代码示例中看到的那样,<code>csv.reader</code>返回的读取器对象是可迭代的,并由 Python 列表对象组成。因此,<code>csvReader</code>对象中的每一行都可以通过以下方式访问:</p> <pre><code class="language-py">for row in csvReader: print('The album "'+row[0]+'" was released in '+str(row[1])) </code></pre> <p>这是输出:</p> <pre><code class="language-py">The album "Name" was released in Year The album "Monty Python's Flying Circus" was released in 1970 The album "Another Monty Python Record" was released in 1971 The album "Monty Python's Previous Record" was released in 1972 ... </code></pre> <p>注意第一行:<code>The album "Name" was released in Year</code>。尽管这可能是编写示例代码时容易忽略的结果,但在现实世界中,你不希望这些内容出现在你的数据中。一个不那么熟练的程序员可能只是跳过<code>csvReader</code>对象中的第一行,或者编写一个特殊情况来处理它。幸运的是,<code>csv.reader</code>函数的替代方案会自动处理所有这些。进入<code>DictReader</code>:</p> <pre><code class="language-py">from urllib.request import urlopen from io import StringIO import csv data = urlopen('http://pythonscraping.com/files/MontyPythonAlbums.csv')   .read().decode('ascii', 'ignore') dataFile = StringIO(data) dictReader = csv.DictReader(dataFile) print(dictReader.fieldnames) for row in dictReader: print(row) </code></pre> <p><code>csv.DictReader</code>将 CSV 文件中每一行的值作为字典对象返回,而不是列表对象,字段名称存储在变量<code>dictReader.fieldnames</code>中,并作为每个字典对象的键:</p> <pre><code class="language-py">['Name', 'Year'] {'Name': 'Monty Python's Flying Circus', 'Year': '1970'} {'Name': 'Another Monty Python Record', 'Year': '1971'} {'Name': 'Monty Python's Previous Record', 'Year': '1972'} </code></pre> <p>当然,与<code>csvReader</code>相比,创建、处理和打印这些<code>DictReader</code>对象需要稍长时间,但其便利性和可用性往往超过了额外的开销。此外,请记住,在进行网页抓取时,从外部服务器请求和检索网站数据所需的开销几乎总是任何你编写的程序中不可避免的限制因素,因此担心哪种技术可以减少总运行时间的微秒级别问题通常是没有意义的!</p> <h1 id="pdf">PDF</h1> <p>作为 Linux 用户,我深知收到一个<em>.docx</em>文件,而我的非微软软件将其搞乱的痛苦,还有努力寻找解析某些新的 Apple 媒体格式的解码器。在某些方面,Adobe 在 1993 年创建其便携式文档格式(PDF)方面具有革命性。PDF 允许不同平台的用户以完全相同的方式查看图像和文本文档,而不受查看平台的影响。</p> <p>尽管将 PDF 存储在网络上有点过时(为什么要将内容存储在静态、加载缓慢的格式中,而不是编写 HTML 呢?),但 PDF 仍然是无处不在的,特别是在处理官方表格和文件时。</p> <p>2009 年,英国人尼克·因斯(Nick Innes)因向巴克莱市议会根据英国版《信息自由法》请求公开学生测试结果信息而成为新闻人物。经过一些重复请求和拒绝后,他最终以 184 份 PDF 文档的形式收到了他寻找的信息。</p> <p>虽然 Innes 坚持不懈,并最终获得了一个更合适格式的数据库,但如果他是一个专业的网络爬虫,他很可能本可以节省很多时间,并直接使用 Python 的许多 PDF 解析模块处理 PDF 文档。</p> <p>不幸的是,由于 PDF 是一个相对简单和开放源码的文档格式,在 PDF 解析库方面竞争激烈。这些项目通常会在多年间建立、弃用、重建。目前最受欢迎、功能齐全且易于使用的库是 <a href="https://pypi.org/project/pypdf/" target="_blank">pypdf</a>。</p> <p>Pypdf 是一个免费的开源库,允许用户从 PDF 中提取文本和图像。它还允许您对 PDF 文件执行操作,并且如果您想生成 PDF 文件而不仅仅是阅读它们,也可以直接从 Python 进行操作。</p> <p>您可以像往常一样使用 pip 进行安装:</p> <pre><code class="language-py">$ pip install pypdf </code></pre> <p>文档位于 <a href="https://pypdf.readthedocs.io/en/latest/index.html" target="_blank"><em>https://pypdf.readthedocs.io/en/latest/index.html</em></a>。</p> <p>下面是一个基本的实现,允许您从本地文件对象中读取任意 PDF 到字符串:</p> <pre><code class="language-py">from urllib.request import urlretrieve from pypdf import PdfReader urlretrieve(   'http://pythonscraping.com/pages/warandpeace/chapter1.pdf',   'chapter1.pdf' ) reader = PdfReader('chapter1.pdf') for page in reader.pages:     print(page.extract_text()) </code></pre> <p>这提供了熟悉的纯文本输出:</p> <pre><code class="language-py">CHAPTER I "Well, Prince, so Genoa and Lucca are now just family estates of the Buonapartes. But I warn you, if you don't tell me that this means war, if you still try to defend the infamies and horrors perpetrated by that Antichrist- I really believe he is Antichrist- I will </code></pre> <p>注意,PDF 文件参数必须是一个实际的文件对象。在将其传递给 <code>Pdfreader</code> 类之前,您必须先将文件下载到本地。然而,如果您处理大量的 PDF 文件,并且不希望保留原始文件,您可以在从文本中提取后,通过再次将相同文件名传递给 <code>urlretrieve</code> 来覆盖先前的文件。</p> <p>Pypdf 的输出可能不完美,特别是对于带有图像、奇怪格式文本或以表格或图表形式排列的 PDF。然而,对于大多数仅包含文本的 PDF,输出应与将 PDF 视为文本文件时的输出没有区别。</p> <h1 id="microsoft-word-和-docx">Microsoft Word 和 .docx</h1> <p>冒犯微软朋友的风险在此:我不喜欢 Microsoft Word。并不是因为它本质上是个糟糕的软件,而是因为它的用户如何误用它。它有一种特殊的才能,可以将本应是简单文本文档或 PDF 的内容转变为体积庞大、打开缓慢、易于在机器之间丢失所有格式的东西,并且由于某种原因,在内容通常意味着静态的情况下却是可编辑的。</p> <p>Word 文件是为内容创建而设计,而不是为内容共享。尽管如此,在某些网站上它们无处不在,包含重要文件、信息,甚至图表和多媒体;总之,所有可以和应该使用 HTML 创建的内容。</p> <p>在约 2008 年之前,Microsoft Office 产品使用专有的 <em>.doc</em> 文件格式。这种二进制文件格式难以阅读,而且其他文字处理软件的支持很差。为了跟上时代并采用许多其他软件使用的标准,Microsoft 决定使用基于 Open Office XML 的标准,使得这些文件与开源及其他软件兼容。</p> <p>不幸的是,Python 对于由 Google Docs、Open Office 和 Microsoft Office 使用的此文件格式的支持仍然不够完善。有 <a href="http://python-docx.readthedocs.org/en/latest/" target="_blank">python-docx 库</a>,但它只能让用户创建文档并仅读取基本的文件数据,如文件的大小和标题,而不是实际的内容。要读取 Microsoft Office 文件的内容,您需要自己解决方案。</p> <p>第一步是从文件中读取 XML:</p> <pre><code class="language-py">from zipfile import ZipFile from urllib.request import urlopen from io import BytesIO wordFile = urlopen('http://pythonscraping.com/pages/AWordDocument.docx').read() wordFile = BytesIO(wordFile) document = ZipFile(wordFile) xml_content = document.read('word/document.xml') print(xml_content.decode('utf-8')) </code></pre> <p>这段代码将一个远程 Word 文档作为二进制文件对象读取(<code>BytesIO</code> 类似于本章前面使用的 <code>StringIO</code>),使用 Python 的核心 zipfile 库解压缩它(所有的 <em>.docx</em> 文件都被压缩以节省空间),然后读取解压后的 XML 文件。</p> <p><a href="http://pythonscraping.com/pages/AWordDocument.docx" target="_blank"><em>http://pythonscraping.com/pages/AWordDocument.docx</em></a> 上展示了 图 10-2 中的 Word 文档。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1002.png" alt="" loading="lazy"></p> <h6 id="图-10-2这是一个-word-文档里面可能包含您非常想要的内容但由于我将其作为-docx-文件放在网站上而不是发布为-html所以访问起来很困难单词unfortunatly拼写错误">图 10-2。这是一个 Word 文档,里面可能包含您非常想要的内容,但由于我将其作为 <em>.docx</em> 文件放在网站上而不是发布为 HTML,所以访问起来很困难。单词“unfortunatly”拼写错误。</h6> <p>Python 脚本读取我简单 Word 文档的输出如下所示:</p> <pre><code class="language-py"><w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/ wordprocessingCanvas" xmlns:cx="http://schemas.microsoft.com/office/d rawing/2014/chartex" xmlns:cx1="http://schemas.microsoft.com/office/d rawing/2015/9/8/chartex" xmlns:cx2="http://schemas.microsoft.com/offi ce/drawing/2015/10/21/chartex" xmlns:cx3="http://schemas.microsoft.co m/office/drawing/2016/5/9/chartex" xmlns:cx4="http://schemas.microsof *`...More schema data here...`* <w:body><w:p w14:paraId="19A18025" w14:textId="54C8E458" w:rsidR="007 45992" w:rsidRDefault="00BF6C9C" w:rsidP="00BF6C9C"><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>A Word Document on a Website</w:t ></w:r></w:p><w:p w14:paraId="501E7A3A" w14:textId="77777777" w:rsidR ="00BF6C9C" w:rsidRDefault="00BF6C9C" w:rsidP="00BF6C9C"/><w:p w14:pa raId="13929BE7" w14:textId="20FEDCDB" w:rsidR="00BF6C9C" w:rsidRPr="0 0BF6C9C" w:rsidRDefault="00BF6C9C" w:rsidP="00BF6C9C"><w:r><w:t xml:s pace="preserve">This is a Word document, full of content that you wan t very much. </w:t></w:r><w:proofErr w:type="spellStart"/><w:r><w:t>U nfortuna</w:t></w:r><w:r w:rsidR="00BC14C7"><w:t>t</w:t></w:r><w:r><w :t>ly</w:t></w:r><w:proofErr w:type="spellEnd"/><w:r><w:t xml:space=" preserve">, it’s difficult to access because I’m putting it on my web site as a .docx file, rather than just publishing it as HTML. </w:t>< /w:r></w:p><w:sectPr w:rsidR="00BF6C9C" w:rsidRPr="00BF6C9C"><w:pgSz  w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:botto m="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/><w :cols w:space="720"/><w:docGrid w:linePitch="360"/></w:sectPr></w:bod y></w:document> </code></pre> <p>这里显然有大量的元数据,但实际上您想要的文本内容被埋藏起来了。幸运的是,文档中的所有文本,包括顶部的标题,都包含在 <code>w:t</code> 标签中,这使得抓取变得很容易:</p> <pre><code class="language-py">from zipfile import ZipFile from urllib.request import urlopen from io import BytesIO from bs4 import BeautifulSoup wordFile = urlopen('http://pythonscraping.com/pages/AWordDocument.docx').read() wordFile = BytesIO(wordFile) document = ZipFile(wordFile) xml_content = document.read('word/document.xml') wordObj = BeautifulSoup(xml_content.decode('utf-8'), 'xml') textStrings = wordObj.find_all('w:t') for textElem in textStrings:     print(textElem.text) </code></pre> <p>注意,与通常在 <code>BeautifulSoup</code> 中使用的 <em>html.parser</em> 解析器不同,您需要将 <em>xml</em> 解析器传递给它。这是因为在像 <code>w:t</code> 这样的 HTML 标签名中,冒号是非标准的,而 <em>html.parser</em> 无法识别它们。</p> <p>输出还不完美,但已经接近了,并且打印每个 <code>w:t</code> 标签到新行使得很容易看出 Word 如何分割文本:</p> <pre><code class="language-py">A Word Document on a Website This is a Word document, full of content that you want very much. Unfortuna t ly , it’s difficult to access because I’m putting it on my website as a .docx file, rather than just publishing it as HTML. </code></pre> <p>注意,单词“unfortunatly”被分割成多行。在原始的 XML 中,它被标签 <code><w:proofErr w:type="spellStart"/></code> 包围。这是 Word 用红色波浪线突出显示拼写错误的方式。</p> <p>文档标题之前有样式描述符标签 <code><w:pstyle w:val="Title"></code>。虽然这并没有使我们非常容易识别标题(或其他样式化的文本),但使用 BeautifulSoup 的导航功能可能会有所帮助:</p> <pre><code class="language-py">textStrings = wordObj.find_all('w:t') for textElem in textStrings:     style = textElem.parent.parent.find('w:pStyle')     if style is not None and style['w:val'] == 'Title':         print('Title is: {}'.format(textElem.text))     else:         print(textElem.text) </code></pre> <p>这个函数可以很容易扩展以在各种文本样式周围打印标签或以其他方式标记它们。</p> <p>¹ 这个“填充”位稍后会在 ISO 标准中困扰我们。</p> <p>² 根据<a href="https://w3techs.com/technologies/history_overview/character_encoding" target="_blank">W3Techs</a>提供的数据,该网站使用网络爬虫收集这些统计数据。</p> <p>³ ECMA 是 ISO 标准的原始贡献者之一,所以其网站采用了一种 ISO 的编码方式并不令人意外。</p> <h1 id="第十一章处理脏数据">第十一章:处理脏数据</h1> <p>到目前为止,在本书中,我通过使用通常格式良好的数据源,如果数据偏离预期,则完全删除数据,忽略了格式不良的数据问题。但是,在网络抓取中,您通常无法太挑剔数据的来源或其外观。</p> <p>由于错误的标点符号、不一致的大写、换行和拼写错误,脏数据在网络上可能是一个大问题。本章介绍了一些工具和技术,帮助您通过更改编码方式和在数据进入数据库后清理数据,从根源上预防问题。</p> <p>这是网络抓取与其近亲数据科学交汇的章节。虽然“数据科学家”这个职称可能让人联想到先进的编程技术和高级数学,但事实上,大部分工作是苦工活。在能够用于构建机器学习模型之前,某人必须清理和规范这些数百万条记录,这就是数据科学家的工作。</p> <h1 id="文本清理">文本清理</h1> <p>Python 是一种非常适合文本处理的编程语言。您可以轻松编写干净、功能性、模块化的代码,甚至处理复杂的文本处理项目。使用以下代码,我们可以从维基百科关于 Python 的文章中获取文本<a href="http://en.wikipedia.org/wiki/Python_(programming_language)" target="_blank"><em>http://en.wikipedia.org/wiki/Python_(programming_language)</em></a>:</p> <pre><code class="language-py">from urllib.request import urlopen from bs4 import BeautifulSoup url = 'http://en.wikipedia.org/wiki/Python_(programming_language)' html = urlopen(url) bs = BeautifulSoup(html, 'html.parser') content = bs.find('div', {'id':'mw-content-text'}).find_all('p') content = [p.get_text() for p in content] </code></pre> <p>此内容开始:</p> <pre><code class="language-py">Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation via the off-side rule.[33] </code></pre> <p>我们将对这段文本执行几项操作:</p> <ul> <li> <p>删除形式为“[123]”的引用</p> </li> <li> <p>删除换行符</p> </li> <li> <p>将文本分割为句子</p> </li> <li> <p>删除句子中含有旁注的括号文本</p> </li> <li> <p>删除文本中未包含的插图描述</p> </li> <li> <p>将文本转换为小写</p> </li> <li> <p>删除所有标点符号</p> </li> </ul> <p>需要注意的是,这些函数必须按特定顺序应用。例如,如果删除标点符号(包括方括号),将难以识别和删除后续的引用。删除标点符号并将所有文本转换为小写后,也将无法将文本拆分为句子。</p> <p>删除换行符和将文本转换为小写的函数非常简单:</p> <pre><code class="language-py">def replace_newlines(text):     return text.replace('\n', ' ') def make_lowercase(text):     return text.lower() </code></pre> <p>这里换行用空格字符(“ ”)替换,而不是完全删除,以避免出现这样的文本:</p> <pre><code class="language-py">It uses dynamic name resolution (late binding), which binds method and variable names during program execution. Its design offers some support for functional programming in the Lisp tradition. </code></pre> <p>被转换为以下文本:</p> <pre><code class="language-py">It uses dynamic name resolution (late binding), which binds method and variable names during program execution. Its design offers some support for functional programming in the Lisp tradition. </code></pre> <p>插入空格可以确保所有句子之间仍然有空格。</p> <p>有了这个想法,我们可以编写分割句子的函数:</p> <pre><code class="language-py">def split_sentences(text):     return [s.strip() for s in text.split('. ')] </code></pre> <p>我们不是简单地在句号上进行拆分,而是在句号和空格上进行拆分。这可以避免出现小数,例如普遍存在的“Python 2.5”,或者代码示例如:</p> <pre><code class="language-py">if (c = 1) { ...} </code></pre> <p>防止被错误地分割成句子。此外,我们希望确保任何双空格或其他奇怪的句子都通过在返回之前使用<code>strip</code>函数去除每个前导或尾随空格来进行清理。</p> <p>但是,不能立即调用 <code>split_sentences</code>。许多句子紧随其后引用:</p> <pre><code class="language-py">capable of exception handling and interfacing with the Amoeba operating system.[13] Its implementation began in December 1989.[44] </code></pre> <p>删除引用的函数可以写成这样:</p> <pre><code class="language-py">import re CITATION_REGEX = re.compile('\[[0-9]*\]') def strip_citations(text):     return re.sub(CITATION_REGEX, '', text) </code></pre> <p>变量名 <code>CITATION_REGEX</code> 采用大写,表示它是一个常量,并且在函数本身之外预先编译。函数也可以写成这样:</p> <pre><code class="language-py">def strip_citations(text):     return re.sub(r'\[[0-9]*\]', '', text) </code></pre> <p>然而,这会强制 Python 每次运行函数时重新编译这个正则表达式(这可能是成千上万次,具体取决于项目),而不是预先编译好并准备好使用。虽然程序的速度在网页抓取中并不一定是一个显著的瓶颈,但在函数外预先编译正则表达式非常容易实现,并允许您通过合适的变量名来文档化代码中的正则表达式。</p> <p>删除括号文本,例如:</p> <pre><code class="language-py">all versions of Python (including 2.7[56]) had security issues </code></pre> <p>和:</p> <pre><code class="language-py">dynamic name resolution (late binding), which binds method </code></pre> <p>对于删除引用也是一种类似的模式。一个很好的初步方法可能是:</p> <pre><code class="language-py">PARENS_REGEX = re.compile('\(.*\)') def remove_parentheses(text):     return re.sub(PARENS_REGEX, '', text) </code></pre> <p>的确,这确实从上述示例中删除了括号文本,但它也从像这样的部分中删除了括号内的任何内容:</p> <pre><code class="language-py">This has the advantage of avoiding a classic C error of mistaking an assignment operator = for an equality operator == in conditions: if (c = 1) { ...} is syntactically valid </code></pre> <p>此外,如果文本中存在不匹配的括号,会存在危险。开放括号可能导致在找到任何形式的闭合括号时,大段文本被移除。</p> <p>为了解决这个问题,我们可以检查括号文本中通常见到的字符类型,仅查找它们,并限制括号文本的长度:</p> <pre><code class="language-py">PARENS_REGEX = re.compile('\([a-z A-Z \+\.,\-]{0,100}\)') def remove_parentheses(text):     return re.sub(PARENS_REGEX, '', text) </code></pre> <p>偶尔,文本中可能存在未提取的插图描述。例如:</p> <pre><code class="language-py">Hello world program: </code></pre> <p>它前面是一段未提取出来的代码块。</p> <p>这些描述通常很短,以换行符开头,只包含字母,并以冒号结尾。我们可以使用正则表达式删除它们:</p> <pre><code class="language-py">DESCRIPTION_REGEX = re.compile('\n[a-z A-Z]*:') def remove_descriptions(text):     return re.sub(DESCRIPTION_REGEX, '', text) </code></pre> <p>在这一点上,我们可以删除标点符号。因为许多前面的步骤依赖于标点符号的存在来确定哪些文本保留哪些删除,所以剥离标点符号通常是任何文本清理任务的最后一步。</p> <p><a href="https://docs.python.org/3/library/string.html" target="_blank">Python 字符串模块</a> 包含许多方便的字符集合,其中之一是 <code>string.punctuation</code>。这是所有 ASCII 标点符号的集合:</p> <pre><code class="language-py">>>> import string >>> string.punctuation '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' </code></pre> <p>我们可以使用 <code>re.escape</code>(它转义任何保留的正则表达式符号)和用 <code>|</code> 字符连接所有 ASCII 标点的字符串,将其转换为正则表达式:</p> <pre><code class="language-py">puncts = [re.escape(c) for c in string.punctuation] PUNCTUATION_REGEX = re.compile('|'.join(puncts)) def remove_punctuation(text):     return re.sub(PUNCTUATION_REGEX, '', text) </code></pre> <p>在所有这些字符串操作中,Unicode 字符往往会在字符串中被错误表示。特别常见的是 Unicode 的“不间断空格”,在 HTML 中表示为 <code>&nbsp;</code>,在网页文本中经常可以找到。我们在 Wikipedia 文本中打印出来可以看到它显示为 <code>\xa0</code>:</p> <pre><code class="language-py"> python\xa020 was released... </code></pre> <p>无论遇到哪些奇怪的字符,都可以使用 Python 的 <code>unicodedata</code> 包来修复它们。规范化 Unicode 字符将是清理文本的最后一步:</p> <pre><code class="language-py">def normalize(text):     return unicodedata.normalize('NFKD', text) </code></pre> <p>在这一点上,您已经拥有一组短小而精心组织的函数,可以执行各种文本清理操作。因为我们可能希望添加、删除或更改调用函数的顺序,所以我们可以将这些函数添加到列表中,并在文本上以一般方式调用它们:</p> <pre><code class="language-py">text_operations = [     strip_citations,     remove_parentheses,     remove_descriptions,     replace_newlines,     split_sentences,     make_lowercase,     remove_punctuation,     normalize ] cleaned = content for op in text_operations:     if type(cleaned) == list:         cleaned = [op(c) for c in cleaned]     else:         cleaned = op(cleaned) print(cleaned) </code></pre> <p>尽管 Python 通常不被认为是像 JavaScript 或更极端的例子中的 Haskell 那样的函数语言,但要记住,在这种情况下,函数可以像变量一样传递!</p> <h1 id="使用标准化文本工作">使用标准化文本工作</h1> <p>一旦您清理了文本,接下来该怎么做呢?一个常见的技术是将其分成更容易量化和分析的小块文本。计算语言学家称这些为<em>n-grams</em>,其中 n 表示每个文本块中的单词数。在本例中,我们将专门使用 2-gram,即 2 个单词的文本块。</p> <p>N-gram 通常不跨越句子。因此,我们可以使用前一节中获取的文本,将其拆分为句子,并为列表中的每个句子创建 2-gram。</p> <p>一个用于将文本分解为 n-gram 的 Python 函数可以编写为:</p> <pre><code class="language-py">def getNgrams(text, n):     text = text.split(' ')     return [text[i:i+n] for i in range(len(text)-n+1)] getNgrams('web scraping with python', 2) </code></pre> <p>这个函数在文本“web scraping with python”上的输出是:</p> <pre><code class="language-py">[['web', 'scraping'], ['scraping', 'with'], ['with', 'python']] </code></pre> <p>这个函数的一个问题是,它返回了许多重复的 2-gram。它遇到的每个 2-gram 都会被添加到列表中,而没有记录其频率。记录这些 2-gram 的频率是有趣的,而不仅仅是它们的存在,因为这可以在清理和数据标准化算法变更的效果图中非常有用。如果数据成功标准化,唯一 n-gram 的总数将减少,而找到的 n-gram 的总计数(即标识为 n-gram 的唯一或非唯一项的数量)不会减少。换句话说,相同数量的 n-gram 将有更少的“桶”。</p> <p>你可以通过修改收集 n-gram 的代码,将它们添加到<code>Counter</code>对象中来完成。在这里,<code>cleaned</code>变量是我们在前一节中获取的已清理句子的列表:</p> <pre><code class="language-py">from collections import Counter def getNgrams(text, n):     text = text.split(' ')     return [' '.join(text[i:i+n]) for i in range(len(text)-n+1)] def countNGramsFromSentences(sentences, n):     counts = Counter()     for sentence in sentences:         counts.update(getNgrams(sentence, n))     return counts </code></pre> <p>还有许多其他创建 n-gram 计数的方法,例如将它们添加到字典对象中,其中列表的值指向它被看到的次数的计数。这样做的缺点是它需要更多的管理,并且使排序变得棘手。</p> <p>然而,使用<code>Counter</code>对象也有一个缺点:它不能存储列表,因为列表是不可散列的。将它们转换为元组(可散列)将很好地解决问题,并且在这种情况下,将它们转换为字符串也是有道理的,通过在列表理解中使用<code>' '.join(text[i:i+n])</code>将它们转换为字符串。</p> <p>我们可以使用前一节中清理的文本调用<code>countNGramsFromSentences</code>函数,并使用<code>most_common</code>函数获取按最常见排序的 2-gram 列表:</p> <pre><code class="language-py">counts = countNGramsFromSentences(cleaned, 2) print(counts.most_common()) </code></pre> <p>这里是结果:</p> <pre><code class="language-py">('in the', 19), ('of the', 19), ('such as', 18), ('as a', 14), ('in python', 12), ('python is', 9), ('of python', 9), ('the python', 9)... </code></pre> <p>截至本文撰写时,有 2814 个独特的 2-gram,其中最受欢迎的组合包含在任何英文文本中都非常常见的词组,例如“such as”。根据您的项目需求,您可能希望移除类似于这样没有太多关联性的 n-gram。如何做到这一点是第[12 章的一个话题。</p> <p>此外,通常需要停下来考虑要消耗多少计算资源来规范化数据。有许多情况下,不同拼写的单词是等效的,但为了解决这种等效性,您需要检查每个单词,看它是否与您预先编程的等效项匹配。</p> <p>例如,“Python 1st”和“Python first”都出现在 2-gram 列表中。然而,如果要制定一个一刀切的规则,即“所有的 first、second、third 等都将被解析为 1st、2nd、3rd 等(反之亦然)”,那么每个单词将额外增加约 10 次检查。</p> <p>同样地,连字符的不一致使用(例如“co-ordinated”与“coordinated”)、拼写错误以及其他自然语言的不一致性将影响 n-gram 的分组,并可能使输出的结果变得模糊,如果这些不一致性足够普遍的话。</p> <p>在处理连字符词的情况下,一种解决方法可能是完全删除连字符,并将该词视为一个字符串,这只需要进行一次操作。然而,这也意味着连字符短语(这种情况实在是太常见了)将被视为一个单词。采取另一种方法,将连字符视为空格可能是一个更好的选择。只是要做好准备,偶尔会有“co ordinated”和“ordinated attack”这样的情况出现!</p> <h1 id="使用-pandas-清理数据">使用 Pandas 清理数据</h1> <p>本节不是关于中国的可爱熊猫,而是关于 Python 数据分析软件包<em>pandas</em>。如果您有过数据科学和机器学习的工作经验,那么很可能在此之前就已经接触过它,因为它在该领域中无处不在。</p> <p>Pandas 是由程序员 Wes McKinney 在 2008 年独立开发的项目。2009 年,他将该项目公开,并迅速获得了广泛认可。该软件包填补了数据整理中的一个特定空白。在某些方面,它的功能类似于电子表格,具有良好的打印效果和易于重塑的数据透视功能。它还充分利用了底层 Python 代码和数据科学库的强大和灵活性。</p> <p>有些人在安装像 numpy、pandas 和 scikit-learn 这样的数据科学库时,可能会推荐使用<a href="https://www.anaconda.com" target="_blank">安装包管理系统 Anaconda</a>。虽然 Anaconda 对这些包有很好的支持,但使用 pip 也可以轻松安装 pandas:</p> <pre><code class="language-py">pip install pandas </code></pre> <p>习惯上,该软件包被导入为<code>pd</code>而不是完整名称<code>pandas</code>:</p> <pre><code class="language-py">import pandas as pd </code></pre> <h1 id="不要从-pandas-中导入单独的方法和类">不要从 Pandas 中导入单独的方法和类</h1> <p>pandas 生态系统庞大、复杂,并且经常与内置 Python 函数和软件包的命名空间重叠。因此,几乎总是应该从<code>pd</code>开始引用 pandas 函数,而不是直接导入它们,例如:</p> <pre><code class="language-py">from pandas import array from pandas.DataFrame import min </code></pre> <p>在这些情况下,上述导入可能会与内置 Python <code>array</code>模块和<code>min</code>函数造成混淆。</p> <p>一个已接受的例外可能是对作为 DataFrame 类导入的:</p> <pre><code class="language-py">from pandas import DataFrame </code></pre> <p>在这种情况下,<code>DataFrame</code>不在 Python 标准库中,并且很容易被识别为 pandas 类。然而,这是您可能会看到的一个例外,并且许多人仍然喜欢将 DataFrame 类称为<code>pd.DataFrame</code>。因为库在代码中经常被引用,这是为什么惯例是将 pandas 导入为<code>pd</code>而不是全名的一个原因!</p> <p>您在 pandas 库中最常使用的对象是数据框架。这些类似于电子表格或表格,可以通过多种方式构建。例如:</p> <pre><code class="language-py">df = pd.DataFrame([['a', 1], ['b', 2], ['c', 3]]) df.head() </code></pre> <p><code>head</code>方法生成一个漂亮打印的数据框架,显示数据及其列和标题,如图 11-1 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1101.png" alt="" loading="lazy"></p> <h6 id="图11-1一个简单的-pandas-数据框架">图 11-1。一个简单的 pandas 数据框架</h6> <p>数据框架始终需要索引和列名。如果未提供这些信息,如本例中仅提供简单的数据矩阵,它们将被自动生成。数据框架的索引(0, 1, 2)可以在左侧以粗体显示,列名(0, 1)在顶部以粗体显示。</p> <p>与使用原始的 Python 列表和字典不同,数据框提供了大量方便的辅助函数,用于对数据进行排序、清理、操作、排列和显示。如果您处理较大的数据集,它们还比列表和字典提供了速度和内存优势。</p> <h2 id="清理">清理</h2> <p>在接下来的示例中,您将使用从<a href="https://en.wikipedia.org/wiki/List_of_countries_with_McDonald%27s_restaurants" target="_blank">Wikipedia 的《麦当劳餐厅分布国家列表》</a>抓取的数据。我们可以使用<code>pd.read_csv</code>函数直接从 CSV 文件读取数据到数据框架:</p> <pre><code class="language-py">df = pd.read_csv('countries.csv') df.head(10) </code></pre> <p>可选地,可以传递一个整数给<code>head</code>方法,以打印出除默认值 5 之外的行数。这样可以很好地查看早期抓取的 CSV 数据,如图 11-2 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1102.png" alt="" loading="lazy"></p> <h6 id="图11-2显示具有餐厅的国家列表">图 11-2。显示具有餐厅的国家列表</h6> <p>这里的列名有些冗长且格式不佳。我们可以使用<code>rename</code>方法对它们进行重命名:</p> <pre><code class="language-py">df.rename(columns={     '#': 'Order',     'Country/territory': 'Country',     'Date of first store': 'Date',     'First outlet location': 'Location',     'Max. no. ofoperatingoutlets': 'Outlets'     }, inplace=True) </code></pre> <p>在这里,我们向<code>columns</code>关键字参数传递一个字典,其中键是原始列名,值是新列名。布尔参数<code>inplace</code>意味着列会在原始数据框架中就地重命名,而不是返回一个新的数据框架。</p> <p>接下来,我们可以通过将要处理的列名列表传递给切片语法<code>[]</code>,来隔离我们想要处理的列:</p> <pre><code class="language-py">df = df[['Order', 'Country', 'Date', 'Location', 'Outlets']] </code></pre> <p>现在我们已经拥有了我们想要的重新标记的 DataFrame 列,我们可以查看数据了。有几件事情我们需要修复。首先,“首次店铺日期”或“日期”列中的日期通常格式良好,但它们也包含额外的文本甚至其他日期。作为一个简单的策略,我们可以决定保留匹配“日期”格式的第一个内容,并丢弃其余内容。</p> <p>函数可以通过首先使用与上述相同的“切片”语法选择整个 DataFrame 列来应用于 DataFrame 中的整个列。一个单独选择的列是一个 pandas <code>Series</code> 实例。<code>Series</code> 类有一个 <code>apply</code> 方法,它可以将一个函数应用到 Series 中的每个值上:</p> <pre><code class="language-py">import re date_regex = re.compile('[A-Z][a-z]+ [0-9]{1,2}, [0-9]{4}') df['Date'] = df['Date'].apply(lambda d: date_regex.findall(d)[0]) </code></pre> <p>在这里,我正在使用 lambda 运算符来应用一个函数,该函数获取所有 <code>date_regex</code> 的匹配项,并将第一个匹配项作为日期返回。</p> <p>清理后,这些日期可以使用 <code>to_datetime</code> 函数转换为实际的 pandas datetime 值:</p> <pre><code class="language-py">df['Date'] = pd.to_datetime(df['Date']) </code></pre> <p>往往,在快速高效生成“干净”数据与保留完全准确和细微数据之间存在微妙的平衡。例如,我们的日期清理将英国行的以下文本减少到单个日期:“<em>英格兰:1974 年 11 月 13 日[21] 威尔士:1984 年 12 月 3 日 苏格兰:1987 年 11 月 23 日[22] 北爱尔兰:1991 年 10 月 12 日</em>”,变成了单个日期:“<em>1974-11-13</em>”。</p> <p>从技术上讲,这是正确的。如果整个英国作为一个整体来考虑,那么 1974 年 11 月 13 日是麦当劳首次出现的日期。然而,很巧合的是,日期按照时间顺序写在单元格中,并且我们决定选择第一个日期,并且最早的日期是正确选择的。我们可以想象许多其他情况,在那些情况下我们可能就没有那么幸运。</p> <p>在某些情况下,您可以对数据进行调查,并决定您选择的清理方法是否足够好。也许在您查看的大多数情况下都是正确的。也许在一半的时间里,向一个方向不正确,在另一半时间里,向另一个方向不正确,对于您的目的来说,在大型数据集上平衡是可以接受的。或者您可能决定需要另一种方法来更准确地清理或捕捉数据。</p> <p>数据集的“出口”列也存在类似的挑战。此列包含文本,如“<em>13,515[10][验证失败][11]</em>”和“ <em>(不包括季节性餐厅) 43(包括季节性和移动餐厅)</em>”,这些都不是我们希望进行进一步分析的干净整数。同样,我们可以使用简单的方法获取数据集中可用的第一个整数:</p> <pre><code class="language-py">int_regex = re.compile('[0-9,]+') def str_to_int(s):     s = int_regex.findall(s)[0]     s = s.replace(',','')     return int(s) df['Outlets'] = df['Outlets'].apply(str_to_int) </code></pre> <p>尽管这也可以写成一个 lambda 函数,但如果需要多个步骤,可以考虑将逻辑拆分到一个单独的函数中。这样做的好处是在探索性数据处理过程中,可以轻松打印出任何发现的异常,以便进一步考虑:</p> <pre><code class="language-py">def str_to_int(s):     try:         s = int_regex.findall(s)[0]         s = s.replace(',','')     except:         print(f'Whoops: {s}')     return int(s) </code></pre> <p>最后,DataFrame 已经清理好,准备进行进一步的分析,如 图 11-3 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1103.png" alt="" loading="lazy"></p> <h6 id="图-11-3-具有清洁列标题格式化日期和整数数据的-dataframe">图 11-3. 具有清洁列标题、格式化日期和整数数据的 DataFrame</h6> <h2 id="索引排序和过滤">索引、排序和过滤</h2> <p>请记住,所有的 DataFrame 都有一个索引,无论您是否明确提供了一个。麦当劳的数据本身有一个方便的索引:”Order“ 列,表示国家接收第一家麦当劳餐厅的时间顺序。我们可以使用 <code>set_index</code> 方法设置索引:</p> <pre><code class="language-py">df.set_index(['Order'], inplace=True) df.head() </code></pre> <p>这会丢弃旧索引并将 ”Order“ 列移到索引中。同样,<code>inplace</code> 关键字参数意味着这是在原始 DataFrame 上进行的就地操作,而不是返回 DataFrame 的副本。</p> <p><code>sort_values</code> 方法可用于按一个或多个列排序数据。在这个方法中也可以使用 <code>inplace</code> 关键字。然而,因为排序通常用于探索性分析,不需要永久排序,所以将 DataFrame 返回用于打印可能更有用:</p> <pre><code class="language-py">df.sort_values(by=['Outlets', 'Date'], ascending=False) </code></pre> <p>这显示了拥有最多麦当劳的国家是美国,其次是中国,然后是日本。法国,我相信会很高兴知道,排名第四,是欧洲国家中麦当劳最多的国家!</p> <p>使用 <code>query</code> 方法很容易过滤 DataFrame。它的参数是一个查询字符串:</p> <pre><code class="language-py">df.query('Outlets < 100') </code></pre> <p>这将返回一个 DataFrame,其中仅包含 Outlets 数量小于 100 的记录。大多数常规的 Python 比较运算符在使用查询方法进行 DataFrame 过滤时都有效,但是这种查询语言不是 Python 语法。例如,这将引发异常:</p> <pre><code class="language-py">df.query('Date is not None') </code></pre> <p>如果你想测试任何空值的存在或不存在,正确的 pandas 方法是使用 <code>isnull</code> 和 <code>notnull</code> 查询函数:</p> <pre><code class="language-py">df.query('Date.isnull()') df.query('Date.notnull()') </code></pre> <p>正如你可能猜到的那样,这些语句同时捕获了 <code>None</code> 值以及来自底层 numpy 包的 <code>NaN</code> 对象。</p> <p>如果我们想要添加另一个逻辑子句,可以用一个单与号分隔它们:</p> <pre><code class="language-py">df.query('Outlets < 100 & Date < "01-06-1990"') </code></pre> <p>使用单管道表示一个 <code>or</code> 语句:</p> <pre><code class="language-py">df.query('Outlets < 100 | Date < "01-06-1990"') </code></pre> <p>注意这里不需要整个日期 (<code>"1990-01-01"</code>),只写年份 <code>"1990"</code> 也可以。Pandas 在解释字符串为日期时相当宽容,尽管你应该始终仔细检查返回的数据是否符合你的期望。</p> <h2 id="更多关于-pandas-的信息">更多关于 Pandas 的信息</h2> <p>我真诚地希望你与 pandas 的旅程不会就此结束。我们很幸运,pandas 的创造者和终身<a href="https://en.wikipedia.org/wiki/Benevolent_dictator_for_life" target="_blank">仁慈独裁者</a> Wes McKinney,也写了一本关于它的书:<a href="https://www.oreilly.com/library/view/python-for-data/9781098104023/" target="_blank"><em>Python for Data Analysis</em></a>。</p> <p>如果你计划在数据科学领域做更多的事情,或者只是想在 Python 中偶尔清理和分析数据的好工具,我建议你去了解一下。</p> <h1 id="第十二章读写自然语言">第十二章:读写自然语言</h1> <p>到目前为止,您在本书中处理的数据形式大多是数字或可计数值。在大多数情况下,您只是存储数据而没有进行后续分析。本章尝试解决英语这一棘手的主题。¹</p> <p>当您在其图像搜索中输入“可爱小猫”时,Google 是如何知道您正在寻找什么?因为围绕可爱小猫图像的文本。当您在 YouTube 的搜索栏中输入“死鹦鹉”时,YouTube 是如何知道要播放某个蒙提·派森的段子?因为每个上传视频的标题和描述文本。</p> <p>实际上,即使输入“已故鸟蒙提·派森”等术语,也会立即出现相同的“死鹦鹉”段子,尽管页面本身没有提到“已故”或“鸟”这两个词。Google 知道“热狗”是一种食物,而“煮狗幼犬”则完全不同。为什么?这都是统计数据!</p> <p>尽管您可能认为文本分析与您的项目无关,但理解其背后的概念对各种机器学习以及更一般地在概率和算法术语中建模现实世界问题的能力都非常有用。</p> <p>例如,Shazam 音乐服务可以识别包含某个歌曲录音的音频,即使该音频包含环境噪音或失真。Google 正在基于图像本身自动为图像添加字幕。² 通过将已知的热狗图像与其他热狗图像进行比较,搜索引擎可以逐渐学习热狗的外观并观察这些模式在其显示的其他图像中的表现。</p> <h1 id="数据摘要">数据摘要</h1> <p>在第十一章中,您看到了如何将文本内容分解为 n-gram,即长度为<em>n</em>的短语集。基本上,这可以用于确定哪些词组和短语在文本段落中最常用。此外,它还可用于通过回溯原始文本并提取围绕这些最流行短语之一的句子来创建自然语音数据摘要。</p> <p>您将用美国第九任总统威廉·亨利·哈里森的就职演讲作为本章中许多代码示例的源头。</p> <p>您将使用此<a href="http://pythonscraping.com/files/inaugurationSpeech.txt" target="_blank">演讲</a>的完整文本作为本章中许多代码示例的源头。</p> <p>在第十一章的清理代码中稍作修改,可以将此文本转换为准备好分割成 n-gram 的句子列表:</p> <pre><code class="language-py">import re import string  def replace_newlines(text):     return text.replace('\n', ' ') def make_lowercase(text):     return text.lower() def split_sentences(text):     return [s.strip() for s in text.split('. ')] puncts = [re.escape(c) for c in string.punctuation] PUNCTUATION_REGEX = re.compile('|'.join(puncts)) def remove_punctuation(text):     return re.sub(PUNCTUATION_REGEX, '', text) </code></pre> <p>然后,我们获取文本并按特定顺序调用这些函数:</p> <pre><code class="language-py">content = str(   urlopen('http://pythonscraping.com/files/inaugurationSpeech.txt').read(),   'utf-8' ) text_operations = [     replace_newlines,     split_sentences,     make_lowercase,     remove_punctuation ] cleaned = content for op in text_operations:     if type(cleaned) == list:         cleaned = [op(c) for c in cleaned]     else:         cleaned = op(cleaned) print(cleaned) </code></pre> <p>接下来我们使用清理后的文本来获取所有 2-gram 的 <code>Counter</code> 对象,并找出最受欢迎的那些:</p> <pre><code class="language-py">def getNgrams(text, n):     text = text.split(' ')     return [' '.join(text[i:i+n]) for i in range(len(text)-n+1)] def countNGramsFromSentences(sentences, n):     counts = Counter()     for sentence in sentences:         counts.update(getNgrams(sentence, n))     return counts counts = countNGramsFromSentences(cleaned, 2) print(counts.most_common()) </code></pre> <p>此示例说明了 Python 标准库 collections 的便利性和强大性。不,编写一个创建字典计数器、按值排序并返回这些顶级值的最受欢迎键的函数并不特别困难。但是,了解内置 collections 并能够根据手头任务选择合适的工具可以节省很多代码行!</p> <p>输出的部分包括:</p> <pre><code class="language-py">[('of the', 213), ('in the', 65), ('to the', 61), ('by the', 41), ('the constitution', 34), ('of our', 29), ('to be', 26), ('the people', 24), ('from the', 24), ('that the', 23)... </code></pre> <p>在这些二元组中,“宪法”似乎是演讲中一个相当受欢迎的主题,但“of the”、“in the” 和 “to the” 看起来并不特别显着。您如何自动且准确地摆脱不想要的词?</p> <p>幸运的是,有些人认真研究“有趣”词和“无趣”词之间的差异,他们的工作可以帮助我们做到这一点。布里格姆·杨大学的语言学教授马克·戴维斯维护着<a href="http://corpus.byu.edu/coca/" target="_blank">当代美国英语语料库</a>,这是一个收集了超过 4.5 亿字的流行美国出版物最近十年左右的集合。</p> <p>5000 最常见的单词列表可以免费获取,幸运的是,这已经足够作为基本过滤器,以清除最常见的二元组。仅前一百个单词就大大改善了结果,同时加入了 <code>isCommon</code> 和 <code>filterCommon</code> 函数:</p> <pre><code class="language-py">COMMON_WORDS = ['the', 'be', 'and', 'of', 'a', 'in', 'to', 'have', 'it', 'i', 'that', 'for', 'you', 'he', 'with', 'on', 'do', 'say', 'this', 'they', 'is', 'an', 'at', 'but', 'we', 'his', 'from', 'that', 'not', 'by', 'she', 'or', 'as', 'what', 'go', 'their', 'can', 'who', 'get', 'if', 'would', 'her', 'all', 'my', 'make', 'about', 'know', 'will', 'as', 'up', 'one', 'time', 'has', 'been', 'there', 'year', 'so', 'think', 'when', 'which', 'them', 'some', 'me', 'people', 'take', 'out', 'into', 'just', 'see', 'him', 'your', 'come', 'could', 'now', 'than', 'like', 'other', 'how', 'then', 'its', 'our', 'two', 'more', 'these', 'want', 'way', 'look', 'first', 'also', 'new', 'because', 'day', 'more', 'use', 'no', 'man', 'find', 'here', 'thing', 'give', 'many', 'well'] def isCommon(ngram):   return any([w in COMMON_WORDS for w in ngram.split(' ')]) def filterCommon(counts):   return Counter({key: val for key, val in counts.items() if not isCommon(key)}) filterCommon(counts).most_common() </code></pre> <p>这生成了在文本正文中找到的以下出现超过两次的二元组:</p> <pre><code class="language-py">('united states', 10), ('executive department', 4), ('general government', 4), ('called upon', 3), ('chief magistrate', 3), ('legislative body', 3), ('same causes', 3), ('government should', 3), ('whole country', 3) </code></pre> <p>恰如其分地,列表中的前两项是“美利坚合众国”和“行政部门”,这在总统就职演讲中是可以预期的。</p> <p>需要注意的是,您使用的是一份相对现代的常用词列表来过滤结果,这可能并不适合于这段文字是在 1841 年编写的事实。然而,因为您仅使用列表中的前一百个左右单词——可以假设这些单词比后一百个单词更为稳定,且您似乎得到了令人满意的结果,您很可能可以省去追溯或创建 1841 年最常见单词列表的麻烦(尽管这样的努力可能会很有趣)。</p> <p>现在从文本中提取了一些关键主题,这如何帮助您撰写文本摘要?一种方法是搜索每个“流行”的 n-gram 的第一句话,理论上第一次出现将提供对内容体的令人满意的概述。前五个最受欢迎的 2-gram 提供了这些要点:</p> <ul> <li> <p>“美利坚合众国宪法是包含这一授予各个组成政府部门的权力的工具。”</p> </li> <li> <p>“联邦政府所构成的行政部门提供了这样一个人。”</p> </li> <li> <p>“联邦政府没有侵占任何州保留的权利。”</p> </li> <li> <p>“Called from a retirement which I had supposed was to continue for the residue of my life to fill the chief executive office of this great and free nation, I appear before you, fellow-citizens, to take the oaths which the constitution prescribes as a necessary qualification for the performance of its duties; and in obedience to a custom coeval with our government and what I believe to be your expectations I proceed to present to you a summary of the principles which will govern me in the discharge of the duties which I shall be called upon to perform.”</p> </li> <li> <p>“政府必须永远不要使用机器来‘为罪犯洗白或者掩盖罪行’。”</p> </li> </ul> <p>当然,它可能不会很快出现在 CliffsNotes 上,但考虑到原始文档长达 217 句,第四句(“Called from a retirement...”)相当简洁地概述了主题,对于第一遍来说还算不错。</p> <p>对于更长的文本块或更多样化的文本,检索一个段落中“最重要”的句子时可能值得查看 3-gram 甚至 4-gram。在这种情况下,只有一个 3-gram 被多次使用,即“exclusive metallic currency”,指的是提出美国货币金本位制的重要问题。对于更长的段落,使用 3-gram 可能是合适的。</p> <p>另一种方法是寻找包含最流行 n-gram 的句子。这些句子通常会比较长,所以如果这成为问题,你可以寻找包含最高比例流行 n-gram 词的句子,或者自行创建一个结合多种技术的评分指标。</p> <h1 id="马尔可夫模型">马尔可夫模型</h1> <p>您可能听说过马尔可夫文本生成器。它们已经因娱乐目的而流行起来,例如<a href="http://yes.thatcan.be/my/next/tweet/" target="_blank">“That can be my next tweet!”</a> 应用程序,以及它们用于生成听起来真实的垃圾邮件以欺骗检测系统。</p> <p>所有这些文本生成器都基于马尔可夫模型,该模型经常用于分析大量随机事件集,其中一个离散事件后跟随另一个离散事件具有一定的概率。</p> <p>例如,您可以构建一个天气系统的马尔可夫模型,如图 12-1 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1201.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-12-1描述理论天气系统的马尔可夫模型">图 12-1。描述理论天气系统的马尔可夫模型</h6> <p>在这个模型中,每个晴天有 70%的几率第二天仍然是晴天,有 20%的几率第二天是多云,仅有 10%的几率下雨。如果今天是雨天,那么第二天有 50%的几率还是下雨,25%的几率是晴天,25%的几率是多云。</p> <p>您可能会注意到这个马尔可夫模型中的几个属性:</p> <ul> <li> <p>所有从任何一个节点出发的百分比必须总和为 100%。无论系统多么复杂,下一步总是必须有 100%的机会能够导向其他地方。</p> </li> <li> <p>尽管每次只有三种天气可能性,您可以使用此模型生成无限的天气状态列表。</p> </li> <li> <p>只有当前节点的状态会影响您下一步将去哪里。如果您在晴天节点上,不管前 100 天是晴天还是雨天,明天出太阳的几率都完全相同:70%。</p> </li> <li> <p>达到某些节点可能比其他节点更困难。这背后的数学相当复杂,但可以很容易地看出,雨天(箭头指向少于“100%”)在任何给定时间点上都比晴天或多云状态更不可能到达该状态。</p> </li> </ul> <p>显然,这是一个简单的系统,马尔可夫模型可以任意扩展。谷歌的页面排名算法部分基于马尔可夫模型,其中网站表示为节点,入站/出站链接表示为节点之间的连接。着陆在特定节点上的“可能性”表示该站点的相对流行程度。也就是说,如果我们的天气系统代表一个极小的互联网,“雨天”将具有较低的页面排名,而“多云”将具有较高的页面排名。</p> <p>有了这些,让我们回到一个更具体的例子:分析和生成文本。</p> <p>再次使用前面分析过的威廉·亨利·哈里森的就职演讲,您可以编写以下代码,根据其文本结构生成任意长度的马尔可夫链(链长度设置为 100):</p> <pre><code class="language-py">from urllib.request import urlopen from random import randint from collections import defaultdict def retrieveRandomWord(wordList):     randIndex = randint(1, sum(wordList.values()))     for word, value in wordList.items():         randIndex -= value         if randIndex <= 0:             return word def cleanAndSplitText(text):     # Remove newlines and quotes     text = text.replace('\n', ' ').replace('"', '');     # Make sure punctuation marks are treated as their own "words,"     # so that they will be included in the Markov chain     punctuation = [',','.',';',':']     for symbol in punctuation:         text = text.replace(symbol, f' {symbol} ');     # Filter out empty words     return [word for word in text.split(' ') if word != ''] def buildWordDict(text):     words = cleanAndSplitText(text)     wordDict = defaultdict(dict)     for i in range(1, len(words)):         wordDict[words[i-1]][words[i]] = \   wordDict[words[i-1]].get(words[i], 0) + 1     return wordDict text = str(urlopen('http://pythonscraping.com/files/inaugurationSpeech.txt')           .read(), 'utf-8') wordDict = buildWordDict(text) #Generate a Markov chain of length 100 length = 100 chain = ['I'] for i in range(0, length):     newWord = retrieveRandomWord(wordDict[chain[-1]])     chain.append(newWord) print(' '.join(chain)) </code></pre> <p>此代码的输出每次运行时都会发生变化,但以下是它将生成的不可思议的无意义文本的示例:</p> <pre><code class="language-py">I sincerely believe in Chief Magistrate to make all necessary sacrifices and oppression of the remedies which we may have occurred to me in the arrangement and disbursement of the democratic claims them , consolatory to have been best political power in fervently commending every other addition of legislation , by the interests which violate that the Government would compare our aboriginal neighbors the people to its accomplishment . The latter also susceptible of the Constitution not much mischief , disputes have left to betray . The maxim which may sometimes be an impartial and to prevent the adoption or </code></pre> <p>那么代码中到底发生了什么呢?</p> <p>函数<code>buildWordDict</code>接收从互联网检索到的文本字符串。然后进行一些清理和格式化,删除引号并在其他标点周围放置空格,以便它有效地被视为一个独立的单词。然后,它构建一个二维字典——字典的字典,其形式如下:</p> <pre><code class="language-py">{word_a : {word_b : 2, word_c : 1, word_d : 1}, word_e : {word_b : 5, word_d : 2},...} </code></pre> <p>在这个示例字典中,<code>word_a</code>出现了四次,其中两次后跟<code>word_b</code>,一次后跟<code>word_c</code>,一次后跟<code>word_d.</code>然后,“word_e”跟随了七次:五次跟随<code>word_b</code>,两次跟随<code>word_d</code>。</p> <p>如果我们绘制此结果的节点模型,则表示<code>word_a</code>的节点将有一个 50%的箭头指向<code>word_b</code>(它四次中有两次跟随),一个 25%的箭头指向<code>word_c</code>,和一个 25%的箭头指向<code>word_d.</code></p> <p>在构建了这个字典之后,它可以作为查找表用于查看下一步该去哪里,无论你当前在文本中的哪个词上。³ 使用字典的字典示例,你目前可能在<code>word_e</code>上,这意味着你会将字典<code>{word_b : 5, word_d: 2}</code>传递给<code>retrieveRandomWord</code>函数。该函数反过来按出现次数加权从字典中检索一个随机词。</p> <p>通过从一个随机的起始词(在这种情况下,无处不在的“I”)开始,你可以轻松地遍历马尔可夫链,生成任意数量的单词。</p> <p>随着收集更多类似写作风格的源文本,这些马尔可夫链的“真实性”会得到改善。尽管此示例使用了 2-gram 来创建链条(其中前一个词预测下一个词),但也可以使用 3-gram 或更高阶的 n-gram,其中两个或更多词预测下一个词。</p> <p>尽管这些应用程序很有趣,并且是在网络爬取期间积累的大量文本的极好用途,但这些应用程序可能会使人们难以看到马尔可夫链的实际应用。如本节前面提到的,马尔可夫链模拟了网页如何从一个页面链接到下一个页面。这些链接的大量集合可以形成有用的类似网络的图形,用于存储、跟踪和分析。这种方式,马尔可夫链为如何思考网络爬行和你的网络爬虫如何思考奠定了基础。</p> <h2 id="维基百科的六度分隔结论">维基百科的六度分隔:结论</h2> <p>在第六章中,你创建了一个从一个维基百科文章到下一个文章的爬虫程序,从凯文·贝肯的文章开始,并在第九章中将这些链接存储到数据库中。为什么我再提一遍呢?因为事实证明,在选择一条从一个页面开始并在目标页面结束的链接路径的问题(即找到从<a href="https://en.wikipedia.org/wiki/Kevin_Bacon" target="_blank"><em>https://en.wikipedia.org/wiki/Kevin_Bacon</em></a>到<a href="https://en.wikipedia.org/wiki/Eric_Idle" target="_blank"><em>https://en.wikipedia.org/wiki/Eric_Idle</em></a>的一系列页面)与找到一个首尾都有定义的马尔可夫链是相同的。</p> <p>这类问题属于<em>有向图</em>问题,其中 A → B 并不一定意味着 B → A。单词“足球”经常会后接“球员”,但你会发现“球员”后接“足球”的次数要少得多。尽管凯文·贝肯的维基百科文章链接到他的家乡费城的文章,但费城的文章并没有回链到他。</p> <p>相比之下,原始的“凯文·贝肯的六度分隔游戏”是一个<em>无向图</em>问题。如果凯文·贝肯和茱莉亚·罗伯茨在《心灵裂缝》中演出,那么茱莉亚·罗伯茨必然也和凯文·贝肯在《心灵裂缝》中演出,因此关系是双向的(没有“方向”)。与有向图问题相比,无向图问题在计算机科学中较少见,并且两者在计算上都很难解决。</p> <p>虽然已经对这类问题进行了大量工作,并且有许多变体,但在有向图中寻找最短路径——从维基百科文章“凯文·贝肯”到所有其他维基百科文章的路径——最好且最常见的方法之一是通过广度优先搜索。</p> <p>首先搜索直接链接到起始页面的所有链接执行<em>广度优先搜索</em>。如果这些链接不包含目标页面(即你正在搜索的页面),则搜索第二层链接——由起始页面链接的页面链接。该过程持续进行,直到达到深度限制(在本例中为 6)或找到目标页面为止。</p> <p>使用如下所述的链接表的广度优先搜索的完整解决方案如下:</p> <pre><code class="language-py">import pymysql conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',     user='', passwd='', db='mysql', charset='utf8') cur = conn.cursor() cur.execute('USE wikipedia') def getUrl(pageId): cur.execute('SELECT url FROM pages WHERE id = %s', (int(pageId))) return cur.fetchone()[0] def getLinks(fromPageId): cur.execute('SELECT toPageId FROM links WHERE fromPageId = %s',         (int(fromPageId))) if cur.rowcount == 0: return [] return [x[0] for x in cur.fetchall()] def searchBreadth(targetPageId, paths=[[1]]): newPaths = [] for path in paths: links = getLinks(path[-1]) for link in links: if link == targetPageId: return path + [link] else: newPaths.append(path+[link]) return searchBreadth(targetPageId, newPaths) nodes = getLinks(1) targetPageId = 28624 pageIds = searchBreadth(targetPageId) for pageId in pageIds: print(getUrl(pageId)) </code></pre> <p><code>getUrl</code>是一个辅助函数,它根据页面 ID 从数据库中检索 URL。类似地,<code>getLinks</code>接受一个<code>fromPageId</code>,代表当前页面的整数 ID,并获取它链接到的所有页面的整数 ID 列表。</p> <p>主函数<code>searchBreadth</code>递归地工作以构建从搜索页面到目标页面的所有可能路径列表,并在找到达到目标页面的路径时停止:</p> <ul> <li> <p>它从一个单一路径<code>[1]</code>开始,表示用户在目标页面 ID 为 1(凯文·贝肯)的页面上停留并且不跟随任何链接。</p> </li> <li> <p>对于路径列表中的每条路径(在第一次通过中,只有一条路径,因此此步骤很简单),获取表示路径中最后一页的页面外部链接的所有链接。</p> </li> <li> <p>对于每个出站链接,它检查它们是否与<code>targetPageId</code>匹配。如果匹配,则返回该路径。</p> </li> <li> <p>如果没有匹配,将一个新路径添加到新的(现在更长的)路径列表中,包含旧路径加上新的出站页面链接。</p> </li> <li> <p>如果在这个级别根本找不到<code>targetPageId</code>,则发生递归,并且使用相同的<code>targetPageId</code>和新的更长路径列表调用<code>searchBreadth</code>。</p> </li> </ul> <p>找到包含两个页面之间路径的页面 ID 列表后,将每个 ID 解析为其实际 URL 并打印出来。</p> <p>在此数据库中,搜索凯文·贝肯页面(页面 ID 为 1)和搜索埃里克·艾德尔页面(数据库中页面 ID 为 28624)之间链接的输出是:</p> <pre><code class="language-py">/wiki/Kevin_Bacon /wiki/Primetime_Emmy_Award_for_Outstanding_Lead_Actor_in_a_ Miniseries_or_a_Movie /wiki/Gary_Gilmore /wiki/Eric_Idle </code></pre> <p>这转化为链接的关系:凯文·贝肯 → 黄金时段艾美奖 → 加里·吉尔莫 → 埃里克·艾德尔。</p> <p>除了解决六度问题和建模外,还可以使用有向和无向图来模拟在网页抓取中遇到的各种情况。哪些网站链接到其他网站?哪些研究论文引用了其他研究论文?在零售网站上,哪些产品倾向于与其他产品一起显示?这种链接的强度是多少?这个链接是双向的吗?</p> <p>识别这些基本关系类型对于基于抓取数据生成模型、可视化和预测非常有帮助。</p> <h1 id="自然语言工具包">自然语言工具包</h1> <p>到目前为止,本章主要集中在对文本体中单词的统计分析上。哪些词最受欢迎?哪些词不寻常?哪些词可能会在哪些其他词之后出现?它们是如何被组合在一起的?您所缺少的是理解,以您能力所及的程度,这些词代表什么。</p> <p><em>自然语言工具包</em>(<a href="http://www.nltk.org/install.html" target="_blank">NLTK</a>)是一套设计用来识别和标记自然英文文本中词性的 Python 库。其开发始于 2000 年,过去的 20 多年里,全球数十名开发者为该项目做出了贡献。尽管它提供的功能非常强大(整本书都可以专门讨论 NLTK),本节仅集中介绍其少数用途。</p> <h2 id="安装和设置">安装和设置</h2> <p><code>nltk</code>模块可以像其他 Python 模块一样安装,可以直接通过 NLTK 网站下载包,也可以使用任意数量的第三方安装程序与关键词<code>nltk</code>一起安装。有关完整的安装说明和故障排除帮助,请参阅<a href="http://www.nltk.org/install.html" target="_blank">NLTK 网站</a>。</p> <p>安装了模块后,您可以浏览广泛的文本语料库,这些语料库可以下载和使用:</p> <pre><code class="language-py">>>> import nltk >>> nltk.download() </code></pre> <p>这将打开 NLTK 下载器。您可以在终端使用其菜单提供的命令进行导航:</p> <pre><code class="language-py">NLTK Downloader ---------------------------------------------------------------------------     d) Download   l) List    u) Update   c) Config   h) Help   q) Quit --------------------------------------------------------------------------- Downloader> l Packages:   [*] abc................. Australian Broadcasting Commission 2006   [ ] alpino.............. Alpino Dutch Treebank   [*] averaged_perceptron_tagger Averaged Perceptron Tagger   [ ] averaged_perceptron_tagger_ru Averaged Perceptron Tagger (Russian)   [ ] basque_grammars..... Grammars for Basque   [ ] bcp47............... BCP-47 Language Tags   [ ] biocreative_ppi..... BioCreAtIvE (Critical Assessment of Information                            Extraction Systems in Biology)   [ ] bllip_wsj_no_aux.... BLLIP Parser: WSJ Model   [*] book_grammars....... Grammars from NLTK Book   [*] brown............... Brown Corpus   [ ] brown_tei........... Brown Corpus (TEI XML Version)   [ ] cess_cat............ CESS-CAT Treebank   [ ] cess_esp............ CESS-ESP Treebank   [*] chat80.............. Chat-80 Data Files   [*] city_database....... City Database   [*] cmudict............. The Carnegie Mellon Pronouncing Dictionary (0.6)   [ ] comparative_sentences Comparative Sentence Dataset   [ ] comtrans............ ComTrans Corpus Sample   [*] conll2000........... CONLL 2000 Chunking Corpus Hit Enter to continue: </code></pre> <p>语料库列表的最后一页包含其集合:</p> <pre><code class="language-py">Collections:   [P] all-corpora......... All the corpora   [P] all-nltk............ All packages available on nltk_data gh-pages                            branch   [P] all................. All packages   [*] book................ Everything used in the NLTK Book   [P] popular............. Popular packages   [P] tests............... Packages for running tests   [ ] third-party......... Third-party data packages ([*] marks installed packages; [P] marks partially installed collections) </code></pre> <p>在这里的练习中,我们将使用书籍集合。您可以通过下载器界面或在 Python 中下载它:</p> <pre><code class="language-py">nltk.download('book') </code></pre> <h2 id="使用-nltk-进行统计分析">使用 NLTK 进行统计分析</h2> <p>NLTK 非常适合生成关于文本段落中单词计数、词频和词多样性的统计信息。如果您只需要相对简单的计算(例如,在文本段落中使用的独特单词的数量),那么导入<code>nltk</code>可能有些大材小用——它是一个庞大的模块。然而,如果您需要对文本进行相对广泛的分析,您可以轻松获得几乎任何想要的度量函数。</p> <p>使用 NLTK 进行分析始终从<code>Text</code>对象开始。可以通过以下方式从简单的 Python 字符串创建<code>Text</code>对象:</p> <pre><code class="language-py">from nltk import word_tokenize from nltk import Text tokens = word_tokenize('Here is some not very interesting text') text = Text(tokens) </code></pre> <p>函数<code>word_tokenize</code>的输入可以是任何 Python 文本字符串。任何文本都可以传递进去,但是 NLTK 语料库非常适合用来玩耍和研究功能。你可以通过从 book 模块导入所有内容来使用前面部分下载的 NLTK 集合:</p> <pre><code class="language-py">from nltk.book import * </code></pre> <p>这加载了九本书:</p> <pre><code class="language-py">*** Introductory Examples for the NLTK Book *** Loading text1, ..., text9 and sent1, ..., sent9 Type the name of the text or sentence to view it. Type: 'texts()' or 'sents()' to list the materials. text1: Moby Dick by Herman Melville 1851 text2: Sense and Sensibility by Jane Austen 1811 text3: The Book of Genesis text4: Inaugural Address Corpus text5: Chat Corpus text6: Monty Python and the Holy Grail text7: Wall Street Journal text8: Personals Corpus text9: The Man Who Was Thursday by G . K . Chesterton 1908 </code></pre> <p>你将在所有接下来的例子中使用<code>text6</code>,“Monty Python and the Holy Grail”(1975 年电影的剧本)。</p> <p>文本对象可以像普通的 Python 数组一样进行操作,就像它们是文本中包含的单词的数组一样。利用这一特性,你可以计算文本中唯一单词的数量,并将其与总单词数进行比较(记住 Python 的<code>set</code>只包含唯一值):</p> <pre><code class="language-py">>>> len(text6)/len(set(text6)) 7.833333333333333 </code></pre> <p>上面显示了脚本中每个单词平均使用约八次的情况。你还可以将文本放入频率分布对象中,以确定一些最常见的单词和各种单词的频率:</p> <pre><code class="language-py">>>> from nltk import FreqDist >>> fdist = FreqDist(text6) >>> fdist.most_common(10) [(':', 1197), ('.', 816), ('!', 801), (',', 731), ("'", 421), ('[', 3 19), (']', 312), ('the', 299), ('I', 255), ('ARTHUR', 225)] >>> fdist["Grail"] 34 </code></pre> <p>因为这是一个剧本,所以写作方式可能会显现一些特定的艺术形式。例如,大写的“ARTHUR”经常出现,因为它出现在剧本中亚瑟王每一行的前面。此外,冒号(:)在每一行之前出现,作为角色名和角色台词之间的分隔符。利用这一事实,我们可以看到电影中有 1,197 行!</p> <p>在前几章中我们称之为 2-grams,在 NLTK 中称为<em>bigrams</em>(有时你也可能听到 3-grams 被称为<em>trigrams</em>,但我更喜欢 2-gram 和 3-gram 而不是 bigram 或 trigram)。你可以非常轻松地创建、搜索和列出 2-grams:</p> <pre><code class="language-py">>>> from nltk import bigrams >>> bigrams = bigrams(text6) >>> bigramsDist = FreqDist(bigrams) >>> bigramsDist[('Sir', 'Robin')] 18 </code></pre> <p>要搜索 2 元组“Sir Robin”,你需要将其分解为元组(“Sir”,“Robin”),以匹配频率分布中表示 2 元组的方式。还有一个<code>trigrams</code>模块以相同方式工作。对于一般情况,你还可以导入<code>ngrams</code>模块:</p> <pre><code class="language-py">>>> from nltk import ngrams >>> fourgrams = ngrams(text6, 4) >>> fourgramsDist = FreqDist(fourgrams) >>> fourgramsDist[('father', 'smelt', 'of', 'elderberries')] 1 </code></pre> <p>这里,调用<code>ngrams</code>函数将文本对象分解为任意大小的 n-grams,由第二个参数控制。在这种情况下,你将文本分解为 4-grams。然后,你可以展示短语“father smelt of elderberries”在剧本中正好出现一次。</p> <p>频率分布、文本对象和 n-grams 也可以在循环中进行迭代和操作。例如,以下代码打印出所有以单词“coconut”开头的 4-grams:</p> <pre><code class="language-py">from nltk.book import * from nltk import ngrams fourgrams = ngrams(text6, 4) [f for f in fourgrams if f[0] == 'coconut'] </code></pre> <p>NLTK 库拥有大量工具和对象,旨在组织、计数、排序和测量大段文本。尽管我们只是初步了解了它们的用途,但这些工具大多设计良好,对熟悉 Python 的人操作起来相当直观。</p> <h2 id="使用-nltk-进行词汇分析">使用 NLTK 进行词汇分析</h2> <p>到目前为止,你只是根据它们自身的价值比较和分类了遇到的所有单词。没有区分同音异义词或单词使用的上下文。</p> <p>尽管有些人可能会觉得同音异义词很少会成问题,你也许会惊讶地发现它们出现的频率有多高。大多数以英语为母语的人可能并不经常意识到一个词是同音异义词,更不用说考虑它可能在不同语境中被误认为另一个词了。</p> <p>“他在实现他写一部客观哲学的目标时非常客观,主要使用客观语态的动词”对人类来说很容易解析,但可能会让网页抓取器以为同一个词被使用了四次,导致它简单地丢弃了关于每个词背后意义的所有信息。</p> <p>除了查找词性,能够区分一个词在不同用法下的差异也许会很有用。例如,你可能想要查找由常见英语词组成的公司名称,或分析某人对公司的看法。“ACME 产品很好”和“ACME 产品不坏”可能有着相同的基本意思,即使一句话使用了“好”而另一句话使用了“坏”。</p> <p>除了测量语言之外,NLTK 还可以根据上下文和自身庞大的词典帮助找到词语的含义。在基本水平上,NLTK 可以识别词性:</p> <pre><code class="language-py">>>> from nltk.book import * >>> from nltk import word_tokenize >>> text = word_tokenize('Strange women lying in ponds distributing swords'\ 'is no basis for a system of government.') >>> from nltk import pos_tag >>> pos_tag(text) [('Strange', 'NNP'), ('women', 'NNS'), ('lying', 'VBG'), ('in', 'IN') , ('ponds', 'NNS'), ('distributing', 'VBG'), ('swords', 'NNS'), ('is' , 'VBZ'), ('no', 'DT'), ('basis', 'NN'), ('for', 'IN'), ('a', 'DT'), ('system', 'NN'), ('of', 'IN'), ('government', 'NN'), ('.', '.')] </code></pre> <p>每个词都被分为一个包含该词和标识其词性的标签的<em>元组</em>(有关这些标签的更多信息,请参见前面的侧边栏)。虽然这看起来可能是一个简单的查找,但正确执行这项任务所需的复杂性在以下示例中变得显而易见:</p> <pre><code class="language-py">>>> text = word_tokenize('The dust was thick so he had to dust') >>> pos_tag(text) [('The', 'DT'), ('dust', 'NN'), ('was', 'VBD'), ('thick', 'JJ'), ('so', 'RB'), ('he', 'PRP'), ('had', 'VBD'), ('to', 'TO'), ('dust', 'VB')] </code></pre> <p>注意,“dust”一词在句子中使用了两次:一次作为名词,另一次作为动词。NLTK 根据它们在句子中的上下文正确地识别了两种用法。NLTK 通过使用由英语语言定义的上下文无关语法来识别词性。<em>上下文无关语法</em>是定义哪些东西允许跟随其他东西的规则集合。在这种情况下,它们定义了哪些词性可以跟随其他词性。每当遇到一个模棱两可的词如“dust”时,上下文无关语法的规则被参考,并选择一个符合规则的适当词性。</p> <p>在特定语境中知道一个词是动词还是名词有什么意义呢?在计算机科学研究实验室里或许很有意思,但它如何帮助网页抓取呢?</p> <p>网页抓取中的一个常见问题涉及搜索。你可能正在从网站上抓取文本,并希望搜索其中“google”一词的实例,但只有在它被用作动词时才这样做,而不是作为专有名词。或者你可能只想寻找公司 Google 的实例,并且不希望依赖人们对大小写的正确使用来找到这些实例。在这里,<code>pos_tag</code>函数可以极为有用:</p> <pre><code class="language-py">from nltk import word_tokenize, sent_tokenize, pos_tag sentences = [     'Google is one of the best companies in the world.',     ' I constantly google myself to see what I\'m up to.' ] nouns = ['NN', 'NNS', 'NNP', 'NNPS'] for sentence in sentences:     for word, tag in pos_tag(word_tokenize(sentence)):         if word.lower() == 'google' and tag in nouns:             print(sentence) </code></pre> <p>这只打印包含“google”(或“Google”)一词的句子(作为某种名词,而不是动词)。当然,您可以更具体地要求只打印带有“NNP”(专有名词)标记的 Google 实例,但即使是 NLTK 有时也会出错,因此根据应用程序的不同,留给自己一些灵活性也是有好处的。</p> <p>大部分自然语言的歧义可以通过 NLTK 的<code>pos_tag</code>函数来解决。通过搜索文本中目标单词或短语 <em>以及</em> 其标记,您可以极大地提高抓取器搜索的准确性和效率。</p> <h1 id="其他资源">其他资源</h1> <p>通过机器处理、分析和理解自然语言是计算机科学中最困难的任务之一,关于此主题已经有无数的书籍和研究论文被撰写。我希望这里的涵盖范围能激发您思考超越传统的网络抓取,或者至少给您在开始进行需要自然语言分析的项目时提供一些初始方向。</p> <p>对于初学者语言处理和 Python 的自然语言工具包有许多优秀的资源。特别是 Steven Bird、Ewan Klein 和 Edward Loper 的书籍<a href="http://oreil.ly/1HYt3vV" target="_blank"><em>Natural Language Processing with Python</em></a>(O'Reilly)对该主题提供了全面和初步的介绍。</p> <p>此外,James Pustejovsky 和 Amber Stubbs 的书籍<a href="http://oreil.ly/S3BudT" target="_blank"><em>Natural Language Annotations for Machine Learning</em></a>(O'Reilly)提供了一个略微更高级的理论指南。您需要掌握 Python 的知识才能实施这些教训;所涵盖的主题与 Python 的自然语言工具包完全契合。</p> <p>¹ 尽管本章描述的许多技术可以应用于所有或大多数语言,但现在只专注于英语自然语言处理也是可以的。例如,工具如 Python 的自然语言工具包专注于英语。根据<a href="http://w3techs.com/technologies/overview/content_language/all" target="_blank">W3Techs</a>,仍有 53%的互联网内容是用英语编写的(其次是西班牙语,仅占 5.4%)。但谁知道呢?英语在互联网上的主导地位几乎肯定会在未来发生变化,因此在未来几年可能需要进一步更新。</p> <p>² Oriol Vinyals 等人,<a href="http://bit.ly/1HEJ8kX" target="_blank">“一幅图值千言(连贯):构建图片的自然描述”</a>,<em>Google 研究博客</em>,2014 年 11 月 17 日。</p> <p>³ 例外是文本中的最后一个单词,因为没有任何单词跟随最后一个单词。在我们的示例文本中,最后一个单词是句号(.),这很方便,因为文本中还有 215 个其他出现,所以不会形成死胡同。但是,在马尔科夫生成器的实际实现中,最后一个单词可能是您需要考虑的内容。</p> <h1 id="第十三章穿越表单和登录">第十三章:穿越表单和登录</h1> <p>当您开始超越 Web 抓取的基础时,一个最先提出的问题是:“我如何访问登录屏幕背后的信息?”网络越来越向互动、社交媒体和用户生成内容发展。表单和登录是这些类型网站的一个组成部分,几乎不可避免。幸运的是,它们也相对容易处理。</p> <p>到目前为止,我们示例爬虫中与 Web 服务器的大多数交互都是使用 HTTP <code>GET</code>请求信息。本章重点介绍<code>POST</code>方法,该方法将信息推送到 Web 服务器进行存储和分析。</p> <p>表单基本上为用户提供了一种提交<code>POST</code>请求,Web 服务器可以理解和使用的方法。就像网站上的链接标签帮助用户格式化<code>GET</code>请求一样,HTML 表单帮助他们格式化<code>POST</code>请求。当然,通过少量的编码,我们也可以创建这些请求并使用爬虫提交它们。</p> <h1 id="python-requests-库">Python Requests 库</h1> <p>虽然使用 Python 核心库可以导航网页表单,但有时一些语法糖会让生活变得更甜美。当你开始执行比基本的<code>GET</code>请求更多的操作时,看看 Python 核心库之外的东西可能会有所帮助。</p> <p><a href="http://www.python-requests.org" target="_blank">Requests 库</a>在处理复杂的 HTTP 请求、Cookie、标头等方面非常出色。Requests 的创始人 Kenneth Reitz 对 Python 的核心工具有什么看法:</p> <blockquote> <p>Python 的标准 urllib2 模块提供了大部分你需要的 HTTP 功能,但 API 是彻底破损的。它是为不同的时间和不同的 Web 构建的。即使是最简单的任务,也需要大量工作(甚至是方法覆盖)。</p> <p>事情不应该这样。在 Python 中不应该这样。</p> </blockquote> <p>与任何 Python 库一样,Requests 库可以通过任何第三方 Python 库管理器(如 pip)安装,或者通过下载和安装<a href="https://github.com/kennethreitz/requests/tarball/master" target="_blank">源文件</a>来安装。</p> <h1 id="提交一个基本表单">提交一个基本表单</h1> <p>大多数 Web 表单包含几个 HTML 字段、一个提交按钮和一个动作页面,实际上处理表单处理的地方。HTML 字段通常包含文本,但也可能包含文件上传或其他非文本内容。</p> <p>大多数流行的网站在其<em>robots.txt</em>文件中阻止对其登录表单的访问(第二章讨论了刮取这些表单的合法性),所以为了安全起见,我构建了一系列不同类型的表单和登录页面在<em>pythonscraping.com</em>上,您可以在那里运行您的网络爬虫。<a href="http://pythonscraping.com/pages/files/form.html" target="_blank"><em>http://pythonscraping.com/pages/files/form.html</em></a> 是这些表单中最基本的位置。</p> <p>表单的整个 HTML 代码如下:</p> <pre><code class="language-py"><form method="post" action="processing.php"> First name: <input type="text" name="firstname"><br> Last name: <input type="text" name="lastname"><br> <input type="submit" value="Submit"> </form> </code></pre> <p>这里需要注意几点:首先,两个输入字段的名称分别是 <code>firstname</code> 和 <code>lastname</code>。这很重要。这些字段的名称决定了在提交表单时将 <code>POST</code> 到服务器的变量参数的名称。如果你想模仿表单在提交你自己的数据时所采取的动作,你需要确保你的变量名称匹配。</p> <p>第二点需要注意的是,表单的 action 是 <em>processing.php</em>(绝对路径为 <a href="http://pythonscraping.com/pages/files/processing.php" target="_blank"><em>http://pythonscraping.com/pages/files/processing.php</em></a>)。对表单的任何 <code>POST</code> 请求应该在 <em>这个</em> 页面上进行,而不是表单本身所在的页面。记住:HTML 表单的目的只是帮助网站访问者格式化正确的请求,以发送给真正执行动作的页面。除非你正在研究如何格式化请求本身,否则不需要过多关注可以找到表单的页面。</p> <p>使用 Requests 库提交表单只需四行代码,包括导入和指令以打印内容(是的,就是这么简单):</p> <pre><code class="language-py">import requests params = {'firstname': 'Ryan', 'lastname': 'Mitchell'} r = requests.post( 'http://pythonscraping.com/pages/files/processing.php',   data=params ) print(r.text) </code></pre> <p>表单提交后,脚本应返回页面的内容:</p> <pre><code class="language-py">Hello there, Ryan Mitchell! </code></pre> <p>此脚本可应用于互联网上遇到的许多简单表单。例如,注册“使用 Python 进行网页抓取”通讯的表单如下所示:</p> <pre><code class="language-py"> <form id="eclg-form">   <div class="input-field">     <label>First Name</label>     <input type="text" name="first_name" class="eclg_firstname">   </div>   <div class="input-field">     <label>Last Name</label>     <input type="text" name="last_name" class="eclg_lastname">   </div>   <div class="input-field">     <label>Email</label>     <input type="text" name="email" class="eclg_email">   </div>   <div class="input-field input-submit">     <button type="button" id="eclg-submit-btn">Send </button>     <div class="eclg_ajax_loader" style="display: none;"> <img decoding="async" src="https://pythonscraping.com/wp-content/ plugins/email-capture-lead-generation//images/ajax_loader.gif"> </div>   </div>   <div class="eclg-message-container"></div> </form> </code></pre> <p>虽然一开始看起来可能有些吓人,但大多数情况下(稍后我们将讨论例外情况),你只需寻找两件事:</p> <ul> <li> <p>要提交的字段(或字段)的名称和数据。在这种情况下,名字是 <code>first_name</code>、姓是 <code>last_name</code> 和电子邮件地址是 <code>email</code>。</p> </li> <li> <p>表单本身的 action 属性;也就是表单提交数据的页面。</p> </li> </ul> <p>在这种情况下,表单的 action 不明显。与传统的 HTML 表单不同,此页面使用 JavaScript 程序检测表单提交并将其提交到正确的 URL。</p> <p>在这种情况下,使用浏览器的网络工具会很方便。只需打开网络选项卡,填写表单,点击提交按钮,观察发送到网络的值(图 13-1)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1301.png" alt="" loading="lazy"></p> <h6 id="图13-1-发送到-pythonscrapingcom-的通讯订阅表单的请求">图 13-1. 发送到 pythonscraping.com 的通讯订阅表单的请求</h6> <p>虽然你可以深入研究复杂的 JavaScript,并最终得出相同的答案,但使用网络选项卡可以让你轻松地看到表单内容被提交到 <a href="https://pythonscraping.com/wp-admin/admin-ajax.php" target="_blank"><em>https://pythonscraping.com/wp-admin/admin-ajax.php</em></a>。</p> <p>此外,Payload 选项卡显示发送到此端点的第四个表单值:<code>action: eclg_add_newsletter</code>。</p> <p>有了这个,我们可以在 Python 中复制表单提交的过程:</p> <pre><code class="language-py">import requests params = {     'firstname': 'Ryan',     'lastname': 'Mitchell',     'email': 'ryanemitchell@gmail.com',     'action': 'eclg_add_newsletter' } r = requests.post('https://pythonscraping.com/wp-admin/admin-ajax.php',                    data=params) print(r.text) </code></pre> <p>在这种情况下,表单提供了一个 JSON 格式的响应:</p> <pre><code class="language-py">{"status":"1","errmsg":"You have subscribed successfully!."} </code></pre> <h1 id="单选按钮复选框和其他输入">单选按钮、复选框和其他输入</h1> <p>显然,并非所有的网络表单都是由文本字段和提交按钮组成的。标准的 HTML 包含多种可能的表单输入字段:单选按钮、复选框和选择框等。HTML5 还增加了滑块(范围输入字段)、电子邮件、日期等。利用自定义的 JavaScript 字段,可能性是无限的,包括颜色选择器、日历和开发人员接下来想出的任何东西。</p> <p>无论任何类型的表单字段看起来多么复杂,你只需要关心两件事情:元素的名称和其值。元素的名称可以通过查看源代码并找到<code>name</code>属性来确定。值有时可能会更加棘手,因为它可能会在表单提交之前由 JavaScript 立即填充。例如,作为相当奇特的表单字段的颜色选择器,可能会具有像<code>#F03030</code>这样的值。</p> <p>如果你不确定输入字段值的格式,可以使用各种工具跟踪浏览器发送到和从站点的<code>GET</code>和<code>POST</code>请求。跟踪<code>GET</code>请求的最佳方法,正如前面提到的,是查看站点的 URL。如果 URL 如下所示:</p> <blockquote> <p><code>http://domainname.com?thing1=foo&thing2=bar</code></p> </blockquote> <p>你就知道这对应于这种类型的表单:</p> <pre><code class="language-py"><form method="GET" action="someProcessor.php"> <input type="someCrazyInputType" name="thing1" value="foo" /> <input type="anotherCrazyInputType" name="thing2" value="bar" /> <input type="submit" value="Submit" /> </form> </code></pre> <p>对应于 Python 参数对象:</p> <pre><code class="language-py">{'thing1':'foo', 'thing2':'bar'} </code></pre> <p>如果你卡在一个看起来复杂的<code>POST</code>表单上,并且你想准确地查看浏览器发送到服务器的参数,最简单的方法是使用浏览器的检查器或开发者工具查看它们(见图 13-2)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1302.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-13-2-表单数据部分突出显示为框显示了-post-参数thing1和thing2它们的值分别为foo和bar">图 13-2. 表单数据部分,突出显示为框,显示了 POST 参数“thing1”和“thing2”,它们的值分别为“foo”和“bar”</h6> <h1 id="提交文件和图像">提交文件和图像</h1> <p>虽然文件上传在互联网上很常见,但文件上传并不是网络爬虫中经常使用的内容。不过,你可能想为自己的网站编写一个涉及文件上传的测试。无论如何,了解如何做这件事是很有用的。</p> <p>在<em><a href="http://pythonscraping.com/pages/files/form2.html" target="_blank"><em>http://pythonscraping.com/pages/files/form2.html</em></a></em>上有一个练习文件上传表单。页面上的表单标记如下:</p> <pre><code class="language-py"><form action="processing2.php" method="post" enctype="multipart/form-data">   Submit a jpg, png, or gif: <input type="file" name="uploadFile"><br>   <input type="submit" value="Upload File"> </form> </code></pre> <p>除了<code><input></code>标签具有类型属性<code>file</code>之外,它看起来基本与前面示例中使用的文本表单相同。幸运的是,Python Requests 库使用表单的方式也类似:</p> <pre><code class="language-py">import requests files = {'uploadFile': open('files/python.png', 'rb')} r = requests.post('http://pythonscraping.com/pages/files/processing2.php',   files=files) print(r.text) </code></pre> <p>请注意,与简单的字符串不同,提交到表单字段(名称为<code>uploadFile</code>)的值现在是一个 Python 文件对象,由<code>open</code>函数返回。在这个例子中,你正在提交一个图像文件,该文件存储在本地机器上,路径为<em>../files/Python-logo.png</em>,相对于运行 Python 脚本的位置。</p> <p>是的,确实如此!</p> <h1 id="处理登录和-cookies">处理登录和 Cookies</h1> <p>到目前为止,我们主要讨论了允许您向网站提交信息或在表单之后立即查看所需信息的表单。这与登录表单有何不同,登录表单允许您在访问网站期间处于永久“已登录”状态?</p> <p>大多数现代网站使用 Cookie 来跟踪谁已登录和谁未登录。网站在验证您的登录凭据后,将它们存储在您的浏览器 Cookie 中,该 Cookie 通常包含服务器生成的令牌、超时和跟踪信息。然后,网站将此 Cookie 用作身份验证的一种证明,该证明显示在您在网站上停留期间访问的每个页面上。在 1990 年代中期 Cookie 的广泛使用之前,保持用户安全验证并跟踪他们是网站的一个巨大问题。</p> <p>尽管 Cookie 对于网站开发人员是一个很好的解决方案,但对于网络爬虫来说可能会有问题。您可以整天提交登录表单,但如果您不跟踪表单发送给您的 Cookie,那么您访问的下一页将表现得好像您根本没有登录过一样。</p> <p>我在<a href="http://pythonscraping.com/pages/cookies/login.html" target="_blank"><em>http://pythonscraping.com/pages/cookies/login.html</em></a>创建了一个简单的登录表单(用户名可以是任何内容,但密码必须是“password”)。该表单在<em><a href="http://pythonscraping.com/pages/cookies/welcome.php" target="_blank"><em>http://pythonscraping.com/pages/cookies/welcome.php</em></a></em>处理,其中包含指向主页的链接,<em><a href="http://pythonscraping.com/pages/cookies/profile.php" target="_blank"><em>http://pythonscraping.com/pages/cookies/profile.php</em></a></em>。</p> <p>如果您尝试在登录之前访问欢迎页面或个人资料页面,您将收到错误消息并获得登录指示。在个人资料页面上,会检查您浏览器的 Cookie,以查看其 Cookie 是否设置在登录页面上。</p> <p>使用 Requests 库跟踪 Cookie 很容易:</p> <pre><code class="language-py">import requests params = {'username': 'Ryan', 'password': 'password'} r = requests.post(     'https://pythonscraping.com/pages/cookies/welcome.php',     params) print(r.text) print('Cookie is set to:') print(r.cookies.get_dict()) print('Going to profile page...') r = requests.get('https://pythonscraping.com/pages/cookies/profile.php',                   cookies=r.cookies) print(r.text) </code></pre> <p>在这里,您将登录参数发送到欢迎页面,该页面充当登录表单的处理器。您从上次请求的结果中检索 Cookie,打印结果以进行验证,然后通过设置<code>cookies</code>参数将其发送到个人资料页面。</p> <p>这在简单情况下效果很好,但是如果您要处理的是频繁修改 Cookie 而没有警告的更复杂的站点,或者如果您根本不想考虑 Cookie,那怎么办?在这种情况下,Requests 的<code>session</code>函数完美地解决了这个问题:</p> <pre><code class="language-py">session = requests.Session() params = {'username': 'Ryan', 'password': 'password'} s = session.post('https://pythonscraping.com/pages/cookies/welcome.php', params) print('Cookie is set to:') print(s.cookies.get_dict()) print('Going to profile page...') s = session.get('https://pythonscraping.com/pages/cookies/profile.php') print(s.text) </code></pre> <p>在这种情况下,会话对象(通过调用<code>requests.Session()</code>检索)会跟踪会话信息,例如 Cookie、标头,甚至您可能在 HTTP 之上运行的协议的信息,例如 HTTPAdapters。</p> <p>Requests 是一个了不起的库,也许仅次于 Selenium(在第十四章中介绍)的完整性,它可以处理所有这些而不需要程序员考虑或编写代码。虽然让库来完成所有工作可能很诱人,但在编写网络爬虫时,始终要意识到 Cookie 的样子以及它们在控制什么,这非常重要。这可以节省许多痛苦的调试时间,或者弄清楚为什么网站的行为很奇怪!</p> <h2 id="http-基本访问身份验证">HTTP 基本访问身份验证</h2> <p>在 Cookie 出现之前,处理登录的一种流行方式是使用 HTTP <em>基本访问身份验证</em>。你偶尔还会看到它,尤其是在高安全性或企业站点上,以及一些 API 上。我创建了一个页面,地址是<a href="http://pythonscraping.com/pages/auth/login.php" target="_blank"><em>http://pythonscraping.com/pages/auth/login.php</em></a>,具有此类身份验证(图 13-3)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1303.png" alt="Alt Text" loading="lazy"></p> <h6 id="图13-3用户必须提供用户名和密码才能访问受基本访问身份验证保护的页面">图 13-3。用户必须提供用户名和密码才能访问受基本访问身份验证保护的页面</h6> <p>与这些示例一样,你可以使用任何用户名登录,但密码必须是“password”。</p> <p>Requests 包含一个专门设计用于处理 HTTP 身份验证的<code>auth</code>模块:</p> <pre><code class="language-py">import requests from requests.auth import AuthBase from requests.auth import HTTPBasicAuth auth = HTTPBasicAuth('ryan', 'password') r = requests.post(     url='https://pythonscraping.com/pages/auth/login.php', auth=auth) print(r.text) </code></pre> <p>尽管这看起来像是一个普通的<code>POST</code>请求,但在请求中将一个<code>HTTPBasicAuth</code>对象作为<code>auth</code>参数传递。结果文本将是由用户名和密码保护的页面(或者如果请求失败,则为拒绝访问页面)。</p> <h1 id="其他表单问题">其他表单问题</h1> <p>Web 表单是恶意机器人的热门入口点。你不希望机器人创建用户帐户、占用宝贵的服务器处理时间或在博客上提交垃圾评论。因此,安全功能通常被纳入现代网站的 HTML 表单中,这些功能可能不会立即显现。</p> <h6 id="提示-1">提示</h6> <p>如需有关 CAPTCHA 的帮助,请查看第十六章,其中涵盖了 Python 中的图像处理和文本识别。</p> <p>如果你遇到一个神秘的错误,或者服务器因为未知原因拒绝你的表单提交,请查看第十七章,其中涵盖了蜜罐、隐藏字段和其他网站采取的安全措施,以保护其表单。</p> <h1 id="第十四章javascript-抓取">第十四章:JavaScript 抓取</h1> <p>客户端脚本语言是在浏览器内部而不是在 Web 服务器上运行的语言。客户端语言的成功取决于浏览器正确解释和执行语言的能力。</p> <p>虽然有数百种服务器端编程语言,但只有一种客户端编程语言。这是因为让每个浏览器制造商达成标准协议的难度很大。在进行网页抓取时,语言种类越少越好。</p> <h1 id="其他客户端编程语言">其他客户端编程语言</h1> <p>有些读者可能会对这句话提出异议:“只有一种客户端编程语言。”技术上存在 ActionScript 和 VBScript 等语言。然而,这些语言已不再受支持,并且在 VBScript 的情况下,仅被单个浏览器支持过。因此,它们很少被看到。</p> <p>如果你想对此挑剔,任何人都可以创建新的客户端编程语言!可能有很多这样的语言存在!唯一的问题是得到浏览器的广泛支持,使该语言有效并被其他人使用。</p> <p>有些人还争论说 CSS 和 HTML 本身就是编程语言。在理论上我同意这一点。Lara Schenck 在这个主题上有一篇出色而有趣的博客文章:<a href="https://notlaura.com/is-css-turing-complete/" target="_blank"><em>https://notlaura.com/is-css-turing-complete/</em></a>。</p> <p>然而,在实际操作中,CSS 和 HTML 通常被视为与“编程语言”分开的标记语言,并且本书对它们有广泛的覆盖。</p> <p>JavaScript 是迄今为止在 Web 上最常见和最受支持的客户端脚本语言。它可以用于收集用户跟踪信息,无需重新加载页面即可提交表单,嵌入多媒体,甚至支持整个在线游戏。即使看似简单的页面通常也包含多个 JavaScript 片段。你可以在页面的源代码中的<code>script</code>标签之间找到它:</p> <pre><code class="language-py"><script> alert("This creates a pop-up using JavaScript"); </script> </code></pre> <h1 id="javascript-简介">JavaScript 简介</h1> <p>对于您正在抓取的代码至少有一些了解是非常有帮助的。考虑到这一点,熟悉 JavaScript 是个好主意。</p> <p><em>JavaScript</em> 是一种弱类型语言,其语法经常与 C++和 Java 进行比较。尽管语法的某些元素,如操作符、循环和数组,可能类似,但语言的弱类型和脚本化特性可能使它对某些程序员来说成为难以应付的“野兽”。</p> <p>例如,以下递归计算斐波那契数列的值,直到 100,并将它们打印到浏览器的开发者控制台:</p> <pre><code class="language-py"><script> function fibonacci(a, b){ var nextNum = a + b; console.log(nextNum+" is in the Fibonacci sequence"); if(nextNum < 100){   fibonacci(b, nextNum); } } fibonacci(1, 1); </script> </code></pre> <p>注意,所有变量都是通过在其前面加上 <code>var</code> 来标识的。这类似于 PHP 中的 <code>$</code> 符号或 Java 或 C++ 中的类型声明(<code>int</code>、<code>String</code>、<code>List</code> 等)。Python 不同之处在于它没有这种明确的变量声明。</p> <p>JavaScript 也擅长传递函数:</p> <pre><code class="language-py"><script> var fibonacci = function() { var a = 1; var b = 1; return function () { [b, a] = [a + b, b]; return b; } } var fibInstance = fibonacci(); console.log(fibInstance()+" is in the Fibonacci sequence"); console.log(fibInstance()+" is in the Fibonacci sequence"); console.log(fibInstance()+" is in the Fibonacci sequence"); </script> </code></pre> <p>起初可能会感到艰难,但如果你从 lambda 表达式的角度来思考(在 第五章 中介绍),问题就会变得简单起来。变量 <code>fibonacci</code> 被定义为一个函数。它的函数值返回一个函数,该函数打印出 Fibonacci 序列中逐渐增大的值。每次调用它时,它返回计算 Fibonacci 的函数,并再次执行并增加函数中的值。</p> <p>你还可能会看到像这样用 JavaScript ES6 引入的箭头语法编写的函数:</p> <pre><code class="language-py"><script> const fibonacci = () => { var a = 1; var b = 1; return () => { [b, a] = [a + b, b]; return b; } } const fibInstance = fibonacci(); console.log(fibInstance()+" is in the Fibonacci sequence"); console.log(fibInstance()+" is in the Fibonacci sequence"); console.log(fibInstance()+" is in the Fibonacci sequence"); </script> </code></pre> <p>在这里,我使用 JavaScript 关键字 <code>const</code> 表示一个常量变量,它以后不会被重新分配。你可能还会看到关键字 <code>let</code>,表示可以重新分配的变量。这些关键字也是在 ES6 中引入的。</p> <p>将函数作为变量传递也在处理用户操作和回调时非常有用,当涉及阅读 JavaScript 时,习惯这种编程风格是值得的。</p> <h2 id="常见的-javascript-库">常见的 JavaScript 库</h2> <p>尽管核心 JavaScript 语言很重要,但在现代网络上,如果不使用该语言的众多第三方库之一,你无法走得太远。查看页面源代码时,你可能会看到一种或多种常用的库。</p> <p>通过 Python 执行 JavaScript 可能会非常耗时和处理器密集,特别是在大规模执行时。熟悉 JavaScript 并能够直接解析它(而无需执行以获取信息)可能非常有用,并且能节省你大量麻烦。</p> <h3 id="jquery">jQuery</h3> <p><em>jQuery</em> 是一种极为常见的库,被超过 70% 的网站使用¹。使用 jQuery 的网站很容易识别,因为它的代码中某处会包含对 jQuery 的导入:</p> <pre><code class="language-py"><script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></ script> </code></pre> <p>如果在网站上找到了 jQuery,你在进行抓取时必须小心。jQuery 擅长动态创建 HTML 内容,这些内容仅在执行 JavaScript 后才出现。如果使用传统方法抓取页面内容,你只能获取到在 JavaScript 创建内容之前预加载的页面(这个抓取问题在 “Ajax 和动态 HTML” 中有更详细的讨论)。</p> <p>此外,这些页面更有可能包含动画、交互内容和嵌入媒体,这可能会使抓取变得更具挑战性。</p> <h3 id="google-analytics">Google Analytics</h3> <p><em>Google Analytics</em> 被大约 50% 的所有网站使用²,使其成为可能是互联网上最常见的 JavaScript 库和最受欢迎的用户跟踪工具。<a href="http://pythonscraping.com" target="_blank"><em>http://pythonscraping.com</em></a> 和 <a href="http://www.oreilly.com/" target="_blank"><em>http://www.oreilly.com/</em></a> 均使用 Google Analytics。</p> <p>确定页面是否使用 Google Analytics 很容易。它将在底部具有类似以下内容的 JavaScript(摘自 O’Reilly Media 网站):</p> <pre><code class="language-py"><!-- Google Analytics --> <script type="text/javascript"> var _gaq = _gaq || [];  _gaq.push(['_setAccount', 'UA-4591498-1']); _gaq.push(['_setDomainName', 'oreilly.com']); _gaq.push(['_addIgnoredRef', 'oreilly.com']); _gaq.push(['_setSiteSpeedSampleRate', 50]); _gaq.push(['_trackPageview']); (function() { var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true; ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'; var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s); })(); </script> </code></pre> <p>此脚本处理了用于跟踪您从页面到页面访问的 Google Analytics 特定 cookie。对于那些设计为执行 JavaScript 和处理 cookie(例如稍后在本章中讨论的 Selenium 使用的那些)的网络爬虫,这有时可能会成为一个问题。</p> <p>如果网站使用 Google Analytics 或类似的 Web 分析系统,而您不希望该网站知道它正在被爬取或抓取,则确保丢弃任何用于分析的 cookie 或完全丢弃 cookie。</p> <h3 id="google-地图">Google 地图</h3> <p>如果您在互联网上花费了一些时间,几乎可以肯定地看到嵌入到网站中的 <em>Google 地图</em>。其 API 使得在任何网站上轻松嵌入具有自定义信息的地图成为可能。</p> <p>如果您正在抓取任何类型的位置数据,了解 Google 地图的工作原理将使您能够轻松获取格式良好的纬度/经度坐标,甚至地址。在 Google 地图中表示位置的最常见方式之一是通过 <em>标记</em>(也称为 <em>图钉</em>)。</p> <p>标记可以通过以下代码插入到任何 Google 地图中:</p> <pre><code class="language-py">var marker = new google.maps.Marker({       position: new google.maps.LatLng(-25.363882,131.044922),       map: map,       title: 'Some marker text'   }); </code></pre> <p>Python 使得可以轻松提取在 <code>google.maps.LatLng(</code> 和 <code>)</code> 之间发生的所有坐标实例,以获取纬度/经度坐标列表。</p> <p>使用 <a href="https://developers.google.com/maps/documentation/javascript/examples/geocoding-reverse" target="_blank">Google 逆地理编码 API</a>,您可以将这些坐标对解析为适合存储和分析的地址。</p> <h1 id="ajax-和动态-html">Ajax 和动态 HTML</h1> <p>到目前为止,我们与 web 服务器通信的唯一方法是通过检索新页面发送某种 HTTP 请求。如果您曾经提交过表单或在不重新加载页面的情况下从服务器检索信息,那么您可能使用过使用 Ajax 的网站。</p> <p>与一些人的观点相反,Ajax 不是一种语言,而是一组用于完成特定任务的技术(与网页抓取类似)。<em>Ajax</em> 代表 <em>异步 JavaScript 和 XML</em>,用于向 web 服务器发送信息并接收信息,而无需发出单独的页面请求。</p> <h6 id="注意-2">注意</h6> <p>您不应该说,“这个网站将用 Ajax 编写。”而应该说,“这个网站将使用 Ajax 与 web 服务器通信。”</p> <p>像 Ajax 一样,<em>动态 HTML</em>(DHTML)是用于共同目的的一组技术。DHTML 是 HTML 代码、CSS 语言或两者的组合,它随着客户端脚本在页面上改变 HTML 元素而改变。例如,当用户移动鼠标时,可能会出现一个按钮,点击时可能会改变背景颜色,或者 Ajax 请求可能会触发加载一个新的内容块。</p> <p>请注意,尽管“动态”一词通常与“移动”或“变化”等词语相关联,交互式 HTML 组件的存在、移动图像或嵌入式媒体并不一定使页面成为 DHTML 页面,尽管它可能看起来很动态。此外,互联网上一些看起来最无聊、最静态的页面背后可能运行着依赖于 JavaScript 来操作 HTML 和 CSS 的 DHTML 进程。</p> <p>如果你经常爬取多个网站,很快你就会遇到这样一种情况:你在浏览器中看到的内容与你从网站源代码中检索到的内容不匹配。当你查看爬虫的输出时,可能会摸不着头脑,试图弄清楚为什么你在浏览器上看到的内容在网页源代码中竟然找不到。</p> <p>该网页还可能有一个加载页面,看起来似乎会将您重定向到另一个结果页面,但您会注意到,当此重定向发生时,页面的 URL 没有发生变化。</p> <p>这两种情况都是由于你的爬虫未能执行页面上发生魔法的 JavaScript 导致的。没有 JavaScript,HTML 就只是呆呆地呈现在那里,而网站可能看起来与在你的网页浏览器中看到的完全不同。</p> <p>页面可能有几个迹象表明它可能在使用 Ajax 或 DHTML 来更改或加载内容,但在这种情况下,只有两种解决方案:直接从 JavaScript 中获取内容;或使用能够执行 JavaScript 并在浏览器中查看网站的 Python 包来爬取网站。</p> <h1 id="在-python-中执行-selenium-中的-javascript">在 Python 中执行 Selenium 中的 JavaScript</h1> <p><a href="http://www.seleniumhq.org" target="_blank">Selenium</a> 是一个强大的网页抓取工具,最初用于网站测试。如今,当需要准确地呈现网页在浏览器中的外观时,也会使用 Selenium。Selenium 通过自动化浏览器加载网页,获取所需数据,甚至拍摄屏幕截图或验证网站上发生的某些动作来工作。</p> <p>Selenium 不包含自己的网络浏览器;它需要与第三方浏览器集成才能运行。例如,如果你用 Firefox 运行 Selenium,你会看到一个 Firefox 实例在你的屏幕上打开,导航到网站,并执行你在代码中指定的操作。尽管这可能很有趣,我更喜欢我的脚本在后台静静地运行,并经常使用 Chrome 的<em>无头</em>模式来做到这一点。</p> <p>一个<em>无头浏览器</em>将网站加载到内存中,并在页面上执行 JavaScript,但不会向用户显示网站的图形渲染。通过将 Selenium 与无头 Chrome 结合使用,你可以运行一个非常强大的网页抓取器,轻松处理 cookies、JavaScript、标头以及其他所有你需要的东西,就像使用渲染浏览器一样。</p> <h2 id="安装和运行-selenium">安装和运行 Selenium</h2> <p>你可以从<a href="https://pypi.python.org/pypi/selenium" target="_blank">其网站</a>下载 Selenium 库,或者使用像 pip 这样的第三方安装程序从命令行安装它。</p> <pre><code class="language-py">$ pip install selenium </code></pre> <p>以前的 Selenium 版本要求你手动下载一个 webdriver 文件,以便它与你的网络浏览器进行交互。这个 webdriver 之所以被称为这样,是因为它是网页浏览器的软件<em>驱动程序</em>。就像硬件设备的软件驱动程序一样,它允许 Python Selenium 包与你的浏览器进行接口和控制。</p> <p>不幸的是,由于浏览器的新版本频繁发布,并且多亏了自动更新,这意味着 Selenium 驱动程序也必须经常更新。导航到浏览器驱动程序的网站(例如<a href="http://chromedriver.chromium.org/downloads" target="_blank"><em>http://chromedriver.chromium.org/downloads</em></a>),下载新文件,并替换旧文件是一个频繁的烦恼。在 2021 年十月发布的 Selenium 4 中,这整个过程被 webdriver 管理器 Python 包替代。</p> <p>webdriver 管理器可以通过 pip 安装:</p> <pre><code class="language-py">$ pip install webdriver-manager </code></pre> <p>调用时,webdriver 管理器会下载最新的驱动程序:</p> <pre><code class="language-py">from selenium import webdriver from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager driver = webdriver.Chrome(service=Service(ChromeDriverManager().install())) driver.get("http://www.python.org") time.sleep(2) driver.close() </code></pre> <p>当然,如果这个脚本经常运行,每次运行时都安装一个新的驱动文件以防止 Chrome 浏览器自上次运行以来被更新是低效的。驱动程序管理器安装的输出只是驱动程序位于你的<code>driver</code>目录中的路径:</p> <pre><code class="language-py">CHROMEDRIVER_PATH = ChromeDriverManager().install() driver = webdriver.Chrome(service=Service(CHROMEDRIVER_PATH)) </code></pre> <p>如果你仍然喜欢手动下载文件,你可以通过将自己的路径传递给<code>Service</code>对象来实现:</p> <pre><code class="language-py">from selenium import webdriver from selenium.webdriver.chrome.service import Service CHROMEDRIVER_PATH = 'drivers/chromedriver_mac_arm64/chromedriver' driver = webdriver.Chrome(service=Service(CHROMEDRIVER_PATH)) driver.get("http://www.python.org") time.sleep(2) driver.close() </code></pre> <p>尽管许多页面使用 Ajax 加载数据,我已经创建了一个样例页面<a href="http://pythonscraping.com/pages/javascript/ajaxDemo.html" target="_blank"><em>http://pythonscraping.com/pages/javascript/ajaxDemo.html</em></a>供你对抗抓取器。这个页面包含一些样例文本,硬编码到页面的 HTML 中,在两秒延迟后被 Ajax 生成的内容替换。如果你使用传统方法来抓取这个页面的数据,你只会得到加载页面,而无法获取你想要的数据。</p> <p>Selenium 库是调用<a href="https://selenium-python.readthedocs.io/api.html" target="_blank"><code>webdriver</code></a>对象的 API。请注意,这是一个代表或充当你下载的 webdriver 应用程序的 Python 对象。虽然“driver”和<code>webdriver</code>这两个术语通常可以互换使用来指代这两个东西(Python 对象和应用程序本身),但在概念上区分它们是很重要的。</p> <p><code>webdriver</code>对象有点像浏览器,它可以加载网站,但也可以像<code>BeautifulSoup</code>对象一样用于查找页面元素,与页面上的元素交互(发送文本,单击等),并执行其他操作以驱动网络爬虫。</p> <p>以下代码检索测试页面上 Ajax“墙”后面的文本:</p> <pre><code class="language-py">from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.options import Options import time chrome_options = Options() chrome_options.add_argument("--headless") driver = webdriver.Chrome(   service=Service(CHROMEDRIVER_PATH),   options=chrome_options ) driver.get('http://pythonscraping.com/pages/javascript/ajaxDemo.html') time.sleep(3) print(driver.find_element(By.ID, 'content').text) driver.close() </code></pre> <p>这将使用 Chrome 库创建一个新的 Selenium webdriver,告诉 webdriver 加载页面,然后在查看页面以检索(希望加载的)内容之前暂停执行三秒钟。</p> <p>当您在 Python 中实例化一个新的 Chrome webdriver 时,可以通过<code>Options</code>对象传递各种选项。在这种情况下,我们使用<code>--headless</code>选项使 webdriver 在后台运行:</p> <pre><code class="language-py">chrome_options = Options() chrome_options.add_argument('--headless') </code></pre> <p>无论您是使用驱动程序管理程序包安装驱动程序还是自行下载驱动程序,都必须将此路径传递给<code>Service</code>对象,并传递您的选项,以创建新的 webdriver:</p> <pre><code class="language-py">driver = webdriver.Chrome(   service=Service(CHROMEDRIVER_PATH),   options=chrome_options ) </code></pre> <p>如果一切配置正确,脚本应该在几秒钟内运行,然后结果如下所示:</p> <pre><code class="language-py">Here is some important text you want  to retrieve! A button to click! </code></pre> <h2 id="selenium-选择器">Selenium 选择器</h2> <p>在以前的章节中,您已使用<code>BeautifulSoup</code>选择器(如<code>find</code>和<code>find_all</code>)选择页面元素。 Selenium 使用非常相似的一组方法来选择元素:<code>find_element</code>和<code>find_elements</code>。</p> <p>从 HTML 中找到和选择元素的方法有很多,您可能会认为 Selenium 会使用各种参数和关键字参数来执行这些方法。但是,对于<code>find_element</code>和<code>find_elements</code>,这两个函数都只有两个参数:<code>By</code>对象和字符串选择器。</p> <p><code>By</code>对象指定选择器字符串应如何解释,有以下选项列表:</p> <p><code>By.ID</code></p> <p>在示例中使用;通过它们的 HTML <code>id</code>属性查找元素。</p> <p><code>By.NAME</code></p> <p>通过它们的<code>name</code>属性查找 HTML 标记。这对于 HTML 表单很方便。</p> <p><code>By.XPATH</code></p> <p>使用 XPath 表达式选择匹配的元素。XPath 语法将在本章的后面更详细地介绍。</p> <p><code>By.LINK_TEXT</code></p> <p>通过它们包含的文本查找 HTML <code><a></code>标签。例如,可以使用<code>(By.LINK_TEXT,'Next')</code>选择标记为“Next”的链接。</p> <p><code>By.PARTIAL_LINK_TEXT</code></p> <p>类似于<code>LINK_TEXT</code>,但匹配部分字符串。</p> <p><code>By.TAG_NAME</code></p> <p>通过标记名称查找 HTML 标记。</p> <p><code>By.CLASS_NAME</code></p> <p>用于通过它们的 HTML <code>class</code>属性查找元素。为什么这个函数是<code>CLASS_NAME</code>而不仅仅是<code>CLASS</code>?使用形式<code>object.CLASS</code>会为 Selenium 的 Java 库创建问题,其中<code>.class</code>是保留方法。为了保持各种语言之间的 Selenium 语法一致,使用了<code>CLASS_NAME</code>。</p> <p><code>By.CSS_SELECTOR</code></p> <p>使用<code>class</code>、<code>id</code>或<code>tag</code>名称查找元素,使用<code>#idName</code>、<code>.className</code>、<code>tagName</code>约定。</p> <p>在前面的示例中,您使用了选择器<code>driver.find_element(By.ID,'content')</code>,虽然以下选择器也可以使用:</p> <pre><code class="language-py">driver.find_element(By.CSS_SELECTOR, '#content') driver.find_element(By.TAG_NAME, 'div') </code></pre> <p>当然,如果要在页面上选择多个元素,大多数这些元素选择器都可以通过使用<code>elements</code>(即,使其复数化)返回一个 Python 元素列表:</p> <pre><code class="language-py">driver.find_elements(By.CSS_SELECTOR, '#content') driver.find_elements(By.TAG_NAME, 'div') </code></pre> <p>如果仍然希望使用 BeautifulSoup 解析此内容,则可以通过使用 webdriver 的 <code>page_source</code> 函数来实现,该函数将页面的源代码作为字符串返回,正如在当前时间由 DOM 查看的那样:</p> <pre><code class="language-py">pageSource = driver.page_source bs = BeautifulSoup(pageSource, 'html.parser') print(bs.find(id='content').get_text()) </code></pre> <h2 id="等待加载">等待加载</h2> <p>请注意,尽管页面本身包含一个 HTML 按钮,但 Selenium 的 <code>.text</code> 函数以与检索页面上所有其他内容相同的方式检索按钮的文本值。</p> <p>如果将<code>time.sleep</code>暂停时间更改为一秒而不是三秒,则返回的文本将变为原始文本:</p> <pre><code class="language-py">This is some content that will appear on the page while it's loading. You don't care about scraping this. </code></pre> <p>尽管此解决方案有效,但效率略低,并且实施它可能在大规模上造成问题。页面加载时间不一致,取决于任何特定毫秒的服务器负载,并且连接速度会自然变化。尽管此页面加载应仅需超过两秒,但您将其整整三秒时间来确保其完全加载。更高效的解决方案将重复检查完全加载页面上特定元素的存在,并仅在该元素存在时返回。</p> <p>以下程序使用带有 ID <code>loadedButton</code> 的按钮的出现来声明页面已完全加载:</p> <pre><code class="language-py">from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC chrome_options = Options() chrome_options.add_argument("--headless") driver = webdriver.Chrome(     service=Service(CHROMEDRIVER_PATH),     options=chrome_options) driver.get('http://pythonscraping.com/pages/javascript/ajaxDemo.html') try:     element = WebDriverWait(driver, 10).until(                        EC.presence_of_element_located((By.ID, 'loadedButton'))) finally:     print(driver.find_element(By.ID, 'content').text)     driver.close() </code></pre> <p>此脚本引入了几个新的导入项,特别是<code>WebDriverWait</code>和<code>expected_conditions</code>,两者在此处组合以形成 Selenium 称之为的<em>隐式等待</em>。</p> <p>隐式等待与显式等待不同,它在继续之前等待 DOM 中的某种状态发生,而显式等待则定义了一个硬编码时间,例如前面示例中的三秒等待时间。在隐式等待中,触发 DOM 状态由<code>expected_condition</code>定义(请注意,此处导入被转换为<code>EC</code>,这是一种常用的简洁约定)。在 Selenium 库中,预期条件可以是许多事物,包括:</p> <ul> <li> <p>弹出警报框。</p> </li> <li> <p>元素(例如文本框)处于<em>选定</em>状态。</p> </li> <li> <p>页面标题更改,或现在在页面上或特定元素中显示文本。</p> </li> <li> <p>现在,一个元素对 DOM 可见,或者一个元素从 DOM 中消失。</p> </li> </ul> <p>大多数这些预期条件要求您首先指定要监视的元素。元素使用定位器指定。请注意,定位器与选择器不同(有关选择器的更多信息,请参见“Selenium Selectors”)。<em>定位器</em>是一种抽象查询语言,使用<code>By</code>对象,可以以多种方式使用,包括制作选择器。</p> <p>在下面的代码中,使用定位器查找具有 ID <code>loadedButton</code> 的元素:</p> <pre><code class="language-py">EC.presence_of_element_located((By.ID, 'loadedButton')) </code></pre> <p>定位器还可以用来创建选择器,使用<code>find_element</code> webdriver 函数:</p> <pre><code class="language-py">print(driver.find_element(By.ID, 'content').text) </code></pre> <p>如果不需要使用定位器,请不要使用;这将节省您的导入。然而,这个方便的工具用于各种应用,并具有极大的灵活性。</p> <h2 id="xpath">XPath</h2> <p><em>XPath</em>(即<em>XML Path</em>)是用于导航和选择 XML 文档部分的查询语言。1999 年由 W3C 创立,偶尔在 Python、Java 和 C#等语言中处理 XML 文档时使用。</p> <p>虽然 BeautifulSoup 不支持 XPath,但本书中的许多其他库(如 Scrapy 和 Selenium)支持。它通常可以像 CSS 选择器(例如<code>mytag#idname</code>)一样使用,尽管它设计用于与更广义的 XML 文档一起工作,而不是特定的 HTML 文档。</p> <p>XPath 语法有四个主要概念:</p> <p>根节点与非根节点</p> <p><code>/div</code> 仅在文档根部选择 div 节点。</p> <p><code>//div</code> 在文档中选择所有的 div。</p> <p>属性选择</p> <p><code>//@href</code> 选择具有属性<code>href</code>的任何节点。</p> <p><code>//a[@href='http://google.com']</code> 选择文档中指向 Google 的所有链接。</p> <p>通过位置选择节点</p> <p><code>//a[3]</code> 选择文档中的第三个链接。</p> <p><code>//table[last()]</code> 选择文档中的最后一个表格。</p> <p><code>//a[position() < 3]</code> 选择文档中的前两个链接。</p> <p>星号(*)匹配任何字符或节点,并可在各种情况下使用。</p> <p><code>//table/tr/*</code> 选择所有表格中<code>tr</code>标签的子节点(这对使用<code>th</code>和<code>td</code>标签选择单元格很有用)。</p> <p><code>//div[@*]</code> 选择所有带有任何属性的<code>div</code>标签。</p> <p>XPath 语法还具有许多高级功能。多年来,它发展成为一个相对复杂的查询语言,具有布尔逻辑、函数(如<code>position()</code>)和各种此处未讨论的运算符。</p> <p>如果您有无法通过此处显示的功能解决的 HTML 或 XML 选择问题,请参阅<a href="https://msdn.microsoft.com/en-us/enus/library/ms256471" target="_blank">Microsoft 的 XPath 语法页面</a>。</p> <h1 id="其他-selenium-webdrivers">其他 Selenium WebDrivers</h1> <p>在前一节中,Chrome WebDriver(ChromeDriver)与 Selenium 一起使用。大多数情况下,不需要浏览器弹出屏幕并开始网页抓取,因此在无头模式下运行会很方便。然而,以非无头模式运行,并/或使用不同的浏览器驱动程序,可以出于多种原因进行良好的实践:</p> <ul> <li> <p>故障排除。如果您的代码在无头模式下运行失败,可能很难在没有看到页面的情况下诊断失败原因。</p> </li> <li> <p>您还可以暂停代码执行并与网页交互,或在您的抓取器运行时使用检查工具来诊断问题。</p> </li> <li> <p>测试可能依赖于特定的浏览器才能运行。一个浏览器中的失败而另一个浏览器中没有可能指向特定于浏览器的问题。</p> </li> </ul> <p>在大多数情况下,最好使用 webdriver 管理器获取您的浏览器驱动程序。例如,您可以使用 webdriver 管理器来获取 Firefox 和 Microsoft Edge 的驱动程序:</p> <pre><code class="language-py">from webdriver_manager.firefox import GeckoDriverManager ​from webdriver_manager.microsoft import EdgeChromiumDriverManager print(GeckoDriverManager().install()) print(EdgeChromiumDriverManager().install()) </code></pre> <p>然而,如果您需要一个已弃用的浏览器版本或者通过 webdriver 管理器无法获取的浏览器(例如 Safari),您可能仍然需要手动下载驱动程序文件。</p> <p>今天的每个主要浏览器都有许多官方和非官方团体参与创建和维护 Selenium Web 驱动程序。Selenium 团队整理了一个用于参考的<a href="http://www.seleniumhq.org/download/" target="_blank">这些 Web 驱动程序的集合</a>。</p> <h1 id="处理重定向">处理重定向</h1> <p>客户端重定向是由 JavaScript 在您的浏览器中执行的页面重定向,而不是在服务器上执行的重定向,在发送页面内容之前。当您访问网页时,有时很难区分两者的区别。重定向可能发生得如此之快,以至于您没有注意到加载时间的任何延迟,并假定客户端重定向实际上是服务器端重定向。</p> <p>然而,在网页抓取时,差异是显而易见的。一个服务器端的重定向,根据它是如何处理的,可以通过 Python 的 urllib 库轻松地遍历,而无需 Selenium 的帮助(有关如何执行此操作的更多信息,请参见第六章)。客户端重定向除非执行 JavaScript,否则根本不会处理。</p> <p>Selenium 能够以处理其他 JavaScript 执行的方式处理这些 JavaScript 重定向;然而,这些重定向的主要问题在于何时停止页面执行,即如何判断页面何时完成重定向。一个演示页面在<a href="http://pythonscraping.com/pages/javascript/redirectDemo1.html" target="_blank"><em>http://pythonscraping.com/pages/javascript/redirectDemo1.html</em></a>上展示了这种类型的重定向,带有两秒的暂停。</p> <p>您可以通过“观察”页面初始加载时的 DOM 中的一个元素来巧妙地检测重定向,然后重复调用该元素,直到 Selenium 抛出<code>StaleElementReferenceException</code>;元素不再附加到页面的 DOM 中,网站已重定向:</p> <pre><code class="language-py">from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.common.exceptions import StaleElementReferenceException import time def waitForLoad(driver):     elem = driver.find_element(By.TAG_NAME, "html")     count = 0     for _ in range(0, 20):         try:             elem == driver.find_element(By.TAG_NAME, "html")         except StaleElementReferenceException:             return         time.sleep(0.5)     print("Timing out after 10 seconds and returning") chrome_options = Options() chrome_options.add_argument("--headless") driver = webdriver.Chrome(   service=Service(CHROMEDRIVER_PATH),   options=chrome_options ) driver.get("http://pythonscraping.com/pages/javascript/redirectDemo1.html") waitForLoad(driver) print(driver.page_source) driver.close() </code></pre> <p>这个脚本每隔半秒检查一次页面,超时时间为 10 秒,尽管检查时间和超时时间可以根据需要轻松调整。</p> <p>或者,您可以编写一个类似的循环来检查页面当前的 URL,直到 URL 发生变化或者匹配您正在寻找的特定 URL 为止。</p> <p>在 Selenium 中等待元素出现和消失是一个常见任务,您也可以像上一个按钮加载示例中使用的<code>WebDriverWait</code>函数一样使用相同的方法。在这里,您提供了一个 15 秒的超时时间和一个 XPath 选择器,用于查找页面主体内容来完成相同的任务:</p> <pre><code class="language-py">from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.chrome.options import Options from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException chrome_options = Options() chrome_options.add_argument("--headless") driver = webdriver.Chrome(     executable_path='drivers/chromedriver',      options=chrome_options) driver.get('http://pythonscraping.com/pages/javascript/redirectDemo1.html') try:   txt = 'This is the page you are looking for!'     bodyElement = WebDriverWait(driver, 15).until(   EC.presence_of_element_located((   By.XPATH,   f'//body[contains(text(), "{txt}")]'   ))   )     print(bodyElement.text) except TimeoutException:     print('Did not find the element') </code></pre> <h1 id="javascript-的最后一点说明">JavaScript 的最后一点说明</h1> <p>当今大多数互联网上的网站都在使用 JavaScript。³ 幸运的是,对我们来说,在许多情况下,这种 JavaScript 的使用不会影响你对页面的抓取。JavaScript 可能仅限于为站点的跟踪工具提供动力,控制站点的一小部分或操作下拉菜单,例如。在它影响到你抓取网站的方式时,可以使用像 Selenium 这样的工具来执行 JavaScript,以生成你在本书第一部分学习抓取的简单 HTML 页面。</p> <p>记住:仅因为一个网站使用 JavaScript 并不意味着所有传统的网络爬取工具都不再适用。JavaScript 的目的最终是生成可以由浏览器渲染的 HTML 和 CSS 代码,或通过 HTTP 请求和响应与服务器动态通信。一旦使用 Selenium,页面上的 HTML 和 CSS 可以像处理其他任何网站代码一样读取和解析,通过本书前几章的技术可以发送和处理 HTTP 请求和响应,即使不使用 Selenium 也可以。</p> <p>此外,JavaScript 甚至可以成为网络爬虫的一个好处,因为它作为“浏览器端内容管理系统”的使用可能向外界公开有用的 API,使您可以更直接地获取数据。有关更多信息,请参见第十五章。</p> <p>如果你在处理某个棘手的 JavaScript 情况时仍然遇到困难,你可以在第十七章中找到关于 Selenium 和直接与动态网站交互的信息,包括拖放界面等。</p> <p>¹ 查看 Web Technology Surveys 分析,网址为<a href="https://w3techs.com/technologies/details/js-jquery" target="_blank"><em>https://w3techs.com/technologies/details/js-jquery</em></a>,W3Techs 使用网络爬虫随时间监测技术使用趋势。</p> <p>² W3Techs,《Google Analytics 网站使用统计和市场份额》。</p> <p>³ W3Techs,《网站上作为客户端编程语言使用的 JavaScript 的使用统计》。</p> <h1 id="第十五章通过-api-爬行">第十五章:通过 API 爬行</h1> <p>JavaScript 一直是网络爬虫的克星。在互联网古老历史的某个时刻,你可以确保向 Web 服务器请求一个 HTML 页面,所得到的内容与在浏览器中看到的完全相同。</p> <p>随着 JavaScript 和 Ajax 内容的生成和加载变得更加普遍,这种情况变得不那么常见了。在第十四章中,你看到了解决这个问题的一种方法:使用 Selenium 自动化浏览器并获取数据。这是一件容易的事情。它几乎总是有效的。</p> <p>问题在于,当你拥有像 Selenium 这样强大和高效的“锤子”时,每个网络抓取问题开始看起来都很像一个钉子。</p> <p>在本章中,你将完全消除 JavaScript 的影响(甚至无需执行它或加载它!),直接访问数据源:生成数据的 API。</p> <h1 id="api-简介">API 简介</h1> <p>尽管关于 REST、GraphQL、JSON 和 XML API 的复杂性有无数书籍、演讲和指南,但它们的核心基于一个简单的概念。一个<em>API</em>,或者应用程序编程接口,定义了一种标准化的语法,允许一个软件件与另一个软件件通信,即使它们可能是用不同的语言编写或以其他方式不同结构化的。</p> <p>本节专注于 Web API(特别是允许 Web 服务器与浏览器通信的 API),并使用术语<em>API</em>特指这种类型。但是你可能想记住,在其他情境中,<em>API</em>也是一个通用术语,可以用来允许例如 Java 程序与同一台机器上运行的 Python 程序通信。API 并不总是“通过互联网”并且不一定涉及任何 Web 技术。</p> <p>Web API 最常被使用的是那些使用良好宣传和文档化的公共服务的开发人员。例如,美国国家气象局提供了一个<a href="https://www.weather.gov/documentation/services-web-api" target="_blank">weather API</a>,可以获取任何地点的当前天气数据和预报。Google 在其<a href="https://console.developers.google.com" target="_blank">开发者部分</a>提供了几十种 API,用于语言翻译、分析和地理位置。</p> <p>这些 API 的文档通常描述路由或<em>端点</em>,即你可以请求的 URL,带有可变参数,可以是 URL 路径中的一部分,也可以是<code>GET</code>参数。</p> <p>例如,以下示例将<code>pathparam</code>作为路由路径的参数提供:</p> <pre><code class="language-py">http://example.com/the-api-route/pathparam </code></pre> <p>而这个示例将<code>pathparam</code>作为参数<code>param1</code>的值提供:</p> <pre><code class="language-py">http://example.com/the-api-route?param1=pathparam </code></pre> <p>尽管像许多计算机科学中的主题一样,有关何时以及在哪里通过路径或参数传递变量的哲学辩论一直在进行,但两种通过 API 传递变量数据的方法经常被使用。</p> <p>API 的响应通常以 JSON 或 XML 格式返回。在现代,JSON 比 XML 更流行,但您仍然可能会看到一些 XML 响应。许多 API 允许您更改响应类型,使用另一个参数来定义您想要的响应类型。</p> <p>这里是一个 JSON 格式的 API 响应的示例:</p> <pre><code class="language-py">{"user":{"id": 123, "name": "Ryan Mitchell", "city": "Boston"}} </code></pre> <p>这里是一个 XML 格式的 API 响应的示例:</p> <pre><code class="language-py"><user><id>123</id><name>Ryan Mitchell</name><city>Boston</city></user> </code></pre> <p><a href="http://ip-api.com" target="_blank">ip-api.com</a> 提供了一个易于使用且简单的 API,将 IP 地址转换为实际物理地址。您可以尝试在浏览器中输入以下内容进行简单的 API 请求:¹</p> <pre><code class="language-py">http://ip-api.com/json/50.78.253.58 </code></pre> <p>这应该产生类似以下的响应:</p> <pre><code class="language-py">{"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"} </code></pre> <p>注意,请求路径中包含<code>json</code>参数。您可以通过相应地更改此参数来请求 XML 或 CSV 响应:</p> <pre><code class="language-py">http://ip-api.com/xml/50.78.253.58 http://ip-api.com/csv/50.78.253.58 </code></pre> <h2 id="http-方法和-api">HTTP 方法和 API</h2> <p>在前面的部分中,您看到了通过<code>GET</code>请求从服务器获取信息的 API。通过 HTTP,有四种主要的方式(或<em>方法</em>)来请求从 Web 服务器获取信息:</p> <ul> <li> <p><code>`GET`</code></p> </li> <li> <p><code>`POST`</code></p> </li> <li> <p><code>`PUT`</code></p> </li> <li> <p><code>`DELETE`</code></p> </li> </ul> <p>从技术上讲,还存在更多的方法(如<code>HEAD</code>、<code>OPTIONS</code>和<code>CONNECT</code>),但它们在 API 中很少使用,您几乎不太可能看到它们。绝大多数 API 限制自己使用这四种方法或这四种方法的子集。常见的是只看到使用<code>GET</code>,或者只看到使用<code>GET</code>和<code>POST</code>的 API。</p> <p>当您通过浏览器地址栏访问网站时,使用的是<code>GET</code>。当您像这样调用<a href="http://ip-api.com/json/50.78.253.58" target="_blank"><em>http://ip-api.com/json/50.78.253.58</em></a>时,您可以将<code>GET</code>视为说:“嘿,Web 服务器,请检索/获取我这些信息。”</p> <p>根据定义,<code>GET</code>请求不会对服务器数据库中的信息进行任何更改。不会存储任何内容;不会修改任何内容。只是读取信息。</p> <p><code>POST</code> 在您填写表单或提交信息时使用。每次登录网站时,您都在使用<code>POST</code>请求与您的用户名和(希望的话)加密密码。如果您使用 API 发送<code>POST</code>请求,则表示:“请将此信息存储在您的数据库中。”</p> <p><code>PUT</code> 在与网站交互时使用较少,但在 API 中有时会用到。<code>PUT</code> 请求用于更新对象或信息。例如,一个 API 可能需要用<code>POST</code>请求创建新用户,但如果要更新用户的电子邮件地址,则可能需要用<code>PUT</code>请求。²</p> <p><code>DELETE</code> 被用来删除对象,正如您可以想象的那样。例如,如果您向 <em><a href="http://example.com/user/23" target="_blank">http://example.com/user/23</a></em> 发送一个 <code>DELETE</code> 请求,它将删除 ID 为 23 的用户。<code>DELETE</code> 方法在公共 API 中并不常见,这些 API 主要用于传播信息或允许用户创建或发布信息,而不是允许用户从数据库中删除信息。</p> <p>与 <code>GET</code> 请求不同,<code>POST</code>、<code>PUT</code> 和 <code>DELETE</code> 请求允许您在请求的主体中发送信息,除了 URL 或路由外。</p> <p>就像您从 Web 服务器收到的响应一样,这些数据通常被格式化为 JSON 或者更少见的 XML,这些数据的格式由 API 的语法定义。例如,如果您正在使用一个用于在博客文章上创建评论的 API,您可能会发出 <code>PUT</code> 请求到:</p> <pre><code class="language-py">http://example.com/comments?post=123 </code></pre> <p>使用以下请求主体:</p> <pre><code class="language-py">{"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"}} </code></pre> <p>请注意,博客文章的 ID(<code>123</code>)作为 URL 的参数传递,您正在创建的新评论的内容则在请求的主体中传递。参数和数据可以同时传递到参数和主体中。哪些参数是必需的以及它们被传递的位置,再次由 API 的语法确定。</p> <h2 id="更多关于-api-响应的信息">更多关于 API 响应的信息</h2> <p>正如您在本章开头看到的 ip-api.com 示例中所见,API 的一个重要特性是它们具有良好格式化的响应。最常见的响应格式包括 <em>可扩展标记语言</em>(XML)和 <em>JavaScript 对象表示法</em>(JSON)。</p> <p>近年来,JSON 比 XML 更受欢迎的原因有几个主要因素。首先,良好设计的 XML 文件通常比 JSON 文件更小。例如,比较以下这段包含 98 个字符的 XML 数据:</p> <pre><code class="language-py"><user><firstname>Ryan</firstname><lastname>Mitchell</lastname><username>Kludgist </username></user> </code></pre> <p>现在看看相同的 JSON 数据:</p> <pre><code class="language-py">{"user":{"firstname":"Ryan","lastname":"Mitchell","username":"Kludgist"}} </code></pre> <p>这仅有 73 个字符,比等效的 XML 小了整整 36%。</p> <p>当然,有人可能会争辩说 XML 可以这样格式化:</p> <pre><code class="language-py"><user firstname="ryan" lastname="mitchell" username="Kludgist"></user> </code></pre> <p>但这被认为是一种不良做法,因为它不支持数据的深度嵌套。尽管如此,它仍然需要 71 个字符,大约与等效的 JSON 一样长。</p> <p>JSON 之所以比 XML 更受欢迎的另一个原因是由于 Web 技术的变化。过去,接收 API 的一端通常是服务器端脚本,如 PHP 或 .NET。如今,像 Angular 或 Backbone 这样的框架可能会发送和接收 API 调用。服务器端技术对数据的形式有些中立。但像 Backbone 这样的 JavaScript 库更容易处理 JSON。</p> <p>尽管 API 通常被认为具有 XML 响应或 JSON 响应,但任何事情都有可能。API 的响应类型仅受其创建者想象力的限制。CSV 是另一种典型的响应输出(如在 ip-api.com 示例中看到的)。某些 API 甚至可能设计用于生成文件。可以向服务器发送请求以生成带有特定文本覆盖的图像或请求特定的 XLSX 或 PDF 文件。</p> <p>有些 API 根本不返回任何响应。例如,如果您正在向服务器发出请求以创建新的博客文章评论,则它可能仅返回 HTTP 响应代码 200,意味着“我发布了评论;一切都很好!”其他可能返回类似这样的最小响应:</p> <pre><code class="language-py">{"success": true} </code></pre> <p>如果发生错误,可能会得到这样的响应:</p> <pre><code class="language-py">{"error": {"message": "Something super bad happened"}} </code></pre> <p>或者,如果 API 配置不是特别好,您可能会得到一个无法解析的堆栈跟踪或一些简单的英文文本。在向 API 发出请求时,通常最明智的做法是首先检查您收到的响应是否实际上是 JSON(或 XML、CSV 或您期望返回的任何其他格式)。</p> <h1 id="解析-json">解析 JSON</h1> <p>在本章中,您已经看过各种类型的 API 及其功能,并查看了这些 API 的示例 JSON 响应。现在让我们看看如何解析和使用这些信息。</p> <p>在本章开头,您看到了 ip-api.com API 的示例,该 API 将 IP 地址解析为物理地址:</p> <pre><code class="language-py">http://ip-api.com/json/50.78.253.58 </code></pre> <p>您可以使用 Python 的 JSON 解析函数解码此请求的输出:</p> <pre><code class="language-py">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')) </code></pre> <p>这将打印 IP 地址<code>50.78.253.58</code>的国家代码。</p> <p>Python 使用的 JSON 解析库是 Python 核心库的一部分。只需在顶部输入<code>import json</code>,一切就搞定了!与许多语言不同,可能将 JSON 解析为特殊的 JSON 对象或 JSON 节点,Python 使用了一种更灵活的方法,将 JSON 对象转换为字典,将 JSON 数组转换为列表,将 JSON 字符串转换为字符串等等。这样一来,访问和操作存储在 JSON 中的值就变得非常容易了。</p> <p>以下快速演示了 Python 的 JSON 库如何处理可能在 JSON 字符串中遇到的值:</p> <pre><code class="language-py">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')) </code></pre> <p>这是输出:</p> <pre><code class="language-py">[{'number': 0}, {'number': 1}, {'number': 2}] {'number': 1} 3 pear </code></pre> <p>第 1 行是字典对象的列表,第 2 行是字典对象,第 3 行是整数(访问字典中的整数的总和),第 4 行是字符串。</p> <h1 id="未记录的-api">未记录的 API</h1> <p>到目前为止,在本章中,我们只讨论了已记录的 API。它们的开发人员打算让它们被公众使用,发布有关它们的信息,并假设 API 将被其他开发人员使用。但是绝大多数 API 根本没有任何已发布的文档。</p> <p>但为什么要创建没有任何公共文档的 API 呢?正如本章开头提到的,这一切都与 JavaScript 有关。</p> <p>传统上,动态网站的 Web 服务器在用户请求页面时有几个任务:</p> <ul> <li> <p>处理用户请求网站页面的<code>GET</code>请求</p> </li> <li> <p>从出现在该页面上的数据库中检索数据</p> </li> <li> <p>为页面的 HTML 模板格式化数据</p> </li> <li> <p>将格式化后的 HTML 发送给用户</p> </li> </ul> <p>随着 JavaScript 框架的普及,许多由服务器处理的 HTML 创建任务移至了浏览器端。服务器可能会向用户的浏览器发送一个硬编码的 HTML 模板,但是会发起单独的 Ajax 请求来加载内容,并将其放置在 HTML 模板的正确位置。所有这些操作都会在浏览器/客户端上进行。</p> <p>这最初对 Web 爬虫构成了问题。他们习惯于请求一个 HTML 页面,并返回完全相同的内容已就绪的 HTML 页面。但是,现在他们得到的是一个 HTML 模板,没有任何内容。</p> <p>Selenium 用于解决这个问题。现在程序员的网络爬虫可以变成浏览器,请求 HTML 模板,执行任何 JavaScript,允许所有数据加载到位,然后仅<em>然后</em>再爬取页面数据。因为 HTML 已经全部加载,它基本上变成了一个先前解决过的问题——解析和格式化现有的 HTML 问题。</p> <p>然而,由于整个内容管理系统(原本只存在于 Web 服务器中)基本上已经移至浏览器客户端,即使是最简单的网站也可能膨胀到数兆字节的内容和十几个 HTTP 请求。</p> <p>此外,当使用 Selenium 时,所有用户可能不关心的“额外内容”都会被加载:调用跟踪程序,加载侧边栏广告,调用侧边栏广告的跟踪程序。图像、CSS、第三方字体数据——所有这些都需要加载。当您使用浏览器浏览网页时,这可能看起来很好,但是如果您正在编写一个需要快速移动、收集特定数据并尽可能减少对 Web 服务器负载的网络爬虫,则可能加载的数据量要比您需要的数据多 100 倍。</p> <p>但是,所有这些 JavaScript、Ajax 和 Web 现代化都有其积极的一面:因为服务器不再将数据格式化为 HTML,它们通常只是作为数据库本身的薄包装。这个薄包装只是从数据库中提取数据,并通过 API 返回到页面中。</p> <p>当然,这些 API 并不打算被除了网页本身之外的任何人或任何东西使用,因此开发人员将它们未记录,并假设(或希望)没有人会注意到它们。但是它们确实存在。</p> <p>例如,美国零售巨头 target.com 通过 JSON 加载其所有搜索结果。您可以通过访问<a href="https://www.target.com/s?searchTerm=web%20scraping%20with%20python" target="_blank"><em>https://www.target.com/s?searchTerm=web%20scraping%20with%20python</em></a>在他们的网站上搜索产品。</p> <p>如果您使用 urllib 或 Requests 库来爬取此页面,您将找不到任何搜索结果。这些结果是通过对 URL 的 API 调用单独加载的:</p> <pre><code class="language-py">https://redsky.target.com/redsky_aggregations/v1/web/plp_search_v2 </code></pre> <p>因为 Target 的 API 每个请求都需要一个密钥,并且这些 API 密钥会超时,我建议你自己试一试并查看 JSON 结果。</p> <p>当然,你可以使用 Selenium 加载所有搜索结果并解析生成的 HTML。不过,每次搜索将需要进行约 260 次请求并传输数兆字节的数据。直接使用 API,你只需发出一次请求并传输大约只有你所需的 10 KB 精美格式的数据。</p> <h2 id="查找未记录的-api">查找未记录的 API</h2> <p>在之前的章节中,你使用 Chrome 检查器来检查 HTML 页面的内容,但现在你将以稍微不同的目的使用它:来检查用于构建页面的调用的请求和响应。</p> <p>要做到这一点,请打开 Chrome 检查器窗口,点击 Network 选项卡,如图 15-1 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1501.png" alt="" loading="lazy"></p> <h6 id="图-15-1chrome-网络检查工具提供了浏览器正在进行和接收的所有调用的视图">图 15-1。Chrome 网络检查工具提供了浏览器正在进行和接收的所有调用的视图。</h6> <p>注意,在页面加载之前需要打开此窗口。关闭窗口时不会跟踪网络调用。</p> <p>在页面加载时,每当浏览器向 Web 服务器发出调用以获取渲染页面所需的附加信息时,你将实时看到一条线。这可能包括 API 调用。</p> <p>找到未记录的 API 可能需要一些侦探工作(为了避免这种侦探工作,请参见“记录未记录的 API”),特别是在具有大量网络调用的大型站点中。不过,一般来说,一旦看到它,你就会知道。</p> <p>API 调用往往具有几个有助于在网络调用列表中定位它们的特征:</p> <ul> <li> <p>它们通常包含 JSON 或 XML。你可以使用搜索/过滤字段来过滤请求列表。</p> </li> <li> <p>使用<code>GET</code>请求,URL 将包含传递给它们的参数值。例如,如果你正在寻找一个 API 调用,返回搜索结果或加载特定页面的数据,那么这将非常有用。只需用你使用的搜索词、页面 ID 或其他标识信息来过滤结果。</p> </li> <li> <p>它们通常是 XHR 类型。</p> </li> </ul> <p>API 可能并不总是显而易见,特别是在具有大量功能的大型网站中,可能在加载单个页面时会进行数百次调用。然而,通过一点实践,就能更轻松地找到草堆中的比喻性针。</p> <h2 id="记录未记录的-api">记录未记录的 API</h2> <p>当你发现正在进行的 API 调用时,通常有必要至少部分记录它,特别是如果你的爬虫将严重依赖该调用。你可能希望在网站上加载多个页面,在检查器控制台的网络选项卡中过滤目标 API 调用。通过这样做,你可以看到调用在不同页面之间的变化,并确定它接受和返回的字段。</p> <p>每个 API 调用都可以通过关注以下字段来识别和记录:</p> <ul> <li> <p>使用的 HTTP 方法</p> </li> <li> <p>输入</p> <ul> <li> <p>路径参数</p> </li> <li> <p>标头(包括 cookie)</p> </li> <li> <p>正文内容(用于<code>PUT</code>和<code>POST</code>调用)</p> </li> </ul> </li> <li> <p>输出</p> <ul> <li> <p>响应头(包括设置的 cookie)</p> </li> <li> <p>响应体类型</p> </li> <li> <p>响应体字段</p> </li> </ul> </li> </ul> <h1 id="将-api-与其他数据源结合使用">将 API 与其他数据源结合使用</h1> <p>尽管许多现代 Web 应用程序的存在理由是获取现有数据并以更吸引人的方式格式化它,但我认为在大多数情况下这并不是一个有趣的事情。如果你只使用 API 作为数据源,那么你所能做的就是简单地复制已经存在并基本上已经发布的别人的数据库。更有趣的可能是以新颖的方式结合两个或更多数据源,或者使用 API 作为一个工具来从新的角度查看抓取的数据。</p> <p>让我们来看一个例子,说明如何将 API 中的数据与网络抓取结合使用,以查看哪些地区对维基百科贡献最多。</p> <p>如果你在维基百科上花了很多时间,你可能已经见过文章的修订历史页面,显示了最近的编辑列表。如果用户在编辑时已登录维基百科,会显示他们的用户名。如果未登录,则记录其 IP 地址,如图 15-2 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1502.png" alt="Alt Text" loading="lazy"></p> <h6 id="图15-2-维基百科-python-条目修订历史页面上匿名编辑者的-ip-地址">图 15-2. 维基百科 Python 条目修订历史页面上匿名编辑者的 IP 地址</h6> <p>在历史页面上提供的 IP 地址是 121.97.110.145。根据目前的信息,使用 ip-api.com API,该 IP 地址来自菲律宾卡尼洛,菲律宾(IP 地址有时会在地理位置上略微变动)。</p> <p>这些信息单独来看并不那么有趣,但是如果你能够收集关于维基百科编辑及其发生地点的许多地理数据,会怎么样呢?几年前,我确实做到了,并使用<a href="https://developers.google.com/chart/interactive/docs/gallery/geochart" target="_blank">Google 的 GeoChart 库</a>创建了一个<a href="http://www.pythonscraping.com/pages/wikipedia.html" target="_blank">有趣的图表</a>,展示了对英语维基百科以及其他语言维基百科的编辑来源(图 15-3)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1503.png" alt="Alt Text" loading="lazy"></p> <h6 id="图15-3-使用-google-的-geochart-库创建的维基百科编辑可视化">图 15-3. 使用 Google 的 GeoChart 库创建的维基百科编辑可视化</h6> <p>创建一个基本的脚本来爬取维基百科,查找修订历史页面,然后在这些修订历史页面上查找 IP 地址并不难。使用修改自第六章的代码,以下脚本正是如此:</p> <pre><code class="language-py">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) </code></pre> <p>该程序使用两个主要函数:<code>getLinks</code>(在第六章中也使用过),以及新的<code>getHistoryIPs</code>,后者搜索所有带有类<code>mw-anonuserlink</code>的链接内容(指示使用 IP 地址而不是用户名的匿名用户)并将其作为集合返回。</p> <p>这段代码还使用了一个有些任意的(但对于本例来说很有效)搜索模式来查找要检索修订历史记录的文章。它首先检索由起始页面链接到的所有维基百科文章的历史记录(在本例中是关于 Python 编程语言的文章)。之后,它会随机选择一个新的起始页面,并检索该页面链接到的所有文章的修订历史页面。它会一直持续下去,直到碰到没有链接的页面。</p> <p>现在您已经有了检索 IP 地址作为字符串的代码,您可以将其与上一节中的<code>getCountry</code>函数结合使用,将这些 IP 地址解析为国家。您将稍微修改<code>getCountry</code>以处理导致 404 Not Found 错误的无效或格式不正确的 IP 地址:</p> <pre><code class="language-py">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) </code></pre> <p>这是示例输出:</p> <pre><code class="language-py">-------------------- 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 </code></pre> <h1 id="关于-api-的更多信息">关于 API 的更多信息</h1> <p>本章展示了现代 API 通常用于访问网络数据的几种方式,以及这些 API 如何用于构建更快更强大的网络爬虫。如果您想构建 API 而不仅仅是使用它们,或者如果您想进一步了解它们的构建和语法理论,我推荐阅读<a href="http://bit.ly/RESTful-Web-APIs" target="_blank">《RESTful Web APIs》</a>,作者是 Leonard Richardson、Mike Amundsen 和 Sam Ruby(O’Reilly)。这本书提供了关于在网络上使用 API 的理论和实践的强大概述。此外,Mike Amundsen 还有一系列有趣的视频,<a href="http://oreil.ly/1GOXNhE" target="_blank"><em>《Designing APIs for the Web》</em></a>(O’Reilly),教你如何创建自己的 API——如果您决定以方便的格式向公众提供您的抓取数据,这是一个有用的技能。</p> <p>虽然有些人可能对 JavaScript 和动态网站的无处不在感到惋惜,使得传统的“抓取和解析 HTML 页面”的做法已经过时,但我对我们的新机器人统治者表示欢迎。随着动态网站对 HTML 页面的人类消费依赖程度降低,更多地依赖于严格格式化的 JSON 文件进行 HTML 消费,这为每个试图获取干净、格式良好的数据的人提供了便利。</p> <p>网络已不再是偶尔带有多媒体和 CSS 装饰的 HTML 页面集合。它是数百种文件类型和数据格式的集合,一次传输数百个,形成您通过浏览器消耗的页面。真正的诀窍通常是超越你面前的页面,抓取其源头的数据。</p> <p>¹ 这个 API 将 IP 地址解析为地理位置,您稍后在本章中也将使用它。</p> <p>² 实际上,许多 API 在更新信息时使用<code>POST</code>请求代替<code>PUT</code>请求。新实体是创建还是仅更新一个旧实体通常取决于 API 请求本身的结构。但是,了解区别仍然是很重要的,您经常会在常用 API 中遇到<code>PUT</code>请求。</p> <h1 id="第十六章图像处理与文本识别">第十六章:图像处理与文本识别</h1> <p>从谷歌的自动驾驶汽车到能够识别假钞的自动售货机,机器视觉是一个具有深远目标和影响的广阔领域。本章专注于该领域的一个小方面:文本识别——具体而言,如何利用各种 Python 库识别和使用在线找到的基于文本的图像。</p> <p>当你不希望文本被机器人发现和阅读时,使用图像代替文本是一种常见的技术。这在联系表单上经常见到,当电子邮件地址部分或完全呈现为图像时。取决于执行的技巧如何,这甚至可能对人类观众不可察觉,但机器人很难读取这些图像,这种技术足以阻止大多数垃圾邮件发送者获取您的电子邮件地址。</p> <p>当然,CAPTCHA 利用了用户能够阅读安全图像而大多数机器人不能的事实。一些 CAPTCHA 比其他更难,这是我们将在本书后面解决的问题。</p> <p>但 CAPTCHA 并不是网络上唯一需要图像转文本翻译帮助的地方。甚至很多文档都是从硬拷贝扫描并放在网络上,这使得这些文档对大部分互联网用户而言是无法访问的,尽管它们就在“人们的视线之中”。没有图像转文本的能力,唯一的方法是让人类手动输入它们,但谁有时间做这件事呢。</p> <p>将图像转换为文本称为<em>光学字符识别</em>(OCR)。一些主要的库可以执行 OCR,许多其他库支持它们或构建在它们之上。这些库体系相当复杂,因此建议您在尝试本章中的任何练习之前先阅读下一节。</p> <p>本章中使用的所有示例图像都可以在 GitHub 仓库文件夹<em>Chapter16_ImageProcessingFiles</em>中找到。为简洁起见,所有文中代码示例将简称为<em>files</em>目录。</p> <h1 id="库概述">库概述</h1> <p>Python 是处理图像和阅读、基于图像的机器学习甚至图像创建的绝佳语言。虽然有许多库可用于图像处理,但我将专注于两个:Pillow 和 Tesseract。</p> <p>当处理并对来自网络的图像进行 OCR 时,这两个库组成了一个强大的互补二重奏。<em>Pillow</em>执行第一次清理和过滤图像,<em>Tesseract</em>则尝试将这些图像中找到的形状与其已知文本库进行匹配。</p> <p>本章涵盖了它们的安装和基本使用,以及这两个库一起工作的几个示例。我还将介绍一些高级的 Tesseract 训练,以便您可以训练 Tesseract 识别您可能在网络上遇到的额外字体和语言(甚至是 CAPTCHA)。</p> <h2 id="pillow">Pillow</h2> <p>尽管 Pillow 可能不是最全功能的图像处理库,但它具有您可能需要的所有功能,甚至更多——除非您计划用 Python 重写 Photoshop,否则您读的不是这本书!Pillow 还有一个优点,即是其中一些更好文档化的第三方库之一,并且非常容易上手使用。</p> <p>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:</p> <pre><code class="language-py">from PIL import Image, ImageFilter kitten = Image.open('kitten.jpg') blurryKitten = kitten.filter(ImageFilter.GaussianBlur) blurryKitten.save('kitten_blurred.jpg') blurryKitten.show() </code></pre> <p>在上面的例子中,<em>kitten.jpg</em> 图像将在您默认的图像查看器中打开,并添加模糊效果,同时也会保存为同一目录下更模糊的 <em>kitten_blurred.jpg</em>。</p> <p>您将使用 Pillow 对图像执行预处理,使其更易于机器读取,但正如前面提到的,您也可以使用该库进行许多其他操作,而不仅仅是这些简单的滤镜应用。欲了解更多信息,请查看 <a href="http://pillow.readthedocs.org" target="_blank">Pillow 文档</a>。</p> <h2 id="tesseract">Tesseract</h2> <p>Tesseract 是一个 OCR 库。由 Google 赞助(一个显然以其 OCR 和机器学习技术而闻名的公司),Tesseract 被普遍认为是目前最好、最准确的开源 OCR 系统。</p> <p>除了准确外,它还非常灵活。它可以被训练来识别任何数量的字体(只要这些字体在自身内部相对一致,您很快就会看到)。它还可以扩展到识别任何 Unicode 字符。</p> <p>本章同时使用命令行程序 <em>Tesseract</em> 及其第三方 Python 包装 <em>pytesseract</em>。这两者将明确命名为其中的一个,所以当您看到 Tesseract 时,我指的是命令行软件,当您看到 pytesseract 时,我特指它的第三方 Python 包装。</p> <h3 id="安装-tesseract">安装 Tesseract</h3> <p>对于 Windows 用户,有一个方便的 <a href="https://code.google.com/p/tesseract-ocr/downloads/list" target="_blank">可执行安装程序</a>。截至目前,当前版本是 3.02,尽管新版本也应该是可以的。</p> <p>Linux 用户可以使用 <code>apt-get</code> 安装 Tesseract:</p> <pre><code class="language-py">$ sudo apt-get tesseract-ocr </code></pre> <p>在 Mac 上安装 Tesseract 稍微复杂一些,但可以通过许多第三方安装程序轻松完成,例如 <a href="http://brew.sh" target="_blank">Homebrew</a>,它在 第九章 中用于安装 MySQL。例如,您可以安装 Homebrew 并使用它在两行命令中安装 Tesseract:</p> <pre><code class="language-py">$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/\ HEAD/install.sh)" $ brew install tesseract </code></pre> <p>Tesseract 也可以从源代码安装,在<a href="https://code.google.com/p/tesseract-ocr/downloads/list" target="_blank">项目的下载页面</a>。</p> <p>要将图像转换为文本,Tesseract 使用在各种语言(或字符集)的大型数据集上训练过的机器学习模型。要查看安装的可用模型,请使用以下命令:</p> <pre><code class="language-py">$ tesseract --list-langs </code></pre> <p>这将打印存储模型的目录(在 Linux 上是<em>/usr/local/share</em>,在使用 HomeBrew 安装的 Mac 上是<em>/opt/homebrew/share/tessdata/</em>),以及可用的模型。</p> <p>安装了 Tesseract 后,您可以准备安装 Python 包装库 pytesseract,它使用您现有的 Tesseract 安装来读取图像文件并输出可在 Python 脚本中使用的字符串和对象。</p> <p>如往常一样,您可以通过 pip 安装 pytesseract:</p> <pre><code class="language-py">$ pip install pytesseract </code></pre> <p>Pytesseract 可以与 PIL 结合使用从图像中读取文本:</p> <pre><code class="language-py">from PIL import Image import pytesseract print(pytesseract.image_to_string(Image.open('files/test.png'))) </code></pre> <p>如果 pytesseract 无法识别您是否已安装了 Tesseract,则可以使用以下命令获取您的 Tesseract 安装位置:</p> <pre><code class="language-py">$ which tesseract </code></pre> <p>并且在 Python 中,通过包含这一行来指定 pytesseract 的位置:</p> <pre><code class="language-py">pytesseract.pytesseract.tesseract_cmd = '/path/to/tesseract' </code></pre> <p>Pytesseract 除了像上面代码示例中返回图像 OCR 结果之外,还有几个有用的功能。它可以估算框文件(每个字符边界的像素位置):</p> <pre><code class="language-py">print(pytesseract.image_to_boxes(Image.open('files/test.png'))) </code></pre> <p>它还可以返回所有数据的完整输出,如置信度分数、页数和行数、框数据以及其他信息:</p> <pre><code class="language-py">print(pytesseract.image_to_data(Image.open('files/test.png'))) </code></pre> <p>这两个文件的默认输出为以空格或制表符分隔的字符串文件,但您也可以将输出作为字典或(如果 UTF-8 解码不够用)字节字符串获取:</p> <pre><code class="language-py">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)) </code></pre> <p>本章节同时使用了 pytesseract 库和通过<code>subprocess</code>库从 Python 触发 Tesseract 的命令行 Tesseract。虽然 pytesseract 库很有用且方便,但它无法完成一些 Tesseract 函数,因此熟悉所有方法是很好的。</p> <h2 id="numpy">NumPy</h2> <p>虽然 NumPy 对于简单的 OCR 并非必需,但如果您想要在本章后面介绍的训练 Tesseract 识别额外字符集或字体,您将需要它。您还将在本章后面的某些代码示例中使用它进行简单的数学任务(如加权平均数)。</p> <p>NumPy 是用于线性代数和其他大规模数学应用的强大库。NumPy 与 Tesseract 配合良好,因为它能够将图像数学地表示为大型像素数组并进行操作。</p> <p>NumPy 可以通过任何第三方 Python 安装器如 pip 来安装,或者通过<a href="https://pypi.python.org/pypi/numpy" target="_blank">下载软件包</a>并使用<code>$ python setup.py install</code>进行安装。</p> <p>即使您不打算运行使用它的代码示例,我强烈建议您安装它或将其添加到 Python 工具库中。它有助于完善 Python 的内置数学库,并具有许多有用的特性,特别是对于操作数字列表。</p> <p>按照惯例,NumPy 作为<code>np</code>导入,并且可以如下使用:</p> <pre><code class="language-py">import numpy as np numbers = [100, 102, 98, 97, 103] print(np.std(numbers)) print(np.mean(numbers)) </code></pre> <p>此示例打印了提供给它的数字集的标准差和均值。</p> <h1 id="处理格式良好的文本">处理格式良好的文本</h1> <p>幸运的话,大多数需要处理的文本应该相对干净且格式良好。格式良好的文本通常符合几个要求,尽管“混乱”和“格式良好”之间的界限可能是主观的。</p> <p>总的来说,格式良好的文本</p> <ul> <li> <p>以一种标准字体书写(不包括手写字体、草书字体或过度装饰的字体)</p> </li> <li> <p>如果复制或拍摄,具有极其清晰的线条,没有复制伪影或黑斑</p> </li> <li> <p>对齐良好,没有倾斜的字母</p> </li> <li> <p>不会跑到图像之外,也没有截断的文本或图像边缘的边距</p> </li> </ul> <p>其中一些问题可以在预处理中修复。例如,图像可以转换为灰度,亮度和对比度可以调整,根据需要可以裁剪和旋转图像。但是,某些基本限制可能需要更广泛的训练。请参阅“阅读 CAPTCHA 和训练 Tesseract”。</p> <p>图 16-1 是格式良好文本的理想示例。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1601.png" alt="Alt Text" loading="lazy"></p> <h6 id="图16-1-保存为tiff-文件以供-tesseract-读取的示例文本">图 16-1. 保存为.tiff 文件以供 Tesseract 读取的示例文本</h6> <p>在<em>files</em>目录中,您可以从命令行运行 Tesseract 来读取此文件并将结果写入文本文件:</p> <pre><code class="language-py">$ tesseract text.png textoutput $ cat textoutput.txt </code></pre> <p>输出包含新创建的<em>textoutput.txt</em>文件的内容:</p> <pre><code class="language-py">This is some text, written in Arial, that will be read by Tesseract. Here are some symbols: !|@#$%&*() </code></pre> <p>您可以看到结果大多是准确的,尽管它在<code>!</code>和<code>@</code>之间添加了额外的竖线字符。总体而言,这使您能够相当舒适地阅读文本。</p> <p>在模糊图像文本、创建一些 JPG 压缩伪影和添加轻微背景渐变后,Tesseract 的结果变得更糟(见图 16-2)。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1602.png" alt="Alt Text" loading="lazy"></p> <h6 id="图16-2-不幸的是您在互联网上遇到的许多文档更像是这种情况而不是前面的例子">图 16-2. 不幸的是,您在互联网上遇到的许多文档更像是这种情况,而不是前面的例子</h6> <p>而不是将结果写入文件,您还可以在文件名通常出现的地方传递一个破折号(<code>-</code>),Tesseract 将结果回显到终端:</p> <pre><code class="language-py">$ tesseract text_bad.png - </code></pre> <p>Tesseract 由于背景渐变的原因无法处理此图像,因此产生了以下输出:</p> <pre><code class="language-py">This is some text, written In Arlal, that" Tesseract. Here are some symbols: _ </code></pre> <p>请注意,一旦背景渐变使文本更难以区分,文本就会被截断,并且每行的最后一个字符都是错误的,因为 Tesseract 试图徒劳地理解它。此外,JPG 伪影和模糊使得 Tesseract 难以区分小写字母<em>i</em>和大写字母<em>I</em>以及数字<em>1</em>。</p> <p>在这里,使用 Python 脚本首先清理图像非常方便。使用 Pillow 库,您可以创建一个阈值滤镜来去除背景中的灰色,突出文本,并使图像更清晰,以便 Tesseract 读取。</p> <p>此外,您可以使用 pytesseract 库而不是从命令行使用 Tesseract 来运行 Tesseract 命令并读取生成的文件:</p> <pre><code class="language-py">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)) </code></pre> <p>结果图像被自动创建为 <em>text_cleaned.png</em>,如 图 16-3 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1603.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-16-3通过将图像的前一混乱版本通过阈值过滤器进行处理而创建的图像">图 16-3。通过将图像的前一“混乱”版本通过阈值过滤器进行处理而创建的图像</h6> <p>除了一些几乎难以辨认或缺失的标点符号外,文本是可读的,至少对我们来说是这样。Tesseract 尽力而为:</p> <pre><code class="language-py">This is some text, written In Anal, that will be read by Tesseract Here are some symbols: !@#$%"&'() </code></pre> <p>逗号和句号非常小,是图像整理的首要受害者,几乎从我们的视野和 Tesseract 的视野中消失。还有不幸的是,Tesseract 将“Arial”误解为“Anal”,这是 Tesseract 将 <em>r</em> 和 <em>i</em> 解释为单个字符 <em>n</em> 的结果。</p> <p>尽管如此,它仍然比之前的版本有所改进,其中近一半的文本被切掉。</p> <p>Tesseract 最大的弱点似乎是背景亮度不均。Tesseract 的算法在读取文本之前尝试自动调整图像的对比度,但使用类似 Pillow 库这样的工具可能会获得更好的结果。</p> <p>提交给 Tesseract 之前绝对需要修复的图像包括倾斜的图像、有大量非文本区域或其他问题的图像。</p> <h2 id="自动调整图像">自动调整图像</h2> <p>在前面的例子中,值 143 被实验性地选择为将所有图像像素调整为黑色或白色以便 Tesseract 读取图像的“理想”阈值。但是,如果您有许多图像,所有图像都有稍有不同的灰度问题,并且无法合理地手动调整所有图像,那该怎么办?</p> <p>找到最佳解决方案(或至少是相当不错的解决方案)的一种方法是对一系列调整到不同值的图像运行 Tesseract,并通过某种组合来选择最佳结果,这些组合包括 Tesseract 能够读取的字符和/或字符串的数量以及它读取这些字符的“置信度”。</p> <p>您使用的确切算法可能因应用程序而异,但以下是通过图像处理阈值进行迭代以找到“最佳”设置的一个示例:</p> <pre><code class="language-py">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])) </code></pre> <p>该脚本有两个功能:</p> <p><code>cleanFile</code></p> <p>接收原始的“坏”文件和一个阈值变量以运行 PIL 阈值工具。它处理文件并返回 PIL 图像对象。</p> <p><code>getConfidence</code></p> <p>接收清理后的 PIL 图像对象并将其传递给 Tesseract。它计算每个识别字符串的平均置信度(按该字符串中的字符数加权),以及识别字符的数量。</p> <p>通过改变阈值并在每个值上获取识别字符的置信度和数量,您可以得到输出:</p> <pre><code class="language-py">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 </code></pre> <p>无论是结果中的平均置信度还是识别字符的数量,都显示出明显的趋势。两者都倾向于在阈值约为 145 时达到峰值,这接近手动找到的“理想”结果 143。</p> <p>140 和 145 的阈值都给出了最大数量的识别字符(83 个),但是 145 的阈值为这些找到的字符提供了最高的置信度,因此您可能希望选择该结果,并返回在该阈值下被识别为图像包含的文本的“最佳猜测”。</p> <p>当然,仅仅找到“最多”字符并不一定意味着所有这些字符都是真实的。在某些阈值下,Tesseract 可能会将单个字符拆分为多个字符,或者将图像中的随机噪声解释为实际不存在的文本字符。在这种情况下,您可能更倾向于更重视每个评分的平均置信度。</p> <p>例如,如果你找到的结果读取(部分):</p> <pre><code class="language-py">threshold: 145, confidence: 75.5662650602 numChars 83 threshold: 150, confidence: 97.1234567890 numChars 82 </code></pre> <p>如果结果让您的置信度增加超过 20%,仅丢失一个字符,并假设 145 的阈值结果仅仅是不正确的,或者可能分割一个字符或者找到了不存在的东西,那么选择该结果可能是个明智的选择。</p> <p>这是某些前期实验用于完善您的阈值选择算法可能会派上用场的部分。例如,您可能希望选择其置信度和字符数的<em>乘积</em>最大化的得分(在本例中,145 仍以 6272 的产品获胜,在我们的想象例子中,阈值 150 以 7964 的产品获胜),或者其他某种度量。</p> <p>请注意,此类选择算法除了仅限于<code>threshold</code>之外,还适用于任意 PIL 工具值。您还可以通过改变每个值的值来选择两个或多个值,并以类似的方式选择最佳结果分数。</p> <p>显然,这种选择算法在计算上是非常密集的。您在每张图片上都要运行 PIL 和 Tesseract 多次,而如果您事先知道“理想”的阈值值,您只需运行它们一次。</p> <p>请记住,当您开始处理的图像时,您可能会开始注意到找到的“理想”值中的模式。而不是尝试从 80 到 200 的每个阈值,您可能实际上只需要尝试从 130 到 180 的阈值。</p> <p>您甚至可以采用另一种方法,并选择首次通过时间间隔为 20 的阈值,然后使用贪心算法在前一次迭代中找到的“最佳”解决方案之间减小您的阈值步长,以获得最佳结果。当您处理多个变量时,这种方法可能也是最佳的。</p> <h2 id="从网站上的图像中抓取文本">从网站上的图像中抓取文本</h2> <p>使用 Tesseract 从硬盘上的图像中读取文本可能并不那么令人兴奋,但是当与网页抓取器一起使用时,它可以成为一个强大的工具。图片在网站上可能会无意中混淆文本(例如在本地餐馆网站上的菜单的 JPG 副本),但它们也可以有意地隐藏文本,正如我将在下一个例子中展示的那样。</p> <p>尽管亚马逊的 <em>robots.txt</em> 文件允许爬取其产品页面,但书籍预览通常不会被通过的爬虫所捕捉到。这是因为书籍预览是通过用户触发的 Ajax 脚本加载的,图像被精心隐藏在多层的 div 和 iframe 中。当然,即使你能访问这些图像,还有一个不小的问题是将它们作为文本进行阅读。</p> <p>以下脚本就实现了这一壮举:它导航到托尔斯泰的大字版《伊凡·伊里奇之死》,打开阅读器,收集图像网址,然后系统地从每一个图像中下载、阅读和打印文本。</p> <h1 id="选择一个测试主题">选择一个测试主题</h1> <p>当涉及到处理它未经训练的字体时,Tesseract 在处理大格式的书籍版本时表现得更好,特别是如果图像较小。下一节将介绍如何训练 Tesseract 以识别不同字体,这可以帮助它读取包括非大字版书籍预览在内的更小字号!</p> <p>请注意,此代码依赖于亚马逊上的实时列表以及亚马逊网站的几个架构特性才能正确运行。如果此列表下架或更换,请随时用另一本具有预览功能的书籍 URL 进行替换(我发现大字版和无衬线字体效果良好)。</p> <p>因为这是一个相对复杂的代码,整合了前几章的多个概念,我在整个过程中添加了注释,以便更容易理解正在进行的操作:</p> <pre><code class="language-py"># 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() </code></pre> <p>尽管理论上这个脚本可以使用任何类型的 Selenium webdriver 运行,但我发现它目前与 Chrome 一起工作最为可靠。</p> <p>正如你之前使用 Tesseract 阅读器时所经历的那样,它能够基本上清晰地打印出书籍的许多长段落,就像在第一章的预览中所看到的那样:</p> <pre><code class="language-py">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!” </code></pre> <p>大字版和无衬线字体确保了图像的无误转录。在转录中可能出现错误的情况下,可以通过基于字典单词列表的猜测进行修正(也许还可以根据相关专有名词如“Melvinski”进行补充)。</p> <p>有时候,错误可能会涵盖整个单词,比如文本第三页上的情况:</p> <pre><code class="language-py">it is he who is dead and not 1. </code></pre> <p>在这种情况下,“I” 这个单词被字符 “1” 替换了。在这里,马尔科夫链分析可能会有所帮助,除了一个单词字典之外。如果文本的任何部分包含一个极不常见的短语(“and not 1”),则可以假设该文本实际上是更常见的短语(“and not I”)。</p> <p>当然,这些字符替换遵循可预测的模式是有帮助的:“vi” 变成了 “w”,“I” 变成了 “1”。如果你的文本中这些替换经常发生,你可以创建一个列表,用来“尝试”新词和短语,选择最合理的解决方案。一种方法可能是替换频繁混淆的字符,并使用与字典中的词匹配的解决方案,或者是一个被认可的(或最常见的)n-gram。</p> <p>如果您选择这种方法,请务必阅读第十二章以获取有关处理文本和自然语言处理的更多信息。</p> <p>尽管此示例中的文本是常见的无衬线字体,Tesseract 应该能够相对容易地识别它,有时稍微重新训练也有助于提高准确性。下一节将讨论另一种解决错乱文本问题的方法,需事先投入一些时间。</p> <p>通过为 Tesseract 提供大量具有已知值的文本图像集合,Tesseract 可以“学习”以便在将来更精确和准确地识别相同字体,即使文本中偶尔存在背景和位置问题。</p> <h1 id="阅读-captcha-并训练-tesseract">阅读 CAPTCHA 并训练 Tesseract</h1> <p>尽管大多数人熟悉 <em>CAPTCHA</em> 这个词,但很少有人知道它代表什么:<em>Completely Automated Public Turing Test to Tell Computers and Humans Apart</em>。它笨重的首字母缩略语暗示了它在阻碍本应完全可用的网络界面中的作用,因为人类和非人类机器人经常难以解决 CAPTCHA 测试。</p> <p>图灵测试是由艾伦·图灵在其 1950 年的论文《计算机机器与智能》中首次描述的。在这篇论文中,他描述了一个理论情景,其中一个人可以通过计算机终端与人类和人工智能程序进行交流。如果在随意对话中,人类无法区分人类和 AI 程序,那么 AI 程序被认为通过了图灵测试。图灵推理认为,从所有意图和目的来看,人工智能会真正地“思考”。</p> <p>在图灵测试理论提出 70 年后,如今 CAPTCHA 主要用于激怒人类而不是机器。2017 年,Google 关闭了其标志性的 reCAPTCHA,这在很大程度上是因为它倾向于阻止合法的网站用户。¹(参见图 16-4 的例子。)许多其他公司也效仿,用替代性的防机器人程序替换传统的基于文本的 CAPTCHA。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1604.png" alt="" loading="lazy"></p> <h6 id="图-16-4-google-recaptcha-的文本2017-年之前">图 16-4. Google reCAPTCHA 的文本,2017 年之前</h6> <p>尽管 CAPTCHA 的流行度有所下降,但它们仍然常用,尤其是在较小的网站上。它们还可以作为计算机阅读样本“困难”文本的来源。也许您的目标不是解决 CAPTCHA,而是阅读扫描不良的 PDF 或手写笔记。但原则是相同的。</p> <p>鉴于此,我创建了一个表单,机器人“被阻止”提交,因为它需要解决一个 CAPTCHA:<a href="https://pythonscraping.com/humans-only/" target="_blank"><em>https://pythonscraping.com/humans-only/</em></a>。在这一部分中,您将训练 Tesseract 库以识别其特定字体和文本变化,以便高可靠性地解决此 CAPTCHA。</p> <p>如果您是机器人并且难以阅读此图像,“U8DG” 是图 16-5 中 CAPTCHA 的解决方案。作为机器人的 Tesseract 当然难以解决它。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1605.png" alt="图片" loading="lazy"></p> <h6 id="图-16-5-防机器人验证码位于httpspythonscrapingcomhumans-only">图 16-5. 防机器人验证码位于<a href="https://pythonscraping.com/humans-only/" target="_blank"><em>https://pythonscraping.com/humans-only/</em></a></h6> <pre><code class="language-py">$ tesseract U8DG.png - u& DS </code></pre> <p>在这种情况下,Tesseract 返回五个字符(包括一个空格),并且只正确识别了一个字符,大写的 D。</p> <p>问题不在于 Tesseract 读取文本的能力差,或者这个验证码对计算机来说过于复杂——而是这种手写字体与 Tesseract "开箱即用" 的常规英文字体不同。幸运的是,可以训练它识别额外的字体、字符和语言。</p> <h2 id="训练-tesseract">训练 Tesseract</h2> <p>无论您是为验证码还是任何其他文本进行训练,都有几个因素需要考虑,这些因素会极大地影响 Tesseract 的性能以及您训练的方法:</p> <ul> <li> <p>字符是否在图像中重叠,或者您是否可以在每个字符周围画出整齐的矩形而不会有其他字符的部分侵犯这个矩形?</p> </li> <li> <p>文本中是否存在多种字体或书写风格的变体,还是仅使用单一字体?</p> </li> <li> <p>图像中是否有任何背景图像、线条或其他分散注意力的垃圾?</p> </li> <li> <p>字符之间是否有高对比度,字符与背景之间是否有清晰的边界?</p> </li> <li> <p>字体是否是比较标准的有衬线或无衬线字体,还是具有随机元素和“手写”风格的不寻常字体?</p> </li> </ul> <p>如果某些文本样本中字符有重叠,您可以考虑仅使用没有重叠的文本样本。如果每个文本样本都有重叠,则考虑在训练之前进行预处理以分离字符。</p> <h3 id="爬取和准备图像">爬取和准备图像</h3> <p>预处理有助于去除任何背景垃圾,并改善图像中字符的颜色、对比度和分离度。</p> <h1 id="需要多少图像">需要多少图像?</h1> <p>您应该获取多少图像?我建议每个字符大约有 10 个示例,如果您的文本有高变异性或随机性,则更多。Tesseract 偶尔会丢弃文件,例如由于重叠的框或其他神秘的原因,因此您可能希望有一些额外的缓冲空间。如果发现您的 OCR 结果不如预期,或者 Tesseract 在某些字符上出现问题,创建额外的训练数据并再次尝试是一个良好的调试步骤。</p> <p>此外,如果同一文本样本中存在多种字体变体,或者涉及其他变体(随机倾斜或混淆文本),您可能需要更多的训练数据。</p> <p>如果字体比较标准且没有其他严重的复杂因素,请确保先尝试使用 Tesseract 而不需额外训练!没有训练的情况下,性能可能已经满足您的需求,而训练可能是非常耗时的过程。</p> <p>训练需要向 Tesseract 提供至少每个您希望其能够识别的字符的几个示例。以下内容下载了包含四个字符的每个样本 CAPTCHA 图像的 100 个示例,共计 400 个字符样本:</p> <pre><code class="language-py">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]}')     </code></pre> <p>在审查下载的训练图像之后,现在是决定是否需要进行任何预处理的时候了。这些 CAPTCHA 图像中的文本为灰色,背景为黑色。您可以编写一个<em>cleanImage</em>函数,将其转换为白色背景上的黑色文本,并添加白色边框,以确保每个字符与图像边缘分离:</p> <pre><code class="language-py">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}') </code></pre> <h3 id="使用-tesseract-训练项目创建框文件">使用 Tesseract 训练项目创建框文件</h3> <p>接下来,您需要使用这些清理过的图像来创建<em>框文件</em>。框文件包含图像中每个字符占据一行,后跟该字符的边界框坐标。例如,包含字符“AK6F”的 CAPTCHA 图像可能具有相应的框文件:</p> <pre><code class="language-py">A 32 34 54 58 K 66 32 91 56 6 101 34 117 57 F 135 32 156 57 </code></pre> <p>我在<a href="https://github.com/REMitchell/tesseract-trainer" target="_blank"><em>https://github.com/REMitchell/tesseract-trainer</em></a>创建了一个项目,其中包括一个 Web 应用程序,帮助创建这些框文件。要使用此项目创建框文件,请按照以下步骤操作:</p> <ol> <li> <p>将每个 CAPTCHA 图像重命名为其解决方案。例如,包含“AK6F”的图像将被重命名为“AK6F.png.”</p> </li> <li> <p>在 Tesseract 训练项目中,打开名为<em>createBoxes.html</em>的文件,使用您选择的 Web 浏览器。</p> </li> <li> <p>单击“添加新文件”链接,并选择在第一步中重命名的多个图像文件。</p> </li> <li> <p>Web 应用程序将基于图像名称自动生成框。将这些框拖动到其对应字符周围,如图 16-6 所示。</p> </li> <li> <p>当您满意框的放置位置时,请单击“下载.box”以下载框文件,接下来的图像应该会出现。</p> </li> </ol> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1606.png" alt="" loading="lazy"></p> <h6 id="图-16-6使用-tesseract-训练器工具创建框文件">图 16-6。使用 Tesseract 训练器工具创建框文件</h6> <p>作为可选步骤,我建议您播放一些好的播客或电视节目,因为这将是几个小时的乏味工作。确切的时间取决于您需要绘制多少个框。</p> <p>创建框文件后的下一步是向 Tesseract 展示您所有的辛勤工作,并让它进行训练。该过程的最终目标是创建一个<em>traineddata</em>文件,您可以将其添加到您的 Tesseract 语言目录中。</p> <p>在 Tesseract 训练项目中,<a href="https://github.com/REMitchell/tesseract-trainer" target="_blank"><em>https://github.com/REMitchell/tesseract-trainer</em></a>,我包含了一个名为 trainer.py 的文件。此脚本期望项目根目录下有一个<em>data</em>目录,并在其下有<em>cleaned</em>和<em>box</em>目录:</p> <ul> <li> <p><em>data</em></p> <ul> <li> <p><em>cleaned</em></p> <ul> <li>具有任何预处理和清理完成的 CAPTCHA 图像,文件名与框文件匹配</li> </ul> </li> <li> <p><em>box</em></p> <ul> <li>从 Web 应用程序下载的框文件</li> </ul> </li> </ul> </li> </ul> <p>在创建您的<em>.box</em>文件和图像文件夹之后,请将这些数据复制到备份文件夹中,然后再进行任何进一步的操作。尽管运行数据训练脚本不太可能删除任何内容,但当涉及到花费数小时来创建<em>.box</em>文件时,最好还是小心为好。</p> <h3 id="从-box-文件训练-tesseract">从 box 文件训练 Tesseract</h3> <p>执行数据分析并创建 Tesseract 所需的训练文件涉及许多步骤。<em>trainer.py</em>文件会为您完成所有这些工作。</p> <p>该程序采取的初始设置和步骤可以在该类的<code>__init__</code>和<code>runAll</code>方法中看到:</p> <pre><code class="language-py">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() </code></pre> <p>在<em>trainer.py</em>的底部创建了一个新的<code>TesseractTrainer</code>实例,并调用了 runAll 方法:</p> <pre><code class="language-py">trainer = TesseractTrainer('captcha', 'captchaFont') trainer.runAll() </code></pre> <p>将传递给<code>TesseractTrainer</code>对象的三个属性是:</p> <p><code>languageName</code></p> <p>Tesseract 用来跟踪语言的三个字母语言代码。对于特定的训练场景,我更喜欢创建一个新语言,而不是合并它或使用它来替换 Tesseract 预训练的英文数据。</p> <p><code>fontName</code></p> <p>您选择的字体名称。这可以是任何东西,但必须是一个没有空格的单词。在实践中,这仅用于训练过程中的内部目的,您不太可能看到它或需要引用它。</p> <p><code>directory</code></p> <p>包含清理图像和 box 文件的目录名。默认情况下,这是 data。如果您有多个项目,您可以为每个项目传入一个唯一的数据目录名称,以保持所有内容的组织。</p> <p>让我们来看一些使用的个别方法。</p> <p><code>createDirectories</code>会进行一些初始的清理工作,并创建子目录,如稍后将存储训练文件的<em>exp</em>目录。</p> <p><code>createFontProperties</code>会创建一个必需的文件<em>font_properties</em>,让 Tesseract 知道您正在创建的新字体:</p> <pre><code class="language-py">captchaFont 0 0 0 0 0 </code></pre> <p>该文件包含字体名称,后面跟着 1 和 0,表示是否考虑斜体、粗体或字体的其他版本。训练具有这些属性的字体是一个有趣的练习,但不幸的是超出了本书的范围。</p> <p><code>renameFiles</code>会重命名所有<em>.box</em>文件及其相应的图像文件,名称需符合 Tesseract 所需(这里的文件编号是顺序数字,以保持多个文件分开):</p> <ul> <li> <p><em><languageName>.<fontName>.exp<fileNumber>.box</em></p> </li> <li> <p><em><languageName>.<fontName>.exp<fileNumber>.tiff</em></p> </li> </ul> <p><code>extractUnicode</code>会查看所有已创建的<em>.box</em>文件,并确定可以训练的总字符集。生成的 Unicode 文件将告诉您找到了多少不同的字符,这可能是快速查看是否缺少任何内容的好方法。</p> <p>下面的三个函数,<code>runShapeClustering</code>、<code>runMfTraining</code>和<code>runCtTraining</code>,分别创建文件<code>shapetable</code>、<code>pfftable</code>和<code>normproto</code>。它们都提供关于每个字符的几何和形状的信息,以及提供 Tesseract 用于计算给定字符是哪种类型的概率的统计信息。</p> <p>最后,Tesseract 将每个编译的数据文件夹重命名为所需语言名称的前缀(例如,<em>shapetable</em>重命名为<em>cap.shapetable</em>),并将所有这些文件编译成最终的训练数据文件<em>cap.traineddata</em>。</p> <h3 id="使用-tesseract-的-traineddata-文件">使用 Tesseract 的 traineddata 文件</h3> <p><em>traineddata</em>文件是整个过程的主要输出。该文件告诉 Tesseract 如何在你提供的训练数据集中识别字符。要使用该文件,你需要将其移动到你的<em>tessdata</em>根目录。</p> <p>你可以使用以下命令找到这个文件夹:</p> <pre><code class="language-py">$ tesseract --list-langs </code></pre> <p>这将提供类似以下的输出:</p> <pre><code class="language-py">List of available languages in "/opt/homebrew/share/tessdata/" (3): eng osd snum </code></pre> <p>然后将<code>TESSDATA_PREFIX</code>环境变量设置为此目录:</p> <pre><code class="language-py">$ export TESSDATA_PREFIX=/opt/homebrew/share/tessdata/ </code></pre> <p>最后,将你的新<em>traineddata</em>文件移动到<em>languages</em>目录:</p> <pre><code class="language-py">$ cp data/exp/cap.traineddata $TESSDATA_PREFIX/cap.traineddata </code></pre> <p>安装新的<em>traineddata</em>文件后,Tesseract 应该会自动识别它作为新语言,并能够解决其遇到的新 CAPTCHA:</p> <pre><code class="language-py">$ tesseract -l captcha U8DG.png - U8DG </code></pre> <p>成功!显著改进了之前将图像解释为<code>u& DS</code>的情况。</p> <p>这只是对 Tesseract 字体训练和识别能力的简要概述。如果你对深入训练 Tesseract 感兴趣,也许开始自己的 CAPTCHA 训练文件库,或者与世界分享新的字体识别能力,我建议查看<a href="https://github.com/tesseract-ocr/tesseract" target="_blank">文档</a>。</p> <h1 id="检索-captcha-并提交解决方案">检索 CAPTCHA 并提交解决方案</h1> <p>许多流行的内容管理系统经常会被预先编程的机器人注册,这些机器人知道这些用户注册页面的著名位置。例如,在<a href="http://pythonscraping.com" target="_blank"><em>http://pythonscraping.com</em></a>上,即使有 CAPTCHA(诚然,不够强大),也无法阻止注册量的增加。</p> <p>那么这些机器人是如何做到的呢?你已经成功解决了硬盘上围绕的图像中的 CAPTCHA,但是如何制作一个完全功能的机器人呢?本节综合了前几章涵盖的许多技术。如果你还没有,建议至少浏览第十三章。</p> <p>大多数基于图像的 CAPTCHA 具有几个特性:</p> <ul> <li> <p>它们是由服务器端程序动态生成的图像。它们可能具有看起来不像传统图像的图像源,例如<code><img src="WebForm.aspx?id=8AP85CQKE9TJ"></code>,但可以像任何其他图像一样下载和操作。</p> </li> <li> <p>图像的解决方案存储在服务器端数据库中。</p> </li> <li> <p>如果您花费的时间过长,许多 CAPTCHA 会超时。对于机器人来说,这通常不是问题,但是排队 CAPTCHA 解决方案以供以后使用,或者可能延迟 CAPTCHA 请求和提交解决方案之间时间的其他做法,可能不会成功。</p> </li> </ul> <p>处理这个问题的一般方法是将 CAPTCHA 图像文件下载到您的硬盘上,清理它,使用 Tesseract 解析图像,并在适当的表单参数下返回解决方案。</p> <p>我创建了一个页面,位于<a href="http://pythonscraping.com/humans-only" target="_blank"><em>http://pythonscraping.com/humans-only</em></a>,带有一个 CAPTCHA 保护的评论表单,用于编写一个击败其的机器人。该机器人使用命令行的 Tesseract 库,而不是 pytesseract 包装器,尽管可以使用任一包。</p> <p>要开始,加载页面并找到需要与其余表单数据一起 POST 的隐藏令牌的位置:</p> <pre><code class="language-py">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'] </code></pre> <p>这个隐藏令牌恰好也是在页面上呈现的 CAPTCHA 图像的文件名,这使得编写<code>getCaptchaSolution</code>函数相对简单:</p> <pre><code class="language-py">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 </code></pre> <p>请注意,此脚本将在两种情况下失败:如果 Tesseract 未从图像中精确提取出四个字符(因为我们知道这个 CAPTCHA 的所有有效解决方案必须有四个字符),或者如果它提交了表单但 CAPTCHA 解决方案错误。</p> <p>在第一种情况下,您可以重新加载页面并重试,可能不会受到 Web 服务器的任何惩罚。在第二种情况下,服务器可能会注意到您错误地解决了 CAPTCHA,并对您进行惩罚。许多服务器在多次失败的 CAPTCHA 尝试后,会阻止用户或对其进行更严格的筛选。</p> <p>当然,作为这个特定服务器的所有者,我可以证明它非常宽容,不太可能阻止您!</p> <p>表单数据本身相对较长,您可以在 GitHub 存储库或在自己提交表单时的浏览器网络检查工具中完整查看。检查 CAPTCHA 解决方案的长度并使用 Requests 库提交它是相当简单的,但是:</p> <pre><code class="language-py">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!') </code></pre> <p>如果 CAPTCHA 解决方案是正确的(通常是这样),您应该期望看到类似以下内容的打印输出:</p> <pre><code class="language-py">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"}} </code></pre> <p>尽管 CAPTCHA 不像 10 或 20 年前那样普遍,但许多站点仍在使用它们,了解如何处理它们非常重要。此外,通过处理 CAPTCHA 解决方案而获得的技能,可以轻松转化为您可能遇到的其他图像到文本场景。</p> <p>¹ 参见 Rhett Jones,“Google 终于杀死了 CAPTCHA”,Gizmodo,2017 年 3 月 11 日,<a href="https://gizmodo.com/google-has-finally-killed-the-captcha-1793190374" target="_blank"><em>https://gizmodo.com/google-has-finally-killed-the-captcha-1793190374</em></a>。</p> <h1 id="第十七章避免爬虫陷阱">第十七章:避免爬虫陷阱</h1> <p>很少有什么比爬取一个网站、查看输出,并没有看到浏览器中清晰可见的数据更令人沮丧的了。或者提交一个应该完全正常但被网络服务器拒绝的表单。或者因未知原因被一个网站阻止 IP 地址。</p> <p>这些是一些最难解决的 bug,不仅因为它们可能是如此意外(在一个网站上运行良好的脚本在另一个看似相同的网站上可能根本不起作用),而且因为它们故意不提供任何显眼的错误消息或堆栈跟踪供使用。你被识别为机器人,被拒绝了,而你却不知道为什么。</p> <p>在本书中,我写了很多在网站上执行棘手任务的方法,包括提交表单、提取和清理困难数据以及执行 JavaScript。这一章有点像一个杂项章节,因为这些技术来自各种各样的学科。然而,它们都有一个共同点:它们旨在克服一个唯一目的是防止自动爬取网站的障碍。</p> <p>无论这些信息对你目前有多么重要,我强烈建议你至少浏览一下这一章。你永远不知道它何时会帮助你解决一个难题或完全防止一个问题的发生。</p> <h1 id="关于伦理的一点说明">关于伦理的一点说明</h1> <p>在本书的前几章中,我讨论了网络爬虫所处的法律灰色地带,以及一些伦理和法律指南。说实话,对我来说,这一章在伦理上可能是最难写的一章。我的网站一直受到机器人、垃圾邮件发送者、网络爬虫和各种不受欢迎的虚拟访客的困扰,也许你的网站也是如此。那么为什么要教人们如何构建更好的机器人呢?</p> <p>我认为包括这一章有几个重要原因:</p> <ul> <li> <p>有一些完全符合道德和法律的理由可以爬取一些不希望被爬取的网站。在我以前的一份工作中,我作为一个网络爬虫,自动收集网站上发布客户姓名、地址、电话号码和其他个人信息的信息,而这些客户并未同意发布这些信息到互联网上。我使用爬取的信息向网站提出合法请求,要求其删除这些信息。为了避免竞争,这些网站密切关注着这些信息不被爬取。然而,我的工作确保了我公司客户(其中一些人有骚扰者,是家庭暴力的受害者,或者有其他非常充分的理由希望保持低调)的匿名性,这为网络爬取提供了一个令人信服的理由,我很感激我有必要的技能来完成这项工作。</p> </li> <li> <p>尽管几乎不可能建立一个“抓取器免疫”的网站(或者至少是一个仍然容易被合法用户访问的网站),但我希望本章的信息能帮助那些希望捍卫其网站免受恶意攻击的人。在整本书中,我将指出每种网页抓取技术的一些弱点,你可以用来保护自己的网站。请记住,今天网络上的大多数机器人仅仅是在进行广泛的信息和漏洞扫描;即使使用本章描述的几种简单技术,也很可能会阻挡其中 99%。然而,它们每个月都在变得更加复杂,最好做好准备。</p> </li> <li> <p>像大多数程序员一样,我不认为隐瞒任何教育信息是一件积极的事情。</p> </li> </ul> <p>在阅读本章节时,请记住,许多这些脚本和描述的技术不应该在你能找到的每个网站上运行。这不仅仅是不好的做法,而且你可能最终会收到停止和停止信函或者更糟糕的后果(如果你收到这样的信函,详细信息请参阅第二章)。但我不会每次讨论新技术时都敲打你的头。所以,在本章剩余部分,就像哲学家 Gump 曾经说过的:“这就是我要说的一切。”</p> <h1 id="仿人行为">仿人行为</h1> <p>对于不想被抓取的网站而言,基本挑战在于如何区分机器人和人类。虽然许多网站使用的技术(如 CAPTCHA)很难欺骗,但你可以通过一些相对简单的方法使你的机器人看起来更像人类。</p> <h2 id="调整你的标头">调整你的标头</h2> <p>在整本书中,你已经使用 Python Requests 库来创建、发送和接收 HTTP 请求,比如在第十三章处理网站上的表单。Requests 库也非常适合设置标头。HTTP 标头是每次向 Web 服务器发出请求时由你发送的属性或偏好列表。HTTP 定义了几十种晦涩的标头类型,其中大多数不常用。然而,大多数主流浏览器在发起任何连接时一直使用以下七个字段(显示了来自我的浏览器示例数据):</p> <table> <thead> <tr> <th><code>Host</code></th> <th><a href="https://www.google.com/" target="_blank">https://www.google.com/</a></th> </tr> </thead> <tbody> <tr> <td><code>Connection</code></td> <td>keep-alive</td> </tr> <tr> <td><code>Accept</code></td> <td>text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,<em>/</em>;q=0.8,application/signed-exchange;v=b3;q=0.7</td> </tr> <tr> <td><code>User-Agent</code></td> <td>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</td> </tr> <tr> <td><code>Referrer</code></td> <td><a href="https://www.google.com/" target="_blank">https://www.google.com/</a></td> </tr> <tr> <td><code>Accept-Encoding</code></td> <td>gzip, deflate, sdch</td> </tr> <tr> <td><code>Accept-Language</code></td> <td>en-US,en;q=0.8</td> </tr> </tbody> </table> <p>这里是一个使用默认的 urllib 库进行典型 Python 网页抓取的标头:</p> <table> <thead> <tr> <th><code>Accept-Encoding</code></th> <th>identity</th> </tr> </thead> <tbody> <tr> <td><code>User-Agent</code></td> <td>Python-urllib/3.9</td> </tr> </tbody> </table> <p>如果你是一个试图阻止爬虫的网站管理员,你更可能放行哪一个?</p> <p>幸运的是,使用 Requests 库可以完全自定义头部。<a href="https://www.whatismybrowser.com" target="_blank"><em>https://www.whatismybrowser.com</em></a>这个网站非常适合测试服务器可见的浏览器属性。你将使用以下脚本爬取这个网站以验证你的 Cookie 设置。</p> <pre><code class="language-py">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) </code></pre> <p>输出应显示标题现在与代码中的<code>headers</code>字典对象中设置的相同。</p> <p>尽管网站可以基于 HTTP 头部的任何属性检查“人类性”,但我发现通常唯一重要的设置是<code>User-Agent</code>。无论你在做什么项目,将其设置为比<code>Python-urllib/3.9</code>更不引人注意的内容都是个好主意。此外,如果你遇到一个极为可疑的网站,填充诸如<code>Accept-Language</code>等常用但很少检查的头部之一可能是说服它你是人类的关键。</p> <h2 id="使用-javascript-处理-cookie">使用 JavaScript 处理 Cookie</h2> <p>正确处理 Cookie 可以缓解许多爬取问题,尽管 Cookie 也可能是双刃剑。使用 Cookie 跟踪你在网站上的活动进度的网站可能会试图阻止显示异常行为的爬虫,如过快地完成表单或访问过多页面。尽管这些行为可以通过关闭和重新打开与网站的连接,甚至更改你的 IP 地址来掩饰,但如果你的 Cookie 暴露了你的身份,你的伪装努力可能会徒劳无功(详见第二十章了解更多关于如何做到这一点的信息)。</p> <p>Cookies 有时也是爬取网站必需的。如第十三章所示,在网站上保持登录状态需要能够保存并呈现页面到页面的 Cookie。有些网站甚至不要求你真正登录并获得新版本的 Cookie,只需持有一个旧的“已登录”Cookie 并访问网站即可。</p> <p>如果你正在爬取一个或少数几个特定的网站,我建议检查这些网站生成的 Cookie,并考虑你的爬虫可能需要处理哪些 Cookie。各种浏览器插件可以在你访问和浏览网站时显示 Cookie 的设置方式。<a href="http://www.editthiscookie.com" target="_blank">EditThisCookie</a>,一款 Chrome 扩展,是我喜欢的工具之一。</p> <p>若要了解有关使用 Requests 库处理 cookies 的更多信息,请查看“处理登录和 cookies”中的代码示例在第十三章。当然,由于它无法执行 JavaScript,因此 Requests 库将无法处理许多现代跟踪软件生成的 cookies,例如 Google Analytics,这些 cookies 仅在客户端脚本执行后(或有时基于页面事件,如按钮点击,在浏览页面时发生)设置。为了处理这些问题,您需要使用 Selenium 和 Chrome WebDriver 包(我在第十四章中介绍了它们的安装和基本用法)。</p> <p>您可以通过访问任何站点(<a href="http://pythonscraping.com" target="_blank"><em>http://pythonscraping.com</em></a>,例如)并在 webdriver 上调用 <code>get_cookies()</code> 查看 cookies:</p> <pre><code class="language-py">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()) </code></pre> <p>这提供了相当典型的 Google Analytics cookies 数组:</p> <pre><code class="language-py">[{'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'}] </code></pre> <p>要操作 cookies,您可以调用 <code>delete_cookie()</code>、<code>add_cookie()</code> 和 <code>delete_all_cookies()</code> 函数。此外,您可以保存和存储 cookies 以供其他网络爬虫使用。以下示例让您了解这些函数如何协同工作:</p> <pre><code class="language-py">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()) </code></pre> <p>在这个示例中,第一个 webdriver 检索一个网站,打印 cookies,然后将它们存储在变量 <code>savedCookies</code> 中。第二个 webdriver 加载同一个网站,删除自己的 cookies,并添加第一个 webdriver 的 cookies。</p> <p>注意第二个 webdriver 必须先加载网站,然后再添加 cookies。这样 Selenium 才知道 cookies 属于哪个域,即使加载网站本身对爬虫没有实际用处。</p> <p>完成后,第二个 webdriver 应该有与第一个相同的 cookies。根据 Google Analytics 的说法,这第二个 webdriver 现在与第一个完全相同,并且它们将以相同的方式被跟踪。如果第一个 webdriver 已登录到一个站点,第二个 webdriver 也将是如此。</p> <h2 id="tls-指纹识别">TLS 指纹识别</h2> <p>在 2000 年代初,许多大型科技公司喜欢在面试程序员时提出经典谜题。当招聘经理意识到两件事情时,这种做法大多数已经淡出:候选人共享和记忆谜题解决方案,以及“解决谜题的能力”与工作表现之间的关联并不紧密。</p> <p>然而,这些经典的面试谜题之一仍然作为传输层安全协议的隐喻是有价值的。它是这样的:</p> <p>您需要通过危险的路线向朋友发送一条绝密消息,如果解锁的包含消息的盒子被间谍拦截(但是,如果间谍没有钥匙,则带锁的消息盒是安全的)。您将消息放入可以用多个挂锁锁定的盒子中。虽然您有相应钥匙的挂锁和您的朋友也有自己的挂锁及其相应的钥匙,但是您的朋友的钥匙不适用于您的挂锁,反之亦然。如何确保您的朋友能够在其端解锁盒子并安全地接收消息?</p> <p>请注意,即使作为单独的运输发送解锁您的挂锁的密钥也不起作用。间谍会拦截并复制这些钥匙并保存以备将来使用。此外,稍后发送钥匙也不起作用(尽管这是“谜题作为隐喻”有点崩溃的地方),因为间谍可以复制<em>盒子本身</em>,如果稍后发送一个钥匙,则可以解锁他们的盒子副本。</p> <p>一个解决方案是这样的:你把你的挂锁放在盒子上并将其寄给你的朋友。你的朋友收到了锁上的盒子,把他们自己的挂锁放在上面(这样盒子上就有两个挂锁),然后把它寄回来。你移走你的挂锁,只剩下他们的挂锁寄给你的朋友。你的朋友收到盒子并解锁它。</p> <p>本质上,这是如何在不可信网络上建立安全通信的方法。在像 HTTPS 这样的安全通信协议上,所有消息都使用密钥进行加密和解密。如果攻击者获得了密钥(谜题中的秘密消息表示的),则能够读取发送的任何消息。</p> <p>那么如何将您将用于加密和解密未来消息的密钥发送给朋友,而不会被攻击者拦截和使用?用您自己的“挂锁”加密它,发送给朋友,朋友添加他们自己的“挂锁”,您移除您的“挂锁”,然后发送回来供朋友“解锁”。通过这种方式,秘密密钥安全地交换。</p> <p>这个“锁定”、发送、添加另一个“锁定”等整个过程由传输层安全协议(TLS)处理。安全地建立双方共知的密钥的这一过程称为<em>TLS 握手</em>。</p> <p>除了建立一个双方共知的密钥或<em>主秘密</em>外,握手期间还建立了许多其他事情:</p> <ul> <li> <p>双方支持的 TLS 协议的最高版本(在握手的其余部分中将使用的版本)</p> </li> <li> <p>将使用的加密库是哪个</p> </li> <li> <p>将使用的压缩方法</p> </li> <li> <p>服务器的身份,由其公共证书表示</p> </li> <li> <p>验证主秘密对双方都有效并且通信现在是安全的</p> </li> </ul> <p>每次与新的 Web 服务器联系和建立新的 HTTP 会话时,都会执行整个 TLS 握手过程(有关会话的更多信息,请参见第一章)。由你的计算机发送的确切 TLS 握手消息由进行连接的应用程序确定。例如,Chrome 可能支持略有不同的 TLS 版本或加密库,因此在 TLS 握手中发送的消息将不同。</p> <p>由于 TLS 握手过程非常复杂,并涉及的变量很多,聪明的服务器管理员意识到,在 TLS 握手期间由客户端发送的消息在某种程度上对每个应用程序都是独一无二的。这些消息形成了一种<em>TLS 指纹</em>,可以显示出消息是由 Chrome、Microsoft Edge、Safari 甚至 Python Requests 库生成的。</p> <p>您可以通过访问(或爬取)<a href="https://tools.scrapfly.io/api/fp/ja3?extended=1" target="_blank"><em>https://tools.scrapfly.io/api/fp/ja3?extended=1</em></a>来查看由您的 TLS 握手生成的一些信息。为了使 TLS 指纹更易于管理和比较,通常会使用称为 JA3 的哈希方法,其结果显示在此 API 响应中。JA3 哈希指纹被编入大型数据库,并在以后需要识别应用程序时进行查找。</p> <p>TLS 指纹有点像用户代理 cookie,它是一个长字符串,用于标识您用于发送数据的应用程序。但与用户代理 cookie 不同的是,它不容易修改。在 Python 中,TLS 由<a href="https://github.com/python/cpython/blob/3.11/Lib/ssl.py" target="_blank">SSL 库</a>控制。理论上,也许你可以重写 SSL 库。通过努力和奉献,也许你能够修改 Python 发送的 TLS 指纹,使其足够不同,以致于服务器无法识别 JA3 哈希以阻止 Python 机器人。通过更加努力和奉献,你甚至可能冒充一个无害的浏览器!一些项目,如<a href="https://github.com/lwthiker/curl-impersonate" target="_blank"><em>https://github.com/lwthiker/curl-impersonate</em></a>,正在试图做到这一点。</p> <p>不幸的是,TLS 指纹问题的本质意味着任何仿冒库都需要由志愿者进行频繁维护,并且容易快速退化。在这些项目获得更广泛的关注和可靠性之前,有一种更简单的方法可以规避 TLS 指纹识别和阻断:Selenium。</p> <p>在整本书中,我都警告过不要在存在替代解决方案时使用自动化浏览器来解决问题。浏览器使用大量内存,经常加载不必要的页面,并需要额外的依赖项来保持您的网络爬虫运行。但是,当涉及到 TLS 指纹时,避免麻烦并使用浏览器是很合理的选择。</p> <p>请记住,无论您使用浏览器的无头版本还是非无头版本,您的 TLS 指纹都将是相同的。因此,可以关闭图形并使用最佳实践仅加载您需要的数据——目标服务器不会知道(至少根据您的 TLS 数据)!</p> <h2 id="时间至关重要">时间至关重要</h2> <p>一些受到良好保护的网站可能会阻止您提交表单或与网站进行交互,如果您操作得太快的话。即使这些安全功能没有启用,从网站下载大量信息比正常人类快得多也是一个被注意并被封锁的好方法。</p> <p>因此,虽然多线程编程可能是加快页面加载速度的好方法——允许您在一个线程中处理数据,同时在另一个线程中反复加载页面——但对于编写良好的爬虫来说却是一种糟糕的策略。您应该始终尽量减少单个页面加载和数据请求。如果可能的话,尽量将它们间隔几秒钟,即使您必须添加额外的:</p> <pre><code class="language-py">import time time.sleep(3) </code></pre> <p>是否需要在页面加载之间增加这几秒钟的额外时间通常需要通过实验来确定。我曾多次为了从网站中抓取数据而苦苦挣扎,每隔几分钟就要证明自己“不是机器人”(手动解决 CAPTCHA,将新获取的 cookie 粘贴回到爬虫中,以便网站将爬虫本身视为“已证明其人类性”),但添加了 <code>time.sleep</code> 解决了我的问题,并使我可以无限期地进行抓取。</p> <p>有时候你必须放慢脚步才能更快地前进!</p> <h1 id="常见的安全特性">常见的安全特性</h1> <p>多年来一直使用并继续使用许多试金石测试,以不同程度的成功将网络爬虫与使用浏览器的人类分开。虽然如果机器人下载了一些对公众可用的文章和博客文章并不是什么大事,但如果机器人创建了数千个用户帐户并开始向您网站的所有成员发送垃圾邮件,则这是一个大问题。网络表单,特别是处理帐户创建和登录的表单,如果容易受到机器人的不加区分使用,那么对于安全和计算开销来说,它们对许多站点所有者的最大利益(或至少他们认为是)是限制对站点的访问。</p> <p>这些以表单和登录为中心的反机器人安全措施可能对网络爬虫构成重大挑战。</p> <p>请记住,这只是创建这些表单的自动化机器人时可能遇到的一些安全措施的部分概述。请参阅第十六章,有关处理 CAPTCHA 和图像处理的内容,以及第十三章,有关处理标头和 IP 地址的内容,获取有关处理受良好保护的表单的更多信息。</p> <h2 id="隐藏的输入字段值">隐藏的输入字段值</h2> <p>HTML 表单中的“隐藏”字段允许浏览器查看字段中包含的值,但用户看不到它们(除非他们查看站点的源代码)。随着使用 cookie 在网站上存储变量并在之间传递它们的增加,隐藏字段在一段时间内不再受欢迎,然后发现了它们的另一个出色用途:防止网页抓取程序提交表单。</p> <p>图 17-1 显示了 LinkedIn 登录页面上这些隐藏字段的示例。虽然表单只有三个可见字段(用户名、密码和提交按钮),但它向服务器传递了大量信息。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1701.png" alt="Alt Text" loading="lazy"></p> <h6 id="图-17-1-linkedin-登录表单有几个隐藏字段">图 17-1. LinkedIn 登录表单有几个隐藏字段。</h6> <p>隐藏字段主要用于防止 Web 抓取程序的两种主要方式:字段可以在表单页面上使用随机生成的变量填充,服务器期望在处理页面上提交该变量。如果表单中没有这个值,服务器可以合理地认为提交不是源自表单页面的有机操作,而是直接由机器人发布到处理页面。绕过此措施的最佳方法是首先抓取表单页面,收集随机生成的变量,然后从那里发布到处理页面。</p> <p>第二种方法有点像“蜜罐”。如果表单包含一个隐藏字段,其名称看似无害,比如用户名或电子邮件地址,那么一个编写不良的机器人可能会填写该字段并尝试提交它,而不管它对用户是否隐藏。任何带有实际值的隐藏字段(或者在表单提交页面上与其默认值不同的值)都应被忽略,甚至可能会阻止用户访问该站点。</p> <p>简而言之:有时需要检查表单所在的页面,看看服务器可能期望您漏掉的任何内容。如果看到几个隐藏字段,通常带有大量随机生成的字符串变量,那么 Web 服务器可能会在表单提交时检查它们的存在。此外,可能还会有其他检查,以确保表单变量仅被使用一次,是最近生成的(这消除了仅仅将它们存储在脚本中并随时使用的可能性),或两者兼而有之。</p> <h2 id="避免蜜罐">避免蜜罐</h2> <p>尽管 CSS 在区分有用信息和无用信息(例如通过读取<code>id</code>和<code>class</code>标签)方面大多数情况下使生活变得极为简单,但在 Web 抓取程序方面有时可能会出现问题。如果通过 CSS 从用户隐藏网页上的字段,可以合理地假设平均访问该网站的用户将无法填写它,因为它在浏览器中不显示。如果表单被填充,很可能是有机器人在操作,并且该帖子将被丢弃。</p> <p>这不仅适用于表单,还适用于链接、图像、文件和站点上的任何其他项目,这些项目可以被机器人读取,但对于通过浏览器访问站点的普通用户而言是隐藏的。访问站点上的“隐藏”链接可能很容易触发一个服务器端脚本,该脚本将阻止用户的 IP 地址,将该用户从站点注销,或者采取其他措施防止进一步访问。事实上,许多商业模型都是基于这个概念的。</p> <p>例如,位于<a href="http://pythonscraping.com/pages/itsatrap.html" target="_blank"><em>http://pythonscraping.com/pages/itsatrap.html</em></a>的页面。这个页面包含两个链接,一个被 CSS 隐藏,另一个可见。此外,它包含一个带有两个隐藏字段的表单:</p> <pre><code class="language-py"><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> </code></pre> <p>这三个元素以三种方式对用户隐藏:</p> <ul> <li> <p>第一个链接使用简单的 CSS <code>display:none</code>属性隐藏。</p> </li> <li> <p>电话字段是一个隐藏的输入字段。</p> </li> <li> <p>电子邮件字段通过将其向右移动 50,000 像素(可能超出所有人的显示器屏幕)并隐藏显眼的滚动条来隐藏它。</p> </li> </ul> <p>幸运的是,因为 Selenium 呈现它访问的页面,它能够区分页面上视觉上存在的元素和不存在的元素。元素是否存在于页面上可以通过<code>is_displayed()</code>函数确定。</p> <p>例如,以下代码检索先前描述的页面,并查找隐藏链接和表单输入字段:</p> <pre><code class="language-py">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")}') </code></pre> <p>Selenium 捕获每个隐藏字段,产生以下输出:</p> <pre><code class="language-py">The link http://pythonscraping.com/dontgohere is a trap Do not change value of phone Do not change value of email </code></pre> <p>虽然你可能不想访问你发现的任何隐藏链接,但你会想确保你提交了任何预填充的隐藏表单值(或者让 Selenium 为你提交),并与其他表单一起提交。总之,简单忽略隐藏字段是危险的,尽管在与它们交互时必须小心。</p> <h1 id="人类检查清单">人类检查清单</h1> <p>这一章节以及这本书中有很多关于如何构建一个看起来不像爬虫而更像人类的爬虫的信息。如果你不断被网站阻止而又不知道原因,这里有一个你可以用来解决问题的检查清单:</p> <ul> <li> <p>首先,如果你从 Web 服务器接收的页面是空白的、缺少信息的,或者与你期望的(或者在你自己的浏览器中看到的)不同,很可能是由于 JavaScript 在站点上执行以创建页面。查看第十四章。</p> </li> <li> <p>如果你正在向网站提交表单或进行<code>POST</code>请求,请检查页面以确保网站期望你提交的一切都被提交并且格式正确。使用诸如 Chrome 的 Inspector 面板之类的工具查看发送到网站的实际<code>POST</code>请求,确保你拥有一切,并且“有机”的请求看起来与你的机器人发送的请求相同。</p> </li> <li> <p>如果您尝试登录网站但无法保持登录状态,或者网站出现其他奇怪的“状态”行为,请检查您的 cookies。确保 cookies 在每次页面加载之间都被正确保存,并且您的 cookies 被发送到该网站以处理每个请求。</p> </li> <li> <p>如果您从客户端收到 HTTP 错误,特别是 403 Forbidden 错误,这可能表示网站已将您的 IP 地址识别为机器人,不愿再接受任何请求。您需要等待直到您的 IP 地址从列表中移除,或者获取一个新的 IP 地址(要么去星巴克,要么参考第二十章)。为了确保您不会再次被阻止,请尝试以下方法:</p> <ul> <li> <p>确保您的抓取器不要过快地浏览网站。快速抓取是一种不良实践,会给网站管理员的服务器带来沉重负担,可能会让您陷入法律麻烦,并且是抓取器被列入黑名单的头号原因。为您的抓取器添加延迟,并让它们在夜间运行。记住:匆忙编写程序或收集数据是糟糕项目管理的表现;提前计划以避免出现这种混乱。</p> </li> <li> <p>最明显的一种:更改您的 headers!一些网站会阻止任何宣称自己是抓取器的内容。如果您对一些合理的 header 值感到不确定,请复制您自己浏览器的 headers。</p> </li> <li> <p>确保您不要点击或访问任何人类通常无法访问的内容(更多信息请参考“避免蜜罐”)。</p> </li> <li> <p>如果您发现自己需要跨越许多困难障碍才能获得访问权限,请考虑联系网站管理员,让他们知道您的操作。尝试发送电子邮件至<em>webmaster@<domain name></em>或<em>admin@<domain name></em>,请求使用您的抓取器。管理员也是人,您可能会惊讶地发现他们对分享数据的态度是多么的乐意。</p> </li> </ul> </li> </ul> <h1 id="第十八章使用抓取器测试您的网站">第十八章:使用抓取器测试您的网站</h1> <p>在使用具有大型开发堆栈的 Web 项目时,通常只有“堆栈”的“后端”部分会定期进行测试。今天大多数编程语言(包括 Python)都有某种类型的测试框架,但网站前端通常被排除在这些自动化测试之外,尽管它们可能是项目中唯一面向客户的部分。</p> <p>问题的一部分是,网站经常是许多标记语言和编程语言的混合物。您可以为 JavaScript 的某些部分编写单元测试,但如果它与其交互的 HTML 已更改以使 JavaScript 在页面上没有预期的操作,则此单元测试是无用的,即使它正常工作。</p> <p>前端网站测试的问题经常被放在后面或者委托给只有最多一个清单和一个 bug 跟踪器的低级程序员。然而,只需稍微付出更多的努力,你就可以用一系列单元测试替换这个清单,并用网页抓取器代替人眼。</p> <p>想象一下:为 Web 开发进行测试驱动开发。每天测试以确保 Web 界面的所有部分都正常运行。一套测试在有人添加新的网站功能或更改元素位置时运行。本章介绍了测试的基础知识以及如何使用基于 Python 的 Web 抓取器测试各种网站,从简单到复杂。</p> <h1 id="测试简介">测试简介</h1> <p>如果您以前从未为代码编写过测试,那么现在没有比现在更好的时间开始了。拥有一套可以运行以确保代码按预期执行(至少是您为其编写了测试的范围)的测试集合会节省您时间和担忧,并使发布新更新变得容易。</p> <h2 id="什么是单元测试">什么是单元测试?</h2> <p><em>测试</em> 和 <em>单元测试</em> 这两个词通常可以互换使用。通常,当程序员提到“编写测试”时,他们真正的意思是“编写单元测试”。另一方面,当一些程序员提到编写单元测试时,他们实际上在编写其他类型的测试。</p> <p>尽管定义和实践往往因公司而异,但一个单元测试通常具有以下特征:</p> <ul> <li> <p>每个单元测试测试组件功能的一个方面。例如,它可能确保从银行账户中提取负数美元时会抛出适当的错误消息。</p> <p>单元测试通常根据它们所测试的组件分组在同一个类中。你可能会有一个测试,测试从银行账户中提取负美元值,然后是一个测试过度支出的银行账户行为的单元测试。</p> </li> <li> <p>每个单元测试可以完全独立运行,单元测试所需的任何设置或拆卸必须由单元测试本身处理。同样,单元测试不得干扰其他测试的成功或失败,并且它们必须能够以任何顺序成功运行。</p> </li> <li> <p>每个单元测试通常至少包含一个<em>断言</em>。例如,一个单元测试可能会断言 2 + 2 的答案是 4。偶尔,一个单元测试可能只包含一个失败状态。例如,如果抛出异常,则可能失败,但如果一切顺利,则默认通过。</p> </li> <li> <p>单元测试与大部分代码分离。虽然它们必须导入和使用它们正在测试的代码,但它们通常保存在单独的类和目录中。</p> </li> </ul> <p>尽管可以编写许多其他类型的测试—例如集成测试和验证测试—但本章主要关注单元测试。不仅单元测试在最近推动的面向测试驱动开发中变得极为流行,而且它们的长度和灵活性使它们易于作为示例进行操作,并且 Python 具有一些内置的单元测试功能,你将在下一节中看到。</p> <h1 id="python-单元测试">Python 单元测试</h1> <p>Python 的单元测试模块<code>unittest</code>已包含在所有标准 Python 安装中。只需导入并扩展<code>unittest.TestCase</code>,它将:</p> <ul> <li> <p>提供<code>setUp</code>和<code>tearDown</code>函数,分别在每个单元测试之前和之后运行</p> </li> <li> <p>提供几种类型的“assert”语句,以允许测试通过或失败</p> </li> <li> <p>运行所有以<code>test_</code>开头的函数作为单元测试,并忽略未以测试形式开头的函数</p> </li> </ul> <p>下面提供了一个简单的单元测试,用于确保 2 + 2 = 4,根据 Python 的定义:</p> <pre><code class="language-py">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() </code></pre> <p>尽管在这里<code>setUp</code>和<code>tearDown</code>不提供任何有用的功能,但它们被包含在内以进行说明。请注意,这些函数在每个单独的测试之前和之后运行,而不是在类的所有测试之前和之后运行。</p> <p>当从命令行运行测试函数的输出应如下所示:</p> <pre><code class="language-py">Setting up the test Tearing down the test . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK </code></pre> <p>这表明测试已成功运行,2 + 2 确实等于 4。</p> <h2 id="测试维基百科">测试维基百科</h2> <p>测试网站的前端(不包括我们将在下一节中介绍的 JavaScript)只需将 Python<code>unittest</code>库与 Web 爬虫结合使用即可:</p> <pre><code class="language-py">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() </code></pre> <p>这次有两个测试:第一个测试页面的标题是否是预期的“Monty Python”,第二个确保页面具有内容<code>div</code>。</p> <p>请注意,页面内容仅加载一次,并且全局对象<code>bs</code>在测试之间共享。这是通过使用<code>unittest</code>指定的<code>setUpClass</code>函数实现的,该函数在类开始时只运行一次(不像<code>setUp</code>,它在每个单独的测试之前运行)。使用<code>setUpClass</code>而不是<code>setUp</code>可以节省不必要的页面加载;您可以一次获取内容并对其运行多个测试。</p> <p><code>setUpClass</code>和<code>setUp</code>之间的一个主要架构差异,除了它们何时以及多频繁地运行之外,是<code>setUpClass</code>是一个静态方法,它“属于”类本身并具有全局类变量,而<code>setUp</code>是一个属于类的特定实例的实例函数。这就是为什么<code>setUp</code>可以在<code>self</code>上设置属性——该类的特定实例——而<code>setUpClass</code>只能访问类<code>TestWikipedia</code>上的静态类属性。</p> <p>虽然一次只测试一个页面可能看起来并不那么强大或有趣,正如您可能从第六章中记得的那样,构建可以迭代地移动通过网站所有页面的网络爬虫相对容易。当您将一个网络爬虫与对每个页面进行断言的单元测试结合在一起时会发生什么?</p> <p>有许多方法可以重复运行测试,但是你必须小心地每次加载每个页面,以及你还必须避免一次在内存中持有大量信息。以下设置正好做到了这一点:</p> <pre><code class="language-py">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() </code></pre> <p>有几件事情要注意。首先,这个类中只有一个实际的测试。其他函数技术上只是辅助函数,尽管它们完成了大部分计算工作来确定测试是否通过。因为测试函数执行断言语句,测试结果被传回测试函数,在那里断言发生。</p> <p>另外,虽然<code>contentExists</code>返回一个布尔值,但<code>titleMatchesURL</code>返回用于评估的值本身。要了解为什么你希望传递值而不仅仅是布尔值,请比较布尔断言的结果:</p> <pre><code class="language-py">====================================================================== 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 </code></pre> <p>与<code>assertEquals</code>语句的结果一样:</p> <pre><code class="language-py">====================================================================== 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' </code></pre> <p>哪一个更容易调试?(在这种情况下,错误是由于重定向导致的,当文章 <em><a href="http://wikipedia.org/wiki/u-2%20spy%20plane" target="_blank">http://wikipedia.org/wiki/u-2 spy plane</a></em> 重定向到一个名为“Lockheed U-2”的文章时。)</p> <h1 id="使用-selenium-进行测试">使用 Selenium 进行测试</h1> <p>就像在第十四章中的 Ajax 抓取一样,当进行网站测试时,JavaScript 在处理特定的网站时会出现特殊的挑战。幸运的是,Selenium 已经有了一个处理特别复杂网站的优秀框架;事实上,这个库最初就是为网站测试而设计的!</p> <p>尽管显然使用相同的语言编写,Python 单元测试和 Selenium 单元测试的语法却惊人地不相似。Selenium 不要求其单元测试被包含在类中的函数中;它的<code>assert</code>语句不需要括号;测试在通过时静默通过,仅在失败时产生某种消息:</p> <pre><code class="language-py">driver = webdriver.Chrome() driver.get('http://en.wikipedia.org/wiki/Monty_Python') assert 'Monty Python' in driver.title driver.close() </code></pre> <p>当运行时,这个测试应该不会产生任何输出。</p> <p>以这种方式,Selenium 测试可以比 Python 单元测试更加随意地编写,并且<code>assert</code>语句甚至可以集成到常规代码中,当代码执行希望在未满足某些条件时终止时。</p> <h2 id="与站点互动">与站点互动</h2> <p>最近,我想通过一个本地小企业的网站联系他们的联系表单,但发现 HTML 表单已损坏;当我点击提交按钮时什么也没有发生。经过一番调查,我发现他们使用了一个简单的 mailto 表单,旨在用表单内容发送电子邮件给他们。幸运的是,我能够利用这些信息发送电子邮件给他们,解释表单的问题,并雇佣他们,尽管有技术问题。</p> <p>如果我要编写一个传统的爬虫来使用或测试这个表单,我的爬虫很可能只会复制表单的布局并直接发送电子邮件,完全绕过表单。我该如何测试表单的功能性,并确保它通过浏览器正常工作?</p> <p>虽然前几章已经讨论了导航链接、提交表单和其他类型的交互活动,但我们所做的一切本质上是为了 <em>绕过</em> 浏览器界面,而不是使用它。另一方面,Selenium 可以通过浏览器(在这种情况下是无头 Chrome 浏览器)直接输入文本、点击按钮以及执行所有操作,并检测到诸如损坏的表单、糟糕编码的 JavaScript、HTML 拼写错误以及其他可能困扰实际客户的问题。</p> <p>这种测试的关键在于 Selenium 元素的概念。这个对象在 第十四章 简要提到,并且可以通过如下调用返回:</p> <pre><code class="language-py">usernameField = driver.find_element_by_name('username') </code></pre> <p>正如您可以在浏览器中对网站的各个元素执行多种操作一样,Selenium 可以对任何给定元素执行许多操作。其中包括:</p> <pre><code class="language-py">myElement.click() myElement.click_and_hold() myElement.release() myElement.double_click() myElement.send_keys_to_element('content to enter') </code></pre> <p>除了对元素执行一次性操作外,动作串也可以组合成 <em>动作链</em>,可以在程序中存储并执行一次或多次。动作链之所以有用,是因为它们可以方便地串接多个动作,但在功能上与显式调用元素上的动作完全相同,就像前面的例子一样。</p> <p>要了解这种差异,请查看 <a href="http://pythonscraping.com/pages/files/form.html" target="_blank"><em>http://pythonscraping.com/pages/files/form.html</em></a> 上的表单页面(这在 第十三章 中曾作为示例使用)。我们可以以这种方式填写表单并提交:</p> <pre><code class="language-py">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() </code></pre> <p>方法 1 在两个字段上调用 <code>send_keys</code>,然后点击提交按钮。方法 2 使用单个动作链在调用 <code>perform</code> 方法后依次点击和输入每个字段的文本。无论使用第一种方法还是第二种方法,此脚本的操作方式都相同,并打印此行:</p> <pre><code class="language-py">Hello there, Ryan Mitchell! </code></pre> <p>两种方法之间还有另一种变化,除了它们用于处理命令的对象之外:请注意,第一种方法点击“提交”按钮,而第二种方法在提交表单时使用回车键。因为完成相同动作的事件序列有很多思考方式,使用 Selenium 可以完成相同的动作的方法也有很多。</p> <h3 id="拖放">拖放</h3> <p>点击按钮和输入文本是一回事,但是 Selenium 真正发光的地方在于它处理相对新颖的 Web 交互形式的能力。Selenium 允许轻松操作拖放接口。使用其拖放功能需要指定一个<em>源</em>元素(要拖动的元素)和要拖动到的目标元素或偏移量。</p> <p>该演示页面位于<a href="http://pythonscraping.com/pages/javascript/draggableDemo.html" target="_blank"><em>http://pythonscraping.com/pages/javascript/draggableDemo.html</em></a>,展示了这种类型界面的一个示例:</p> <pre><code class="language-py">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) </code></pre> <p>从演示页面的<code>message div</code>中打印出两条消息。第一条消息是:</p> <pre><code class="language-py">Prove you are not a bot, by dragging the square from the blue area to the red area! </code></pre> <p>然后,在任务完成后,内容再次打印出来,现在读取:</p> <pre><code class="language-py">You are definitely not a bot! </code></pre> <p>正如演示页面所示,将元素拖动以证明你不是机器人是许多验证码的共同主题。尽管机器人早就能够拖动物体(只需点击、按住和移动),但“拖动此物”作为验证人类的想法似乎无法消亡。</p> <p>此外,这些可拖动的验证码库很少使用任何对机器人困难的任务,例如“将小猫的图片拖到牛的图片上”(这需要你识别图片为“小猫”和“牛”,并解析指令);相反,它们通常涉及数字排序或类似前面示例中的其他相当琐碎的任务。</p> <p>当然,它们的强大之处在于其变化如此之多,而且使用频率如此之低;可能没有人会费力去制作一个能够击败所有验证码的机器人。无论如何,这个例子足以说明为什么你不应该在大型网站上使用这种技术。</p> <h3 id="拍摄截图">拍摄截图</h3> <p>除了通常的测试功能外,Selenium 还有一个有趣的技巧,可能会使你的测试(或者让你的老板印象深刻)更加轻松:截图。是的,可以从运行的单元测试中创建照片证据,而无需实际按下 PrtScn 键:</p> <pre><code class="language-py">driver = webdriver.Chrome() driver.get('http://www.pythonscraping.com/') driver.get_screenshot_as_file('tmp/pythonscraping.png') </code></pre> <p>此脚本导航到<a href="http://pythonscraping.com" target="_blank"><em>http://pythonscraping.com</em></a>,然后将首页的截图存储在本地的<em>tmp</em>文件夹中(此文件夹必须已经存在才能正确存储)。截图可以保存为多种图像格式。</p> <h1 id="第十九章并行网络爬虫">第十九章:并行网络爬虫</h1> <p>网络爬虫速度快。至少,通常比雇佣十几个实习生手工复制互联网数据要快得多!当然,科技的进步和享乐主义跑步机要求在某一点上甚至这都不够“快”。这就是人们通常开始寻求分布式计算的时候。</p> <p>与大多数其他技术领域不同,网络爬虫通常不能简单地通过“将更多的周期投入到问题中”来改进。运行一个进程是快速的;运行两个进程不一定是两倍快。运行三个进程可能会让你被禁止访问你正在猛击的远程服务器!</p> <p>但是,在某些情况下,并行网络爬虫或运行并行线程或进程仍然有益:</p> <ul> <li> <p>从多个来源(多个远程服务器)收集数据而不仅仅是单个来源</p> </li> <li> <p>在收集的数据上执行长时间或复杂的操作(例如进行图像分析或 OCR),这些操作可以与获取数据并行进行。</p> </li> <li> <p>从一个大型网络服务中收集数据,在这里你需要为每个查询付费,或者在你的使用协议范围内创建多个连接到服务。</p> </li> </ul> <h1 id="进程与线程">进程与线程</h1> <p>线程和进程不是 Python 特有的概念。虽然确切的实现细节在操作系统之间不同,并且依赖于操作系统,但计算机科学界的一般共识是,进程更大并且有自己的内存,而线程更小并且在包含它们的进程内共享内存。</p> <p>通常,当你运行一个简单的 Python 程序时,你在自己的进程中运行它,该进程包含一个线程。但是 Python 支持多进程和多线程。多进程和多线程都实现了同样的最终目标:以并行方式执行两个编程任务,而不是以更传统的线性方式一个接一个地运行函数。</p> <p>但是,你必须仔细考虑每个方案的利弊。例如,每个进程都有自己由操作系统分配的内存。这意味着内存不在进程之间共享。虽然多个线程可以愉快地写入相同的共享 Python 队列、列表和其他对象,但进程不能,必须更显式地传递这些信息。</p> <p>使用多线程编程在单独的线程中执行具有共享内存的任务通常被认为比多进程编程更容易。但是这种方便性是有代价的。</p> <p>Python 的全局解释器锁(GIL)用于防止多个线程同时执行同一行代码。GIL 确保所有进程共享的通用内存不会变得损坏(例如,内存中的字节一半被写入一个值,另一半被写入另一个值)。这种锁定使得编写多线程程序并在同一行内知道你得到什么成为可能,但它也有可能造成瓶颈。</p> <h1 id="多线程爬取">多线程爬取</h1> <p>以下示例说明了使用多线程执行任务:</p> <pre><code class="language-py">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] </code></pre> <p>这是对经典的<a href="http://wiki.c2.com/?FizzBuzzTest" target="_blank">FizzBuzz 编程测试</a>的参考,输出略显冗长:</p> <pre><code class="language-py">1 Counter 2 Counter 3 Fizz 3 Counter 4 Counter 5 Buzz 5 Counter 6 Fizz 6 Counter </code></pre> <p>脚本启动三个线程,一个每三秒打印一次“Fizz”,另一个每五秒打印一次“Buzz”,第三个每秒打印一次“Counter”。</p> <p>你可以在线程中执行有用的任务,如爬取网站,而不是打印 Fizz 和 Buzz:</p> <pre><code class="language-py">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] </code></pre> <p>注意包含这一行:</p> <pre><code class="language-py">time.sleep(5) </code></pre> <p>因为你几乎比单线程快了将近一倍的速度爬取维基百科,所以包含这一行可以防止脚本给维基百科的服务器造成过大负载。在实践中,在针对请求数量不是问题的服务器上运行时,应该删除这一行。</p> <p>如果你想要稍微改写这个例子,以便追踪线程迄今为止共同看到的文章,以便不会重复访问任何文章,你可以在多线程环境中使用列表的方式与在单线程环境中使用它一样:</p> <pre><code class="language-py">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)   ... </code></pre> <p>注意,将路径附加到已访问路径列表的操作是<code>scrape_article</code>执行的第一个动作。这减少了但并没有完全消除它被重复爬取的机会。</p> <p>如果你运气不好,两个线程仍然可能在同一瞬间偶然遇到相同的路径,两者都会看到它不在已访问列表中,然后都会将其添加到列表并同时进行爬取。但是,实际上由于执行速度和维基百科包含的页面数量,这种情况不太可能发生。</p> <p>这是一个<em>竞争条件</em>的例子。竞争条件对于有经验的程序员来说可能很难调试,因此评估代码中这些潜在情况,估计它们发生的可能性,并预测其影响的严重性是很重要的。</p> <p>在这种特定的竞争条件下,爬虫两次访问同一页的情况可能不值得去解决。</p> <h2 id="竞争条件和队列">竞争条件和队列</h2> <p>虽然你可以使用列表在线程之间通信,但列表并非专门设计用于线程之间通信,它们的错误使用很容易导致程序执行缓慢甚至由竞争条件导致的错误。</p> <p>列表适用于追加或读取,但不太适合从任意点删除项目,尤其是从列表开头删除。使用如下语句:</p> <pre><code class="language-py">myList.pop(0) </code></pre> <p>实际上需要 Python 重新编写整个列表,从而减慢程序执行速度。</p> <p>更危险的是,列表还使得在不是线程安全的情况下方便地写入一行。例如:</p> <pre><code class="language-py">myList[len(myList)-1] </code></pre> <p>在多线程环境中,这可能实际上并不会获取列表中的最后一个项目,或者如果计算 <code>len(myList)-1</code> 的值恰好在另一个操作修改列表之前立即进行,则可能会引发异常。</p> <p>有人可能会认为前面的陈述可以更“Python 化”地写成 <code>myList[-1]</code>,当然,像我这样的前 Java 开发者,在弱点时刻从未不小心写过非 Python 风格的代码(尤其是回想起像 <code>myList[myList.length-1]</code> 这样的模式的日子)!但即使您的代码毫无可指摘,也请考虑以下涉及列表的其他非线程安全代码形式:</p> <pre><code class="language-py">my_list[i] = my_list[i] + 1 my_list.append(my_list[-1]) </code></pre> <p>这两者都可能导致竞态条件,从而导致意外结果。您可能会尝试另一种方法,并使用除列表之外的其他变量类型。例如:</p> <pre><code class="language-py"># 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 </code></pre> <p>这似乎是一个很好的解决方案,直到您意识到在第一行和第二行之间的瞬间,您可能已经无意中覆盖了来自另一个线程的另一条消息,其文本为“我已检索到消息”。因此,现在您只需为每个线程构建一系列复杂的个人消息对象,并添加一些逻辑来确定谁获取什么……或者您可以使用专为此目的构建的 <code>Queue</code> 模块。</p> <p>队列是类似列表的对象,可以采用先进先出(FIFO)或后进先出(LIFO)方法。队列通过 <code>queue.put('My message')</code> 从任何线程接收消息,并可以将消息传输给调用 <code>queue.get()</code> 的任何线程。</p> <p>队列不是设计用来存储静态数据的,而是以线程安全的方式传输数据。从队列检索数据后,它应该仅存在于检索它的线程中。因此,它们通常用于委派任务或发送临时通知。</p> <p>这在网页抓取中可能很有用。例如,假设您希望将爬取器收集到的数据持久化到数据库中,并且希望每个线程能够快速持久化其数据。一个共享的单一连接可能会导致问题(单一连接无法并行处理请求),但每个爬取线程都分配自己的数据库连接也是没有意义的。随着爬取器的规模扩大(最终您可能从一百个不同的网站中收集数据,每个网站一个线程),这可能会转化为大量大多数空闲的数据库连接,仅在加载页面后偶尔进行写入。</p> <p>相反,您可以拥有少量数据库线程,每个线程都有自己的连接,等待从队列中接收并存储项目。这提供了一组更易管理的数据库连接:</p> <pre><code class="language-py">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] </code></pre> <p>此脚本创建三个线程:两个线程在维基百科上进行随机遍历页面,第三个线程将收集的数据存储在 MySQL 数据库中。有关 MySQL 和数据存储的更多信息,请参见第九章。</p> <p>此爬虫也比之前的版本简化了一些。它不再处理页面的标题和 URL,而是只关注 URL。此外,鉴于两个线程可能同时尝试将相同的 URL 添加到<code>visited</code>列表中,我已将此列表转换为集合。虽然它不严格地线程安全,但冗余设计确保任何重复不会对最终结果产生影响。</p> <h2 id="线程模块的更多特性">线程模块的更多特性</h2> <p>Python 的<code>threading</code>模块是在低级别<code>_thread</code>模块之上构建的高级接口。虽然<code>_thread</code>可以完全独立使用,但它需要更多的工作,而且不提供让生活变得如此愉快的小东西——例如便捷函数和巧妙功能。</p> <p>例如,您可以使用像<code>enumerate</code>这样的静态函数来获取通过<code>threading</code>模块初始化的所有活动线程列表,而无需自己跟踪它们。类似地,<code>activeCount</code>函数提供线程的总数。来自<code>_thread</code>的许多函数都有更方便或更易记的名称,例如<code>currentThread</code>而不是<code>get_ident</code>来获取当前线程的名称。</p> <p>关于线程模块的一个很好的特点是,可以轻松创建本地线程数据,这些数据对其他线程不可用。如果您有多个线程,每个线程分别从不同的网站抓取数据,并且每个线程跟踪其自己的本地已访问页面列表,这可能是一个不错的功能。</p> <p>此本地数据可以在线程函数内的任何点上通过调用<code>threading.local()</code>来创建:</p> <pre><code class="language-py">import threading def crawler(url):     data = threading.local()     data.visited = []     # Crawl site threading.Thread(target=crawler, args=('http://brookings.edu')).start() </code></pre> <p>这解决了在线程共享对象之间发生竞争条件的问题。每当一个对象不需要被共享时,它就不应该被共享,并且应该保留在本地线程内存中。为了安全地在线程之间共享对象,仍然可以使用前一节中的<code>Queue</code>。</p> <p>线程模块充当一种线程保姆,并且可以高度定制以定义这种保姆的具体任务。<code>isAlive</code>函数默认查看线程是否仍然活动。直到线程完成(或崩溃)时,该函数将返回<code>True</code>。</p> <p>通常,爬虫设计为长时间运行。<code>isAlive</code>方法可以确保如果线程崩溃,它将重新启动:</p> <pre><code class="language-py">threading.Thread(target=crawler) t.start() while True:     time.sleep(1)     if not t.isAlive():         t = threading.Thread(target=crawler)         t.start() </code></pre> <p>另外,可以通过扩展<code>threading.Thread</code>对象来添加其他监控方法:</p> <pre><code class="language-py">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() </code></pre> <p>这个新的<code>Crawler</code>类包含一个<code>isDone</code>方法,可以用来检查爬虫是否完成了爬取工作。如果还有一些需要完成的额外记录方法,以便线程不能关闭,但大部分爬取工作已经完成,这可能会很有用。通常情况下,<code>isDone</code>可以替换为某种状态或进度度量 - 例如已记录的页面数或当前页面。</p> <p>任何由<code>Crawler.run</code>引发的异常都会导致类重新启动,直到<code>isDone</code>为<code>True</code>并退出程序。</p> <p>在您的爬虫类中扩展<code>threading.Thread</code>可以提高它们的健壮性和灵活性,以及您同时监视许多爬虫的任何属性的能力。</p> <h1 id="多进程">多进程</h1> <p>Python 的<code>Processing</code>模块创建了可以从主进程启动和加入的新进程对象。以下代码使用了线程进程部分中的 FizzBuzz 示例来演示:</p> <pre><code class="language-py">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] </code></pre> <p>请记住,每个进程都被操作系统视为独立的个体程序。如果通过操作系统的活动监视器或任务管理器查看进程,您应该能看到这一点,就像在图 19-1 中所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_1901.png" alt="" loading="lazy"></p> <h6 id="图-19-1-在运行-fizzbuzz-时运行的五个-python-进程">图 19-1. 在运行 FizzBuzz 时运行的五个 Python 进程</h6> <p>第四个进程的 PID 为 76154,是运行中的 Jupyter 笔记本实例,如果您是从 IPython 笔记本中运行此程序,应该会看到它。第五个进程 83560 是执行的主线程,在程序首次执行时启动。PID 是由操作系统顺序分配的。除非在 FizzBuzz 脚本运行时有另一个程序快速分配 PID,否则您应该会看到另外三个顺序 PID - 在本例中为 83561、83562 和 83563。</p> <p>这些 PID 也可以通过使用<code>os</code>模块在代码中找到:</p> <pre><code class="language-py">import os ... `# prints the child PID` `os``.``getpid``(``)` `# prints the parent PID` os.getppid() </code></pre> <p>您程序中的每个进程应该为<code>os.getpid()</code>行打印不同的 PID,但在<code>os.getppid()</code>上将打印相同的父 PID。</p> <p>从技术上讲,对于这个特定程序,不需要几行代码。如果不包括结束的<code>join</code>语句:</p> <pre><code class="language-py">[p.join() for p in processes] </code></pre> <p>父进程仍将结束并自动终止子进程。但是,如果希望在这些子进程完成后执行任何代码,则需要这种连接。</p> <p>例如:</p> <pre><code class="language-py">[p.start() for p in processes] print('Program complete') </code></pre> <p>如果不包括<code>join</code>语句,输出将如下所示:</p> <pre><code class="language-py">Program complete 1 2 </code></pre> <p>如果包括<code>join</code>语句,程序将等待每个进程完成后再继续:</p> <pre><code class="language-py">[p.start() for p in processes] [p.join() for p in processes] print('Program complete') </code></pre> <pre><code class="language-py">... Fizz 99 Buzz 100 Program complete </code></pre> <p>如果您想要提前停止程序执行,您当然可以使用 Ctrl-C 来终止父进程。父进程的终止也将终止已生成的任何子进程,因此可以放心使用 Ctrl-C,不用担心意外地使进程在后台运行。</p> <h2 id="多进程爬取">多进程爬取</h2> <p>多线程维基百科爬取示例可以修改为使用单独的进程而不是单独的线程:</p> <pre><code class="language-py">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] </code></pre> <p>再次,您通过包含<code>time.sleep(5)</code>来人为地减慢爬虫的过程,以便可以在不对维基百科服务器施加不合理负载的情况下用于示例目的。</p> <p>在这里,您正在用<code>os.getpid()</code>替换传递为参数的用户定义的<code>thread_name</code>,这不需要作为参数传递,并且可以在任何时候访问。</p> <p>这将产生如下的输出:</p> <pre><code class="language-py">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 </code></pre> <p>理论上,与分开线程爬行相比,单独进程爬行略快,有两个主要原因:</p> <ul> <li> <p>进程不受 GIL 锁定的限制,可以同时执行相同的代码行并修改同一个(实际上是同一对象的不同实例化)对象。</p> </li> <li> <p>进程可以在多个 CPU 核心上运行,这可能会提供速度优势,如果您的每个进程或线程都是处理器密集型的。</p> </li> </ul> <p>然而,这些优势伴随着一个主要的缺点。在前述程序中,所有找到的 URL 都存储在全局的<code>visited</code>列表中。当您使用多个线程时,此列表在所有线程之间共享;除非存在罕见的竞争条件,否则一个线程无法访问已被另一个线程访问过的页面。然而,现在每个进程都获得其自己独立版本的 visited 列表,并且可以自由地访问其他进程已经访问过的页面。</p> <h2 id="在进程之间通信">在进程之间通信</h2> <p>进程在其自己独立的内存中运行,如果您希望它们共享信息,这可能会导致问题。</p> <p>将前面的示例修改为打印 visited 列表的当前输出,您可以看到这个原理的实际应用:</p> <pre><code class="language-py">def scrape_article(path): visited.append(path) print("Process {} list is now: {}".format(os.getpid(), visited)) </code></pre> <p>这导致输出如下所示:</p> <pre><code class="language-py">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'] </code></pre> <p>但是通过两种类型的 Python 对象:队列和管道,可以在同一台机器上的进程之间共享信息。</p> <p>一个<em>队列</em>与先前看到的线程队列类似。信息可以由一个进程放入队列,由另一个进程移除。一旦这些信息被移除,它就从队列中消失了。因为队列被设计为“临时数据传输”的一种方法,所以不适合保存静态引用,例如“已经访问过的网页列表”。</p> <p>但是,如果这个静态的网页列表被某种爬取委托替代怎么办?爬虫可以从一个队列中弹出一个路径来爬取(例如<em>/wiki/Monty_Python</em>),并且返回一个包含“找到的 URL”列表的独立队列,该队列将由爬取委托处理,以便只有新的 URL 被添加到第一个任务队列中:</p> <pre><code class="language-py">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() </code></pre> <p>这个爬虫与最初创建的爬虫之间存在一些结构上的差异。不同于每个进程或线程按照其分配的起始点进行自己的随机漫步,它们共同努力完成对网站的完整覆盖爬行。每个进程可以从队列中获取任何“任务”,而不仅仅是它们自己找到的链接。</p> <p>你可以看到它的实际效果,例如进程 97024 同时爬取<em>蒙提·派森</em>和<em>费城</em>(凯文·贝肯的电影):</p> <pre><code class="language-py">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 </code></pre> <h1 id="多进程爬虫另一种方法">多进程爬虫——另一种方法</h1> <p>所有讨论的多线程和多进程爬取方法都假设你需要对子线程和子进程进行某种形式的“家长监护”。你可以同时启动它们,你可以同时结束它们,你可以在它们之间发送消息或共享内存。</p> <p>但是,如果你的爬虫设计得不需要任何指导或通信呢?现在还没有理由着急使用<code>import _thread</code>。</p> <p>例如,假设你想并行爬取两个类似的网站。你编写了一个爬虫,可以通过一个小的配置更改或者命令行参数来爬取这两个网站中的任何一个。你完全可以简单地做以下操作:</p> <pre><code class="language-py">$ python my_crawler.py website1 </code></pre> <pre><code class="language-py">$ python my_crawler.py website2 </code></pre> <p>然后,你就启动了一个多进程网络爬虫,同时又节省了 CPU 因为要保留一个父进程而产生的开销!</p> <p>当然,这种方法也有缺点。如果你想以这种方式在<em>同一个</em>网站上运行两个网络爬虫,你需要某种方式来确保它们不会意外地开始爬取相同的页面。解决方案可能是创建一个 URL 规则(“爬虫 1 爬取博客页面,爬虫 2 爬取产品页面”)或者以某种方式划分网站。</p> <p>或者,你可以通过某种中间数据库来处理这种协调,比如<a href="https://redis.io/" target="_blank">Redis</a>。在前往新链接之前,爬虫可以向数据库发出请求:“这个页面已经被爬取过了吗?”爬虫将数据库用作进程间通信系统。当然,如果没有仔细考虑,这种方法可能会导致竞态条件或者如果数据库连接慢的话会有延迟(这可能只在连接到远程数据库时才会出现问题)。</p> <p>你可能还会发现,这种方法不太具有可扩展性。使用<code>Process</code>模块可以动态增加或减少爬取网站或存储数据的进程数量。通过手动启动它们,要么需要一个人物理上运行脚本,要么需要一个单独的管理脚本(无论是 bash 脚本、cron 作业还是其他方式)来完成这个任务。</p> <p>然而,我过去曾非常成功地使用过这种方法。对于小型的一次性项目来说,这是一种快速获取大量信息的好方法,尤其是跨多个网站。</p> <h1 id="第二十章网络抓取代理">第二十章:网络抓取代理</h1> <p>这是本书的最后一章也算是相当合适的。到目前为止,您一直在命令行中运行所有 Python 应用程序,限制在您的家用计算机的范围内。正如谚语所说:“如果你爱某物,请释放它。”</p> <p>尽管您可能会因为目前不“需要”此步骤而推迟此步骤,但当您停止尝试从笔记本电脑上运行 Python 网络爬虫时,您可能会惊讶于生活变得多么容易。</p> <p>此外,自 2015 年第一版本书出版以来,一个专门的网络抓取代理公司行业已经兴起并蓬勃发展。支付某人为您运行网络爬虫曾经是支付云服务器实例和在其上运行爬虫的事情。现在,您可以通过 API 请求基本上说“获取此网站”,一个远程程序将处理详细信息,处理任何安全问题,并将数据返回给您(当然要收费!)。</p> <p>在本章中,我们将介绍一些方法,可以通过远程 IP 地址路由您的请求,将软件托管和运行在其他地方,甚至完全将工作转移到网页抓取代理。</p> <h1 id="为什么要使用远程服务器">为什么要使用远程服务器?</h1> <p>尽管在推出面向广大用户使用的网络应用程序时,使用远程服务器似乎是一个显而易见的步骤,但程序员为自己目的构建的工具通常仍然在本地运行。在没有将程序移至其他地方的动机的情况下,为什么要做任何事情?通常将其移到其他地方的原因可以分为两大类:需要替代 IP 地址(要么是因为您的 IP 被阻止了,要么是为了防止被阻止),以及需要更强大和灵活性。</p> <h2 id="避免-ip-地址阻塞">避免 IP 地址阻塞</h2> <p>在构建网络爬虫时,一个经验法则是:几乎一切都可以伪造。您可以从您不拥有的地址发送电子邮件,从命令行自动化鼠标移动数据,甚至通过发送来自 Internet Explorer 9.0 的网站流量来惊吓网站管理员。</p> <p>唯一无法伪造的是您的 IP 地址。在现实世界中,任何人都可以寄给您一封信,署名是:“总统,华盛顿特区 1600 号宾夕法尼亚大道,20500 号。”然而,如果这封信的邮戳是从新墨西哥州的阿尔伯克基寄出的,您几乎可以确定您并没有在与美国总统通信。¹</p> <p>大多数阻止网络爬虫访问网站的方法集中在检测人类和机器人之间的差异。将 IP 地址封锁起来有点像农民放弃喷洒杀虫剂,而选择火烧田地。这是一种最后的但有效的丢弃来自问题 IP 地址发送的数据包的方法。但是,这种解决方案存在问题:</p> <ul> <li> <p>IP 地址访问列表的维护非常痛苦。尽管大型网站通常有自己的程序自动化管理这些列表的一些常规工作(机器人阻止机器人!),但仍然需要有人偶尔检查它们,或者至少监控它们的增长以便及时发现问题。</p> </li> <li> <p>每个地址在接收数据包时都会增加一小段处理时间,因为服务器必须根据列表检查接收到的数据包以决定是否批准它们。许多地址乘以许多数据包可能会迅速累加。为了节省处理时间和复杂性,管理员通常将这些 IP 地址分组成块,并制定诸如“这个范围内的所有 256 个地址都被阻止”的规则,如果有几个严密聚集的违规者的话。这导致我们到达第三点。</p> </li> <li> <p>IP 地址阻止可能会导致“好人”也被阻止访问。例如,我曾在 Olin College of Engineering 读本科时,有一名学生编写了一些软件,试图操纵当时流行的<a href="http://digg.com/" target="_blank"><em>http://digg.com</em></a>网站上的内容投票。这个软件被阻止了,而那个被阻止的单个 IP 地址导致整个宿舍无法访问该网站。学生简单地将他的软件移动到了另一台服务器;与此同时,Digg 失去了许多主要目标受众的常规用户页面访问量。</p> </li> </ul> <p>尽管它有缺点,但 IP 地址阻止仍然是服务器管理员阻止怀疑的网络爬虫访问服务器的极为常见的方法。如果一个 IP 地址被阻止,唯一的真正解决办法就是从不同的 IP 地址进行爬取。这可以通过将爬虫移动到新的服务器或者通过像 Tor 这样的服务将流量路由到不同的服务器来实现。</p> <h2 id="可移植性和可扩展性">可移植性和可扩展性</h2> <p>一些任务对家用计算机和互联网连接来说太大了。虽然你不想给任何单个网站带来大负载,但你可能正在跨越广泛的网站收集数据,因此需要比你当前设置提供的带宽和存储空间多得多。</p> <p>此外,通过将计算密集型处理转移,你可以释放出家用机器的周期来完成更重要的任务(<em>魔兽世界</em>,有人?)。你不必担心维持电力和互联网连接。你可以在星巴克启动你的应用程序,收拾好笔记本电脑,离开时仍然知道一切都在安全运行。同样,在以后只要有互联网连接的地方,你都可以访问你收集的数据。</p> <p>如果你有一个需要大量计算能力的应用程序,单个亚马逊超大型计算实例无法满足你的需求,你也可以考虑<em>分布式计算</em>。这允许多台机器并行工作以完成你的目标。举个简单的例子,你可以让一台机器爬取一组网站,另一台爬取另一组网站,并让它们都将收集到的数据存储在同一个数据库中。</p> <p>当然,正如前几章所指出的,许多人可以复制 Google 搜索的功能,但很少有人能够复制 Google 搜索的规模。分布式计算是计算机科学的一个广阔领域,超出了本书的范围。然而,学习如何将你的应用程序部署到远程服务器是必要的第一步,你可能会对现在计算机的功能感到惊讶。</p> <h1 id="tor">Tor</h1> <p>洋葱路由器网络,更为人熟知的是缩写为<em>Tor</em>的网络,是一组志愿者服务器,设置为通过许多层次(因此有洋葱参考)的不同服务器来路由和重新路由流量,以模糊其起源。数据在进入网络之前被加密,以便如果任何特定服务器被监听,通信的性质就无法被揭示。此外,尽管任何特定服务器的入站和出站通信可能会被破坏,但为了解密通信的真实起点和终点,你需要知道沿通信路径的<em>所有</em>服务器的入站和出站通信的详细信息——这几乎是不可能的壮举。</p> <p>Tor 通常被人权工作者和政治告密者用于与记者交流,并且它的大部分资金来自美国政府。当然,它也经常被用于非法活动,因此仍然是政府监视的持续目标——尽管目前尚不清楚这种监视的有用性。</p> <h1 id="tor-匿名性的限制">Tor 匿名性的限制</h1> <p>尽管你在本书中使用 Tor 的原因是为了更改你的 IP 地址,而不是实现完全的匿名性,但值得花一点时间来讨论 Tor 匿名流量的一些优点和限制。</p> <p>尽管在使用 Tor 时你可以假设你的 IP 地址对于网站服务器来说是不可追踪的,但你与该网站分享的任何信息都可能暴露你的身份。例如,如果你登录自己的 Gmail 账户然后进行具有指控性的 Google 搜索,这些搜索现在就可以与你的身份联系起来。</p> <p>除了显而易见的之外,甚至登录 Tor 的行为本身可能对你的匿名性构成危险。2013 年 12 月,一名哈佛大学本科生试图逃避期末考试,通过 Tor 网络向学校发送了一封炸弹威胁邮件,使用了匿名邮件账户。当哈佛的 IT 团队查看他们的日志时,他们发现在发送炸弹威胁时,只有一台机器注册了一个已知学生的 Tor 网络流量。尽管他们无法确定这些流量的最终目的地(只知道它是通过 Tor 发送的),但时间上的匹配以及在该时间段内只有一台机器登录的事实已足以起诉这名学生。²</p> <p>登录 Tor 并不是一个自动的隐形斗篷,也不会让您在互联网上随意行事。虽然它是一个有用的工具,但请确保谨慎使用,智慧使用,当然还有道德。</p> <p>安装和运行 Tor 是使用 Python 与 Tor 的必备条件,正如你将在下一节看到的那样。幸运的是,Tor 服务安装和启动非常简单。只需访问<a href="https://www.torproject.org/download" target="_blank">Tor 下载页面</a>,下载、安装、打开并连接即可。请注意,使用 Tor 时你的互联网速度可能会变慢。耐心一点——可能它要绕地球几次!</p> <h2 id="pysocks">PySocks</h2> <p>PySocks 是一个非常简单的 Python 模块,能够通过代理服务器路由流量,并且与 Tor 一起工作效果非常好。您可以从<a href="https://pypi.python.org/pypi/PySocks/1.5.0" target="_blank">其网站</a>下载它,或者使用任何第三方模块管理器来安装它。</p> <p>尽管对于此模块并不存在太多的文档,但使用它非常简单。运行此代码时,Tor 服务必须在端口 9150 上运行(默认端口):</p> <pre><code class="language-py">import socks import socket from urllib.request import urlopen socks.set_default_proxy(socks.PROXY_TYPE_SOCKS5, "localhost", 9150) socket.socket = socks.socksocket print(urlopen('http://icanhazip.com').read()) </code></pre> <p>网站<a href="http://icanhazip.com" target="_blank"><em>http://icanhazip.com</em></a>仅显示连接到服务器的客户端的 IP 地址,对于测试目的可能很有用。运行此脚本时,应显示一个不是您自己的 IP 地址。</p> <p>如果您想要使用 Selenium 和 ChromeDriver 与 Tor,您完全不需要 PySocks——只需确保 Tor 当前正在运行,并添加可选的<code>proxy-server</code> Chrome 选项,指定 Selenium 应该连接到端口 9150 上的 socks5 协议:</p> <pre><code class="language-py">from selenium import webdriver from selenium.webdriver.chrome.service import Service from selenium.webdriver.chrome.options import Options from webdriver_manager.chrome import ChromeDriverManager CHROMEDRIVER_PATH = ChromeDriverManager().install() driver = webdriver.Chrome(service=Service(CHROMEDRIVER_PATH)) chrome_options = Options() chrome_options.add_argument('--headless') chrome_options.add_argument('--proxy-server=socks5://127.0.0.1:9150') driver = webdriver.Chrome(   service=Service(CHROMEDRIVER_PATH),   options=chrome_options ) driver.get('http://icanhazip.com') print(driver.page_source) driver.close() </code></pre> <p>再次强调,这应该打印出一个不是您自己的 IP 地址,而是您当前正在使用的 Tor 客户端的 IP 地址。</p> <h1 id="远程托管">远程托管</h1> <p>虽然在您使用信用卡时完全匿名性会丢失,但远程托管您的网络爬虫可能会显著提高其速度。这是因为你可以购买比你拥有的大得多的机器时间,但也因为连接不再需要通过多层 Tor 网络到达目的地。</p> <h2 id="从网站托管账户运行">从网站托管账户运行</h2> <p>如果你有个人或商业网站,你可能已经拥有从外部服务器运行网络爬虫的手段了。即使在相对封闭的 Web 服务器上,你无法访问命令行,也可以通过 Web 界面触发脚本的启动和停止。</p> <p>如果您的网站托管在 Linux 服务器上,服务器很可能已经运行 Python。如果您在 Windows 服务器上托管,您可能没有运气;您需要具体检查 Python 是否已安装,或者服务器管理员是否愿意安装它。</p> <p>大多数小型 Web 托管提供商配备了称为<em>cPanel</em>的软件,用于提供基本管理服务以及有关您的网站和相关服务的信息。如果您可以访问 cPanel,您可以通过转到 Apache Handlers 并添加新处理程序(如果尚不存在)来确保 Python 已设置为在服务器上运行:</p> <pre><code class="language-py">Handler: cgi-script Extension(s): .py </code></pre> <p>这告诉您的服务器,所有 Python 脚本都应作为<em>CGI 脚本</em>执行。CGI 代表<em>通用网关接口</em>,是可以在服务器上运行并动态生成显示在网站上的内容的任何程序。通过明确将 Python 脚本定义为 CGI 脚本,您正在授权服务器执行它们,而不仅仅是在浏览器中显示或向用户发送下载。</p> <p>编写您的 Python 脚本,将其上传到服务器,并将文件权限设置为 755 以允许执行。要执行脚本,请通过浏览器导航到您上传的位置(或者更好地编写一个爬虫来代劳)。如果您担心一般公众能够访问和执行脚本,您有两个选择:</p> <ul> <li> <p>将脚本存储在不常见或隐藏的 URL,并确保从任何其他可访问 URL 中不链接到脚本,以避免搜索引擎索引它。</p> </li> <li> <p>用密码保护脚本,或要求在执行之前发送密码或秘密令牌。</p> </li> </ul> <p>当然,从专门设计用于显示网站的服务运行 Python 脚本有些取巧。例如,您可能会注意到您的网络爬虫兼网站加载速度有点慢。事实上,页面实际上并没有加载(包括您可能编写的所有<code>print</code>语句的输出),直到整个抓取完成为止。这可能需要几分钟、几小时或根本不会完成,这取决于编写的方式。虽然它确实完成了工作,但您可能希望获得更多实时输出。为此,您需要一个不仅仅是为 Web 设计的服务器。</p> <h2 id="从云端运行">从云端运行</h2> <p>回到计算机的旧时代,程序员为了执行其代码而支付或预留了计算机上的时间。随着个人计算机的出现,这变得不再必要——您只需在自己的计算机上编写和执行代码。现在,程序员们再次转向按小时支付的计算实例。</p> <p>然而,这一次,用户不是为单一物理机器的时间付费,而是为其等效计算能力付费,通常分布在多台机器之间。这种系统的模糊结构允许计算能力根据高峰需求时段定价。例如,亚马逊允许在低成本更重要的情况下对“竞价实例”进行竞标。</p> <p>计算实例还更专业化,可以根据应用程序的需求进行选择,例如“高内存”、“快速计算”和“大容量存储”。虽然 Web 爬虫通常不使用太多内存,但对于您的爬取应用程序,您可能希望考虑大容量存储或快速计算,而不是选择更通用的实例。如果您进行大量的自然语言处理、OCR 工作或路径搜索(例如维基百科的六度分隔问题),快速计算实例可能非常适合。如果您正在爬取大量数据、存储文件或进行大规模分析,您可能需要选择一个具有存储优化的实例。</p> <p>虽然支出可以无限制地增加,但截至目前,最便宜的 Google 实例 f1-micro 每小时仅需 0.9 美分(不到一分钱),相当于 Amazon EC2 微型实例的每小时 0.8 美分。由于规模经济效应,购买大公司的小型计算实例几乎总是比购买自己的物理专用机器便宜。因为现在您不需要雇佣 IT 人员来保持其运行。</p> <p>当然,本书不涵盖逐步设置和运行云计算实例的详细说明,但您可能会发现并不需要逐步说明。Amazon 和 Google(更不用说行业中无数的小公司了)为争夺云计算市场份额,已经使得设置新实例就像按照简单的提示操作、考虑一个应用程序名称并提供信用卡号码那样简单。截至目前,Amazon 和 Google 还提供价值数百美元的免费计算小时,以进一步吸引新客户。</p> <p>如果您是云计算的新手,DigitalOcean 也是一个提供计算实例(他们称之为 droplets)的优秀供应商,起价每小时仅为 0.6 美分。他们拥有非常简单的用户界面,并通过电子邮件向您发送任何新创建的 droplet 的 IP 地址和凭据,以便您登录并开始运行。虽然他们更专注于 Web 应用程序托管、DNS 管理和负载均衡,但您可以从您的实例运行任何您想要的东西!</p> <p>一旦您设置好实例,您将成为一个有 IP 地址、用户名和公私钥的自豪新机主,这些可以用于通过 SSH 连接到您的实例。从那里开始,一切都应该和操作您自己的服务器一样——当然,您不再需要担心硬件维护或运行自己的各种高级监控工具。</p> <p>对于快速且简单的任务,特别是如果你没有处理 SSH 和密钥对的经验,我发现 Google 的 Cloud 平台实例可以更容易地立即启动和运行。它们有一个简单的启动器,甚至在启动后还提供一个按钮,可以在浏览器中查看 SSH 终端,如图 20-1 所示。</p> <p><img src="https://gitee.com/OpenDocCN/ibooker-python-zh/raw/master/docs/web-scp-py-3e/img/wsp3_2001.png" alt="" loading="lazy"></p> <h6 id="图20-1-来自正在运行的-google-cloud-平台-vm-实例的基于浏览器的终端">图 20-1. 来自正在运行的 Google Cloud 平台 VM 实例的基于浏览器的终端</h6> <h2 id="展望未来">展望未来</h2> <p>互联网在不断变化。为我们带来图像、视频、文本和其他数据文件的技术正在不断更新和重新发明。为了跟上步伐,从互联网抓取数据所使用的技术集合也必须发生变化。</p> <p>谁知道呢?未来版本的本文可能完全省略 JavaScript,因为它是过时且很少使用的技术,而转而专注于 HTML8 全息解析。然而,不会改变的是成功地抓取任何网站(或者未来用于“网站”的东西)所需的思维方式和一般方法。</p> <p>在任何网页抓取项目中,你应该始终问自己:</p> <ul> <li> <p>我想要回答的问题是什么,或者我想要解决的问题是什么?</p> </li> <li> <p>什么数据可以帮助我实现这一目标?它在哪里?</p> </li> <li> <p>网站如何显示这些数据?我能否准确地识别出网站代码的哪一部分包含这些信息?</p> </li> <li> <p>如何隔离数据并获取它?</p> </li> <li> <p>为了使其更有用,需要进行哪些处理或分析?</p> </li> <li> <p>如何使这个过程更好、更快、更强大?</p> </li> </ul> <p>此外,你不仅需要理解如何独立使用本书中介绍的工具,还要理解它们如何共同解决更大的问题。有时数据很容易获取且格式良好,可以使用简单的抓取器来完成。其他时候,你需要多加思考。</p> <p>在第十六章中,例如,你结合了 Selenium 库来识别亚马逊上加载的 Ajax 图像,并使用 Tesseract 进行 OCR 读取。在“维基百科六度分离问题”中,你使用正则表达式编写了一个爬虫,将链接信息存储在数据库中,然后使用图解算法回答了“凯文·贝肯与埃里克·爱德尔之间的最短链接路径是什么”的问题。</p> <p>在自动化数据收集方面,几乎没有无法解决的问题。只要记住:互联网就是一个巨大的 API,其用户界面相对较差。</p> <h1 id="网络抓取代理">网络抓取代理</h1> <p>本书讨论了许多产品和技术,重点放在自由和开源软件上。在讨论付费产品的情况下,通常是因为不存在免费替代品,不切实际,和/或者付费产品非常普及,如果不提及会感到有所遗漏。</p> <p>网页抓取代理和 API 服务行业是一个有点奇怪的行业。它是新兴的、相对小众的,但仍然极度拥挤,入门门槛较低。正因为如此,目前还没有任何大家都会同意需要讨论的大型“家喻户晓的名字”。是的,有些名字比其他的大,有些服务比其他的更好,但这个行业确实相当混乱。</p> <p>另外,因为网页抓取代理需要大量设备和电力运行,目前不存在可行的免费替代方案,未来也不太可能存在。</p> <p>这让我处于一个棘手的位置,需要写一些你可能没有听说过但却想要你的钱的公司。请放心,虽然我对这些公司有自己的看法,但我没有因此被付费。我曾使用过它们的服务,与它们的代表交谈过,并且在几种情况下因研究目的而获得了免费的账户积分,但我没有任何推广它们的动机。我对这些公司没有任何财务或情感投资。</p> <p>当您阅读本节时,我建议您更广泛地考虑网页抓取代理和 API 服务的属性、它们的特长、您的预算以及项目需求。这些档案被设计为案例研究和“市场上有什么”的示例,而不是特定的认可。如果您确实想要向其中某些公司付款,那就是您和它们之间的事情!</p> <h2 id="scrapingbee">ScrapingBee</h2> <p>ScrapingBee 是这份清单中最小的公司。它专注于 JavaScript 自动化、无头浏览器和外观不起眼的 IP 地址。其 API 文档完善,但如果您不喜欢阅读,ScrapingBee 网站还提供了 API 请求生成工具,可以通过按钮点击和复制/粘贴来解决问题。</p> <p>在评估代理服务时需要考虑的一个重要特性是返回请求数据所需的时间。请求不仅需要从您的计算机路由到它们的服务器再到目标服务器,然后再返回,而且代理服务可能会在其端口缓冲这些请求,不会立即发送出去。请求返回需要一分钟甚至更长时间并不罕见。在正式评估过程中,重要的是在一天中的不同时间测量这些请求,并对可能要使用的任何功能进行多次请求。</p> <p>直接使用 ScrapingBee 的 API,我们可以抓取产品页面并打印结果及其获取所需时间:</p> <pre><code class="language-py">import requests import time  start = time.time() params = {     'api_key': SCRAPING_BEE_KEY,     'url': 'https://www.target.com/p/-/A-83650487', } response = requests.get('https://app.scrapingbee.com/api/v1/', params=params) print(f'Time: {time.time() - start}') print(f'HTTP status: {response.status_code}') print(f'Response body: {response.content}') </code></pre> <p>ScrapingBee 还有一个可以通过 pip 安装的<a href="https://pypi.org/project/scrapingbee/" target="_blank">Python 包</a>:</p> <pre><code class="language-py">$ pip install scrapingbee </code></pre> <p>这是一个软件开发工具包(SDK),它让您以稍微更方便的方式使用 API 的各种功能。例如,上述请求可以编写为:</p> <pre><code class="language-py">from scrapingbee import ScrapingBeeClient start = time.time() client = ScrapingBeeClient(api_key=SCRAPING_BEE_KEY) response = client.get('https://www.target.com/p/-/A-83650487') print(f'Time: {time.time() - start}') print(f'HTTP status: {response.status_code}') print(f'Response body: {response.content}') </code></pre> <p>请注意,响应是 Python requests 响应,可以像前面的示例一样使用。</p> <p>抓取 API 服务通常以“积分”为单位,其中一个基本 API 请求花费一个积分。使用无头浏览器进行 JavaScript 渲染或居住 IP 地址等功能可能需要从 5 个积分到 75 个积分不等。每个付费账户级别每月提供一定数量的积分。</p> <p>尽管有 1,000 个免费试用积分,ScrapingBee 的付费订阅从每月 $50 起,可获得 150,000 个积分,即每美元 3,000 个积分。与大多数这类服务一样,有大量的量级折扣 —— 每月支出增加,积分可以降低到每美元 13,000 个或更少。</p> <p>如果您想最大化请求,请注意,ScrapingBee 对 JavaScript 渲染收取 5 个积分,并默认打开。这意味着上述请求每个都将花费 5 个积分,而不是 1 个。</p> <p>这对于可能没有阅读本书 第十四章 的客户来说非常方便,他们不理解为什么在他们的网页浏览器中看到的数据在从 ScrapingBee 返回的抓取结果中没有出现。如果这些客户阅读了 第十五章,他们还会了解如何在完全不使用 JavaScript 渲染的情况下获取他们想要的数据。如果您已经阅读了这两章,可以关闭 JavaScript 渲染并将请求成本降低 80% 使用:</p> <pre><code class="language-py">client = ScrapingBeeClient(api_key=SCRAPING_BEE_KEY) params = {'render_js': 'false'} response = client.get('https://www.target.com/p/-/A-83650487', params=params) </code></pre> <p>像许多这类服务一样,ScrapingBee 提供使用“高级” IP 地址的选项,这可能会防止您的爬虫被警惕频繁使用的 IP 地址的网站屏蔽。这些 IP 地址被报告为由较小的电信公司拥有的住宅地址。如果这还不够,ScrapingBee 还提供每次请求 75 个积分的“隐身”IP 地址。我得到的隐身 IP 地址被列为数据中心和 VPN 服务器,所以目前尚不清楚隐身 IP 地址具体是什么,以及相对于高级地址提供了什么真正的优势。</p> <h2 id="scraperapi">ScraperAPI</h2> <p>如其名,ScraperAPI 拥有大多数清洁和符合 REST 原则的 API,具有大量功能。它支持异步请求,允许您发出抓取请求,并在稍后的 API 调用中获取结果。或者,您可以提供一个 Webhook 端点,在请求完成后将结果发送到该端点。</p> <p>ScraperAPI 的简单一积分调用如下所示:</p> <pre><code class="language-py">import requests import time  start = time.time() params = {     'api_key': SCRAPER_API_KEY,     'url': 'https://www.target.com/p/-/A-83650487' } response = requests.get('http://api.scraperapi.com', params=params) print(f'Time: {time.time() - start}') print(f'HTTP status: {response.status_code}') print(f'Response body: {response.content}') </code></pre> <p>ScraperAPI 还有一个可以用 pip 安装的 SDK:</p> <pre><code class="language-py">$ pip install scraperapi-sdk </code></pre> <p>与大多数这类 SDK 一样,它只是 Python requests 库的一个非常薄的包装。与 ScrapingBee API 一样,返回一个 Python Requests 响应:</p> <pre><code class="language-py">from scraper_api import ScraperAPIClient client = ScraperAPIClient(SCRAPER_API_KEY) start = time.time() result = client.get('https://www.target.com/p/-/A-83650487') print(f'Time: {time.time() - start}') print(f'HTTP status: {response.status_code}') print(f'Response body: {response.content}') </code></pre> <p>在评估网络爬取服务时,可能会倾向于偏爱围绕其 API 构建 Python SDK 的服务。然而,您应该仔细考虑它将减少多少编程工作量或提供多少便利。技术上,可以轻松地围绕任何爬取 API 编写 Python “SDK”,包括您自己的。此示例 SDK 仅围绕想象中的 API 编写了几行代码:</p> <pre><code class="language-py">class RyansAPIClient:     def __init__(self, key):         self.key = key         self.api_root = 'http://api.pythonscraping.com/ryansApiPath'     def get(url):   params = {'key': self.key, 'url': url}         return requests.get(self.api_root, params=params) </code></pre> <p>但 ScraperAPI 的一个独特功能是其自动解析工具,适用于亚马逊产品和谷歌搜索结果。请求亚马逊产品页面或亚马逊或谷歌搜索结果页面的成本为 5 个积分,而大多数请求只需 1 个积分。尽管文档确实提到了对亚马逊产品端点的显式调用 <a href="https://api.scraperapi.com/structured/amazon/product" target="_blank"><em>https://api.scraperapi.com/structured/amazon/product</em></a>,但此服务似乎默认已打开:</p> <pre><code class="language-py">from scraper_api import ScraperAPIClient client = ScraperAPIClient(SCRAPER_API_KEY) start = time.time() result = client.get('https://www.amazon.com/Web-Scraping-Python-Collecting\ -Modern/dp/1491985577') print(f'Time: {time.time() - start}') print(f'HTTP status: {response.status_code}') print(f'Response body: {response.text}') </code></pre> <p>有了响应:</p> <pre><code class="language-py">Time: 4.672130823135376 HTTP status: 200 Response body: {"name":"Web Scraping with Python: Collecting More Data from the Modern Web","product_information":{"publisher": "‎O'Reilly Media; 2nd edition (May 8, 2018)","language":"‎English", "paperback":"‎306 pages","isbn_10":"‎1491985577","isbn_13": "‎978-1491985571","item_weight":"‎1.21 pounds" ... </code></pre> <p>虽然编写亚马逊产品解析工具并不是一项难以克服的挑战,但在多年的测试和维护解析工具责任上进行卸载可能是非常值得的成本。</p> <p>如前所述,ScraperAPI 还允许您对其 API 发出异步请求,并在稍后的时间获取结果。此请求在返回时少于<code>100 ms</code>:</p> <pre><code class="language-py">start = time.time() params = {     'apiKey': SCRAPER_API_KEY,     'url': 'https://www.target.com/p/-/A-83650487' } response = requests.post('https://async.scraperapi.com/jobs', json=params) print(f'Time: {time.time() - start}') print(f'HTTP status: {response.status_code}') print(f'Response body: {response.content}') </code></pre> <p>请注意,这是一个 <code>POST</code> 请求,而不是前面示例中的 <code>GET</code> 请求。在某种意义上,我们正在向 ScraperAPI 的服务器发布用于创建存储实体的数据。此外,用于发送密钥的属性从 <code>api_key</code> 变更为 <code>apiKey</code>。</p> <p>响应主体仅包含可以获取作业的 URL:</p> <pre><code class="language-py">Time: 0.09664416313171387 HTTP status: 200 Response body: b'{"id":"728a365b-3a2a-4ed0-9209-cc4e7d88de96", "attempts":0,"status":"running","statusUrl":"https://async. scraperapi.com/jobs/728a365b-3a2a-4ed0-9209-cc4e7d88de96", "url":"https://www.target.com/p/-/A-83650487"}' </code></pre> <p>调用它不需要 API 密钥——UUID 在这里足够作为安全措施——并且假设他们端已完成请求,则返回目标的主体:</p> <pre><code class="language-py">response = requests.get('https://async.scraperapi.com/jobs/\ 728a365b-3a2a-4ed0-9209-cc4e7d88de96') print(f'Response body: {response.content}') </code></pre> <p>这些异步请求的结果将存储最多四个小时,或直到您检索数据。虽然您可以在家里通过多线程爬虫和少量代码实现类似的结果,但在旋转住宅和移动 IP 地址、更改原始国家、管理会话数据、呈现所有 JavaScript(这会快速使机器变慢)并在仪表板中跟踪所有成功和失败时,您不能轻松地做到这一点。</p> <p>异步请求和 Webhooks(代理服务将结果返回到您提供的 URL)是 API 服务中的出色功能,特别适用于较大和长时间运行的爬取项目。ScraperAPI 提供此功能,每次请求均不需额外费用,这尤为方便。</p> <h2 id="oxylabs">Oxylabs</h2> <p>Oxylabs 是一家总部位于立陶宛的大型公司,专注于搜索引擎结果页面(SERP)和产品页面的抓取。其产品生态系统和 API 有一定的学习曲线。创建账户后,您必须激活(使用一周试用或付费订阅)您想要使用的每个“产品”,并创建与每个产品特定的用户名/密码凭据。这些用户名/密码凭据有点类似于 API 密钥。</p> <p>Web Scraper API 产品允许您进行如下调用,使用 Web Scraper API 用户名和密码:</p> <pre><code class="language-py">import requests import time start = time.time() data = {     'url': 'https://www.target.com/p/-/A-83650487',     'source': 'universal', } response = requests.post(     'https://realtime.oxylabs.io/v1/queries',     auth=(OXYLABS_USERNAME, OXYLABS_PASSWORD),     json=data ) response = response.json()['results'][0] print(f'Time: {time.time() - start}') print(f'HTTP status: {response["status_code"]}') print(f'Response body: {response["content"]}') </code></pre> <p>但是,如果目标 URL 切换为 amazon.com 域,则用户可能会感到惊讶:</p> <pre><code class="language-py">data = {     'url': 'https://www.amazon.com/Web-Scraping-Python-Collecting-Modern\ -dp-1491985577/dp/1491985577',     'source': 'universal', } response = requests.post(     'https://realtime.oxylabs.io/v1/queries',     auth=(OXYLABS_USERNAME, OXYLABS_PASSWORD), ) print(response.json()) </code></pre> <p>此代码打印出错误消息:</p> <pre><code class="language-py">{'message': 'provided url is not supported'} </code></pre> <p>类似于 ScraperAPI,Oxylabs 还有专门设计用于网站如 Amazon 和 Google 的解析工具。然而,要解析这些域名,无论是否使用特殊的解析工具,您都必须专门订阅 SERP Scraper API 产品(用于抓取 Google、Bing、百度或 Yandex)或 E-Commerce Scraper API 产品(用于抓取 Amazon、Aliexpress、eBay 等)。</p> <p>如果订阅了 E-Commerce Scraper API 产品,则可以通过将 <code>source</code> 属性更改为 <code>amazon</code> 并传递特定于电子商务的凭据成功抓取 Amazon 域:</p> <pre><code class="language-py">data = {     'url': 'https://www.amazon.com/Web-Scraping-Python-Collecting-Modern\ -dp-1491985577/dp/1491985577',     'source': 'amazon', } response = requests.post(     'https://realtime.oxylabs.io/v1/queries',     auth=(OXYLABS_USERNAME_ECOMMERCE, OXYLABS_PASSWORD),     json=data ) </code></pre> <p>它并没有做任何特殊处理;它只是像往常一样返回页面内容。要使用产品信息格式化模板,我们还必须将属性 <code>parse</code> 设置为 <code>True</code>:</p> <pre><code class="language-py">data = {     'url': 'https://www.amazon.com/Web-Scraping-Python-Collecting-Modern\ -dp-1491985577/dp/1491985577',     'source': 'amazon',   'parse': True } </code></pre> <p>这会解析网站并返回格式化的 JSON 数据:</p> <pre><code class="language-py">... 'page': 1, 'price': 32.59, 'stock': 'Only 7 left in stock - order soon', 'title': 'Web Scraping with Python: Collecting More Data from the Modern Web', 'buybox': [{'name': 'buy_new', 'price': 32.59, 'condition': 'new'}, ... </code></pre> <p>需要记住的是,解析工具本身并不专门针对 E-Commerce Scraper API 产品。我们也可以使用常规的 Web Scraper API 产品解析 target.com 域,将源设置回 universal 并使用 Web Scraper API 凭据:</p> <pre><code class="language-py">data = {     'url': 'https://www.target.com/p/-/A-83650487',     'source': 'universal',     'parse': True } response = requests.post(     'https://realtime.oxylabs.io/v1/queries',     auth=(OXYLABS_USERNAME, OXYLABS_PASSWORD),     json=data ) </code></pre> <p>这将返回 JSON 格式的产品数据:</p> <pre><code class="language-py">'url': 'https://www.target.com/p/-/A-83650487', 'price': 44.99, 'title': 'Web Scraping with Python - 2nd Edition by Ryan Mitchell (Paperback)', 'category': 'Target/Movies, Music & Books/Books/All Book Genres/Computers & Techn ology Books', 'currency': 'USD', 'description': 'Error while parsing `description`: `(<class \'AttributeError\'>, AttributeError("\'NoneType\' object has no attribute \'xpath\'"))`.', 'rating_sco re': 0, 'parse_status_code': 12004 </code></pre> <p>因为它尝试自动解析目标域名为 target.com 的页面,所以可能会偶尔遇到错误,就像在描述中所做的那样。幸运的是,用户还可以编写自定义解析器,这些解析器与任何 API 产品类型兼容(Web Scraper API、SERP Scraper API、E-Commerce Scraper API 等)。这些自定义解析器采用由 Oxylabs 指定的 JSON 文件格式,定义了各种字段和收集数据的 XPath 选择器。</p> <p>这些自定义解析器本质上就是网页抓取器的“业务逻辑”。值得考虑的是,如果您转移到另一个网页抓取 API 或代理平台,这些模板基本上将变得无用,并且需要进行大幅修改、重写,或者您的新代码库需要专门编写以使其与其它平台兼容。在 Oxylabs 特定语言中编写网页抓取模板可能会在选择其他平台时有所限制。</p> <p>还需强调一点,这些不同的 API“产品”(实际上使用相同的 API 端点和调用结构)的定义,并非基于它们的特定功能,而是基于它们被允许发送请求的领域,这些领域随时可能发生变化。</p> <p>特定产品管辖的领域可能并不一定得到该产品的良好支持。例如,Oxylab 的 SERP Scraping API 宣传支持百度和必应等网站,但并未为它们开发解析模板。这种“支持”可能仅仅是能够指定类似于以下搜索的能力:</p> <pre><code class="language-py">data = {     'query': 'foo',     'source': 'bing_search', } </code></pre> <p>而不是完整写出 URL:</p> <pre><code class="language-py">data = {     'url': 'https://bing.com?q=foo',     'source': 'bing', } </code></pre> <p>请注意,虽然我对 Oxylab 的某些 API 产品持批评态度,但这些批评并不针对公司本身,也不应被视为全面审查或推荐。我只是将其作为一个案例研究或供将来评估类似产品的人参考的例子。</p> <p>在评估 API 和网络爬取服务时,始终要考虑广告宣传内容、实际提供内容以及目标受众是谁。API 调用的结构可能揭示关于产品实际构建的重要信息,甚至文档也可能具有误导性。</p> <p>Oxylabs 也有许多优点。它是最佳代理 IP 地址提供商之一。Oxylabs 持续获取各种类型和大量 IP 地址,公开列出的类型包括住宅、移动和数据中心。与其他代理服务一样,这些 IP 地址的成本较高,具体费用根据类型而定。但是,Oxylabs 按 GB 计费这些代理服务,而不是按请求计费。目前,成本从每 GB 的低容量移动 IP 地址 22 美元到每 GB 的高容量住宅 IP 地址 8 美元不等。</p> <h2 id="zyte">Zyte</h2> <p>Zyte,之前名为 Scrapinghub,是另一家大型网络爬虫代理和 API 服务公司。成立于 2010 年,也是最早之一。虽然我对这些公司没有特别的偏好,但如果说作为 Scrapy 的维护者,Zyte 在某种程度上确实脱颖而出。自 2019 年起,它还主办<a href="https://www.extractsummit.io" target="_blank">Web 数据抽取峰会</a>。</p> <p>作为一家大公司,Zyte 拥有前述公司的大多数功能,甚至更多。与大多数其他公司不同的是,它还直接销售数据。例如,如果你需要职位发布信息、房地产数据或产品信息,它可以提供这些数据集,或提供能够构建定制数据集的顾问。</p> <p>Zyte 维护着 Scrapy,并将其以 Scrapy Cloud 的形式纳入其产品线。这个工具允许你从 GitHub 仓库或本地机器使用<a href="https://pypi.org/project/shub/" target="_blank">Scrapinghub 命令行客户端</a>在云中部署和运行 Scrapy 项目。这使得你的网络爬虫可以跨平台且可移植,同时与 Zyte 生态系统紧密集成。</p> <p>一旦部署了一个 Scrapy 项目,Zyte 会找到项目中的所有蜘蛛类,并自动加载到您的仪表板中。您可以使用 Zyte 的仪表板 UI 启动和监视这些蜘蛛在云中运行,然后查看或下载生成的数据。</p> <p>当然,Zyte 还有一个 API。它在某种程度上类似于其他 API,因为它大量依赖于 Python 的 requests 包。它也类似于 Oxylab 的 API,因为它完全使用 POST 方法以及 HTTP 基本身份验证。然而,与 Oxylab 不同的是,只有一个 Zyte 密钥通过基本身份验证发送,而不是用户名和密码:</p> <pre><code class="language-py">import time from base64 import b64decode import requests json_data = {     'url': 'https://www.target.com/p/-/A-83650487',     'httpResponseBody': True, } start = time.time() response = requests.post('https://api.zyte.com/v1/extract',     auth=(ZYTE_KEY, ''), json=json_data) response = response.json() print(f'Time: {time.time() - start}') print(f'HTTP status: {response["statusCode"]}') body = b64decode(response["httpResponseBody"]) print(f'Response body: {body}') </code></pre> <p>所有响应主体都以 base64 编码字符串返回,而不是 HTML 或 JSON 文本。使用 Python 的<code>base64</code>包处理这一点非常简单。它还允许您像处理任何其他请求响应一样通过简单解码响应来检索二进制数据、图像和其他文件。</p> <p>如果您不想使用 Scrapy 并且有一个相当简单的项目,Zyte 的自动提取 API 使用 AI 检测页面上的各种字段,并将它们作为 JSON 格式数据返回。目前,它适用于文章和产品类型。显然,它不需要使用 base64 编码,因为它解析的所有页面必须是文本:</p> <pre><code class="language-py">json_data = [{     'url': 'https://www.target.com/p/-/A-83650487',     'pageType': 'product', }] response = requests.post(     'https://autoextract.zyte.com/v1/extract',     auth=(ZYTE_KEY, ''),     json=json_data ) print(response.json()) </code></pre> <p>Zyte 的自动提取 API 文档提供了 URL <em><a href="https://autoextract.scrapinghub.com/v1/extract" target="_blank">https://autoextract.scrapinghub.com/v1/extract</a></em>,作为其之前名称 ScrapingHub 的一个遗物。如果您看到这一点,请知道您通常可以用<code>zyte.com</code>替换<code>scrapinghub.com</code>,如果 Zyte 决定关闭旧域名,则可以为您的代码提供一些向后兼容性。</p> <p>Zyte 的产品主要面向在企业环境中工作的开发人员,他们希望完全透明和控制他们的爬虫。然而,Zyte 更倾向于通过其 Zyte 智能代理管理器将 IP 地址管理权从用户手中拿走。Zyte 控制通过哪些 IP 地址进行代理流量。IP 地址在会话之间保持不变,但如果一个 IP 地址被阻止,则会切换 IP 地址。Zyte 尝试使用 IP 地址切换来创建一个看起来有机的流量流向站点,避免引起怀疑。</p> <p>使用智能代理管理器很简单,尽管在您的计算机上安装证书可能会增加复杂性:</p> <pre><code class="language-py">response = requests.get(     'https://www.target.com/p/-/A-83650487',     proxies={         'http': f'http://{ZYTE_KEY}:@proxy.crawlera.com:8011/',         'https': f'http://{ZYTE_KEY}:@proxy.crawlera.com:8011/',     },     verify='/path/to/zyte-proxy-ca.crt'  ) print(response.text) </code></pre> <p>如果您不想使用证书(尽管这不推荐),您可以在请求模块中关闭验证:</p> <pre><code class="language-py">response = requests.get( ... verify=False ) </code></pre> <p>当然,Zyte 还有关于如何将其<a href="https://scrapy-zyte-smartproxy.readthedocs.io/en/latest/" target="_blank">代理服务与 Scrapy 集成</a>的说明,然后可以在其 Scrapy Cloud 中运行。</p> <p>代理请求大约每美元 1600 个(或者更昂贵的月度计划更少),API 请求从每美元开始约 12000 个。Scrapy Cloud 计划相对廉价,有一个慷慨的免费层和一个每月 9 美元的“专业”层。这很可能鼓励使用 Scrapy 并促进与 Zyte 平台的集成。</p> <h1 id="其他资源-1">其他资源</h1> <p>许多年前,“云计算”主要是那些愿意费时阅读文档并且已经具备一定服务器管理经验的人的领域。如今,由于云计算服务的普及和竞争加剧,工具得到了显著改进。</p> <p>然而,如果要构建大规模或更复杂的网页抓取器和网络爬虫,你可能需要更多关于创建数据收集和存储平台的指导。</p> <p><a href="http://oreil.ly/1FVOw6y" target="_blank"><em>Google Compute Engine</em></a> 由马克·科恩、凯瑟琳·赫利和保罗·纽森(O'Reilly)撰写,是一本关于使用 Google 云计算的简明资源,涵盖了 Python 和 JavaScript。它不仅涵盖了 Google 的用户界面,还包括了命令行和脚本工具,可以让你的应用程序拥有更大的灵活性。</p> <p>如果你更喜欢使用亚马逊,米奇·加纳特的<a href="http://oreil.ly/VSctQP" target="_blank"><em>Python and AWS Cookbook</em></a>(O'Reilly)是一本简短但非常实用的指南,可以帮助你开始使用 Amazon Web Services,并展示如何运行可扩展的应用程序。</p> <p>¹ 从技术上讲,IP 地址可以伪造出站的数据包,这是分布式拒绝服务攻击中使用的技术,攻击者并不关心是否接收返回的数据包(如果发送的话,会发送到错误的地址)。但网页抓取定义上是一种需要从网络服务器获取响应的活动,因此我们认为 IP 地址是无法伪造的一个因素。</p> <p>² 参见尼古拉斯·P·范多斯,“哈佛大学二年级学生被指控炸弹威胁”,<em>哈佛深红报</em>,2023 年 12 月 17 日,<a href="https://www.thecrimson.com/article/2013/12/17/student-charged-bomb-threat" target="_blank"><em>https://www.thecrimson.com/article/2013/12/17/student-charged-bomb-threat</em></a>。</p>

posted @ 2024-06-17 19:07  绝不原创的飞龙  阅读(21)  评论(0编辑  收藏  举报