Golang | 让爬虫利用chromium访问页面
上次写了一个特别简单的爬虫架子。问题主要就是缺一些功能,比如说分布式爬虫,比如说更健全的重试。我们也不好在Go里面运行JS,比如说很多前端渲染的页面,就无法被爬取到。
为了解决运行JS的问题,我们可以考虑使用浏览器自动化一类工具。
自动化浏览器操作
自动化工具其实蛮多的,从模拟鼠标点击的autoit,到通过浏览器插件来加载代码完成自动化,还有比如说phantomjs这种基于qtwebkit的工具,前人耗费了不知道多少头发来偷懒造福大众。
这其中特别出名的有一个Selenium,可以和几乎任何浏览器交互完成爬取。也变成了测试和爬虫的标配。
不过今天我是想纯用chromium来着,我不大喜欢装一些额外的二进制。
headless chromium
chrome是Google出的浏览器这大家都知道了,但是可能不少人不是很清楚,headless chromium是啥玩意儿。
简单说,这就是一个没有界面的浏览器。
可以直接完成一些简单任务比如说访问一个页面然后保存成pdf:
chrome --headless --disable-gpu --print-to-pdf https://blog.jeffthecoder.xyz/posts/golang-write-a-crawler-with-redis/
可以打印渲染后网页的整个DOM结构:
chrome --headless --disable-gpu --dump-dom https://blog.jeffthecoder.xyz/posts/golang-write-a-crawler-with-redis/
或者,我们更想要的是可以暴露出来websocket接口然后通过websocket来控制浏览器:
chrome --headless --disable-gpu --remote-debugging-port=9222
其他的程序可以通过devtools协议和这个端口上的浏览器交互。这些例子都来源于chrome的文档,有兴趣的话可以先看看文档里面的内容,我就不多说这东西可以做什么了。
devtools protocol
devtools协议是chrome提出来的一个协议,通过websocket可以对网页做一些调试,比如说执行JS、模拟环境、检查网页本身。
支持这个协议的库有很多,比如说nodejs的puppeteer,go里面的chromedp,新版本selenium也将要支持原生的devtools协议。
puppeteer是Google主导开发的nodejs的一个package,会随着nodejs安装module的时候安装对应版本的chromium。puppeteer后来也出了一个包叫puppeteer-core,是不带chromium,golang的chromedp和puppeteer-core一样,需要自己装chrome/chromium或者用远程的连接。
有兴趣的同学可以直接移步puppeteer的项目页、文档页以及chromedp的项目页和文档页
远程的headless chromium
刚才说到实际上devtools协议是一个基于Websocket的协议。我们其实不需要在本地运行chrome也可以。比如说我在k8s里面,只要启动一个deployment/replicaset,建一个关联的service就行。但是中间有一个问题:devtools的URL后面有一串类似于ID的东西,怎么获取到?
我们运行一个等着被控制的浏览器时,终端一定会打印
$ google-chrome --headless --remote-debugging-port=9222 --disable-gpu
DevTools listening on ws://127.0.0.1:9222/devtools/browser/f5ad1e7d-dd5d-49ee-b8f1-b8ba1f1d02eb
实际上puppeteer/chromedp也是和这个ws接口交互。
我们要把chrome和自己的程序拆分开,需要的主要就是:
- 让chrome监听局域网的IP
- 找到最后这串f5ad1e7d-dd5d-49ee-b8f1-b8ba1f1d02eb是啥
监听局域网IP简单,加参数 –remote-debugging-address=0.0.0.0,就好了。
主要问题是,URL最后这串东西我没找到是啥,而且每一次启动chrome都不大一样。但是我发现chrome除了会handle这个websocket的接口,还有这些
$ curl -v http://127.0.0.1:9222/json/version
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 9222 (#0)
> GET /json/version HTTP/1.1
> Host: 127.0.0.1:9222
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length:437
< Content-Type:application/json; charset=UTF-8
<
{
"Browser": "HeadlessChrome/84.0.4147.89",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/84.0.4147.89 Safari/537.36",
"V8-Version": "8.4.371.19",
"WebKit-Version": "537.36 (@19abfe7bcba9318a0b2a6bc6634a67fc834aa592)",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/8999f284-3dc2-4f0b-bd8f-0c56e420e546"
}
* Connection #0 to host 127.0.0.1 left intact
* Closing connection 0
里面那个 webSocketDebuggerUrl 就是之前终端里面打印的websocket的地址。URL里面的IP会根据你访问用的IP改变的
{
"Browser": "HeadlessChrome/84.0.4147.89",
"Protocol-Version": "1.3",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/84.0.4147.89 Safari/537.36",
"V8-Version": "8.4.371.19",
"WebKit-Version": "537.36 (@19abfe7bcba9318a0b2a6bc6634a67fc834aa592)",
"webSocketDebuggerUrl": "ws://192.168.2.156:9222/devtools/browser/8999f284-3dc2-4f0b-bd8f-0c56e420e546"
}
这样我们就可以直接用HTTP先获取到websocket url,然后用puppeteer或者chromedp来控制浏览器就行了。
chromedp
如果用chromedp改造我们上一个程序,看起来大概就是这个样子:
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/chromedp"
)
type ChromiumVersion struct {
Browser string `json:"Browser"`
ProtocolVersion string `json:"Protocol-Version"`
UserAgent string `json:"User-Agent"`
V8Version string `json:"V8-Version"`
WebKitVersion string `json:"WebKit-Version"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
var (
versionUrl string
interval time.Duration
timeout time.Duration
)
func init() {
flag.StringVar(&versionUrl, "target", "http://127.0.0.1:9222/json/version", "url to get browser info")
flag.DurationVar(&interval, "interval", 0, "interval between http get")
flag.DurationVar(&timeout, "timeout", time.Second*15, "timeout for http get")
}
func doWork(url string) ([]string, error) {
// 请求chrome的version和debug地址
// 由于k8s里面pod可能会重启,每次都获取一下可能会比较好?
res, err := http.Get(versionUrl)
if err != nil {
return nil, err
}
if res.StatusCode/100 != 2 {
return nil, fmt.Errorf("unexcepted status code when fetching browser info: %v", res.StatusCode)
}
defer res.Body.Close()
var chromiumVersion ChromiumVersion
if err := json.NewDecoder(res.Body).Decode(&chromiumVersion); err != nil {
return nil, err
}
log.Println("find browser: ", chromiumVersion.Browser, " @ ", chromiumVersion.WebSocketDebuggerURL)
// 加上超时
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// 用remote allocator就可以通过websocket连接到chromium
allocator, browserCancel := chromedp.NewRemoteAllocator(ctx, chromiumVersion.WebSocketDebuggerURL)
defer browserCancel()
// chromedp的逻辑中,新的一个tab就是从已经存在browser的ctx中新建一个context
pageCtx, pageCancel := chromedp.NewContext(allocator)
defer pageCancel()
// 打开页面,并且选取href中所有有magnet的a,本来想用magnet:但是:在css selector中不合法
var nodes []*cdp.Node
if err := chromedp.Run(pageCtx,
chromedp.Navigate(url),
chromedp.Nodes("a[href*=magnet]", &nodes, chromedp.ByQueryAll),
); err != nil {
return nil, err
}
// 逐个检查节点是不是magnet链接
var results = make(map[string]string)
for _, node := range nodes {
xpath := node.FullXPath()
log.Println(xpath)
if href := node.AttributeValue("href"); strings.HasPrefix(href, "magnet:") {
var text string
// 把链接的内容拉出来
if err := chromedp.Run(pageCtx,
chromedp.TextContent(xpath, &text, chromedp.BySearch),
); err != nil {
return nil, err
}
log.Println(text, ": ", href)
results[text] = href
}
}
// 想的话发个邮件什么的
// 最后返回要添加回队列的URL
// 我这段程序节选自一个网页监测的片段,因为是要监测同一个页面,所以把原来的页面URL重新放回去就好
return []string{url}, nil
}
其他可以做的
实际上除了获取到chrome的debug url然后开一个新标签以外,还可以获取到每个tab/page的URL。
比如说我们可以获取到这样一个数组:
[ {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/163F3581EAA278F6F8D7C5802FFE180F",
"id": "163F3581EAA278F6F8D7C5802FFE180F",
"title": "新标签页",
"type": "page",
"url": "chrome://newtab/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/163F3581EAA278F6F8D7C5802FFE180F"
}, {
"description": "",
"devtoolsFrontendUrl": "/devtools/inspector.html?ws=127.0.0.1:9222/devtools/page/9F01434B8B49CEB8DFF60D898ED9F507",
"faviconUrl": "https://blog.jeffthecoder.xyz/favicon.ico",
"id": "9F01434B8B49CEB8DFF60D898ED9F507",
"title": "从CentOS开始的Linux之旅",
"type": "page",
"url": "https://blog.jeffthecoder.xyz/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/9F01434B8B49CEB8DFF60D898ED9F507"
} ]
通过这个数组我们也可以获取到已经打开的标签,然后直接重用之前的标签。