Alex Liang

Node.js爬蟲實戰:如何query動態網頁資料

前陣子工作上需要寫爬蟲撈政府機關的資料回來,一般的靜態網頁可以由URL的規則找到資料連結,而動態網頁常無法使用此方式。最常遇到的是client端按下button後送javascript的動作給後端,然後才render網頁。

這篇簡單記錄如何抓取這類型網站的資料。

我們以北市府法規局網站為例,使用chrome的開發者工具。
這裡分成2個部分:

  1. 檢視送出query的header帶了哪些參數
  2. 觀察取得下一頁資料時,header又加入哪些參數

一開始我們先測試最少需要哪些輸入才能查詢,在這個例子中至少需要勾選一個類別加上發布期間才能query資料。假設查詢營建類,從105年1月1日到105年12月31日。開啟開發人員工具,進入Network頁面,此時按”送出查詢”。

在Network頁面下,我們找到wfLaw_Interpretation_SearchResult.aspx,點進去後可看到以下畫面:
HTTP Request
紅色框起來的部分便是query送出的表格資料,這裡可以看到有三個欄位需要控制:’TCGC’, ‘TADF’及’TADT’。程式需要二個套件: request和cheerio,前者可送出HTTP request;後者可以把回傳的網頁資料透過類似jQuery的selector抓取需要的內容

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const Request = require('request');
const cheerio = require('cheerio');

const request = Request.defaults({
jar: true,
timeout: 60 * 1000,
});

request.post({
url: 'http://www.laws.taipei.gov.tw/lawsystem/wfLaw_Interpretation_SearchResult.aspx',
form: {
TCGC: '003008',
TLC1: 'AND',
TLC2: 'AND',
TADF: '1010604',
TADT: '1010604'
},
}, (error, response, body) => {
if (error) {
console.error(error);
}
if (response.statusCode !== 200) {
console.log(response.statusCode);
}

const $ = cheerio.load(body);
const span = $('#ContentPlaceHolder1_gvList_lblRecordCount');
let count = $(span[0]).text();
console.log(`Query結果共${count}筆資料`);
});

這段程式碼會印出query結果的資料數量。到此解決第一部分。
如果query數量超過一頁的上限,想抓取下一頁的資料會發現HTML並無下一頁的連結
Next Page

它是透過javascript去產生下一頁的結果,我們使用開發者工具觀察點擊下一頁的動作:
Form Data 1
Form Data 2

Form Data有

  1. _EVENTTARGET
  2. _VIEWSTATE
  3. _VIEWGERNERATOR
  4. _EVENTVALIDATION
  5. ctl00$ContentPlaceHolder1$gvList$ctl01$ddlPage
  6. ctl00$ContentPlaceHolder1$gvList$ctl24$ddlPage

這6個值,其中_EVENTTARGET是固定的;ctl00$ContentPlaceHolder1$gvList$ctl01$ddlPage和ctl00$ContentPlaceHolder1$gvList$ctl24$ddlPage為從第幾頁過來和目前頁數-1

而2, 3, 4這三個值與cookie有關,在前一頁可取出這些值。於是程式碼需改寫成:

app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const Request = require('request');
const cheerio = require('cheerio');

const request = Request.defaults({
jar: true,
timeout: 60 * 1000,
});

request.post({
url: 'http://www.laws.taipei.gov.tw/lawsystem/wfLaw_Interpretation_SearchResult.aspx',
form: {
TCGC: '003010',
TLC1: 'AND',
TLC2: 'AND',
TADF: '1050101',
TADT: '1051231'
},
}, (error, response, body) => {
if (error) {
console.error(error);
}
if (response.statusCode !== 200) {
console.log(response.statusCode);
}

const $ = cheerio.load(body);
// Get next page raw HTML
const eventValidation = $('#__EVENTVALIDATION').val();
const viewState = $('#__VIEWSTATE').val();
const viewStateGenerator = $('#__VIEWSTATEGENERATOR').val();

request.post({
url: 'http://www.laws.taipei.gov.tw/lawsystem/wfLaw_Interpretation_SearchResult.aspx',
form: {
__EVENTTARGET: 'ctl00$ContentPlaceHolder1$gvList$ctl01$ddlPage',
__VIEWSTATE: viewState,
__VIEWSTATEGENERATOR: viewStateGenerator,
__EVENTVALIDATION: eventValidation,
ctl00$ContentPlaceHolder1$gvList$ctl01$ddlPage: 1,
ctl00$ContentPlaceHolder1$gvList$ctl24$ddlPage: 1
},
}, (err, resp, nextPage) => {
if (err) {
console.error(err);
callback(err);
return;
}
console.log(nextPage);
});
});

取得第一個頁面後,記下view state等內容,再送出一個request並帶入剛才得到的狀態。如此可印出下一頁的內容。

最後給個提醒,面對這種動態網站時,如果我們使用async queue一次跑多個worker抓取資料,request的設定就不能使用default。原因是很有可能不同worker會用到同一個request object去query,假如A worker在抓取第二頁時,B worker送出另一個第二頁的query,會發生回傳內容不如預期的情況。
這時我們需要改變request object為區域變數,並且使用request.jar()取得custom cookie jar,範例如下:

app.js
1
2
3
4
5
6
7
function crawler4DynamicPage() {
const customJar = request.jar();
const options = {
jar: customJar,
timeout: 60 * 1000,
};
}

送query時再帶入options,這樣才能避免worker之間打架的情形

參考資料