1. 程式人生 > >【nodejs 爬蟲】使用 puppeteer 爬取鏈家房價資訊

【nodejs 爬蟲】使用 puppeteer 爬取鏈家房價資訊

# 使用 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)