不是做一個爬蟲網站,
而是做一個能長期運行的交通事件資料庫
即時網頁會消失、官方不提供歷史 API。這個專案真正的資產是:把每一瞬間的觀測存下來, 再由系統推導延誤、取消、停駛、班距異常與車位變化。以下規劃附一組 今天從澳門國際機場實際抓下來並解析成功的資料 作為可行性證據。
以上四個數字讀自 data/latest/mfm_flights.json(一次真實採集的輸出)。 機場採集器目前仍是本地腳本、尚未接排程,所以這一頁是建置快照: 重跑 collectors/mfm_airport.py 並重新部署才會更新。
→ 即時停車場使用率(91 個停車場,Workers + D1 每 30 分鐘採集)
這三個頁面已部署到 nexusdata-web.pages.dev(Cloudflare Pages)。
/parking 的數字是瀏覽器直接向 採集器 Worker 的 /api/snapshot 取的,每 5 分鐘重新整理一次, 所以那不是建置當下凍結的快照;/live 與本規劃頁仍是建置快照。01定位與取捨
整個專案只靠一個判斷成立:觀測比結果值钱。 官方頁面只告訴你「現在」,而且隨時改版;你要做的是把「每一個現在」變成歷史, 然後從歷史推導出官方永遠不會發布的指標。
要做的
- 定時採集 → 存原始快照 → 解析 → 與上一筆比較 → 產生事件
- 事件定義要能被審計:來源網址、抓取時間、官方發布時間、內容 hash、parser 版本全部留痕
- 把「資料不完整」當成一種獨立結果,而不是硬湊成一個判斷
不做的
- 不把「航班從畫面消失」標成取消 —— 那是
missing_from_feed - 不把巴士的預計到站時間當成實際到站時間
- 不繞過登入、驗證碼或其他技術限制;不對官方介面高頻壓力測試
- 不在第一期就追求「全澳門所有路線」
02產品範圍與優先級
按「可取得性 × 事件定義清晰度」排序。第一版只做四類,且先做機場。
| 優先 | 類別 | 第一階段可記錄 | 主要價值 | 可行性(實測) |
|---|---|---|---|---|
| P0 | 機場航班 | 計劃/預計/實際時間、狀態、閘口、取消 | 最容易量化的取消與延誤 | 已驗證可取 |
| P0 | 巴士 | 路線、站點、到站預報、改道/停站 | 實際等車時間與服務可靠度 | 需逆向 SPA |
| P1 | 停車場 | 總車位、可用車位、滿位/開放狀態 | 繁忙時段與泊位壓力 | 來源待確認 |
| P2 | 輕軌 | 官方公告、服務時間、停駛、月度載客 | 先建事件庫,逐班資料不可假設可得 | 僅統計值 |
可行性這一欄是這一輪新增的:規劃階段就應該把「來源能不能取到」當成排程依據, 而不是等做到第 3 週才發現要逆向。
03三層資料模型(核心原則)
first_seen_at / last_seen_at,用於辨識「某個東西從何時的哪個狀態變成哪個狀態」。04系統架構
官方網站 / App API / 公告 RSS / 社交平台公告
|
v
Collector Workers(Python + httpx,必要時 Playwright)
├─ mfm_airport_collector 每 5 分鐘 [已實作並驗證]
├─ dsat_bus_realtime_collector 每 30-60 秒 [需先逆向]
├─ dsat_notice_collector 每 2-5 分鐘
├─ parking_collector 每 2-5 分鐘
├─ lrt_notice_collector 每 10-30 分鐘
└─ smg_weather_warning_collector 每 5 分鐘(延誤原因變數)
|
+-----------+------------+
v v
Raw Storage PostgreSQL (+ TimescaleDB)
R2 / S3 / MinIO ├─ 主檔資料
大型 HTML / 截圖 ├─ 時序觀測
小型 JSON → inline ├─ 事件資料
└─ 品質與異常紀錄
|
v
Analytics Jobs(每小時 / 每日 / 每月)
取消率、延誤分佈、實際候車時間、班距異常、滿位熱圖、颱風關聯
|
v
FastAPI ── Next.js Dashboard / 公眾查詢頁 / Telegram 提醒05來源實測結果(這一輪的新证据)
規劃裡最大的不確定性是「這些頁面到底能不能穩定取到結構化資料」。這一輪直接驗證了。
| 來源 | HTTP | 大小 | 渲染方式 | 結論 |
|---|---|---|---|---|
macau-airport.com/en/flights/real-time/departures | 200 | 423 KB | 伺服器直接渲染表格列 | httpx 即可,不需 Playwright |
macau-airport.com/en/flights/real-time/arrivals | 200 | 382 KB | 同上 | 同parser 复用 |
bis.dsat.gov.mo/ | 403 | 128 B | SPA,根路徑拒绝非瀏覽器 UA | 需瀏覽器逆向,列為第 2 週 spike |
bis.dsat.gov.mo/ws/ | 404 | 122 B | 主機活、路由存在差異 | 代表存在可尋的 API 表面 |
dsat.gov.mo/dsat/carpark_realtime_core.aspx | 200 | 144 KB | 伺服器渲染,91 個停車場一次回齊 | 已上線採集器(Workers + D1,每 30 分鐘) |
dsat.gov.mo/dsat/carpark_detail.aspx?id=N | 200 | ~5 KB | 同上,格式為 available/total | 總車位來源;91/91 已成功回填 |
機場頁面實際長什麼樣
每趟航班是一個 <tr>,關鍵欄位直接以 attribute 呈現, 對解析器非常友善:
<tr class="detail" id='NX856'
data-flight-date="2026-09-28"
data-is-departure="1">
<td class='d-none d-md-table-cell'>08:20</td> -- 計劃時間
<td><span style='display:none'>Air Macau</span> -- 航空公司(隱藏但可取)
<img src='.../Airlines/NX.png'/></td> -- IATA code 在圖檔名裡
<td class='d-none d-md-table-cell'>Osaka-Kansai</td> -- 航點
<td class='d-none d-md-table-cell'>7</td> -- 登機閘口
<td class='green-flight d-none d-md-table-cell'>
TOOK OFF AT 08:27 -- 狀態 + 實際時間
</td>
<td><span class='d-none'>iscompleted</span></td> -- 完成旗標
</tr>頁面另有 Last updated on: 28/09/2026 13:20:01。探集器必須把官方發布時間與自己的抓取時間分開存, 才能判斷究竟是官方沒更新還是採集器死了。
TOOK OFF AT hh:mm · Landed at hh:mm · CANCELLED / Cancelled(大小寫不一致)· BOARDING · FINAL CALL · GATE CLOSED · CHECK-IN B3-B9 OPEN · CHECK-IN CLOSED · Expected at hh:mmnormalized_status 集合;規劃原稿裡的 8 個值不夠用, 實測後新增了 checkin_open 與 estimated(見第 6 節)。停車場頁面實際長什麼樣
每個停車場是一個 <tr>,車種以並排的 inline-block 儲存格呈現, 詳情頁的儲存格直接寫著 available/total:
<div style="width:140px;display:inline-block">
<span class="carpark_icon_wrap">
<img src="./images/carpark_car.png?v=20260915" /> -- 車種(小客車)
<img src="./images/carpark_n.jpg" alt="夜" /> -- 夜間服務徽章
</span>
143/247 -- 可用/總車位
</div>這裡有三個會咬人的細節,解析器已經用回測 fixture 把它們鎖住:
- 徽章會插在圖示與數字之間。用「圖示往後 N 個字元找數字」的方式, 在亞馬喇前地(id 6009)會整個解不到——必須先剝掉標籤、再讀储存格尾端的數字。
- 詳情頁還有一塊
<!-- ... -->註解掉的舊車位表。註解一定要先拿掉,否則會把官方早就放棄維護的欄位當成第二個資料來源。 -不是 0。91 個停車場中有 38 個至少有一個車種顯示-,6 個完全沒有小客車感測器。把它們當成 0,等於對全澳門宣告 「這些停車場都滿了」。
carpark_ss_orange / carpark_ss_yellow兩種旗標,DSAT 沒有公布圖例。採集器把原始 token 原字存入 source_flag,不做任何解讀;等拿到官方定義再補映射, 屆時用原始快照重跑即可,不需要重新採集。carpark_24.jpg(24 小時收費)與 carpark_n.jpg(夜間服務)是兩枚不同的徽章,解析器已分開記錄為 open_24h 與 night_service。為什麼排程用 Cloudflare Workers,不是 GitHub Actions
原本考慮用 GitHub 的 worker 每 30 分鐘抓一次,實查後三個理由改判:Actions 的 scheduled workflow 在最壞情況下會延遲數十分鐘且順序不保證;每個公开 repo 的 60 天無活動後排程會被停用;而每一次运行都要重新下載依賴。Workers 的 Cron Trigger 是平台層排程,D1 則是帶事務的關式庫——雨者都在免費方案內,而且寫入做成冪等 (unique (facility_id, source_updated_at)),不會把 30 分鐘的重複 採樣變成重複資料。
取捨:D1 免費層從 2026-09-01 開始真正執行每日列數上限,所以採集器必須寫得冪等, 而不是依賴「每天只會抓 48 次」。這是同一個決策逼出來的工程約束。
06資料庫 Schema
先用 Supabase PostgreSQL 建,資料量起來再上 TimescaleDB。 下面是三個骨架表,完整 DDL 見 db/schema.sql。
6.1 原始快照表(最重要的一張)
create table raw_snapshots (
id bigserial primary key,
source_id uuid not null references data_sources(id),
fetched_at timestamptz not null default now(), -- 我何時抓的
source_published_at timestamptz, -- 官方何時发布的
request_url text not null,
response_status integer,
content_hash char(64) not null, -- SHA-256
storage_uri text, -- 大型 HTML/截圖 → R2/S3
inline_payload jsonb, -- 小型 JSON → 直接入表
parser_version text,
parse_status text not null default 'pending',
parse_error text,
duration_ms integer
);
-- 同一來源、同一內容、同一小時不重複入库
create unique index uq_raw_snapshot_source_hash_hour
on raw_snapshots (source_id, content_hash, date_trunc('hour', fetched_at));6.2 航班觀測
create table flight_observations ( id bigserial primary key, flight_id uuid not null references flights(id), raw_snapshot_id bigint references raw_snapshots(id), observed_at timestamptz not null, source_updated_at timestamptz, scheduled_time timestamptz, estimated_time timestamptz, -- 'Expected at' → 低信心 actual_time timestamptz, -- 'TOOK OFF AT' / 'Landed at' → 高信心 gate text, checkin_counter text, baggage_belt text, raw_status text, -- 保留原字串,永不丢 normalized_status text not null, delay_minutes integer, delay_confidence numeric(4,3),-- 1.000 實際 / 0.700 估計(實測新增) is_cancelled boolean not null default false, source_record jsonb not null );
flight_key 規則:MFM:{YYYY-MM-DD}:{arrival|departure}: {flight_number}:{station}, 例:MFM:2026-09-28:departure:NX856:Osaka-Kansai。 這個鍵是去重與事件串接的基礎,必須穩定。
6.3 統一事件流(前端只查這張)
create table transport_events ( id uuid primary key default gen_random_uuid(), transport_mode text not null, -- flight|bus|lrt|parking|ferry|traffic entity_type text not null, entity_id text not null, event_type text not null, severity text not null default 'info', -- info|minor|major|critical status text not null default 'active', -- active|resolved|superseded|unknown started_at timestamptz not null, ended_at timestamptz, title_tc text not null, description_tc text, cause_category text, source_url text, confidence numeric(4,3), metadata jsonb );
前端不需要分别查 flight_events、bus_service_events、 parking_observations,一張事件流就能拼出統一時間軸:
14:05 機場 | 離港 NX123 | 取消 | 高 14:07 巴士 | 路線 25B | 班距異常 23 分 | 中 14:10 停車場 | 栢港停車場 | 滿位 | 低
07事件判定規則
不要直接相信文字狀態,以時間差作主判定;文字狀態只作辅證。
航班
| 取消 | 官方明示 CANCELLED / 取消。消失不算。 |
| 延誤 | (預計或實際 − 計劃) ≥ 15 分鐘,或顯示 Delayed |
| 準點 | 最終實際時間相對計劃 −14 ~ +14 分鐘 |
| 嚴重延誤 | ≥ 60 分鐘 |
| 資料不完整 | 標 unknown / missing_from_feed,不直接當取消 |
巴士(沒有公開的「原定到站時間」)
第一期不强行稱「延誤分鐘」,改用三個可靠指標:
- 預測等候時間:某站某路線首次被偵測到 → 消失或到站的區間
- 班距 Headway:同路線相鄰兩班到同一站的實際時間差
- 異常班距:實際班距 > 該時段歷史中位數 × 1.5~2
路線 25B · 亞馬喇前地 · 平日 08:00-09:00 歷史中位班距 8 分;今日相鄰兩班相隔 23 分 → 服務異常,excess_wait = 15 分 → 不断言「班次取消」,除非官方公告可驗證
採集器内的事件偵測流水線
1 抓取 → 2 存 raw snapshot → 3 SHA-256 去重 → 4 解析成標準化紀錄 → 5 與最近一筆觀測比較 → 6 找出欄位變化 → 7 產生/更新事件 → 8 更新 entity.last_seen_at → 9 送進聚合 → 10 符合條件则觸發通知
這一輪已在 collectors/mfm_airport.py:diff_events() 實作步驟 5-7, 並用真實來源驗證了三段行為:首次運行產生 192 筆 first_seen; 4 秒後再次運行,因來源內容未變而產生 0 筆事件(去重與 diff 正確); 8 分鐘後的第三次運行抓到 2 筆真實狀態變化(BR802 departed、 FD763 boarding)。這就是一個可用的事件流的雛形。
08採集頻率分級
| 來源 | 離峰 | 特別時段 | 備註 |
|---|---|---|---|
| 機場離港/抵港 | 5 分 | 1-2 分 | 颱風、雷暴、節假日提速;須保留狀態變化 |
| 巴士即時到站 | 30-60 秒 | 20-30 秒 | 先選 20-50 條重點路線測試 |
| 巴士改道/特別通知 | 2-5 分 | 1 分 | 辨識服務中斷的主要訊號 |
| 停車場 | 2-5 分 | 1 分 | 週末、活動日;適合做可用泊位趨勢 |
| 輕軌公告 | 10-30 分 | 2-5 分 | 初期以公告事件為主 |
| 天氣警告(SMG) | 5 分 | 1 分 | 作為延誤原因的關聯變數 |
原規劃的 6 分鐘級距經過實測微調:機場頁面的 Last updated 實際約為每分鐘刷新, 所以 5 分鐘是安全的;巴士如果取不到 API 而必須走頁面,頻率就要往下修正以免形成壓力。
09四週路線圖(含驗收標準)
| 階段 | 工作 | 完成標準(可驗證) | 狀態 |
|---|---|---|---|
| 第 0 週 | 來源可行性 spike:機場、巴士、停車場各自的取数路径 | 每類來源有明確 HTTP 證據與選定解析方式 | 機場+停車場完成 |
| 第 1 週 | 航班取消追蹤器:Supabase 建表、探集器上排程、狀態變化入庫、告警 | 连续 7 天不間断採集;能回答「NX123 的取消首次出現在何時」 | collector 已寫好 |
| 第 2-3 週 | 巴士可靠度:20 條高需求路線(機場、口岸、閘外、外港碼頭、氹仔碼頭、半島核心區)、 班距與長時間無車異常偵測、DSAT 調整公告解析 | 能輸出「某站某路線的實際候車時間分佈」,且每筆都能回溯到原始快照 | 阻塞於 DSAT 逆向 |
| 第 4 週 | 停車場 + 交通事件地圖:主檔建檔、满位率/最早满位時間/平日週末熱圖、MapLibre 圖層 | 一張地圖能叠加航班、巴士、停車場、天氣警告事件 | 資料層已完成,待地圖 91 場主檔+每 30 分鐘觀測+ v_hourly_fullness 已就緒; 尚未做的是座標地理編碼與前端圖層 |
| 第 2 階段 | 輕軌(公告 + 服務時間 + 月度載客結構化)、渡輪(按營運商分别處理) | 統一進 transport_events,同一条時間軸可查 | 未開始 |
技術選型(定案)
| 層 | 技術 | 原因 |
|---|---|---|
| 採集器 | Python + httpx(動態頁才 Playwright);輕量 HTML 頁改用 Workers 內建 fetch | 實測顯示機場頁不需無頭瀏覽器,省掉一大塊運維成本;停車場頁小到可以直接跑在 edge |
| 排程 | 兩軌:系統 cron / Docker(航班)+ Cloudflare Cron Trigger(停車場,每 30 分鐘) | 航班採集器要跑 diff 與告警,留在長駐程序;停車場是單頁擷取,用平台排程免去一台主機 |
| API | FastAPI | 舆 Python 採集/分析同棧,共用 schema 定義 |
| 資料庫 | Supabase PostgreSQL → TimescaleDB;停車場先行落 Cloudflare D1 | 關係 + 時序 + 聚合一處解决;D1 的免費層足以支撐 30 分鐘一次的寫入,且冪等鍵讓重跑不產生重複資料 |
| 快取/佇列 | Redis | 去重、任務管理、即時結果快取 |
| 原始檔案 | 本地 data/raw/ → R2(待啟用) | 目前帳號尚未開啟 R2(API 回 10042),先用本機目錄+內容雜湊去重,啟用後直接把同批檔案搬上去 |
| 前端 | Next.js 靜態導出(output: "export")部署在 Cloudflare Pages + MapLibre | Pages 沒有 Node 執行期,所以整站預渲染成靜態檔,即時資料一律由瀏覽器打 Worker API;這個組合已經實際跑在 nexusdata-web.pages.dev 上,不是紙上選型 |
| 監控 | Uptime Kuma + Grafana + Sentry | 採集器失敗、資料中斷、來源改版都要能當日報警 |
10資料品質與合規紅線
留痕是底線
來源網址、抓取時間、官方發布時間、content hash、parser version 缺一不可審計。缺了這些,你的數字跟別人截的圖没有区别。
取消要硬判定
只有官方明示取消才算强判定;否則一律 missing_from_feed。誤報一次,平台公信力就没了。
預測不是事實
巴士 ETA、航班 Expected at 都要带信心標記,聚合時舆實際值分開統計。
有禮貌地採集
控制頻率、合理 User-Agent、退避重試、優先找網站實際使用的公開 API,不繞過登入與驗證碼。
改版是必然
每個 parser 配測試樣本(已保存的 HTML 快照就是天然 fixture),解析失敗或行數異常就告警。
來源不是你的
把 DSAT/機場平台視為面向乘客的發布管道,不做高頻壓力測試,不推測未公開介面可永久使用。
11最小可行產品與價值路徑
最值得先做的仍舊是那句結論:「澳門機場航班取消與延誤歷史資料庫」。 理由在本輪實測後更硬:事件定義清晰(官方直接寫 CANCELLED)、 單页就能拿到 192 筆結構化觀測、不需要瀏覽器自動化、 而且一天内就出現了 22 筆取消舆 7 筆延誤 —— 資料量可控但訊號足ク。
对外指標
- 今日取消數舆取消率
- 按航空公司 / 航點 / 抵離的中位、P90、最長延誤
- 取消首次出現時間(領先官方統計)
- 近 7 / 30 / 90 日趨勢
- 取消舆颱風、雷暴、能見度、節假日的關聯
受眾舆付费點
- 旅客:我的航班有没有被取消、延誤多久
- 接送/租車/酒店:提前判斷需求波動
- 旅遊業舆保險:延誤險定價與理賠事實根據
- 媒體:颱風季可做新聞級圖表
API 表面
GET /api/v1/flights?date=&direction=
GET /api/v1/flights/{flight_key}/history
GET /api/v1/flights/metrics?from=&to=
GET /api/v1/bus/routes/{code}/reliability
GET /api/v1/parking/heatmap?day_type=
GET /api/v1/events?mode=&status=
GET /api/v1/events/timeline12尚未驗證、需要下一步確認
| 假設 | 目前證據 | 如果不成立 |
|---|---|---|
| DSAT 巴士有可穩定存取的即時資料管道 | 根路徑 403,需瀏覽器 UA/Referer 或走 SPA 内部 API;本輪未成功取数 | 巴士改為「重點 20 站的人工/半自動錄入 + 公告解析」,可靠度指標延後一期 |
| 停車場有逐場即時可用車位來源 | 已成立並上線。91 個停車場、每場自帶官方時間戳,詳情頁提供 available/total;採集器已部署於 Cloudflare,cron 每 30 分鐘實際觸發 | —(此項已從假設轉為事實) |
| 停車場總車位(主檔)短期不變 | total_* 是主檔欄位、目前由一次性 script 回填;85/91 有小客車總位 | 把 bootstrap 排成每月重跑,並在 SQL 裡保留變更歷史;使用率一律以 「當時的 total」計算,不用今天的 total 回推昨天 |
carpark_ss_orange / ss_yellow 代表可解讀的停車場狀態 | 官方無圖例;本輪只原字存入 source_flag,未做任何判定 | 維持不解讀。若始终無定義,就從儀表板移除,只保留為除錯用的原始欄位 |
| 輕軌可提供逐班運行資料 | 官方公開的是月度統計(日均載客量) | 輕軌只做事件庫(公告、停駛、特殊安排)與載客趨勢 |
| 機場頁面結構短期不大幅改版 | 本輪为伺服器渲染,欄位含義需靠 class 推断,已有硬編碼風險 | 需要行數閾值告警 + 快照回放重建;这也是 L2 存在的意義 |