亲宝软件园·资讯

展开

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

blog_bug 人气:0
# 使用 puppeteer 爬取链家房价信息 [toc] 此文记录了使用 `puppeteer` 库进行动态网站爬取的过程。 ## 页面结构 [地址](https://wh.lianjia.com/chengjiao/) 链家的历史成交记录页面在这里,它是`后台渲染模式`,无法通过监听和模拟 `xhr 请求`来快速获取,只能想办法分析它的页面结构,进行元素提取。 页面通过分页进行管理,例如其第二页链接为`https://wh.lianjia.com/chengjiao/baibuting/pg2/`,遍历分页没问题了。 有问题的是,通过首页可以看到它的历史信息有 5 万多条,一页有 30 条,但它的主页只显示了 100 页,没办法通过遍历分页获取全部数据。 好在,链家提供了筛选器。经过测试,使用街道级的区域筛选可以满足分页的限制。 ![](https://img2020.cnblogs.com/blog/1755814/202004/1755814-20200407153741746-177134372.jpg) 那么爬取思路就是,遍历`区级`按钮,在每个区级按钮下面遍历其`街道按钮`,在每个街道按钮下,遍历其每个分页。 ## 爬虫库 nodejs 领域的爬虫库,比较常用的有 `cheerio`、`pupeteer`。其中,`cheerio`一般用作静态网页的爬取,`pupeteer` 常用作爬取动态网页。 虽然链家网页是后台静态生成的,但是考虑到要对页面进行操作(*点击其区域选择器*),因此优先考虑选用 `pupeteer` 库。 ### pupeteer 库 pupeteer 库是谷歌浏览器在17年自行开发Chrome Headless特性后,与之同时推出的。本质上就是一个不含界面的浏览器,有点像电脑的终端,所有操作都通过代码进行操作。 这样,我们就可以在对网站进行检索之前,操作指定元素滚动到底部,以触发更多信息。或者在需要翻页的时候,操作代码对翻页按钮进行点击,然后对翻页后的页面进行相关处理。 ## 实现 这是其 [git 地址](https://github.com/puppeteer/puppeteer),这是其[中文教程](https://zhaoqize.github.io/puppeteer-api-zh_CN/)。 ### 打开待爬页面 ```js // 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` 中打开链家网站了。 光打开是不够的,我们期待的是在网页中操作筛选按钮,获取每个街道的页面,以便我们遍历其分页进行查询。 ### 遍历区级页面 我们首先要找到区级按钮,并点击它。 **标准思路** ```js (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`是什么东东。 ![](https://img2020.cnblogs.com/blog/1755814/202004/1755814-20200407153757454-1927482362.jpg) 这是 `pupeteer` 内部的组织结构,一个 `page` 下面有很多个 `Frame` `,一个Frame` 下面有一个 `Execution context`。 我们这个报错刚好就是在点击第二个按钮时触发的。 那就了然了。点击第一下导航成功, `page` 就变了,而你的第二个 `district` 还在依赖之前的那个 `page` ,结果找不到 `Execution context` ,然后就报错了。 如何解决呢? 有两个思路。 #### 方法一 将区级按钮的链接缓存下来,这样在遍历跳转的时候,它就不会依赖 `原page` 。 ```js (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` 环境下的全局变量,除非你传参数进去**: ```js let name = 'bug' page.$eval('id',(ele/* 这个参数是该方法自身返回的所选择元素 */, nodeParam)=>{ console.log(nodeParam) // 'bug' },name) ``` #### 方法二 另一个办法,就是在进行链接跳转时,不在 `原page` 直接跳,而是新开一个 `page2` 页面。这样你就不能使用点击,而是获取其链接。 ```js (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` 做分页循环。 我喜欢缓存,毕竟新开页面也要耗内存不是? ```js (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` 之类的字段。仔细看了下分页组件,果然在标签属性里有总页数。 以上思路中,第二个大概是最二的,然而我就是用的这个方法…出了好多低级错误,才换。其实第二个只要简单优化一下也可以用,比如获取分页按钮的最后一个,如果是`下一页`,就获取它前面的兄弟元素,还是能轻松得到总页数。 总之让我们用最简单的吧: ```js // 遍历页码 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}`) // 跳转拼接的分页链接 // 业务代码 } ``` ### 业务信息 这样,我们就实现了每一页数据的遍历,可以开开心心地写业务逻辑了。 **基本上能看到的数据,都可以抓取下来,全凭你的兴趣。** 这里分享一下我的部分爬虫代码: ```js // 基本就是 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` 封装,不然异步执行得有点乱。 ```js const saveTOLocal = function (obj) { // 返回一个 promise 对象 return new Promise((resolve, reject) => { // 读取文件 fs.readFile('.https://img.qb5200.com/download-x/data/yichengjiao.json', 'utf8', (err, data) => { let res = JSON.parse(data) // 更新内容 res.push(obj) // 写入文件 fs.writeFile(`.https://img.qb5200.com/download-x/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)` 的方式,没有加任何配置,是为了方便理解。现在,这些关键的配置必须要补上了。 ```js page.goto(url, { /* 网络超时,默认是 30s 。 但难免遇到网络不好的时候,如果一过 30s 就报错,还是挺难受的。 设为 0 表示无限等待。 */ timeout:0, /* 页面认为跳转成功的满足条件,默认是 'load',页面的 load 事件触发才算成功。 但其实大部分情况下用不到 load 条件,我们需要的很多页面信息都在结构和样式里,当 domcontentloaded 触发就够用了。 时间对比上,load 要两三秒,domcontentloaded 一秒都用不了,提升非常大。 */ waitUntil:'domcontentloaded' }) ``` **业务优化** 链家这个网站自身特性上,它一个街道有时对应好几个区,当你爬完这个区的所有街道,爬另一个区时发现又跳回这个街道再爬一次,就很消耗时间做无用功。 我的解决办法是在爬街道的时候,**给街道名做缓存**。当下次爬到它时,就直接跳过爬下一个。 我还有一个额外的需求是爬每套房子的坐标,在分页界面没有,必须跳转到该房子的链接下找。如果每个房子都跳一遍,5万 套,一个 1s 也要十几个小时。 不过链家中的房子地址是以小区为单位的,同一小区的所有房子共享同一坐标。所以,我**在爬取街道信息的时候,都新建一个小区名缓存**,如之前有记录,就不必跳转直接沿用之前的坐标。据测试,一个街道的几百栋房子,一般分布在 60 个左右的小区里。所以我只需要跳转60次就能获取几百个数据。 ## 成果展示 综合使用上述方法,共花了一个半小时获取了 5万 套房子的属性和坐标。 这是使用 `leaflet` 做的一点可视化: **房价热力图** ![](https://img2020.cnblogs.com/blog/1755814/202004/1755814-20200407153813418-155754062.jpg) **房屋点聚合** ![](https://img2020.cnblogs.com/blog/1755814/202004/1755814-20200407153822502-1336071637.jpg) ![](https://img2020.cnblogs.com/blog/1755814/202004/1755814-20200407153829874-1005488740.jpg) **百度热力图** ![](https://img2020.cnblogs.com/blog/1755814/202004/1755814-20200407153838723-797205481.jpg)

加载全部内容

相关教程
猜你喜欢
用户评论