DEV Community

codemee
codemee

Posted on

誠品線上庫存查詢工具 — 技術報告

⚠ 本文由 Claude Code 生成

1. 問題背景

誠品線上(eslite.com)的商品頁面雖然會顯示庫存狀態,但僅能逐一開啟網頁人工確認,無法:

  • 一次查詢多筆商品
  • 對限量或熱銷商品做「上架/補貨」的持續監控
  • 以結構化格式(JSON)取得資料供其他程式串接

因此本專案目標是撰寫一支輕量 CLI 工具,直接呼叫誠品線上背後的商品 API,取代人工刷新網頁的行為。開發過程中遇到的核心技術問題並非「業務邏輯」,而是如何繞過前端防護、正確組出可用的 API 請求。以下逐項說明。


2. 核心技術問題與解法

2.1 Cloudflare TLS 指紋偵測導致 403

誠品線上的 API 後端(athena.eslite.com)架設在 Cloudflare 之後。Cloudflare 除了檢查 HTTP Header,還會在 TLS handshake 階段比對 JA3/JA3S 指紋(ClientHello 中的 cipher suite 順序、TLS 擴充欄位順序、支援的橢圓曲線等)。

Python 的 requestshttpx 底層使用 OpenSSL 產生的 TLS 指紋,與真實 Chrome 瀏覽器不同,即使把 User-Agent 偽裝成 Chrome,Cloudflare 仍能從 TLS 層辨識出「非瀏覽器流量」並直接回傳 403 Forbidden

# ❌ 會被 Cloudflare 擋下(TLS 指紋不符,即使偽造了 User-Agent)
import requests

resp = requests.get(
    "https://athena.eslite.com/api/v1/products/10012013492683166052007",
    headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0"},
)
print(resp.status_code)  # 403
Enter fullscreen mode Exit fullscreen mode

解法:改用 curl-cffi,它底層綁定 curl-impersonate,能重現特定瀏覽器版本(如 Chrome 120)的實際 TLS/HTTP2 指紋,而非只偽造 Header:

# ✅ curl-cffi 以 impersonate 參數重現真實 Chrome 的 TLS 指紋
from curl_cffi import requests as cffi_requests

resp = cffi_requests.get(
    "https://athena.eslite.com/api/v1/products/10012013492683166052007",
    headers={"Accept": "application/json", "Referer": "https://www.eslite.com/"},
    impersonate="chrome120",
    timeout=10,
)
resp.raise_for_status()
Enter fullscreen mode Exit fullscreen mode

impersonate="chrome120" 這行是整個工具能否運作的關鍵,缺少它請求一律被 Cloudflare 攔截。

2.2 API 要求動態時間戳參數

觀察誠品線上網頁實際發出的請求後發現,商品 API 每次呼叫都必須帶上 datetime query 參數,格式為 YYYYMMDDHHmmss(如 20260705143210),推測用於後端做請求時效驗證或快取失效判斷:

from datetime import datetime

def fetch_product(guid: str) -> dict:
    url = f"https://athena.eslite.com/api/v1/products/{guid}"
    ts = datetime.now().strftime("%Y%m%d%H%M%S")
    resp = cffi_requests.get(
        url,
        params={"datetime": ts},
        headers=_HEADERS,
        impersonate="chrome120",
        timeout=10,
    )
    resp.raise_for_status()
    return resp.json()
Enter fullscreen mode Exit fullscreen mode

若省略此參數或帶入固定值,部分情況下 API 會回傳錯誤或非預期的快取結果,因此每次請求都即時產生當下時間戳。

2.3 商品 GUID 的容錯擷取

使用者輸入的來源可能是純數字 GUID,也可能是完整商品頁 URL(如 https://www.eslite.com/product/10012013492683166052007)。為避免要求使用者自行複製 GUID,改用正規表達式從任意字串中擷取 20 位以上的連續數字:

import re

_GUID_RE = re.compile(r"\d{20,}")

def extract_guid(text: str) -> str:
    m = _GUID_RE.search(text)
    if m:
        return m.group()
    raise ValueError(f"無法從 {text!r} 解析出商品 GUID(需為 20 位以上數字)")
Enter fullscreen mode Exit fullscreen mode

這讓 CLI 同時支援兩種輸入形式:

uv run eslite_stock.py 10012013492683166052007
uv run eslite_stock.py https://www.eslite.com/product/10012013492683166052007
Enter fullscreen mode Exit fullscreen mode

2.4 持續監控(--watch)與狀態顯示

限量書籍的補貨時機難以預測,因此加入 --watch N 參數,每 N 秒重新查詢並清空終端機重繪,直到使用者按 Ctrl+C 中斷:

if args.watch:
    try:
        while True:
            console.clear()
            query_all(guids, args.raw_json)
            console.print(f"\n[dim]每 {args.watch} 秒更新一次,按 Ctrl+C 結束[/dim]")
            time.sleep(args.watch)
    except KeyboardInterrupt:
        console.print("\n[dim]已停止監控[/dim]")
Enter fullscreen mode Exit fullscreen mode

庫存與購買狀態以顏色區分,方便在終端機掃視多筆商品時快速辨識可搶購的項目:

_BUTTON_LABEL = {
    "add_to_shopping_cart": "[bold green]可加入購物車[/bold green]",
    "out_of_stock":         "[bold red]缺貨[/bold red]",
    "not_for_sale":         "[yellow]停售[/yellow]",
    "pre_order":            "[cyan]預購中[/cyan]",
}
Enter fullscreen mode Exit fullscreen mode

2.5 多目標查詢與錯誤隔離

支援一次傳入多個 GUID/URL,且單一商品查詢失敗(如 GUID 打錯、該商品已下架)不應中斷整批查詢:

def query_all(guids: list[str], raw_json: bool) -> None:
    for i, guid in enumerate(guids):
        try:
            data = fetch_product(guid)
        except Exception as e:
            status = getattr(getattr(e, "response", None), "status_code", None)
            if status:
                console.print(f"[red]HTTP {status}(GUID: {guid})[/red]")
            else:
                console.print(f"[red]錯誤:{e}(GUID: {guid})[/red]")
            continue
        ...
Enter fullscreen mode Exit fullscreen mode

每筆商品各自 try/except,確保部分失敗不影響其餘商品的查詢結果。

2.6 Windows 終端機中文編碼相容性

在部分 Windows 終端環境(如舊版 cmd.exe)下,sys.stdout 預設編碼並非 UTF-8,直接輸出中文書名或 rich 的方塊字元會出現亂碼或 UnicodeEncodeError。啟動時主動偵測並重新設定編碼:

if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8":
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
Enter fullscreen mode Exit fullscreen mode

2.7 PEP 723 獨立腳本(零安裝執行)

本工具以 PEP 723 內嵌相依套件宣告,使用者無需事先 pip install 或維護虛擬環境,uv run 會自動解析並快取相依套件:

# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "curl-cffi>=0.7",
#   "rich>=13",
# ]
# ///
Enter fullscreen mode Exit fullscreen mode
uv run eslite_stock.py <GUID或URL> [--watch 秒] [--json]
Enter fullscreen mode Exit fullscreen mode

3. 架構小結

使用者輸入 (GUID / URL)
        │
        ▼
  extract_guid()  ──▶ 正規表達式擷取 20 位以上數字
        │
        ▼
  fetch_product()  ──▶ curl-cffi + impersonate="chrome120" 繞過 Cloudflare TLS 指紋偵測
        │              帶入即時 datetime 參數
        ▼
  athena.eslite.com/api/v1/products/{guid}
        │
        ▼
  show_product() / json.dumps()  ──▶ rich 彩色輸出 或 原始 JSON
        │
        ▼
  (若 --watch)迴圈重新查詢,Ctrl+C 結束
Enter fullscreen mode Exit fullscreen mode

整個專案最主要、也最不易從程式碼本身看出「為什麼這樣寫」的技術決策,是 2.1 節的 TLS 指紋問題:一般開發者在遇到 API 回傳 403 時,直覺會先懷疑 Header 或 Cookie,但根因其實在 TLS 層,必須改用能重現瀏覽器指紋的函式庫(curl-cffi)才能解決,單純偽造 User-Agent 完全無效。

Top comments (0)