Puppeteer 爬取文章实战


Puppeteer 是谷歌推出的一款无头浏览器,使用无头浏览器模拟真实用户操作,可以很方便地通过脚本爬取网页信息。下面我将使用 Puppeteer、generic-pool 和 express 来搭建一个通用的文章爬取接口。

一、接口设计

1. 通用性设计

要使接口能兼容不同的网站,这是个不小的挑战。
首先不同网站的文章信息基本都可以通过css选择器来选中,同时puppeteer是支持css选择器来爬取文章的,然而不同网站的文章信息的css选择器又不相同,因此css选择器是一个变数,需要在接口参数传入。
其次,不同网站可能会有防止爬虫的策略。对此,puppeteer有着天然的优势。只要有相对应的兼容策略,即可获取文章信息。不同的网站兼容策略不同,因此兼容策略的选择也需要在接口参数传入。

2. 多并发设计

如果爬取的文章数量不多,那程序将失去意义。爬取大量文章,就需要程序有足够高的效率。然而puppeteer是无头浏览器,需要对页面进行渲染,其效率比起普通的爬虫脚本要慢得多。我们可以多并发的爬取文章,增加服务器的资源利用率,从而提高效率。
puppeteer是通过 browser(浏览器)与 page(页面)两种资源来访问网页,可以将这两种资源纳入资源池来管理,从而获取多并发的能力,并且可以有效防止 browser 和 page 的频繁创建和销毁导致对服务器资源的过度开销。有关资源池在puppeteer的运用主要参考了【使用 generic-pool 优化 puppeteer 并发问题】,并使用 async/await 对其进行简化。

3. 大致流程

有了通用性与多并发的解决思路,就可以设计出大致的流程:

  1. 用户调用接口,输入:文章列表页面地址、css选择器、兼容策略
  2. 从 browserPool 中获取一个 browser
  3. 从 browser 中获取 pagePool
  4. 从 pagePool 中获取一个 page,并跳转至文章列表页面
  5. 根据css选择器,获取文章列表信息
  6. 遍历文章列表,从 pagePool 中获取多个 page,并发的跳转到各个文章详情页面
  7. 根据css选择器,获取文章详情信息
  8. 将文章信息持久化

4.接口参数

参数名描述
url文章列表页面地址
article_num收集文章数量
selectorcss选择器,json格式
policy兼容策略,json格式

二、代码解析

1. browserPool 的创建

先忽略有关使用 express 搭建 web 服务,直接看 puppeteer 相关代码

browser 的池化使多个请求隔离在不同的浏览器上,多并发地爬取不同网站的文章。

先安装 puppeteer 与 generic-pool

npm install puppeteer generic-pool -s --registry=https://registry.npm.taobao.org

创建配置文件 config.js,配置 puppeteer、browserPool 和 pagePool 参数
关于puppeteer的配置参考 puppeteer中文文档

module.exports = {
    puppeteer: {
        headless: false, // 是否启用无头模式页面
        //executablePath: '/usr/bin/chromium-browser',
        //args:['--no-sandbox'],
        ignoreHTTPSErrors: true,
        timeout: 0
    },
    browserPool: {
        min:1,
        max:5,
        idleTimeoutMillis:3600000
    },
    pagePool: {
        min:1,
        max:10,
        idleTimeoutMillis:300000
    }
}

创建 browserPool.js

const puppeteer = require('puppeteer')
const genericPool = require('generic-pool')
const config = require('../config')
const PagePool = require('./pagePool')

module.exports = createPool(config.browserPool);

/**
 * 初始化一个 Puppeteer 池
 * @param {Object} [options={}] 创建池的配置配置
 * @param {Number} [options.max=5] 最多产生多少个 puppeteer 实例 。如果你设置它,请确保 在引用关闭时调用清理池。 pool.drain().then(()=>pool.clear())
 * @param {Number} [options.min=1] 保证池中最少有多少个实例存活
 * @param {Number} [options.maxUses=500] 每一个 实例 最大可重用次数,超过后将重启实例。0表示不检验
 * @param {Number} [options.testOnBorrow=true] 在将 实例 提供给用户之前,池应该验证这些实例。
 * @param {Boolean} [options.autostart=false] 是不是需要在 池 初始化时 初始化 实例
 * @param {Number} [options.idleTimeoutMillis=3600000] 如果一个实例 60分钟 都没访问就关掉他
 * @param {Number} [options.evictionRunIntervalMillis=300000] 每 5分钟 检查一次 实例的访问状态
 * @param {Object} [options.puppeteerArgs={}] puppeteer.launch 启动的参数
 * @param {Function} [options.validator=(instance)=>Promise.resolve(true))] 用户自定义校验 参数是 取到的一个实例
 * @param {Object} [options.otherConfig={}] 剩余的其他参数 // For all opts, see opts at https://github.com/coopernurse/node-pool#createpool
 * @return {Object} pool
 */
function createPool(options = {}) {

  // 定义参数
  const {
    max = 5,
    min = 1,
    maxUses = 500,
    testOnBorrow = true,
    autostart = true,
    idleTimeoutMillis = 3600000,
    evictionRunIntervalMillis = 300000,
    validator = () => Promise.resolve(true),
    ...otherConfig
  } = options

  // 定义生命周期
  const factory = {
    create: async () => {
      let browser = await puppeteer.launch(config.puppeteer).catch(e=>{console.error(e)});
      let pagePoolConfig = config.pagePool;
      pagePoolConfig.browser = browser;
      let pagePool = await PagePool.createPool(pagePoolConfig);
      browser.pagePool = pagePool;
      browser.useCount = 0;
      return browser;
    },
    destroy: async browser => {
      await browser.pagePool.drain();
      browser.close();
    },
    validate: browser => {
      // 执行一次自定义校验,并且校验校验 实例已使用次数。 当 返回 reject 时 表示实例不可用
      return validator(browser).then(valid => 
        Promise.resolve(valid && (maxUses <= 0 || ++browser.useCount <= maxUses))
      )
    }
  }

  // 配置generic-pool
  const genericConfig = {
    max,
    min,
    testOnBorrow,
    autostart,
    idleTimeoutMillis,
    evictionRunIntervalMillis,
    ...otherConfig
  }

  // 初始化资源池
  var pool = genericPool.createPool(factory, genericConfig);

  // 定义使用方法
  pool.use = async fn => {
    let page = await pool.acquire();
    let result = await fn(page).catch(e => {
      pool.release(page);
      throw e;
    });
    pool.release(page);
    return result;
  }

  return pool;
}

注意在生命周期的 create 阶段,使用 puppeteer.launch() 创建 browser 对象,再创建pagePool对象,并将pagePool对象设置为 browser 的属性值,这样每个 browser 都拥有了自己的 pagePool。

以上代码还为 browserPool 定义了使用方法 use,参数是一个同步方法,这样做的好处是使用完 browser 资源,包括出现异常的时候,可以自动回收。

2. pagePool 的创建

page 的池化是为单次请求的多篇文章的爬取赋予了多并发的能力。

直接创建 pagePool.js

const genericPool = require('generic-pool')


/**
 * 初始化一个 Puppeteer 池
 * @param {Object} [options={}] 创建池的配置配置
 * @param {Number} [options.max=10] 最多产生多少个 puppeteer 实例 。如果你设置它,请确保 在引用关闭时调用清理池。 pool.drain().then(()=>pool.clear())
 * @param {Number} [options.min=0] 保证池中最少有多少个实例存活
 * @param {Number} [options.maxUses=500] 每一个 实例 最大可重用次数,超过后将重启实例。0表示不检验
 * @param {Number} [options.testOnBorrow=true] 在将 实例 提供给用户之前,池应该验证这些实例。
 * @param {Boolean} [options.autostart=false] 是不是需要在 池 初始化时 初始化 实例
 * @param {Number} [options.idleTimeoutMillis=300000] 如果一个实例 5分钟 都没访问就关掉他
 * @param {Number} [options.evictionRunIntervalMillis=30000] 每 30秒 检查一次 实例的访问状态
 * @param {Function} [options.validator=(instance)=>Promise.resolve(true))] 用户自定义校验 参数是 取到的一个实例
 * @param {Object} [options.otherConfig={}] 剩余的其他参数 // For all opts, see opts at https://github.com/coopernurse/node-pool#createpool
 * @return {Object} pool
 */
exports.createPool = async (options = {}) => {

  // 定义参数
  const {
    max = 1,
    min = 0,
    maxUses = 500,
    testOnBorrow = true,
    autostart = true,
    idleTimeoutMillis = 20000,
    evictionRunIntervalMillis = 10000,
    validator = () => Promise.resolve(true),
    browser = {},
    ...otherConfig
  } = options

  // 定义生命周期
  const factory = {
    create: async () => {
      let page = await browser.newPage();
      page.useCount = 0;
      return page;
    },
    destroy: page => {
      page.close();
    },
    validate: page => {
      // 执行一次自定义校验,并且校验校验 实例已使用次数。 当 返回 reject 时 表示实例不可用
      return validator(page).then(valid => 
        Promise.resolve(valid && (maxUses <= 0 || ++page.useCount <= maxUses))
      )
    }
  }

  // 配置generic-pool
  const generiConfig = {
    max,
    min,
    testOnBorrow,
    autostart,
    idleTimeoutMillis,
    evictionRunIntervalMillis,
    ...otherConfig
  }

  // 初始化资源池
  var pool = genericPool.createPool(factory, generiConfig);

  // 定义使用方法
  pool.use = async fn => {
    let page = await pool.acquire();
    let result = await fn(page).catch(e => {
      pool.release(page);
      throw e;
    });
    pool.release(page);
    return result;
  }
  
  return pool;
}

与 browserPool.js 不同的是,pagePool.js 不直接创建并暴露 pagePool 对象,
只是暴露了创建 pagePool 的方法,因为程序中将会创建不止一个pagePool。

3. 爬取文章列表

通过运行以下代码,可获得文章列表的标题、链接和封面。

const BrowserPool = require("./utils/browserPool");

/**
 * 参数说明:
 * url: 文章列表页面地址
 * article_num: 收集文章的数量
 * selector: css选择器
 * policy: 兼容策略
 */
module.exports = async function(url, article_num, selector, policy){

    // 获取browser并使用
    await BrowserPool.use(async browser=>{

        // 获取pagePool
        let pagePool = browser.pagePool;

        // 获取page并使用
        let list = await pagePool.use(async page=>{

            // 跳转到文章列表页面
            await page.goto(url,{timeout:0});
            await page.waitForSelector(selector.article_list);

            // 翻页到指定数量的文章,大部分网站通过页面滚动到底部自动翻页,这里模拟用户滚动页面
            let height = 0;
            while((await page.$$(selector.article_list)).length < article_num){
                await page.evaluate(()=>{
                    window.scrollBy(0,100);
                });
                height += 100;
                await page.waitFor(50);
            }

            // 滚动至网页底部,加载封面,因为大部分网站图片使用异步加载
            let scrollHeight = await page.evaluate(()=>{
                return document.body.scrollHeight;
            })
            for(; height<scrollHeight; height+=100){
              await page.evaluate(()=>{
                window.scrollBy(0,100);
              });
              await page.waitFor(50);
            }

            // 获取标题列表
            let title_list = await page.evaluate((article_num,selector)=>{
                let article_list = document.querySelectorAll(selector.title_list);
                let title_list = [];
                let i = 0;
                for(let article of article_list){
                    if(i++>=article_num){
                        break;
                    }
                    let title = article.innerText;
                    if(title_list.includes(title)){
                        title_list.push('');
                    } else{
                        title_list.push(article.innerText);
                    }
                }
                return title_list;
            },article_num,selector);

            // 获取封面列表
            let cover_pic_list = await page.evaluate((article_num,selector)=>{
                let cover_list = document.querySelectorAll(selector.cover_list);
                let cover_pic_list = [];
                let i = 0;
                for(let cover of cover_list){
                    if(i++ >= article_num){
                        break;
                    }
                    let cover_pic = '';
                    if(selector.cover_list.search(/^.*img$/)>=0){
                        cover_pic = cover.src;
                    }
                    cover_pic_list.push(cover_pic);
                }
                return cover_pic_list;
            },article_num,selector);

            // 获取文章链接
            let url_list = await page.evaluate((article_num,selector)=>{
                let article_list = document.querySelectorAll(selector.article_list);
                let url_list = [];
                let i = 1;
                for(let article of article_list){
                    if(i++>article_num){
                        break;
                    }
                    url_list.push(article.href);
                }
                return url_list;
            },article_num,selector);

            // 装配列表
            let list = [];
            for(let i=0; i<article_num; i++){
                list.push({
                    title: title_list[i],
                    origin_url: url_list[i],
                    cover_pic: cover_pic_list[i]
                });
            }
            
            return list;
        })

        console.info(list);
    })
}

4. 爬取文章详情

获取了文章列表,我们就可以对列表中的每篇文章的url进行访问,并爬取文章详情。

为了能同时开启多个 page,并发的爬取文章详情,这里使用了 Promise.all() 函数。

在 detal 方法的最后获得一个 article 对象,其中就包括了爬取到的作者、发布时间、内容等信息。在这之后可以按照自己的需求对 article 进行持久化。

const BrowserPool = require("./utils/browserPool");

/**
 * 参数说明:
 * url: 文章列表页面地址
 * article_num: 收集文章的数量
 * selector: css选择器
 * policy: 兼容策略
 */
module.exports = async function(url, article_num, selector, policy){

    // 获取browser并使用
    await BrowserPool.use(async browser=>{

        // 获取文章列表(省略)

        // 获取文章详情
        let tasks = [];
        for (let article of list){
            task = detail(pagePool,article,selector,policy);
            tasks.push(task);
        }
        await Promise.all(tasks);
    })

    async function detail(pagePool, article, selector, policy){

        // 获取page并使用
        article = await pagePool.use(async page=>{

            // 跳转详情页面
            await page.goto(article.origin_url,{timeout:0});
            
            // 等待内容加载完成
            let tasks = [];
            tasks.push(page.waitForSelector(selector.author,{timeout:30000}));
            tasks.push(page.waitForSelector(selector.content,{timeout:30000}));
            tasks.push(page.waitForSelector(selector.publish_time,{timeout:30000}));
            let loadResult = await Promise.all(tasks).catch(e=>{
                log.warn("页面无法加载:"+article.origin_url);
                return null;
            });
            if(loadResult == null){
                return null;
            }

            // 根据css选择器获取文章信息
            var data = await page.evaluate(selector => {
                var data = {};
                data.author = document.querySelector(selector.author).innerText;
                data.content = document.querySelector(selector.content).innerHTML.replace(/<\/{0,1}strong>/g,'');
                data.publish_time = document.querySelector(selector.publish_time).innerText;
                data.contentText = document.querySelector(selector.content).innerText;
                data.word_count = data.contentText.length;
                return data;
            },selector).catch(e=>{
                console.error("内容获取失败:"+article+"("+e+")");
                return null;
            });
            if(data == null){
                return null;
            }
            data.title = article.title;
            data.origin_url = article.origin_url;
            data.cover_pic = article.cover_pic;
            return data;
        })
        if(article==null){
            return null;
        }
    
        // 对 article 进行持久化(省略)
        
    }
}

5. 兼容策略

兼容策略并无固定的做法,需要根据不同网站定制不同的策略。

举个例子,有些网站,在文章列表页面是无法直接获取文章链接,它的文章链接隐藏在了js中的一个点击事件中。按照传统的爬虫程序,就不得不深入研究该html中的js。但puppeteer 不需要,我们只需要模拟用户点击这个文章,在新弹出的窗口就能获取文章链接了。代码如下:

            // 获取文章链接
            let url_list = [];
            if(policy.get_url == 'SELECTOR'){ // 通过选择器
                url_list = await page.evaluate((article_num,selector)=>{
                    let article_list = document.querySelectorAll(selector.article_list);
                    let url_list = [];
                    let i = 1;
                    for(let article of article_list){
                        if(i++>article_num){
                            break;
                        }
                        url_list.push(article.href);
                    }
                    return url_list;
                },article_num,selector)
            } else { // 通过点击
                let article_list = await page.$$(selector.article_list);
                let i = 1;
                for(let article of article_list){
                    if(i++>article_num){
                        break;
                    }
                    if(exist_indexs.includes(i-1)){
                        url_list.push('');
                        continue;
                    }
                    let newPagePromise = new Promise(x => browser.once('targetcreated', target => x(target.page())));//创建newPagePromise对象
                    await article.click();
                    await page.waitFor(1000);
                    let newPage = await newPagePromise;
                    url_list.push(newPage.url());
                    await newPage.close();
                }
            }

又比如,有些网站在文章详情页需要滚动至底部才会完整加载,可以使用如下代码:

            // 跳转详情页面
            await page.goto(article.origin_url,{timeout:0});

            // 滚动加载文章内容
            if(policy.get_content == 'SCROLL'){
                let scrollHeight = await page.evaluate(()=>{
                    return document.body.scrollHeight;
                })
                for(let height=0; height<scrollHeight; height+=50){
                await page.evaluate(()=>{
                    window.scrollBy(0,50);
                });
                await page.waitFor(200);
                }
            }

总之,无论网站怎么设计,都是要给用户使用的,而 puppeteer 可以模拟几乎所有的用户行为,所以 。。。你懂的

三、完整源码

码云地址

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值