Puppeteer使用总结

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(扩展程序)等
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容