网页巡检系统的搭建(下)

675 阅读10分钟

作者:Rui

上篇我们介绍了网页巡检系统的背景、需求分析和技术方案,本篇主要介绍方案的实现和在开发过程中需要注意的事项(踩坑点)。

功能清单

  1. 管理后台
  • 巡检页面列表页:设置巡检任务基本信息,如:目标链接、任务名、预期截图链接、设备类型......
  • 巡检日志列表页:巡检日志信息,如:状态码、白屏对比差异(与白屏的差异值)、预期对比差异(与预期的差异值)、预期截图链接、实际截图链接、网页错误信息......
  1. 巡检器(巡检服务)
  2. 消息通知: 利用企微通知、钉钉、电话或短信等方式进行通知
  3. 数据报表: 通过图表展示巡检日志的汇总信息

功能实现

功能清单的第1、3、4点都是比较容易实现的,这里就不多赘述,我们主要介绍巡检服务的实现。

名词说明

名词说明
预期截图页面的正确截图,通常需要后台使用者确定截图是否正确
当前截图每次巡检时得到的页面截图
差异图片pixelmatch 能将两张图片的差异反映在一张图片上,该图片称为差异图片

服务通信

巡检服务使用 Egg.js 框架开发,并与其他服务隔离部署,防止服务崩溃和排队影响其他服务运行,所以巡检服务与管理后台服务通信需要使用接口进行。

管理后台需要提供以下接口:

  1. 任务列表获取接口:巡检服务可通过该接口获取到每次需要检测的任务信息(通常是一个数组)。
  2. 更新预期截图接口:当巡检服务刚上线或者巡检任务发生变更(如:链接变更、网页迭代),巡检服务需要更新预期截图。
  3. 上传巡检日志接口:巡检服务完成一次巡检任务时,通过该接口上传巡检日志信息。

巡检服务需要提供接口:

  1. 更新巡检任务接口:当巡检任务发生变更(如:新增链接、链接变更、网页迭代),巡检服务需要跑出预期截图。

定时巡检

巡检服务是定时运行的,所以我们将它使用 Egg.js 的定时任务功能实现。在定时任务里,我们将实现各项检测逻辑。这里简单画了一下巡检的流程图:

这里简单解释一下一些步骤操作:

  1. 预期截图保存在项目里,方便每次巡检任务的截图对比。每次获取任务列表后,先判断任务的预期截图是否存在,没有则去下载。
  2. 先进行“访问不通检测”,如果目标链接是错误的,就没必要进行后续的“白屏检测”和“UI错乱检测”环节。
  3. 完成截图步骤后,如果任务不存在预期截图链接(巡检任务刚启动时),会将当前截图当成预期截图,并通过“更新预期截图接口”更新后台的任务信息。

页面截图

在上篇的技术调研提到,我们将使用 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;
}
  1. 复用 browser 和 page 实例。避免这两类实例的反复创建和销毁,不然影响服务的性能,复用它们的实例,有条件的话可以实现 browser 和 page 的实例池。
  2. 及时销毁 page 和 browser 实例,移除事件监听钩子。在逻辑的最后需要调用实例的 close 方法销毁,并且调用 page 的 removeAllListeners 方法移除所有事件监听钩子,否则容易出现内存泄露问题。
  3. 当前截图按天保存,方便后续批量删除。因为随着时间的推移,图片将会越来越多,截图按天保存,后续可以精准删除太久远的图片。
  4. 移动端页面的截图不全,{ fullPage: true } 不生效。在 Puppeteer 中,{ fullPage: true } 的滚动效果是按照 document.body 的滚动条作为标准的。也就是说,它会尝试捕获整个 body 元素的内容。如果滚动条是在内部 div 上,截图就会不完整。这里通过设置网页的 viewport 为 body 元素的 scrollHeight 和 scrollWidth(将视口宽高设置为网页宽高),可以得到移动端页面的完整截图。
  5. 对于不同类型的页面(如:移动端和 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;
  }
}
  1. 代码第33行,pixelmatch 将两张图片的差异反映在一张图片上,我们将该差异图片保存起来,它能直观地看到两张图片的差异点在哪,例如:

  2. 代码第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)。

页面性能评分检测

lighthouse 库


收集网页信息

通过 Puppeteer 的 page 实例提供的事件监听钩子获取到网页的某些信息,事件监听列表可查看官网文档

注意事项

  1. UI错乱检测不适用于动画较多(如:gif、视频背景、轮播图)的页面,因为这类页面产生的检测结果误差很大,很容易产生误报。
  2. UI错乱检测同样不适用于滚屏页面,因为目前的截图逻辑可能只能得到一屏的页面截图,即截图不全。
  3. 微信环境模拟存在问题,需要完成微信 sdk 方法重写。投放在微信公众号的页面可能会调用微信的 sdk 方法,但这些方法并不存在 chrome 内,所以需要特殊处理,比如对 Puppeteer 的 browser 实例重写微信的 sdk 方法,注入一些微信对象。