TL;DR

给太长不看同学的内容速览:

  • Headless是Chrome 59中加入的一种新的运行模式
  • Headless Chrome可以替代PhantomJS,并且更加强大
  • 可以通过Chrome DevTools Protocol这个协议对远程的Chrome浏览器进行调试
  • chrome-remote-interface是Nodejs下Chrome DevTools Protocol的封装
  • 可以使用Emulation.setVisibleSize整个页面进行截屏

PhantomJS的问题

之前有数报表的导出图片功能是用PhantomJS做的。PhantomJS有两个很大的问题:第一是,它的渲染引擎和JavaScript引擎基于Qt5,版本不是很高,所以渲染的时候会有一些兼容问题,而且JavaScript引擎也相对比较古老(最新的PhantomJS release是2.1版,这个版本基于Qt5.5。Qt5.5使用的Chromium内核版本是40,Chromium现在最新版本是62)。第二,PhantomJS现在已经处于一种维护不多的状态(Github上有1901个open issues)。

作为一个个人项目,PhantomJS在各种自动化测试以及页面自动化操作中被广泛使用,达到了很高的高度。但因为以上两个缺点,使用PhantomJS将不会是长久之计。

Chrome的Headless模式

Headless Chrome其实不是一个全新的工具,而是普通的Chrome浏览器的headless模式。headless就是指Chrome的UI部分是不运行的。

所以只要你的机器上安装了Chrome 59+,你就可以使用Headless Chrome。相比之前npm install时经常要从bitbucket下载PhantomJS binary的麻烦事,Headless Chrome要方便不少,毕竟Web开发者一般都安装了Chrome。

你可以在命令行中用headless模式启动Chrome:

chrome \
  --headless \                   # Runs Chrome in headless mode.
  --disable-gpu \                # Temporarily needed for now.
  --remote-debugging-port=9222 \
  https://www.chromestatus.com   # URL to open. Defaults to about:blank.

我们可以直接在Chrome的CLI中进行一些操作,比如截屏:

chrome --headless --disable-gpu --screenshot --window-size=1280,1696 https://www.chromestatus.com/

但一般我们很少会这样直接使用Headless Chrome。对这部分有兴趣的同学可以看官方文档,这里就不多说了。

Headless Chrome的最大的优点就是,它就是Chrome,所以可以保持Evergreen,也就是持续的更新。并且我们可以在Headless模式使用Chrome带来的所有的现代Web平台的特性。所以Headless Chrome就成为了PhantomJS的完美升级版替代品。

强大的Chrome DevTools Protocol

要在脚本中和Chrome进行交互,需要用Chrome DevTools Protocol这个协议。所以这里首先介绍一下这个协议。

简单的说,我们可以在启动Chrome的时候开启一个用于远程调试的端口。然后我们可以在浏览器或者其他客户端中和Chrome建立socket连接,并使用Chrome DevTools Protocol进行通信。

Chrome DevTools Protocol通信的格式是JSON。比如我们想截屏,就可以发一个消息:

{
	id:30,
	method:"Page.captureScreenshot",
	params: {
		format:"png",
		quality:100
	}
}

返回的消息:

{
	id:30,
	data:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADIA..."	
}

具体的API可以参考文档,我们从文档里可以看到,Chrome DevTools Protocol包含的范围非常广。简单的说,我们平时在Chrome DevTool里面可以做到的事情,能获取的数据,我们使用Chrome DevTools Protocol都可以做到。因为Chrome DevTool其实就是基于这个协议进行开发的一个B/S架构的工具。当然这个协议也应该是随着Chrome DevTool的开发,被标准化。现在不仅仅是Chrome,其他浏览器也支持部分Chrome DevTools Protocol。

我们在Chrome的拓展里也可以调用这一套API。所以Chrome拓展的潜力是很大的。可以通过合理使用Chrome DevTools Protocol获得更接近自带DevTool的debug体验。也可以对内存、DOM、渲染等数据进行二次的分析和利用。

Nodejs服务

直接使用Chrome DevTools Protocol还是比较麻烦的。社区已经有了封装好的Nodejs包chrome-remote-interface可以直接使用。我们可以直接像调用JavaScript API那样来和Chrome进行通信。

下面就演示一下如何在Node中进行Chrome的截屏:

const CDP = require('chrome-remote-interface');
const Koa = require('koa');
const app = new Koa();

const viewportWidth = 1440;
const viewportHeight = 900;
const delay = 500;

app.use(async ctx => {
  ctx.body = await capture(ctx.request.query.url);
});

app.listen(3000);

const capture = function (url) {
  return new Promise((resolve, reject) => {
    CDP.New().then((target) => {
      return CDP({ target });
    }).then(async (client) => {

      const { Page } = client;
      await Page.enable();
      await Page.navigate({ url: url });
  
      Page.loadEventFired(() => {
        setTimeout(async () => {
          const { data } = await Page.captureScreenshot();
          resolve(Buffer.from(data, 'base64'));
          const id = client.target.id;
          client.close();
          CDP.Close({ id });
        }, delay);
      });
    }).catch((err) => {
      console.error(err);
    });
  })
}

chrome-remote-interface将一个socket通信的来回封装成了一个异步的函数调用,返回一个Promise。在Node7.8+的环境,我们可以用async/await来轻松的进行流程控制。这里是具体的DemoAPI文档,基本和Chrome DevTools Protocol里的接口是一一对应的。

这个服务监听3000端口,在请求中拿到url参数,调用capture函数截屏。我们这里默认Chrome的调试端口是127.0.0.1:9222。如果需要调整,可以在初始化CDP实例的时候传入参数。

我们调用CDP.New()初始化一个新的Tab,等待Page加载完成,打开url,然后等待页面的加载事件触发之后执行回调,在回调里调用截屏API并且获取数据。最后我们关闭这个Tab。整个截图的流程就是这样。

Tip:截取整个页面

我遇到的一个很大的问题就是,页面比较长,我希望截取的图片的高度就是页面的高度,也就是说我希望给整个页面截屏。

一开始我截屏的结果是这样的:

这和想象的不一样啊,下面的大半截都没有了 xiaohuangji

所以要如何截取整个页面的呢?我在chrome-remote-interface的wiki里看到了一篇文章。然而截屏的时候报错,说Emulation.forceViewport不存在.原来这个API已经在新版的Chrome中被废弃了。

最终我找到了一篇神奇的博客,完美的解决了我的问题,核心代码是这样的:

Page.loadEventFired(async() => {
    if (fullPage) {
      const {root: {nodeId: documentNodeId}} = await DOM.getDocument();
      const {nodeId: bodyNodeId} = await DOM.querySelector({
        selector: 'body',
        nodeId: documentNodeId,
      });

      const {model: {height}} = await DOM.getBoxModel({nodeId: bodyNodeId});
      await Emulation.setVisibleSize({width: device.width, height: height});
      await Emulation.setDeviceMetricsOverride({width: device.width, height:height, screenWidth: device.width, screenHeight: height, deviceScaleFactor: 1, fitWindow: false, mobile: false});
      await Emulation.setPageScaleFactor({pageScaleFactor:1});
    }
  });

  setTimeout(async function() {
    const screenshot = await Page.captureScreenshot({format: "png", fromSurface: true});
    const buffer = new Buffer(screenshot.data, 'base64');
    fs.writeFile('desktop.png', buffer, 'base64', function(err) {
      if (err) {
        console.error(err);
      } else {
        console.log('Screenshot saved');
      }
    });
      client.close();
  }, screenshotDelay);

}).on('error', err => {
  console.error('Cannot connect to browser:', err);
});

原理就是用Emulation这个API,去调整页面的一些属性。其中核心的方法是Emulation.setVisibleSize,文档里对这个方法的说明是:

Resizes the frame/viewport of the page. Note that this does not affect the frame’s container (e.g. browser window). Can be used to produce screenshots of the specified size.

然后我们就可以愉快的给整个页面截屏了,比如这样:

full image

Docker镜像

最后我们需要部署这两个服务,社区里有自制的Headless Chrome的Docker镜像,比如yukinying/chrome-headless-browser-docker。注意如果我们需要调整Chrome的默认window大小,可以修改Dockerfile然后自行build镜像。

ENTRYPOINT ["/usr/bin/dumb-init", "--", \
            "/usr/bin/google-chrome-unstable", \
            "--disable-gpu", \
            "--window-size=1440,900"  # 在ENTRYPOINT命令参数中加上window-size
            "--headless", \
            "--remote-debugging-address=0.0.0.0", \
            "--remote-debugging-port=9222", \
            "--user-data-dir=/data"]

Node服务的镜像就比较简单了:

FROM node:latest
# Create app directory
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY . /usr/src/app

RUN npm install koa chrome-remote-interface
EXPOSE 3000
CMD [ "node", "index.js" ]

生产中应该用pm2这样的Process Manager来保持进程的运行。依赖也应该写在package.json里。

服务之间的关系如下:

service

如果大家想使用Headless Chrome的话,最好还是去浏览一下相关的文档,因为这些内容都属于比较新的东西,变化也比较快。本文主要是简单的介绍一下这方面实现的可行性。下面是相关的链接: