puppeteer爬虫小技巧

背景

puppeteer 也许是目前市面上最好用的爬虫工具(之一)(严谨)了,除了爬虫,puppeteer 还能用于自动化测试等诸多方面,我们手动在浏览器上进行的操作,几乎都可以使用 puppeteer 代替。

在我们编写网络爬虫的过程中会经常遇到各种问题,掌握下面 7 个技巧能非常有效的解决我们的很多问题。

本文我们先介绍 7 个编写 puppeteer 爬虫的小技巧,最后用一个爬取京东商品详情的 demo 来巩固学习。

1. 无头模式

我们在开发阶段,关闭无头模式能直观的预览页面的渲染效果,更快速的定位问题所在。但是这也会增加电脑的资源消耗,当我们的电脑配置较低时,或者有大量的 jest 测试同步进行,关闭无头模式很容易导致我们测试失败。此时就需要我们打开无头模式了,设置启动参数 headless: true 即可。

const puppeteer = require('puppeteer');

async function launch() {
   const browser = await puppeteer.launch({
       headless: true
   });
   console.log('Browser launched in headless mode!');
   await browser.close();
}

launch().catch(e => {});

2. slowMo

有时候因为网络原因或者其他页面渲染的原因,默认模式下 puppeteer 操作过快,会出现找不到页面元素等各种意想不到的异常,这时候我们可以设置 slowMo: number 来让 puppeteer 的每一步操作都间隔一定时间。

我们也可以使用 browser.waitForTarget() 方法来等待元素渲染完毕,但是在我使用的过程中即使使用了此方法,也经常会出现找不到页面元素的情况,不知为何,有知道的大佬还请指教。

或者使用 page.waitFor 方法来等待一定的时长,或者等待特定的元素渲染完毕,实际使用过程中此方法还是非常有效的。

const puppeteer = require('puppeteer');
async function launch() {
   const browser = await puppeteer.launch({
       headless: false,
     	 slowMo: 1000
   });
   const page = await browser.newPage();
   await page.waitFor(2000);
   console.log('Browser launched in headless mode!');
   await browser.close();
}

launch().catch(e => {});

3. 避免新建无用的 tab 页

一个最简单的 puppeteer 的 demo 如下:

const puppeteer = require('puppeteer');

(async () => {
  // 下面这一步启动浏览器时已经新建了一个空白 tab 页
  // 所以 await browser.newPage(); 大多数时候是多余的
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://www.baidu.com/');
  await page.screenshot({path: 'example.png'});

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

上述demo新建了一个多余的tab页,我们可以做如下改变:

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  // 获取已有的第一个 tab 页
  const page = (await browser.pages())[0]
  await page.goto('https://www.baidu.com/');
  await page.screenshot({path: 'example.png'});

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

这样我们就能避免新建多余的tab页。

4. 使用代理

很多网站会记录访问者的 IP,并根据 IP 来判断访问者是真实的用户还是爬虫,如果发现是爬虫在获取数据,就会做出相应的限制措施。这时候我们就能使用代理来伪装我们的访问IP,从而防止被网站限制访问。

我们可以到一个免费的代理提供商网站(如 Hidester )去注册一个账户,获取代理服务器地址,然后启动puppeteer浏览器时传入启动参数 '--proxy-server=PROXY_SERVER_ADDRESS' 并且在打开的页面登录我们的账户即可使用代理。

我们可以使用 page.authenticate() 来自动登录我们的账户。

const puppeteer = require('puppeteer');
async function launch() {
   const browser = await puppeteer.launch({
       headless: true,
       args: ['--proxy-server=PROXY_SERVER_ADDRESS']
   });
   console.log('Browser launched in headless mode!');
   const page = await browser.newPage()
   await page.authenticate({
       username: 'USERNAME',
       password: 'PASSWORD'
   });
   await browser.close();
}

launch().catch(e => {});

5. 设置 User-Agent

有些网站也会根据浏览器发送到请求头判断访问者是真实用户还是爬虫,这时候我们就可以通过设置 User-Agent  来防止被网站限制访问,使用 page.setUserAgent() 即可。

await page.setUserAgent(
   'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36'
);

6. 加上 await

puppeteer 的所有 api 都是返回一个 promise 的,大多数情况下我们需要在前面加上 await ,否则可能会出现意向不到的错误,特别是当我们的爬虫比较复杂的时候。

const puppeteer = require('puppeteer');

(async () => {
  const browser = await puppeteer.launch();
  const page = (await browser.pages())[0]
  page.goto('https://www.baidu.com/');
  page.screenshot({path: 'example.png'});

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

以上代码运行时就会报错,因为我们执行 page.gotopage.screenshot 的时候并没有加上 await ,而此时浏览器可能已经被关闭了。

7. 设置正确的视窗分辨率

有时候需要设置正确的视窗分辨率,让页面元素在视窗内渲染出来,我们才能在页面中查找到该元素,然后进行后续操作。目前绝大多数桌面显示器的分辨率是 1920x1080 或 1366x768,所以我们可以设置视窗为:

await page.setViewport({
   width: 1920,
   height: 1080
});

如果我们访问的是手机网站,我们也可以模拟手机浏览器进行访问:

const puppeteer = require('puppeteer');
// 获取 iPhone6 浏览器的模拟参数
const iPhone = puppeteer.devices['iPhone 6'];

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  // 模拟 iPhone6 的浏览器
  await page.emulate(iPhone);
  await page.goto('https://www.google.com');
  await browser.close();
})();

爬取京东商品详情

最后我们放一个实际的例子,看看怎样使用 puppeteer 爬取京东商品详情。

// jd.js
// 爬取京东商品详情
const fs = require("fs-extra");
const path = require("path");
const request = require("request");
const puppeteer = require("puppeteer");

/* ============================================================
  Promise-Based Download Function
============================================================ */
const downloadImage = (src, dest, callback) => {
  request.head(src, (err, res, body) => {
    if (err) {
      console.log(err);
      return;
    }
    src &&
      request(src)
        .pipe(fs.createWriteStream(dest))
        .on("close", () => {
          callback && callback(null, dest);
        });
  });
};

/* ============================================================
  wait until images to load
============================================================ */

function sleep(time) {
  return new Promise((resolve) => setTimeout(resolve, time));
}


/* ============================================================
  Download All products Images in a page
============================================================ */

async function downloadFromPage(key, url, page) {

  await page.goto(url);
  await sleep(5000)
  /**
   * 使用 [...Array().keys()] 快速批量产生图片元素的选择器
   * 有些选择器对应的元素是不存在于页面上的
   * 所以查找的时候需要 trycatch
   */
  const phoneImgs = [
    "#spec-list > ul > li.img-hover > img",
  ]
    .concat([...Array(10).keys()].map(i => `#spec-list > ul > li:nth-child(${i > 0 ? i : 1}) > img`));
  const detailImgs = [...Array(30).keys()].map(i => `#J-detail-content > div:nth-child(3) > img:nth-child(${i > 0 ? i : 1})`)
    .concat([...Array(30).keys()].map(i => `#J-detail-content > div:nth-child(4) > img:nth-child(${i > 0 ? i : 1})`))
    .concat([...Array(30).keys()].map(i => `#J-detail-content > div:nth-child(5) > img:nth-child(${i > 0 ? i : 1})`))
    .concat(["#J-detail-pop-tpl-top-new > div:nth-child(2) > div > img",
      "#J-detail-pop-tpl-top-new > div:nth-child(4) > div > img",]);
  let images = []
  /**
   * 使用 for...of...循环保证内部 promise 顺序执行
   * 不可以使用 foreach 循环
   */
  for (const selector of phoneImgs) {
    try {
      await sleep(200)
      // page.hover 能自动移动页面,让 selector 对应的元素出现在视图中
      // 这样我们才能操作元素
      await page.hover(selector)
      // 获取图片的 src 并存入数组中
      const src = await page.$eval("#spec-img", (img) => img.src);
      images = images.concat(src)
    } catch (error) { }
  }
  for (const selector of detailImgs) {
    try {
      await sleep(200)
      await page.hover(selector)
      const src = await page.$eval(selector, (img) => img.src);
      images = images.concat(src)
    } catch (error) { }
  }
  // 对图片的 src 去重
  images = [...new Set(images)]
  console.log(JSON.stringify(images, null, 2))
  // 循环下载所有图片
  for (const [i, url] of images.entries()) {
    try {
      // 图片的储存位置,
      const filepath = path.resolve(__dirname, `images/jd/${key}/${i}.png`)
      // 需要先保证文件存在
      fs.ensureFileSync(filepath)
      await downloadImage(url, filepath);
      console.log(filepath)
    } catch (error) {
      console.log(error)
    }
  }

}

// 我们爬取 vivo x30、vivo s6、iQOO Neo3 的商品详情
const pageUrls = {
  x30: "https://item.jd.com/100005755995.html",
  Neo3: "https://item.jd.com/100012820012.html",
  S6: "https://item.jd.com/100011924580.html"
};
(async () => {
  // 启动浏览器
  const browser = await puppeteer.launch({
    headless: false, // 关闭无头浏览器模式,方便我们查看运行情况
    // 如果打开无头模式,运行会更快
    defaultViewport: {
      width: 1500,
      height: 1000
    }
  });
  // 新建页面
  const page = await browser.newPage();
  const keys = Object.keys(pageUrls)
  for (const key of keys) {
    const url = pageUrls[key]
    await downloadFromPage(key, url, page)
  }
  await browser.close();
})();

安装了所需的依赖,运行程序 node jd.js ,就能在 images/jd 文件夹看到下载的商品详情的图片了。

参考链接:

  1. https://github.com/puppeteer/puppeteer
  2. https://dzone.com/articles/5-puppeteer-tricks-that-will-make-your-web-scrapin