写爬虫或者做页面数据抽取时,最头疼的往往不是网络请求,而是拿到一大坨HTML之后怎么把想要的那几个值抠出来。用正则硬扛太脆弱,页面结构稍微变一下就全挂;用标准库 net/html 手动遍历DOM树又太啰嗦,找一个 class 都要写好几层判断。Go 社区里有个很成熟的方案叫 goquery,它把 jQuery 那套熟悉的选择器语法搬到了 Go 里,几行代码就能定位到目标元素,本文就围绕它详细讲讲怎么用。

goquery 是什么,为什么值得选
goquery 是基于 net/html 和 cascadia 两个库封装出来的解析工具。net/html 负责把HTML字符串解析成DOM树,cascadia 是一个纯Go实现的CSS选择器引擎,负责按选择器匹配节点,goquery 在这两者之上提供了类似 jQuery 的链式 API。也就是说,你在前端写惯了的 $(".article .title") 这种写法,在Go里几乎可以原样翻译过来。
安装非常简单,直接执行下面的命令即可:
go get github.com/PuerkitoBio/goquery
相比正则提取,goquery的优势在于容错性强:浏览器能正常渲染的HTML,net/html 基本都能解析,哪怕标签没闭合、属性带引号不带引号都能处理。相比手写DOM遍历,选择器表达意图更直接,代码量能少一大半。另外它是纯Go实现,没有CGO依赖,交叉编译到任何平台都没问题,这点对爬虫项目部署很友好。
基础用法:加载HTML并选中元素
先看一个最小的例子,从一段HTML字符串中提取所有链接。goquery 提供了 NewDocumentFromReader、NewDocumentFromResponse 等多种构造方式,最常用的还是从 io.Reader 加载:
package main
import (
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
)
func main() {
html := `<html>
<body>
<div class="list">
<a href="https://ipipp.com/page1">第一页</a>
<a href="https://ipipp.com/page2">第二页</a>
</div>
</body>
</html>`
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
panic(err)
}
// 用CSS选择器找到所有a标签,遍历并取出href和文本
doc.Find(".list a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
text := s.Text()
if exists {
fmt.Printf("链接%d: %s -> %s\n", i, text, href)
}
})
}核心就是两个方法:Find 接收任意合法的CSS选择器,返回一个 Selection;Each 对选中的每个元素执行回调,回调参数里 s 代表当前元素,可以继续调用 s.Find、s.Text、s.Attr 等。这种嵌套能力在解析列表页时特别有用:先选中每一项的容器,再在容器内部找标题、价格、链接等字段。
常用选择器与提取技巧
goquery 支持 cascadia 的完整选择器语法,日常开发中高频的包括:#id 按 id 匹配,.class 按 class 匹配,a[href] 按属性存在匹配,a[href*="detail"] 按属性包含匹配,div > p 只匹配直接子元素,tr:nth-child(2) 按位置匹配。把这些组合起来基本能覆盖绝大多数页面的定位需求。
提取数据时常用的几个方法值得单独说一说。Text() 返回元素内的所有文本(含子孙节点拼接);Html() 返回元素内部HTML片段,需要保留结构时用它;Attr("name") 返回属性值和一个 bool 表示是否存在;Eq(index) 在选中集合里取第几个元素;First() 和 Last() 顾名思义。下面这个例子演示解析一个表格:
doc.Find("table.data tbody tr").Each(func(i int, row *goquery.Selection) {
name := row.Find("td").Eq(0).Text()
price := row.Find("td").Eq(1).Text()
link, _ := row.Find("td a").Attr("href")
fmt.Printf("第%d行: 名称=%s, 价格=%s, 链接=%s\n", i+1, name, price, link)
})有个细节容易被忽略:Text() 返回的是原始拼接文本,通常带有换行和大量空格,建议用 strings.TrimSpace 包一层,多个空白合并成一个可以再配合 regexp 处理,比如 regexp.MustCompile(`\s+`).ReplaceAllString(text, " "),数据会干净很多。
结合网络请求的完整实战
实际项目中一般是先发HTTP请求拿到响应体,再交给 goquery 解析。下面给出一个完整例子,抓取一个书籍列表页并输出结构化结果:
package main
import (
"fmt"
"log"
"net/http"
"regexp"
"strings"
"github.com/PuerkitoBio/goquery"
)
type Book struct {
Title string
Price string
}
var spaceRe = regexp.MustCompile(`\s+`)
func clean(s string) string {
return strings.TrimSpace(spaceRe.ReplaceAllString(s, " "))
}
func main() {
req, _ := http.NewRequest("GET", "https://ipipp.com/books", nil)
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Fatalf("请求失败,状态码: %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromResponse(resp)
if err != nil {
log.Fatal(err)
}
var books []Book
doc.Find("ul.book-list li.item").Each(func(i int, s *goquery.Selection) {
books = append(books, Book{
Title: clean(s.Find("h3.title").Text()),
Price: clean(s.Find("span.price").Text()),
})
})
for _, b := range books {
fmt.Printf("《%s》 售价: %s\n", b.Title, b.Price)
}
}这段代码里有两个工程上的好习惯:一是记得关闭响应体并检查状态码,避免连接泄漏;二是把文本清洗逻辑抽成独立函数,遍历逻辑保持简洁。如果目标站点是 GBK 编码(不少老站点仍是),直接解析会乱码,需要先用 golang.org/x/text/encoding/simplifiedchinese 把响应体转成 UTF-8 再喂给 goquery。
注意事项与性能建议
第一点,goquery 只解析静态HTML,页面里由 JavaScript 动态渲染的内容它拿不到。遇到这类站点,要么找接口直接请求数据,要么换 chromedp 这类无头浏览器方案,不要在 goquery 上死磕。第二点,选择器越精确越好,带结构层级的选择器比单纯的样式类名更不容易被页面改版误伤,因为样式类经常被前端构建工具改名。
性能方面,单个页面的解析开销通常远小于网络IO,一般不需要优化。但如果要批量解析上千个页面,注意每次 NewDocumentFromReader 都会完整建树,不要把同一个 body 重复解析多遍,应该解析一次后用多个选择器分别提取。另外 Each 里不要做耗时操作比如再发HTTP请求,会串行阻塞,需要并发时把数据收集好后丢给 goroutine 池处理更合理。
最后提一句调试技巧:选择器没匹配到任何元素时,goquery 不会报错,只是集合为空,Each 一次都不执行。排查时可以先打印 doc.Find(selector).Length() 确认命中数量,再逐步简化选择器定位是哪一段失效,这比盲目改代码效率高得多。掌握这些之后,用Go做页面数据提取基本就是流水线作业了。