使用 puppeteer 爬取链家房价信息

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

页面结构

地址

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

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

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

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

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

爬虫库

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是什么东东。

这是 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 爬取链家房价信息的更多相关文章

  1. Python爬取链家二手房源信息

    爬取链家网站二手房房源信息,第一次做,仅供参考,要用scrapy.   import scrapy,pypinyin,requests import bs4 from ..items import L ...

  2. python3 爬虫教学之爬取链家二手房(最下面源码) //以更新源码

    前言 作为一只小白,刚进入Python爬虫领域,今天尝试一下爬取链家的二手房,之前已经爬取了房天下的了,看看链家有什么不同,马上开始. 一.分析观察爬取网站结构 这里以广州链家二手房为例:http:/ ...

  3. python - 爬虫入门练习 爬取链家网二手房信息

    import requests from bs4 import BeautifulSoup import sqlite3 conn = sqlite3.connect("test.db&qu ...

  4. python爬取链家二手房信息,确认过眼神我是买不起的人

    前言 本文的文字及图片来源于网络,仅供学习.交流使用,不具有任何商业用途,如有问题请及时联系我们以作处理. PS:如有需要Python学习资料的小伙伴可以加点击下方链接自行获取 python免费学习资 ...

  5. Python爬虫项目--爬取链家热门城市新房

    本次实战是利用爬虫爬取链家的新房(声明: 内容仅用于学习交流, 请勿用作商业用途) 环境 win8, python 3.7, pycharm 正文 1. 目标网站分析 通过分析, 找出相关url, 确 ...

  6. python爬虫:爬取链家深圳全部二手房的详细信息

    1.问题描述: 爬取链家深圳全部二手房的详细信息,并将爬取的数据存储到CSV文件中 2.思路分析: (1)目标网址:https://sz.lianjia.com/ershoufang/ (2)代码结构 ...

  7. python爬虫:利用BeautifulSoup爬取链家深圳二手房首页的详细信息

    1.问题描述: 爬取链家深圳二手房的详细信息,并将爬取的数据存储到Excel表 2.思路分析: 发送请求--获取数据--解析数据--存储数据 1.目标网址:https://sz.lianjia.com ...

  8. Python的scrapy之爬取链家网房价信息并保存到本地

    因为有在北京租房的打算,于是上网浏览了一下链家网站的房价,想将他们爬取下来,并保存到本地. 先看链家网的源码..房价信息 都保存在 ul 下的li 里面 ​ 爬虫结构: ​ 其中封装了一个数据库处理模 ...

  9. Scrapy实战篇(一)之爬取链家网成交房源数据(上)

    今天,我们就以链家网南京地区为例,来学习爬取链家网的成交房源数据. 这里推荐使用火狐浏览器,并且安装firebug和firepath两款插件,你会发现,这两款插件会给我们后续的数据提取带来很大的方便. ...

随机推荐

  1. C++读入输出优化

    读入输出优化虽然对于小数据没有半点作用,但是对于大数据来说,可以优化几十ms. 有时就是那么几十ms,可以被卡掉大数据的点 读入优化 int read() { int x=0,sig=1; char ...

  2. FreeSql 插入数据,如何返回自增值

    FreeSql是一个功能强大的 .NET ORM 功能库,支持 .NetFramework 4.0+..NetCore 2.1+.Xamarin 等支持 NetStandard 所有运行平台. 以 M ...

  3. cmd 输入输出

    cmd 输入输出 首先在编写如: #define _CRT_SECURE_NO_WARNINGS #include<stdio.h> #include<stdlib.h> vo ...

  4. python使用while循环实现九九乘法表

    a = 1while a <= 9: b = 1 while b <= a: print("%d*%d=%d\t" % (b, a, a * b), end=" ...

  5. selenium基本对象之——数值型

    python的数值类型,除了魔法方法以为,只有下面的这些方法: 整形的方法有:as_integer_ratio.bit_length.from_bytes.to_bytes.conjugate.ima ...

  6. 2020 还不会泡 Github 你就落伍了

    前言 回想起两年前缸接触 GitHub 那会儿,就发现网上完全搜不到一篇关于 github 使用的文章,虽然自己倒腾几下慢慢的也就上手了,但毕竟花费了不少时间. 时间对每个人都是宝贵的,一直很好奇 G ...

  7. 事务特性,事务的隔离级别以及spring中定义的事务传播行为

    .katex { display: block; text-align: center; white-space: nowrap; } .katex-display > .katex > ...

  8. 微服务系列之 Consul 注册中心

    原文链接:https://mrhelloworld.com/posts/spring/spring-cloud/consul-service-registry/ Netflix Eureka 2.X ...

  9. Java多线程并发06——CAS与AQS

    在进行更近一步的了解Java锁的知识之前,我们需要先了解与锁有关的两个概念 CAS 与 AQS.关注我的公众号「Java面典」了解更多 Java 相关知识点. CAS(Compare And Swap ...

  10. Python——项目-小游戏2-动画绘制

    实现游戏循环还有事件的监听 在上一讲中 你需要完成这样的这样的效果, 如果你还没有完成,请不要继续往下阅读!!切记切记切记.,重要的事情说三遍 我们来看一下什么是游戏循环 所谓的游戏循环很好的理解 就 ...