用 GitHub Actions 抓原價屋報價,做成可分享估價單的靜態網站
想做一個「免登入、不用伺服器、連結就是估價單」的配電腦網站,最省事的組合是:GitHub Actions 定時抓原價屋估價頁並解析成 JSON,GitHub Pages 放純靜態網頁,分享時把整張單(分類、品名、當時價格、日期)壓縮後寫進網址 # 後面。以下以 原價屋估價單分享 (原始碼 )為例,整理做法與實際踩到的坑。
以下內容整理於 2026 年 10 月;原價屋頁面結構隨時可能改版,實作細節以 repo 現況為準。這是個人工具,不是原價屋官方網站。
整體架構
整個網站沒有後端,資料流只有三段:
抓價:
fetch_coolpc.py下載https://www.coolpc.com.tw/evaluate.php,交給parse_coolpc.py解析成data/coolpc_prices.json(30 個分類 → 群組 → 商品)。產生網頁資料:
build_site.py依site.json與builds/*.json(預設配置)輸出三個檔:docs/data.json:主機相關 11 個分類與預設配置,首頁一定載入。docs/data-more.json:其他分類(螢幕、鍵鼠、作業系統…),切到「全部分類」才載入。docs/history.json:主機零件的價格變動紀錄。
前端:
docs/index.html加builder-ui.js、builder.css,原生 HTML/CSS/JavaScript,不需要 build 工具。
所有腳本只用 Python 3 標準函式庫,CI 不必 pip install。
抓價與解析
Big5 解碼要有 fallback
原價屋估價頁回傳 Big5。用 cp950 解大部分沒問題,但頁面偶爾混進香港增補字(例如 0xFB40 ),整頁 decode() 直接丟例外,排程就會連續失敗。
解法是逐字退回 big5hkscs (行為與瀏覽器的 TextDecoder('big5') 一致),真的解不出來才換成 � ,並把字數印到 stderr,排程不中斷。
另外送 Accept-Encoding: gzip 可以把每次傳輸從約 1.06MB 降到約 220KB; urllib 不會自動解壓,要自己看 Content-Encoding 再 gzip.decompress()。
估價頁的資料結構
每個分類是一個 <SELECT name=nN> ,商品是 <OPTION value=N>品名, $價格[↘$下殺價] 符號</OPTION> 。解析時要注意:
disabled的 OPTION 是說明列(活動、續行),不是商品。活動說明或運送提醒偶爾被寫成一般 OPTION、標價
$1,解析時略過標價 ≤ $1 的列。驗證方式:各分類解析出的商品數,應等於該分類第一列「共有商品 N 樣」減去略過的
$1說明列。品名裡的「搭板專案」、「組裝價」、「任搭折N」等是條件價,解析成
flags,前端再提示使用條件。
GitHub Actions 只在價格變動時 commit
workflow 每小時 :30 抓一次,重新產出資料後, 只有內容真的變了才 commit (以下為節錄,省略產生估價單 Markdown 的步驟):
幾個要點:
先
git add -A再比 staged 。不要寫成git diff --quiet -- <檔案>,它看不到第一次產生、還沒追蹤的新檔。輸出要 deterministic 。內容沒變時
build_site.py沿用上次的generated時間戳,history.json的 key 排序且不放時間戳,才不會每小時產生空 commit。時區 :runner 是 UTC,時間戳在 Python 裡用
ZoneInfo("Asia/Taipei")產生。測試 workflow 要排除資料 commit:
test.yml的paths-ignore加上data/**、docs/data.json、docs/history.json等,機器人的報價 commit 才不會觸發測試。
排程不準時
GitHub 的 schedule 只是排進佇列。實測 2026 年 9 月每天 03:30 UTC 的排程,實際在 08:11 到 10:01 UTC 才開跑,晚了 4.7 到 6.5 小時,還有一天整次被跳過。
所以「每小時更新」實際上可能是一天幾次。網頁上方要標出報價日期,快照超過 24 小時沒更新另外提示,不要對使用者承諾準時。
public repo 使用標準 runner 不計 Actions 分鐘數,每次約 10 到 25 秒;private repo 每小時跑一次約 720 分鐘/月,會吃掉免費方案大半額度。
分享連結就是估價單
分享時把估價單快照(每列分類、品名、當時價格,加上報價日期)序列化成 JSON,壓縮後放在網址 hash:
連結 | 打開後 |
|---|---|
| 唯讀估價單(可列印、存 PDF、帶 QR Code) |
| 直接進編輯畫面 |
hash 不會送到伺服器,所以估價單完全不經過任何後端;GitHub Pages 換網域做 301 時,瀏覽器也會帶著 # 後面的內容,舊分享連結不會壞。
用字首標記編碼版本
q 的第一個字元是編碼方式 ,不是資料格式版本:
1:base64url(JSON)2:base64url(deflate-raw(JSON)),用瀏覽器內建的CompressionStream
規則是解碼端永遠同時支援 1 與 2 ,新欄位直接加在 JSON 裡(舊版網頁會忽略不認得的鍵),不為新欄位升版。實測 8 列主機單從 1,243 字元壓到 1,028 字元;中文品名不太好壓,加購的其他商品越多省得越多。
實作時要注意:
不能只檢查
CompressionStream存不存在 。部分舊版 Chromium 有這個類別但不支援deflate-raw,要實際試建一次,失敗就退回1。解壓後限制大小 (例如 256KB),避免壓縮炸彈。
編碼變成 async 之後,剪貼簿要改寫法 。Safari 在離開使用者手勢後不讓寫剪貼簿,先把壓縮結果快取起來;拿不到時把 promise 交給
ClipboardItem,不要await完再呼叫writeText()。Node 端要用到
deflate-raw需 Node 20.12 以上。
打開舊連結時跟現價比
打開分享連結時一定先還原當初的內容,再跟目前的報價逐列對照,列出「分享時 → 現在」的漲跌、合計差與已下架品項。改過的列改用現價,沒改的列保留原報價,並另外列出「跟分享時比換了哪幾格」。
用品名對照,不用選項 id
原價屋選項的 value 只是清單位置,每次更新都可能變。估價單、預設配置與分享連結一律用「分類 + 品名」當鍵;原價屋自己的手機查價清單與帶單功能也是用品名當商品鍵。
品名對不到時還要再判斷一次:
品名開頭的「搭板專案」、「裝機價」等條件價結束後,品名會變,但
{…}裡的型號不變。這種情況標「優惠已結束」,跟同型號現價比,不算下架。同型號也找不到,才標「已下架」,並保留最後的品名與價格讓使用者改選。
預設配置的零件下架時,排程不能失敗:估價單標「已下架」不計價,網頁沿用上一版資料,再由人工找替代品。
價格歷史
每個零件旁的「30 天最低/最高」與迷你折線,來自 docs/history.json 。設計重點:
key 是「分類 id + 型號」,型號取品名
{…}內的文字;搭板、組裝價這類條件價另外加一段條件碼分開記,一般件的「30 天最低」才不會是搭板價。同型號有多款(代理、平輸)時取最低價,網頁標「同型號最低款」。
只在價格變動時追加,同一天只留最新一筆,保留 90 天。
上一版檔案壞掉或格式不對就從頭記,並在 stderr 提示,不讓排程失敗。
既有網站第一次啟用時,從
git log -- data/coolpc_prices.json依序回填(淺 clone 要先git fetch --unshallow)。實際回填 42 次快照,得到 2,761 個 key(其中 162 個是條件價件)、約 158KB。
前端與 Python 的型號與條件 regex 必須完全一致,否則前端查不到 key;這類「兩邊各寫一份」的規則要用測試釘住。
帶單回原價屋估價頁
配好後可以在新分頁開啟原價屋官方估價頁,品項與數量已預先選好。原價屋接受 POST:
iname=<>品名1<>品名2…,icnt=<>數量1<>數量2…表單要用 Big5 編碼送出:前端臨時建立
<form method="post" accept-charset="big5" target="_blank">再submit()。不需要 cookie 或 session;對不到的品名會被直接略過、不報錯,所以送出前先提醒已下架的列。
這只是開啟估價頁,不會下單。原價屋另有會送出 Email、手機或地址的頁面,網站一律不串。
驗收時要注意:部分 App 內建的瀏覽器面板會把 target="_blank" 改成同分頁 GET,POST 內容會遺失,看起來像功能壞掉;請用一般瀏覽器確認。
店家模式:用原價屋報價開自家估價單
電腦店老闆或業務可以開店家模式幫客人報價:估價單抬頭換成店名與聯絡方式,以原價屋合計為基準填一個本店價、加組裝費等服務,也能隱藏原價屋單價,再複製一段含本店合計與連結的訊息傳給客人。整套仍然沒有後端,店家資料存在自己的瀏覽器,報價跟著分享連結走。
資料放在哪
資料 | 存放位置 | 內容 |
|---|---|---|
店家資訊 | 這台裝置的 | 店名、業務、電話、LINE ID、地址、標語 |
店家報價 | 每個分頁各一份,分享時寫進連結 | 本店價、服務列(最多 8 列)、客戶稱呼、有效期限、備註、是否隱藏原價屋單價 |
分享連結不升版,只在 JSON 加選用欄位: s (店家資訊)、 p ([本店價, 填價時的原價屋合計])、 x (服務列)、 c (客戶)、 u (有效期限)、 m (備註)、 h (隱藏)。店家資訊存成固定順序的陣列,尾端空字串省略,連結短一點。
重點是沒有店家資料時,編碼結果跟改版前逐字相同 ,用測試釘住;舊版網頁打開店家連結,只會當成一般分享單顯示,不會報錯。
連結內容一律當成不可信
分享連結任何人都能捏造,店家欄位又會顯示在估價單抬頭、變成撥號與 LINE 連結,所以解碼時逐欄清理與驗證,不合格只丟該欄:
先移除控制字元、零寬字元與 bidi 控制字元,再檢查長度(例如店名 30 字、地址 60 字、備註 200 字);超長整欄丟棄,不截斷。
電話只收數字、
+-()#與空白,且至少 6 碼;LINE ID 限@?[a-z0-9._-]{3,20}。店名含「原價屋」或
coolpc一律不收,避免冒用;沒有店名就不算店家單。金額必須是範圍內的整數,服務列可以是負數(折抵),$0 顯示「免費」;有效期限必須是真實存在的
YYYY-MM-DD。tel:與https://line.me/...的網址只由同一個函式產生,值不合格就不出連結。
驗收時把 <svg onload>、 <img onerror> 放進店名、品名、服務名稱、客戶、備註等每個欄位,確認都只顯示成文字(包含分頁標題),沒有執行。
本店價要能察覺過期
本店價是整單一個價,不逐項填。填價時同時記下當下的原價屋合計,之後只要合計對不上,就代表零件或價格變了(以下為省略欄位驗證的簡化版):
改零件、改數量或全部改用現價後標「需重新確認」,店家按「保留本店價」可以解除,改回原本的配置會自動解除。客人改過零件再分享回來,估價單上顯示「本店價(需向店家確認)」。
誰能改這張單
「同一家店」用正規化後的店名判斷,不分大小寫、全半形與空白。
自己的單 :店家模式開著,而且這張單沒有店家或就是本店,可以編輯。店家打開客人自己配的一般分享單再轉傳,會帶上自己的店家資訊。
別家店的單 :唯讀;店家模式開著時可以按「改用本店報價」,改成自己的單。
客人 (本機沒有店家資料):唯讀,沒有「帶到原價屋估價頁」按鈕,改提供「複製零件清單」。
唯讀估價單的店家版
抬頭是店家區塊:手機點電話直接撥號、點 LINE 加好友;另列客戶稱呼與有效期限,過期會標示。
合計依序列出原價屋合計、本店價、服務列、本店合計。
拿掉原價屋專屬的漲跌 ▲▼、下架、條件標籤、任搭折與帶單按鈕。
隱藏模式只剩項目、品名、數量欄,整頁不出現任何原價屋金額。
保留「由網站產生」的小字與店家版聲明,不假裝是店家自己的系統。
店家設定連結
老闆設定一次,用「店家設定連結」把店家資訊傳給同事,格式是 ?openExternalBrowser=1#s=1<base64url(JSON)>:
連結不含業務姓名 。打開時先跳出設定對話框、標出會被取代的欄位,保留這台裝置原本的業務姓名,按「儲存」才套用,取消不改任何資料。
openExternalBrowser=1是給在 LINE 裡點開的情況,讓 LINE 內建瀏覽器改用外部瀏覽器開啟;這點還沒在實機驗證。估價單的分享網址只帶
#,不帶?後面的參數,設定連結的參數不會跟著估價單傳出去。
驗收重點
店家模式關閉時,估價單、摘要與分享連結都跟改版前相同。
輸入本店價與備註時,畫面重畫後輸入框要保留值與游標。網址同步(
history.replaceState)加上 400ms 延遲與try/catch,避免 Safari 限制呼叫頻率時報錯;Safari 注音輸入尚未實測。1440、1024、768、390px 都沒有水平溢出,手機上的撥號、LINE 與勾選框至少 44px;列印或存 PDF 是白底、沒有按鈕,合計區不跨頁。
想要即時報價得加 CORS 代理
原價屋沒有開 CORS,網頁不能直接 fetch() 估價頁。repo 裡準備了 Cloudflare Worker 代理(原樣轉送 Big5 頁面、加 CORS header、邊緣快取 5 分鐘),再用 parse_coolpc.py 的 JavaScript 版在瀏覽器解析。
目前網站沒有啟用,價格只靠 workflow 快照。若要啟用,兩份解析器要一起改,並用同一份頁面驗證兩邊輸出的 JSON 完全相同。
其他自動化與測試
追蹤配單來源 :預設配置照公開的每月組裝建議文章配。另一個每日 workflow 抓文章的菜單段落,跟上次存檔比對;菜單有變就開 Issue 附差異,同標題 Issue 還開著就改成留言,人工收到後再重配。整套不用 AI。
測試 :push/PR 時跑 Node 與 Python 測試,並比對網站與模板的介面檔是否一致。
Node 22 的
node --test不接受目錄參數 ,CI 要用 glob:
本機試跑
瀏覽 http://localhost:8765/ 即可預覽。要另做一個網站(例如某款遊戲專用的配單站),在新目錄跑 build_site.py --init 會複製腳本與網頁骨架,再改 site.json 與 builds/*.json。