NexusData
Macau Mobility Intelligence · 規劃書 v1

不是做一個爬蟲網站,
而是做一個能長期運行的交通事件資料庫

即時網頁會消失、官方不提供歷史 API。這個專案真正的資產是:把每一瞬間的觀測存下來, 再由系統推導延誤、取消、停駛、班距異常與車位變化。以下規劃附一組 今天從澳門國際機場實際抓下來並解析成功的資料 作為可行性證據。

已驗證來源
MFM
抵港 + 離港,純 HTTP 可取
單次解析航班
192
96 離港 / 96 抵港
今日已判定的取消
22
11.5% 取消率(即時快照)
今日已判定的延誤
7
≥15 分鐘,最長 43 分鐘

以上四個數字讀自 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三層資料模型(核心原則)

L1 · 主檔資料 Master Data
航班、路線、站點、停車場、航空公司。相對穩定,帶 first_seen_at / last_seen_at,用於辨識「某個東西從何時的哪個狀態變成哪個狀態」。
↓ 每次採集寫入,不可覆蓋
L2 · 原始觀測 Raw Snapshots
完整 HTML / JSON / 截圖 / response headers,SHA-256 去重。這層是保險: 日後發現解析邏輯錯了,可以重跑快照重新計算,而不會永久失去歷史。
↓ 可重播、可重算
L3 · 標準化事件 Derived Events
取消、延誤開始、延誤增加、落地、起飛、改道、停站、滿位、恢復服務。 這一層可以被删掉重建 —— 只要 L2 還在。

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 提醒
關於 n8n 的界線
n8n 負責排程、告警、通知分發;不要把抓取、重試、去重、解析、存快照 這整條鏈交給 n8n。那些需要程式級重試與幂等保證的邏輯,放 Python worker 才守得住。

05來源實測結果(這一輪的新证据)

規劃裡最大的不確定性是「這些頁面到底能不能穩定取到結構化資料」。這一輪直接驗證了。

來源HTTP大小渲染方式結論
macau-airport.com/en/flights/real-time/departures200423 KB伺服器直接渲染表格列httpx 即可,不需 Playwright
macau-airport.com/en/flights/real-time/arrivals200382 KB同上同parser 复用
bis.dsat.gov.mo/403128 BSPA,根路徑拒绝非瀏覽器 UA需瀏覽器逆向,列為第 2 週 spike
bis.dsat.gov.mo/ws/404122 B主機活、路由存在差異代表存在可尋的 API 表面
dsat.gov.mo/dsat/carpark_realtime_core.aspx200144 KB伺服器渲染,91 個停車場一次回齊已上線採集器(Workers + D1,每 30 分鐘)
dsat.gov.mo/dsat/carpark_detail.aspx?id=N200~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。探集器必須把官方發布時間與自己的抓取時間分開存, 才能判斷究竟是官方沒更新還是採集器死了。

觀察到的狀態詞彙(2026-09-28 實測)
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:mm
這批詞彙直接決定了 L3 的 normalized_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 與告警,留在長駐程序;停車場是單頁擷取,用平台排程免去一台主機
APIFastAPI舆 Python 採集/分析同棧,共用 schema 定義
資料庫Supabase PostgreSQL → TimescaleDB;停車場先行落 Cloudflare D1關係 + 時序 + 聚合一處解决;D1 的免費層足以支撐 30 分鐘一次的寫入,且冪等鍵讓重跑不產生重複資料
快取/佇列Redis去重、任務管理、即時結果快取
原始檔案本地 data/raw/ → R2(待啟用)目前帳號尚未開啟 R2(API 回 10042),先用本機目錄+內容雜湊去重,啟用後直接把同批檔案搬上去
前端Next.js 靜態導出(output: "export")部署在 Cloudflare Pages + MapLibrePages 沒有 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/機場平台視為面向乘客的發布管道,不做高頻壓力測試,不推測未公開介面可永久使用。

商業化前要先釐清
公開可查 ≠ 可轉售。若要對企業客收费(旅遊業、保險、酒店、租車、接送服務), 需要逐來源確認 Terms of Use,必要時走官方授權申請;同時保留「資料僅供參考」的免責表述。

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/timeline

12尚未驗證、需要下一步確認

假設目前證據如果不成立
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 存在的意義