授權方式(Auhorization): CC-BY 4.0

Updated: 更新一些說明和link,主要增加jsdom也無法處理export url的說明。(感謝chilijung提供jsdom)

說明

5/10參加了支持松,因為沒有事前準備的關係,早上到場的時候和kirby才開始討論要做的題目。幸好kirby的經驗豐富,提議把目標放在做一個比較簡單,今日之內可以完成的題目。本來預計利用HTML5 Audio API做線上可以band jam的東西,幸好沒有冒然去實踐XD。

最後我們決定要做未完成的癌症資料視覺化,之前的問題在於本來的crawler是用phantomjs寫的,但因為衛福部的伺服器撐不住,導致該程式無法順利批次下載檔案。而我自己覺得若是非必要,儘量不會用phantomjs去寫crawler。於是就自己重新寫了一隻crawler。

會後,有同學向kirby老師詢問如何撰寫crawler程式的問題(我不太清楚原問題,有描述錯誤請見諒),主要可能想爬里長費用的支出資料?當下我們有稍微說明了一下,一般crawler的運作模式。可是我想還是稍微分享一下當天撰寫這隻crawler的過程,讓同學能減少進入寫crawler的門檻。

其實蠻建議所有後端的programmer都具備擁有撰寫crawler的能力,一方面是能更清楚網頁從伺服器render到browser之間的過程,http的header和session之間的運作,並且能夠俱有基礎操作DOM的能力。

基本概念

crawler 運作的基本三個步驟,之後詳細的說明都是基於這三個步驟。

  1. fetch html
  2. parse DOM
  3. post Data

基本上就是先送http request 取得html,透過分析DOM,最後送出post data接著重複這三個步驟

過程 (警告: 以下文章含有大量程式碼)

接下來我會一步一步說明當天撰寫的過程,當然這過程中,有更好的寫法都歡迎交流。

嘗試抓取html

main.ls
(error, res, body) <- request do
  url: 'https://cris.hpa.gov.tw/pagepub/Home.aspx?itemNo=cr.q.10'

這時候會出現[Error: CERT_UNTRUSTED],上網查之後發現跟SSL有關於是必須加上strictSSL: false就可以了,另外似乎會檢查User-Agent,不然會一直得到系統發生錯誤,於是我們利用以下的code, 就可以取得和browser裡面一致的html, 可以進行下一步驟

main.ls
(error, res, body) <- request do
  url: 'https://cris.hpa.gov.tw/pagepub/Home.aspx?itemNo=cr.q.10'
  headers:
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.131'
  strictSSL: false

分析DOM 及 需要送出的FORM DATA

此時要懂得善用chrome的開發者工具,檢查元素能夠幫助我們了解DOM tree的組成及css的class,能夠利用cheerio像jQuery一樣輕鬆的操作DOM。Network能夠幫助我們了解http request的header和form data,當然比較新入門的人可以安裝request maker之類能夠攔截post data的extension。
以下是兩張截圖更進階說明上面兩項如何使用。

可以看見input的name是WR1_1$ctl138,jQuery的寫法是$('input[name="WR1_1$ctl138"]'),querySelector的寫法是document.querySelector('input[name="WR1_1$ctl138"]')

選到Network,記得勾選preserve log,一般點開是沒有東西,要開著開發者工具讀取網頁,才會出現記錄。因為同時也會有其他css,image的讀取記錄,所以使用filter: asp來過濾需要看的request。

點下該request之後可以看到reqeust header跟response header,這邊可以觀察到這個網站是使用multipart/form-data的形式。(nodejs裡面的request套件會很貼心的幫忙處理application/x-www-form-urlencoded, multipart/form-data使用其他工具的人要注意一下)

------WebKitFormBoundaryYURB10KOO88NmWbP
Content-Disposition: form-data; name="WR1_1_Q_DataII"

1

對應到一般就是 WR1_1_Q_DataII=1,仿照這個post data我們的程式如下。

main.ls
(error, res, body) <- request do
  url: 'https://cris.hpa.gov.tw/pagepub/Home.aspx?itemNo=cr.q.10'
  method: 'POST'
  strictSSL: false
  headers:
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.131'
    form: 
    '__EVENTTARGET':''
    '__EVENTARGUMENT':''
    '__VIEWSTATE': ''
    '__EVENTVALIDATION': ''
    '__VIEWSTATE_ID': ''
    'WR1_1$btnNext': '下一步>'
    'WR1_1_Q_DataII': '1'        #      [1 2]: [發生率 死亡率]
    'WR1_1_Q_PointII': 'A'       #  [A B C D]: [粗率 ... 年齡別率]
    'WR1_1_Q_SexII': '3'         #  [0 1 2 3]: [不分性別 男性 女性 男性及女性]
    'WR1_1$ctl138': 'on'

這時候會發現我們需要 __EVENTVALIDATION 和 __VIEWSTATE_ID,這是asp對於網頁state控制的實現方式有興趣的人可以看這篇
於是程式會改成這樣。

main.ls
$ = cheerio.load body
event-validation = $('#__EVENTVALIDATION').val!
viewstate-id = $('#__VIEWSTATE_ID').val!
(error, res, body) <- request do
    ...(忽略)
    '__EVENTVALIDATION': event-validation
    '__VIEWSTATE_ID': viewstate-id
        ... (忽略)

body也就是從第一個步驟取得的html data丟到cheerio產生jQuery-like DOM之後取出需要的資訊再放入post data。
這樣我們送出post data後,如果能夠取得和使用browser一樣的結果,表示距離結果已經踏出了一大步。
這個網頁需要五個步驟因此不贅述,大致上都是按照前面的步驟去走,只是送出的post data不一樣。

取得下載檔案

最後到了第五個步驟的時候,會發現這個網頁有點不一樣的地方。他的網頁做了跳轉也就是header.location ...
於是我們的程式必須取得跳轉後的html,撰寫如下:

main.ls
report-url = 'https://cris.hpa.gov.tw' + res.headers.location
(error, res, body) <- request do
  url: report-url
  headers:
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/34.0.1847.131'
    'Referer': 'https://cris.hpa.gov.tw/pagepub/Home.aspx?itemNo=cr.q.10'
  strictSSL: false

這邊要注意的就是,從res.header.location取得跳轉的網址,必須加上原本網頁的host url。還有就是送出新的request的時候必須加上Referer,不然無法取得正確的html。
另外就是必須使用cookie,來保持一致的session,加入cookie的方式如下:

main.ls
jar = request.jar!
request = request.defaults jar: jar

以上的程式碼要放在最前面。這樣可以保證每次request都帶有cookie。

終於我們可以看到和browser一樣列出許多表格的結果,網頁上面有個選項可以匯出Excel,此時困難點又來了。
觀察該元素會發現是一個侵入式的javascript,並不是直接一個連結,這時候心想如果用phantomjs可以直接模擬browser操作,事情就結束了。但是身為一個hacker無法妥協改回phantomjs,於是觀察DOM的組成之後有兩種解決方式。

var formatDropDown = document.getElementById('WR1_1_ReportViewer1_ctl01_ctl05_ctl00');
if (formatDropDown.selectedIndex == 0)
    return false;
window.open(document.getElementById('WR1_1_ReportViewer1').ClientController.m_exportUrlBase + encodeURIComponent(formatDropDown.value), '_blank')
formatDropDown.selectedIndex = 0;
document.getElementById('WR1_1_ReportViewer1_ctl01_ctl05_ctl01').Controller.SetViewerLinkActive(document.getElementById('WR1_1_ReportViewer1_ctl01_ctl05_ctl00').selectedIndex != 0);return false;

觀察之後會發現它藏在document.getElementById('WR1_1_ReportViewer1').ClientController.m_exportUrlBase裡面但是,我們無法用cheerio或jsdom取得url。因為他是new了一個js object叫RSClientController再把它放到element裡面去,於是必須用另外的方法取得url。

目標url:

https://cris.hpa.gov.tw/Reserved.ReportViewerWebControl.axd?Mode=true&ReportID=713bdc7f332440a59e6af085215fbc9d&ControlID=3c68fadfe8e94ed2866e280ac0479fe6&Culture=1028&UICulture=1028&ReportStack=1&OpType=Export&FileName=CrReportN_A01_C&ContentDisposition=AlwaysAttachment&Format=Excel

可以從下載裡面找到這個link。

會發現除了ReportID和ControlID其他都是固定的,另外就是OpType=Export&FileName=CrReportN_A01_C&ContentDisposition=AlwaysAttachment&Format=Excel,這段決定他是匯出Excel。

方法1: 使用regex

main.ls
$ = cheerio.load body
script = $('script')[5]
script-text = $(script).text!
matched = script-text.match /\\(\/Reserved.ReportViewerWebControl.axd.+&OpType=Export.+&Format=)/

export-url = 'https://cris.hpa.gov.tw' + matched.1 + 'Excel'

因為這段url其實還是藏在html的script tag裡面,於是我們找到這段script tag,並且使用 regular expression的方式截取出我們需要的url path。

方法2: iframe src

這個AppReport仔細觀察會發現下面是用iframe內嵌的網頁,所以可以用$('iframe').attr('src')取得網頁,之後再$('#report').attr('src')取得ReportID跟ControlID的網址,之後再轉換成我們要的格式。轉換的方式如下:
(注意這邊會需要兩次request才能用下面的程式碼截取)

main.ls
paths = $('#report').attr('src') / '&'

export-url = 'https://cris.hpa.gov.tw' + paths.0 + '&' + paths.1 + '&' + paths.2 + '&' + paths.3 + '&' + paths.4 + '&' + paths.5
export-url += '&OpType=Export&FileName=CrReportN_A01_C&ContentDisposition=AlwaysAttachment&Format=Excel'

最後終於可以得到Excel,而匯出成檔案的程式如下:

main.ls
request do
    url: export-url
    ...(省略) 
.pipe fs.createWriteStream 'result.xls'

最後就得到result.xls這隻檔案囉。

批次處理

因為javascript是non-blocking特性,所以要靠一些方法來達成批次處理的方式,例如async.series

async.series 按照前一個function完成之後才會繼續執行下一個function。

main.ls
tasks = []
do
  code <- lists.forEach
  tasks.push (cb) ->
    error, message <- export-file code
    cb null, message

err, results<- async.series tasks
if results.length is lists.length
  console.log 'All Done'

結論

寫crawler都是要靠經驗,平常多觀察別人的url、DOM的設計,還有header、session的運作。如此能夠幫助自己更了解網頁的運作。

相關的程式碼在:
https://github.com/yhsiang/cris-cancer

視覺化:
http://g0v.github.io/cancer/viz/ by tkirby

Big Query 視覺化:
http://littleq0903.github.io/bq-taiwan/ by 小Q

Comments

comments powered by Disqus