自動從網站上爬取全中國大學排名數據
近日有學生問我關於國內大學報考的一些建議,剛好我需要一些數據教學生如何運用數據庫的檢索功能。所以便在網上找尋一堆有關國內大學排名的資料。事實上,有許多知名的機構對各間大學進行評分和排名,例如 QS世界大學排名、THE世界大學排名、USNews世界大學排名等。於是我便找了某一個排名網站來查詢國內的大學排名資料。取得的資料可以用作課堂教學用途,又可以把相關的排名數據用作給學生選取志願,真是一舉兩得。所以最後我找到了一個單頁式應用的排名網站,但卻發現這個網站有一點有趣。
單頁式應用網站的數據爬取
眾所週知,爬蟲(Web Crawler)是一種可以通過程式的方式來自動取得發佈在網絡上的任何形式的內容,當中包括圖片、文字、影片等。而現時開發爬蟲的主流程式語言是使用Python,Python對網絡工具開發非常友好,且Python中又有許多不同的庫 (beautifulsoup、lxml) 可以極其方便地從HTML中解析和獲取想要的內容。但可惜,單頁式應用網站不是使用傳統網站般,一頁一頁的方式來運行,而是一次過載入一個網站的所有頁面的佈局和結構,在用戶切換頁面時只需載入當前用戶需要的網頁內容即可,由於利用瀏覽器的緩存功能來保存網站的佈局和結構,所以這個方式可大量節省伺服器的流量和用戶每次訪問網站中的網頁時載入的等待時間。
若是如此,是否單頁式應用網站這種新穎的網頁技術會更難使用爬蟲來獲取數據呢?非也,事實並非如此,恰恰相反的是,這種新穎的網頁技術將使網頁的內容更加容易被爬蟲所獲取。原因在於每次切換頁面時,網站只需利用AJAX技術取得內容,再加把內容更新至佈局上即可,所以可以通過Chrome的開發工具輕易地取得更新的內容連結,而這個更新的內容往住會使用非常簡單的方式儲存 (JSON格式) 並把內容給分類清晰。所以在爬取時使用工具來分析HTML結構都不需要。直接使用Python的requests庫取得連結內容,然後使用json庫中的json.loads( )
解析即可,所以單頁式應用網站的數據理論上和實際上爬取應該是沒什麼難度才對,但事實上開發者也知道爬蟲的存在,開發者會想辦法阻止一般的程式爬蟲來爬取他們的數據,一般的方法會在客戶端發出請求時,檢查發出請求的是否缺少某一些資料包含在當中,例如檢查請求頭 (Request header) 是否資料不齊全,不符合瀏覽器請求標準,又或者網站自動生成一個訪問令牌 (access token),在每次請求時都要携帶,否則當作是不合規的訪問者。在我找到的單頁式應用的排名網站,卻使用了有趣的方式來防止其網站內容被爬蟲輕易地獲取。
單頁式應用網站的防爬蟲方法
在我找到的單頁式應用的排名網站中,我利用Chrome的開發工具查找出其網站上更新內容時與後端通訊接口的連結 (API)。
在Chrome開發工具中使用網絡的一欄可以監控整個網站運行時發出的任何請求。一般來說,在向後端取得數據時,會使用JSON作為返回的格式。
而在監控的過程中,我卻發現在切換頁面時並沒有任何以JSON格式的請求響應 (Request response),而在深入研究後,最終卻在JS類別當中,發現了網站頁面上的排名的數據。原來其網站使用了JSONP的方法來獲得數據。這個方法原本是應用在不同網站之間的跨域傳遞數據,但現在卻被用在這裡。
所以為什麼會用到JSONP的方法來從後端取得數據呢?帶著這個問題我檢查了一下它的請求響應內容。驚訝地,我發現這個網站正在利用JSONP來防止爬蟲可以直接從這個請求的連結中取得想要的數據內容。
所以到底是怎麼實現的防爬蟲呢?
從請求響應 (Request response) 的內容中,我們可以看到JSONP的代碼中,不單止有頁面上應該有的內容,更有JavaScript的變量混入其中,而這些混入的變量才是重點,其變量直接導致爬蟲不可以從這個後端接口中直接取得數據。既然如此,那麼我就需要找出所有傳入這個JSONP的參數才能正確地還原數據。
想找出傳入JSONP的所有參數的值不是一件容易的事,而且可以看見所有變量和網站的JavaScript程式碼都直接使用了混淆加密 (JavaScript Obfuscator) 的方式,導致程式碼不是人類可以輕鬆閱讀,所以在找了好一會兒後,我便放棄了這個方法。
既然如此,我就需要打開Excel,然後再一個一個把網頁數據打上去Excel當中嗎,那樣子實在太過愚蠢了。所以我又想到了另一個方法。
利用JavaScript爬取單頁式應用網站(SPA)數據
在單頁式應用網站(SPA)當中,網頁之間的切換形式不再是傳統的方式重新刷新整個頁面,而是利用JavaScript更新當前的整個排版佈局和頁面數據,那麼我們就可以利用Chrome開發工具的主控台,直接注入我們的JavaScript程式碼,對整個文件樹 (DOM tree) 進行元素抓取和內容爬取,再利用JavaScript程式碼對頁面進行換頁。最後我們就可以實現對頁面注入JavaScript的方式來進行數據獲取。
在JavaScript中,我們可以利用document.querySelector( )
來抓取在文件樹 (DOM tree) 想要的元素。由於這個語句很常用,所以我把它包裝成一個function。
function $( selector ) { return document.querySelector( selector ); }
而如何準確抓取想要的元素的selector,我們可以利用Chrome的開發工具來找出想要的元素,再按下右鍵,選取「複製」,並選取「複製selector」。
let school_cn = $("#content-box > div.rk-table-box > table > tbody > tr:nth-child(1) > td.align-left > div > div.univname > div:nth-child(1) > div > div > a").innerText;
複製後的selector看起來有點一長,但卻可以準確抓取到想要的元素。然後利用 innerText 取得元素中的文字內容。
在取得頁面上所有裝有數據的selector後,我們便可以利用for迴圈來對網頁內其他的大學內容進行抓取。
for( let i = 1; i <= 30; i++ ) { let schoolName = $( schoolNameSelector ).innerText; }
抓取後的文字內容,我們可以利用陣列 (Array) 來保存起來。
let schools = []; for( let i = 1; i <= 30; i++ ) { let schoolName = $( schoolNameSelector ).innerText; let province = $( provinceSelector ).innerText; let category = $( categorySelector ).innerText; let score = $( scoreSelector ).innerText; schools.push({ schoolName, province, category, score }); }
這時通過簡單的幾句程式,我們就可以對一個頁面的內容對行抓取,不過我們明顯不會只抓取一個頁面,不然我們可以直接手動把頁面的內容直接複製下來。因此我們可以利用對 a 標籤執行 click( )
的方法來進行換頁。
當然,我們也需要利用Chrome的開發工具來取得「下一頁」的a標籤selector才可以。
$("#content-box > ul > li.ant-pagination-next > a").click();
通過這個方法,我們就可以在單頁式應用網站中進行換頁了。
但實際執行這句程式的話,會發現換頁的速度會跟不上程式執行的速度!因為程式執行的速度會快過網頁頁面的切換,所以在網頁還沒完全完成的時候,程式就直接去執行程式對元素抓取了。所以我們需要一些方法來等待頁面完全載入完畢。
function sleep( ms ) { return new Promise( resolve => setTimeout( resolve, ms ) ); }
最終,我在網上發現了這個簡潔的方法。通過setTimeout( )、async、await
的語法,我們可以在JavaScript的Event Loop中模擬等待的功能。
現在我們可以實現網頁內容抓取,並進行換頁。
太棒了,現在已經可以從網站上利用純JavaScript抓取到想要的數據內容,不然真的不知道要花何年何月才可以把這一些數據複製下來。但現在還有一個問題。雖然可以抓取到想要的數據,但最後顯示的格式卻是JavaScript的數據結構,是否可以把這些數據自動做成一個Microsoft Excel的格式給我下載下來呢?
答案是當然可以了!我們可以利用JavaScript生成一個CSV格式的檔案,再把它給下載下來。
當然,我們要利用JavaScript程式把數據轉成以逗號分隔的格式才可以。
function dataFormatting( schools ) { let csvHeader = Object.keys( schools[ 0 ] ); res = Object.keys( schools[ 0 ] ).join( "," ) + "\n" schools.forEach( school => { csvHeader.forEach( (col, i) => { if( Array.isArray( school[ col ] ) ) { res += `"${ school[ col ].join(",") }"${ (i == csvHeader.length - 1) ? "\n" : "," }`; } else { res += `"${ school[ col ] }"${ (i == csvHeader.length - 1) ? "\n" : "," }`; } }); }); return res; }
上方的function就是直接把JavaScript內的Array數據,轉換成逗號分隔的CSV格式。
最後我們利用JavaScript生成一個a標籤進行檔案下載。
function downloadCSV( filename, csvData ) { let a = document.createElement( "a" ); a.href = `data:text/csv;charset=utf-8,${csvData}`; a.download = filename; a.click(); }
現我們看看效果吧。
若想進入一個網頁後,爬取數據後再返回上一頁的話,可以加上history.back( )
語句。
現在讓我們看看最後的效果吧~
總結
這個方法看似性能不佳,執行較慢。但勝在足夠方便,在沒有依賴Python的情況下也可以對網頁實現數據爬取。當然,應付這個網站的方法事實上不止這一個,結合Python程式語言和 selenium 庫也可以實現。最近我有朋友使用這個庫實現了澳門政府防疫指引通知數據爬取並發送電郵,有興趣的話可以參考一下。
喜欢我的作品吗?别忘了给予支持与赞赏,让我知道在创作的路上有你陪伴,一起延续这份热忱!