Go 解析 HTML详解

在前面的章节中,我们讨论了整个 web 页面,这对于大多数 web scraper 来说并不实际。虽然从一个网页中获取所有内容是很好的,但在大多数情况下,您只需要每个网页中的一小部分信息。为了提取这些信息,您必须学习解析 web 的标准格式,其中最常见的是 HTML。

本章将涵盖以下主题:

HTML 是用于提供网页上下文的标准格式。HTML 页面定义浏览器应该绘制哪些元素、元素的内容和样式,以及页面应该如何响应用户的交互。回顾我们的http://example.com/index.html 响应,您可以看到以下内容,这是 HTML 文档的外观:

<!doctype html>
<html>
<head>
  <title>Example Domain</title>
  <meta charset="utf-8" />
  <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <!-- The <style> section was removed for brevity -->
</head>
<body>
  <div>
    <h1>Example Domain</h1>
    <p>This domain is established to be used for illustrative examples 
       in documents. You may use this domain in examples without prior
       coordination or asking for permission.</p>
    <p><a href="http://www.iana.org/domains/example">More 
        information...</a></p>
  </div>
</body>
</html>

遵循 HTML 规范的文件遵循一组严格的规则,这些规则定义了文档的语法和结构。通过学习这些规则,您可以快速轻松地从任何网页检索任何信息

HTML 文档通过使用带有元素名称的标记来定义网页的元素。标签总是被尖括号包围,例如<body>标签。每个元素通过在标记名前使用正斜杠定义标记集的结尾,例如</body>。元素的内容位于一组开始标记和结束标记之间。例如,<body>和匹配的</body>标记之间的所有内容定义了 body 元素的内容。

有些标记还具有在称为属性的键值对中定义的额外属性。这些用于描述有关元素的额外信息。在所示的示例中,有一个名为href的属性的<a>标记,其值为https://www.iana.org/domains/example 。在本例中,href<a>标记的属性,它告诉浏览器该元素链接到提供的 URL。在后面的章节中,我们将更深入地研究如何浏览这些链接。

每个 HTML 文档都有一个特定的布局,从<!doctype>标记开始。此标记用于定义用于验证此特定文档的 HTML 规范的版本。在我们的例子中,<!doctype html>指的是 HTML5 规范。您有时可能会看到这样的标记:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">

这将描述一个HTML 4.01(严格的)网页,该网页遵循所提供 URL 中提供的定义。我们不会使用提供的定义来验证本书中的页面,因为通常不需要这样做。

<!doctype>标记后面是<html>标记,它保存网页的实际内容。在<html>标签内,您将找到文档的<head><body>标签。<head>标记包含页面本身的元数据,如标题,以及用于构建网页的外部文件。这些文件可能用于设置样式,或者用于描述元素对用户交互的反应。

的实际网页上 http://example.com/index.html ,您可以看到<style>标签,用于描述网页上各种类型元素的大小、颜色、字体和间距。此信息已从本书的 HTML 文档中删除,以保留空间。

<body>标记包含您感兴趣的大量数据。在<body>元素中,您将找到所有文本、图像、视频和链接,其中包含您的网络抓取需要的信息。从网页上收集你需要的数据可以用许多不同的方法来完成;您将在以下部分中看到一些常用方法。

搜索内容最基本的方法是使用 Go 标准库中的strings包。strings包允许您对字符串对象执行各种操作,包括搜索匹配项、计算出现次数以及将字符串拆分为数组。这个包的实用程序可以涵盖您可能遇到的一些用例。

使用strings包,我们可以提取一条快速而简单的信息,即计算网页中包含的链接数。strings包有一个名为Count()的函数,该函数返回子字符串在字符串中出现的次数。正如我们之前看到的,链接包含在<a>标记中。通过计算"<a"的出现次数,我们可以大致了解页面中的链接数量。下面给出了一个示例:

package main

import (
  "fmt"
  "io/ioutil"
  "net/http"
  "strings"
)

func main() {
  resp, err := http.Get("https://www.packtpub.com/")
  if err != nil {
    panic(err)
  }

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

  stringBody := string(data)

  numLinks := strings.Count(stringBody, "<a")
  fmt.Printf("Packt Publishing homepage has %d links!\n", numLinks)
}

在本例中,Count()函数用于查找 Packt 发布网站主页中"<a"的出现次数。

strings包中另一个有用的方法是Contains()方法。这用于检查字符串中是否存在子字符串。例如,您可以检查用于构建类似于此处给出的网页的 HTML 版本:

package main

import (
  "io/ioutil"
  "net/http"
  "strings"
)

func main() {
  resp, err := http.Get("https://www.packtpub.com/")
  if err != nil {
    panic(err)
  }

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

  stringBody := strings.ToLower(string(data))

  if strings.Contains(stringBody, "<!doctype html>") {
    println("This webpage is HTML5")
  } else if strings.Contains(stringBody, "html/strict.dtd") {
    println("This webpage is HTML4 (Strict)")
  } else if strings.Contains(stringBody, "html/loose.dtd") {
    println("This webpage is HTML4 (Tranistional)")
  } else if strings.Contains(stringBody, "html/frameset.dtd") {
    println("This webpage is HTML4 (Frameset)")
  } else {
    println("Could not determine doctype!")
  }
}

本例查找包含在<!doctype>标记中的信息,以检查它是否包含 HTML 版本的某些指示符。运行此代码将显示 Packt Publishing 的主页是根据 HTML5 规范构建的。

依赖strings包可以揭示关于网页的一些非常简单的信息,但它确实有其缺点。在前面的两个示例中,如果文档中有语句在意外的位置包含字符串,则匹配可能会产生误导。过度概括字符串搜索可能会导致错误信息,使用更强大的工具可以避免这些错误信息。

Go 标准库中的regexp包通过使用正则表达式提供了更深层次的搜索。这定义了一种语法,允许您以更复杂的术语搜索字符串,以及从文档中检索字符串。通过在正则表达式中使用捕获组,可以从网页中提取与查询匹配的数据。以下是regexp包可以帮助您完成的一些有用任务。

在上一节中,我们使用strings包计算页面上的链接数。通过使用regexp包,我们可以进一步利用此示例,使用以下正则表达式检索实际链接:

 <a.*href\s*=\s*["'](http[s]{0,1}:\/\/.[^\s]*)["'].*>

这个查询应该匹配任何看起来像 URL 的字符串,在href属性中,在<a>标记中。

以下程序打印 Packt Publishing 主页上的所有链接。通过查询<img>标签的src属性,可以使用相同的技术收集所有图像:

package main

import (
  "fmt"
  "io/ioutil"
  "net/http"
        "regexp"
)

func main() {
  resp, err := http.Get("https://www.packtpub.com/")
  if err != nil {
    panic(err)
  }

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

  stringBody := string(data)

        re := regexp.MustCompile(`<a.*href\s*=\s*["'](http[s]{0,1}:\/\/.[^\s]*)["'].*>`)
        linkMatches := re.FindAllStringSubmatch(stringBody, -1)

        fmt.Printf("Found %d links:\n", len(linkMatches))
        for _,linkGroup := range(linkMatches){
            println(linkGroup[1])
        }
}

正则表达式也可用于查找网页本身上显示的内容。例如,您可能正在尝试查找某个项目的价格。下面的例子显示了 Packt Publishing 网站上的动手编程书的价格:

package main

import (
  "fmt"
  "io/ioutil"
  "net/http"
        "regexp"
)

func main() {
  resp, err := http.Get("https://www.packtpub.com/application-development/hands-go-programming")
  if err != nil {
    panic(err)
  }

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

  stringBody := string(data)

  re := regexp.MustCompile(`.*main-book-price.*\n.*(\$[0-9]*\.[0-9]{0,2})`)
  priceMatches := re.FindStringSubmatch(stringBody)

  fmt.Printf("Book Price: %s\n", priceMatches[1])
}

该程序查找与main-book-price匹配的文本字符串,然后在下一行查找 USD 格式的十进制数。

您可以看到,正则表达式可以用于提取文档中的字符串,strings包主要用于发现字符串。这两种技术都有相同的问题:您可能会在意外的地方匹配字符串。为了获得更细粒度的方法,搜索需要更结构化。

在前面解析 HTML 文档的示例中,我们将 HTML 简单地视为可搜索文本,您可以通过查找特定字符串来发现信息。幸运的是,HTML 文档实际上有一个结构。您可以看到,每一组标记都可以被视为一个对象,称为节点,而节点又可以包含更多的节点。这将创建根节点、父节点和子节点的层次结构,提供结构化文档。特别是,HTML 文档与 XML 文档非常相似,尽管它们并不完全兼容 XML。由于这种类似 XML 的结构,我们可以使用 XPath 查询在页面中搜索内容。

XPath 查询定义了一种遍历 XML 文档中节点层次结构并返回匹配元素的方法。在我们前面的示例中,为了计数和检索链接,我们需要查找<a>标记,我们需要按字符串搜索标记。如果在 HTML 文档中的意外位置(例如在代码注释或转义文本中)发现类似的匹配字符串,则此方法可能会出现问题。如果我们使用 XPath 查询,如//a/@href,我们可以遍历实际<a>标记节点的 HTML 文档结构,并检索href属性。

使用 XPath 之类的结构化查询语言,还可以轻松地收集未格式化的数据。在前面的例子中,我们主要关注产品的价格。价格更容易处理,因为它们通常遵循特定的格式。例如,可以使用正则表达式查找美元符号,后跟一个或多个数字、一个句点和两个以上的数字。另一方面,如果要检索内容没有格式的一个或多个文本块,则使用基本字符串搜索将变得更加困难。XPath 允许您检索节点内的所有文本内容,从而简化了这一过程。

Go 标准库对 XML 文档和元素的处理提供了基本支持;不幸的是,没有 XPath 支持。然而,开源社区已经为 Go 构建了各种 XPath 库。我推荐的是 GitHub 用户antchfxhtmlquery

您可以使用以下命令获取此库:

go get github.com/antchfx/htmlquery

下面的示例演示如何使用 XPath 查询来发现一些基本的产品信息:

package main

import (
  "regexp"
  "strings"

  "github.com/antchfx/htmlquery"
)

func main() {
  doc, err := htmlquery.LoadURL("https://www.packtpub.com/packt/offers/free-learning")
  if err != nil {
    panic(err)
  }

  dealTextNodes := htmlquery.Find(doc, `//div[@class="dotd-main-book-summary float-left"]//text()`)

  if err != nil {
    panic(err)
  }

  println("Here is the free book of the day!")
  println("----------------------------------")

  for _, node := range dealTextNodes {
    text := strings.TrimSpace(node.Data)
    matchTagNames, _ := regexp.Compile("^(div|span|h2|br|ul|li)$")
    text = matchTagNames.ReplaceAllString(text,"")
    if text != "" {
      println(text)
    }
  }
}

此程序选择在包含class属性的div元素中找到的任何text(),匹配值为dotd-main-book-summary。此查询还返回目标div元素的子节点的名称,例如divh2,以及空文本节点。出于这个原因,我们删除任何已知的 HTML 标记(使用正则表达式),只打印不是空字符串的其余文本节点。

在本例中,我们将使用 XPath 查询从 Packt 发布网站检索最新版本。在这个网页上,有一系列的<div>标签,其中包含更多的<div>标签,这将最终导致我们的信息。这些<div>标记中的每一个都包含一个名为class的属性,该属性描述了节点的用途。我们特别关注landing-page-row类。landing-page-row类中与书籍相关的<div>标记有一个名为itemtype的属性,它告诉我们div是一本书的,应该包含包含名称和价格的其他属性。用strings包无法实现这一点,正则表达式的设计将非常困难。

让我们来看看下面的例子:

package main

import (
  "fmt"
  "strconv"

  "github.com/antchfx/htmlquery"
)

func main() {
  doc, err := htmlquery.LoadURL("https://www.packtpub.com/latest-
  releases")
  if err != nil {
    panic(err)
  }

  nodes := htmlquery.Find(doc, `//div[@class="landing-page-row 
  cf"]/div[@itemtype="http://schema.org/Product"]`)
  if err != nil {
    panic(err)
  }

  println("Here are the latest releases!")
  println("-----------------------------")

  for _, node := range nodes {
    var title string
    var price float64

    for _, attribute := range node.Attr {
      switch attribute.Key {
      case "data-product-title":
        title = attribute.Val
      case "data-product-price":
        price, err = strconv.ParseFloat(attribute.Val, 64)
        if err != nil {
          println("Failed to parse price")
        }
      }
    }
    fmt.Printf("%s ($%0.2f)\n", title, price)
  }
}

使用直接以文档中的元素为目标的 XPath 查询,我们可以导航到精确节点的精确属性,以检索每本书的名称和价格。

您可以看到,使用结构化查询语言比基本字符串搜索更容易搜索和检索信息。然而,XPath 是为通用 XML 文档而不是 HTML 设计的。还有另一种专门为 HTML 设计的结构化查询语言。创建了层叠样式表CSS),以提供一种向 HTML 页面添加样式元素的方法。在 CSS 文件中,您将定义一个或多个元素的路径,以及描述外观的内容。元素路径的定义称为 CSS 选择器,专门为 HTML 文档编写。

CSS 选择器理解我们在搜索 HTML 文档时可以使用的公共属性。在前面的 XPath 示例中,我们经常使用诸如div[@class="some-class"]之类的查询来搜索类名为some-class的元素。CSS 选择器通过简单地使用.来提供class属性的简写。同样的 XPath 查询看起来像一个 CSS 查询。这里使用的另一种常用速记是搜索具有id属性的元素,该属性在 CSS 中表示为#符号。为了找到idmain-body的元素,可以使用div#main-body作为 CSS 选择器。CSS 选择器规范中还有许多其他细节,它们扩展了通过 XPath 可以完成的工作,并简化了常见查询。

尽管 Go 标准库中不支持 CSS 选择器,但开源社区仍然有许多工具提供此功能,其中最好的工具是 GitHub 用户PuerkitoBio提供的goquery

您可以使用以下命令获取库:

go get github.com/PuerkitoBio/goquery

以下示例将改进 XPath 示例,使用goquery代替htmlquery

package main

import (
  "fmt"
  "strconv"

  "github.com/PuerkitoBio/goquery"
)

func main() {
  doc, err := goquery.NewDocument("https://www.packtpub.com/latest-
  releases")
  if err != nil {
    panic(err)
  }

  println("Here are the latest releases!")
  println("-----------------------------")
  doc.Find(`div.landing-page-row div[itemtype$="/Product"]`).
    Each(func(i int, e *goquery.Selection) {
      var title string
      var price float64

      title,_ = e.Attr("data-product-title")
      priceString, _ := e.Attr("data-product-price")
      price, err = strconv.ParseFloat(priceString, 64)
      if err != nil {
        println("Failed to parse price")
      }
      fmt.Printf("%s ($%0.2f)\n", title, price)
    })
}

使用goquery,搜索每日交易变得更加简洁。在这个查询中,我们使用 CSS 选择器通过使用$=操作符提供的一个辅助功能。我们可以简单地匹配以/Product结尾的字符串,而不是查找itemtype属性,匹配精确的字符串http://schema.org/Product。我们还使用.操作符查找landing-page-row类。这个示例和 XPath 示例之间需要注意的一个关键区别是,您不需要匹配 class 属性的整个值。当我们使用 XPath 进行搜索时,我们必须使用@class="landing-page-row cf"作为查询。在 CSS 中,类不需要精确匹配。只要元素包含landing-page-row class,它就匹配。

在这里给出的代码中,您可以看到收集产品示例的 CSS 选择器版本:

package main

import (
  "bufio"
  "strings"

  "github.com/PuerkitoBio/goquery"
)

func main() {
  doc, err := goquery.NewDocument("https://www.packtpub.com/packt/offers/free-learning")
  if err != nil {
    panic(err)
  }

  println("Here is the free book of the day!")
  println("----------------------------------")
  rawText := doc.Find(`div.dotd-main-book-summary div:not(.eighteen-days-countdown-bar)`).Text()
  reader := bufio.NewReader(strings.NewReader(rawText))

  var line []byte
  for err == nil{
    line, _, err = reader.ReadLine()
    trimmedLine := strings.TrimSpace(string(line))
    if trimmedLine != "" {
      println(trimmedLine)
    }
  }
}

在本例中,您还可以使用 CSS 查询返回所有子元素中的所有文本。我们使用:not()操作符排除倒计时,最后处理文本行以忽略空格和空行。

您可以看到,使用不同的工具从 HTML 页面提取数据有多种方法。基本字符串搜索和regex搜索可以使用非常简单的技术收集信息,但在某些情况下需要更多结构化查询语言。XPath 提供了强大的搜索功能,它假设文档是 XML 格式的,并且可以覆盖一般搜索。CSS 选择器是从 HTML 文档中搜索和提取数据的最简单方法,并提供了许多特定于 HTML 的有用功能。

第 5 章网页抓取导航中,我们将探讨高效、安全地抓取互联网的最佳方式。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

Service Mesh实践指南 -〔周晶〕

算法面试通关40讲 -〔覃超〕

数据分析实战45讲 -〔陈旸〕

Vim 实用技巧必知必会 -〔吴咏炜〕

Flink核心技术与实战 -〔张利兵〕

物联网开发实战 -〔郭朝斌〕

容量保障核心技术与实战 -〔吴骏龙〕

Redis源码剖析与实战 -〔蒋德钧〕

结构思考力 · 透过结构看思考 -〔李忠秋〕