【nodejs 爬蟲】使用 puppeteer 爬取鏈家房價資訊
阿新 • • 發佈:2020-04-07
# 使用 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('./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)` 的方式,沒有加任何配置,是為了方便理解。現在,這些關鍵的配置必須要補上了。
```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)