作者:Rui
上篇我们介绍了网页巡检系统的背景、需求分析和技术方案,本篇主要介绍方案的实现和在开发过程中需要注意的事项(踩坑点)。
功能清单
- 管理后台
- 巡检页面列表页:设置巡检任务基本信息,如:目标链接、任务名、预期截图链接、设备类型......
- 巡检日志列表页:巡检日志信息,如:状态码、白屏对比差异(与白屏的差异值)、预期对比差异(与预期的差异值)、预期截图链接、实际截图链接、网页错误信息......
- 巡检器(巡检服务)
- 消息通知: 利用企微通知、钉钉、电话或短信等方式进行通知
- 数据报表: 通过图表展示巡检日志的汇总信息
功能实现
功能清单的第1、3、4点都是比较容易实现的,这里就不多赘述,我们主要介绍巡检服务的实现。
名词说明
名词 | 说明 |
---|---|
预期截图 | 页面的正确截图,通常需要后台使用者确定截图是否正确 |
当前截图 | 每次巡检时得到的页面截图 |
差异图片 | pixelmatch 能将两张图片的差异反映在一张图片上,该图片称为差异图片 |
服务通信
巡检服务使用 Egg.js 框架开发,并与其他服务隔离部署,防止服务崩溃和排队影响其他服务运行,所以巡检服务与管理后台服务通信需要使用接口进行。
管理后台需要提供以下接口:
- 任务列表获取接口:巡检服务可通过该接口获取到每次需要检测的任务信息(通常是一个数组)。
- 更新预期截图接口:当巡检服务刚上线或者巡检任务发生变更(如:链接变更、网页迭代),巡检服务需要更新预期截图。
- 上传巡检日志接口:巡检服务完成一次巡检任务时,通过该接口上传巡检日志信息。
巡检服务需要提供接口:
- 更新巡检任务接口:当巡检任务发生变更(如:新增链接、链接变更、网页迭代),巡检服务需要跑出预期截图。
定时巡检
巡检服务是定时运行的,所以我们将它使用 Egg.js 的定时任务功能实现。在定时任务里,我们将实现各项检测逻辑。这里简单画了一下巡检的流程图:
这里简单解释一下一些步骤操作:
- 预期截图保存在项目里,方便每次巡检任务的截图对比。每次获取任务列表后,先判断任务的预期截图是否存在,没有则去下载。
- 先进行“访问不通检测”,如果目标链接是错误的,就没必要进行后续的“白屏检测”和“UI错乱检测”环节。
- 完成截图步骤后,如果任务不存在预期截图链接(巡检任务刚启动时),会将当前截图当成预期截图,并通过“更新预期截图接口”更新后台的任务信息。
页面截图
在上篇的技术调研提到,我们将使用 Puppeteer 进行页面运行和截图,它的执行流程如下:
示例代码:
const userAgentMap = {
// iphone 12/13 pro
h5: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1',
pc: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
// iphone 12/13 pro 微信环境
wx: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 wechatdevtools/1.06.2210310 MicroMessenger/8.0.5 webview/17001920801005283 webdebugger port/23497 token/62bc098101969b16b6ea74489d745e79',
};
const viewPortMap = {
pc: { width: 1600, height: 1080 },
wap: { width: 375, height: 667 },
};
/**
* 运行页面并得到页面的截图
* @param {Array<Object>} taskList 任务列表
* @return {Object} 截图结果
*/
async screenshot(taskList) {
if (!taskList?.length) return;
let browser = null;
let page = null;
try {
console.log('浏览器初始化开始');
browser = await puppeteer.launch({
headless: 'new', // 新版无头模式, 区别可参考: https://developer.chrome.com/docs/chromium/new-headless?hl=zh-cn
args: [
'--no-sandbox', // 禁用 Chromium 的沙箱
'--disable-setuid-sandbox', // 禁用 setuid 沙箱
'--disable-dev-profile', // 禁用开发者配置文件
'--disable-gpu', // 禁用 GPU 硬件加速
'--disable-extensions', // 禁用所有 Chromium 扩展
],
timeout: 30000,
});
console.log('浏览器初始化成功');
for (const task of taskList) {
try {
const { id, name, url, type, target_screen } = task;
if (!url) continue;
const errorEventList = [];
const consoleEventList = [];
// 获取第一个page对象, puppeteer默认会创建一个空白的页面
page = (await browser.pages())[0];
if (!page) {
page = await browser.newPage();
}
// 在打开网页前禁用缓存
await page.setCacheEnabled(false);
await page.setViewport(viewPortMap[type] || viewPortMap.wap);
// 设置用户代理
await page.setUserAgent(userAgentMap[type]);
// 采集页面的error事件
page.on('error', function(error) {
errorEventList.push(error);
});
// 采集页面的console事件, 只采集error类型
page.on('console', function(msg) {
msg.type() === 'error' && consoleEventList.push({
args: msg.args(),
location: msg.location(),
stackTrace: msg.stackTrace(),
text: msg.text(),
type: msg.type(),
});
});
// 页面连接超时时间为30s
await page.goto(url, { waitUntil: [ 'load', 'domcontentloaded', 'networkidle0', 'networkidle2' ], timeout: 30000 });
// 等待1.5s, 保证页面加载完成
await new Promise(r => setTimeout(r, 1500));
// 移动端页面需特殊处理
if (type !== TARGETTYPE.pc) {
// eslint-disable-next-line no-undef
const width = await page.evaluate(() => document.body.scrollWidth);
// eslint-disable-next-line no-undef
const height = await page.evaluate(() => document.body.scrollHeight);
// 移动端页面将视口缩放为375px,这样截图的宽度为375px
await page.setViewport({ width, height, deviceScaleFactor: 375 / width });
}
// 获取当前时间的字符串
const dayTimeStr = parseTime(new Date(), '{y}_{m}_{d}_{h}_{i}_{s}_{MS}');
const dayStr = dayTimeStr.slice(0, 10);
// 预期截图放同一个文件夹, 其他类型图片按天保存
const screenshotType = !target_screen ? 'target' : 'current';
if (target_screen && !fs.existsSync(path.resolve(__dirname, `../assets/${screenshotType}/${dayStr}`))) {
fs.mkdirSync(path.resolve(__dirname, `../assets/${screenshotType}/${dayStr}`));
}
const shortPicPath = `${screenshotType}/${!target_screen ? '' : dayStr + '/'}${id}_${name}_${dayTimeStr}.jpg`;
await page.screenshot({
path: path.resolve(__dirname, `../assets/${shortPicPath}`),
fullPage: true,
type: 'jpeg', // 保存为jpeg格式
quality: 90, // 图片质量
});
// 记录截图的图片路径
Object.assign(task, {
picPath: `/public/${shortPicPath}`,
errorEventList,
consoleEventList,
});
// 移除页面的事件监听, 否则监听器会在下一次任务持续生效,导致内存泄漏
page.removeAllListeners();
} catch (error) {
console.error(`${task.name}截图失败`);
console.error(error);
}
}
// 移除事件监听、关闭页面、关闭浏览器
page?.removeAllListeners();
await page?.close();
await browser?.close();
} catch (error) {
// 移除事件监听、关闭页面、关闭浏览器
page?.removeAllListeners();
await page?.close();
await browser?.close();
console.error('浏览器运行过程中报错');
console.error(error);
}
return taskList;
}
- 复用 browser 和 page 实例。避免这两类实例的反复创建和销毁,不然影响服务的性能,复用它们的实例,有条件的话可以实现 browser 和 page 的实例池。
- 及时销毁 page 和 browser 实例,移除事件监听钩子。在逻辑的最后需要调用实例的 close 方法销毁,并且调用 page 的 removeAllListeners 方法移除所有事件监听钩子,否则容易出现内存泄露问题。
- 当前截图按天保存,方便后续批量删除。因为随着时间的推移,图片将会越来越多,截图按天保存,后续可以精准删除太久远的图片。
- 移动端页面的截图不全,{ fullPage: true } 不生效。在 Puppeteer 中,{ fullPage: true } 的滚动效果是按照 document.body 的滚动条作为标准的。也就是说,它会尝试捕获整个 body 元素的内容。如果滚动条是在内部 div 上,截图就会不完整。这里通过设置网页的 viewport 为 body 元素的 scrollHeight 和 scrollWidth(将视口宽高设置为网页宽高),可以得到移动端页面的完整截图。
- 对于不同类型的页面(如:移动端和 pc 端),要根据页面的类型设置 page 的 viewport 和 userAgent,因为很多网站都是根据 userAgent 来判断当前设备类型,然后做特殊处理。
访问不通检测
访问不通检测比较简单,只需要请求目标链接,然后判断请求的状态码是否为200即可。
示例代码:
try {
const requsetResult = await ctx.curl(task.scene_url);
if (requsetResult.res.statusCode !== 200) {
console.error('页面访问失败');
return;
}
console.log('页面访问正常');
} catch (error) {
console.error('页面访问失败');
}
白屏检测、UI错乱检测
白屏检测和UI错乱检测的原理基本一样,都是通过图像对比的方式进行,只是它们对比的截图不一样:
- 白屏检测:当前截图 和 纯白色图片 对比
- UI错乱检测:当前截图 和 预期截图 对比
检测流程如下:
示例代码:
/**
* 比较两张图片的差异
* @param {Object} task 任务对象
* @return {Object} 对比结果
*/
async imageComparison(task) {
const sceneName = `${task.id}_${task.name}`;
try {
let currentPicPath = task.picPath;
// 如果当前截图不存在, 则重新生成当前截图
if (!currentPicPath) {
console.error(`${sceneName}实际图片不存在`);
const screenshotResult = await this.screenshot([ task ]);
if (!screenshotResult[0].picPath) {
// 再次生成实际图片失败, 则抛出异常
throw new Error(`${sceneName}实际图片生成失败`);
}
currentPicPath = screenshotResult[0].picPath;
}
// 预期截图
const targetPic = jpeg.decode(fs.readFileSync(path.resolve(__dirname, `../assets/${task.target_screen.split('/public/')[1]}`)));
// 当前截图
const currentPic = jpeg.decode(fs.readFileSync(path.resolve(__dirname, `../assets/${currentPicPath.split('/public/')[1]}`)));
const { width, height } = currentPic;
// diffPic图片需要使用png, jpeg会压缩图片, 导致diffPic图片和targetPic、currentPic的data长度不一致, pixelmatch会报错
const diffPic = new PNG({ width, height });
let whiteScreenDiff = 0;
let targetDiff = 0;
try {
// 白屏检测, 当前截图和纯白色图片对比, 如果匹配度为100%, 则说明页面是白屏
whiteScreenDiff = pixelmatch(currentPic.data, diffPic.data, null, width, height, { threshold: 0 });
// 和目标图片进行对比, threshold 默认为0.1
targetDiff = pixelmatch(targetPic.data, currentPic.data, diffPic.data, width, height);
} catch (error) {
// 对比程序报错时,认为是UI错乱
console.error(`${sceneName}图片对比程序错误`);
console.error(error);
targetDiff = width * height;
}
const dayTimeStr = parseTime(new Date(), '{y}_{m}_{d}_{h}_{i}_{s}_{MS}');
const dayStr = dayTimeStr.slice(0, 10);
// 图片按天保存, 如果diff目录不存在, 则创建
if (!fs.existsSync(path.resolve(__dirname, '../assets/diff/' + dayStr))) {
fs.mkdirSync(path.resolve(__dirname, '../assets/diff/' + dayStr));
}
const shortPicPath = `diff/${dayStr}/${sceneName}_${dayTimeStr}.png`;
// 将差异图片保存
fs.writeFileSync(path.resolve(__dirname, `../assets/${shortPicPath}`), PNG.sync.write(diffPic));
if ((whiteScreenDiff / (width * height)) < 0.1) {
console.log('白屏啦');
}
if ((targetDiff / (width * height)) > 0.1) {
console.log('UI错乱啦');
}
return {
currentPicPath,
diffPicPath: `/ht/public/${shortPicPath}`,
picWidth: width,
picHeight: height,
whiteScreenDiff,
whiteScreenDiffRatio: whiteScreenDiff / (width * height),
targetDiff,
targetDiffRatio: targetDiff / (width * height),
};
} catch (error) {
console.error(`${sceneName}图片对比过程错误`);
console.error(error);
return null;
}
}
-
代码第33行,pixelmatch 将两张图片的差异反映在一张图片上,我们将该差异图片保存起来,它能直观地看到两张图片的差异点在哪,例如:
-
代码第49和52行分别是白屏和UI错乱的判断条件,可自行调整。其中:
指标 说明 whiteScreenDiff / (width * height) 白屏差异像素与总像素的比值,数值越大表示越不可能是白屏,故该值越大越好 targetDiff / (width * height) 当前截图差异像素与总像素的比值,数值越大表示越可能是UI错乱,故该值越小越好
其他检测
微信封禁检测
实现也比较简单,先留个坑。
慢请求检测
通过 Puppeteer 的 page 实例的 request 和 requestfinished 的事件监听钩子得到每个请求的开始和结束时间,两个时间差即可确定每个请求的时长,请求耗时较长的请求需要特别重点关注。
资源大小检测
资源(如:图片、js文件、css文件)大小检测,先通过 Puppeteer 的 page 实例的 requestfinished 事件监听钩子得到 HTTPRequest,通过 HTTPRequest.headers() 得到响应头,根据响应头的 content-length 属性即可得到资源的大小。若有些请求响应头没有该字段,则可能是浏览器缓存导致,可尝试禁用浏览器和页面缓存:page.setCacheEnabled(false)。
页面性能评分检测
收集网页信息
通过 Puppeteer 的 page 实例提供的事件监听钩子获取到网页的某些信息,事件监听列表可查看官网文档。
注意事项
- UI错乱检测不适用于动画较多(如:gif、视频背景、轮播图)的页面,因为这类页面产生的检测结果误差很大,很容易产生误报。
- UI错乱检测同样不适用于滚屏页面,因为目前的截图逻辑可能只能得到一屏的页面截图,即截图不全。
- 微信环境模拟存在问题,需要完成微信 sdk 方法重写。投放在微信公众号的页面可能会调用微信的 sdk 方法,但这些方法并不存在 chrome 内,所以需要特殊处理,比如对 Puppeteer 的 browser 实例重写微信的 sdk 方法,注入一些微信对象。