Web Scraping Workshop — HKBU Comm

LIHKG 熱門帖子爬蟲

使用 requests + lxml XPath 爬取 lihkg.com/category/1?order=hot 的熱門帖子。代碼分為 8 格,可逐格複製到 Google Colab 執行,或點擊右上角「複製全部代碼」一次貼入。

爬取字段一覽

thread_id
帖子唯一 ID
列表 API
title
帖子標題
列表 API
username
發帖用戶名
user.nickname
user_gender
用戶性別 M/F
列表 API
create_time
發帖時間(HKT)
Unix→HKT
like_count
帖子點讚數
列表 API
dislike_count
帖子不點讚數
列表 API
reply_count
回覆總數
no_of_reply
total_page
帖子頁數
列表 API
cat_name
板塊名稱
category.name
body_text
正文純文字
XPath //text()
body_like
正文點讚數
詳情 API
body_dislike
正文不點讚數
詳情 API
thread_url
帖子連結
自動生成

本爬蟲中的 XPath 應用

tree.xpath("//text()")— 提取正文 HTML 中所有文字節點(格 3、格 5 的核心)
lxml_html.fromstring(html)— 將 HTML 字符串解析為可用 XPath 查詢的元素樹
LIHKG 使用 JSON API,帖子列表字段直接從 JSON 讀取;正文(msg 字段)是 HTML 格式,需要 XPath 解析提取純文字

使用步驟

  1. 1打開 Google Colab(colab.research.google.com),新建筆記本
  2. 2點擊右上角「複製全部代碼」,貼入第一個代碼格,然後按 Shift+Enter 執行
  3. 3或逐格複製(點擊每格右上角的「複製」按鈕),分別貼入 Colab 的不同格子
  4. 4修改格 2 的參數:MAX_PAGES(爬幾頁)、FETCH_BODY(是否爬正文)
  5. 5執行格 7(主爬蟲),等待爬取完成
  6. 6執行格 8,查看結果並自動下載 lihkg_hot.csv 到本地
  7. 7中途中斷?直接重新執行格 7,會自動從斷點繼續

代碼格(共 8 格)

1
📦
安裝套件SETUP

在 Google Colab 中安裝所需的 Python 套件。lxml 用於 XPath 解析,BeautifulSoup 用於清洗正文 HTML。

# 安裝所需套件(Google Colab 已預裝 requests,只需安裝 lxml)
!pip install lxml beautifulsoup4 pandas --quiet
2
⚙️
導入套件與設定參數CONFIG

導入所有套件,並設定爬蟲的核心參數:目標板塊、最大頁數、休息時間、請求頭。修改這裡的參數即可調整爬蟲行為。

import requests                    # 發送 HTTP 請求
import time, random                  # 控制休息時間(防反爬)
import csv, os, json                 # 文件讀寫與斷點記錄
from lxml import html as lxml_html   # XPath 解析 HTML 正文
from bs4 import BeautifulSoup        # 清洗 HTML 為純文字
from datetime import datetime, timezone, timedelta

# ── 爬蟲參數設定 ──────────────────────────────────────────────
CATEGORY_ID   = 1        # 板塊 ID:1=吹水台,5=時事台,32=娛樂台
ORDER         = "hot"    # 排序:hot=熱門,new=最新
MAX_PAGES     = 5        # 最多爬幾頁(每頁最多 60 帖)
FETCH_BODY    = True     # True=同時爬正文,False=只爬標題等列表字段
OUTPUT_FILE   = "lihkg_hot.csv"
CHECKPOINT    = "lihkg_checkpoint.json"

# ── 防反爬休息時間(秒)────────────────────────────────────────
SLEEP_LIST    = (1.5, 3.0)   # 翻頁之間的休息時間範圍
SLEEP_BODY    = (0.8, 1.8)   # 爬正文之間的休息時間範圍
MAX_RETRIES   = 3             # 請求失敗最多重試次數

# ── 請求頭(模擬真實瀏覽器,LIHKG 需要這兩個自定義頭部)────────
HEADERS = {
    "User-Agent":       "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                        "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
    "X-LI-DEVICE":      "web",           # LIHKG 必需:標識設備類型
    "X-LI-DEVICE-TYPE": "browser",       # LIHKG 必需:標識為瀏覽器
    "Referer":          "https://lihkg.com/",
    "Accept":           "application/json",
    "Accept-Language":  "zh-HK,zh;q=0.9",
}

print("✅ 套件導入完成,參數設定完成")
3
🔧
輔助函數HELPERS

定義三個輔助函數:時間轉換、HTML 清洗(用 XPath 提取正文純文字)、帶重試的 HTTP 請求。

def unix_to_hkt(ts):
    """將 Unix 時間戳轉換為香港時間字符串(UTC+8)"""
    hkt = timezone(timedelta(hours=8))
    return datetime.fromtimestamp(ts, tz=hkt).strftime("%Y-%m-%d %H:%M:%S")


def clean_body_html(raw_html):
    """
    用 lxml XPath 從帖子正文 HTML 中提取純文字。
    LIHKG 正文是 HTML 格式,包含 <a>、<img>、<br> 等標籤。
    
    XPath 策略:
      1. 用 //text() 提取所有文字節點
      2. 過濾掉空白節點
      3. 用換行符拼接
    """
    if not raw_html:
        return ""
    # 將 HTML 字符串解析為 lxml 元素樹
    tree = lxml_html.fromstring(f"<div>{raw_html}</div>")
    
    # ── XPath 核心:提取所有文字節點 ──────────────────────────
    # //text()       → 選取所有後代的文字節點
    # normalize-space() 在 Python 端處理,避免空白節點干擾
    text_nodes = tree.xpath("//text()")
    
    # 過濾空白節點,拼接成純文字
    lines = [t.strip() for t in text_nodes if t.strip()]
    return " ".join(lines)


def safe_get(url, retries=MAX_RETRIES):
    """帶自動重試的 HTTP GET 請求,返回解析後的 JSON 或 None"""
    for attempt in range(1, retries + 1):
        try:
            r = requests.get(url, headers=HEADERS, timeout=10)
            if r.status_code == 200:
                return r.json()                    # 成功返回 JSON
            elif r.status_code == 429:             # 被限速
                print(f"  ⚠️ 限速(429),等待 20 秒..."); time.sleep(20)
            else:
                print(f"  ⚠️ HTTP {r.status_code},第{attempt}次重試..."); time.sleep(5)
        except Exception as e:
            print(f"  ⚠️ 請求錯誤:{e},第{attempt}次重試..."); time.sleep(5)
    return None  # 超過重試次數,返回 None

print("✅ 輔助函數定義完成")
4
🎯
爬取帖子列表(XPath 解析字段)XPATH CORE

爬取帖子列表 API,並用 XPath 從每個帖子的 JSON 轉換的 HTML 中提取字段。這是本節課的核心 XPath 練習部分。

def fetch_thread_list(page):
    """
    爬取指定頁碼的帖子列表。
    API:/api_v2/thread/latest?cat_id={id}&page={n}&count=60&type=now&order=hot
    返回:(帖子列表, 是否還有下一頁)
    """
    url = (f"https://lihkg.com/api_v2/thread/latest"
           f"?cat_id={CATEGORY_ID}&page={page}&count=60&type=now&order={ORDER}")
    
    data = safe_get(url)
    if not data or data.get("success") != 1:
        return [], False
    
    resp  = data["response"]
    items = resp.get("items", [])
    has_more = resp.get("is_pagination", False)  # True=還有下一頁
    return items, has_more


def parse_thread(thread):
    """
    從帖子列表的單個 JSON 對象中提取所有字段。
    
    LIHKG API 返回的是 JSON,但帖子正文(msg)是 HTML 字符串。
    我們將 JSON 字段直接讀取,正文部分用 XPath 解析。
    
    ── JSON 字段映射 ─────────────────────────────────────────
    thread["user"]["nickname"]        → 用戶名
    thread["create_time"]             → 發帖時間(Unix 時間戳)
    thread["like_count"]              → 帖子點讚數
    thread["dislike_count"]           → 帖子不點讚數
    thread["total_page"]              → 帖子頁數
    thread["category"]["name"]        → 板塊名稱
    """
    user     = thread.get("user", {})
    category = thread.get("category", {})
    
    return {
        "thread_id":    thread.get("thread_id", ""),
        "title":        thread.get("title", ""),
        # 用戶名:從嵌套的 user 對象中提取
        "username":     user.get("nickname", thread.get("user_nickname", "")),
        "user_gender":  thread.get("user_gender", ""),
        # 發帖時間:Unix 時間戳 → 香港時間
        "create_time":  unix_to_hkt(thread.get("create_time", 0)),
        # 點讚 / 不點讚(帖子標題層面)
        "like_count":   thread.get("like_count", 0),
        "dislike_count":thread.get("dislike_count", 0),
        "reply_count":  thread.get("no_of_reply", 0),
        # 帖子頁數(每頁 25 條回覆)
        "total_page":   thread.get("total_page", 1),
        # 板塊名稱:從嵌套的 category 對象中提取
        "cat_name":     category.get("name", ""),
        "thread_url":   f"https://lihkg.com/thread/{thread.get('thread_id','')}/page/1",
        # 正文字段(後續爬取後填入)
        "body_text":    "",
        "body_like":    0,
        "body_dislike": 0,
    }

print("✅ 帖子列表爬取函數定義完成")
5
📄
爬取帖子正文(XPath 提取純文字)XPATH BODY

爬取帖子詳情 API,取得樓主第一樓的 HTML 正文,再用 XPath 提取純文字。重點示範 XPath 在真實 HTML 清洗中的應用。

def fetch_body(thread_id):
    """
    爬取帖子正文(樓主第一樓)。
    API:/api_v2/thread/{thread_id}/page/1?order=reply_time
    
    正文字段說明:
      item_data[0].msg          → HTML 格式正文
      item_data[0].like_count   → 正文點讚數
      item_data[0].dislike_count→ 正文不點讚數
      item_data[0].msg_num      → 樓層號(1 = 樓主)
      item_data[0].page         → 所在頁碼
    """
    url  = f"https://lihkg.com/api_v2/thread/{thread_id}/page/1?order=reply_time"
    data = safe_get(url)
    
    if not data or data.get("success") != 1:
        return "", 0, 0
    
    item_data = data["response"].get("item_data", [])
    if not item_data:
        return "", 0, 0
    
    # 找到樓主第一樓(msg_num == 1)
    first = next((p for p in item_data if p.get("msg_num") == 1), item_data[0])
    
    # ── XPath 核心:從 HTML 正文提取純文字 ────────────────────
    # first["msg"] 是 HTML 字符串,例如:
    #   '<a href="...">連結</a>今日天氣好好<br>出去行山'
    # 
    # 用 clean_body_html() 中的 XPath //text() 提取所有文字節點
    body_text = clean_body_html(first.get("msg", ""))
    
    return body_text, first.get("like_count", 0), first.get("dislike_count", 0)

print("✅ 正文爬取函數定義完成")
6
💾
斷點記錄(支持中斷後繼續)CHECKPOINT

斷點記錄功能:每爬完一頁就保存進度到 JSON 文件。中途中斷後再次運行,會自動從上次停止的地方繼續,不重複爬取。

def load_checkpoint():
    """讀取斷點記錄,返回上次爬到的頁碼和已爬帖子 ID 集合"""
    if os.path.exists(CHECKPOINT):
        with open(CHECKPOINT, "r", encoding="utf-8") as f:
            d = json.load(f)
        print(f"📌 發現斷點:上次爬到第 {d['last_page']} 頁,已爬 {len(d['ids'])} 帖")
        return d["last_page"], set(d["ids"])
    print("🆕 無斷點記錄,從第 1 頁開始")
    return 0, set()


def save_checkpoint(page, ids):
    """保存當前進度到斷點記錄文件"""
    with open(CHECKPOINT, "w", encoding="utf-8") as f:
        json.dump({"last_page": page, "ids": list(ids),
                   "saved_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")},
                  f, ensure_ascii=False, indent=2)

print("✅ 斷點函數定義完成")
7
🚀
主爬蟲:翻頁循環 + 保存 CSVMAIN

主函數:自動翻頁爬取所有帖子,寫入 CSV,每頁保存斷點。爬完後自動在 Google Colab 中下載 CSV 文件。

def run():
    """
    主爬蟲函數。流程:
    1. 讀取斷點 → 2. 逐頁爬列表 → 3. 爬正文 → 4. 寫 CSV → 5. 保存斷點
    """
    last_page, done_ids = load_checkpoint()
    start_page = last_page + 1

    # ── CSV 字段定義 ──────────────────────────────────────────
    fields = ["thread_id","title","username","user_gender","create_time",
              "like_count","dislike_count","reply_count","total_page",
              "cat_name","body_text","body_like","body_dislike","thread_url"]

    # 新文件寫表頭,舊文件追加(斷點續爬)
    mode = "a" if os.path.exists(OUTPUT_FILE) else "w"
    f_csv = open(OUTPUT_FILE, mode, newline="", encoding="utf-8-sig")
    writer = csv.DictWriter(f_csv, fieldnames=fields)
    if mode == "w":
        writer.writeheader()  # 只在新文件時寫表頭

    total = 0
    try:
        for page in range(start_page, (MAX_PAGES or 999) + 1):
            print(f"
{'─'*50}")
            print(f"📖 第 {page} 頁...")

            threads, has_more = fetch_thread_list(page)
            if not threads:
                print("⚠️ 列表為空,停止"); break

            for i, t in enumerate(threads):
                tid = t.get("thread_id")
                if tid in done_ids:
                    continue                      # 跳過已爬取的帖子

                row = parse_thread(t)             # 解析列表字段

                if FETCH_BODY:
                    print(f"  [{i+1}/{len(threads)}] 爬正文:{row['title'][:25]}...")
                    body, bl, bd = fetch_body(tid)
                    row["body_text"]    = body
                    row["body_like"]    = bl
                    row["body_dislike"] = bd
                    # 爬正文後隨機休息(防反爬)
                    time.sleep(random.uniform(*SLEEP_BODY))

                writer.writerow(row)              # 寫入 CSV
                done_ids.add(tid)
                total += 1

            f_csv.flush()                         # 刷新緩衝區,確保數據寫入磁盤
            save_checkpoint(page, done_ids)       # 每頁保存斷點

            print(f"✅ 第 {page} 頁完成,累計 {total} 帖")

            if not has_more:
                print("✅ 已爬完所有頁面"); break

            # 翻頁前隨機休息(防反爬)
            sleep = random.uniform(*SLEEP_LIST)
            print(f"⏸️  休息 {sleep:.1f} 秒...")
            time.sleep(sleep)

    except KeyboardInterrupt:
        print(f"
⚠️ 用戶中斷,已保存斷點(第 {page} 頁)")
        save_checkpoint(page - 1, done_ids)
    finally:
        f_csv.close()

    print(f"
{'='*50}")
    print(f"🎉 爬取完成!共 {total} 帖 → {OUTPUT_FILE}")
    return total


# ── 執行爬蟲 ─────────────────────────────────────────────────
run()
8
📊
查看結果 + 自動下載 CSVOUTPUT

用 pandas 查看爬取結果,顯示統計摘要,並自動觸發 Google Colab 下載 CSV 文件到本地。

import pandas as pd

# 讀取 CSV 並顯示基本統計
df = pd.read_csv(OUTPUT_FILE, encoding="utf-8-sig")

print(f"📊 共爬取 {len(df)} 個帖子")
print(f"
前 5 行預覽:")
print(df[["title","username","like_count","cat_name"]].head())

print(f"
各板塊帖子數:")
print(df["cat_name"].value_counts().head(10))

print(f"
點讚最高 5 帖:")
print(df.nlargest(5,"like_count")[["title","username","like_count","cat_name"]].to_string(index=False))

# ── 自動下載 CSV(Google Colab 專用)────────────────────────
try:
    from google.colab import files
    files.download(OUTPUT_FILE)       # 自動觸發瀏覽器下載
    print(f"
✅ {OUTPUT_FILE} 已開始下載!")
except ImportError:
    # 非 Colab 環境(本地 Jupyter)
    print(f"
💡 文件已保存至:{os.path.abspath(OUTPUT_FILE)}")
    print("   在 Colab 左側文件面板可找到並手動下載")

⚠️ 注意事項

LIHKG 有反爬機制,請勿將 SLEEP_LIST 設為低於 1 秒,否則可能被封 IP
開啟 FETCH_BODY=True 時,每帖需要額外一次 API 請求,爬取速度較慢
僅供學術研究用途,請遵守 LIHKG 使用條款,不得用於商業目的
CSV 使用 utf-8-sig 編碼,在 Excel 中可正確顯示中文