最近遇到开发需求,需要将SpringBoot后端的一个H5网页,转换为图片,并发送到指定的接口上。由于要考虑到要兼容各种CSS样式和AJAX请求的因素,因此用内嵌浏览器的实现方法往往会导致网页样式出不来。
因此思路是操作服务器本地的Chrome,访问网页再“截图”为图片。然而Java本身并没有合适的控制本地Chrome的API,而nodejs的puppeteer提供了可以控制headless chrome的API接口,其中就包括对网页进行截屏的API。因此,决定用Java控制nodejs完成网页转图片文件的操作,再用Java读取转换完的图片文件进行操作。 这里以ubuntu服务器为例说明步骤如下:
- 安装Chrome或Chromium。前者可以在Chrome官网下载deb包(Chrome官网),后者直接用apt-get就可以安装(具体步骤)。
- 安装nodejs,具体步骤可参照github上的说明。
- 将npm的库变更为淘宝的镜像。
- 运行以下命令安装puppeteer。如果直接安装puppeteer会自动再安装一个Chromium,为了避免这个问题可以只安装puppeteer-core。详细说明。
cnpm i puppeteer-core
- 用Chrome访问需要转图片的H5网页,检查字体是否正常。如果发现字体缺失,可以从windows/fonts里copy字体到服务器上。如果原字体文件的格式为ttc,需要转换成ttf,可以安装fontforge,具体操作为
apt-get install fontforge
然后用fontforge将ttc另存为ttf,并将文件copy到服务器的/usr/share/fonts/truetype/xxx,其中xxx文件夹可以自行创建,然后运行以下命令,系统可以自动检测/usr/share/fonts及子目录下的ttf文件,刷新字体缓存。
sudo mkfontscale
sudo mkfontdir
sudo fc-cache -fv
fc-list
- 创建截图脚本screenshot.js,如下:
const puppeteer = require('puppeteer-core');
const os = require('os');
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
};
(async () => {
const browser = await puppeteer.launch({
headless: true,
executablePath: process.argv[2]
});
const page = await browser.newPage();
await page.goto(process.argv[3]);
// 仅指定宽度,高度根据返回结果确定
await page.setViewport({width:parseInt(process.argv[4]), height:0});
// 等待页面自动执行的AJAX
await timeout(10000);
const pagesize = await page.evaluate(() => {
// TODO: 在这里可以动态操作dom元素
return {
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight};
});
// 重新调整viewport大小,适配真实的页面
await page.setViewport({width:pagesize.width, height:pagesize.height});
const path = process.argv[5].replace('~',os.homedir());
await page.screenshot({path: path, fullpage: true});
console.log("file " + path + " is saved, page size:" + pagesize.width + "," + pagesize.height);
await browser.close();
})();
- 在Java里调用node,利用processbuilder构造命令行参数,再创建执行进程,并等待返回结果。
protected String generateScreenShot(Properties prop) throws Exception {
log.info("generate screenshot");
ProcessBuilder pb = new ProcessBuilder("node",
// 不指定这个参数的话,nodejs执行出现unhandled exception时,会block进程
"--unhandled-rejections=strict",
prop.getProperty("alert_screenshot_script_file"),
prop.getProperty("alert_screenshot_browser"),
prop.getProperty("alert_detail_url"),
prop.getProperty("alert_screenshot_width"),
prop.getProperty("alert_screenshot_file"));
pb.directory(ResourceUtils.getFile("classpath:" + prop.getProperty("alert_screenshot_script_path")));
// 将正常的output和error分开显示
pb.redirectErrorStream(false);
Process p = pb.start();
// 为了支持分开显示,采用线程的方法,同时防止read()之类的操作阻塞主线程
Thread t1 = printScreenShotInformation(p.getInputStream(), false);
Thread t2 = printScreenShotInformation(p.getErrorStream(), true);
// 等待执行
int exitcode = p.waitFor();
Thread.sleep(1000);
// 如果有线程有read()没返回,可以强制退出
t1.interrupt();
t2.interrupt();
String strFilePath = prop.getProperty("alert_screenshot_file").replaceFirst("^~", System.getProperty("user.home"));
log.info(String.format("fininsh saving a screen shot to %s with exit code:%d", strFilePath, exitcode));
return strFilePath;
}
protected Thread printScreenShotInformation(final InputStream in, boolean bIsErr) {
Thread t = new Thread() {
@Override
public void run() {
try {
int n;
StringBuilder sb = new StringBuilder();
while ((n = in.read()) != -1) {
char c = (char)n;
if(c == '\r' || c == '\n') {
if(sb.length() > 0) {
if(bIsErr) {
log.error(sb.toString());
}
else {
log.info(sb.toString());
}
sb = new StringBuilder();
}
}
else {
sb.append(c);
}
}
if(sb.length() > 0) {
if(bIsErr) {
log.error(sb.toString());
}
else {
log.info(sb.toString());
}
}
}
catch(Exception e) {
e.printStackTrace();
}
}
};
t.start();
return t;
}