Python 网络爬取第三版(三)
原文:
annas-archive.org/md5/3c359a3a3947ea27259c8eac15f155d2译者:飞龙
第二部分:高级爬虫
您已经打下了一些网络爬虫的基础;现在是有趣的部分。直到此时,您的网络爬虫相对而言都比较“笨拙”。除非服务器直接以良好格式呈现信息,否则它们无法检索信息。它们接受所有信息的表面价值,并将其存储而不进行任何分析。它们会被表单、网站交互甚至 JavaScript 所困扰。简言之,它们在除非信息确实想要被检索,否则无法有效地检索信息。
本书的这一部分将帮助您分析原始数据,以获取数据背后的故事——这些故事通常被网站隐藏在 JavaScript、登录表单和反爬措施的层层之下。您将学习如何使用网络爬虫测试您的网站、自动化流程,并大规模访问互联网。通过本节的学习,您将掌握工具,可以收集和操作几乎任何形式、任何部分互联网上的数据。
第十章:读取文档
现在很容易把互联网主要看作是由基于文本的网站和新型 Web 2.0 多媒体内容构成的集合,而这些内容大部分可以忽略不计以便进行网络抓取。然而,这忽略了互联网最根本的本质:作为传输文件的内容不可知的载体。
尽管互联网在上世纪 60 年代末已经存在,但 HTML 直到 1992 年才首次亮相。在此之前,互联网主要由电子邮件和文件传输组成;我们今天所知的网页概念并不存在。换句话说,互联网不是 HTML 文件的集合。它是许多类型文档的集合,其中 HTML 文件通常用作展示它们的框架。如果不能阅读各种类型的文档,包括文本、PDF、图像、视频、电子邮件等,我们将错过大量可用数据的一部分。
本章涵盖了处理文档的内容,无论是将它们下载到本地文件夹还是阅读它们并提取数据。您还将了解处理各种文本编码,这使得即使是阅读外语 HTML 页面也变得可能。
文档编码
文档的编码告诉应用程序——无论是您计算机的操作系统还是您自己的 Python 代码——如何读取它。这种编码通常可以从其文件扩展名中推断出来,尽管文件扩展名并不一定反映其编码。例如,我可以将myImage.jpg保存为myImage.txt而不会出现问题——至少直到我的文本编辑器试图打开它。幸运的是,这种情况很少见,通常文件扩展名就足够了解它以正确地阅读。
从根本上讲,所有文档都是用 0 和 1 编码的。此外,编码算法定义了诸如“每个字符多少位”或“每个像素颜色用多少位表示”(对于图像文件)之类的内容。此外,您可能还有一层压缩或某种空间减少算法,如 PNG 文件的情况。
尽管一开始处理非 HTML 文件可能看起来令人畏惧,但请放心,通过正确的库,Python 将能够适应处理任何格式的信息。文本文件、视频文件和图像文件之间唯一的区别在于它们的 0 和 1 的解释方式。本章涵盖了几种常见的文件类型:文本、CSV、PDF 和 Word 文档。
注意,这些从根本上讲都是存储文本的文件。关于处理图像的信息,请建议您阅读本章以熟悉处理和存储不同类型文件,然后转到第十六章获取更多有关图像处理的信息!
文本
在线存储纯文本文件有些不寻常,但它在极简或老式网站中很受欢迎,用于存储大量文本文件。例如,互联网工程任务组(IETF)将其所有已发布文档存储为 HTML、PDF 和文本文件(参见https://www.ietf.org/rfc/rfc1149.txt 作为示例)。大多数浏览器将正常显示这些文本文件,你应该能够毫无问题地进行抓取。
对于大多数基本文本文档,比如位于http://www.pythonscraping.com/pages/warandpeace/chapter1.txt 的练习文件,你可以使用以下方法:
from urllib.request import urlopen
textPage = urlopen('http://www.pythonscraping.com/'\
'pages/warandpeace/chapter1.txt')
print(textPage.read())
通常,当你使用urlopen检索页面时,你会将其转换为BeautifulSoup对象以解析 HTML。在这种情况下,你可以直接读取页面。将其转换为 BeautifulSoup 对象,虽然完全可行,但却是适得其反的——没有 HTML 可解析,因此该库将变得无用。一旦将文本文件读取为字符串,你只需像处理其他读入 Python 的字符串一样分析它即可。当然,这里的缺点是你无法使用 HTML 标签作为上下文线索,指导你找到实际需要的文本,而非你不想要的文本。当你尝试从文本文件中提取特定信息时,这可能会带来挑战。
文本编码与全球互联网
大多数情况下,文件扩展名就足以告诉你如何正确读取文件。然而,最基本的所有文档——.txt 文件,奇怪的是,这个规则不适用。
使用上述描述的方法读取文本通常会很好地运行,成功率达到 10 次中的 9 次。然而,在处理互联网文本时可能会遇到一些棘手的问题。接下来,我们将介绍从 ASCII 到 Unicode 到 ISO 的英语和外语编码基础,以及如何处理它们。
文本编码的历史
ASCII 最早在 1960 年代开发,当时比特昂贵,除了拉丁字母和少数标点符号外,没有理由编码其他内容。因此,只使用 7 位来编码 128 个大写字母、小写字母和标点符号。即使有了所有这些创意,他们仍然有 33 个非打印字符,随着技术的变化,其中一些被使用、替换或变为过时。对每个人来说,都有足够的空间,对吧?
正如任何程序员所知道的那样,7 是一个奇怪的数字。它不是一个好看的 2 的幂,但它非常接近。20 世纪 60 年代的计算机科学家们争论是否应该添加一个额外的位,以便获得一个好看的圆整数,而不是为了减少文件占用的空间。最终,7 位赢得了。然而,在现代计算中,每个 7 位序列在开头填充了一个额外的 0¹,留下了我们两全其美的结果——文件增大了 14%,同时只有 128 个字符的灵活性。
在 1990 年代初期,人们意识到不仅仅是英语存在更多的语言,如果计算机能够显示它们将是非常好的。一个名为 Unicode 联盟的非营利组织尝试通过为每个需要在任何文本文档中使用的字符建立编码来实现一种通用的文本编码器。目标是包括从本书所写的拉丁字母到西里尔字母(кириллица)、汉字象形文字、数学和逻辑符号(⨊、≥),甚至是表情符号和其他符号,如生化危险标志(☣)和和平符号(☮)的所有内容。
最终产生的编码器,你可能已经知道,被称为UTF-8,这个名称令人困惑地指的是“通用字符集—转换格式 8 位”。这里的8 位并不是每个字符的大小,而是一个字符需要显示的最小大小。
UTF-8 字符的实际大小是灵活的。它可以从 1 字节到 4 字节不等,这取决于它在可能字符列表中的位置(更常见的字符用较少的字节编码;更晦涩的字符需要更多字节)。
这种灵活的编码是如何实现的?起初,使用 7 位和最终无用的前导 0 在 ASCII 中看起来像是一个设计缺陷,但证明对 UTF-8 是一个巨大的优势。因为 ASCII 如此流行,Unicode 决定利用这个前导 0 位,声明所有以 0 开头的字节表示该字符只使用一个字节,并使 ASCII 和 UTF-8 的两种编码方案相同。因此,以下字符在 UTF-8 和 ASCII 中都是有效的:
01000001 - A
01000010 - B
01000011 - C
以下字符仅在 UTF-8 中有效,如果将文档解释为 ASCII 文档,则将呈现为不可打印字符。
11000011 10000000 - À
11000011 10011111 - ß
11000011 10100111 - ç
除了 UTF-8 之外,还存在其他 UTF 标准,如 UTF-16、UTF-24 和 UTF-32,尽管在正常情况下很少遇到使用这些格式编码的文档,这超出了本书的范围。
尽管 ASCII 的这一原始“设计缺陷”对 UTF-8 有重大优势,但劣势并未完全消失。每个字符中的前 8 位信息仍然只能编码 128 个字符,而不是完整的 256 个。在需要多个字节的 UTF-8 字符中,额外的前导位被花费在校验位上,而不是字符编码上。在 4 字节字符的 32 位中,仅使用 21 位用于字符编码,共计 2,097,152 个可能的字符,其中目前已分配 1,114,112 个。
当然,所有通用语言编码标准的问题在于,任何单一外语编写的文档可能比其实际需要的要大得多。尽管您的语言可能只包含大约 100 个字符,但每个字符需要 16 位,而不像英语专用的 ASCII 那样只需 8 位。这使得 UTF-8 中的外语文本文档大约是英语文本文档的两倍大小,至少对于不使用拉丁字符集的外语而言。
ISO 通过为每种语言创建特定编码来解决这个问题。与 Unicode 类似,它使用与 ASCII 相同的编码,但在每个字符的开头使用填充 0 位,以便为所有需要的语言创建 128 个特殊字符。这对于欧洲语言尤其有利,这些语言也严重依赖拉丁字母表(保留在编码的位置 0-127),但需要额外的特殊字符。这使得 ISO-8859-1(为拉丁字母表设计)可以拥有分数(如½)或版权符号(©)等符号。
其他 ISO 字符集,比如 ISO-8859-9(土耳其语)、ISO-8859-2(德语等多种语言)和 ISO-8859-15(法语等多种语言),在互联网上也很常见。
尽管 ISO 编码文档的流行度近年来有所下降,但约有 9% 的互联网网站仍使用某种 ISO 格式²,因此在抓取网站前了解和检查编码是至关重要的。
编码的实际应用
在前一节中,您使用了 urlopen 的默认设置来读取可能在互联网上遇到的文本文档。这对大多数英文文本效果很好。然而,一旦遇到俄语、阿拉伯语,或者甚至像“résumé”这样的单词,可能会遇到问题。
例如,看下面的代码:
from urllib.request import urlopen
textPage = urlopen('http://www.pythonscraping.com/'\
'pages/warandpeace/chapter1-ru.txt')
print(textPage.read())
这读取了原版《战争与和平》的第一章(用俄语和法语写成),并将其打印到屏幕上。屏幕文本部分内容如下:
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.
此外,使用大多数浏览器访问该页面会导致乱码(见 Figure 10-1)。
图 10-1. 用 ISO-8859-1 编码的法语和西里尔文本,许多浏览器中的默认文本文档编码
即使对于母语为俄语的人来说,这可能会有些难以理解。问题在于 Python 试图将文档读取为 ASCII 文档,而浏览器试图将其读取为 ISO-8859-1 编码的文档。当然,两者都没有意识到它实际上是一个 UTF-8 文档。
您可以显式地定义字符串为 UTF-8,这样正确地将输出格式化为西里尔字符:
from urllib.request import urlopen
textPage = urlopen('http://www.pythonscraping.com/'\
'pages/warandpeace/chapter1-ru.txt')
print(str(textPage.read(), 'utf-8'))
使用 BeautifulSoup 实现这一概念如下:
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')
Python 默认将所有字符编码为 UTF-8。您可能会倾向于不作更改,并为编写的每个网络抓取器使用 UTF-8 编码。毕竟,UTF-8 也能顺利处理 ASCII 字符以及外语字符。然而,重要的是要记住,有 9% 的网站使用某种 ISO 编码版本,因此您无法完全避免这个问题。
不幸的是,在处理文本文档时,无法确定文档具体使用的编码。一些库可以检查文档并做出最佳猜测(使用一些逻辑来认识到“раÑÑказє可能不是一个单词),但很多时候它们会出错。
幸运的是,在 HTML 页面的情况下,编码通常包含在网站 <head> 部分的标签中。大多数网站,特别是英语网站,都有这样的标签:
<meta charset="utf-8" />
而 ECMA International 的网站 就有这个标签:³
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=iso-8859-1">
如果您计划大量进行网页抓取,特别是国际网站,最好查找这个元标签,并在读取页面内容时使用它推荐的编码方式。
CSV
在进行网页抓取时,您可能会遇到 CSV 文件或喜欢以这种方式格式化数据的同事。幸运的是,Python 有一个 出色的库 既可以读取也可以写入 CSV 文件。虽然该库能处理多种 CSV 变体,但本节主要关注标准格式。如果您有特殊情况需要处理,请参考文档!
读取 CSV 文件
Python 的 csv 库主要用于处理本地文件,假设需要处理的 CSV 数据存储在您的计算机上。不幸的是,情况并非总是如此,特别是在进行网页抓取时。有几种方法可以解决这个问题:
-
手动下载文件并将 Python 指向本地文件位置。
-
编写一个 Python 脚本来下载文件,读取文件,并在检索后(可选)删除文件。
-
从网页中检索文件字符串,并将字符串包装在
StringIO对象中,以便其像文件一样运行。
尽管前两种选项可行,但将文件保存在硬盘上会占用空间,而你完全可以将它们保存在内存中,这是不良实践。最好的做法是将文件作为字符串读入,并将其包装在一个对象中,使 Python 能够将其视为文件,而无需保存文件。以下脚本从互联网获取 CSV 文件(在本例中,是http://pythonscraping.com/files/MontyPythonAlbums.csv上的 Monty Python 专辑列表),并逐行将其打印到终端:
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)
输出看起来像这样:
['Name', 'Year']
["Monty Python's Flying Circus", '1970']
['Another Monty Python Record', '1971']
["Monty Python's Previous Record", '1972']
...
正如你从代码示例中看到的那样,csv.reader返回的读取器对象是可迭代的,并由 Python 列表对象组成。因此,csvReader对象中的每一行都可以通过以下方式访问:
for row in csvReader:
print('The album "'+row[0]+'" was released in '+str(row[1]))
这是输出:
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
...
注意第一行:The album "Name" was released in Year。尽管这可能是编写示例代码时容易忽略的结果,但在现实世界中,你不希望这些内容出现在你的数据中。一个不那么熟练的程序员可能只是跳过csvReader对象中的第一行,或者编写一个特殊情况来处理它。幸运的是,csv.reader函数的替代方案会自动处理所有这些。进入DictReader:
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)
csv.DictReader将 CSV 文件中每一行的值作为字典对象返回,而不是列表对象,字段名称存储在变量dictReader.fieldnames中,并作为每个字典对象的键:
['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'}
当然,与csvReader相比,创建、处理和打印这些DictReader对象需要稍长时间,但其便利性和可用性往往超过了额外的开销。此外,请记住,在进行网页抓取时,从外部服务器请求和检索网站数据所需的开销几乎总是任何你编写的程序中不可避免的限制因素,因此担心哪种技术可以减少总运行时间的微秒级别问题通常是没有意义的!
作为 Linux 用户,我深知收到一个*.docx*文件,而我的非微软软件将其搞乱的痛苦,还有努力寻找解析某些新的 Apple 媒体格式的解码器。在某些方面,Adobe 在 1993 年创建其便携式文档格式(PDF)方面具有革命性。PDF 允许不同平台的用户以完全相同的方式查看图像和文本文档,而不受查看平台的影响。
尽管将 PDF 存储在网络上有点过时(为什么要将内容存储在静态、加载缓慢的格式中,而不是编写 HTML 呢?),但 PDF 仍然是无处不在的,特别是在处理官方表格和文件时。
2009 年,英国人尼克·因斯(Nick Innes)因向巴克莱市议会根据英国版《信息自由法》请求公开学生测试结果信息而成为新闻人物。经过一些重复请求和拒绝后,他最终以 184 份 PDF 文档的形式收到了他寻找的信息。
虽然 Innes 坚持不懈,并最终获得了一个更合适格式的数据库,但如果他是一个专业的网络爬虫,他很可能本可以节省很多时间,并直接使用 Python 的许多 PDF 解析模块处理 PDF 文档。
不幸的是,由于 PDF 是一个相对简单和开放源码的文档格式,在 PDF 解析库方面竞争激烈。这些项目通常会在多年间建立、弃用、重建。目前最受欢迎、功能齐全且易于使用的库是 pypdf。
Pypdf 是一个免费的开源库,允许用户从 PDF 中提取文本和图像。它还允许您对 PDF 文件执行操作,并且如果您想生成 PDF 文件而不仅仅是阅读它们,也可以直接从 Python 进行操作。
您可以像往常一样使用 pip 进行安装:
$ pip install pypdf
文档位于 https://pypdf.readthedocs.io/en/latest/index.html。
下面是一个基本的实现,允许您从本地文件对象中读取任意 PDF 到字符串:
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())
这提供了熟悉的纯文本输出:
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
注意,PDF 文件参数必须是一个实际的文件对象。在将其传递给 Pdfreader 类之前,您必须先将文件下载到本地。然而,如果您处理大量的 PDF 文件,并且不希望保留原始文件,您可以在从文本中提取后,通过再次将相同文件名传递给 urlretrieve 来覆盖先前的文件。
Pypdf 的输出可能不完美,特别是对于带有图像、奇怪格式文本或以表格或图表形式排列的 PDF。然而,对于大多数仅包含文本的 PDF,输出应与将 PDF 视为文本文件时的输出没有区别。
Microsoft Word 和 .docx
冒犯微软朋友的风险在此:我不喜欢 Microsoft Word。并不是因为它本质上是个糟糕的软件,而是因为它的用户如何误用它。它有一种特殊的才能,可以将本应是简单文本文档或 PDF 的内容转变为体积庞大、打开缓慢、易于在机器之间丢失所有格式的东西,并且由于某种原因,在内容通常意味着静态的情况下却是可编辑的。
Word 文件是为内容创建而设计,而不是为内容共享。尽管如此,在某些网站上它们无处不在,包含重要文件、信息,甚至图表和多媒体;总之,所有可以和应该使用 HTML 创建的内容。
在约 2008 年之前,Microsoft Office 产品使用专有的 .doc 文件格式。这种二进制文件格式难以阅读,而且其他文字处理软件的支持很差。为了跟上时代并采用许多其他软件使用的标准,Microsoft 决定使用基于 Open Office XML 的标准,使得这些文件与开源及其他软件兼容。
不幸的是,Python 对于由 Google Docs、Open Office 和 Microsoft Office 使用的此文件格式的支持仍然不够完善。有 python-docx 库,但它只能让用户创建文档并仅读取基本的文件数据,如文件的大小和标题,而不是实际的内容。要读取 Microsoft Office 文件的内容,您需要自己解决方案。
第一步是从文件中读取 XML:
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'))
这段代码将一个远程 Word 文档作为二进制文件对象读取(BytesIO 类似于本章前面使用的 StringIO),使用 Python 的核心 zipfile 库解压缩它(所有的 .docx 文件都被压缩以节省空间),然后读取解压后的 XML 文件。
http://pythonscraping.com/pages/AWordDocument.docx 上展示了 图 10-2 中的 Word 文档。
图 10-2。这是一个 Word 文档,里面可能包含您非常想要的内容,但由于我将其作为 .docx 文件放在网站上而不是发布为 HTML,所以访问起来很困难。单词“unfortunatly”拼写错误。
Python 脚本读取我简单 Word 文档的输出如下所示:
<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>
这里显然有大量的元数据,但实际上您想要的文本内容被埋藏起来了。幸运的是,文档中的所有文本,包括顶部的标题,都包含在 w:t 标签中,这使得抓取变得很容易:
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)
注意,与通常在 BeautifulSoup 中使用的 html.parser 解析器不同,您需要将 xml 解析器传递给它。这是因为在像 w:t 这样的 HTML 标签名中,冒号是非标准的,而 html.parser 无法识别它们。
输出还不完美,但已经接近了,并且打印每个 w:t 标签到新行使得很容易看出 Word 如何分割文本:
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.
注意,单词“unfortunatly”被分割成多行。在原始的 XML 中,它被标签 <w:proofErr w:type="spellStart"/> 包围。这是 Word 用红色波浪线突出显示拼写错误的方式。
文档标题之前有样式描述符标签 <w:pstyle w:val="Title">。虽然这并没有使我们非常容易识别标题(或其他样式化的文本),但使用 BeautifulSoup 的导航功能可能会有所帮助:
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)
这个函数可以很容易扩展以在各种文本样式周围打印标签或以其他方式标记它们。
¹ 这个“填充”位稍后会在 ISO 标准中困扰我们。
² 根据W3Techs提供的数据,该网站使用网络爬虫收集这些统计数据。
³ ECMA 是 ISO 标准的原始贡献者之一,所以其网站采用了一种 ISO 的编码方式并不令人意外。
第十一章:处理脏数据
到目前为止,在本书中,我通过使用通常格式良好的数据源,如果数据偏离预期,则完全删除数据,忽略了格式不良的数据问题。但是,在网络抓取中,您通常无法太挑剔数据的来源或其外观。
由于错误的标点符号、不一致的大写、换行和拼写错误,脏数据在网络上可能是一个大问题。本章介绍了一些工具和技术,帮助您通过更改编码方式和在数据进入数据库后清理数据,从根源上预防问题。
这是网络抓取与其近亲数据科学交汇的章节。虽然“数据科学家”这个职称可能让人联想到先进的编程技术和高级数学,但事实上,大部分工作是苦工活。在能够用于构建机器学习模型之前,某人必须清理和规范这些数百万条记录,这就是数据科学家的工作。
文本清理
Python 是一种非常适合文本处理的编程语言。您可以轻松编写干净、功能性、模块化的代码,甚至处理复杂的文本处理项目。使用以下代码,我们可以从维基百科关于 Python 的文章中获取文本http://en.wikipedia.org/wiki/Python_(programming_language):
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]
此内容开始:
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]
我们将对这段文本执行几项操作:
-
删除形式为“[123]”的引用
-
删除换行符
-
将文本分割为句子
-
删除句子中含有旁注的括号文本
-
删除文本中未包含的插图描述
-
将文本转换为小写
-
删除所有标点符号
需要注意的是,这些函数必须按特定顺序应用。例如,如果删除标点符号(包括方括号),将难以识别和删除后续的引用。删除标点符号并将所有文本转换为小写后,也将无法将文本拆分为句子。
删除换行符和将文本转换为小写的函数非常简单:
def replace_newlines(text):
return text.replace('\n', ' ')
def make_lowercase(text):
return text.lower()
这里换行用空格字符(“ ”)替换,而不是完全删除,以避免出现这样的文本:
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.
被转换为以下文本:
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.
插入空格可以确保所有句子之间仍然有空格。
有了这个想法,我们可以编写分割句子的函数:
def split_sentences(text):
return [s.strip() for s in text.split('. ')]
我们不是简单地在句号上进行拆分,而是在句号和空格上进行拆分。这可以避免出现小数,例如普遍存在的“Python 2.5”,或者代码示例如:
if (c = 1) { ...}
防止被错误地分割成句子。此外,我们希望确保任何双空格或其他奇怪的句子都通过在返回之前使用strip函数去除每个前导或尾随空格来进行清理。
但是,不能立即调用 split_sentences。许多句子紧随其后引用:
capable of exception handling and interfacing with the Amoeba
operating system.[13] Its implementation began in December 1989.[44]
删除引用的函数可以写成这样:
import re
CITATION_REGEX = re.compile('\[[0-9]*\]')
def strip_citations(text):
return re.sub(CITATION_REGEX, '', text)
变量名 CITATION_REGEX 采用大写,表示它是一个常量,并且在函数本身之外预先编译。函数也可以写成这样:
def strip_citations(text):
return re.sub(r'\[[0-9]*\]', '', text)
然而,这会强制 Python 每次运行函数时重新编译这个正则表达式(这可能是成千上万次,具体取决于项目),而不是预先编译好并准备好使用。虽然程序的速度在网页抓取中并不一定是一个显著的瓶颈,但在函数外预先编译正则表达式非常容易实现,并允许您通过合适的变量名来文档化代码中的正则表达式。
删除括号文本,例如:
all versions of Python (including 2.7[56]) had security issues
和:
dynamic name resolution (late binding), which binds method
对于删除引用也是一种类似的模式。一个很好的初步方法可能是:
PARENS_REGEX = re.compile('\(.*\)')
def remove_parentheses(text):
return re.sub(PARENS_REGEX, '', text)
的确,这确实从上述示例中删除了括号文本,但它也从像这样的部分中删除了括号内的任何内容:
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
此外,如果文本中存在不匹配的括号,会存在危险。开放括号可能导致在找到任何形式的闭合括号时,大段文本被移除。
为了解决这个问题,我们可以检查括号文本中通常见到的字符类型,仅查找它们,并限制括号文本的长度:
PARENS_REGEX = re.compile('\([a-z A-Z \+\.,\-]{0,100}\)')
def remove_parentheses(text):
return re.sub(PARENS_REGEX, '', text)
偶尔,文本中可能存在未提取的插图描述。例如:
Hello world program:
它前面是一段未提取出来的代码块。
这些描述通常很短,以换行符开头,只包含字母,并以冒号结尾。我们可以使用正则表达式删除它们:
DESCRIPTION_REGEX = re.compile('\n[a-z A-Z]*:')
def remove_descriptions(text):
return re.sub(DESCRIPTION_REGEX, '', text)
在这一点上,我们可以删除标点符号。因为许多前面的步骤依赖于标点符号的存在来确定哪些文本保留哪些删除,所以剥离标点符号通常是任何文本清理任务的最后一步。
Python 字符串模块 包含许多方便的字符集合,其中之一是 string.punctuation。这是所有 ASCII 标点符号的集合:
>>> import string
>>> string.punctuation
'!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
我们可以使用 re.escape(它转义任何保留的正则表达式符号)和用 | 字符连接所有 ASCII 标点的字符串,将其转换为正则表达式:
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)
在所有这些字符串操作中,Unicode 字符往往会在字符串中被错误表示。特别常见的是 Unicode 的“不间断空格”,在 HTML 中表示为 ,在网页文本中经常可以找到。我们在 Wikipedia 文本中打印出来可以看到它显示为 \xa0:
python\xa020 was released...
无论遇到哪些奇怪的字符,都可以使用 Python 的 unicodedata 包来修复它们。规范化 Unicode 字符将是清理文本的最后一步:
def normalize(text):
return unicodedata.normalize('NFKD', text)
在这一点上,您已经拥有一组短小而精心组织的函数,可以执行各种文本清理操作。因为我们可能希望添加、删除或更改调用函数的顺序,所以我们可以将这些函数添加到列表中,并在文本上以一般方式调用它们:
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)
尽管 Python 通常不被认为是像 JavaScript 或更极端的例子中的 Haskell 那样的函数语言,但要记住,在这种情况下,函数可以像变量一样传递!
使用标准化文本工作
一旦您清理了文本,接下来该怎么做呢?一个常见的技术是将其分成更容易量化和分析的小块文本。计算语言学家称这些为n-grams,其中 n 表示每个文本块中的单词数。在本例中,我们将专门使用 2-gram,即 2 个单词的文本块。
N-gram 通常不跨越句子。因此,我们可以使用前一节中获取的文本,将其拆分为句子,并为列表中的每个句子创建 2-gram。
一个用于将文本分解为 n-gram 的 Python 函数可以编写为:
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)
这个函数在文本“web scraping with python”上的输出是:
[['web', 'scraping'], ['scraping', 'with'], ['with', 'python']]
这个函数的一个问题是,它返回了许多重复的 2-gram。它遇到的每个 2-gram 都会被添加到列表中,而没有记录其频率。记录这些 2-gram 的频率是有趣的,而不仅仅是它们的存在,因为这可以在清理和数据标准化算法变更的效果图中非常有用。如果数据成功标准化,唯一 n-gram 的总数将减少,而找到的 n-gram 的总计数(即标识为 n-gram 的唯一或非唯一项的数量)不会减少。换句话说,相同数量的 n-gram 将有更少的“桶”。
你可以通过修改收集 n-gram 的代码,将它们添加到Counter对象中来完成。在这里,cleaned变量是我们在前一节中获取的已清理句子的列表:
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
还有许多其他创建 n-gram 计数的方法,例如将它们添加到字典对象中,其中列表的值指向它被看到的次数的计数。这样做的缺点是它需要更多的管理,并且使排序变得棘手。
然而,使用Counter对象也有一个缺点:它不能存储列表,因为列表是不可散列的。将它们转换为元组(可散列)将很好地解决问题,并且在这种情况下,将它们转换为字符串也是有道理的,通过在列表理解中使用' '.join(text[i:i+n])将它们转换为字符串。
我们可以使用前一节中清理的文本调用countNGramsFromSentences函数,并使用most_common函数获取按最常见排序的 2-gram 列表:
counts = countNGramsFromSentences(cleaned, 2)
print(counts.most_common())
这里是结果:
('in the', 19), ('of the', 19), ('such as', 18), ('as a', 14),
('in python', 12), ('python is', 9), ('of python', 9),
('the python', 9)...
截至本文撰写时,有 2814 个独特的 2-gram,其中最受欢迎的组合包含在任何英文文本中都非常常见的词组,例如“such as”。根据您的项目需求,您可能希望移除类似于这样没有太多关联性的 n-gram。如何做到这一点是第[12 章的一个话题。
此外,通常需要停下来考虑要消耗多少计算资源来规范化数据。有许多情况下,不同拼写的单词是等效的,但为了解决这种等效性,您需要检查每个单词,看它是否与您预先编程的等效项匹配。
例如,“Python 1st”和“Python first”都出现在 2-gram 列表中。然而,如果要制定一个一刀切的规则,即“所有的 first、second、third 等都将被解析为 1st、2nd、3rd 等(反之亦然)”,那么每个单词将额外增加约 10 次检查。
同样地,连字符的不一致使用(例如“co-ordinated”与“coordinated”)、拼写错误以及其他自然语言的不一致性将影响 n-gram 的分组,并可能使输出的结果变得模糊,如果这些不一致性足够普遍的话。
在处理连字符词的情况下,一种解决方法可能是完全删除连字符,并将该词视为一个字符串,这只需要进行一次操作。然而,这也意味着连字符短语(这种情况实在是太常见了)将被视为一个单词。采取另一种方法,将连字符视为空格可能是一个更好的选择。只是要做好准备,偶尔会有“co ordinated”和“ordinated attack”这样的情况出现!
使用 Pandas 清理数据
本节不是关于中国的可爱熊猫,而是关于 Python 数据分析软件包pandas。如果您有过数据科学和机器学习的工作经验,那么很可能在此之前就已经接触过它,因为它在该领域中无处不在。
Pandas 是由程序员 Wes McKinney 在 2008 年独立开发的项目。2009 年,他将该项目公开,并迅速获得了广泛认可。该软件包填补了数据整理中的一个特定空白。在某些方面,它的功能类似于电子表格,具有良好的打印效果和易于重塑的数据透视功能。它还充分利用了底层 Python 代码和数据科学库的强大和灵活性。
有些人在安装像 numpy、pandas 和 scikit-learn 这样的数据科学库时,可能会推荐使用安装包管理系统 Anaconda。虽然 Anaconda 对这些包有很好的支持,但使用 pip 也可以轻松安装 pandas:
pip install pandas
习惯上,该软件包被导入为pd而不是完整名称pandas:
import pandas as pd
不要从 Pandas 中导入单独的方法和类
pandas 生态系统庞大、复杂,并且经常与内置 Python 函数和软件包的命名空间重叠。因此,几乎总是应该从pd开始引用 pandas 函数,而不是直接导入它们,例如:
from pandas import array
from pandas.DataFrame import min
在这些情况下,上述导入可能会与内置 Python array模块和min函数造成混淆。
一个已接受的例外可能是对作为 DataFrame 类导入的:
from pandas import DataFrame
在这种情况下,DataFrame不在 Python 标准库中,并且很容易被识别为 pandas 类。然而,这是您可能会看到的一个例外,并且许多人仍然喜欢将 DataFrame 类称为pd.DataFrame。因为库在代码中经常被引用,这是为什么惯例是将 pandas 导入为pd而不是全名的一个原因!
您在 pandas 库中最常使用的对象是数据框架。这些类似于电子表格或表格,可以通过多种方式构建。例如:
df = pd.DataFrame([['a', 1], ['b', 2], ['c', 3]])
df.head()
head方法生成一个漂亮打印的数据框架,显示数据及其列和标题,如图 11-1 所示。
图 11-1。一个简单的 pandas 数据框架
数据框架始终需要索引和列名。如果未提供这些信息,如本例中仅提供简单的数据矩阵,它们将被自动生成。数据框架的索引(0, 1, 2)可以在左侧以粗体显示,列名(0, 1)在顶部以粗体显示。
与使用原始的 Python 列表和字典不同,数据框提供了大量方便的辅助函数,用于对数据进行排序、清理、操作、排列和显示。如果您处理较大的数据集,它们还比列表和字典提供了速度和内存优势。
清理
在接下来的示例中,您将使用从Wikipedia 的《麦当劳餐厅分布国家列表》抓取的数据。我们可以使用pd.read_csv函数直接从 CSV 文件读取数据到数据框架:
df = pd.read_csv('countries.csv')
df.head(10)
可选地,可以传递一个整数给head方法,以打印出除默认值 5 之外的行数。这样可以很好地查看早期抓取的 CSV 数据,如图 11-2 所示。
图 11-2。显示具有餐厅的国家列表
这里的列名有些冗长且格式不佳。我们可以使用rename方法对它们进行重命名:
df.rename(columns={
'#': 'Order',
'Country/territory': 'Country',
'Date of first store': 'Date',
'First outlet location': 'Location',
'Max. no. ofoperatingoutlets': 'Outlets'
}, inplace=True)
在这里,我们向columns关键字参数传递一个字典,其中键是原始列名,值是新列名。布尔参数inplace意味着列会在原始数据框架中就地重命名,而不是返回一个新的数据框架。
接下来,我们可以通过将要处理的列名列表传递给切片语法[],来隔离我们想要处理的列:
df = df[['Order', 'Country', 'Date', 'Location', 'Outlets']]
现在我们已经拥有了我们想要的重新标记的 DataFrame 列,我们可以查看数据了。有几件事情我们需要修复。首先,“首次店铺日期”或“日期”列中的日期通常格式良好,但它们也包含额外的文本甚至其他日期。作为一个简单的策略,我们可以决定保留匹配“日期”格式的第一个内容,并丢弃其余内容。
函数可以通过首先使用与上述相同的“切片”语法选择整个 DataFrame 列来应用于 DataFrame 中的整个列。一个单独选择的列是一个 pandas Series 实例。Series 类有一个 apply 方法,它可以将一个函数应用到 Series 中的每个值上:
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])
在这里,我正在使用 lambda 运算符来应用一个函数,该函数获取所有 date_regex 的匹配项,并将第一个匹配项作为日期返回。
清理后,这些日期可以使用 to_datetime 函数转换为实际的 pandas datetime 值:
df['Date'] = pd.to_datetime(df['Date'])
往往,在快速高效生成“干净”数据与保留完全准确和细微数据之间存在微妙的平衡。例如,我们的日期清理将英国行的以下文本减少到单个日期:“英格兰:1974 年 11 月 13 日[21] 威尔士:1984 年 12 月 3 日 苏格兰:1987 年 11 月 23 日[22] 北爱尔兰:1991 年 10 月 12 日”,变成了单个日期:“1974-11-13”。
从技术上讲,这是正确的。如果整个英国作为一个整体来考虑,那么 1974 年 11 月 13 日是麦当劳首次出现的日期。然而,很巧合的是,日期按照时间顺序写在单元格中,并且我们决定选择第一个日期,并且最早的日期是正确选择的。我们可以想象许多其他情况,在那些情况下我们可能就没有那么幸运。
在某些情况下,您可以对数据进行调查,并决定您选择的清理方法是否足够好。也许在您查看的大多数情况下都是正确的。也许在一半的时间里,向一个方向不正确,在另一半时间里,向另一个方向不正确,对于您的目的来说,在大型数据集上平衡是可以接受的。或者您可能决定需要另一种方法来更准确地清理或捕捉数据。
数据集的“出口”列也存在类似的挑战。此列包含文本,如“13,515[10][验证失败][11]”和“ (不包括季节性餐厅) 43(包括季节性和移动餐厅)”,这些都不是我们希望进行进一步分析的干净整数。同样,我们可以使用简单的方法获取数据集中可用的第一个整数:
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)
尽管这也可以写成一个 lambda 函数,但如果需要多个步骤,可以考虑将逻辑拆分到一个单独的函数中。这样做的好处是在探索性数据处理过程中,可以轻松打印出任何发现的异常,以便进一步考虑:
def str_to_int(s):
try:
s = int_regex.findall(s)[0]
s = s.replace(',','')
except:
print(f'Whoops: {s}')
return int(s)
最后,DataFrame 已经清理好,准备进行进一步的分析,如 图 11-3 所示。
图 11-3. 具有清洁列标题、格式化日期和整数数据的 DataFrame
索引、排序和过滤
请记住,所有的 DataFrame 都有一个索引,无论您是否明确提供了一个。麦当劳的数据本身有一个方便的索引:”Order“ 列,表示国家接收第一家麦当劳餐厅的时间顺序。我们可以使用 set_index 方法设置索引:
df.set_index(['Order'], inplace=True)
df.head()
这会丢弃旧索引并将 ”Order“ 列移到索引中。同样,inplace 关键字参数意味着这是在原始 DataFrame 上进行的就地操作,而不是返回 DataFrame 的副本。
sort_values 方法可用于按一个或多个列排序数据。在这个方法中也可以使用 inplace 关键字。然而,因为排序通常用于探索性分析,不需要永久排序,所以将 DataFrame 返回用于打印可能更有用:
df.sort_values(by=['Outlets', 'Date'], ascending=False)
这显示了拥有最多麦当劳的国家是美国,其次是中国,然后是日本。法国,我相信会很高兴知道,排名第四,是欧洲国家中麦当劳最多的国家!
使用 query 方法很容易过滤 DataFrame。它的参数是一个查询字符串:
df.query('Outlets < 100')
这将返回一个 DataFrame,其中仅包含 Outlets 数量小于 100 的记录。大多数常规的 Python 比较运算符在使用查询方法进行 DataFrame 过滤时都有效,但是这种查询语言不是 Python 语法。例如,这将引发异常:
df.query('Date is not None')
如果你想测试任何空值的存在或不存在,正确的 pandas 方法是使用 isnull 和 notnull 查询函数:
df.query('Date.isnull()')
df.query('Date.notnull()')
正如你可能猜到的那样,这些语句同时捕获了 None 值以及来自底层 numpy 包的 NaN 对象。
如果我们想要添加另一个逻辑子句,可以用一个单与号分隔它们:
df.query('Outlets < 100 & Date < "01-06-1990"')
使用单管道表示一个 or 语句:
df.query('Outlets < 100 | Date < "01-06-1990"')
注意这里不需要整个日期 ("1990-01-01"),只写年份 "1990" 也可以。Pandas 在解释字符串为日期时相当宽容,尽管你应该始终仔细检查返回的数据是否符合你的期望。
更多关于 Pandas 的信息
我真诚地希望你与 pandas 的旅程不会就此结束。我们很幸运,pandas 的创造者和终身仁慈独裁者 Wes McKinney,也写了一本关于它的书:Python for Data Analysis。
如果你计划在数据科学领域做更多的事情,或者只是想在 Python 中偶尔清理和分析数据的好工具,我建议你去了解一下。
第十二章:读写自然语言
到目前为止,您在本书中处理的数据形式大多是数字或可计数值。在大多数情况下,您只是存储数据而没有进行后续分析。本章尝试解决英语这一棘手的主题。¹
当您在其图像搜索中输入“可爱小猫”时,Google 是如何知道您正在寻找什么?因为围绕可爱小猫图像的文本。当您在 YouTube 的搜索栏中输入“死鹦鹉”时,YouTube 是如何知道要播放某个蒙提·派森的段子?因为每个上传视频的标题和描述文本。
实际上,即使输入“已故鸟蒙提·派森”等术语,也会立即出现相同的“死鹦鹉”段子,尽管页面本身没有提到“已故”或“鸟”这两个词。Google 知道“热狗”是一种食物,而“煮狗幼犬”则完全不同。为什么?这都是统计数据!
尽管您可能认为文本分析与您的项目无关,但理解其背后的概念对各种机器学习以及更一般地在概率和算法术语中建模现实世界问题的能力都非常有用。
例如,Shazam 音乐服务可以识别包含某个歌曲录音的音频,即使该音频包含环境噪音或失真。Google 正在基于图像本身自动为图像添加字幕。² 通过将已知的热狗图像与其他热狗图像进行比较,搜索引擎可以逐渐学习热狗的外观并观察这些模式在其显示的其他图像中的表现。
数据摘要
在第十一章中,您看到了如何将文本内容分解为 n-gram,即长度为n的短语集。基本上,这可以用于确定哪些词组和短语在文本段落中最常用。此外,它还可用于通过回溯原始文本并提取围绕这些最流行短语之一的句子来创建自然语音数据摘要。
您将用美国第九任总统威廉·亨利·哈里森的就职演讲作为本章中许多代码示例的源头。
您将使用此演讲的完整文本作为本章中许多代码示例的源头。
在第十一章的清理代码中稍作修改,可以将此文本转换为准备好分割成 n-gram 的句子列表:
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)
然后,我们获取文本并按特定顺序调用这些函数:
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)
接下来我们使用清理后的文本来获取所有 2-gram 的 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
counts = countNGramsFromSentences(cleaned, 2)
print(counts.most_common())
此示例说明了 Python 标准库 collections 的便利性和强大性。不,编写一个创建字典计数器、按值排序并返回这些顶级值的最受欢迎键的函数并不特别困难。但是,了解内置 collections 并能够根据手头任务选择合适的工具可以节省很多代码行!
输出的部分包括:
[('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)...
在这些二元组中,“宪法”似乎是演讲中一个相当受欢迎的主题,但“of the”、“in the” 和 “to the” 看起来并不特别显着。您如何自动且准确地摆脱不想要的词?
幸运的是,有些人认真研究“有趣”词和“无趣”词之间的差异,他们的工作可以帮助我们做到这一点。布里格姆·杨大学的语言学教授马克·戴维斯维护着当代美国英语语料库,这是一个收集了超过 4.5 亿字的流行美国出版物最近十年左右的集合。
5000 最常见的单词列表可以免费获取,幸运的是,这已经足够作为基本过滤器,以清除最常见的二元组。仅前一百个单词就大大改善了结果,同时加入了 isCommon 和 filterCommon 函数:
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()
这生成了在文本正文中找到的以下出现超过两次的二元组:
('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)
恰如其分地,列表中的前两项是“美利坚合众国”和“行政部门”,这在总统就职演讲中是可以预期的。
需要注意的是,您使用的是一份相对现代的常用词列表来过滤结果,这可能并不适合于这段文字是在 1841 年编写的事实。然而,因为您仅使用列表中的前一百个左右单词——可以假设这些单词比后一百个单词更为稳定,且您似乎得到了令人满意的结果,您很可能可以省去追溯或创建 1841 年最常见单词列表的麻烦(尽管这样的努力可能会很有趣)。
现在从文本中提取了一些关键主题,这如何帮助您撰写文本摘要?一种方法是搜索每个“流行”的 n-gram 的第一句话,理论上第一次出现将提供对内容体的令人满意的概述。前五个最受欢迎的 2-gram 提供了这些要点:
-
“美利坚合众国宪法是包含这一授予各个组成政府部门的权力的工具。”
-
“联邦政府所构成的行政部门提供了这样一个人。”
-
“联邦政府没有侵占任何州保留的权利。”
-
“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.”
-
“政府必须永远不要使用机器来‘为罪犯洗白或者掩盖罪行’。”
当然,它可能不会很快出现在 CliffsNotes 上,但考虑到原始文档长达 217 句,第四句(“Called from a retirement...”)相当简洁地概述了主题,对于第一遍来说还算不错。
对于更长的文本块或更多样化的文本,检索一个段落中“最重要”的句子时可能值得查看 3-gram 甚至 4-gram。在这种情况下,只有一个 3-gram 被多次使用,即“exclusive metallic currency”,指的是提出美国货币金本位制的重要问题。对于更长的段落,使用 3-gram 可能是合适的。
另一种方法是寻找包含最流行 n-gram 的句子。这些句子通常会比较长,所以如果这成为问题,你可以寻找包含最高比例流行 n-gram 词的句子,或者自行创建一个结合多种技术的评分指标。
马尔可夫模型
您可能听说过马尔可夫文本生成器。它们已经因娱乐目的而流行起来,例如“That can be my next tweet!” 应用程序,以及它们用于生成听起来真实的垃圾邮件以欺骗检测系统。
所有这些文本生成器都基于马尔可夫模型,该模型经常用于分析大量随机事件集,其中一个离散事件后跟随另一个离散事件具有一定的概率。
例如,您可以构建一个天气系统的马尔可夫模型,如图 12-1 所示。
图 12-1。描述理论天气系统的马尔可夫模型
在这个模型中,每个晴天有 70%的几率第二天仍然是晴天,有 20%的几率第二天是多云,仅有 10%的几率下雨。如果今天是雨天,那么第二天有 50%的几率还是下雨,25%的几率是晴天,25%的几率是多云。
您可能会注意到这个马尔可夫模型中的几个属性:
-
所有从任何一个节点出发的百分比必须总和为 100%。无论系统多么复杂,下一步总是必须有 100%的机会能够导向其他地方。
-
尽管每次只有三种天气可能性,您可以使用此模型生成无限的天气状态列表。
-
只有当前节点的状态会影响您下一步将去哪里。如果您在晴天节点上,不管前 100 天是晴天还是雨天,明天出太阳的几率都完全相同:70%。
-
达到某些节点可能比其他节点更困难。这背后的数学相当复杂,但可以很容易地看出,雨天(箭头指向少于“100%”)在任何给定时间点上都比晴天或多云状态更不可能到达该状态。
显然,这是一个简单的系统,马尔可夫模型可以任意扩展。谷歌的页面排名算法部分基于马尔可夫模型,其中网站表示为节点,入站/出站链接表示为节点之间的连接。着陆在特定节点上的“可能性”表示该站点的相对流行程度。也就是说,如果我们的天气系统代表一个极小的互联网,“雨天”将具有较低的页面排名,而“多云”将具有较高的页面排名。
有了这些,让我们回到一个更具体的例子:分析和生成文本。
再次使用前面分析过的威廉·亨利·哈里森的就职演讲,您可以编写以下代码,根据其文本结构生成任意长度的马尔可夫链(链长度设置为 100):
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))
此代码的输出每次运行时都会发生变化,但以下是它将生成的不可思议的无意义文本的示例:
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
那么代码中到底发生了什么呢?
函数buildWordDict接收从互联网检索到的文本字符串。然后进行一些清理和格式化,删除引号并在其他标点周围放置空格,以便它有效地被视为一个独立的单词。然后,它构建一个二维字典——字典的字典,其形式如下:
{word_a : {word_b : 2, word_c : 1, word_d : 1},
word_e : {word_b : 5, word_d : 2},...}
在这个示例字典中,word_a出现了四次,其中两次后跟word_b,一次后跟word_c,一次后跟word_d.然后,“word_e”跟随了七次:五次跟随word_b,两次跟随word_d。
如果我们绘制此结果的节点模型,则表示word_a的节点将有一个 50%的箭头指向word_b(它四次中有两次跟随),一个 25%的箭头指向word_c,和一个 25%的箭头指向word_d.
在构建了这个字典之后,它可以作为查找表用于查看下一步该去哪里,无论你当前在文本中的哪个词上。³ 使用字典的字典示例,你目前可能在word_e上,这意味着你会将字典{word_b : 5, word_d: 2}传递给retrieveRandomWord函数。该函数反过来按出现次数加权从字典中检索一个随机词。
通过从一个随机的起始词(在这种情况下,无处不在的“I”)开始,你可以轻松地遍历马尔可夫链,生成任意数量的单词。
随着收集更多类似写作风格的源文本,这些马尔可夫链的“真实性”会得到改善。尽管此示例使用了 2-gram 来创建链条(其中前一个词预测下一个词),但也可以使用 3-gram 或更高阶的 n-gram,其中两个或更多词预测下一个词。
尽管这些应用程序很有趣,并且是在网络爬取期间积累的大量文本的极好用途,但这些应用程序可能会使人们难以看到马尔可夫链的实际应用。如本节前面提到的,马尔可夫链模拟了网页如何从一个页面链接到下一个页面。这些链接的大量集合可以形成有用的类似网络的图形,用于存储、跟踪和分析。这种方式,马尔可夫链为如何思考网络爬行和你的网络爬虫如何思考奠定了基础。
维基百科的六度分隔:结论
在第六章中,你创建了一个从一个维基百科文章到下一个文章的爬虫程序,从凯文·贝肯的文章开始,并在第九章中将这些链接存储到数据库中。为什么我再提一遍呢?因为事实证明,在选择一条从一个页面开始并在目标页面结束的链接路径的问题(即找到从https://en.wikipedia.org/wiki/Kevin_Bacon到https://en.wikipedia.org/wiki/Eric_Idle的一系列页面)与找到一个首尾都有定义的马尔可夫链是相同的。
这类问题属于有向图问题,其中 A → B 并不一定意味着 B → A。单词“足球”经常会后接“球员”,但你会发现“球员”后接“足球”的次数要少得多。尽管凯文·贝肯的维基百科文章链接到他的家乡费城的文章,但费城的文章并没有回链到他。
相比之下,原始的“凯文·贝肯的六度分隔游戏”是一个无向图问题。如果凯文·贝肯和茱莉亚·罗伯茨在《心灵裂缝》中演出,那么茱莉亚·罗伯茨必然也和凯文·贝肯在《心灵裂缝》中演出,因此关系是双向的(没有“方向”)。与有向图问题相比,无向图问题在计算机科学中较少见,并且两者在计算上都很难解决。
虽然已经对这类问题进行了大量工作,并且有许多变体,但在有向图中寻找最短路径——从维基百科文章“凯文·贝肯”到所有其他维基百科文章的路径——最好且最常见的方法之一是通过广度优先搜索。
首先搜索直接链接到起始页面的所有链接执行广度优先搜索。如果这些链接不包含目标页面(即你正在搜索的页面),则搜索第二层链接——由起始页面链接的页面链接。该过程持续进行,直到达到深度限制(在本例中为 6)或找到目标页面为止。
使用如下所述的链接表的广度优先搜索的完整解决方案如下:
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))
getUrl是一个辅助函数,它根据页面 ID 从数据库中检索 URL。类似地,getLinks接受一个fromPageId,代表当前页面的整数 ID,并获取它链接到的所有页面的整数 ID 列表。
主函数searchBreadth递归地工作以构建从搜索页面到目标页面的所有可能路径列表,并在找到达到目标页面的路径时停止:
-
它从一个单一路径
[1]开始,表示用户在目标页面 ID 为 1(凯文·贝肯)的页面上停留并且不跟随任何链接。 -
对于路径列表中的每条路径(在第一次通过中,只有一条路径,因此此步骤很简单),获取表示路径中最后一页的页面外部链接的所有链接。
-
对于每个出站链接,它检查它们是否与
targetPageId匹配。如果匹配,则返回该路径。 -
如果没有匹配,将一个新路径添加到新的(现在更长的)路径列表中,包含旧路径加上新的出站页面链接。
-
如果在这个级别根本找不到
targetPageId,则发生递归,并且使用相同的targetPageId和新的更长路径列表调用searchBreadth。
找到包含两个页面之间路径的页面 ID 列表后,将每个 ID 解析为其实际 URL 并打印出来。
在此数据库中,搜索凯文·贝肯页面(页面 ID 为 1)和搜索埃里克·艾德尔页面(数据库中页面 ID 为 28624)之间链接的输出是:
/wiki/Kevin_Bacon
/wiki/Primetime_Emmy_Award_for_Outstanding_Lead_Actor_in_a_
Miniseries_or_a_Movie
/wiki/Gary_Gilmore
/wiki/Eric_Idle
这转化为链接的关系:凯文·贝肯 → 黄金时段艾美奖 → 加里·吉尔莫 → 埃里克·艾德尔。
除了解决六度问题和建模外,还可以使用有向和无向图来模拟在网页抓取中遇到的各种情况。哪些网站链接到其他网站?哪些研究论文引用了其他研究论文?在零售网站上,哪些产品倾向于与其他产品一起显示?这种链接的强度是多少?这个链接是双向的吗?
识别这些基本关系类型对于基于抓取数据生成模型、可视化和预测非常有帮助。
自然语言工具包
到目前为止,本章主要集中在对文本体中单词的统计分析上。哪些词最受欢迎?哪些词不寻常?哪些词可能会在哪些其他词之后出现?它们是如何被组合在一起的?您所缺少的是理解,以您能力所及的程度,这些词代表什么。
自然语言工具包(NLTK)是一套设计用来识别和标记自然英文文本中词性的 Python 库。其开发始于 2000 年,过去的 20 多年里,全球数十名开发者为该项目做出了贡献。尽管它提供的功能非常强大(整本书都可以专门讨论 NLTK),本节仅集中介绍其少数用途。
安装和设置
nltk模块可以像其他 Python 模块一样安装,可以直接通过 NLTK 网站下载包,也可以使用任意数量的第三方安装程序与关键词nltk一起安装。有关完整的安装说明和故障排除帮助,请参阅NLTK 网站。
安装了模块后,您可以浏览广泛的文本语料库,这些语料库可以下载和使用:
>>> import nltk
>>> nltk.download()
这将打开 NLTK 下载器。您可以在终端使用其菜单提供的命令进行导航:
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:
语料库列表的最后一页包含其集合:
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)
在这里的练习中,我们将使用书籍集合。您可以通过下载器界面或在 Python 中下载它:
nltk.download('book')
使用 NLTK 进行统计分析
NLTK 非常适合生成关于文本段落中单词计数、词频和词多样性的统计信息。如果您只需要相对简单的计算(例如,在文本段落中使用的独特单词的数量),那么导入nltk可能有些大材小用——它是一个庞大的模块。然而,如果您需要对文本进行相对广泛的分析,您可以轻松获得几乎任何想要的度量函数。
使用 NLTK 进行分析始终从Text对象开始。可以通过以下方式从简单的 Python 字符串创建Text对象:
from nltk import word_tokenize
from nltk import Text
tokens = word_tokenize('Here is some not very interesting text')
text = Text(tokens)
函数word_tokenize的输入可以是任何 Python 文本字符串。任何文本都可以传递进去,但是 NLTK 语料库非常适合用来玩耍和研究功能。你可以通过从 book 模块导入所有内容来使用前面部分下载的 NLTK 集合:
from nltk.book import *
这加载了九本书:
*** 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
你将在所有接下来的例子中使用text6,“Monty Python and the Holy Grail”(1975 年电影的剧本)。
文本对象可以像普通的 Python 数组一样进行操作,就像它们是文本中包含的单词的数组一样。利用这一特性,你可以计算文本中唯一单词的数量,并将其与总单词数进行比较(记住 Python 的set只包含唯一值):
>>> len(text6)/len(set(text6))
7.833333333333333
上面显示了脚本中每个单词平均使用约八次的情况。你还可以将文本放入频率分布对象中,以确定一些最常见的单词和各种单词的频率:
>>> 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
因为这是一个剧本,所以写作方式可能会显现一些特定的艺术形式。例如,大写的“ARTHUR”经常出现,因为它出现在剧本中亚瑟王每一行的前面。此外,冒号(:)在每一行之前出现,作为角色名和角色台词之间的分隔符。利用这一事实,我们可以看到电影中有 1,197 行!
在前几章中我们称之为 2-grams,在 NLTK 中称为bigrams(有时你也可能听到 3-grams 被称为trigrams,但我更喜欢 2-gram 和 3-gram 而不是 bigram 或 trigram)。你可以非常轻松地创建、搜索和列出 2-grams:
>>> from nltk import bigrams
>>> bigrams = bigrams(text6)
>>> bigramsDist = FreqDist(bigrams)
>>> bigramsDist[('Sir', 'Robin')]
18
要搜索 2 元组“Sir Robin”,你需要将其分解为元组(“Sir”,“Robin”),以匹配频率分布中表示 2 元组的方式。还有一个trigrams模块以相同方式工作。对于一般情况,你还可以导入ngrams模块:
>>> from nltk import ngrams
>>> fourgrams = ngrams(text6, 4)
>>> fourgramsDist = FreqDist(fourgrams)
>>> fourgramsDist[('father', 'smelt', 'of', 'elderberries')]
1
这里,调用ngrams函数将文本对象分解为任意大小的 n-grams,由第二个参数控制。在这种情况下,你将文本分解为 4-grams。然后,你可以展示短语“father smelt of elderberries”在剧本中正好出现一次。
频率分布、文本对象和 n-grams 也可以在循环中进行迭代和操作。例如,以下代码打印出所有以单词“coconut”开头的 4-grams:
from nltk.book import *
from nltk import ngrams
fourgrams = ngrams(text6, 4)
[f for f in fourgrams if f[0] == 'coconut']
NLTK 库拥有大量工具和对象,旨在组织、计数、排序和测量大段文本。尽管我们只是初步了解了它们的用途,但这些工具大多设计良好,对熟悉 Python 的人操作起来相当直观。
使用 NLTK 进行词汇分析
到目前为止,你只是根据它们自身的价值比较和分类了遇到的所有单词。没有区分同音异义词或单词使用的上下文。
尽管有些人可能会觉得同音异义词很少会成问题,你也许会惊讶地发现它们出现的频率有多高。大多数以英语为母语的人可能并不经常意识到一个词是同音异义词,更不用说考虑它可能在不同语境中被误认为另一个词了。
“他在实现他写一部客观哲学的目标时非常客观,主要使用客观语态的动词”对人类来说很容易解析,但可能会让网页抓取器以为同一个词被使用了四次,导致它简单地丢弃了关于每个词背后意义的所有信息。
除了查找词性,能够区分一个词在不同用法下的差异也许会很有用。例如,你可能想要查找由常见英语词组成的公司名称,或分析某人对公司的看法。“ACME 产品很好”和“ACME 产品不坏”可能有着相同的基本意思,即使一句话使用了“好”而另一句话使用了“坏”。
除了测量语言之外,NLTK 还可以根据上下文和自身庞大的词典帮助找到词语的含义。在基本水平上,NLTK 可以识别词性:
>>> 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'), ('.', '.')]
每个词都被分为一个包含该词和标识其词性的标签的元组(有关这些标签的更多信息,请参见前面的侧边栏)。虽然这看起来可能是一个简单的查找,但正确执行这项任务所需的复杂性在以下示例中变得显而易见:
>>> 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')]
注意,“dust”一词在句子中使用了两次:一次作为名词,另一次作为动词。NLTK 根据它们在句子中的上下文正确地识别了两种用法。NLTK 通过使用由英语语言定义的上下文无关语法来识别词性。上下文无关语法是定义哪些东西允许跟随其他东西的规则集合。在这种情况下,它们定义了哪些词性可以跟随其他词性。每当遇到一个模棱两可的词如“dust”时,上下文无关语法的规则被参考,并选择一个符合规则的适当词性。
在特定语境中知道一个词是动词还是名词有什么意义呢?在计算机科学研究实验室里或许很有意思,但它如何帮助网页抓取呢?
网页抓取中的一个常见问题涉及搜索。你可能正在从网站上抓取文本,并希望搜索其中“google”一词的实例,但只有在它被用作动词时才这样做,而不是作为专有名词。或者你可能只想寻找公司 Google 的实例,并且不希望依赖人们对大小写的正确使用来找到这些实例。在这里,pos_tag函数可以极为有用:
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)
这只打印包含“google”(或“Google”)一词的句子(作为某种名词,而不是动词)。当然,您可以更具体地要求只打印带有“NNP”(专有名词)标记的 Google 实例,但即使是 NLTK 有时也会出错,因此根据应用程序的不同,留给自己一些灵活性也是有好处的。
大部分自然语言的歧义可以通过 NLTK 的pos_tag函数来解决。通过搜索文本中目标单词或短语 以及 其标记,您可以极大地提高抓取器搜索的准确性和效率。
其他资源
通过机器处理、分析和理解自然语言是计算机科学中最困难的任务之一,关于此主题已经有无数的书籍和研究论文被撰写。我希望这里的涵盖范围能激发您思考超越传统的网络抓取,或者至少给您在开始进行需要自然语言分析的项目时提供一些初始方向。
对于初学者语言处理和 Python 的自然语言工具包有许多优秀的资源。特别是 Steven Bird、Ewan Klein 和 Edward Loper 的书籍Natural Language Processing with Python(O'Reilly)对该主题提供了全面和初步的介绍。
此外,James Pustejovsky 和 Amber Stubbs 的书籍Natural Language Annotations for Machine Learning(O'Reilly)提供了一个略微更高级的理论指南。您需要掌握 Python 的知识才能实施这些教训;所涵盖的主题与 Python 的自然语言工具包完全契合。
¹ 尽管本章描述的许多技术可以应用于所有或大多数语言,但现在只专注于英语自然语言处理也是可以的。例如,工具如 Python 的自然语言工具包专注于英语。根据W3Techs,仍有 53%的互联网内容是用英语编写的(其次是西班牙语,仅占 5.4%)。但谁知道呢?英语在互联网上的主导地位几乎肯定会在未来发生变化,因此在未来几年可能需要进一步更新。
² Oriol Vinyals 等人,“一幅图值千言(连贯):构建图片的自然描述”,Google 研究博客,2014 年 11 月 17 日。
³ 例外是文本中的最后一个单词,因为没有任何单词跟随最后一个单词。在我们的示例文本中,最后一个单词是句号(.),这很方便,因为文本中还有 215 个其他出现,所以不会形成死胡同。但是,在马尔科夫生成器的实际实现中,最后一个单词可能是您需要考虑的内容。
第十三章:穿越表单和登录
当您开始超越 Web 抓取的基础时,一个最先提出的问题是:“我如何访问登录屏幕背后的信息?”网络越来越向互动、社交媒体和用户生成内容发展。表单和登录是这些类型网站的一个组成部分,几乎不可避免。幸运的是,它们也相对容易处理。
到目前为止,我们示例爬虫中与 Web 服务器的大多数交互都是使用 HTTP GET请求信息。本章重点介绍POST方法,该方法将信息推送到 Web 服务器进行存储和分析。
表单基本上为用户提供了一种提交POST请求,Web 服务器可以理解和使用的方法。就像网站上的链接标签帮助用户格式化GET请求一样,HTML 表单帮助他们格式化POST请求。当然,通过少量的编码,我们也可以创建这些请求并使用爬虫提交它们。
Python Requests 库
虽然使用 Python 核心库可以导航网页表单,但有时一些语法糖会让生活变得更甜美。当你开始执行比基本的GET请求更多的操作时,看看 Python 核心库之外的东西可能会有所帮助。
Requests 库在处理复杂的 HTTP 请求、Cookie、标头等方面非常出色。Requests 的创始人 Kenneth Reitz 对 Python 的核心工具有什么看法:
Python 的标准 urllib2 模块提供了大部分你需要的 HTTP 功能,但 API 是彻底破损的。它是为不同的时间和不同的 Web 构建的。即使是最简单的任务,也需要大量工作(甚至是方法覆盖)。
事情不应该这样。在 Python 中不应该这样。
与任何 Python 库一样,Requests 库可以通过任何第三方 Python 库管理器(如 pip)安装,或者通过下载和安装源文件来安装。
提交一个基本表单
大多数 Web 表单包含几个 HTML 字段、一个提交按钮和一个动作页面,实际上处理表单处理的地方。HTML 字段通常包含文本,但也可能包含文件上传或其他非文本内容。
大多数流行的网站在其robots.txt文件中阻止对其登录表单的访问(第二章讨论了刮取这些表单的合法性),所以为了安全起见,我构建了一系列不同类型的表单和登录页面在pythonscraping.com上,您可以在那里运行您的网络爬虫。http://pythonscraping.com/pages/files/form.html 是这些表单中最基本的位置。
表单的整个 HTML 代码如下:
<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>
这里需要注意几点:首先,两个输入字段的名称分别是 firstname 和 lastname。这很重要。这些字段的名称决定了在提交表单时将 POST 到服务器的变量参数的名称。如果你想模仿表单在提交你自己的数据时所采取的动作,你需要确保你的变量名称匹配。
第二点需要注意的是,表单的 action 是 processing.php(绝对路径为 http://pythonscraping.com/pages/files/processing.php)。对表单的任何 POST 请求应该在 这个 页面上进行,而不是表单本身所在的页面。记住:HTML 表单的目的只是帮助网站访问者格式化正确的请求,以发送给真正执行动作的页面。除非你正在研究如何格式化请求本身,否则不需要过多关注可以找到表单的页面。
使用 Requests 库提交表单只需四行代码,包括导入和指令以打印内容(是的,就是这么简单):
import requests
params = {'firstname': 'Ryan', 'lastname': 'Mitchell'}
r = requests.post(
'http://pythonscraping.com/pages/files/processing.php',
data=params
)
print(r.text)
表单提交后,脚本应返回页面的内容:
Hello there, Ryan Mitchell!
此脚本可应用于互联网上遇到的许多简单表单。例如,注册“使用 Python 进行网页抓取”通讯的表单如下所示:
<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>
虽然一开始看起来可能有些吓人,但大多数情况下(稍后我们将讨论例外情况),你只需寻找两件事:
-
要提交的字段(或字段)的名称和数据。在这种情况下,名字是
first_name、姓是last_name和电子邮件地址是email。 -
表单本身的 action 属性;也就是表单提交数据的页面。
在这种情况下,表单的 action 不明显。与传统的 HTML 表单不同,此页面使用 JavaScript 程序检测表单提交并将其提交到正确的 URL。
在这种情况下,使用浏览器的网络工具会很方便。只需打开网络选项卡,填写表单,点击提交按钮,观察发送到网络的值(图 13-1)。
图 13-1. 发送到 pythonscraping.com 的通讯订阅表单的请求
虽然你可以深入研究复杂的 JavaScript,并最终得出相同的答案,但使用网络选项卡可以让你轻松地看到表单内容被提交到 https://pythonscraping.com/wp-admin/admin-ajax.php。
此外,Payload 选项卡显示发送到此端点的第四个表单值:action: eclg_add_newsletter。
有了这个,我们可以在 Python 中复制表单提交的过程:
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)
在这种情况下,表单提供了一个 JSON 格式的响应:
{"status":"1","errmsg":"You have subscribed successfully!."}
单选按钮、复选框和其他输入
显然,并非所有的网络表单都是由文本字段和提交按钮组成的。标准的 HTML 包含多种可能的表单输入字段:单选按钮、复选框和选择框等。HTML5 还增加了滑块(范围输入字段)、电子邮件、日期等。利用自定义的 JavaScript 字段,可能性是无限的,包括颜色选择器、日历和开发人员接下来想出的任何东西。
无论任何类型的表单字段看起来多么复杂,你只需要关心两件事情:元素的名称和其值。元素的名称可以通过查看源代码并找到name属性来确定。值有时可能会更加棘手,因为它可能会在表单提交之前由 JavaScript 立即填充。例如,作为相当奇特的表单字段的颜色选择器,可能会具有像#F03030这样的值。
如果你不确定输入字段值的格式,可以使用各种工具跟踪浏览器发送到和从站点的GET和POST请求。跟踪GET请求的最佳方法,正如前面提到的,是查看站点的 URL。如果 URL 如下所示:
http://domainname.com?thing1=foo&thing2=bar
你就知道这对应于这种类型的表单:
<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>
对应于 Python 参数对象:
{'thing1':'foo', 'thing2':'bar'}
如果你卡在一个看起来复杂的POST表单上,并且你想准确地查看浏览器发送到服务器的参数,最简单的方法是使用浏览器的检查器或开发者工具查看它们(见图 13-2)。
图 13-2. 表单数据部分,突出显示为框,显示了 POST 参数“thing1”和“thing2”,它们的值分别为“foo”和“bar”
提交文件和图像
虽然文件上传在互联网上很常见,但文件上传并不是网络爬虫中经常使用的内容。不过,你可能想为自己的网站编写一个涉及文件上传的测试。无论如何,了解如何做这件事是很有用的。
在*http://pythonscraping.com/pages/files/form2.html*上有一个练习文件上传表单。页面上的表单标记如下:
<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>
除了<input>标签具有类型属性file之外,它看起来基本与前面示例中使用的文本表单相同。幸运的是,Python Requests 库使用表单的方式也类似:
import requests
files = {'uploadFile': open('files/python.png', 'rb')}
r = requests.post('http://pythonscraping.com/pages/files/processing2.php',
files=files)
print(r.text)
请注意,与简单的字符串不同,提交到表单字段(名称为uploadFile)的值现在是一个 Python 文件对象,由open函数返回。在这个例子中,你正在提交一个图像文件,该文件存储在本地机器上,路径为*../files/Python-logo.png*,相对于运行 Python 脚本的位置。
是的,确实如此!
处理登录和 Cookies
到目前为止,我们主要讨论了允许您向网站提交信息或在表单之后立即查看所需信息的表单。这与登录表单有何不同,登录表单允许您在访问网站期间处于永久“已登录”状态?
大多数现代网站使用 Cookie 来跟踪谁已登录和谁未登录。网站在验证您的登录凭据后,将它们存储在您的浏览器 Cookie 中,该 Cookie 通常包含服务器生成的令牌、超时和跟踪信息。然后,网站将此 Cookie 用作身份验证的一种证明,该证明显示在您在网站上停留期间访问的每个页面上。在 1990 年代中期 Cookie 的广泛使用之前,保持用户安全验证并跟踪他们是网站的一个巨大问题。
尽管 Cookie 对于网站开发人员是一个很好的解决方案,但对于网络爬虫来说可能会有问题。您可以整天提交登录表单,但如果您不跟踪表单发送给您的 Cookie,那么您访问的下一页将表现得好像您根本没有登录过一样。
我在http://pythonscraping.com/pages/cookies/login.html创建了一个简单的登录表单(用户名可以是任何内容,但密码必须是“password”)。该表单在*http://pythonscraping.com/pages/cookies/welcome.php处理,其中包含指向主页的链接,http://pythonscraping.com/pages/cookies/profile.php*。
如果您尝试在登录之前访问欢迎页面或个人资料页面,您将收到错误消息并获得登录指示。在个人资料页面上,会检查您浏览器的 Cookie,以查看其 Cookie 是否设置在登录页面上。
使用 Requests 库跟踪 Cookie 很容易:
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)
在这里,您将登录参数发送到欢迎页面,该页面充当登录表单的处理器。您从上次请求的结果中检索 Cookie,打印结果以进行验证,然后通过设置cookies参数将其发送到个人资料页面。
这在简单情况下效果很好,但是如果您要处理的是频繁修改 Cookie 而没有警告的更复杂的站点,或者如果您根本不想考虑 Cookie,那怎么办?在这种情况下,Requests 的session函数完美地解决了这个问题:
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)
在这种情况下,会话对象(通过调用requests.Session()检索)会跟踪会话信息,例如 Cookie、标头,甚至您可能在 HTTP 之上运行的协议的信息,例如 HTTPAdapters。
Requests 是一个了不起的库,也许仅次于 Selenium(在第十四章中介绍)的完整性,它可以处理所有这些而不需要程序员考虑或编写代码。虽然让库来完成所有工作可能很诱人,但在编写网络爬虫时,始终要意识到 Cookie 的样子以及它们在控制什么,这非常重要。这可以节省许多痛苦的调试时间,或者弄清楚为什么网站的行为很奇怪!
HTTP 基本访问身份验证
在 Cookie 出现之前,处理登录的一种流行方式是使用 HTTP 基本访问身份验证。你偶尔还会看到它,尤其是在高安全性或企业站点上,以及一些 API 上。我创建了一个页面,地址是http://pythonscraping.com/pages/auth/login.php,具有此类身份验证(图 13-3)。
图 13-3。用户必须提供用户名和密码才能访问受基本访问身份验证保护的页面
与这些示例一样,你可以使用任何用户名登录,但密码必须是“password”。
Requests 包含一个专门设计用于处理 HTTP 身份验证的auth模块:
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)
尽管这看起来像是一个普通的POST请求,但在请求中将一个HTTPBasicAuth对象作为auth参数传递。结果文本将是由用户名和密码保护的页面(或者如果请求失败,则为拒绝访问页面)。
其他表单问题
Web 表单是恶意机器人的热门入口点。你不希望机器人创建用户帐户、占用宝贵的服务器处理时间或在博客上提交垃圾评论。因此,安全功能通常被纳入现代网站的 HTML 表单中,这些功能可能不会立即显现。
提示
如需有关 CAPTCHA 的帮助,请查看第十六章,其中涵盖了 Python 中的图像处理和文本识别。
如果你遇到一个神秘的错误,或者服务器因为未知原因拒绝你的表单提交,请查看第十七章,其中涵盖了蜜罐、隐藏字段和其他网站采取的安全措施,以保护其表单。
第十四章:JavaScript 抓取
客户端脚本语言是在浏览器内部而不是在 Web 服务器上运行的语言。客户端语言的成功取决于浏览器正确解释和执行语言的能力。
虽然有数百种服务器端编程语言,但只有一种客户端编程语言。这是因为让每个浏览器制造商达成标准协议的难度很大。在进行网页抓取时,语言种类越少越好。
其他客户端编程语言
有些读者可能会对这句话提出异议:“只有一种客户端编程语言。”技术上存在 ActionScript 和 VBScript 等语言。然而,这些语言已不再受支持,并且在 VBScript 的情况下,仅被单个浏览器支持过。因此,它们很少被看到。
如果你想对此挑剔,任何人都可以创建新的客户端编程语言!可能有很多这样的语言存在!唯一的问题是得到浏览器的广泛支持,使该语言有效并被其他人使用。
有些人还争论说 CSS 和 HTML 本身就是编程语言。在理论上我同意这一点。Lara Schenck 在这个主题上有一篇出色而有趣的博客文章:https://notlaura.com/is-css-turing-complete/。
然而,在实际操作中,CSS 和 HTML 通常被视为与“编程语言”分开的标记语言,并且本书对它们有广泛的覆盖。
JavaScript 是迄今为止在 Web 上最常见和最受支持的客户端脚本语言。它可以用于收集用户跟踪信息,无需重新加载页面即可提交表单,嵌入多媒体,甚至支持整个在线游戏。即使看似简单的页面通常也包含多个 JavaScript 片段。你可以在页面的源代码中的script标签之间找到它:
<script>
alert("This creates a pop-up using JavaScript");
</script>
JavaScript 简介
对于您正在抓取的代码至少有一些了解是非常有帮助的。考虑到这一点,熟悉 JavaScript 是个好主意。
JavaScript 是一种弱类型语言,其语法经常与 C++和 Java 进行比较。尽管语法的某些元素,如操作符、循环和数组,可能类似,但语言的弱类型和脚本化特性可能使它对某些程序员来说成为难以应付的“野兽”。
例如,以下递归计算斐波那契数列的值,直到 100,并将它们打印到浏览器的开发者控制台:
<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>
注意,所有变量都是通过在其前面加上 var 来标识的。这类似于 PHP 中的 $ 符号或 Java 或 C++ 中的类型声明(int、String、List 等)。Python 不同之处在于它没有这种明确的变量声明。
JavaScript 也擅长传递函数:
<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>
起初可能会感到艰难,但如果你从 lambda 表达式的角度来思考(在 第五章 中介绍),问题就会变得简单起来。变量 fibonacci 被定义为一个函数。它的函数值返回一个函数,该函数打印出 Fibonacci 序列中逐渐增大的值。每次调用它时,它返回计算 Fibonacci 的函数,并再次执行并增加函数中的值。
你还可能会看到像这样用 JavaScript ES6 引入的箭头语法编写的函数:
<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>
在这里,我使用 JavaScript 关键字 const 表示一个常量变量,它以后不会被重新分配。你可能还会看到关键字 let,表示可以重新分配的变量。这些关键字也是在 ES6 中引入的。
将函数作为变量传递也在处理用户操作和回调时非常有用,当涉及阅读 JavaScript 时,习惯这种编程风格是值得的。
常见的 JavaScript 库
尽管核心 JavaScript 语言很重要,但在现代网络上,如果不使用该语言的众多第三方库之一,你无法走得太远。查看页面源代码时,你可能会看到一种或多种常用的库。
通过 Python 执行 JavaScript 可能会非常耗时和处理器密集,特别是在大规模执行时。熟悉 JavaScript 并能够直接解析它(而无需执行以获取信息)可能非常有用,并且能节省你大量麻烦。
jQuery
jQuery 是一种极为常见的库,被超过 70% 的网站使用¹。使用 jQuery 的网站很容易识别,因为它的代码中某处会包含对 jQuery 的导入:
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></
script>
如果在网站上找到了 jQuery,你在进行抓取时必须小心。jQuery 擅长动态创建 HTML 内容,这些内容仅在执行 JavaScript 后才出现。如果使用传统方法抓取页面内容,你只能获取到在 JavaScript 创建内容之前预加载的页面(这个抓取问题在 “Ajax 和动态 HTML” 中有更详细的讨论)。
此外,这些页面更有可能包含动画、交互内容和嵌入媒体,这可能会使抓取变得更具挑战性。
Google Analytics
Google Analytics 被大约 50% 的所有网站使用²,使其成为可能是互联网上最常见的 JavaScript 库和最受欢迎的用户跟踪工具。http://pythonscraping.com 和 http://www.oreilly.com/ 均使用 Google Analytics。
确定页面是否使用 Google Analytics 很容易。它将在底部具有类似以下内容的 JavaScript(摘自 O’Reilly Media 网站):
<!-- 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>
此脚本处理了用于跟踪您从页面到页面访问的 Google Analytics 特定 cookie。对于那些设计为执行 JavaScript 和处理 cookie(例如稍后在本章中讨论的 Selenium 使用的那些)的网络爬虫,这有时可能会成为一个问题。
如果网站使用 Google Analytics 或类似的 Web 分析系统,而您不希望该网站知道它正在被爬取或抓取,则确保丢弃任何用于分析的 cookie 或完全丢弃 cookie。
Google 地图
如果您在互联网上花费了一些时间,几乎可以肯定地看到嵌入到网站中的 Google 地图。其 API 使得在任何网站上轻松嵌入具有自定义信息的地图成为可能。
如果您正在抓取任何类型的位置数据,了解 Google 地图的工作原理将使您能够轻松获取格式良好的纬度/经度坐标,甚至地址。在 Google 地图中表示位置的最常见方式之一是通过 标记(也称为 图钉)。
标记可以通过以下代码插入到任何 Google 地图中:
var marker = new google.maps.Marker({
position: new google.maps.LatLng(-25.363882,131.044922),
map: map,
title: 'Some marker text'
});
Python 使得可以轻松提取在 google.maps.LatLng( 和 ) 之间发生的所有坐标实例,以获取纬度/经度坐标列表。
使用 Google 逆地理编码 API,您可以将这些坐标对解析为适合存储和分析的地址。
Ajax 和动态 HTML
到目前为止,我们与 web 服务器通信的唯一方法是通过检索新页面发送某种 HTTP 请求。如果您曾经提交过表单或在不重新加载页面的情况下从服务器检索信息,那么您可能使用过使用 Ajax 的网站。
与一些人的观点相反,Ajax 不是一种语言,而是一组用于完成特定任务的技术(与网页抓取类似)。Ajax 代表 异步 JavaScript 和 XML,用于向 web 服务器发送信息并接收信息,而无需发出单独的页面请求。
注意
您不应该说,“这个网站将用 Ajax 编写。”而应该说,“这个网站将使用 Ajax 与 web 服务器通信。”
像 Ajax 一样,动态 HTML(DHTML)是用于共同目的的一组技术。DHTML 是 HTML 代码、CSS 语言或两者的组合,它随着客户端脚本在页面上改变 HTML 元素而改变。例如,当用户移动鼠标时,可能会出现一个按钮,点击时可能会改变背景颜色,或者 Ajax 请求可能会触发加载一个新的内容块。
请注意,尽管“动态”一词通常与“移动”或“变化”等词语相关联,交互式 HTML 组件的存在、移动图像或嵌入式媒体并不一定使页面成为 DHTML 页面,尽管它可能看起来很动态。此外,互联网上一些看起来最无聊、最静态的页面背后可能运行着依赖于 JavaScript 来操作 HTML 和 CSS 的 DHTML 进程。
如果你经常爬取多个网站,很快你就会遇到这样一种情况:你在浏览器中看到的内容与你从网站源代码中检索到的内容不匹配。当你查看爬虫的输出时,可能会摸不着头脑,试图弄清楚为什么你在浏览器上看到的内容在网页源代码中竟然找不到。
该网页还可能有一个加载页面,看起来似乎会将您重定向到另一个结果页面,但您会注意到,当此重定向发生时,页面的 URL 没有发生变化。
这两种情况都是由于你的爬虫未能执行页面上发生魔法的 JavaScript 导致的。没有 JavaScript,HTML 就只是呆呆地呈现在那里,而网站可能看起来与在你的网页浏览器中看到的完全不同。
页面可能有几个迹象表明它可能在使用 Ajax 或 DHTML 来更改或加载内容,但在这种情况下,只有两种解决方案:直接从 JavaScript 中获取内容;或使用能够执行 JavaScript 并在浏览器中查看网站的 Python 包来爬取网站。
在 Python 中执行 Selenium 中的 JavaScript
Selenium 是一个强大的网页抓取工具,最初用于网站测试。如今,当需要准确地呈现网页在浏览器中的外观时,也会使用 Selenium。Selenium 通过自动化浏览器加载网页,获取所需数据,甚至拍摄屏幕截图或验证网站上发生的某些动作来工作。
Selenium 不包含自己的网络浏览器;它需要与第三方浏览器集成才能运行。例如,如果你用 Firefox 运行 Selenium,你会看到一个 Firefox 实例在你的屏幕上打开,导航到网站,并执行你在代码中指定的操作。尽管这可能很有趣,我更喜欢我的脚本在后台静静地运行,并经常使用 Chrome 的无头模式来做到这一点。
一个无头浏览器将网站加载到内存中,并在页面上执行 JavaScript,但不会向用户显示网站的图形渲染。通过将 Selenium 与无头 Chrome 结合使用,你可以运行一个非常强大的网页抓取器,轻松处理 cookies、JavaScript、标头以及其他所有你需要的东西,就像使用渲染浏览器一样。
安装和运行 Selenium
你可以从其网站下载 Selenium 库,或者使用像 pip 这样的第三方安装程序从命令行安装它。
$ pip install selenium
以前的 Selenium 版本要求你手动下载一个 webdriver 文件,以便它与你的网络浏览器进行交互。这个 webdriver 之所以被称为这样,是因为它是网页浏览器的软件驱动程序。就像硬件设备的软件驱动程序一样,它允许 Python Selenium 包与你的浏览器进行接口和控制。
不幸的是,由于浏览器的新版本频繁发布,并且多亏了自动更新,这意味着 Selenium 驱动程序也必须经常更新。导航到浏览器驱动程序的网站(例如http://chromedriver.chromium.org/downloads),下载新文件,并替换旧文件是一个频繁的烦恼。在 2021 年十月发布的 Selenium 4 中,这整个过程被 webdriver 管理器 Python 包替代。
webdriver 管理器可以通过 pip 安装:
$ pip install webdriver-manager
调用时,webdriver 管理器会下载最新的驱动程序:
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()
当然,如果这个脚本经常运行,每次运行时都安装一个新的驱动文件以防止 Chrome 浏览器自上次运行以来被更新是低效的。驱动程序管理器安装的输出只是驱动程序位于你的driver目录中的路径:
CHROMEDRIVER_PATH = ChromeDriverManager().install()
driver = webdriver.Chrome(service=Service(CHROMEDRIVER_PATH))
如果你仍然喜欢手动下载文件,你可以通过将自己的路径传递给Service对象来实现:
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()
尽管许多页面使用 Ajax 加载数据,我已经创建了一个样例页面http://pythonscraping.com/pages/javascript/ajaxDemo.html供你对抗抓取器。这个页面包含一些样例文本,硬编码到页面的 HTML 中,在两秒延迟后被 Ajax 生成的内容替换。如果你使用传统方法来抓取这个页面的数据,你只会得到加载页面,而无法获取你想要的数据。
Selenium 库是调用webdriver对象的 API。请注意,这是一个代表或充当你下载的 webdriver 应用程序的 Python 对象。虽然“driver”和webdriver这两个术语通常可以互换使用来指代这两个东西(Python 对象和应用程序本身),但在概念上区分它们是很重要的。
webdriver对象有点像浏览器,它可以加载网站,但也可以像BeautifulSoup对象一样用于查找页面元素,与页面上的元素交互(发送文本,单击等),并执行其他操作以驱动网络爬虫。
以下代码检索测试页面上 Ajax“墙”后面的文本:
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()
这将使用 Chrome 库创建一个新的 Selenium webdriver,告诉 webdriver 加载页面,然后在查看页面以检索(希望加载的)内容之前暂停执行三秒钟。
当您在 Python 中实例化一个新的 Chrome webdriver 时,可以通过Options对象传递各种选项。在这种情况下,我们使用--headless选项使 webdriver 在后台运行:
chrome_options = Options()
chrome_options.add_argument('--headless')
无论您是使用驱动程序管理程序包安装驱动程序还是自行下载驱动程序,都必须将此路径传递给Service对象,并传递您的选项,以创建新的 webdriver:
driver = webdriver.Chrome(
service=Service(CHROMEDRIVER_PATH),
options=chrome_options
)
如果一切配置正确,脚本应该在几秒钟内运行,然后结果如下所示:
Here is some important text you want to retrieve!
A button to click!
Selenium 选择器
在以前的章节中,您已使用BeautifulSoup选择器(如find和find_all)选择页面元素。 Selenium 使用非常相似的一组方法来选择元素:find_element和find_elements。
从 HTML 中找到和选择元素的方法有很多,您可能会认为 Selenium 会使用各种参数和关键字参数来执行这些方法。但是,对于find_element和find_elements,这两个函数都只有两个参数:By对象和字符串选择器。
By对象指定选择器字符串应如何解释,有以下选项列表:
By.ID
在示例中使用;通过它们的 HTML id属性查找元素。
By.NAME
通过它们的name属性查找 HTML 标记。这对于 HTML 表单很方便。
By.XPATH
使用 XPath 表达式选择匹配的元素。XPath 语法将在本章的后面更详细地介绍。
By.LINK_TEXT
通过它们包含的文本查找 HTML <a>标签。例如,可以使用(By.LINK_TEXT,'Next')选择标记为“Next”的链接。
By.PARTIAL_LINK_TEXT
类似于LINK_TEXT,但匹配部分字符串。
By.TAG_NAME
通过标记名称查找 HTML 标记。
By.CLASS_NAME
用于通过它们的 HTML class属性查找元素。为什么这个函数是CLASS_NAME而不仅仅是CLASS?使用形式object.CLASS会为 Selenium 的 Java 库创建问题,其中.class是保留方法。为了保持各种语言之间的 Selenium 语法一致,使用了CLASS_NAME。
By.CSS_SELECTOR
使用class、id或tag名称查找元素,使用#idName、.className、tagName约定。
在前面的示例中,您使用了选择器driver.find_element(By.ID,'content'),虽然以下选择器也可以使用:
driver.find_element(By.CSS_SELECTOR, '#content')
driver.find_element(By.TAG_NAME, 'div')
当然,如果要在页面上选择多个元素,大多数这些元素选择器都可以通过使用elements(即,使其复数化)返回一个 Python 元素列表:
driver.find_elements(By.CSS_SELECTOR, '#content')
driver.find_elements(By.TAG_NAME, 'div')
如果仍然希望使用 BeautifulSoup 解析此内容,则可以通过使用 webdriver 的 page_source 函数来实现,该函数将页面的源代码作为字符串返回,正如在当前时间由 DOM 查看的那样:
pageSource = driver.page_source
bs = BeautifulSoup(pageSource, 'html.parser')
print(bs.find(id='content').get_text())
等待加载
请注意,尽管页面本身包含一个 HTML 按钮,但 Selenium 的 .text 函数以与检索页面上所有其他内容相同的方式检索按钮的文本值。
如果将time.sleep暂停时间更改为一秒而不是三秒,则返回的文本将变为原始文本:
This is some content that will appear on the page while it's loading.
You don't care about scraping this.
尽管此解决方案有效,但效率略低,并且实施它可能在大规模上造成问题。页面加载时间不一致,取决于任何特定毫秒的服务器负载,并且连接速度会自然变化。尽管此页面加载应仅需超过两秒,但您将其整整三秒时间来确保其完全加载。更高效的解决方案将重复检查完全加载页面上特定元素的存在,并仅在该元素存在时返回。
以下程序使用带有 ID loadedButton 的按钮的出现来声明页面已完全加载:
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()
此脚本引入了几个新的导入项,特别是WebDriverWait和expected_conditions,两者在此处组合以形成 Selenium 称之为的隐式等待。
隐式等待与显式等待不同,它在继续之前等待 DOM 中的某种状态发生,而显式等待则定义了一个硬编码时间,例如前面示例中的三秒等待时间。在隐式等待中,触发 DOM 状态由expected_condition定义(请注意,此处导入被转换为EC,这是一种常用的简洁约定)。在 Selenium 库中,预期条件可以是许多事物,包括:
-
弹出警报框。
-
元素(例如文本框)处于选定状态。
-
页面标题更改,或现在在页面上或特定元素中显示文本。
-
现在,一个元素对 DOM 可见,或者一个元素从 DOM 中消失。
大多数这些预期条件要求您首先指定要监视的元素。元素使用定位器指定。请注意,定位器与选择器不同(有关选择器的更多信息,请参见“Selenium Selectors”)。定位器是一种抽象查询语言,使用By对象,可以以多种方式使用,包括制作选择器。
在下面的代码中,使用定位器查找具有 ID loadedButton 的元素:
EC.presence_of_element_located((By.ID, 'loadedButton'))
定位器还可以用来创建选择器,使用find_element webdriver 函数:
print(driver.find_element(By.ID, 'content').text)
如果不需要使用定位器,请不要使用;这将节省您的导入。然而,这个方便的工具用于各种应用,并具有极大的灵活性。
XPath
XPath(即XML Path)是用于导航和选择 XML 文档部分的查询语言。1999 年由 W3C 创立,偶尔在 Python、Java 和 C#等语言中处理 XML 文档时使用。
虽然 BeautifulSoup 不支持 XPath,但本书中的许多其他库(如 Scrapy 和 Selenium)支持。它通常可以像 CSS 选择器(例如mytag#idname)一样使用,尽管它设计用于与更广义的 XML 文档一起工作,而不是特定的 HTML 文档。
XPath 语法有四个主要概念:
根节点与非根节点
/div 仅在文档根部选择 div 节点。
//div 在文档中选择所有的 div。
属性选择
//@href 选择具有属性href的任何节点。
//a[@href='http://google.com'] 选择文档中指向 Google 的所有链接。
通过位置选择节点
//a[3] 选择文档中的第三个链接。
//table[last()] 选择文档中的最后一个表格。
//a[position() < 3] 选择文档中的前两个链接。
星号(*)匹配任何字符或节点,并可在各种情况下使用。
//table/tr/* 选择所有表格中tr标签的子节点(这对使用th和td标签选择单元格很有用)。
//div[@*] 选择所有带有任何属性的div标签。
XPath 语法还具有许多高级功能。多年来,它发展成为一个相对复杂的查询语言,具有布尔逻辑、函数(如position())和各种此处未讨论的运算符。
如果您有无法通过此处显示的功能解决的 HTML 或 XML 选择问题,请参阅Microsoft 的 XPath 语法页面。
其他 Selenium WebDrivers
在前一节中,Chrome WebDriver(ChromeDriver)与 Selenium 一起使用。大多数情况下,不需要浏览器弹出屏幕并开始网页抓取,因此在无头模式下运行会很方便。然而,以非无头模式运行,并/或使用不同的浏览器驱动程序,可以出于多种原因进行良好的实践:
-
故障排除。如果您的代码在无头模式下运行失败,可能很难在没有看到页面的情况下诊断失败原因。
-
您还可以暂停代码执行并与网页交互,或在您的抓取器运行时使用检查工具来诊断问题。
-
测试可能依赖于特定的浏览器才能运行。一个浏览器中的失败而另一个浏览器中没有可能指向特定于浏览器的问题。
在大多数情况下,最好使用 webdriver 管理器获取您的浏览器驱动程序。例如,您可以使用 webdriver 管理器来获取 Firefox 和 Microsoft Edge 的驱动程序:
from webdriver_manager.firefox import GeckoDriverManager
from webdriver_manager.microsoft import EdgeChromiumDriverManager
print(GeckoDriverManager().install())
print(EdgeChromiumDriverManager().install())
然而,如果您需要一个已弃用的浏览器版本或者通过 webdriver 管理器无法获取的浏览器(例如 Safari),您可能仍然需要手动下载驱动程序文件。
今天的每个主要浏览器都有许多官方和非官方团体参与创建和维护 Selenium Web 驱动程序。Selenium 团队整理了一个用于参考的这些 Web 驱动程序的集合。
处理重定向
客户端重定向是由 JavaScript 在您的浏览器中执行的页面重定向,而不是在服务器上执行的重定向,在发送页面内容之前。当您访问网页时,有时很难区分两者的区别。重定向可能发生得如此之快,以至于您没有注意到加载时间的任何延迟,并假定客户端重定向实际上是服务器端重定向。
然而,在网页抓取时,差异是显而易见的。一个服务器端的重定向,根据它是如何处理的,可以通过 Python 的 urllib 库轻松地遍历,而无需 Selenium 的帮助(有关如何执行此操作的更多信息,请参见第六章)。客户端重定向除非执行 JavaScript,否则根本不会处理。
Selenium 能够以处理其他 JavaScript 执行的方式处理这些 JavaScript 重定向;然而,这些重定向的主要问题在于何时停止页面执行,即如何判断页面何时完成重定向。一个演示页面在http://pythonscraping.com/pages/javascript/redirectDemo1.html上展示了这种类型的重定向,带有两秒的暂停。
您可以通过“观察”页面初始加载时的 DOM 中的一个元素来巧妙地检测重定向,然后重复调用该元素,直到 Selenium 抛出StaleElementReferenceException;元素不再附加到页面的 DOM 中,网站已重定向:
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()
这个脚本每隔半秒检查一次页面,超时时间为 10 秒,尽管检查时间和超时时间可以根据需要轻松调整。
或者,您可以编写一个类似的循环来检查页面当前的 URL,直到 URL 发生变化或者匹配您正在寻找的特定 URL 为止。
在 Selenium 中等待元素出现和消失是一个常见任务,您也可以像上一个按钮加载示例中使用的WebDriverWait函数一样使用相同的方法。在这里,您提供了一个 15 秒的超时时间和一个 XPath 选择器,用于查找页面主体内容来完成相同的任务:
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')
JavaScript 的最后一点说明
当今大多数互联网上的网站都在使用 JavaScript。³ 幸运的是,对我们来说,在许多情况下,这种 JavaScript 的使用不会影响你对页面的抓取。JavaScript 可能仅限于为站点的跟踪工具提供动力,控制站点的一小部分或操作下拉菜单,例如。在它影响到你抓取网站的方式时,可以使用像 Selenium 这样的工具来执行 JavaScript,以生成你在本书第一部分学习抓取的简单 HTML 页面。
记住:仅因为一个网站使用 JavaScript 并不意味着所有传统的网络爬取工具都不再适用。JavaScript 的目的最终是生成可以由浏览器渲染的 HTML 和 CSS 代码,或通过 HTTP 请求和响应与服务器动态通信。一旦使用 Selenium,页面上的 HTML 和 CSS 可以像处理其他任何网站代码一样读取和解析,通过本书前几章的技术可以发送和处理 HTTP 请求和响应,即使不使用 Selenium 也可以。
此外,JavaScript 甚至可以成为网络爬虫的一个好处,因为它作为“浏览器端内容管理系统”的使用可能向外界公开有用的 API,使您可以更直接地获取数据。有关更多信息,请参见第十五章。
如果你在处理某个棘手的 JavaScript 情况时仍然遇到困难,你可以在第十七章中找到关于 Selenium 和直接与动态网站交互的信息,包括拖放界面等。
¹ 查看 Web Technology Surveys 分析,网址为https://w3techs.com/technologies/details/js-jquery,W3Techs 使用网络爬虫随时间监测技术使用趋势。
² W3Techs,《Google Analytics 网站使用统计和市场份额》。
³ W3Techs,《网站上作为客户端编程语言使用的 JavaScript 的使用统计》。