iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
自我挑戰組

IT工具與自我學IT的過程分享系列 第 17

【最終回總整理】我如何打造一套「ibon 票數偵測 + LINE 通知」系統(7 天學完實作到部署)

  • 分享至 

  • xImage
  •  

【最終回總整理】我如何打造一套「ibon 票數偵測 + LINE 通知」系統(7 天學完實作到部署)

不靠搶票外掛、不洗版、不吵人,只在「有感」的時候提醒你。這篇把 Day 1–7 的重點 一次打包
架構圖、爬蟲解析、差異比對(diff)、通知策略、訊息排版、部署 Runbook、常見雷坑與最佳實務。
直接複製本文即可貼到 iT 邦幫忙(支援 Markdown + 內嵌 SVG 圖),無須另行下載圖片


TL;DR(30 秒看完)

  • 做了什麼:一個會定期檢查 ibon 票況、只在變化時通知的 LINE Bot。
  • 厲害在哪
    • 解析器把雜亂的頁面 → 結構化資料(區域/價格/張數/狀態)。
    • 「只在變化時 + 合併視窗(coalesce)」→ 不洗版、不吵人。
    • 支援偏好過濾(只看 A 區、只看 2800)。
    • Cloud Run + Scheduler + Firestore → 便宜、耐用、好維運
  • 怎麼上線:Docker 打包 → Artifact Registry → Cloud Run → Scheduler 定時 → LINE Webhook → 監控告警。

系統架構(從檢查到通知)


一、解析頁面 → 結構化資料(Day 1–2)

把 ibon 的「票區 / 價格 / 當前狀態」抽出來。重點:穩定定位元素、清洗中文字串、保留「熱賣中/售完」等非數字狀態。

import requests
from bs4 import BeautifulSoup

def extract_ticket_info(url: str):
    html = requests.get(url, timeout=10).text
    soup = BeautifulSoup(html, "lxml")
    event = {
        "title": soup.select_one(".title").get_text(strip=True),
        "venue": soup.select_one(".venue").get_text(strip=True),
        "datetime": soup.select_one(".time").get_text(strip=True),
    }
    areas = []
    for row in soup.select(".ticket-area-row"):
        name = row.select_one(".name").get_text(strip=True)
        status = row.select_one(".status").get_text(strip=True)  # 可能是數字/熱賣中/已售完
        code = row.get("data-code") or name
        areas.append({"code": code, "name": name, "status": status})
    return {"event": event, "areas": areas}

實務小撇步

  • 切換不同活動頁測試,避免只對單一 DOM 結構 overfit。
  • 對「熱賣中」先不猜張數(避免誤導);後續可用啟發式估計。

二、防呆與健壯性(Day 3)

  • requests逾時 + 重試(backoff),避免網路抖動。
  • 將原始 HTML 的關鍵片段存到 Firestore,之後好 debug。
  • 禮貌抓取:合理間隔、加 UA、遵守網站條款。
import time, random
import requests

def fetch(url, retries=3):
    for i in range(retries):
        try:
            return requests.get(url, timeout=8, headers={"User-Agent":"Mozilla/5.0"}).text
        except Exception:
            time.sleep(1.5 * (i+1) + random.random())
    raise RuntimeError("fetch failed")

三、把雜訊變重點:差異比對 + 通知策略(Day 5)

核心:只在真正「有變化」時通知,並把短時間的多次變化合併成一則。

def diff_areas(old: list, new: list):
    old_map = {a["code"]: a for a in old}
    new_map = {a["code"]: a for a in new}
    changed, added, removed = [], [], []
    for code, a in new_map.items():
        if code not in old_map:
            added.append(a); continue
        if old_map[code]["status"] != a["status"]:
            changed.append({"from": old_map[code], "to": a})
    for code, a in old_map.items():
        if code not in new_map:
            removed.append(a)
    return {"changed": changed, "added": added, "removed": removed}
import time
def should_notify(change, policy="coalesce", last_sent=None, coalesce_sec=30):
    now = time.time()
    active = bool(change["changed"] or change["added"] or change["removed"])
    if policy == "always": return True, now
    if policy == "on_change": return active, (now if active else last_sent)
    if policy == "coalesce":
        if not active: return False, last_sent
        if not last_sent or now - last_sent >= coalesce_sec:  # 開新視窗
            return True, now
        return False, last_sent  # 視窗內:合併
    return active, (now if active else last_sent)

合併視窗是怎麼運作的?


四、讓訊息「一眼就懂」:排版 + 偏好過濾(Day 6)

  • 文字版:可售/熱賣/售完 三區塊,長列表自動截斷。
  • Flex 卡片版:標題、兩欄區塊、底部按鈕。
  • 偏好過濾:只顯示你在乎的價位、區域或關鍵字。
def apply_preferences(areas, prices=None, zones=None, regex=None):
    import re
    def keep(a):
        nm = a["name"]; ok = True
        if prices: ok &= any(p in nm for p in prices)
        if zones:  ok &= any(z in nm for z in zones)
        if regex:  ok &= re.search(regex, nm or "") is not None
        return ok
    return [a for a in areas if keep(a)]

def format_text_message(info, change=None):
    ev = info["event"]; out = [f"🎫 {ev['title']}", f"📍 {ev['venue']}  🗓 {ev['datetime']}", ""]
    avail = [a for a in info["areas"] if str(a["status"]).isdigit()]
    hot   = [a for a in info["areas"] if a["status"] in ("熱賣中","Hot","hot")]
    sold  = [a for a in info["areas"] if a["status"] in ("已售完","SoldOut","sold")]
    if avail: out.append("✅ 可售:" + "  ".join(f"{a['name']} {a['status']}" for a in avail[:8]) + (f" …(+{len(avail)-8})" if len(avail)>8 else ""))
    if hot:   out.append("🟢 熱賣中:" + "、".join(a["name"] for a in hot[:10]) + (f" …(+{len(hot)-10})" if len(hot)>10 else ""))
    if sold:  out.append("🔴 售完:" + "、".join(a["name"] for a in sold[:10]) + (f" …(+{len(sold)-10})" if len(sold)>10 else ""))
    if change:
        lines = []
        for c in change["changed"][:5]: lines.append(f"- {c['to']['name']}: {c['from']['status']} → {c['to']['status']}")
        if lines: out += ["", "🔔 變化摘要:"] + lines
    return "\n".join(out)

訊息排版藍圖(3 秒掃完重點)


五、部署到雲端(Day 7)

  • Docker 打包 → Artifact Registry → Cloud Run
  • Firestore 存 job 與快照 → Cloud Scheduler 定時戳 /cron/tick
  • LINE Webhook 指到 /webhook,Logging/Monitoring 開告警

成本心法:間隔 vs 請求量(估算)

建議預設 60 秒;手刀期可短到 20–30 秒;深夜可放到 120–300 秒,搭配合併視窗減噪音。


真實情境範例(吸睛示範)

情境 A|等補票的小芸

  • 偏好:只看 2800、1F、A/B 區
  • 政策:on-change + coalesce(30s)
  • 體感:每 10~20 分鐘來一次通知,但都是有用的

情境 B|演唱會開票 5 分鐘前的阿傑

  • 政策:always(短時間)
  • 體感:1 分鐘內可能連續 2–3 則通知,確保不漏關鍵變化;開賣後再切回 on-change

情境 C|維運值班的阿豪

  • 關心整體穩定性,觀察 抓取失敗率 / 延遲,超標就 LINE 告警到群組。

部署小抄(可直接用)

requirements.txt

flask==3.0.3
gunicorn==21.2.0
requests==2.32.3
beautifulsoup4==4.12.3
lxml==5.2.1
google-cloud-firestore==2.16.1
google-cloud-logging==3.10.0
line-bot-sdk==3.11.0

Dockerfile

FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . && pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
CMD exec gunicorn -b :$PORT -w 2 --timeout 90 app:app

gcloud(摘要)

# 推鏡像
PROJECT_ID=$(gcloud config get-value project)
REGION=asia-east1
IMAGE=asia-east1-docker.pkg.dev/$PROJECT_ID/ibon-repo/ibon-watch:latest
docker build -t "$IMAGE" . && docker push "$IMAGE"

# 部署 Cloud Run
gcloud run deploy ibon-watch --image "$IMAGE" --region $REGION --platform managed --allow-unauthenticated \
  --set-env-vars "DEFAULT_PERIOD_SEC=60,MAX_PER_TICK=5,TICK_SOFT_DEADLINE_SEC=25,LINE_CHANNEL_ACCESS_TOKEN=XXX,LINE_CHANNEL_SECRET=YYY"

# 每 60s 觸發
SERVICE_URL=$(gcloud run services describe ibon-watch --region $REGION --format='value(status.url)')
gcloud scheduler jobs create http ibon-cron --schedule="* * * * *" --uri="$SERVICE_URL/cron/tick" --http-method=POST

雷坑地圖(踩過的人才知道)

  • Webhook 不回 200 → LINE 會重試,記得回成功 JSON。
  • 排程打不進來 → 若關閉公開存取,改用 OIDC 觸發。
  • 解析格式變了 → 快速回滾:從快取/差異紀錄抓舊版 DOM,寫條件式兼容。
  • 通知洗版 → 打開 coalesce 視窗,或加上價位/區域偏好過濾。

版權與禮貌

  • 本文僅做提醒與資訊整理;購票仍需你親自完成。
  • 請維持合理檢查頻率與流量,尊重網站資源與服務條款。

上一篇
Day 7|升級路線圖與壓軸總整理(附部署檔清單)
下一篇
【三天學會樹莓派 5】從入門到實戰、再到全家族選購指南(含圖解 / 教學 / 比較)
系列文
IT工具與自我學IT的過程分享20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言