【干货】Puppeteer前端检测最佳实践

avatar
公众号 @fliggyfe

本文来自飞猪前端美少女@清锁同学,最近使用 Puppeteer 进行前端页面检测的探索,梳理成文分享给大伙!

Puppeteer 是一个 Node 库,它提供了一个高级 API 来通过 DevTools 协议控制 Chromium 或 Chrome。在H5页面检测中可以通过Puppeteer的提供的api直接控制Chrome模拟用户操作来进行UI Test或者作为爬虫访问页面来收集数据加以使用。针对任意页面检测,我们的检测内容如下图所示: puppeteer.png

页面全屏截图

在页面检测中,需要使用puppeteer获取页面全屏截图,即包含页面滚动部分。根据官网提供的官网的demo 以及API pagescreenshot API,本以为简单几行代码就可以搞定,于是写了第一版demo。

const puppeteer = require('puppeteer');
const iPhone = puppeteer.devices['iPhone 6'];
const url = 'https://h5.m.taobao.com/trip/home/index.html';
(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.emulate(iPhone); // 生成 iPhone 6 模拟器
  await page.goto(url, { waitUntil: ['networkidle0'] }); // networkidle2 会一直等待,直到页面加载后同时没有存在 2 个以上的资源请求,这个种状态持续至少 500 ms

  await page.screenshot({path: 'pics/demo1.png', fullPage: true});

  await browser.close();
})();

结果截图如下所示:

问:为何没有截全屏

试了几个页面都只截首屏的图片,然后看下这几个页面的页面结构,发现这些页面的body高度或者为0,或者等于视口高度。

fullPage参数为true是对完整的页面viewport截图,针对页面需要滚动的部分也会截图。但若页面高度等于视口高度或者页面高度为0,则只会截取首屏图片。 看下fullPage为true针对页面超出视口高度的场景及其对应的截图:

滚动元素截屏

查看了各个页面的特点,发现了共同点:即滚动容器元素的scrollHeight一定大于视口高度。通过page.evaluate方法遍历所有div节点,找到一个scrollHeight大于视口高度的节点,将其标记为滚动节点。如果所有元素节点的scrollHeight都不大于视口高度,则body为滚动节点。

await page.evaluate(() => {
    const clientHeight = document.documentElement.clientHeight;
    const divs = [...document.querySelectorAll('div')];
    const len = divs.length;
    let boxEl = null;
    let i = 0; 
    for(; i < len; i++) {
      const div = divs[i];
      if(div.scrollHeight > clientHeight)  {
        boxEl = div;
        break;
      }
    }
    if(!boxEl && i === len) {
      boxEl = document.querySelector('body');
    }
    boxEl.setAttribute('id', 'Puppeteer_Page_Box');
  });

const scrollableSectionEl = await page.$('#Puppeteer_Page_Box');

// 针对元素截屏
await scrollableSectionEl.screenshot({
    path: 'pics/demo2.png',
    fullPage: true,
});

此时报错 screenshot的参数fullPage和clip只能用一个,元素使用fullPage时,会同时给clip设置默认值clip = { x: 0, y: 0, width, height, scale: 1 }; ,导致报错。因此针对元素截图不能使用fullPage:true,需要设置指定裁剪区域。

const bounding_box = await scrollableSectionEl.boundingBox();
  await scrollableSectionEl.screenshot({
    path: 'pics/demo22.png',
    clip: bounding_box,
  });

滑到页面底部

此时截图仍然不是全部页面。大多页面都是懒加载渲染,需要一直滚动页面直到到达页面底部,然后再截图。需要注意的点:页面加载中需要获取接口,根据接口返回渲染页面。此时定时器时间应该大于接口返回时间,否则最后截图仍是未完全加载页面

await page.evaluate(async (scrollableSectionEl, deviceHeight = 667) => {
    await new Promise((resolve, reject) => {
      let totalHeight = 0;
      let times = 0;
      const timer = setInterval(() => {
          times++;
          const scrollHeight = scrollableSectionEl.scrollHeight;
          scrollableSectionEl.scrollBy(0, deviceHeight);
          totalHeight = totalHeight + deviceHeight;
          // 设定最多加载10屏,
          if(times > 10 || totalHeight >= scrollHeight){
            clearInterval(timer);
            resolve();
          }
      }, 2000); // 设定页面加载接口在2s内返回
    }); 
  }, scrollableSectionEl, deviceHeight);

// 获取滚动容器的scrollHeight值
const scrollHeight = await page.$eval('#Puppeteer_Page_Box', el => el.scrollHeight);
const bounding_box = await scrollableSectionEl.boundingBox();
await scrollableSectionEl.screenshot({
    path: 'pics/demo3.png',
    clip: {
      x: bounding_box.x,
      y: bounding_box.y,
      height: scrollHeight, // 滚动元素截图高度为scrollHeight值
      width: deviceWidth,
    },
  });

此时页面截图:

只截取了最后一屏页面截图,整个scrollHeight剩下部分全部为空白。。。

虽然是调用元素截图,但是截图针对视口的。我们在设置元素截图的高度之前需要设置页面视口高度,保证页面视口大小等于元素截图高度。

await page.setViewport({
    height: scrollHeight,
    width: deviceWidth,
    deviceScaleFactor: 2,
    isMobile: true,
    hasTouch: true,
    isLandscape: false,
  });

  const bounding_box = await scrollableSectionEl.boundingBox();
  await scrollableSectionEl.screenshot({
    path: 'pics/demo4.png',
    clip: {
      x: bounding_box.x,
      y: bounding_box.y,
      height: scrollHeight,
      width: deviceWidth,
    },
  });

此时页面截图:

死链检测

在页面检测中,检验页面里是否存在“死链”是非常重要的。死链包括协议死链和内容死链两种形式。协议死链:页面的TCP协议状态/HTTP协议状态明确表示的死链,常见的如404、403、503状态等。内容死链:服务器返回状态是正常的,但内容已经变更为不存在、已删除或需要权限等与原内容无关的信息页面。针对我们的场景,只要用户不可正常访问的链接都认为是“死链”。由于前面截图时已经将页面滑到底部,就确保我们能够获取到页面所有内容。

标准死链检测

const standardLinks = await page.evaluate(() => {
    const els = [...document.querySelectorAll("a[href*='//']")];
    return els.map(el => {
        return el.href.trim();
    })
});
const badLinks = []; // 死链集合
for(const link of standardLinks) {
    const res = await page.goto(link);
    const status = res.status();
    if(status >= 400) { // 针对用户不可正常访问的链接认为是死链
      badLinks.push(link);
    }
}

特殊死链检测

针对大多数页面,通用死链检测已经能覆盖了。由于页面的多样性,历史原因等,很多链接不是直接写在链接元素上的,而是点击后调用js跳转。针对这种情况,需要识别这部分元素模拟点击操作,获取跳转链接。依据指定规则拿到所有的可跳转元素后,需要挨个执行点击操作,调用page.waitForNavigation方法等待新地址加载后再返回到检测页面。针对符合点击跳转规则的元素,但触发点击事件无需跳转时,调用page.waitForNavigation方法会导致打开新页面的超时错误,此时应忽略该错误继续往下触发点击跳转。

  const tapLinks = []; // 所有点击跳转的链接
  const visitLink = async (index = 0) => {
    try {
      const links = await page.$$("[data-click]:not([href*='//'])");  // 获取所有符合点击跳转规则的元素,根据页面实际情况定义选择器
      if (links[index]) {
        await links[index].tap(); // 模拟点击
        await page.waitForNavigation({ // 等待加载新页面地址
          timeout: 5000, // 新地址超时时间
          waitUntil: 'load'
        });
        const currentPage = await page.url();
        tapLinks.push(currentPage);
        await page.goBack({ waitUntil: "networkidle0" }); // 返回到原先的检测页面
        return visitLink(index + 1); // 继续检测下一个可点击元素
      }
    } catch(err) {
      await visitLink(index + 1); // 对于不含页面跳转的点击元素,会导致超时报错,此时可直接进入下一个模拟点击
    }
  };
  await visitLink();

通过上述操作,可以获得所有点击跳转的链接地址,如下图所示

通用死链检测

通过标准死链检测获取到所有a标签跳转的链接地址,通过特殊死链检测获取到所有点击元素js打开的链接地址,我们可以拿到页面上所有的链接地址。循环遍历打开链接地址,根据响应状态判断是否为用户不可正常访问的链接。

  const badLinks = []; // 死链集合
  const totalLinks = tapLinks.concat(standardLinks);
  for(const link of totalLinks) {
    const res = await page.goto(link);
    const status = res.status();
    if(status >= 400) {
      badLinks.push(link);
    }
  }

图片检测

页面检测中图片检测是非常重要的一环,我们默认通用图片检测为:图片大小检测、图片压缩检测、图片尺寸检测、图片相似度检测。

获取img图片大小&尺寸

const imgInfoList = await page.evaluate(async () => {
    const imgs = [...document.querySelectorAll('img')]; // 获取所有图片
    const promises = imgs.map(async img => {
      const size = '...';  // 获取图片字节大小,参见下文
      return {
        src: img.src, // 图片地址
        width: img.width, // 图片渲染宽度
        height: img.height, // 图片渲染高度
        naturalWidth: img.naturalWidth,  // 图片固有宽度
        naturalHeight: img.naturalHeight, // 图片固有高度
        size, // 图片字节大小
      };
    });
    const list = await Promise.all(promises);
    return list;
  });

获取背景图

页面使用图片的渲染方式不同,除了常规img标签引入图片外,很多图片以背景图的方式展示。如图所示

const bgImgInfoList = await page.evaluate(async () => {
    let bgImgs = [...document.querySelectorAll('div[placeholder]')];   // 依赖页面规则获取所有可能背景图的元素,根据页面实际情况定义选择器
    async function getBackgroundSize(elem) {
      var computedStyle = getComputedStyle(elem),
          image = new Image(),
          src = computedStyle.backgroundImage.replace(/url\((['"])?(.*?)\1\)/gi, '$2'),  // 背景图片地址
          cssSize = computedStyle.backgroundSize,
          elemW = parseInt(computedStyle.width.replace('px', ''), 10),
          elemH = parseInt(computedStyle.height.replace('px', ''), 10),
          elemDim = [elemW, elemH],
          computedDim = [],
          ratio;
      image.src = src;
      ratio = image.width > image.height ? image.width / image.height : image.height / image.width;
      cssSize = cssSize.split(' ');
      computedDim[0] = cssSize[0];
      computedDim[1] = cssSize.length > 1 ? cssSize[1] : 'auto';

      const size = '...';  // 获取图片字节大小,参见下文

     // 获取图片渲染尺寸大小
      if(cssSize[0] === 'cover') {
        if(elemDim[0] > elemDim[1]) {
            if(elemDim[0] / elemDim[1] >= ratio) {
                computedDim[0] = elemDim[0];
                computedDim[1] = 'auto';
            } else {
                computedDim[0] = 'auto';
                computedDim[1] = elemDim[1];
            }
        } else {
            computedDim[0] = 'auto';
            computedDim[1] = elemDim[1];
        }
      } else if(cssSize[0] === 'contain') {
          if(elemDim[0] < elemDim[1]) {
              computedDim[0] = elemDim[0];
              computedDim[1] = 'auto';
          } else {
              if(elemDim[0] / elemDim[1] >= ratio) {
                  computedDim[0] = 'auto';
                  computedDim[1] = elemDim[1];
              } else {
                  computedDim[1] = 'auto';
                  computedDim[0] = elemDim[0];
              }
          }
      } else {
          for(var i = cssSize.length; i--;) {
            if (cssSize[i].indexOf('px') > -1) {
              computedDim[i] = cssSize[i].replace('px', '');
            } else if (cssSize[i].indexOf('%') > -1) {
              computedDim[i] = elemDim[i] * (cssSize[i].replace('%', '') / 100);
            }
          }
      }

      return {
        src, // 图片地址
        size, // 图片字节大小
        width: computedDim[0], // 图片渲染宽度
        height: computedDim[1], // 图片渲染高度
        naturalWidth: image.naturalWidth,  // 图片固有宽度
        naturalHeight: image.naturalHeight, // 图片固有高度
      };
    }
    const promises = bgImgs.map(async el => {
      return await getBackgroundSize(el);
    });
    const list = await Promise.all(promises);
    return list;
  });
  });

获取图片字节大小

获取base64图片大小

上述获取图片字节大小的方式无法获取base64图片的字节大小,针对base64图片需要单独处理。我们先来了解下base64图片的编码原理。 Base64编码要求把3个8位字节(38=24)转化为4个6位的字节(46=24),之后在6位的前面补两个0,形成8位一个字节的形式。 如果剩下的字符不足3个字节,则用0填充,输出字符使用’=’,因此编码后输出的文本末尾可能会出现1或2个’=’。通过base64编码原理我们知道,base64的图片字符流中的每8个字符就有两个是用0补充,而且字符流的末尾还可能存在‘=’号。

// 需要计算文件流大小,首先把头部的'data:image/png;base64,'去掉
let str = src.replace(/^\s*data:image\/(png|gif|jpg|jpeg);base64,/, ''); 
const equalIndex = str.indexOf('=');
if (str.indexOf('=') > 0) {
   str = str.substring(0, equalIndex);  // 获取=前的内容
}
const strLength = str.length;
size = parseInt(strLength - (strLength / 8) * 2);  // base64图片字节大小

获取普通图片大小

	const res = await fetch(src, {
        method: 'HEAD'
    });
    size = res.headers.get('content-length');

最终代码

	const base64Reg = /^\s*data:([a-z]+\/[a-z0-9-+.]+(;[a-z-]+=[a-z0-9-]+)?)?(;base64)?,([a-z0-9!$&',()*+;=\-._~:@\/?%\s]*?)\s*$/i;
      const isBase64 = base64Reg.test(src);
      let size = 0;
      if(isBase64){
        let str = src.replace(/^\s*data:image\/(png|gif|jpg);base64,/, '');
        const equalIndex = str.indexOf('=');
        if (str.indexOf('=') > 0) {
            str = str.substring(0, equalIndex);
        }
        const strLength = str.length;
        size = parseInt(strLength - (strLength / 8) * 2);
      } else {
        const res = await fetch(src, {
          method: 'HEAD'
        });
        size = res.headers.get('content-length');
      }

图片大小 & 尺寸检测

图片大小检测标准

  1. 图片过大会导致网络请求耗时增加,使用户等待交互的时间过长,针对普通图片检测标准为:单个图片大小要小于50KB。
  2. base64图片过大会导致css体积过大,css过大直接影响页面渲染,因此针对base64图片检测标准为:单个图片大小要小于5KB

图片尺寸检测标准

图片太大而有效显示区域较小时会增加内存的消耗,应根据显示区域大小合理控制图片大小。针对所有类型图片的检测标准为:图片固有尺寸应小于图片渲染尺寸的3倍

上文已经获取到全部图片的大小,只要做简单的判断即可。

图片地址检测

检测标准

集团CDN图片提供的对小图片压缩,裁剪,质量压缩,提供图片webp格式等功能。除了icon等小图片,针对宽高大于100px的图片,应使用剪裁后的图片地址。 非集团CDN图片,可检测图片地址是否为webp图片,webp图片具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当优秀、稳定和统一。

集团CDN图片剪裁前后大小对比

如下图所示的普通商品图片

剪裁前原图地址:gw.alicdn.com/bao/uploade…

剪裁后图片地址:gw.alicdn.com/bao/uploade…

图片大小对比:商品原图大小是354KB,而剪裁后的图片只有10.2KB。剪裁后的图片能够极大地减少图片大小。

webp格式支持

针对支持webp的环境,需要检测图片地址是否为webp图片有效地址

图片相似度检测

检测标准

同一个页面,除了下拉箭头等小icon外,针对图片尺寸大于50px的图片,会做相似度检测。相似度大于95%的图片会标记出来,避免活动页或者商品页出现重复商品图片。

图片相似度检测算法

常见的图片相似度检测算法有:平均哈希算法、感知哈希算法、颜色分布法、内容特征法。我们检测使用的是平均哈希算法。使用该算法的原因是:计算速度快。

平均哈希算法步骤

  1. 缩小尺寸。最快速的去除高频和细节,只保留结构明暗的方法就是缩小尺寸。将图片缩小到8x8的尺寸,总共64个像素。摒弃不同尺寸、比例带来的图片差异。
  2. 简化色彩。将缩小后的图片,转为64级灰度。也就是说,所有像素点总共只有64种颜色。
  3. 计算所有64个像素的灰度平均值。
  4. 比较像素的灰度。将每个像素的灰度,与平均值进行比较。大于或等于平均值,记为1;小于平均值,记为0。
  5. 计算哈希值。将上一步的比较结果,组合在一起,就构成了一个64位的整数,这就是这张图片的指纹。如果图片放大或缩小,或改变纵横比,结果值也不会改变。增加或减少亮度或对比度,或改变颜色,对hash值都不会太大的影响。
  6. 计算哈希值的差异,得出相似度。

平均哈希算法实现

  // 缩小尺寸
   async function compressImg(src, imgWidth = 8){
        return new Promise(function(resolve){
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          const image = new Image();
          image.crossOrigin = 'Anonymous'; // 允许浏览器在下载图像数据时允许跨域访问请求
          image.onload = function(){
            canvas.width = imgWidth;
            canvas.height = imgWidth;
            ctx.drawImage(image, 0, 0, imgWidth, imgWidth);
            const data = ctx.getImageData(0, 0, imgWidth, imgWidth);
            resolve(data);
          }
          image.onerror = function(){
            resolve("");
          }
          image.src = src;
        });
   }

   // 简化色彩,转换成灰图
   function createGrayscale(imgData) {
      const newData = Array(imgData.data.length);
      newData.fill(0);
      imgData.data.forEach((_data, index) => {
        if ((index + 1) % 4 === 0) {
            const R = imgData.data[index - 3];
            const G = imgData.data[index - 2];
            const B = imgData.data[index - 1];
            const gray = ~~((R + G + B) / 3);
            newData[index - 3] = gray;
            newData[index - 2] = gray;
            newData[index - 1] = gray;
            newData[index] = 255;
        }
      });
      return createImgData(newData);
    }

   // 计算hash值
   function getAHashFingerprint(imgData) {
        const grayList = imgData.data.reduce((pre, cur, index) => {
            if ((index + 1) % 4 === 0) {
                pre.push(imgData.data[index - 1]);
            }
            return pre;
        }, []);
        const length = grayList.length;
        const grayAverage = grayList.reduce((pre, next) => (pre + next), 0) / length;
        return grayList.map(gray => (gray >= grayAverage ? 1 : 0)).join('');
    }

拿到所有图片的的hash值后,根据hash间的汉明距离判断相似度。

// 获取汉明距离
function getHammingDistance(str1, str2) {
    let distance = 0;
    const str1Arr = str1.split('');
    const str2Arr = str2.split('');
    distance = Math.abs(str1Arr.length - str2Arr.length);
    str1Arr.forEach((letter, index) => {
      if (letter !== str2Arr[index]) {
        distance++;
      }
    });
    return distance;
  }

const repeatImgs = []; // 存储相似图片
const len = imgList.length; // 所有图片
for(let i = 0; i < len - 1; i++) {
    for(let j = i + 1; j < len; j++) {
      const hash1 = newImgList[i].hash || '';
      const hash2 = newImgList[j].hash || '';
      const hammingDistance = getHammingDistance(hash1, hash2);
      const hammingSimilarity = ((hash1.length - hammingDistance) / hash1.length).toFixed(2);
      if(hammingSimilarity >= 0.95) { // 保存相似度大于95%的图片
        repeatImgs.push({
          src1: newImgList[i].src,
          src2: newImgList[j].src,
          hammingSimilarity,
        });
      }
    }
  }

图片相似度检测结果

总结

基于puppeteer的前端检测不仅能够发现页面死链,也可以针对指定的链接规则做检测,最大限度的保障页面链接的有效性。针对图片的检测能够最大限度的减小图片大小,减少网络请求耗时。针对商品促销页面等,可以通过图片相似度检测来避免出现重复商品。后续还会添加白屏检测、异常页面检测等功能,丰富前端检测功能更好地保障页面质量。

Last but not lease

阿里巴巴飞猪部门正在招聘前端,目前我们在 Serverless 、微前端运营工作台、端渲染、互动营销、招选投搭、智能化、体验技术、数据度量有不少建设,欢迎有能力同学进来落地技术产生业务价值,想带人同学过来直接带一个方向也是可以的,欢迎关注飞猪前端团队公众号【Fliggy F2E】直接聊天联系即可!