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. 大致流程
有了通用性与多并发的解决思路,就可以设计出大致的流程:
- 用户调用接口,输入:文章列表页面地址、css选择器、兼容策略
- 从 browserPool 中获取一个 browser
- 从 browser 中获取 pagePool
- 从 pagePool 中获取一个 page,并跳转至文章列表页面
- 根据css选择器,获取文章列表信息
- 遍历文章列表,从 pagePool 中获取多个 page,并发的跳转到各个文章详情页面
- 根据css选择器,获取文章详情信息
- 将文章信息持久化
4.接口参数
参数名 | 描述 |
---|---|
url | 文章列表页面地址 |
article_num | 收集文章数量 |
selector | css选择器,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 可以模拟几乎所有的用户行为,所以 。。。你懂的