Skip to content

Latest commit

 

History

History
417 lines (304 loc) · 24.7 KB

File metadata and controls

417 lines (304 loc) · 24.7 KB

十、100 倍爬取

到目前为止,您应该已经对如何构建一个可靠的 web 刮板有了非常广泛的了解。到目前为止,您已经学会了如何高效、安全和尊重地从互联网收集信息。您拥有的工具足以在中小型规模上构建 web scraper,这可能正是您实现目标所需要的。然而,总有一天,您可能需要提高应用程序的规模,以处理大型和生产规模的项目。您可能很幸运,可以通过提供服务谋生,而且,随着业务的发展,您将需要一个健壮且可管理的体系结构。在本章中,我们将回顾构成一个好的 web 抓取系统的体系结构组件,并查看来自开源社区的示例项目。以下是我们将讨论的主题:

  • web 刮片系统的组件
  • 用 colly 抓取 HTML 页面
  • 使用 chrome 协议抓取 JavaScript 页面
  • 使用 dataflowkit 进行分布式爬取

web 刮片系统的组件

第 7 章与并发性刮除中,关于并发性,我们看到了在工作 goroutine 和主 goroutine 之间定义清晰的角色分离如何帮助缓解程序中的问题。通过明确地赋予主 goroutine 维护目标 url 状态的责任,并允许 scraper 线程专注于 scraper,我们为构建一个模块化系统奠定了基础,该系统可以轻松地独立扩展组件。这种关注的分离是建立任何类型的大型系统的基础。

有几个主要组件组成了 web 刮板。如果适当地解耦,这些组件中的每一个都应该能够在不影响系统其他部分的情况下进行扩展。如果您能够将此系统分解为自己的包并将其重新用于其他项目,您就会知道这种解耦是否可靠。你甚至可能想把它发布到开源社区!让我们来看看其中的一些组件。

队列

在网络刮板可以开始收集信息之前,它需要知道去哪里。它还需要知道它在哪里。一个合适的排队系统将实现这两个目标。队列可以以多种不同的方式设置。在前面的许多示例中,我们使用了[]stringmap[string]string来保存 scraper 应该使用的目标 URL。这适用于将工作推给工人的小型刮纸机。

在较大的应用程序中,工作窃取队列将是首选。在工作窃取队列中,工作线程将以完成任务的速度从队列中取出第一个可用的作业。这样,如果您需要您的系统来增加吞吐量,您可以简单地添加更多的工作线程。在这个系统中,队列本身不需要关心工人的状态,只关注作业的状态。这对推送工人的系统是有益的,因为它必须知道有多少工人,哪些工人忙,哪些工人闲,并处理上下班的工人。

排队系统并不总是主要爬取应用程序的一部分。对于外部队列(如数据库)或流媒体平台(如 Redis 和 Kafka),有许多合适的解决方案。这些工具将支持您的排队系统到您自己想象的极限。

隐藏物

正如我们在第 3 章爬虫礼仪中所看到的,缓存网页是高效爬虫器的重要组成部分。有了缓存,如果我们知道没有任何变化,就可以避免从网站请求内容。在前面的示例中,我们使用了本地缓存,将内容保存到本地计算机上的文件夹中。在具有多台机器的大型 web scraper 中,这会导致问题,因为每台机器都需要维护自己的缓存。拥有一个共享缓存解决方案可以解决这个问题,并提高 web 刮板的效率。

有许多不同的方法来解决这个问题。与排队系统非常相似,数据库可以帮助存储信息的缓存。大多数数据库都支持二进制对象的存储,因此无论您是存储 HTML 页面、图像还是任何其他内容,都可以将其放入数据库中。您还可以包含大量关于文件的元数据,例如文件的恢复日期、过期日期、大小、Etag 等。您可以使用的另一个缓存解决方案是一种云对象存储形式,如 AmazonS3、Google 云存储和 Microsoft 对象存储。这些服务通常提供低成本的存储解决方案,这些解决方案模仿文件系统,需要特定的 SDK 或使用其 API。您可以使用的第三个解决方案是一个网络文件系统NFS),每个节点都可以在其中进行连接。就 scraper 代码而言,在 NFS 上写入缓存与在本地文件系统上写入缓存是一样的。在配置工作计算机以连接到 NFS 时可能会遇到一些挑战。每种方法都有其独特的优点和缺点,具体取决于您自己的设置。

存储

在大多数情况下,当你浏览网页时,你会寻找非常具体的信息。相对于网页本身的大小,这可能是一个非常小的数据量。由于缓存存储网页的全部内容,因此需要其他存储系统来存储解析后的信息。web scraper 的存储组件可以像文本文件一样简单,也可以像分布式数据库一样大。

现在,有许多数据库解决方案可以满足不同的需求。如果您有具有许多复杂关系的数据,那么 SQL 数据库可能非常适合您。如果您有更多嵌套结构的数据,那么您可能希望查看 NoSQL 数据库。还有一些解决方案提供全文索引,使搜索文档更容易,如果您需要将数据按时间顺序关联,还可以提供时间序列数据库。因为没有一刀切的解决方案,Go 标准库只提供一个包,通过sql包处理最常见的数据库系列。

构建sql包是为了提供一组通用函数,用于与 MySQL、PostgreSQL 和 Couchbase 等 SQL 数据库通信。对于这些数据库中的每一个,都编写了一个单独的驱动程序,以适应sql包定义的框架。这些驱动程序以及其他各种驱动程序都可以在 GitHub 上找到,并且可以轻松地与您的项目集成。sql包的核心提供了打开和关闭数据库连接、查询数据库、迭代结果行以及对数据执行插入和修改的方法。通过为驱动程序指定一个标准接口,Go 允许您以较少的工作量将数据库换成另一个 SQL 数据库。

日志

在设计爬取系统时,一个经常被忽略的系统是日志系统。重要的是,首先要有清晰的日志语句,而不要记录太多不必要的项目。这些声明应告知操作员刮板的当前状态以及刮板遇到的任何错误或成功。这有助于您了解 web 刮板的整体运行状况。

可以完成的最简单的日志记录是使用println()fmt.Println()类型的语句将消息打印到终端。这对于单个节点来说已经足够好了,但是,随着 scraper 发展成为分布式体系结构,它会导致问题。为了检查系统中的运行情况,操作员需要登录到每台机器以查看日志。如果系统中存在实际问题,尝试将多个源的日志拼凑在一起可能很难进行诊断。在这一点上,为分布式计算构建的日志系统是理想的。

在开源世界中有许多可用的日志记录解决方案。其中一个比较流行的选择是 Graylog。设置 Graylog 服务器是一个简单的过程,需要 MongoDB 数据库和 Elasticsearch 数据库来支持它。Graylog 定义了一种称为 GELF 的 JSON 格式,用于将日志数据发送到服务器,并接受一组非常灵活的密钥。Graylog 服务器可以接受来自多个源的日志流,您还可以定义后期处理操作,例如根据用户定义的规则重新格式化数据和发送警报。还有许多其他类似的系统,以及提供非常类似功能的付费服务。

由于有各种各样的日志记录解决方案,开源社区已经建立了一个库,可以减轻与不同系统集成的负担。GitHub 用户sirupsenlogrus包提供了一个用于编写日志语句的标准实用程序,以及日志格式化程序的插件体系结构。许多人已经构建了用于记录语句的格式化程序,包括一个用于将 GELF 语句发送到 Graylog 服务器的格式化程序。如果您决定在 scraper 开发期间更改日志服务器,则只需更改格式化程序,而不需要替换所有日志语句。

用 colly 抓取 HTML 页面

colly是 GitHub 上可用的项目之一,涵盖了前面讨论的大多数系统。由于它依赖于本地缓存和排队系统,因此该项目构建为在一台机器上运行。

colly中的主要工作对象Collector构建为在自己的 goroutine 中运行,允许您同时运行多个Collectors。此设计使您能够使用不同的参数(例如爬网延迟、白名单和黑名单以及代理)同时从多个站点进行抓取。

colly仅用于处理 HTML 和 XML 文件。它不支持 JavaScript 执行。然而,您会惊讶于使用纯 HTML 可以收集到多少信息。以下示例改编自 GitHubREADME

package main

import (
  "github.com/gocolly/colly"
  "fmt"
)

func main() {
  c := colly.NewCollector(colly.AllowedDomains("go-colly.org"))

  // Find and visit all links
  c.OnHTML("a[href]", func(e *colly.HTMLElement) {
    e.Request.Visit(e.Attr("href"))
  })

  c.OnRequest(func(r *colly.Request) {
    fmt.Println("Visiting", r.URL)
  })

  c.Visit("http://go-colly.org/")
}

在运行本例之前,通过 go get github.com/gocolly/colly/...下载colly

在本例中,创建了一个Collector并定义了go-colly.org的白名单,以及使用OnHTML()函数的回调。在此函数中,它对包含href属性的<a>标记执行 CSS 查询。回调指定收集器应导航到该链接中包含的端点。对于它访问的每个新页面,它都会重复访问每个链接的过程。使用OnRequest()函数向收集器添加另一个回调。此回调打印它访问的每个站点的 URL 的名称。如您所见,Collector对网站执行深度优先爬网,因为它在检查同一页面上的其他链接之前,会尽可能深入地跟踪每个链接。

colly还提供了许多其他功能,如尊重robots.txt、队列的可扩展存储系统以及系统中不同事件的各种回调。这个项目是一个伟大的起点,任何网页刮板,只需要 HTML 页面。它不需要太多的设置,并且有一个灵活的系统来解析 HTML 页面。

使用 chrome 协议抓取 JavaScript 页面

第 5 章网页抓取导航中,我们研究了使用selenium和 WebDriver 协议导航需要 JavaScript 的网站。最近开发的另一个协议提供了更多的功能,您可以利用这些功能来驱动 web 浏览器。Chrome DevTools 协议最初用于 Chrome 浏览器,但它已被 W3C 的 Web 平台孵化器社区小组作为一个项目采用。主要的 web 浏览器共同开发了一个称为 DevTools 协议的标准协议,用于所有浏览器。

DevTools 协议允许外部程序连接到 web 浏览器并发送命令来运行 JavaScript,以及从浏览器收集信息。最重要的是,该协议允许程序按需收集 HTML。这样,如果您正在抓取通过 JavaScript 加载搜索结果的网页,您可以等待结果显示,请求 HTML 页面,然后继续解析所需的信息。

GitHub 上的chrome-protocol项目由 GitHub 用户4ydx开发,提供了使用 DevTools 协议驱动兼容 web 浏览器的访问权限。因为这些浏览器公开一个端口,就像 web 服务器一样,所以您可以在多台机器上运行浏览器。使用chrome-protocol包,您可以通过端口连接到浏览器,并开始构建一系列任务,例如:

  • Navigate:打开一个网页
  • FindAll:通过 CSS 查询搜索元素
  • Click:向特定元素发送点击事件

您可以向浏览器发送更多的操作,通过构建自己的自定义脚本,您可以浏览 JavaScript 网站并收集所需的数据

示例–亚马逊每日交易

在下面的示例中,我们将使用chrome-protocolgoqueryamazon.com检索每日交易。这个例子有点复杂,所以程序被分成了更小的块,我们将一块一块地讨论。让我们从包和import语句开始,如下代码所示:

package main

import (
  "encoding/json"
  "fmt"
  "strings"
  "time"

  "github.com/4ydx/cdp/protocol/dom"
  "github.com/4ydx/chrome-protocol"
  "github.com/4ydx/chrome-protocol/actions"
  "github.com/PuerkitoBio/goquery"
)

这段代码导入运行程序其余部分所需的包。我们以前从未见过的一些新软件包包括:

  • encoding/json:处理 JSON 数据的 Go 标准库
  • github.com/4ydx/chrome-protocol:使用 DevTools 协议的开源库
  • github.com/4ydx/chrome-protocol/actions:定义 DevTools 协议操作的开源库
  • github.com/4ydx/cdp/protocol/dom:使用chrome-protocol处理 DOM 节点的开源库

其他导入的库您应该很熟悉,因为我们在前面的章节中已经使用过它们。接下来,我们将定义两个函数:一个用于从 Amazon 检索 HTML 页面的函数,另一个用于使用goquery解析结果。以下代码显示了检索 HTML 数据的函数:

func getHTML() string {
  browser := cdp.NewBrowser("/usr/bin/google-chrome", 9222, "browser.log")
  handle := cdp.Start(browser, cdp.LogBasic)
  err := actions.EnableAll(handle, 2*time.Second)
  if err != nil {
    panic(err)
  }
  _, err = actions.Navigate(handle, "https://www.amazon.com/gp/goldbox", 
     30*time.Second)
  if err != nil {
    panic(err)}

  var nodes []dom.Node
  retries := 5

  for len(nodes) == 0 && retries > 0 {
    nodes, err = actions.FindAll(
      handle,
      "div.GB-M-COMMON.GB-SUPPLE:first-child #widgetContent",
      10*time.Second)
    retries--
    time.Sleep(1 * time.Second)
  }

  if len(nodes) == 0 || retries == 0 {
    panic("could not find results")
  }

  reply, err := actions.Evaluate(handle, "document.body.outerHTML;", 30*time.Second)
  if err != nil {
    panic(err)
  }

  a := struct{
    Value string
  }{}
  json.Unmarshal([]byte("{\"value\":" + string(*reply.Result.Value)+"}"), &a)
  body := a.Value

  handle.Stop(false)
  browser.Stop()
  return body
}

该函数首先打开 Google Chrome 浏览器的一个新实例,并为其获取一个句柄,以备将来使用。我们使用actions.EnableAll()功能来确保 Chrome 浏览器中发生的所有事件都被发送回我们的程序,这样我们就不会错过任何东西。接下来,我们导航到https://www.amazon.com/gp/goldbox ,这是亚马逊的每日交易网页

如果您使用一个简单的GET命令检索此页面,您将得到一个相当空的 HTML 代码外壳,其中有许多 JavaScript 文件等待运行。在浏览器中发出请求会自动运行填充剩余内容的 JavaScript。

然后,该函数进入一个for循环,该循环检查包含要填充到页面中的每日交易数据的 HTML 元素。for循环将每秒检查 5 秒(由 retries 变量定义),然后再查找结果或放弃。如果没有结果,我们就退出程序。接下来,该函数向浏览器发送请求,通过 JavaScript 命令检索<body>元素。结果的处理有点棘手,因为回复的值需要作为 JSON 字符串处理,以便返回原始 HTML 内容。解析出内容后,函数将返回该内容。

第二个函数负责解析 HTML 内容,如下所示:

func parseProducts(htmlBody string) []string {
  rdr := strings.NewReader(htmlBody)
  body, err := goquery.NewDocumentFromReader(rdr)
  if err != nil {
    panic(err)
  }

  products := []string{}
  details := body.Find("div.dealDetailContainer")
  details.Each(func(_ int, detail *goquery.Selection) {
    println(".")
    title := detail.Find("a#dealTitle").Text()
    price := detail.Find("div.priceBlock").Text()

    title = strings.TrimSpace(title)
    price = strings.TrimSpace(price)

    products = append(products, title + "\n"+price)
  })
  return products
}

很像这个例子,我们在第 4 章解析 HTML中看到,我们使用goquery首先查找包含结果的 HTML 元素。在该容器中,我们迭代每个日常交易项目的详细信息,提取每个项目的标题和价格。然后,我们将每个产品的标题和价格字符串附加到一个数组中,并返回该数组。

main函数将这两个函数联系在一起,首先检索 HTML 页面的主体,然后将其传递给解析结果。然后,main功能打印每天交易的标题和价格。main功能如下:

func main() {
  println("getting HTML...")
  html := getHTML()
  println("parsing HTML...")
  products := parseProducts(html)

  println("Results:")
  for _, product := range products {
    fmt.Println(product + "\n")
  }
}

正如您所看到的,驱动 web 浏览器可能比仅使用简单的 HTTP 请求进行抓取更加困难,但这是可以做到的。

使用 dataflowkit 进行分布式爬取

现在,您已经看到了构建全功能 web scraper 的进展,我想向您介绍一下今天构建的 Go 中最完整的 web scraper 项目。dataflowkit由 GitHub 用户slotix提供,是一个全功能 web scraper,它是模块化的,可扩展的,用于构建可扩展的大规模分布式应用程序。它允许使用多个后端来存储缓存和计算的信息,并且能够通过 DevTools 协议执行简单的 HTTP 请求和驱动浏览器。除此之外,dataflowkit还有一个命令行界面和一个 JSON 格式来声明 web 抓取脚本。

dataflowkit的体系结构分为两个不同的部分:获取和解析。系统的获取和解析阶段都构建为独立的二进制文件,在不同的机器上运行。它们通过 API 通过 HTTP 进行通信,如果您需要发送或接收任何信息,您也可以这样做。通过将它们作为单独的实体运行,获取操作和解析操作可以随着系统的增长而独立扩展。根据您爬取的站点类型,您可能需要比爬取器更多的抓取器,因为 JavaScript 站点往往需要更多的资源。一旦接收到页面,解析页面通常只提供很少的开销。

要开始使用dataflowkit,您可以使用以下代码从 GitHub 克隆它:

git clone https://github.com/slotix/dataflowkit

或通过go get,使用以下代码:

go get github.com/slotix/dataflowkit/...

提取服务

Fetch 服务负责通过简单的 HTTP 请求或驱动 Google Chrome 等 web 浏览器检索 HTML 数据。要开始使用 Fetch 服务,首先,导航到您的本地存储库并从cmd/fetch.d目录运行go build。构建完成后,您可以通过./fetch.d启动服务。

在启动获取服务之前,必须先启动 Google Chrome 浏览器的实例。此实例必须使用--remote-debugging-port选项集启动(通常设置为 9222)。您也可以使用--headless标志在不显示任何内容的情况下运行。

Fetch 服务现在可以接受命令了。您现在应该打开第二个终端窗口,导航到cmd/fetch.cli目录并运行go build。这将构建 CLI 工具,您可以使用该工具向获取服务发送命令。使用 CLI,您可以让 Fetch 服务代表您检索网页,如下所示:

./fetch.cli -u example.com

这也可以通过对 Fetch 服务的/fetch发出一个简单的 JSONPOST请求来完成。在 Go 中,您将编写类似以下代码的代码:

package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "io/ioutil"
  "net/http"

  "github.com/slotix/dataflowkit/fetch"
)

func main() {
  r := fetch.Request{
    Type: "base",
    URL: "http://example.com",
    Method: "GET",
    UserToken: "randomString",
    Actions: "",
  }

  data, err := json.Marshal(&r)

  if err != nil {
    panic(err)
  }
  resp, err := http.Post("http://localhost:8000/fetch", "application/json", bytes.NewBuffer(data))
  if err != nil {
    panic(err)
  }

  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    panic(err)
  }

  fmt.Println(string(body))
}

fetch.Request对象是构造POST请求数据的一种方便方式,json库使得作为请求主体附加变得容易。在前面的章节中,您已经看到了其余大部分代码。在本例中,我们使用基本类型的 fetcher,它只使用 HTTP 请求。如果我们需要驱动浏览器,我们将能够在请求中向浏览器发送操作。

动作以 JSON 对象数组的形式发送,表示一小部分命令。到目前为止,仅支持 click 和 paginate 命令。如果您想向浏览器发送一个click命令,您的获取请求将类似于以下示例:

r := fetch.Request{
    Type: "chrome",
    URL: "http://example.com",
    Method: "GET",
    UserToken: "randomString",
    Actions: `[{"click":{"element":"a"}}]`,
}

通过与外部获取服务通信,您可以轻松控制 HTTP 请求和驱动 web 浏览器之间的来回切换。结合远程执行的强大功能,您可以确保为正确的作业调整正确的机器大小。

解析服务

解析服务负责解析 HTML 页面中的数据,并以易于使用的格式(如 CSV、XML 或 JSON)返回数据。解析服务依赖于 Fetch 服务来检索页面,而不是独立工作。要开始使用解析服务,首先导航到您的本地存储库并从cmd/parse.d目录运行go build。构建完成后,您可以通过./parse.d启动服务。配置解析服务时,您可以设置许多选项,以确定用于缓存结果的后端:如何处理分页、获取服务的位置,等等。现在,我们将使用标准默认值。

要将命令发送到解析服务,您可以使用POST请求发送到/parse端点。请求主体包含关于打开哪个站点、如何将 HTML 元素映射到字段和字段以及如何格式化返回的数据的信息。让我们看看第 4 章中的每日交易示例,解析 HTML,并为解析服务构建请求。首先,我们来看一下packageimport语句,如下所示:

package main

import (
  "bytes"
  "encoding/json"
  "fmt"
  "io/ioutil"
  "net/http"

  "github.com/slotix/dataflowkit/fetch"
  "github.com/slotix/dataflowkit/scrape"
)

在这里,您可以看到我们在哪里进口必要的dataflowkit包。在本例中,fetch包用于构建解析服务的请求,以发送给获取服务。您可以在main功能中看到,如下所示:

func main() {
  r := scrape.Payload{
    Name: "Daily Deals",
    Request: fetch.Request{
      Type: "Base",
      URL: "https://www.packtpub.com/latest-releases",
      Method: "GET",
    },
    Fields: []scrape.Field{
      {
        Name: "Title",
        Selector: `div.landing-page-row div[itemtype$="/Product"]  
         div.book-block-title`,
        Extractor: scrape.Extractor{
          Types: []string{"text"},
          Filters: []string{"trim"},
        },
      }, {
        Name: "Price",
        Selector: `div.landing-page-row div[itemtype$="/Product"] div.book-block-
        price-discounted`,
        Extractor: scrape.Extractor{
          Types: []string{"text"},
          Filters: []string{"trim"},
        },
      },
    },
    Format: "CSV",
  }

这个scrape.Payload对象是我们用来与解析服务通信的对象。它定义了对 Fetch 服务的请求,以及如何收集和格式化数据。在本例中,我们希望收集两个字段的行:标题和价格。我们使用 CSS 选择器定义在何处查找字段以及从何处提取数据。该程序将使用的Extractor是文本提取器,它将复制匹配元素的所有内部文本。

最后,我们将请求发送到解析服务并等待结果,如下例所示:

  data, err := json.Marshal(&r)

  if err != nil {
    panic(err)
  }
  resp, err := http.Post("http://localhost:8001/parse", "application/json", 
  bytes.NewBuffer(data))
  if err != nil {
    panic(err)
  }

  body, err := ioutil.ReadAll(resp.Body)
  if err != nil {
    panic(err)
  }

  fmt.Println(string(body))
}

解析服务使用一个 JSON 对象进行响应,该对象总结了整个过程,包括在哪里可以找到包含结果的文件,如以下示例所示:

{
  "Output file":"results/f5ae68fa_2019-01-13_22:53.CSV",
  "Requests":{
    "initial":1
  },
  "Responses":1,
  "Task ID":"1Fk0qAso17vNnKpzddCyWUcVv6r",
  "Took":"3.209452023s"
}

解析服务提供的便利性,让您作为一个用户,在它的基础上更具创造性。对于开源、可组合的系统,你可以从一个坚实的基础开始,运用你最好的技术来建立一个完整的系统。您拥有足够的知识和工具来构建高效、强大的系统,但我希望您的学习不会到此为止!

总结

在本章中,我们在引擎盖下查看了构成坚实的爬虫系统的组件。我们使用colly删除不需要 JavaScript 的 HTML 页面。我们使用chrome-protocol来驱动 web 浏览器删除确实需要 JavaScript 的站点。最后,我们检查了dataflowkit并了解了它的体系结构如何为构建分布式网络爬虫打开了大门。在 Go 中构建分布式系统还有很多需要学习和做的事情,但这就是本书的范围。我希望你能看看其他一些关于在 Go 中构建应用程序的出版物,并继续磨练你的技能!