Python Web 爬取教程(一)
一、入门指南
我们将直接进入深水区,而不是每个库后面的安装说明:这一章介绍了一般的网站抓取和我们将在本书中实现的需求。
你可能希望对网站抓取有一个全面的介绍,但是因为你正在读这本书,我希望你已经知道什么是网站抓取,并且你想学习如何用 Python 来做。
因此,我只给你一个主题的浏览,然后直接进入创建一个抓取网站的脚本的深度!
网站抓取
随着互联网的普及,需要抓取网站,在那里你可以分享你的内容和大量数据。第一批广为人知的刮刀是由搜索引擎开发者发明的(比如谷歌或 AltaVista)。这些抓取器(几乎)会遍历整个互联网,扫描每一个网页,从中提取信息,并建立一个你可以搜索的索引。
每个人都可以创造一个刮刀。我们很少有人会尝试实现这样一个大的应用,这可能是谷歌或必应的新竞争。但是我们可以将范围缩小到一两个网页,以结构化的方式提取信息,并将结果导出到数据库或结构化文件(JSON、CSV、XML、Excel 表)。
如今,数字化转型是公司使用并希望参与的新流行语。这种转变的一个组成部分是通过 API 向每个人(或者至少是对该数据感兴趣的其他公司)提供数据访问点。有了这些 API,你不需要投入时间和其他资源来创建一个网站抓取器。
尽管提供 API 对 scraper 开发者没有好处,但这个过程很慢,许多公司都懒得创建这些访问点,因为他们有一个网站,维护就够了。
网站抓取项目
有很多使用案例,你可以利用你的网站抓取知识。有些可能是常识,有些则是极端情况。在本节中,您将找到一些可以利用您的知识的用例。
创建 scraper 的主要原因是从网站中提取信息。这些信息可以是公司销售的产品清单、食品杂货的营养细节,或者是过去 15 年的 NFL 结果。这些项目中的大多数都是进一步数据分析的基础:手动收集所有这些数据是一个漫长且容易出错的过程。
有时你会遇到这样的项目,你需要从一个网站提取数据,然后加载到另一个网站—进行迁移。我最近有一个项目,我的客户将他的网站转移到 WordPress,而旧的博客引擎的导出功能并不意味着将其导入 WordPress。我创建了一个 scraper,提取所有帖子(大约 35000 个)及其图片,对内容进行一些格式化以使用 WordPress 短代码,然后将所有这些帖子导入新网站。
一个奇怪的项目可能是下载整个互联网!理论上这不是不可能的:你从一个网站开始,下载它,提取并跟随这个页面上的所有链接,并下载新的网站。如果你抓取的网站都有互相链接,你就可以浏览(并下载)整个互联网。我不建议你开始这个项目,因为你不会有足够的磁盘空间来容纳整个互联网,但这个想法很有趣。让我知道你有多远,如果你实现这样一个刮刀。
网站是瓶颈
通过网站收集数据最困难的部分之一是网站各不相同。我指的不仅仅是数据,还有版面。因为每个网站都有不同的布局,使用不同的(或没有)HTML IDs 来标识字段,等等,所以很难创建一个适合所有网站的 scraper。
如果这还不够,许多网站经常改变布局。如果发生这种情况,您的铲运机将无法像以前那样工作。在这种情况下,唯一的选择就是重新审视你的代码,使其适应目标网站的变化。
不幸的是,如果你想编写专门的数据提取器,你不会学到帮助你创建一个总是工作的刮刀的秘密技巧。我将在本书中展示一些例子,如果使用了 HTML 标准,这些例子将始终有效。
本书中的工具
在这本书里,你将会学到用 Python 做网站抓取的基本工具。你很快就会意识到从头开始制造每一件刮刀有多难。
但是 Python 有一个很棒的社区,有很多项目可以帮助你专注于你的刮刀的重要部分:数据提取。我将向您介绍像requests库、Beautiful Soup和Scrapy这样的工具。
requests库是处理 HTTP 的繁琐任务的轻量级包装器,它是推荐的方式:
建议将请求包用于更高级别的 HTTP 客户端接口。
— Python 3 文档
Beautiful Soup是一个内容解析器。它不是一个网站抓取工具,因为它不会自动导航页面,而且很难扩展。但是它有助于解析内容,并为您提供了以友好的方式从 XML 和 HTML 结构中提取所需信息的选项。
Scrapy是一个网站抓取框架/库。比Beautiful Soup厉害多了,还可以规模化。因此,你可以用Scrapy更容易地创建更复杂的刮刀。但是另一方面,您有更多的选项可以配置。微调Scrapy可能是一个问题,如果你做错了,你可能会搞砸很多。但是伴随着强大的力量而来的是巨大的责任:你必须小心使用Scrapy。
尽管Scrapy是为网站抓取而创建的 Python 库,但有时我更喜欢requests和Beautiful Soup的组合,因为它是轻量级的,我可以在短时间内编写我的 scraper—,并且我不需要缩放或并行执行。
准备
在开始一个网站刮刀的时候,哪怕是一个小脚本,也要做好任务的准备。一开始,您需要考虑一些法律和技术问题。
在这一节,我会给你一个简短的清单,列出你应该做些什么来为网站抓取工作或任务做准备:
-
网站的所有者允许刮痧吗?要找到答案,请阅读网站的条款&条件和隐私政策。
-
能不能刮出自己感兴趣的部分?更多信息见
robots.txt文件,并使用可处理该信息的工具。 -
网站使用什么技术?有一些免费的工具可以帮助你完成这项任务,但是你可以查看网站的 HTML 代码来找到答案。
-
我应该使用什么工具?根据你的任务和网站的结构,有不同的路径可以选择。
现在让我们来看一下提到的每一项的详细描述。
术语和机器人
刮擦目前几乎没有任何限制;没有法律规定什么可以刮,什么不可以。
然而,有一些准则定义了你应该尊重什么。没有强制执行;你可以完全忽略这些建议,但你不应该。
在你开始任何搜集任务之前,看看你想收集数据的网站的条款&条件和隐私政策。如果抓取没有限制,那么您应该查看给定网站的robots.txt文件。
阅读网站的条款和条件时,您可以搜索以下关键词来查找限制条件:
-
刮刀/刮削
-
爬虫/爬行
-
马胃蝇蛆
-
蜘蛛;状似蜘蛛的物体;星形轮;十字叉;连接柄;十字头
-
程序
大多数时候可以找到这些关键词,这使得你的搜索更容易。如果你运气不好,你需要通读全部法律内容,这并不容易,至少我认为法律内容读起来总是枯燥无味的。
在欧盟,有一种数据保护权利已经存在了几年,但从 2018 年开始严格执行:GDPR。不要把私人的私人数据收集到你的信息中——如果因为你的信息收集而泄露出去,你可能要承担责任。
robots.txt
大多数网站都提供了一个名为robots.txt的文件,用来告诉网络爬虫哪些东西可以刮,哪些东西不应该碰。当然,尊重这些建议取决于开发者,但是我建议你总是服从文件的内容。
让我们看看这样一个文件的例子:
User-agent: *
Disallow: /covers/
Disallow: /api/
Disallow: /*checkval
Disallow: /*wicket:interface
Disallow: ?print_view=true
Disallow: /*/search
Disallow: /*/product-search
Allow: /*/product-search/discipline
Disallow: /*/product-search/discipline?*facet-subj=
Disallow: /*/product-search/discipline?*facet-pdate=
Disallow: /*/product-search/discipline?*facet-type=category
前面的代码块来自 www.apress.com/robots.txt 。正如你所看到的,大多数内容告诉你什么是不允许的。比如刮刀不该刮 www.apress.com/covers/ 。
除了 Allow 和 Disallow 条目之外,用户代理也很有趣。每台铲运机都应有一个标识,该标识通过用户代理参数提供。由谷歌和必应创建的更大的机器人有它们独特的标识符。因为它们是将你的页面添加到搜索结果中的抓取器,你可以定义排除,让这些机器人不再骚扰你。在本章的后面,您将创建一个脚本,该脚本将使用自定义用户代理检查并遵循robots.txt文件的指导原则。
在一个robots.txt file中可以有其他条目,但它们不是标准的。要了解更多关于这些条目的信息,请访问 https://en.wikipedia.org/wiki/Robots_exclusion_standard 。
网站技术
另一个有用的准备步骤是查看目标网站使用的技术。
有一个名为builtwith的 Python 库,旨在检测网站利用的技术。这个库的问题是上一个版本 1.3.2 是 2015 年发布的,和 Python 3 不兼容。因此,您不能像使用 PyPI 中的库一样使用它。 1
然而,在 2017 年 5 月,Python 3 支持已被添加到源代码中,但新版本尚未发布(然而,我在 2017 年 11 月写这篇文章)。这并不意味着我们不能使用这个工具;我们必须手动安装它。
首先,从 https://bitbucket.org/richardpenman/builtwith/downloads/ 下载源码。如果您愿意,可以使用 Mercurial 克隆存储库,以便在发生新的更改时保持最新。
下载源代码后,导航到下载源代码的文件夹,并执行以下命令:
pip install .
该命令将builtwith安装到您的 Python 环境中,您可以使用它。
现在,如果您打开 Python CLI,您可以查看您的目标站点,看看它使用了什么技术。
>>> from builtwith import builtwith
>>> builtwith('http://www.apress.com')
{'javascript-frameworks': ['AngularJS', 'jQuery'], 'font-scripts': ['Font Awesome'], 'tag-managers': ['Google Tag Manager'], 'analytics': ['Optimizely']}
前面的代码块显示了 Apress 在其网站上使用的技术。你可以从 AngularJS 中学到,如果你打算写一个 scraper,你应该准备好处理用 JavaScript 呈现的动态内容。
builtwith不是一个神奇的工具,它是一个下载给定网址的网站抓取器;解析其内容;根据它的知识库,它会告诉你网站使用了哪些技术。该工具使用基本的 Python 功能,这意味着有时您无法在感兴趣的网站中获得信息,但大多数情况下您可以获得足够的信息。
使用 Chrome 开发工具
为了浏览网站并确定需求的领域,我们将使用 Google Chrome 的内置 DevTools 。如果你不知道这个工具能为你做什么,这里有一个快速介绍。
Chrome 开发者工具 (简称 DevTools),是谷歌 Chrome 内置的一套网页创作和调试工具。 DevTools 为 web 开发人员提供了对浏览器内部及其 web 应用的深度访问。使用 DevTools 有效地跟踪布局问题,设置 JavaScript 断点,并深入了解代码优化。
*如你所见,DevTools 为你提供了查看浏览器内部工作的工具。我们不需要什么特别的东西;我们将使用 DevTools 来查看信息驻留在哪里。
在这一节中,我将通过截图指导我们完成我开始(或者只是评估)一个抓取项目时通常会做的步骤。
设置
首先,你必须准备获取信息。即使我们知道要刮哪个网站,提取什么样的数据,我们也需要一些准备。
基本的网站抓取工具是简单的工具,将网站内容下载到内存中,然后提取这些数据。这意味着它们不能像 JavaScript 一样运行动态内容,因此我们必须通过禁用 JavaScript 渲染来使我们的浏览器类似于一个简单的刮刀。
首先,用鼠标右键单击网页,从菜单中选择“Inspect”,如图 1-1 所示。
图 1-1
启动 Chrome 的 DevTools
或者,你可以在 Windows 中按下CTRL+SHIFT+I或者在 Mac 上按下 z + ⇧+ I来打开 DevTools 窗口。
然后定位设置按钮(三个垂直排列的点,如图 1-2 。)并点击它:
图 1-2
设置菜单位于三个点的下方
或者,您可以在 Windows 中按下F1。
现在向下滚动到设置屏幕的底部,确保Disable JavaScript被选中,如图 1-3 所示。
图 1-3
禁用 JavaScript
现在重新加载页面,退出设置窗口,但是保持在 inspector 视图中,因为我们将使用这里可用的 HTML 元素选择器。
注意
如果你想知道你的抓取器如何看到网站,禁用 JavaScript 是必要的。
在本书的后面,您将学习如何抓取利用 JavaScript 呈现动态内容的网站的选项。
但是为了充分理解和享受这些额外的功能,您必须学习基础知识。
工具注意事项
如果你正在读这本书,你很可能会用 Python 3 编写你的刮刀。但是,您必须决定使用哪些工具。
在这本书里,你会学到交易的工具,你可以自己决定用什么,但现在我会和你分享我是如何决定一种方法的。
如果你正在处理一个简单的网站—,简单,我的意思是一个不过度使用 JavaScript 渲染的网站—,那么你可以选择用Beautiful Soup + requests创建一个爬虫或者使用Scrapy。如果您必须处理大量数据,并且希望加快速度,请使用Scrapy。最终你会在 90%的任务中使用Scrapy,你可以将Beautiful Soup整合到Scrapy中一起使用。
如果网站使用 JavaScript 进行渲染,您可以对 AJAX/XHR 调用进行逆向工程并使用您喜欢的工具,或者您可以使用一个工具来为您渲染网站。这样的工具是 Selenium 和 Portia。我将在本书中向您介绍这些方法,您可以决定哪种方法最适合您,哪种方法更容易使用。
开始编码
在这个冗长的介绍之后,是时候写一些代码了。我猜你渴望让你的手指变得“脏”并且创造你的第一个刮刀。
在这一节中,我们将编写简单的 Python 3 脚本来帮助您开始抓取,并利用您在本章前面所读到的一些信息。
这些微型脚本不会是成熟的应用,只是本书中等待您的小演示。
解析 robots.txt
让我们创建一个应用,它解析目标网站的robots.txt文件并根据内容进行操作。
Python 有一个名为robotparser的内置模块,它使我们能够读取和理解robots.txt文件,并询问解析器我们是否可以抓取目标网站的给定部分。
我们将使用之前显示的来自Apress.com的robots.txt文件。要跟进,打开您选择的 Python 编辑器,创建一个名为robots.py的文件,并添加以下代码:
from urllib import robotparser
robot_parser = robotparser.RobotFileParser()
def prepare(robots_txt_url):
robot_parser.set_url(robots_txt_url)
robot_parser.read()
def is_allowed(target_url, user_agent='*'):
return robot_parser.can_fetch(user_agent, target_url)
if __name__ == '__main__':
prepare('http://www.apress.com/robots.txt')
print(is_allowed('http://www.apress.com/covers/'))
print(is_allowed('http://www.apress.com/gp/python'))
现在让我们运行示例应用。如果我们做的一切都是正确的(并且 Apress 没有改变它的机器人指南),我们应该取回False和True,因为我们不被允许访问covers文件夹,但是在 Python 部分没有限制。
> python robots.py
False
True
如果你自己写 scraper,不使用Scrapy,这段代码片段还是不错的。集成robotparser并在访问之前检查每个 URL,这有助于您自动执行满足网站所有者访问请求的任务。
在本章的前面,我提到过您可以在一个robots.txt文件中定义用户代理特定的限制。因为我无法访问 Apress 网站,所以我在自己的主页上为这本书创建了一个自定义条目,该条目如下所示:
User-Agent: bookbot
Disallow: /category/software-development/java-software-development/
现在来看看这是如何工作的。为此,您必须修改之前编写的 Python 代码(robots.py)或创建一个新的代码,以便在您调用is_allowed函数时提供一个用户代理,因为它已经接受了一个用户代理作为参数。
from urllib import robotparser
robot_parser = robotparser.RobotFileParser()
def prepare(robots_txt_url):
robot_parser.set_url(robots_txt_url)
robot_parser.read()
def is_allowed(target_url, user_agent='*'):
return robot_parser.can_fetch(user_agent, target_url)
if __name__ == '__main__':
prepare('http://hajba.hu/robots.txt')
print(is_allowed('http://hajba.hu/category/software-development/java-software-development/', 'bookbot'))
print(is_allowed('http://hajba.hu/category/software-development/java-software-development/', 'my-agent'))
print(is_allowed('http://hajba.hu/category/software-development/java-software-development/', 'googlebot'))
上述代码将产生以下输出:
False
True
True
不幸的是,你无法阻止恶意机器人抓取你的网站,因为在大多数情况下,它们会忽略你的robots.txt文件中的设置。
创建链接提取器
在这个冗长的介绍之后,是时候创建我们的第一个 scraper 了,它将从给定的页面中提取链接。
这个例子很简单;我们不会使用任何专门的工具来抓取网站,只使用标准 Python 3 安装中可用的库。
让我们打开一个文本编辑器(或者您选择的 Python IDE)。我们将在一个名为link_extractor.py的文件中工作。
from urllib.request import urlopen
import re
def download_page(url):
return urlopen(url).read().decode('utf-8')
def extract_links(page):
link_regex = re.compile('<a[^>]+href="\'["\']', re.IGNORECASE)
return link_regex.findall(page)
if __name__ == '__main__':
target_url = 'http://www.apress.com/'
apress = download_page(target_url)
links = extract_links(apress)
for link in links:
print(link)
前面的代码块提取了所有的链接,这些链接可以在 Apress 主页上找到(仅在第一页上)。如果用 Python 命令link_extractor.py运行代码,会看到很多以斜杠(/)开头的 URL,没有任何域信息。这是因为这些是apress.com网站的内部链接。为了解决这个问题,我们可以手动在链接集中查找这样的条目,或者使用 Python 标准库中已经存在的工具:urljoin。
from urllib.request import urlopen, urljoin
import re
def download_page(url):
return urlopen(url).read().decode('utf-8')
def extract_links(page):
link_regex = re.compile('<a[^>]+href="\'["\']', re.IGNORECASE)
return link_regex.findall(page)
if __name__ == '__main__':
target_url = 'http://www.apress.com/'
apress = download_page(target_url)
links = extract_links(apress)
for link in links:
print(urljoin(target_url, link))
正如您所看到的,当您运行修改后的代码时,这种新方法会将 http://www.apress.com 添加到每个缺少此前缀的 URL,例如 http://www.apress.com/gp/python ,但保留其他 URL,如 https://twitter.com/apress 。
前面的代码示例使用正则表达式来查找网站的 HTML 代码中的所有锚标记(<a>)。正则表达式是一个很难学的题目,也不容易写。这就是为什么我们不会深入这个话题,而会在本书中使用更高级的工具,比如Beautiful Soup,来提取我们的内容。
提取图像
在这一节中,我们将从网站中提取图像源。我们还不会下载任何图片,只是想知道这些图片在网上的位置。
图像与上一节中的链接非常相似,但是它们是由<img>标签定义的,并且有一个src属性,而不是一个href。
有了这些信息,您可以在这里停下来,尝试自己编写提取器。接下来,你会找到我的解决方案。
from urllib.request import urlopen, urljoin
import re
def download_page(url):
return urlopen(url).read().decode('utf-8')
def extract_image_locations(page):
img_regex = re.compile('<img[^>]+src="\'["\']', re.IGNORECASE)
return img_regex.findall(page)
if __name__ == '__main__':
target_url = 'http://www.apress.com/'
apress = download_page(target_url)
image_locations = extract_image_locations(apress)
for src in image_locations:
print(urljoin(target_url, src))
如果仔细观察,我只修改了一些变量名和正则表达式。我可以使用前一节中的链接提取器,只修改表达式。
摘要
在这一章中,你已经基本了解了网站抓取以及如何准备抓取工作。
除了简介之外,您还为从网页中提取信息的抓取器创建了第一个构建块,比如链接和图像源。
正如你可能猜到的,第一章仅仅是个开始。在接下来的章节中会有更多的内容。
您将学习创建一个刮刀的要求,并且您将使用像Beautiful Soup和Scrapy这样的工具编写您的第一个刮刀。敬请期待,继续阅读!
PyPI——Python 包索引
*
二、输入需求
在介绍性章节之后,是时候让你开始一个真正的刮擦项目了。
在本章中,您将学习在接下来的两章中,使用Beautiful Soup和Scrapy必须提取哪些数据。
不用担心;要求很简单。我们将从以下网站中提取信息: https://www.sainsburys.co.uk/ 。
Sainsbury's 是一家提供大量商品的在线商店。这是一个伟大的网站抓取项目的来源。
我将指导您找到满足需求的方法,并且您将了解我是如何处理一个抓取项目的。
图 2-1
2017 年万圣节塞恩斯伯里的登陆页面
要求
如果你看看这个网站,你可以看到这是一个简单的网页,有很多信息。让我告诉你我们将提取哪些部分。
一个想法是从万圣节主题网站中提取一些东西(见图 2-1 )。用于他们的主题登陆页面)。然而,这不是一个选项,因为你不能自己尝试;至少在 2017 年,当你读到这篇—的时候,万圣节已经结束了,我不能保证未来的销售会是一样的。
因此,您将提取食品杂货的信息。更具体地说,您将从“肉类和鱼类”部门收集营养细节。
对于每个包含营养详细信息的条目,您可以提取以下信息:
-
产品名称
-
产品的 URL
-
项码
-
每 100 克的营养成分:
-
千卡能量
-
能量单位为千焦
-
脂肪
-
浸透
-
碳水化合物
-
总糖
-
淀粉
-
纤维
-
蛋白质
-
盐
-
-
原产国
-
单价
-
单位
-
评论数量
-
平均分
这看起来很多,但不要担心!您将学习如何使用自动化脚本从该部门的所有产品中提取这些信息。而且如果你很敏锐,有上进心,你可以延伸这方面的知识,提取所有产品的所有营养信息。
准备
正如我在前一章提到的,在你开始你的 scraper 开发之前,你应该看看网站的条款和条件,以及robots.txt文件,看看你是否能提取你需要的信息。
在撰写这一部分时(2017 年 11 月),网站的条款和条件中没有关于刮刀限制的条目。这意味着,你可以创建一个机器人来提取信息。
下一步是查看robots.txt文件,在http://sainsburys.co.uk/robots.txt找到。
# __PUBLIC_IP_ADDR__ - Internet facing IP Address or Domain name.
User-agent: *
Disallow: /webapp/wcs/stores/servlet/OrderItemAdd
Disallow: /webapp/wcs/stores/servlet/OrderItemDisplay
Disallow: /webapp/wcs/stores/servlet/OrderCalculate
Disallow: /webapp/wcs/stores/servlet/QuickOrderCmd
Disallow: /webapp/wcs/stores/servlet/InterestItemDisplay
Disallow: /webapp/wcs/stores/servlet/ProductDisplayLargeImageView
Disallow: /webapp/wcs/stores/servlet/QuickRegistrationFormView
Disallow: /webapp/wcs/stores/servlet/UserRegistrationAdd
Disallow: /webapp/wcs/stores/servlet/PostCodeCheckBeforeAddToTrolleyView
Disallow: /webapp/wcs/stores/servlet/Logon
Disallow: /webapp/wcs/stores/servlet/RecipesTextSearchDisplayView
Disallow: /webapp/wcs/stores/servlet/PostcodeCheckView
Disallow: /webapp/wcs/stores/servlet/ShoppingListDisplay
Disallow: /webapp/wcs/stores/servlet/gb/groceries/get-ideas/advertising
Disallow: /webapp/wcs/stores/servlet/gb/groceries/get-ideas/development
Disallow: /webapp/wcs/stores/servlet/gb/groceries/get-ideas/dormant
Disallow: /shop/gb/groceries/get-ideas/dormant/
Disallow: /shop/gb/groceries/get-ideas/advertising/
Disallow: /shop/gb/groceries/get-ideas/development
Sitemap: http://www.sainsburys.co.uk/sitemap.xml
在代码块中,你可以看到什么是允许的,什么是不允许的,这个robots.txt非常严格,只有Disallow个条目,但这是针对所有机器人的。
从这篇课文中我们能发现什么?例如,你不应该创建通过这个网站自动订购的机器人。但这对我们来说并不重要,因为我们只需要收集信息—不需要购买。这个robots.txt文件对我们的目的没有限制;我们可以自由地继续我们的准备和刮擦。
什么会限制我们的目标?
问得好。在robots.txt中引用“肉&鱼”部门的条目可能会限制我们的抓取意图。一个示例条目如下所示:
User-agent: *
Disallow: /shop/gb/groceries/meat-fish/
Disallow: /shop/gb/groceries/
但这将使搜索引擎无法查找塞恩斯伯里销售的商品,这将是一个巨大的利润损失。
浏览“肉类和鱼类”
正如本章开始时提到的,我们将从“肉类和鱼类”部门提取数据。这部分网站的网址是 www.sainsburys.co.uk/shop/gb/groceries/meat-fish 。
让我们在 Chrome 浏览器中打开 URL,禁用 JavaScript,并按照上一章所述重新加载浏览器窗口。请记住,禁用 JavaScript 使您能够看到网站的 HTML 代码,就像基本的 scraper 会看到它一样。
当我写这篇文章时,该部门的网站看起来如图 2-2 。
图 2-2
用 Chrome 的 DevTools 检查了“肉类和鱼类”部门的页面
出于我们的目的,左侧的导航菜单很有趣。它包含到我们将在其中找到要提取的产品的页面的链接。让我们使用选择工具(或点击 CTRL-SHIFT-C)选择包含这些链接的框,如图 2-3 所示。
图 2-3
选择左侧的导航栏
现在我们可以在 DevTools 中看到,每个链接都在一个无序列表(<ul>)的列表元素(<li>标签)中,类为categories departments。记下这些信息,因为我们以后会用到。
链接,有一个指向右边的小箭头(>),告诉我们它们只是一个分组类别,如果我们点击它们,我们会在它们下面找到另一个导航菜单。让我们检查一下烤晚餐选项,如图 2-4 所示。
图 2-4
“烧烤晚餐”子菜单
在这里,我们可以看到该页面没有产品,但有另一个详细的网站链接列表。如果我们看看 DevTools 中的 HTML 结构,我们可以看到这些链接也是无序列表的元素。这个无序列表有一个类categories aisles。
现在我们可以进一步进入牛肉类别,这里我们列出了产品(在一个大过滤盒之后),如图 2-5 所示。
图 2-5
“牛肉”类别的产品
这里我们需要考察两件事:一是产品列表;另一个是导航。
如果该类别包含的产品超过 36 个(这是网站上显示的默认数量),这些产品将被拆分到多个页面中。因为我们想要提取所有产品的信息,所以我们必须浏览所有这些页面。如果我们选择导航,我们可以看到它又是一个无序的类列表pages,如图 2-6 所示。
图 2-6
带有类“pages”的无序列表
在这些列表元素中,我们感兴趣的是带有向右箭头符号的元素,它包含类next。这告诉我们,如果我们有下一页,我们必须导航或没有。
现在让我们找到产品详细信息页面的链接。所有产品都在一个无序列表中(再次)。该列表有productLister gridView类,如图 2-7 所示。
图 2-7
从开发工具中选择产品列表
每个产品都在带有类gridItem的列表元素中。如果我们打开其中一个产品的详细信息,我们可以看到导航链接在哪里:位于一些div和一个h3中。我们注意到最后一个div有类productNameAndPromotions,如图 2-8 所示。
图 2-8
选择产品名称
现在我们已经达到了产品的层次,我们可以更进一步,专注于真正的任务:识别所需的信息。
选择所需信息
基于图 2-9 中所示的产品,我们将发现我们所需信息所在的元素。
图 2-9
我们将在示例中使用的详细产品页面
现在我们已经有了产品,让我们来确定所需的信息。和前面一样,我们可以使用选择工具,找到所需的文本,并从 HTML 代码中读取属性。
产品的名称在一个头(h1)里面,这个头在一个带有类productTitleDescriptionContainer的div里面。
价格和单位在pricing类的一个div中。价格本身就在pricePerUnit类的一段(p);单位在pricePerUnitUnit班的一个span。
提取评级很棘手,因为这里我们只看到评级的星星,但我们想要数字评级本身。让我们来看看图片的 HTML 定义,如图 2-10 所示。
图 2-10
图像的 HTML 代码
我们可以看到图像的位置在类numberOfReviews的label中,它有一个属性alt,包含评论平均值的十进制值。在图像之后,是包含评论数量的文本。
项目代码在itemCode类的段落内。
如图 2-11 所示的营养信息在nutritionTable类的table中。该表的每一行(tr)都包含我们需要的数据的一个条目:该行的标题(th)包含名称,第一列(td)包含值。唯一的例外是能量信息,因为两行包含值,但只有第一行是标题。正如你将看到的,我们也将通过一些特定的代码来解决这个问题。
图 2-11
营养表
如图 2-12 所示,原产国在productText类 div 的一个段落内。这个字段不是惟一的:每个描述都在一个productText div中。这将使提取有点复杂,但也有一个解决方案。
图 2-12
在 Chrome 的开发工具中选择“原产国”
尽管我们必须提取许多字段,但我们很容易在网站中识别它们。现在是提取数据和学习交易工具的时候了!
概述应用
在定义了需求并且我们找到了要提取的每个条目之后,是时候计划应用的结构和行为了。
如果你想一想如何着手这个项目,你会从大爆炸开始,“让我们锤代码”的想法。但是你以后会意识到,你可以把整个脚本分解成更小的步骤。下面是一个例子:
-
下载起始页面,在本例中是“肉类和鱼类”部门,并提取产品页面的链接。
-
下载产品页面并提取详细产品的链接。
-
从已经下载的产品页面中提取我们感兴趣的信息。
-
导出提取的信息。
这些步骤可以识别我们正在开发的应用的功能。
步骤 1 还提供了一点东西:如果你还记得你看到的用 DevTools 进行的分析,一些链接只是一个分组类别,你必须从这个分组类别中提取细节页面链接。
浏览网站
在我们开始学习你将用来抓取网站数据的第一个工具之前,我想向你展示如何浏览网站—,这将是抓取器的另一个组成部分。
网站由页面和页面之间的链接组成。如果你还记得你的数学研究,你会意识到一个网站可以被描绘成一个图形,如图 2-13 所示。
图 2-13
导航路径
因为网站是一个图表,你可以使用图表算法来浏览页面和链接:广度优先搜索(BFS)和深度优先搜索(DFS)。
使用 BFS,你可以进入图形的一个层次,收集下一个层次所需的所有 URL。例如,您从“肉类和鱼类”部门页面开始,提取下一个所需的级别的所有 URL,如“畅销商品或“烧烤晚餐”。“然后你就有了所有这些网址,去最畅销的网站,提取所有链接到详细产品页面的网址。完成这些之后,您可以转到“烧烤晚餐”页面,并从那里提取所有产品的详细信息,以此类推。最后,您将获得所有产品页面的 URL,您可以从中提取所需的信息。
使用 DFS,您可以通过“肉与鱼”、“最畅销商品”、“最畅销商品”和“最畅销商品”直接找到第一种商品,并从其网站上提取信息。然后你去“畅销商品页面上的下一个商品,从那里提取信息。如果您有来自“最畅销商品的所有商品,那么您将移动到“烧烤晚宴”并从那里提取所有商品。
如果你问我,我会说这两种算法都很好,而且结果是一样的。我可以写两个脚本并比较它们,看哪一个更快,但是这种比较会有偏见和缺陷。 1
因此,您将实现一个导航网站的脚本,并且您可以更改它背后的算法以使用 BFS 或 DFS。
如果你对**感兴趣为什么?**对于这两种算法,我建议你考虑马格努斯·赫特兰德的书: Python 算法。22
创建导航
如果你看算法,实现导航是简单的,因为这是唯一的诀窍:实现伪代码。
好吧,我有点懒,因为你也需要实现链接提取,这可能有点复杂,但你已经有了第一章中的构建块,你可以自由使用它。
def extract_links(page):
if not page:
return []
link_regex = re.compile('<a[^>]+href="\'["\']', re.IGNORECASE)
return [urljoin(page, link) for link in link_regex.findall(page)]
def get_links(page_url):
host = urlparse(page_url)[1]
page = download_page(page_url)
links = extract_links(page)
return [link for link in links if urlparse(link)[1] == host]
所示的两个函数提取页面,链接仍然指向 Sainsbury 的网站。
注意
如果你不过滤掉外部 URL,你的脚本可能永远不会结束。这只有在你想浏览整个 WWW 来看看你能从一个网站到达多远的时候才有用。
extract_links函数负责一个空页面或None页面。urljoin不会对此抱怨,但是re.findall会抛出一个异常,你不希望这种情况发生。
get_links函数返回指向同一主机的网页的所有链接。要找出使用哪个主机,您可以利用urlparse函数、3 返回一个元组。这个元组的第二个参数是从 URL 中提取的主机。
这些是最基本的。现在出现了两种搜索算法:
def depth_first_search(start_url):
from collections import deque
visited = set()
queue = deque()
queue.append(start_url)
while queue:
url = queue.popleft()
if url in visited:
continue
visited.add(url)
for link in get_links(url):
queue.appendleft(link)
print(url)
def breadth_first_search(start_url):
from collections import deque
visited = set()
queue = deque()
queue.append(start_url)
while queue:
url = queue.popleft()
if url in visited:
continue
visited.add(url)
queue.extend(get_links(url))
print(url)
如果你看一下刚刚展示的两个函数,你会发现它们的代码只有一个不同(提示:突出显示):你如何把它们放入队列,这是一个堆栈。
requests图书馆
要成功实现这个脚本,您必须了解一点关于requests库的知识。
我非常喜欢 Python 核心库的可扩展性,但是有时你需要由社区成员开发的库。图书馆就是其中之一。
使用基本的 Python urlopen可以创建简单的请求和相应的数据,但是使用起来很复杂。requests库在这种复杂性之上添加了一个友好的层,使网络编程变得容易:它负责重定向,并且可以为您处理会话和 cookies。Python 文档推荐使用它作为工具。
同样,我不会向您详细介绍这个库,只提供必要的信息。如果您需要更多信息,请查看该项目的网站。 4
装置
作为“Pythonista”,您已经知道如何安装库。但是为了完整起见,我把它包括在这里。
pip install requests
现在你可以继续写这本书了。
获取页面
使用请求库:requests.get(url)请求页面很容易。
这将返回一个包含基本信息的响应对象,比如状态代码和内容。内容通常是您请求的网站的主体,但是如果您请求一些二进制数据(如图像或声音文件)或 JSON,那么您会得到它们。对于这本书,我们将重点讨论 HTML 内容。
您可以通过调用响应的文本参数从响应中获取 HTML 内容:
import requests
r = requests.get('http://www.hajba.hu')
if r.status_code == 200:
print(r.text[:250])
else:
print(r.status_code)
前面的代码块请求我的网站的首页,如果服务器返回状态代码200,这意味着 OK,它打印内容的前 250 个字符。如果服务器返回不同的状态,则打印该代码。
您可以看到如下成功结果的示例:
<!DOCTYPE html>
<html lang="en-US">
<head>
<meta property="og:type" content="website" />
<meta property="og:url" content="http://hajba.hu/2017/10/26/red-hat-forum-osterreich-2017/" />
<meta name="twitter:card" content="summary_large_image" />
至此,我们完成了requests库的基础知识。随着我在本书后面介绍更多关于库的概念,我会告诉你更多关于它的内容。
现在是时候跳过 Python 3 的默认urllib调用,改为requests了。
切换到requests
现在是时候完成脚本并使用requests库下载页面了。
到目前为止,您已经知道如何实现这一点,但这里还是有代码。
def download_page(url):
try:
return requests.get(url).text
except:
print('error in the url', url)
我用一个 try-except 块包围了请求方法调用,因为内容可能会有一些编码问题,我们会得到一个异常,它会杀死整个应用;我们不希望这样,因为网站很大,重新开始需要太多的资源。 5
将代码放在一起
现在,如果你把所有的东西放在一起,用 'https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/' 作为starting_url来运行这两个函数,那么你应该会得到与这个类似的结果。
starting navigation with BFS
https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/
http://www.sainsburys.co.uk
https://www.sainsburys.co.uk/shop/gb/groceries
https://www.sainsburys.co.uk/shop/gb/groceries/favourites
https://www.sainsburys.co.uk/shop/gb/groceries/great-offers
starting navigation with DFS
https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/
http://www.sainsburys.co.uk/accessibility
http://www.sainsburys.co.uk/shop/gb/groceries
http://www.sainsburys.co.uk/terms
http://www.sainsburys.co.uk/cookies
如果您的结果略有不同,那么网站的结构在此期间发生了变化。
从打印的 URL 中可以看出,当前的解决方案是初步的:代码导航整个网站,而不是只关注“肉&鱼”部门和营养细节。
一种选择是扩展过滤器,只返回相关链接,但是我不喜欢正则表达式,因为它们很难阅读。相反,让我们继续下一章。
摘要
本章为您准备了本书的剩余部分:您已经满足了需求,分析了要抓取的网站,并确定了感兴趣的字段在 HTML 代码中的位置。您实现了一个简单的 scraper,主要使用基本的 Python 工具,它可以在网站中导航。
在下一章中,你将学习Beautiful Soup,一个简单的提取器库,帮助你忘记正则表达式,并增加了更多的功能来像 boss 一样遍历和提取 HTML 树。
在这里阅读更多关于这个话题: www.ibm.com/developerworks/library/j-jtp02225/index.html
2
www.apress.com/gp/book/9781484200568
3
https://docs.python.org/3/library/urllib.parse.html
4
人类请求:HTTP:http://docs.python-requests.org/en/master/
5
我将与你分享一个写作秘密:当我为这一章创建代码时,我遇到了六个由编码问题引起的异常,其中一个是在“肉和鱼”部分。
三、使用 BeautifulSoup
在本章中,你将学习如何使用 Beautiful Soup,一个轻量级的 Python 库,轻松地提取和导航 HTML 内容,忘记过于复杂的正则表达式和文本解析。
在我让您直接进入编码之前,我将告诉您一些关于这个工具的事情,以便您熟悉它。
如果您没有心情阅读枯燥的介绍性文本或基础教程,请随意跳到下一节;如果你不理解我后面的方法或者代码,回到这里。
我发现Beautiful Soup很容易使用,它是处理 HTML DOM 元素的完美工具:你可以用这个工具导航、搜索,甚至修改文档。它有极好的用户体验,你会在本章的第一节看到。
安装Beautiful Soup
尽管我们都知道可以在 Python 环境中安装模块,但为了完整起见,让我(像本书中一样)为这个琐碎但必须完成的任务添加一个小节。
pip install beautifulsoup4
数字 4 至关重要,因为我用 4.6.0 版本开发并测试了本书中的例子。
简单的例子
经过冗长的介绍,现在是时候开始编码了,用简单的例子让自己熟悉Beautiful Soup并尝试一些基本特性,而不用创建复杂的刮刀。
这些例子将展示Beautiful Soup的构建模块以及需要时如何使用它们。
您不会抓取现有的站点,而是使用为每个用例准备的 HTML 文本。
对于这些例子,我假设您已经在 Python 脚本或交互式命令行中输入了from bs4 import BeautifulSoup,所以您已经准备好使用Beautiful Soup。
解析 HTML 文本
Beautiful Soup的最基本用法是从 HTML 字符串中解析和提取信息,这在每一篇教程中都可以看到。
这是最基本的一步,因为当你下载一个网站时,你把它的内容发送给Beautiful Soup解析,但是如果你把一个变量传递给解析器,就没有什么可看的了。
大多数情况下,您将使用以下多行字符串:
example_html = """
<html>
<head>
<title>Your Title Here</title>
</head>
<body bgcolor="#ffffff">
<center>
<img align="bottom" src="clouds.jpg"/>
</center>
<hr/>
<a href="http://somegreatsite.com">Link Name</a> is a link to another nifty site
<h1>This is a Header</h1>
<h2>This is a Medium Header</h2>
Send me mail at <a href="mailto:support@yourcompany.com">support@yourcompany.com</a>.
<p>This is a paragraph!</p>
<p>
<b>This is a new paragraph!</b><br/>
<b><i>This is a new sentence without a paragraph break, in bold italics.</i></b>
<a>This is an empty anchor</a>
</p>
<hr/>
</body>
</html>
"""
要用Beautiful Soup创建解析树,只需编写以下代码:
soup = BeautifulSoup(example_html, 'html.parser')
函数调用的第二个参数定义了使用哪个解析器。如果您不提供任何解析器,您将得到如下错误消息:
UserWarning: No parser was explicitly specified, so I'm using the best available HTML parser for this system ("html.parser"). This usually isn't a problem, but if you run this code on another system, or in a different virtual environment, it may use a different parser and behave differently.
The code that caused this warning is on line 1 of the file <stdin>. To get rid of this warning, change code that looks like this:
BeautifulSoup(YOUR_MARKUP)
to this:
BeautifulSoup(YOUR_MARKUP, "html.parser")
这个警告被很好的定义,告诉你你需要知道的一切。因为您可以对 Beautiful Soup 使用不同的解析器(见本章后面),所以您不能假设它将总是使用同一个解析器;如果安装了更好的版本,它将使用该版本。此外,这可能会导致意想不到的行为,例如,您的脚本变慢。
现在您可以使用soup变量在 HTML 中导航。
解析远程 HTML
Beautiful Soup不是 HTTP 客户端,因此您不能向它发送 URL 来进行提取。你可以尝试一下。
soup = BeautifulSoup('http://hajba.hu', 'html.parser')
前面的代码会产生如下警告消息:
UserWarning: "http://hajba.hu" looks like a URL. Beautiful Soup is not an HTTP client. You should probably use an HTTP client like requests to get the document behind the URL, and feed that document to Beautiful Soup.
要将远程 HTML 页面转换成 soup,应该使用requests库。
soup = BeautifulSoup(requests.get('http://hajba.hu').text, 'html.parser')
解析文件
解析内容的第三个选项是读取文件。你不必阅读整个文件;对于Beautiful Soup来说,如果您向它的构造函数提供一个打开的文件句柄,它会完成剩下的工作,这就足够了。
with open('example.html') as infile:
soup = BeautifulSoup(infile , 'html.parser')
find和find_all的区别
你会和Beautiful Soup : find和find_all过度使用两种方法。
这两者的区别在于其函数和返回类型:find返回只有一个 —如果有多个节点符合条件,则返回第一个;None,如果什么也没有发现。find_all以列表形式返回与所提供的参数匹配的所有结果;该列表可以为空。
这意味着,每次搜索带有某个id的标签时,您都可以使用find,因为您可以假设一个id在一个页面中只使用一次。或者,如果您正在寻找一个标签的第一次出现,那么您也可以使用find。如果你不确定,使用find_all,迭代结果。
提取所有链接
刮刀的核心作用是从网站中提取链接,这些链接指向其他页面或其他网站。
链接在锚标记(<a>)中,它们指向的地方在这些锚的href属性中。要查找所有具有href属性的锚标记,可以使用下面的代码:
links = soup.find_all('a', href=True)
for link in links:
print(link['href'])
对前面介绍的 HTML 运行这段代码,您会得到以下结果:
http://somegreatsite.com
mailto:support@yourcompany.com
find_all方法调用包括href=True参数。这告诉Beautiful Soup只返回那些具有href属性的锚标签。这使您可以自由地访问结果链接上的该属性,而无需检查它们是否存在。
要验证这一点,请尝试运行前面的代码,但是要从函数调用中删除参数href=True。这会导致一个异常,因为空锚没有一个href属性。
您可以将任何属性添加到find_all方法中,也可以搜索属性不存在的标签。
提取所有图像
抓取工具的第二大使用案例是从网站上提取图像并下载它们,或者只是存储它们的信息,比如它们的位置、显示大小、可选文本等等。
像链接提取器一样,这里可以使用 soup 的find_all方法,并指定过滤器标签。
images = soup.find_all('img', src=True)
寻找一个 present src属性有助于找到有东西要显示的图像。当然,有时 source 属性是通过 JavaScript 添加的,你必须做一些逆向工程—,但这不是本章的主题。
通过标签的属性查找标签
有时,您必须根据标签的属性来查找标签。例如,我们通过它们的 class 属性来识别上一章需求的 HTML 块。
前面几节已经向您展示了如何在有属性的地方找到标记。现在是时候找到属性有特定值的标签了。
两个用例主导了这个主题:通过id或class属性进行搜索。
soup.find('p', id="first")
soup.find_all('p', class_="paragraph")
您可以在find和find_all方法中使用任何属性。唯一的例外是class,因为它是 Python 中的一个关键字。但是,如你所见,你可以用class_来代替。
这意味着您可以搜索来源为clouds.jpg的图像。
soup.find('img', src='clouds.jpg')
您也可以使用正则表达式来查找特定类型的标记,它们的属性通过某种条件来限定它们。例如,显示 GIF 文件的所有图像标签。
soup.find('img', src=re.compile('\.gif$'))
此外,标签的文本也是它的属性之一。这意味着您可以搜索包含特定文本(或文本片段)的标签。
soup.find_all('p', text="paragraph")
soup.find_all('p', text=re.compile('paragraph'))
前面两个例子的不同之处在于它们的结果。因为在示例 HTML 中没有仅包含文本“paragraph”的段落,所以返回一个空列表。第二个方法调用返回包含单词“paragraph”的段落标签列表
基于属性查找多个标签
之前,您已经看到了如何根据标签的属性找到一种标签(<p>、<img>)。
然而,Beautiful Soup也为您提供了其他选项:例如,您可以找到共享相同标准的多个标签。看下一个例子:
for tag in soup.find_all(re.compile('h')):
print(tag.name)
在这里,您搜索所有以h开头的标签。结果会是这样的。
html
head
hr
h1
h2
hr
另一个例子是查找包含文本“段落”的所有标签
soup.find_all(True, text=re.compile('paragraph'))
这里使用 True 关键字匹配所有标签。如果不提供属性来缩小搜索范围,将会得到 HTML 文档中所有标签的列表。
改变内容
我很少使用Beautiful Soup的这个函数,但是有效的用例是存在的。因此,我认为你应该学会如何改变汤的内容。而且因为我不怎么用这个功能,这部分比较骨感,就不深究了。
添加标签和属性
向 HTML 添加标签很容易,尽管很少使用。如果你添加了一个标签,你必须注意在哪里以及如何添加。可以用两种方法:insert和append。两人都在做汤的标签。
insert需要插入新标签的位置,以及新标签本身。
append仅要求新标签将新标签附加到调用该方法的父标签的末端。
因为汤本身就是一个标签,所以你也可以对它使用这些方法,但是你必须小心。例如,尝试以下代码:
h2 = soup.new_tag('h2')
h2.string = 'This is a second-level header'
soup.insert(0, h2)
在这里,首先要将新的标签h2插入到汤里。这会产生以下代码(我省略了大部分 HTML):
<h2>This is a second-level header</h2><html>
或者,您可以将 0 更改为 1,以便在第二个位置插入新标签。在这种情况下,您的标签被插入到 HTML 的末尾,在</html>标签之后。
soup.insert(1, h2)
这导致
</html><h2>This is a second-level header</h2>
对于刚刚展示的两种方法,也有方便的方法:insert_before、insert_after。
append方法将新标签附加在标签的末尾。这意味着它的行为类似于insert_after方法。
soup.append(soup.new_tag('p'))
上述代码会产生以下结果:
</html><p></p>
唯一不同的是,insert_after方法不是在 soup 对象上实现的,而是在标签上实现的。
无论如何,使用这些方法,您必须注意在文档中插入或追加新标签的位置。
向标签添加属性很容易。因为标签的行为类似于字典,所以您可以像向字典添加键和值一样添加新属性。
soup.head['style'] = 'bold'
尽管前面的代码不影响呈现的输出,但它向head标签添加了新的属性。
<head style="bold">
更改标签和属性
有时你不想添加新的标签,但想改变现有的内容。例如,您想将段落内容更改为粗体。
for p in soup.find_all('p', text=True):
p.string.wrap(soup.new_tag('b'))
如果您想更改包含某些格式的标签的内容(如粗体或斜体标签),但又想保留内容,可以使用 unwrap 功能。
soup = BeautifulSoup('<p> This is a <b>new</b> paragraph!</p>')
p = soup.p.b.unwrap()
print(soup.p)
另一个例子是改变标签的 id 或类别。这与添加新属性的工作方式相同:您可以从 soup 中获取标记,并更改字典值。
for t in soup.findAll(True, id=True):
t['class'] = 'withid'
print(t)
前面的示例将class withid更改(或添加)到所有具有 id 属性的标签。
删除标签和属性
如果你想删除一个标签,你可以在标签上使用extract()或者decompose()。
从树中移除标签并将其返回,这样您可以在将来使用它或将其添加到 HTML 内容的不同位置。
decompose()永久删除选中的标签。没有返回值,没有以后的用法;它永远消失了。
print(soup.title.extract())
print(soup.head)
使用本节的示例 HTML 运行前面的代码示例会产生以下几行:
<title>Your Title Here</title>
<head>
</head>
或者,您可以将extract()更改为decompose()。
print(soup.title.decompose())
print(soup.head)
在这里,结果只在第一行发生了变化,但没有得到任何结果。
None
<head>
</head>
删除不仅仅对标签有效;您也可以删除标签的属性。
想象一下,您的标签有一个名为 display 的属性,您想从每个标签中删除这个显示属性。您可以通过以下方式完成:
for tag in soup.find_all(True, display=True):
del tag['display']
如果现在计算具有显示属性的标记的出现次数,将得到 0。
print(len(soup.find_all(True, display=True)))
查找评论
有时您需要在 HTML 代码中找到注释来对 JavaScript 调用进行逆向工程,因为有时网站的内容是在注释中传递的,JavaScript 会正确地呈现它。
for comment in soup.find_all(text=lambda text:isinstance(text, Comment)):
print(comment)
前面的代码查找并打印所有评论的内容。为了让它工作,你也需要从bs4包中导入Comments。
皈依者
这是对Beautiful Soup来说最简单的部分之一,因为正如你从 Python 学习中所知,在 Python 中一切都是对象,对象有一个方法__str__返回这个对象的字符串表示。
不是每次都写类似于soup.__str__()的东西,而是在每次将对象转换成字符串—时调用这个方法,例如当您将它打印到控制台:print(soup)时。
但是,这将产生与您在 HTML 内容中提供的相同的字符串表示。而且,你知道,你可以做得更好,提供一个格式化的字符串。
这就是为什么Beautiful Soup有prettify方法的原因。默认情况下,该方法打印所选标记树的漂亮的格式化版本。是的,这意味着你可以美化你的整个汤或者只是 HTML 内容的一个选择子集。
print(soup.find('p').prettify())
这个调用的结果是(soup是从本节开始使用 HTML 创建的)
<p>
This is a new paragraph!
</p>
提取所需信息
现在是时候准备你的手指和键盘了,因为你即将创建你的第一个专用刮刀,它将从 Sainsbury 的网站上提取所需的信息,如第二章所述。
本章展示的所有源代码都可以在本书源代码中的bs_scraper.py文件中找到。
但是,我建议,您可以从尝试使用从本书中学到的工具和知识来实现每个功能开始。我保证,这并不难,如果你的解决方案与我的略有不同,不要担心。这是编码;我们每个人都有自己的风格和方法。重要的是最后的结果。
识别、提取和调用目标 URL
创建 scraper 的第一步是识别将我们引向产品页面的链接。在第二章中,我们使用 Chrome 的 DevTools 来查找相应的链接及其位置。
这些链接在一个无序列表(<ul>中,该列表有一个类categories departments。您可以使用以下代码从页面中提取它们:
links = []
ul = soup.find('ul', class_='categories departments')
if ul:
for li in ul.find_all('li'):
a = li.find('a', href=True)
if a:
links.append(a['href'])
现在,您已经有了指向列出产品的页面的链接,每个页面最多显示 36 个产品。
然而,其中一些链接会导致其他分组,这可能会在您到达产品页面之前导致第三层分组,正如您在图 3-1 中看到的那样。
图 3-1
三层导航
导航从“鸡肉&火鸡”到“酱汁,腌泡汁&约克郡布丁”,这导致第三层链接。
因此,您的脚本也应该能够导航这样的链,并获得产品列表。
product_pages = []
visited = set()
queue = deque()
queue.extend(department_links)
while queue:
link = queue.popleft()
if link in visited:
continue
visited.add(link)
soup = get_page(link)
ul = soup.find('ul', class_='productLister gridView')
if ul:
product_pages.append(link)
else:
ul = soup.find('ul', class_='categories shelf')
if not ul:
ul = soup.find('ul', class_='categories aisles')
if not ul:
continue
for li in ul.find_all('li'):
a = li.find('a', href=True)
if a:
queue.append(a['href'])
前面的代码使用前一章中简单的广度优先搜索(BFS)来浏览所有的 URL,直到找到产品列表。可以把算法改成深度优先搜索(DFS);这将产生一个逻辑上更清晰的解决方案,因为如果您的代码找到一个指向导航层的 URL,它会深入挖掘,直到找到所有页面。
代码首先查找货架(categories shelf),这是提取categories aisles之前的最后一层导航。这是因为如果它首先提取过道,并且因为所有这些 URL 都已经被访问过,货架和它们的内容将会丢失。
浏览产品页面
在第二章中,您已经看到产品可以在多个页面上列出。要收集每个产品的信息,您需要在这些页面之间导航。
如果你像我一样懒惰,你可能会想到使用过滤器,并将每页的产品计数设置为 108 ,就像图 3-2 中一样。
图 3-2
过滤器设置为显示 108 个结果
尽管这是一个好主意,但一个类别可能至少包含 109 产品—,在这种情况下,您需要浏览您的脚本。
products = []
visited = set()
queue = deque()
queue.extend(product_pages)
while queue:
product_page = queue.popleft()
if product_page in visited:
continue
visited.add(product_page)
soup = get_page(product_page)
if soup:
ul = soup.find('ul', class_='productLister gridView')
if ul:
for li in ul.find_all('li', class_="gridItem"):
a = li.find('a', href=True)
if a:
products.append(a['href'])
next_page = soup.find('li', class_="next")
if next_page:
a = next_page.find('a', href=True)
if a:
queue.append(a['href'])
前面的代码块浏览所有产品列表,并将产品站点的 URL 添加到产品列表中。
我又用了 BFS,DFS 也可以。有趣的是下一页的处理:你不搜索导航的编号,而是连续搜索指向下一页的链接。这对于拥有成千上万页面的大型网站非常有用。它们不会列在第一个站点上。 1
提取信息
您到达了产品页面。现在是时候提取所有需要的信息了。
因为您已经在第二章中确定并记录了位置,所以将所有东西连接在一起将是一项简单的任务。
根据您的喜好,您可以使用字典、命名元组或类来存储产品信息。在这里,您将使用字典和类创建代码。
使用字典
您创建的第一个解决方案将把提取的产品信息存储在字典中。
字典中的键将是字段的名称(例如,稍后将用作 CSV[逗号分隔值]中的标题)、提取信息的值。
因为您提取的每个产品都有一个 URL,所以您可以按如下方式初始化产品的字典:
product = {'url': url}
我可以在这里列出如何提取所有需要的信息,但我将只列出棘手的部分。作为练习,其他的积木你应该自己弄清楚。
可以休息一下,放下书,尝试实现提取器。如果你对营养信息或产品来源感到困惑,你可以在下面找到帮助。
如果您很懒,您可以在本节的后面找到我的整个解决方案,或者查看本书提供的源代码。
对我来说,最有趣最懒的部分就是营养信息表的提取。这是一个懒惰的解决方案,因为我使用表的行标题作为字典中的键来存储值。它们符合要求,因此不需要添加自定义代码来读取表头并决定使用哪个值。
table = soup.find('table', class_="nutritionTable")
if table:
rows = table.findAll('tr')
for tr in rows[1:]:
th = tr.find('th', class_="rowHeader")
td = tr.find('td')
if not th:
product['Energy kcal'] = td.text
else:
product[th.text] = td.text
提取产品的来源是最复杂的部分,至少在我看来是这样。在这里,您需要找到一个包含特定文本及其兄弟文本的标题(<h3>)。这个兄弟保存所有的文本,但是以一种纯粹的格式,你需要使其可读。
product_origin_header = soup.find('h3', class_="productDataItemHeader", text='Country of Origin')
if product_origin_header:
product_text = product_origin_header.find_next_sibling('div', class_="productText")
if product_text:
origin_info = []
for p in product_text.find_all('p'):
origin_info.append(p.text.strip())
product['Country of Origin'] = '; '.join(origin_info)
在实现了一个解决方案之后,我希望您已经得到了类似于以下代码的东西:
Extracting product information into dictionaries
product_information = []
visited = set()
for url in product_urls:
if url in visited:
continue
visited.add(url)
product = {'url': url}
soup = get_page(url)
if not soup:
continue # something went wrong with the download
h1 = soup.find('h1')
if h1:
product['name'] = h1.text.strip()
pricing = soup.find('div', class_="pricing")
if pricing:
p = pricing.find('p', class_="pricePerUnit")
unit = pricing.find('span', class_="pricePerUnitUnit")
if p:
product['price'] = p.text.strip()
if unit:
product['unit'] = unit.text.strip()
label = soup.find('label', class_="numberOfReviews")
if label:
img = label.find('img', alt=True)
if img:
product['rating'] = img['alt'].strip()
reviews = reviews_pattern.findall(label.text.strip())
if reviews:
product['reviews'] = reviews[0]
item_code = soup.find('p', class_="itemCode")
if item_code:
item_codes = item_code_pattern.findall(item_code.text.strip())
if item_codes:
product['itemCode'] = item_codes[0]
table = soup.find('table', class_="nutritionTable")
if table:
rows = table.findAll('tr')
for tr in rows[1:]:
th = tr.find('th', class_="rowHeader")
td = tr.find('td')
if not th:
product['Energy kcal'] = td.text
else:
product[th.text] = td.text
product_origin_header = soup.find('h3', class_="productDataItemHeader", text='Country of Origin')
if product_origin_header:
product_text = product_origin_header.find_next_sibling('div', class_="productText")
if product_text:
origin_info = []
for p in product_text.find_all('p'):
origin_info.append(p.text.strip())
product['Country of Origin'] = '; '.join(origin_info)
product_information.append(product)
正如您在前面的代码中看到的,这是 scraper 的最大部分。但是嘿!你完成了你的第一个 scraper,它从一个真实的网站中提取有意义的信息。
您可能已经注意到了代码中实现的警告:每个 HTML 标签都要经过验证。如果不存在,则不进行任何处理;这将是一场灾难,应用会崩溃。
提取商品代码和检查数量的正则表达式也是一种懒惰的方式。尽管我不是正则表达式专家,但我可以创建一些简单的模式,并把它们用于我的目的。
reviews_pattern = re.compile("Reviews \((\d+)\)")
item_code_pattern = re.compile("Item code: (\d+)")
使用类
您可以像实现基于字典的解决方案一样实现基于类的解决方案。唯一的区别是在计划阶段:当使用字典时,你不需要提前计划太多,但是对于类,你需要定义类模型。
对于我的解决方案,我使用了一种简单、实用的方法并创建了两个类:一个保存基本信息;第二个是营养细节的键值对。
我不打算深入 OOP 2 概念。如果想了解更多,可以参考不同的 Python 书籍。
正如你已经知道的,填充这些对象也是不同的。对于如何解决这样的问题有不同的选择, 3 但是我使用了一个懒惰的版本,在那里我直接访问和设置每个字段。
无法预料的变化
在自己实现源代码的同时,你可能发现了一些问题,需要做出反应。
其中一个变化可能是营养表。即使我们抓取了一个网站,所有页面的渲染也是不一样的。有时他们展示不同的元素或不同的风格。此外,有时营养表中包含的数值与要求中的数值不同,如图 3-3 和 3-4 所示。
图 3-4
第三种营养表
图 3-3
一种不同的营养餐桌
在这种情况下该怎么办?首先,向你的顾客(如果你有的话)提及你已经找到了包含营养信息的表格,但是细节和格式不同。然后想出一个对结果有利的解决方案,你不必在代码中创建额外的差事让它发生。
在我的例子中,我使用了最简单的解决方案,并从那些表中导出了我所能导出的所有内容。这意味着我的结果有不在需求中的字段,有些可能会丢失,比如总糖。此外,因为脂肪和碳水化合物的子列表在每个条目前都有笨拙的破折号,或者有些行只包含文本“of which”,所以我稍微调整了一下前面的代码来处理这些情况。
table = soup.find('table', class_="nutritionTable")
if table:
rows = table.findAll('tr')
for tr in rows[1:]:
th = tr.find('th', class_="rowHeader")
td = tr.find('td')
if not td:
continue
if not th:
product['Energy kcal'] = td.text
else:
product[th.text.replace('-', ").strip()] = td.text
前面代码中能量和能量千卡 ( if not th)的例外情况自动固定在表格中,表格为每一行提供标签。
这样的变化是不可避免的。即使您获得了需求并准备了抓取过程,页面中还是会出现异常。因此,始终做好准备,编写可以处理意外情况的代码,您不必重做所有工作。你可以在这一章的后面读到更多关于我如何处理这些事情的内容。
导出数据
现在,所有信息都已收集完毕,我们希望将它们存储在某个地方,因为将它们保存在内存中对我们的客户来说没有多大用处。
在本节中,您将看到如何将您的信息保存到一个 CSV 或 JSON 文件中,或者保存到一个关系数据库(SQLite)中的基本方法。
每个子部分将为以下导出对象创建代码:类和字典。
至 CSV
存储数据的好老朋友是 CSV。Python 提供了内置功能来将您的信息导出到这种文件类型中。
因为您在上一节中实现了两个解决方案,所以现在您将为这两个解决方案创建导出。但是不用担心;您将保持两种解决方案的简单性。
常见的部分是 Python 的csv模块。它是集成的,有你需要的一切。
快速浏览csv模块
在这里,您可以快速了解 Python 标准库的csv模块。如果你需要更多的信息或参考,你可以在线阅读。4
我将在这一节重点写 CSV 文件;在这里,我将介绍一些基础知识,让您顺利地完成将导出的信息写入 CSV 文件的示例。
对于代码示例,我假设您做了import csv。
编写 CSV 文件很容易:如果您知道如何编写文件,就差不多完成了。您必须打开一个文件句柄并创建一个 CSV writer。
with open('result.csv', 'w') as outfile:
spamwriter = csv.writer(outfile)5
前面的代码示例是我能想到的最简单的示例。然而,还有很多选项需要配置,这有时对您来说很重要。
-
dialect:使用 dialect 参数,您可以指定组合在一起的格式属性,以表示一种通用格式。这样的方言有excel(默认方言)excel_tab或unix_dialect。你也可以定义自己的方言。 -
delimiter:如果指定/不指定方言,可以通过此参数自定义分隔符。如果您必须使用一些特殊字符来进行定界,这可能是需要的,因为逗号和转义不能解决问题,或者您的规范是限制性的。 -
quotechar:顾名思义,您可以覆盖默认报价。有时您的文本包含引号字符,转义会导致在 MS Excel 中出现不需要的表示形式。 -
quoting:如果编写器在字段值中遇到分隔符,则自动引用。您可以覆盖默认行为,并且可以完全禁用引用(尽管我不鼓励您这样做)。 -
lineterminator:该设置使您能够改变行尾的字符。它默认为'\r\n',但在 Windows 中你不希望这样,只是'\n'。
大多数时候,您不需要更改任何设置(并且依赖于 Excel 配置)就可以了。然而,我鼓励你花些时间尝试不同的设置。如果您的数据集和导出配置有问题,您将从 csv 模块—中获得一个异常,如果您的脚本已经抓取了所有信息并在导出时终止,这是很糟糕的。
行尾
如果你像我一样在 Windows 环境下工作,建议你为你的 writer 设置行尾。否则,你会得到不想要的结果。
with open('result.csv', 'w') as outfile:
spamwriter = csv.writer(outfile)
spamwriter.writerow([1,2,3,4,5])
spamwriter.writerow([6,7,8,9,10])
前面的代码产生了图 3-5 中的 CSV 文件。
图 3-5
空行过多的 CSV 文件
要解决这个问题,请将lineterminator参数设置为编写器的创作。
with open('result.csv', 'w') as outfile:
spamwriter = csv.writer(outfile, lineterminator='\n')
spamwriter.writerow([1,2,3,4,5])
spamwriter.writerow([6,7,8,9,10])
头球
什么是没有头文件的 CSV 文件?对于那些知道以什么顺序期待什么的人来说是有用的,但是如果顺序或列数改变了,你就不能期待什么好东西了。
编写标题的工作方式与编写行的工作方式相同:您必须手动完成。
with open('result.csv', 'w') as outfile:
spamwriter = csv.writer(outfile, lineterminator="\n")
spamwriter.writerow(['average', 'mean', 'median', 'max', 'sum'])
spamwriter.writerow([1,2,3,4,5])
spamwriter.writerow([6,7,8,9,10])
这产生了图 3-6 的 CSV 文件。
图 3-6
带标题的 CSV 文件
保存字典
为了保存字典,Python 有一个定制的 writer 对象来处理这个键值对对象:DictWriter。
这个 writer 对象正确地处理字典元素到行的映射,使用键将值写入正确的列。因此,您必须向DictWriter的构造函数提供一个额外的元素:字段名列表。此列表决定了列的顺序;如果您想要编写的字典中缺少一个键,Python 就会抛出一个错误。
如果结果的顺序不重要,那么在将结果写入您想要编写的字典的键时,您可以很容易地设置字段名称。但是,这会导致各种问题:顺序没有定义;它在你运行它的每台机器上都是随机的(有时也在同一台机器上);如果您选择的字典缺少一些键,那么您的整个导出将缺少这些值。
**如何克服这个障碍?**对于动态解决方案,您可以计算所有结果字典上所有键的并集 6 。这可以确保您不会遇到如下错误:
ValueError: dict contains fields not in fieldnames: 'Monounsaturates', 'Sugars'
或者,您可以预先定义要使用的标题集。在这种情况下,您可以控制字段的顺序,但是您必须知道所有可能的字段。如果像处理营养表一样处理动态键值对,这并不容易。
正如您所看到的,对于这两个选项,您必须在编写 CSV 文件之前创建可能的标题列表(集)。您可以通过遍历所有产品信息并将每个产品信息的键放入一个集合中来实现这一点,或者您可以将提取方法中的键添加到一个全局集合中。
导出到 CSV 文件如下所示。
with open('sainsbury.csv', 'w') as outfile:
spamwriter = csv.DictWriter(outfile, fieldnames=get_field_names(product_information), lineterminator="\n")
spamwriter.writeheader()
spamwriter.writerows(product_information)
我希望你的代码像这样。如您所见,我使用了一个额外的方法来收集所有的头字段。但是,如前所述,使用更适合你的版本。我的解决方案比较慢,因为我在行上迭代了多次。
保存课程
在处理数据集时使用类的问题是,我们不知道商品最终会是什么样子。这是因为两种产品的营养成分表会有所不同。为了克服这个障碍,您可以编写一个键规范化函数,尝试将产品的不同键映射到一个键,并且您可以使用它来映射到您的类的正确属性。但这是一项艰巨的任务,不在本书的讨论范围之内。因此,我们将坚持使用上一章定义的基本信息,并基于这些信息创建一个类。
class Product:
def __init__(self, url):
self.url = url
self.name = None
self.item_code = None
self.product_origin = None
self.price_per_unit = None
self.unit = None
self.reviews = None
self.rating = None
self.energy_kcal = None
self.energy_kj = None
self.fat = None
self.saturates = None
self.carbohydrates = None
self.total_sugars = None
self.starc = None
self.fibre = None
self.protein = None
self.salt = None
即使使用这种结构,您也需要一个从表到产品类属性的最小键映射。这是因为有些属性需要用不同名称的表中的值来填充,例如total_sugars将从字段总糖中获取值。
现在类已经准备好了,让我们修改 scraper 来使用Product s 而不是字典。为了节省空间,我将只包括被修改的函数的前几行。
def extract_product_information(product_urls):
product_information = []
visited = set()
for url in product_urls:
if url in visited:
continue
visited.add(url)
product = Product(url)
soup = get_page(url)
if not soup:
continue
h1 = soup.find('h1')
if h1:
product.name = h1.text.strip()
如您所见,代码没有太大变化;我突出了不同的部分。您必须以类似的方式修改代码来填充类的字段。
现在是将类保存到 CSV 的时候了。没有太多大惊小怪,这里是我的解决方案。
def write_results_to_csv(filename, rows):
with open(filename, 'w') as outfile:
spamwriter = csv.DictWriter(outfile, fieldnames=get_field_names(rows), lineterminator="\n")
spamwriter.writeheader()
spamwriter.writerows(map(lambda p: p.__dict__, rows))
这里是get_field_names函数。
def get_field_names(product_information):
return set(vars(product_information[0]).keys()))
使用get_field_names方法似乎有点过度劳累。如果您愿意,您可以添加函数体而不是方法调用,或者在Product类中创建一个方法来返回字段名称。
同样,这种方法会导致 CSV 文件中的列顺序不可预测。为了确保运行和计算机之间的顺序,您应该为fieldnames定义一个固定列表,并将其用于导出。
另一个有趣的代码部分是使用Product类的__dict__方法。这是一个方便的内置方法,可以将实例对象的属性转换为字典。vars内置函数的工作方式类似于__dict__函数,并将给定实例对象的变量作为字典返回。
至 JSON
另一种更流行的保存数据的方式是 JSON 文件。因此,您将创建代码块来将字典和类导出到 JSON 文件。
快速浏览json模块
这也将是一个快速的介绍。Python 标准库的json模块很庞大,你可以在网上找到更多信息。7
正如在 CSV 部分中一样,我将着重于编写 JSON 文件,因为应用将产品信息写入 JSON 文件。
我假设您对本节中的示例做了import json。
将 JSON 对象写入文件与使用 CSV 一样简单,甚至更简单。您可以简单地告诉json模块将其内容写入给定的文件句柄。
with open('result.json', 'w') as outfile:
json.dump([{'average':12, 'median': 11}, {'average': 10, 'median': 10}], outfile)
前面的例子将内容(一个列表中的两个字典)写入result.json文件。
你可以对结果有更多的控制。因为 Python 中的 JSON 对象通常是字典,所以您不能保证它们在导出文件中出现的键的顺序。如果您关心这个问题(为了在运行之间保持一致的表示),那么您可以将 dump 方法的参数sort_keys设置为True。这将在把字典写到输出之前按照它们的关键字对它们进行排序。
with open('result.json', 'w') as outfile:
json.dump([{'average':12, 'median': 11}, {'average': 10, 'median': 10}],outfile, sort_keys=True)
此外,这是您现在需要知道的关于将数据写入 JSON 文件的所有内容。
保存字典
正如您在上一节中读到的,将结果写入 JSON 很容易,甚至比使用 CSV 更容易。不仅仅是因为 JSON 文件是字典(或字典列表),而且您不必关心字典中的键:如果丢失了什么,也不会影响导出。当然,如果您试图导入文件的内容,那么您必须检查当前的 JSON 对象是否有您想要提取的键。
with open('sainsbury.json', 'w') as outfile:
json.dump(product_information, outfile)
前面的代码将填充了产品信息的列表保存到指定的 JSON 文件中。
保存课程
将一个类保存到 JSON 文件中并不是一项简单的任务,因为类不是保存到 JSON 文件中的典型对象。
让我们直接进入代码,编写将结果导出到 JSON 文件的方法,就像字典解决方案一样。
def write_results_to_json(filename, rows):
with open(filename, 'w') as outfile:
json.dump(rows, outfile)
现在,如果您运行 scraper 并到达导出方法调用,您将得到类似这样的错误。
TypeError: Object of type 'Product' is not JSON serializable
该消息告诉您一切:Product类的一个实例是不可序列化的。为了克服这个小障碍,让我们使用在将Product实例导出到 CSV 文件时学到的技巧。
def write_results_to_json(filename, products):
with open(filename, 'w') as outfile:
json.dump(map(lambda p: p.__dict__, products), outfile)
这不是最终的解决方案,因为map也是不可序列化的;我们必须把它包装成可迭代的。
def write_results_to_json(filename, rows):
with open(filename, 'w') as outfile:
json.dump(list(map(lambda p: p.__dict__, rows)), outfile)
到关系数据库
现在,您将学习如何连接到数据库并将数据写入其中。为了简单起见,所有代码都将使用 SQLite,因为它不需要任何安装或配置。
您将在本节中编写的代码将是数据库不可知的;您可以移植您的代码来填充任何关系数据库(MySQL、Postgres)。
您在本章中提取的数据(您将在本书中看到)不需要关系数据库,因为它没有定义关系。我不会深入讨论关系数据库的细节,因为我的目的是让您开始搜集,许多客户需要 MySQL 表中的数据。因此,在本节中,您将看到如何将提取的信息保存到 SQLite 3 数据库中。这种方法类似于其他数据库。唯一的区别是,这些数据库需要更多的配置(如用户名、密码、连接信息),但有大量的资源可用。
第一步是决定数据库模式。一种选择是将所有内容放在一个表中。在这种情况下,您将有一些空列,但您不必处理来自营养表的动态名称。另一种方法是将公共信息(除了营养表之外的所有信息)存储在一个表中,并用键-值对引用第二个表。
第一种方法在以本章的方式使用字典时是很好的,因为你在一个字典中有所有的条目,很难将营养表从其他内容中分离出来。第二种方法适用于类,因为已经有两个类存储了公共信息和动态营养表。
当然,还有第三种方法:把这些列固定下来,然后你就可以跳过那些不需要的/未知的键,这些键来自网站上不同的营养表。这样,您必须注意错误处理和丢失键—,但这保持了模式的可维护性。
为了简化示例,我将采用第三种方法。预期字段在第二章中定义,您可以基于该列表创建一个模式。
CREATE TABLE IF NOT EXISTS sainsburys (
item_code INTEGER PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
energy_kcal TEXT,
energy_kjoule TEXT,
fat TEXT,
saturates TEXT,
carbohydrates TEXT,
total_sugars TEXT,
starch TEXT,
fibre TEXT,
protein TEXT,
salt TEXT,
country_of_origin TEXT,
price_per_unit TEXT,
unit TEXT,
number_of_reviews INTEGER,
average_rating REAL
)
这个 DDL 是 SQLite 3;您可能需要根据您使用的数据库来更改它。如您所见,只有当表不存在时,我们才创建它。这避免了多次运行应用时的错误和错误处理。该表的主键是产品代码。URL 和产品名称不能为空;对于其他属性,您可以允许 null。
当您向数据库添加条目时,有趣的代码就出现了。可能有两种情况:您插入一个新值,或者产品已经在表中,您想更新它。
当您插入一个新值时,您必须确保信息包含每一列的名称,否则,您必须避免异常。对于本章的产品,您可以创建一个映射器,在保存之前将键映射到它们的数据库表示。我不会这样做,但是您可以随意扩展示例。
当更新时,数据库中已经有一个条目。因此,您必须找到条目并更新相关(或所有)字段。自然地,如果您使用历史数据集,那么您不需要任何更新,只需要插入。
使用 SQLite,您可以在一个查询中获得两种解决方案。
INSERT OR REPLACE INTO sainsburys
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
插入或替换解决了识别数据库中已经存在的条目并分别更新它们的问题。当然,这种解决方案只适用于从存储在数据库中的信息中获得固定 ID 的项目。如果您使用动态创建的技术 id,那么您需要想办法在数据库中找到相应的条目并更新它,除非您希望历史数据存储在您的数据库中。
def save_to_sqlite(database_path, rows):
global connection
connection = __connect(database_path)
__ensure_table()
for row in rows:
__save_row(row)
__close_connection()
def __connect(database):
return sqlite3.connect(database)
def __close_connection():
if connection:
connection.close()
def __ensure_table():
connection.execute(table_ddl)
def __save_row(row):
connection.execute(sqlite_insert, (
row.get('item_code'), row.get('name'), row.get('url'), row.get('Energy kcal'), row.get('Energy'),
row.get('Fat'), row.get('Saturates'), row.get('Carbohydrates'), row.get('Total Sugars'), row.get('Starch'),
row.get('Fibre'), row.get('Protein'), row.get('Salt'), row.get('Country of Origin'), row.get('price'),
row.get('unit'), row.get('reviews'), row.get('rating')))
前面的代码是在数据库中保存条目的示例。
主要入口点是save_to_sqlite函数。database_path变量保存目标 SQLite 数据库的路径。如果它不存在,代码将为您创建它。rows变量包含列表中的数据字典。
有趣的部分是__save_row函数。它保存了一行,如您所见,它需要关于您想要保存的对象的大量信息。如果给定的键不在要持久化的行中,我使用dict类的get方法来避免Key Error。
如果你正在使用类,我建议你看看peewee、、 8 、一个 ORM 、 9 、工具,它可以帮助你将对象映射到关系数据库模式。它内置了对 MySQL、PostgreSQL 和 SQLite 的支持。在示例中,我也将使用 peewee,因为我喜欢这个工具。 10
在这里您可以找到 peewee 的快速入门,我们将把收集到的数据保存到与前面相同的 SQLite 数据库模式中。
要入门,你得改编Product类;它必须扩展peewee.Model类,并且字段必须是peewee字段类型。
from peewee import Model, TextField, IntegerField, DecimalField
class ProductOrm(Model):
url = TextField()
name = TextField()
item_code = IntegerField
product_origin = TextField()
price_per_unit = TextField()
unit = TextField()
reviews = IntegerField()
rating = DecimalField
energy_kcal = TextField()
energy_kj = TextField()
fat = TextField()
saturates = TextField()
carbohydrates = TextField()
total_sugars = TextField()
starch = TextField()
fibre = TextField()
protein = TextField()
salt = TextField()
这种结构使您能够在以后用peewee使用该类,并使用 ORM 存储信息,而无需任何转换。我将这个类命名为ProductOrm,以显示它与之前使用的Product类的区别。
要保存该类的一个实例,只需修改前一节中的函数。
我们仍然必须确保数据库连接是打开的,并且目标表存在。为了做到这一点,我们利用我们知道的函数,以及peewee必须提供的函数。
import peewee
from product import ProductOrm
def save_to_sqlite(database_path, rows):
"""
This function saves all entries into the database
:param database_path: the path to the SQLite file. If not exists, it will be created.
:param rows: the list of ProductOrm objects elements to save to the database
"""
__connect(database_path)
__ensure_table()
for row in rows:
row.save()
def __connect(database):
ProductOrm._meta.database = peewee.SqliteDatabase(database)
def __ensure_table():
ProductOrm.create_table(True)
在这里你可以看到使用 peewee 提供了一个巧妙的节省版本。必须向我们使用的Model提供数据库连接,为了动态地修改它,在连接到数据库时,您必须访问一个受保护的字段。或者,如果您不想动态提供目标数据库,您也可以在ProductOrm类中定义它。
import peewee
class ProductOrm(Model):
url = TextField()
name = TextField()
item_code = IntegerField
product_origin = TextField()
price_per_unit = TextField()
unit = TextField()
reviews = IntegerField()
rating = DecimalField
energy_kcal = TextField()
energy_kj = TextField()
fat = TextField()
saturates = TextField()
carbohydrates = TextField()
total_sugars = TextField()
starch = TextField()
fibre = TextField()
protein = TextField()
salt = TextField()
class Meta:
database = peewee.SqliteDatabase('sainsburys.db')
无论如何,您都可以使用peewee来接管所有持久化数据的操作:创建表和保存数据。
要创建表,必须调用ProductOrm类上的create_table方法。有了提供的True参数,这个方法调用将确保您的目标数据库有这个表,如果这个表不存在,它将被创建。将如何创建该表?这是基于你这个开发者提供的 ORM 模型。peewee基于ProductOrm类创建 DDL 信息:文本字段将成为TEXT数据库列,而IntegerField字段将生成INTEGER列。
并且要保存实体本身,必须对实例化的对象本身调用save方法。这消除了您对目标表的名称、哪个参数保存在哪个列、如何构造INSERT语句的所有了解…如果您问我,我会说这很棒。
NoSQL 的数据库
忘记现代数据库是一种耻辱,现代数据库是最先进的。因此,在本节中,您将把收集的信息导出到 MongoDB 中。
如果您熟悉这个数据库,并且跟随我在本书中的例子,您已经知道我将如何处理这个解决方案:我将使用以前的构建块。在本例中,JSON 导出。
NoSQL 数据库是一个很好的选择,因为大多数时候它们被设计用来存储与数据库中的其他条目很少或没有关系的文档,至少它们不应该做得过多。
安装 MongoDB
与 SQLite 不同,您必须在计算机上安装 MongoDB 才能运行它。
在这一节中,我不会详细介绍如何安装和配置 MongoDB 这取决于你,他们的主页有非常好的文档, 11 尤其是对 Python 开发者来说。
我假设您已经安装了 MongoDB 和 Python 库:PyMongo。没有这一点,您将很难理解代码示例。
写入 MongoDB
如前所述,我将只关注写入目标数据库,因为 scraper 存储信息,但不会从数据库中读取任何条目。
编写像 MongoDB 这样的 NoSQL 数据库更容易,因为它不需要真正的结构,您可以随心所欲地将所有内容放入其中。当然,做这样的事情是荒谬的;我们需要结构来避免混乱。然而,从理论上讲,你可以把所有东西都塞进你的数据库。
将“基本”字典保存到 MongoDB 数据库中可以直接使用。因为数据库按原样存储对象,所以您不必进行任何转换。并且您可以重用代码来保存到 JSON 文件中。是的,即使是上课。
import pymongo
connection = None
db = None
def save_to_database(database_name, products):
global connection
__connect(database_name)
for product in products:
__save(product)
__close_connection()
def __save(product):
db['sainsburys'].insert_one(product.__dict__)
def __connect(database):
global connection, db
connection = pymongo.MongoClient()
db = connection[database]
def __close_connection():
if connection:
connection.close()
我的版本就像 SQL 版本。我打开到所提供的数据库的连接,并将每个产品插入 MongoDB 数据库。为了获得产品的 JSON 表示,我使用了__dict__变量。
如果您想将一个集合插入数据库,请使用insert_many而不是insert_one。
如果你有兴趣使用一个类似于peewee的库,仅仅用于 MongoDB 和 ODM(对象-文档映射),你可以看看MongoEngine。
性能改进
如果你把这一章的代码放在一起运行提取器,你会发现它有多慢。
串行操作总是很慢,并且取决于您的网络连接,它可能比慢速还要慢。Beautiful Soup背后的解析器是另一个可以提高性能的地方,但这并不是一个很大的提升。此外,如果您在完成应用之前遇到错误,会发生什么?你会丢失所有数据吗?
在本节中,我将尝试为您提供如何处理这种情况的选项,但是实现它们取决于您。
您可以在本节中创建不同解决方案的基准,但是正如我在本书前面提到的,这没有意义,因为环境总是在变化,并且您无法确保您的脚本在完全相同的条件下运行。
更改解析器
改进Beautiful Soup的一个方法是改变解析器,它使用解析器从 HTML 内容中创建对象模型。
Beautiful Soup可以使用以下解析器:
-
html.parser -
lxml(用pip install lxml安装) -
html5lib(用pip install html5lib安装)
正如您在本书中已经看到的,默认的解析器是html.parser—,它已经随 Python 标准库一起安装了。
改变解析器并没有带来速度上的提升,你很快就能看到不同,只是一些小的改进。然而,为了查看一些有缺陷的基准测试,我添加了一个计时器,它从脚本的开头开始,打印提取所有 3,005 个产品所需的时间,而不将它们写入任何存储。
表 3-1 显示了Beautiful Soup在抓取“肉&鱼”部门的 3005 种产品时可用的不同解析器之间的比较。
表 3-1
一些执行速度比较
|句法分析程序
|
进入
|
花费的时间(秒)
|
| --- | --- | --- |
| html.parser | Three thousand and five | 2,347.9281 |
| lxml | Three thousand and five | 2167.9156 |
| lxml-xml | Three thousand and five | 2457.7533 |
| html5lib | Three thousand and five | 2,544.8480 |
如您所见,差异非常显著。因为它是一个用 C 语言编写的定义良好的解析器,所以它可以非常快地处理结构良好的文档。
html5lib很慢;它唯一的优点是可以从任何输入创建有效的 HTML5 代码。
选择解析器
有取舍。如果需要速度,建议你安装lxml。如果你不能依赖于给 Python 安装任何外部模块,那么你应该使用内置的html.parser。
无论您如何决定,您都必须记住:如果您更改了解析器,那么 soup 的解析树也会更改。这意味着你必须重新审视并修改你的代码。
只解析需要的内容
即使使用优化的解析器,创建 HTML 文本的文档模型也需要时间。页面越大,这个模型创建得越慢。
稍微调整性能的一个选择是告诉Beautiful Soup你需要整个页面的哪一部分,它将从相关部分创建对象模型。为此,您可以使用一个SoupStrainer对象。
A SoupStrainer告诉Beautiful Soup提取哪些部分,解析树将只包含这些元素。如果您能够将所需的信息缩小到 HTML 的一小部分,那么这个过程会加快一点。
strainer = SoupStrainer(name='ul', attrs={'class': 'productLister gridView'})
soup = BeautifulSoup(content, 'html.parser', parse_only=strainer)
前面的代码创建了一个简单的SoupStrainer,它将解析树限制为具有 class 属性'productLister gridView'—的无序列表,这有助于将站点减少到所需的部分—,并且它使用这个过滤器来创建 soup。
因为你已经有了一个工作刮刀,你可以用一个滤网来代替汤的调用来加快速度。
以下信息在网上很难找到:可以在过滤器中使用多个属性来解析网站。例如,如果提取产品页面的链接,根据当前部门链接的级别,您有三个选项:
-
该链接指向产品页面。
-
该链接指向一级子列表。
-
该链接从第一级子列表指向第二级子列表。
在这种情况下,您有三个不同的类,但是如果它们中的任何一个存在的话,您想要创建这个汤。你可以这样做:
BeautifulSoup(content, 'html.parser', name="ul",
attrs={'class': ['productLister gridView', 'categories shelf', 'categories aisles']})
在这里,您已经列出了可能发生的所有三个版本的列表,并且这个汤包含了所有相关的信息。
使用硬缓存的(有缺陷的)基准测试: 12 我的脚本使用过滤器获得了 100%的加速(从 158.907 秒到 79.109 秒)。
工作时保存
如果您的应用在运行时遇到异常,当前版本会当场崩溃,您收集的所有信息都会丢失。
一种方法是使用 DFS。使用这种方法,您可以直接沿着目标图,以最短的方式提取产品。此外,当您遇到一个产品时,您将它保存到您的目标介质(CSV、JSON、关系或 NoSQL 数据库)。
另一种方法是保留 BFS,并在提取产品时保存产品。这与使用 DFS 算法的方法相同。唯一的区别是当你接触到产品时。
这两种方法都需要一种重新开始工作的机制,或者至少通过跳过已经编写好的产品来节省一些时间。为此,您创建一个函数来加载目标文件的内容,将提取的 URL 存储在内存中,并跳过已经提取的产品的下载。
按照本章的 BFS 解决方案,当准备好时,您必须将extract_product_information函数修改为yield每条产品信息。然后,将该方法的调用封装到一个循环中,并将结果保存到目标中。
当然,这产生了一些开销:每次保存一个片段时都要打开一个文件句柄,必须注意将条目保存到 JSON 数组中,每次写操作都要打开和关闭数据库连接……或者,在提取过程中打开和关闭(文件句柄或数据库连接)。在这些情况下,您必须注意刷新/提交结果;如果发生了什么,你提取的数据会被保存。
试试怎么样-除了?
嗯,将整个提取代码包装在一个try-except块中也是一种解决方案,但是您必须确保不会忘记发生的异常,并且您可以在以后获得丢失的数据。但是这种异常可能发生在你在主页面上的时候,主页面会引导你进入细节页面——从我的经验来看,我知道一旦你将代码封装到一个异常处理块中,你将会忘记在将来重新访问这些问题。
长期发展
有时你为更大的项目开发 scrapers,你不能在每次修改后都启动你的脚本,因为这需要太多的时间。
尽管您实现的这个 scraper 很短,可以提取大约 3000 个产品,但完成—需要一些时间,如果您在数据提取中出现错误,修复错误并重新开始总是很耗时。
在这种情况下,我利用中间步骤结果的缓存;有时我会缓存 HTML 代码本身。这部分是关于我的方法和我的观点。
因为您已经掌握了很深的 Python 知识,所以这一节也是可选的:如果您知道如何利用这些方法,请随意。
缓存中间步骤结果
当我开始使用一个基本的、自己编写的爬行器(就像本例中的这个)时,我总是做的第一件事就是缓存中间步骤的结果。
将这种方法应用到本章的代码中,将每一步后得到的 URL 导出到一个文件中,并更改应用,以便它在启动时读回上一步的文件,并跳过抓取,直到下一步。
在这种情况下,您面临的挑战是编写代码,以便在出现问题的地方继续工作。对于中间结果,这可能意味着您必须再次抓取网站的最大部分,因为您的脚本在保存产品的所有信息之前就已经死亡—,或者在将要保存提取的信息时死亡。
这一步并不坏,因为你有一个检查点,如果你踩错了,你可以继续。但是老实说,这需要很多额外的工作,比如保存中间步骤和并为每个阶段加载它们。因为我很懒,而且在我的开发旅程中学到了很多,所以我使用 next 解决方案作为我所有抓取任务的基础。
缓存整个网站
更好的方法是在本地缓存整个网站。从长远来看,每次重新运行脚本都会带来更好的性能。
在实现这种方法时,我扩展了网站收集方法的功能,以通过缓存进行路由:如果请求的 URL 在缓存中,则返回缓存的版本;如果不存在,收集站点并将结果存储在缓存中。
在开发过程中,您可以使用基于文件的缓存或数据库缓存来存储网站。在本节中,您将学习这两种方法。
缓存的基本思想是创建一个标识网站的键。关键字是唯一的标识符,网页的 URL 也是唯一的。所以,我们就以此为关键,页面的内容就是值。
但是我们有一些限制(表 3-2 ):这些 URL 可能会很长,一些解决方案对关键字有限制,比如长度或包含的字符。
表 3-2
操作系统的限制
|操作系统
|
文件系统
|
无效的文件名字符
|
最大文件名长度
|
| --- | --- | --- | --- |
| Linux 操作系统 | Ext3/Ext4 | /和\0 | 255 字节 |
| x 是什么 | HFS 加 | :和\0 | 255 个 UTF-16 编码单位 |
| Windows 操作系统 | Windows NT 文件系统(NT File System) | \、/、?、:、*、"、>、<和| | 255 个字符 |
因此,我建议一个简单的解决方案:基于 URL 创建一个散列。
哈希很短,如果你选择一个好的算法,你可以避免大量页面的冲突。我将使用hashlib.blake2b散列函数,因为它比通常使用的散列函数(例如 MD5)更快,并且和 SHA-3 13 一样安全。此外,该算法生成 128 个字符,这对于所有三种主流操作系统来说都足够短。
基于文件的缓存
老派开发人员(比如我)想到的第一个方法是将页面保存到文件中。这是最简单的解决方案,因为写文件不需要数据库,只需要写权限。大多数情况下,这是因为您在本地开发了自己的刮刀。对于生产运行,如果运行一次,就不需要缓存网站。如果您执行多次运行,那么您必须处理缓存失效(请看后面一节)。
您只需要实现三个功能:初始化缓存、从缓存中检索请求的 URL 内容,以及将 URL 内容保存到文件系统。因为功能可以很好地封装,所以我决定将我的缓存实现为一个类。你不需要遵循我的方法;使用最适合你的需求和技能(喜欢)的编程风格。 14
数据库缓存
另一种解决方案是将网站保存到数据库中。还有两种选择:使用关系数据库或 NoSQL 数据库。因为网站是文档,我建议你尝试使用 NoSQL 数据库。但是为了完整起见,我将在本节中向您展示这两种方法。
至于产品细节,在这一节中,我将使用 SQLite 3 作为关系数据库。缓存和文件缓存一样简单:类必须从数据库加载缓存,并将新内容保存到数据库。唯一不同的是,后台的系统是数据库。
我的方法与基于文件的版本相同:将数据库的内容加载到内存中,并使用这个缓存返回内容。那是因为它让脚本快多了!
我不想在这里建立基准。您必须自己决定如何利用内存使用和磁盘读取。对于许多网站来说,将内容保存在内存中的成本很低。
我使用从 URL 生成的相同 ID,因为它足够好,也是一个很好的主键。有些人依赖技术 ID(自动生成的数字标识符),但对于这个网站来说,生成的 ID 或简单地使用 URL 就很合适。
节省空间
将目标网站保存在本地会占用很多空间。用这种方法保存 Sainsbury 的网站需要 253 MB 的空间。对于现在的电脑来说,这并不是什么大事,但这只是一个网页,是整个网站的一小部分。也许你有多个网站,随着时间的推移,占用的空间越来越大,你想节省空间。如果你不想,那就跳过这一节。
使用文件或数据库时,可以通过压缩页面内容来节省空间。这只需要修改你的保护程序和加载程序的方法,以及zlib的用法。当保存时,你应该压缩内容,当你读回文件时,你应该解压。
因为您使用的是 Python 3,而zlib需要一个类似字节的对象来压缩,所以您必须对字符串进行编码和解码。
为了比较区别,我的基于文件的缓存需要 253 MB 的空间;在我切换到压缩后,它只需要 49 MB。差别真大!
但是每朵玫瑰都有它的刺:节省空间需要更多的计算时间来解压缩内容。在我的电脑上用当前保存的数据集,解压时刮刀运行慢了 31 秒。这听起来可能不坏,但按比例来说,这多了 17%的时间。但是如果您将这个结果与不同解析器的运行时间进行比较,那么您在处理脚本的细节时节省了 90%以上的运行时间。你不会让网站超负荷,因为你每天要运行 100 次你的脚本。
更新缓存
开发缓存时要考虑的另一个因素是失效时间。当缓存中的条目无效时,解析器应该何时再次下载它?
这个问题没有确切的答案。你应该考虑你抓取的网站,然后设置一个超时值。
对于一个网上商店,我会用一个星期,但至少一天,因为唯一可以改变的是产品的价格和评论。其他信息不会那么经常变化。
如果你看看本章的示例代码和目标网站,你会想到在缓存中只存储产品页面的想法。为什么?如果您存储了所有的页面,那么在包含产品详细信息的页面因为过时而被丢弃之前,您不会得到关于新添加产品的信息。但是你不会离开产品页面,所以它们是一个很好的目标,每次都要缓存,如果评论不那么重要的话,每周刷新一次。
缓存的方法并不复杂。对于基于文件的缓存,您必须查看文件的修改日期,如果修改日期超过了宽限期,您可以将其从缓存中移除(并删除文件)。对于数据库,您应该将修改时间戳添加到正在保存的实体中。然后协议是相同的:如果条目太旧,删除它,然后刮刀做它的工作,重新下载网站。
本章的源代码
您可以在源代码的 chapter_03 文件夹中找到为本章创建的所有代码,作为完整的解析器。
-
basic_scraper.py包含基本的刮刀,将信息提取到字典中。它没有任何性能调整,但是您可以更改Beautiful Soup使用的解析器来获得一些小的改进。 -
包含基本 scraper 的扩展版本:它使用类来存储提取的信息,并将这些类保存到 SQLite 和 MongoDB 数据源。
-
file_cache.py包含基于文件的缓存,用于在文件系统中存储下载的页面。最终的解决方案是用zlib进行压缩,并在启动时丢弃旧的条目。 -
downloader.py包含一个下载器,对你的刮刀隐藏缓存和下载过程。您可以透明地切换缓存,或许还可以在缓存上进行一些组合,以实现从一个缓存到另一个缓存的迁移。请随意尝试!
摘要
在这一章中,你学到了很多,比如如何一起使用Beautiful Soup和requests,并且你创建了你的第一个完整的 scraper 应用,它收集了第二章中的需求。
scraper 将收集的结果导出到不同的存储中,比如 CSV、JSON 和数据库。
但是每朵玫瑰都有它的刺:您了解了这个更简单的解决方案的瓶颈,并应用了一些技术来使它性能更好。有了这个,你就知道了编写自己的 scraper 有多复杂。
即使有这么长的一章,仍有一些要点没有触及,例如,尊重robots.txt文件。你可以扩展这一章的代码来兑现网站的 robots.txt 文件;你有这样做的基础。
在下一章,你将学习Scrapy,Python 的网站抓取工具,它利用了你肩上的这些优化。您必须做的唯一事情是创建提取器代码并正确配置Scrapy。
除非你运气好。有一次,我遇到了一个网站,所有链接都在 HTML 代码中,但是用 JS-magic 隐藏起来了。
2
OOP:面向对象编程
3
例如,构建器或工厂模式,一个带有所有参数的构造函数。
4
https://docs.python.org/3/library/csv.html
5
我必须承认,每次我写 CSV 文件时,我都使用 spamwriter 作为变量的名称。我想这让我对正在发生的事情有了一个全面的了解。
6
集合论: https://en.wikipedia.org/wiki/Union_(set_theory)
7
https://docs.python.org/3/library/json.html
8
https://github.com/coleifer/peewee
9
对象关系映射
10
我从 2007 年开始使用 ORM 工具,我喜欢这个想法,但是有些查询会变得非常复杂。
11
https://docs.mongodb.com/getting-started/python/
12
硬缓存:从缓存中获取所有信息,如果有人试图从互联网上收集任何信息,请拒绝。这使得刮擦在运行之间有点一致。
13
更多信息,请访问: https://blake2.net/
14
或者,为了更加一致,您可以创建一个下载器,对代码的用户隐藏缓存。