美文网首页
Puppeteer使用总结

Puppeteer使用总结

作者: OF656 | 来源:发表于2019-10-31 13:27 被阅读0次

    Puppeteer使用总结

    Puppeteer是 Google Chrome 团队官方的 Headless Chrome 工具,平时常用它来完成一些烦杂的重复性工作,也写过一些爬虫,在浏览器中手动完成的大部分事情都可以使用 Puppeteer 完成。也算是测试同学手中的一大利器吧。

    安装

    就按管方文档中来吧,主要就是设置两个环境变量:

    # 如果不想安装Chromium.app
    # export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
    # 如果要安装Chromium.app,国外的源太慢,切回到国内的源
    # export PUPPETEER_DOWNLOAD_HOST=https://storage.googleapis.com.cnpmjs.org
    npm i puppeteer
    

    如果没有安装Chromium.app,要用本地的Chrome,只要设置好本地的Chrome位置即可:

    const browser = await puppeteer.launch({
       executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
       headless: false,
       slowMo: 500,
       devtools: true
     });
    

    在Docker上运行

    docker run -p 8080:3000 --restart always -d --name browserless browserless/chrome
    

    然后在脚本中

    const puppeteer = require('puppeteer');
     
    // 从 puppeteer.launch() 为:
    const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://localhost:3000' });
    const page = await browser.newPage();
     ...
    await page.goto(...);
    ...
    await browser.disconnect();
    

    注意:
    因为Chrome默认使用 /dev/shm 共享内存,但是 docker 默认 /dev/shm 很小。所以启动Chrome要添加参数 -disable-dev-shm-usage ,不用/dev/shm共享内存。

    获取Console内容

    page.on('console', async msg => {
      if (msg.text() === 'CONVEY_DONE') {
        await browser.close();
      }
    });
    

    加断点调试

    只要在前端 evaluate 的代码中加入 debugger 就可以了,当执行到此处时,会进入调试状态:

    await page.evaluate(() => {debugger;});
    

    添加自定义函数

    添加MD5函数

    const puppeteer = require('puppeteer');
    const crypto = require('crypto');
    
    puppeteer.launch().then(async browser => {
      const page = await browser.newPage();
      
      await page.exposeFunction('md5', text =>
        crypto.createHash('md5').update(text).digest('hex')
      );
      await page.evaluate(async () => {
        // 使用 window.md5 计算哈希
        const myString = 'PUPPETEER';
        const myHash = await window.md5(myString);
        console.log(md5 of ${myString} is ${myHash});
      });
      await browser.close();
    });
    

    添加readfile函数

    const puppeteer = require('puppeteer');
    const fs = require('fs');
    
    puppeteer.launch().then(async browser => {
      const page = await browser.newPage();
      await page.exposeFunction('readfile', async filePath => {
        return new Promise((resolve, reject) => {
          fs.readFile(filePath, 'utf8', (err, text) => {
            if (err)
              reject(err);
            else
              resolve(text);
          });
        });
      });
      await page.evaluate(async () => {
        // 使用 window.readfile 读取文件内容
        const content = await window.readfile('/etc/hosts');
        console.log(content);
      });
      await browser.close();
    });
    
    

    向中 window 添加方法的功能很强大,可以避免浏览器的一些限制。

    页面加载前定制处理

    evaluateOnNewDocument 可以指定函数在所属的页面被创建,并且所属页面的任意 script 执行之前被调用。可以用这个办法修改页面的javascript环境,比如给 Math.random 设定种子等。

    下面是在页面加载前重写 navigator.languages 属性的例子:

    // preload.js
    // 重写 `languages` 属性,使其用一个新的get方法
    Object.defineProperty(navigator, "languages", {
      get: function() {
        return ["en-US", "en", "bn"];
      }
    });
    // preload.js 和当前的代码在同一个目录
    const preloadFile = fs.readFileSync('./preload.js', 'utf8');
    await page.evaluateOnNewDocument(preloadFile);
    

    再举个重置定位信息的例子:

    //Firstly, we need to override the permissions
    //so we don't have to click "Allow Location Access"
    const context = browser.defaultBrowserContext();
    await context.overridePermissions(url, ['geolocation']);
    
    ...
    
    const page = await browser.newPage();
    //whenever the location is requested, it will be set to our given lattitude, longitude
    await page.evaluateOnNewDocument(function () {
        navigator.geolocation.getCurrentPosition = function (cb) {
            setTimeout(() => {
                cb({
                    'coords': {
                        accuracy: 21,
                        altitude: null,
                        altitudeAccuracy: null,
                        heading: null,
                        latitude: 0.62896,
                        longitude: 77.3111303,
                        speed: null
                    }
                })
            }, 1000)
        }
    });
    

    请求拦截

    举个例子,通过请求拦截器取消所有图片请求,这样可以加快执行的速度:

    const puppeteer = require('puppeteer');
    puppeteer.launch().then(async browser => {
      const page = await browser.newPage();
      await page.setRequestInterception(true);
      page.on('request', interceptedRequest => {
        if (interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg'))
          interceptedRequest.abort();
        else
          // 改写request对象
          interceptedRequest.continue(
            headers: Object.assign({}, request.headers(), {
                'SlaveID': '4c625b7861a92c7971cd2029c2fd3c4a'
            });
      });
      await page.goto('https://example.com');
      await browser.close();
    });
    

    注意 启用请求拦截器会禁用页面缓存。

    并行运行

    const puppeteer = require('puppeteer')
    const parallel = 5;
    
    (async () => {
      puppeteer.launch().then(async browser => {
        const promises = []
        for (let i = 0; i < parallel; i++) {
          console.log('Page ID Spawned', i)
          promises.push(browser.newPage().then(async page => {
            await page.setViewport({ width: 1280, height: 800 })
            await page.goto('https://en.wikipedia.org/wiki/' + i)
            await page.screenshot({ path: 'wikipedia_' + i + '.png' })
          }))
        }
        await Promise.all(promises)
        await browser.close()
      })
    })();
    

    前端运行的代码

    在运用Puppeteer过程中,免不得大量的运行在前端的代码,即运行在浏览器中的代码。主要用于查找元素、获取元元素的属性等,以下举几个例子说明:

    定位元素

    // button的id和class等属性变化,文本却不变,可以用innerText来准确定位操作它
    await page.evaluate(() => {
      let btns = [...document.querySelector(".HmktE").querySelectorAll("button")];
      btns.forEach(function (btn) {
        if (btn.innerText == "Log In")
          btn.click();
      });
    });
    
    

    获取元素信息

    一个thal 中的例子,回调函数可以接收多个参数:

      for (let h = 1; h <= numPages; h++) {
        // 跳转到指定页码
        await page.goto(`${searchUrl}&p=${h}`);
        // 执行爬取
        const users = await page.evaluate((sInfo, sName, sEmail) => {
          return Array.prototype.slice.apply(document.querySelectorAll(sInfo))
            .map($userListItem => {
              // 用户名
              const username = $userListItem.querySelector(sName).innerText;
              // 邮箱
              const $email = $userListItem.querySelector(sEmail);
              const email = $email ? $email.innerText : undefined;
              return {
                username,
                email,
              };
            })
            // 不是所有用户都显示邮箱
            .filter(u => !!u.email);
        }, USER_LIST_INFO_SELECTOR, USER_LIST_USERNAME_SELECTOR, USER_LIST_EMAIL_SELECTOR);
    
    await page.waitForSelector('.block-items');
    const orders = await page.$eval('.block-items', element => {
        const ordersHTMLCollection = element.querySelectorAll('.block-item');
        const ordersElementArray = Array.prototype.slice.call(ordersHTMLCollection);
        const orders = ordersElementArray.map(item => {
            const a = item.querySelector('.order-img a');
            return {
                href: a.getAttribute('href'),
                title: a.getAttribute('title'),
            };
        });
        return orders;
    });
    console.log(`found ${orders.length} order`);
    

    运行于前端的代码,主要是由 page.$eval()page.evaluate()之类的函数来执行。它们有些区别。 page.evaluate ,可传入多个参数,或第二个参数作为句柄,而 page.$eval 则针对选中的一个 DOM 元素执行操作。比如:

    // 获取 html
    // 获取上下文句柄
    const bodyHandle = await page.$('body');
    // 执行计算
    const bodyInnerHTML = await page.evaluate(dom => dom.innerHTML, bodyHandle);
    // 销毁句柄
    await bodyHandle.dispose();
    console.log('bodyInnerHTML:', bodyInnerHTML);
    

    page.$eval看上去简洁得多:

    const bodyInnerHTML = await page.$eval('body', dom => dom.innerHTML);
    console.log('bodyInnerHTML: ', bodyInnerHTML);
    

    截图

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

    // 截屏
    await page.screenshot({
        path: './full.png',
        fullPage: true
        // 也可截部分
        // clip: {x: 0, y: 0, width: 1920, height: 800}
    });
    // 截元素
    let [el] = await page.$x('#order-item');
    await el.screenshot({
        path: './part.png'
    });
    

    避免页面中DOM变化

    如果页面中DOM会被javascript改动时,可以考虑合并多个 async ,不要用:

    const $atag = await page.$('a.order-list');
    const link = await $atag.getProperty('href');
    await $atag.click();
    

    而是用用一个 async 代替:

    await page.evaluate(() => {
        const $atag = document.querySelector('a.order-list');
        const text = $atag.href;
        $atag.click();
    });
    

    两个运行环境

    Puppeteer代码是分别跑在Node.js和浏览器两个javascript运行时中的。Puppeteer脚本是运行在Node.js中的,但是 evaluateevaluateHandle 等操作DOM的代码却是运行在浏览器中的。同样,Puppeteer也提供了提供了 ElementHandleJsHandle 将 页面中元素和DOM对象封装成对应的 Node.js 对象,这样可以直接这些对象的封装函数进行操作 Page DOM。理解这些概念很重要。

    所以在执行前端代码时,前端代码函数会先被序列化传给浏览器再运行。所以,两个运行时不能共享变量:

    // 不能工作,浏览器中访问不到atag这个变量
    const atag = 'a';
    await page.goto(...);
    const clicked = await page.evaluate(() => document.querySelector(atag).click());
    

    只能用变量传递的方式:

    const atag = 'a';
    await page.goto(...);
    const clicked = await page.evaluate(($sel) => document.querySelector($sel).click(), atag);
    

    等待

    等待页面加载

    几个打开页面的函数,如goto、waitForNavigation、reload等函数内置有等待参数:waitUtil 和 timeout,可以用它来等待页面打开:

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

    另外,点击了链接之后,需要使用 page.waitForNavigation 来等待页面加载。

    await page.goto(...);
    await Promise.all([
        page.click('a'),
        await page.waitForNavigation()
    ]);
    

    等待元素或响应

    • page.waitForXPath:用XPath等待页面元素,返回对应的 ElementHandle 实例
    • page.waitForSelector :用CSS选择器等待页面元素,返回对应的 ElementHandle 实例
    • page.waitForResponse :等待响应结束,返回 Response 实例
    • page.waitForRequest:等待请求发起,返回 Request 实例
    await page.waitForXPath('//a');
    await page.waitForSelector('#gameAccount');
    await page.waitForResponse('.../api/user/123');
    await page.waitForRequest('.../api/users');
    

    自定义等待

    如果现有的等待机制都不能满足需求,puppeteer 还提供了两个函数:

    • page.waitForFunction:等待在页面中自定义函数的执行结果,返回 JsHandle 实例
    • page.waitFor:设置指定的等待时间
    await page.goto('...', { 
        timeout: 60000, 
        waitUntil: 'networkidle2' 
    });
    // 业务代码中设定window中的对象,存在表示加载完成
    let acquireHandle = await page.waitForFunction('window.ACQUIREDONE', {
        polling: 120
    });
    const acquireResult = await acquireHandle.jsonValue();
    console.info(acquireResult);
    

    基于Puppeteer的框架

    从上面看出Puppeteer编写脚本并不是很直观,可以考虑用其它更好的框架,比如Rize 。比如,用Rize写的代码类似于下面这样的,明显比原生的Puppeteer代码要简洁、直观的多。

    原生的Puppeteer代码:

    const puppeteer = require('puppeteer')
    void (async () => {
      const browser = await puppeteer.launch()
      const page = await browser.newPage()
      await page.goto('https://github.com')
      await page.screenshot({ path: 'github.png' })
      await browser.close()
    })()
    

    对比用Rize写的代码:

    const Rize = require('rize')
    const rize = new Rize()
    rize
      .goto('https://github.com')
      .saveScreenshot('github.png')
      .end()
    

    而且用Rize写代码时,仍然可以用原生Puppeteer的Api来写。

    性能优化

    • 如有可能尽量使用同一个浏览器实例,或多个实例指定相同的缓存路径,这样缓存可以共用
    • 通过请求拦截没必要加载的资源,比如图片或媒体等
    • 减少打开的 tab 页数量,以免占用太多的资源,长时间运行的Puppeteer脚本,最好定时重启 Chrome 实例
    • 启动Chrome时关闭没必要的配置,比如:-no-sandbox(沙箱功能),--disable-extensions(扩展程序)等

    相关文章

      网友评论

          本文标题:Puppeteer使用总结

          本文链接:https://www.haomeiwen.com/subject/hyxtbctx.html