2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

Improve runtime resilience and flightwatch fallback (#202) * Add ADSB.lol fallback for rare-aircraft scans * Keep flight observation attribution accurate on fallback * Add DCA and PNS airport aliases * Avoid pytest collection exit in companion tests * Test rare-aircraft provider fallback * Run flightwatch regression tests before deployment * Add pull request regression gate for runtime fixes * Let pull request regression checks run beside scheduled deploys

SHA
2c1375781a689830911d01ea25ec65b2329909f3
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (6)

  • .github/workflows/hourly.yml modified · +37 / −1
  • config/airport_iata_aliases.json modified · +2 / −0
  • pipeline/flightnews.py modified · +5 / −2
  • pipeline/flightwatch.py modified · +35 / −15
  • tests/test_companion.py modified · +2 / −1
  • tests/test_flightwatch.py modified · +34 / −0

Full diff

@@ -4,17 +4,48 @@ on:\n   schedule:\n     - cron: "1 * * * *" # hourly; 16:01 UTC is 00:01 Asia/Taipei\n   workflow_dispatch:\n+  pull_request:\n+    branches: [main]\n+    paths:\n+      - ".github/workflows/hourly.yml"\n+      - "pipeline/adsb.py"\n+      - "pipeline/flightwatch.py"\n+      - "pipeline/flightnews.py"\n+      - "pipeline/airport_codes.py"\n+      - "config/airport_iata_aliases.json"\n+      - "tests/test_companion.py"\n+      - "tests/test_flightwatch.py"\n+      - "tests/test_airport_codes.py"\n   push:\n     branches: [main]\n     paths-ignore:\n       - "data/**" # bot data commits must not retrigger the workflow\n \n concurrency:\n-  group: hourly\n+  group: ${{ github.event_name == 'pull_request' && format('hourly-pr-{0}', github.event.pull_request.number) || 'hourly' }}\n   cancel-in-progress: false\n \n jobs:\n+  regression:\n+    if: github.event_name == 'pull_request'\n+    runs-on: ubuntu-latest\n+    permissions:\n+      contents: read\n+    steps:\n+      - uses: actions/checkout@v5\n+      - uses: actions/setup-python@v6\n+        with:\n+          python-version: "3.12"\n+          cache: pip\n+      - run: pip install -r requirements.txt\n+      - name: Run pipeline regression tests\n+        run: |\n+          python tests/test_companion.py\n+          python tests/test_flightwatch.py\n+          python tests/test_airport_codes.py\n+\n   update:\n+    if: github.event_name != 'pull_request'\n     runs-on: ubuntu-latest\n     permissions:\n       contents: write # commits data/ back to the repo\n@@ -40,6 +71,11 @@ jobs:\n       - name: Test summary boundary guard\n         run: python tests/test_summary_guard.py\n \n+      - name: Test source enrichment and flightwatch resilience\n+        run: |\n+          python tests/test_companion.py\n+          python tests/test_flightwatch.py\n+\n       - name: Test headline priority and subject scope\n         run: python tests/test_headline_rotation.py\n \n@@ -7,6 +7,8 @@\n     "Chicago O'Hare": "ORD",\n     "O'Hare": "ORD",\n     "芝加哥奧黑爾": "ORD",\n+    "Ronald Reagan Washington National Airport": "DCA",\n+    "Pensacola International Airport": "PNS",\n     "Philadelphia International Airport": "PHL",\n     "Northeast Philadelphia Airport": "PNE",\n     "Atlantic City Bader Field": "AIY",\n@@ -128,12 +128,15 @@ def assemble_source(event: dict) -> str:\n     operator = _operator_details(event)\n     arrival = event.get("arrival") or {}\n     rar = event.get("rarity") or {}\n+    primary_provider = str(event.get("primaryProvider") or "Airplanes.live")\n+    secondary_provider = ("ADSB.lol"\n+                          if primary_provider != "ADSB.lol" else "未使用獨立第二來源")\n     lines = [\n         '<SOURCE type="flight_observation">',\n         f"事件 ID:{event.get('eventId')}",\n         "資料類型:公開 ADS-B 觀測",\n-        "主要資料來源:Airplanes.live",\n-        "第二資料來源:ADSB.lol",\n+        f"主要資料來源:{primary_provider}",\n+        f"第二資料來源:{secondary_provider}",\n         f"交叉確認狀態:{event.get('crossCheck')}",\n         f"機場 ICAO:{airport.get('icao')}",\n         f"機場名稱:{airport.get('name_zh')}",\n@@ -438,17 +438,31 @@ def main() -> None:\n         "duplicate_events_skipped", "secondary_confirmations",\n         "conflicting_sources", "queued_news_events")}\n \n-    # ONE wide-area scan per run; failure leaves every file untouched.\n+    # Prefer Airplanes.live, but fail over to ADSB.lol for the wide-area\n+    # scan. A single provider outage must not silently blind the monitor.\n+    # If both providers fail, preserve every state file: unknown sky is\n+    # never interpreted as an empty sky.\n+    scan_provider = "Airplanes.live"\n     stats["provider_requests"] += 1\n     try:\n         raw_rows = adsb.airplanes_live_point(CENTER_LAT, CENTER_LON, RADIUS_NM)\n-    except adsb.ProviderDown as exc:\n+    except adsb.ProviderDown as primary_exc:\n         stats["provider_failures"] += 1\n-        print(f"flightwatch: primary provider unavailable ({exc}); "\n-              "state untouched - an API failure is NOT an empty sky")\n-        print("flightwatch stats: "\n-              + " ".join(f"{k}={v}" for k, v in stats.items()))\n-        return\n+        print(f"flightwatch: Airplanes.live unavailable ({primary_exc}); "\n+              "trying ADSB.lol wide-area fallback")\n+        stats["provider_requests"] += 1\n+        try:\n+            raw_rows = adsb.adsb_lol_point(CENTER_LAT, CENTER_LON, RADIUS_NM)\n+            scan_provider = "ADSB.lol"\n+            print("flightwatch: ADSB.lol wide-area fallback active")\n+        except adsb.ProviderDown as fallback_exc:\n+            stats["provider_failures"] += 1\n+            print(f"flightwatch: both scan providers unavailable "\n+                  f"(Airplanes.live: {primary_exc}; ADSB.lol: {fallback_exc}); "\n+                  "state untouched - API failure is NOT an empty sky")\n+            print("flightwatch stats: "\n+                  + " ".join(f"{k}={v}" for k, v in stats.items()))\n+            return\n     stats["aircraft_returned"] = len(raw_rows)\n \n     state = _load_state()\n@@ -553,14 +567,19 @@ def main() -> None:\n             continue\n         stats["rarity_candidates"] += 1\n \n-        # Secondary source ONLY now, for this one candidate.\n-        stats["provider_requests"] += 1\n-        try:\n-            secondary = adsb.adsb_lol_icao(ac["hex"])\n-            cross = cross_check(ac, secondary)\n-        except adsb.ProviderDown:\n-            stats["provider_failures"] += 1\n-            cross = "secondary_unavailable"\n+        # Cross-check only when ADSB.lol is independent of the wide-area\n+        # scan. If ADSB.lol already rescued the scan, do not pretend that a\n+        # second request to the same provider is independent confirmation.\n+        if scan_provider == "ADSB.lol":\n+            cross = "confirmed_by_primary_only"\n+        else:\n+            stats["provider_requests"] += 1\n+            try:\n+                secondary = adsb.adsb_lol_icao(ac["hex"])\n+                cross = cross_check(ac, secondary)\n+            except adsb.ProviderDown:\n+                stats["provider_failures"] += 1\n+                cross = "secondary_unavailable"\n         if cross == "confirmed_by_two_sources":\n             stats["secondary_confirmations"] += 1\n         elif cross == "conflicting_sources":\n@@ -581,6 +600,7 @@ def main() -> None:\n             "notBeforeUtc": iso(now + timedelta(\n                 minutes=PUBLICATION_DELAY_MIN)),\n             "bootstrap": bootstrap,\n+            "primaryProvider": scan_provider,\n             "crossCheck": cross,\n             "airport": {k: airport[k] for k in\n                         ("icao", "iata", "name_zh", "name_en")},\n@@ -166,4 +166,5 @@ def get(self, url, **kwargs):\n print(f"\n{CHECKS} checks passed, {FAILED} failed"\n       if not FAILED else f"\n{CHECKS - FAILED}/{CHECKS} passed, "\n       f"{FAILED} FAILED")\n-sys.exit(1 if FAILED else 0)\n+if __name__ == "__main__":\n+    sys.exit(1 if FAILED else 0)\n@@ -509,6 +509,39 @@ def test_flight_no_candidates_no_ai():\n     print("test_flight_no_candidates_no_ai: done")\n \n \n+def test_scan_provider_fallback():\n+    _reset_data()\n+    original_primary = adsb.airplanes_live_point\n+    original_fallback = adsb.adsb_lol_point\n+    original_enabled = os.environ.get("FLIGHT_TRACKING_ENABLED")\n+    calls = {"primary": 0, "fallback": 0}\n+\n+    def primary(*_args, **_kwargs):\n+        calls["primary"] += 1\n+        raise adsb.ProviderDown("HTTP 403")\n+\n+    def fallback(*_args, **_kwargs):\n+        calls["fallback"] += 1\n+        return []\n+\n+    adsb.airplanes_live_point = primary\n+    adsb.adsb_lol_point = fallback\n+    os.environ["FLIGHT_TRACKING_ENABLED"] = "true"\n+    try:\n+        flightwatch.main()\n+    finally:\n+        adsb.airplanes_live_point = original_primary\n+        adsb.adsb_lol_point = original_fallback\n+        if original_enabled is None:\n+            os.environ.pop("FLIGHT_TRACKING_ENABLED", None)\n+        else:\n+            os.environ["FLIGHT_TRACKING_ENABLED"] = original_enabled\n+\n+    check(calls == {"primary": 1, "fallback": 1},\n+          "Airplanes.live failure triggers exactly one ADSB.lol wide-area fallback")\n+    print("test_scan_provider_fallback: done")\n+\n+\n def test_flight_bad_quote_and_reject():\n     _reset_data()\n     flightnews.save_queue([json.loads(json.dumps(FLIGHT_EVENT))])\n@@ -550,6 +583,7 @@ def main():\n         test_source_assembly_privacy,\n         test_flight_events_auto_publish,\n         test_flight_fallback_history,\n+        test_scan_provider_fallback,\n         test_flight_gate_rejects_conflicts_and_publishes_reliable_risk,\n         test_flight_no_candidates_no_ai,\n         test_flight_bad_quote_and_reject,

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.