Golang | 让爬虫利用chromium访问页面

2020 07 28, Tue

上次写了一个特别简单的爬虫架子。问题主要就是缺一些功能,比如说分布式爬虫,比如说更健全的重试。我们也不好在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和自己的程序拆分开,需要的主要就是:

  1. 让chrome监听局域网的IP
  2. 找到最后这串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"
} ]

通过这个数组我们也可以获取到已经打开的标签,然后直接重用之前的标签。