phantomjs实现服务端屏幕截图

phantomjs

前言

前阵子遇到一个需求,微信小程序的某一个界面,需要屏幕截图,然后分享出去。但小程序官方没有开放可以截取超过屏幕的完整界面的 API,也就是只能截取当前屏幕可以看到的内容。而我们需要截长图,所以现有的 API 不能满足需求,只能服务端想办法生成图片。我偶然发现了 phantomjs,可以由服务端去做类似爬虫的操作来保存图片。

思路

想到一种解决方案,就是做一个 H5 的页面,服务端用一个基于 webkit 内核的无界面浏览器 phantomjs,去访问 H5 页面地址,渲染,生成图片,并保存到服务器上,从而实现截图功能。

phantomjs

官网 http://phantomjs.org/

phantomjs 是 一个基于 webkit 内核的无头浏览器,没有 UI 界面。它就是一个浏览器,只是内部的点击、翻页等人为相关操作需要程序设计实现。

提供了 javascript API 接口,可以通过 js 直接与 webkit 内核交互,在此之上可以结合 Java 语言等,通过 Java 调用 js 等相关操作,从而解决了以前 c/c++ 才能比较好的基于 webkit 开发优质采集器的限制。

提供了 windows、linux、mac 等不同 OS 的安装使用包,也就是说可以在不同平台上,二次开发采集项目(爬虫)或是自动项目测试等工作。

常用内置对象

1
2
3
4
5
6
7
8
// 获得系统操作对象,包括命令行参数、phantomjs系统设置等信息
var system = require('system');
// 获取操作dom或web网页的对象,通过它可以打开网页、接收网页内容、request、response参数,其为最核心对象。
var page = require('webpage');
// 获取文件系统对象,通过它可以操作操作系统的文件操作,包括read、write、move、copy、delete等。
var fs = require('fs');

常用API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 通过page对象通过url链接打开页面,加载完成后回调
page.open(url, function (status) {}
// 当page.open调用时,会首先执行该函数,在此可以预置一些参数或函数,用于后边的回调函数中
page.onLoadStarted = function() {}
// page的所要加载的资源在加载过程中,失败回调处理
page.onResourceError = function(resourceError) {}
// page的所要加载的资源在发起请求时,可以回调该函数
page.onResourceRequested = function(requestData, networkRequest) {}
// page的所要加载的资源在加载过程中,每加载一个相关资源,都会在此先做出响应,它相当于http头部分,核心回调对象为response,可以获取本次请求的cookies、userAgent等
page.onResourceReceived = function(response) {}
// 打印一些输出信息到控制台
page.onConsoleMessage = function (msg) {}
// alert也是无法直接弹出的,但可以回调alert的内容
page.onAlert = function(msg) {}
// 当page.open时,http请求(不包括所引起的其它的加载资源)出现了异常,如404、no route to web site等,都会在此回调显示。
page.onError = function(msg, trace) {}
// 当page.open打开的url,或者是在打开过程中进行了跳转,可以在这个函数中回调。
page.onUrlChanged = function(targetUrl) {}
// 当page.open的目标URL被真正打开后,会在调用open的回调函数前调用该函数,在此可以进行内部的翻页等操作
page.onLoadFinished = function(status) {}
// 在所加载的web page内部执行该函数,像翻页、点击、滑动等,均可在此中执行
page.evaluate(function(){});
// 将当前page的现状渲染成图片,输出到指定的文件中去。
page.render("");

实现

下载 phantomjs

可以直接去官网, 找到对应服务器操作系统的压缩包,然后下载。

1
$ wget https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-2.1.1-linux-x86_64.tar.bz2

但我在顺丰的服务器里下载 phantomjs 官网提供的压缩包,下载速度连 1KB/s 都没有。
或者也下载源码,进行编译(编译真的超级慢)。于是我只能先下载到本地,然后上传到七牛云 CDN 加速的存储空间。

下载 & 解压

1
2
$ wget https://cdn.bingo.ren/phantomjs-2.1.1-linux-x86_64.tar.bz2
$ tar -jxvf phantomjs-2.1.1-linux-x86_64.tar.bz2

phantomjs 的可执行脚本在根目录下的 bin 目录下,测试一下是否可用

1
2
$ phantomjs-2.1.1-linux-x86_64/bin/phantomjs -v
-->2.1.1

除此之外,还需要一个 js 脚本,来当 phantomjs 的参数被调用。

request.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var page = require('webpage').create();
var system = require('system');
var address = system.args[1]; // 截图页面地址
var output = system.args[2]; // 保存图片名
page.viewportSize = { width: 414*2, height: 736*2 }; // 页面初始高度
page.open(address, function (status) { // 打开页面
if (status === "success") { // 加载完成
// 通过在JS获取页面的渲染高度
var rect = page.evaluate(function () {
return document.getElementsByTagName('html')[0].getBoundingClientRect();
});
// 按照实际页面的高度,设定渲染的宽高
page.clipRect = {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height
};
// 预留一定的渲染时间
window.setTimeout(function () {
page.render(output);
// var base64 = page.renderBase64('JPEG');
// console.log(base64);
page.close();
console.log('success');
phantom.exit();
}, 1000);
}
});

渲染图片的 API 有两个:

  • page.render('IMG_NAME') : 渲染并保存图片到指定的路径
  • page.renderBase64('JPEG') : 渲染并转换图片为指定格式的 Base64 编码

切记,代码最后一定要加入 phantom.exit() 方法确保 phantomjs 退出。

测试

1
$ /bin/phantomjs request.js https://www.baidu.com images/baidu.png

看到终端控制台输出了success,则保存图片成功,实现了 html 页面的截屏。

JavaEE 调度

小程序调用 JavaEE 接口,应用在接口的实现类中去调 phantomJS,渲染生成图片,上传到七牛云,并把七牛云图片地址返回给前端,最终实现截屏。

声明常量

首先定义字符串常量,包括要访问并保存图片的 h5 页面地址,phantomJS 脚本路径,js 路径,保存图片的路径等等,根据项目的需求和服务器的配置自行修改。

DKConstant.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 蛋壳域名
*/
private static final String DK_DOMIN = "https://api-wxc.sf-rush.com/";
/**
* phantomJS 配置
*/
private static final String DK_PHANTOMJS_PATH = "/data/wwwroot/sftc.dankal.cn/phantomjs/";
/**
* phantomJS 脚本路径
*/
public static final String DK_PHANTOMJS_SHELLPATH = DK_PHANTOMJS_PATH + "bin/phantomjs";
/**
* phantomJS js路径
*/
public static final String DK_PHANTOMJS_JSPATH = DK_PHANTOMJS_PATH + "request.js";
/**
* phantomJS 截图保存路径
*/
public static final String DK_PHANTOMJS_OUTPUTPATH = DK_PHANTOMJS_PATH + "images/";
/**
* phantomJS 页面地址
*/
public static final String DK_PHANTOMJS_WEB_URL = DK_DOMIN + "web/index.html?order_id=";
/**
* phantomJS 图片地址
*/
public static final String DK_PHANTOMJS_IMAGES = DK_DOMIN + "phantomjs/images/";

封装网页截屏工具类

主要用到了 Runtime 去调用系统的

Runtime.getRuntime()可以取得当前 JVM 的运行时环境,是 Java 中唯一一个可以获取到运行时环境的方法。Runtime 可以用来调用外部命令,可以自己写一个 bat 或 shell 脚本,然后用 exec(cmd) 来调用它。

我们就用它去调用 phantomJS。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* HTML页面截屏工具类
*/
public class HtmlScreenShotUtil {
/**
* 网页截屏,并保存图片
*
* @param url 页面地址
* @param output 保存图片名(不带后缀)
*/
public static String screenShot(String url, String output) {
String outPutPath = DK_PHANTOMJS_OUTPUTPATH + output;
Runtime rt = Runtime.getRuntime();
StringBuilder sb = new StringBuilder();
try {
String cmd = DK_PHANTOMJS_SHELLPATH + " " + DK_PHANTOMJS_JSPATH + " " + url + " " + outPutPath;
Process process = rt.exec(cmd);
InputStream is = process.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
String tmp = "";
try {
while ((tmp = br.readLine()) != null) {
sb.append(tmp);
}
} catch (IOException e) {
e.printStackTrace();
}
} catch (IOException e) {
e.printStackTrace();
}
return sb.toString();
}
}

接口调用

请求

POST /order/share/screenShot

1
2
3
4
{
"order_id": 3631,
"name": "Bingo💤"
}

服务端业务逻辑

SpringMVC 相关的略过,主要贴一下在业务逻辑层中对工具类的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 订单分享屏幕截图
*/
public APIResponse screenShot(APIRequest request) {
JSONObject requestObject = JSONObject.fromObject(request.getRequestParam());
if (!requestObject.containsKey("order_id"))
return APIUtil.paramErrorResponse("order_id不能为空");
String url = DK_PHANTOMJS_WEB_URL + requestObject.getInt("order_id");
String imgName = System.currentTimeMillis() + ".jpg";
// 保存图片
String result = HtmlScreenShotUtil.screenShot(url, imgName);
// imgName = DK_PHANTOMJS_IMAGES + imgName; // 可直接访问的路径
JSONObject resultObject = new JSONObject();
if (result.endsWith("success")) {
// 上传七牛云,返回七牛图片路径
String imgSrc = qiniuService.uploadImageWithBase64(result.replace("success", ""));
resultObject.put("img", imgSrc);
return APIUtil.getResponse(SUCCESS, resultObject);
} else {
resultObject.put("error", result);
return APIUtil.submitErrorResponse("生成图片失败", resultObject);
}
}

我这里使用的渲染的方法是 page.renderBase64('JPEG') 而不是 page.render(output), 在控制台会先输出图片的 base64 编码,然后再输出 success,也就是以success结尾。

所以我的处理是,判断返回的结果是不是以success结尾,如果是,则说明是图片渲染成功,把结果字符串中的success替换为空字符串,那么剩下的就是图片的 base64 编码了。

接着调用自己封装的七牛云上传的服务类的方法,参数为图片的 base64 编码,把图片上传到七牛云,最后把返回的七牛云图片地址返回给前端,一整套操作就结束了。

为什么这里不直接用 imgName 呢?(看注释掉的那一行,由于「服务器上保存图片的地址」与「项目的根路径」是在同级目录,所以可以直接通过相同的域名访问到,imgName 其实是保存在服务器上的图片的 HTTP URL。)

原因有两点:

  • 因为有上传七牛云的需求,直接通过 base64 上传图片更快,更简单。
  • 我遇到了一个问题,一直保存不了图片。

这两个点都是坑,真的踩了很久,踩得很深,非常有必要记录下来。

接口响应

1
2
3
4
5
6
7
{
"state": 200,
"message": "success",
"result": {
"img": "https://sf.dankal.cn/share/15059086604928607cc30c67c.jpg"
}
}

效果

问题

渲染 Base64 的方式,上传图片失败

使用 page.renderBase64('JPEG'); 渲染并转换图片为指定格式的 Base64 编码,phantomjs 的这个 API 没有问题的。但是我遇到了一个情况,就是当 request.js 中的宽高设得很大时,会有问题。

1
page.viewportSize = { width: 414*2, height: 736*2 };

为了渲染的图片的分辨率足够高,保证图片足够清晰,我一开始直接用 Retina 屏 iPhone 7 的比例「4.7英寸,414 x 746(4倍)」,也就是 {width: 414*4, height: 736*4}

结果发现获取到的 base64 编码非常长,超过了字符串的最大长度 65355。那么获取到的 base64 编码在转为 Java 字符串的时候会抛异常,就算不抛异常,也是不完整的。上传肯定会失败的,就算上传成功,图片也是无法显示或者显示不完整的。

解决

理论上是解决不了的,但为什么我这里还是使用这种方案呢?因为经过调试,我发现2倍的4.7英寸的分辨率,刚刚合适,清晰度还行,Base64 编码的长度也只有几千。

因为我们的界面是有固定的模板的,生成的每一张图片几乎都只有文字内容是不同的,所差无几,很稳定。 Base64 编码的长度不会超过 65355,所以可以规避这种方案的缺陷。

感悟:真正合适的方案应该是经过比较、评估、权衡而得出来的,而不是理论上的。

保存图片的方式,保存不了图片

问题是这样的,在完成各种常规操作后,在我本机的 macOS 一点问题都没有。
同样的操作,部署到 centOS 后,shell 直接操作 phantomjs 没有问题。
单独写一个 Java 测试类来调用 phantomjs,在终端中用 javac 编译 java 文件,然后再 java 执行 class 文件,也没有问题。
但是在 centOS 部署了 JavaEE 后,前端访问接口,服务端再去调用 phantomjs,就出问题了。

通过 debug,发现确实有调用了 phantomjs,在 phantomjs 的 js 中 console.log 的输出也完全正常,可以看到已经爬到网页源代码,一切似乎都很正常,唯独图片没有被保存到服务器上。

tomcat 没有指定用户和权限,相关的目录权限都是 755。

解决

在万能的创软俱乐部中,我提出了我的问题,万能的超哥就提出了建议:

基本是权限问题了
ps指令看看真正的运行帐号是什么
也许只是你以为是root

我的第一反应就是,sudo ... 也就是在 cmd 前加 sudo。

1
rt.exec("sudo " + cmd);

然而并没有作用,好吧,耍小聪明还是没用的,还是看一下 ps 吧。先在我的 macOS 上执行:

1
2
3
$ ps -ef | grep tomcat
501 91736 85028 0 5:25PM ?? 0:51.52 /Library/Java/JavaVirtualMachines/jdk1.8.0_144.jdk/Contents/Home/bin/java -Djava.util.logging.config.file=/Users/bingo/Library/Caches/IntelliJIdea2017.1/tomcat/Unnamed_javaweb_ddj_2/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Dcom.sun.management.jmxremote= -Dcom.sun.management.jmxremote.port=1099 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false -Djava.rmi.server.hostname=127.0.0.1 -Djdk.tls.ephemeralDHKeySize=2048 -Djava.endorsed.dirs=/usr/local/apache-tomcat-8.0.36/endorsed -classpath /usr/local/apache-tomcat-8.0.36/bin/bootstrap.jar:/usr/local/apache-tomcat-8.0.36/bin/tomcat-juli.jar -Dcatalina.base=/Users/bingo/Library/Caches/IntelliJIdea2017.1/tomcat/Unnamed_javaweb_ddj_2 -Dcatalina.home=/usr/local/apache-tomcat-8.0.36 -Djava.io.tmpdir=/usr/local/apache-tomcat-8.0.36/temp org.apache.catalina.startup.Bootstrap start
501 91868 91555 0 5:26PM ttys001 0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn tomcat

可以看到用户是 501,就是当前我登录的用户,权限没有问题。接下来看一下服务器上的:

1
2
3
$ ps -ef | grep tomcat
root 306 32747 0 17:26 pts/0 00:00:00 grep --color tomcat
www 32111 1 2 16:33 ? 00:01:08 /usr/java/jdk1.8.0_121/bin/java -Djava.util.logging.config.file=/usr/local/tomcat/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Djava.security.egd=file:/dev/./urandom -server -Xms256m -Xmx938m -Dfile.encoding=UTF-8 -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Djava.library.path=/usr/local/apr/lib -Djava.endorsed.dirs=/usr/local/tomcat/endorsed -classpath /usr/local/tomcat/bin/bootstrap.jar:/usr/local/tomcat/bin/tomcat-juli.jar -Dcatalina.base=/usr/local/tomcat -Dcatalina.home=/usr/local/tomcat -Djava.io.tmpdir=/usr/local/tomcat/temp org.apache.catalina.startup.Bootstrap start

www ! 好吧,看来真的是权限问题。给 www 用户分配图片保存的目录的写入权限即可。

1
2
$ su www
$ chmod u+x /path/to/save/img

解决中文乱码

用 phantomjs 去截取中文页面的网站可能会出现乱码的情况,也就是截图中中文的位置全是方框。

解决办法:安装字体

1
2
3
4
# centOS
$ yum install bitmap-fonts bitmap-fonts-cjk
# ubuntu
$ sudo apt-get install xfonts-wqy

解决字体大小不一

执行上面命令安装完后,再去截图中文的页面就不会出现一堆的方框了,但是字体存在粗细不一致的问题,而且字体很丑,跟我在 macOS 下的苹方字体差别太大了。

解决办法:安装微软雅黑字体,建立字体索引,更新字体缓存。

微软雅黑字体 msyh.ttf 有点难找,可以从 windows 下拷贝过去,也可以在网上下载。由于我是 macOS,只能去网上找了,为了方便以后系统迁移以及多个环境需要再次下载,我已经传到 CDN 七牛云上了,下载地址:https://cdn.bingo.ren/msyh.ttf

1
2
3
4
5
6
7
8
9
10
11
12
13
# centOS
$ yum -y install mkfontscale fontconfig
# unbuntu
$ apt-get -y install fontconfig xfonts-utils
$ mkdir /usr/share/fonts/win/
# 下载微软雅黑字体
$ wget https://cdn.bingo.ren/msyh.ttf -O /usr/share/fonts/win/msyh.ttf
# 建立字体索引,更新字体缓存
$ cd /usr/share/fonts/win/
$ mkfontscale
$ mkfontdir
$ fc-cache

解决 Emoji 表情乱码

安装完微软雅黑字体后,再去截图,发现中文字体的还原度很高了,大小粗细等问题都没有了。但是,还有一个问题,就是当中文中包含 Emoji 表情,也就是 Unicode 编码的时候,又乱码了,全都是方框。

解决办法:安装支持 Unicode 编码的字体。

1
2
$ cd /usr/share/fonts/win/
$ wget https://cdn.bingo.ren/Segoe-UI-Symbol.ttf

然后再次建立字体索引,更新字体缓存

1
2
3
$ mkfontscale
$ mkfontdir
$ fc-cache

然而,虽然不会出现方框了,但是黑白的。暂时解决不了,翻遍了字体都没找到彩色的 emoji 字体,也许是我理解的概念不对?emoji 的显示似乎不是应该字体支持,而是浏览器支持,而 phantomjs 却是无界面的浏览器,所以无法渲染出理想的 emoji 表情?但是我在 macOS 下是可以截成功的,难道还跟系统的架构有关系?我尝试把 macOS 的字体全部导入 centOS,也无效,待深入研究,再看能否解决。

后话

没想到服务端生成图片会遇到那么多的坑,这里我只列举了主要的注意点,其实还有很多细节的问题会卡住整套流程,比如 phantomjs 截图宽高不对,需要按照实际页面的高度,设定渲染的宽高;又比如无法截长图,需要前端控制 CSS,不能让 htmlheight: 100% 等等。

尽管遇到了很多坑,做了很多尝试,花了我很多时间和精力去解决问题,最后还有黑白 emoji 的问题没有解决。但我一点也不感到意外,也不会后悔。因为在解决问题的过程中,我在不断开阔思维,不断扩充知识面。随便公司给我的时间不多,但我仍然会为了最后一个问题,去研究 emoji,研究 Unicode 编码,以及它在不同平台的不同浏览器的显示差异及原理等。

  • 本文作者: Bingo
  • 本文链接: https://blog.bingo.ren/51.html
  • 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!