Skip to content
Published:

Puppeteer - 通过 DevTools 协议控制 Chromium 或 Chrome 的自动化测试工具

Table of contents

Open Table of contents

如何创建一个 Browser 实例

puppeteer 提供了两种方法用于创建一个 Browser 实例:

//使用 puppeteer.launch 启动 Chrome
(async () => {
  const browser = await puppeteer.launch({
    headless: false, //有浏览器界面启动
    slowMo: 100, //放慢浏览器执行速度,方便测试观察
    args: [
      //启动 Chrome 的参数,详见上文中的介绍
      "–no-sandbox",
      "--window-size=1280,960",
    ],
  });
  const page = await browser.newPage();
  await page.goto("https://www.baidu.com");
  await page.close();
  await browser.close();
})();

//使用 puppeteer.connect 连接一个已经存在的 Chrome 实例
(async () => {
  //通过 9222 端口的 http 接口获取对应的 websocketUrl
  let version = await request({
    uri: "http://127.0.0.1:9222/json/version",
    json: true,
  });
  //直接连接已经存在的 Chrome
  let browser = await puppeteer.connect({
    browserWSEndpoint: version.webSocketDebuggerUrl,
  });
  const page = await browser.newPage();
  await page.goto("https://www.baidu.com");
  await page.close();
  await browser.disconnect();
})();

这两种方式的对比:

加载导航页面

Pupeeteer 中的基本上所有的操作都是异步的,以上几个 API 都涉及到关于打开一个页面,什么情况下才能判断这个函数执行完毕呢,这些函数都提供了两个参数 waitUtil 和 timeout,waitUtil 表示直到什么出现就算执行完毕,timeout 表示如果超过这个时间还没有结束就抛出异常。

await page.goto("https://www.baidu.com", {
  timeout: 30 * 1000,
  waitUntil: [
    "load", //等待 “load” 事件触发
    "domcontentloaded", //等待 “domcontentloaded” 事件触发
    "networkidle0", //在 500ms 内没有任何网络连接
    "networkidle2", //在 500ms 内网络连接个数不超过 2 个
  ],
});

以上 waitUtil 有四个事件,业务可以根据需求来设置其中一个或者多个触发才以为结束,networkidle0 和 networkidle2 中的 500ms 对时间性能要求高的用户来说,还是有点长的

等待元素、请求、响应

await page.waitForXPath("//img");
await page.waitForSelector("#uniqueId");
await page.waitForResponse("https://d.youdata.netease.com/api/dash/hello");
await page.waitForRequest("https://d.youdata.netease.com/api/dash/hello");

case1:截图

我们使用 Puppeteer 既可以对某个页面进行截图,也可以对页面中的某个元素进行截图:

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  //设置可视区域大小
  await page.setViewport({ width: 1920, height: 800 });
  await page.goto("https://youdata.163.com");
  //对整个页面截图
  await page.screenshot({
    path: "./files/capture.png", //图片保存路径
    type: "png",
    fullPage: true, //边滚动边截图
    // clip: {x: 0, y: 0, width: 1920, height: 800}
  });
  //对页面某个元素截图
  let [element] = await page.$x("/html/body/section[4]/div/div[2]");
  await element.screenshot({
    path: "./files/element.png",
  });
  await page.close();
  await browser.close();
})();

我们怎么去获取页面中的某个元素呢?

case2: 模拟用户登录

(async () => {
  const browser = await puppeteer.launch({
    slowMo: 100, //放慢速度
    headless: false,
    defaultViewport: { width: 1440, height: 780 },
    ignoreHTTPSErrors: false, //忽略 https 报错
    args: ["--start-fullscreen"], //全屏打开页面
  });
  const page = await browser.newPage();
  await page.goto("https://demo.youdata.com");
  //输入账号密码
  const uniqueIdElement = await page.$("#uniqueId");
  await uniqueIdElement.type("admin@admin.com", { delay: 20 });
  const passwordElement = await page.$("#password", { delay: 20 });
  await passwordElement.type("123456");
  //点击确定按钮进行登录
  let okButtonElement = await page.$("#btn-ok");
  //等待页面跳转完成,一般点击某个按钮需要跳转时,都需要等待 page.waitForNavigation() 执行完毕才表示跳转成功
  await Promise.all([okButtonElement.click(), page.waitForNavigation()]);
  console.log("admin 登录成功");
  await page.close();
  await browser.close();
})();

那么 ElementHandle 都提供了哪些操作元素的函数呢?

case3:请求拦截

请求在有些场景下很有必要,拦截一下没必要的请求提高性能,我们可以在监听 Page 的 request 事件,并进行请求拦截,前提是要开启请求拦截 page.setRequestInterception(true)。

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  const blockTypes = new Set(["image", "media", "font"]);
  await page.setRequestInterception(true); //开启请求拦截
  page.on("request", request => {
    const type = request.resourceType();
    const shouldBlock = blockTypes.has(type);
    if (shouldBlock) {
      //直接阻止请求
      return request.abort();
    } else {
      //对请求重写
      return request.continue({
        //可以对 url,method,postData,headers 进行覆盖
        headers: Object.assign({}, request.headers(), {
          "puppeteer-test": "true",
        }),
      });
    }
  });
  await page.goto("https://demo.youdata.com");
  await page.close();
  await browser.close();
})();

那 page 页面上都提供了哪些事件呢?

case4:植入 javascript 代码

Puppeteer 最强大的功能是,你可以在浏览器里执行任何你想要运行的 javascript 代码,下面是我在爬 188 邮箱的收件箱用户列表时,发现每次打开收件箱再关掉都会多处一个 iframe 来,随着打开收件箱的增多,iframe 增多到浏览器卡到无法运行,所以我在爬虫代码里加了删除无用 iframe 的脚本:

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto("https://webmail.vip.188.com");
  //注册一个 Node.js 函数,在浏览器里运行
  await page.exposeFunction("md5", text =>
    crypto.createHash("md5").update(text).digest("hex")
  );
  //通过 page.evaluate 在浏览器里执行删除无用的 iframe 代码
  await page.evaluate(async () => {
    let iframes = document.getElementsByTagName("iframe");
    for (let i = 3; i < iframes.length - 1; i++) {
      let iframe = iframes[i];
      if (iframe.name.includes("frameBody")) {
        iframe.src = "about:blank";
        try {
          iframe.contentWindow.document.write("");
          iframe.contentWindow.document.clear();
        } catch (e) {}
        //把iframe从页面移除
        iframe.parentNode.removeChild(iframe);
      }
    }
    //在页面中调用 Node.js 环境中的函数
    const myHash = await window.md5("PUPPETEER");
    console.log(`md5 of ${myString} is ${myHash}`);
  });
  await page.close();
  await browser.close();
})();

基于 antd 的鼠标拖拽失效问题

const dragAndDrop = async (
  page,
  sourceX,
  sourceY,
  destinationX,
  destinationY,
  waitTime
) => {
  await page.evaluate(`(async (sourceX, sourceY, destinationX, destinationY, waitTime) => {
  
        // 每步间隔
        const sleep = (waitTime) => {
            waitTime = waitTime === null ? 10 : waitTime;
            return new Promise(resolve => setTimeout(resolve, waitTime));
        }
  
        // 获取起始位置的元素、定义被拖拽元素当前所在元素、定义被拖拽元素前一个所在元素
        const elementDragged = document.elementFromPoint(sourceX, sourceY);
        var elementDraggedOverNow = elementDragged;
        var elementDraggedOverPrior = elementDraggedOverNow;
  
        // 创建 DataTransfer 对象
        const dataTransfer = new DataTransfer();
        dataTransfer.effectAllowed = 'all';
        dataTransfer.dropEffect = 'move';
  
        // 开始拖拽被拖拽元素触发 dragstart
        elementDragged.dispatchEvent(
            new DragEvent('dragstart', {
                dataTransfer,
                bubbles: true
            })
        );
  
        // 被拖拽元素进入可放置元素触发 dragenter
        elementDragged.dispatchEvent(
            new DragEvent('dragenter', {
                dataTransfer,
                bubbles: true
            })
        );
        await sleep(waitTime);
  
        // 拖拽被拖拽元素
        const dragMove = async (x, y) => {
            
            elementDragged.dispatchEvent(
                new DragEvent('drag', {
                    dataTransfer,
                    bubbles: true,
                    clientX: x,
                    clientY: y,
                })
            );
            elementDraggedOverNow = document.elementFromPoint(x, y);
            // 是否进入新元素,如果是,应触发前一个元素的 dragleave 和新元素的 dragenter
            if (!elementDraggedOverNow.isSameNode(elementDraggedOverPrior)) {
                elementDraggedOverPrior.dispatchEvent(
                    new DragEvent('dragleave', {
                        dataTransfer,
                        bubbles: true,
                        clientX: x,
                        clientY: y,
                    })
                );
                elementDraggedOverNow.dispatchEvent(
                    new DragEvent('dragenter', {
                        dataTransfer,
                        bubbles: true,
                        clientX: x,
                        clientY: y,
                    })
                );
                elementDraggedOverPrior = elementDraggedOverNow;
            }
            // 被拖拽元素在可放置元素上时触发 dragover
            elementDraggedOverNow.dispatchEvent(
                new DragEvent('dragover', {
                    dataTransfer,
                    bubbles: true,
                    clientX: x,
                    clientY: y,
                })
            );
            await sleep(waitTime);
        }
        // 先水平拖拽
        if (sourceX <= destinationX) {
            for (let x = sourceX; x <= destinationX; x++) {
                await dragMove(x, sourceY);
            }
        } else {
            for (let x = sourceX; x >= destinationX; x--) {
                await dragMove(x, sourceY);
            }
        }
  
        // 再垂直拖拽
        if (sourceY <= destinationY) {
            for (let y = sourceY; y <= destinationY; y++) {
                await dragMove(destinationX, y);
            }
        } else {
            for (let y = sourceY; y >= destinationY; y--) {
                await dragMove(destinationX, y);
            }
        }
  
        // 在被拖拽元素当前所在的元素上放置被拖拽元素时触发 drop
        elementDraggedOverNow.dispatchEvent(
            new DragEvent('drop', {
                bubbles: true,
                dataTransfer
            })
        );
        await sleep(waitTime);
  
        // 结束拖拽被拖拽元素触发 dragend
        elementDragged.dispatchEvent(
            new DragEvent('dragend', {
                dataTransfer,
                bubbles: true
            })
        );
  
        // 当元素变得不再是拖动操作的选中目标时触发 dragexit
        elementDragged.dispatchEvent(
            new DragEvent('dragexit', {
                dataTransfer,
                bubbles: true
            })
        );
        await sleep(waitTime);
  
    })(${sourceX}, ${sourceY}, ${destinationX}, ${destinationY}, ${waitTime})`);
};

case5: 如何抓取 iframe 中的元素

一个 Frame 包含了一个执行上下文(Execution Context),我们不能跨 Frame 执行函数,一个页面中可以有多个 Frame,主要是通过 iframe 标签嵌入的生成的。其中在页面上的大部分函数其实是 page.mainFrame().xx 的一个简写,Frame 是树状结构,我们可以通过 frame.childFrames() 遍历到所有的 Frame,如果想在其它 Frame 中执行函数必须获取到对应的 Frame 才能进行相应的处理

以下是在登录 188 邮箱时,其登录窗口其实是嵌入的一个 iframe,以下代码时我们在获取 iframe 并进行登录

(async () => {
  const browser = await puppeteer.launch({ headless: false, slowMo: 50 });
  const page = await browser.newPage();
  await page.goto("https://www.188.com");
  //点击使用密码登录
  let passwordLogin = await page.waitForXPath('//*[@id="qcode"]/div/div[2]/a');
  await passwordLogin.click();
  for (const frame of page.mainFrame().childFrames()) {
    //根据 url 找到登录页面对应的 iframe
    if (frame.url().includes("passport.188.com")) {
      await frame.type(".dlemail", "admin@admin.com");
      await frame.type(".dlpwd", "123456");
      await Promise.all([frame.click("#dologin"), page.waitForNavigation()]);
      break;
    }
  }
  await page.close();
  await browser.close();
})();

case7: 文件的上传和下载

在自动化测试中,经常会遇到对于文件的上传和下载的需求,那么在 Puppeteer 中如何实现呢?

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  //通过 CDP 会话设置下载路径
  const cdp = await page.target().createCDPSession();
  await cdp.send("Page.setDownloadBehavior", {
    behavior: "allow", //允许所有下载请求
    downloadPath: "path/to/download", //设置下载路径
  });
  //点击按钮触发下载
  await (await page.waitForSelector("#someButton")).click();
  //等待文件出现,轮训判断文件是否出现
  await waitForFile("path/to/download/filename");

  //上传时对应的 inputElement 必须是<input>元素
  let inputElement = await page.waitForXPath('//input[@type="file"]');
  await inputElement.uploadFile("/path/to/file");
  browser.close();
  // 如果需要上传多个文件
  //  await inputElement.uploadFile('路径1','路径2' ...,'路径n');
})();

case8:跳转新 tab 页处理

在点击一个按钮跳转到新的 Tab 页时会新开一个页面,这个时候我们如何获取改页面对应的 Page 实例呢?可以通过监听 Browser 上的 targetcreated 事件来实现,表示有新的页面创建:

let page = await browser.newPage();
await page.goto(url);
let btn = await page.waitForSelector("#btn");
//在点击按钮之前,事先定义一个 Promise,用于返回新 tab 的 Page 对象
const newPagePromise = new Promise(res =>
  browser.once("targetcreated", target => res(target.page()))
);
await btn.click();
//点击按钮后,等待新tab对象
let newPage = await newPagePromise;

case9: 模拟不同的设备

Puppeteer 提供了模拟不同设备的功能,其中 puppeteer.devices 对象上定义很多设备的配置信息,这些配置信息主要包含 viewport 和 userAgent,然后通过函数 page.emulate 实现不同设备的模拟

const puppeteer = require("puppeteer");
const iPhone = puppeteer.devices["iPhone 6"];
puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.emulate(iPhone);
  await page.goto("https://www.google.com");
  await browser.close();
});

注意 旧方法 (Puppeteer versions <= v1.14.0) 中设备可以通过 require('puppeteer/DeviceDescriptors') 方法获得。