使用 Chromedp 爬取動態網站資料

Reading time ~4 minutes

在現代的網頁開發中,JavaScript 驅動的動態網站變得越來越普遍,這對使用傳統 HTML 解析的爬蟲工具帶來了挑戰。傳統的方法不再適用,因為網頁的內容必須在執行 JavaScript 後才會生成。解析這種網站需要更多前端的知識。

chromedp 是一個用 Go 語言編寫的工具包,它通過 Chrome 的 DevTools 協議進行無頭瀏覽器(Headless Browser)自動化,使開發者能夠程式化地控制 Chrome 瀏覽器,方便地爬取和解析動態生成的內容。本文將介紹如何使用 chromedp 來建立一個簡單的網路爬蟲。

基本原理

chromedp 主要通過與 Chrome 瀏覽器的 DevTools 協議通信來實現其功能。這使得開發者可以模擬使用者操作,例如導航到網頁、點擊按鈕、填寫表單以及提取動態載入的內容。這些操作在無頭模式(Headless mode)下進行,瀏覽器界面不可見,從而提高效能和資源利用效率。透過這種方式,chromedp 可以處理傳統 HTML 解析工具無法處理的情況,特別是在處理動態生成的內容時。

簡單範例程式碼

以下是一個使用 chromedp 爬取網站資料的簡單範例,這個範例展示了如何導航到一個網站、選擇一些元素、提交表單並提取所需的數據:

package main

import (
 "context"
 "fmt"
 "log"
 "time"

 "github.com/chromedp/chromedp"
)

func main() {
 // 建立 context
 ctx, cancel := chromedp.NewContext(context.Background())
 defer cancel()

 // 分配瀏覽器
 ctx, cancel = chromedp.NewExecAllocator(ctx, chromedp.DefaultExecAllocatorOptions[:]...)
 defer cancel()

 // 建立具有timeout 30秒的 context
 ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
 defer cancel()

 // 執行任務
 var res string
 err := chromedp.Run(ctx,
  chromedp.Navigate("https://example.com"),
  chromedp.WaitVisible(`body`, chromedp.ByQuery),
  chromedp.SendKeys(`input[name="q"]`, "chromedp", chromedp.ByQuery),
  chromedp.Click(`input[type="submit"]`, chromedp.ByQuery),
  chromedp.WaitVisible(`#result-stats`, chromedp.ByQuery),
  chromedp.Text(`#result-stats`, &res, chromedp.ByQuery),
 )

 if err != nil {
  log.Fatal(err)
 }

 fmt.Println("Search Result Stats:", res)
}

在這個範例中,我們建立了一個 chromedp 任務,它會導航到 example.com,等待頁面載入完成後,在搜尋框中輸入 “chromedp”,點擊提交按鈕,然後等待搜尋結果統計資訊的元素可見,並提取該資訊。這是一個基本範例,展示了如何使用 chromedp 進行基本的網頁互動和數據提取。你可以根據需要擴展此範例以實現更複雜的爬蟲功能。

接下來我們用一個更實用的案例來實作,以下範例展示如何使用 chromedp 爬取中華職棒(CPBL)的賽程表,此網站由 Vue.js 實作。

解析文件

中華職棒賽程網站的網址是 https://cpbl.com.tw/schedule。首先,我們用檢視網頁原始碼(View page source)來看看,可以發現裡面找不到任何賽程資料。另外,我們還能發現這段程式碼:

var app = new Vue({
            el: "#Center",
            mixins: [mixin],

以及另一段用來取得賽程資訊的程式:

$.ajax({
    url: '/schedule/getgamedatas',
    type: 'POST',
    data: filterData,
    headers: {
        RequestVerificationToken: 'PzmpuUOvS4z2zH_QhwgFQYTzVC82b0n2QH30wEOJ12kOWA6zeq0Yn7_6d2v_o-ZTWuNPe3HjrqsMqAHp9sL0F5KB4KM1:5jgubJ0tGDTK3cLm2JU7_bCw9JqLOG8j8yeNiWDhR4nnTACLXerDqmzB5chZv-iqY8m1ep6IirI3hAwRCPfNTU6jO_E1'
    },
    success: function(result) {
        if (result.Success) {
            _this.gameDatas = JSON.parse(result.GameDatas);
            _this.getGames();
        }
    },
    error: function(res) {
        console.log(res);
    },
    complete: function () {
        $("body").unblock()
    }
});

很明顯這是一個用 Vue.js 寫的網頁。我們當然可以試著去打它的 API,但看到那串 Token,可能做了某些保護,使用 chromedp 的方式可能更簡單。

那怎麼開始解析呢?用 ChatGPT 或許是一個好方法,打開 Chrome 的開發人員工具,在 Elements 那邊可以看到已經是最終的網頁結果,試著把它存成一個檔案並詢問 ChatGPT:

資料結構也可以順便請它設計一下:

這當然只能當作一開始的參考,後面也可以請它幫你直接寫程式。不過,我試了一下,它寫出來的只能當範例,不能產出正確的結果,但拿來作為基礎修改其實也不錯用。

先定義一下需求,我們需要寫一個函數,可以輸入年月和比賽種類來取得賽程資訊。

依照這些資訊,先來寫一個比較粗略的版本來實驗一下:

type Game struct {
 No       int    `json:"no"`
 Year     int    `json:"year"`
 Month    int    `json:"month"`
 Day      int    `json:"day"`
 Home     string `json:"home"`
 Away     string `json:"away"`
 Ballpark string `json:"ballpark"`
}

func getGameNodes(nodes *[]*cdp.Node) chromedp.Action {
 return chromedp.ActionFunc(func(ctx context.Context) error {
  ctxWithTimeout, cancel := context.WithTimeout(ctx, 900*time.Millisecond)
  defer cancel()

  chromedp.Nodes("div.game", nodes).Do(ctxWithTimeout)
  for _, n := range *nodes {
   dom.RequestChildNodes(n.NodeID).WithDepth(6).Do(ctxWithTimeout)
  }

  return nil
 })
}

func selectMonth(month string) chromedp.QueryAction {
 return chromedp.SetValue("div.item.month select", month, chromedp.ByQueryAll)
}

func selectYear(year string) chromedp.QueryAction {
 return chromedp.SetValue("div.item.year select", year, chromedp.ByQueryAll)
}

func selectGameType(gtype string) chromedp.Action {
 return chromedp.SetValue("div.item.game_type select", gtype, chromedp.ByQueryAll)
}

func fetchGamesByMonth(ctx context.Context, year string, month string) ([]Game, error) {
 chromedp.Run(ctx, selectMonth(month),
  chromedp.WaitVisible("div.ScheduleGroup"),
  chromedp.Sleep(800*time.Millisecond),
 )

 var nodes []*cdp.Node
 var mn string

 chromedp.Run(ctx,
  chromedp.Text(".date_selected .date", &mn),
  getGameNodes(&nodes),
 )

 var games []Game = make([]Game, len(nodes))
 for i, node := range nodes {
  games[i].No, _ = strconv.Atoi(strings.Trim(node.Children[0].Children[0].Children[0].Children[1].Children[0].NodeValue, " "))
  games[i].Ballpark = node.Children[0].Children[0].Children[0].Children[0].Children[0].NodeValue
  games[i].Year, _ = strconv.Atoi(year)
  monthInt, _ := strconv.Atoi(month)
  games[i].Month = monthInt + 1
  dataDate := node.Parent.Children[0].AttributeValue("data-date")
  day, _ := strconv.Atoi(dataDate)
  games[i].Day = day
  games[i].Away = node.Children[0].Children[0].Children[1].Children[0].Children[0].AttributeValue("title")
  games[i].Home = node.Children[0].Children[0].Children[1].Children[2].Children[0].AttributeValue("title")
 }

 return games, nil
}

這個版本程式碼很粗略但可用,主要使用 NodeValue 和 AttributeValue 來取值。這種方法的問題在於,這些 chromedp 呼叫每一個都需要與 Chrome 通信,而這是通過 Chrome DevTools Protocol 來實現的。Chrome DevTools Protocol 使用 WebSocket 進行通信,這樣頻繁來回不僅效率低,穩定性也較差。

下面這個範例是從 ChatGPT 學來的方法再優化的:

// FetchSchedule fetches the schedule from CPBL website based on the year, month, and game type
func FetchSchedule(year int, month int, gameType string) ([]GameSchedule, error) {
 ctx, cancel := chromedp.NewContext(context.Background())
 defer cancel()

 var schedules []GameSchedule

 // Define the URL
 url := "https://cpbl.com.tw/schedule"

 // Run chromedp tasks
 err := chromedp.Run(ctx,
  chromedp.Navigate(url),
  chromedp.WaitReady(`.ScheduleTableList`), // Wait for year select to be ready
  chromedp.Evaluate(fmt.Sprintf("document.querySelector('#Center').__vue__.filters.kindCode = '%s'", gameType), nil),
  chromedp.Evaluate(fmt.Sprintf("document.querySelector('#Center').__vue__.calendar.year = %d", year), nil),
  chromedp.WaitReady(`.ScheduleTableList`), // Wait for year select to be ready
  chromedp.Evaluate(fmt.Sprintf("document.querySelector('#Center').__vue__.calendar.month = %d", month-1), nil),
  chromedp.Evaluate(`document.querySelector('#Center').__vue__.getGameDatas()`, nil), // Wait for table to be visible
  chromedp.Sleep(2*time.Second), // Wait for table to load
  chromedp.Evaluate(`
   (() => {
    let schedules = [];
    document.querySelectorAll('.ScheduleTable tbody .date').forEach(dateDiv => {
     let date = dateDiv.innerText.trim();
     let parent = dateDiv.parentNode;
     parent.querySelectorAll('.game').forEach(gameDiv => {
      let location = gameDiv.querySelector('.place') ? gameDiv.querySelector('.place').innerText.trim() : '';
      let game_no = gameDiv.querySelector('.game_no') ? gameDiv.querySelector('.game_no').innerText.trim() : '';
      let away_team = gameDiv.querySelector('.team.away span') ? gameDiv.querySelector('.team.away span').title.trim() : '';
      let home_team = gameDiv.querySelector('.team.home span') ? gameDiv.querySelector('.team.home span').title.trim() : '';
      let score = gameDiv.querySelector('.score') ? gameDiv.querySelector('.score').innerText.trim() : '';
      let remark = gameDiv.querySelector('.remark .note div') ? gameDiv.querySelector('.remark .note div').innerText.trim() : '';
      schedules.push({ date, location, game_no, away_team, home_team, score, remark });
     });
    });
    return schedules;
   })()
  `, &schedules),
 )

 if err != nil {
  return nil, err
 }

 return schedules, nil
}

這個版本大量使用 chromedp.Evaluate 來內嵌 JavaScript 程式碼直接在網頁執行。這樣可讀性更好,且避免了頻繁與 Chrome 通信。這種方法更高效且穩定。