Python-Web-爬虫秘籍-全-

Python Web 爬虫秘籍(全)

原文:zh.annas-archive.org/md5/6ba628f13aabe820a089a16eaa190089

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

互联网包含大量数据。这些数据既通过结构化 API 提供,也通过网站直接提供。虽然 API 中的数据高度结构化,但在网页中找到的信息通常是非结构化的,需要收集、提取和处理才能有价值。收集数据只是旅程的开始,因为这些数据还必须存储、挖掘,然后以增值形式向他人展示。

通过这本书,您将学习从网站收集各种信息所需的核心任务。我们将介绍如何收集数据,如何执行几种常见的数据操作(包括存储在本地和远程数据库中),如何执行常见的基于媒体的任务,如将图像和视频转换为缩略图,如何使用 NTLK 清理非结构化数据,如何检查几种数据挖掘和可视化工具,以及构建基于微服务的爬虫和 API 的核心技能,这些技能可以并且将在云上运行。

通过基于配方的方法,我们将学习独立的技术,以解决不仅仅是爬取,还包括数据操作和管理、数据挖掘、可视化、微服务、容器和云操作中涉及的特定任务。这些配方将以渐进和整体的方式建立技能,不仅教授如何执行爬取的基础知识,还将带您从爬取的结果到通过云向他人提供的服务。我们将使用 Python、容器和云生态系统中的常用工具构建一个实际的网络爬虫服务。

这本书适合谁

这本书适合那些想要学习使用爬取过程从网站提取数据以及如何使用各种数据管理工具和云服务的人。编码将需要基本的 Python 编程语言技能。

这本书还适合那些希望了解更大的工具生态系统,用于检索、存储和搜索数据,以及使用现代工具和 Python 库创建数据 API 和云服务的人。您可能还会使用 Docker 和 Amazon Web Services 在云上打包和部署爬虫。

本书涵盖内容

第一章,“开始爬取”,介绍了网页爬取的几个概念和工具。我们将研究如何安装并使用工具,如 requests、urllib、BeautifulSoup、Scrapy、PhantomJS 和 Selenium 进行基本任务。

第二章,“数据获取和提取”,基于对 HTML 结构的理解以及如何查找和提取嵌入式数据。我们将涵盖 DOM 中的许多概念以及如何使用 BeautifulSoup、XPath、LXML 和 CSS 选择器查找和提取数据。我们还简要介绍了 Unicode / UTF8 的工作。

第三章,“处理数据”,教你如何以多种格式加载和操作数据,然后如何将数据存储在各种数据存储中(S3、MySQL、PostgreSQL 和 ElasticSearch)。网页中的数据以各种格式表示,最常见的是 HTML、JSON、CSV 和 XML。我们还将研究使用消息队列系统,主要是 AWS SQS,来帮助构建强大的数据处理管道。

第四章,“处理图像、音频和其他资产”,研究了检索多媒体项目的方法,将它们存储在本地,并执行诸如 OCR、生成缩略图、制作网页截图、从视频中提取音频以及在 YouTube 播放列表中找到所有视频 URL 等多项任务。

第五章,爬取-行为准则,涵盖了与爬取的合法性有关的几个概念,以及进行礼貌爬取的实践。我们将研究处理 robots.txt 和站点地图的工具,以尊重网络主机对可接受行为的要求。我们还将研究爬行的几个方面的控制,比如使用延迟、包含爬行的深度和长度、使用用户代理以及实施缓存以防止重复请求。

第六章,爬取挑战与解决方案,涵盖了编写健壮爬虫时面临的许多挑战,以及如何处理许多情况。这些情况包括分页、重定向、登录表单、保持爬虫在同一域内、请求失败时重试以及处理验证码。

第七章,文本整理和分析,探讨了各种工具,比如使用 NLTK 进行自然语言处理,以及如何去除常见的噪音词和标点符号。我们经常需要处理网页的文本内容,以找到页面上作为文本一部分的信息,既不是结构化/嵌入式数据,也不是多媒体。这需要使用各种概念和工具来清理和理解文本。

第八章,搜索、挖掘和可视化数据,涵盖了在网上搜索数据、存储和组织数据,以及从已识别的关系中得出结果的几种方法。我们将看到如何理解维基百科贡献者的地理位置,找到 IMDB 上演员之间的关系,以及在 Stack Overflow 上找到与特定技术匹配的工作。

第九章,创建一个简单的数据 API,教会我们如何创建一个爬虫作为服务。我们将使用 Flask 为爬虫创建一个 REST API。我们将在这个 API 后面运行爬虫作为服务,并能够提交请求来爬取特定页面,以便从爬取和本地 ElasticSearch 实例中动态查询数据。

第十章,使用 Docker 创建爬虫微服务,通过将服务和 API 打包到 Docker 集群中,并通过消息队列系统(AWS SQS)分发请求,继续扩展我们的爬虫服务。我们还将介绍使用 Docker 集群工具来扩展和缩减爬虫实例。

第十一章,使爬虫成为真正的服务,通过充实上一章中创建的服务来结束,添加一个爬虫,汇集了之前介绍的各种概念。这个爬虫可以帮助分析 StackOverflow 上的职位发布,以找到并比较使用指定技术的雇主。该服务将收集帖子,并允许查询以找到并比较这些公司。

为了充分利用本书

本书中所需的主要工具是 Python 3 解释器。这些配方是使用 Anaconda Python 发行版的免费版本编写的,具体版本为 3.6.1。其他 Python 3 发行版应该也能很好地工作,但尚未经过测试。

配方中的代码通常需要使用各种 Python 库。这些都可以使用pip进行安装,并且可以使用pip install进行访问。在需要的地方,这些安装将在配方中详细说明。

有几个配方需要亚马逊 AWS 账户。AWS 账户在第一年可以免费使用免费层服务。配方不需要比免费层服务更多的东西。可以在portal.aws.amazon.com/billing/signup上创建一个新账户。

几个食谱将利用 Elasticsearch。GitHub 上有一个免费的开源版本,网址是github.com/elastic/elasticsearch,该页面上有安装说明。Elastic.co 还提供了一个完全功能的版本(还带有 Kibana 和 Logstash),托管在云上,并提供为期 14 天的免费试用,网址是info.elastic.co(我们将使用)。还有一个 docker-compose 版本,具有所有 x-pack 功能,网址是github.com/elastic/stack-docker,所有这些都可以通过简单的docker-compose up命令启动。

最后,一些食谱使用 MySQL 和 PostgreSQL 作为数据库示例,以及这些数据库的几个常见客户端。对于这些食谱,这些都需要在本地安装。 MySQL Community Server 可在dev.mysql.com/downloads/mysql/上找到,而 PostgreSQL 可在www.postgresql.org/上找到。

我们还将研究创建和使用多个食谱的 docker 容器。 Docker CE 是免费的,可在www.docker.com/community-edition上获得。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址是github.com/PacktPublishing/Python-Web-Scraping-Cookbook。我们还有其他代码包,来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“这将循环遍历多达 20 个字符,并将它们放入sw索引中,文档类型为people

代码块设置如下:

from elasticsearch import Elasticsearch
import requests
import json

if __name__ == '__main__':
    es = Elasticsearch(
        [

任何命令行输入或输出都按如下方式编写:

$ curl https://elastic:tduhdExunhEWPjSuH73O6yLS@7dc72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243

粗体:表示一个新术语、一个重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会出现在文本中。这是一个例子:“从管理面板中选择系统信息。”

警告或重要说明会出现在这样的地方。提示和技巧会出现在这样的地方。

第一章:开始爬取

在本章中,我们将涵盖以下主题:

  • 设置 Python 开发环境

  • 使用 Requests 和 Beautiful Soup 爬取 Python.org

  • 使用 urllib3 和 Beautiful Soup 爬取 Python.org

  • 使用 Scrapy 爬取 Python.org

  • 使用 Selenium 和 PhantomJs 爬取 Python.org

介绍

网上可用的数据量在数量和形式上都在持续增长。企业需要这些数据来做决策,特别是随着需要大量数据进行训练的机器学习工具的爆炸式增长。很多数据可以通过应用程序编程接口获得,但同时也有很多有价值的数据仍然只能通过网页抓取获得。

本章将重点介绍设置爬取环境的几个基本原理,并使用行业工具进行基本数据请求。Python 是本书的首选编程语言,也是许多进行爬取系统构建的人的首选语言。它是一种易于使用的编程语言,拥有丰富的工具生态系统,适用于许多任务。如果您使用其他语言进行编程,您会发现很容易上手,也许永远不会回头!

设置 Python 开发环境

如果您以前没有使用过 Python,拥有一个可用的开发环境是很重要的。本书中的示例将全部使用 Python,并且是交互式示例的混合,但主要是作为脚本实现,由 Python 解释器解释。这个示例将向您展示如何使用virtualenv设置一个隔离的开发环境,并使用pip管理项目依赖。我们还会获取本书的代码并将其安装到 Python 虚拟环境中。

准备工作

我们将专门使用 Python 3.x,特别是在我的情况下是 3.6.1。虽然 Mac 和 Linux 通常已安装了 Python 2 版本,而 Windows 系统没有安装。因此很可能需要安装 Python 3。您可以在 www.python.org 找到 Python 安装程序的参考资料。

您可以使用python --version检查 Python 的版本

pip已经随 Python 3.x 一起安装,因此我们将省略其安装说明。此外,本书中的所有命令行示例都在 Mac 上运行。对于 Linux 用户,命令应该是相同的。在 Windows 上,有替代命令(如 dir 而不是 ls),但这些替代命令将不会被涵盖。

如何做...

我们将使用pip安装许多包。这些包将被安装到一个 Python 环境中。通常可能会与其他包存在版本冲突,因此在跟着本书的示例进行操作时,一个很好的做法是创建一个新的虚拟 Python 环境,确保我们将使用的包能够正常工作。

虚拟 Python 环境是用virtualenv工具管理的。可以用以下命令安装它:

~ $ pip install virtualenv
Collecting virtualenv
 Using cached virtualenv-15.1.0-py2.py3-none-any.whl
Installing collected packages: virtualenv
Successfully installed virtualenv-15.1.0

现在我们可以使用virtualenv。但在那之前,让我们简要地看一下pip。这个命令从 PyPI 安装 Python 包,PyPI 是一个拥有成千上万个包的包存储库。我们刚刚看到了使用 pip 的 install 子命令,这可以确保一个包被安装。我们也可以用pip list来查看当前安装的所有包:

~ $ pip list
alabaster (0.7.9)
amqp (1.4.9)
anaconda-client (1.6.0)
anaconda-navigator (1.5.3)
anaconda-project (0.4.1)
aniso8601 (1.3.0)

我截取了前几行,因为安装了很多包。对我来说,安装了 222 个包。

也可以使用pip uninstall命令卸载包。我留给您去尝试一下。

现在回到virtualenv。使用virtualenv非常简单。让我们用它来创建一个环境并安装来自 github 的代码。让我们一步步走过这些步骤:

  1. 创建一个代表项目的目录并进入该目录。
~ $ mkdir pywscb
~ $ cd pywscb
  1. 初始化一个名为 env 的虚拟环境文件夹:
pywscb $ virtualenv env
Using base prefix '/Users/michaelheydt/anaconda'
New python executable in /Users/michaelheydt/pywscb/env/bin/python
copying /Users/michaelheydt/anaconda/bin/python => /Users/michaelheydt/pywscb/env/bin/python
copying /Users/michaelheydt/anaconda/bin/../lib/libpython3.6m.dylib => /Users/michaelheydt/pywscb/env/lib/libpython3.6m.dylib
Installing setuptools, pip, wheel...done.
  1. 这将创建一个 env 文件夹。让我们看看安装了什么。
pywscb $ ls -la env
total 8
drwxr-xr-x 6  michaelheydt staff 204 Jan 18 15:38 .
drwxr-xr-x 3  michaelheydt staff 102 Jan 18 15:35 ..
drwxr-xr-x 16 michaelheydt staff 544 Jan 18 15:38 bin
drwxr-xr-x 3  michaelheydt staff 102 Jan 18 15:35 include
drwxr-xr-x 4  michaelheydt staff 136 Jan 18 15:38 lib
-rw-r--r-- 1  michaelheydt staff 60 Jan 18 15:38  pip-selfcheck.json
  1. 现在我们激活虚拟环境。这个命令使用env文件夹中的内容来配置 Python。之后所有的 python 活动都是相对于这个虚拟环境的。
pywscb $ source env/bin/activate
(env) pywscb $
  1. 我们可以使用以下命令检查 python 是否确实使用了这个虚拟环境:
(env) pywscb $ which python
/Users/michaelheydt/pywscb/env/bin/python

有了我们创建的虚拟环境,让我们克隆书籍的示例代码并看看它的结构。

(env) pywscb $ git clone https://github.com/PacktBooks/PythonWebScrapingCookbook.git
 Cloning into 'PythonWebScrapingCookbook'...
 remote: Counting objects: 420, done.
 remote: Compressing objects: 100% (316/316), done.
 remote: Total 420 (delta 164), reused 344 (delta 88), pack-reused 0
 Receiving objects: 100% (420/420), 1.15 MiB | 250.00 KiB/s, done.
 Resolving deltas: 100% (164/164), done.
 Checking connectivity... done.

这创建了一个PythonWebScrapingCookbook目录。

(env) pywscb $ ls -l
 total 0
 drwxr-xr-x 9 michaelheydt staff 306 Jan 18 16:21 PythonWebScrapingCookbook
 drwxr-xr-x 6 michaelheydt staff 204 Jan 18 15:38 env

让我们切换到它并检查内容。

(env) PythonWebScrapingCookbook $ ls -l
 total 0
 drwxr-xr-x 15 michaelheydt staff 510 Jan 18 16:21 py
 drwxr-xr-x 14 michaelheydt staff 476 Jan 18 16:21 www

有两个目录。大部分 Python 代码都在py目录中。www包含一些我们将使用的网络内容,我们将使用本地 web 服务器不时地访问它。让我们看看py目录的内容:

(env) py $ ls -l
 total 0
 drwxr-xr-x 9  michaelheydt staff 306 Jan 18 16:21 01
 drwxr-xr-x 25 michaelheydt staff 850 Jan 18 16:21 03
 drwxr-xr-x 21 michaelheydt staff 714 Jan 18 16:21 04
 drwxr-xr-x 10 michaelheydt staff 340 Jan 18 16:21 05
 drwxr-xr-x 14 michaelheydt staff 476 Jan 18 16:21 06
 drwxr-xr-x 25 michaelheydt staff 850 Jan 18 16:21 07
 drwxr-xr-x 14 michaelheydt staff 476 Jan 18 16:21 08
 drwxr-xr-x 7  michaelheydt staff 238 Jan 18 16:21 09
 drwxr-xr-x 7  michaelheydt staff 238 Jan 18 16:21 10
 drwxr-xr-x 9  michaelheydt staff 306 Jan 18 16:21 11
 drwxr-xr-x 8  michaelheydt staff 272 Jan 18 16:21 modules

每个章节的代码都在与章节匹配的编号文件夹中(第二章没有代码,因为它都是交互式 Python)。

请注意,有一个modules文件夹。本书中的一些食谱使用这些模块中的代码。确保你的 Python 路径指向这个文件夹。在 Mac 和 Linux 上,你可以在你的.bash_profile文件中设置这一点(在 Windows 上是在环境变量对话框中):

export PYTHONPATH="/users/michaelheydt/dropbox/packt/books/pywebscrcookbook/code/py/modules"
export PYTHONPATH

每个文件夹中的内容通常遵循与章节中食谱顺序相匹配的编号方案。以下是第六章文件夹的内容:

(env) py $ ls -la 06
 total 96
 drwxr-xr-x 14 michaelheydt staff 476 Jan 18 16:21 .
 drwxr-xr-x 14 michaelheydt staff 476 Jan 18 16:26 ..
 -rw-r--r-- 1  michaelheydt staff 902 Jan 18 16:21  01_scrapy_retry.py
 -rw-r--r-- 1  michaelheydt staff 656 Jan 18 16:21  02_scrapy_redirects.py
 -rw-r--r-- 1  michaelheydt staff 1129 Jan 18 16:21 03_scrapy_pagination.py
 -rw-r--r-- 1  michaelheydt staff 488 Jan 18 16:21  04_press_and_wait.py
 -rw-r--r-- 1  michaelheydt staff 580 Jan 18 16:21  05_allowed_domains.py
 -rw-r--r-- 1  michaelheydt staff 826 Jan 18 16:21  06_scrapy_continuous.py
 -rw-r--r-- 1  michaelheydt staff 704 Jan 18 16:21  07_scrape_continuous_twitter.py
 -rw-r--r-- 1  michaelheydt staff 1409 Jan 18 16:21 08_limit_depth.py
 -rw-r--r-- 1  michaelheydt staff 526 Jan 18 16:21  09_limit_length.py
 -rw-r--r-- 1  michaelheydt staff 1537 Jan 18 16:21 10_forms_auth.py
 -rw-r--r-- 1  michaelheydt staff 597 Jan 18 16:21  11_file_cache.py
 -rw-r--r-- 1  michaelheydt staff 1279 Jan 18 16:21 12_parse_differently_based_on_rules.py

在食谱中,我会说明我们将使用<章节目录>/<食谱文件名>中的脚本。

恭喜,你现在已经配置了一个带有书籍代码的 Python 环境!

现在,如果你想退出 Python 虚拟环境,你可以使用以下命令退出:

(env) py $ deactivate
 py $

检查一下 python,我们可以看到它已经切换回来了:

py $ which python
 /Users/michaelheydt/anaconda/bin/python

我不会在本书的其余部分使用虚拟环境。当你看到命令提示时,它们将是以下形式之一"<目录> \("或者简单的"\)"。

现在让我们开始爬取一些数据。

使用 Requests 和 Beautiful Soup 从 Python.org 上爬取数据

在这个食谱中,我们将安装 Requests 和 Beautiful Soup,并从 www.python.org 上爬取一些内容。我们将安装这两个库,并对它们有一些基本的了解。在随后的章节中,我们将深入研究它们。

准备好了...

在这个食谱中,我们将从www.python.org/events/pythonevents中爬取即将到来的 Python 事件。以下是Python.org 事件页面的一个示例(它经常更改,所以你的体验会有所不同):

我们需要确保 Requests 和 Beautiful Soup 已安装。我们可以使用以下命令来安装:

pywscb $ pip install requests
Downloading/unpacking requests
 Downloading requests-2.18.4-py2.py3-none-any.whl (88kB): 88kB downloaded
Downloading/unpacking certifi>=2017.4.17 (from requests)
 Downloading certifi-2018.1.18-py2.py3-none-any.whl (151kB): 151kB downloaded
Downloading/unpacking idna>=2.5,<2.7 (from requests)
 Downloading idna-2.6-py2.py3-none-any.whl (56kB): 56kB downloaded
Downloading/unpacking chardet>=3.0.2,<3.1.0 (from requests)
 Downloading chardet-3.0.4-py2.py3-none-any.whl (133kB): 133kB downloaded
Downloading/unpacking urllib3>=1.21.1,<1.23 (from requests)
 Downloading urllib3-1.22-py2.py3-none-any.whl (132kB): 132kB downloaded
Installing collected packages: requests, certifi, idna, chardet, urllib3
Successfully installed requests certifi idna chardet urllib3
Cleaning up...
pywscb $ pip install bs4
Downloading/unpacking bs4
 Downloading bs4-0.0.1.tar.gz
 Running setup.py (path:/Users/michaelheydt/pywscb/env/build/bs4/setup.py) egg_info for package bs4

如何做...

现在让我们去学习一下爬取一些事件。对于这个食谱,我们将开始使用交互式 python。

  1. ipython命令启动它:
$ ipython
Python 3.6.1 |Anaconda custom (x86_64)| (default, Mar 22 2017, 19:25:17)
Type "copyright", "credits" or "license" for more information.
IPython 5.1.0 -- An enhanced Interactive Python.
? -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help -> Python's own help system.
object? -> Details about 'object', use 'object??' for extra details.
In [1]:
  1. 接下来导入 Requests
In [1]: import requests
  1. 我们现在使用 requests 来对以下 url 进行 GET HTTP 请求:www.python.org/events/python-events/,通过进行GET请求:
In [2]: url = 'https://www.python.org/events/python-events/'
In [3]: req = requests.get(url)
  1. 这下载了页面内容,但它存储在我们的 requests 对象 req 中。我们可以使用.text属性检索内容。这打印了前 200 个字符。
req.text[:200]
Out[4]: '<!doctype html>\n<!--[if lt IE 7]> <html class="no-js ie6 lt-ie7 lt-ie8 lt-ie9"> <![endif]-->\n<!--[if IE 7]> <html class="no-js ie7 lt-ie8 lt-ie9"> <![endif]-->\n<!--[if IE 8]> <h'

现在我们有了页面的原始 HTML。我们现在可以使用 beautiful soup 来解析 HTML 并检索事件数据。

  1. 首先导入 Beautiful Soup
In [5]: from bs4 import BeautifulSoup
  1. 现在我们创建一个BeautifulSoup对象并传递 HTML。
In [6]: soup = BeautifulSoup(req.text, 'lxml')
  1. 现在我们告诉 Beautiful Soup 找到最近事件的主要<ul>标签,然后获取其下的所有<li>标签。
In [7]: events = soup.find('ul', {'class': 'list-recent-events'}).findAll('li')
  1. 最后,我们可以循环遍历每个<li>元素,提取事件详情,并将每个打印到控制台:
In [13]: for event in events:
 ...: event_details = dict()
 ...: event_details['name'] = event_details['name'] = event.find('h3').find("a").text
 ...: event_details['location'] = event.find('span', {'class', 'event-location'}).text
 ...: event_details['time'] = event.find('time').text
 ...: print(event_details)
 ...:
{'name': 'PyCascades 2018', 'location': 'Granville Island Stage, 1585 Johnston St, Vancouver, BC V6H 3R9, Canada', 'time': '22 Jan. – 24 Jan. 2018'}
{'name': 'PyCon Cameroon 2018', 'location': 'Limbe, Cameroon', 'time': '24 Jan. – 29 Jan. 2018'}
{'name': 'FOSDEM 2018', 'location': 'ULB Campus du Solbosch, Av. F. D. Roosevelt 50, 1050 Bruxelles, Belgium', 'time': '03 Feb. – 05 Feb. 2018'}
{'name': 'PyCon Pune 2018', 'location': 'Pune, India', 'time': '08 Feb. – 12 Feb. 2018'}
{'name': 'PyCon Colombia 2018', 'location': 'Medellin, Colombia', 'time': '09 Feb. – 12 Feb. 2018'}
{'name': 'PyTennessee 2018', 'location': 'Nashville, TN, USA', 'time': '10 Feb. – 12 Feb. 2018'}

整个示例都在01/01_events_with_requests.py脚本文件中可用。以下是它的内容,它逐步汇总了我们刚刚做的所有内容:

import requests
from bs4 import BeautifulSoup

def get_upcoming_events(url):
    req = requests.get(url)

    soup = BeautifulSoup(req.text, 'lxml')

    events = soup.find('ul', {'class': 'list-recent-events'}).findAll('li')

    for event in events:
        event_details = dict()
        event_details['name'] = event.find('h3').find("a").text
        event_details['location'] = event.find('span', {'class', 'event-location'}).text
        event_details['time'] = event.find('time').text
        print(event_details)

get_upcoming_events('https://www.python.org/events/python-events/')

你可以在终端中使用以下命令运行它:

$ python 01_events_with_requests.py
{'name': 'PyCascades 2018', 'location': 'Granville Island Stage, 1585 Johnston St, Vancouver, BC V6H 3R9, Canada', 'time': '22 Jan. – 24 Jan. 2018'}
{'name': 'PyCon Cameroon 2018', 'location': 'Limbe, Cameroon', 'time': '24 Jan. – 29 Jan. 2018'}
{'name': 'FOSDEM 2018', 'location': 'ULB Campus du Solbosch, Av. F. D. Roosevelt 50, 1050 Bruxelles, Belgium', 'time': '03 Feb. – 05 Feb. 2018'}
{'name': 'PyCon Pune 2018', 'location': 'Pune, India', 'time': '08 Feb. – 12 Feb. 2018'}
{'name': 'PyCon Colombia 2018', 'location': 'Medellin, Colombia', 'time': '09 Feb. – 12 Feb. 2018'}
{'name': 'PyTennessee 2018', 'location': 'Nashville, TN, USA', 'time': '10 Feb. – 12 Feb. 2018'}

它的工作原理...

我们将在下一章节详细介绍 Requests 和 Beautiful Soup,但现在让我们总结一下关于它的一些关键点。关于 Requests 的一些重要点:

  • Requests 用于执行 HTTP 请求。我们用它来对事件页面的 URL 进行 GET 请求。

  • Requests 对象保存了请求的结果。不仅包括页面内容,还有很多其他关于结果的项目,比如 HTTP 状态码和头部信息。

  • Requests 仅用于获取页面,不进行解析。

我们使用 Beautiful Soup 来解析 HTML 和在 HTML 中查找内容。

要理解这是如何工作的,页面的内容具有以下 HTML 来开始“即将到来的事件”部分:

我们利用 Beautiful Soup 的强大功能:

  • 找到代表该部分的<ul>元素,通过查找具有值为list-recent-eventsclass属性的<ul>来找到。

  • 从该对象中,我们找到所有<li>元素。

每个<li>标签代表一个不同的事件。我们遍历每一个,从子 HTML 标签中找到事件数据,制作一个字典:

  • 名称从<h3>标签的子标签<a>中提取

  • 位置是具有event-location类的<span>的文本内容

  • 时间是从<time>标签的datetime属性中提取的。

使用 urllib3 和 Beautiful Soup 爬取 Python.org

在这个配方中,我们将使用 requests 替换为另一个库urllib3。这是另一个常见的用于从 URL 检索数据以及处理 URL 的各个部分和处理各种编码的库。

准备工作...

这个配方需要安装urllib3。所以用pip安装它:

$ pip install urllib3
Collecting urllib3
 Using cached urllib3-1.22-py2.py3-none-any.whl
Installing collected packages: urllib3
Successfully installed urllib3-1.22

如何做...

该代码在01/02_events_with_urllib3.py中实现。代码如下:

import urllib3
from bs4 import BeautifulSoup

def get_upcoming_events(url):
    req = urllib3.PoolManager()
    res = req.request('GET', url)

    soup = BeautifulSoup(res.data, 'html.parser')

    events = soup.find('ul', {'class': 'list-recent-events'}).findAll('li')

    for event in events:
        event_details = dict()
        event_details['name'] = event.find('h3').find("a").text
        event_details['location'] = event.find('span', {'class', 'event-location'}).text
        event_details['time'] = event.find('time').text
        print(event_details)

get_upcoming_events('https://www.python.org/events/python-events/')

使用 Python 解释器运行它。你将得到与前一个配方相同的输出。

它的工作原理

这个配方唯一的区别是我们如何获取资源:

req = urllib3.PoolManager()
res = req.request('GET', url)

Requests不同,urllib3不会自动应用头部编码。前面示例中代码片段能够工作的原因是因为 BS4 能够很好地处理编码。但你应该记住编码是爬取的一个重要部分。如果你决定使用自己的框架或其他库,请确保编码得到很好的处理。

还有更多...

Requests 和 urllib3 在功能方面非常相似。一般建议在进行 HTTP 请求时使用 Requests。以下代码示例说明了一些高级功能:

import requests

# builds on top of urllib3's connection pooling
# session reuses the same TCP connection if 
# requests are made to the same host
# see https://en.wikipedia.org/wiki/HTTP_persistent_connection for details
session = requests.Session()

# You may pass in custom cookie
r = session.get('http://httpbin.org/get', cookies={'my-cookie': 'browser'})
print(r.text)
# '{"cookies": {"my-cookie": "test cookie"}}'

# Streaming is another nifty feature
# From http://docs.python-requests.org/en/master/user/advanced/#streaming-requests
# copyright belongs to reques.org
r = requests.get('http://httpbin.org/stream/20', stream=True) 
for line in r.iter_lines():
  # filter out keep-alive new lines
  if line:
     decoded_line = line.decode('utf-8')
     print(json.loads(decoded_line))

使用 Scrapy 爬取 Python.org

Scrapy是一个非常流行的开源 Python 爬虫框架,用于提取数据。它最初是为了爬取而设计的,但它也发展成了一个强大的网络爬虫解决方案。

在我们之前的配方中,我们使用 Requests 和 urllib2 来获取数据,使用 Beautiful Soup 来提取数据。Scrapy 提供了所有这些功能以及许多其他内置模块和扩展。在使用 Python 进行爬取时,这也是我们的首选工具。

Scrapy 提供了一些强大的功能值得一提:

  • 内置扩展,用于进行 HTTP 请求和处理压缩、认证、缓存、操作用户代理和 HTTP 头部

  • 内置支持使用选择器语言(如 CSS 和 XPath)选择和提取数据,以及支持利用正则表达式选择内容和链接

  • 编码支持以处理语言和非标准编码声明

  • 灵活的 API,可以重用和编写自定义中间件和管道,提供了一种干净简单的方式来执行任务,比如自动下载资源(例如图片或媒体)并将数据存储在文件系统、S3、数据库等中

准备工作...

有几种方法可以使用 Scrapy 创建一个爬虫。一种是编程模式,我们在代码中创建爬虫和爬虫。还可以从模板或生成器配置一个 Scrapy 项目,然后使用scrapy命令从命令行运行爬虫。本书将遵循编程模式,因为它可以更有效地将代码放在一个文件中。这将有助于我们在使用 Scrapy 时组合特定的、有针对性的配方。

这并不一定是比使用命令行执行 Scrapy 爬虫更好的方式,只是这本书的设计决定。最终,这本书不是关于 Scrapy 的(有其他专门讲 Scrapy 的书),而是更多地阐述了在爬取时可能需要做的各种事情,以及在云端创建一个功能齐全的爬虫服务。

如何做...

这个配方的脚本是01/03_events_with_scrapy.py。以下是代码:

import scrapy
from scrapy.crawler import CrawlerProcess

class PythonEventsSpider(scrapy.Spider):
    name = 'pythoneventsspider'    start_urls = ['https://www.python.org/events/python-events/',]
    found_events = []

    def parse(self, response):
        for event in response.xpath('//ul[contains(@class, "list-recent-events")]/li'):
            event_details = dict()
            event_details['name'] = event.xpath('h3[@class="event-title"]/a/text()').extract_first()
            event_details['location'] = event.xpath('p/span[@class="event-location"]/text()').extract_first()
            event_details['time'] = event.xpath('p/time/text()').extract_first()
            self.found_events.append(event_details)

if __name__ == "__main__":
    process = CrawlerProcess({ 'LOG_LEVEL': 'ERROR'})
    process.crawl(PythonEventsSpider)
    spider = next(iter(process.crawlers)).spider
    process.start()

    for event in spider.found_events: print(event)

以下是运行脚本并显示输出的过程:

~ $ python 03_events_with_scrapy.py
{'name': 'PyCascades 2018', 'location': 'Granville Island Stage, 1585 Johnston St, Vancouver, BC V6H 3R9, Canada', 'time': '22 Jan. – 24 Jan. '}
{'name': 'PyCon Cameroon 2018', 'location': 'Limbe, Cameroon', 'time': '24 Jan. – 29 Jan. '}
{'name': 'FOSDEM 2018', 'location': 'ULB Campus du Solbosch, Av. F. D. Roosevelt 50, 1050 Bruxelles, Belgium', 'time': '03 Feb. – 05 Feb. '}
{'name': 'PyCon Pune 2018', 'location': 'Pune, India', 'time': '08 Feb. – 12 Feb. '}
{'name': 'PyCon Colombia 2018', 'location': 'Medellin, Colombia', 'time': '09 Feb. – 12 Feb. '}
{'name': 'PyTennessee 2018', 'location': 'Nashville, TN, USA', 'time': '10 Feb. – 12 Feb. '}
{'name': 'PyCon Pakistan', 'location': 'Lahore, Pakistan', 'time': '16 Dec. – 17 Dec. '}
{'name': 'PyCon Indonesia 2017', 'location': 'Surabaya, Indonesia', 'time': '09 Dec. – 10 Dec. '}

使用另一个工具得到相同的结果。让我们快速回顾一下它是如何工作的。

它是如何工作的

我们将在后面的章节中详细介绍 Scrapy,但让我们快速浏览一下这段代码,以了解它是如何完成这个爬取的。Scrapy 中的一切都围绕着创建spider。蜘蛛根据我们提供的规则在互联网上爬行。这个蜘蛛只处理一个单独的页面,所以它并不是一个真正的蜘蛛。但它展示了我们将在后面的 Scrapy 示例中使用的模式。

爬虫是通过一个类定义创建的,该类继承自 Scrapy 爬虫类之一。我们的类继承自scrapy.Spider类。

class PythonEventsSpider(scrapy.Spider):
    name = 'pythoneventsspider'    start_urls = ['https://www.python.org/events/python-events/',]

每个爬虫都有一个name,还有一个或多个start_urls,告诉它从哪里开始爬行。

这个爬虫有一个字段来存储我们找到的所有事件:

    found_events = []

然后,爬虫有一个名为 parse 的方法,它将被调用来处理爬虫收集到的每个页面。

def parse(self, response):
        for event in response.xpath('//ul[contains(@class, "list-recent-events")]/li'):
            event_details = dict()
            event_details['name'] = event.xpath('h3[@class="event-title"]/a/text()').extract_first()
            event_details['location'] = event.xpath('p/span[@class="event-location"]/text()').extract_first()
            event_details['time'] = event.xpath('p/time/text()').extract_first()
            self.found_events.append(event_details)

这个方法的实现使用了 XPath 选择器来从页面中获取事件(XPath 是 Scrapy 中导航 HTML 的内置方法)。它构建了event_details字典对象,类似于其他示例,然后将其添加到found_events列表中。

剩下的代码执行了 Scrapy 爬虫的编程执行。

    process = CrawlerProcess({ 'LOG_LEVEL': 'ERROR'})
    process.crawl(PythonEventsSpider)
    spider = next(iter(process.crawlers)).spider
    process.start()

它从创建一个 CrawlerProcess 开始,该过程执行实际的爬行和许多其他任务。我们传递了一个 ERROR 的 LOG_LEVEL 来防止大量的 Scrapy 输出。将其更改为 DEBUG 并重新运行以查看差异。

接下来,我们告诉爬虫进程使用我们的 Spider 实现。我们从爬虫中获取实际的蜘蛛对象,这样当爬取完成时我们就可以获取项目。然后我们通过调用process.start()来启动整个过程。

当爬取完成后,我们可以迭代并打印出找到的项目。

    for event in spider.found_events: print(event)

这个例子并没有涉及到 Scrapy 的任何强大功能。我们将在本书的后面更深入地了解一些更高级的功能。

使用 Selenium 和 PhantomJS 来爬取 Python.org

这个配方将介绍 Selenium 和 PhantomJS,这两个框架与之前的配方中的框架非常不同。实际上,Selenium 和 PhantomJS 经常用于功能/验收测试。我们想展示这些工具,因为它们从爬取的角度提供了独特的好处。我们将在本书的后面看到一些,比如填写表单、按按钮和等待动态 JavaScript 被下载和执行的能力。

Selenium 本身是一个与编程语言无关的框架。它提供了许多编程语言绑定,如 Python、Java、C#和 PHP(等等)。该框架还提供了许多专注于测试的组件。其中三个常用的组件是:

  • 用于录制和重放测试的 IDE

  • Webdriver 实际上启动了一个 Web 浏览器(如 Firefox、Chrome 或 Internet Explorer),通过发送命令并将结果发送到所选的浏览器来运行脚本

  • 网格服务器在远程服务器上执行带有 Web 浏览器的测试。它可以并行运行多个测试用例。

准备工作

首先,我们需要安装 Selenium。我们可以使用我们信赖的pip来完成这个过程:

~ $ pip install selenium
Collecting selenium
 Downloading selenium-3.8.1-py2.py3-none-any.whl (942kB)
 100% |████████████████████████████████| 952kB 236kB/s
Installing collected packages: selenium
Successfully installed selenium-3.8.1

这将安装 Python 的 Selenium 客户端驱动程序(语言绑定)。如果你将来想要了解更多信息,可以在github.com/SeleniumHQ/selenium/blob/master/py/docs/source/index.rst找到更多信息。

对于这个配方,我们还需要在目录中有 Firefox 的驱动程序(名为geckodriver)。这个文件是特定于操作系统的。我已经在文件夹中包含了 Mac 的文件。要获取其他版本,请访问github.com/mozilla/geckodriver/releases

然而,当运行这个示例时,你可能会遇到以下错误:

FileNotFoundError: [Errno 2] No such file or directory: 'geckodriver'

如果你这样做了,将 geckodriver 文件放在系统的 PATH 中,或者将01文件夹添加到你的路径中。哦,你还需要安装 Firefox。

最后,需要安装 PhantomJS。你可以在phantomjs.org/下载并找到安装说明。

如何做...

这个配方的脚本是01/04_events_with_selenium.py

  1. 以下是代码:
from selenium import webdriver

def get_upcoming_events(url):
    driver = webdriver.Firefox()
    driver.get(url)

    events = driver.find_elements_by_xpath('//ul[contains(@class, "list-recent-events")]/li')

    for event in events:
        event_details = dict()
        event_details['name'] = event.find_element_by_xpath('h3[@class="event-title"]/a').text
        event_details['location'] = event.find_element_by_xpath('p/span[@class="event-location"]').text
        event_details['time'] = event.find_element_by_xpath('p/time').text
        print(event_details)

    driver.close()

get_upcoming_events('https://www.python.org/events/python-events/')
  1. 然后用 Python 运行脚本。你会看到熟悉的输出:
~ $ python 04_events_with_selenium.py
{'name': 'PyCascades 2018', 'location': 'Granville Island Stage, 1585 Johnston St, Vancouver, BC V6H 3R9, Canada', 'time': '22 Jan. – 24 Jan.'}
{'name': 'PyCon Cameroon 2018', 'location': 'Limbe, Cameroon', 'time': '24 Jan. – 29 Jan.'}
{'name': 'FOSDEM 2018', 'location': 'ULB Campus du Solbosch, Av. F. D. Roosevelt 50, 1050 Bruxelles, Belgium', 'time': '03 Feb. – 05 Feb.'}
{'name': 'PyCon Pune 2018', 'location': 'Pune, India', 'time': '08 Feb. – 12 Feb.'}
{'name': 'PyCon Colombia 2018', 'location': 'Medellin, Colombia', 'time': '09 Feb. – 12 Feb.'}
{'name': 'PyTennessee 2018', 'location': 'Nashville, TN, USA', 'time': '10 Feb. – 12 Feb.'}

在这个过程中,Firefox 将弹出并打开页面。我们重用了之前的配方并采用了 Selenium。

Firefox 弹出的窗口

它的工作原理

这个配方的主要区别在于以下代码:

driver = webdriver.Firefox()
driver.get(url)

这个脚本获取了 Firefox 驱动程序,并使用它来获取指定 URL 的内容。这是通过启动 Firefox 并自动化它去到页面,然后 Firefox 将页面内容返回给我们的应用程序。这就是为什么 Firefox 弹出的原因。另一个区别是,为了找到东西,我们需要调用find_element_by_xpath来搜索结果的 HTML。

还有更多...

在许多方面,PhantomJS 与 Selenium 非常相似。它对各种 Web 标准有快速和本地支持,具有 DOM 处理、CSS 选择器、JSON、Canvas 和 SVG 等功能。它经常用于 Web 测试、页面自动化、屏幕捕捉和网络监控。

Selenium 和 PhantomJS 之间有一个关键区别:PhantomJS 是无头的,使用 WebKit。正如我们所看到的,Selenium 打开并自动化浏览器。如果我们处于一个连续集成或测试环境中,浏览器没有安装,我们也不希望打开成千上万个浏览器窗口或标签,那么这并不是很好。无头浏览器使得这一切更快更高效。

PhantomJS 的示例在01/05_events_with_phantomjs.py文件中。只有一行代码需要更改:

driver = webdriver.PhantomJS('phantomjs')

运行脚本会产生与 Selenium/Firefox 示例类似的输出,但不会弹出浏览器,而且完成时间更短。

第二章:数据获取和提取

在本章中,我们将涵盖:

  • 如何使用 BeautifulSoup 解析网站和导航 DOM

  • 使用 Beautiful Soup 的查找方法搜索 DOM

  • 使用 XPath 和 lxml 查询 DOM

  • 使用 XPath 和 CSS 选择器查询数据

  • 使用 Scrapy 选择器

  • 以 Unicode / UTF-8 格式加载数据

介绍

有效抓取的关键方面是理解内容和数据如何存储在 Web 服务器上,识别要检索的数据,并理解工具如何支持此提取。在本章中,我们将讨论网站结构和 DOM,介绍使用 lxml、XPath 和 CSS 解析和查询网站的技术。我们还将看看如何处理其他语言和不同编码类型(如 Unicode)开发的网站。

最终,理解如何在 HTML 文档中查找和提取数据归结为理解 HTML 页面的结构,它在 DOM 中的表示,查询 DOM 以查找特定元素的过程,以及如何根据数据的表示方式指定要检索的元素。

如何使用 BeautifulSoup 解析网站和导航 DOM

当浏览器显示网页时,它会在一种称为文档对象模型DOM)的表示中构建页面内容的模型。DOM 是页面整个内容的分层表示,以及结构信息、样式信息、脚本和其他内容的链接。

理解这种结构对于能够有效地从网页上抓取数据至关重要。我们将看一个示例网页,它的 DOM,并且检查如何使用 Beautiful Soup 导航 DOM。

准备就绪

我们将使用示例代码的www文件夹中包含的一个小型网站。要跟着做,请从www文件夹内启动一个 Web 服务器。可以使用 Python 3 来完成这个操作:

www $ python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

可以通过右键单击页面并选择检查来检查 Chrome 中的网页 DOM。这将打开 Chrome 开发者工具。在浏览器中打开http://localhost:8080/planets.html。在 Chrome 中,您可以右键单击并选择“检查”以打开开发者工具(其他浏览器也有类似的工具)。

在页面上选择检查

这将打开开发者工具和检查器。DOM 可以在元素选项卡中检查。

以下显示了表中第一行的选择:

检查第一行

每一行行星都在一个<tr>元素内。这个元素及其相邻元素有几个特征,我们将检查它们,因为它们被设计为模拟常见的网页。

首先,这个元素有三个属性:idplanetname。属性在抓取中通常很重要,因为它们通常用于识别和定位嵌入在 HTML 中的数据。

其次,<tr>元素有子元素,在这种情况下是五个<td>元素。我们经常需要查看特定元素的子元素,以找到所需的实际数据。

这个元素还有一个父元素<tbody>。还有兄弟元素,以及一组<tr>子元素。从任何行星,我们可以向上到父元素并找到其他行星。正如我们将看到的,我们可以使用各种工具中的各种构造,比如 Beautiful Soup 中的find函数系列,以及XPath查询,轻松地导航这些关系。

如何做...

这个配方以及本章中的大多数其他配方都将以 iPython 的交互方式呈现。但是每个配方的代码都可以在脚本文件中找到。这个配方的代码在02/01_parsing_html_wtih_bs.py中。您可以输入以下内容,或者从脚本文件中复制粘贴。

现在让我们通过 Beautiful Soup 解析 HTML。我们首先通过以下代码将此页面加载到BeautifulSoup对象中,该代码创建一个 BeautifulSoup 对象,使用 requests.get 加载页面内容,并将其加载到名为 soup 的变量中。

In [1]: import requests
   ...: from bs4 import BeautifulSoup
   ...: html = requests.get("http://localhost:8080/planets.html").text
   ...: soup = BeautifulSoup(html, "lxml")
   ...:

通过将其转换为字符串,可以检索soup对象中的 HTML(大多数 BeautifulSoup 对象都具有此特性)。以下显示了文档中 HTML 的前 1000 个字符:

In [2]: str(soup)[:1000]
Out[2]: '<html>\n<head>\n</head>\n<body>\n<div id="planets">\n<h1>Planetary data</h1>\n<div id="content">Here are some interesting facts about the planets in our solar system</div>\n<p></p>\n<table border="1" id="planetsTable">\n<tr id="planetHeader">\n<th>\n</th>\n<th>\r\n Name\r\n </th>\n<th>\r\n Mass (10²⁴kg)\r\n </th>\n<th>\r\n Diameter (km)\r\n </th>\n<th>\r\n How it got its Name\r\n </th>\n<th>\r\n More Info\r\n </th>\n</tr>\n<tr class="planet" id="planet1" name="Mercury">\n<td>\n<img src="img/mercury-150x150.png"/>\n</td>\n<td>\r\n Mercury\r\n </td>\n<td>\r\n 0.330\r\n </td>\n<td>\r\n 4879\r\n </td>\n<td>Named Mercurius by the Romans because it appears to move so swiftly.</td>\n<td>\n<a href="https://en.wikipedia.org/wiki/Mercury_(planet)">Wikipedia</a>\n</td>\n</tr>\n<tr class="p'

我们可以使用soup的属性来导航 DOM 中的元素。soup代表整个文档,我们可以通过链接标签名称来深入文档。以下导航到包含数据的<table>

In [3]: str(soup.html.body.div.table)[:200]
Out[3]: '<table border="1" id="planetsTable">\n<tr id="planetHeader">\n<th>\n</th>\n<th>\r\n Name\r\n </th>\n<th>\r\n Mass (10²⁴kg)\r\n </th>\n<th>\r\n '

以下是获取表格的第一个子<tr>

In [6]: soup.html.body.div.table.tr
Out[6]: <tr id="planetHeader">
<th>
</th>
<th>
                    Name
                </th>
<th>
                    Mass (10²⁴kg)
                </th>
<th>
                    Diameter (km)
                </th>
<th>
                    How it got its Name
                </th>
<th>
                    More Info
                </th>
</tr>

请注意,此类表示法仅检索该类型的第一个子节点。要找到更多,需要迭代所有子节点,我们将在下一步中进行,或者使用查找方法(下一个示例)。

每个节点都有子节点和后代。后代是给定节点下面的所有节点(甚至比直接子节点更深层次的节点),而子节点是第一级后代。以下是获取表格的子节点,实际上是一个list_iterator对象:

In [4]: soup.html.body.div.table.children
Out[4]: <list_iterator at 0x10eb11cc0>

我们可以使用for循环或 Python 生成器来检查迭代器中的每个子元素。以下使用生成器来获取所有子节点,并将它们的 HTML 组成的前几个字符作为列表返回:

In [5]: [str(c)[:45] for c in soup.html.body.div.table.children]
Out[5]:
['\n',
 '<tr id="planetHeader">\n<th>\n</th>\n<th>\r\n ',
 '\n',
 '<tr class="planet" id="planet1" name="Mercury',
 '\n',
 '<tr class="planet" id="planet2" name="Venus">',
 '\n',
 '<tr class="planet" id="planet3" name="Earth">',
 '\n',
 '<tr class="planet" id="planet4" name="Mars">\n',
 '\n',
 '<tr class="planet" id="planet5" name="Jupiter',
 '\n',
 '<tr class="planet" id="planet6" name="Saturn"',
 '\n',
 '<tr class="planet" id="planet7" name="Uranus"',
 '\n',
 '<tr class="planet" id="planet8" name="Neptune',
 '\n',
 '<tr class="planet" id="planet9" name="Pluto">',
 '\n']

最后,节点的父节点可以使用.parent属性找到:

In [7]: str(soup.html.body.div.table.tr.parent)[:200]
Out[7]: '<table border="1" id="planetsTable">\n<tr id="planetHeader">\n<th>\n</th>\n<th>\r\n Name\r\n </th>\n<th>\r\n Mass (10²⁴kg)\r\n </th>\n<th>\r\n '

它是如何工作的

Beautiful Soup 将页面的 HTML 转换为其自己的内部表示。这个模型与浏览器创建的 DOM 具有相同的表示。但是 Beautiful Soup 还提供了许多强大的功能,用于导航 DOM 中的元素,例如我们在使用标签名称作为属性时所看到的。当我们知道 HTML 中的标签名称的固定路径时,这些功能非常适合查找东西。

还有更多...

这种导航 DOM 的方式相对不灵活,并且高度依赖于结构。可能随着网页由其创建者更新,结构会随时间改变。页面甚至可能看起来相同,但具有完全不同的结构,从而破坏您的抓取代码。

那么我们该如何处理呢?正如我们将看到的,有几种搜索元素的方法比定义显式路径要好得多。一般来说,我们可以使用 XPath 和 Beautiful Soup 的查找方法来做到这一点。我们将在本章后面的示例中检查这两种方法。

使用 Beautiful Soup 的查找方法搜索 DOM

我们可以使用 Beautiful Soup 的查找方法对 DOM 进行简单搜索。这些方法为我们提供了一个更灵活和强大的构造,用于查找不依赖于这些元素的层次结构的元素。在本示例中,我们将检查这些函数的几种常见用法,以定位 DOM 中的各种元素。

准备工作

如果您想将以下内容剪切并粘贴到 ipython 中,您可以在02/02_bs4_find.py中找到示例。

如何做...

我们将从一个新的 iPython 会话开始,并首先加载行星页面:

In [1]: import requests
 ...: from bs4 import BeautifulSoup
 ...: html = requests.get("http://localhost:8080/planets.html").text
 ...: soup = BeautifulSoup(html, "lxml")
 ...:

在上一个示例中,为了访问表格中的所有<tr>,我们使用了链式属性语法来获取表格,然后需要获取子节点并对其进行迭代。这会有一个问题,因为子节点可能是除了<tr>之外的其他元素。获取<tr>子元素的更优选方法是使用findAll

让我们首先找到<table>

In [4]: table = soup.find("table")
   ...: str(table)[:100]
   ...:
Out[4]: '<table border="1" id="planetsTable">\n<tr id="planetHeader">\n<th>\n</th>\n<th>\r\n Nam'

这告诉 soup 对象在文档中查找第一个<table>元素。从这个元素中,我们可以使用findAll找到所有属于该表格的<tr>元素的后代:

In [8]: [str(tr)[:50] for tr in table.findAll("tr")]
Out[8]:
['<tr id="planetHeader">\n<th>\n</th>\n<th>\r\n ',
 '<tr class="planet" id="planet1" name="Mercury">\n<t',
 '<tr class="planet" id="planet2" name="Venus">\n<td>',
 '<tr class="planet" id="planet3" name="Earth">\n<td>',
 '<tr class="planet" id="planet4" name="Mars">\n<td>\n',
 '<tr class="planet" id="planet5" name="Jupiter">\n<t',
 '<tr class="planet" id="planet6" name="Saturn">\n<td',
 '<tr class="planet" id="planet7" name="Uranus">\n<td',
 '<tr class="planet" id="planet8" name="Neptune">\n<t',
 '<tr class="planet" id="planet9" name="Pluto">\n<td>']

请注意这些是后代而不是直接的子代。将查询更改为"td"以查看区别。没有直接的子代是<td>,但每行都有多个<td>元素。总共会找到 54 个<td>元素。

如果我们只想要包含行星数据的行,这里有一个小问题。表头也被包括在内。我们可以通过利用目标行的id属性来解决这个问题。以下代码找到了id值为"planet3"的行。

In [14]: table.find("tr", {"id": "planet3"})
    ...:
Out[14]:
<tr class="planet" id="planet3" name="Earth">
<td>
<img src="img/earth-150x150.png"/>
</td>
<td>
                    Earth
                </td>
<td>
                    5.97
                </td>
<td>
                    12756
                </td>
<td>
                    The name Earth comes from the Indo-European base 'er,'which produced the Germanic noun 'ertho,' and ultimately German 'erde,'
                    Dutch 'aarde,' Scandinavian 'jord,' and English 'earth.' Related forms include Greek 'eraze,' meaning
                    'on the ground,' and Welsh 'erw,' meaning 'a piece of land.'
                </td>
<td>
<a href="https://en.wikipedia.org/wiki/Earth">Wikipedia</a>
</td>
</tr>

太棒了!我们利用了这个页面使用这个属性来表示具有实际数据的表行。

现在让我们再进一步,收集每个行星的质量,并将名称和质量放入字典中:

In [18]: items = dict()
    ...: planet_rows = table.findAll("tr", {"class": "planet"})
    ...: for i in planet_rows:
    ...: tds = i.findAll("td")
    ...: items[tds[1].text.strip()] = tds[2].text.strip()
    ...:

In [19]: items
Out[19]:
{'Earth': '5.97',
 'Jupiter': '1898',
 'Mars': '0.642',
 'Mercury': '0.330',
 'Neptune': '102',
 'Pluto': '0.0146',
 'Saturn': '568',
 'Uranus': '86.8',
 'Venus': '4.87'}

就像这样,我们已经从页面中嵌入的内容中制作了一个很好的数据结构。

使用 XPath 和 lxml 查询 DOM

XPath 是一种用于从 XML 文档中选择节点的查询语言,对于进行网页抓取的任何人来说,它是必须学习的查询语言。XPath 相对于其他基于模型的工具,为其用户提供了许多好处:

  • 可以轻松地浏览 DOM 树

  • 比 CSS 选择器和正则表达式等其他选择器更复杂和强大

  • 它有一个很棒的(200+)内置函数集,并且可以通过自定义函数进行扩展

  • 它得到了解析库和抓取平台的广泛支持

XPath 包含七种数据模型(我们之前已经看到了其中一些):

  • 根节点(顶级父节点)

  • 元素节点(<a>..</a>

  • 属性节点(href="example.html"

  • 文本节点("this is a text"

  • 注释节点(<!-- a comment -->

  • 命名空间节点

  • 处理指令节点

XPath 表达式可以返回不同的数据类型:

  • 字符串

  • 布尔值

  • 数字

  • 节点集(可能是最常见的情况)

(XPath)定义了相对于当前节点的节点集。XPath 中定义了总共 13 个轴,以便轻松搜索不同的节点部分,从当前上下文节点或根节点。

lxml是一个 Python 包装器,位于 libxml2 XML 解析库之上,后者是用 C 编写的。C 中的实现有助于使其比 Beautiful Soup 更快,但在某些计算机上安装起来也更困难。最新的安装说明可在以下网址找到:lxml.de/installation.html

lxml 支持 XPath,这使得管理复杂的 XML 和 HTML 文档变得相当容易。我们将研究使用 lxml 和 XPath 一起的几种技术,以及如何使用 lxml 和 XPath 来导航 DOM 并访问数据。

准备工作

这些片段的代码在02/03_lxml_and_xpath.py中,如果你想节省一些输入。我们将首先从lxml中导入html,以及requests,然后加载页面。

In [1]: from lxml import html
   ...: import requests
   ...: page_html = requests.get("http://localhost:8080/planets.html").text

到这一点,lxml 应该已经作为其他安装的依赖项安装了。如果出现错误,请使用pip install lxml进行安装。

如何做...

我们要做的第一件事是将 HTML 加载到 lxml 的“etree”中。这是 lxml 对 DOM 的表示。

in [2]: tree = html.fromstring(page_html)

tree变量现在是 DOM 的 lxml 表示,它对 HTML 内容进行了建模。现在让我们来看看如何使用它和 XPath 从文档中选择各种元素。

我们的第一个 XPath 示例将是查找所有在<table>元素下的<tr>元素。

In [3]: [tr for tr in tree.xpath("/html/body/div/table/tr")]
Out[3]:
[<Element tr at 0x10cfd1408>,
 <Element tr at 0x10cfd12c8>,
 <Element tr at 0x10cfd1728>,
 <Element tr at 0x10cfd16d8>,
 <Element tr at 0x10cfd1458>,
 <Element tr at 0x10cfd1868>,
 <Element tr at 0x10cfd1318>,
 <Element tr at 0x10cfd14a8>,
 <Element tr at 0x10cfd10e8>,
 <Element tr at 0x10cfd1778>,
 <Element tr at 0x10cfd1638>]

这个 XPath 从文档的根部通过标签名称进行导航,直到<tr>元素。这个例子看起来类似于 Beautiful Soup 中的属性表示法,但最终它更加具有表现力。请注意结果中的一个区别。所有的<tr>元素都被返回了,而不仅仅是第一个。事实上,如果每个级别的标签都有多个项目可用,那么这个路径的搜索将在所有这些<div>上执行。

实际结果是一个lxml元素对象。以下使用etree.tostring()获取与元素相关的 HTML(尽管它们已经应用了编码):

In [4]: from lxml import etree
   ...: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div/table/tr")]
Out[4]:
[b'<tr id="planetHeader">
\n <th>&#',
 b'<tr id="planet1" class="planet" name="Mercury">&#1',
 b'<tr id="planet2" class="planet" name="Venus">
',
 b'<tr id="planet3" class="planet" name="Earth">
',
 b'<tr id="planet4" class="planet" name="Mars">
\n',
 b'<tr id="planet5" class="planet" name="Jupiter">&#1',
 b'<tr id="planet6" class="planet" name="Saturn">&#13',
 b'<tr id="planet7" class="planet" name="Uranus">&#13',
 b'<tr id="planet8" class="planet" name="Neptune">&#1',
 b'<tr id="planet9" class="planet" name="Pluto">
',
 b'<tr id="footerRow">
\n <td>
']

现在让我们看看如何使用 XPath 来选择只有行星的<tr>元素。

In [5]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div/table/tr[@class='planet']")]
Out[5]:
[b'<tr id="planet1" class="planet" name="Mercury">&#1',
 b'<tr id="planet2" class="planet" name="Venus">
',
 b'<tr id="planet3" class="planet" name="Earth">
',
 b'<tr id="planet4" class="planet" name="Mars">
\n',
 b'<tr id="planet5" class="planet" name="Jupiter">&#1',
 b'<tr id="planet6" class="planet" name="Saturn">&#13',
 b'<tr id="planet7" class="planet" name="Uranus">&#13',
 b'<tr id="planet8" class="planet" name="Neptune">&#1',
 b'<tr id="planet9" class="planet" name="Pluto">
']

在标签旁边使用[]表示我们要根据当前元素的某些条件进行选择。@表示我们要检查标签的属性,在这种情况下,我们要选择属性等于"planet"的标签。

还有另一个要指出的是查询中有 11 个<tr>行。如前所述,XPath 在每个级别上对所有找到的节点进行导航。这个文档中有两个表,都是不同<div>的子元素,都是<body>元素的子元素。具有id="planetHeader"的行来自我们想要的目标表,另一个具有id="footerRow"的行来自第二个表。

以前我们通过选择class="row"<tr>来解决了这个问题,但还有其他值得简要提及的方法。首先,我们还可以使用[]来指定 XPath 的每个部分中的特定元素,就像它们是数组一样。看下面的例子:

In [6]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div[1]/table/tr")]
Out[6]:
[b'<tr id="planetHeader">
\n <th>&#',
 b'<tr id="planet1" class="planet" name="Mercury">&#1',
 b'<tr id="planet2" class="planet" name="Venus">
',
 b'<tr id="planet3" class="planet" name="Earth">
',
 b'<tr id="planet4" class="planet" name="Mars">
\n',
 b'<tr id="planet5" class="planet" name="Jupiter">&#1',
 b'<tr id="planet6" class="planet" name="Saturn">&#13',
 b'<tr id="planet7" class="planet" name="Uranus">&#13',
 b'<tr id="planet8" class="planet" name="Neptune">&#1',
 b'<tr id="planet9" class="planet" name="Pluto">
']

XPath 中的数组从 1 开始而不是 0(一个常见的错误来源)。这选择了第一个<div>。更改为[2]选择了第二个<div>,因此只选择了第二个<table>

In [7]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div[2]/table/tr")]
Out[7]: [b'<tr id="footerRow">
\n <td>
']

这个文档中的第一个<div>也有一个 id 属性:

  <div id="planets">  

这可以用来选择这个<div>

In [8]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div[@id='planets']/table/tr")]
Out[8]:
[b'<tr id="planetHeader">
\n <th>&#',
 b'<tr id="planet1" class="planet" name="Mercury">&#1',
 b'<tr id="planet2" class="planet" name="Venus">
',
 b'<tr id="planet3" class="planet" name="Earth">
',
 b'<tr id="planet4" class="planet" name="Mars">
\n',
 b'<tr id="planet5" class="planet" name="Jupiter">&#1',
 b'<tr id="planet6" class="planet" name="Saturn">&#13',
 b'<tr id="planet7" class="planet" name="Uranus">&#13',
 b'<tr id="planet8" class="planet" name="Neptune">&#1',
 b'<tr id="planet9" class="planet" name="Pluto">
']

之前我们根据 class 属性的值选择了行星行。我们也可以排除行:

In [9]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div[@id='planets']/table/tr[@id!='planetHeader']")]
Out[9]:
[b'<tr id="planet1" class="planet" name="Mercury">&#1',
 b'<tr id="planet2" class="planet" name="Venus">
',
 b'<tr id="planet3" class="planet" name="Earth">
',
 b'<tr id="planet4" class="planet" name="Mars">
\n',
 b'<tr id="planet5" class="planet" name="Jupiter">&#1',
 b'<tr id="planet6" class="planet" name="Saturn">&#13',
 b'<tr id="planet7" class="planet" name="Uranus">&#13',
 b'<tr id="planet8" class="planet" name="Neptune">&#1',
 b'<tr id="planet9" class="planet" name="Pluto">
']

假设行星行没有属性(也没有标题行),那么我们可以通过位置来做到这一点,跳过第一行:

In [10]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div[@id='planets']/table/tr[position() > 1]")]
Out[10]:
[b'<tr id="planet1" class="planet" name="Mercury">&#1',
 b'<tr id="planet2" class="planet" name="Venus">
',
 b'<tr id="planet3" class="planet" name="Earth">
',
 b'<tr id="planet4" class="planet" name="Mars">
\n',
 b'<tr id="planet5" class="planet" name="Jupiter">&#1',
 b'<tr id="planet6" class="planet" name="Saturn">&#13',
 b'<tr id="planet7" class="planet" name="Uranus">&#13',
 b'<tr id="planet8" class="planet" name="Neptune">&#1',
 b'<tr id="planet9" class="planet" name="Pluto">
']

可以使用parent::*来导航到节点的父级:

In [11]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div/table/tr/parent::*")]
Out[11]:
[b'<table id="planetsTable" border="1">
\n ',
 b'<table id="footerTable">
\n <tr id="']

这返回了两个父级,因为这个 XPath 返回了两个表的行,所以找到了所有这些行的父级。*是一个通配符,代表任何名称的任何父级标签。在这种情况下,这两个父级都是表,但通常结果可以是任意数量的 HTML 元素类型。下面的结果相同,但如果两个父级是不同的 HTML 标签,那么它只会返回<table>元素。

In [12]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div/table/tr/parent::table")]
Out[12]:
[b'<table id="planetsTable" border="1">
\n ',
 b'<table id="footerTable">
\n <tr id="']

还可以通过位置或属性指定特定的父级。以下选择具有id="footerTable"的父级:

In [13]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div/table/tr/parent::table[@id='footerTable']")]
Out[13]: [b'<table id="footerTable">
\n <tr id="']

父级的快捷方式是...也表示当前节点):

In [14]: [etree.tostring(tr)[:50] for tr in tree.xpath("/html/body/div/table/tr/..")]
Out[14]:
[b'<table id="planetsTable" border="1">
\n ',
 b'<table id="footerTable">
\n <tr id="']

最后一个示例找到了地球的质量:

In [15]: mass = tree.xpath("/html/body/div[1]/table/tr[@name='Earth']/td[3]/text()[1]")[0].strip()
    ...: mass
Out[15]: '5.97'

这个 XPath 的尾部/td[3]/text()[1]选择了行中的第三个<td>元素,然后选择了该元素的文本(这是元素中所有文本的数组),并选择了其中的第一个质量。

它是如何工作的

XPath 是XSLT(可扩展样式表语言转换)标准的一部分,提供了在 XML 文档中选择节点的能力。HTML 是 XML 的一种变体,因此 XPath 可以在 HTML 文档上工作(尽管 HTML 可能格式不正确,在这种情况下会破坏 XPath 解析)。

XPath 本身旨在模拟 XML 节点、属性和属性的结构。该语法提供了查找与表达式匹配的 XML 中的项目的方法。这可以包括匹配或逻辑比较 XML 文档中任何节点、属性、值或文本的任何部分。

XPath 表达式可以组合成非常复杂的路径在文档中。还可以根据相对位置导航文档,这在根据相对位置而不是 DOM 中的绝对位置找到数据时非常有帮助。

理解 XPath 对于知道如何解析 HTML 和执行网页抓取是至关重要的。正如我们将看到的,它是许多高级库的基础,并为其提供了实现,比如 lxml。

还有更多...

XPath 实际上是处理 XML 和 HTML 文档的一个了不起的工具。它在功能上非常丰富,我们仅仅触及了它在演示 HTML 文档中常见的一些示例的表面。

要了解更多,请访问以下链接:

使用 XPath 和 CSS 选择器查询数据

CSS 选择器是用于选择元素的模式,通常用于定义应该应用样式的元素。它们也可以与 lxml 一起用于选择 DOM 中的节点。CSS 选择器通常被广泛使用,因为它们比 XPath 更紧凑,并且通常在代码中更可重用。以下是可能使用的常见选择器的示例:

您要寻找的内容 示例
所有标签 *
特定标签(即tr .planet
类名(即"planet" tr.planet
具有ID "planet3"的标签 tr#planet3
表的子tr table tr
表的后代tr table tr
带有属性的标签(即带有id="planet4"tr a[id=Mars]

准备工作

让我们开始使用与上一个示例中使用的相同的启动代码来检查 CSS 选择器。这些代码片段也在02/04_css_selectors.py中。

In [1]: from lxml import html
   ...: import requests
   ...: page_html = requests.get("http://localhost:8080/planets.html").text
   ...: tree = html.fromstring(page_html)
   ...:

如何做...

现在让我们开始使用 XPath 和 CSS 选择器。以下选择所有具有等于"planet"的类的<tr>元素:

In [2]: [(v, v.xpath("@name")) for v in tree.cssselect('tr.planet')]
Out[2]:
[(<Element tr at 0x10d3a2278>, ['Mercury']),
 (<Element tr at 0x10c16ed18>, ['Venus']),
 (<Element tr at 0x10e445688>, ['Earth']),
 (<Element tr at 0x10e477228>, ['Mars']),
 (<Element tr at 0x10e477408>, ['Jupiter']),
 (<Element tr at 0x10e477458>, ['Saturn']),
 (<Element tr at 0x10e4774a8>, ['Uranus']),
 (<Element tr at 0x10e4774f8>, ['Neptune']),
 (<Element tr at 0x10e477548>, ['Pluto'])]

可以通过多种方式找到地球的数据。以下是基于id获取行的方法:

In [3]: tr = tree.cssselect("tr#planet3")
   ...: tr[0], tr[0].xpath("./td[2]/text()")[0].strip()
   ...:
Out[3]: (<Element tr at 0x10e445688>, 'Earth')

以下示例使用具有特定值的属性:

In [4]: tr = tree.cssselect("tr[name='Pluto']")
   ...: tr[0], tr[0].xpath("td[2]/text()")[0].strip()
   ...:
Out[5]: (<Element tr at 0x10e477548>, 'Pluto')

请注意,与 XPath 不同,不需要使用@符号来指定属性。

工作原理

lxml 将您提供的 CSS 选择器转换为 XPath,然后针对底层文档执行该 XPath 表达式。实质上,lxml 中的 CSS 选择器提供了一种简写 XPath 的方法,使得查找符合某些模式的节点比使用 XPath 更简单。

还有更多...

由于 CSS 选择器在底层使用 XPath,因此与直接使用 XPath 相比,使用它会增加一些开销。然而,这种差异几乎不成问题,因此在某些情况下,更容易只使用 cssselect。

可以在以下位置找到 CSS 选择器的完整描述:www.w3.org/TR/2011/REC-css3-selectors-20110929/

使用 Scrapy 选择器

Scrapy 是一个用于从网站提取数据的 Python 网络爬虫框架。它提供了许多强大的功能,用于浏览整个网站,例如跟踪链接的能力。它提供的一个功能是使用 DOM 在文档中查找数据,并且现在,相当熟悉的 XPath。

在这个示例中,我们将加载 StackOverflow 上当前问题的列表,然后使用 scrapy 选择器解析它。使用该选择器,我们将提取每个问题的文本。

准备工作

此示例的代码位于02/05_scrapy_selectors.py中。

如何做...

我们首先从scrapy中导入Selector,还有requests,以便我们可以检索页面:

In [1]: from scrapy.selector import Selector
   ...: import requests
   ...:

接下来加载页面。在此示例中,我们将检索 StackOverflow 上最近的问题并提取它们的标题。我们可以使用以下查询来实现:

In [2]: response = requests.get("http://stackoverflow.com/questions")

现在创建一个Selector并将其传递给响应对象:

In [3]: selector = Selector(response)
   ...: selector
   ...:
Out[3]: <Selector xpath=None data='<html>\r\n\r\n <head>\r\n\r\n <title>N'>

检查此页面的内容,我们可以看到问题的 HTML 具有以下结构:

StackOverflow 问题的 HTML

使用选择器,我们可以使用 XPath 找到这些:

In [4]: summaries = selector.xpath('//div[@class="summary"]/h3')
   ...: summaries[0:5]
   ...:
Out[4]:
[<Selector xpath='//div[@class="summary"]/h3' data='<h3><a href="/questions/48353091/how-to-'>,
 <Selector xpath='//div[@class="summary"]/h3' data='<h3><a href="/questions/48353090/move-fi'>,
 <Selector xpath='//div[@class="summary"]/h3' data='<h3><a href="/questions/48353089/java-la'>,
 <Selector xpath='//div[@class="summary"]/h3' data='<h3><a href="/questions/48353086/how-do-'>,
 <Selector xpath='//div[@class="summary"]/h3' data='<h3><a href="/questions/48353085/running'>]

现在我们进一步深入每个问题的标题。

In [5]: [x.extract() for x in summaries.xpath('a[@class="question-hyperlink"]/text()')][:10]
Out[5]:
['How to convert stdout binary file to a data URL?',
 'Move first letter from sentence to the end',
 'Java launch program and interact with it programmatically',
 'How do I build vala from scratch',
 'Running Sql Script',
 'Mysql - Auto create, update, delete table 2 from table 1',
 'how to map meeting data corresponding calendar time in java',
 'Range of L*a* b* in Matlab',
 'set maximum and minimum number input box in js,html',
 'I created generic array and tried to store the value but it is showing ArrayStoreException']

工作原理

在底层,Scrapy 构建其选择器基于 lxml。它提供了一个较小且略微简单的 API,性能与 lxml 相似。

还有更多...

要了解有关 Scrapy 选择器的更多信息,请参见:doc.scrapy.org/en/latest/topics/selectors.html

以 unicode / UTF-8 加载数据

文档的编码告诉应用程序如何将文档中的字符表示为文件中的字节。基本上,编码指定每个字符有多少位。在标准 ASCII 文档中,所有字符都是 8 位。HTML 文件通常以每个字符 8 位编码,但随着互联网的全球化,情况并非总是如此。许多 HTML 文档以 16 位字符编码,或者使用 8 位和 16 位字符的组合。

一种特别常见的 HTML 文档编码形式被称为 UTF-8。这是我们将要研究的编码形式。

准备工作

我们将从位于http://localhost:8080/unicode.html的本地 Web 服务器中读取名为unicode.html的文件。该文件采用 UTF-8 编码,并包含编码空间不同部分的几组字符。例如,页面在浏览器中如下所示:

浏览器中的页面

使用支持 UTF-8 的编辑器,我们可以看到西里尔字母在编辑器中是如何呈现的:

编辑器中的 HTML

示例的代码位于02/06_unicode.py中。

如何做...

我们将研究如何使用urlopenrequests来处理 UTF-8 中的 HTML。这两个库处理方式不同,让我们来看看。让我们开始导入urllib,加载页面并检查一些内容。

In [8]: from urllib.request import urlopen
   ...: page = urlopen("http://localhost:8080/unicode.html")
   ...: content = page.read()
   ...: content[840:1280]
   ...:
Out[8]: b'><strong>Cyrillic</strong> &nbsp; U+0400 \xe2\x80\x93 U+04FF &nbsp; (1024\xe2\x80\x931279)</p>\n <table class="unicode">\n <tbody>\n <tr valign="top">\n <td width="50">&nbsp;</td>\n <td class="b" width="50">\xd0\x89</td>\n <td class="b" width="50">\xd0\xa9</td>\n <td class="b" width="50">\xd1\x89</td>\n <td class="b" width="50">\xd3\x83</td>\n </tr>\n </tbody>\n </table>\n\n '

请注意,西里尔字母是以多字节代码的形式读入的,使用\符号,例如\xd0\x89

为了纠正这一点,我们可以使用 Python 的str语句将内容转换为 UTF-8 格式:

In [9]: str(content, "utf-8")[837:1270]
Out[9]: '<strong>Cyrillic</strong> &nbsp; U+0400 – U+04FF &nbsp; (1024–1279)</p>\n <table class="unicode">\n <tbody>\n <tr valign="top">\n <td width="50">&nbsp;</td>\n <td class="b" width="50">Љ</td>\n <td class="b" width="50">Щ</td>\n <td class="b" width="50">щ</td>\n <td class="b" width="50">Ӄ</td>\n </tr>\n </tbody>\n </table>\n\n '

请注意,输出现在已经正确编码了字符。

我们可以通过使用requests来排除这一额外步骤。

In [9]: import requests
   ...: response = requests.get("http://localhost:8080/unicode.html").text
   ...: response.text[837:1270]
   ...:
'<strong>Cyrillic</strong> &nbsp; U+0400 – U+04FF &nbsp; (1024–1279)</p>\n <table class="unicode">\n <tbody>\n <tr valign="top">\n <td width="50">&nbsp;</td>\n <td class="b" width="50">Љ</td>\n <td class="b" width="50">Щ</td>\n <td class="b" width="50">щ</td>\n <td class="b" width="50">Ӄ</td>\n </tr>\n </tbody>\n </table>\n\n '

它是如何工作的

在使用urlopen时,通过使用 str 语句并指定应将内容转换为 UTF-8 来明确执行了转换。对于requests,该库能够通过在文档中看到以下标记来确定 HTML 中的内容是以 UTF-8 格式编码的:

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

还有更多...

互联网上有许多关于 Unicode 和 UTF-8 编码技术的资源。也许最好的是以下维基百科文章,其中有一个很好的摘要和描述编码技术的表格:en.wikipedia.org/wiki/UTF-8

第三章:处理数据

在本章中,我们将涵盖:

  • 使用 CSV 和 JSON 数据

  • 使用 AWS S3 存储数据

  • 使用 MySQL 存储数据

  • 使用 PostgreSQL 存储数据

  • 使用 Elasticsearch 存储数据

  • 如何使用 AWS SQS 构建健壮的 ETL 管道

介绍

在本章中,我们将介绍 JSON、CSV 和 XML 格式的数据使用。这将包括解析和将这些数据转换为其他格式的方法,包括将数据存储在关系数据库、Elasticsearch 等搜索引擎以及包括 AWS S3 在内的云存储中。我们还将讨论通过使用 AWS Simple Queue Service(SQS)等消息系统创建分布式和大规模的抓取任务。目标是既了解您可能检索和需要解析的各种数据形式,又了解可以存储您已抓取的数据的各种后端。最后,我们首次介绍了 Amazon Web Service(AWS)的一项服务。在本书结束时,我们将深入研究 AWS,并进行初步介绍。

使用 CSV 和 JSON 数据

从 HTML 页面中提取数据是使用上一章节中的技术完成的,主要是使用 XPath 通过各种工具和 Beautiful Soup。虽然我们主要关注 HTML,但 HTML 是 XML(可扩展标记语言)的一种变体。XML 曾经是在 Web 上表达数据的最流行形式之一,但其他形式已经变得流行,甚至超过了 XML。

您将看到的两种常见格式是 JSON(JavaScript 对象表示)和 CSV(逗号分隔值)。CSV 易于创建,是许多电子表格应用程序的常见形式,因此许多网站提供该格式的数据,或者您需要将抓取的数据转换为该格式以进行进一步存储或协作。由于 JSON 易于在 JavaScript(和 Python)等编程语言中使用,并且许多数据库现在支持它作为本机数据格式,因此 JSON 确实已成为首选格式。

在这个示例中,让我们来看看将抓取的数据转换为 CSV 和 JSON,以及将数据写入文件,以及从远程服务器读取这些数据文件。我们将研究 Python CSV 和 JSON 库。我们还将研究使用pandas进行这些技术。

这些示例中还隐含了将 XML 数据转换为 CSV 和 JSON 的过程,因此我们不会为这些示例专门设置一个部分。

准备工作

我们将使用行星数据页面,并将该数据转换为 CSV 和 JSON 文件。让我们从将行星数据从页面加载到 Python 字典对象列表中开始。以下代码(在(03/get_planet_data.py)中找到)提供了执行此任务的函数,该函数将在整个章节中重复使用:

import requests
from bs4 import BeautifulSoup

def get_planet_data():
   html = requests.get("http://localhost:8080/planets.html").text
   soup = BeautifulSoup(html, "lxml")

   planet_trs = soup.html.body.div.table.findAll("tr", {"class": "planet"})

   def to_dict(tr):
      tds = tr.findAll("td")
      planet_data = dict()
      planet_data['Name'] = tds[1].text.strip()
      planet_data['Mass'] = tds[2].text.strip()
      planet_data['Radius'] = tds[3].text.strip()
      planet_data['Description'] = tds[4].text.strip()
      planet_data['MoreInfo'] = tds[5].findAll("a")[0]["href"].strip()
      return planet_data

   planets = [to_dict(tr) for tr in planet_trs]

   return planets

if __name__ == "__main__":
   print(get_planet_data())

运行脚本会产生以下输出(简要截断):

03 $python get_planet_data.py
[{'Name': 'Mercury', 'Mass': '0.330', 'Radius': '4879', 'Description': 'Named Mercurius by the Romans because it appears to move so swiftly.', 'MoreInfo': 'https://en.wikipedia.org/wiki/Mercury_(planet)'}, {'Name': 'Venus', 'Mass': '4.87', 'Radius': '12104', 'Description': 'Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the\r\n heavens. Other civilizations have named it for their god or goddess of love/war.', 'MoreInfo': 'https://en.wikipedia.org/wiki/Venus'}, {'Name': 'Earth', 'Mass': '5.97', 'Radius': '12756', 'Description': "The name Earth comes from the Indo-European base 'er,'which produced the Germanic noun 'ertho,' and ultimately German 'erde,'\r\n Dutch 'aarde,' Scandinavian 'jord,' and English 'earth.' Related forms include Greek 'eraze,' meaning\r\n 'on the ground,' and Welsh 'erw,' meaning 'a piece of land.'", 'MoreInfo': 'https://en.wikipedia.org/wiki/Earth'}, {'Name': 'Mars', 'Mass': '0.642', 'Radius': '6792', 'Description': 'Named by the Romans for their god of war because of its red, bloodlike color. Other civilizations also named this planet\r\n from this attribute; for example, the Egyptians named it "Her Desher," meaning "the red one."', 'MoreInfo':
...

可能需要安装 csv、json 和 pandas。您可以使用以下三个命令来完成:

pip install csv
pip install json
pip install pandas

如何做

我们将首先将行星数据转换为 CSV 文件。

  1. 这将使用csv执行。以下代码将行星数据写入 CSV 文件(代码在03/create_csv.py中):
import csv
from get_planet_data import get_planet_data

planets = get_planet_data()

with open('../../www/planets.csv', 'w+', newline='') as csvFile:
    writer = csv.writer(csvFile)
    writer.writerow(['Name', 'Mass', 'Radius', 'Description', 'MoreInfo'])
for planet in planets:
        writer.writerow([planet['Name'], planet['Mass'],planet['Radius'], planet['Description'], planet['MoreInfo']])

  1. 输出文件放入我们项目的 www 文件夹中。检查它,我们看到以下内容:
Name,Mass,Radius,Description,MoreInfo
Mercury,0.330,4879,Named Mercurius by the Romans because it appears to move so swiftly.,https://en.wikipedia.org/wiki/Mercury_(planet)
Venus,4.87,12104,Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the heavens. Other civilizations have named it for their god or goddess of love/war.,https://en.wikipedia.org/wiki/Venus
Earth,5.97,12756,"The name Earth comes from the Indo-European base 'er,'which produced the Germanic noun 'ertho,' and ultimately German 'erde,' Dutch 'aarde,' Scandinavian 'jord,' and English 'earth.' Related forms include Greek 'eraze,' meaning 'on the ground,' and Welsh 'erw,' meaning 'a piece of land.'",https://en.wikipedia.org/wiki/Earth
Mars,0.642,6792,"Named by the Romans for their god of war because of its red, bloodlike color. Other civilizations also named this planet from this attribute; for example, the Egyptians named it ""Her Desher,"" meaning ""the red one.""",https://en.wikipedia.org/wiki/Mars
Jupiter,1898,142984,The largest and most massive of the planets was named Zeus by the Greeks and Jupiter by the Romans; he was the most important deity in both pantheons.,https://en.wikipedia.org/wiki/Jupiter
Saturn,568,120536,"Roman name for the Greek Cronos, father of Zeus/Jupiter. Other civilizations have given different names to Saturn, which is the farthest planet from Earth that can be observed by the naked human eye. Most of its satellites were named for Titans who, according to Greek mythology, were brothers and sisters of Saturn.",https://en.wikipedia.org/wiki/Saturn
Uranus,86.8,51118,"Several astronomers, including Flamsteed and Le Monnier, had observed Uranus earlier but had recorded it as a fixed star. Herschel tried unsuccessfully to name his discovery ""Georgian Sidus"" after George III; the planet was named by Johann Bode in 1781 after the ancient Greek deity of the sky Uranus, the father of Kronos (Saturn) and grandfather of Zeus (Jupiter).",https://en.wikipedia.org/wiki/Uranus
Neptune,102,49528,"Neptune was ""predicted"" by John Couch Adams and Urbain Le Verrier who, independently, were able to account for the irregularities in the motion of Uranus by correctly predicting the orbital elements of a trans- Uranian body. Using the predicted parameters of Le Verrier (Adams never published his predictions), Johann Galle observed the planet in 1846\. Galle wanted to name the planet for Le Verrier, but that was not acceptable to the international astronomical community. Instead, this planet is named for the Roman god of the sea.",https://en.wikipedia.org/wiki/Neptune
Pluto,0.0146,2370,"Pluto was discovered at Lowell Observatory in Flagstaff, AZ during a systematic search for a trans-Neptune planet predicted by Percival Lowell and William H. Pickering. Named after the Roman god of the underworld who was able to render himself invisible.",https://en.wikipedia.org/wiki/Pluto

我们将这个文件写入 www 目录,以便我们可以通过我们的 Web 服务器下载它。

  1. 现在可以在支持 CSV 内容的应用程序中使用这些数据,例如 Excel:

在 Excel 中打开的文件

  1. 还可以使用csv库从 Web 服务器读取 CSV 数据,并首先使用requests检索内容。以下代码在03/read_csv_from_web.py中:
import requests
import csv

planets_data = requests.get("http://localhost:8080/planets.csv").text
planets = planets_data.split('\n')
reader = csv.reader(planets, delimiter=',', quotechar='"')
lines = [line for line in reader][:-1]
for line in lines: print(line)

以下是部分输出

['Name', 'Mass', 'Radius', 'Description', 'MoreInfo']
['Mercury', '0.330', '4879', 'Named Mercurius by the Romans because it appears to move so swiftly.', 'https://en.wikipedia.org/wiki/Mercury_(planet)']
['Venus', '4.87', '12104', 'Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the heavens. Other civilizations have named it for their god or goddess of love/war.', 'https://en.wikipedia.org/wiki/Venus']
['Earth', '5.97', '12756', "The name Earth comes from the Indo-European base 'er,'which produced the Germanic noun 'ertho,' and ultimately German 'erde,' Dutch 'aarde,' Scandinavian 'jord,' and English 'earth.' Related forms include Greek 'eraze,' meaning 'on the ground,' and Welsh 'erw,' meaning 'a piece of land.'", 'https://en.wikipedia.org/wiki/Earth']

有一点要指出的是,CSV 写入器留下了一个尾随空白,如果不处理,就会添加一个空列表项。这是通过切片行来处理的:以下语句返回除最后一行之外的所有行:

lines = [line for line in reader][:-1]

  1. 这也可以很容易地使用 pandas 完成。以下从抓取的数据构造一个 DataFrame。代码在03/create_df_planets.py中:
import pandas as pd
planets_df = pd.read_csv("http://localhost:8080/planets_pandas.csv", index_col='Name')
print(planets_df)

运行此命令将产生以下输出:

                                               Description Mass Radius
Name 
Mercury Named Mercurius by the Romans because it appea...  0.330 4879
Venus   Roman name for the goddess of love. This plane...   4.87 12104
Earth   The name Earth comes from the Indo-European ba...   5.97 12756
Mars    Named by the Romans for their god of war becau...  0.642 6792
Jupiter The largest and most massive of the planets wa...   1898 142984
Saturn  Roman name for the Greek Cronos, father of Zeu...    568 120536
Uranus  Several astronomers, including Flamsteed and L...   86.8 51118
Neptune Neptune was "predicted" by John Couch Adams an...    102 49528
Pluto   Pluto was discovered at Lowell Observatory in ... 0.0146 2370
  1. DataFrame也可以通过简单调用.to_csv()保存到 CSV 文件中(代码在03/save_csv_pandas.py中):
import pandas as pd
from get_planet_data import get_planet_data

# construct a data from from the list planets = get_planet_data()
planets_df = pd.DataFrame(planets).set_index('Name')
planets_df.to_csv("../../www/planets_pandas.csv")
  1. 可以使用pd.read_csv()非常轻松地从URL中读取 CSV 文件,无需其他库。您可以使用03/read_csv_via_pandas.py中的代码:
import pandas as pd
planets_df = pd.read_csv("http://localhost:8080/planets_pandas.csv", index_col='Name')
print(planets_df)
  1. 将数据转换为 JSON 也非常容易。使用 Python 可以使用 Python 的json库对 JSON 进行操作。该库可用于将 Python 对象转换为 JSON,也可以从 JSON 转换为 Python 对象。以下将行星列表转换为 JSON 并将其打印到控制台:将行星数据打印为 JSON(代码在03/convert_to_json.py中):
import json
from get_planet_data import get_planet_data
planets=get_planet_data()
print(json.dumps(planets, indent=4))

执行此脚本将产生以下输出(省略了部分输出):

[
    {
        "Name": "Mercury",
        "Mass": "0.330",
        "Radius": "4879",
        "Description": "Named Mercurius by the Romans because it appears to move so swiftly.",
        "MoreInfo": "https://en.wikipedia.org/wiki/Mercury_(planet)"
    },
    {
        "Name": "Venus",
        "Mass": "4.87",
        "Radius": "12104",
        "Description": "Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the heavens. Other civilizations have named it for their god or goddess of love/war.",
        "MoreInfo": "https://en.wikipedia.org/wiki/Venus"
    },
  1. 这也可以用于轻松地将 JSON 保存到文件(03/save_as_json.py):
import json
from get_planet_data import get_planet_data
planets=get_planet_data()
with open('../../www/planets.json', 'w+') as jsonFile:
   json.dump(planets, jsonFile, indent=4)
  1. 使用!head -n 13 ../../www/planets.json检查输出,显示:
[
    {
        "Name": "Mercury",
        "Mass": "0.330",
        "Radius": "4879",
        "Description": "Named Mercurius by the Romans because it appears to move so swiftly.",
        "MoreInfo": "https://en.wikipedia.org/wiki/Mercury_(planet)"
    },
    {
        "Name": "Venus",
        "Mass": "4.87",
        "Radius": "12104",
        "Description": "Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the heavens. Other civilizations have named it for their god or goddess of love/war.",
  1. 可以使用requests从 Web 服务器读取 JSON 并将其转换为 Python 对象(03/read_http_json_requests.py):
import requests
import json

planets_request = requests.get("http://localhost:8080/planets.json")
print(json.loads(planets_request.text))
  1. pandas 还提供了将 JSON 保存为 CSV 的功能(03/save_json_pandas.py):
import pandas as pd
from get_planet_data import get_planet_data

planets = get_planet_data()
planets_df = pd.DataFrame(planets).set_index('Name')
planets_df.reset_index().to_json("../../www/planets_pandas.json", orient='records')

不幸的是,目前还没有一种方法可以漂亮地打印从.to_json()输出的 JSON。还要注意使用orient='records'和使用rest_index()。这对于复制与使用 JSON 库示例写入的相同 JSON 结构是必要的。

  1. 可以使用.read_json()将 JSON 读入 DataFrame,也可以从 HTTP 和文件中读取(03/read_json_http_pandas.py):
import pandas as pd
planets_df = pd.read_json("http://localhost:8080/planets_pandas.json").set_index('Name')
print(planets_df)

工作原理

csvjson库是 Python 的标准部分,提供了一种简单的方法来读取和写入这两种格式的数据。

在某些 Python 发行版中,pandas 并不是标准配置,您可能需要安装它。pandas 对 CSV 和 JSON 的功能也更高级,提供了许多强大的数据操作,还支持从远程服务器访问数据。

还有更多...

选择 csv、json 或 pandas 库由您决定,但我倾向于喜欢 pandas,并且我们将在整本书中更多地研究其在抓取中的使用,尽管我们不会深入研究其用法。

要深入了解 pandas,请查看pandas.pydata.org,或者阅读我在 Packt 出版的另一本书《Learning pandas, 2ed》。

有关 csv 库的更多信息,请参阅docs.python.org/3/library/csv.html

有关 json 库的更多信息,请参阅docs.python.org/3/library/json.html

使用 AWS S3 存储数据

有许多情况下,我们只想将我们抓取的内容保存到本地副本以进行存档、备份或以后进行批量分析。我们还可能希望保存这些网站的媒体以供以后使用。我为广告合规公司构建了爬虫,我们会跟踪并下载网站上基于广告的媒体,以确保正确使用,并且以供以后分析、合规和转码。

这些类型系统所需的存储空间可能是巨大的,但随着云存储服务(如 AWS S3(简单存储服务))的出现,这比在您自己的 IT 部门中管理大型 SAN(存储区域网络)要容易得多,成本也更低。此外,S3 还可以自动将数据从热存储移动到冷存储,然后再移动到长期存储,例如冰川,这可以为您节省更多的钱。

我们不会深入研究所有这些细节,而只是看看如何将我们的planets.html文件存储到 S3 存储桶中。一旦您能做到这一点,您就可以保存任何您想要的内容。

准备就绪

要执行以下示例,您需要一个 AWS 账户,并且可以访问用于 Python 代码的密钥。它们将是您账户的唯一密钥。我们将使用boto3库来访问 S3。您可以使用pip install boto3来安装它。此外,您需要设置环境变量进行身份验证。它们看起来像下面这样:

AWS_ACCESS_KEY_ID=AKIAIDCQ5PH3UMWKZEWA

AWS_SECRET_ACCESS_KEY=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

这些可以在 AWS 门户的 IAM(身份访问管理)部分找到。

将这些密钥放在环境变量中是一个好习惯。在代码中使用它们可能会导致它们被盗。在编写本书时,我将它们硬编码并意外地将它们检入 GitHub。第二天早上,我醒来收到了来自 AWS 的关键消息,说我有成千上万台服务器在运行!GitHub 有爬虫在寻找这些密钥,它们会被找到并用于不正当目的。等我把它们全部关闭的时候,我的账单已经涨到了 6000 美元,全部是在一夜之间产生的。幸运的是,AWS 免除了这些费用!

如何做到这一点

我们不会解析planets.html文件中的数据,而只是使用 requests 从本地 web 服务器检索它:

  1. 以下代码(在03/S3.py中找到)读取行星网页并将其存储在 S3 中:
import requests
import boto3

data = requests.get("http://localhost:8080/planets.html").text

# create S3 client, use environment variables for keys s3 = boto3.client('s3')

# the bucket bucket_name = "planets-content"   # create bucket, set s3.create_bucket(Bucket=bucket_name, ACL='public-read')
s3.put_object(Bucket=bucket_name, Key='planet.html',
              Body=data, ACL="public-read")
  1. 这个应用程序将给出类似以下的输出,这是 S3 信息,告诉您关于新项目的各种事实。

{'ETag': '"3ada9dcd8933470221936534abbf7f3e"',
 'ResponseMetadata': {'HTTPHeaders': {'content-length': '0',
   'date': 'Sun, 27 Aug 2017 19:25:54 GMT',
   'etag': '"3ada9dcd8933470221936534abbf7f3e"',
   'server': 'AmazonS3',
   'x-amz-id-2': '57BkfScql637op1dIXqJ7TeTmMyjVPk07cAMNVqE7C8jKsb7nRO+0GSbkkLWUBWh81k+q2nMQnE=',
   'x-amz-request-id': 'D8446EDC6CBA4416'},
  'HTTPStatusCode': 200,
  'HostId': '57BkfScql637op1dIXqJ7TeTmMyjVPk07cAMNVqE7C8jKsb7nRO+0GSbkkLWUBWh81k+q2nMQnE=',
  'RequestId': 'D8446EDC6CBA4416',
  'RetryAttempts': 0}}
  1. 这个输出告诉我们对象已成功创建在存储桶中。此时,您可以转到 S3 控制台并查看您的存储桶:

S3 中的存储桶

  1. 在存储桶中,您将看到planet.html文件:

存储桶中的文件

  1. 通过点击文件,您可以看到 S3 中文件的属性和 URL:

S3 中文件的属性

它是如何工作的

boto3 库以 Pythonic 语法封装了 AWS S3 API。.client()调用与 AWS 进行身份验证,并为我们提供了一个用于与 S3 通信的对象。确保您的密钥在环境变量中,否则这将无法工作。

存储桶名称必须是全局唯一的。在撰写本文时,这个存储桶是可用的,但您可能需要更改名称。.create_bucket()调用创建存储桶并设置其 ACL。put_object()使用boto3上传管理器将抓取的数据上传到存储桶中的对象。

还有更多...

有很多细节需要学习来使用 S3。您可以在以下网址找到 API 文档:docs.aws.amazon.com/AmazonS3/latest/API/Welcome.html。Boto3 文档可以在以下网址找到:boto3.readthedocs.io/en/latest/

虽然我们只保存了一个网页,但这个模型可以用来在 S3 中存储任何类型的基于文件的数据。

使用 MySQL 存储数据

MySQL 是一个免费的、开源的关系数据库管理系统(RDBMS)。在这个例子中,我们将从网站读取行星数据并将其存储到 MySQL 数据库中。

准备工作

您需要访问一个 MySQL 数据库。您可以在本地安装一个,也可以在云中安装,也可以在容器中安装。我正在使用本地安装的 MySQL 服务器,并且将root密码设置为mypassword。您还需要安装 MySQL python 库。您可以使用pip install mysql-connector-python来安装它。

  1. 首先要做的是使用终端上的mysql命令连接到数据库:
# mysql -uroot -pmypassword
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 4
Server version: 5.7.19 MySQL Community Server (GPL)

Copyright (c) 2000, 2017, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>
  1. 现在我们可以创建一个数据库,用来存储我们抓取的信息:
mysql> create database scraping;
Query OK, 1 row affected (0.00 sec)
  1. 现在使用新的数据库:
mysql> use scraping;
Database changed
  1. 并在数据库中创建一个行星表来存储我们的数据:

mysql> CREATE TABLE `scraping`.`planets` (
 `id` INT NOT NULL AUTO_INCREMENT,
 `name` VARCHAR(45) NOT NULL,
 `mass` FLOAT NOT NULL,
 `radius` FLOAT NOT NULL,
 `description` VARCHAR(5000) NULL,
 PRIMARY KEY (`id`));
Query OK, 0 rows affected (0.02 sec)

现在我们准备好抓取数据并将其放入 MySQL 数据库中。

如何做到这一点

  1. 以下代码(在03/store_in_mysql.py中找到)将读取行星数据并将其写入 MySQL:
import mysql.connector
import get_planet_data
from mysql.connector import errorcode
from get_planet_data import get_planet_data

try:
    # open the database connection
    cnx = mysql.connector.connect(user='root', password='mypassword',
                                  host="127.0.0.1", database="scraping")

    insert_sql = ("INSERT INTO Planets (Name, Mass, Radius, Description) " +
                  "VALUES (%(Name)s, %(Mass)s, %(Radius)s, %(Description)s)")

    # get the planet data
    planet_data = get_planet_data()

    # loop through all planets executing INSERT for each with the cursor
    cursor = cnx.cursor()
    for planet in planet_data:
        print("Storing data for %s" % (planet["Name"]))
        cursor.execute(insert_sql, planet)

    # commit the new records
    cnx.commit()

    # close the cursor and connection
    cursor.close()
    cnx.close()

except mysql.connector.Error as err:
    if err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
        print("Something is wrong with your user name or password")
    elif err.errno == errorcode.ER_BAD_DB_ERROR:
        print("Database does not exist")
    else:
        print(err)
else:
    cnx.close()
  1. 这将产生以下输出:
Storing data for Mercury
Storing data for Venus
Storing data for Earth
Storing data for Mars
Storing data for Jupiter
Storing data for Saturn
Storing data for Uranus
Storing data for Neptune
Storing data for Pluto
  1. 使用 MySQL Workbench,我们可以看到记录已写入数据库(您也可以使用 mysql 命令行):

使用 MySQL Workbench 显示的记录

  1. 以下代码可用于检索数据(03/read_from_mysql.py):
import mysql.connector
from mysql.connector import errorcode

try:
  cnx = mysql.connector.connect(user='root', password='mypassword',
                  host="127.0.0.1", database="scraping")
  cursor = cnx.cursor(dictionary=False)

  cursor.execute("SELECT * FROM scraping.Planets")
  for row in cursor:
    print(row)

  # close the cursor and connection
  cursor.close()
  cnx.close()

except mysql.connector.Error as err:
  if err.errno == errorcode.ER_ACCESS_DENIED_ERROR:
    print("Something is wrong with your user name or password")
  elif err.errno == errorcode.ER_BAD_DB_ERROR:
    print("Database does not exist")
  else:
    print(err)
finally:
  cnx.close()

  1. 这将产生以下输出:
(1, 'Mercury', 0.33, 4879.0, 'Named Mercurius by the Romans because it appears to move so swiftly.', 'https://en.wikipedia.org/wiki/Mercury_(planet)')
(2, 'Venus', 4.87, 12104.0, 'Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the heavens. Other civilizations have named it for their god or goddess of love/war.', 'https://en.wikipedia.org/wiki/Venus')
(3, 'Earth', 5.97, 12756.0, "The name Earth comes from the Indo-European base 'er,'which produced the Germanic noun 'ertho,' and ultimately German 'erde,' Dutch 'aarde,' Scandinavian 'jord,' and English 'earth.' Related forms include Greek 'eraze,' meaning 'on the ground,' and Welsh 'erw,' meaning 'a piece of land.'", 'https://en.wikipedia.org/wiki/Earth')
(4, 'Mars', 0.642, 6792.0, 'Named by the Romans for their god of war because of its red, bloodlike color. Other civilizations also named this planet from this attribute; for example, the Egyptians named it "Her Desher," meaning "the red one."', 'https://en.wikipedia.org/wiki/Mars')
(5, 'Jupiter', 1898.0, 142984.0, 'The largest and most massive of the planets was named Zeus by the Greeks and Jupiter by the Romans; he was the most important deity in both pantheons.', 'https://en.wikipedia.org/wiki/Jupiter')
(6, 'Saturn', 568.0, 120536.0, 'Roman name for the Greek Cronos, father of Zeus/Jupiter. Other civilizations have given different names to Saturn, which is the farthest planet from Earth that can be observed by the naked human eye. Most of its satellites were named for Titans who, according to Greek mythology, were brothers and sisters of Saturn.', 'https://en.wikipedia.org/wiki/Saturn')
(7, 'Uranus', 86.8, 51118.0, 'Several astronomers, including Flamsteed and Le Monnier, had observed Uranus earlier but had recorded it as a fixed star. Herschel tried unsuccessfully to name his discovery "Georgian Sidus" after George III; the planet was named by Johann Bode in 1781 after the ancient Greek deity of the sky Uranus, the father of Kronos (Saturn) and grandfather of Zeus (Jupiter).', 'https://en.wikipedia.org/wiki/Uranus')
(8, 'Neptune', 102.0, 49528.0, 'Neptune was "predicted" by John Couch Adams and Urbain Le Verrier who, independently, were able to account for the irregularities in the motion of Uranus by correctly predicting the orbital elements of a trans- Uranian body. Using the predicted parameters of Le Verrier (Adams never published his predictions), Johann Galle observed the planet in 1846\. Galle wanted to name the planet for Le Verrier, but that was not acceptable to the international astronomical community. Instead, this planet is named for the Roman god of the sea.', 'https://en.wikipedia.org/wiki/Neptune')
(9, 'Pluto', 0.0146, 2370.0, 'Pluto was discovered at Lowell Observatory in Flagstaff, AZ during a systematic search for a trans-Neptune planet predicted by Percival Lowell and William H. Pickering. Named after the Roman god of the underworld who was able to render himself invisible.', 'https://en.wikipedia.org/wiki/Pluto')

工作原理

使用mysql.connector访问 MySQL 数据库涉及使用库中的两个类:connectcursorconnect类打开并管理与数据库服务器的连接。从该连接对象,我们可以创建一个光标对象。该光标用于使用 SQL 语句读取和写入数据。

在第一个例子中,我们使用光标将九条记录插入数据库。直到调用连接的commit()方法,这些记录才会被写入数据库。这将执行将所有行写入数据库的操作。

读取数据使用类似的模型,只是我们使用光标执行 SQL 查询(SELECT),并遍历检索到的行。由于我们是在读取而不是写入,因此无需在连接上调用commit()

还有更多...

您可以从以下网址了解更多关于 MySQL 并安装它:https://dev.mysql.com/doc/refman/5.7/en/installing.html。有关 MySQL Workbench 的信息,请访问:https://dev.mysql.com/doc/workbench/en/

使用 PostgreSQL 存储数据

在这个示例中,我们将我们的行星数据存储在 PostgreSQL 中。PostgreSQL 是一个开源的关系数据库管理系统(RDBMS)。它由一个全球志愿者团队开发,不受任何公司或其他私人实体控制,源代码可以免费获得。它具有许多独特的功能,如分层数据模型。

准备工作

首先确保您可以访问 PostgreSQL 数据实例。同样,您可以在本地安装一个,运行一个容器,或者在云中获取一个实例。

与 MySQL 一样,我们需要首先创建一个数据库。该过程与 MySQL 几乎相同,但命令和参数略有不同。

  1. 从终端执行终端上的 psql 命令。这将带您进入 psql 命令处理器:
# psql -U postgres psql (9.6.4) Type "help" for help. postgres=# 
  1. 现在创建抓取数据库:
postgres=# create database scraping;
CREATE DATABASE
postgres=#
  1. 然后切换到新数据库:
postgres=# \connect scraping You are now connected to database "scraping" as user "postgres". scraping=# 
  1. 现在我们可以创建 Planets 表。我们首先需要创建一个序列表:
scraping=# CREATE SEQUENCE public."Planets_id_seq" scraping-#  INCREMENT 1 scraping-#  START 1 scraping-#  MINVALUE 1 scraping-#  MAXVALUE 9223372036854775807 scraping-#  CACHE 1; CREATE SEQUENCE scraping=# ALTER SEQUENCE public."Planets_id_seq" scraping-#  OWNER TO postgres; ALTER SEQUENCE scraping=# 
  1. 现在我们可以创建表:
scraping=# CREATE TABLE public."Planets" scraping-# ( scraping(# id integer NOT NULL DEFAULT nextval('"Planets_id_seq"'::regclass), scraping(# name text COLLATE pg_catalog."default" NOT NULL, scraping(# mass double precision NOT NULL, scraping(# radius double precision NOT NULL, scraping(# description text COLLATE pg_catalog."default" NOT NULL, scraping(# moreinfo text COLLATE pg_catalog."default" NOT NULL, scraping(# CONSTRAINT "Planets_pkey" PRIMARY KEY (name) scraping(# ) scraping-# WITH ( scraping(# OIDS = FALSE scraping(# )
</span>scraping-# TABLESPACE pg_default; CREATE TABLE scraping=# scraping=# ALTER TABLE public."Planets" scraping-# OWNER to postgres; ALTER TABLE scraping=# \q

要从 Python 访问 PostgreSQL,我们将使用psycopg2库,因此请确保在 Python 环境中安装了它,使用pip install psycopg2

我们现在准备好编写 Python 将行星数据存储在 PostgreSQL 中。

如何操作

我们按照以下步骤进行:

  1. 以下代码将读取行星数据并将其写入数据库(代码在03/save_in_postgres.py中):
import psycopg2
from get_planet_data import get_planet_data

try:
  # connect to PostgreSQL
  conn = psycopg2.connect("dbname='scraping' host='localhost' user='postgres' password='mypassword'")

  # the SQL INSERT statement we will use
  insert_sql = ('INSERT INTO public."Planets"(name, mass, radius, description, moreinfo) ' +
          'VALUES (%(Name)s, %(Mass)s, %(Radius)s, %(Description)s, %(MoreInfo)s);')

  # open a cursor to access data
  cur = conn.cursor()

  # get the planets data and loop through each
  planet_data = get_planet_data()
  for planet in planet_data:
    # write each record
    cur.execute(insert_sql, planet)

  # commit the new records to the database
  conn.commit()
  cur.close()
  conn.close()

  print("Successfully wrote data to the database")

except Exception as ex:
  print(ex)

  1. 如果成功,您将看到以下内容:
Successfully wrote data to the database
  1. 使用诸如 pgAdmin 之类的 GUI 工具,您可以检查数据库中的数据:

在 pgAdmin 中显示的记录

  1. 可以使用以下 Python 代码查询数据(在03/read_from_postgresql.py中找到):
import psycopg2

try:
  conn = psycopg2.connect("dbname='scraping' host='localhost' user='postgres' password='mypassword'")

  cur = conn.cursor()
  cur.execute('SELECT * from public."Planets"')
  rows = cur.fetchall()
  print(rows)

  cur.close()
  conn.close()

except Exception as ex:
  print(ex)

  1. 并导致以下输出(略有截断:
(1, 'Mercury', 0.33, 4879.0, 'Named Mercurius by the Romans because it appears to move so swiftly.', 'https://en.wikipedia.org/wiki/Mercury_(planet)'), (2, 'Venus', 4.87, 12104.0, 'Roman name for the goddess of love. This planet was considered to be the brightest and most beautiful planet or star in the heavens. Other civilizations have named it for their god or goddess of love/war.', 'https://en.wikipedia.org/wiki/Venus'), (3, 'Earth', 5.97, 12756.0, "The name Earth comes from the Indo-European base 'er,'which produced the Germanic noun 'ertho,' and ultimately German 'erde,' Dutch 'aarde,' Scandinavian 'jord,' and English 'earth.' Related forms include Greek 'eraze,' meaning 'on the ground,' and Welsh 'erw,' meaning 'a piece of land.'", 'https://en.wikipedia.org/wiki/Earth'), (4, 'Mars', 0.642, 6792.0, 'Named by the Romans for their god of war because of its red, bloodlike color. Other civilizations also named this planet from this attribute; for example, the Egyptians named it 

工作原理

使用psycopg2库访问 PostgreSQL 数据库涉及使用库中的两个类:connectcursorconnect类打开并管理与数据库服务器的连接。从该连接对象,我们可以创建一个cursor对象。该光标用于使用 SQL 语句读取和写入数据。

在第一个例子中,我们使用光标将九条记录插入数据库。直到调用连接的commit()方法,这些记录才会被写入数据库。这将执行将所有行写入数据库的操作。

读取数据使用类似的模型,只是我们使用游标执行 SQL 查询(SELECT),并遍历检索到的行。由于我们是在读取而不是写入,所以不需要在连接上调用commit()

还有更多...

有关 PostgreSQL 的信息可在https://www.postgresql.org/找到。pgAdmin 可以在https://www.pgadmin.org/获得。psycopg的参考资料位于http://initd.org/psycopg/docs/usage.html

在 Elasticsearch 中存储数据

Elasticsearch 是基于 Lucene 的搜索引擎。它提供了一个分布式、多租户能力的全文搜索引擎,具有 HTTP Web 界面和无模式的 JSON 文档。它是一个非关系型数据库(通常称为 NoSQL),专注于存储文档而不是记录。这些文档可以是许多格式之一,其中之一对我们有用:JSON。这使得使用 Elasticsearch 非常简单,因为我们不需要将我们的数据转换为/从 JSON。我们将在本书的后面更多地使用 Elasticsearch

现在,让我们去将我们的行星数据存储在 Elasticsearch 中。

准备就绪

我们将访问一个本地安装的 Elasticsearch 服务器。为此,我们将使用Elasticsearch-py库从 Python 中进行操作。您很可能需要使用 pip 来安装它:pip install elasticsearch

与 PostgreSQL 和 MySQL 不同,我们不需要提前在 Elasticsearch 中创建表。Elasticsearch 不关心结构化数据模式(尽管它确实有索引),因此我们不必经历这个过程。

如何做到

将数据写入 Elasticsearch 非常简单。以下 Python 代码使用我们的行星数据执行此任务(03/write_to_elasticsearch.py):

from elasticsearch import Elasticsearch
from get_planet_data import get_planet_data

# create an elastic search object
es = Elasticsearch()

# get the data
planet_data = get_planet_data()

for planet in planet_data:
  # insert each planet into elasticsearch server
  res = es.index(index='planets', doc_type='planets_info', body=planet)
  print (res)

执行此操作将产生以下输出:

{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF3_T0Z2t9T850q6', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF5QT0Z2t9T850q7', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF5XT0Z2t9T850q8', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF5fT0Z2t9T850q9', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF5mT0Z2t9T850q-', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF5rT0Z2t9T850q_', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF50T0Z2t9T850rA', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF56T0Z2t9T850rB', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}
{'_index': 'planets', '_type': 'planets_info', '_id': 'AV4qIF6AT0Z2t9T850rC', '_version': 1, 'result': 'created', '_shards': {'total': 2, 'successful': 1, 'failed': 0}, 'created': True}

输出显示了每次插入的结果,为我们提供了 elasticsearch 分配给文档的_id等信息。

如果您也安装了 logstash 和 kibana,您可以在 Kibana 内部看到数据:

![Kibana 显示和索引

我们可以使用以下 Python 代码查询数据。此代码检索“planets”索引中的所有文档,并打印每个行星的名称、质量和半径(03/read_from_elasticsearch.py):

from elasticsearch import Elasticsearch

# create an elastic search object
es = Elasticsearch()

res = es.search(index="planets", body={"query": {"match_all": {}}})

print("Got %d Hits:" % res['hits']['total'])
for hit in res['hits']['hits']:
 print("%(Name)s %(Mass)s: %(Radius)s" % hit["_source"])Got 9 Hits:

这将产生以下输出:

Mercury 0.330: 4879
Mars 0.642: 6792
Venus 4.87: 12104
Saturn 568: 120536
Pluto 0.0146: 2370
Earth 5.97: 12756
Uranus 86.8: 51118
Jupiter 1898: 142984
Neptune 102: 49528

它是如何工作的

Elasticsearch 既是 NoSQL 数据库又是搜索引擎。您将文档提供给 Elasticsearch,它会解析文档中的数据并自动为该数据创建搜索索引。

在插入过程中,我们使用了elasticsearch库的.index()方法,并指定了一个名为“planets”的索引,一个文档类型planets_info,最后是文档的主体,即我们的行星 Python 对象。elasticsearch库将该对象转换为 JSON 并将其发送到 Elasticsearch 进行存储和索引。

索引参数用于通知 Elasticsearch 如何创建索引,它将用于索引和我们在查询时可以用来指定要搜索的一组文档。当我们执行查询时,我们指定了相同的索引“planets”并执行了一个匹配所有文档的查询。

还有更多...

您可以在https://www.elastic.co/products/elasticsearch找到有关 elasticsearch 的更多信息。有关 python API 的信息可以在http://pyelasticsearch.readthedocs.io/en/latest/api/找到

我们还将在本书的后面章节回到 Elasticsearch。

如何使用 AWS SQS 构建强大的 ETL 管道

爬取大量站点和数据可能是一个复杂和缓慢的过程。但它可以充分利用并行处理,无论是在本地使用多个处理器线程,还是使用消息队列系统将爬取请求分发给报告爬虫。在类似于提取、转换和加载流水线(ETL)的过程中,可能还需要多个步骤。这些流水线也可以很容易地使用消息队列架构与爬取相结合来构建。

使用消息队列架构给我们的流水线带来了两个优势:

  • 健壮性

  • 可伸缩性

处理变得健壮,因为如果处理单个消息失败,那么消息可以重新排队进行处理。因此,如果爬虫失败,我们可以重新启动它,而不会丢失对页面进行爬取的请求,或者消息队列系统将把请求传递给另一个爬虫。

它提供了可伸缩性,因为在同一系统或不同系统上可以监听队列上的多个爬虫。然后,可以在不同的核心或更重要的是不同的系统上同时处理多个消息。在基于云的爬虫中,您可以根据需要扩展爬虫实例的数量以处理更大的负载。

可以使用的常见消息队列系统包括:Kafka、RabbitMQ 和 Amazon SQS。我们的示例将利用 Amazon SQS,尽管 Kafka 和 RabbitMQ 都非常适合使用(我们将在本书的后面看到 RabbitMQ 的使用)。我们使用 SQS 来保持使用 AWS 基于云的服务的模式,就像我们在本章早些时候使用 S3 一样。

准备就绪

例如,我们将构建一个非常简单的 ETL 过程,该过程将读取主行星页面并将行星数据存储在 MySQL 中。它还将针对页面中的每个更多信息链接传递单个消息到队列中,其中 0 个或多个进程可以接收这些请求,并对这些链接执行进一步处理。

要从 Python 访问 SQS,我们将重新使用boto3库。

如何操作-将消息发布到 AWS 队列

03/create_messages.py文件包含了读取行星数据并将 URL 发布到 SQS 队列的代码:

from urllib.request import urlopen
from bs4 import BeautifulSoup

import boto3
import botocore

# declare our keys (normally, don't hard code this)
access_key="AKIAIXFTCYO7FEL55TCQ"
access_secret_key="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"

# create sqs client
sqs = boto3.client('sqs', "us-west-2",
                   aws_access_key_id = access_key, 
                   aws_secret_access_key = access_secret_key)

# create / open the SQS queue
queue = sqs.create_queue(QueueName="PlanetMoreInfo")
print (queue)

# read and parse the planets HTML
html = urlopen("http://127.0.0.1:8080/pages/planets.html")
bsobj = BeautifulSoup(html, "lxml")

planets = []
planet_rows = bsobj.html.body.div.table.findAll("tr", {"class": "planet"})

for i in planet_rows:
  tds = i.findAll("td")

  # get the URL
  more_info_url = tds[5].findAll("a")[0]["href"].strip()

  # send the URL to the queue
  sqs.send_message(QueueUrl=queue["QueueUrl"],
           MessageBody=more_info_url)
  print("Sent %s to %s" % (more_info_url, queue["QueueUrl"]))

在终端中运行代码,您将看到类似以下的输出:

{'QueueUrl': 'https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo', 'ResponseMetadata': {'RequestId': '2aad7964-292a-5bf6-b838-2b7a5007af22', 'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'Server', 'date': 'Mon, 28 Aug 2017 20:02:53 GMT', 'content-type': 'text/xml', 'content-length': '336', 'connection': 'keep-alive', 'x-amzn-requestid': '2aad7964-292a-5bf6-b838-2b7a5007af22'}, 'RetryAttempts': 0}} Sent https://en.wikipedia.org/wiki/Mercury_(planet) to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Venus to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Earth to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Mars to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Jupiter to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Saturn to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Uranus to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Neptune to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Sent https://en.wikipedia.org/wiki/Pluto to https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo

现在进入 AWS SQS 控制台。您应该看到队列已经被创建,并且它包含 9 条消息:

SQS 中的队列

工作原理

该代码连接到给定帐户和 AWS 的 us-west-2 地区。然后,如果队列不存在,则创建队列。然后,对于源内容中的每个行星,程序发送一个消息,该消息包含该行星的更多信息 URL。

此时,没有人在监听队列,因此消息将一直保留在那里,直到最终被读取或它们过期。每条消息的默认生存期为 4 天。

如何操作-读取和处理消息

要处理消息,请运行03/process_messages.py程序:

import boto3
import botocore
import requests
from bs4 import BeautifulSoup

print("Starting")

# declare our keys (normally, don't hard code this)
access_key = "AKIAIXFTCYO7FEL55TCQ"
access_secret_key = "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"

# create sqs client
sqs = boto3.client('sqs', "us-west-2", 
          aws_access_key_id = access_key, 
          aws_secret_access_key = access_secret_key)

print("Created client")

# create / open the SQS queue
queue = sqs.create_queue(QueueName="PlanetMoreInfo")
queue_url = queue["QueueUrl"]
print ("Opened queue: %s" % queue_url)

while True:
  print ("Attempting to receive messages")
  response = sqs.receive_message(QueueUrl=queue_url,
                 MaxNumberOfMessages=1,
                 WaitTimeSeconds=1)
  if not 'Messages' in response:
    print ("No messages")
    continue

  message = response['Messages'][0]
  receipt_handle = message['ReceiptHandle']
  url = message['Body']

  # parse the page
  html = requests.get(url)
  bsobj = BeautifulSoup(html.text, "lxml")

  # now find the planet name and albedo info
  planet=bsobj.findAll("h1", {"id": "firstHeading"} )[0].text
  albedo_node = bsobj.findAll("a", {"href": "/wiki/Geometric_albedo"})[0]
  root_albedo = albedo_node.parent
  albedo = root_albedo.text.strip()

  # delete the message from the queue
  sqs.delete_message(
    QueueUrl=queue_url,
    ReceiptHandle=receipt_handle
  )

  # print the planets name and albedo info
  print("%s: %s" % (planet, albedo))

使用python process_messages.py运行脚本。您将看到类似以下的输出:

Starting Created client Opened queue: https://us-west-2.queue.amazonaws.com/414704166289/PlanetMoreInfo Attempting to receive messages Jupiter: 0.343 (Bond) 0.52 (geom.)[3] Attempting to receive messages Mercury (planet): 0.142 (geom.)[10] Attempting to receive messages Uranus: 0.300 (Bond) 0.51 (geom.)[5] Attempting to receive messages Neptune: 0.290 (bond) 0.41 (geom.)[4] Attempting to receive messages Pluto: 0.49 to 0.66 (geometric, varies by 35%)[1][7] Attempting to receive messages Venus: 0.689 (geometric)[2] Attempting to receive messages Earth: 0.367 geometric[3] Attempting to receive messages Mars: 0.170 (geometric)[8] 0.25 (Bond)[7] Attempting to receive messages Saturn: 0.499 (geometric)[4] Attempting to receive messages No messages

工作原理

程序连接到 SQS 并打开队列。打开队列以进行读取也是使用sqs.create_queue完成的,如果队列已经存在,它将简单地返回队列。

然后,它进入一个循环调用sqs.receive_message,指定队列的 URL,每次读取消息的数量,以及如果没有消息可用时等待的最长时间(以秒为单位)。

如果读取了一条消息,将检索消息中的 URL,并使用爬取技术读取 URL 的页面并提取行星的名称和有关其反照率的信息。

请注意,我们会检索消息的接收处理。这是删除队列中的消息所必需的。如果我们不删除消息,它将在一段时间后重新出现在队列中。因此,如果我们的爬虫崩溃并且没有执行此确认,消息将由 SQS 再次提供给另一个爬虫进行处理(或者在其恢复正常时由相同的爬虫处理)。

还有更多...

您可以在以下网址找到有关 S3 的更多信息:https://aws.amazon.com/s3/。有关 API 详细信息的具体内容,请访问:https://aws.amazon.com/documentation/s3/

第四章:处理图像、音频和其他资产

在本章中,我们将涵盖:

  • 在网上下载媒体内容

  • 使用 urllib 解析 URL 以获取文件名

  • 确定 URL 的内容类型

  • 从内容类型确定文件扩展名

  • 下载并将图像保存到本地文件系统

  • 下载并将图像保存到 S3

  • 为图像生成缩略图

  • 使用 Selenium 进行网站截图

  • 使用外部服务对网站进行截图

  • 使用 pytessaract 对图像执行 OCR

  • 创建视频缩略图

  • 将 MP4 视频转换为 MP3

介绍

在抓取中的一个常见做法是下载、存储和进一步处理媒体内容(非网页或数据文件)。这些媒体可以包括图像、音频和视频。为了正确地将内容存储在本地(或在 S3 等服务中),我们需要知道媒体类型,并且仅仅信任 URL 中的文件扩展名是不够的。我们将学习如何根据来自 Web 服务器的信息下载和正确表示媒体类型。

另一个常见的任务是生成图像、视频甚至网站页面的缩略图。我们将研究如何生成缩略图并制作网站页面截图的几种技术。这些缩略图经常用作新网站上缩略图链接,以链接到现在存储在本地的抓取媒体。

最后,通常需要能够转码媒体,例如将非 MP4 视频转换为 MP4,或更改视频的比特率或分辨率。另一个场景是从视频文件中提取音频。我们不会讨论视频转码,但我们将使用ffmpeg从 MP4 文件中提取 MP3 音频。从那里开始,还可以使用ffmpeg转码视频。

从网上下载媒体内容

从网上下载媒体内容是一个简单的过程:使用 Requests 或其他库,就像下载 HTML 内容一样。

准备工作

解决方案的util文件夹中的urls.py模块中有一个名为URLUtility的类。该类处理本章中的几种场景,包括下载和解析 URL。我们将在这个配方和其他一些配方中使用这个类。确保modules文件夹在您的 Python 路径中。此外,此配方的示例位于04/01_download_image.py文件中。

如何做到这一点

以下是我们如何进行的步骤:

  1. URLUtility类可以从 URL 下载内容。配方文件中的代码如下:
import const
from util.urls import URLUtility

util = URLUtility(const.ApodEclipseImage())
print(len(util.data))
  1. 运行时,您将看到以下输出:
Reading URL: https://apod.nasa.gov/apod/image/1709/BT5643s.jpg
Read 171014 bytes
171014

示例读取了171014字节的数据。

它是如何工作的

URL 被定义为const模块中的常量const.ApodEclipseImage()

def ApodEclipseImage():
    return "https://apod.nasa.gov/apod/image/1709/BT5643s.jpg" 

URLUtility类的构造函数具有以下实现:

def __init__(self, url, readNow=True):
    """ Construct the object, parse the URL, and download now if specified"""
  self._url = url
    self._response = None
  self._parsed = urlparse(url)
    if readNow:
        self.read()

构造函数存储 URL,解析它,并使用read()方法下载文件。以下是read()方法的代码:

def read(self):
    self._response = urllib.request.urlopen(self._url)
    self._data = self._response.read()

该函数使用urlopen获取响应对象,然后读取流并将其存储为对象的属性。然后可以使用数据属性检索该数据:

@property def data(self):
    self.ensure_response()
    return self._data

然后,该代码简单地报告了该数据的长度,值为171014

还有更多...

这个类将用于其他任务,比如确定文件的内容类型、文件名和扩展名。接下来我们将研究解析 URL 以获取文件名。

使用 urllib 解析 URL 以获取文件名

从 URL 下载内容时,我们经常希望将其保存在文件中。通常情况下,将文件保存在 URL 中找到的文件名中就足够了。但是 URL 由许多片段组成,那么我们如何从 URL 中找到实际的文件名,特别是在文件名后经常有许多参数的情况下?

准备工作

我们将再次使用URLUtility类来完成这个任务。该配方的代码文件是04/02_parse_url.py

如何做到这一点

使用您的 Python 解释器执行配方文件。它将运行以下代码:

util = URLUtility(const.ApodEclipseImage())
print(util.filename_without_ext)

这导致以下输出:

Reading URL: https://apod.nasa.gov/apod/image/1709/BT5643s.jpg
Read 171014 bytes
The filename is: BT5643s

它是如何工作的

URLUtility的构造函数中,调用了urlib.parse.urlparse。 以下演示了交互式使用该函数:

>>> parsed = urlparse(const.ApodEclipseImage())
>>> parsed
ParseResult(scheme='https', netloc='apod.nasa.gov', path='/apod/image/1709/BT5643s.jpg', params='', query='', fragment='')

ParseResult对象包含 URL 的各个组件。 路径元素包含路径和文件名。 对.filename_without_ext属性的调用仅返回没有扩展名的文件名:

@property def filename_without_ext(self):
    filename = os.path.splitext(os.path.basename(self._parsed.path))[0]
    return filename

os.path.basename的调用仅返回路径的文件名部分(包括扩展名)。 os.path.splittext()然后分隔文件名和扩展名,并且该函数返回该元组/列表的第一个元素(文件名)。

还有更多...

这似乎有点奇怪,它没有将扩展名作为文件名的一部分返回。 这是因为我们不能假设我们收到的内容实际上与扩展名所暗示的类型匹配。 更准确的是使用 Web 服务器返回的标题来确定这一点。 这是我们下一个配方。

确定 URL 的内容类型

当从 Web 服务器获取内容的GET请求时,Web 服务器将返回许多标题,其中一个标识了内容的类型,从 Web 服务器的角度来看。 在这个配方中,我们学习如何使用它来确定 Web 服务器认为的内容类型。

做好准备

我们再次使用URLUtility类。 配方的代码在04/03_determine_content_type_from_response.py中。

如何做到这一点

我们按以下步骤进行:

  1. 执行配方的脚本。 它包含以下代码:
util = URLUtility(const.ApodEclipseImage())
print("The content type is: " + util.contenttype)
  1. 得到以下结果:
Reading URL: https://apod.nasa.gov/apod/image/1709/BT5643s.jpg
Read 171014 bytes
The content type is: image/jpeg

它是如何工作的

.contentype属性的实现如下:

@property def contenttype(self):
    self.ensure_response()
    return self._response.headers['content-type']

_response对象的.headers属性是一个类似字典的标题类。 content-type键将检索服务器指定的content-type。 对ensure_response()方法的调用只是确保已执行.read()函数。

还有更多...

响应中的标题包含大量信息。 如果我们更仔细地查看响应的headers属性,我们可以看到返回以下标题:

>>> response = urllib.request.urlopen(const.ApodEclipseImage())
>>> for header in response.headers: print(header)
Date
Server
Last-Modified
ETag
Accept-Ranges
Content-Length
Connection
Content-Type
Strict-Transport-Security

我们可以看到每个标题的值。

>>> for header in response.headers: print(header + " ==> " + response.headers[header])
Date ==> Tue, 26 Sep 2017 19:31:41 GMT
Server ==> WebServer/1.0
Last-Modified ==> Thu, 31 Aug 2017 20:26:32 GMT
ETag ==> "547bb44-29c06-5581275ce2b86"
Accept-Ranges ==> bytes
Content-Length ==> 171014
Connection ==> close
Content-Type ==> image/jpeg
Strict-Transport-Security ==> max-age=31536000; includeSubDomains

这本书中有许多我们不会讨论的内容,但对于不熟悉的人来说,知道它们存在是很好的。

从内容类型确定文件扩展名

使用content-type标题来确定内容的类型,并确定用于存储内容的扩展名是一个很好的做法。

做好准备

我们再次使用了我们创建的URLUtility对象。 配方的脚本是04/04_determine_file_extension_from_contenttype.py

如何做到这一点

通过运行配方的脚本来进行。

可以使用.extension属性找到媒体类型的扩展名:

util = URLUtility(const.ApodEclipseImage())
print("Filename from content-type: " + util.extension_from_contenttype)
print("Filename from url: " + util.extension_from_url)

这导致以下输出:

Reading URL: https://apod.nasa.gov/apod/image/1709/BT5643s.jpg
Read 171014 bytes
Filename from content-type: .jpg
Filename from url: .jpg

这报告了从文件类型和 URL 确定的扩展名。 这些可能不同,但在这种情况下它们是相同的。

它是如何工作的

以下是.extension_from_contenttype属性的实现:

@property def extension_from_contenttype(self):
    self.ensure_response()

    map = const.ContentTypeToExtensions()
    if self.contenttype in map:
        return map[self.contenttype]
    return None 

第一行确保我们已从 URL 读取响应。 然后,该函数使用在const模块中定义的 Python 字典,其中包含内容类型到扩展名的字典:

def ContentTypeToExtensions():
    return {
        "image/jpeg": ".jpg",
  "image/jpg": ".jpg",
  "image/png": ".png"
  }

如果内容类型在字典中,则将返回相应的值。 否则,将返回None

注意相应的属性.extension_from_url

@property def extension_from_url(self):
    ext = os.path.splitext(os.path.basename(self._parsed.path))[1]
    return ext

这使用与.filename属性相同的技术来解析 URL,但是返回代表扩展名而不是基本文件名的[1]元素。

还有更多...

如前所述,最好使用content-type标题来确定用于本地存储文件的扩展名。 除了这里提供的技术之外,还有其他技术,但这是最简单的。

下载并将图像保存到本地文件系统

有时在爬取时,我们只是下载和解析数据,比如 HTML,提取一些数据,然后丢弃我们读取的内容。其他时候,我们希望通过将其存储为文件来保留已下载的内容。

如何做

这个配方的代码示例在04/05_save_image_as_file.py文件中。文件中重要的部分是:

# download the image item = URLUtility(const.ApodEclipseImage())

# create a file writer to write the data FileBlobWriter(expanduser("~")).write(item.filename, item.data)

用你的 Python 解释器运行脚本,你将得到以下输出:

Reading URL: https://apod.nasa.gov/apod/image/1709/BT5643s.jpg
Read 171014 bytes
Attempting to write 171014 bytes to BT5643s.jpg:
The write was successful

工作原理

这个示例只是使用标准的 Python 文件访问函数将数据写入文件。它通过使用标准的写入数据接口以面向对象的方式来实现,使用了FileBlobWriter类的基于文件的实现:

""" Implements the IBlobWriter interface to write the blob to a file """   from interface import implements
from core.i_blob_writer import IBlobWriter

class FileBlobWriter(implements(IBlobWriter)):
    def __init__(self, location):
        self._location = location

    def write(self, filename, contents):
        full_filename = self._location + "/" + filename
        print ("Attempting to write {0} bytes to {1}:".format(len(contents), filename))

        with open(full_filename, 'wb') as outfile:
            outfile.write(contents)

        print("The write was successful")

该类传递一个表示文件应该放置的目录的字符串。实际上,数据是在稍后调用.write()方法时写入的。这个方法合并了文件名和directory (_location),然后打开/创建文件并写入字节。with语句确保文件被关闭。

还有更多...

这篇文章可以简单地使用一个包装代码的函数来处理。这个对象将在本章中被重复使用。我们可以使用 Python 的鸭子类型,或者只是一个函数,但是接口的清晰度更容易。说到这一点,以下是这个接口的定义:

""" Defines the interface for writing a blob of data to storage """   from interface import Interface

class IBlobWriter(Interface):
   def write(self, filename, contents):
      pass

我们还将看到另一个实现这个接口的方法,让我们可以将文件存储在 S3 中。通过这种类型的实现,通过接口继承,我们可以很容易地替换实现。

下载并保存图像到 S3

我们已经看到了如何在第三章中将内容写入 S3,处理数据。在这里,我们将把这个过程扩展到 IBlobWriter 的接口实现,以便写入 S3。

准备工作

这个配方的代码示例在04/06_save_image_in_s3.py文件中。还要确保你已经将 AWS 密钥设置为环境变量,这样 Boto 才能验证脚本。

如何做

我们按照以下步骤进行:

  1. 运行配方的脚本。它将执行以下操作:
# download the image item = URLUtility(const.ApodEclipseImage())

# store it in S3 S3BlobWriter(bucket_name="scraping-apod").write(item.filename, item.data)
  1. 在 S3 中检查,我们可以看到存储桶已经创建,并且图像已放置在存储桶中:

S3 中的图像

工作原理

以下是S3BlobWriter的实现:

class S3BlobWriter(implements(IBlobWriter)):
    def __init__(self, bucket_name, boto_client=None):
        self._bucket_name = bucket_name

        if self._bucket_name is None:
            self.bucket_name = "/"    # caller can specify a boto client (can reuse and save auth times)
  self._boto_client = boto_client

        # or create a boto client if user did not, use secrets from environment variables
  if self._boto_client is None:
            self._boto_client = boto3.client('s3')

    def write(self, filename, contents):
        # create bucket, and put the object
  self._boto_client.create_bucket(Bucket=self._bucket_name, ACL='public-read')
        self._boto_client.put_object(Bucket=self._bucket_name,
  Key=filename,
  Body=contents,
  ACL="public-read")

我们之前在写入 S3 的配方中看到了这段代码。这个类将它整齐地包装成一个可重用的接口实现。创建一个实例时,指定存储桶名称。然后每次调用.write()都会保存在同一个存储桶中。

还有更多...

S3 在存储桶上提供了一个称为启用网站的功能。基本上,如果你设置了这个选项,存储桶中的内容将通过 HTTP 提供。我们可以将许多图像写入这个目录,然后直接从 S3 中提供它们,而不需要实现一个 Web 服务器!

为图像生成缩略图

许多时候,在下载图像时,你不想保存完整的图像,而只想保存缩略图。或者你也可以同时保存完整尺寸的图像和缩略图。在 Python 中,使用 Pillow 库可以很容易地创建缩略图。Pillow 是 Python 图像库的一个分支,包含许多有用的图像处理函数。你可以在Pillow 官网找到更多关于 Pillow 的信息。在这个配方中,我们使用 Pillow 来创建图像缩略图。

准备工作

这个配方的脚本是04/07_create_image_thumbnail.py。它使用了 Pillow 库,所以确保你已经用 pip 或其他包管理工具将 Pillow 安装到你的环境中。

pip install pillow

如何做

以下是如何进行配方:

运行配方的脚本。它将执行以下代码:

from os.path import expanduser
import const
from core.file_blob_writer import FileBlobWriter
from core.image_thumbnail_generator import ImageThumbnailGenerator
from util.urls import URLUtility

# download the image and get the bytes img_data = URLUtility(const.ApodEclipseImage()).data

# we will store this in our home folder fw = FileBlobWriter(expanduser("~"))

# Create a thumbnail generator and scale the image tg = ImageThumbnailGenerator(img_data).scale(200, 200)

# write the image to a file fw.write("eclipse_thumbnail.png", tg.bytes)

结果将是一个名为eclipse_thumbnail.png的文件写入你的主目录。

我们创建的缩略图

Pillow 保持宽度和高度的比例一致。

工作原理

ImageThumbnailGenerator类封装了对 Pillow 的调用,为创建图像缩略图提供了一个非常简单的 API:

import io
from PIL import Image

class ImageThumbnailGenerator():
    def __init__(self, bytes):
        # Create a pillow image with the data provided
  self._image = Image.open(io.BytesIO(bytes))

    def scale(self, width, height):
        # call the thumbnail method to create the thumbnail
  self._image.thumbnail((width, height))
        return self    @property
  def bytes(self):
        # returns the bytes of the pillow image   # save the image to an in memory objects  bytesio = io.BytesIO()
        self._image.save(bytesio, format="png")

        # set the position on the stream to 0 and return the underlying data
  bytesio.seek(0)
        return bytesio.getvalue()

构造函数传递图像数据并从该数据创建 Pillow 图像对象。通过调用.thumbnail()创建缩略图,参数是表示缩略图所需大小的元组。这将调整现有图像的大小,并且 Pillow 会保留纵横比。它将确定图像的较长边并将其缩放到元组中表示该轴的值。此图像的高度大于宽度,因此缩略图的高度为 200 像素,并且宽度相应地缩放(在本例中为 160 像素)。

对网站进行截图

一个常见的爬取任务是对网站进行截图。在 Python 中,我们可以使用 selenium 和 webdriver 来创建缩略图。

准备就绪

此示例的脚本是04/08_create_website_screenshot.py。还要确保您的路径中有 selenium,并且已安装 Python 库。

操作步骤

运行该示例的脚本。脚本中的代码如下:

from core.website_screenshot_generator import WebsiteScreenshotGenerator
from core.file_blob_writer import FileBlobWriter
from os.path import expanduser

# get the screenshot image_bytes = WebsiteScreenshotGenerator().capture("http://espn.go.com", 500, 500).image_bytes

# save it to a file FileBlobWriter(expanduser("~")).write("website_screenshot.png", image_bytes)

创建一个WebsiteScreenshotGenerator对象,然后调用其 capture 方法,传递要捕获的网站的 URL 和图像的所需宽度(以像素为单位)。

这将创建一个 Pillow 图像,可以使用.image属性访问,并且可以直接使用.image_bytes访问图像的字节。此脚本获取这些字节并将它们写入到您的主目录中的website_screenshot.png文件中。

您将从此脚本中看到以下输出:

Connected to pydev debugger (build 162.1967.10)
Capturing website screenshot of: http://espn.go.com
Got a screenshot with the following dimensions: (500, 7416)
Cropped the image to: 500 500
Attempting to write 217054 bytes to website_screenshot.png:
The write was successful

我们的结果图像如下(图像的内容会有所不同):

网页截图

工作原理

以下是WebsiteScreenshotGenerator类的代码:

class WebsiteScreenshotGenerator():
    def __init__(self):
        self._screenshot = None   def capture(self, url, width, height, crop=True):
        print ("Capturing website screenshot of: " + url)
        driver = webdriver.PhantomJS()

        if width and height:
            driver.set_window_size(width, height)

        # go and get the content at the url
  driver.get(url)

        # get the screenshot and make it into a Pillow Image
  self._screenshot = Image.open(io.BytesIO(driver.get_screenshot_as_png()))
        print("Got a screenshot with the following dimensions: {0}".format(self._screenshot.size))

        if crop:
            # crop the image
  self._screenshot = self._screenshot.crop((0,0, width, height))
            print("Cropped the image to: {0} {1}".format(width, height))

        return self    @property
  def image(self):
        return self._screenshot

    @property
  def image_bytes(self):
        bytesio = io.BytesIO()
        self._screenshot.save(bytesio, "PNG")
        bytesio.seek(0)
        return bytesio.getvalue()

调用driver.get_screenshot_as_png()完成了大部分工作。它将页面呈现为 PNG 格式的图像并返回图像的字节。然后将这些数据转换为 Pillow 图像对象。

请注意输出中来自 webdriver 的图像高度为 7416 像素,而不是我们指定的 500 像素。PhantomJS 渲染器将尝试处理无限滚动的网站,并且通常不会将截图限制在窗口给定的高度上。

要实际使截图达到指定的高度,请将裁剪参数设置为True(默认值)。然后,此代码将使用 Pillow Image 的裁剪方法设置所需的高度。如果使用crop=False运行此代码,则结果将是高度为 7416 像素的图像。

使用外部服务对网站进行截图

前一个示例使用了 selenium、webdriver 和 PhantomJS 来创建截图。这显然需要安装这些软件包。如果您不想安装这些软件包,但仍想制作网站截图,则可以使用许多可以截图的网络服务之一。在此示例中,我们将使用www.screenshotapi.io上的服务来创建截图。

准备就绪

首先,前往www.screenshotapi.io注册一个免费账户:

免费账户注册的截图

创建账户后,继续获取 API 密钥。这将需要用于对其服务进行身份验证:

API 密钥

操作步骤

此示例的脚本是04/09_screenshotapi.py。运行此脚本将生成一个截图。以下是代码,结构与前一个示例非常相似:

from core.website_screenshot_with_screenshotapi import WebsiteScreenshotGenerator
from core.file_blob_writer import FileBlobWriter
from os.path import expanduser

# get the screenshot image_bytes = WebsiteScreenshotGenerator("bd17a1e1-db43-4686-9f9b-b72b67a5535e")\
    .capture("http://espn.go.com", 500, 500).image_bytes

# save it to a file FileBlobWriter(expanduser("~")).write("website_screenshot.png", image_bytes)

与前一个示例的功能区别在于,我们使用了不同的WebsiteScreenshotGenerator实现。这个来自core.website_screenshot_with_screenshotapi模块。

运行时,以下内容将输出到控制台:

Sending request: http://espn.go.com
{"status":"ready","key":"2e9a40b86c95f50ad3f70613798828a8","apiCreditsCost":1}
The image key is: 2e9a40b86c95f50ad3f70613798828a8
Trying to retrieve: https://api.screenshotapi.io/retrieve
Downloading image: https://screenshotapi.s3.amazonaws.com/captures/2e9a40b86c95f50ad3f70613798828a8.png
Saving screenshot to: downloaded_screenshot.png2e9a40b86c95f50ad3f70613798828a8
Cropped the image to: 500 500
Attempting to write 209197 bytes to website_screenshot.png:
The write was successful

并给我们以下图像:

screenshotapi.io的网站截图

它是如何工作的

以下是此WebsiteScreenshotGenerator的代码:

class WebsiteScreenshotGenerator:
    def __init__(self, apikey):
        self._screenshot = None
  self._apikey = apikey

    def capture(self, url, width, height, crop=True):
        key = self.beginCapture(url, "{0}x{1}".format(width, height), "true", "firefox", "true")

        print("The image key is: " + key)

        timeout = 30
  tCounter = 0
  tCountIncr = 3    while True:
            result = self.tryRetrieve(key)
            if result["success"]:
                print("Saving screenshot to: downloaded_screenshot.png" + key)

                bytes=result["bytes"]
                self._screenshot = Image.open(io.BytesIO(bytes))

                if crop:
                    # crop the image
  self._screenshot = self._screenshot.crop((0, 0, width, height))
                    print("Cropped the image to: {0} {1}".format(width, height))
                break    tCounter += tCountIncr
            print("Screenshot not yet ready.. waiting for: " + str(tCountIncr) + " seconds.")
            time.sleep(tCountIncr)
            if tCounter > timeout:
                print("Timed out while downloading: " + key)
                break
 return self    def beginCapture(self, url, viewport, fullpage, webdriver, javascript):
        serverUrl = "https://api.screenshotapi.io/capture"
  print('Sending request: ' + url)
        headers = {'apikey': self._apikey}
        params = {'url': urllib.parse.unquote(url).encode('utf8'), 'viewport': viewport, 'fullpage': fullpage,
  'webdriver': webdriver, 'javascript': javascript}
        result = requests.post(serverUrl, data=params, headers=headers)
        print(result.text)
        json_results = json.loads(result.text)
        return json_results['key']

    def tryRetrieve(self, key):
        url = 'https://api.screenshotapi.io/retrieve'
  headers = {'apikey': self._apikey}
        params = {'key': key}
        print('Trying to retrieve: ' + url)
        result = requests.get(url, params=params, headers=headers)

        json_results = json.loads(result.text)
        if json_results["status"] == "ready":
            print('Downloading image: ' + json_results["imageUrl"])
            image_result = requests.get(json_results["imageUrl"])
            return {'success': True, 'bytes': image_result.content}
        else:
            return {'success': False}

    @property
  def image(self):
        return self._screenshot

    @property
  def image_bytes(self):
        bytesio = io.BytesIO()
        self._screenshot.save(bytesio, "PNG")
        bytesio.seek(0)
        return bytesio.getvalue()

screenshotapi.io API 是一个 REST API。有两个不同的端点:

首先调用第一个端点,并将 URL 和其他参数传递给其服务。成功执行后,此 API 将返回一个密钥,可用于在另一个端点上检索图像。截图是异步执行的,我们需要不断调用使用从捕获端点返回的密钥的“检索”API。当截图完成时,此端点将返回ready状态值。代码简单地循环,直到设置为此状态,发生错误或代码超时。

当快照可用时,API 会在“检索”响应中返回图像的 URL。然后,代码会检索此图像,并从接收到的数据构造一个 Pillow 图像对象。

还有更多...

screenshotapi.io API 有许多有用的参数。其中几个允许您调整要使用的浏览器引擎(Firefox、Chrome 或 PhantomJS)、设备仿真以及是否在网页中执行 JavaScript。有关这些选项和 API 的更多详细信息,请访问docs.screenshotapi.io/rest-api/

使用 pytesseract 对图像执行 OCR

可以使用 pytesseract 库从图像中提取文本。在本示例中,我们将使用 pytesseract 从图像中提取文本。Tesseract 是由 Google 赞助的开源 OCR 库。源代码在这里可用:github.com/tesseract-ocr/tesseract,您还可以在那里找到有关该库的更多信息。pytesseract 是一个提供了 Python API 的薄包装器,为可执行文件提供了 Python API。

准备工作

确保您已安装 pytesseract:

pip install pytesseract

您还需要安装 tesseract-ocr。在 Windows 上,有一个可执行安装程序,您可以在此处获取:https://github.com/tesseract-ocr/tesseract/wiki/4.0-with-LSTM#400-alpha-for-windows。在 Linux 系统上,您可以使用apt-get

sudo apt-get tesseract-ocr

在 Mac 上安装最简单的方法是使用 brew:

brew install tesseract

此配方的代码位于04/10_perform_ocr.py中。

如何做

执行该配方的脚本。脚本非常简单:

import pytesseract as pt
from PIL import Image

img = Image.open("textinimage.png")
text = pt.image_to_string(img)
print(text)

将要处理的图像是以下图像:

我们将进行 OCR 的图像

脚本给出以下输出:

This is an image containing text.
And some numbers 123456789

And also special characters: !@#$%"&*(_+

它是如何工作的

首先将图像加载为 Pillow 图像对象。我们可以直接将此对象传递给 pytesseract 的image_to_string()函数。该函数在图像上运行 tesseract 并返回它找到的文本。

还有更多...

在爬取应用程序中使用 OCR 的主要目的之一是解决基于文本的验证码。我们不会涉及验证码解决方案,因为它们可能很麻烦,而且也在其他 Packt 标题中有记录。

创建视频缩略图

您可能希望为从网站下载的视频创建缩略图。这些可以用于显示多个视频缩略图的页面,并允许您单击它们观看特定视频。

准备工作

此示例将使用一个名为 ffmpeg 的工具。ffmpeg 可以在 www.ffmpeg.org 上找到。根据您的操作系统的说明进行下载和安装。

如何做

示例脚本位于04/11_create_video_thumbnail.py中。它包括以下代码:

import subprocess
video_file = 'BigBuckBunny.mp4' thumbnail_file = 'thumbnail.jpg' subprocess.call(['ffmpeg', '-i', video_file, '-ss', '00:01:03.000', '-vframes', '1', thumbnail_file, "-y"])

运行时,您将看到来自 ffmpeg 的输出:

 built with Apple LLVM version 8.1.0 (clang-802.0.42)
 configuration: --prefix=/usr/local/Cellar/ffmpeg/3.3.4 --enable-shared --enable-pthreads --enable-gpl --enable-version3 --enable-hardcoded-tables --enable-avresample --cc=clang --host-cflags= --host-ldflags= --enable-libmp3lame --enable-libx264 --enable-libxvid --enable-opencl --enable-videotoolbox --disable-lzma --enable-vda
 libavutil 55\. 58.100 / 55\. 58.100
 libavcodec 57\. 89.100 / 57\. 89.100
 libavformat 57\. 71.100 / 57\. 71.100
 libavdevice 57\. 6.100 / 57\. 6.100
 libavfilter 6\. 82.100 / 6\. 82.100
 libavresample 3\. 5\. 0 / 3\. 5\. 0
 libswscale 4\. 6.100 / 4\. 6.100
 libswresample 2\. 7.100 / 2\. 7.100
 libpostproc 54\. 5.100 / 54\. 5.100
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'BigBuckBunny.mp4':
 Metadata:
 major_brand : isom
 minor_version : 512
 compatible_brands: mp41
 creation_time : 1970-01-01T00:00:00.000000Z
 title : Big Buck Bunny
 artist : Blender Foundation
 composer : Blender Foundation
 date : 2008
 encoder : Lavf52.14.0
 Duration: 00:09:56.46, start: 0.000000, bitrate: 867 kb/s
 Stream #0:0(und): Video: h264 (Constrained Baseline) (avc1 / 0x31637661), yuv420p, 320x180 [SAR 1:1 DAR 16:9], 702 kb/s, 24 fps, 24 tbr, 24 tbn, 48 tbc (default)
 Metadata:
 creation_time : 1970-01-01T00:00:00.000000Z
 handler_name : VideoHandler
 Stream #0:1(und): Audio: aac (LC) (mp4a / 0x6134706D), 48000 Hz, stereo, fltp, 159 kb/s (default)
 Metadata:
 creation_time : 1970-01-01T00:00:00.000000Z
 handler_name : SoundHandler
Stream mapping:
 Stream #0:0 -> #0:0 (h264 (native) -> mjpeg (native))
Press [q] to stop, [?] for help
[swscaler @ 0x7fb50b103000] deprecated pixel format used, make sure you did set range correctly
Output #0, image2, to 'thumbnail.jpg':
 Metadata:
 major_brand : isom
 minor_version : 512
 compatible_brands: mp41
 date : 2008
 title : Big Buck Bunny
 artist : Blender Foundation
 composer : Blender Foundation
 encoder : Lavf57.71.100
 Stream #0:0(und): Video: mjpeg, yuvj420p(pc), 320x180 [SAR 1:1 DAR 16:9], q=2-31, 200 kb/s, 24 fps, 24 tbn, 24 tbc (default)
 Metadata:
 creation_time : 1970-01-01T00:00:00.000000Z
 handler_name : VideoHandler
 encoder : Lavc57.89.100 mjpeg
 Side data:
 cpb: bitrate max/min/avg: 0/0/200000 buffer size: 0 vbv_delay: -1
frame= 1 fps=0.0 q=4.0 Lsize=N/A time=00:00:00.04 bitrate=N/A speed=0.151x 
video:8kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: unknown

输出的 JPG 文件将是以下 JPG 图像:

从视频创建的缩略图

它是如何工作的

.ffmpeg文件实际上是一个可执行文件。代码将以下 ffmpeg 命令作为子进程执行:

ffmpeg -i BigBuckBunny.mp4 -ss 00:01:03.000 -frames:v 1 thumbnail.jpg -y

输入文件是BigBuckBunny.mp4-ss选项告诉我们要检查视频的位置。-frames:v表示我们要提取一个帧。最后,我们告诉ffmpeg将该帧写入thumbnail.jpg-y确认覆盖现有文件)。

还有更多...

ffmpeg 是一个非常多才多艺和强大的工具。我曾经创建过一个爬虫,它会爬取并找到媒体(实际上是在网站上播放的商业广告),并将它们存储在数字档案中。然后,爬虫会通过消息队列发送消息,这些消息会被一组服务器接收,它们的唯一工作就是运行 ffmpeg 将视频转换为许多不同的格式、比特率,并创建缩略图。从那时起,更多的消息将被发送给审计员,使用一个前端应用程序来检查内容是否符合广告合同条款。了解 ffmeg,它是一个很棒的工具。

将 MP4 视频转换为 MP3

现在让我们来看看如何将 MP4 视频中的音频提取为 MP3 文件。你可能想这样做的原因包括想要携带视频的音频(也许是音乐视频),或者你正在构建一个爬虫/媒体收集系统,它还需要音频与视频分开。

这个任务可以使用moviepy库来完成。moviepy是一个很棒的库,可以让你对视频进行各种有趣的处理。其中一个功能就是提取音频为 MP3。

准备工作

确保你的环境中安装了 moviepy:

pip install moviepy

我们还需要安装 ffmpeg,这是我们在上一个示例中使用过的,所以你应该已经满足了这个要求。

如何操作

演示将视频转换为 MP3 的代码在04/12_rip_mp3_from_mp4.py中。moviepy使这个过程变得非常容易。

  1. 以下是在上一个示例中下载的 MP4 文件的提取:
import moviepy.editor as mp
clip = mp.VideoFileClip("BigBuckBunny.mp4")
clip.audio.write_audiofile("movie_audio.mp3")
  1. 当运行时,你会看到输出,比如下面的内容,因为文件正在被提取。这只花了几秒钟:
[MoviePy] Writing audio in movie_audio.mp3
100%|██████████| 17820/17820 [00:16<00:00, 1081.67it/s]
[MoviePy] Done.
  1. 完成后,你将得到一个 MP3 文件:
# ls -l *.mp3 -rw-r--r--@ 1 michaelheydt  staff  12931074 Sep 27 21:44 movie_audio.mp3

还有更多...

有关 moviepy 的更多信息,请查看项目网站zulko.github.io/moviepy/

第五章:抓取 - 行为准则

在本章中,我们将涵盖:

  • 抓取的合法性和有礼貌的抓取

  • 尊重 robots.txt

  • 使用站点地图进行爬行

  • 带延迟的爬行

  • 使用可识别的用户代理

  • 设置每个域的并发请求数量

  • 使用自动节流

  • 缓存响应

介绍

虽然您在技术上可以抓取任何网站,但重要的是要知道抓取是否合法。我们将讨论抓取的法律问题,探讨一般的法律原则,并了解有礼貌地抓取和最大程度地减少对目标网站的潜在损害的最佳做法。

抓取的合法性和有礼貌的抓取

这个配方中没有真正的代码。这只是对涉及抓取的法律问题的一些概念的阐述。我不是律师,所以不要把我在这里写的任何东西当作法律建议。我只是指出在使用抓取器时需要关注的一些事情。

准备就绪

抓取的合法性分为两个问题:

  • 内容所有权

  • 拒绝服务

基本上,网上发布的任何内容都是公开阅读的。每次加载页面时,您的浏览器都会从网络服务器下载内容并将其可视化呈现给您。因此,在某种意义上,您和您的浏览器已经在网上查看任何内容。由于网络的性质,因为有人在网上公开发布内容,他们本质上是在要求您获取这些信息,但通常只是为了特定目的。

大问题在于创建直接寻找并复制互联网上的事物的自动化工具,事物可以是数据、图像、视频或音乐 - 基本上是由他人创建并代表对创建者或所有者有价值的东西。当明确复制项目供您个人使用时,这些项目可能会产生问题,并且在复制并将其用于您或他人的利益时,可能会更有可能产生问题。

视频、书籍、音乐和图像是一些明显引起关注的项目,涉及制作个人或商业用途的副本的合法性。一般来说,如果您从无需授权访问或需要付费访问内容的开放网站(如不需要授权访问或需要付费访问内容的网站)上抓取此类内容,那么您就没问题。还有公平使用规则允许在某些情况下重复使用内容,例如在课堂场景中共享少量文件,其中发布供人们学习的知识并没有真正的经济影响。

从网站上抓取数据通常是一个更加模糊的问题。我的意思是作为服务提供的信息。从我的经验来看,一个很好的例子是能源价格,这些价格发布在供应商的网站上。这些通常是为了方便客户而提供的,而不是供您自由抓取并将数据用于自己的商业分析服务。如果您只是为了非公开数据库而收集数据,或者只是为了自己的使用而收集数据,那么可能没问题。但是,如果您使用该数据库来驱动自己的网站并以自己的名义分享该内容,那么您可能需要小心。

重点是,查看网站的免责声明/服务条款,了解您可以如何使用这些信息。这应该有记录,但如果没有,那并不意味着您可以肆意妄为。始终要小心并运用常识,因为您正在为自己的目的获取他人的内容。

另一个关注点是我归为拒绝服务的概念,它涉及到收集信息的实际过程以及你收集信息的频率。在网站上手动阅读内容的过程与编写自动机器人不断骚扰网络服务器以获取内容的过程有很大的不同。如果访问频率过高,可能会拒绝其他合法用户访问内容,从而拒绝为他们提供服务。这也可能会增加内容的主机的成本,增加他们的带宽成本,甚至是运行服务器的电费。

一个良好管理的网站将识别这些重复和频繁的访问,并使用诸如基于 IP 地址、标头和 cookie 的规则的 Web 应用程序防火墙关闭它们。在其他情况下,这些可能会被识别,并联系您的 ISP 要求您停止执行这些任务。请记住,您永远不是真正匿名的,聪明的主机可以找出您是谁,确切地知道您访问了什么内容以及何时访问。

如何做到这一点

那么,你如何成为一个好的爬虫呢?在本章中,我们将涵盖几个因素:

  • 您可以从尊重robots.txt文件开始

  • 不要爬取您在网站上找到的每个链接,只爬取站点地图中给出的链接。

  • 限制您的请求,就像汉·索洛对丘巴卡说的那样:放轻松;或者,不要看起来像您在重复爬取内容

  • 让自己被网站识别

尊重 robots.txt

许多网站希望被爬取。这是兽性的本质:Web 主机将内容放在其网站上供人类查看。但同样重要的是其他计算机也能看到内容。一个很好的例子是搜索引擎优化(SEO)。SEO 是一个过程,您实际上设计您的网站以便被 Google 等搜索引擎的爬虫爬取,因此您实际上是在鼓励爬取。但与此同时,发布者可能只希望网站的特定部分被爬取,并告诉爬虫不要爬取网站的某些部分,要么是因为不适合分享,要么是因为不重要而浪费了 Web 服务器资源。

通常,您被允许和不被允许爬取的规则包含在大多数网站上的一个名为robots.txt的文件中。robots.txt是一个可读但可解析的文件,可用于识别您被允许和不被允许爬取的位置。

robots.txt文件的格式不幸地不是标准的,任何人都可以进行自己的修改,但是对于格式有很强的共识。robots.txt文件通常位于站点的根 URL。为了演示robots.txt文件,以下代码包含了亚马逊在amazon.com/robots.txt上提供的摘录。我编辑了它,只显示了重要的概念:

User-agent: *
Disallow: /exec/obidos/account-access-login
Disallow: /exec/obidos/change-style
Disallow: /exec/obidos/flex-sign-in
Disallow: /exec/obidos/handle-buy-box
Disallow: /exec/obidos/tg/cm/member/
Disallow: /gp/aw/help/id=sss
Disallow: /gp/cart
Disallow: /gp/flex

...

Allow: /wishlist/universal*
Allow: /wishlist/vendor-button*
Allow: /wishlist/get-button*

...

User-agent: Googlebot
Disallow: /rss/people/*/reviews
Disallow: /gp/pdp/rss/*/reviews
Disallow: /gp/cdp/member-reviews/
Disallow: /gp/aw/cr/

...
Allow: /wishlist/universal*
Allow: /wishlist/vendor-button*
Allow: /wishlist/get-button*

可以看到文件中有三个主要元素:

  • 用户代理声明,以下行直到文件结束或下一个用户代理声明将被应用

  • 允许爬取的一组 URL

  • 禁止爬取的一组 URL

语法实际上非常简单,Python 库存在以帮助我们实现robots.txt中包含的规则。我们将使用reppy库来尊重robots.txt

准备工作

让我们看看如何使用reppy库来演示robots.txt。有关reppy的更多信息,请参阅其 GitHub 页面github.com/seomoz/reppy

可以这样安装reppy

pip install reppy

但是,我发现在我的 Mac 上安装时出现了错误,需要以下命令:

CFLAGS=-stdlib=libc++ pip install reppy

在 Google 上搜索robots.txt Python 解析库的一般信息通常会引导您使用 robotparser 库。此库适用于 Python 2.x。对于 Python 3,它已移至urllib库。但是,我发现该库在特定情况下报告不正确的值。我将在我们的示例中指出这一点。

如何做到这一点

要运行该示例,请执行05/01_sitemap.py中的代码。脚本将检查 amazon.com 上是否允许爬取多个 URL。运行时,您将看到以下输出:

True: http://www.amazon.com/
False: http://www.amazon.com/gp/dmusic/
True: http://www.amazon.com/gp/dmusic/promotions/PrimeMusic/
False: http://www.amazon.com/gp/registry/wishlist/

它是如何工作的

  1. 脚本首先通过导入reppy.robots开始:
from reppy.robots import Robots
  1. 然后,代码使用Robots来获取 amazon.com 的robots.txt
url = "http://www.amazon.com" robots = Robots.fetch(url + "/robots.txt")
  1. 使用获取的内容,脚本检查了几个 URL 的可访问性:
paths = [
  '/',
  '/gp/dmusic/', '/gp/dmusic/promotions/PrimeMusic/',
 '/gp/registry/wishlist/'  ]   for path in paths:
  print("{0}: {1}".format(robots.allowed(path, '*'), url + path))

此代码的结果如下:

True: http://www.amazon.com/
False: http://www.amazon.com/gp/dmusic/
True: http://www.amazon.com/gp/dmusic/promotions/PrimeMusic/
False: http://www.amazon.com/gp/registry/wishlist/

robots.allowed的调用给出了 URL 和用户代理。它根据 URL 是否允许爬取返回TrueFalse。在这种情况下,指定的 URL 的结果为 True、False、True 和 False。让我们看看如何。

/ URL 在robots.txt中没有条目,因此默认情况下是允许的。但是,在*用户代理组下的文件中有以下两行:

Disallow: /gp/dmusic/
Allow: /gp/dmusic/promotions/PrimeMusic

不允许/gp/dmusic,因此返回 False。/gp/dmusic/promotions/PrimeMusic 是明确允许的。如果未指定 Allowed:条目,则 Disallow:/gp/dmusic/行也将禁止从/gp/dmusic/进一步的任何路径。这基本上表示以/gp/dmusic/开头的任何 URL 都是不允许的,除了允许爬取/gp/dmusic/promotions/PrimeMusic。

在使用robotparser库时存在差异。robotparser报告/gp/dmusic/promotions/PrimeMusic是不允许的。该库未正确处理此类情况,因为它在第一次匹配时停止扫描robots.txt,并且不会进一步查找文件以寻找此类覆盖。

还有更多...

首先,有关robots.txt的详细信息,请参阅developers.google.com/search/reference/robots_txt

请注意,并非所有站点都有robots.txt,其缺失并不意味着您有权自由爬取所有内容。

此外,robots.txt文件可能包含有关在网站上查找站点地图的信息。我们将在下一个示例中检查这些站点地图。

Scrapy 还可以读取robots.txt并为您找到站点地图。

使用站点地图进行爬行

站点地图是一种允许网站管理员通知搜索引擎有关可用于爬取的网站上的 URL 的协议。网站管理员希望使用此功能,因为他们实际上希望他们的信息被搜索引擎爬取。网站管理员希望使该内容可通过搜索引擎找到,至少通过搜索引擎。但您也可以利用这些信息。

站点地图列出了站点上的 URL,并允许网站管理员指定有关每个 URL 的其他信息:

  • 上次更新时间

  • 内容更改的频率

  • URL 在与其他 URL 的关系中有多重要

站点地图在以下情况下很有用:

  • 网站的某些区域无法通过可浏览的界面访问;也就是说,您无法访问这些页面

  • Ajax、Silverlight 或 Flash 内容通常不会被搜索引擎处理

  • 网站非常庞大,网络爬虫有可能忽略一些新的或最近更新的内容

  • 当网站具有大量孤立或链接不良的页面时

  • 当网站具有较少的外部链接时

站点地图文件具有以下结构:

<?xml version="1.0" encoding="utf-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" 
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
    <url>
        <loc>http://example.com/</loc>
        <lastmod>2006-11-18</lastmod>
        <changefreq>daily</changefreq>
        <priority>0.8</priority>
    </url>
</urlset>

站点中的每个 URL 都将用<url></url>标签表示,所有这些标签都包裹在外部的<urlset></urlset>标签中。始终会有一个指定 URL 的<loc></loc>标签。其他三个标签是可选的。

网站地图文件可能非常庞大,因此它们经常被分成多个文件,然后由单个网站地图索引文件引用。该文件的格式如下:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
   <sitemap>
      <loc>http://www.example.com/sitemap1.xml.gz</loc>
      <lastmod>2014-10-01T18:23:17+00:00</lastmod>
   </sitemap>
</sitemapindex>

在大多数情况下,sitemap.xml 文件位于域的根目录下。例如,对于 nasa.gov,它是www.nasa.gov/sitemap.xml。但请注意,这不是一个标准,不同的网站可能在不同的位置拥有地图或地图。

特定网站的网站地图也可能位于该网站的 robots.txt 文件中。例如,microsoft.com 的 robots.txt 文件以以下内容结尾:

Sitemap: https://www.microsoft.com/en-us/explore/msft_sitemap_index.xml
Sitemap: https://www.microsoft.com/learning/sitemap.xml
Sitemap: https://www.microsoft.com/en-us/licensing/sitemap.xml
Sitemap: https://www.microsoft.com/en-us/legal/sitemap.xml
Sitemap: https://www.microsoft.com/filedata/sitemaps/RW5xN8
Sitemap: https://www.microsoft.com/store/collections.xml
Sitemap: https://www.microsoft.com/store/productdetailpages.index.xml

因此,要获取 microsoft.com 的网站地图,我们首先需要读取 robots.txt 文件并提取该信息。

现在让我们来看看如何解析网站地图。

准备工作

你所需要的一切都在 05/02_sitemap.py 脚本中,以及与其在同一文件夹中的 sitemap.py 文件。sitemap.py 文件实现了一个基本的网站地图解析器,我们将在主脚本中使用它。在这个例子中,我们将获取 nasa.gov 的网站地图数据。

如何做

首先执行 05/02_sitemap.py 文件。确保相关的 sitemap.py 文件与其在同一目录或路径下。运行后,几秒钟后,你将会得到类似以下的输出:

Found 35511 urls
{'lastmod': '2017-10-11T18:23Z', 'loc': 'http://www.nasa.gov/centers/marshall/history/this-week-in-nasa-history-apollo-7-launches-oct-11-1968.html', 'tag': 'url'}
{'lastmod': '2017-10-11T18:22Z', 'loc': 'http://www.nasa.gov/feature/researchers-develop-new-tool-to-evaluate-icephobic-materials', 'tag': 'url'}
{'lastmod': '2017-10-11T17:38Z', 'loc': 'http://www.nasa.gov/centers/ames/entry-systems-vehicle-development/roster.html', 'tag': 'url'}
{'lastmod': '2017-10-11T17:38Z', 'loc': 'http://www.nasa.gov/centers/ames/entry-systems-vehicle-development/about.html', 'tag': 'url'}
{'lastmod': '2017-10-11T17:22Z', 'loc': 'http://www.nasa.gov/centers/ames/earthscience/programs/MMS/instruments', 'tag': 'url'}
{'lastmod': '2017-10-11T18:15Z', 'loc': 'http://www.nasa.gov/centers/ames/earthscience/programs/MMS/onepager', 'tag': 'url'}
{'lastmod': '2017-10-11T17:10Z', 'loc': 'http://www.nasa.gov/centers/ames/earthscience/programs/MMS', 'tag': 'url'}
{'lastmod': '2017-10-11T17:53Z', 'loc': 'http://www.nasa.gov/feature/goddard/2017/nasa-s-james-webb-space-telescope-and-the-big-bang-a-short-qa-with-nobel-laureate-dr-john', 'tag': 'url'}
{'lastmod': '2017-10-11T17:38Z', 'loc': 'http://www.nasa.gov/centers/ames/entry-systems-vehicle-development/index.html', 'tag': 'url'}
{'lastmod': '2017-10-11T15:21Z', 'loc': 'http://www.nasa.gov/feature/mark-s-geyer-acting-deputy-associate-administrator-for-technical-human-explorations-and-operations', 'tag': 'url'}

程序在所有 nasa.gov 的网站地图中找到了 35,511 个 URL!代码只打印了前 10 个,因为输出量会相当大。使用这些信息来初始化对所有这些 URL 的爬取肯定需要相当长的时间!

但这也是网站地图的美妙之处。许多,如果不是所有的结果都有一个 lastmod 标签,告诉你与该关联 URL 末端的内容上次修改的时间。如果你正在实现一个有礼貌的爬虫来爬取 nasa.gov,你会想把这些 URL 及其时间戳保存在数据库中,然后在爬取该 URL 之前检查内容是否实际上已经改变,如果没有改变就不要爬取。

现在让我们看看这实际是如何工作的。

工作原理

该方法的工作如下:

  1. 脚本开始调用 get_sitemap()
map = sitemap.get_sitemap("https://www.nasa.gov/sitemap.xml")
  1. 给定一个指向 sitemap.xml 文件(或任何其他文件 - 非压缩)的 URL。该实现简单地获取 URL 处的内容并返回它:
def get_sitemap(url):
  get_url = requests.get(url)    if get_url.status_code == 200:
  return get_url.text
    else:
  print ('Unable to fetch sitemap: %s.' % url) 
  1. 大部分工作是通过将该内容传递给 parse_sitemap() 来完成的。在 nasa.gov 的情况下,这个网站地图包含以下内容,即网站地图索引文件:
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="//www.nasa.gov/sitemap.xsl"?>
<sitemapindex >
<sitemap><loc>http://www.nasa.gov/sitemap-1.xml</loc><lastmod>2017-10-11T19:30Z</lastmod></sitemap>
<sitemap><loc>http://www.nasa.gov/sitemap-2.xml</loc><lastmod>2017-10-11T19:30Z</lastmod></sitemap>
<sitemap><loc>http://www.nasa.gov/sitemap-3.xml</loc><lastmod>2017-10-11T19:30Z</lastmod></sitemap>
<sitemap><loc>http://www.nasa.gov/sitemap-4.xml</loc><lastmod>2017-10-11T19:30Z</lastmod></sitemap>
</sitemapindex>
  1. process_sitemap() 从调用 process_sitemap() 开始:
def parse_sitemap(s):
  sitemap = process_sitemap(s)
  1. 这个函数开始调用 process_sitemap(),它返回一个包含 loclastmodchangeFreq 和 priority 键值对的 Python 字典对象列表:
def process_sitemap(s):
  soup = BeautifulSoup(s, "lxml")
  result = []    for loc in soup.findAll('loc'):
  item = {}
  item['loc'] = loc.text
        item['tag'] = loc.parent.name
        if loc.parent.lastmod is not None:
  item['lastmod'] = loc.parent.lastmod.text
        if loc.parent.changeFreq is not None:
  item['changeFreq'] = loc.parent.changeFreq.text
        if loc.parent.priority is not None:
  item['priority'] = loc.parent.priority.text
        result.append(item)    return result
  1. 这是通过使用 BeautifulSouplxml 解析网站地图来执行的。loc 属性始终被设置,如果有相关的 XML 标签,则会设置 lastmodchangeFreq 和 priority。.tag 属性本身只是指出这个内容是从 <sitemap> 标签还是 <url> 标签中检索出来的(<loc> 标签可以在任何一个标签上)。

parse_sitemap() 然后继续逐一处理这些结果:

while sitemap:
  candidate = sitemap.pop()    if is_sub_sitemap(candidate):
  sub_sitemap = get_sitemap(candidate['loc'])
  for i in process_sitemap(sub_sitemap):
  sitemap.append(i)
  else:
  result.append(candidate)
  1. 检查每个项目。如果它来自网站地图索引文件(URL 以 .xml 结尾且 .tag 是网站地图),那么我们需要读取该 .xml 文件并解析其内容,然后将结果放入我们要处理的项目列表中。在这个例子中,识别出了四个网站地图文件,每个文件都被读取、处理、解析,并且它们的 URL 被添加到结果中。

为了演示一些内容,以下是 sitemap-1.xml 的前几行:

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="//www.nasa.gov/sitemap.xsl"?>
<urlset >
<url><loc>http://www.nasa.gov/</loc><changefreq>daily</changefreq><priority>1.0</priority></url>
<url><loc>http://www.nasa.gov/connect/apps.html</loc><lastmod>2017-08-14T22:15Z</lastmod><changefreq>yearly</changefreq></url>
<url><loc>http://www.nasa.gov/socialmedia</loc><lastmod>2017-09-29T21:47Z</lastmod><changefreq>monthly</changefreq></url>
<url><loc>http://www.nasa.gov/multimedia/imagegallery/iotd.html</loc><lastmod>2017-08-21T22:00Z</lastmod><changefreq>yearly</changefreq></url>
<url><loc>http://www.nasa.gov/archive/archive/about/career/index.html</loc><lastmod>2017-08-04T02:31Z</lastmod><changefreq>yearly</changefreq></url>

总的来说,这一个网站地图有 11,006 行,所以大约有 11,000 个 URL!而且总共,正如报道的那样,所有三个网站地图中共有 35,511 个 URL。

还有更多...

网站地图文件也可能是经过压缩的,并以 .gz 扩展名结尾。这是因为它可能包含许多 URL,压缩将节省大量空间。虽然我们使用的代码不处理 gzip 网站地图文件,但可以使用 gzip 库中的函数轻松添加这个功能。

Scrapy 还提供了使用网站地图开始爬取的功能。其中之一是 Spider 类的一个特化,SitemapSpider。这个类有智能来解析网站地图,然后开始跟踪 URL。为了演示,脚本05/03_sitemap_scrapy.py将从 nasa.gov 的顶级网站地图索引开始爬取:

import scrapy
from scrapy.crawler import CrawlerProcess

class Spider(scrapy.spiders.SitemapSpider):
  name = 'spider'
  sitemap_urls = ['https://www.nasa.gov/sitemap.xml']    def parse(self, response):
  print("Parsing: ", response)   if __name__ == "__main__":
  process = CrawlerProcess({
  'DOWNLOAD_DELAY': 0,
  'LOG_LEVEL': 'DEBUG'
  })
  process.crawl(Spider)
  process.start()

运行时,会有大量输出,因为爬虫将开始爬取所有 30000 多个 URL。在输出的早期,您将看到以下输出:

2017-10-11 20:34:27 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.nasa.gov/sitemap.xml> (referer: None)
2017-10-11 20:34:27 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (301) to <GET https://www.nasa.gov/sitemap-4.xml> from <GET http://www.nasa.gov/sitemap-4.xml>
2017-10-11 20:34:27 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (301) to <GET https://www.nasa.gov/sitemap-2.xml> from <GET http://www.nasa.gov/sitemap-2.xml>
2017-10-11 20:34:27 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (301) to <GET https://www.nasa.gov/sitemap-3.xml> from <GET http://www.nasa.gov/sitemap-3.xml>
2017-10-11 20:34:27 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (301) to <GET https://www.nasa.gov/sitemap-1.xml> from <GET http://www.nasa.gov/sitemap-1.xml>
2017-10-11 20:34:27 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.nasa.gov/sitemap-4.xml> (referer: None)

Scrapy 已经找到了所有的网站地图并读取了它们的内容。不久之后,您将开始看到许多重定向和通知,指出正在解析某些页面:

2017-10-11 20:34:30 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (302) to <GET https://www.nasa.gov/image-feature/jpl/pia21629/neptune-from-saturn/> from <GET https://www.nasa.gov/image-feature/jpl/pia21629/neptune-from-saturn>
2017-10-11 20:34:30 [scrapy.downloadermiddlewares.redirect] DEBUG: Redirecting (302) to <GET https://www.nasa.gov/centers/ames/earthscience/members/nasaearthexchange/Ramakrishna_Nemani/> from <GET https://www.nasa.gov/centers/ames/earthscience/members/nasaearthexchang
e/Ramakrishna_Nemani>
Parsing: <200 https://www.nasa.gov/exploration/systems/sls/multimedia/sls-hardware-being-moved-on-kamag-transporter.html>
Parsing: <200 https://www.nasa.gov/exploration/systems/sls/M17-057.html>

带延迟的爬取

快速抓取被认为是一种不良实践。持续不断地访问网站页面可能会消耗 CPU 和带宽,而且强大的网站会识别到您这样做并阻止您的 IP。如果您运气不好,可能会因违反服务条款而收到一封恶意的信!

在爬虫中延迟请求的技术取决于您的爬虫是如何实现的。如果您使用 Scrapy,那么您可以设置一个参数,告诉爬虫在请求之间等待多长时间。在一个简单的爬虫中,只需按顺序处理 URL 的列表,您可以插入一个 thread.sleep 语句。

如果您实施了一个分布式爬虫集群,以分散页面请求的负载,比如使用具有竞争消费者的消息队列,情况可能会变得更加复杂。这可能有许多不同的解决方案,这超出了本文档提供的范围。

准备工作

我们将使用带延迟的 Scrapy。示例在o5/04_scrape_with_delay.py中。

如何做

Scrapy 默认在页面请求之间强加了 0 秒的延迟。也就是说,默认情况下不会在请求之间等待。

  1. 这可以使用DOWNLOAD_DELAY设置来控制。为了演示,让我们从命令行运行脚本:
05 $ scrapy runspider 04_scrape_with_delay.py -s LOG_LEVEL=WARNING
Parsing: <200 https://blog.scrapinghub.com>
Parsing: <200 https://blog.scrapinghub.com/page/2/>
Parsing: <200 https://blog.scrapinghub.com/page/3/>
Parsing: <200 https://blog.scrapinghub.com/page/4/>
Parsing: &lt;200 https://blog.scrapinghub.com/page/5/>
Parsing: <200 https://blog.scrapinghub.com/page/6/>
Parsing: <200 https://blog.scrapinghub.com/page/7/>
Parsing: <200 https://blog.scrapinghub.com/page/8/>
Parsing: <200 https://blog.scrapinghub.com/page/9/>
Parsing: <200 https://blog.scrapinghub.com/page/10/>
Parsing: <200 https://blog.scrapinghub.com/page/11/>
Total run time: 0:00:07.006148
Michaels-iMac-2:05 michaelheydt$ 

这将爬取 blog.scrapinghub.com 上的所有页面,并报告执行爬取的总时间。LOG_LEVEL=WARNING会删除大部分日志输出,只会输出打印语句的输出。这使用了默认的页面等待时间为 0,爬取大约需要七秒钟。

  1. 页面之间的等待时间可以使用DOWNLOAD_DELAY设置。以下在页面请求之间延迟五秒:
05 $ scrapy runspider 04_scrape_with_delay.py -s DOWNLOAD_DELAY=5 -s LOG_LEVEL=WARNING
Parsing: <200 https://blog.scrapinghub.com>
Parsing: <200 https://blog.scrapinghub.com/page/2/>
Parsing: <200 https://blog.scrapinghub.com/page/3/>
Parsing: <200 https://blog.scrapinghub.com/page/4/>
Parsing: <200 https://blog.scrapinghub.com/page/5/>
Parsing: <200 https://blog.scrapinghub.com/page/6/>
Parsing: <200 https://blog.scrapinghub.com/page/7/>
Parsing: <200 https://blog.scrapinghub.com/page/8/>
Parsing: <200 https://blog.scrapinghub.com/page/9/>
Parsing: <200 https://blog.scrapinghub.com/page/10/>
Parsing: <200 https://blog.scrapinghub.com/page/11/>
Total run time: 0:01:01.099267

默认情况下,这实际上并不会等待 5 秒。它将等待DOWNLOAD_DELAY秒,但是在DOWNLOAD_DELAY的 0.5 到 1.5 倍之间有一个随机因素。为什么这样做?这会让您的爬虫看起来“不那么机械化”。您可以通过使用RANDOMIZED_DOWNLOAD_DELAY=False设置来关闭这个功能。

它是如何工作的

这个爬虫是作为一个 Scrapy 爬虫实现的。类定义从声明爬虫名称和起始 URL 开始:

class Spider(scrapy.Spider):
  name = 'spider'
  start_urls = ['https://blog.scrapinghub.com']

解析方法查找 CSS 'div.prev-post > a',并跟踪这些链接。

爬虫还定义了一个 close 方法,当爬取完成时,Scrapy 会调用这个方法:

def close(spider, reason):
  start_time = spider.crawler.stats.get_value('start_time')
  finish_time = spider.crawler.stats.get_value('finish_time')
  print("Total run time: ", finish_time-start_time)

这访问了爬虫的统计对象,检索了爬虫的开始和结束时间,并向用户报告了差异。

还有更多...

脚本还定义了在直接使用 Python 执行脚本时的代码:

if __name__ == "__main__":
  process = CrawlerProcess({
  'DOWNLOAD_DELAY': 5,
  'RANDOMIZED_DOWNLOAD_DELAY': False,
  'LOG_LEVEL': 'DEBUG'
  })
  process.crawl(Spider)
  process.start()

这是通过创建一个 CrawlerProcess 对象开始的。这个对象可以传递一个表示设置和值的字典,以配置爬取。这默认为五秒的延迟,没有随机化,并且输出级别为 DEBUG。

使用可识别的用户代理

如果您违反了服务条款并被网站所有者标记了怎么办?您如何帮助网站所有者联系您,以便他们可以友好地要求您停止对他们认为合理的抓取级别?

为了方便这一点,您可以在请求的 User-Agent 标头中添加有关自己的信息。我们已经在robots.txt文件中看到了这样的例子,比如来自 amazon.com。在他们的robots.txt中明确声明了一个用于 Google 的用户代理:GoogleBot。

在爬取过程中,您可以在 HTTP 请求的 User-Agent 标头中嵌入自己的信息。为了礼貌起见,您可以输入诸如'MyCompany-MyCrawler(mybot@mycompany.com)'之类的内容。如果远程服务器标记您违规,肯定会捕获这些信息,如果提供了这样的信息,他们可以方便地与您联系,而不仅仅是关闭您的访问。

如何做到

根据您使用的工具,设置用户代理会有所不同。最终,它只是确保 User-Agent 标头设置为您指定的字符串。在使用浏览器时,这通常由浏览器设置为标识浏览器和操作系统。但您可以在此标头中放入任何您想要的内容。在使用请求时,这非常简单:

url = 'https://api.github.com/some/endpoint'
headers = {'user-agent': 'MyCompany-MyCrawler (mybot@mycompany.com)'}
r = requests.get(url, headers=headers) 

在使用 Scrapy 时,只需配置一个设置即可:

process = CrawlerProcess({
 'USER_AGENT': 'MyCompany-MyCrawler (mybot@mycompany.com)'  }) process.crawl(Spider) process.start()

它是如何工作的

传出的 HTTP 请求有许多不同的标头。这些确保 User-Agent 标头对目标网站的所有请求都设置为此值。

还有更多...

虽然可能将任何内容设置为 User-Agent 标头,但有些 Web 服务器会检查 User-Agent 标头并根据内容做出响应。一个常见的例子是使用标头来识别移动设备以提供移动展示。

但有些网站只允许特定 User-Agent 值访问内容。设置自己的值可能导致 Web 服务器不响应或返回其他错误,比如未经授权。因此,在使用此技术时,请确保检查它是否有效。

设置每个域的并发请求数量

一次只爬取一个网址通常效率低下。因此,通常会同时向目标站点发出多个页面请求。通常情况下,远程 Web 服务器可以相当有效地处理多个同时的请求,而在您的端口,您只是在等待每个请求返回数据,因此并发通常对您的爬虫工作效果很好。

但这也是聪明的网站可以识别并标记为可疑活动的模式。而且,您的爬虫端和网站端都有实际限制。发出的并发请求越多,双方都需要更多的内存、CPU、网络连接和网络带宽。这都涉及成本,并且这些值也有实际限制。

因此,通常最好的做法是设置您将同时向任何 Web 服务器发出的请求的数量限制。

它是如何工作的

有许多技术可以用来控制并发级别,这个过程通常会相当复杂,需要控制多个请求和执行线程。我们不会在这里讨论如何在线程级别进行操作,只提到了内置在 Scrapy 中的构造。

Scrapy 在其请求中天生是并发的。默认情况下,Scrapy 将最多同时向任何给定域发送八个请求。您可以使用CONCURRENT_REQUESTS_PER_DOMAIN设置来更改这一点。以下将该值设置为 1 个并发请求:

process = CrawlerProcess({
 'CONCURRENT_REQUESTS_PER_DOMAIN': 1  }) process.crawl(Spider) process.start()

使用自动节流

与控制最大并发级别紧密相关的是节流的概念。不同的网站在不同时间对请求的处理能力不同。在响应时间较慢的时期,减少请求的数量是有意义的。这可能是一个繁琐的过程,需要手动监控和调整。

幸运的是,对于我们来说,scrapy 还提供了通过名为AutoThrottle的扩展来实现这一点的能力。

如何做到

可以使用AUTOTHROTTLE_TARGET_CONCURRENCY设置轻松配置 AutoThrottle。

process = CrawlerProcess({
 'AUTOTHROTTLE_TARGET_CONCURRENCY': 3  }) process.crawl(Spider) process.start()

它是如何工作的

scrapy 跟踪每个请求的延迟。利用这些信息,它可以调整请求之间的延迟,以便在特定域上同时活动的请求不超过AUTOTHROTTLE_TARGET_CONCURRENCY,并且请求在任何给定的时间跨度内均匀分布。

还有更多...

有很多控制节流的选项。您可以在以下网址上获得它们的概述:doc.scrapy.org/en/latest/topics/autothrottle.html?&_ga=2.54316072.1404351387.1507758575-507079265.1505263737#settings.

使用 HTTP 缓存进行开发

网络爬虫的开发是一个探索过程,将通过各种细化来迭代检索所需的信息。在开发过程中,您经常会反复访问远程服务器和这些服务器上的相同 URL。这是不礼貌的。幸运的是,scrapy 也通过提供专门设计用于帮助解决这种情况的缓存中间件来拯救您。

如何做到这一点

Scrapy 将使用名为 HttpCacheMiddleware 的中间件模块缓存请求。启用它就像将HTTPCACHE_ENABLED设置为True一样简单:

process = CrawlerProcess({
 'AUTOTHROTTLE_TARGET_CONCURRENCY': 3  }) process.crawl(Spider) process.start()

它是如何工作的

HTTP 缓存的实现既简单又复杂。Scrapy 提供的HttpCacheMiddleware根据您的需求有大量的配置选项。最终,它归结为将每个 URL 及其内容存储在存储器中,并附带缓存过期的持续时间。如果在过期间隔内对 URL 进行第二次请求,则将检索本地副本,而不是进行远程请求。如果时间已经过期,则从 Web 服务器获取内容,存储在缓存中,并设置新的过期时间。

还有更多...

有许多配置 scrapy 缓存的选项,包括存储内容的方式(文件系统、DBM 或 LevelDB)、缓存策略以及如何处理来自服务器的 Http 缓存控制指令。要探索这些选项,请查看以下网址:doc.scrapy.org/en/latest/topics/downloader-middleware.html?_ga=2.50242598.1404351387.1507758575-507079265.1505263737#dummy-policy-default.

第六章:爬取挑战和解决方案

在本章中,我们将涵盖:

  • 重试失败的页面下载

  • 支持页面重定向

  • 等待 Selenium 中的内容可用

  • 将爬行限制为单个域

  • 处理无限滚动页面

  • 控制爬行的深度

  • 控制爬行的长度

  • 处理分页网站

  • 处理表单和基于表单的授权

  • 处理基本授权

  • 通过代理防止被禁止爬取

  • 随机化用户代理

  • 缓存响应

介绍

开发可靠的爬虫从来都不容易,我们需要考虑很多假设。如果网站崩溃了怎么办?如果响应返回意外数据怎么办?如果您的 IP 被限制或阻止了怎么办?如果需要身份验证怎么办?虽然我们永远无法预测和涵盖所有假设,但我们将讨论一些常见的陷阱、挑战和解决方法。

请注意,其中几个配方需要访问我提供的作为 Docker 容器的网站。它们需要比我们在早期章节中使用的简单静态站点更多的逻辑。因此,您需要使用以下 Docker 命令拉取和运行 Docker 容器:

docker pull mheydt/pywebscrapecookbook
docker run -p 5001:5001 pywebscrapecookbook

重试失败的页面下载

使用重试中间件,Scrapy 可以轻松处理失败的页面请求。安装后,Scrapy 将在接收以下 HTTP 错误代码时尝试重试:

[500, 502, 503, 504, 408]

可以使用以下参数进一步配置该过程:

  • RETRY_ENABLED(True/False-默认为 True)

  • RETRY_TIMES(在任何错误上重试的次数-默认为 2)

  • RETRY_HTTP_CODES(应该重试的 HTTP 错误代码列表-默认为[500, 502, 503, 504, 408])

如何做到

06/01_scrapy_retry.py脚本演示了如何配置 Scrapy 进行重试。脚本文件包含了以下 Scrapy 的配置:

process = CrawlerProcess({
  'LOG_LEVEL': 'DEBUG',
  'DOWNLOADER_MIDDLEWARES':
 {  "scrapy.downloadermiddlewares.retry.RetryMiddleware": 500
  },
  'RETRY_ENABLED': True,
  'RETRY_TIMES': 3 }) process.crawl(Spider) process.start()

它是如何工作的

Scrapy 在运行蜘蛛时会根据指定的重试配置进行重试。在遇到错误时,Scrapy 会在放弃之前最多重试三次。

支持页面重定向

Scrapy 中的页面重定向是使用重定向中间件处理的,默认情况下已启用。可以使用以下参数进一步配置该过程:

  • REDIRECT_ENABLED:(True/False-默认为 True)

  • REDIRECT_MAX_TIMES:(对于任何单个请求要遵循的最大重定向次数-默认为 20)

如何做到

06/02_scrapy_redirects.py脚本演示了如何配置 Scrapy 来处理重定向。这为任何页面配置了最多两次重定向。运行该脚本会读取 NASA 站点地图并爬取内容。其中包含大量重定向,其中许多是从 HTTP 到 HTTPS 版本的 URL 的重定向。输出会很多,但以下是一些演示输出的行:

Parsing: <200 https://www.nasa.gov/content/earth-expeditions-above/>
['http://www.nasa.gov/content/earth-expeditions-above', 'https://www.nasa.gov/content/earth-expeditions-above']

此特定 URL 在重定向后被处理,从 URL 的 HTTP 版本重定向到 HTTPS 版本。该列表定义了涉及重定向的所有 URL。

您还将能够看到输出页面中重定向超过指定级别(2)的位置。以下是一个例子:

2017-10-22 17:55:00 [scrapy.downloadermiddlewares.redirect] DEBUG: Discarding <GET http://www.nasa.gov/topics/journeytomars/news/index.html>: max redirections reached

它是如何工作的

蜘蛛被定义为以下内容:

class Spider(scrapy.spiders.SitemapSpider):
  name = 'spider'
  sitemap_urls = ['https://www.nasa.gov/sitemap.xml']    def parse(self, response):
  print("Parsing: ", response)
  print (response.request.meta.get('redirect_urls'))

这与我们之前基于 NASA 站点地图的爬虫相同,只是增加了一行打印redirect_urls。在对parse的任何调用中,此元数据将包含到达此页面所发生的所有重定向。

爬行过程使用以下代码进行配置:

process = CrawlerProcess({
  'LOG_LEVEL': 'DEBUG',
  'DOWNLOADER_MIDDLEWARES':
 {  "scrapy.downloadermiddlewares.redirect.RedirectMiddleware": 500
  },
  'REDIRECT_ENABLED': True,
  'REDIRECT_MAX_TIMES': 2 }) 

重定向默认已启用,但这将将最大重定向次数设置为 2,而不是默认值 20。

等待 Selenium 中的内容可用

动态网页的一个常见问题是,即使整个页面已经加载完成,因此 Selenium 中的get()方法已经返回,仍然可能有我们需要稍后访问的内容,因为页面上仍有未完成的 Ajax 请求。这个的一个例子是需要点击一个按钮,但是在加载页面后,直到所有数据都已异步加载到页面后,按钮才被启用。

以以下页面为例:the-internet.herokuapp.com/dynamic_loading/2。这个页面加载非常快,然后呈现给我们一个开始按钮:

屏幕上呈现的开始按钮

按下按钮后,我们会看到一个进度条,持续五秒:

等待时的状态栏

当这个完成后,我们会看到 Hello World!

页面完全渲染后

现在假设我们想要爬取这个页面,以获取只有在按下按钮并等待后才暴露的内容?我们该怎么做?

如何做到这一点

我们可以使用 Selenium 来做到这一点。我们将使用 Selenium 的两个功能。第一个是点击页面元素的能力。第二个是等待直到页面上具有特定 ID 的元素可用。

  1. 首先,我们获取按钮并点击它。按钮的 HTML 如下:
<div id='start'>
   <button>Start</button>
</div>
  1. 当按下按钮并加载完成后,以下 HTML 将被添加到文档中:
<div id='finish'>
   <h4>Hello World!"</h4>
</div>
  1. 我们将使用 Selenium 驱动程序来查找开始按钮,点击它,然后等待直到div中的 ID 为'finish'可用。然后我们获取该元素并返回封闭的<h4>标签中的文本。

您可以通过运行06/03_press_and_wait.py来尝试这个。它的输出将是以下内容:

clicked
Hello World!

现在让我们看看它是如何工作的。

它是如何工作的

让我们分解一下解释:

  1. 我们首先从 Selenium 中导入所需的项目:
from selenium import webdriver
from selenium.webdriver.support import ui
  1. 现在我们加载驱动程序和页面:
driver = webdriver.PhantomJS() driver.get("http://the-internet.herokuapp.com/dynamic_loading/2")
  1. 页面加载后,我们可以检索按钮:
button = driver.find_element_by_xpath("//*/div[@id='start']/button")
  1. 然后我们可以点击按钮:
button.click() print("clicked")
  1. 接下来我们创建一个WebDriverWait对象:
wait = ui.WebDriverWait(driver, 10)
  1. 有了这个对象,我们可以请求 Selenium 的 UI 等待某些事件。这也设置了最长等待 10 秒。现在使用这个,我们可以等到我们满足一个标准;使用以下 XPath 可以识别一个元素:
wait.until(lambda driver: driver.find_element_by_xpath("//*/div[@id='finish']"))
  1. 当这完成后,我们可以检索 h4 元素并获取其封闭文本:
finish_element=driver.find_element_by_xpath("//*/div[@id='finish']/h4") print(finish_element.text)

限制爬行到单个域

我们可以通知 Scrapy 将爬行限制在指定域内的页面。这是一个重要的任务,因为链接可以指向网页的任何地方,我们通常希望控制爬行的方向。Scrapy 使这个任务非常容易。只需要设置爬虫类的allowed_domains字段即可。

如何做到这一点

这个示例的代码是06/04_allowed_domains.py。您可以使用 Python 解释器运行脚本。它将执行并生成大量输出,但如果您留意一下,您会发现它只处理 nasa.gov 上的页面。

它是如何工作的

代码与之前的 NASA 网站爬虫相同,只是我们包括allowed_domains=['nasa.gov']

class Spider(scrapy.spiders.SitemapSpider):
  name = 'spider'
  sitemap_urls = ['https://www.nasa.gov/sitemap.xml']
  allowed_domains=['nasa.gov']    def parse(self, response):
  print("Parsing: ", response) 

NASA 网站在其根域内保持相当一致,但偶尔会有指向其他网站的链接,比如 boeing.com 上的内容。这段代码将阻止转移到这些外部网站。

处理无限滚动页面

许多网站已经用无限滚动机制取代了“上一页/下一页”分页按钮。这些网站使用这种技术在用户到达页面底部时加载更多数据。因此,通过点击“下一页”链接进行爬行的策略就会崩溃。

虽然这似乎是使用浏览器自动化来模拟滚动的情况,但实际上很容易找出网页的 Ajax 请求,并使用这些请求来爬取,而不是实际页面。让我们以spidyquotes.herokuapp.com/scroll为例。

准备就绪

在浏览器中打开spidyquotes.herokuapp.com/scroll。当你滚动到页面底部时,页面将加载更多内容:

要抓取的引用的屏幕截图

页面打开后,进入开发者工具并选择网络面板。然后,滚动到页面底部。您将在网络面板中看到新内容:

开发者工具选项的屏幕截图

当我们点击其中一个链接时,我们可以看到以下 JSON:

{
"has_next": true,
"page": 2,
"quotes": [{
"author": {
"goodreads_link": "/author/show/82952.Marilyn_Monroe",
"name": "Marilyn Monroe",
"slug": "Marilyn-Monroe"
},
"tags": ["friends", "heartbreak", "inspirational", "life", "love", "sisters"],
"text": "\u201cThis life is what you make it...."
}, {
"author": {
"goodreads_link": "/author/show/1077326.J_K_Rowling",
"name": "J.K. Rowling",
"slug": "J-K-Rowling"
},
"tags": ["courage", "friends"],
"text": "\u201cIt takes a great deal of bravery to stand up to our enemies, but just as much to stand up to our friends.\u201d"
},

这很棒,因为我们只需要不断生成对/api/quotes?page=x的请求,增加x直到回复文档中存在has_next标签。如果没有更多页面,那么这个标签将不在文档中。

如何做到这一点

06/05_scrapy_continuous.py文件包含一个 Scrapy 代理,它爬取这组页面。使用 Python 解释器运行它,你将看到类似以下的输出(以下是输出的多个摘录):

<200 http://spidyquotes.herokuapp.com/api/quotes?page=2>
2017-10-29 16:17:37 [scrapy.core.scraper] DEBUG: Scraped from <200 http://spidyquotes.herokuapp.com/api/quotes?page=2>
{'text': "“This life is what you make it. No matter what, you're going to mess up sometimes, it's a universal truth. But the good part is you get to decide how you're going to mess it up. Girls will be your friends - they'll act like it anyway. But just remember, some come, some go. The ones that stay with you through everything - they're your true best friends. Don't let go of them. Also remember, sisters make the best friends in the world. As for lovers, well, they'll come and go too. And baby, I hate to say it, most of them - actually pretty much all of them are going to break your heart, but you can't give up because if you give up, you'll never find your soulmate. You'll never find that half who makes you whole and that goes for everything. Just because you fail once, doesn't mean you're gonna fail at everything. Keep trying, hold on, and always, always, always believe in yourself, because if you don't, then who will, sweetie? So keep your head high, keep your chin up, and most importantly, keep smiling, because life's a beautiful thing and there's so much to smile about.”", 'author': 'Marilyn Monroe', 'tags': ['friends', 'heartbreak', 'inspirational', 'life', 'love', 'sisters']}
2017-10-29 16:17:37 [scrapy.core.scraper] DEBUG: Scraped from <200 http://spidyquotes.herokuapp.com/api/quotes?page=2>
{'text': '“It takes a great deal of bravery to stand up to our enemies, but just as much to stand up to our friends.”', 'author': 'J.K. Rowling', 'tags': ['courage', 'friends']}
2017-10-29 16:17:37 [scrapy.core.scraper] DEBUG: Scraped from <200 http://spidyquotes.herokuapp.com/api/quotes?page=2>
{'text': "“If you can't explain it to a six year old, you don't understand it yourself.”", 'author': 'Albert Einstein', 'tags': ['simplicity', 'understand']}

当它到达第 10 页时,它将停止,因为它会看到内容中没有设置下一页标志。

它是如何工作的

让我们通过蜘蛛来看看这是如何工作的。蜘蛛从以下开始 URL 的定义开始:

class Spider(scrapy.Spider):
  name = 'spidyquotes'
  quotes_base_url = 'http://spidyquotes.herokuapp.com/api/quotes'
  start_urls = [quotes_base_url]
  download_delay = 1.5

然后解析方法打印响应,并将 JSON 解析为数据变量:

  def parse(self, response):
  print(response)
  data = json.loads(response.body)

然后它循环遍历 JSON 对象的引用元素中的所有项目。对于每个项目,它将向 Scrapy 引擎产生一个新的 Scrapy 项目:

  for item in data.get('quotes', []):
  yield {
  'text': item.get('text'),
  'author': item.get('author', {}).get('name'),
  'tags': item.get('tags'),
 } 

然后它检查数据 JSON 变量是否具有'has_next'属性,如果有,它将获取下一页并向 Scrapy 产生一个新的请求来解析下一页:

if data['has_next']:
    next_page = data['page'] + 1
  yield scrapy.Request(self.quotes_base_url + "?page=%s" % next_page)

还有更多...

也可以使用 Selenium 处理无限滚动页面。以下代码在06/06_scrape_continuous_twitter.py中:

from selenium import webdriver
import time

driver = webdriver.PhantomJS()   print("Starting") driver.get("https://twitter.com") scroll_pause_time = 1.5   # Get scroll height last_height = driver.execute_script("return document.body.scrollHeight") while True:
  print(last_height)
  # Scroll down to bottom
  driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")    # Wait to load page
  time.sleep(scroll_pause_time)    # Calculate new scroll height and compare with last scroll height
  new_height = driver.execute_script("return document.body.scrollHeight")
  print(new_height, last_height)    if new_height == last_height:
  break
  last_height = new_height

输出将类似于以下内容:

Starting
4882
8139 4882
8139
11630 8139
11630
15055 11630
15055
15055 15055
Process finished with exit code 0

这段代码首先从 Twitter 加载页面。调用.get()将在页面完全加载时返回。然后检索scrollHeight,程序滚动到该高度并等待新内容加载片刻。然后再次检索浏览器的scrollHeight,如果与last_height不同,它将循环并继续处理。如果与last_height相同,则没有加载新内容,然后您可以继续检索已完成页面的 HTML。

控制爬取的深度

可以使用 Scrapy 的DepthMiddleware中间件来控制爬取的深度。深度中间件限制了 Scrapy 从任何给定链接获取的跟随数量。这个选项对于控制你深入到特定爬取中有多有用。这也用于防止爬取过长,并且在你知道你要爬取的内容位于从爬取开始的页面的一定数量的分离度内时非常有用。

如何做到这一点

深度控制中间件默认安装在中间件管道中。深度限制的示例包含在06/06_limit_depth.py脚本中。该脚本爬取源代码提供的端口 8080 上的静态站点,并允许您配置深度限制。该站点包括三个级别:0、1 和 2,并且每个级别有三个页面。文件名为CrawlDepth<level><pagenumber>.html。每个级别的第 1 页链接到同一级别的其他两页,以及下一级别的第 1 页。到达第 2 级的链接结束。这种结构非常适合检查 Scrapy 中如何处理深度处理。

它是如何工作的

深度限制可以通过设置DEPTH_LIMIT参数来执行:

process = CrawlerProcess({
    'LOG_LEVEL': 'CRITICAL',
    'DEPTH_LIMIT': 2,
    'DEPT_STATS': True })

深度限制为 1 意味着我们只会爬取一层,这意味着它将处理start_urls中指定的 URL,然后处理这些页面中找到的任何 URL。使用DEPTH_LIMIT我们得到以下输出:

Parsing: <200 http://localhost:8080/CrawlDepth0-1.html>
Requesting crawl of: http://localhost:8080/CrawlDepth0-2.html
Requesting crawl of: http://localhost:8080/Depth1/CrawlDepth1-1.html
Parsing: <200 http://localhost:8080/Depth1/CrawlDepth1-1.html>
Requesting crawl of: http://localhost:8080/Depth1/CrawlDepth1-2.html
Requesting crawl of: http://localhost:8080/Depth1/depth1/CrawlDepth1-2.html
Requesting crawl of: http://localhost:8080/Depth1/depth2/CrawlDepth2-1.html
Parsing: <200 http://localhost:8080/CrawlDepth0-2.html>
Requesting crawl of: http://localhost:8080/CrawlDepth0-3.html
<scrapy.statscollectors.MemoryStatsCollector object at 0x109f754e0>
Crawled: ['http://localhost:8080/CrawlDepth0-1.html', 'http://localhost:8080/Depth1/CrawlDepth1-1.html', 'http://localhost:8080/CrawlDepth0-2.html']
Requested: ['http://localhost:8080/CrawlDepth0-2.html', 'http://localhost:8080/Depth1/CrawlDepth1-1.html', 'http://localhost:8080/Depth1/CrawlDepth1-2.html', 'http://localhost:8080/Depth1/depth1/CrawlDepth1-2.html', 'http://localhost:8080/Depth1/depth2/CrawlDepth2-1.html', 'http://localhost:8080/CrawlDepth0-3.html']

爬取从CrawlDepth0-1.html开始。该页面有两行,一行到CrawlDepth0-2.html,一行到CrawlDepth1-1.html。然后请求解析它们。考虑到起始页面在深度 0,这些页面在深度 1,是我们深度的限制。因此,我们将看到这两个页面被解析。但是,请注意,这两个页面的所有链接,虽然请求解析,但由于它们在深度 2,超出了指定的限制,因此被 Scrapy 忽略。

现在将深度限制更改为 2:

process = CrawlerProcess({
  'LOG_LEVEL': 'CRITICAL',
  'DEPTH_LIMIT': 2,
  'DEPT_STATS': True })

然后输出变成如下:

Parsing: <200 http://localhost:8080/CrawlDepth0-1.html>
Requesting crawl of: http://localhost:8080/CrawlDepth0-2.html
Requesting crawl of: http://localhost:8080/Depth1/CrawlDepth1-1.html
Parsing: <200 http://localhost:8080/Depth1/CrawlDepth1-1.html>
Requesting crawl of: http://localhost:8080/Depth1/CrawlDepth1-2.html
Requesting crawl of: http://localhost:8080/Depth1/depth1/CrawlDepth1-2.html
Requesting crawl of: http://localhost:8080/Depth1/depth2/CrawlDepth2-1.html
Parsing: <200 http://localhost:8080/CrawlDepth0-2.html>
Requesting crawl of: http://localhost:8080/CrawlDepth0-3.html
Parsing: <200 http://localhost:8080/Depth1/depth2/CrawlDepth2-1.html>
Parsing: <200 http://localhost:8080/CrawlDepth0-3.html>
Parsing: <200 http://localhost:8080/Depth1/CrawlDepth1-2.html>
Requesting crawl of: http://localhost:8080/Depth1/CrawlDepth1-3.html
<scrapy.statscollectors.MemoryStatsCollector object at 0x10d3d44e0>
Crawled: ['http://localhost:8080/CrawlDepth0-1.html', 'http://localhost:8080/Depth1/CrawlDepth1-1.html', 'http://localhost:8080/CrawlDepth0-2.html', 'http://localhost:8080/Depth1/depth2/CrawlDepth2-1.html', 'http://localhost:8080/CrawlDepth0-3.html', 'http://localhost:8080/Depth1/CrawlDepth1-2.html']
Requested: ['http://localhost:8080/CrawlDepth0-2.html', 'http://localhost:8080/Depth1/CrawlDepth1-1.html', 'http://localhost:8080/Depth1/CrawlDepth1-2.html', 'http://localhost:8080/Depth1/depth1/CrawlDepth1-2.html', 'http://localhost:8080/Depth1/depth2/CrawlDepth2-1.html', 'http://localhost:8080/CrawlDepth0-3.html', 'http://localhost:8080/Depth1/CrawlDepth1-3.html']

请注意,之前被忽略的三个页面,当DEPTH_LIMIT设置为 1 时,现在被解析了。现在,这个深度下找到的链接,比如CrawlDepth1-3.html页面的链接,现在被忽略了,因为它们的深度超过了 2。

控制爬取的长度

爬取的长度,即可以解析的页面数量,可以通过CLOSESPIDER_PAGECOUNT设置来控制。

如何操作

我们将使用06/07_limit_length.py中的脚本。该脚本和爬虫与 NASA 站点地图爬虫相同,只是增加了以下配置来限制解析的页面数量为 5:

if __name__ == "__main__":
  process = CrawlerProcess({
  'LOG_LEVEL': 'INFO',
  'CLOSESPIDER_PAGECOUNT': 5
  })
  process.crawl(Spider)
  process.start()

当运行时,将生成以下输出(在日志输出中交错):

<200 https://www.nasa.gov/exploration/systems/sls/multimedia/sls-hardware-being-moved-on-kamag-transporter.html>
<200 https://www.nasa.gov/exploration/systems/sls/M17-057.html>
<200 https://www.nasa.gov/press-release/nasa-awards-contract-for-center-protective-services-for-glenn-research-center/>
<200 https://www.nasa.gov/centers/marshall/news/news/icymi1708025/>
<200 https://www.nasa.gov/content/oracles-completed-suit-case-flight-series-to-ascension-island/>
<200 https://www.nasa.gov/feature/goddard/2017/asteroid-sample-return-mission-successfully-adjusts-course/>
<200 https://www.nasa.gov/image-feature/jpl/pia21754/juling-crater/>

工作原理

请注意,我们将页面限制设置为 5,但实际示例解析了 7 页。CLOSESPIDER_PAGECOUNT的值应被视为 Scrapy 将至少执行的值,但可能会略微超出。

处理分页网站

分页将大量内容分成多个页面。通常,这些页面有一个供用户点击的上一页/下一页链接。这些链接通常可以通过 XPath 或其他方式找到,然后跟随以到达下一页(或上一页)。让我们来看看如何使用 Scrapy 遍历页面。我们将看一个假设的例子,爬取自动互联网搜索结果。这些技术直接适用于许多具有搜索功能的商业网站,并且很容易修改以适应这些情况。

准备工作

我们将演示如何处理分页,示例将从提供的容器网站中爬取一组页面。该网站模拟了五个页面,每个页面上都有上一页和下一页的链接,以及每个页面中的一些嵌入数据,我们将提取这些数据。

这个集合的第一页可以在http://localhost:5001/pagination/page1.html中看到。以下图片显示了这个页面的打开情况,我们正在检查下一页按钮:

检查下一页按钮

页面有两个感兴趣的部分。第一个是下一页按钮的链接。这个链接通常有一个类来标识链接作为下一页的链接。我们可以使用这个信息来找到这个链接。在这种情况下,我们可以使用以下 XPath 找到它:

//*/a[@class='next']

第二个感兴趣的部分实际上是从页面中检索我们想要的数据。在这些页面上,这是由具有class="data"属性的<div>标签标识的。这些页面只有一个数据项,但在这个搜索结果页面爬取的示例中,我们将提取多个项目。

现在让我们实际运行这些页面的爬虫。

如何操作

有一个名为06/08_scrapy_pagination.py的脚本。用 Python 运行此脚本,Scrapy 将输出大量内容,其中大部分将是标准的 Scrapy 调试输出。但是,在这些输出中,您将看到我们提取了所有五个页面上的数据项:

Page 1 Data
Page 2 Data
Page 3 Data
Page 4 Data
Page 5 Data

工作原理

代码从定义CrawlSpider和起始 URL 开始:

class PaginatedSearchResultsSpider(CrawlSpider):
    name = "paginationscraper"
  start_urls = [
"http://localhost:5001/pagination/page1.html"
  ]

然后定义了规则字段,它告诉 Scrapy 如何解析每个页面以查找链接。此代码使用前面讨论的 XPath 来查找页面中的下一个链接。Scrapy 将使用此规则在每个页面上查找下一个要处理的页面,并将该请求排队等待处理。对于找到的每个页面,回调参数告诉 Scrapy 调用哪个方法进行处理,在本例中是parse_result_page

rules = (
# Extract links for next pages
  Rule(LinkExtractor(allow=(),
restrict_xpaths=("//*/a[@class='next']")),
callback='parse_result_page', follow=True),
)

声明了一个名为all_items的单个列表变量来保存我们找到的所有项目:

all_items = []

然后定义了parse_start_url方法。Scrapy 将调用此方法来解析爬行中的初始 URL。该函数简单地将处理推迟到parse_result_page

def parse_start_url(self, response):
  return self.parse_result_page(response)

然后,parse_result_page方法使用 XPath 来查找<div class="data">标签中<h1>标签内的文本。然后将该文本附加到all_items列表中:

def parse_result_page(self, response):
    data_items = response.xpath("//*/div[@class='data']/h1/text()")
for data_item in data_items:
 self.all_items.append(data_item.root)

爬行完成后,将调用closed()方法并写出all_items字段的内容:

def closed(self, reason):
  for i in self.all_items:
  print(i) 

使用 Python 作为脚本运行爬虫,如下所示:

if __name__ == "__main__":
  process = CrawlerProcess({
  'LOG_LEVEL': 'DEBUG',
  'CLOSESPIDER_PAGECOUNT': 10   })
  process.crawl(ImdbSearchResultsSpider)
  process.start()

请注意,CLOSESPIDER_PAGECOUNT属性被设置为10。这超过了该网站上的页面数量,但在许多(或大多数)情况下,搜索结果可能会有数千个页面。在适当数量的页面后停止是一个很好的做法。这是爬虫的良好行为,因为在前几页之后,与您的搜索相关的项目的相关性急剧下降,因此在前几页之后继续爬行会大大减少回报,通常最好在几页后停止。

还有更多...

正如在本教程开始时提到的,这很容易修改为在各种内容网站上进行各种自动搜索。这种做法可能会推动可接受使用的极限,因此这里进行了泛化。但是,要获取更多实际示例,请访问我的博客:www.smac.io

处理表单和基于表单的授权

我们经常需要在爬取网站内容之前登录网站。这通常是通过一个表单完成的,我们在其中输入用户名和密码,按Enter,然后获得以前隐藏的内容的访问权限。这种类型的表单认证通常称为 cookie 授权,因为当我们授权时,服务器会创建一个 cookie,用于验证您是否已登录。Scrapy 尊重这些 cookie,所以我们所需要做的就是在爬行过程中自动化表单。

准备工作

我们将在容器网站的页面上爬行以下 URL:http://localhost:5001/home/secured。在此页面上,以及从该页面链接出去的页面,有我们想要抓取的内容。但是,此页面被登录阻止。在浏览器中打开页面时,我们会看到以下登录表单,我们可以在其中输入darkhelmet作为用户名,vespa作为密码:

输入用户名和密码凭证

按下Enter后,我们将得到验证,并被带到最初想要的页面。

那里没有太多的内容,但这条消息足以验证我们已经登录,我们的爬虫也知道这一点。

如何操作

我们按照以下步骤进行:

  1. 如果您检查登录页面的 HTML,您会注意到以下表单代码:
<form action="/Account/Login" method="post"><div>
 <label for="Username">Username</label>
 <input type="text" id="Username" name="Username" value="" />
 <span class="field-validation-valid" data-valmsg-for="Username" data-valmsg-replace="true"></span></div>
<div>
 <label for="Password">Password</label>
 <input type="password" id="Password" name="Password" />
 <span class="field-validation-valid" data-valmsg-for="Password" data-valmsg-replace="true"></span>
 </div> 
 <input type="hidden" name="returnUrl" />
<input name="submit" type="submit" value="Login"/>
 <input name="__RequestVerificationToken" type="hidden" value="CfDJ8CqzjGWzUMJKkKCmxuBIgZf3UkeXZnVKBwRV_Wu4qUkprH8b_2jno5-1SGSNjFqlFgLie84xI2ZBkhHDzwgUXpz6bbBwER0v_-fP5iTITiZi2VfyXzLD_beXUp5cgjCS5AtkIayWThJSI36InzBqj2A" /></form>
  1. 要使 Scrapy 中的表单处理器工作,我们需要该表单中用户名和密码字段的 ID。它们分别是UsernamePassword。现在我们可以使用这些信息创建一个蜘蛛。这个蜘蛛在脚本文件06/09_forms_auth.py中。蜘蛛定义以以下内容开始:
class Spider(scrapy.Spider):
  name = 'spider'
  start_urls = ['http://localhost:5001/home/secured']
  login_user = 'darkhelmet'
  login_pass = 'vespa'
  1. 我们在类中定义了两个字段login_userlogin_pass,用于保存我们要使用的用户名。爬行也将从指定的 URL 开始。

  2. 然后更改parse方法以检查页面是否包含登录表单。这是通过使用 XPath 来查看页面是否有一个类型为密码的输入表单,并且具有idPassword的方式来完成的:

def parse(self, response):
  print("Parsing: ", response)    count_of_password_fields = int(float(response.xpath("count(//*/input[@type='password' and @id='Password'])").extract()[0]))
  if count_of_password_fields > 0:
  print("Got a password page") 
  1. 如果找到了该字段,我们将返回一个FormRequest给 Scrapy,使用它的from_response方法生成:
return scrapy.FormRequest.from_response(
 response,
  formdata={'Username': self.login_user, 'Password': self.login_pass},
  callback=self.after_login)
  1. 这个函数接收响应,然后是一个指定需要插入数据的字段的 ID 的字典,以及这些值。然后定义一个回调函数,在 Scrapy 执行这个 FormRequest 后执行,并将生成的表单内容传递给它:
def after_login(self, response):
  if "This page is secured" in str(response.body):
  print("You have logged in ok!")
  1. 这个回调函数只是寻找单词This page is secured,只有在登录成功时才会返回。当成功运行时,我们将从我们的爬虫的打印语句中看到以下输出:
Parsing: <200 http://localhost:5001/account/login?ReturnUrl=%2Fhome%2Fsecured>
Got a password page
You have logged in ok!

它是如何工作的

当您创建一个FormRequest时,您正在指示 Scrapy 代表您的进程构造一个表单 POST 请求,使用指定字典中的数据作为 POST 请求中的表单参数。它构造这个请求并将其发送到服务器。在收到 POST 的答复后,它调用指定的回调函数。

还有更多...

这种技术在许多其他类型的表单输入中也很有用,不仅仅是登录表单。这可以用于自动化,然后执行任何类型的 HTML 表单请求,比如下订单,或者用于执行搜索操作的表单。

处理基本授权

一些网站使用一种称为基本授权的授权形式。这在其他授权方式(如 cookie 授权或 OAuth)出现之前很流行。它也常见于企业内部网络和一些 Web API。在基本授权中,一个头部被添加到 HTTP 请求中。这个头部,Authorization,传递了 Basic 字符串,然后是值<username>:<password>的 base64 编码。所以在 darkhelmet 的情况下,这个头部会如下所示:

Authorization: Basic ZGFya2hlbG1ldDp2ZXNwYQ==, with ZGFya2hlbG1ldDp2ZXNwYQ== being darkhelmet:vespa base 64 encoded.

请注意,这并不比以明文发送更安全(尽管通过 HTTPS 执行时是安全的)。然而,大部分情况下,它已经被更健壮的授权表单所取代,甚至 cookie 授权允许更复杂的功能,比如声明:

如何做到

在 Scrapy 中支持基本授权是很简单的。要使其对爬虫和爬取的特定网站起作用,只需在您的爬虫中定义http_userhttp_passname字段。以下是示例:

class SomeIntranetSiteSpider(CrawlSpider):
    http_user = 'someuser'
    http_pass = 'somepass'
    name = 'intranet.example.com'
    # .. rest of the spider code omitted ...

它是如何工作的

当爬虫爬取由名称指定的网站上的任何页面时,它将使用http_userhttp_pass的值来构造适当的标头。

还有更多...

请注意,这个任务是由 Scrapy 的HttpAuthMiddleware模块执行的。有关基本授权的更多信息也可以在developer.mozilla.org/en-US/docs/Web/HTTP/Authentication上找到。

通过代理来防止被屏蔽

有时候您可能会因为被识别为爬虫而被屏蔽,有时候是因为网站管理员看到来自统一 IP 的爬取请求,然后他们会简单地屏蔽对该 IP 的访问。

为了帮助防止这个问题,可以在 Scrapy 中使用代理随机化中间件。存在一个名为scrapy-proxies的库,它实现了代理随机化功能。

准备工作

您可以从 GitHub 上获取scrapy-proxies,网址为github.com/aivarsk/scrapy-proxies,或者使用pip install scrapy_proxies进行安装。

如何做到

使用scrapy-proxies是通过配置完成的。首先要配置DOWNLOADER_MIDDLEWARES,并确保安装了RetryMiddlewareRandomProxyHttpProxyMiddleware。以下是一个典型的配置:

# Retry many times since proxies often fail
RETRY_TIMES = 10
# Retry on most error codes since proxies fail for different reasons
RETRY_HTTP_CODES = [500, 503, 504, 400, 403, 404, 408]

DOWNLOADER_MIDDLEWARES = {
 'scrapy.downloadermiddlewares.retry.RetryMiddleware': 90,
 'scrapy_proxies.RandomProxy': 100,
 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 110,
}

PROXY_LIST设置被配置为指向一个包含代理列表的文件:

PROXY_LIST = '/path/to/proxy/list.txt'

然后,我们需要让 Scrapy 知道PROXY_MODE

# Proxy mode
# 0 = Every requests have different proxy
# 1 = Take only one proxy from the list and assign it to every requests
# 2 = Put a custom proxy to use in the settings
PROXY_MODE = 0

如果PROXY_MODE2,那么您必须指定一个CUSTOM_PROXY

CUSTOM_PROXY = "http://host1:port"

它是如何工作的

这个配置基本上告诉 Scrapy,如果对页面的请求失败,并且每个 URL 最多重试RETRY_TIMES次中的任何一个RETRY_HTTP_CODES,则使用PROXY_LIST指定的文件中的代理,并使用PROXY_MODE定义的模式。通过这种方式,您可以让 Scrapy 退回到任意数量的代理服务器,以从不同的 IP 地址和/或端口重试请求。

随机化用户代理

您使用的用户代理可能会影响爬虫的成功。一些网站将直接拒绝为特定的用户代理提供内容。这可能是因为用户代理被识别为被禁止的爬虫,或者用户代理是不受支持的浏览器(即 Internet Explorer 6)的用户代理。

对爬虫的控制另一个原因是,根据指定的用户代理,内容可能会在网页服务器上以不同的方式呈现。目前移动网站通常会这样做,但也可以用于桌面,比如为旧版浏览器提供更简单的内容。

因此,将用户代理设置为默认值以外的其他值可能是有用的。Scrapy 默认使用名为scrapybot的用户代理。可以通过使用BOT_NAME参数进行配置。如果使用 Scrapy 项目,Scrapy 将把代理设置为您的项目名称。

对于更复杂的方案,有两个常用的扩展可以使用:scrapy-fake-agentscrapy-random-useragent

如何做到这一点

我们按照以下步骤进行操作:

  1. scrapy-fake-useragent可在 GitHub 上找到,网址为github.com/alecxe/scrapy-fake-useragent,而scrapy-random-useragent可在github.com/cnu/scrapy-random-useragent找到。您可以使用pip install scrapy-fake-agent和/或pip install scrapy-random-useragent来包含它们。

  2. scrapy-random-useragent将从文件中为每个请求选择一个随机用户代理。它配置在两个设置中:

DOWNLOADER_MIDDLEWARES = {
    'scrapy.contrib.downloadermiddleware.useragent.UserAgentMiddleware': None,
    'random_useragent.RandomUserAgentMiddleware': 400
}
  1. 这将禁用现有的UserAgentMiddleware,并用RandomUserAgentMiddleware中提供的实现来替换它。然后,您需要配置一个包含用户代理名称列表的文件的引用:
USER_AGENT_LIST = "/path/to/useragents.txt"
  1. 配置完成后,每个请求将使用文件中的随机用户代理。

  2. scrapy-fake-useragent使用了不同的模型。它从在线数据库中检索用户代理,该数据库跟踪使用最普遍的用户代理。配置 Scrapy 以使用它的设置如下:

DOWNLOADER_MIDDLEWARES = {
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
    'scrapy_fake_useragent.middleware.RandomUserAgentMiddleware': 400,
}
  1. 它还具有设置使用的用户代理类型的能力,例如移动或桌面,以强制选择这两个类别中的用户代理。这是使用RANDOM_UA_TYPE设置执行的,默认为随机。

  2. 如果使用scrapy-fake-useragent与任何代理中间件,那么您可能希望对每个代理进行随机化。这可以通过将RANDOM_UA_PER_PROXY设置为 True 来实现。此外,您还需要将RandomUserAgentMiddleware的优先级设置为大于scrapy-proxies,以便在处理之前设置代理。

缓存响应

Scrapy 具有缓存 HTTP 请求的能力。如果页面已经被访问过,这可以大大减少爬取时间。通过启用缓存,Scrapy 将存储每个请求和响应。

如何做到这一点

06/10_file_cache.py脚本中有一个可用的示例。在 Scrapy 中,默认情况下禁用了缓存中间件。要启用此缓存,将HTTPCACHE_ENABLED设置为True,将HTTPCACHE_DIR设置为文件系统上的一个目录(使用相对路径将在项目的数据文件夹中创建目录)。为了演示,此脚本运行了 NASA 网站的爬取,并缓存了内容。它的配置如下:

if __name__ == "__main__":
  process = CrawlerProcess({
  'LOG_LEVEL': 'CRITICAL',
  'CLOSESPIDER_PAGECOUNT': 50,
  'HTTPCACHE_ENABLED': True,
  'HTTPCACHE_DIR': "."
  })
  process.crawl(Spider)
  process.start()

我们要求 Scrapy 使用文件进行缓存,并在当前文件夹中创建一个子目录。我们还指示它将爬取限制在大约 500 页。运行此操作时,爬取将大约需要一分钟(取决于您的互联网速度),并且大约会有 500 行的输出。

第一次执行后,您会发现您的目录中现在有一个.scrapy文件夹,其中包含缓存数据。 结构将如下所示:

再次运行脚本只需要几秒钟,将产生相同的输出/报告已解析的页面,只是这次内容将来自缓存而不是 HTTP 请求。

还有更多...

在 Scrapy 中有许多缓存的配置和选项。默认情况下,由HTTPCACHE_EXPIRATION_SECS指定的缓存过期时间设置为 0。 0 表示缓存项永远不会过期,因此一旦写入,Scrapy 将永远不会通过 HTTP 再次请求该项。实际上,您可能希望将其设置为某个会过期的值。

文件存储仅是缓存的选项之一。通过将HTTPCACHE_STORAGE设置为scrapy.extensions.httpcache.DbmCacheStoragescrapy.extensions.httpcache.LeveldbCacheStorage,也可以将项目缓存在 DMB 和 LevelDB 中。如果您愿意,还可以编写自己的代码,将页面内容存储在另一种类型的数据库或云存储中。

最后,我们来到缓存策略。Scrapy 自带两种内置策略:Dummy(默认)和 RFC2616。这可以通过将HTTPCACHE_POLICY设置更改为scrapy.extensions.httpcache.DummyPolicyscrapy.extensions.httpcache.RFC2616Policy来设置。

RFC2616 策略通过以下操作启用 HTTP 缓存控制意识:

  • 不要尝试存储设置了 no-store 缓存控制指令的响应/请求

  • 如果设置了 no-cache 缓存控制指令,即使是新鲜的响应,也不要从缓存中提供响应

  • 从 max-age 缓存控制指令计算新鲜度生存期

  • 从 Expires 响应标头计算新鲜度生存期

  • 从 Last-Modified 响应标头计算新鲜度生存期(Firefox 使用的启发式)

  • 从 Age 响应标头计算当前年龄

  • 从日期标头计算当前年龄

  • 根据 Last-Modified 响应标头重新验证陈旧的响应

  • 根据 ETag 响应标头重新验证陈旧的响应

  • 为任何接收到的响应设置日期标头

  • 支持请求中的 max-stale 缓存控制指令

第七章:执行词形还原

如何做

  • 一些过程,比如我们将使用的过程,需要额外下载它们用于执行各种分析的各种数据集。可以通过执行以下操作来下载它们:安装 NLTK

  • 安装 NLTK

  • NTLK GUI

  • 执行词干提取

  • 首先我们从 NLTK 导入句子分词器:

  • 识别和删除短词

  • 句子分割的第一个例子在07/01_sentence_splitting1.py文件中。这使用 NLTK 中内置的句子分割器,该分割器使用内部边界检测算法:

  • 识别和删除罕见单词

  • 然后使用sent_tokenize分割句子,并报告句子:

  • 我们按照以下步骤进行:

  • 介绍

  • 您可以使用语言参数选择所需的语言。例如,以下内容将基于德语进行分割:

  • 阅读和清理工作列表中的描述

挖掘数据通常是工作中最有趣的部分,文本是最常见的数据来源之一。我们将使用 NLTK 工具包介绍常见的自然语言处理概念和统计模型。我们不仅希望找到定量数据,比如我们已经抓取的数据中的数字,还希望能够分析文本信息的各种特征。这种文本信息的分析通常被归类为自然语言处理(NLP)的一部分。Python 有一个名为 NLTK 的库,提供了丰富的功能。我们将调查它的几种功能。

在 Mac 上,这实际上会弹出以下窗口:

如何做

NLTK 的核心可以使用 pip 安装:

从 StackOverflow 抓取工作列表

文本整理和分析

  1. 选择安装所有并按下下载按钮。工具将开始下载许多数据集。这可能需要一段时间,所以喝杯咖啡或啤酒,然后不时地检查一下。完成后,您就可以继续进行下一个步骤了。
pip install nltk
  1. 执行句子分割
import nltk nltk.download() showing info https://raw.githubusercontent.com/nltk/nltk_data/gh-pages/index.xml
  1. 删除标点符号

在本章中,我们将涵盖:

执行标记化

在这个配方中,我们学习安装 Python 的自然语言工具包 NTLK。

许多 NLP 过程需要将大量文本分割成句子。这可能看起来是一个简单的任务,但对于计算机来说可能会有问题。一个简单的句子分割器可以只查找句号(。),或者使用其他算法,比如预测分类器。我们将使用 NLTK 来检查两种句子分割的方法。

这将产生以下输出:

我们将使用存储在07/sentence1.txt文件中的句子。它包含以下内容,这些内容是从 StackOverflow 上的随机工作列表中提取的:

我们正在寻找具有以下经验的开发人员:ASP.NET,C#,SQL Server 和 AngularJS。我们是一个快节奏,高度迭代的团队,必须随着我们的工厂的增长迅速适应。我们需要那些习惯于解决新问题,创新解决方案,并且每天与公司的各个方面进行互动的人。有创意,有动力,能够承担责任并支持您创建的应用程序。帮助我们更快地将火箭送出去!

识别和删除停用词

  1. 从 StackOverflow 工作列表创建词云
from nltk.tokenize import sent_tokenize
  1. 然后加载文件:
with open('sentence1.txt', 'r') as myfile:
  data=myfile.read().replace('\n', '')
  1. 拼接 n-gram
sentences = sent_tokenize(data)   for s in sentences:
  print(s)

如果您想创建自己的分词器并自己训练它,那么可以使用PunktSentenceTokenizer类。sent_tokenize实际上是这个类的派生类,默认情况下实现了英语的句子分割。但是您可以从 17 种不同的语言模型中选择:

We are seeking developers with demonstrable experience in: ASP.NET, C#, SQL Server, and AngularJS.
We are a fast-paced, highly iterative team that has to adapt quickly as our factory grows.
We need people who are comfortable tackling new problems, innovating solutions, and interacting with every facet of the company on a daily basis.
Creative, motivated, able to take responsibility and support the applications you create.
Help us get rockets out the door faster!
  1. 执行句子分割
Michaels-iMac-2:~ michaelheydt$ ls ~/nltk_data/tokenizers/punkt PY3   finnish.pickle  portuguese.pickle README   french.pickle  slovene.pickle czech.pickle  german.pickle  spanish.pickle danish.pickle  greek.pickle  swedish.pickle dutch.pickle  italian.pickle  turkish.pickle english.pickle  norwegian.pickle estonian.pickle  polish.pickle
  1. 计算单词的频率分布
sentences = sent_tokenize(data, language="german") 

还有更多...

要了解更多关于这个算法的信息,可以阅读citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.85.5017&rep=rep1&type=pdf上提供的源论文。

执行标记化

标记化是将文本转换为标记的过程。这些标记可以是段落、句子和常见的单词,通常是基于单词级别的。NLTK 提供了许多标记器,将在本教程中进行演示。

如何做

这个示例的代码在07/02_tokenize.py文件中。它扩展了句子分割器,演示了五种不同的标记化技术。文件中的第一句将是唯一被标记化的句子,以便我们保持输出的数量在合理范围内:

  1. 第一步是简单地使用内置的 Python 字符串.split()方法。结果如下:
print(first_sentence.split())
['We', 'are', 'seeking', 'developers', 'with', 'demonstrable', 'experience', 'in:', 'ASP.NET,', 'C#,', 'SQL', 'Server,', 'and', 'AngularJS.'] 

句子是在空格边界上分割的。注意,诸如“:”和“,”之类的标点符号包括在生成的标记中。

  1. 以下演示了如何使用 NLTK 中内置的标记器。首先,我们需要导入它们:
from nltk.tokenize import word_tokenize, regexp_tokenize, wordpunct_tokenize, blankline_tokenize

以下演示了如何使用word_tokenizer

print(word_tokenize(first_sentence))
['We', 'are', 'seeking', 'developers', 'with', 'demonstrable', 'experience', 'in', ':', 'ASP.NET', ',', 'C', '#', ',', 'SQL', 'Server', ',', 'and', 'AngularJS', '.'] 

结果现在还将标点符号分割为它们自己的标记。

以下使用了正则表达式标记器,它允许您将任何正则表达式表达为标记器。它使用了一个'\w+'正则表达式,结果如下:

print(regexp_tokenize(first_sentence, pattern='\w+')) ['We', 'are', 'seeking', 'developers', 'with', 'demonstrable', 'experience', 'in', 'ASP', 'NET', 'C', 'SQL', 'Server', 'and', 'AngularJS']

wordpunct_tokenizer的结果如下:

print(wordpunct_tokenize(first_sentence))
['We', 'are', 'seeking', 'developers', 'with', 'demonstrable', 'experience', 'in', ':', 'ASP', '.', 'NET', ',', 'C', '#,', 'SQL', 'Server', ',', 'and', 'AngularJS', '.']

blankline_tokenize产生了以下结果:

print(blankline_tokenize(first_sentence))
['We are seeking developers with demonstrable experience in: ASP.NET, C#, SQL Server, and AngularJS.']

可以看到,这并不是一个简单的问题。根据被标记化的文本类型的不同,你可能会得到完全不同的结果。

执行词干提取

词干提取是将标记减少到其词干的过程。从技术上讲,它是将屈折(有时是派生)的单词减少到它们的词干形式的过程-单词的基本根形式。例如,单词fishingfishedfisher都来自根词fish。这有助于将被处理的单词集合减少到更容易处理的较小基本集合。

最常见的词干提取算法是由 Martin Porter 创建的,NLTK 提供了 PorterStemmer 中这个算法的实现。NLTK 还提供了 Snowball 词干提取器的实现,这也是由 Porter 创建的,旨在处理英语以外的其他语言。NLTK 还提供了一个名为 Lancaster 词干提取器的实现。Lancaster 词干提取器被认为是这三种中最激进的词干提取器。

如何做

NLTK 在其 PorterStemmer 类中提供了 Porter 词干提取算法的实现。可以通过以下代码轻松创建一个实例:

>>> from nltk.stem import PorterStemmer
>>> pst = PorterStemmer() >>> pst.stem('fishing') 'fish'

07/03_stemming.py文件中的脚本将 Porter 和 Lancaster 词干提取器应用于我们输入文件的第一句。执行词干提取的主要部分是以下内容:

pst = PorterStemmer() lst = LancasterStemmer() print("Stemming results:")   for token in regexp_tokenize(sentences[0], pattern='\w+'):
  print(token, pst.stem(token), lst.stem(token))

结果如下:

Stemming results:
We We we
are are ar
seeking seek seek
developers develop develop
with with with
demonstrable demonstr demonst
experience experi expery
in in in
ASP asp asp
NET net net
C C c
SQL sql sql
Server server serv
and and and
AngularJS angularj angulars

从结果可以看出,Lancaster 词干提取器确实比 Porter 词干提取器更激进,因为后者将几个单词进一步缩短了。

执行词形还原

词形还原是一个更系统的过程,将单词转换为它们的基本形式。词干提取通常只是截断单词的末尾,而词形还原考虑了单词的形态分析,评估上下文和词性以确定屈折形式,并在不同规则之间做出决策以确定词根。

如何做

在 NTLK 中可以使用WordNetLemmatizer进行词形还原。这个类使用 WordNet 服务,一个在线语义数据库来做出决策。07/04_lemmatization.py文件中的代码扩展了之前的词干提取示例,还计算了每个单词的词形还原。重要的代码如下:

from nltk.stem import PorterStemmer
from nltk.stem.lancaster import LancasterStemmer
from nltk.stem import WordNetLemmatizer

pst = PorterStemmer() lst = LancasterStemmer() wnl = WordNetLemmatizer()   print("Stemming / lemmatization results") for token in regexp_tokenize(sentences[0], pattern='\w+'):
  print(token, pst.stem(token), lst.stem(token), wnl.lemmatize(token))

结果如下:

Stemming / lemmatization results
We We we We
are are ar are
seeking seek seek seeking
developers develop develop developer
with with with with
demonstrable demonstr demonst demonstrable
experience experi expery experience
in in in in
ASP asp asp ASP
NET net net NET
C C c C
SQL sql sql SQL
Server server serv Server
and and and and
AngularJS angularj angulars AngularJS

使用词形还原过程的结果有一些差异。这表明,根据您的数据,其中一个可能比另一个更适合您的需求,因此如果需要,可以尝试所有这些方法。

确定和去除停用词

停用词是在自然语言处理情境中不提供太多上下文含义的常见词。这些词通常是语言中最常见的词。这些词在英语中至少包括冠词和代词,如Imetheiswhichwhoat等。在处理文档中的含义时,通常可以通过在处理之前去除这些词来方便处理,因此许多工具都支持这种能力。NLTK 就是其中之一,并且支持大约 22 种语言的停用词去除。

如何做

按照以下步骤进行(代码在07/06_freq_dist.py中可用):

  1. 以下演示了使用 NLTK 去除停用词。首先,从导入停用词开始:
>>> from nltk.corpus import stopwords
  1. 然后选择所需语言的停用词。以下选择英语:
>>> stoplist = stopwords.words('english')
  1. 英语停用词列表有 153 个单词:
>>> len(stoplist) 153
  1. 这不是太多,我们可以在这里展示它们所有:
>>> stoplist
 ['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours', 'yourself', 'yourselves', 'he', 'him', 'his', 'himself', 'she', 'her', 'hers', 'herself', 'it', 'its', 'itself', 'they', 'them', 'their', 'theirs', 'themselves', 'what', 'which', 'who', 'whom', 'this', 'that', 'these', 'those', 'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'of', 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up', 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', 'just', 'don', 'should', 'now', 'd', 'll', 'm', 'o', 're', 've', 'y', 'ain', 'aren', 'couldn', 'didn', 'doesn', 'hadn', 'hasn', 'haven', 'isn', 'ma', 'mightn', 'mustn', 'needn', 'shan', 'shouldn', 'wasn', 'weren', 'won', 'wouldn']
  1. 从单词列表中去除停用词可以通过简单的 Python 语句轻松完成。这在07/05_stopwords.py文件中有演示。脚本从所需的导入开始,并准备好我们要处理的句子:
from nltk.tokenize import sent_tokenize
from nltk.tokenize import regexp_tokenize
from nltk.corpus import stopwords

with open('sentence1.txt', 'r') as myfile:
  data = myfile.read().replace('\n', '')   sentences = sent_tokenize(data) first_sentence = sentences[0]   print("Original sentence:") print(first_sentence)
  1. 这产生了我们熟悉的以下输出:
Original sentence:
We are seeking developers with demonstrable experience in: ASP.NET, C#, SQL Server, and AngularJS.
  1. 然后我们对该句子进行标记化:
tokenized = regexp_tokenize(first_sentence, '\w+') print("Tokenized:", tokenized)
  1. 使用以下输出:
Tokenized: ['We', 'are', 'seeking', 'developers', 'with', 'demonstrable', 'experience', 'in', 'ASP', 'NET', 'C', 'SQL', 'Server', 'and', 'AngularJS']
  1. 然后我们可以使用以下语句去除停用词列表中的标记:
stoplist = stopwords.words('english') cleaned = [word for word in tokenized if word not in stoplist] print("Cleaned:", cleaned)

使用以下输出:

Cleaned: ['We', 'seeking', 'developers', 'demonstrable', 'experience', 'ASP', 'NET', 'C', 'SQL', 'Server', 'AngularJS']

还有更多...

去除停用词有其目的。这是有帮助的,正如我们将在后面的一篇文章中看到的,我们将在那里创建一个词云(停用词在词云中不提供太多信息),但也可能是有害的。许多其他基于句子结构推断含义的自然语言处理过程可能会因为去除停用词而受到严重阻碍。

计算单词的频率分布

频率分布计算不同数据值的出现次数。这些对我们很有价值,因为我们可以用它们来确定文档中最常见的单词或短语,从而推断出哪些具有更大或更小的价值。

可以使用几种不同的技术来计算频率分布。我们将使用内置在 NLTK 中的工具来进行检查。

如何做

NLTK 提供了一个类,ntlk.probabilities.FreqDist,可以让我们非常容易地计算列表中值的频率分布。让我们使用这个类来进行检查(代码在07/freq_dist.py中):

  1. 要使用 NLTK 创建频率分布,首先从 NTLK 中导入该功能(还有标记器和停用词):
from nltk.probabilities import FreqDist
from nltk.tokenize import regexp_tokenize
from nltk.corpus import stopwords
  1. 然后我们可以使用FreqDist函数根据单词列表创建频率分布。我们将通过读取wotw.txt(《世界大战》- 古腾堡出版社提供)的内容,对其进行标记化并去除停用词来进行检查:
with open('wotw.txt', 'r') as file:
  data = file.read() tokens = [word.lower() for word in regexp_tokenize(data, '\w+')] stoplist = stopwords.words('english') without_stops = [word for word in tokens if word not in stoplist]
  1. 然后我们可以计算剩余单词的频率分布:
freq_dist = FreqDist(without_stops)
  1. freq_dist是一个单词到单词计数的字典。以下打印了所有这些单词(只显示了几行输出,因为有成千上万个唯一单词):
print('Number of words: %s' % len(freq_dist)) for key in freq_dist.keys():
  print(key, freq_dist[key])
**Number of words: 6613
shall 8
dwell 1
worlds 2
inhabited 1
lords 1
world 26
things 64**
  1. 我们可以使用频率分布来识别最常见的单词。以下报告了最常见的 10 个单词:
print(freq_dist.most_common(10))
[('one', 201), ('upon', 172), ('said', 166), ('martians', 164), ('people', 159), ('came', 151), ('towards', 129), ('saw', 129), ('man', 126), ('time', 122)] 

我希望火星人在前 5 名中。它是第 4 名。

还有更多...

我们还可以使用这个来识别最不常见的单词,通过使用.most_common()的负值进行切片。例如,以下内容找到了最不常见的 10 个单词:

print(freq_dist.most_common()[-10:])
[('bitten', 1), ('gibber', 1), ('fiercer', 1), ('paler', 1), ('uglier', 1), ('distortions', 1), ('haunting', 1), ('mockery', 1), ('beds', 1), ('seers', 1)]

有相当多的单词只出现一次,因此这只是这些值的一个子集。只出现一次的单词数量可以通过以下方式确定(由于有 3,224 个单词,已截断):

dist_1 = [item[0] for item in freq_dist.items() if item[1] == 1] print(len(dist_1), dist_1)

3224 ['dwell', 'inhabited', 'lords', 'kepler', 'quoted', 'eve', 'mortal', 'scrutinised', 'studied', 'scrutinise', 'multiply', 'complacency', 'globe', 'infusoria', ...

识别和去除罕见单词

我们可以通过利用查找低频词的能力来删除低频词,这些词在某个领域中属于正常范围之外,或者只是从给定领域中被认为是罕见的单词列表中删除。但我们将使用的技术对两者都适用。

如何做

罕见单词可以通过构建一个罕见单词列表然后从正在处理的标记集中删除它们来移除。罕见单词列表可以通过使用 NTLK 提供的频率分布来确定。然后您决定应该使用什么阈值作为罕见单词的阈值:

  1. 07/07_rare_words.py 文件中的脚本扩展了频率分布配方,以识别出现两次或更少的单词,然后从标记中删除这些单词:
with open('wotw.txt', 'r') as file:
  data = file.read()   tokens = [word.lower() for word in regexp_tokenize(data, '\w+')] stoplist = stopwords.words('english') without_stops = [word for word in tokens if word not in stoplist]   freq_dist = FreqDist(without_stops)   print('Number of words: %s' % len(freq_dist))   # all words with one occurrence dist = [item[0] for item in freq_dist.items() if item[1] <= 2] print(len(dist)) not_rare = [word for word in without_stops if word not in dist]   freq_dist2 = FreqDist(not_rare) print(len(freq_dist2))

输出结果为:

Number of words: 6613
4361
2252

通过这两个步骤,删除停用词,然后删除出现 2 次或更少的单词,我们将单词的总数从 6,613 个减少到 2,252 个,大约是原来的三分之一。

识别和删除罕见单词

删除短单词也可以用于去除内容中的噪声单词。以下内容检查了删除特定长度或更短单词。它还演示了通过选择不被视为短的单词(长度超过指定的短单词长度)来进行相反操作。

如何做

我们可以利用 NLTK 的频率分布有效地计算短单词。我们可以扫描源中的所有单词,但扫描结果分布中所有键的长度会更有效,因为它将是一个显著较小的数据集:

  1. 07/08_short_words.py 文件中的脚本举例说明了这个过程。它首先加载了 wotw.txt 的内容,然后计算了单词频率分布(删除短单词后)。然后它识别了三个字符或更少的单词:
short_word_len = 3 short_words = [word for word in freq_dist.keys() if len(word) <= short_word_len] print('Distinct # of words of len <= %s: %s' % (short_word_len, len(short_words))) 

这将导致:

Distinct # of words of len <= 3: 184
  1. 通过更改列表推导中的逻辑运算符可以找到不被视为短的单词:
unshort_words = [word for word in freq_dist.keys() if len(word) > short_word_len] print('Distinct # of word > len %s: %s' % (short_word_len, len(unshort_words)))

结果为:

Distinct # of word > len 3: 6429

删除标点符号

根据使用的分词器和这些分词器的输入,可能希望从生成的标记列表中删除标点符号。regexp_tokenize 函数使用 '\w+' 作为表达式可以很好地去除标点符号,但 word_tokenize 做得不太好,会将许多标点符号作为它们自己的标记返回。

如何做

通过列表推导和仅选择不是标点符号的项目,类似于从标记中删除其他单词的标点符号的删除。07/09_remove_punctuation.py 文件演示了这一点。让我们一起走过这个过程:

  1. 我们将从以下开始,它将从工作列表中word_tokenize一个字符串:
>>> content = "Strong programming experience in C#, ASP.NET/MVC, JavaScript/jQuery and SQL Server" >>> tokenized = word_tokenize(content) >>> stop_list = stopwords.words('english') >>> cleaned = [word for word in tokenized if word not in stop_list] >>> print(cleaned)
['Strong', 'programming', 'experience', 'C', '#', ',', 'ASP.NET/MVC', ',', 'JavaScript/jQuery', 'SQL', 'Server'] 
  1. 现在我们可以用以下方法去除标点符号:
>>> punctuation_marks = [':', ',', '.', "``", "''", '(', ')', '-', '!', '#'] >>> tokens_cleaned = [word for word in cleaned if word not in punctuation_marks] >>> print(tokens_cleaned)
['Strong', 'programming', 'experience', 'C', 'ASP.NET/MVC', 'JavaScript/jQuery', 'SQL', 'Server']
  1. 这个过程可以封装在一个函数中。以下是在 07/punctuation.py 文件中,将删除标点符号:
def remove_punctuation(tokens):
  punctuation = [':', ',', '.', "``", "''", '(', ')', '-', '!', '#']
  return [token for token in tokens if token not in punctuation]

还有更多...

删除标点符号和符号可能是一个困难的问题。虽然它们对许多搜索没有价值,但标点符号也可能需要保留作为标记的一部分。以搜索工作网站并尝试找到 C# 编程职位为例,就像在这个配方中的示例一样。C# 的标记化被分成了两个标记:

>>> word_tokenize("C#") ['C', '#']

实际上我们有两个问题。将 C 和 # 分开后,我们失去了 C# 在源内容中的信息。然后,如果我们从标记中删除 #,那么我们也会失去这些信息,因为我们也无法从相邻的标记中重建 C#。

拼接 n-gram

关于 NLTK 被用于识别文本中的 n-gram 已经写了很多。n-gram 是文档/语料库中常见的一组单词,长度为n个单词(出现 2 次或更多)。2-gram 是任何常见的两个单词,3-gram 是一个三个单词的短语,依此类推。我们不会研究如何确定文档中的 n-gram。我们将专注于从我们的标记流中重建已知的 n-gram,因为我们认为这些 n-gram 对于搜索结果比任何顺序中找到的 2 个或 3 个独立单词更重要。

在解析工作列表的领域中,重要的 2-gram 可能是诸如计算机科学SQL Server数据科学大数据之类的东西。此外,我们可以将 C#视为'C''#'的 2-gram,因此在处理工作列表时,我们可能不希望使用正则表达式解析器或'#'作为标点符号。

我们需要有一个策略来识别我们的标记流中的这些已知组合。让我们看看如何做到这一点。

如何做到这一点

首先,这个例子并不打算进行详尽的检查或者最佳性能的检查。只是一个简单易懂的例子,可以轻松应用和扩展到我们解析工作列表的例子中:

  1. 我们将使用来自StackOverflow SpaceX 的工作列表的以下句子来检查这个过程:

我们正在寻找具有以下方面经验的开发人员:ASP.NET、C#、SQL Server 和 AngularJS。我们是一个快节奏、高度迭代的团队,随着我们的工厂的增长,我们必须快速适应。

  1. 这两个句子中有许多高价值的 2-gram(我认为工作列表是寻找 2-gram 的好地方)。仅仅看一下,我就可以挑出以下内容是重要的:
    • ASP.NET
  • C#

  • SQL Server

  • 快节奏

  • 高度迭代

  • 快速适应

  • 可证明的经验

  1. 现在,虽然这些在技术上的定义可能不是 2-gram,但当我们解析它们时,它们都将被分开成独立的标记。这可以在07/10-ngrams.py文件中显示,并在以下示例中显示:
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords

with open('job-snippet.txt', 'r') as file:
  data = file.read()   tokens = [word.lower() for word in word_tokenize(data)] stoplist = stopwords.words('english') without_stops = [word for word in tokens if word not in stoplist] print(without_stops)

这产生了以下输出:

['seeking', 'developers', 'demonstrable', 'experience', ':', 'asp.net', ',', 'c', '#', ',', 'sql', 'server', ',', 'angularjs', '.', 'fast-paced', ',', 'highly', 'iterative', 'team', 'adapt', 'quickly', 'factory', 'grows', '.']

我们希望从这个集合中去掉标点,但我们希望在构建一些 2-gram 之后再去做,特别是这样我们可以将"C#"拼接成一个单个标记。

  1. 07/10-reconstruct-2grams.py文件中的脚本演示了一个函数来实现这一点。首先,我们需要描述我们想要重建的 2-gram。在这个文件中,它们被定义为以下内容:
grams = {
  "c": [{"#": ""}],
  "sql": [{"server": " "}],
  "fast": [{"paced": "-"}],
  "highly": [{"iterative": " "}],
  "adapt": [{"quickly": " "}],
  "demonstrable": [{"experience", " "}]
}

grams是一个字典,其中键指定了 2-gram 的“左”侧。每个键都有一个字典列表,其中每个字典键可以是 2-gram 的右侧,值是将放在左侧和右侧之间的字符串。

  1. 有了这个定义,我们能够看到我们的标记中的"C""#"被重构为"C#"。"SQL""Server"将成为"SQL Server""fast""paced"将导致"faced-paced"

所以我们只需要一个函数来使这一切工作。这个函数在07/buildgrams.py文件中定义:

def build_2grams(tokens, patterns):
  results = []
  left_token = None
 for i, t in enumerate(tokens):
  if left_token is None:
  left_token = t
            continue    right_token = t

        if left_token.lower() in patterns:
  right = patterns[left_token.lower()]
  if right_token.lower() in right:
  results.append(left_token + right[right_token.lower()] + right_token)
  left_token = None
 else:
  results.append(left_token)
  else:
  results.append(left_token)
  left_token = right_token

    if left_token is not None:
  results.append(left_token)
  return results
  1. 这个函数,给定一组标记和一个以前描述的格式的字典,将返回一组修订后的标记,其中任何匹配的 2-gram 都被放入一个单个标记中。以下演示了它的一些简单用法:
grams = {
  'c': {'#': ''} } print(build_2grams(['C'], grams)) print(build_2grams(['#'], grams)) print(build_2grams(['C', '#'], grams)) print(build_2grams(['c', '#'], grams))

这导致以下输出:

['C']
['#']
['C#']
['c#']
  1. 现在让我们将其应用到我们的输入中。这个完整的脚本在07/10-reconstruct-2grams.py文件中(并添加了一些 2-gram):
grams = {
  "c": {"#": ""},
  "sql": {"server": " "},
  "fast": {"paced": "-"},
  "highly": {"iterative": " "},
  "adapt": {"quickly": " "},
  "demonstrable": {"experience": " "},
  "full": {"stack": " "},
  "enterprise": {"software": " "},
  "bachelor": {"s": "'"},
  "computer": {"science": " "},
  "data": {"science": " "},
  "current": {"trends": " "},
  "real": {"world": " "},
  "paid": {"relocation": " "},
  "web": {"server": " "},
  "relational": {"database": " "},
  "no": {"sql": " "} }   with open('job-snippet.txt', 'r') as file:
  data = file.read()   tokens = word_tokenize(data) stoplist = stopwords.words('english') without_stops = [word for word in tokens if word not in stoplist] result = remove_punctuation(build_2grams(without_stops, grams)) print(result)

结果如下:

['We', 'seeking', 'developers', 'demonstrable experience', 'ASP.NET', 'C#', 'SQL Server', 'AngularJS', 'We', 'fast-paced', 'highly iterative', 'team', 'adapt quickly', 'factory', 'grows']

完美!

还有更多...

我们向build_2grams()函数提供一个字典,该字典定义了识别 2-gram 的规则。在这个例子中,我们预定义了这些 2-gram。可以使用 NLTK 来查找 2-gram(以及一般的 n-gram),但是在这个小样本的一个工作职位中,可能找不到任何 2-gram。

从 StackOverflow 抓取工作列表

现在让我们将一些内容整合起来,从 StackOverflow 的工作列表中获取信息。这次我们只看一个列表,这样我们就可以了解这些页面的结构并从中获取信息。在后面的章节中,我们将研究如何从多个列表中聚合结果。现在让我们学习如何做到这一点。

准备就绪

实际上,StackOverflow 使得从他们的页面中抓取数据变得非常容易。我们将使用来自stackoverflow.com/jobs/122517/spacex-enterprise-software-engineer-full-stack-spacex?so=p&sec=True&pg=1&offset=22&cl=Amazon%3b+的内容。在您阅读时,这可能不再可用,因此我已经在07/spacex-job-listing.html文件中包含了此页面的 HTML,我们将在本章的示例中使用。

StackOverflow 的工作列表页面非常有结构。这可能是因为它们是由程序员创建的,也是为程序员创建的。页面(在撰写本文时)看起来像下面这样:

StackOverflow 工作列表

所有这些信息都被编码在页面的 HTML 中。您可以通过分析页面内容自行查看。但 StackOverflow 之所以如此出色的原因在于它将其大部分页面数据放在一个嵌入的 JSON 对象中。这是放置在<script type="application/ld+json>HTML 标签中的,所以很容易找到。下面显示了此标签的截断部分(描述被截断,但所有标记都显示出来):

工作列表中嵌入的 JSON

这使得获取内容非常容易,因为我们可以简单地检索页面,找到这个标签,然后使用json库将此 JSON 转换为 Python 对象。除了实际的工作描述,还包括了工作发布的大部分“元数据”,如技能、行业、福利和位置信息。我们不需要在 HTML 中搜索信息-只需找到这个标签并加载 JSON。请注意,如果我们想要查找项目,比如工作职责**,我们仍然需要解析描述。还要注意,描述包含完整的 HTML,因此在解析时,我们仍需要处理 HTML 标记。

如何做到这一点

让我们去获取这个页面的工作描述。我们将在下一个示例中对其进行清理。

这个示例的完整代码在07/12_scrape_job_stackoverflow.py文件中。让我们来看一下:

  1. 首先我们读取文件:
with open("spacex-job-listing.txt", "r") as file:
  content = file.read()
  1. 然后,我们将内容加载到BeautifulSoup对象中,并检索<script type="application/ld+json">标签:
bs = BeautifulSoup(content, "lxml") script_tag = bs.find("script", {"type": "application/ld+json"})
  1. 现在我们有了这个标签,我们可以使用json库将其内容加载到 Python 字典中:
job_listing_contents = json.loads(script_tag.contents[0]) print(job_listing_contents)

这个输出看起来像下面这样(为了简洁起见,这是截断的):

{'@context': 'http://schema.org', '@type': 'JobPosting', 'title': 'SpaceX Enterprise Software Engineer, Full Stack', 'skills': ['c#', 'sql', 'javascript', 'asp.net', 'angularjs'], 'description': '<h2>About this job</h2>\r\n<p><span>Location options: <strong>Paid relocation</strong></span><br/><span>Job type: <strong>Permanent</strong></span><br/><span>Experience level: <strong>Mid-Level, Senior</strong></span><br/><span>Role: <strong>Full Stack Developer</strong></span><br/><span>Industry: <strong>Aerospace, Information Technology, Web Development</strong></span><br/><span>Company size: <strong>1k-5k people</strong></span><br/><span>Company type: <strong>Private</strong></span><br/></p><br/><br/><h2>Technologies</h2> <p>c#, sql, javascript, asp.net, angularjs</p> <br/><br/><h2>Job description</h2> <p><strong>Full Stack Enterprise&nbsp;Software Engineer</strong></p>\r\n<p>The EIS (Enterprise Information Systems) team writes the software that builds rockets and powers SpaceX. We are responsible for 
  1. 这很棒,因为现在我们可以做一些简单的任务,而不涉及 HTML 解析。例如,我们可以仅使用以下代码检索工作所需的技能:
# print the skills for skill in job_listing_contents["skills"]:
  print(skill)

它产生以下输出:

c#
sql
javascript
asp.net
angularjs

还有更多...

描述仍然存储在此 JSON 对象的描述属性中的 HTML 中。我们将在下一个示例中检查该数据的解析。

阅读和清理工作列表中的描述

工作列表的描述仍然是 HTML。我们将要从这些数据中提取有价值的内容,因此我们需要解析这个 HTML 并执行标记化、停用词去除、常用词去除、进行一些技术 2-gram 处理,以及一般的所有这些不同的过程。让我们来做这些。

准备就绪

我已经将确定基于技术的 2-gram 的代码折叠到07/tech2grams.py文件中。我们将在文件中使用tech_2grams函数。

如何做...

这个示例的代码在07/13_clean_jd.py文件中。它延续了07/12_scrape_job_stackoverflow.py文件的内容:

  1. 我们首先从我们加载的描述的描述键创建一个BeautifulSoup对象。我们也会打印出来看看它是什么样子的:
desc_bs = BeautifulSoup(job_listing_contents["description"], "lxml") print(desc_bs) <p><span>Location options: <strong>Paid relocation</strong></span><br/><span>Job type: <strong>Permanent</strong></span><br/><span>Experience level: <strong>Mid-Level, Senior</strong></span><br/><span>Role: <strong>Full Stack Developer</strong></span><br/><span>Industry: <strong>Aerospace, Information Technology, Web Development</strong></span><br/><span>Company size: <strong>1k-5k people</strong></span><br/><span>Company type: <strong>Private</strong></span><br/></p><br/><br/><h2>Technologies</h2> <p>c#, sql, javascript, asp.net, angularjs</p> <br/><br/><h2>Job description</h2> <p><strong>Full Stack Enterprise Software Engineer</strong></p>
<p>The EIS (Enterprise Information Systems) team writes the software that builds rockets and powers SpaceX. We are responsible for all of the software on the factory floor, the warehouses, the financial systems, the restaurant, and even the public home page. Elon has called us the "nervous system" of SpaceX because we connect all of the other teams at SpaceX to ensure that the entire rocket building process runs smoothly.</p>
<p><strong>Responsibilities:</strong></p>
<ul>
<li>We are seeking developers with demonstrable experience in: ASP.NET, C#, SQL Server, and AngularJS. We are a fast-paced, highly iterative team that has to adapt quickly as our factory grows. We need people who are comfortable tackling new problems, innovating solutions, and interacting with every facet of the company on a daily basis. Creative, motivated, able to take responsibility and support the applications you create. Help us get rockets out the door faster!</li>
</ul>
<p><strong>Basic Qualifications:</strong></p>
<ul>
<li>Bachelor's degree in computer science, engineering, physics, mathematics, or similar technical discipline.</li>
<li>3+ years of experience developing across a full-stack:  Web server, relational database, and client-side (HTML/Javascript/CSS).</li>
</ul>
<p><strong>Preferred Skills and Experience:</strong></p>
<ul>
<li>Database - Understanding of SQL. Ability to write performant SQL. Ability to diagnose queries, and work with DBAs.</li>
<li>Server - Knowledge of how web servers operate on a low-level. Web protocols. Designing APIs. How to scale web sites. Increase performance and diagnose problems.</li>
<li>UI - Demonstrated ability creating rich web interfaces using a modern client side framework. Good judgment in UX/UI design.  Understands the finer points of HTML, CSS, and Javascript - know which tools to use when and why.</li>
<li>System architecture - Knowledge of how to structure a database, web site, and rich client side application from scratch.</li>
<li>Quality - Demonstrated usage of different testing patterns, continuous integration processes, build deployment systems. Continuous monitoring.</li>
<li>Current - Up to date with current trends, patterns, goings on in the world of web development as it changes rapidly. Strong knowledge of computer science fundamentals and applying them in the real-world.</li>
</ul> <br/><br/></body></html>
  1. 我们想要浏览一遍,去掉所有的 HTML,只留下描述的文本。然后我们将对其进行标记。幸运的是,使用BeautifulSoup很容易就能去掉所有的 HTML 标签:
just_text = desc_bs.find_all(text=True) print(just_text)

['About this job', '\n', 'Location options: ', 'Paid relocation', 'Job type: ', 'Permanent', 'Experience level: ', 'Mid-Level, Senior', 'Role: ', 'Full Stack Developer', 'Industry: ', 'Aerospace, Information Technology, Web Development', 'Company size: ', '1k-5k people', 'Company type: ', 'Private', 'Technologies', ' ', 'c#, sql, javascript, asp.net, angularjs', ' ', 'Job description', ' ', 'Full Stack Enterprise\xa0Software Engineer', '\n', 'The EIS (Enterprise Information Systems) team writes the software that builds rockets and powers SpaceX. We are responsible for all of the software on the factory floor, the warehouses, the financial systems, the restaurant, and even the public home page. Elon has called us the "nervous system" of SpaceX because we connect all of the other teams at SpaceX to ensure that the entire rocket building process runs smoothly.', '\n', 'Responsibilities:', '\n', '\n', 'We are seeking developers with demonstrable experience in: ASP.NET, C#, SQL Server, and AngularJS. We are a fast-paced, highly iterative team that has to adapt quickly as our factory grows. We need people who are comfortable tackling new problems, innovating solutions, and interacting with every facet of the company on a daily basis. Creative, motivated, able to take responsibility and support the applications you create. Help us get rockets out the door faster!', '\n', '\n', 'Basic Qualifications:', '\n', '\n', "Bachelor's degree in computer science, engineering, physics, mathematics, or similar technical discipline.", '\n', '3+ years of experience developing across a full-stack:\xa0 Web server, relational database, and client-side (HTML/Javascript/CSS).', '\n', '\n', 'Preferred Skills and Experience:', '\n', '\n', 'Database - Understanding of SQL. Ability to write performant SQL. Ability to diagnose queries, and work with DBAs.', '\n', 'Server - Knowledge of how web servers operate on a low-level. Web protocols. Designing APIs. How to scale web sites. Increase performance and diagnose problems.', '\n', 'UI - Demonstrated ability creating rich web interfaces using a modern client side framework. Good judgment in UX/UI design.\xa0 Understands the finer points of HTML, CSS, and Javascript - know which tools to use when and why.', '\n', 'System architecture - Knowledge of how to structure a database, web site, and rich client side application from scratch.', '\n', 'Quality - Demonstrated usage of different testing patterns, continuous integration processes, build deployment systems. Continuous monitoring.', '\n', 'Current - Up to date with current trends, patterns, goings on in the world of web development as it changes rapidly. Strong knowledge of computer science fundamentals and applying them in the real-world.', '\n', ' ']

太棒了!我们现在已经有了这个,它已经被分解成可以被视为句子的部分!

  1. 让我们把它们全部连接在一起,对它们进行词标记,去掉停用词,并应用常见的技术工作 2-gram:
joined = ' '.join(just_text) tokens = word_tokenize(joined)   stop_list = stopwords.words('english') with_no_stops = [word for word in tokens if word not in stop_list] cleaned = remove_punctuation(two_grammed) print(cleaned)

这样就会得到以下输出:

['job', 'Location', 'options', 'Paid relocation', 'Job', 'type', 'Permanent', 'Experience', 'level', 'Mid-Level', 'Senior', 'Role', 'Full-Stack', 'Developer', 'Industry', 'Aerospace', 'Information Technology', 'Web Development', 'Company', 'size', '1k-5k', 'people', 'Company', 'type', 'Private', 'Technologies', 'c#', 'sql', 'javascript', 'asp.net', 'angularjs', 'Job', 'description', 'Full-Stack', 'Enterprise Software', 'Engineer', 'EIS', 'Enterprise', 'Information', 'Systems', 'team', 'writes', 'software', 'builds', 'rockets', 'powers', 'SpaceX', 'responsible', 'software', 'factory', 'floor', 'warehouses', 'financial', 'systems', 'restaurant', 'even', 'public', 'home', 'page', 'Elon', 'called', 'us', 'nervous', 'system', 'SpaceX', 'connect', 'teams', 'SpaceX', 'ensure', 'entire', 'rocket', 'building', 'process', 'runs', 'smoothly', 'Responsibilities', 'seeking', 'developers', 'demonstrable experience', 'ASP.NET', 'C#', 'SQL Server', 'AngularJS', 'fast-paced', 'highly iterative', 'team', 'adapt quickly', 'factory', 'grows', 'need', 'people', 'comfortable', 'tackling', 'new', 'problems', 'innovating', 'solutions', 'interacting', 'every', 'facet', 'company', 'daily', 'basis', 'Creative', 'motivated', 'able', 'take', 'responsibility', 'support', 'applications', 'create', 'Help', 'us', 'get', 'rockets', 'door', 'faster', 'Basic', 'Qualifications', 'Bachelor', "'s", 'degree', 'computer science', 'engineering', 'physics', 'mathematics', 'similar', 'technical', 'discipline', '3+', 'years', 'experience', 'developing', 'across', 'full-stack', 'Web server', 'relational database', 'client-side', 'HTML/Javascript/CSS', 'Preferred', 'Skills', 'Experience', 'Database', 'Understanding', 'SQL', 'Ability', 'write', 'performant', 'SQL', 'Ability', 'diagnose', 'queries', 'work', 'DBAs', 'Server', 'Knowledge', 'web', 'servers', 'operate', 'low-level', 'Web', 'protocols', 'Designing', 'APIs', 'scale', 'web', 'sites', 'Increase', 'performance', 'diagnose', 'problems', 'UI', 'Demonstrated', 'ability', 'creating', 'rich', 'web', 'interfaces', 'using', 'modern', 'client-side', 'framework', 'Good', 'judgment', 'UX/UI', 'design', 'Understands', 'finer', 'points', 'HTML', 'CSS', 'Javascript', 'know', 'tools', 'use', 'System', 'architecture', 'Knowledge', 'structure', 'database', 'web', 'site', 'rich', 'client-side', 'application', 'scratch', 'Quality', 'Demonstrated', 'usage', 'different', 'testing', 'patterns', 'continuous integration', 'processes', 'build', 'deployment', 'systems', 'Continuous monitoring', 'Current', 'date', 'current trends', 'patterns', 'goings', 'world', 'web development', 'changes', 'rapidly', 'Strong', 'knowledge', 'computer science', 'fundamentals', 'applying', 'real-world']

我认为这是从工作清单中提取出来的一组非常好的和精细的关键词。

第八章:搜索、挖掘和可视化数据

在本章中,我们将涵盖:

  • IP 地址地理编码

  • 收集维基百科编辑的 IP 地址

  • 在维基百科上可视化贡献者位置频率

  • 从 StackOverflow 工作列表创建词云

  • 在维基百科上爬取链接

  • 在维基百科上可视化页面关系

  • 计算维基百科页面之间的分离度

介绍

在本章中,我们将研究如何搜索 Web 内容,推导分析结果,并可视化这些结果。我们将学习如何定位内容的发布者并可视化其位置的分布。然后,我们将研究如何爬取、建模和可视化维基百科页面之间的关系。

IP 地址地理编码

地理编码是将地址转换为地理坐标的过程。这些地址可以是实际的街道地址,可以使用各种工具进行地理编码,例如 Google 地图地理编码 API(developers.google.com/maps/documentation/geocoding/intro)。 IP 地址可以通过各种应用程序进行地理编码,以确定计算机及其用户的位置。一个非常常见和有价值的用途是分析 Web 服务器日志,以确定您网站的用户来源。

这是可能的,因为 IP 地址不仅代表计算机的地址,可以与该计算机进行通信,而且通常还可以通过在 IP 地址/位置数据库中查找来转换为大致的物理位置。有许多这些数据库可用,所有这些数据库都由各种注册机构(如 ICANN)维护。还有其他工具可以报告公共 IP 地址的地理位置。

有许多免费的 IP 地理位置服务。我们将研究一个非常容易使用的服务,即 freegeoip.net。

准备工作

Freegeoip.net 是一个免费的地理编码服务。如果您在浏览器中转到www.freegeoip.net,您将看到一个类似以下的页面:

freegeoip.net 主页

默认页面报告您的公共 IP 地址,并根据其数据库给出 IP 地址的地理位置。这并不准确到我家的实际地址,实际上相差几英里,但在世界上的一般位置是相当准确的。我们可以使用这种分辨率甚至更低的数据做重要的事情。通常,只知道 Web 请求的国家来源对于许多目的已经足够了。

Freegeoip 允许您每小时进行 15000 次调用。每次页面加载都算一次调用,正如我们将看到的,每次 API 调用也算一次。

如何做到这一点

我们可以爬取这个页面来获取这些信息,但幸运的是,freegeoip.net 为我们提供了一个方便的 REST API 来使用。在页面下方滚动,我们可以看到 API 文档:

freegeoio.net API 文档

我们可以简单地使用 requests 库使用正确格式的 URL 进行 GET 请求。例如,只需在浏览器中输入以下 URL,即可返回给定 IP 地址的地理编码数据的 JSON 表示:

IP 地址的示例 JSON

一个 Python 脚本,用于演示这一点,可以在08/01_geocode_address.py中找到。这很简单,包括以下内容:

import json
import requests

raw_json = requests.get("http://www.freegeoip.net/json/63.153.113.92").text
parsed = json.loads(raw_json) print(json.dumps(parsed, indent=4, sort_keys=True)) 

这有以下输出:

{
    "city": "Deer Lodge",
    "country_code": "US",
    "country_name": "United States",
    "ip": "63.153.113.92",
    "latitude": 46.3797,
    "longitude": -112.7202,
    "metro_code": 754,
    "region_code": "MT",
    "region_name": "Montana",
    "time_zone": "America/Denver",
    "zip_code": "59722"
}

请注意,对于这个 IP 地址,您的输出可能会有所不同,并且不同的 IP 地址肯定会有所不同。

如何收集维基百科编辑的 IP 地址

处理地理编码 IP 地址的聚合结果可以提供有价值的见解。这在服务器日志中非常常见,也可以在许多其他情况下使用。许多网站包括内容贡献者的 IP 地址。维基百科提供了他们所有页面的更改历史。由维基百科未注册用户创建的编辑在历史中公布其 IP 地址。我们将研究如何创建一个爬虫,以浏览给定维基百科主题的历史,并收集未注册编辑的 IP 地址。

准备工作

我们将研究对维基百科的 Web 抓取页面所做的编辑。此页面位于:en.wikipedia.org/wiki/Web_scraping。以下是此页面的一小部分:

查看历史选项卡

注意右上角的查看历史。单击该链接可访问编辑历史:

检查 IP 地址

我把这个滚动了一点,以突出一个匿名编辑。请注意,我们可以使用源中的mw-userling mw-anonuserlink类来识别这些匿名编辑条目。

还要注意,您可以指定要列出的每页编辑的数量,可以通过向 URL 添加参数来指定。以下 URL 将给我们最近的 500 次编辑:

en.wikipedia.org/w/index.php?title=Web_scraping&offset=&limit=500&action=history

因此,我们不是爬行多个不同的页面,每次走 50 个,而是只做一个包含 500 个页面。

操作方法

我们按以下步骤进行:

  1. 执行抓取的代码在脚本文件08/02_geocode_wikipedia_edits.py中。运行脚本会产生以下输出(截断到前几个地理 IP):
Reading page: https://en.wikipedia.org/w/index.php?title=Web_scraping&offset=&limit=500&action=history
Got 106 ip addresses
{'ip': '2601:647:4a04:86d0:1cdf:8f8a:5ca5:76a0', 'country_code': 'US', 'country_name': 'United States', 'region_code': 'CA', 'region_name': 'California', 'city': 'Sunnyvale', 'zip_code': '94085', 'time_zone': 'America/Los_Angeles', 'latitude': 37.3887, 'longitude': -122.0188, 'metro_code': 807}
{'ip': '194.171.56.13', 'country_code': 'NL', 'country_name': 'Netherlands', 'region_code': '', 'region_name': '', 'city': '', 'zip_code': '', 'time_zone': 'Europe/Amsterdam', 'latitude': 52.3824, 'longitude': 4.8995, 'metro_code': 0}
{'ip': '109.70.55.226', 'country_code': 'DK', 'country_name': 'Denmark', 'region_code': '85', 'region_name': 'Zealand', 'city': 'Roskilde', 'zip_code': '4000', 'time_zone': 'Europe/Copenhagen', 'latitude': 55.6415, 'longitude': 12.0803, 'metro_code': 0}
{'ip': '101.177.247.131', 'country_code': 'AU', 'country_name': 'Australia', 'region_code': 'TAS', 'region_name': 'Tasmania', 'city': 'Lenah Valley', 'zip_code': '7008', 'time_zone': 'Australia/Hobart', 'latitude': -42.8715, 'longitude': 147.2751, 'metro_code': 0}

脚本还将地理 IP 写入geo_ips.json文件。下一个示例将使用该文件,而不是再次进行所有页面请求。

工作原理

解释如下。脚本首先执行以下代码:

if __name__ == "__main__":
  geo_ips = collect_geo_ips('Web_scraping', 500)
  for geo_ip in geo_ips:
  print(geo_ip)
  with open('geo_ips.json', 'w') as outfile:
  json.dump(geo_ips, outfile)

调用collect_geo_ips,该函数将请求指定主题的页面和最多 500 次编辑。然后将这些地理 IP 打印到控制台,并写入geo_ips.json文件。

collect_geo_ips的代码如下:

def collect_geo_ips(article_title, limit):
  ip_addresses = get_history_ips(article_title, limit)
  print("Got %s ip addresses" % len(ip_addresses))
  geo_ips = get_geo_ips(ip_addresses)
  return geo_ips

此函数首先调用get_history_ips,报告找到的数量,然后对每个 IP 地址重复请求get_geo_ips

get_history_ips的代码如下:

def get_history_ips(article_title, limit):
  history_page_url = "https://en.wikipedia.org/w/index.php?title=%s&offset=&limit=%s&action=history" % (article_title, limit)
  print("Reading page: " + history_page_url)
  html = requests.get(history_page_url).text
    soup = BeautifulSoup(html, "lxml")    anon_ip_anchors = soup.findAll("a", {"class": "mw-anonuserlink"})
  addresses = set()
  for ip in anon_ip_anchors:
  addresses.add(ip.get_text())
  return addresses

这个函数构建了历史页面的 URL,检索页面,然后提取所有具有mw-anonuserlink类的不同 IP 地址。

然后,get_geo_ips获取这组 IP 地址,并对每个 IP 地址调用freegeoip.net以获取数据。

def get_geo_ips(ip_addresses):
  geo_ips = []
  for ip in ip_addresses:
  raw_json = requests.get("http://www.freegeoip.net/json/%s" % ip).text
        parsed = json.loads(raw_json)
  geo_ips.append(parsed)
  return geo_ips

还有更多...

虽然这些数据很有用,但在下一个示例中,我们将读取写入geo_ips.json的数据(使用 pandas),并使用条形图可视化用户按国家的分布。

在维基百科上可视化贡献者位置频率

我们可以使用收集的数据来确定来自世界各地的维基百科文章的编辑频率。这可以通过按国家对捕获的数据进行分组并计算与每个国家相关的编辑数量来完成。然后,我们将对数据进行排序并创建一个条形图来查看结果。

操作方法

这是一个使用 pandas 执行的非常简单的任务。示例的代码在08/03_visualize_wikipedia_edits.py中。

  1. 代码开始导入 pandas 和matplotlib.pyplot
>>> import pandas as pd
>>> import matplotlib.pyplot as plt
  1. 我们在上一个示例中创建的数据文件已经以可以直接被 pandas 读取的格式。这是使用 JSON 作为数据格式的好处之一;pandas 内置支持从 JSON 读取和写入数据。以下使用pd.read_json()函数读取数据并在控制台上显示前五行:
>>> df = pd.read_json("geo_ips.json") >>> df[:5]) city country_code country_name ip latitude \
0 Hanoi VN Vietnam 118.70.248.17 21.0333 
1 Roskilde DK Denmark 109.70.55.226 55.6415 
2 Hyderabad IN India 203.217.144.211 17.3753 
3 Prague CZ Czechia 84.42.187.252 50.0833 
4 US United States 99.124.83.153 37.7510

longitude metro_code region_code region_name time_zone \
0 105.8500 0 HN Thanh Pho Ha Noi Asia/Ho_Chi_Minh 
1 12.0803 0 85 Zealand Europe/Copenhagen 
2 78.4744 0 TG Telangana Asia/Kolkata 
3 14.4667 0 10 Hlavni mesto Praha Europe/Prague 
4 -97.8220 0
zip_code 
0 
1 4000 
2 
3 130 00 
4
  1. 对于我们的直接目的,我们只需要country_code列,我们可以用以下方法提取它(并显示该结果中的前五行):
>>> countries_only = df.country_code
>>> countries_only[:5]

0 VN
1 DK
2 IN
3 CZ
4 US
Name: country_code, dtype:object
  1. 现在我们可以使用.groupby('country_code')来对这个系列中的行进行分组,然后在结果上,调用.count()将返回每个组中的项目数。该代码还通过调用.sort_values()将结果从最大到最小值进行排序:
>>> counts = df.groupby('country_code').country_code.count().sort_values(ascending=False) >>> counts[:5]

country_code
US 28
IN 12
BR 7
NL 7
RO 6
Name: country_code, dtype: int64 

仅从这些结果中,我们可以看出美国在编辑方面绝对领先,印度是第二受欢迎的。

这些数据可以很容易地可视化为条形图:

counts.plot(kind='bar') plt.show()

这导致以下条形图显示所有国家的总体分布:

编辑频率的直方图

从 StackOverflow 职位列表创建词云

现在让我们来看看如何创建一个词云。词云是一种展示一组文本中关键词频率的图像。图像中的单词越大,它在文本中的重要性就越明显。

准备工作

我们将使用 Word Cloud 库来创建我们的词云。该库的源代码可在github.com/amueller/word_cloud上找到。这个库可以通过pip install wordcloud安装到你的 Python 环境中。

如何做到这一点

创建词云的脚本在08/04_so_word_cloud.py文件中。这个示例是从第七章的堆栈溢出示例中继续提供数据的可视化。

  1. 首先从 NLTK 中导入词云和频率分布函数:
from wordcloud import WordCloud
from nltk.probability import FreqDist
  1. 然后,词云是从我们从职位列表中收集的单词的概率分布生成的:
freq_dist = FreqDist(cleaned) wordcloud = WordCloud(width=1200, height=800).generate_from_frequencies(freq_dist) 

现在我们只需要显示词云:

import matplotlib.pyplot as plt
plt.imshow(wordcloud, interpolation='bilinear') plt.axis("off") plt.show()

生成的词云如下:

职位列表的词云

位置和大小都有一些内置的随机性,所以你得到的结果可能会有所不同。

在维基百科上爬取链接

在这个示例中,我们将编写一个小程序来利用爬取维基百科页面上的链接,通过几个深度级别。在这个爬取过程中,我们将收集页面之间以及每个页面引用的页面之间的关系。在此过程中,我们将建立这些页面之间的关系,最终在下一个示例中进行可视化。

准备工作

这个示例的代码在08/05_wikipedia_scrapy.py中。它引用了代码示例中modules/wikipedia文件夹中的一个模块的代码,所以确保它在你的 Python 路径中。

如何做到这一点

你可以使用示例 Python 脚本。它将使用 Scrapy 爬取单个维基百科页面。它将爬取的页面是 Python 页面,网址为en.wikipedia.org/wiki/Python_(programming_language),并收集该页面上的相关链接。

运行时,你将看到类似以下的输出:

/Users/michaelheydt/anaconda/bin/python3.6 /Users/michaelheydt/Dropbox/Packt/Books/PyWebScrCookbook/code/py/08/05_wikipedia_scrapy.py
parsing: https://en.wikipedia.org/wiki/Python_(programming_language)
parsing: https://en.wikipedia.org/wiki/C_(programming_language)
parsing: https://en.wikipedia.org/wiki/Object-oriented_programming
parsing: https://en.wikipedia.org/wiki/Ruby_(programming_language)
parsing: https://en.wikipedia.org/wiki/Go_(programming_language)
parsing: https://en.wikipedia.org/wiki/Java_(programming_language)
------------------------------------------------------------
0 Python_(programming_language) C_(programming_language)
0 Python_(programming_language) Java_(programming_language)
0 Python_(programming_language) Go_(programming_language)
0 Python_(programming_language) Ruby_(programming_language)
0 Python_(programming_language) Object-oriented_programming

输出的第一部分来自 Scrapy 爬虫,并显示传递给解析方法的页面。这些页面以我们的初始页面开头,并通过该页面的前五个最常见的链接。

此输出的第二部分是对被爬取的页面以及在该页面上找到的链接的表示,这些链接被认为是未来处理的。第一个数字是找到关系的爬取级别,然后是父页面和在该页面上找到的链接。对于每个找到的页面/链接,都有一个单独的条目。由于这是一个深度爬取,我们只显示从初始页面找到的页面。

它是如何工作的

让我们从主脚本文件08/05_wikipedia_scrapy.py中的代码开始。这是通过创建一个WikipediaSpider对象并运行爬取开始的:

process = CrawlerProcess({
    'LOG_LEVEL': 'ERROR',
    'DEPTH_LIMIT': 1 })

process.crawl(WikipediaSpider)
spider = next(iter(process.crawlers)).spider
process.start()

这告诉 Scrapy 我们希望运行一层深度,我们得到一个爬虫的实例,因为我们想要检查其属性,这些属性是爬取的结果。然后用以下方法打印结果:

print("-"*60)

for pm in spider.linked_pages:
    print(pm.depth, pm.title, pm.child_title)

爬虫的每个结果都存储在linked_pages属性中。每个对象都由几个属性表示,包括页面的标题(维基百科 URL 的最后部分)和在该页面的 HTML 内容中找到的每个页面的标题。

现在让我们来看一下爬虫的功能。爬虫的代码在modules/wikipedia/spiders.py中。爬虫首先定义了一个 Scrapy Spider的子类:

class WikipediaSpider(Spider):
    name = "wikipedia"
  start_urls = [ "https://en.wikipedia.org/wiki/Python_(programming_language)" ]

我们从维基百科的 Python 页面开始。接下来是定义一些类级变量,以定义爬取的操作方式和要检索的结果:

page_map = {}
linked_pages = []
max_items_per_page = 5 max_crawl_depth = 1

这次爬取的每个页面都将由爬虫的解析方法处理。让我们来看一下。它从以下开始:

def parse(self, response):
    print("parsing: " + response.url)

    links = response.xpath("//*/a[starts-with(@href, '/wiki/')]/@href")

    link_counter = {}

在每个维基百科页面中,我们寻找以/wiki开头的链接。页面中还有其他链接,但这些是这次爬取将考虑的重要链接。

这个爬虫实现了一个算法,其中页面上找到的所有链接都被计算为相似。有相当多的重复链接。其中一些是虚假的。其他代表了多次链接到其他页面的真正重要性。

max_items_per_page定义了我们将进一步调查当前页面上有多少链接。每个页面上都会有相当多的链接,这个算法会计算所有相似的链接并将它们放入桶中。然后它会跟踪max_items_per_page最受欢迎的链接。

这个过程是通过使用links_counter变量来管理的。这是当前页面和页面上找到的所有链接之间的映射字典。对于我们决定跟踪的每个链接,我们计算它在页面上被引用的次数。这个变量是该 URL 和计数引用次数的对象之间的映射:

class LinkReferenceCount:
    def __init__(self, link):
        self.link = link
  self.count = 0

然后,代码遍历所有识别的链接:

for l in links:
    link = l.root
    if ":" not in link and "International" not in link and link != self.start_urls[0]:
        if link not in link_counter:
            link_counter[link] = LinkReferenceCount(link)
        link_counter[link].count += 1

这个算法检查每个链接,并根据规则(链接中没有“:”,也没有“国际”因为它非常受欢迎所以我们排除它,最后我们不包括起始 URL)只考虑它们进行进一步的爬取。如果链接通过了这一步,那么就会创建一个新的LinkReferenceCounter对象(如果之前没有看到这个链接),或者增加它的引用计数。

由于每个页面上可能有重复的链接,我们只想考虑max_items_per_page最常见的链接。代码通过以下方式实现了这一点:

references = list(link_counter.values())
s = sorted(references, key=lambda x: x.count, reverse=True)
top = s[:self.max_items_per_page]

link_counter字典中,我们提取所有的LinkReferenceCounter对象,并按计数排序,然后选择前max_items_per_page个项目。

下一步是对这些符合条件的项目进行记录,记录在类的linked_pages字段中。这个列表中的每个对象都是PageToPageMap类型。这个类有以下定义:

class PageToPageMap:
    def __init__(self, link, child_link, depth): #, parent):
  self.link = link
  self.child_link = child_link
  self.title = self.get_page_title(self.link)
        self.child_title = self.get_page_title(self.child_link)
        self.depth = depth    def get_page_title(self, link):
        parts = link.split("/")
        last = parts[len(parts)-1]
        label = urllib.parse.unquote(last)
        return label

从根本上说,这个对象表示一个源页面 URL 到一个链接页面 URL,并跟踪爬取的当前级别。标题属性是维基百科 URL 最后部分的 URL 解码形式,代表了 URL 的更加人性化的版本。

最后,代码将新的页面交给 Scrapy 进行爬取。

for item in top:
    new_request = Request("https://en.wikipedia.org" + item.link,
                          callback=self.parse, meta={ "parent": pm })
    yield new_request

还有更多...

这个爬虫/算法还跟踪爬取中当前的深度级别。如果认为新链接超出了爬取的最大深度。虽然 Scrapy 可以在一定程度上控制这一点,但这段代码仍然需要排除超出最大深度的链接。

这是通过使用PageToPageMap对象的深度字段来控制的。对于每个爬取的页面,我们检查响应是否具有元数据,这是表示给定页面的“父”PageToPageMap对象的属性。我们可以通过以下代码找到这个:

depth = 0 if "parent" in response.meta:
    parent = response.meta["parent"]
    depth = parent.depth + 1

页面解析器中的此代码查看是否有父对象。只有爬取的第一个页面没有父页面。如果有一个实例,这个爬取的深度被认为是更高的。当创建新的PageToPageMap对象时,这个值被传递并存储。

代码通过使用请求对象的 meta 属性将此对象传递到爬取的下一级别:

meta={ "parent": pm }

通过这种方式,我们可以将数据从 Scrapy 蜘蛛的一个爬取级别传递到下一个级别。

在维基百科上可视化页面关系

在这个示例中,我们使用之前收集的数据,并使用 NetworkX Python 库创建一个力导向网络可视化页面关系。

准备工作

NetworkX 是用于建模、可视化和分析复杂网络关系的软件。您可以在networkx.github.io找到更多关于它的信息。它可以通过pip install networkx在您的 Python 环境中安装。

如何做到这一点

此示例的脚本位于08/06_visualizze_wikipedia_links.py文件中。运行时,它会生成维基百科上初始 Python 页面上找到的链接的图表:

链接的图表

现在我们可以看到页面之间的关系了!

工作原理

爬取从定义一级深度爬取开始:

crawl_depth = 1 process = CrawlerProcess({
    'LOG_LEVEL': 'ERROR',
    'DEPTH_LIMIT': crawl_depth
})
process.crawl(WikipediaSpider)
spider = next(iter(process.crawlers)).spider
spider.max_items_per_page = 5 spider.max_crawl_depth = crawl_depth
process.start()

for pm in spider.linked_pages:
    print(pm.depth, pm.link, pm.child_link)
print("-"*80)

这些信息与之前的示例类似,现在我们需要将其转换为 NetworkX 可以用于图的模型。这始于创建一个 NetworkX 图模型:

g = nx.Graph()

NetworkX 图由节点和边组成。从收集的数据中,我们必须创建一组唯一的节点(页面)和边(页面引用另一个页面的事实)。可以通过以下方式执行:

nodes = {}
edges = {}

for pm in spider.linked_pages:
    if pm.title not in nodes:
        nodes[pm.title] = pm
        g.add_node(pm.title)

    if pm.child_title not in nodes:
        g.add_node(pm.child_title)

    link_key = pm.title + " ==> " + pm.child_title
    if link_key not in edges:
        edges[link_key] = link_key
        g.add_edge(pm.title, pm.child_title)

这通过遍历我们爬取的所有结果,并识别所有唯一节点(不同的页面),以及页面之间的所有链接。对于每个节点和边,我们使用 NetworkX 进行注册。

接下来,我们使用 Matplotlib 创建绘图,并告诉 NetworkX 如何在绘图中创建可视化效果:

plt.figure(figsize=(10,8))

node_positions = nx.spring_layout(g)

nx.draw_networkx_nodes(g, node_positions, g.nodes, node_color='green', node_size=50)
nx.draw_networkx_edges(g, node_positions)

labels = { node: node for node in g.nodes() }
nx.draw_networkx_labels(g, node_positions, labels, font_size=9.5)

plt.show()

其中重要的部分首先是使用 NetworkX 在节点上形成弹簧布局。这计算出节点的实际位置,但不渲染节点或边。这是接下来的两行的目的,它们给出了 NetworkX 如何渲染节点和边的指令。最后,我们需要在节点上放置标签。

还有更多...

这次爬取只进行了一级深度的爬取。可以通过对代码进行以下更改来增加爬取的深度:

crawl_depth = 2 process = CrawlerProcess({
    'LOG_LEVEL': 'ERROR',
    'DEPTH_LIMIT': crawl_depth
})
process.crawl(WikipediaSpider)
spider = next(iter(process.crawlers)).spider
spider.max_items_per_page = 5 spider.max_crawl_depth = crawl_depth
process.start()

基本上唯一的变化是增加一级深度。然后得到以下图表(任何弹簧图都会有随机性,因此实际结果会有不同的布局):

链接的蜘蛛图

这开始变得有趣,因为我们现在开始看到页面之间的相互关系和循环关系。

我敢你进一步增加深度和每页的链接数。

计算分离度

现在让我们计算任意两个页面之间的分离度。这回答了从源页面到另一个页面需要浏览多少页面的问题。这可能是一个非平凡的图遍历问题,因为两个页面之间可能有多条路径。幸运的是,对于我们来说,NetworkX 使用完全相同的图模型,具有内置函数来解决这个问题。

如何做到这一点

这个示例的脚本在08/07_degrees_of_separation.py中。代码与之前的示例相同,进行了 2 层深度的爬取,只是省略了图表,并要求 NetworkX 解决Python_(programming_language)Dennis_Ritchie之间的分离度:

Degrees of separation: 1
 Python_(programming_language)
   C_(programming_language)
    Dennis_Ritchie

这告诉我们,要从Python_(programming_language)Dennis_Ritchie,我们必须通过另一个页面:C_(programming_language)。因此,一度分离。如果我们直接到C_(programming_language),那么就是 0 度分离。

它是如何工作的

这个问题的解决方案是由一种称为A的算法解决的。A**算法确定图中两个节点之间的最短路径。请注意,这条路径可以是不同长度的多条路径,正确的结果是最短路径。对我们来说好消息是,NetworkX 有一个内置函数来为我们做这个。它可以用一条简单的语句完成:

path = nx.astar_path(g, "Python_(programming_language)", "Dennis_Ritchie")

从这里我们报告实际路径:

degrees_of_separation = int((len(path) - 1) / 2)
print("Degrees of separation: {}".format(degrees_of_separation))
for i in range(0, len(path)):
    print(" " * i, path[i])

还有更多...

有关A*算法的更多信息,请查看此页面

第九章:创建一个简单的数据 API

在本章中,我们将涵盖:

  • 使用 Flask-RESTful 创建 REST API

  • 将 REST API 与抓取代码集成

  • 添加一个用于查找工作列表技能的 API

  • 将数据存储在 Elasticsearch 中作为抓取请求的结果

  • 在抓取之前检查 Elasticsearch 中的列表

介绍

我们现在已经达到了学习抓取的一个激动人心的转折点。从现在开始,我们将学习使用几个 API、微服务和容器工具将抓取器作为服务运行,所有这些都将允许在本地或云中运行抓取器,并通过标准化的 REST API 访问抓取器。

我们将在本章中开始这个新的旅程,使用 Flask-RESTful 创建一个简单的 REST API,最终我们将使用它来对服务进行页面抓取请求。我们将把这个 API 连接到一个 Python 模块中实现的抓取器功能,该模块重用了在第七章中讨论的从 StackOverflow 工作中抓取的概念,文本整理和分析

最后几个食谱将重点介绍将 Elasticsearch 用作这些结果的缓存,存储我们从抓取器中检索的文档,然后首先在缓存中查找它们。我们将在第十一章中进一步研究 ElasticCache 的更复杂用法,比如使用给定技能集进行工作搜索,使抓取器成为真正的服务

使用 Flask-RESTful 创建 REST API

我们从使用 Flask-RESTful 创建一个简单的 REST API 开始。这个初始 API 将由一个单一的方法组成,让调用者传递一个整数值,并返回一个 JSON 块。在这个食谱中,参数及其值以及返回值在这个时候并不重要,因为我们首先要简单地使用 Flask-RESTful 来运行一个 API。

准备工作

Flask 是一个 Web 微框架,可以让创建简单的 Web 应用功能变得非常容易。Flask-RESTful 是 Flask 的一个扩展,可以让创建 REST API 同样简单。您可以在flask.pocoo.org上获取 Flask 并了解更多信息。Flask-RESTful 可以在https://flask-restful.readthedocs.io/en/latest/上了解。可以使用pip install flask将 Flask 安装到您的 Python 环境中。Flask-RESTful 也可以使用pip install flask-restful进行安装。

本书中其余的食谱将在章节目录的子文件夹中。这是因为这些食谱中的大多数要么需要多个文件来操作,要么使用相同的文件名(即:apy.py)。

如何做

初始 API 实现在09/01/api.py中。API 本身和 API 的逻辑都在这个单一文件api.py中实现。API 可以以两种方式运行,第一种方式是简单地将文件作为 Python 脚本执行。

然后可以使用以下命令启动 API:

python api.py

运行时,您将首先看到类似以下的输出:

Starting the job listing API
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 * Restarting with stat
Starting the job listing API
 * Debugger is active!
 * Debugger pin code: 362-310-034

该程序在127.0.0.1:5000上公开了一个 REST API,我们可以使用GET请求到路径/joblisting/<joblistingid>来请求工作列表。我们可以使用 curl 尝试一下:

curl localhost:5000/joblisting/1

此命令的结果将如下:

{
 "YouRequestedJobWithId": "1"
}

就像这样,我们有一个正在运行的 REST API。现在让我们看看它是如何实现的。

它是如何工作的

实际上并没有太多的代码,这就是 Flask-RESTful 的美妙之处。代码以导入flaskflask_restful开始。

from flask import Flask
from flask_restful import Resource, Api

接下来是用于设置 Flask-RESTful 的初始配置的代码:

app = Flask(__name__)
api = Api(app)

接下来是一个代表我们 API 实现的类的定义:

class JobListing(Resource):
    def get(self, job_listing_id):
        print("Request for job listing with id: " + job_listing_id)
        return {'YouRequestedJobWithId': job_listing_id}

Flask-RESTful 将映射 HTTP 请求到这个类的方法。具体来说,按照惯例,GET请求将映射到名为get的成员函数。将 URL 的值映射到函数的jobListingId参数。然后,该函数返回一个 Python 字典,Flask-RESTful 将其转换为 JSON。

下一行代码告诉 Flask-RESTful 如何将 URL 的部分映射到我们的类:

api.add_resource(JobListing, '/', '/joblisting/<string:job_listing_id>')

这定义了以/joblisting开头的路径的 URL 将映射到我们的JobListing类,并且 URL 的下一部分表示要传递给get方法的jobListingId参数的字符串。由于在此映射中未定义其他动词,因此假定使用 GET HTTP 动词。

最后,我们有一段代码,指定了当文件作为脚本运行时,我们只需执行app.run()(在这种情况下传递一个参数以便获得调试输出)。

if __name__ == '__main__':
    print("Starting the job listing API")
    app.run(debug=True)

然后,Flask-RESTful 找到我们的类并设置映射,开始在127.0.0.1:5000(默认值)上监听,并将请求转发到我们的类和方法。

还有更多...

Flask-RESTful 的默认运行端口是5000。可以使用app.run()的替代形式来更改。对于我们的食谱,将其保留在 5000 上就可以了。最终,您会在类似容器的东西中运行此服务,并在前面使用诸如 NGINX 之类的反向代理,并执行公共端口映射到内部服务端口。

将 REST API 与抓取代码集成

在这个食谱中,我们将把我们为从 StackOverflow 获取干净的工作列表编写的代码与我们的 API 集成。这将导致一个可重用的 API,可以用来执行按需抓取,而客户端无需了解抓取过程。基本上,我们将创建一个作为服务的抓取器,这是我们在本书的其余食谱中将花费大量时间的概念。

准备工作

这个过程的第一部分是将我们在第七章中编写的现有代码创建为一个模块,以便我们可以重用它。我们将在本书的其余部分中的几个食谱中重用这段代码。在将其与 API 集成之前,让我们简要地检查一下这个模块的结构和内容。

该模块的代码位于项目的模块文件夹中的sojobs(用于 StackOverflow 职位)模块中。

sojobs 文件夹

在大多数情况下,这些文件是从第七章中使用的文件复制而来,即文本整理和分析。可重用的主要文件是scraping.py,其中包含几个函数,用于方便抓取。在这个食谱中,我们将使用的函数是get_job_listing_info

def get_job_listing(job_listing_id):
    print("Got a request for a job listing with id: " + job_listing_id)

    req = requests.get("https://stackoverflow.com/jobs/" + job_listing_id)
    content = req.text

    bs = BeautifulSoup(content, "lxml")
    script_tag = bs.find("script", {"type": "application/ld+json"})

    job_listing_contents = json.loads(script_tag.contents[0])
    desc_bs = BeautifulSoup(job_listing_contents["description"], "lxml")
    just_text = desc_bs.find_all(text=True)

    joined = ' '.join(just_text)
    tokens = word_tokenize(joined)

    stop_list = stopwords.words('english')
    with_no_stops = [word for word in tokens if word.lower() not in stop_list]
    two_grammed = tech_2grams(with_no_stops)
    cleaned = remove_punctuation(two_grammed)

    result = {
        "ID": job_listing_id,
        "JSON": job_listing_contents,
        "TextOnly": just_text,
        "CleanedWords": cleaned
    }

    return json.dumps(result)

回到第七章中的代码,您可以看到这段代码是我们在那些食谱中创建的重用代码。不同之处在于,这个函数不是读取单个本地的.html文件,而是传递了一个工作列表的标识符,然后构造了该工作列表的 URL,使用 requests 读取内容,执行了几项分析,然后返回结果。

请注意,该函数返回一个 Python 字典,其中包含请求的工作 ID、原始 HTML、列表的文本和清理后的单词列表。该 API 将这些结果聚合返回给调用者,其中包括ID,因此很容易知道请求的工作,以及我们执行各种清理的所有其他结果。因此,我们已经创建了一个增值服务,用于工作列表,而不仅仅是获取原始 HTML。

确保你的 PYTHONPATH 环境变量指向模块目录,或者你已经设置好你的 Python IDE 以在这个目录中找到模块。否则,你将会得到找不到这个模块的错误。

如何做

我们按以下步骤进行食谱:

  1. 这个食谱的 API 代码在09/02/api.py中。这扩展了上一个食谱中的代码,以调用sojobs模块中的这个函数。服务的代码如下:
from flask import Flask
from flask_restful import Resource, Api
from sojobs.scraping import get_job_listing_info

app = Flask(__name__)
api = Api(app)

class JobListing(Resource):
    def get(self, job_listing_id):
        print("Request for job listing with id: " + job_listing_id)
        listing = get_job_listing_info(job_listing_id)
        print("Got the following listing as a response: " + listing)
        return listing

api.add_resource(JobListing, '/', '/joblisting/<string:job_listing_id>')

if __name__ == '__main__':
    print("Starting the job listing API")
    app.run(debug=True)

请注意,主要的区别是从模块导入函数,并调用函数并从结果返回数据。

  1. 通过执行带有 Python api.py的脚本来运行服务。然后我们可以使用curl测试 API。以下请求我们之前检查过的 SpaceX 工作列表。
curl localhost:5000/joblisting/122517
  1. 这导致了相当多的输出。以下是部分响应的开头:
"{\"ID\": \"122517\", \"JSON\": {\"@context\": \"http://schema.org\", \"@type\": \"JobPosting\", \"title\": \"SpaceX Enterprise Software Engineer, Full Stack\", \"skills\": [\"c#\", \"sql\", \"javascript\", \"asp.net\", \"angularjs\"], \"description\": \"<h2>About this job</h2>\\r\\n<p><span>Location options: <strong>Paid relocation</strong></span><br/><span>Job type: <strong>Permanent</strong></span><br/><span>Experience level: <strong>Mid-Level, Senior</strong></span><br/><span>Role: <strong>Full Stack Developer</strong></span><br/><span>Industry: <strong>Aerospace, Information Technology, Web Development</strong></span><br/><span>Company size: <strong>1k-5k people</strong></span><br/><span>Company type: <strong>Private</strong></span><br/></p><br/><br/><h2>Technologies</h2> <p>c#, sql, javascr

添加一个 API 来查找工作列表的技能

在这个食谱中,我们向我们的 API 添加了一个额外的操作,允许我们请求与工作列表相关的技能。这演示了一种能够检索数据的子集而不是整个列表内容的方法。虽然我们只对技能做了这个操作,但这个概念可以很容易地扩展到任何其他数据的子集,比如工作的位置、标题,或者几乎任何对 API 用户有意义的其他内容。

准备工作

我们要做的第一件事是向sojobs模块添加一个爬取函数。这个函数将被命名为get_job_listing_skills。以下是这个函数的代码:

def get_job_listing_skills(job_listing_id):
    print("Got a request for a job listing skills with id: " + job_listing_id)

    req = requests.get("https://stackoverflow.com/jobs/" + job_listing_id)
    content = req.text

    bs = BeautifulSoup(content, "lxml")
    script_tag = bs.find("script", {"type": "application/ld+json"})

    job_listing_contents = json.loads(script_tag.contents[0])
    skills = job_listing_contents['skills']

    return json.dumps(skills)

这个函数检索工作列表,提取 StackOverflow 提供的 JSON,然后只返回 JSON 的skills属性。

现在,让我们看看如何添加一个方法来调用 REST API。

如何做

我们按以下步骤进行食谱:

  1. 这个食谱的 API 代码在09/03/api.py中。这个脚本添加了一个额外的类JobListingSkills,具体实现如下:
class JobListingSkills(Resource):
    def get(self, job_listing_id):
        print("Request for job listing's skills with id: " + job_listing_id)
        skills = get_job_listing_skills(job_listing_id)
        print("Got the following skills as a response: " + skills)
        return skills

这个实现与上一个食谱类似,只是调用了获取技能的新函数。

  1. 我们仍然需要添加一个语句来告诉 Flask-RESTful 如何将 URL 映射到这个类的get方法。因为我们实际上是在检索整个工作列表的子属性,我们将扩展我们的 URL 方案,包括一个额外的段代表整体工作列表资源的子属性。
api.add_resource(JobListingSkills, '/', '/joblisting/<string:job_listing_id>/skills')
  1. 现在我们可以使用以下 curl 仅检索技能:
curl localhost:5000/joblisting/122517/skills

这给我们带来了以下结果:

"[\"c#\", \"sql\", \"javascript\", \"asp.net\", \"angularjs\"]"

将数据存储在 Elasticsearch 中作为爬取请求的结果

在这个食谱中,我们扩展了我们的 API,将我们从爬虫那里收到的数据保存到 Elasticsearch 中。我们稍后会使用这个(在下一个食谱中)来通过使用 Elasticsearch 中的内容来优化请求,以便我们不会重复爬取已经爬取过的工作列表。因此,我们可以与 StackOverflow 的服务器友好相处。

准备工作

确保你的 Elasticsearch 在本地运行,因为代码将访问localhost:9200上的 Elasticsearch。有一个很好的快速入门可用于 www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html,或者你可以在 第十章 中查看 Docker Elasticsearch 食谱,使用 Docker 创建爬虫微服务,如果你想在 Docker 中运行它。

安装后,你可以使用以下curl检查正确的安装:

curl 127.0.0.1:9200?pretty

如果安装正确,你将得到类似以下的输出:

{
 "name": "KHhxNlz",
 "cluster_name": "elasticsearch",
 "cluster_uuid": "fA1qyp78TB623C8IKXgT4g",
 "version": {
 "number": "6.1.1",
 "build_hash": "bd92e7f",
 "build_date": "2017-12-17T20:23:25.338Z",
 "build_snapshot": false,
 "lucene_version": "7.1.0",
 "minimum_wire_compatibility_version": "5.6.0",
 "minimum_index_compatibility_version": "5.0.0"
 },
 "tagline": "You Know, for Search"
}

您还需要安装 elasticsearch-py。它可以在www.elastic.co/guide/en/elasticsearch/client/python-api/current/index.html找到,但可以使用pip install elasticsearch快速安装。

如何做到的

我们将对我们的 API 代码进行一些小的更改。之前的代码已经复制到09/04/api.py中,并进行了一些修改。

  1. 首先,我们为 elasticsearch-py 添加了一个导入:
from elasticsearch import Elasticsearch
  1. 现在我们对JobListing类的get方法进行了快速修改(我在 JobListingSkills 中也做了同样的修改,但出于简洁起见,这里省略了):
class JobListing(Resource):
    def get(self, job_listing_id):
        print("Request for job listing with id: " + job_listing_id)
        listing = get_job_listing_info(job_listing_id)

        es = Elasticsearch()
        es.index(index='joblistings', doc_type='job-listing', id=job_listing_id, body=listing)

        print("Got the following listing as a response: " + listing)
        return listing
  1. 这两行新代码创建了一个Elasticsearch对象,然后将结果文档插入到 ElasticSearch 中。在第一次调用 API 之前,我们可以通过以下 curl 看到没有内容,也没有'joblistings'索引:
curl localhost:9200/joblistings
  1. 考虑到我们刚刚安装了 Elasticsearch,这将导致以下错误。
{"error":{"root_cause":[{"type":"index_not_found_exception","reason":"no such index","resource.type":"index_or_alias","resource.id":"joblistings","index_uuid":"_na_","index":"joblistings"}],"type":"index_not_found_exception","reason":"no such index","resource.type":"index_or_alias","resource.id":"joblistings","index_uuid":"_na_","index":"joblistings"},"status":404}
  1. 现在通过python api.py启动 API。然后发出curl以获取作业列表(curl localhost:5000/joblisting/122517)。这将导致类似于之前的配方的输出。现在的区别是这个文档将存储在 Elasticsearch 中。

  2. 现在重新发出先前的 curl 以获取索引:

curl localhost:9200/joblistings
  1. 现在你会得到以下结果(只显示前几行):
{
 "joblistings": {
  "aliases": {},
  "mappings": {
   "job-listing": {
     "properties": {
       "CleanedWords" {
         "type": "text",
         "fields": {
           "keyword": {
           "type": "keyword",
           "ignore_above": 256
          }
        }
       },
     "ID": {
       "type": "text",
       "fields": {
         "keyword": {
         "type": "keyword",
         "ignore_above": 256
        }
      }
    },

已经创建了一个名为joblistings的索引,这个结果展示了 Elasticsearch 通过检查文档识别出的索引结构。

虽然 Elasticsearch 是无模式的,但它会检查提交的文档并根据所找到的内容构建索引。

  1. 我们刚刚存储的特定文档可以通过以下 curl 检索:
curl localhost:9200/joblistings/job-listing/122517
  1. 这将给我们以下结果(同样,只显示内容的开头):
{
 "_index": "joblistings",
 "_type": "job-listing",
 "_id": "122517",
 "_version": 1,
 "found": true,
 "_source": {
  "ID": "122517",
  "JSON": {
   "@context": "http://schema.org",
   "@type": "JobPosting",
   "title": "SpaceX Enterprise Software Engineer, Full Stack",
   "skills": [
    "c#",
    "sql",
    "javascript",
    "asp.net",
    "angularjs"
  ],
  "description": "<h2>About this job</h2>\r\n<p><span>Location options: <strong>Paid relocation</strong></span><br/><span>Job type: <strong>Permanent</strong></span><br/><span>Experience level: <strong>Mid-Level,

就像这样,只用两行代码,我们就将文档存储在了 Elasticsearch 数据库中。现在让我们简要地看一下这是如何工作的。

它是如何工作的

使用以下行执行了文档的存储:

es.index(index='joblistings', doc_type='job-listing', id=job_listing_id, body=listing)

让我们检查每个参数相对于存储这个文档的作用。

index参数指定我们要将文档存储在其中的 Elasticsearch 索引。它的名称是joblistings。这也成为用于检索文档的 URL 的第一部分。

每个 Elasticsearch 索引也可以有多个文档“类型”,这些类型是逻辑上的文档集合,可以表示索引内不同类型的文档。我们使用了'job-listing',这个值也构成了用于检索特定文档的 URL 的第二部分。

Elasticsearch 不要求为每个文档指定标识符,但如果我们提供一个,我们可以查找特定的文档而不必进行搜索。我们将使用文档 ID 作为作业列表 ID。

最后一个参数body指定文档的实际内容。这段代码只是传递了从爬虫接收到的结果。

还有更多...

让我们简要地看一下 Elasticsearch 通过查看文档检索的结果为我们做了什么。

首先,我们可以在结果的前几行看到索引、文档类型和 ID:

{
 "_index": "joblistings",
 "_type": "job-listing",
 "_id": "122517",

当使用这三个值进行查询时,文档的检索非常高效。

每个文档也存储了一个版本,这种情况下是 1。

    "_version": 1,

如果我们使用相同的代码进行相同的查询,那么这个文档将再次存储,具有相同的索引、文档类型和 ID,因此版本将增加。相信我,再次对 API 进行 curl,你会看到这个版本增加到 2。

现在检查"JSON"属性的前几个属性的内容。我们将 API 返回的结果的此属性分配为嵌入在 HTML 中的 StackOverflow 作业描述的 JSON。

 "JSON": {
  "@context": "http://schema.org",
  "@type": "JobPosting",
  "title": "SpaceX Enterprise Software Engineer, Full Stack",
  "skills": [
   "c#",
   "sql",
   "javascript",
   "asp.net",
   "angularjs"
  ],

这就是像 StackOverflow 这样的网站给我们提供结构化数据的美妙之处,使用 Elasticsearch 等工具,我们可以得到结构良好的数据。我们可以并且将利用这一点,只需很少量的代码就可以产生很大的效果。我们可以轻松地使用 Elasticsearch 执行查询,以识别基于特定技能(我们将在即将到来的示例中执行此操作)、行业、工作福利和其他属性的工作列表。

我们的 API 的结果还返回了一个名为CleanedWords的属性,这是我们的几个 NLP 过程提取高价值词语和术语的结果。以下是最终存储在 Elasticsearch 中的值的摘录:

 "CleanedWords": [
  "job",
  "Location",
  "options",
  "Paid relocation",
  "Job",
  "type",
  "Permanent",
  "Experience",
  "level",

而且,我们将能够使用这些来执行丰富的查询,帮助我们根据这些特定词语找到特定的匹配项。

在爬取之前检查 Elasticsearch 中是否存在列表

现在让我们通过检查是否已经存储了工作列表来利用 Elasticsearch 作为缓存,因此不需要再次访问 StackOverflow。我们扩展 API 以执行对工作列表的爬取,首先搜索 Elasticsearch,如果结果在那里找到,我们返回该数据。因此,我们通过将 Elasticsearch 作为工作列表缓存来优化这个过程。

如何做

我们按照以下步骤进行:

这个示例的代码在09/05/api.py中。JobListing类现在有以下实现:

class JobListing(Resource):
    def get(self, job_listing_id):
        print("Request for job listing with id: " + job_listing_id)

        es = Elasticsearch()
        if (es.exists(index='joblistings', doc_type='job-listing', id=job_listing_id)):
            print('Found the document in ElasticSearch')
            doc =  es.get(index='joblistings', doc_type='job-listing', id=job_listing_id)
            return doc['_source']

        listing = get_job_listing_info(job_listing_id)
        es.index(index='joblistings', doc_type='job-listing', id=job_listing_id, body=listing)

        print("Got the following listing as a response: " + listing)
        return listing

在调用爬虫代码之前,API 会检查文档是否已经存在于 Elasticsearch 中。这是通过名为exists的方法执行的,我们将要获取的索引、文档类型和 ID 传递给它。

如果返回 true,则使用 Elasticsearch 对象的get方法检索文档,该方法也具有相同的参数。这将返回一个表示 Elasticsearch 文档的 Python 字典,而不是我们存储的实际数据。实际的数据/文档是通过访问字典的'_source'键来引用的。

还有更多...

JobListingSkills API 实现遵循了稍微不同的模式。以下是它的代码:

class JobListingSkills(Resource):
    def get(self, job_listing_id):
        print("Request for job listing's skills with id: " + job_listing_id)

        es = Elasticsearch()
        if (es.exists(index='joblistings', doc_type='job-listing', id=job_listing_id)):
            print('Found the document in ElasticSearch')
            doc =  es.get(index='joblistings', doc_type='job-listing', id=job_listing_id)
            return doc['_source']['JSON']['skills']

        skills = get_job_listing_skills(job_listing_id)

        print("Got the following skills as a response: " + skills)
        return skills

这个实现仅在检查文档是否已经存在于 ElasticSearch 时使用 ElasticSearch。它不会尝试保存从爬虫中新检索到的文档。这是因为get_job_listing爬虫的结果只是技能列表,而不是整个文档。因此,这个实现可以使用缓存,但不会添加新数据。这是设计决策之一,即对爬取方法进行不同的设计,返回的只是被爬取文档的子集。

对此的一个潜在解决方案是,将这个 API 方法调用get_job_listing_info,然后保存文档,最后只返回特定的子集(在这种情况下是技能)。再次强调,这最终是围绕 sojobs 模块的用户需要哪些类型的方法的设计考虑。出于这些初始示例的目的,考虑到在该级别有两个不同的函数返回不同的数据集更好。

第十章:使用 Docker 创建爬虫微服务

在本章中,我们将涵盖:

  • 安装 Docker

  • 从 Docker Hub 安装 RabbitMQ 容器

  • 运行一个 Docker 容器(RabbitMQ)

  • 停止和删除容器和镜像

  • 创建一个 API 容器

  • 使用 Nameko 创建一个通用微服务

  • 创建一个爬取微服务

  • 创建一个爬虫容器

  • 创建后端(ElasticCache)容器

  • 使用 Docker Compose 组合和运行爬虫容器

介绍

在本章中,我们将学习如何将我们的爬虫容器化,使其准备好进入现实世界,开始为真正的、现代的、云启用的操作打包。这将涉及将爬虫的不同元素(API、爬虫、后端存储)打包为可以在本地或云中运行的 Docker 容器。我们还将研究将爬虫实现为可以独立扩展的微服务。

我们将主要关注使用 Docker 来创建我们的容器化爬虫。Docker 为我们提供了一种方便和简单的方式,将爬虫的各个组件(API、爬虫本身以及其他后端,如 Elasticsearch 和 RabbitMQ)打包为一个服务。通过使用 Docker 对这些组件进行容器化,我们可以轻松地在本地运行容器,编排组成服务的不同容器,还可以方便地发布到 Docker Hub。然后我们可以轻松地部署它们到云提供商,以在云中创建我们的爬虫。

关于 Docker(以及容器一般)的一大好处是,我们既可以轻松地安装预打包的容器,而不必费力地获取应用程序的安装程序并处理所有配置的麻烦。我们还可以将我们编写的软件打包到一个容器中,并在不必处理所有这些细节的情况下运行该容器。此外,我们还可以发布到私有或公共存储库以分享我们的软件。

Docker 真正伟大的地方在于容器在很大程度上是平台无关的。任何基于 Linux 的容器都可以在任何操作系统上运行,包括 Windows(它在虚拟化 Linux 时使用 VirtualBox,并且对 Windows 用户来说基本上是透明的)。因此,一个好处是任何基于 Linux 的 Docker 容器都可以在任何 Docker 支持的操作系统上运行。不再需要为应用程序创建多个操作系统版本了!

让我们学习一些 Docker 知识,并将我们的爬虫组件放入容器中。

安装 Docker

在这个教程中,我们将学习如何安装 Docker 并验证其是否正在运行。

准备工作

Docker 支持 Linux、macOS 和 Windows,因此它覆盖了主要平台。Docker 的安装过程因您使用的操作系统而异,甚至在不同的 Linux 发行版之间也有所不同。

Docker 网站对安装过程有很好的文档,因此本教程将快速浏览 macOS 上安装的重要要点。安装完成后,至少从 CLI 方面来看,Docker 的用户体验是相同的。

参考文献,Docker 的安装说明主页位于:docs.docker.com/engine/installation/

如何做

我们将按照以下步骤进行:

  1. 我们将使用一个名为 Docker 社区版的 Docker 变体,并在 macOS 上进行安装。在 macOS 的下载页面上,您将看到以下部分。点击稳定频道的下载,除非您感到勇敢并想使用 Edge 频道。

Docker 下载页面

  1. 这将下载一个Docker.dmg文件。打开 DMG,您将看到以下窗口:

Docker for Mac 安装程序窗口

  1. Moby鲸鱼拖到您的应用程序文件夹中。然后打开Docker.app。您将被要求验证安装,因此输入密码,安装将完成。完成后,您将在状态栏中看到 Moby:

Moby 工具栏图标

  1. 点击 Moby 可以获得许多配置设置、状态和信息。我们将主要使用命令行工具。要验证命令行是否正常工作,请打开终端并输入命令 docker info。Docker 将为您提供有关其配置和状态的一些信息。

从 Docker Hub 安装 RabbitMQ 容器

可以从许多容器存储库获取预构建的容器。Docker 预先配置了与 Docker Hub 的连接,许多软件供应商和爱好者在那里发布一个或多个配置的容器。

在这个教程中,我们将安装 RabbitMQ,这将被我们在另一个教程中使用的另一个工具 Nameko 所使用,以作为我们的抓取微服务的消息总线。

准备工作

通常,安装 RabbitMQ 是一个相当简单的过程,但它确实需要几个安装程序:一个用于 Erlang,然后一个用于 RabbitMQ 本身。如果需要管理工具,比如基于 Web 的管理 GUI,那就是另一步(尽管是一个相当小的步骤)。通过使用 Docker,我们可以简单地获取所有这些预配置的容器。让我们去做吧。

如何做

我们按照以下步骤进行教程:

  1. 可以使用docker pull命令获取容器。此命令将检查并查看本地是否已安装容器,如果没有,则为我们获取。从命令行尝试该命令,包括--help标志。您将得到以下信息,告诉您至少需要一个参数:容器的名称和可能的标签:
$ docker pull --help

Usage: docker pull [OPTIONS] NAME[:TAG|@DIGEST]

Pull an image or a repository from a registry

Options:
  -a, --all-tags Download all tagged images in the repository
      --disable-content-trust Skip image verification (default true)
      --help Print usage
  1. 我们将拉取rabbitmq:3-management容器。冒号前的部分是容器名称,第二部分是标签。标签通常代表容器的版本或特定配置。在这种情况下,我们希望获取带有 3-management 标签的 RabbitMQ 容器。这个标签意味着我们想要带有 RabbitMQ 版本 3 和管理工具安装的容器版本。

在我们这样做之前,您可能会想知道这是从哪里来的。它来自 Docker Hub(hub.docker.com),来自 RabbitMQ 存储库。该存储库的页面位于hub.docker.com/_/rabbitmq/,并且看起来像下面这样:

RabbitMQ 存储库页面请注意显示标签的部分,以及它具有 3-management 标签。如果您向下滚动,还会看到有关容器和标签的更多信息,以及它们的组成。

  1. 现在让我们拉取这个容器。从终端发出以下命令:
$docker pull rabbitmq:3-management
  1. Docker 将访问 Docker Hub 并开始下载。您将在类似以下的输出中看到这一过程,这可能会根据您的下载速度运行几分钟:
3-management: Pulling from library/rabbitmq
e7bb522d92ff: Pull complete 
ad90649c4d84: Pull complete 
5a318b914d6c: Pull complete 
cedd60f70052: Pull complete 
f4ec28761801: Pull complete 
b8fa44aa9074: Pull complete 
e3b16d5314a0: Pull complete 
7d93dd9659c8: Pull complete 
356c2fc6e036: Pull complete 
3f52408394ed: Pull complete 
7c89a0fb0219: Pull complete 
1e37a15bd7aa: Pull complete 
9313c22c63d5: Pull complete 
c21bcdaa555d: Pull complete 
Digest: sha256:c7466443efc28846bb0829d0f212c1c32e2b03409996cee38be4402726c56a26 
Status: Downloaded newer image for rabbitmq:3-management 

恭喜!如果这是您第一次使用 Docker,您已经下载了您的第一个容器镜像。您可以使用 docker images 命令验证它是否已下载和安装。

$ docker images 
REPOSITORY TAG IMAGE    ID           CREATED     SIZE 
rabbitmq   3-management 6cb6e2f951a8 10 days ago 151MB

运行 Docker 容器(RabbitMQ)

在这个教程中,我们将学习如何运行 docker 镜像,从而创建一个容器。

准备工作

我们将启动我们在上一个教程中下载的 RabbitMQ 容器镜像。这个过程代表了许多容器的运行方式,因此它是一个很好的例子。

如何做

我们按照以下步骤进行教程:

  1. 到目前为止,我们已经下载了一个可以运行以创建实际容器的镜像。容器是使用特定参数实例化的镜像,这些参数需要配置容器中的软件。我们通过运行 docker run 并传递镜像名称/标签以及运行镜像所需的任何其他参数来运行容器(这些参数特定于镜像,通常可以在 Docker Hub 页面上找到镜像的参数)。

我们需要使用以下特定命令来运行 RabbitMQ 使用此镜像:

$ docker run -d -p 15672:15672 -p 5672:5672 rabbitmq:3-management
094a138383764f487e5ad0dab45ff64c08fe8019e5b0da79cfb1c36abec69cc8
  1. docker run告诉 Docker 在容器中运行一个镜像。我们要运行的镜像在语句的末尾:rabbitmq:3-management-d选项告诉 Docker 以分离模式运行容器,这意味着容器的输出不会路由到终端。这允许我们保留对终端的控制。-p选项将主机端口映射到容器端口。RabbitMQ 使用 5672 端口进行实际命令,15672 端口用于 Web UI。这将在您的实际操作系统上的相同端口映射到容器中运行的软件使用的端口。

大的十六进制值输出是容器的标识符。第一部分,094a13838376,是 Docker 创建的容器 ID(对于每个启动的容器都会有所不同)。

  1. 我们可以使用 docker ps 来检查正在运行的容器,这会给我们每个容器的进程状态:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
094a13838376 rabbitmq:3-management "docker-entrypoint..." 5 minutes ago Up 5 minutes 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp dreamy_easley

我们可以看到容器 ID 和其他信息,例如它基于哪个镜像,它已经运行了多长时间,容器暴露了哪些端口,我们定义的端口映射,以及 Docker 为我们创建的友好名称,以便我们引用容器。

  1. 检查是否正在运行的真正方法是打开浏览器,导航到localhost:15672,即 RabbitMQ 管理 UI 的 URL:

RabbitMQ 管理 UI 登录页面

  1. 该容器的默认用户名和密码是 guest:guest。输入这些值,您将看到管理 UI:

管理 UI

还有更多...

这实际上是我们将在 RabbitMQ 中取得的进展。在以后的教程中,我们将使用 Nameko Python 微服务框架,它将在我们不知情的情况下透明地使用 RabbitMQ。我们首先需要确保它已安装并正在运行。

创建和运行 Elasticsearch 容器

当我们正在查看拉取容器镜像和启动容器时,让我们去运行一个 Elasticsearch 容器。

如何做

像大多数 Docker 一样,有很多不同版本的 Elasticsearch 容器可用。我们将使用 Elastic 自己的 Docker 存储库中提供的官方 Elasticsearch 镜像:

  1. 要安装镜像,请输入以下内容:
$docker pull docker.elastic.co/elasticsearch/elasticsearch:6.1.1

请注意,我们正在使用另一种指定要拉取的镜像的方式。由于这是在 Elastic 的 Docker 存储库上,我们包括了包含容器镜像 URL 的限定名称,而不仅仅是镜像名称。 :6.1.1 是标签,指定了该镜像的特定版本。

  1. 在处理此过程时,您将看到一些输出,显示下载过程。完成后,您将看到几行让您知道已完成:
Digest: sha256:9e6c7d3c370a17736c67b2ac503751702e35a1336724741d00ed9b3d00434fcb 
Status: Downloaded newer image for docker.elastic.co/elasticsearch/elasticsearch:6.1.1
  1. 现在让我们检查 Docker 中是否有可用的镜像:
$ docker images 
REPOSITORY TAG IMAGE ID CREATED SIZE 
rabbitmq 3-management 6cb6e2f951a8 12 days ago 151MB docker.elastic.co/elasticsearch/elasticsearch 6.1.1 06f0d8328d66 2 weeks ago 539MB
  1. 现在我们可以使用以下 Docker 命令运行 Elasticsearch:
docker run -e ELASTIC_PASSWORD=MagicWord -p 9200:9200 -p 9300:9300 docker.elastic.co/elasticsearch/elasticsearch:6.1.1
  1. 环境变量ELASTIC_PASSWORD传递密码,两个端口将主机端口映射到容器中暴露的 Elasticsearch 端口。

  2. 接下来,检查容器是否在 Docker 中运行:

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
308a02f0e1a5 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 7 seconds ago Up 6 seconds 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp romantic_kowalevski
094a13838376 rabbitmq:3-management "docker-entrypoint..." 47 hours ago Up 47 hours 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp dreamy_easley
  1. 最后,执行以下 curl。如果 Elasticsearch 正在运行,您将收到You Know, for Search消息:
$ curl localhost:9200
{
 "name" : "8LaZfMY",
 "cluster_name" : "docker-cluster",
 "cluster_uuid" : "CFgPERC8TMm5KaBAvuumvg",
 "version" : {
 "number" : "6.1.1",
 "build_hash" : "bd92e7f",
 "build_date" : "2017-12-17T20:23:25.338Z",
 "build_snapshot" : false,
 "lucene_version" : "7.1.0",
 "minimum_wire_compatibility_version" : "5.6.0",
 "minimum_index_compatibility_version" : "5.0.0"
 },
 "tagline" : "You Know, for Search"
}

停止/重新启动容器并删除镜像

让我们看看如何停止和删除一个容器,然后也删除它的镜像。

如何做

我们按照以下步骤进行:

  1. 首先查询正在运行的 Docker 容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
308a02f0e1a5 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 7 seconds ago Up 6 seconds 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp romantic_kowalevski
094a13838376 rabbitmq:3-management "docker-entrypoint..." 47 hours ago Up 47 hours 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp dreamy_easley
  1. 让我们停止 Elasticsearch 容器。要停止一个容器,我们使用docker stop <container-id>。Elasticsearch 的容器 ID 是308a02f0e1a5。以下停止容器
$ docker stop 30
30

为了确认容器已停止,Docker 将回显您告诉它停止的容器 ID

请注意,我不必输入完整的容器 ID,只输入了 30。你只需要输入容器 ID 的前几位数字,直到你输入的内容在所有容器中是唯一的。这是一个很好的快捷方式!

  1. 检查运行的容器状态,Docker 只报告其他容器:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
094a13838376 rabbitmq:3-management "docker-entrypoint..." 2 days ago Up 2 days 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp dreamy_easley
  1. 容器没有运行,但也没有被删除。让我们来使用docker ps -a命令:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
308a02f0e1a5 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 11 minutes ago Exited (143) 5 minutes ago romantic_kowalevski
548fc19e8b8d docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 12 minutes ago Exited (130) 12 minutes ago competent_keller
15c83ca72108 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 15 minutes ago Exited (130) 14 minutes ago peaceful_jennings
3191f204c661 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 18 minutes ago Exited (130) 16 minutes ago thirsty_hermann
b44f1da7613f docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 25 minutes ago Exited (130) 19 minutes ago

这列出了当前系统上的所有容器。实际上,我截断了我的列表,因为我有很多这样的容器!

  1. 我们可以使用docker restart来重新启动我们的 Elasticsearch 容器:
$ docker restart 30
30
  1. 如果你检查docker ps,你会看到容器再次运行。

这很重要,因为这个容器在容器的文件系统中存储了 Elasticsearch 数据。通过停止和重新启动,这些数据不会丢失。因此,您可以停止以回收容器使用的资源(CPU 和内存),然后在以后的某个时间重新启动而不会丢失。

  1. 无论是运行还是停止,容器都会占用磁盘空间。可以删除容器以回收磁盘空间。这可以使用docker container rm <container-id>来完成,但是只有在容器没有运行时才能删除容器。让我们尝试删除正在运行的容器:
$ docker container rm 30
Error response from daemon: You cannot remove a running container 308a02f0e1a52fe8051d1d98fa19f8ac01ff52ec66737029caa07a8358740bce. Stop the container before attempting removal or force remove
  1. 我们收到了有关容器运行的警告。我们可以使用一个标志来强制执行,但最好先停止它。停止可以确保容器内的应用程序干净地关闭:
$ docker stop 30
30
$ docker rm 30
30
  1. 现在,如果你回到 docker ps -a,Elasticsearch 容器不再在列表中,容器的磁盘空间被回收。

请注意,我们现在已经丢失了存储在该容器中的任何数据!这超出了本书的范围,但大多数容器可以被告知将数据存储在主机的文件系统上,因此我们不会丢失数据。

  1. 容器的磁盘空间已经被删除,但是容器的镜像仍然在磁盘上。如果我们想创建另一个容器,这是很好的。但是如果你也想释放那个空间,你可以使用docker images rm <image-id>。回到 Docker 镜像结果,我们可以看到该镜像的 ID 是06f0d8328d66。以下删除该镜像,我们可以获得那个空间(在这种情况下是 539MB):
$ docker image rm 06
Untagged: docker.elastic.co/elasticsearch/elasticsearch:6.1.1
Untagged: docker.elastic.co/elasticsearch/elasticsearch@sha256:9e6c7d3c370a17736c67b2ac503751702e35a1336724741d00ed9b3d00434fcb
Deleted: sha256:06f0d8328d66a0f620075ee689ddb2f7535c31fb643de6c785deac8ba6db6a4c
Deleted: sha256:133d33f65d5a512c5fa8dc9eb8d34693a69bdb1a696006628395b07d5af08109
Deleted: sha256:ae2e02ab7e50b5275428840fd68fced2f63c70ca998a493d200416026c684a69
Deleted: sha256:7b6abb7badf2f74f1ee787fe0545025abcffe0bf2020a4e9f30e437a715c6d6a

现在镜像已经消失,我们也已经回收了那个空间。

请注意,如果还存在任何使用该镜像运行的容器,那么这将失败,这些容器可能正在运行或已停止。只是做一个docker ps -a可能不会显示有问题的容器,所以你可能需要使用docker ps -a来找到已停止的容器并首先删除它们。

还有更多...

在这一点上,你已经了解了足够多关于 Docker 的知识,可以变得非常危险!所以让我们继续研究如何创建我们自己的容器,并安装我们自己的应用程序。首先,让我们去看看如何将爬虫变成一个可以在容器中运行的微服务。

使用 Nameko 创建通用微服务

在接下来的几个步骤中,我们将创建一个可以作为 Docker 容器内的微服务运行的爬虫。但在直接进入火坑之前,让我们先看看如何使用一个名为 Nameko 的 Python 框架创建一个基本的微服务。

准备工作

我们将使用一个名为 Nameko 的 Python 框架(发音为[nah-meh-koh])来实现微服务。与 Flask-RESTful 一样,使用 Nameko 实现的微服务只是一个类。我们将指示 Nameko 如何将该类作为服务运行,并且 Nameko 将连接一个消息总线实现,以允许客户端与实际的微服务进行通信。

默认情况下,Nameko 使用 RabbitMQ 作为消息总线。RabbitMQ 是一个高性能的消息总线,非常适合在微服务之间进行消息传递。它与我们之前在 SQS 中看到的模型类似,但更适合于位于同一数据中心的服务,而不是跨云。这实际上是 RabbitMQ 的一个很好的用途,因为我们现在倾向于在相同的环境中集群/扩展微服务,特别是在容器化集群中,比如 Docker 或 Kubernetes。

因此,我们需要在本地运行一个 RabbitMQ 实例。确保你有一个 RabbitMQ 容器运行,就像在之前的示例中展示的那样。

还要确保你已经安装了 Nameko:

pip install Nameko

如何做到这一点

我们按照以下步骤进行操作:

  1. 示例微服务实现在10/01/hello_microservice.py中。这是一个非常简单的服务,可以传递一个名字,微服务会回复Hello, <name>!

  2. 要运行微服务,我们只需要从终端执行以下命令(在脚本所在的目录中):

$nameko run hello_microservice
  1. Nameko 打开与指定微服务名称匹配的 Python 文件,并启动微服务。启动时,我们会看到几行输出:
starting services: hello_microservice
Connected to amqp://guest:**@127.0.0.1:5672//
  1. 这表明 Nameko 已经找到了我们的微服务,并且已经连接到了一个 AMQP 服务器(RabbitMQ)的 5672 端口(RabbitMQ 的默认端口)。微服务现在已经启动并且正在等待请求。

如果你进入 RabbitMQ API 并进入队列选项卡,你会看到 Nameko 已经自动为微服务创建了一个队列。

  1. 现在我们必须做一些事情来请求微服务。我们将看两种方法来做到这一点。首先,Nameko 带有一个交互式 shell,让我们可以交互地向 Nameko 微服务发出请求。你可以在一个单独的终端窗口中使用以下命令启动 shell,与运行微服务的窗口分开:
nameko shell
  1. 你会看到一个交互式的 Python 会话开始,输出类似于以下内容:
Nameko Python 3.6.1 |Anaconda custom (x86_64)| (default, Mar 22 2017, 19:25:17)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.57)] shell on darwin
Broker: pyamqp://guest:guest@localhost
In [1]:
  1. 在这个 shell 中,我们可以简单地将 Nameko 称为'n'。要与我们的服务交谈,我们发出以下声明:
n.rpc.hello_microservice.hello(name='Mike')
  1. 这告诉 Nameko 我们想要调用hello_microservicehello方法。按下Enter后,你会得到以下结果:
Out[1]: 'Hello, Mike!'
  1. 如果你在运行服务的终端窗口中检查,你应该会看到额外的一行输出:
Received a request from: Mike
  1. 也可以在 Python 代码中调用微服务。在10/01/say_hi.py中有一个实现。用 Python 执行这个脚本会得到以下输出:
$python say_hi.py
Hello, Micro-service Client!

那么让我们去看看这些是如何实现的。

它是如何工作的

让我们首先看一下hello_microservice.py中微服务的实现。实际上并没有太多的代码,所以这里是全部代码:

from nameko.rpc import rpc

class HelloMicroService:
    name = "hello_microservice"    @rpc
  def hello(self, name):
        print('Received a request from: ' + name)
        return "Hello, {}!".format(name)

有两件事情要指出关于这个类。第一是声明name = "hello_microservice"。这是微服务的实际名称声明。这个成员变量被用来代替类名。

第二个是在hello方法上使用@rpc属性。这是一个 Nameko 属性,指定这个方法应该作为rpc风格的方法被微服务公开。因此,调用者会一直等待,直到从微服务接收到回复。还有其他实现方式,但是对于我们的目的,这是我们将使用的唯一方式。

当使用 nameko run 命令运行时,该模块将检查文件中带有 Nameko 属性的方法,并将它们连接到底层总线。

say_hi.py中的实现构建了一个可以调用服务的动态代理。代码如下:

from nameko.standalone.rpc import ClusterRpcProxy

CONFIG = {'AMQP_URI': "amqp://guest:guest@localhost"}

with ClusterRpcProxy(CONFIG) as rpc:
    result = rpc.hello_microservice.hello("Micro-service Client")
    print(result)

动态代理是由ClusterRpcProxy类实现的。创建该类时,我们传递一个配置对象,该对象指定了服务所在的 AMQP 服务器的地址,在这种情况下,我们将这个实例称为变量rpc。然后,Nameko 动态识别下一个部分.hello_microservice作为微服务的名称(如在微服务类的名称字段中指定的)。

接下来的部分.hello代表要调用的方法。结合在一起,Nameko 调用hello_microservicehello方法,传递指定的字符串,由于这是一个 RPC 代理,它会等待接收到回复。

远程过程调用,简称 RPC,会一直阻塞,直到结果从其他系统返回。与发布模型相比,发布模型中消息被发送后发送应用程序继续进行。

还有更多...

在 Nameko 中有很多好东西,我们甚至还没有看到。一个非常有用的因素是,Nameko 运行多个微服务实例的监听器。撰写本文时,默认值为 10。在底层,Nameko 将来自微服务客户端的请求发送到 RabbitMQ 队列,其中将有 10 个同时的请求处理器监听该队列。如果有太多的请求需要同时处理,RabbitMQ 将保留消息,直到 Nameko 回收现有的微服务实例来处理排队的消息。为了增加微服务的可伸缩性,我们可以通过微服务的配置简单地增加工作人员的数量,或者在另一个 Docker 容器中运行一个单独的 Nameko 微服务容器,或者在另一台计算机系统上运行。

创建一个抓取微服务

现在让我们把我们的抓取器变成一个 Nameko 微服务。这个抓取微服务将能够独立于 API 的实现而运行。这将允许抓取器独立于 API 的实现进行操作、维护和扩展。

如何做

我们按照以下步骤进行:

  1. 微服务的代码很简单。代码在10/02/call_scraper_microservice.py中,如下所示:
from nameko.rpc import rpc
import sojobs.scraping 

class ScrapeStackOverflowJobListingsMicroService:
    name = "stack_overflow_job_listings_scraping_microservice"    @rpc
  def get_job_listing_info(self, job_listing_id):
        listing = sojobs.scraping.get_job_listing_info(job_listing_id)
        print(listing)
        return listing

if __name__ == "__main__":
    print(ScrapeStackOverflowJobListingsMicroService("122517"))
  1. 我们创建了一个类来实现微服务,并给它一个单一的方法get_job_listing_info。这个方法简单地包装了sojobs.scraping模块中的实现,但是给它一个@rpc属性,以便 Nameko 在微服务总线上公开该方法。这可以通过打开终端并使用 Nameko 运行服务来运行。
$ nameko run scraper_microservice
 starting services: stack_overflow_job_listings_scraping_microservice
 Connected to amqp://guest:**@127.0.0.1:5672//
  1. 现在我们可以使用10/02/call_scraper_microservice.py脚本中的代码运行抓取器。文件中的代码如下:
from nameko.standalone.rpc import ClusterRpcProxy

CONFIG = {'AMQP_URI': "amqp://guest:guest@localhost"}

with ClusterRpcProxy(CONFIG) as rpc:
    result = rpc.stack_overflow_job_listings_scraping_microservice.get_job_listing_info("122517")
    print(result)
  1. 这基本上与上一个教程中客户端的代码相同,但是更改了微服务和方法的名称,并当然传递了特定的工作列表 ID。运行时,您将看到以下输出(已截断):
{"ID": "122517", "JSON": {"@context": "http://schema.org", "@type": "JobPosting", "title": "SpaceX Enterprise Software Engineer, Full Stack", "skills": ["c#", "sql", "javascript", "asp.net", "angularjs"], 

...
  1. 就像这样,我们已经创建了一个从 StackOverflow 获取工作列表的微服务!

还有更多...

这个微服务只能使用ClusterRpcProxy类调用,不能被任何人通过互联网甚至本地使用 REST 调用。我们将在即将到来的教程中解决这个问题,在那里我们将在一个容器中创建一个 REST API,该 API 将与另一个运行在另一个容器中的微服务进行通信。

创建一个抓取容器

现在我们为我们的抓取微服务创建一个容器。我们将学习 Dockerfile 以及如何指示 Docker 如何构建容器。我们还将研究如何为我们的 Docker 容器提供主机名,以便它们可以通过 Docker 集成的 DNS 系统相互找到。最后但并非最不重要的是,我们将学习如何配置我们的 Nameko 微服务,以便与另一个容器中的 RabbitMQ 通信,而不仅仅是在本地主机上。

准备工作

我们要做的第一件事是确保 RabbitMQ 在一个容器中运行,并分配给一个自定义的 Docker 网络,连接到该网络的各种容器将相互通信。除了许多其他功能外,它还提供了软件定义网络(SDN)功能,以在容器、主机和其他系统之间提供各种类型的集成。

Docker 自带了几个预定义的网络。您可以使用docker network ls命令查看当前安装的网络:

$ docker network ls
NETWORK ID   NAME                                     DRIVER  SCOPE
bc3bed092eff bridge                                   bridge  local
26022f784cc1 docker_gwbridge                          bridge  local
448d8ce7f441 dockercompose2942991694582470787_default bridge  local
4e549ce87572 dockerelkxpack_elk                       bridge  local
ad399a431801 host                                     host    local
rbultxlnlhfb ingress                                  overlay swarm
389586bebcf2 none                                     null    local
806ff3ec2421 stackdockermaster_stack                  bridge  local

为了让我们的容器相互通信,让我们创建一个名为scraper-net的新桥接网络。

$ docker network create --driver bridge scraper-net
e4ea1c48395a60f44ec580c2bde7959641c4e1942cea5db7065189a1249cd4f1

现在,当我们启动一个容器时,我们使用--network参数将其连接到scraper-net

$docker run -d --name rabbitmq --network scrape-rnet -p 15672:15672 -p 5672:5672 rabbitmq:3-management

这个容器现在连接到scraper-net网络和主机网络。因为它也连接到主机,所以仍然可以从主机系统连接到它。

还要注意,我们使用了--name rabbitmq作为一个选项。这给了这个容器名字rabbitmq,但 Docker 也会解析来自连接到scraper-net的其他容器的 DNS 查询,以便它们可以找到这个容器!

现在让我们把爬虫放到一个容器中。

如何做到这一点

我们按照以下步骤进行配方:

  1. 我们创建容器的方式是创建一个dockerfile,然后使用它告诉 Docker 创建一个容器。我在10/03文件夹中包含了一个 Dockerfile。内容如下(我们将在它是如何工作部分检查这意味着什么):
FROM python:3 WORKDIR /usr/src/app

RUN pip install nameko BeautifulSoup4 nltk lxml
RUN python -m nltk.downloader punkt -d /usr/share/nltk_data all

COPY 10/02/scraper_microservice.py .
COPY modules/sojobs sojobs

CMD ["nameko", "run", "--broker", "amqp://guest:guest@rabbitmq", "scraper_microservice"]
  1. 要从这个 Dockerfile 创建一个镜像/容器,在终端中,在10/03文件夹中,运行以下命令:
$docker build ../.. -f Dockerfile  -t scraping-microservice
  1. 这告诉 Docker,我们想要根据给定的 Dockerfile 中的指令构建一个容器(用-f 指定)。创建的镜像由指定

-t scraping-microservicebuild后面的../..指定了构建的上下文。在构建时,我们将文件复制到容器中。这个上下文指定了复制相对于的主目录。当你运行这个命令时,你会看到类似以下的输出:

Sending build context to Docker daemon 2.128MB
Step 1/8 : FROM python:3
 ---> c1e459c00dc3
Step 2/8 : WORKDIR /usr/src/app
 ---> Using cache
 ---> bf047017017b
Step 3/8 : RUN pip install nameko BeautifulSoup4 nltk lxml
 ---> Using cache
 ---> a30ce09e2f66
Step 4/8 : RUN python -m nltk.downloader punkt -d /usr/share/nltk_data all
 ---> Using cache
 ---> 108b063908f5
Step 5/8 : COPY 10/07/. .
 ---> Using cache
 ---> 800a205d5283
Step 6/8 : COPY modules/sojobs sojobs
 ---> Using cache
 ---> 241add5458a5
Step 7/8 : EXPOSE 5672
 ---> Using cache
 ---> a9be801d87af
Step 8/8 : CMD nameko run --broker amqp://guest:guest@rabbitmq scraper_microservice
 ---> Using cache
 ---> 0e1409911ac9
Successfully built 0e1409911ac9
Successfully tagged scraping-microservice:latest
  1. 这可能需要一些时间,因为构建过程需要将所有的 NLTK 文件下载到容器中。要检查镜像是否创建,可以运行以下命令:
$ docker images | head -n 2
REPOSITORY            TAG    IMAGE ID     CREATED     SIZE
scraping-microservice latest 0e1409911ac9 3 hours ago 4.16GB
  1. 请注意,这个容器的大小是 4.16GB。这个镜像是基于Python:3容器的,可以看到大小为692MB
$ docker images | grep python
 python 3 c1e459c00dc3 2 weeks ago 692MB

这个容器的大部分大小是因为包含了 NTLK 数据文件。

  1. 现在我们可以使用以下命令将这个镜像作为一个容器运行:
03 $ docker run --network scraper-net scraping-microservice
starting services: stack_overflow_job_listings_scraping_microservice
Connected to amqp://guest:**@rabbitmq:5672//

我们组合的爬虫现在在这个容器中运行,这个输出显示它已经连接到一个名为rabbitmq的系统上的 AMQP 服务器。

  1. 现在让我们测试一下这是否有效。在另一个终端窗口中运行 Nameko shell:
03 $ nameko shell
Nameko Python 3.6.1 |Anaconda custom (x86_64)| (default, Mar 22 2017, 19:25:17)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.57)] shell on darwin
Broker: pyamqp://guest:guest@localhost
In [1]:
  1. 现在,在提示符中输入以下内容来调用微服务:
n.rpc.stack_overflow_job_listings_scraping_microservice.get_job_listing_info("122517")
  1. 由于抓取的结果,你会看到相当多的输出(以下是截断的):
Out[1]: '{"ID": "122517", "JSON": {"@context": "http://schema.org", "@type": "JobPosting", "title": "SpaceX Enterprise Software Engineer, Full Stack", "skills": ["c#", "sql", "javascript", "asp.net"

恭喜!我们现在已经成功调用了我们的爬虫微服务。现在,让我们讨论这是如何工作的,以及 Dockerfile 是如何构建微服务的 Docker 镜像的。

它是如何工作的

让我们首先讨论 Dockerfile,通过在构建过程中告诉 Docker 要做什么来逐步了解它的内容。第一行:

FROM python:3

这告诉 Docker,我们想要基于 Docker Hub 上找到的Python:3镜像构建我们的容器镜像。这是一个预先构建的 Linux 镜像,安装了 Python 3。下一行告诉 Docker,我们希望所有的文件操作都是相对于/usr/src/app文件夹的。

WORKDIR /usr/src/app

在构建镜像的这一点上,我们已经安装了一个基本的 Python 3。然后我们需要安装我们的爬虫使用的各种库,所以下面告诉 Docker 运行 pip 来安装它们:

RUN pip install nameko BeautifulSoup4 nltk lxml

我们还需要安装 NLTK 数据文件:

RUN python -m nltk.downloader punkt -d /usr/share/nltk_data all

接下来,我们将实现我们的爬虫复制进去。以下是将scraper_microservice.py文件从上一个配方的文件夹复制到容器镜像中。

COPY 10/02/scraper_microservice.py .

这也取决于sojobs模块,因此我们也复制它:

COPY modules/sojobs sojobs

最后一行告诉 Docker 在启动容器时要运行的命令:

CMD ["nameko", "run", "--broker", "amqp://guest:guest@rabbitmq", "scraper_microservice"]

这告诉 Nameko 在scraper_microservice.py中运行微服务,并且还与名为rabbitmq的系统上的 RabbitMQ 消息代理进行通信。由于我们将 scraper 容器附加到 scraper-net 网络,并且还对 RabbitMQ 容器执行了相同操作,Docker 为我们连接了这两个容器!

最后,我们从 Docker 主机系统中运行了 Nameko shell。当它启动时,它报告说它将与 AMQP 服务器(RabbitMQ)通信pyamqp://guest:guest@localhost。当我们在 shell 中执行命令时,Nameko shell 将该消息发送到 localhost。

那么它如何与容器中的 RabbitMQ 实例通信呢?当我们启动 RabbitMQ 容器时,我们告诉它连接到scraper-net网络。它仍然连接到主机网络,因此只要我们在启动时映射了5672端口,我们仍然可以与 RabbitMQ 代理进行通信。

我们在另一个容器中的微服务正在 RabbitMQ 容器中监听消息,然后响应该容器,然后由 Nameko shell 接收。这很酷,不是吗?

创建 API 容器

此时,我们只能使用 AMQP 或使用 Nameko shell 或 Nameko ClusterRPCProxy类与我们的微服务进行通信。因此,让我们将我们的 Flask-RESTful API 放入另一个容器中,与其他容器一起运行,并进行 REST 调用。这还需要我们运行一个 Elasticsearch 容器,因为该 API 代码还与 Elasticsearch 通信。

准备就绪

首先让我们在附加到scraper-net网络的容器中启动 Elasticsearch。我们可以使用以下命令启动它:

$ docker run -e ELASTIC_PASSWORD=MagicWord --name=elastic --network scraper-net  -p 9200:9200 -p 9300:9300 docker.elastic.co/elasticsearch/elasticsearch:6.1.1

Elasticsearch 现在在我们的scarper-net网络上运行。其他容器中的应用程序可以使用名称 elastic 访问它。现在让我们继续创建 API 的容器。

如何做

我们按照以下步骤进行:

  1. 10/04文件夹中有一个api.py文件,该文件实现了一个修改后的 Flask-RESTful API,但进行了几处修改。让我们检查 API 的代码:
from flask import Flask
from flask_restful import Resource, Api
from elasticsearch import Elasticsearch
from nameko.standalone.rpc import ClusterRpcProxy

app = Flask(__name__)
api = Api(app)

CONFIG = {'AMQP_URI': "amqp://guest:guest@rabbitmq"}

class JobListing(Resource):
    def get(self, job_listing_id):
        print("Request for job listing with id: " + job_listing_id)

        es = Elasticsearch(hosts=["elastic"])
        if (es.exists(index='joblistings', doc_type='job-listing', id=job_listing_id)):
            print('Found the document in Elasticsearch')
            doc =  es.get(index='joblistings', doc_type='job-listing', id=job_listing_id)
            return doc['_source']

        print('Not found in Elasticsearch, trying a scrape')
        with ClusterRpcProxy(CONFIG) as rpc:
            listing = rpc.stack_overflow_job_listings_scraping_microservice.get_job_listing_info(job_listing_id)
            print("Microservice returned with a result - storing in Elasticsearch")
            es.index(index='joblistings', doc_type='job-listing', id=job_listing_id, body=listing)
            return listing

api.add_resource(JobListing, '/', '/joblisting/<string:job_listing_id>')

if __name__ == '__main__':
    print("Starting the job listing API ...")
    app.run(host='0.0.0.0', port=8080, debug=True)
  1. 第一个变化是 API 上只有一个方法。我们现在将重点放在JobListing方法上。在该方法中,我们现在进行以下调用以创建 Elasticsearch 对象:
es = Elasticsearch(hosts=["elastic"])
  1. 默认构造函数假定 Elasticsearch 服务器在 localhost 上。此更改现在将其指向 scraper-net 网络上名为 elastic 的主机。

  2. 第二个变化是删除对 sojobs 模块中函数的调用。相反,我们使用Nameko ClusterRpcProxy对象调用在 scraper 容器内运行的 scraper 微服务。该对象传递了一个配置,将 RPC 代理指向 rabbitmq 容器。

  3. 最后一个变化是 Flask 应用程序的启动:

    app.run(host='0.0.0.0', port=8080, debug=True)
  1. 默认连接到 localhost,或者 127.0.0.1。在容器内部,这不会绑定到我们的scraper-net网络,甚至不会绑定到主机网络。使用0.0.0.0将服务绑定到所有网络接口,因此我们可以通过容器上的端口映射与其通信。端口也已移至8080,这是比 5000 更常见的 REST API 端口。

  2. 将 API 修改为在容器内运行,并与 scraper 微服务通信后,我们现在可以构建容器。在10/04文件夹中有一个 Dockerfile 来配置容器。其内容如下:

FROM python:3 WORKDIR /usr/src/app

RUN pip install Flask-RESTful Elasticsearch Nameko

COPY 10/04/api.py .

CMD ["python", "api.py"]

这比以前容器的 Dockerfile 简单。该容器没有 NTLK 的所有权重。最后,启动只需执行api.py文件。

  1. 使用以下内容构建容器:
$docker build ../.. -f Dockerfile -t scraper-rest-api
  1. 然后我们可以使用以下命令运行容器:
$docker run -d -p 8080:8080 --network scraper-net scraper-rest-api
  1. 现在让我们检查一下我们的所有容器是否都在运行:
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
55e438b4afcd scraper-rest-api "python -u api.py" 46 seconds ago Up 45 seconds 0.0.0.0:8080->8080/tcp vibrant_sammet
bb8aac5b7518 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 3 hours ago Up 3 hours 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp elastic
ac4f51c1abdc scraping-microservice "nameko run --brok..." 3 hours ago Up 3 hours thirsty_ritchie
18c2f01f58c7 rabbitmq:3-management "docker-entrypoint..." 3 hours ago Up 3 hours 4369/tcp, 5671/tcp, 0.0.0.0:5672->5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp rabbitmq
  1. 现在,从主机终端上,我们可以向 REST 端点发出 curl 请求(输出已截断):
$ curl localhost:8080/joblisting/122517
"{\"ID\": \"122517\", \"JSON\": {\"@context\": \"http://schema.org\", \"@type\": \"JobPosting\", \"title\": \"SpaceX Enterprise Software Engineer, Full Stack\", \"skills\": [\"c#\", \"sql\", \"javas

然后我们就完成了。我们已经将 API 和功能容器化,并在容器中运行了 RabbitMQ 和 Elasticsearch。

还有更多...

这种类型的容器化对于操作的设计和部署是一个巨大的优势,但是我们仍然需要创建许多 Docker 文件、容器和网络来连接它们,并独立运行它们。幸运的是,我们可以使用 docker-compose 来简化这个过程。我们将在下一个步骤中看到这一点。

使用 docker-compose 在本地组合和运行爬虫

Compose 是一个用于定义和运行多容器 Docker 应用程序的工具。使用 Compose,您可以使用 YAML 文件配置应用程序的服务。然后,通过一个简单的配置文件和一个命令,您可以从配置中创建和启动所有服务。

准备就绪

使用 Compose 的第一件事是确保已安装。Compose 会随 Docker for macOS 自动安装。在其他平台上,可能已安装或未安装。您可以在以下网址找到说明:docs.docker.com/compose/install/#prerequisites

此外,请确保我们之前创建的所有现有容器都没有在运行,因为我们将创建新的容器。

如何做到这一点

我们按照以下步骤进行:

  1. Docker Compose 使用docker-compose.yml文件告诉 Docker 如何将容器组合为services。在10/05文件夹中有一个docker-compose.yml文件,用于将我们的爬虫的所有部分作为服务启动。以下是文件的内容:
version: '3' services:
 api: image: scraper-rest-api
  ports:
  - "8080:8080"
  networks:
  - scraper-compose-net    scraper:
 image: scraping-microservice
  depends_on:
  - rabbitmq
  networks:
  - scraper-compose-net    elastic:
 image: docker.elastic.co/elasticsearch/elasticsearch:6.1.1
  ports:
  - "9200:9200"
  - "9300:9300"
  networks:
  - scraper-compose-net    rabbitmq:
 image: rabbitmq:3-management
  ports:
  - "15672:15672"
  networks:
  - scraper-compose-net   networks:
 scraper-compose-net: driver: bridge

使用 Docker Compose,我们不再考虑容器,而是转向与服务一起工作。在这个文件中,我们描述了四个服务(api、scraper、elastic 和 rabbitmq)以及它们的创建方式。每个服务的图像标签告诉 Compose 要使用哪个 Docker 图像。如果需要映射端口,那么我们可以使用ports标签。network标签指定要连接服务的网络,在这种情况下,文件中还声明了一个bridged网络。最后要指出的一件事是 scraper 服务的depends_on标签。该服务需要在之前运行rabbitmq服务,这告诉 docker compose 确保按指定顺序进行。

  1. 现在,要启动所有内容,打开一个终端并从该文件夹运行以下命令:
    $ docker-compose up
  1. Compose 在读取配置并弄清楚要做什么时会暂停一会儿,然后会有相当多的输出,因为每个容器的输出都将流式传输到这个控制台。在输出的开头,您将看到类似于以下内容:
Starting 10_api_1 ...
 Recreating elastic ...
 Starting rabbitmq ...
 Starting rabbitmq
 Recreating elastic
 Starting rabbitmq ... done
 Starting 10_scraper_1 ...
 Recreating elastic ... done
 Attaching to rabbitmq, 10_api_1, 10_scraper_1, 10_elastic_1
  1. 在另一个终端中,您可以发出docker ps命令来查看已启动的容器:
$ docker ps
 CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
 2ed0d456ffa0 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 3 minutes ago Up 2 minutes 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp 10_elastic_1
 8395989fac8d scraping-microservice "nameko run --brok..." 26 minutes ago Up 3 minutes 10_scraper_1
 4e9fe8479db5 rabbitmq:3-management "docker-entrypoint..." 26 minutes ago Up 3 minutes 4369/tcp, 5671-5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp rabbitmq
 0b0df48a7201 scraper-rest-api "python -u api.py" 26 minutes ago Up 3 minutes 0.0.0.0:8080->8080/tcp 10_api_1

注意服务容器的名称。它们被两个不同的标识符包裹。前缀只是运行组合的文件夹,本例中为 10(用于'10_'前缀)。您可以使用-p 选项来更改这个,以指定其他内容。尾随的数字是该服务的容器实例编号。在这种情况下,我们每个服务只启动了一个容器,所以这些都是 _1。不久之后,当我们进行扩展时,我们将看到这一点发生变化。

您可能会问:如果我的服务名为rabbitmq,而 Docker 创建了一个名为10_rabbitmq_1的容器,那么使用rabbitmq作为主机名的微服务如何连接到 RabbitMQ 实例?在这种情况下,Docker Compose 已经为您解决了这个问题,因为它知道rabbitmq需要被转换为10_rabbitmq_1。太棒了!

  1. 作为启动此环境的一部分,Compose 还创建了指定的网络:
$ docker network ls | head -n 2
 NETWORK ID NAME DRIVER SCOPE
 0e27be3e30f2 10_scraper-compose-net bridge local

如果我们没有指定网络,那么 Compose 将创建一个默认网络并将所有内容连接到该网络。在这种情况下,这将正常工作。但在更复杂的情况下,这个默认值可能不正确。

  1. 现在,此时一切都已经启动并运行。让我们通过调用 REST 抓取 API 来检查一切是否正常运行:
$ curl localhost:8080/joblisting/122517
 "{\"ID\": \"122517\", \"JSON\": {\"@context\": \"http://schema.org\", \"@type\": \"JobPosting\", \"title\": \"SpaceX Enterprise Software Engineer, Full Stack\", \"
...
  1. 同时,让我们通过检查工作列表的索引来确认 Elasticsearch 是否正在运行,因为我们已经请求了一个:
$ curl localhost:9200/joblisting
{"error":{"root_cause":{"type":"index_not_found_exception","reason":"no such index","resource.type":"index_or_alias","resource.id":"joblisting","index_uuid":"_na_","index":"j
...
  1. 我们还可以使用 docker-compose 来扩展服务。如果我们想要添加更多微服务容器以增加处理请求的数量,我们可以告诉 Compose 增加 scraper 服务容器的数量。以下命令将 scraper 容器的数量增加到 3 个:
docker-compose up --scale scraper=3
  1. Compose 将会考虑一会儿这个请求,然后发出以下消息,说明正在启动另外两个 scraper 服务容器(随后会有大量输出来自这些容器的初始化):
10_api_1 is up-to-date
10_elastic_1 is up-to-date
10_rabbitmq_1 is up-to-date
Starting 10_scraper_1 ... done
Creating 10_scraper_2 ...
Creating 10_scraper_3 ...
Creating 10_scraper_2 ... done
Creating 10_scraper_3 ... done
Attaching to 10_api_1, 10_elastic_1, 10_rabbitmq_1, 10_scraper_1, 10_scraper_3, 10_scraper_2
  1. docker ps现在将显示三个正在运行的 scraper 容器:
Michaels-iMac-2:09 michaelheydt$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
b9c2da0c9008 scraping-microservice "nameko run --brok..." About a minute ago Up About a minute 10_scraper_2
643221f85364 scraping-microservice "nameko run --brok..." About a minute ago Up About a minute 10_scraper_3
73dc31fb3d92 scraping-microservice "nameko run --brok..." 6 minutes ago Up 6 minutes 10_scraper_1
5dd0db072483 scraper-rest-api "python api.py" 7 minutes ago Up 7 minutes 0.0.0.0:8080->8080/tcp 10_api_1
d8e25b6ce69a rabbitmq:3-management "docker-entrypoint..." 7 minutes ago Up 7 minutes 4369/tcp, 5671-5672/tcp, 15671/tcp, 25672/tcp, 0.0.0.0:15672->15672/tcp 10_rabbitmq_1
f305f81ae2a3 docker.elastic.co/elasticsearch/elasticsearch:6.1.1 "/usr/local/bin/do..." 7 minutes ago Up 7 minutes 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp 10_elastic_1
  1. 现在我们可以看到我们有三个名为10_scraper_110_scraper_210_scraper_3的容器。很酷!如果你进入 RabbitMQ 管理界面,你会看到有三个连接:

![RabbitMQ 中的 Nameko 队列请注意每个队列都有不同的 IP 地址。在像我们创建的桥接网络上,Compose 会在172.23.0网络上分配 IP 地址,从.2开始。

操作上,所有来自 API 的抓取请求都将被路由到 rabbitmq 容器,实际的 RabbitMQ 服务将把消息传播到所有活动连接,因此传播到所有三个容器,帮助我们扩展处理能力。

服务实例也可以通过发出一个较小数量的容器的规模值来缩减,Compose 将会响应并删除容器,直到达到指定的值。

当一切都完成时,我们可以告诉 Docker Compose 关闭所有内容:

$ docker-compose down
Stopping 10_scraper_1 ... done
Stopping 10_rabbitmq_1 ... done
Stopping 10_api_1 ... done
Stopping 10_elastic_1 ... done
Removing 10_scraper_1 ... done
Removing 10_rabbitmq_1 ... done
Removing 10_api_1 ... done
Removing 10_elastic_1 ... done
Removing network 10_scraper-compose-net

执行docker ps现在将显示所有容器都已被移除。

还有更多...

我们几乎没有涉及 Docker 和 Docker Compose 的许多功能,甚至还没有开始研究使用 Docker swarm 等服务。虽然 docker Compose 很方便,但它只在单个主机上运行容器,最终会有可扩展性的限制。Docker swarm 将执行类似于 Docker Compose 的操作,但是在集群中跨多个系统进行操作,从而实现更大的可扩展性。但希望这让你感受到了 Docker 和 Docker Compose 的价值,以及在创建灵活的抓取服务时它们的价值。

第十一章:使 Scraper 成为一个真正的服务

在本章中,我们将涵盖:

  • 创建和配置 Elastic Cloud 试用账户

  • 使用 curl 访问 Elastic Cloud 集群

  • 使用 Python 连接 Elastic Cloud 集群

  • 使用 Python API 执行 Elasticsearch 查询

  • 使用 Elasticsearch 查询具有特定技能的工作

  • 修改 API 以按技能搜索工作

  • 将配置存储在环境中

为 ECS 创建 AWS IAM 用户和密钥对

  • 配置 Docker 以与 ECR 进行身份验证

  • 将容器推送到 ECR

  • 创建 ECS 集群

  • 创建任务来运行我们的容器

  • 在 AWS 中启动和访问容器

介绍

在本章中,我们将首先添加一个功能,使用 Elasticsearch 搜索工作列表,并扩展 API 以实现此功能。然后将 Elasticsearch 功能移至 Elastic Cloud,这是将我们的基于云的 Scraper 云化的第一步。然后,我们将将我们的 Docker 容器移至 Amazon Elastic Container Repository(ECR),最后在 Amazon Elastic Container Service(ECS)中运行我们的容器(和 Scraper)。

创建和配置 Elastic Cloud 试用账户

在这个示例中,我们将创建和配置一个 Elastic Cloud 试用账户,以便我们可以将 Elasticsearch 作为托管服务使用。Elastic Cloud 是 Elasticsearch 创建者提供的云服务,提供了完全托管的 Elasticsearch 实现。

虽然我们已经研究了将 Elasticsearch 放入 Docker 容器中,但在 AWS 中实际运行带有 Elasticsearch 的容器非常困难,因为存在许多内存要求和其他系统配置,这些配置在 ECS 中很难实现。因此,对于云解决方案,我们将使用 Elastic Cloud。

如何做

我们将按照以下步骤进行:

  1. 打开浏览器,转到www.elastic.co/cloud/as-a-service/signup。您将看到一个类似以下内容的页面:

Elastic Cloud 注册页面

  1. 输入您的电子邮件并点击“开始免费试用”按钮。当邮件到达时,请进行验证。您将被带到一个页面来创建您的集群:

集群创建页面

  1. 在其他示例中,我将使用 AWS(而不是 Google)在俄勒冈州(us-west-2)地区,所以我将为这个集群选择这两个选项。您可以选择适合您的云和地区。您可以将其他选项保持不变,然后只需按“创建”。然后您将看到您的用户名和密码。记下来。以下屏幕截图给出了它如何显示用户名和密码:

Elastic Cloud 账户的凭据信息我们不会在任何示例中使用 Cloud ID。

  1. 接下来,您将看到您的端点。对我们来说,Elasticsearch URL 很重要:

  1. 就是这样 - 你已经准备好了(至少可以使用 14 天)!

使用 curl 访问 Elastic Cloud 集群

Elasticsearch 基本上是通过 REST API 访问的。Elastic Cloud 也是一样的,实际上是相同的 API。我们只需要知道如何正确构建 URL 以进行连接。让我们来看看。

如何做

我们将按照以下步骤进行:

  1. 当您注册 Elastic Cloud 时,您会获得各种端点和变量,例如用户名和密码。URL 类似于以下内容:
https://<account-id>.us-west-2.aws.found.io:9243

根据云和地区,域名的其余部分以及端口可能会有所不同。

  1. 我们将使用以下 URL 的略微变体来与 Elastic Cloud 进行通信和身份验证:
https://<username>:<password>@<account-id>.us-west-2.aws.found.io:9243
  1. 目前,我的 URL 是(在您阅读此内容时将被禁用):
https://elastic:tduhdExunhEWPjSuH73O6yLS@d7c72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243
  1. 可以使用 curl 检查基本身份验证和连接:
$ curl https://elastic:tduhdExunhEWPjSuH73O6yLS@7dc72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243
{
  "name": "instance-0000000001",
  "cluster_name": "7dc72d3327076cc4daf5528103c46a27",
  "cluster_uuid": "g9UMPEo-QRaZdIlgmOA7hg",
  "version": {
    "number": "6.1.1",
    "build_hash": "bd92e7f",
    "build_date": "2017-12-17T20:23:25.338Z",
    "build_snapshot": false,
    "lucene_version": "7.1.0",
    "minimum_wire_compatibility_version": "5.6.0",
    "minimum_index_compatibility_version": "5.0.0"
  },
  "tagline": "You Know, for Search"
}
Michaels-iMac-2:pems michaelheydt$

然后我们可以开始交谈了!

使用 Python 连接 Elastic Cloud 集群

现在让我们看看如何使用 Elasticsearch Python 库连接到 Elastic Cloud。

准备工作

此示例的代码位于11/01/elasticcloud_starwars.py脚本中。此脚本将从 swapi.co API/网站中获取 Star Wars 角色数据,并将其放入 Elastic Cloud 中。

如何做

我们按照以下步骤进行:

  1. 将文件作为 Python 脚本执行:
$ python elasticcloud_starwars.py
  1. 这将循环遍历最多 20 个字符,并将它们放入sw索引中,文档类型为people。代码很简单(用您的 URL 替换 URL):
from elasticsearch import Elasticsearch
import requests
import json

if __name__ == '__main__':
    es = Elasticsearch(
        [
            "https://elastic:tduhdExunhEWPjSuH73O6yLS@d7c72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243"
  ])

i = 1 while i<20:
    r = requests.get('http://swapi.co/api/people/' + str(i))
    if r.status_code is not 200:
 print("Got a " + str(r.status_code) + " so stopping")
 break  j = json.loads(r.content)
 print(i, j)
 #es.index(index='sw', doc_type='people', id=i, body=json.loads(r.content))
  i = i + 1
  1. 连接是使用 URL 进行的,用户名和密码添加到其中。数据是使用 GET 请求从 swapi.co 中提取的,然后使用 Elasticsearch 对象上的.index()调用。您将看到类似以下的输出:
1 Luke Skywalker
2 C-3PO
3 R2-D2
4 Darth Vader
5 Leia Organa
6 Owen Lars
7 Beru Whitesun lars
8 R5-D4
9 Biggs Darklighter
10 Obi-Wan Kenobi
11 Anakin Skywalker
12 Wilhuff Tarkin
13 Chewbacca
14 Han Solo
15 Greedo
16 Jabba Desilijic Tiure
Got a 404 so stopping

还有更多...

当您注册 Elastic Cloud 时,您还会获得一个指向 Kibana 的 URL。Kibana 是 Elasticsearch 的强大图形前端:

  1. 在浏览器中打开 URL。您将看到一个登录页面:

Kibana 登录页面

  1. 输入您的用户名和密码,然后您将进入主仪表板:

创建索引模式

我们被要求为我们的应用程序创建一个索引模式:sw 创建的一个索引。在索引模式文本框中,输入sw*,然后按下下一步。

  1. 我们将被要求选择时间过滤器字段名称。选择 I don't want to use the Time Filter,然后按下 Create Index Pattern 按钮。几秒钟后,您将看到创建的索引的确认:

创建的索引

  1. 现在点击 Discover 菜单项,您将进入交互式数据浏览器,在那里您将看到我们刚刚输入的数据:

添加到我们的索引的数据

在这里,您可以浏览数据,看看 Elasticsearch 如何有效地存储和组织这些数据。

使用 Python API 执行 Elasticsearch 查询

现在让我们看看如何使用 Elasticsearch Python 库搜索 Elasticsearch。我们将在 Star Wars 索引上执行简单的搜索。

准备工作

确保在示例中修改连接 URL 为您的 URL。

如何做

搜索的代码在11/02/search_starwars_by_haircolor.py脚本中,只需执行该脚本即可运行。这是一个相当简单的搜索,用于查找头发颜色为blond的角色:

  1. 代码的主要部分是:
es = Elasticsearch(
    [
        "https://elastic:tduhdExunhEWPjSuH73O6yLS@7dc72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243"
  ])

search_definition = {
    "query":{
        "match": {
            "hair_color": "blond"
  }
    }
}

result = es.search(index="sw", doc_type="people", body=search_definition)
print(json.dumps(result, indent=4))
  1. 通过构建表达 Elasticsearch DSL 查询的字典来执行搜索。在这种情况下,我们的查询要求所有文档的"hair_color"属性为"blond"。然后将此对象作为.search方法的 body 参数传递。此方法的结果是描述找到的内容(或未找到的内容)的字典。在这种情况下:
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.3112576,
    "hits": [
      {
        "_index": "sw",
        "_type": "people",
        "_id": "1",
        "_score": 1.3112576,
        "_source": {
          "name": "Luke Skywalker",
          "height": "172",
          "mass": "77",
          "hair_color": "blond",
          "skin_color": "fair",
          "eye_color": "blue",
          "birth_year": "19BBY",
          "gender": "male",
          "homeworld": "https://swapi.co/api/planets/1/",
          "films": [
            "https://swapi.co/api/films/2/",
            "https://swapi.co/api/films/6/",
            "https://swapi.co/api/films/3/",
            "https://swapi.co/api/films/1/",
            "https://swapi.co/api/films/7/"
          ],
          "species": [
            "https://swapi.co/api/species/1/"
          ],
          "vehicles": [
            "https://swapi.co/api/vehicles/14/",
            "https://swapi.co/api/vehicles/30/"
          ],
          "starships": [
            "https://swapi.co/api/starships/12/",
            "https://swapi.co/api/starships/22/"
          ],
          "created": "2014-12-09T13:50:51.644000Z",
          "edited": "2014-12-20T21:17:56.891000Z",
          "url": "https://swapi.co/api/people/1/"
        }
      },
      {
        "_index": "sw",
        "_type": "people",
        "_id": "11",
        "_score": 0.80259144,
        "_source": {
          "name": "Anakin Skywalker",
          "height": "188",
          "mass": "84",
          "hair_color": "blond",
          "skin_color": "fair",
          "eye_color": "blue",
          "birth_year": "41.9BBY",
          "gender": "male",
          "homeworld": "https://swapi.co/api/planets/1/",
          "films": [
            "https://swapi.co/api/films/5/",
            "https://swapi.co/api/films/4/",
            "https://swapi.co/api/films/6/"
          ],
          "species": [
            "https://swapi.co/api/species/1/"
          ],
          "vehicles": [
            "https://swapi.co/api/vehicles/44/",
            "https://swapi.co/api/vehicles/46/"
          ],
          "starships": [
            "https://swapi.co/api/starships/59/",
            "https://swapi.co/api/starships/65/",
            "https://swapi.co/api/starships/39/"
          ],
          "created": "2014-12-10T16:20:44.310000Z",
          "edited": "2014-12-20T21:17:50.327000Z",
          "url": "https://swapi.co/api/people/11/"
        }
      }
    ]
  }
}

结果为我们提供了有关搜索执行的一些元数据,然后是hits属性中的结果。每个命中都会返回实际文档以及索引名称、文档类型、文档 ID 和分数。分数是文档与搜索查询相关性的 lucene 计算。虽然此查询使用属性与值的精确匹配,但您可以看到这两个文档仍然具有不同的分数。我不确定为什么在这种情况下,但搜索也可以不太精确,并基于各种内置启发式来查找“类似”某个句子的项目,也就是说,例如当您在 Google 搜索框中输入文本时。

还有更多...

Elasticsearch 搜索 DSL 和搜索引擎本身非常强大和富有表现力。我们只会在下一个配方中查看这个例子和另一个例子,所以我们不会详细介绍。要了解更多关于 DSL 的信息,您可以从官方文档开始www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html

使用 Elasticsearch 查询具有特定技能的工作

在这个配方中,我们回到使用我们创建的爬虫从 StackOverflow 中爬取和存储工作列表到 Elasticsearch。然后,我们扩展这个功能,查询 Elasticsearch 以找到包含一个或多个指定技能的工作列表。

准备工作

我们将使用一个本地 Elastic Cloud 引擎而不是本地 Elasticsearch 引擎。如果您愿意,您可以更改。现在,我们将在一个本地运行的 Python 脚本中执行此过程,而不是在容器内或在 API 后面执行。

如何做到这一点

我们按照以下步骤进行:

  1. 该配方的代码位于11/03/search_jobs_by_skills.py文件中。
from sojobs.scraping import get_job_listing_info
from elasticsearch import Elasticsearch
import json

if __name__ == "__main__":

    es = Elasticsearch()

    job_ids = ["122517", "163854", "138222", "164641"]

    for job_id in job_ids:
        if not es.exists(index='joblistings', doc_type='job-listing', id=job_id):
            listing = get_job_listing_info(job_id)
            es.index(index='joblistings', doc_type='job-listing', id=job_id, body=listing)

    search_definition = {
        "query": {
            "match": {
                "JSON.skills": {
                    "query": "c#"   }
            }
        }
    }

    result = es.search(index="joblistings", doc_type="job-listing", body=search_definition)
    print(json.dumps(result, indent=4))

这段代码的第一部分定义了四个工作列表,如果它们尚不可用,则将它们放入 Elasticsearch 中。它遍历了这个工作的 ID,如果尚未可用,则检索它们并将它们放入 Elasticsearch 中。

其余部分定义了要针对 Elasticsearch 执行的查询,并遵循相同的模式来执行搜索。唯一的区别在于搜索条件的定义。最终,我们希望将一系列工作技能与工作列表中的技能进行匹配。

这个查询只是将单个技能与我们的工作列表文档中的技能字段进行匹配。示例指定我们要匹配目标文档中的 JSON.skills 属性。这些文档中的技能就在文档的根部下面,所以在这个语法中我们用 JSON 作为前缀。

Elasticsearch 中的这个属性是一个数组,我们的查询值将匹配该属性数组中的任何一个值为"c#"的文档。

  1. 在 Elasticsearch 中只使用这四个文档运行此搜索将产生以下结果(这里的输出只显示结果,而不是返回的四个文档的完整内容):
{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.031828,
    "hits": [

放入 Elasticsearch 的每个工作都有 C#作为技能(我随机选择了这些文档,所以这有点巧合)。

  1. 这些搜索的结果返回了每个被识别的文档的全部内容。如果我们不希望每次命中都返回整个文档,我们可以更改查询以实现这一点。让我们修改查询,只返回命中的 ID。将search_definition变量更改为以下内容:
search_definition = {
    "query": {
        "match": {
            "JSON.skills": {
                "query": "c# sql"
  }
        }
    },
    "_source": ["ID"]
}
  1. 包括"_source"属性告诉 Elasticsearch 在结果中返回指定的文档属性。执行此查询将产生以下输出:
{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.031828,
    "hits": [
      {
        "_index": "joblistings",
        "_type": "job-listing",
        "_id": "164641",
        "_score": 1.031828,
        "_source": {
          "ID": "164641"
        }
      },
      {
        "_index": "joblistings",
        "_type": "job-listing",
        "_id": "122517",
        "_score": 0.9092852,
        "_source": {
          "ID": "122517"
        }
      }
    ]
  }
}

现在,每个命中只返回文档的 ID 属性。如果有很多命中,这将有助于控制结果的大小。

  1. 让我们来到这个配方的最终目标,识别具有多种技能的文档。这实际上是对search_defintion进行了一个非常简单的更改:
search_definition={
  "query": {
    "match": {
      "JSON.skills": {
        "query": "c# sql",
        "operator": "AND"
      }
    }
  },
  "_source": [
    "ID"
  ]
}

这说明我们只想要包含"c#""sql"两个技能的文档。然后运行脚本的结果如下:

{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.031828,
    "hits": [
      {
        "_index": "joblistings",
        "_type": "job-listing",
        "_id": "164641",
        "_score": 1.031828,
        "_source": {
          "ID": "164641"
        }
      },
      {
        "_index": "joblistings",
        "_type": "job-listing",
        "_id": "122517",
        "_score": 0.9092852,
        "_source": {
          "ID": "122517"
        }
      }
    ]
  }
}

结果集现在减少到两个命中,如果您检查,这些是唯一具有这些技能值的两个。

修改 API 以按技能搜索工作

在这个配方中,我们将修改我们现有的 API,添加一个方法来搜索具有一组技能的工作。

如何做到这一点

我们将扩展 API 代码。 我们将对 API 的实现进行两个基本更改。 第一个是我们将为搜索功能添加一个额外的 Flask-RESTful API 实现,第二个是我们将 Elasticsearch 和我们自己的微服务的地址都可通过环境变量进行配置。

API 实现在11/04_scraper_api.py中。 默认情况下,该实现尝试连接到本地系统上的 Elasticsearch。 如果您正在使用 Elastic Cloud,请确保更改 URL(并确保索引中有文档):

  1. 可以通过简单执行脚本来启动 API:
$ python scraper_api.py
Starting the job listing API ...
 * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit)
 * Restarting with stat
Starting the job listing API ...
 * Debugger is active!
 * Debugger pin code: 449-370-213
  1. 要进行搜索请求,我们可以向/joblistings/search端点进行 POST,以"skills=<用空格分隔的技能>"的形式传递数据。 以下是使用 C#和 SQL 进行作业搜索的示例:
$ curl localhost:8080/joblistings/search -d "skills=c# sql"
{
  "took": 4,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1.031828,
    "hits": [
      {
        "_index": "joblistings",
        "_type": "job-listing",
        "_id": "164641",
        "_score": 1.031828,
        "_source": {
          "ID": "164641"
        }
      },
      {
        "_index": "joblistings",
        "_type": "job-listing",
        "_id": "122517",
        "_score": 0.9092852,
        "_source": {
          "ID": "122517"
        }
      }
    ]
  }
}

我们得到了在上一个食谱中看到的结果。 现在我们已经通过互联网实现了我们的搜索功能!

工作原理

这通过添加另一个 Flask-RESTful 类实现来实现:

class JobSearch(Resource):
    def post(self):
        skills = request.form['skills']
        print("Request for jobs with the following skills: " + skills)

        host = 'localhost'
  if os.environ.get('ES_HOST'):
            host = os.environ.get('ES_HOST')
        print("ElasticSearch host: " + host)

        es = Elasticsearch(hosts=[host])
        search_definition = {
            "query": {
                "match": {
                    "JSON.skills": {
                        "query": skills,
                        "operator": "AND"
  }
                }
            },
            "_source": ["ID"]
        }

        try:
            result = es.search(index="joblistings", doc_type="job-listing", body=search_definition)
            print(result)
            return result

        except:
            return sys.exc_info()[0]

api.add_resource(JobSearch, '/', '/joblistings/search')

这个类实现了一个 post 方法,作为映射到/joblistings/search的资源。 进行 POST 操作的原因是我们传递了一个由多个单词组成的字符串。 虽然这可以在 GET 操作中进行 URL 编码,但 POST 允许我们将其作为键值传递。 虽然我们只有一个键,即 skills,但未来扩展到其他键以支持其他搜索参数可以简单地添加。

还有更多...

从 API 实现中执行搜索的决定是应该在系统发展时考虑的。 这是我的观点,仅仅是我的观点(但我认为其他人会同意),就像 API 调用实际的爬取微服务一样,它也应该调用一个处理搜索的微服务(然后该微服务将与 Elasticsearch 进行接口)。 这也适用于存储从爬取微服务返回的文档,以及访问 Elasticsearch 以检查缓存文档。 但出于我们在这里的目的,我们将尽量保持简单。

在环境中存储配置

这个食谱指出了在上一个食谱中对 API 代码进行的更改,以支持12-Factor应用程序的一个因素。 12-Factor 应用程序被定义为设计为软件即服务运行的应用程序。 我们已经在这个方向上移动了一段时间的爬虫,将其分解为可以独立运行的组件,作为脚本或容器运行,并且很快我们将看到,作为云中的组件。 您可以在12factor.net/上了解有关 12-Factor 应用程序的所有信息。

Factor-3 指出我们应该通过环境变量将配置传递给我们的应用程序。 虽然我们绝对不希望硬编码诸如外部服务的 URL 之类的东西,但使用配置文件也不是最佳实践。 在部署到各种环境(如容器或云)时,配置文件通常会固定在镜像中,并且无法根据应用程序动态部署到不同环境而随需求更改。

修复此问题的最佳方法是始终查找环境变量中的配置设置,这些设置可以根据应用程序的运行方式而改变。 大多数用于运行 12-Factor 应用程序的工具允许根据环境决定应用程序应该在何处以及如何运行来设置环境变量。

如何做到这一点

在我们的工作列表实现中,我们使用以下代码来确定 Elasticsearch 的主机:

host = 'localhost'
if os.environ.get('ES_HOST'):
    host = os.environ.get('ES_HOST')
print("ElasticSearch host: " + host)

es = Elasticsearch(hosts=[host])

这是一个简单直接的操作,但对于使我们的应用程序在不同环境中具有极高的可移植性非常重要。 默认情况下使用 localhost,但让我们使用ES_HOST环境变量定义不同的主机。

技能搜索的实现也进行了类似的更改,以允许我们更改我们的爬虫微服务的本地主机的默认值:

CONFIG = {'AMQP_URI': "amqp://guest:guest@localhost"}
if os.environ.get('JOBS_AMQP_URL'):
    CONFIG['AMQP_URI'] = os.environ.get('JOBS_AMQP_URL')
print("AMQP_URI: " + CONFIG["AMQP_URI"])

with ClusterRpcProxy(CONFIG) as rpc:

我们将在接下来的教程中看到 Factor-3 的使用,当我们将这段代码移到 AWS 的弹性容器服务时。

创建用于 ECS 的 AWS IAM 用户和密钥对

在这个教程中,我们将创建一个身份和访问管理(IAM)用户账户,以允许我们访问 AWS 弹性容器服务(ECS)。我们需要这个,因为我们将把我们的爬虫和 API 打包到 Docker 容器中(我们已经做过了),但现在我们将把这些容器移到 AWS ECS 并在那里运行它们,使我们的爬虫成为一个真正的云服务。

准备就绪

这假设你已经创建了一个 AWS 账户,我们在之前的章节中使用过它,当我们查看 SQS 和 S3 时。你不需要另一个账户,但我们需要创建一个非根用户,该用户具有使用 ECS 的权限。

操作步骤

有关如何创建具有 ECS 权限和密钥对的 IAM 用户的说明可以在docs.aws.amazon.com/AmazonECS/latest/developerguide/get-set-up-for-amazon-ecs.html找到。

这个页面上有很多说明,比如设置 VPC 和安全组。现在只关注创建用户、分配权限和创建密钥对。

我想要强调的一件事是你创建的 IAM 账户的权限。在docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html上有关于如何做这个的详细说明。我曾经见过这样的操作没有做好。只需确保当你检查刚刚创建的用户的权限时,以下权限已经被分配:

AWS IAM 凭证

我直接将这些附加到我用于 ECS 的账户上,而不是通过组。如果没有分配这些,当推送容器到 ECR 时会出现加密的身份验证错误。

还有一件事:我们需要访问密钥 ID 和相关的密钥。这将在创建用户时呈现给你。如果你没有记录下来,你可以在用户账户页面的安全凭证选项卡中创建另一个:

请注意,无法获取已存在的访问密钥 ID 的密钥。你需要创建另一个。

配置 Docker 以便与 ECR 进行身份验证

在这个教程中,我们将配置 Docker 以便能够将我们的容器推送到弹性容器仓库(ECR)。

准备就绪

Docker 的一个关键元素是 Docker 容器仓库。我们之前使用 Docker Hub 来拉取容器。但我们也可以将我们的容器推送到 Docker Hub,或者任何兼容 Docker 的容器仓库,比如 ECR。但这并不是没有问题的。docker CLI 并不自然地知道如何与 ECR 进行身份验证,所以我们需要做一些额外的工作来让它能够工作。

确保安装了 AWS 命令行工具。这些工具是必需的,用于让 Docker 能够与 ECR 进行身份验证。在docs.aws.amazon.com/cli/latest/userguide/installing.html上有很好的说明。安装验证通过后,你需要配置 CLI 以使用前面教程中创建的账户。这可以通过aws configure命令来完成,该命令会提示你输入四个项目:

$ aws configure
AWS Access Key ID [None]: AKIA---------QKCVQAA
AWS Secret Access Key [None]: KEuSaLgn4dpyXe-------------VmEKdhV
Default region name [None]: us-west-2
Default output format [None]: json

将密钥替换为之前检索到的密钥,并设置默认区域和数据类型。

操作步骤

我们按照以下步骤进行教程:

  1. 执行以下命令。这将返回一个命令,用于对接 Docker 和 ECR 进行身份验证:
$ aws ecr get-login --no-include-email --region us-west-2 docker login -u AWS -p eyJwYXlsb2FkIjoiN3BZVWY4Q2JoZkFwYUNKOUp6c1BkRy80VmRYN0Y2LzQ0Y2pVNFJKZTA5alBrUEdSMHlNUk9TMytsTFVURGtxb3Q5VTZqV0xxNmRCVHJnL1FIb2lGbEF0dVZhNFpEOUkxb1FxUTNwcUluaVhqS1FCZmU2WTRLNlQrbjE4VHdiOEpqbmtwWjJJek8xRlR2Y2Y5S3NGRlQrbDZhcktUNXZJbjNkb1czVGQ2TXZPUlg5cE5Ea2w4S29vamt6SE10Ym8rOW5mLzBvVkRRSDlaY3hqRG45d0FzNVA5Z1BPVUU5OVFrTEZGeENPUHJRZmlTeHFqaEVPcGo3ZVAxL3pCNnFTdjVXUEozaUNtV0I0b1lFNEcyVzA4M2hKQmpESUFTV1VMZ1B0MFI2YUlHSHJxTlRvTGZOR1R5clJ2VUZKcnFWZGptMkZlR0ppK3I5emFrdGFKeDJBNVRCUzBzZDZaOG1yeW1Nd0dBVi81NDZDeU1XYVliby9reWtaNUNuZE8zVXFHdHFKSnJmQVRKakhlVU1jTXQ1RjE0Tk83OWR0ckNnYmZmUHdtS1hXOVh6MklWUG5VUlJsekRaUjRMMVFKT2NjNlE0NWFaNkR2enlDRWw1SzVwOEcvK3lSMXFPYzdKUWpxaUErdDZyaCtDNXJCWHlJQndKRm5mcUJhaVhBMVhNMFNocmlNd0FUTXFjZ0NtZTEyUGhOMmM2c0pNTU5hZ0JMNEhXSkwyNXZpQzMyOVI2MytBUWhPNkVaajVMdG9iMVRreFFjbjNGamVNdThPM0ppZnM5WGxPSVJsOHlsUUh0LzFlQ2ZYelQ1cVFOU2g1NjFiVWZtOXNhNFRRWlhZUlNLVVFrd3JFK09EUXh3NUVnTXFTbS9FRm1PbHkxdEpncXNzVFljeUE4Y1VYczFnOFBHL2VwVGtVTG1ReFYwa0p5MzdxUmlIdHU1OWdjMDRmZWFSVGdSekhQcXl0WExzdFpXcTVCeVRZTnhMeVVpZW0yN3JkQWhmaStpUHpMTXV1NGZJa3JjdmlBZFF3dGwrdEVORTNZSVBhUnZJMFN0Q1djN2J2blI2Njg3OEhQZHJKdXlYaTN0czhDYlBXNExOamVCRm8waUt0SktCckJjN0tUZzJEY1d4NlN4b1Vkc2ErdnN4V0N5NWFzeWdMUlBHYVdoNzFwOVhFZWpPZTczNE80Z0l5RklBU0pHR3o1SVRzYVkwbFB6ajNEYW9QMVhOT3dhcDYwcC9Gb0pQMG1ITjNsb202eW1EaDA0WEoxWnZ0K0lkMFJ4bE9lVUt3bzRFZFVMaHJ2enBMOUR4SGI5WFFCMEdNWjFJRlI0MitSb3NMaDVQa0g1RHh1bDJZU0pQMXc0UnVoNUpzUm5rcmF3dHZzSG5PSGd2YVZTeWl5bFR0cFlQY1haVk51NE5iWnkxSzQwOG5XTVhiMFBNQzJ5OHJuNlpVTDA9IiwiZGF0YWtleSI6IkFRRUJBSGo2bGM0WElKdy83bG4wSGMwMERNZWs2R0V4SENiWTRSSXBUTUNJNThJblV3QUFBSDR3ZkFZSktvWklodmNOQVFjR29HOHdiUUlCQURCb0Jna3Foa2lHOXcwQkJ3RXdIZ1lKWUlaSUFXVURCQUV1TUJFRURQdTFQVXQwRDFkN3c3Rys3Z0lCRUlBN21Xay9EZnNOM3R5MS9iRFdRYlZtZjdOOURST2xhQWFFbTBFQVFndy9JYlBjTzhLc0RlNDBCLzhOVnR0YmlFK1FXSDBCaTZmemtCbzNxTkE9IiwidmVyc2lvbiI6IjIiLCJ0eXBlIjoiREFUQV9LRVkiLCJleHBpcmF0aW9uIjoxNTE1NjA2NzM0fQ== https://270157190882.dkr.ecr.us-west-2.amazonaws.com

这个输出是一个命令,你需要执行它来让你的 docker CLI 与 ECR 进行身份验证!这个密钥只在几个小时内有效(我相信是十二小时)。你可以从docker login开始的位置复制所有内容,一直到密钥末尾的 URL。

  1. 在 Mac(和 Linux)上,我通常简化为以下步骤:
$(aws ecr get-login --no-include-email --region us-west-2)
WARNING! Using --password via the CLI is insecure. Use --password-stdin.
Login Succeeded

更容易。在这一点上,我们可以使用 docker 命令将容器推送到 ECR。

这是我见过的一些问题的地方。我发现密钥末尾的 URL 可能仍然是根用户,而不是您为 ECR 创建的用户(此登录必须是该用户)。如果是这种情况,后续命令将出现奇怪的身份验证问题。解决方法是删除所有 AWS CLI 配置文件并重新配置。这种解决方法并不总是有效。有时候,我不得不使用一个全新的系统/虚拟机,通过 AWS CLI 安装/配置,然后生成这个密钥才能使其工作。

将容器推送到 ECR

在这个食谱中,我们将重建我们的 API 和微服务容器,并将它们推送到 ECR。我们还将 RabbitMQ 容器推送到 ECR。

准备就绪

请耐心等待,因为这可能会变得棘手。除了我们的容器镜像之外,我们还需要将 RabbitMQ 容器推送到 ECR。ECS 无法与 Docker Hub 通信,也无法拉取该镜像。这将非常方便,但同时也可能是一个安全问题。

从家庭互联网连接推送这些容器到 ECR 可能需要很长时间。我在 EC2 中创建了一个与我的 ECR 相同地区的 Linux 镜像,从 github 上拉取了代码,在那台 EC2 系统上构建了容器,然后推送到 ECR。如果不是几秒钟的话,推送只需要几分钟。

首先,让我们在本地系统上重建我们的 API 和微服务容器。我已经在11/05食谱文件夹中包含了 Python 文件、两个 docker 文件和微服务的配置文件。

让我们从构建 API 容器开始:

$ docker build ../.. -f Dockerfile-api -t scraper-rest-api:latest

这个 docker 文件与之前的 API Docker 文件类似,只是修改了从11/05文件夹复制文件的部分。

FROM python:3
WORKDIR /usr/src/app

RUN pip install Flask-RESTful Elasticsearch Nameko
COPY 11/11/scraper_api.py .

CMD ["python", "scraper_api.py"]

然后构建 scraper 微服务的容器:

$ docker build ../.. -f Dockerfile-microservice -t scraper-microservice:latest

这个 Dockerfile 与微服务的 Dockerfile 略有不同。它的内容如下:

FROM python:3
WORKDIR /usr/src/app

RUN pip install nameko BeautifulSoup4 nltk lxml
RUN python -m nltk.downloader punkt -d /usr/share/nltk_data all

COPY 11/05/scraper_microservice.py .
COPY modules/sojobs sojobs

CMD ["python", "-u", "scraper_microservice.py"]

现在我们准备好配置 ECR 来存储我们的容器,供 ECS 使用。

我们现在使用 python 而不是“nameko run”命令来运行微服务。这是由于 ECS 中容器启动顺序的问题。如果 RabbitMQ 服务器尚未运行,“nameko run”命令的性能不佳,而在 ECS 中无法保证 RabbitMQ 服务器已经运行。因此,我们使用 python 启动。因此,该实现具有一个启动,基本上是复制“nameko run”的代码,并用 while 循环和异常处理程序包装它,直到容器停止。

如何操作

我们按照以下步骤进行:

  1. 登录到我们为 ECS 创建的帐户后,我们可以访问弹性容器仓库。这项服务可以保存我们的容器供 ECS 使用。有许多 AWS CLI 命令可以用来处理 ECR。让我们从列出现有仓库的以下命令开始:
$ aws ecr describe-repositories
{
    "repositories": []
}
  1. 现在我们还没有任何仓库,让我们创建一些。我们将创建三个仓库,分别用于不同的容器:scraper-rest-api、scraper-microservice,以及一个 RabbitMQ 容器,我们将其命名为rabbitmq。每个仓库都映射到一个容器,但可以有多个标签(每个最多有 1,000 个不同的版本/标签)。让我们创建这三个仓库:
$ aws ecr create-repository --repository-name scraper-rest-api
{
  "repository": {
    "repositoryArn": "arn:aws:ecr:us-west-2:414704166289:repository/scraper-rest-api",
    "repositoryUri": "414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api",
    "repositoryName": "scraper-rest-api",
    "registryId": "414704166289",
    "createdAt": 1515632756.0
  }
}

05 $ aws ecr create-repository --repository-name scraper-microservice
{
  "repository": {
    "repositoryArn": "arn:aws:ecr:us-west-2:414704166289:repository/scraper-microservice",
    "registryId": "414704166289",
    "repositoryName": "scraper-microservice",
    "repositoryUri": "414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice",
    "createdAt": 1515632772.0
  }
}

05 $ aws ecr create-repository --repository-name rabbitmq
{
  "repository": {
    "repositoryArn": "arn:aws:ecr:us-west-2:414704166289:repository/rabbitmq",
    "repositoryName": "rabbitmq",
    "registryId": "414704166289",
    "createdAt": 1515632780.0,
    "repositoryUri": "414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq"
  }
}

注意返回的数据。我们需要在接下来的步骤中使用每个仓库的 URL。

  1. 我们需要标记我们的本地容器镜像,以便它们的 docker 知道当我们推送它们时,它们应该去我们 ECR 中的特定仓库。此时,您的 docker 中应该有以下镜像:
$ docker images
REPOSITORY           TAG          IMAGE ID     CREATED        SIZE
scraper-rest-api     latest       b82653e11635 29 seconds ago 717MB
scraper-microservice latest       efe19d7b5279 11 minutes ago 4.16GB
rabbitmq             3-management 6cb6e2f951a8 2 weeks ago    151MB
python               3            c1e459c00dc3 3 weeks ago    692MB
  1. 使用<image-id> <ECR-repository-uri> docker tag 进行标记。让我们标记所有三个(我们不需要对 python 镜像进行操作):
$ docker tag b8 414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api

$ docker tag ef 414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice

$ docker tag 6c 414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq
  1. 现在的 docker 镜像列表中显示了标记的镜像以及原始镜像:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api latest b82653e11635 4 minutes ago 717MB
scraper-rest-api latest b82653e11635 4 minutes ago 717MB
414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice latest efe19d7b5279 15 minutes ago 4.16GB
scraper-microservice latest efe19d7b5279 15 minutes ago 4.16GB
414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq latest 6cb6e2f951a8 2 weeks ago 151MB
rabbitmq 3-management 6cb6e2f951a8 2 weeks ago 151MB
python 3 c1e459c00dc3 3 weeks ago 692MB
  1. 现在我们最终将镜像推送到 ECR:
$ docker push 414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api
The push refers to repository [414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api]
7117db0da9a9: Pushed
8eb1be67ed26: Pushed
5fcc76c4c6c0: Pushed
6dce5c484bde: Pushed
057c34df1f1a: Pushed
3d358bf2f209: Pushed
0870b36b7599: Pushed
8fe6d5dcea45: Pushed
06b8d020c11b: Pushed
b9914afd042f: Pushed
4bcdffd70da2: Pushed
latest: digest: sha256:2fa2ccc0f4141a1473386d3592b751527eaccb37f035aa08ed0c4b6d7abc9139 size: 2634

$ docker push 414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice
The push refers to repository [414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice]
3765fccaf6a6: Pushed
4bde7a8212e1: Pushed
d0aa245987b4: Pushed
5657283a8f79: Pushed
4f33694fe63a: Pushed
5fcc76c4c6c0: Pushed
6dce5c484bde: Pushed
057c34df1f1a: Pushed
3d358bf2f209: Pushed
0870b36b7599: Pushed
8fe6d5dcea45: Pushed
06b8d020c11b: Pushed
b9914afd042f: Pushed
4bcdffd70da2: Pushed
latest: digest: sha256:02c1089689fff7175603c86d6ef8dc21ff6aaffadf45735ef754f606f2cf6182 size: 3262

$ docker push 414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq
The push refers to repository [414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq]
e38187f05202: Pushed
ea37471972cd: Pushed
2f1d47e88a53: Pushed
e8c84964de08: Pushed
d0537ac3fb13: Pushed
9f345d60d035: Pushed
b45610229549: Pushed
773afacc96cc: Pushed
5eb8d21fccbb: Pushed
10699a5bd960: Pushed
27be686b9e1f: Pushed
96bfbdb03e1c: Pushed
1709335ba200: Pushed
2ec5c0a4cb57: Pushed
latest: digest: sha256:74308ef1dabc1a0b9615f756d80f5faf388f4fb038660ae42f437be45866b65e size: 3245
  1. 现在检查镜像是否已经到达仓库。以下是scraper-rest-api的情况:
$ aws ecr list-images --repository-name scraper-rest-api
{
  "imageIds": [
    {
      "imageTag": "latest",
      "imageDigest": "sha256:2fa2ccc0f4141a1473386d3592b751527eaccb37f035aa08ed0c4b6d7abc9139"
    }
  ]
}

现在我们的容器已经存储在 ECR 中,我们可以继续创建一个集群来运行我们的容器。

创建一个 ECS 集群

弹性容器服务(ECS)是 AWS 在云中运行 Docker 容器的服务。使用 ECS 有很多强大的功能(和细节)。我们将看一个简单的部署,它在单个 EC2 虚拟机上运行我们的容器。我们的目标是将我们的爬虫放到云中。关于使用 ECS 扩展爬虫的详细信息将在另一个时间(和书籍)中介绍。

如何做到

我们首先使用 AWS CLI 创建一个 ECR 集群。然后我们将在集群中创建一个 EC2 虚拟机来运行我们的容器。

我在11/06文件夹中包含了一个 shell 文件,名为create-cluster-complete.sh,它可以一次运行所有这些命令。

有许多步骤需要进行配置,但它们都相当简单。让我们一起走过它们:

  1. 以下创建了一个名为 scraper-cluster 的 ECR 集群:
$ aws ecs create-cluster --cluster-name scraper-cluster
{
  "cluster": {
    "clusterName": "scraper-cluster",
    "registeredContainerInstancesCount": 0,
    "clusterArn": "arn:aws:ecs:us-west-2:414704166289:cluster/scraper-cluster",
    "status": "ACTIVE",
    "activeServicesCount": 0,
    "pendingTasksCount": 0,
    "runningTasksCount": 0
  }
}

哇,这太容易了!嗯,还有一些细节要处理。在这一点上,我们没有任何 EC2 实例来运行容器。我们还需要设置密钥对、安全组、IAM 策略,哎呀!看起来很多,但我们将很快、很容易地完成它。

  1. 创建一个密钥对。每个 EC2 实例都需要一个密钥对来启动,并且需要远程连接到实例(如果您想要的话)。以下是创建一个密钥对,将其放入本地文件,然后与 AWS 确认它已创建:
$ aws ec2 create-key-pair --key-name ScraperClusterKP --query 'KeyMaterial' --output text > ScraperClusterKP.pem

$ aws ec2 describe-key-pairs --key-name ScraperClusterKP
{
  "KeyPairs": [
    {
      "KeyFingerprint": "4a:8a:22:fa:53:a7:87:df:c5:17:d9:4f:b1:df:4e:22:48:90:27:2d",
      "KeyName": "ScraperClusterKP"
    }
  ]
}
  1. 现在我们创建安全组。安全组允许我们从互联网打开端口到集群实例,因此允许我们访问运行在我们的容器中的应用程序。我们将创建一个安全组,其中包括端口 22(ssh)和 80(http),以及 RabbitMQ 的两个端口(5672 和 15672)被打开。我们需要打开 80 端口以与 REST API 进行通信(我们将在下一个步骤中将 80 映射到 8080 容器)。我们不需要打开 15672 和 5672 端口,但它们有助于通过允许您从 AWS 外部连接到 RabbitMQ 来调试该过程。以下四个命令创建了安全组和该组中的规则:
$ aws  ec2  create-security-group  --group-name  ScraperClusterSG  --description  "Scraper Cluster SG”
{
  "GroupId": "sg-5e724022"
} 
$ aws ec2 authorize-security-group-ingress --group-name ScraperClusterSG --protocol tcp --port 22 --cidr 0.0.0.0/0

$ aws ec2 authorize-security-group-ingress --group-name ScraperClusterSG --protocol tcp --port 80 --cidr 0.0.0.0/0

$ aws ec2 authorize-security-group-ingress --group-name ScraperClusterSG --protocol tcp --port 5672 --cidr 0.0.0.0/0

$ aws ec2 authorize-security-group-ingress --group-name ScraperClusterSG --protocol tcp --port 15672 --cidr 0.0.0.0/0

您可以使用 aws ec2 describe-security-groups --group-names ScraperClusterSG 命令确认安全组的内容。这将输出该组的 JSON 表示。

  1. 要将 EC2 实例启动到 ECS 集群中,需要放置一个 IAM 策略,以允许它进行连接。它还需要具有与 ECR 相关的各种能力,例如拉取容器。这些定义在配方目录中包含的两个文件ecsPolicy.jsonrolePolicy.json中。以下命令将这些策略注册到 IAM(输出被省略):
$ aws iam create-role --role-name ecsRole --assume-role-policy-document file://ecsPolicy.json

$ aws  iam  put-role-policy  --role-name  ecsRole  --policy-name  ecsRolePolicy  --policy-document  file://rolePolicy.json

$ aws iam create-instance-profile --instance-profile-name ecsRole 
$ aws iam add-role-to-instance-profile --instance-profile-name ecsRole --role-name ecsRole

在启动实例之前,我们需要做一件事。我们需要有一个文件将用户数据传递给实例,告诉实例连接到哪个集群。如果我们不这样做,它将连接到名为default而不是scraper-cluster的集群。这个文件是userData.txt在配方目录中。这里没有真正的操作,因为我提供了这个文件。

  1. 现在我们在集群中启动一个实例。我们需要使用一个经过优化的 ECS AMI 或创建一个带有 ECS 容器代理的 AMI。我们将使用一个带有此代理的预构建 AMI。以下是启动实例的步骤:
$ aws ec2 run-instances --image-id ami-c9c87cb1 --count 1 --instance-type m4.large --key-name ScraperClusterKP --iam-instance-profile "Name= ecsRole" --security-groups ScraperClusterSG --user-data file://userdata.txt

这将输出描述您的实例的一些 JSON。

  1. 几分钟后,您可以检查此实例是否在容器中运行:
$ aws ecs list-container-instances --cluster scraper-cluster
{
  "containerInstanceArns": [
    "arn:aws:ecs:us-west-2:414704166289:container-instance/263d9416-305f-46ff-a344-9e7076ca352a"
  ]
}

太棒了!现在我们需要定义要在容器实例上运行的任务。

这是一个 m4.large 实例。它比适用于免费层的 t2.micro 大一点。因此,如果您想保持成本低廉,请确保不要让它长时间运行。

创建一个运行我们的容器的任务

在这个步骤中,我们将创建一个 ECS 任务。任务告诉 ECR 集群管理器要运行哪些容器。任务是对要在 ECR 中运行的容器以及每个容器所需的参数的描述。任务描述会让我们联想到我们使用 Docker Compose 所做的事情。

准备工作

任务定义可以使用 GUI 构建,也可以通过提交任务定义 JSON 文件来启动。我们将使用后一种技术,并检查文件td.json的结构,该文件描述了如何一起运行我们的容器。此文件位于11/07配方文件夹中。

操作步骤

以下命令将任务注册到 ECS:

$ aws ecs register-task-definition --cli-input-json file://td.json
{
  "taskDefinition": {
    "volumes": [

    ],
    "family": "scraper",
    "memory": "4096",
    "placementConstraints": [

    ]
  ],
  "cpu": "1024",
  "containerDefinitions": [
    {
      "name": "rabbitmq",
      "cpu": 0,
      "volumesFrom": [

      ],
      "mountPoints": [

      ],
      "portMappings": [
        {
          "hostPort": 15672,
          "protocol": "tcp",
          "containerPort": 15672
        },
        {
          "hostPort": 5672,
          "protocol": "tcp",
          "containerPort": 5672
        }
      ],
      "environment": [

      ],
      "image": "414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq",
      "memory": 256,
      "essential": true
    },
    {
      "name": "scraper-microservice",
      "cpu": 0,
      "essential": true,
      "volumesFrom": [

      ],
      "mountPoints": [

      ],
      "portMappings": [

      ],
      "environment": [
        {
          "name": "AMQP_URI",
          "value": "pyamqp://guest:guest@rabbitmq"
        }
      ],
      "image": "414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice",
      "memory": 256,
      "links": [
        "rabbitmq"
      ]
    },
    {
      "name": "api",
      "cpu": 0,
      "essential": true,
      "volumesFrom": [

      ],
      "mountPoints": [

      ],
      "portMappings": [
        {
          "hostPort": 80,
          "protocol": "tcp",
          "containerPort": 8080
        }
      ],
      "environment": [
        {
          "name": "AMQP_URI",
          "value": "pyamqp://guest:guest@rabbitmq"
        },
        {
          "name": "ES_HOST",
          "value": "https://elastic:tduhdExunhEWPjSuH73O6yLS@7dc72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243"
        }
      ],
      "image": "414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api",
      "memory": 128,
      "links": [
        "rabbitmq"
      ]
    }
  ],
  "requiresCompatibilities": [
    "EC2"
  ],
  "status": "ACTIVE",
  "taskDefinitionArn": "arn:aws:ecs:us-west-2:414704166289:task-definition/scraper:7",
  "requiresAttributes": [
    {
      "name": "com.amazonaws.ecs.capability.ecr-auth"
    }
  ],
  "revision": 7,
  "compatibilities": [
    "EC2"
  ]
}

输出是由 ECS 填写的任务定义,并确认接收到任务定义。

它是如何工作的

任务定义由两个主要部分组成。第一部分提供有关整体任务的一些一般信息,例如为整个容器允许多少内存和 CPU。然后它包括一个定义我们将运行的三个容器的部分。

文件以定义整体设置的几行开头:

{
    "family": "scraper-as-a-service",
  "requiresCompatibilities": [
        "EC2"
  ],
  "cpu": "1024",
  "memory": "4096",
  "volumes": [], 

任务的实际名称由"family"属性定义。我们声明我们的容器需要 EC2(任务可以在没有 EC2 的情况下运行-我们的任务需要它)。然后我们声明我们希望将整个任务限制为指定的 CPU 和内存量,并且我们不附加任何卷。

现在让我们来看一下定义容器的部分。它以以下内容开始:

"containerDefinitions": [

现在让我们逐个检查每个容器的定义。以下是rabbitmq容器的定义:

{
    "name": "rabbitmq",
  "image": "414704166289.dkr.ecr.us-west-2.amazonaws.com/rabbitmq",   "cpu": 0,
  "memory": 256,
  "portMappings": [
        {
            "containerPort": 15672,
  "hostPort": 15672,
  "protocol": "tcp"
  },
  {
            "containerPort": 5672,
  "hostPort": 5672,
  "protocol": "tcp"
  }
    ],
  "essential": true },

第一行定义了容器的名称,此名称还参与 API 和 scraper 容器通过 DNS 解析此容器的名称。图像标签定义了要为容器拉取的 ECR 存储库 URI。

确保将此容器和其他两个容器的图像 URL 更改为您的存储库的图像 URL。

接下来是定义允许为此容器分配的最大 CPU(0 表示无限)和内存。端口映射定义了容器主机(我们在集群中创建的 EC2 实例)和容器之间的映射。我们映射了两个 RabbitMQ 端口。

基本标签表示此容器必须保持运行。如果失败,整个任务将被停止。

接下来定义的容器是 scraper 微服务:

{
    "name": "scraper-microservice",
  "image": "414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-microservice",
  "cpu": 0,
  "memory": 256,
  "essential": true,
  "environment": [
        {
            "name": "AMQP_URI",
  "value": "pyamqp://guest:guest@rabbitmq"
  }
    ],
  "links": [
        "rabbitmq"
  ]
},

这与具有环境变量和链接定义的不同。环境变量是rabbitmq容器的 URL。ECS 将确保在此容器中将环境变量设置为此值(实现 Factor-3)。虽然这与我们在本地使用 docker compose 运行时的 URL 相同,但如果rabbitmq容器的名称不同或在另一个集群上,它可能是不同的 URL。

链接设置需要一点解释。链接是 Docker 的一个已弃用功能,但在 ECS 中仍在使用。在 ECS 中,它们是必需的,以便容器解析同一集群网络中其他容器的 DNS 名称。这告诉 ECS,当此容器尝试解析rabbitmq主机名(如环境变量中定义的那样)时,它应返回分配给该容器的 IP 地址。

文件的其余部分定义了 API 容器:

{
  "name": "api",
  "image": "414704166289.dkr.ecr.us-west-2.amazonaws.com/scraper-rest-api",
  "cpu": 0,
  "memory": 128,
  "essential": true,
  "portMappings": [
    {
      "containerPort": 8080,
      "hostPort": 80,
      "protocol": "tcp"
    }
  ],
  "environment": [
    {
      "name": "AMQP_URI",
      "value": "pyamqp://guest:guest@rabbitmq"
    },
    {
      "name": "ES_HOST",
      "value": "https://elastic:tduhdExunhEWPjSuH73O6yLS@7dc72d3327076cc4daf5528103c46a27.us-west-2.aws.found.io:9243"
    }
  ],
  "links": [
    "rabbitmq"
  ]
}
    ]
}

在此定义中,我们定义了端口映射以允许 HTTP 进入容器,并设置了 API 用于与 Elastic Cloud 和rabbitmq服务器通信的环境变量(该服务器将请求传递给scraper-microservice容器)。这还定义了对rabbitmq的链接,因为也需要解析。

在 AWS 中启动和访问容器

在此配方中,我们将通过告知 ECS 运行我们的任务定义来将我们的 scraper 作为服务启动。然后,我们将通过发出 curl 来检查它是否正在运行,以获取作业列表的内容。

准备工作

在运行任务之前,我们需要做一件事。ECS 中的任务经历多次修订。每次您使用相同名称(“family”)注册任务定义时,ECS 都会定义一个新的修订号。您可以运行任何修订版本。

要运行最新的版本,我们需要列出该 family 的任务定义,并找到最新的修订号。以下列出了集群中的所有任务定义。此时我们只有一个:

$ aws ecs list-task-definitions
{
  "taskDefinitionArns": [
    "arn:aws:ecs:us-west-2:414704166289:task-definition/scraper-as-a-service:17"
  ]
}

请注意我的修订号是 17。虽然这是我当前唯一注册的此任务的版本,但我已经注册(和注销)了 16 个之前的修订版本。

如何做

我们按照以下步骤进行:

  1. 现在我们可以运行我们的任务。我们可以使用以下命令来完成这个操作:
$ aws  ecs  run-task  --cluster  scraper-cluster  --task-definition scraper-as-a-service:17  --count  1
{
  "tasks": [
    {
      "taskArn": "arn:aws:ecs:us-west-2:414704166289:task/00d7b868-1b99-4b54-9f2a-0d5d0ae75197",
      "version": 1,
      "group": "family:scraper-as-a-service",
      "containerInstanceArn": "arn:aws:ecs:us-west-2:414704166289:container-instance/5959fd63-7fd6-4f0e-92aa-ea136dabd762",
      "taskDefinitionArn": "arn:aws:ecs:us-west-2:414704166289:task-definition/scraper-as-a-service:17",
      "containers": [
        {
          "name": "rabbitmq",
          "containerArn": "arn:aws:ecs:us-west-2:414704166289:container/4b14d4d5-422c-4ffa-a64c-476a983ec43b",
          "lastStatus": "PENDING",
          "taskArn": "arn:aws:ecs:us-west-2:414704166289:task/00d7b868-1b99-4b54-9f2a-0d5d0ae75197",
          "networkInterfaces": [

          ]
        },
        {
          "name": "scraper-microservice",
          "containerArn": "arn:aws:ecs:us-west-2:414704166289:container/511b39d2-5104-4962-a859-86fdd46568a9",
          "lastStatus": "PENDING",
          "taskArn": "arn:aws:ecs:us-west-2:414704166289:task/00d7b868-1b99-4b54-9f2a-0d5d0ae75197",
          "networkInterfaces": [

          ]
        },
        {
          "name": "api",
          "containerArn": "arn:aws:ecs:us-west-2:414704166289:container/0e660af7-e2e8-4707-b04b-b8df18bc335b",
          "lastStatus": "PENDING",
          "taskArn": "arn:aws:ecs:us-west-2:414704166289:task/00d7b868-1b99-4b54-9f2a-0d5d0ae75197",
          "networkInterfaces": [

          ]
        }
      ],
      "launchType": "EC2",
      "overrides": {
        "containerOverrides": [
          {
            "name": "rabbitmq"
          },
          {
            "name": "scraper-microservice"
          },
          {
            "name": "api"
          }
        ]
      },
      "lastStatus": "PENDING",
      "createdAt": 1515739041.287,
      "clusterArn": "arn:aws:ecs:us-west-2:414704166289:cluster/scraper-cluster",
      "memory": "4096",
      "cpu": "1024",
      "desiredStatus": "RUNNING",
      "attachments": [

      ]
    }
  ],
  "failures": [

  ]
} 

输出给我们提供了任务的当前状态。第一次运行时,它需要一些时间来启动,因为容器正在复制到 EC2 实例上。造成延迟的主要原因是带有所有 NLTK 数据的scraper-microservice容器。

  1. 您可以使用以下命令检查任务的状态:
$ aws  ecs  describe-tasks  --cluster  scraper-cluster  --task 00d7b868-1b99-4b54-9f2a-0d5d0ae75197

您需要更改任务 GUID 以匹配从运行任务的输出的"taskArn"属性中获取的 GUID。当所有容器都在运行时,我们就可以测试 API 了。

  1. 调用我们的服务,我们需要找到集群实例的 IP 地址或 DNS 名称。您可以从我们创建集群时的输出中获取这些信息,也可以通过门户或以下命令获取。首先,描述集群实例:
$ aws ecs list-container-instances --cluster scraper-cluster
{
  "containerInstanceArns": [
    "arn:aws:ecs:us-west-2:414704166289:container-instance/5959fd63-7fd6-4f0e-92aa-ea136dabd762"
  ]
}
  1. 使用我们 EC2 实例的 GUID,我们可以查询其信息并使用以下命令获取 EC2 实例 ID:
$ aws ecs describe-container-instances --cluster scraper-cluster --container-instances 5959fd63-7fd6-4f0e-92aa-ea136dabd762 | grep "ec2InstanceId"
            "ec2InstanceId": "i-08614daf41a9ab8a2",
  1. 有了那个实例 ID,我们可以获取 DNS 名称:
$ aws ec2 describe-instances --instance-ids i-08614daf41a9ab8a2 | grep "PublicDnsName"
                    "PublicDnsName": "ec2-52-27-26-220.us-west-2.compute.amazonaws.com",
                                        "PublicDnsName": "ec2-52-27-26-220.us-west-2.compute.amazonaws.com"
                                "PublicDnsName": "ec2-52-27-26-220.us-west-2.compute.amazonaws.com"
  1. 有了那个 DNS 名称,我们可以使用 curl 来获取作业列表:
$ curl ec2-52-27-26-220.us-west-2.compute.amazonaws.com/joblisting/122517 | head -n 6

然后我们得到了以下熟悉的结果!

{
  "ID": "122517",
  "JSON": {
    "@context": "http://schema.org",
    "@type": "JobPosting",
    "title": "SpaceX Enterprise Software Engineer, Full Stack",

我们的爬虫现在正在云端运行!

还有更多...

我们的爬虫正在一个m4.large实例上运行,所以我们想要关闭它,以免超出免费使用额度。这是一个两步过程。首先,需要终止集群中的 EC2 实例,然后删除集群。请注意,删除集群不会终止 EC2 实例。

我们可以使用以下命令终止 EC2 实例(以及我们刚刚从集群询问中获取的实例 ID):

$ aws ec2 terminate-instances --instance-ids i-08614daf41a9ab8a2
{
  "TerminatingInstances": [
    {
      "CurrentState": {
        "Name": "shutting-down",
        "Code": 32
      },
      "PreviousState": {
        "Name": "running",
        "Code": 16
      },
      "InstanceId": "i-08614daf41a9ab8a2"
    }
  ]
}

集群可以使用以下命令删除:

$ aws ecs delete-cluster --cluster scraper-cluster
{
  "cluster": {
    "activeServicesCount": 0,
    "pendingTasksCount": 0,
    "clusterArn": "arn:aws:ecs:us-west-2:414704166289:cluster/scraper-cluster",
    "runningTasksCount": 0,
    "clusterName": "scraper-cluster",
    "registeredContainerInstancesCount": 0,
    "status": "INACTIVE"
  }
}
posted @ 2025-09-19 10:35  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报