puppeteer从入门到实践

关于puppeteer

1
2
3
puppeteer是一个node库,他提供了一组用来操纵ChromeAPI(默认headless也就是无UI的chrome,也可以配置为有UI)

有点类似于PhantomJS,但PuppeteerChrome官方团队进行维护的,前景更好。

puppeteer可以做什么

  1. 利用网页生成PDF、图片
  2. 爬取SPA应用,并生成预渲染内容(即“SSR” 服务端渲染)
  3. 可以从网站抓取内容
  4. 自动化表单提交、UI测试、键盘输入等
  5. 帮你创建一个最新的自动化测试环境(chrome),可以直接在此运行测试用例
  6. 捕获站点的时间线,以便追踪你的网站,帮助分析网站性能问题

基本了解之后,我们今天打算来用puppeteer做一个小爬虫试试。

环境和安装

puppeteer依赖v6.4以上的Node,在这里我推荐安装v7.6以上版本的node,便于我们更好地使用async/wait,如果你的机器上已经安装了node,同时对版本又有特定的需求,我建议你了解一下nvm,node版本管理神器,实现多个node版本自由切换。另外,我这里采用v9.4.0版本的node。

puppeteer也是一个npm包,所以安装还是老套路。

1
npm i puppeteer --save

puppeteer安装时自带一个最新版本的Chromium,如果你没有梯子的话,建议使用淘宝的cnpm安装,这样会自动地从淘宝的源下载Chromium。如果你本地已经安装Chromium,可以通过设置环境变量或者npm config中的PUPPETEER_SKIP_CHROMIUM_DOWNLOAD跳过下载,我这里使用npm官方源安装。

小试牛刀-生成网页截图

puppeteer安装完成之后,我们先来个小demo练练手,对指定网站进行截图,我们就拿公司内网试试吧。创建screenshot.js文件,贴入以下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 加载先前安装的 puppeteer
const puppeteer = require('puppeteer')

async function getPic() {
// 创建chrome实例
const browser = await puppeteer.launch()
// 打开一个新的选项卡
const page = await browser.newPage()
// 设置页面宽高
page.setViewport({
width: 1920,
height: 2000
})
// 在我们创建的这个选项卡下,转到公司内网首页
await page.goto('http://777.nd.com.cn/html/index.html')
// 在当前目录下生成nd.png截图
await page.screenshot({path: 'nd.png'})
// 最后,关闭浏览器
await browser.close()
}

getPic()

在当前目录执行node screenshot.js命令,不出意外的话我们将会得到如下截图。

爬虫实践

我们可以轻易地想到,puppeteer功能如此强大,用来做爬虫是再好不过了。接下来的内容也是我自己的实践:网易云音乐的歌曲下面总是有各种各样的神评论,所以我打算用puppeteer来收集一下云音乐的评论。

需求分析

我们的需求是输入一个音乐人的主页链接,收集这个音乐人下的所有热门单曲的所有评论。例如音乐人赵雷的主页下有47首热门单曲,他的主页是http://music.163.com/#/artist?id=6731,接下来我们一步一步实现。

分析音乐人主页的DOM结构

我们发现主页的整个页面是由一个iframe包裹的,iframe中有一个id是song-list-pre-cache的div,该div下有一个table,table下的每一个a标签,就是每一首歌的url以及歌名了。

接下来我们进行第一步。

获取主页歌曲列表

这一步开始前,我们先分析一下具体操作步骤:打开音乐人主页->取到对应iframe->取到id为song-list-pre-cache的节点->遍历所有的a标签节点->取到歌曲的url和名称。

关于分析DOM结构,我这里使用cheerio,其简介为“为服务器特别定制的,快速、灵活的jQuery核心实现”。

我们新建song-list.js文件,执行以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const puppeteer = require('puppeteer')
const cheerio = require('cheerio')
const _ = require('lodash')

const host = 'http://music.163.com/#'

puppeteer
.launch({headless: true})
.then(async browser => {
const page = await browser.newPage()
page.setViewport({width: 1367, height: 768})
// 在网络连接数为0时,再进行下一步操作,确保页面加载完成
await page.goto(`${host}/artist?id=6731`, {waitUntil: 'networkidle0'})
const frames = await page.frames()
// 取到目标iframe
const targetFrame = frames.find(frame => {
return frame.name() === 'contentFrame'
})
const content = await targetFrame.content()
let $ = cheerio.load(content)
const $list = $('#song-list-pre-cache')
// 遍历a标签,并取到其中的对应属性,作为歌曲的链接和名称
const songList = $list
.find('a')
.map((i, elem) => {
const href = $(elem).attr('href')
if (_.startsWith(href, '/song?id=')) {
const title = $(elem).find('b').attr('title')
return {href, title}
}
})
.get()
// 打印歌曲列表
console.log(songList)
await browser.close()
})

不出意外的话,控制台将会打印出音乐人的歌曲列表,列表的每一项包含歌曲的url和title,如下图。

跳转到歌曲页面

在上一步,我们拿到了歌曲的url,那么接下来自然就是跳转到每首歌的页面,进行评论抓取了。

歌曲页面的DOM结构和音乐人页面很类似,也有一个iframe,iframe中有一个id为comment-box的div节点,在这个节点在遍历class为itm的div节点,在itm下,通过具体分析,我们就能取到用户名和评论内容了。关于热评和最新评论,我们先简单处理,不区分。

我们将每首歌第一页的评论都写在content.html文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
const puppeteer = require('puppeteer')
const cheerio = require('cheerio')
const he = require('he')
const fs = require('fs')
const _ = require('lodash')

const host = 'http://music.163.com/#'

/**
* 抽象出抓取评论的函数,便于进一步开发
* @param {*} sTargetFrame
*/

const collectCommentList = async(sTargetFrame) => {
const sContent = await sTargetFrame.content()
const $ = cheerio.load(sContent)
const res = $('#comment-box')
.find('.itm')
.map((i, elem) => {
const brk = $(elem).find('.cntwrap .f-brk')
const brkText = brk.text()
// 用户名
const username = $(brk)
.find('a')
.text()
// 评论字符串
const commentStr = $(brk)
.text()
.replace(username, '')
return {username, commentStr}
})
.get()
fs.appendFile('./content.html', JSON.stringify(res) + '\n')
return res
}

puppeteer
.launch({headless: true})
.then(async browser => {
const page = await browser.newPage()
page.setViewport({width: 1367, height: 768})
await page.goto(`${host}/artist?id=6731`, {waitUntil: 'networkidle0'})

const frames = await page.frames()

const targetFrame = frames.find(frame => {
return frame.name() === 'contentFrame'
})

const content = await targetFrame.content()

let $ = cheerio.load(content)

const $list = $('#song-list-pre-cache')

const songList = $list
.find('a')
.map((i, elem) => {
const href = $(elem).attr('href')
if (_.startsWith(href, '/song?id=')) {
const title = $(elem).find('b').attr('title')
return {href, title}
}
})
.get()

let index = 0
let songItem
console.log(`一共${songList.length}首歌`)
while (songItem = songList[index++]) {
const sPage = await browser.newPage()
console.log(`正在收集${songItem.title}下的评论`)
await sPage.goto(`${host}${songItem.href}`, {
waitUntil: 'networkidle2',
timeout: 0
})
const sFrames = await sPage.frames()
const sTargetFrame = sFrames.find(frame => {
return frame.name() === 'contentFrame'
})

let commentList = await collectCommentList(sTargetFrame)

sPage.close()
}

await browser.close()
})

到此为止,我们已经可以抓取每一首歌的第一页评论了,但是这远远不够,一首热门歌曲往往有上万条评论,这就需要翻页抓取了。

评论翻页

puppeteer功能强大,模拟按钮点击功能当然也不在话下。所以我们接下来要做的事情就是点击下一页按钮后,再重复一次抓取动作即可。

避免篇幅过大,我这里贴出主要的翻页代码,完整代码会在文末给出。

1
2
3
4
5
6
7
8
9
10
11
12
do {
let commentList = await collectCommentList(sTargetFrame)
let nextBtnHandler = await sTargetFrame.$('.znxt')
if (!nextBtnHandler) {
break
}
nextBtnHandler.click()
// 关于如何判断评论加载完成,这里还没有找到更好的方案,所以暂时做一个 sleep 处理
await timeout(1500)
} while (await isBtnAbled(sTargetFrame))

let commentList = await collectCommentList(sTargetFrame)
1
2
3
4
5
const isBtnAbled = async(sTargetFrame, className) => {
const sContent = await sTargetFrame.content()
const $ = cheerio.load(sContent)
return !$('.znxt').hasClass('js-disabled')
}
1
2
3
async function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}

到现在,我们的爬虫功能就基本完成了,我这里暂时只把评论内容存入文本,还没有存放在数据库中。如果你感兴趣,可以把源码clone下来,拓展一下。

关于puppeteer更多更好玩的API,可以自行查看文档,这是一个不断更新的库,所以隔一段时间相关API可能就会有更新,因此阅读文档可能是深入学习的最好方式。

参考文章