在前面的章节中,我们讨论了整个 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 用户antchfx
的htmlquery
。
您可以使用以下命令获取此库:
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
元素的子节点的名称,例如div
和h2
,以及空文本节点。出于这个原因,我们删除任何已知的 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 中表示为#
符号。为了找到id
为main-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 章、网页抓取导航中,我们将探讨高效、安全地抓取互联网的最佳方式。