Python-Web-爬取教程-全-

Python Web 爬取教程(全)

原文:Website Scraping with Python

协议:CC BY-NC-SA 4.0

一、入门指南

我们将直接进入深水区,而不是每个库后面的安装说明:这一章介绍了一般的网站抓取和我们将在本书中实现的需求。

你可能希望对网站抓取有一个全面的介绍,但是因为你正在读这本书,我希望你已经知道什么是网站抓取,并且你想学习如何用 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 SoupScrapy这样的工具。

requests库是处理 HTTP 的繁琐任务的轻量级包装器,它是推荐的方式:

建议将请求包用于更高级别的 HTTP 客户端接口。

— Python 3 文档

Beautiful Soup是一个内容解析器。它不是一个网站抓取工具,因为它不会自动导航页面,而且很难扩展。但是它有助于解析内容,并为您提供了以友好的方式从 XML 和 HTML 结构中提取所需信息的选项。

Scrapy是一个网站抓取框架/库。比Beautiful Soup厉害多了,还可以规模化。因此,你可以用Scrapy更容易地创建更复杂的刮刀。但是另一方面,您有更多的选项可以配置。微调Scrapy可能是一个问题,如果你做错了,你可能会搞砸很多。但是伴随着强大的力量而来的是巨大的责任:你必须小心使用Scrapy

尽管Scrapy为网站抓取而创建的 Python 库,但有时我更喜欢requestsBeautiful Soup的组合,因为它是轻量级的,我可以在短时间内编写我的 scraper,并且我不需要缩放或并行执行。

准备

在开始一个网站刮刀的时候,哪怕是一个小脚本,也要做好任务的准备。一开始,您需要考虑一些法律和技术问题。

在这一节,我会给你一个简短的清单,列出你应该做些什么来为网站抓取工作或任务做准备:

  1. 网站的所有者允许刮痧吗?要找到答案,请阅读网站的条款&条件隐私政策

  2. 能不能刮出自己感兴趣的部分?更多信息见robots.txt文件,并使用可处理该信息的工具。

  3. 网站使用什么技术?有一些免费的工具可以帮助你完成这项任务,但是你可以查看网站的 HTML 代码来找到答案。

  4. 我应该使用什么工具?根据你的任务和网站的结构,有不同的路径可以选择。

现在让我们来看一下提到的每一项的详细描述。

术语和机器人

刮擦目前几乎没有任何限制;没有法律规定什么可以刮,什么不可以。

然而,有一些准则定义了你应该尊重什么。没有强制执行;你可以完全忽略这些建议,但你不应该。

在你开始任何搜集任务之前,看看你想收集数据的网站的条款&条件隐私政策。如果抓取没有限制,那么您应该查看给定网站的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 所示。

img/460350_1_En_1_Fig1_HTML.jpg

图 1-1

启动 Chrome 的 DevTools

或者,你可以在 Windows 中按下CTRL+SHIFT+I或者在 Mac 上按下 z + ⇧+ I来打开 DevTools 窗口。

然后定位设置按钮(三个垂直排列的点,如图 1-2 。)并点击它:

img/460350_1_En_1_Fig2_HTML.jpg

图 1-2

设置菜单位于三个点的下方

或者,您可以在 Windows 中按下F1

现在向下滚动到设置屏幕的底部,确保Disable JavaScript被选中,如图 1-3 所示。

img/460350_1_En_1_Fig3_HTML.jpg

图 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.comrobots.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 没有改变它的机器人指南),我们应该取回FalseTrue,因为我们不被允许访问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 SoupScrapy这样的工具编写您的第一个刮刀。敬请期待,继续阅读!

*

二、输入需求

在介绍性章节之后,是时候让你开始一个真正的刮擦项目了。

在本章中,您将学习在接下来的两章中,使用Beautiful SoupScrapy必须提取哪些数据。

不用担心;要求很简单。我们将从以下网站中提取信息: https://www.sainsburys.co.uk/

Sainsbury's 是一家提供大量商品的在线商店。这是一个伟大的网站抓取项目的来源。

我将指导您找到满足需求的方法,并且您将了解我是如何处理一个抓取项目的。

img/460350_1_En_2_Fig1_HTML.png

图 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 。

img/460350_1_En_2_Fig2_HTML.png

图 2-2

用 Chrome 的 DevTools 检查了“肉类和鱼类”部门的页面

出于我们的目的,左侧的导航菜单很有趣。它包含到我们将在其中找到要提取的产品的页面的链接。让我们使用选择工具(或点击 CTRL-SHIFT-C)选择包含这些链接的框,如图 2-3 所示。

img/460350_1_En_2_Fig3_HTML.png

图 2-3

选择左侧的导航栏

现在我们可以在 DevTools 中看到,每个链接都在一个无序列表(<ul>)的列表元素(<li>标签)中,类为categories departments。记下这些信息,因为我们以后会用到。

链接,有一个指向右边的小箭头(>),告诉我们它们只是一个分组类别,如果我们点击它们,我们会在它们下面找到另一个导航菜单。让我们检查一下烤晚餐选项,如图 2-4 所示。

img/460350_1_En_2_Fig4_HTML.jpg

图 2-4

“烧烤晚餐”子菜单

在这里,我们可以看到该页面没有产品,但有另一个详细的网站链接列表。如果我们看看 DevTools 中的 HTML 结构,我们可以看到这些链接也是无序列表的元素。这个无序列表有一个类categories aisles

现在我们可以进一步进入牛肉类别,这里我们列出了产品(在一个大过滤盒之后),如图 2-5 所示。

img/460350_1_En_2_Fig5_HTML.jpg

图 2-5

“牛肉”类别的产品

这里我们需要考察两件事:一是产品列表;另一个是导航。

如果该类别包含的产品超过 36 个(这是网站上显示的默认数量),这些产品将被拆分到多个页面中。因为我们想要提取所有产品的信息,所以我们必须浏览所有这些页面。如果我们选择导航,我们可以看到它又是一个无序的类列表pages,如图 2-6 所示。

img/460350_1_En_2_Fig6_HTML.jpg

图 2-6

带有类“pages”的无序列表

在这些列表元素中,我们感兴趣的是带有向右箭头符号的元素,它包含类next。这告诉我们,如果我们有下一页,我们必须导航或没有。

现在让我们找到产品详细信息页面的链接。所有产品都在一个无序列表中(再次)。该列表有productLister gridView类,如图 2-7 所示。

img/460350_1_En_2_Fig7_HTML.png

图 2-7

从开发工具中选择产品列表

每个产品都在带有类gridItem的列表元素中。如果我们打开其中一个产品的详细信息,我们可以看到导航链接在哪里:位于一些div和一个h3中。我们注意到最后一个div有类productNameAndPromotions,如图 2-8 所示。

img/460350_1_En_2_Fig8_HTML.png

图 2-8

选择产品名称

现在我们已经达到了产品的层次,我们可以更进一步,专注于真正的任务:识别所需的信息。

选择所需信息

基于图 2-9 中所示的产品,我们将发现我们所需信息所在的元素。

img/460350_1_En_2_Fig9_HTML.jpg

图 2-9

我们将在示例中使用的详细产品页面

现在我们已经有了产品,让我们来确定所需的信息。和前面一样,我们可以使用选择工具,找到所需的文本,并从 HTML 代码中读取属性。

产品的名称在一个头(h1)里面,这个头在一个带有类productTitleDescriptionContainerdiv里面。

价格单位pricing类的一个div中。价格本身就在pricePerUnit类的一段(p);单位在pricePerUnitUnit班的一个span

提取评级很棘手,因为这里我们只看到评级的星星,但我们想要数字评级本身。让我们来看看图片的 HTML 定义,如图 2-10 所示。

img/460350_1_En_2_Fig10_HTML.jpg

图 2-10

图像的 HTML 代码

我们可以看到图像的位置在类numberOfReviewslabel中,它有一个属性alt,包含评论平均值的十进制值。在图像之后,是包含评论数量的文本。

项目代码itemCode类的段落内。

如图 2-11 所示的营养信息nutritionTable类的table中。该表的每一行(tr)都包含我们需要的数据的一个条目:该行的标题(th)包含名称,第一列(td)包含值。唯一的例外是能量信息,因为两行包含值,但只有第一行是标题。正如你将看到的,我们也将通过一些特定的代码来解决这个问题。

img/460350_1_En_2_Fig11_HTML.jpg

图 2-11

营养表

如图 2-12 所示,原产国在productText类 div 的一个段落内。这个字段不是惟一的:每个描述都在一个productText div中。这将使提取有点复杂,但也有一个解决方案。

img/460350_1_En_2_Fig12_HTML.jpg

图 2-12

在 Chrome 的开发工具中选择“原产国”

尽管我们必须提取许多字段,但我们很容易在网站中识别它们。现在是提取数据和学习交易工具的时候了!

概述应用

在定义了需求并且我们找到了要提取的每个条目之后,是时候计划应用的结构和行为了。

如果你想一想如何着手这个项目,你会从大爆炸开始,“让我们锤代码”的想法。但是你以后会意识到,你可以把整个脚本分解成更小的步骤。下面是一个例子:

  1. 下载起始页面,在本例中是“肉类和鱼类”部门,并提取产品页面的链接。

  2. 下载产品页面并提取详细产品的链接。

  3. 从已经下载的产品页面中提取我们感兴趣的信息。

  4. 导出提取的信息。

这些步骤可以识别我们正在开发的应用的功能。

步骤 1 还提供了一点东西:如果你还记得你看到的用 DevTools 进行的分析,一些链接只是一个分组类别,你必须从这个分组类别中提取细节页面链接。

浏览网站

在我们开始学习你将用来抓取网站数据的第一个工具之前,我想向你展示如何浏览网站,这将是抓取器的另一个组成部分。

网站由页面和页面之间的链接组成。如果你还记得你的数学研究,你会意识到一个网站可以被描绘成一个图形,如图 2-13 所示。

img/460350_1_En_2_Fig13_HTML.jpg

图 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 树。

三、使用 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')

findfind_all的区别

你会和Beautiful Soup : findfind_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 块。

前面几节已经向您展示了如何在有属性的地方找到标记。现在是时候找到属性有特定值的标签了。

两个用例主导了这个主题:通过idclass属性进行搜索。

soup.find('p', id="first")
soup.find_all('p', class_="paragraph")

您可以在findfind_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 添加标签很容易,尽管很少使用。如果你添加了一个标签,你必须注意在哪里以及如何添加。可以用两种方法:insertappend。两人都在做汤的标签。

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_beforeinsert_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 Soupprettify方法的原因。默认情况下,该方法打印所选标记树的漂亮的格式化版本。是的,这意味着你可以美化你的整个汤或者只是 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 中看到的那样。

img/460350_1_En_3_Fig1_HTML.jpg

图 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 中一样。

img/460350_1_En_3_Fig2_HTML.jpg

图 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 所示。

img/460350_1_En_3_Fig4_HTML.jpg

图 3-4

第三种营养表

img/460350_1_En_3_Fig3_HTML.jpg

图 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)的例外情况自动固定在表格中,表格为每一行提供标签。

这样的变化是不可避免的。即使您获得了需求并准备了抓取过程,页面中还是会出现异常。因此,始终做好准备,编写可以处理意外情况的代码,您不必重做所有工作。你可以在这一章的后面读到更多关于我如何处理这些事情的内容。

导出数据

现在,所有信息都已收集完毕,我们希望将它们存储在某个地方,因为将它们保存在内存中对我们的客户来说没有多大用处。

在本节中,您将看到如何将您的信息保存到一个 CSVJSON 文件中,或者保存到一个关系数据库(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_tabunix_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 文件。

img/460350_1_En_3_Fig5_HTML.jpg

图 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 文件。

img/460350_1_En_3_Fig6_HTML.jpg

图 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) | \/?:*"><&#124; | 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 Souprequests,并且你创建了你的第一个完整的 scraper 应用,它收集了第二章中的需求。

scraper 将收集的结果导出到不同的存储中,比如 CSV、JSON 和数据库。

但是每朵玫瑰都有它的刺:您了解了这个更简单的解决方案的瓶颈,并应用了一些技术来使它性能更好。有了这个,你就知道了编写自己的 scraper 有多复杂。

即使有这么长的一章,仍有一些要点没有触及,例如,尊重robots.txt文件。你可以扩展这一章的代码来兑现网站的 robots.txt 文件;你有这样做的基础。

在下一章,你将学习Scrapy,Python 的网站抓取工具,它利用了你肩上的这些优化。您必须做的唯一事情是创建提取器代码并正确配置Scrapy

四、使用 Scrapy

在冗长地介绍了Beautiful Soup和定制抓取器之后,是时候看看Scrapy:Python 的网站抓取工具了。

在我看来,这是 Python 目前唯一可行的工具,可以处理开箱即用的复杂抓取任务。可以缓存网页,随心所欲添加并行;你只需要正确配置Scrapy并编写提取代码即可。

在这一章中,你将学习如何最大限度地利用Scrapy来完成你的大部分网站抓取项目。您将编写 Sainsbury 的提取器,配置Scrapy来创建一个网站友好的蜘蛛,并且您将学习如何将自定义导出选项应用到提取的信息。

与上一章相反,我在开始时介绍了Beautiful Soup,然后你创建了这个项目来抓取 Sainsbury 的网站,现在你将通过实现 scraper 项目来学习 Scrapy 的基础知识。在这一章的结尾,我会添加更多的信息和对我们没有在项目中使用的工具的见解,但是我认为知道你将来是否编写自己的刮刀是有用的。

准备好了吗?为什么不呢!

安装Scrapy

您的首要任务是将Scrapy安装到您的 Python 环境中。

要安装Scrapy,只需执行

pip install scrapy

就这样。使用这个命令,您也安装了所有的需求,所以您已经准备好创建 scraper 项目了。

注意

Scrapy的开发者建议将该工具安装到虚拟环境中。这是一个很好的实践,让你的刮削工具有一个干净的版本;这阻碍了您将Scrapy的依赖项更新为不兼容的版本,这会使您的 scraper 无法工作。

如果你安装Scrapy有困难,只要阅读他们的说明。1

创建项目

要开始使用Scrapy,您必须创建一个项目。这有助于你保持文件的有序,并且只关注一个问题。要创建新项目,只需执行以下命令:

scrapy startproject sainsburys

这个调用的结果如下:

New Scrapy project 'sainsburys', using template directory 'c:\\python\\scrapy\\lib\\site-packages\\scrapy\\templates\\project', created in:
    C:\scraping_book\chapter_4\sainsburys

You can start your first spider with
    cd sainsburys
    scrapy genspider example example.com

根据您使用的操作系统和项目所在的位置,前面的文本可能会有所不同。然而,重要的是关于如何创建第一个蜘蛛的信息。

但是在你创建你的第一个蜘蛛之前,让我们看看创建的文件结构,如图 4-1 所示。

img/460350_1_En_4_Fig1_HTML.jpg

图 4-1

项目结构

结构应该差不多;如果没有,也许你正在使用的 Scrapy 新版本有所改变。

配置项目

在深入研究将要用Scrapy实现的主 scraper 的代码之前,您应该正确地配置您的项目。需要基本的配置来显示你是一个“好公民”,你的蜘蛛也是一个很好的工具。

基本配置我建议你每次都做就是添加用户代理,看robots.txt文件兑现。

幸运的是,Scrapy的基本项目框架带有一个配置文件,其中大部分设置都设置正确或被注释掉,但告诉您选项和它接受的值。您可以在settings.py文件中找到项目的配置。

你看一看,会看到增加了很多选项;大部分都被注释掉了。缺省值对于大多数刮削项目来说非常好,但是如果你认为它能给你带来更好的性能或者你需要增加一些复杂性,你可以调整它们。

我经常使用的两个属性是

  • USER_AGENT

  • ROBOTSTXT_OBEY

这些属性的名称已经告诉你它们的用途。

对于USER_AGENT,您会看到一个缺省值,它由 bot 的名称(sainsburys)和一个示例域组成。我主要把它换成 Chrome 代理。你可以通过 Chrome 的 DevTools 获得一个:你打开网络标签,在浏览器中正常加载一个网页,点击网络标签中的请求,将用户代理的值复制到请求的标签中。即使您离线,这也有效。

而要做一个好公民,就把ROBOTSTXT_OBEY留在True上。有了这个,Scrapy负责处理robots.txt文件的内容(如果有的话)。

我建议您删除所有注释掉的设置。这将有助于您稍后阅读该文件,您可以立即看到所有活动的配置;您不必滚动所有行来查看哪些行被注释掉了。即使在具有良好颜色编码的 IDE 中也很难。

除了这些属性,我建议你加上CONCURRENT_REQUESTS = 1。这降低了蜘蛛的速度,但是在测试的时候,你会运行很多代码,你不希望一开始就被禁止访问网站或者你不希望网站的服务器因为你(和 99,999 个其他读者)同时运行 scraper 而无法处理负载而被关闭。如果您查看注释代码,您会发现它的默认值是 16。我将添加一个部分,在那里我将增加并行请求的数量,并将做一个有缺陷的微基准测试。

总结一下:我最终的settings.py文件看起来是这样的:

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

BOT_NAME = 'sainsburys'

SPIDER_MODULES = ['sainsburys.spiders']
NEWSPIDER_MODULE = 'sainsburys.spiders'

USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' \
             '(KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36'

ROBOTSTXT_OBEY = True

CONCURRENT_REQUESTS = 1

在前面的代码中,您可以看到一个 Windows 10 Chrome 用户代理字符串的示例。你不必坚持这一点:随意使用浏览器中的一个;不会有什么影响的。

现在基本的配置已经完成,我们可以实现为我们工作的 spider 了。

术语

在设置配置时,你可以选择学习一些Scrapy的术语,如 middlewearepipeline 。它们是这个刮刀的构建块,如果它缺少您需要的东西,您可以实现自己的代码并扩展功能。

中间件

中间件被钩入Scrapy;这意味着,您可以扩展现有的功能。Scrapy中有两种中间件:

  • 下载器中间件

  • 蜘蛛中间件

顾名思义,您可以扩展下载程序(添加自己的缓存、代理调用、在发送前修改请求或忽略请求,仅举几个例子)或解析器功能(过滤掉一些响应、处理蜘蛛异常、基于响应调用不同的函数等)。).

对于基本的抓取,不需要编写自己的中间件,因为你可以很好地使用可用的工具并且随着Scrapy的发展,更多的定制代码进入了标准库。

需要在settings.py文件中激活中间件。

DOWNLOADER_MIDDLEWARES = {
    'yourproject.middlewares.CustomDownloader': 500
}

SPIDER_MIDDLEWARES = {
    'yourproject.middlewares.SpiderMiddleware': 211
}

如果你有你的中间件,但它们似乎不工作,你可能忘了激活它们。另一个原因可能是它们在错误的位置被执行:您在字典中作为值提供的数字告诉Scrapy中间件应该被执行的顺序:

  • 对于下载器中间件,按照递增的顺序调用process_request方法。

  • 对于下载器中间件,按照递减的顺序调用process_response方法。

  • 对于蜘蛛中间件,按照递增的顺序调用process_spider_input方法。

  • 对于蜘蛛中间件,process_spider_output方法是按照递减的顺序调用的。

因此,可能会发生这样的情况,您期望在请求/响应/输入/输出中得到一些东西,但是它是由一个具有较低/较高优先级的中间件处理的。

管道

管道处理提取的数据。这包括清理、格式化,有时还包括导出数据。尽管Scrapy有内置的管道以给定的格式导出数据(CSV、JSON 在本章后面会详细介绍),有时您需要编写自己的管道来配置结果以满足您(您的客户)的期望。

当你作为一个专业的开发者工作时,你将会编写比中间件更多的管道。然而,这并不像听起来那么糟糕。在这一章中,我们将创建一个简单的项目管道,向您展示它是如何完成的。

与中间件类似,您必须在settings.py文件中激活您的管道。

ITEM_PIPELINES = {
   'yourproject.pipelines.MongoPipeline': 418
}

延长

扩展是在启动时实例化一次的单例类,包含自定义代码,您可以使用它来添加一些自定义功能,这些功能不像中间件那样与下载或抓取相关。此类扩展可用于日志记录或监控内存消耗(这些已经是内置的扩展)。

扩展可以像中间件和管道一样在settings.py中加载。

EXTENSIONS = {
    'scrapy.extensions.memusage.CoreStats': 500
}

选择器

这是你在使用Scrapy时会遇到的最重要的术语。选择器是选择 HTML 特定部分的代码部分。如你所见,选择器的工作类似于Beautiful Souplxml,但它们是Scrapy版本,你可以使用XPathCSS表达式。我更喜欢XPath表达式,因为我从事 XML 和 XML 转换工作多年;所以,我很了解XPath表情。你可以自由使用任何方法,但我会坚持使用XPath

选择器是Scrapy,中的对象,因此它们可以从文本中构造。

from scrapy.selector import Selector

selector = Selector(text='<html><body><h1>Hello Selectors!</h1></body></html>')
print(selector.xpath('//h1/text()').extract()) # ['Hello Selectors!']

或者来自一个回应:

from scrapy.selector import Selector
from scrapy.http import HtmlResponse

response = HtmlResponse(url='http://my.domain.com', body='<html><body><h1>Hello Selectors!</h1></body></html>', encoding='UTF-8')
print(Selector(response=response).css('h1::text').extract()) # ['Hello Selectors!']

但是,因为选择器是提取数据的方式,所以您可以使用

response.xpath()

或者

response.css()

在我看来,这使得Scrapy成为一个很好的工具:你不必费心创建选择器对象,而是使用可用的方便的方法访问。

如果你想阅读更多关于 CSS 选择器 2 或 XPath 表达式的内容,请点击链接。 3

实施 Sainsbury 刮刀

要开始提取代码,你需要一个蜘蛛生成。正如您在上一节中看到的,您创建并配置了项目的基础,您可以使用genspider命令来完成。让我们现在就做吧。首先将目录更改为生成 bot 的目录,然后执行以下命令:

scrapy genspider sainsburys 'https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/'

当执行前面的命令时,您会得到一条奇怪的消息:

Cannot create a spider with the same name as your project

好吧,如果我们找不到同名的蜘蛛,那就给它起个不同的名字吧。我的建议是一个你容易记住的名字。我主要使用"basic",因为它很容易编写,而且我有一个基本的 scraper 来帮我提取。该项目已经有一个唯一的名称;有了basic,我可以随时启动我的蜘蛛,不管是什么项目。

scrapy genspider basic https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish

现在的反应不同了。

Created spider 'basic' using template 'basic' in module:
  sainsburys.spiders.basic

使用这个命令,Scrapy将一个basic.py文件添加到项目的spiders文件夹中。这个文件将是你的蜘蛛的基础;在这里你将实现提取代码。

代码看起来很正常,但是如果你仔细看,你会发现start_urls变量看起来有点奇怪。

start_urls = ['http://https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']

它多了一个http://。这是因为我们为 scraper 一代提供了 URL。Scrapy本意是刮一个域;因此,您应该为蜘蛛创建提供一个域。然而,在这个例子的特殊情况下,我们将只抓取整个域的一个子集(“肉&鱼”)。有两种选择:

  • 您只使用域名'www.sainsburys.co.uk'创建蜘蛛,然后将 URL 的剩余部分添加到 start_urls(或者完全更改条目)。

  • 您只需从 start_urls 条目中删除多余的'http://'

allowed_domains是关于什么?

如果您仔细查看了代码,您会看到有一个允许的域列表。这个列表用来给蜘蛛一个界限。无需设置允许的域,您可以编写一个通过互联网的脚本(跟踪它抓取的页面上的每个链接)。在大多数情况下,您希望将您的抓取保持在一个域中。然而,有时候你不得不处理内部或者子域。在这些情况下,您可以手动扩展该列表来修复此类“问题”

在这里,您应该只设置域。当您生成蜘蛛时,它会将整个 URL 添加到此列表中,但是您需要类似以下的内容:

allowed_domains = ['www.sainsburys.co.uk']

您可以在文件夹01_empty_project中本章的源代码中找到一个带有我的默认配置的空项目的源代码。

准备

这一节很简短。如果您照着做,您已经配置好了一切,不需要任何其他准备。

只是一个快速检查清单,看看你是否准备好了:

  • 你已经阅读了第二章的要求。

  • 您已经创建了一个Scrapy-项目。

  • 您已经按照本章中的描述配置了项目。

  • 你创造了一只蜘蛛。

如果缺少了什么,花时间去弥补;那你就可以跟着去了。

使用外壳

我喜欢在准备工作中使用的一个功能是使用它的 shell,它为我们提供了一个测试和准备代码片段的环境。因为 shell 的行为就像您的 spider 代码一样,所以它非常适合创建您的应用的构建块。

用一种简单的方法(或者类似的方法,就像我们在上一章做的那样),你可以写一部分代码并运行蜘蛛。如果有错误,您应该修复代码并重新运行蜘蛛。如果网站不基于请求限制访问,这是可以的。如果有一个限制,你可能最终会超过它,你的蜘蛛(和你的电脑,当前的 IP,整个公司网络 4 )被禁止进入网站。而且,正如我所见,Sainsbury 的运行落后于 CloudFlare 你最好不要向他们的网站发送并行请求!

Scrapy shell 的工作方式不同:它下载您的目标网页,您可以在这个副本上创建提取逻辑。如果您需要移动到另一个页面,您可以让 shell 下载它,然后您就可以编写下一段代码了。

启动 shell 很容易。

scrapy shell

您可以传递一个<url>参数,这是您的目标 URL。

对于这本书,我们将使用 https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/ :

scrapy shell https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/

或者,您也可以在打开Scrapy的 shell 时不使用任何 URL,或者使用不同的 URL。

>>> fetch('https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/')

现在 shell 已经下载了 URL 后面的网页。这意味着两件事:现在你可以访问肉&鱼页面的内容,并可以尝试你的提取器;第二,您必须下载您想在 shell 中使用的每个页面。尽管第二点听起来很糟糕,但事实并非如此:在Scrapy中获取其他页面变得很容易,因此在 shell 中也是如此。

在 shell 中,你可以访问一个response对象(就像在parse方法中一样,我们将在本章后面编写),通过这个response,你可以使用可用的选择器。

我不想深究如何使用 shell 来准备您的 scraper 脚本。因此,我们将做一个例子:我们获得下一页的 URL。这会给你一个好的开始,并让你有使用外壳做进一步准备的感觉。

您可能还记得,可以在一个无序列表中找到指向详细页面的链接(<ul class="categories departments">)。列表的元素(<li>)有一个锚子(<a>),这些锚的href属性值就是我们要找的 URL。

要获得这些 URL 的列表,可以使用 XPath 编写以下代码:

urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()

使用 CSS 选择器,看起来像这样:

urls = response.css('ul.categories.departments > li > a::attr(href)').extract()

就是这样。就像上一章一样,所有的 URL 都指向该类别的产品列表或包含更多子类别的网站。

我建议您现在更深入地研究 XPath 和 CSS 选择器,以理解您将从下一节开始编写的提取器代码。

定义解析(自身,响应)

现在我们可以开始在basic.py文件中编写代码了。

方法是每个蜘蛛的核心。这个方法在每次Scrapy下载一个 URL 时被调用,大多数时候你用这个方法写你的提取代码。

response参数保存调用 URL 的响应。它可以包含网站的内容,但有时你可以得到错误代码,例如,当网站关闭或不存在。

您可以将整个 scraper 编写到parse方法中,但是我建议将您的代码组织到方法中(实际上,这是许多开发人员建议的做法)。这有助于您将来理解代码想要达到的目的。

因此,parse函数将非常稀疏:它只提取类别页面的 URL(与 shell 的准备过程相同),并启动这些页面的下载和解析。

from scrapy import Request

# some code left out...

def parse(self, response):
    urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()

    for url in urls:
        yield Request(url, callback=self.parse_department_pages)

前面的代码提取了所需类列表中每个锚元素的href属性。有趣的部分是抓取是如何继续的:你yield一个新的Request对象,目标 URL 作为第一个参数,以及callback函数,如果服务器为给定的 URL 返回一个 OK-ish 响应,就应该调用这个函数。在这种情况下,它将是同一个类的parse_department_pages方法。

有一种替代方法可以用更少的代码进入下一页。

def parse(self, response):
    urls = response.xpath('//ul[@class="categories departments"]/li/a')

    for url in urls:
        yield response.follow(url, callback=self.parse_department_pages)

这里我们使用 Scrapy 的语法:在引擎盖下执行相同的代码,但是您不必费心从锚标记中提取确切的引用。而且有时候在网页链接中得不到完全限定(绝对)的 URL 而是相对引用,还得手动添加主机(或者使用urljoin)。通过使用response.follow,你也可以从盒子里得到这个。因此,我建议你使用这种语法,我也会在书中用到它!

目前,从版本 1.4.0 开始,您必须为follow方法提供一个单个 URL 或Link类型的对象。我打赌有人也会添加一个接受列表的方法(例如follow_all),因为我们喜欢让事情变得更简单。

这样,我们就完成了这一部分。让我们继续,看看如何进入产品页面。

在本节结束时,您的basic.py文件应该如下所示:

# -*- coding: utf-8 -*-
import scrapy

class BasicSpider(scrapy.Spider):
    name = 'basic'
    allowed_domains = ['www.sainsburys.co.uk']
    start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']

    def parse(self, response):
        urls = response.xpath('//ul[@class="categories departments"]/li/a')

        for url in urls

:
            yield response.follow(url, callback=self.parse_department_pages)

浏览类别

你的第一个任务是浏览塞恩斯伯里网站的分类页面。在前一章中,您已经看到了找到包含项目详细信息的页面是多么复杂。

正如您在上一章中看到的,每个类别的链接可以指向产品列表或包含子类别及其链接的页面,这些链接可以指向产品列表页面或包含子类别的第三个页面。还好没有更深的层次感。

在这一节中,我们将处理上一节中的代码产生子或子子类别的页面而不是产品详细信息的情况。

在上一节中,我们用 Scrapy 发送请求,并告诉工具用parse_department_pages方法处理响应。

为了实现这种方法,我们必须考虑三种版本的响应:

  • 我们得到一个产品列表页面。

  • 我们得到一个子类别页面。

  • 我们得到一个子类别的页面。

如果响应是一个产品列表,那么应该将响应转发给下一节的方法。然而,我们必须注意触发请求。生成的块将如下所示:

product_grid = response.xpath('//ul[@class="productLister gridView"]')
    if product_grid:
        for product in self.handle_product_listings(response):
            yield product

在前面的代码中,我们用response对象调用了handle_product_listings方法。我们也可以提供产品网格(或者仅仅是网格),因为我们已经提取了它,但是,正如您稍后将看到的,我们需要response在产品网格的页面之间导航。

然后我们yield结果,这也是 Scrapy 抓取这些 URL 的触发器。

下一步是通过更深层次的类别,这些类别由 CSS 类表示,如过道(class="category aisles")和货架(class="category shelves" ) ,就像在超市中一样。

这里的技巧是检查页面的源是否包含货架,如果不包含,那么就访问过道。这是因为包含货架的页面也包含过道,如果您首先获得过道链接,那么如果您不使用缓存,您可能会陷入不断重复获得相同页面的永无止境的循环中。获得相同的页面意味着更慢的抓取(实际上,永远不会结束)和在你的抓取结果中有很多重复的项目。

pages = response.xpath('//ul[@class="categories shelf"]/li/a')
if not pages:
    pages = response.xpath('//ul[@class="categories aisles"]/li/a')
if not pages:
    # here is something fishy
    return

for url in pages:
    yield response.follow(url, callback=self.parse_department_pages)

前面的代码遵循前面提到的方法:它查找货架,如果没有找到,它就查找过道。如果什么都没有找到,那么我们就处在一个无法收集更多信息的页面上:我们已经提取了产品列表的链接,或者页面上没有指向过道或货架的链接。

在这一部分的最后,您的basic.py文件应该看起来像这样:

# -*- coding: utf-8 -*-
import scrapy

class BasicSpider(scrapy.Spider):
    name = 'basic'
    allowed_domains = ['www.sainsburys.co.uk']
    start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']

    def parse(self, response):
        urls = response.xpath('//ul[@class="categories departments"]/li/a')

        for url in urls:
            yield response.follow(url, callback=self.parse_department_pages)

    def parse_department_pages(self, response):
        product_grid = response.xpath('//ul[@class="productLister gridView"]')
        if product_grid:
            for product in self.handle_product_listings(response):
                yield product

        pages = response.xpath('//ul[@class="categories shelf"]/li/a')
        if not pages:
            pages = response.xpath('//ul[@class="categories aisles"]/li/a')
        if not pages:
            # here is something fishy
            return

        for url in pages

:
            yield response.follow(url, callback=self.parse_department_pages)

浏览产品列表

现在,您的代码在某些时候会指向一个产品列表页面。在本节中,如果这些页面的元素太多,无法在一页上显示,我们将浏览这些页面,并且我们将请求下载详细的项目页面。

我们目前在handle_product_listings函数中。

让我们从项目细节开始。

urls = response.xpath('//ul[@class="productLister gridView"]//li[@class="gridItem"]//h3/a')
for url in urls:
    yield response.follow(url, callback=self.parse_product_detail)

前面的代码提取详细页面的 URL,然后将这些 URL 返回到触发抓取的parse_department_pages方法。

next_page = response.xpath('//ul[@class="pages"]/li[@class="next"]/a')
if next_page:
    yield response.follow(next_page, callback=self.handle_product_listings)

这段代码寻找到下一页的链接。如果找到一个(在网站上,它在>符号下),那么它被返回给parse_department_pages方法。注意这里的callback方法:因为我们知道我们得到了另一页产品列表,我们可以使用同样的方法作为回调。

完成这一部分后,您的basic.py文件应该如下所示:

# -*- coding: utf-8 -*-
import scrapy

class BasicSpider(scrapy.Spider):
    name = 'basic'
    allowed_domains = ['www.sainsburys.co.uk']
    start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']

    def parse(self, response):
        urls = response.xpath('//ul[@class="categories departments"]/li/a')

        for url in urls:
            yield response.follow(url, callback=self.parse_department_pages)

    def parse_department_pages(self, response):
        product_grid = response.xpath('//ul[@class="productLister gridView"]')
        if product_grid:
            for product in self.handle_product_listings(response):
                yield product

        pages = response.xpath('//ul[@class="categories shelf"]/li/a')
        if not pages:
            pages = response.xpath('//ul[@class="categories aisles"]/li/a')
        if not pages:
            # here is something fishy
            return

        for url in pages:
            yield response.follow(url, callback=self.parse_department_pages)

    def handle_product_listings(self, response):
        urls = response.xpath('//ul[@class="productLister gridView"]//li[@class="gridItem"]//h3/a')
        for url in urls:
            yield response.follow(url, callback=self.parse_product_detail)

        next_page = response.xpath('//ul[@class="pages"]/li[@class="next"]/a')
        if next_page:
            yield response.follow(next_page, callback=self.handle_product_listings)

提取数据

现在,您的代码可以处理复杂的导航并找到项目详细信息页面,是时候从网站提取所需的信息了。

我们目前采用的是parse_product_detail方法。

现在是时候从项目页面提取所有需要的信息了。实际上,这个过程和你在上一章中所做的是一样的(如果你编码的话):你可以使用查询;然而,您可以在验证每个findfind_all调用时节省一些代码行。

话不多说,让我们直接进入代码。

如果愿意,可以放下书,实现提取逻辑。这并不难,你可以使用前两章的信息来搭配。

我的解决方案是这样的(你的可能不同):

def parse_product_detail(self, response):
    product_name = response.xpath('//h1/text()').extract()[0].strip()
    product_image = response.urljoin(response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])

    price_per_unit = response.xpath('//div[@class="pricing"]/p[@class="pricePerUnit"]/text()').extract()[0].strip()
    units = response.xpath('//div[@class="pricing"]/span[@class="pricePerUnitUnit"]').extract()
    if units:
        unit = units[0].strip()

    ratings = response.xpath('//label[@class="numberOfReviews"]/img/@alt').extract()
    if ratings:
        rating = ratings[0]
    reviews = response.xpath('//label[@class="numberOfReviews"]').extract()
    if reviews:
        reviews = reviews_pattern.findall(reviews[0])
        if reviews:
            product_reviews = reviews[0]

    item_code = item_code_pattern.findall(response.xpath('//p[@class="itemCode"]/text()').extract()[0].strip())[0]

    nutritions = {}
    for row in response.xpath('//table[@class="nutritionTable"]/tr'):
        th = row.xpath('./th/text()').extract()
        if not th:
            th = ['Energy kcal']
        td = row.xpath('./td[1]/text()').extract()[0]
        nutritions[th[0]] = td

    product_origin = ' '.join(response.xpath(
        './/h3[@class="productDataItemHeader" and text()="Country of Origin"]/following-sibling::div[1]/p/text()').extract())

就是这样。提取产品信息需要 30 行代码(使用我的自定义格式设置)。这真是太棒了!

正如您在代码中看到的,有一些有趣的代码块。例如,每个xpath调用都返回一个列表,即使您知道最多只能有一个结果。其中一些列表是空的,因为产品没有评级或单位信息。和Beautiful Soup一样,你也必须和Scrapy一起处理这种情况。

在这一部分之后,您的basic.py文件应该如下所示:

# -*- coding: utf-8 -*-
import scrapy

reviews_pattern = re.compile("Reviews \((\d+)\)")
item_code_pattern = re.compile("Item code: (\d+)")

class BasicSpider(scrapy.Spider):
    name = 'basic'
    allowed_domains = ['www.sainsburys.co.uk']
    start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']

    def parse(self, response):
        urls = response.xpath('//ul[@class="categories departments"]/li/a')

        for url in urls:
            yield response.follow(url, callback=self.parse_department_pages)

    def parse_department_pages(self, response):
        product_grid = response.xpath('//ul[@class="productLister gridView"]')
        if product_grid:
            for product in self.handle_product_listings(response):
                yield product

        pages = response.xpath('//ul[@class="categories shelf"]/li/a')
        if not pages:
            pages = response.xpath('//ul[@class="categories aisles"]/li/a')
        if not pages:
            # here is something fishy
            return

        for url in pages:
            yield response.follow(url, callback=self.parse_department_pages)

    def handle_product_listings(self, response):
        urls = response.xpath('//ul[@class="productLister gridView"]//li[@class="gridItem"]//h3/a')
        for url in urls:
            yield response.follow(url, callback=self.parse_product_detail)

        next_page = response.xpath('//ul[@class="pages"]/li[@class="next"]/a')
        if next_page:
            yield response.follow(next_page, callback=self.handle_product_listings)

    def parse_product_detail(self, response):
        product_name = response.xpath('//h1/text()').extract()[0].strip()
        product_image = response.urljoin(response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])

        price_per_unit = response.xpath('//div[@class="pricing"]/p[@class="pricePerUnit"]/text()').extract()[0].strip()
        units = response.xpath('//div[@class="pricing"]/span[@class="pricePerUnitUnit"]').extract()
        if units:
            unit = units[0].strip()

        ratings = response.xpath('//label[@class="numberOfReviews"]/img/@alt').extract()
        if ratings:
            rating = ratings[0]
        reviews = response.xpath('//label[@class="numberOfReviews"]').extract()
        if reviews:
            reviews = reviews_pattern.findall(reviews[0])
            if reviews:
                product_reviews = reviews[0]

        item_code = item_code_pattern.findall(response.xpath('//p[@class="itemCode"]/text()').extract()[0].strip())[0]

        nutritions = {}
        for row in response.xpath('//table[@class="nutritionTable"]/tr'):
            th = row.xpath('./th/text()').extract()
            if not th:
                th = ['Energy kcal']
            td = row.xpath('./td[1]/text()').extract()[0]
            nutritions[th[0]] = td

        product_origin = ' '.join(response.xpath(
            './/h3[@class="productDataItemHeader" and text()="Country of Origin"]/following-sibling::div[1]/p/text()').extract())

数据放在哪里?

好:您已经完成了,实现了产品提取器,并且您的蜘蛛中有许多包含项目信息的变量,但是在哪里存储它们呢?

使用Scrapy,你必须将数据存储在所谓的中。这些项目是普通的旧 Python 类,可以在items.py文件中找到。除此之外,这些项的行为就像字典一样:您将它们声明为 Python 类,并可以像字典一样使用键值赋值来填充它们。

如果您在上一步之后运行了蜘蛛,您可能会在控制台中看到如下条目:

2018-02-11 11:06:03 [scrapy.extensions.logstats] INFO: Crawled 47 pages (at 47 pages/min), scraped 0 items (at 0 items/min)

在这里你可以看到没有物品被刮除。我们现在会解决这个问题。

让我们采用parse_product_detail方法将数据放入一个条目中。为此,首先我们需要一个条目,它已经在items.py文件中。

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

此类当前为空;我们必须给它添加字段。因为不喜欢每次都写scrapy.Field()(哪怕只是复制+粘贴),所以喜欢做“静态”导入(from scrapy import Item, Field)。

我的解决方案是这样的:您的名称可能会有所不同,这取决于您如何命名字段。

class SainsburysItem(Item):
    url = Field()
    product_name = Field()
    product_image = Field()
    price_per_unit = Field()
    unit = Field()
    rating = Field()
    product_reviews = Field()
    item_code = Field()
    nutritions = Field()
    product_origin = Field()

我唯一更改的是nutritions字段:我没有将所有可能的字段添加到项目定义中。这使得编写文件更容易,导出到 JSON(见后面)更方便。

平面(也称为包含所有字段)类如下所示:

class FlatSainsburysItem(Item):
    url = Field()
    product_name = Field()
    product_image = Field()
    price_per_unit = Field()
    unit = Field()
    rating = Field()
    product_reviews = Field()
    item_code = Field()
    product_origin = Field()

    energy = Field()
    energy_kj = Field()
    kcal = Field()
    fibre_g = Field()
    carbohydrates_g = Field()
    of_which_sugars = Field()
    ...

正如您所看到的,这种方法的问题将出现在代码中:对于营养表,您将字符串作为键,并且您必须将它们映射到这些字段名称。这让事情变得复杂了。除此之外,还有超过 70 个不同的字段需要映射。

我不认为在这里包含这样的映射代码是有用的。有兴趣可以试试,但一般不是这本书或者网站刮痧的要求。

当我们在本章后面将结果导出到文件中时,我们将仔细看看默认情况下包含字典的字段是如何导出的,以及我们可以做些什么来获得与第二章中相同的结果。

现在要添加和使用项目,您必须像这样修改parse_product_detail方法:

def parse_product_detail(self, response):
    item = SainsburysItem()
    item['url'] = response.url
    item['product_name'] = response.xpath('//h1/text()').extract()[0].strip()
    item['product_image'] = response.urljoin(
        response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])

    item['price_per_unit'] = response.xpath('//div[@class="pricing"]/p[@class="pricePerUnit"]/text()').extract()
        [0].strip()
    units = response.xpath('//div[@class="pricing"]/span[@class="pricePerUnitUnit"]').extract()
    if units:
        item['unit'] = units[0].strip()

    ratings = response.xpath('//label[@class="numberOfReviews"]/img/@alt').extract()
    if ratings:
        item['rating'] = ratings[0]
    reviews = response.xpath('//label[@class="numberOfReviews"]').extract()
    if reviews:
        reviews = reviews_pattern.findall(reviews[0])
        if reviews:
            item['product_reviews'] = reviews[0]

    item['item_code'] = \
item_code_pattern.findall(response.xpath('//p[@class="itemCode"]/text()').extract()[0].strip())[0]

    nutritions = {}
    for row in response.xpath('//table[@class="nutritionTable"]/tr'):
        th = row.xpath('./th/text()').extract()
        if not th:
            th = ['Energy kcal']
        td = row.xpath('./td[1]/text()').extract()[0]
        nutritions[th[0]] = td
    item['nutritions'] = nutritions

    item['product_origin'] = ' '.join(response.xpath(
        './/h3[@class="productDataItemHeader" and text()="Country of Origin"]/following-sibling::div[1]/p/text()').extract())

    yield item

这包括定义新条目(将导入添加到文件:from sainsburys.items import SainsburysItem),然后像使用字典一样使用它。在我的项目定义中,我使用了以前版本中的变量名作为Field名,但是如何命名您的字段取决于您。你只需要找到正确的映射。

最后,您必须yield这个项目,这使得Scrapy知道有一个项目要处理。

蜘蛛的当前状态可以在本章的源文件中的文件夹02_basic_spider中找到。

为什么是项目?

问得好!因为项是类似字典的对象;或者,你可以使用字典来存储你的信息。

item = {}

这不会导致编码或处理结果的任何差异,尽管Scrapy的条目包含一些组件使用的扩展信息。例如,导出器查看要导出哪些字段,序列化可以通过Items元数据定制,您可以使用它们来查找内存泄漏。

在本章的后面你会看到,有时用一个简单的字典代替一个条目是很方便的。但是现在,你应该使用物品。

运行蜘蛛

现在是时候启动我们的蜘蛛了,因为我们完成了提取器方法并添加了要导出的项目。

要启动蜘蛛,请执行

scrapy crawl basic

从您的 crawler-projects 主文件夹(scrapy.cfg文件所在的位置)中。对我来说,这是

C:\wswp\chapter_4\sainsburys

根据您的日志记录配置,您可能会看到类似下面的内容:

018-02-11 13:52:20 [scrapy.utils.log] INFO: Scrapy 1.5.0 started (bot: sainsburys)
2018-02-11 13:52:20 [scrapy.utils.log] INFO: Versions: lxml 4.1.1.0, libxml2 2.9.5, cssselect 1.0.3, parsel 1.4.0, w3lib 1.19.0, Twisted 17.9.0, Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)], pyOpenSSL 17.5.0 (OpenSSL 1.1.0g  2 Nov 2017), cryptography 2.1.4, Platform Windows-10-10.0.16299-SP0
2018-02-11 13:52:20 [scrapy.crawler] INFO: Overridden settings: {'BOT_NAME': 'sainsburys', 'CONCURRENT_REQUESTS': 1, 'LOG_LEVEL': 'INFO', 'NEWSPIDER_MODULE': 'sainsburys.spiders', 'ROBOTSTXT_OBEY': True, 'SPIDER_MODULES': ['sainsburys.spiders'], 'USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36'}
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.corestats.CoreStats',
 'scrapy.extensions.telnet.TelnetConsole',
 'scrapy.extensions.logstats.LogStats']
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
 'scrapy.downloadermiddlewares.retry.RetryMiddleware',
 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
 'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware',
 'scrapy.downloadermiddlewares.stats.DownloaderStats']
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
 'scrapy.spidermiddlewares.referer.RefererMiddleware',
 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
 'scrapy.spidermiddlewares.depth.DepthMiddleware']
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled item pipelines:
[]
2018-02-11 13:52:20 [scrapy.core.engine] INFO: Spider opened
2018-02-11 13:52:20 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2018-02-11 13:53:20 [scrapy.extensions.logstats] INFO: Crawled 220 pages (at 220 pages/min), scraped 205 items (at 205 items/min)
2018-02-11 13:54:20 [scrapy.extensions.logstats] INFO: Crawled 442 pages (at 222 pages/min), scraped 416 items (at 211 items/min)
2018-02-11 13:55:20 [scrapy.extensions.logstats] INFO: Crawled 666 pages (at 224 pages/min), scraped 630 items (at 214 items/min)
2018-02-11 13:56:20 [scrapy.extensions.logstats] INFO: Crawled 883 pages (at 217 pages/min), scraped 834 items (at 204 items/min)
...
2018-02-11 14:12:20 [scrapy.extensions.logstats] INFO: Crawled 4525 pages (at 257 pages/min), scraped 4329 items (at 246 items/min)
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Closing spider (finished)
2018-02-11 14:13:01 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 11644228,
 'downloader/request_count': 4720,
 'downloader/request_method_count/GET': 4720,
 'downloader/response_bytes': 72337636,
 'downloader/response_count': 4720,
 'downloader/response_status_count/200': 4718,
 'downloader/response_status_count/302': 1,
 'downloader/response_status_count/404': 1,
 'finish_reason': 'finished',
 'finish_time': datetime.datetime(2018, 2, 11, 13, 13, 1, 337489),
 'item_scraped_count': 4515,
 'log_count/INFO': 27,
 'offsite/domains': 1,
 'offsite/filtered': 416,
 'request_depth_max': 13,
 'response_received_count': 4719,
 'scheduler/dequeued': 4719,
 'scheduler/dequeued/memory': 4719,
 'scheduler/enqueued': 4719,
 'scheduler/enqueued/memory': 4719,
 'start_time': datetime.datetime(2018, 2, 11, 12, 52, 20, 860026)}
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Spider closed (finished)

或者更多的信息在你的屏幕上嗡嗡作响。这是因为默认的日志记录级别。如果不显式地将它设置为INFO,就会得到所有信息Scrapy——开发者认为有用的信息。这些信息的一部分是收集的项目。在控制台上看到处理了哪些项目是很好的,但是对于超过 3000 个条目,这会生成大量不需要的输出。

prcedimg 输出的前几行告诉您运行了什么配置Scrapy。在这里,您可以看到中间件、管道、扩展,以及所有重要的东西,以便在遇到奇怪的结果时进行分析。

2018-02-11 13:53:20 [scrapy.extensions.logstats] INFO: Crawled 220 pages (at 220 pages/min), scraped 205 items (at 205 items/min)

随着时间的推移,屏幕上会弹出一个类似于上一行的新行。这告诉你当前的进度:抓取了多少页,提取了多少项,抓取的速度有多快。这些数字因您的设置而异:如果您增加并发请求并减少请求之间的延迟,速度会更快(当然,这取决于目标网站)。如果你觉得这样的统计很烦人,你可以通过在你的蜘蛛的settings.py中添加以下内容来禁用它们:

EXTENSIONS = {
    'scrapy.extensions.logstats.LogStats': None
}

当抓取完成时,您将看到与此类似的摘要:

2018-02-11 14:13:01 [scrapy.core.engine] INFO: Closing spider (finished)
2018-02-11 14:13:01 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 11644228,
 'downloader/request_count': 4720,
 'downloader/request_method_count/GET': 4720,
 'downloader/response_bytes': 72337636,
 'downloader/response_count': 4720,
 'downloader/response_status_count/200': 4718,
 'downloader/response_status_count/302': 1,
 'downloader/response_status_count/404': 1,
 'finish_reason': 'finished',
 'finish_time': datetime.datetime(2018, 2, 11, 13, 13, 1, 337489),
 'item_scraped_count': 4515,
 'log_count/INFO': 27,
 'offsite/domains': 1,
 'offsite/filtered': 416,
 'request_depth_max': 13,
 'response_received_count': 4719,
 'scheduler/dequeued': 4719,
 'scheduler/dequeued/memory': 4719,
 'scheduler/enqueued': 4719,
 'scheduler/enqueued/memory': 4719,
 'start_time': datetime.datetime(2018, 2, 11, 12, 52, 20, 860026)}
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Spider closed (finished)

在这些统计转储中,您可以找到整个抓取过程的摘要:请求、错误、不同的 HTTP 代码、抓取的项目数量、内存使用情况以及许多其他有用的东西。这可以让您知道在哪里启用扩展(例如,查找哪些外部域被触发或哪些页面没有找到)。

下载在 20 分钟内完成。这比我用第三章的基本刮刀运行要好很多(我让它在这次运行之前运行,用了 4009 秒)。我们不需要写这么多代码。

导出结果

现在您有了提取的数据,有了表示信息的项目,但是蜘蛛一完成,结果就消失了,Python 进程也从您的计算机内存中消失了。

幸运的是,Scrapy为你提供了内置的解决方案,但它们非常基础(你可以称之为原始的)。但是有一种方法可以插入您的自定义解决方案,并使刮刀正常工作。

在这一节中,我们将首先探索内置选项,看看它们是否真的如此简单。然后,我们将看看如何根据我们的需求形成导出,是的,这需要编写一些代码。

因为Scrapy知道抓取会导致保存提取的信息,所以不需要您配置导出器管道。您可以通过命令行使用-o选项告诉Scrapy轻松导出抓取的结果。如果您提供正确的文件扩展名(.csv表示 CSV,.json表示 JSON),或者您也可以添加-t选项,并告诉您想要在指定的输出文件中使用什么格式的数据(使用-t提供的值必须是有效的提要导出器,稍后将详细介绍)。

我在这些默认导出器中遇到的唯一问题是,它们将结果追加到文件中:如果文件不存在,就没有问题。但是,如果文件存在并且包含内容(例如来自以前运行的内容),则新数据会简单地附加到文件中,从而导致无效内容。

除了我将在下一节讨论的 JSON 和 CSV 导出器之外,您还可以以 XML、Pickle 或 Marshal 格式导出您的项目。它们通过内置的项目导出器完成,并使用已经提供的功能。

至 CSV

第一种方法是将所有内容导出到 CSV。正如您在上一段中看到的,您只需使用提供 CSV 文件的-o选项来运行蜘蛛。

scrapy crawl basic -o sainsburys.csv

如果刮刀完成了,你可以打开sainsburys.csv文件,看看它的内容。

item_code,nutritions,price_per_unit,product_image,product_name,product_origin,product_reviews,rating,unit,url
7906825,"{'Energy ': '762kJ/', 'Fat ': '9.8g', 'Saturates': '3.5g', 'Carbohydrates': '6.6g', 'Sugars': '3.5g', 'Protein ': '16g', 'Salt ': '1.71g'}",£3.00,https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg,Black Farmer Reduced Fat Sausages 400g,,0,0.0,,https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1200360&urlRequestType=Base&categoryId=352852&catalogId=10194&langId=44

注意

对于 Windows 用户,您可能会在文件中遇到额外的空行。这是因为目前Scrapy中一个公开的错误,但主要原因是操作系统之间的行尾差异。当我写这篇文章时,GitHub 5 已经有一个拉取请求;它已经被合并,我希望它可以在下一个发布的Scrapy版本中使用。

因为每一行都有很多内容,这里就不多列举了。但是您已经可以看到有趣的部分:营养栏(在我的例子中是第二栏)。它用花括号({})把营养词典写成文本。这不好;因此,我们将实现一个自定义项目导出器来处理这种情况。

至 JSON

导出到 JSON 的工作方式类似于 CSV:您提供一个 JSON 文件作为输出。

scrapy crawl basic -o sainsburys.json

结果是一个 JSON 文件,其中包含如下条目:

{
  "url": "https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1200360&urlRequestType=Base&categoryId=352852&catalogId=10123&langId=44",
  "product_name": "Black Farmer Reduced Fat Sausages 400g",
  "product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg",
  "price_per_unit": "\u00a33.00",
  "rating": "0.0",
  "product_reviews": "0",
  "item_code": "7906825",
  "nutritions": {
    "Energy ": "762kJ/",
    "Fat ": "9.8g",
    "Saturates": "3.5g",
    "Carbohydrates": "6.6g",
    "Sugars": "3.5g",
    "Protein ": "16g",
    "Salt ": "1.71g"
  },
  "product_origin": ""
}

使用 JSON,nutrition字典非常适合导出的结果。琴键可能需要一点整理,但目前结构看起来很棒。

这里有一个小缺陷:那些讨厌的 Unicode 字符。要解决这个问题,将下面一行添加到您的settings.py文件中:

FEED_EXPORT_ENCODING = 'utf-8'

再次运行 scraper 后,相同的条目如下所示:

{
  "url": "https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1200360&urlRequestType=Base&categoryId=276041&catalogId=10172&langId=44",
  "product_name": "Black Farmer Reduced Fat Sausages 400g",
  "product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg",
  "price_per_unit": "£3.00",
  "rating": "0.0",
  "product_reviews": "0",
  "item_code": "7906825",
  "nutritions": {
    "Energy ": "762kJ/",
    "Fat ": "9.8g",
    "Saturates": "3.5g",
    "Carbohydrates": "6.6g",
    "Sugars": "3.5g",
    "Protein ": "16g",
    "Salt ": "1.71g"
  },
  "product_origin": ""
}

作为整个 JSON 文件的替代,您可以使用 JSON-lines。这种格式将每一项都导出为一个 JSON 对象,这样可以处理大量数据,因为您不必将所有内容都加载到内存中,然后将它们放入一个 megaobject 中,以写入文件或由目标平台读取。

Scrapy对于这种结果类型也有一个内置的导出器,您可以使用下面的命令来访问它:

scrapy crawl basic -o sainsburys.jl

如果您在运行蜘蛛程序时查看您的文件系统,您将会看到 JSON-lines 文件一旦被项目管道处理就被写入磁盘!您不必等到抓取完成后才获得有效的文件。

到数据库

对于数据库来说,没有现成的解决方案;不能在命令行中添加额外的参数来将结果写入数据库。

如果您希望您的数据存储在数据库中,那么您必须编写自己的解决方案。然而,因为在数据库中存储是我经常遇到的一个用例,所以我想把它添加到这一节中,而不是在下一节中写如何使用自己的导出器时。

我们将看看两种不同类型的数据库:MongoDB 和 SQLite。它们代表了目前使用的大多数数据库的方法,尽管其他基于云的存储解决方案正在兴起,但大多数客户端仍在使用这些类型的数据库。

MongoDB

首先,让我们创建项目管道。

import pymongo

class MongoDBPipeline(object):

    def __init__(self, mongo_uri, mongo_db, collection_name):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db
        self.collection_name = collection_name

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get('MONGO_URI'),
            mongo_db=crawler.settings.get('MONGO_DATABASE', 'items'),
            collection_name=crawler.settings.get('MONGO_COLLECTION', 'sainsburys')
        )

    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]

    def close_spider(self, spider):
        self.client.close()

    def process_item(self, item, spider):
        self.db[self.collection_name].insert_one(dict(item))
        return item

使用任何数据库的想法是,您需要一个到目标数据库的连接,并且在完成后必须清理。上面的管道就是这样做的。

当抓取开始时,每次蜘蛛启动时都调用open_spiderclose_spider在蜘蛛完成工作并解散时被调用。这是两种方法,你必须打开关闭到数据库的连接。

process_item处理该项目,在这种情况下,该项目存储在数据库中。

但是最有趣的方法是from_crawler。如果存在,它必须返回管道的新实例。提供给该方法的crawler应该用于访问特定于 crawler 的设置。在这个例子中,我们得到连接、数据库和收集设置,其中最后两个有默认值,您不必提供它们。

为了让您的管道工作,您必须在settings.py中配置它。

ITEM_PIPELINES = {
    'sainsburys.pipelines.MongoDBPipeline': 300
}

然后,您需要提供数据库配置。您可以在settings.py文件中这样做(这使得配置是硬编码的):

MONGO_URI = 'localhost:27017'

或者您可以在启动 spider 时通过命令行提供它:

scrapy crawl basic -s MONGO_URI=localhost:27017

因为我们使用的是pymongo,我们甚至不需要提供数据库 URI。在这种情况下,pymongo会创建一个到localhost:27017的默认连接。

运行完蜘蛛后,我们可以在数据库中看到结果,如图 4-2 所示。

img/460350_1_En_4_Fig2_HTML.jpg

图 4-2

与以前相同的项目现在在 MongoDB 中

您可以在本章的源代码中找到一个使用 MongoDB 将提取的信息存储在文件夹03_mongodb中的蜘蛛。

数据库

类似于 MongoDB 解决方案,当使用 SQLite 数据库时,您必须分别在蜘蛛启动和结束时打开和关闭连接。

因为处理营养表变得太复杂(有 70 个字段,可以减少),所以我不会实现导出的这一部分。如果你感兴趣并想尝试一下,不要被我的方法吓倒!

首先,我定义了表 DDL 和 insert 语句。

sqlite_ddl = """
CREATE TABLE IF NOT EXISTS {} (
    item_code INTEGER PRIMARY KEY,
    product_name TEXT NOT NULL,
    url TEXT NOT NULL,
    product_image TEXT,
    product_origin TEXT,
    price_per_unit TEXT,
    unit TEXT,
    product_reviews INTEGER,
    rating REAL
)
"""

sqlite_insert = """
INSERT OR REPLACE INTO {}
    values (?, ?, ?, ?, ?, ?, ?, ?, ?)
"""

然后我写了代码。

class SQLitePipeline:
    def __init__(self, database_location, table_name):
        self.database_location = database_location
        self.table_name = table_name
        self.db = None

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            database_location=crawler.settings.get('SQLITE_LOCATION'),
            table_name=crawler.settings.get('SQLITE_TABLE', 'sainsburys'),
        )

    def open_spider(self, spider):
        self.db = sqlite3.connect(self.database_location)
        self.db.execute(sqlite_ddl.format(self.table_name))

    def close_spider(self, spider):
        if self.db:
            self.db.close()

    def process_item(self, item, spider):
        if type(item) == SainsburysItem:
            self.db.execute(sqlite_insert.format(self.table_name),
                            (
                                item['item_code'], item['product_name'], item['url'], item['product_image'],
                                item['product_origin'], item['price_per_unit'],
                                item['unit'] if hasattr(item, 'unit') else None,
                                int(item['product_reviews']) if hasattr(item, 'product_reviews') else None,
                                float(item['rating']) if hasattr(item, 'rating') else None
                            )
                            )
    self.db.commit()

如您所见,该类的工作方式与上一个示例中的 MongoDB 管道几乎相同。当你把它插入数据库时,有趣的部分就来了。因为我们有一些可为空的字段(以及不必存在于项目中的属性),所以我们必须确保在保存时不会遇到 Python 错误。

为了测试代码,您必须将管道添加到settings.py

ITEM_PIPELINES = {
    'sainsburys.pipelines.MongoDBPipeline': None
    'sainsburys.pipelines.SQLitePipeline': 300
}

现在您可以运行应用了。

scrapy crawl basic -s SQLITE_LOCATION=sainsburys.db

不要忘记添加带有-s设置标志的 SQLite 位置。没有这个你会得到一个异常。

您可以在本章的源代码中找到一个使用 SQLite 将提取的信息存储在文件夹04_sqlite中的蜘蛛。

自带出口商

如果您一直坚持下去,并且认为默认的导出解决方案不符合您的需要,那么这一节是最有趣的。

除了项目管道(我们为数据库连接实现的),您还可以定义自己的提要导出器。这些工作方式类似于内置的 CSV、XML 和 JSON 导出器,但适合您的口味。在本节中,我们将研究这两种方法,即使您已经为数据库存储编写了两个项目管道。

现在,您将实现一个 CSV 管道来正确处理nutritions字段:您将把字段追加到主内容中,而不是以纯文本形式编写整个字典。

这要求你像使用Beautiful Soup,一样将提取的项目存储在缓存中,因为你无法知道在所有项目中可能遇到的字段。记住:网站上有多个不同的营养表,这些营养表或多或少有相同的字段。

过滤重复项

你还记得 SQLite 管道。当我们将一个项目保存到数据库中时,我们在那里定义了INSERT OR REPLACE INTO。这是因为在网站的不同页面上可以找到重复的项目。

使用 SQLite 可以很容易地克服这个问题,但是使用其他导出方式会得到太多的数据,重复的数据总是不好的。当然,后处理(您的客户或数据挖掘算法)可以解决这个问题,但为什么不是您呢?

因为Scrapy是高度可扩展的,所以您将基于商品代码创建一个重复的过滤器。

from scrapy.exceptions import DropItem

class DuplicateItemFilter:
    def __init__(self):
        self.item_codes_seen = set()

    def process_item(self, item, spider):
        if item['item_code'] in self.item_codes_seen:
            raise DropItem("Duplicate item found: %s" % item['item_code'])

        self.item_codes_seen.add(item['item_code'])
        return item

前面的代码将看到的项目代码存储在内部的set,中,如果项目代码已经被看到,那么它将丢弃该项目。

要启用此管道,请将以下代码添加到您的settings.py文件中:

ITEM_PIPELINES {
    'sainsburys.pipelines.DuplicateItemFilter': 1
}

为管道设置一个较低的值可以确保重复项在到达时就被过滤掉,从而为其他任务节省大量工作。

您可以将这样的过滤器管道项目用于各种可能的过滤。如果您不希望某个项目出现在最终的导出中,那么您可以创建一个过滤器管道,将它添加到您的settings.py中,它会处理丢失的值。

无声地丢弃项目

如果您添加上一节中的条目过滤器并运行您的蜘蛛程序,您将会看到许多类似这样的条目:

2018-02-13 09:48:42 [scrapy.core.scraper] WARNING: Dropped: Duplicate item found: 7887890
{'image_urls': ['https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/74/0000000306874/0000000306874_L.jpeg'],
 'item_code': '7887890',
 'nutritions': {'Carbohydrate': '13.7g',
                'Energy': '664kJ',
                'Energy kcal': '158kcal',
                'Fat': '6.0g',
                'Fibre': '2.6g',
                'Mono-unsaturates': '3.5g',
                'Polyunsaturates': '1.5g',
                'Protein': '11.1g',
                'Salt': '0.91g',
                'Saturates': '0.5g',
                'Starch': '10.5g',
                'Sugars': '3.2g'},
 'price_per_unit': '£2.50',
 'product_image

': 'https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/74/0000000306874/0000000306874_L.jpeg',
 'product_name': "Sainsbury's Mediterranean Tuna Fishcakes, Taste the "
                 'Difference 300g',
 'product_origin': 'Produced in United Kingdom Produced using Yellowfin tuna '
                   'caught by hooks and lines in the Western Indian Ocean, '
                   'Eastern Indian Ocean, Western Central Pacific Ocean and '
                   'Eastern Central Pacific Ocean',
 'product_reviews': '4',
 'rating': '2.0',
 'url': 'https://www.sainsburys.co.uk/shop/gb/groceries/all-fish-seafood/sainsburys-mediterranean-tuna-fishcakes--taste-the-difference-300g'}

一种解决方案是将LOG_LEVEL提高到ERROR,但是使用这种方法,您最终会跳过其他警告,这些警告对于分析非预期行为很有用。

另一个解决方案是为丢弃的项目编写自己的日志格式化程序。

from scrapy import logformatter
import logging

class SilentlyDroppedFormatter(logformatter.LogFormatter):
    def dropped(self, item, exception, response, spider):
        return {
            'level': logging.DEBUG,
            'msg': logformatter.DROPPEDMSG,
            'args': {
                'exception': exception,
                'item': item,
            }
        }

要使用该格式化程序,您必须在settings.py文件中启用它。

LOG_FORMATTER = 'sainsburys.formatter.SilentlyDroppedFormatter'

在本章的源代码中,您可以使用文件夹05_item_filter中的重复项目过滤器找到一个蜘蛛。

修复 CSV 文件

你还记得目前导出的 CSV 文件有什么问题吗?是的,他们将营养信息以纯文本的形式写入 CSV 文件的一列。这并不理想。

除此之外,列的顺序可能在不同的运行中有所不同,因为它们存储在一个字典中。 6

您将实现一个项目管道,该管道在抓取过程中存储每个项目,并且仅在蜘蛛完成时导出。

class CsvItemPipeline:

    def __init__(self, csv_filename):
        self.items = []
        self.csv_filename = csv_filename

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            csv_filename=crawler.settings.get('CSV_FILENAME', 'sainsburys.csv'),
        )

    def open_spider(self, spider):
        pass

    def close_spider(self, spider):
        import csv
        with open(self.csv_filename, 'w', encoding='utf-8') as outfile:
            spamwriter = csv.DictWriter(outfile, fieldnames=self.get_fieldnames(), lineterminator="\n")
            spamwriter.writeheader()
            for item in self.items:
                spamwriter.writerow(item)

    def process_item(self, item, spider):
        if type(item) == SainsburysItem:
            new_item = dict(item)
            new_item.pop('nutritions')
            new_item.pop('image_urls')
            self.items.append({**new_item, **item['nutritions']})
        return item

    def get_fieldnames(self):
        field_names = set()
        for product in self.items:
            field_names.update(product.keys())
        return field_names

您可以看到,每一个被处理的条目都被转换成一个包含原始条目所有字段的新字典,然后删除nutritionsimage_urls,最后通过合并两个字典将原始的nutritions字典添加到这个新条目中,并将结果存储在内存中以备后用。

当蜘蛛完成时,所有不同的字段名都从所有条目中提取出来,并用作 CSV 头。Python 安装之间的顺序仍然不同。为了固定顺序(至少对于不是营养信息的标准属性),您可以定义一个基本属性列表,然后添加缺少的值,如下所示:

class CsvItemPipeline:
    fieldnames_standard = ['item_code', 'product_name', 'url', 'price_per_unit', 'unit', 'rating', 'product_reviews','product_origin', 'product_image']

    def get_fieldnames(self):
        field_names = set()
        for product in self.items:
            for key in product.keys():
                if key not in self.fieldnames_standard:
                    field_names.add(key)
        return self.fieldnames_standard + list(field_names)

和往常一样,您可以将这个管道添加到您的settings.py文件中。

ITEM_PIPELINES = {
    'sainsburys.pipelines.CsvItemPipeline': 800,
}

但是,使用这种方法,每次运行 spider 时都会写入 CSV 文件,即使导出为不同的格式或者不想导出。

为了解决这个问题,让我们实现一个 feed exporter。

您可以在本章的源代码中的文件夹06_csv_pipeline中找到一个使用这个 CSV 项目管道的蜘蛛。

CSV 项目出口商

提要导出类似于项目管道,但是您可以用一种通用的方式编写它们,并按需使用它们,而不需要更改settings.py文件。

当您使用-o输出文件将信息保存到 CSV、JSON 或 JSON-lines 文件时,您已经使用了 feed exporters(项目导出器的替代名称),并且Scrapy可以导出要使用的导出器,或者您可以提供-t选项并告诉Scrapy您想要使用哪个导出器。以下列表包含当前内置的提要导出器:

  • csv:将信息保存为 CSV 格式

  • json:将信息保存为 JSON

  • jsonlines:将信息保存为 JSON-lines

  • xml:将信息保存为 XML 格式

  • pickle:将信息保存为 Pickle 数据

  • marshal:以编组格式保存信息,这类似于 Pickle(特定于 Python ),但是没有任何机器架构问题

因为项目导出器类似于项目管道,它们一次只处理一个项目,所以我们必须像对待CsvItemPipeline类一样,将项目保存在内存中。基本上我们会重用已经写好的代码,重命名一些方法。

from scrapy.exporters import BaseItemExporter
import io
import csv

class CsvItemExporter(BaseItemExporter):
    fieldnames_standard = ['item_code', 'product_name', 'url', 'price_per_unit', 'unit', 'rating', 'product_reviews',
                           'product_origin', 'product_image']

    def __init__(self, file, **kwargs):
        self._configure(kwargs)
        if not self.encoding:
            self.encoding = 'utf-8'

        self.file = io.TextIOWrapper(file,
                                     line_buffering=False,
                                     write_through=True,
                                     encoding=self.encoding)
        self.items = []

    def finish_exporting(self):
        spamwriter = csv.DictWriter(self.file,
         fieldnames=self.__get_fieldnames(),
         lineterminator='\n')
        spamwriter.writeheader()
        for item in self.items:
            spamwriter.writerow(item)

    def export_item(self, item):
        new_item = dict(item)
        new_item.pop('nutritions')
        new_item.pop('image_urls')
        self.items.append({**new_item, **item['nutritions']})

    def __get_fieldnames(self):
        field_names = set()
        for product in self.items:
            for key in product.keys():
                if key not in self.fieldnames_standard:
                    field_names.add(key)
        return self.fieldnames_standard + list(field_names)

但是项目出口商有一个问题:他们不删除文件,他们附加到它。幸运的是,有一个解决方案:您可以使用truncate()方法将文件截断为 0 字节。扩展的构造函数如下所示:

def __init__(self, file, **kwargs):
    self._configure(kwargs)
    if not self.encoding:
        self.encoding = 'utf-8'

    self.file = io.TextIOWrapper(file,
                                 line_buffering=False,
                                 write_through=True,
                                 encoding=self.encoding)
    self.file.truncate(0)
    self.items = []

同样,我们必须将物品出口商添加到settings.py中,让Scrapy知道您可以使用另一个选项。

FEED_EXPORTERS = {
    'mycsv': 'sainsburys.exporters.CsvItemExporter'
}

在这里,您提供了mycsv作为饲料出口商的名称。这意味着,以后你可以使用-t选项和mycsv作为参数来调用蜘蛛。

scrapy crawl basic -o mycsv.csv -t mycsv

在本章的源代码中,您可以在文件夹07_csv_feed_exporter中找到一个使用刚刚创建的 feed exporter 的示例蜘蛛。

使用Scrapy缓存

尽管我认为使用缓存是一个高级的配置选项,但我已经为这个主题增加了一个额外的部分。这是因为它将您的执行时间提高了数倍,并且一旦您在本地缓存了网站,您就可以随心所欲地调整 scraper 脚本,而不会使目标服务器过载。

如果您想配置缓存,例如在开发脚本时,在Scrapy中有一些选项。当然,你可以像上一章一样编写自己的缓存,但是在你投入时间、精力和脑细胞来编写你的缓存之前,让我们看看现在有什么,我们可以利用什么。

Scrapy提供缓存。默认配置禁用缓存;这意味着,每次你请求时,每个页面都会被下载。但是正如你所知道的,有很多旋钮可以调节,你可以通过HTTPCACHE_ENABLED = True设置来启用缓存。

有三个现成的 HTTP 缓存选项可供您使用:

  • 文件系统存储

  • DBM 存储

  • LevelDB 存储

和往常一样,你也可以写你自己的解决方案;然而,我认为这个场景不太可能,因为 90%的用例可以被内置的解决方案覆盖。

我的默认缓存配置如下:

HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 0
HTTPCACHE_DIR = 'httpcache'
HTTPCACHE_IGNORE_HTTP_CODES = []

这样你就可以启用缓存,当你运行你的蜘蛛时,它会将你的文件系统上的每个请求-响应对存储在你的项目目录的.scrapy/httpcache文件夹中,从现在开始,当你重新运行你的蜘蛛时,它会使用这个缓存。这是调整脚本的理想方法:下载目标网站的快照,并使用它来微调项目提取。

如果您有任何不希望缓存的 HTTP 响应代码,您可以将它们添加到HTTPCACHE_IGNORE_HTTP_CODES列表中,例如:

HTTPCACHE_IGNORE_HTTP_CODES = [503, 418]

HTTPCACHE_EXPIRATION_SECS设置为0会使文件始终在缓存中。如果给它一个正值,旧的缓存文件将被丢弃。注意该设置需要以秒为单位的数值!

让我们看看缓存提供了什么!

存储解决方案

在本节中,我们将了解Scrapy为缓存提供的不同存储解决方案。开箱后,您有以下选项可用:

  • 文件系统存储

  • DBM 存储

  • LevelDB 存储

但是因为您可以轻松地扩展Scrapy,所以您可以编写自己的存储解决方案(例如使用一个定制的数据库,比如 MongoDB)。

如果你问我,我对基于文件系统的解决方案很满意。但是,如果您是按需运行的(例如在云或容器环境中),您可能会喜欢远程缓存服务,这很可能是基于数据库的。

文件系统存储

如果启用 HTTP 缓存,这是使用的默认解决方案。即使这是默认的,您也可以将下面一行添加到您的settings.py文件中:

HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'

使用这个存储选项,所有请求和响应都被下载并存储在一个文件夹中,该文件夹的名称对于这个 scraper 是唯一的,长度为 40 个字符。在这些文件夹中是标识请求和响应的所有信息,中间件需要这些信息来标识应该从缓存中提供服务的页面。

DBM 存储

要激活 DBM 7 存储器,只需添加(或替换,如果存在的话)。

HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.DbmCacheStorage'

默认设置是使用anydbm模块,但您可以使用HTTPCACHE_DBM_MODULE设置进行更改。

LevelDB 存储

您还可以使用 LevelDB 8 (一种快速的键值存储)作为您的缓存,但是在项目的开发阶段并不鼓励这样做,因为它只允许一个进程同时访问数据库。如果你只是运行你的蜘蛛,这是可以的,但是如果你想为你的项目打开Scrapy shell 并运行蜘蛛,你将会以一个错误结束。

要使用 LevelDB,您可以将settings.py文件中的HTTPCACHE_STORAGE更改为'scrapy.extensions.httpcache.LeveldbCacheStorage',并使用以下命令安装 LevelDB:

pip install leveldb

缓存策略

Scrapy 提供了两个默认的缓存策略:

  • 虚拟政策

  • RFC2616 政策

虚拟政策

虚拟策略是默认设置。在这里,存储每个请求及其响应,当再次看到相同的请求时,返回存储的响应。如果您正在测试您的蜘蛛,并希望同时重放运行,这是非常有用的。

因为这是默认策略,所以您不必向项目的settings.py文件添加任何内容。

RFC2616 政策

该策略知道缓存控制设置,旨在用于生产,以避免下载未更改的页面,节省带宽,并加速爬网。

要启用此策略,请将以下设置添加到您的settings.py文件中:

HTTPCACHE_POLICY = scrapy.extensions.httpcache.RFC2616Policy

知道缓存控制设置是什么意思?这意味着 scraper 根据 RFC2616 缓存规范工作。如果你很懒,不想读完整的规范,这里有一小段摘录Scrapy能为你做什么:

  • 如果网站提供了一个no-store响应,Scrapy不会尝试存储请求或响应。

  • 如果设置了no-cache指令,Scrapy不会从缓存返回响应,即使是最近下载的。

  • 它从AgeDate头计算当前年龄。

  • 它根据max-age指令、ExpiresLast-Modified响应头来计算刷新寿命。

然而,在撰写本书时,一些 RFC2616 合规性要求并未得到满足,例如:

  • Pragma: no-cache支持

  • Vary表头支持

  • 更新或删除后失效

下载图像

尽管这不是我们项目的要求,但是您将会遇到许多除了数据之外还必须下载图像的任务。幸运的是,Scrapy对于这个问题也有一个内置的解决方案。

对于本节,让我们扩展我们的需求,收集图片和物品。除了项目文件之外,这些图像将保存在您的文件系统中,但是您可以配置您的蜘蛛将下载的文件存储在亚马逊 S3 或谷歌云。

因为Scrapy使用 Pillow 来调整图像大小和生成缩略图,所以您必须在开始收集图像之前安装它。

pip install pillow

首先,将以下内容添加到您的settings.py文件中:

ITEM_PIPELINES = {
    'scrapy.pipelines.images.ImagesPipeline': 5
}

你必须告诉 Scrapy 把下载的图像保存在哪里。我使用项目中的图像文件夹。

IMAGES_STORE = 'images'

您提供给IMAGES_STORE的文件夹必须存在。

这两种设置的组合会激活图像管道,图像管道会下载文件并将它们储存在您的电脑硬盘上。

要将项目放入此管道,您必须添加

image_urls = Field()
images = Field()

敬你的Item。这是因为ImagesPipeline使用image_urls字段工作,并将结果图像添加到images字段。

在 Sainsbury's scraper 的例子中,我们必须将product_image重命名为image_urls,在SainsburysItem中添加images,并更改蜘蛛代码,用列表而不是 URL 填充image_urls

item['image_urls'] = [response.urljoin(response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])]

现在,如果你运行你的蜘蛛并保存结果(例如使用scrapy crawl basic -o images.jl),你将会在images/full文件夹中看到下载的图像,类似于图 4-3 所示。

img/460350_1_En_4_Fig3_HTML.jpg

图 4-3

Scrapy 运行时下载的图像

images.jl文件中的值被插入到项目的images字段中。示例值如下所示:

"images": [
    {
      "url": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg",
      "path": "full/4ae5a3a0dfa0fac7f3728d76b788716e8a2bc9fb.jpg",
      "checksum": "132512348d379f8365ca02082a16adf1"
    }
]

这不仅会告诉你文件在你的文件系统上是如何命名的,以及它是从哪里下载的,而且你还会得到一个校验和来验证你的文件系统上的映像是否真的与Scrapy下载的相同。

在前面的例子中,文件可以在images/full/4ae5a3a0dfa0fac7f3728d76b788716e8a2bc9fb.jpg下找到,如图 4-4 所示。

注意

您可以在本章的来源中的08_image_pipeline文件夹中找到本节的来源。

img/460350_1_En_4_Fig4_HTML.jpg

图 4-4

下载图像的示例

Scrapy 使用自己的算法来生成文件名。这意味着你可以遇到不同的文件名比我,如果你在你的电脑上运行蜘蛛。

使用Beautiful SoupScrapy

有时候你已经准备好了一个用Beautiful Soup创建的 HTML 提取器,你不想把它转换成Scrapy代码。或者你有一个团队成员是 ?? 的专家,她创建了提取代码;你只需要负责配置Scrapy

在这种情况下,您可以使用已经存在的代码,因为您可以集成Beautiful SoupScrapy

def parse_product_details_bs(self, response):
    item = SainsburysItem()

    from bs4 import BeautifulSoup
    soup = BeautifulSoup(response.text, 'lxml')
    h1 = soup.find('h1')
    if h1:
        item['product_name'] = h1.text.strip()

在前面的代码中,你可以看到Beautiful SoupScrapy与前一章代码子集的集成。我显式地使用了lxml来提高解析速度,但是您可以使用任何可用的解析器(顺便说一下,当您安装Scrapy时,lxml是现成可用的)。

有了这些信息,你可以重写蜘蛛程序来使用第三章中写的功能。您可以在本章的源代码中的09_beautifulsoup文件夹中找到一个示例解决方案。

记录

有时候,您更喜欢在抓取时在控制台中看到自定义消息。如果您将Scrapy的日志级别削减到INFO,但是您想要查看当前进程的更多信息,这是非常有用的。

每个蜘蛛都有一个记录器,你可以通过它的方法访问它。例如,记录响应的 URL 如下所示:

self.logger.info("URL: %s", response.url)

记录器使用您在settings.py中配置的相同日志级别。如果您在控制台上没有看到日志输出,您可以打开日志记录(将级别降低到DEBUG)。如果它仍然没有出现,那么您可以确定在运行时没有到达代码。

如果你想做标准的日志记录,而不在你的蜘蛛中使用日志记录器(例如,因为你在一个不同的文件中,在那里你不能访问一个蜘蛛),你可以使用Scrapylog模块(该模块已被否决,所以你不应该使用它)或者 Python 的内置logger模块。没有什么考虑;logger与在“标准”Python 应用中的工作方式相同。

(有点)高级配置

因为你可以打开你的Scrapy项目上的很多旋钮,所以我增加了一个部分让你开始尝试一些不同的组合。

这本书有篇幅限制;因此,我不会列出您可以切换的每个设置,只列出最常用的设置。更多设置,看看Scrapy的文档: https://doc.scrapy.org

LOG_LEVEL

在运行蜘蛛程序时,阅读这一章给了你很多输出。但是,您可以将信息限制在一个子集内。

默认情况下,Scrapy使用DEBUG日志级别进行输出。它记录了您可以从代码中获得的每一点信息,大多数情况下这太多了。

但是,您可以通过添加以下行来限制settings.py文件中的日志级别:

LOG_LEVEL = 'INFO'

这会将日志级别设置为仅记录信息以及警告和错误消息。这是因为日志记录级别的工作方式。每一项都有一个优先级,通过日志级别设置,您可以告诉应用“记录具有该优先级及更高优先级的项目”

您可以使用以下列表作为日志级别优先级的参考:

  1. 批评的

  2. 错误

  3. 警告

  4. 信息

  5. 调试

该列表包含Scrapy的日志级别设置。在开发过程中,调试是一个很好的设置,但是在一个正在运行的/实时的系统中,我更喜欢 INFO 或者有时 WARNING 作为日志级别。根据开发人员的不同,您可以使用这个级别获得适量的信息。

CONCURRENT_REQUESTS

你已经在本章开始看到了这个设置。顾名思义,您可以限制对一个网站的并发请求数量。

根据网站的不同,将这个数字调高一点或保持默认值是有意义的。这是因为网络操作(下载网站的代码)需要时间,当线程等待时,进程/应用处于空闲状态。在这种情况下,即使有 GIL,Python 也可以并行执行多个线程,因此当您的代码等待一个页面加载时,您可以下载更多。

然而,你不能永远转动旋钮。您的计算机也有它的极限,16 或 160 个并发请求并没有什么不同。我建议你在开发时从 1 个请求开始,然后使用默认设置 16。这对你有好处,因为你可以更快地获得所需的数据,这对目标网站也有好处,因为它不会被你淹没。

此外,有时目标网站会启用请求监控。这意味着,请求和它们的间隔被监控和评估,如果你的 IP 超过一个阈值,你会被禁止访问这个网站一段时间,有时是永远。因此,请对您的配置负责。

DOWNLOAD_DELAY

伴随并发请求,您也可以设置两次下载之间的延迟。下载延迟告诉蜘蛛在从相同的域或 IP 地址下载另一个页面之间应该等待多少秒(如果CONCURRENT_REQUESTS_PER_IP被设置为非零正数)。

该配置等待秒作为值,但是您也可以提供十进制值。

DOWNLOAD_DELAY = 0.125 # 125 milliseconds

此设置用于避免您的请求对目标服务器造成太大的冲击。有时,这种设置有助于避免检测和模仿类似人类的行为。

自动节流

以前,您已经看到了如何设置硬下载延迟和并发请求,以表现得像一个好公民。然而,使用这种方法,如果服务器繁忙,您可能会有许多请求等待完成。或者,如果服务器开始发回错误消息,这些消息会比200 OK响应更快地返回,后者每秒生成更多请求,因为Scrapy处理错误的速度更快。然而,在出现错误的情况下,scraper 应该发送更少的请求来帮助服务器从其故障状态(希望是暂时的)中恢复。

一个解决方案,也是另一种方法,是使用Scrapy的自动节流特性。默认情况下不启用此功能;您必须使用以下设置来启用它:

AUTOTHROTTLE_ENABLED = True

该设置背后的算法是根据服务器的响应时间来调整下载延迟。如果服务器很忙,它会稍后发送响应,而Scrapy会调整下载延迟以降低发送请求的频率。如果服务器没有困难,下载延迟会减少,更多的请求会发送到服务器。最重要的是:非 ?? 响应不会减少下载延迟。

您也可以为自动节流配置一些设置。例如,设置

AUTOTHROTTLE_START_DELAY = 15

你告诉Scrapy在两个请求之间等待 15 秒。根据服务器的响应时间,Scrapy可以减少或延长这个等待时间。如果延迟很大,Scrapy会增加延迟。然而,你可以给它一个最大值,它不会等待更长时间。

AUTOTHROTTLE_MAX_DELAY = 25

该设置告诉Scrapy最多等待 25 秒,直到下一个请求。

要获得所有请求及其响应的详细信息,可以启用自动节流调试。

AUTOTHROTTLE_DEBUG = True

COOKIES_ENABLED

你知道饼干。它们是存储在浏览器中的设置,每次请求服务器时都会进行交换。它们存储有关您的会话、浏览偏好或网站设置的信息。有时他们需要证明你正在使用浏览器。有时你必须避免子集,因为它们告诉服务器你没有使用浏览器。如果你在欧盟(EU)浏览,你会通过访问几乎所有的欧盟网站得到关于 cookies 的通知。这很烦人,但是要知道网站存储了你的浏览历史信息。

正如你所想,有时需要使用 cookies(例如需要登录的网站),但有时最好避免使用它们。

Scrapy中的默认设置是使用 cookies。这意味着每次目标 web 服务器返回一个 HTTP 参数Set-Cookie,它的值被Scrapy存储在内部,并随着每个新请求被发送回服务器。

您可以通过将以下配置添加到您的settings.py文件中来禁用此设置:

COOKIES_ENABLED = False

如果您想调试服务器和您的蜘蛛之间交换了哪些 cookies,您可以添加以下配置:

COOKIES_DEBUG = True

这将把每个发送的 cookie(请求中的Cookie头)和接收的 cookie(响应中的Set-Cookie头)记录到控制台或您指定的日志框架中。

摘要

在这一章中,你学习了网站抓取工具Scrapy。你用Scrapy实现了第二章需求的刮刀。你已经看到,你需要写的比你使用自制的蜘蛛少得多,你必须处理请求只是举一个例子。

您还学习了一些高级主题,如编写自己的中间件、管道和扩展,以及如果您打开配置面板上的一些旋钮会有什么结果。

现在你已经是一个十足的网站刮刀了。您拥有可以完成 75%的刮擦工作的工具。请随意停止阅读,但请记住,随着大量 JavaScript 网站的出现,这 75%正在减少,这些网站动态呈现数据。

下一章将讨论一个我很少使用的高级主题:用 JavaScript 处理网页。有不同的方法,我们将更深入一些,因为我将向您展示除 Selenium 之外的选项。如果你对“为什么”感兴趣?,“继续读!

五、处理 JavaScript

本章是关于处理利用 JavaScript 动态呈现信息的网站。

在前面的章节中,你已经看到了一个基本的网站抓取器加载网页的内容,并对源代码进行提取。如果包含了 JavaScript,它就不会被执行,页面中的动态信息也会丢失。

这很糟糕,至少在你需要动态数据的情况下。

抓取使用 JavaScript 的网站的另一个有趣的部分是,你可能需要点击或按钮才能进入正确的页面/获得正确的内容,因为这些操作调用了一系列 JavaScript 函数。

现在我会给你如何处理这些问题的选择。大多数时候,如果你用谷歌或其他引擎搜索互联网,你会发现 Selenium 是解决方案。然而,还有其他的选择,我会给你更多的见解。也许其他的选择会更符合你的需求。

逆向工程

第一种选择是高级开发人员的至少我觉得高级开发人员会做更多的逆向工程。

这里的想法是使用 Chrome 的 DevTools(或其他浏览器中的类似功能),启用 JavaScript,并监控XHR网络流,以找出哪些数据是从服务器请求的,并单独呈现。

有了目标端点(或者是一个GET或者是一个POST请求),您就可以看到提供哪些参数以及它们如何影响结果。

让我们看一个简单的例子:在kayak.com你可以搜索航班,因此也可以搜索机场。在这个简单的例子中,我们将对目的地搜索端点进行逆向工程,以提取一些信息,即使这些信息没有价值。

对于这些例子,我将使用 Chrome。这是因为我使用 Chrome 来完成所有的抓取任务。如果你知道如何使用开发者工具,它也可以和 Firefox 一起工作。

首先让我们转到kayak.com,打开 DevTools 窗口,在那里找到网络选项卡,如图 5-1 所示。

img/460350_1_En_5_Fig1_HTML.jpg

图 5-1

打开 DevTools 的 Kayak.com

正如你在图中看到的,我已经导航到了网络选项卡内的 XHR 选项卡,因为所有的 AJAX 和 XHR 调用都列在这里。

现在让我们点击网站上标有 To 的字段。并输入一个字母,例如S,观察 XHR 页签内右侧的数值,如图 5-2 所示。

img/460350_1_En_5_Fig2_HTML.jpg

图 5-2

机场的小名单

现在你在网站上得到一些可能的机场列表,但也有两个 XHR 请求。我们对以marvel开头的请求感兴趣:

www.kayak.com/mv/marvel?f=h&where=so&s=50&lc_cc=US&lc=en&v=v2&cv=5.

这是返回机场信息的请求。它有一些参数,我不知道它们是做什么的,如果改变,结果会受到什么影响,但我知道的是:

  • where是您正在寻找的密钥

  • s是搜索的类型;58是机场

  • lc是区域设置;您可以更改它,并在稍后获得不同的结果

  • v是版本;如果你选择v1而不是默认的v2,那么结果格式会有一点点不同

根据这些信息,我们能从中得到什么?我们了解了一些机场,以及一些关于如何对 JavaScript 进行逆向工程以及何时决定使用不同工具的想法。

在这个例子中,JavaScript 呈现是一个简单的 HTTP GET调用没有什么特别的,我打赌您已经知道如何提取这些端点传递的信息。是的,使用requests和漂亮的汤库或者 Scrapy 和一些Request物品。

回到这个例子:当您改变lc的值时,例如,将请求中的dees,您将得到不同的机场以及在您选择的地区中对这些机场的描述。这意味着 JavaScript 逆向工程不仅仅是找到您想要使用的正确调用,还需要一些思考。

关于逆向工程的思考

如果您发现自己有一个利用 HTTP 端点获取数据的搜索,您可以尝试弄清楚这个搜索是如何工作的。例如,尝试添加搜索表达式,而不是发送一些您期望交付结果的值。这样的表达式可以是*匹配全部,.+计算正则表达式,或者%如果它后面有某种 SQL 查询。

摘要

你看,有时 JavaScript 逆向工程是有回报的:你知道那些讨厌的 XHR 调用是简单的请求,你可以在你的脚本中覆盖它们。然而,有时 JavaScript 会做出更复杂的事情,比如在初始页面加载后呈现和加载数据。相信我,你不会想逆向工程的。

溅泼的量

Splash 1 是用 Python 编写的开源 JavaScript 渲染引擎。它是轻量级的,并与 Scrapy 顺利集成。

它被维护着,并且每隔几个月就会发布新的版本。

设置

Splash 的基本和最简单的用法是从开发人员那里获得一个 Docker 映像并运行它。这确保您拥有项目所需的所有依赖项,并且可以开始使用它。在本节中,我们将使用 Docker。

如果您还没有 Docker,请安装它。你可以在这里找到更多关于安装 Docker 的信息: https://docs.docker.com/manuals/

如果这样做了,您可以在控制台上执行以下命令来获得映像:

docker pull scrapinghub/splash
docker run -p 5023:5023 -p 8050:8050 -p 8051:8051 scrapinghub/splash

注意

在某些机器上,启动 Splash 需要管理员权限。例如,在我的 Windows 10 电脑上,我必须从管理员控制台运行 docker 容器。在类似 Unix 的机器上,您可能需要使用sudo来运行容器。

现在 Splash 正在 localhost:8050 上运行,它看起来应该如图 5-3 所示。

img/460350_1_En_5_Fig3_HTML.jpg

图 5-3

闪屏欢迎画面

现在你可以在右上角输入一个网址,点击Render me!来显示网站。如果您输入 http://sainsburys.co.uk ,您会得到与图 5-4 所示类似的结果(图像会有所不同)。

img/460350_1_En_5_Fig4_HTML.jpg

图 5-4

飞溅渲染塞恩斯伯里的

正如你所看到的,你从你抓取的页面中得到一个截图,在它的下面是一些统计数据和呈现相关网站的请求的时间。在页面底部你看到的是网站的源代码,如图 5-5 所示。

img/460350_1_En_5_Fig5_HTML.jpg

图 5-5

源飞溅

这个源代码是页面呈现后得到的代码。为了验证这一点,您可以打开一个交互式 Python shell 并使用requests获得网站。

>>> import requests
>>> r = requests.get('http://sainsburys.co.uk')
>>> r.text
'<!DOCTYPE html><html class="no-js" lang="en"><head><meta charset="utf-8"><title>Sainsbury\'s</title><meta name="description" content="Shop online at Sainsbury\'s for everything from groceries and clothing to homewares, electricals and more. We also offer a great range of financial services. Live well for less."><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="google-site-verification" content="soOzMsGig7xqxpwJQWd8qJkfOQQvL0j-ZS9fI9eSDiE"><link rel="shortcut icon" href="favicon.ico"><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=IE8"><script type="text/javascript" src="//service.maxymiser.net/cdn/sainsburyscoUK/js/mmcore.js"></script><!--[if lt IE 9]>\n    <script src="https://cdn.polyfill.io/v1/polyfill.min.js"></script>\n    <link rel="stylesheet" href="homepage/css/main_ie8.css?v=65f0de0508c75d5aac7501580ddf4e0a">\n    <![endif]--><!--[if gte IE 9]>\n    <link rel="stylesheet" href="homepage/css/main.css?v=2fadbf3f7bf0aa1b5e3613ec61ebabf7">\n    <![endif]--><link rel="stylesheet" href="homepage/css/main.css?v=2fadbf3f7bf0aa1b5e3613ec61ebabf7"><!--[if !IE]><!--><!--<![endif]--></head><body><script type="text/javascript">(function(a,b,c,d)
....

前面的示例结果只是摘录。如果您将这段代码保存到一个 HTML 文件中,并在浏览器中打开它,然后对 Splash 返回的源代码进行同样的操作,您将看到相同的页面。不同之处在于源代码:Splash 有更多的代码行,并且包含扩展的 JavaScript 函数。

一个生动的例子

要了解如何让 Splash 与动态网站(大量使用 JavaScript)一起工作,让我们看一个不同的例子。例如, http://www.protopage.com/ 会根据原型生成一个网页,您可以对其进行定制。如果您访问该站点,您必须等待几秒钟,直到页面呈现出来。

如果我们想要从这个站点抓取数据(也没有太多可用的,但是想象一下它有很多可以提供的),并且我们使用一个简单的工具(requests库,Scrapy)或者使用默认设置的 Splash,我们只得到告诉我们该页面当前被渲染的基础页面。

为了用 Splash 渲染站点,我修改了脚本(顺便说一下,它是用 Lua 编写的),并将等待时间增加到了三秒

function main(splash, args)
  assert(splash:go(args.url))
  assert(splash:wait(3))
  return {
    html = splash:html(),
    png = splash:png(),
    har = splash:har(),
  }
end

根据目标网站的网络速度和负载,三秒钟可能太短了。请随意为您的目标网站尝试不同的值来呈现页面。

现在这一切都好了,但是如何使用 Splash 来刮网站呢?

与 Scrapy 集成

Splash 开发人员推荐的方法是将该工具与 Scrapy 集成,因为我们使用 Scrapy 作为我们的抓取工具,所以我们将彻底了解它是如何实现的。

首先,我们需要使用pip安装 Splash Python 包。

pip install scrapy-splash

既然已经安装了这个库,我们需要启用与scrapy-splash一起交付的中间件。

DOWNLOADER_MIDDLEWARES = {
    'scrapy_splash.SplashCookiesMiddleware': 720,
    'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}

前进的数字并不完全是经验性的:Splash 中间件必须比HttpProxyMiddleware的顺序更高,后者的默认值是750。为了安全起见(例如 Scrapy 改变了这个代理中间件的默认值),我们可以像这样改变中间件的配置:

DOWNLOADER_MIDDLEWARES = {
    'scrapy_splash.SplashCookiesMiddleware': 720,
    'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}

然后,我们必须添加蜘蛛中间件,以节省磁盘空间和网络流量。这是可选的;如果你不这样做,重复的 Splash 参数会被存储在你的磁盘上并被发送到你的 Splash 服务器上(这在云中会很有趣见下一章关于这个主题的更多内容)。

SPIDER_MIDDLEWARES = {
    'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,
}

现在我们可以定义 Splash 工作所需的一些变量。其中之一是SPLASH_URL,它(显然)告诉中间件 Splash 实例可以在哪里呈现。

SPLASH_URL = 'http://localhost:8050/'

接下来的两个变量是因为 Scrapy 没有提供覆盖请求指纹的方法,这使得在脚本和 Splash 之间路由这些请求和响应有点复杂。然而,Splash 的开发者提出了一个解决方案,你可以使用他们的配置。

DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter'
HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage'

第二个变量指向一个缓存存储解决方案,它可以感知 Splash。如果您正在使用另一个自定义缓存存储,您必须调整它以与 Splash 配合使用。这需要你子类化前面提到的存储类,并用scrapy_splash.splash_request_fingerprint替换所有对scrapy.util.request.request_fingerprint的调用,以解决那些讨厌的变化指纹。

我们必须适应的最后一个变化是Request s 的用法:我们需要使用SplashRequest而不是默认的 Scrapy Request

现在让我们修改塞恩斯伯里的蜘蛛使用飞溅。

调整basic蜘蛛

在理想的情况下,您只需要像我们在上一节中所做的那样修改配置,所有的请求和响应都会经过 Splash,因为我们没有使用ScrapyRequest对象。

不幸的是,我们还需要在 scraper 的代码中进行更多的配置。如果你不相信我,就启动铲运机,不要有飞溅运行。

为了让我们的 scraper 在 Splash 中运行,我们需要修改每个请求调用来使用一个SplashRequest,并且每次我们发起一个新的请求时(启动 scraper 或者yield-调用一些response.follow)。

为了取得良好的开端,我们可以在脚本中添加以下函数:

from scrapy_splash import SplashRequest

def start_requests(self):
    for url in self.start_urls:
        yield SplashRequest(url, callback=self.parse)

这是蜘蛛通过飞溅操作的最低要求。参数说明了一切:URL是目标 URL,callback定义了要使用的方法。有一些选项可以配置 Splash 的行为方式,例如,等待一段时间来呈现网站。比方说,如果我们想在加载页面时多等一秒钟,我们可以这样修改SplashRequests的调用:

yield SplashRequest(url, callback=self.parse, args={'wait':1.0})

所以,我们很好,我们通过 Splash 呈现了第一个页面,但是其他调用,比如导航到详细页面或下一个页面呢?

为了适应这些,我稍微修改了 XPath 提取代码。到目前为止,我们使用的是response.follow方法,其中我们可以提供包含我们想要抓取的潜在下一个 URL 的选择器。

使用 Splash,我们需要提取这些 URL,并将它们作为参数提供给SplashRequest构造函数。我将使用parse方法作为例子。在第四章的结尾是这样的:

def parse(self, response):
    urls = response.xpath('//ul[@class="categories departments"]/li/a')

    for url in urls:
        yield response.follow(url, callback=self.parse_department_pages)

现在看起来是这样的:

def parse(self, response):
    urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()

    for url in urls:
        if url.startswith('http'):
            yield SplashRequest(url, callback=self.parse_department_pages)

我为url.startswith('http')添加了过滤器,以避免在url不包含绝对 URL 时可能发生的潜在错误。在某些情况下,您需要将 URL 与响应的基本 URL 连接在一起以获得目标域(因为url是该域的相对 URL)。下面是一个使用parse方法的例子。

def parse(self, response):
    urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()

    for url in urls:
        yield SplashRequest(response.urljoin(url), callback=self.parse_department_pages)

除了前面提到的,我做的一个改变是将蜘蛛重命名为splash

运行铲运机保持不变。

scrapy crawl splash -o splashburys.jl

在 scraper 完成后,您会在splashburys.jl文件中找到类似于以下摘录的记录。

{"url": "https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1153156&urlRequestType=Base&categoryId=312365&catalogId=10216&langId=44", "product_name": "Sainsbury's Venison Steak, Taste the Difference 250g", "product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/90/0000001442090/0000001442090_L.jpeg", "price_per_unit": "£7.50", "rating": "3.0", "product_reviews": "2", "item_code": "6450995", "nutritions": {"Energy ": "583kJ/", "Fat ": "2.6g", "of which saturates ": "0.9g", "mono-unsaturates ": "1.0g", "polyunsaturates ": "0.6g", "Carbohydrate ": "<0.5g", "of which sugars ": "<0.5g", "Fibre ": "<0.5g", "Protein ": "28.2g", "Sodium ": "0.05g", "Salt ": "0.13g"}, "product_origin": ""}
{"url": "https://www.sainsburys.co.uk/shop/gb/groceries/special-offers-314361-44/sainsburys-salmon-with-lemon-butter--taste-the-difference-145g", "product_name": "Sainsbury's Lightly Smoked Salmon with Wild Garlic Butter, Taste the Difference 145g", "product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/27/0000000301527/0000000301527_L.jpeg", "price_per_unit": "£3.00", "rating": "2.3333", "product_reviews": "3", "item_code": "7880107", "nutritions": {"Energy": "990kJ", "Energy kcal": "238kcal", "Fat": "16.9g", "Saturates": "4.6g", "Mono-unsaturates": "7.5g", "Polyunsaturates": "3.8g", "Carbohydrate": "1.6g", "Sugars": "1.2g", "Fibre": "0.6g", "Protein": "19.6g", "Salt": "0.63g"}, "product_origin": "Packed in United Kingdom Farmed in Scotland Produced from Farmed Scottish ( UK) Atlantic Salmon ( Salmo salar)"}

就是这样:我们把塞恩斯伯里的刮刀换成了飞溅式的。

Splash 不运行时会发生什么?

好问题,但我打赌你已经有答案了。scraper 不会做任何事情,它会退出并显示一条错误消息,该消息包含以下有价值的信息来识别这个特定的错误原因。

2018-04-27 16:07:19 [scrapy.core.scraper] ERROR: Error downloading <GET https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/ via http://localhost:8050/render.html>: Connection was refused by other side: 10061:

摘要

Splash 是一个很好的基于 Python 的网站渲染工具,可以很容易地与 Scrapy 集成。

一个缺点是,你必须通过一个有点复杂的过程或使用 Docker 手动安装它。这使得将它移植到云变得复杂(参见第六章的云解决方案),因此你应该只在本地抓取器中使用 Splash。然而,在本地,它可以给你一个巨大的好处,它与 Scrapy 无缝集成,使用 JavaScript 动态地抓取网站内容。

另一个缺点是速度。当我在本地电脑上使用 Splash 时,它每分钟只能勉强浏览 20 页。这对于我的口味来说太慢了,但有时我无法绕过它。

如果您在互联网上搜索关于网站抓取的内容,您将最常遇到关于 Selenium 的文章和问题。最初,我想把 Selenium 排除在这本书之外,因为我不喜欢它的方法;对我的口味来说有点笨拙。然而,因为它的流行,我决定增加一个关于这个工具的部分。也许你会在 Scrapy 脚本中嵌入一个基于 Selenium 的解决方案(例如,你已经有了一个 Selenium-scraper,但想扩展它),我想帮助你完成这个任务。

首先,我们将了解 Selenium 以及如何独立使用它,然后我们将把它添加到 Scrapy spider 中。

先决条件

要让 Selenium 在您的计算机上工作,您必须像大多数 Python 库一样通过 Python 包索引来安装它。

pip install selenium

要使用 Selenium 进行网站抓取,您需要一个 web 浏览器。这意味着您将看到配置好的 web 浏览器(比如 Firefox 或 Chrome)打开,加载网站,然后 Selenium 开始工作并提取您定义的脚本。

要启用 Selenium 和浏览器之间的链接,您必须安装特定的 web 驱动程序。

对于 Chrome,请访问 https://sites.google.com/a/chromium.org/chromedriver/home 。我下载了 2.38 版本。

对于 Firefox,需要安装 GeckoDriver。可以在 GitHub 上找到。我下载了 0.20.1 版本。

当您运行 Python 脚本时,这些驱动程序必须在PATH上。我把它们都放在一个文件夹中,因为在这种情况下,我只需添加这一个文件夹,我所有的 web 驱动程序都是可用的。

注意

这些 web 驱动程序需要特定的浏览器版本。例如,如果您已经安装了 Chrome 并下载了最新版本的 web 驱动程序,如果您错过了更新浏览器,您可能会遇到如下异常:

raise exception_class(message, screen, stacktrace) selenium.common.exceptions.SessionNotCreatedException: Message: session not created exception: Chrome version must be >= 65.0.3325.0

(Driver info: chromedriver=2.38.552522 (437e6fbedfa8762dec75e2c5b3ddb86763dc9dcb),platform=Windows NT 10.0.16299 x86_64)

基本用法

现在,为了验证一切是否正常,让我们编写一个简单的脚本,使用 Selenium 为我们打开 Sainsbury 的网站。

from selenium.webdriver import Chrome, Firefox

chrome = Chrome()
firefox = Firefox()

chrome.open()  # this opens a Chrome window
firefox.open()  # this opens a Firefox window

chrome.get('https://sainsburys.co.uk')  # navigates to the target website in Chrome
firefox.get('https://sainsburys.co.uk')  # navigates to the target website in Firefox

好了,让浏览器自动打开,导航到目标网站,挺好的。但是抓取信息呢?

因为我们有一个触手可及的网站(在浏览器中),所以我们可以像前面章节那样解析 HTML ,或者使用 Selenium 的产品从网页的 HTML 中提取数据。

我不会详细介绍 Selenium 的提取器,因为这会超出本书的范围,但是让我告诉您,通过使用 Selenium,您可以访问一组不同的提取函数,您可以在您的浏览器实例中使用这些函数。

与 Scrapy 集成

硒可以和 Scrapy 融合。您唯一需要做的就是正确配置 Selenium(在PATH上安装 web 驱动程序并安装浏览器),然后就可以开始玩了。

我喜欢做的是禁用浏览器窗口。这是因为每当我看到一个自动导航页面的浏览器窗口时,我都会分心,如果你把 Scrapy 和 Selenium 结合起来,我会疯掉。

除此之外,您将需要一个中间件,该中间件将在通过 Scrapy 直接发送调用之前拦截调用,并将使用 Selenium 而不是正常的请求。

一个基本的中间件应该是这样的:

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

from scrapy import signals
from scrapy.http import HtmlResponse
from scrapy.utils.python import to_bytes
from selenium import webdriver
from selenium.webdriver.firefox.options import Options

class SeleniumDownloaderMiddleware:

    def __init__(self):
        self.driver = None

    @classmethod
    def from_crawler(cls, crawler):
        middleware = cls()
        crawler.signals.connect(middleware.spider_opened, signals.spider_opened)
        crawler.signals.connect(middleware.spider_closed, signals.spider_closed)
        return middleware

    def process_request(self, request, spider):
        self.driver.get(request.url)
        body = to_bytes(self.driver.page_source)
        return HtmlResponse(self.driver.current_url, body=body, encoding='utf-8', request=request)

    def spider_opened(self, spider):
        options = Options()
        options.set_headless()
        self.driver = webdriver.Firefox(options=options)

    def spider_closed(self, spider):
        if self.driver:
            self.driver.close()
            self.driver.quit()
            self.driver = None

前面的代码使用 Firefox 作为默认浏览器,并在蜘蛛打开时以无头模式启动它。当 spider 关闭时,web 驱动程序也会关闭。

有趣的部分是当请求发生时:它被截取并通过浏览器路由,响应 HTML 代码被包装到一个HtmlResponse对象中。现在,您的蜘蛛获得了加载了 Selenium 的 HTML 代码,您可以使用它进行抓取。

羊血清硒

最近在 GitHub 发现了一个新鲜的项目,叫做 scrapy-selenium。这是一个方便的项目,让你安装并使用它来结合羊瘙痒病和硒的力量。我认为这个项目值得和你分享。

注意

因为这个项目是一个私人项目,它可能有问题。如果你发现一些不工作,随时提出这个项目的问题,开发人员会帮助你解决这个问题。如果没有,给我发一封电子邮件,我会看看我是否能给你一个解决方案,或者可能自己维护应用并提供更新的版本。

这个项目就像我们在上一节中实现的定制中间件一样工作:它拦截请求并使用 Selenium 下载页面。

先说配置。

from shutil import which

SELENIUM_DRIVER_NAME = 'firefox'
SELENIUM_DRIVER_EXECUTABLE_PATH = which('geckodriver')
SELENIUM_DRIVER_ARGUMENTS = ['-headless']

或者,你可以用 Chrome 代替 Firefox,但是在这种情况下,要注意--headless参数:它需要两个破折号。

from shutil import which

SELENIUM_DRIVER_NAME = 'chrome'
SELENIUM_DRIVER_EXECUTABLE_PATH = which('geckodriver')
SELENIUM_DRIVER_ARGUMENTS = ['--headless']
And we need the right middleware:
DOWNLOADER_MIDDLEWARES = {
    'scrapy_selenium.SeleniumMiddleware': 800
}

对于蜘蛛,我重用了 Splash 部分的代码,但是将使用的Request实现改为scrapy-selenium实现:

from scrapy_selenium import SeleniumRequest

我不得不修改构造函数调用以包含 URL 作为命名参数。

def start_requests(self):
    for url in self.start_urls:
        yield SeleniumRequest(url=url, callback=self.parse)

一定要把这些电话都换了。如果您错过了一个,您将得到如下错误:

  yield SeleniumRequest(url, callback=self.parse)
  File "c:\dev\__py_venv\scrapy\lib\site-packages\scrapy_selenium\http.py", line 29, in __init__
    super().__init__(*args, **kwargs)
TypeError: __init__() missing 1 required positional argument: 'url'

摘要

Selenium 是网站抓取器开发人员使用的替代工具,因为它支持通过浏览器进行 JavaScript 渲染。我们看到了一些关于如何将 Selenium 与 Scrapy 集成的解决方案,但是跳过了提取信息的内置方法。

同样,使用 Selenium 这样的外部工具会降低抓取速度,即使是在无头模式下。

美味汤的解决方案

到目前为止,我们一直在寻找可以将基于 JavaScript 的网站抓取与 Scrapy 集成的解决方案。但是有些项目使用 Beautiful Soup 也可以,不需要完整的 scraper 环境。

溅泼的量

Splash 也提供手动使用。这意味着,您可以选择让 Splash 呈现网站,并将源代码返回到您的代码中。我们可以利用它来制作一个简单的刮刀,上面写着漂亮的汤。

这里的想法是向 Splash 发送一个 HTTP 请求,提供要呈现的 URL(和任何配置参数)并获取结果,然后在这个结果上使用 Beautiful Soup,这是一个呈现的 HTML。

为了坚持前面的例子,我们将把 scraper 形式的第三章转换成一个利用 Splash 来呈现 Sainsbury 的页面的工具。

这里的想法是简单地调用 Splash 的 HTTP API 来呈现网页,而不是通过requests库获取页面。这意味着我们唯一的改变是在get_page函数中,在这里我们转发我们想要抓取的 URL 到 Splash。

def get_page(url):
    try:
        r = requests.get('http://localhost:8050/render.html?url=' + url)
        if r.status_code == 200:
            return BeautifulSoup(r.content, bs_parser)
    except Exception as e:
        pass
    return None

如您所见,我们调用 Splash 安装的render.html端点,并提供目标 URL 作为简单的GET参数。

如果您对POST请求更感兴趣,您可以将处理函数更改为如下所示:

def get_page(url):
    try:
        r = requests.post('http://localhost:8050/render.html', data='{'url': '+ url + '}')
        if r.status_code == 200:
            return BeautifulSoup(r.content, bs_parser)
    except Exception as e:
        pass
    return None

当然,我们也可以将硒元素整合到我们美丽的汤液中。它的工作方式和 Scrapy 一样。

同样,我不会使用内置的 Selenium 方法从网站中提取信息。我只使用 Selenium 来呈现页面和提取我需要的信息。

为此,我将在 scraper 中添加两个助手函数,它们在需要的地方初始化和分解 Selenium。

def initialize():
    global selenium
    if not selenium:
        selenium = Firefox()

def tear_down():
    global selenium
    if selenium:
        selenium.quit()
        selenium = None

为了安全起见,我会在每次我们要下载页面的时候,添加一个对initialize()的调用;然而,我将只在脚本完成时调用tear_down()

def get_page(url):
    initialize()
    try:
        selenium.get(url)
        return BeautifulSoup(selenium.page_source, bs_parser)
    except Exception as e:
        pass
    return None

摘要

尽管我们把重点放在 Scrapy 上,因为在我看来它目前是 Python 的网站抓取工具,你可以看到让 Scrapy 处理 JavaScript 的选项可以被添加到“普通的”漂亮的汤抓取器中。这让您可以选择继续使用您已经熟悉的工具!

摘要

在这一章中,我们看了一些利用 JavaScript 抓取网站的方法。我们查看了使用 web 浏览器执行 JavaScript 的主流 Selenium,然后进入了无头世界,在那里您不需要任何窗口来执行 JavaScript,这使得您的脚本可移植且更容易执行。

自然地,使用另一个工具来完成一些额外的渲染需要时间和开销。如果您不需要 JavaScript 渲染,那么创建您的脚本时不需要任何附加组件,如 Splash 或 Selenium。你将受益于速度的提高。

现在我们准备看看如何将我们的蜘蛛部署到云中!

六、云中的网站抓取

在本地运行网站抓取对于一次性任务和少量数据是很好的,在这种情况下你可以很容易地手动触发抓取。

然而,如果您想要重复任务和自动调度,您应该考虑其他解决方案,比如将您的蜘蛛部署到云中的某个地方或购买的服务器插槽中。

在这一章中,我们将看看虚拟服务器网络、云,以及如果你想在云中使用网站抓取,你有哪些选择。我将重点放在Scrapy上,因为它是网站抓取的工具,并且提供了与Scrapy配合使用的服务。

杂乱的云

名字告诉你一切:刺痒云 1 是一个云解决方案,你可以在那里部署你的刺痒蜘蛛。正如该网站所说:“把它想象成一个网页抓取的英雄。”

创建项目

当你到达 ScrapingHub 时,你会想要创建一个项目,因为你得到的页面是空的,如图 6-1 所示。

img/460350_1_En_6_Fig1_HTML.jpg

图 6-1

我公司的空报废网站概述

幸运的是,它很直观:我们必须点击右上角的绿色按钮。

我们将使用 Scrapy spiders,所以选择这个选项,如图 6-2 所示。

img/460350_1_En_6_Fig2_HTML.jpg

图 6-2

创建新项目

既然项目已经创建,我们必须将我们的蜘蛛上传到云中。有两种选择:通过命令行或克隆 GitHub 库,如图 6-3 所示。我们将使用命令行解决方案,因为我是一个书呆子,并且因为大多数时候我使用一些内部 Git 系统而不是 GitHub 来存储我的代码。

img/460350_1_En_6_Fig3_HTML.jpg

图 6-3

新项目和上传选项

如果您决定使用命令行,您有两个选择:直接部署或 Docker 映像。目前,我将继续使用简单部署版本。

部署您的蜘蛛

因为我使用基本的命令行部署,所以我转到蜘蛛的基本文件夹(scrapy.cfg文件所在的位置)并执行以下命令:

pip install shub
shub login
shub deploy

第一次运行shub deploy命令后,您会看到以下信息:

Saved to scrapinghub\sainsburys\scrapinghub.yml.

这个文件很重要,因为如果部署 Python 3 spider,就必须编辑这个文件。因为我关注的是 Python 3,所以我们将使用这种配置。让我们现在就这样做,并将下面一行添加到您的scrapinghub.yml中:

stack: scrapy:1.5-py3

这告诉 ScrapingHub 您想要使用运行在 Python 3 环境中的 Scrapy 版本1.5

更改之后,再次运行shub deploy来更新服务器上的蜘蛛。部署信息类似于图 6-4 所示。

img/460350_1_En_6_Fig4_HTML.jpg

图 6-4

部署信息和历史

开始并等待

展开后,在左上角你会看到你有一个蜘蛛,如图 6-5 所示。点击此链接(或蜘蛛部分的Dashboard菜单项)将导航到您的蜘蛛。

img/460350_1_En_6_Fig5_HTML.jpg

图 6-5

项目中的蜘蛛

点击basic蜘蛛(对我来说是唯一部署的蜘蛛)将会把你带到蜘蛛的页面,如图 6-6 所示。在这里你可以改变一些项目的具体设置,你可以运行蜘蛛。

img/460350_1_En_6_Fig6_HTML.jpg

图 6-6

蜘蛛细节

运行塞恩斯伯里的蜘蛛需要一些时间。但是你可以做,然后等待它的完成。运行完蜘蛛后,你会看到所有关于运行的信息,即使你在运行蜘蛛时有错误,如图 6-7 所示。

img/460350_1_En_6_Fig7_HTML.jpg

图 6-7

已完成的作业

正如您所看到的,您可以获得关于已加载项目、已发送请求和一些统计信息的信息。如果您单击作业编号,您将获得一些详细的统计数据,并且您可以查看运行提取的项目,如图 6-8 所示。

img/460350_1_En_6_Fig8_HTML.jpg

图 6-8

跑步的一些基本统计数据

访问数据

您可以通过某些方式访问提取的信息。最常见的访问是以某种格式下载你的结果,就像你从命令行运行Scrapy时导出它一样,如图 6-9 所示。

img/460350_1_En_6_Fig9_HTML.jpg

图 6-9

导出选项

正如你所看到的,你得到了一些选择,其中一个将适合你的项目需求。

另一种选择是发布数据集。这使得人们即使不知道你是如何收集数据的,也可以使用它。出版有三种风格:

  • Public :每个人都可以访问数据,不需要 ScrapingHub 账号,搜索引擎可以索引。

  • 受保护的:只有拥有 ScrapingHub 账户的用户才能访问这些数据。

  • Private :只有你的报废网络组织的成员才能访问这些数据。

如果你有自信的信息,那就用 private。ScrapingHub 对公开可用的数据集有一些问题,如果没有 ScrapingHub 帐户,你无法访问它们。

无论如何,如果您想要发布一个数据集,您必须为它提供一个描述和一个徽标以供公众使用。我同意这个描述,但是一个标志在我眼里太多了。当然,如果你看一下目录, 2 你就会明白为什么需要一个标志,如图 6-10 所示。

img/460350_1_En_6_Fig10_HTML.jpg

图 6-10

公共数据集目录

从这些数据集中,您可以像通过作业页面一样下载项目。请注意,您必须登录才能看到可用的数据集。

应用接口

ScrapingHub 提供了一个 API,您可以使用它以编程方式访问您的数据。让我们也检查一下这个选项。

我建议您使用scrapinghub Python 库,因为直接访问 API(例如使用curl)并不像文档中描述的那样工作。

pip install scrapinghub[msgpack]

现在,我们准备从一个简单的 Python 代码中访问我们的数据。我将使用交互式解释器,这样您就可以跟着做了。

>>> from scrapinghub import ScrapinghubClient
>>> apikey = 'YOUR-API-KEY'
>>> client = ScrapinghubClient(apikey)
>>>
>>> client.projects.list()
[310577]

登录后,第一步是获取我们项目的 ID。因为我只有一个项目,所以我只能得到一个 ID。你会得到一个不同的,所以相应地更换。

>>> project = client.get_project(310577)
>>> [j['key'] for j in project.jobs.list()]
['310577/1/4']

上面我们列出了与项目相关的所有工作。访问数据需要此作业密钥。如果您有长时间运行的作业,您可以使用作业的metadata信息的state标志:

>>> job = project.jobs.get('310577/1/4')
>>> job.metadata.get('state')
'finished'

现在我们有了感兴趣的作业,让我们检索所有项目。

>>> job.items.iter()
<generator object mpdecode at 0x000001DAC5092D58>
>>> for item in job.items.iter(count=1):
...    print(item)
...
{'url': 'https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1219376&urlRequestType=Base&categoryId=275324&catalogId=10100&langId=44', 'product_name': "Sainsbury's British Pork Mince 20% Fat 500g", 'product_image': 'https://www.sainsburys.co.uk/wcsstore7.27.110/ExtendedSitesCatalogAssetStoimg/productImages/93/0000000327893/0000000327893_L.jpeg', 'image_urls': ['https://www.sainsburys.co.uk/wcsstore7.27.110/ExtendedSitesCatalogAssetStoimg/productImages/93/0000000327893/0000000327893_L.jpeg'], 'price_per_unit': '£1.65', 'rating': '0.0', 'product_reviews': '0', 'item_code': '7916164', 'nutritions': {'Energy kJ': '1104', 'Energy kcal': '265', 'Fat': '18.9g', 'of which saturates': '6.5g', '- mono-unsaturates': '8.0g', '- polyunsaturates': '3.5g', 'Carbohydrate': '1.0g', 'of which sugars': '<0.5g', 'Fibre': '0.6g', 'Protein': '22.5g', 'Salt': '0.50g'}, 'product_origin': ", '_type': 'SainsburysItem'}

正如您在前面的代码中看到的,您可以在与作业相关的项目上获得一个生成器;我打印出了列表的第一个结果。如果您对提取了多少项感兴趣,您可以再次使用作业的metadata

>>> job.metadata.get('scrapystats')['item_scraped_count']
923

正如你所看到的,API 对于从网站上分离数据提取并在以后用脚本自动处理它们非常有用。

限制

免费账户有一些限制。让我们看看它们,即使你能很好地接受这些限制。

首先,有一个并发抓取的限制,这意味着你一次只能运行一个蜘蛛。对于初学者来说,这不是问题,因为您很少想要并行运行蜘蛛。如果您的客户数量在增长,那么您可能会遇到需要并行运行来更快地收集数据的情况。

第二个限制是没有周期性的作业,如果您有需要经常运行的作业,这个限制可能会很烦人。你可以配置它们,但是它们不会运行,除非你订阅一个付费计划,目前开始每月 9 美元。

第三大限制是数据存储。您的刮擦结果仅存储七天。过了这段时间,你的抓取结果就成为历史了。如果您订阅了付费计划,您可以将此期限延长至 120 天。但是如果有自动数据处理(通过 API),或者将数据存储在数据库中,就可以克服这个问题。

摘要

在我看来,如果你有更大的 Scrapy 项目,ScrapingHub 是理想的解决方案,因为它提供了一个易于使用的平台来建立和评估你的项目。Python 库的存在可以访问抓取的数据(也可以与蜘蛛进行交互),这使得自动化数据提取和处理这些数据变得非常方便。免费计划给了你很多,帮助你开始。

皮顿 Anywhere

好吧,当然除了报废 Hub 还有其他选择。一个是 PythonAnywhere, 3 一个平台解决方案,使你能够在云中运行 Python。它有一个免费的“初学者”帐户,对出站互联网访问、CPU 和内存使用有限制,但它将符合我们的目的。

在这一节中,我们将创建一个用 Scrapy 编写的简单的 scraper,并将它上传到云中。

示例脚本

我们将使用不同的 Scrapy 脚本,因为免费帐户在网站上有限制,你可以从你的脚本和 Sainsbury 的没有列出。

因此,我选择了一个网站,并创建了一个简单的刮刀,将提取柏林的景点和景点的名称和描述。

PythonAnywhere 配置

现在是时候配置我们的 PythonAnywhere 帐户并在云中获取脚本了。我将在这里为您一步步描述当前版本的 PythonAnywhere 解决方案,因为它是在 2018 年 4 月 3 日

**使用以下命令安装 Scrapy:

pip install --user scrapy

--user标志是必需的,因为不允许修改全局 Python 包的安装,也不能将Scrapy添加到其中。

现在我们已经为我们的铲运机设置好了一切。要验证这一点,您可以执行以下命令:

~ $ scrapy version
Scrapy 1.5.0

嗯,安装 Scrapy 及其所有依赖项会消耗每天分配的 CPU 容量。如果你想继续这一章的例子,你可以,但是在一个免费的 PythonAnywhere 帐户上它会变得很慢。

上传脚本

有一些方法可以让你的脚本升级到 PythonAnywhere:

  • 从 Github / BitBucket 克隆

  • 作为 ZIP 文件上传(实际上,你可以一个文件一个文件上传,但是 ZIP 更方便)

  • SFTP 和 Rsync 支付帐户

我使用 ZIP 方法:压缩 Scrapy 项目;从 PythonAnywhere 的“文件”菜单上传;然后使用unzip命令将其解压缩,如图 6-11 和 6-12 所示。

img/460350_1_En_6_Fig12_HTML.jpg

图 6-12

解压软件包

img/460350_1_En_6_Fig11_HTML.jpg

图 6-11

berlin.zip文件被上传到我的个人文件夹

现在该文件夹在仪表盘的文件部分下,如图 6-13 所示。

img/460350_1_En_6_Fig13_HTML.jpg

图 6-13

包含berlin文件夹的文件

运行脚本

现在我们可以像在本地一样从 Bash 控制台运行脚本,如图 6-14 所示。因为我们有一个文件系统,我们也可以将结果导出为文件。例如,要获取 JSON-lines 文件中的景点,我们可以执行以下命令:

img/460350_1_En_6_Fig14_HTML.jpg

图 6-14

运行蜘蛛

scrapy crawl sights -o sights.jl

当脚本完成时,如图 6-15 所示,一个新文件被写入项目的文件夹中。如果已经有一个文件,Scrapy 会把新的信息附加到它上面,而不是从头开始重新创建文件。记住这个!

img/460350_1_En_6_Fig15_HTML.jpg

图 6-15

蜘蛛完成了文件的前三行

您可以通过文件页面访问该文件。您可以在这里下载文件,但也可以在您的浏览器中进行编辑,如图 6-16 所示。

img/460350_1_En_6_Fig16_HTML.jpg

图 6-16

下载导出的文件

这只是手动工作…

目前,我们只手动运行了脚本。但这不是我们在云中部署 scraper 时所寻求的方式。

解决方案是添加一个调度程序,它在定义的时间自动启动 scraper。

纪念

如果你正在使用一个调度程序,确保你删除了已经存在的导出文件,因为 Scrapy 不会覆盖它。如果您使用的是自定义项目导出器,那么您可能已经重写了文件的内容。

一种选择是在 Python Anywhere 上设置一个任务。在这里,您必须配置要执行的命令。因为我们知道我们的命令,我们可以将它添加到调度器中,如图 6-17 所示。

img/460350_1_En_6_Fig17_HTML.jpg

图 6-17

使用三段脚本创建任务

在预定时间结束后,您可以访问包含控制台输出的任务日志,可能还有一些错误,如图 6-18 所示。

img/460350_1_En_6_Fig18_HTML.jpg

图 6-18

访问任务的日志

第二种方法是前一种方法的扩展版本:我们创建一个脚本来执行前面定义的命令序列,并将调度程序指向这个脚本。

第一步是创建一个脚本,该脚本更改到项目的文件夹并执行蜘蛛(确保您指向您的主文件夹!).

#!/bin/bash
cd /home/GHajba/berlin
rm sights.jl
scrapy crawl sights -o sights.jl

前面的脚本与我们之前提供给任务的脚本相同,但是我们将每个命令放在自己的行上,这使得它可读。

我使用 PythonAnywhere 的编辑器在我的浏览器中创建了这个文件,如图 6-19 所示。

img/460350_1_En_6_Fig19_HTML.jpg

图 6-19

创建新文件

警告

如果你使用的是 Windows 电脑,文件编辑器会给你的文件添加 Windows 行尾。要解决这个问题(并能够在 Bash shell 中运行脚本),请从控制台执行以下命令:sed -i -e 's/\r$//' berlin_scheduler.sh

由于免费账户对计划任务的数量有限制(你只能有一个),我们将删除之前创建的账户,创建一个新账户,新账户将只执行之前创建的berlin_scheduler.sh,如图 6-20 所示。

img/460350_1_En_6_Fig20_HTML.jpg

图 6-20

创建新的调度程序

任务可用后,您可以访问任务日志,其中包含与之前相同的信息。

在数据库中存储数据?

将提取的结果存储在数据库中是一个可行的选择。因为我们现在在云中使用 PythonAnywhere,所以最好有云存储比如 mLab,这是一个基于云的 MongoDB。

问题是,一个免费帐户只允许 HTTP 和 HTTPS 连接到服务器。这意味着,即使您用 mLab 设置了一个 Mongo 数据库,您也不能创建一个连接来存储数据。

然而,Python Anywhere 为免费用户提供 MySQL。这意味着,您可以存储提取的信息,而不必将所有内容都存储在一个文件中。

让我们看看如何配置 MySQL 并将提取的数据存储在数据库中。

首先,让我们创建一个数据库。你可以在数据库上这样做。我给矿取名berlinsights,如图 6-21 。

img/460350_1_En_6_Fig21_HTML.jpg

图 6-21

创建新的数据库很容易

现在,我们必须配置我们的 Scrapy 项目,以便能够连接到数据库并将信息写入给定的表中。

我们将使用一个简单的项目管道,将景点插入到数据库中。

我们需要数据库表。我使用以下脚本通过数据库控制台创建了它:

img/460350_1_En_6_Fig22_HTML.jpg

图 6-22

使用控制台创建表

create table berlinsights( name varchar(1024) not null, description varchar(4096));

如图 6-22 所示:确保你使用的是正确的数据库!如果你不确定你在哪个数据库上运行,输入status,它会告诉你你在哪个数据库上运行。

如果您忘记了数据库密码,只需在数据库仪表板上设置一个新密码。

现在我们可以创建我们的中间件了。我们将使用pymysql库。

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

import pymysql.cursors

insert_template = """INSERT INTO berlinsights (name, description) VALUES (%s, %s)"""

class BerlinMySQLPipeline(object):

    def process_item(self, item, spider):
        connection = pymysql.connect(host='GHajba.mysql.pythonanywhere-services.com',
                                     user='GHajba',
                                     password='YourDbPassHere',
                                     db='GHajba$berlinsights',
                                     charset='utf8mb4',
                                     cursorclass=pymysql.cursors.DictCursor)
        try:
            with connection.cursor() as cursor:
                cursor.execute(insert_template, (item['name'], item['description']))
                connection.commit()
        finally:
            connection.close()

        return item

过程示例使用我的数据库,所以请确保您正在填写您的数据!因为这个 MySQL 数据库是一个 PythonAnywhere 服务,所以只有在部署了 scraper 之后才能测试连接。

同样是,这个脚本不验证数据库中是否已经有条目。如果您运行它两次,您将得到每个条目的副本。随意修改脚本来过滤或更新已经存在的条目。

运行蜘蛛后,我们可以验证信息是否在数据库中,如图 6-23 所示。

img/460350_1_En_6_Fig23_HTML.jpg

图 6-23

验证控制台中的数据

如果还没有安装pymysql,你可以用下面的命令来完成:

pip install --user pymysq

摘要

Python Anywhere 免费为您提供云托管和调度;但是,它对免费计划的传出连接有限制。这使得它只对练习有价值。另一方面,如果你每月支付 5 美元,你就可以获得一个升级账户,在那里你不必将你的垃圾限制在白名单中。 4

Beautiful Soup呢?

PythonAnywhere 是 Python 的云平台。这意味着你不仅可以在那里运行刺痒的蜘蛛,还可以运行刮刀。这就是我们要简单看的。

方法和以前一样:我们将提取相同的景点,但使用美丽的汤。

幸运的是,requestsbeautifulsoup4库已经安装在主机上,所以您不需要安装任何东西。

第一步是编写和上传脚本。事实上,我已经写了代码,但这并不意味着你不能自己做。一如既往:我的代码示例只是一个解决方案,并且有许多通向最终目标的路径。

import requests
from bs4 import BeautifulSoup

bs_parser = 'html.parser'

def get_page(url):
    try:
        r = requests.get(url)
        if r.status_code == 200:
            return BeautifulSoup(r.content, bs_parser)
    except Exception as e:
        pass
    return None

def get_sights():
    soup = get_page('https://www.berlin.de/en/attractions-and-sights/')
    if not soup:
        return

    for sight in soup.select('div[class*="teaser"]'):
        h3 = sight.find('h3')
        if not h3:
            continue

        a = h3.find('a')
        if not a:
            continue
        name = a.text
        if not name:
            continue

        description = "
        div = sight.find('div', class_="inner")
        if div:
            p = div.find('p')
            if p:
                description = p.text
        if not description:
            continue
        yield (name, description)

if __name__ == '__main__':
    with open('berlin_sights.jl', 'w') as outfile:
        for sight in get_sights():
            outfile.write('{' + '"name": "{}", "description": "{}"'.format(sight[0], sight[1]) + '}\n')

上传后,我们可以运行脚本。运行脚本就像在普通的终端窗口中一样。

python3 berlin.py

过程完成后,您可以访问berlin_sights.jl文件中的结果。第一个条目如下所示:

{"name": "Academy of Arts", "description": "The Academy of Arts is the oldest and most prestigious cultural institution in Germany. Its tasks are to promote contemporary artistic positions

and to safeguard cultural heritage. more »"}

调度脚本的工作方式与 Scrapy 脚本的工作方式相同,所以我不会详细介绍。如果您使用的是Beautiful Soup,请将 PythonAnywhere 视为您的远程 Python 终端。

摘要

在本章中,我们讨论了如何在云中运行服务器的选项。这是解决方案,如果你不想每次都手动运行你的提取器,或者你不想让他们在你的电脑上运行,因为他们吃了很多资源,你的电脑变得缓慢了很长一段时间。

我们关注了 Scraping Hub,它提供了针对Scrapy的服务,这使它变得独一无二。除此之外,他们也是 Splash 的开发者,他们有一个如何在云中运行基于 Splash 的蜘蛛的解决方案。

作为替代方案,我们研究了 PythonAnywhere,在那里可以上传 Python 脚本并执行它们。这不仅对Scrapy有用,对使用漂亮汤的脚本也有用,这也将你的简单抓取器移到了云中。

**

posted @ 2024-08-09 17:40  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报