「讀書人的事,能算偷麼?」孔乙己如果是使用 GitHub Actions 運行爬蟲腳本,就不會被人發現還打斷腳了。

在這篇教程中,我們將探討如何使用 GitHub Actions 來自動運行 Python 爬蟲腳本,並處理和存儲抓取的數據。這個過程包括設置 GitHub Actions 工作流程、運行爬蟲腳本以及將結果存儲到不同的地方(如文件或數據庫)。

今日範例程式:https://github.com/Edit-Mr/2024-GitHub-Actions/tree/main/22
專案 Repo: https://github.com/Edit-Mr/2024SpecAdmitNotifier

背景與目標

身為一位高三的特選生馬上要準備申請大學了,但是每間學校開始申請的時間都不一樣。還好有一間補習班的網站可以查看各間學校的申請時間。但是每天都要打開網站查看有沒有新的公告實在是太麻煩了。所以我決定寫一個爬蟲腳本,每天自動抓取網站上的申請時間,如果有更新的話就使用 Discord 通知我。

成果
成果

撰寫爬蟲腳本

我們使用 Node.js 撰寫爬蟲腳本,使用 axioscheerio 來抓取網站上的數據。

我們要檢查頁面中某個特定表格的內容是否有變更,並將任何變動透過 Discord webhook 發送通知。首先,程式會使用 axios 發送 HTTP 請求來獲取網頁 HTML 資料,接著利用 cheerio 套件來解析這個 HTML,並定位到目標表格的位置。然後,它會將該表格的 HTML 內容與之前已存儲的表格內容進行比對,通過生成 SHA-256 哈希值來判斷新舊表格是否一致。如果表格發生變更,程式會保存新的表格內容,並分析表格中的各個欄位,檢測具體哪些行和欄位的內容發生了改變。當有改變時,它會將變更的具體內容(例如名額、報名日期、面試日期等)組裝成一段訊息,然後透過 Discord 的 webhook URL 發送給用戶進行通知。這個流程確保了用戶可以自動獲得網頁上表格更新的最新消息,而不需要手動檢查網站。

老樣子,我們先來安裝需要的套件:

1npm init -y
2npm install axios cheerio
1const axios = require("axios");
2const cheerio = require("cheerio");
3const fs = require("fs");
4const crypto = require("crypto");
5
6// URL to crawl
7const url =
8    "https://www.reallygood.com.tw/newExam/inside?str=932DEFBF9A06471E3A1436C3808D1BB7";
9
10const webhookUrl = process.env.WEBHOOK_URL;
11
12if (!webhookUrl) {
13    console.error("WEBHOOK_URL environment variable is not set.");
14    process.exit(1);
15}
16
17function calculateHash(content) {
18    return crypto.createHash("sha256").update(content).digest("hex");
19}
20
21async function fetchPage() {
22    try {
23        const { data } = await axios.get(url);
24        return data;
25    } catch (error) {
26        console.error("Error fetching the page:", error);
27        return null;
28    }
29}
30
31function extractTable(html) {
32    const $ = cheerio.load(html);
33    const tableHtml = $(".main_area .article table").eq(5).html();
34    return tableHtml || "";
35}
36
37function saveTableToFile(content, filename) {
38    fs.writeFileSync(filename, content, "utf8");
39}
40
41function compareTables(newTable, oldTableFile) {
42    if (!fs.existsSync(oldTableFile)) return true; // No previous file, treat as change.
43
44    const oldTable = fs.readFileSync(oldTableFile, "utf8");
45    return calculateHash(newTable) !== calculateHash(oldTable);
46}
47
48async function sendDiscordMessage(changes) {
49    const message = {
50        content: changes
51    };
52
53    try {
54        await axios.post(webhookUrl, message);
55        console.log("Change notification sent to Discord.");
56    } catch (error) {
57        console.error("Error sending message to Discord:", error);
58    }
59}
60
61// Main function to execute the crawl and check for changes
62async function main() {
63    const html = await fetchPage();
64    if (!html) return;
65
66    const newTable = extractTable(html);
67
68    // Define the file where the table is stored
69    const tableFile = "table.html";
70
71    // Check if there's any change
72    if (compareTables(newTable, tableFile)) {
73        console.log("Change detected! Saving new table and notifying Discord.");
74        const $ = cheerio.load(`<table>${newTable}</table>`);
75        const old$ = cheerio.load(
76            `<table>${fs.readFileSync(tableFile, "utf8")}</table>`
77        );
78        saveTableToFile(newTable, tableFile);
79        const rows = $("tr");
80        const oldRows = old$("tr");
81        for (let i = 1; i < rows.length; i++) {
82            const row = rows.eq(i);
83            const columns = row.find("td");
84            const oldRow = oldRows
85                .find(
86                    `td:contains('${columns.eq(0).text().replace("𝐍𝐄𝐖", "")}')`
87                )
88                .parent();
89            if (oldRow.length === 0) {
90                console.log("Row", i, "not found in old table.");
91                continue;
92            }
93            const oldColumns = oldRow.find("td");
94
95            let changed = false;
96            for (let j = 0; j < columns.length; j++) {
97                if (
98                    columns.eq(j).text().trim().replace(/\s+/g, " ") !==
99                    oldColumns.eq(j).text().trim().replace(/\s+/g, " ")
100                ) {
101                    changed = true;
102                    console.log(
103                        columns.eq(j).text().length,
104                        oldColumns.eq(j).text().length
105                    );
106                    console.log(
107                        columns.eq(j).text().trim().replace(/\s+/g, " ") +
108                            " !== " +
109                            oldColumns.eq(j).text().trim().replace(/\s+/g, " ")
110                    );
111                    break;
112                }
113            }
114            if (!changed) {
115                continue;
116            }
117            console.log("Change detected in row", i);
118            let message = `#### ${columns
119                .eq(0)
120                .text()
121                .replace("𝐍𝐄𝐖", "")
122                .replace(/\s+/g, " ")} 特選資訊已更新\n**名額:** ${columns
123                .eq(1)
124                .text()
125                .replace(/\s+/g, " ")}\n**報名及繳件日期:** ${columns
126                .eq(2)
127                .text()
128                .replace(/\s+/g, " ")}\n**面試日期:** ${columns
129                .eq(3)
130                .text()
131                .replace(/\s+/g, " ")}\n放榜日期:${columns
132                .eq(4)
133                .text()}\n[簡章下載](${columns.eq(5).find("a").attr("href")})\n`;
134            message += "\n";
135            console.log(message);
136            await sendDiscordMessage(message.replaceAll("\t", " "));
137        }
138    } else {
139        console.log("No changes detected.");
140    }
141}
142main();

設置 GitHub Actions 工作流程

接下來,我們將設置 GitHub Actions 工作流程,以便每天自動運行爬蟲腳本並通過 Discord 通知用戶。在存儲庫的 .github/workflows 目錄下創建一個 YAML 文件,例如 crawl.yml,並添加以下內容:

1name: crawl
2
3on:
4    schedule:
5        - cron: "0 * * * *" # Runs every hour
6    workflow_dispatch:
7
8jobs:
9    build:
10        runs-on: ubuntu-latest
11
12        steps:
13            - name: Checkout code
14              uses: actions/checkout@v3
15
16            - name: Set up Node.js
17              uses: actions/setup-node@v3
18              with:
19                  node-version: "current"
20
21            - name: Install dependencies
22              run: yarn install
23
24            - name: Run index.js
25              run: node index.js
26              env:
27                  WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }}
28            - name: 自動提交
29              uses: stefanzweifel/git-auto-commit-action@v4
30              with:
31                  commit_message: "Update data"
32                  branch: main
33                  commit_user_name: Edit-Mr
34                  commit_user_email: info@elvismao.com

請你到 Discord 的伺服器設定中,新增一個 Webhook,並將 Webhook URL 添加到 GitHub 存儲庫的 Secrets 中,名稱為 WEBHOOK_URL。這樣,GitHub Actions 就可以使用這個 Webhook URL 來發送 Discord 通知。

測試和驗證

推送更改到 GitHub repository,然後檢查 GitHub Actions 頁面來確保工作流程每小時有成功運行。你可以故意編輯一下 table.html 文件,然後再次推送更改,觀察是否會觸發 Discord 通知。如果一切正常,你應該能夠收到 Discord 通知,並查看到表格中的具體變更內容。

小結

今天我們學習了如何使用 GitHub Actions 來自動運行爬蟲腳本,並通過 Discord 通知來通知用戶。這個過程包括設置 GitHub Actions 工作流程、編寫爬蟲腳本以及處理和存儲抓取的數據。這樣的自動化流程可以幫助用戶自動獲取網站上的最新信息,而不需要手動檢查網站。這樣的自動化流程可以應用在許多不同的場景中,幫助用戶節省時間和精力。

毛哥EM

一位喜歡把簡單的事,做得不簡單的高三生。