【nodejs 爬蟲】使用 puppeteer 爬取鏈家房價資訊
- 2020 年 4 月 7 日
- 筆記
使用 puppeteer 爬取鏈家房價資訊
此文記錄了使用 puppeteer
庫進行動態網站爬取的過程。
頁面結構
鏈家的歷史成交記錄頁面在這裡,它是後台渲染模式
,無法通過監聽和模擬 xhr 請求
來快速獲取,只能想辦法分析它的頁面結構,進行元素提取。
頁面通過分頁進行管理,例如其第二頁鏈接為https://wh.lianjia.com/chengjiao/baibuting/pg2/
,遍歷分頁沒問題了。
有問題的是,通過首頁可以看到它的歷史資訊有 5 萬多條,一頁有 30 條,但它的主頁只顯示了 100 頁,沒辦法通過遍歷分頁獲取全部數據。
好在,鏈家提供了篩選器。經過測試,使用街道級的區域篩選可以滿足分頁的限制。
那麼爬取思路就是,遍歷區級
按鈕,在每個區級按鈕下面遍歷其街道按鈕
,在每個街道按鈕下,遍歷其每個分頁。
爬蟲庫
nodejs 領域的爬蟲庫,比較常用的有 cheerio
、pupeteer
。其中,cheerio
一般用作靜態網頁的爬取,pupeteer
常用作爬取動態網頁。
雖然鏈家網頁是後台靜態生成的,但是考慮到要對頁面進行操作(點擊其區域選擇器),因此優先考慮選用 pupeteer
庫。
pupeteer 庫
pupeteer 庫是Google瀏覽器在17年自行開發Chrome Headless特性後,與之同時推出的。本質上就是一個不含介面的瀏覽器,有點像電腦的終端,所有操作都通過程式碼進行操作。
這樣,我們就可以在對網站進行檢索之前,操作指定元素滾動到底部,以觸發更多資訊。或者在需要翻頁的時候,操作程式碼對翻頁按鈕進行點擊,然後對翻頁後的頁面進行相關處理。
實現
打開待爬頁面
// 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
做的一點可視化:
房價熱力圖
房屋點聚合
百度熱力圖