【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

时间:2023-03-08 15:54:51
【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

使用 puppeteer 爬取链家房价信息

此文记录了使用 puppeteer 库进行动态网站爬取的过程。

页面结构

地址

链家的历史成交记录页面在这里,它是后台渲染模式,无法通过监听和模拟 xhr 请求来快速获取,只能想办法分析它的页面结构,进行元素提取。

页面通过分页进行管理,例如其第二页链接为https://wh.lianjia.com/chengjiao/baibuting/pg2/,遍历分页没问题了。

有问题的是,通过首页可以看到它的历史信息有 5 万多条,一页有 30 条,但它的主页只显示了 100 页,没办法通过遍历分页获取全部数据。

好在,链家提供了筛选器。经过测试,使用街道级的区域筛选可以满足分页的限制。

【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

那么爬取思路就是,遍历区级按钮,在每个区级按钮下面遍历其街道按钮,在每个街道按钮下,遍历其每个分页。

爬虫库

nodejs 领域的爬虫库,比较常用的有 cheeriopupeteer。其中,cheerio一般用作静态网页的爬取,pupeteer 常用作爬取动态网页。

虽然链家网页是后台静态生成的,但是考虑到要对页面进行操作(点击其区域选择器),因此优先考虑选用 pupeteer 库。

pupeteer 库

pupeteer 库是谷歌浏览器在17年自行开发Chrome Headless特性后,与之同时推出的。本质上就是一个不含界面的浏览器,有点像电脑的终端,所有操作都通过代码进行操作。

这样,我们就可以在对网站进行检索之前,操作指定元素滚动到底部,以触发更多信息。或者在需要翻页的时候,操作代码对翻页按钮进行点击,然后对翻页后的页面进行相关处理。

实现

这是其 git 地址,这是其中文教程

打开待爬页面

// 1. 引包
const puppeteer = require('puppeteer'); // 2. 在异步环境中执行(pupeteer 所有操作都是异步实现的)
(async ()=>{
// 创建浏览器窗口
const browser = await puppeteer.launch({
headless: false, // 有界面模式,可以查看执行详情
}); // 创建标签页
const page = await browser.newPage(); // 进入待爬页面
await page.goto('https://wh.lianjia.com/chengjiao/'); // 遍历页面
})()

这样就成功在 pupeteer 中打开链家网站了。

光打开是不够的,我们期待的是在网页中操作筛选按钮,获取每个街道的页面,以便我们遍历其分页进行查询。

遍历区级页面

我们首先要找到区级按钮,并点击它。

标准思路

(async ()=>{
// ...... // 使用选择器
/* page.$$() 会在页面执行 document.querySelectorAll,并返回 ElementHandle 对象的数组
page.$() 执行 document.querySelector,返回 ElementHandle 对象
*/
let districts = await page.$$('div[data-role=ershoufang]>div>a') for(let district of districts){
await district.click() // 模拟点击页面对象 // 遍历街道
}
})

第一想法大概是这样写,通过选择器拿到所有按钮,然后挨个点击。

恭喜,收到报错一枚。

Error: Execution context was destroyed, most likely because of a navigation.

说你的执行上下文被干掉了,可能是因为页面的导航。

为了弄清这个问题,我们有必要先看一下Execution context是什么东东。

【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

这是 pupeteer 内部的组织结构,一个 page 下面有很多个 Frame ,一个Frame 下面有一个 Execution context

我们这个报错刚好就是在点击第二个按钮时触发的。

那就了然了。点击第一下导航成功, page 就变了,而你的第二个 district 还在依赖之前的那个 page ,结果找不到 Execution context ,然后就报错了。

如何解决呢?

有两个思路。

方法一

将区级按钮的链接缓存下来,这样在遍历跳转的时候,它就不会依赖 原page

(async ()=>{
// ...... // 使用选择器
/* page.$$eval('选择器', callback(eles)) 会在page页面内部执行 Array.from(document.querySelectorAll(selector)),然后把数组参数传给 callback
*/
let districts = await page.$$eval('div[data-role=ershoufang]>div>a',links=>{
// 对传进来的元素处理
let arr = []
for(let link of links){
arr.push(link.href)
}
return arr
})
for(let district of districts){
await page.goto(district) // 使用 page.goto() 替代点击 // 遍历街道
}
})

这里需要特殊解释的是,对于页面的操作如点击按钮、导航链接等等都是在 node 里完成的。而在页面之中的操作,比如读取元素的某个属性,是在浏览器的引擎里处理的,类似于 html 文件中 script 标签里的脚本。

对于 pupeteer ,它的脚本文件一般都被包裹在 *.*eval() 之中,譬如page.evaluate(pageFunction[, ...args])page.$eval(selector,pageFunction, ...args)elementHandle.$eval(selector, pageFunction, ...args)

在这种脚本中,无法访问 node 环境下的全局变量,除非你传参数进去

let name = 'bug'

page.$eval('id',(ele/* 这个参数是该方法自身返回的所选择元素 */, nodeParam)=>{
console.log(nodeParam) // 'bug'
},name)

方法二

另一个办法,就是在进行链接跳转时,不在 原page 直接跳,而是新开一个 page2 页面。这样你就不能使用点击,而是获取其链接。

(async ()=>{
// 新建一个标签页用来做跳转缓存
const page2 = await browser.newPage();
// ...... // 仍使用原方法获取元素
let districts = await page.$$('div[data-role=ershoufang]>div>a') for(let district of districts){
let link = (await district.getProperty('href'))._remoteObject.value // 获取属性
await page2.goto(link) // 在新页面跳转,原 page 不变
// 遍历街道
}
})

这两种办法都可行,不过第一种办法似乎更简单一点,将每个按钮的链接都缓存过后,似乎也没有再保留 原page 的必要。

总之呢,我们现在已经能够遍历各个区级页面了!

遍历街道页面

以下操作均在遍历区级页面for 循环中书写。

操作与遍历区级页面类似,首先找到街道按钮,然后循环跳转。这里的跳转逻辑也跟上述类似,要么选择缓存其链接,要么新开一个 page3 做分页循环。

我喜欢缓存,毕竟新开页面也要耗内存不是?

(async ()=>{
let streets = await page.$$eval(
'div[data-role=ershoufang] div:last-child a', (links => {
// 对传进来的元素处理
let arr = []
for(let link of links){
arr.push(link.href)
}
return arr
})
)
for(let street of streets){
await page.goto(street) // 使用 page.goto() 替代点击 // 遍历页码
}
})

遍历分页

因为分页的链接处理比较简单,递增就可以了。

有个小问题,我们如何确定循环结束。

有几个思路,

第一,街道首页会显示该区域共有多少套房,每个分页是 30 套,除一下就可以了。

第二,我们可以获取分页按钮的最后一个数值,不过遗憾的是最后一个数值大部分情况下是 下一页,鉴于此我们也许可以做个 while 循环,当该分页的最后一个按钮不是 下一页 时表示遍历结束。但对于房数比较少的区域,也许只有两三页,本来就没有下一页 按钮,那就会直接跳过漏爬。

第三,查看一下页面结构。以上都是从渲染过后的页面上看到的信息,而在页面结构上也许有 totalPage 之类的字段。仔细看了下分页组件,果然在标签属性里有总页数。

以上思路中,第二个大概是最二的,然而我就是用的这个方法…出了好多低级错误,才换。其实第二个只要简单优化一下也可以用,比如获取分页按钮的最后一个,如果是下一页,就获取它前面的兄弟元素,还是能轻松得到总页数。

总之让我们用最简单的吧:

// 遍历页码
let totalPage = await page.$eval('div.house-lst-page-box',el => {
return JSON.parse(el.getAttribute('page-data')).totalPage
}) for (let i = 1; i <= totalPage; i++) {
// 这里的一个小优化,因为街道首页即是第一页,没必要再跳
if(i > 1) await page.goto(`${street}pg${i}`) // 跳转拼接的分页链接 // 业务代码
}

业务信息

这样,我们就实现了每一页数据的遍历,可以开开心心地写业务逻辑了。

基本上能看到的数据,都可以抓取下来,全凭你的兴趣。

这里分享一下我的部分爬虫代码:

// 基本就是 page.$$eval() 选择元素,然后在页面内执行分析,将结果 return 出来
let page_storage = await page.$$eval('ul.listContent>li', (lis => lis.map(li => {
let link = li.querySelector('a').href;
let [orientation, decoration] = li.querySelector('.houseInfo').innerText.split(' | ')
let title = li.querySelector('div.title>a').innerText.split(' ')
let [name, type, area] = [...title]
let date = li.querySelector('.dealDate').innerText
let totalPrice = li.querySelector('.totalPrice .number').innerText
let unitPrice = li.querySelector('.unitPrice .number').innerText
return {
// 用不了 es6 语法
orientation: orientation,
decoration: decoration,
link: link,
name: name,
type: type,
area: area,
date: date,
totalPrice: totalPrice,
unitPrice: unitPrice
}
})) // 成果保存

成果保存

我是把数据先存在本地了,也可以直接保存到数据库。

这里需要注意的是,要将读写文件的操作也做下 Promise 封装,不然异步执行得有点乱。

const saveTOLocal = function (obj) {
// 返回一个 promise 对象
return new Promise((resolve, reject) => {
// 读取文件
fs.readFile('./data/yichengjiao.json', 'utf8', (err, data) => {
let res = JSON.parse(data)
// 更新内容
res.push(obj)
// 写入文件
fs.writeFile(`./data/yichengjiao.json`, JSON.stringify(res), 'utf8', (err) => {
resolve() // 写入完成后,promise resolved
})
})
})
} (async ()=>{
// ......
await saveToLocal(page_storage)
})

因为网络原因,或者代码问题,或者各种奇奇怪怪的意想不到的事情,都可能导致你的爬虫系统崩溃,所以,不要等全部爬取完后统一保存——你可能会搞砸掉所有鸡蛋。而是分阶段性地保存,比如我是以街道为单位进行保存的(上面的以页为单位只是演示)。

同时,还要有预案,当爬虫崩溃后,你要知道它在哪崩溃的,如何让它在崩溃的位置重新启动,而不是每次都要从头开始。

代码优化

主干功能部分已经说完了,对于几个细小的优化点也是很重要的,它很可能会让你节省好多好多时间。

算笔账,比如总共有 5万 套房,你要打开 5万 个网页,一个网页打开两三秒,你需要 40 个小时才能爬完。如果把打开网页的速度提升一秒,你就能节省 20 个小时!

page.goto()

在上面的描述中,我统一用 page.goto(url) 的方式,没有加任何配置,是为了方便理解。现在,这些关键的配置必须要补上了。

page.goto(url, {
/*
网络超时,默认是 30s 。
但难免遇到网络不好的时候,如果一过 30s 就报错,还是挺难受的。
设为 0 表示无限等待。
*/
timeout:0,
/*
页面认为跳转成功的满足条件,默认是 'load',页面的 load 事件触发才算成功。
但其实大部分情况下用不到 load 条件,我们需要的很多页面信息都在结构和样式里,当 domcontentloaded 触发就够用了。
时间对比上,load 要两三秒,domcontentloaded 一秒都用不了,提升非常大。
*/
waitUntil:'domcontentloaded'
})

业务优化

链家这个网站自身特性上,它一个街道有时对应好几个区,当你爬完这个区的所有街道,爬另一个区时发现又跳回这个街道再爬一次,就很消耗时间做无用功。

我的解决办法是在爬街道的时候,给街道名做缓存。当下次爬到它时,就直接跳过爬下一个。

我还有一个额外的需求是爬每套房子的坐标,在分页界面没有,必须跳转到该房子的链接下找。如果每个房子都跳一遍,5万 套,一个 1s 也要十几个小时。

不过链家中的房子地址是以小区为单位的,同一小区的所有房子共享同一坐标。所以,我在爬取街道信息的时候,都新建一个小区名缓存,如之前有记录,就不必跳转直接沿用之前的坐标。据测试,一个街道的几百栋房子,一般分布在 60 个左右的小区里。所以我只需要跳转60次就能获取几百个数据。

成果展示

综合使用上述方法,共花了一个半小时获取了 5万 套房子的属性和坐标。

这是使用 leaflet 做的一点可视化:

房价热力图

【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

房屋点聚合

【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

【nodejs 爬虫】使用 puppeteer 爬取链家房价信息

百度热力图

【nodejs 爬虫】使用 puppeteer 爬取链家房价信息