AVWIRE initial implementation: pipeline (fetch/dedupe/write/build), Modernist templates, hourly GitHub Actions workflow [skip ci]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
完整差異
@@ -0,0 +1,74 @@\n+name: hourly-update\n+\n+on:\n+ schedule:\n+ - cron: "0 * * * *" # every hour on the hour (UTC)\n+ workflow_dispatch:\n+ push:\n+ branches: [main]\n+ paths-ignore:\n+ - "data/**" # bot data commits must not retrigger the workflow\n+\n+permissions:\n+ contents: write\n+ pages: write\n+ id-token: write\n+\n+concurrency:\n+ group: hourly\n+ cancel-in-progress: false\n+\n+jobs:\n+ update:\n+ runs-on: ubuntu-latest\n+ steps:\n+ - uses: actions/checkout@v4\n+\n+ - name: Enable GitHub Pages (build_type=workflow)\n+ uses: actions/configure-pages@v5\n+ with:\n+ enablement: true\n+\n+ - uses: actions/setup-python@v5\n+ with:\n+ python-version: "3.12"\n+ cache: pip\n+\n+ - run: pip install -r requirements.txt\n+\n+ - name: Run pipeline\n+ env:\n+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n+ AEROAPI_KEY: ${{ secrets.AEROAPI_KEY }} # optional\n+ run: |\n+ python pipeline/fetch.py\n+ python pipeline/dedupe.py\n+ python pipeline/write.py\n+ python pipeline/build.py\n+\n+ - name: Commit data\n+ run: |\n+ git config user.name "avwire-bot"\n+ git config user.email "bot@users.noreply.github.com"\n+ git add data site\n+ if git diff --cached --quiet; then\n+ echo "no changes to commit"\n+ else\n+ git commit -m "hourly update $(date -u +%FT%H:%M)"\n+ git pull --rebase origin "${GITHUB_REF_NAME}" || true\n+ git push\n+ fi\n+\n+ - uses: actions/upload-pages-artifact@v3\n+ with:\n+ path: site\n+\n+ deploy:\n+ needs: update\n+ runs-on: ubuntu-latest\n+ environment:\n+ name: github-pages\n+ url: ${{ steps.deployment.outputs.page_url }}\n+ steps:\n+ - uses: actions/deploy-pages@v4\n+ id: deployment\n@@ -0,0 +1,7 @@\n+__pycache__/\n+*.pyc\n+.venv/\n+venv/\n+.DS_Store\n+Thumbs.db\n+design_ref/\n@@ -0,0 +1,62 @@\n+# AVWIRE 航空快訊\n+\n+全自動航空新聞聚合站。GitHub Actions 每小時整點抓取 FAA / NTSB / ICAO / IATA /\n+EASA / Eurocontrol / Reuters / The Aviation Herald / 交通部民航局等免費可信來源,\n+去重合併後由 LLM 自動撰寫雙語(繁中 + EN)新聞稿,產出靜態頁面並部署到\n+GitHub Pages。每篇文章文末列出所有原始來源連結。\n+\n+A fully automated aviation news aggregator: fetch trusted free sources hourly,\n+de-duplicate, draft bilingual stories with an LLM, publish as a static site on\n+GitHub Pages — with every original source credited at the end of each article.\n+\n+## 架構\n+\n+```\n+avwire/\n+├─ .github/workflows/hourly.yml # cron: 0 * * * * + workflow_dispatch\n+├─ pipeline/\n+│ ├─ common.py # 共用設定與來源註冊表\n+│ ├─ CONTRACTS.md # 各階段 JSON 資料契約\n+│ ├─ fetch.py # 抓取 RSS / HTML / API → data/raw/\n+│ ├─ dedupe.py # URL 正規化 + 標題相似度去重、跨來源合併 → data/pending.json\n+│ ├─ write.py # Anthropic API 撰稿(zh+en)→ data/articles/、flashes、incidents\n+│ └─ build.py # JSON → 靜態 HTML(site/)\n+├─ templates/ # Jinja2 模板(Modernist 設計系統)\n+├─ static/ # modernist.css / site.css / app.js\n+├─ data/ # 原始快照與文章 JSON(commit 進 repo 當作資料庫)\n+└─ site/ # 輸出,部署到 GitHub Pages\n+```\n+\n+## 上線設定\n+\n+1. Settings → Secrets and variables → Actions:新增 `ANTHROPIC_API_KEY`\n+ (撰稿用,必要)與 `AEROAPI_KEY`(FlightAware 統計,選用)。\n+2. Settings → Pages → Source 選 **GitHub Actions**。\n+3. Actions 頁手動觸發一次 `hourly-update` 驗證。\n+\n+沒有 `ANTHROPIC_API_KEY` 時管線仍會執行:抓取與建站照常,只是不產生新文章。\n+\n+## 本機開發\n+\n+```bash\n+pip install -r requirements.txt\n+python pipeline/fetch.py\n+python pipeline/dedupe.py\n+ANTHROPIC_API_KEY=... python pipeline/write.py # 可省略\n+AVWIRE_BASE_PATH= python pipeline/build.py # 本機預覽用空 base path\n+python -m http.server -d site 8000\n+```\n+\n+環境變數:\n+\n+| 變數 | 預設 | 說明 |\n+|---|---|---|\n+| `ANTHROPIC_API_KEY` | — | 撰稿用;未設定時跳過撰稿 |\n+| `AEROAPI_KEY` | — | FlightAware AeroAPI(選用統計) |\n+| `AVWIRE_BASE_PATH` | `/avwire` | 站台子路徑(Pages 專案站台) |\n+| `AVWIRE_MODEL` | `claude-opus-5` | 撰稿模型 |\n+\n+## 免責聲明\n+\n+本站內容為自動生成,可能存在錯誤或遺漏,不構成飛安、法遵或投資建議。\n+引用以事實改寫為主,原文著作權屬原單位;任何權威資訊請以官方原始來源為準。\n@@ -0,0 +1,20 @@\n+{\n+ "faa": {\n+ "url": "https://www.faa.gov/newsroom/press_releases",\n+ "etag": "\"1785018971-gzip\"",\n+ "last_modified": "Sat, 25 Jul 2026 22:36:11 GMT"\n+ },\n+ "icao": {\n+ "url": "https://www.icao.int/news",\n+ "last_modified": "Sun, 26 Jul 2026 11:01:49 GMT"\n+ },\n+ "easa": {\n+ "url": "https://www.easa.europa.eu/en/newsroom-and-events/news/feed.xml",\n+ "etag": "\"1785024957-gzip\"",\n+ "last_modified": "Sun, 26 Jul 2026 00:15:57 GMT"\n+ },\n+ "eurocontrol": {\n+ "url": "https://www.eurocontrol.int/newsroom",\n+ "last_modified": "Sun, 26 Jul 2026 11:57:20 GMT"\n+ }\n+}\n@@ -0,0 +1,57 @@\n+{\n+ "source": "avherald",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "Accident: Tracep L410 near Kitshanga/Walikale on Jul 24th 2026, engine failure, aircraft missing",\n+ "url": "https://news.google.com/rss/articles/CBMiWEFVX3lxTE9ITkFYU0VCX092RDZrSEI3Q0kwZ0VicTdKUGZ3MmhrVzFkM0NJajFKNUI1aDJOancwRXB4MXgyb2dvblEtR2RfbFhuc2Rpc2dzVkw5NFM5LWg?oc=5",\n+ "publishedUtc": "2026-07-24T21:07Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Incident: Cargolux B744 at Atlanta on Jul 23rd 2026, cargo fire indication",\n+ "url": "https://news.google.com/rss/articles/CBMiWEFVX3lxTE9EZkhGbkl4aVpraFRnRU9KZUxnOFBMVWZCdjducVZoQzgxSElWZHBNN1hUT3ZaTGJpSmxwTXdKN1V0d3ZqWkhmX0hhNkk0ODkwRXdQQVdvbnE?oc=5",\n+ "publishedUtc": "2026-07-23T15:05Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Accident: Enugu E170 at Benin City on Jul 23rd 2026, runway overrun on landing",\n+ "url": "https://news.google.com/rss/articles/CBMiWEFVX3lxTE91MmVfcUtDX2FkREdQZnZEa21nR0NXZTRvQjg5NzBuV25EOHlLWjZuUXhBbXVQWU5heTdacEFXeVdvNlhJYWE4X25iX291VzMwV0JPZDR6aVo?oc=5",\n+ "publishedUtc": "2026-07-23T21:57Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Incident: Transavia B738 at Amsterdam on Jul 24th 2026, autopilot failure twice",\n+ "url": "https://news.google.com/rss/articles/CBMiUEFVX3lxTE5jQTFwSkVnd0xUb3NQRGE1Y2JfQVVfWVYzRE1rMktEVnYtX0RHdXhHclNfa1JCS3BFYVUzNGR2ejFvVHh3aVl5c0ZDbjNtdk9h?oc=5",\n+ "publishedUtc": "2026-07-24T16:37Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Accident: American B772 near New York on Jul 23rd 2026, turbulence injures two cabin crew",\n+ "url": "https://news.google.com/rss/articles/CBMiWEFVX3lxTE9sUE9QWV83SnFqT3JtYm56TWJxTllpV2ZnUVdCN3VvNFd2LU45cWdkZ000bGNJckhhN0lHaXVvSUhERGdCZ0R6WGhoMm5FNTFoeEJsRmxHMHA?oc=5",\n+ "publishedUtc": "2026-07-23T19:38Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Incident: LOT B38M near Milan on Jul 23rd 2026, captain incapacitated",\n+ "url": "https://news.google.com/rss/articles/CBMiWEFVX3lxTE11bXVwMnE5OEo2cHEwVlRMN0ttR3F6U2NqS2xZVHh1X0JBaG9tV21RdFpqcFZ5NWxJYVJ5OGJTSENHTGwydHkwbUJ5TnNKQ2Ewdk9tRDNqcno?oc=5",\n+ "publishedUtc": "2026-07-24T03:37Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Incident: UPS B763 at Louisville on Jul 24th 2026, pitot heater failure",\n+ "url": "https://news.google.com/rss/articles/CBMiWEFVX3lxTE1zQi1ZMTJfRHg5NU40aGpuNUVhNFN5M2NBaDZCeEZNVmozZW9pS29WdXZGS05VRUN1NzZnWE9RNlMyZHBrS0d3Uk1keF9UOTlYNE5uVnJDQnQ?oc=5",\n+ "publishedUtc": "2026-07-24T07:00Z",\n+ "summary": "",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,78 @@\n+{\n+ "source": "caataiwan",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "115年中秋及教師節連假離島管制航線下週一(7/27)上午9時開放訂位",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2567&lang=1",\n+ "publishedUtc": "2026-07-23T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年7月23日 聯絡電話:02-2349-6041 聯絡人:邱美珍科長 今(115)年中秋及教師節有4天連假,交通部民用航空局今天宣布,澎湖、金門、馬祖三離島管制航線將提供1,340架次,計111,432個座位數,並自7月27日(下週一)上午9時起開放訂位。 今年中秋及教師節假期自115年9月25日(週五)至115年9月28日(週一)有4天連假,民航局規劃,9月24日(週四)至9月29日(週二)為航空疏運期,共計6天。民航局指出,中秋及教師節連假仍以澎湖、金門及馬祖等離島航線為疏運重點,各航空公司均已依據市場需求規劃航班。 在各航線運能提供方面,澎湖航線658架次、50,788個座位數;金門航線522架次、49,804個座位數;馬祖航線160架次、10,840個座位數;上述3離島航線共計提供1,340架次、111,432個座位數,民航局已要求航空公司持續注意訂位狀況,後續並將視需求適時增班。 民航局提醒旅客,此次中秋及教師節連假,於尖峰時段9月24日至9月26日臺灣出發往離島方向,以及9月27日至9月29日離島出發回臺灣方向之機票,均會加註「…",\n+ "image": null\n+ },\n+ {\n+ "title": "偵測更廣、更準,航空氣象新系統,進一步保障飛安",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2566&lang=1",\n+ "publishedUtc": "2026-07-14T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民115年7月14日 聯絡電話:0287702102 聯絡人:董吉利副總臺長 民航局飛航服務總臺首度於國內開發新一代航空氣象系統,不僅節省經費,未來亦可自主維修、升級。而因應桃園國際機場新建第三跑道,民航局也確定將建置新一代低空風切警報系統,不僅提供更先進氣象服務,也更能保障飛安。 航空氣象服務聚焦民航機場及航路區域的氣象測報、預報與警報發布,直接關係到機場是否因惡劣天氣而暫停起降以及航路是否需偏航,是攸關飛航安全的重要防線。過去,我國均自國外採購相關系統,除了造價高昂,維修、更新、升級,也需仰賴外國原廠。 飛航服務總臺總臺長黃麗君表示,此一情況不僅不便,也耗費時間,因此,在汰換舊系統時,總臺決定改採本土研發,建置新一代氣象服務系統。 黃麗君表示,美國擁有最豐富的飛航資料,因此,在演算法上具有一定優勢,此次建置新系統,仍採用美國國家大氣科學研究中心(NCAR)發展之亂流、積冰及雷雨等天氣預報演算法,然後由國內資訊廠商以此基礎開發系統,並與中央氣象署合作,運用超級電腦高速運算資源及高解析度預報模式資料,產出航空氣象產品。 相較於舊系統,黃麗君指出,新系…",\n+ "image": null\n+ },\n+ {\n+ "title": "颱風遠離,民航局協調航空公司、國防部啟動疏運",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2565&lang=1",\n+ "publishedUtc": "2026-07-13T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 發布日期:115年7月12日 聯絡人:王志青組長 聯絡電話:2349-6038 巴威颱風遠離,空運逐漸恢復,為加速疏運旅客,交通部民用航空局協調航空公司,金門、澎湖等離島航線均有加班機或放大機型,國際線區域航線亦放大機型疏運旅客。民航局表示,後續除繼續協調業者放大運能,也將請軍方支援軍機疏運。 此次巴威颱風來襲,國際線航班取消逾千架次,國內線也取消四百多架次。民航局指出,在十日下午航班大量取消前,民航局緊急協調航空公司加開班機,於金門航線加開7架次、澎湖航線加開4架次航班,減輕旅客因航班取消滯留壓力;颱風離境後,民航局也自昨天起即協調航空公司,準備後續疏運。 民航局表示,國際線部分,今日華航與長榮均有放大機型,飛航東北亞、東南亞與兩岸航線,疏運因航班取消延誤行程旅客;國內線部分,民航局已協調航空公司,於金門、澎湖航線加開班機或放大機型共14架次。 民航局表示,今天空運航班陸續恢復後,各地機場均湧入大批旅客,在航空公司有效調度運能下,國內線至中午已逐漸恢復正常,但午後因小三通復航,預期金門將湧入更多旅客,民航局已請航空公司預留運能,待命投入疏運,同時亦以聯繫國防…",\n+ "image": null\n+ },\n+ {\n+ "title": "油價下跌,國籍航空公司國際線燃油附加費7月7日起調降",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2563&lang=1",\n+ "publishedUtc": "2026-07-01T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年7月1日 聯絡電話:2349-6049 聯絡人:陳國樑代理科長 由於航空燃油價格近期下跌,國籍航空公司國際航線客運燃油附加費將自本月7日起調降,短程航線調降為27.5美元,長程航線調降為71.5美元,交通部民用航空局已要求業者充分揭露相關資訊,以維護消費者權益。 為適度維護消費者權益,民航局於2005年與國籍航空公司協調客運燃油附加費調整機制,以中油公司公告之國際航空燃油每桶牌價為收取基準,「未達40美元」為不收費之第1級距,「40美元—未滿50美元」為開始收費之第2級距,短程航線為5美元,長程航線為13美元;當油價每增加10美元時,短程航線增加2.5美元,長程航線增加6.5美元。 民航局表示,依據中油今日公告之國際航線航空燃油價格,由每桶160.1元調降為133.29美元(自第14級距降為第11級距),國籍航空公司之國際航線客運燃油附加費短程航線由35美元調降為27.5美元,長程航線由91美元調降為71.5美元,本月7日起生效。 另民航局已請航空公司應以適當方式揭露相關背景資訊(如油料成本占總成本比例或計算方式等),以化解消費者疑慮,旅…",\n+ "image": null\n+ },\n+ {\n+ "title": "提供更多元選擇,高雄機場明起試辦預約計程車載客",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2562&lang=1",\n+ "publishedUtc": "2026-06-24T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年6月24日 聯絡電話:02-8770-2530 聯絡人:黃武強組長 在交通部完成法制化作業後,高雄機場預計自本月25日起實施預約計程車試辦計畫,交通部民用航空局表示,試辦期間將滾動檢討各項環節,也希望各界踴躍提供意見,作為未來提升服務的參考。 近年旅客使用網路平臺預約叫車服務需求日益增加,為兼顧旅客便利性、運輸服務多元發展及維護機場營運秩序,交通部今年5月15日頒布修正「民用航空機場客運汽車管理辦法」,增訂預約計程車試辦之法源依據。 民航局指出,在法制化作業過程中,民航局高雄國際航空站即已積極準備試辦工作,經過長久努力,並不斷與各方溝通,為試辦營造基礎。 此次試辦共有8家業者參與,高雄站已將合格名單公布於官網,民航局亦已責成高雄站,於試辦期間規劃預約計程車專屬乘車處,並明確標示動線,與現有排班計程車、租賃車有效區隔,以引導旅客在入境後能順利前往乘車處搭車。 為維護旅客權益,民航局也提醒,旅客搭車前務必核對車輛、駕駛等資訊,並遵從現場人員引導,避免搭乘未經核准之車輛。如未事先預約車輛,現場也有排班計程車提供服務,特別是離峰時段,旅客也可選擇…",\n+ "image": null\n+ },\n+ {\n+ "title": "桃園航空城選配安置街廓申領配合加速開發獎勵金期限放寬至115年10月底",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2559&lang=1",\n+ "publishedUtc": "2026-06-04T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年6月4日 聯絡電話:02-8770-1373 聯絡人:楊肅圭代理科長 行政院於今(115 年 6 月 4 日)日第 4006 次院會指示,在不影響桃園國際機場第三跑道建設進度,並請桃園市政府務必先就建物使用執照核發程序予以妥善規劃原則下,同意調整機場園區選配安置街廓民眾之「配合加速開發獎勵金」申領期限,由115 年6 月 30 日展延至同年 10 月31 日。 由於桃園國際機場旅運量疫後快速復甦成長,為有效提升我國航運競爭力,第三跑道建設工程已積極展開,桃園航空城地上物最後搬遷期限,仍於115 年 12 月底完成搬遷,以避免影響國家重大建設工程推動。但考量安置街廓自建住宅時程相近且量體龐大,現有營建資源不敷市場需求,影響自建進度,參考年初調整「安置街廓協助金」之二樓版勘驗完成時間由115年2月28日展延至同年6月30日,爰將選配安置街廓者之「配合加速開發獎勵金」申領時間,併由115 年6 月 30 日展延至同年 10 月31 日,以積極獎勵民眾搬遷,順利取得桃園機場第三跑道開發建設用地。 目前選配安置街廓民眾正積極進行房屋興建工程,為後續…",\n+ "image": null\n+ },\n+ {\n+ "title": "油價下跌,國籍航空公司國際線燃油附加費6月7日起調降",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2558&lang=1",\n+ "publishedUtc": "2026-06-01T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年6月1日 聯絡電話:2349-6041 聯絡人:邱美珍科長 由於航空燃油價格近期下跌,國籍航空公司國際航線客運燃油附加費將自本月7日起調降,短程航線調降為35美元,長程航線調降為91美元,交通部民用航空局已要求業者充分揭露相關資訊,以維護消費者權益。 為適度維護消費者權益,民航局於2005年與國籍航空公司協調客運燃油附加費調整機制,以中油公司公告之國際航空燃油每桶牌價為收取基準,「未達40美元」為不收費之第1級距,「40美元—未滿50美元」為開始收費之第2級距,短程航線為5美元,長程航線為13美元;當油價每增加10美元時,短程航線增加2.5美元,長程航線增加6.5美元。 民航局表示,依據中油今日公告之國際航線航空燃油價格,由每桶208.78元調降為160.1美元(自第18級距降為第14級距),國籍航空公司之國際航線客運燃油附加費短程航線由45美元調降為35美元,長程航線由117美元調降為91美元,本月7日起生效。 另民航局已請航空公司應以適當方式揭露相關背景資訊(如油料成本占總成本比例或計算方式等),以化解消費者疑慮,旅客亦可洽各航空公司…",\n+ "image": null\n+ },\n+ {\n+ "title": "奪綠色機場白金獎、獲ACA等級四認證,高雄機場雙喜臨門!",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2557&lang=1",\n+ "publishedUtc": "2026-05-29T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 發布日期:115年5月29日 電話: 07-8057570 聯絡人:周素華組長 高雄國際機場繳出亮眼環保永續成績單,在國際舞台大放異彩!國際機場協會(ACI)最新公布的綠色機場評比,高雄奪下綠色機場評比的最高榮譽「白金獎」!更首度通過機場碳認證(ACA)等級四,ACI盛讚為「區域機場榜樣」。 ACI舉辦的綠色機場評比,範圍涵蓋亞太及中東地區44個國家、超過600座機場。高雄機場本次榮獲最高榮譽白金獎的關鍵,在於「氣候變遷調適」主題(500萬至1,500萬旅客運量分組)中的卓越表現。 民航局高雄國際航空站主任王郁珍表示,高雄機場執行「以科學為基礎的氣候調適,打造防災韌性機場」專案,面對颱風與豪雨等複合型氣候風險,依循TCFD架構與IPCC科學資料,投入約新台幣24.3億元優化排水與滯洪系統、強化航廈防水及空橋設備耐風能力。 透過這些努力,2024年強颱「山陀兒」直撲高雄時,機場成功維持航廈與跑道零淹水、關鍵設施零損毀及人員零傷亡,並迅速恢復營運,向國際驗證了調適策略的有效性。 在碳認證(ACA)等級四的部分,王郁珍強調,全球獲得等級四機場64座,亞太及中東地區2…",\n+ "image": null\n+ },\n+ {\n+ "title": "中央攜手地方,推進無人機物流常態化落地",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2555&lang=1",\n+ "publishedUtc": "2026-05-26T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年5月26日 聯絡電話:(02)23496084 聯絡人:耿驊(無人機辦公室執行秘書) 發展無人機物流已是國際趨勢,國內經過多年試辦,已累積24條物流廊道,交通部民用航空局今日邀集中央與地方政府代表,希望在風險控管基礎下推動物流實務作業,民航局長何淑萍希望各界能有更多的回饋,讓法規完善的過程裡,納入更多的參與。 民航局今天召開「無人機物流作業輔導會議」,民航局指出,台灣已經廣泛將無人機運用於空拍、監測、農噴、災害搜救等多元領域,而無人機物流運用,也成為政府機關及業界持續關注之議題。今天除了讓大家瞭解如何在風險管控基礎下推動無人機物流實務作業,也希望各級政府瞭解無人機物流作業相關規定後,可選擇適當地點試辦物流作業,累積更多實務經驗並擴大應用。 民航局指出,目前無人機物流還在起步階段,過去幾年,經民航局核准的物流案例共有10案,累計運用了24條無人機飛行廊道,地點遍及桃園與新竹山區、嘉義市區、屏東、花蓮、離島小琉球等,運送物品包含郵政、民生或醫療用品;後續衛福部與業者合作執行的無人機偏鄉醫療運送計畫,也在民航局、衛福部與業者協同合作下,預計將從…",\n+ "image": null\n+ },\n+ {\n+ "title": "就中共涉台措施中「推動全面恢復兩岸空中客運直航正常化」之說明",\n+ "url": "https://www.caa.gov.tw/NewsPublish-Content.aspx?a=381&nid=2551&lang=1",\n+ "publishedUtc": "2026-04-27T00:00Z",\n+ "summary": "交通部民用航空局新聞稿 日期:中華民國115年4月26日 聯絡電話:(02)2349-6015 聯絡人:陳昭諭組長 關於媒體詢問,中共十項涉台措施中「推動全面恢復兩岸空中客運直航正常化」,中方已透過民航小兩會致函我方,何時會啟動協商?陸委會說航點開放交由市場機制解決,和民航局立場一致嗎?對此,交通部民航局有六點說明: 一、陸委會4月16日以新聞稿發布的五點說明,是經政府相關機關會商的共同立場,迄今沒有任何改變。 二、世界各國對於空運的開放,都是秉持安全及對等原則,並考量市場因素,透過談判作出安排。過去兩岸航權、航點開放,是基於當時時空環境所作出的安排。在疫情之後,兩岸情勢及客觀環境已大幅改變,惟政府仍是依據上述原則,並充分考量業者及旅運需求,循序漸進作出最大程度的開放。 三、迄今為止,政府已開放15個定期航班航點(包括北京、上海浦東、上海虹橋、廈門、成都、深圳、廣州、南京、重慶、杭州、福州、青島、武漢、寧波、鄭州),每週可飛航班次420班。另開放13個包機航點(包括:瀋陽、無錫、海口、長沙、西安、濟南、合肥、南昌、天津、溫州、大連、桂林、徐州),航空業者可於獲配航權額度內,於春節、清…",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,183 @@\n+{\n+ "source": "easa",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "EASA Scientific Committee publishes its Annual Report",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-scientific-committee-publishes-its-annual-report",\n+ "publishedUtc": "2026-07-19T21:00Z",\n+ "summary": "The EASA Scientific Committee (SciCom) Annual Report 2025 is now available. This report covers the Committee’s fourth year of operation, and presents the activities undertaken, key achievements, and recommendations for future work.",\n+ "image": null\n+ },\n+ {\n+ "title": "New vacancy published: Domain Owner",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/new-vacancy-published-domain-owner",\n+ "publishedUtc": "2026-07-16T21:00Z",\n+ "summary": "The following new vacancy notice has been published on our Careers portal:",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA modernises flight simulation training devices’ rules to support innovation and future pilot training",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/press-releases/easa-modernises-flight-simulation-training-devices-rules-support-innovation-and-future-pilot",\n+ "publishedUtc": "2026-07-14T21:00Z",\n+ "summary": "COLOGNE, July 15 2026 - The European Union Aviation Safety Agency (EASA) has introduced a new regulatory framework for Flight Simulation Training Devices (FSTDs), marking the most significant evolution of flight simulation qualification in Europe in decades.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA updates Easy Access Rules for Third Country Operators",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-updates-easy-access-rules-third-country-operators",\n+ "publishedUtc": "2026-07-14T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has published Revision from July 2026 of the Easy Access Rules for Third Country Operators (EAR for TCO).",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA releases tool to assess the noise levels of light propeller-driven aeroplanes",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-releases-tool-assess-noise-levels-light-propeller-driven-aeroplanes",\n+ "publishedUtc": "2026-07-09T21:00Z",\n+ "summary": "ENCODE, “EASA's Noise Compliance Demonstration Evaluator”, is an MS Excel tool that can be used to calculate the noise level and confidence interval from data measured during a noise test performed in accordance with Chapter 10 of ICAO Annex 16, Volume I. The tool validates each individual test run against the limitations for environmental conditions and performance.",\n+ "image": null\n+ },\n+ {\n+ "title": "Protecting the backbone of aviation: EASA and EUROCONTROL host workshop on Europe’s GNSS interference Action Plan",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/protecting-backbone-aviation-easa-and-eurocontrol-host-workshop-europes-gnss-interference-action",\n+ "publishedUtc": "2026-07-08T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency and EUROCONTROL joined forces this week, hosting a workshop with more than 100 experts from across the aviation sector to discuss the response to the growing operational impact of Global Navigation Satellite Systems (GNSS) Radio Frequency Interference (RFI) across European and neighbouring airspace.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA issues AD 2026-0134 superseding Emergency Airworthiness Directive 2025-0268-E for Airbus 320 family",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-issues-ad-2026-0134-superseding-emergency-airworthiness-directive-2025-0268-e-airbus-320-family",\n+ "publishedUtc": "2026-07-08T21:00Z",\n+ "summary": "EASA has issued AD 2026-0134 for certain A320 family aeroplanes, which was previously published for public consultation as PAD 26-050, and which supersedes Emergency Airworthiness Directive (EAD) 2025-0268-E. The EAD was issued on November 28, 2025 to address a susceptibility introduced by a software update in one of its onboard computers. It addressed an issue which manifested itself in an event on JetBlue flight 1230 on October 30, 2025.",\n+ "image": null\n+ },\n+ {\n+ "title": "Revised conflict zone advisories for Middle East",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/revised-conflict-zone-advisories-middle-east",\n+ "publishedUtc": "2026-07-07T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA), working with the European Commission and the Member States, has adapted the EU conflict zones advisory for air operators on the Middle East in response to the latest developments in the region.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA Olympics for aviation AI-based systems — Now closed for entries",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-olympics-aviation-ai-based-systems-now-closed-entries",\n+ "publishedUtc": "2026-07-06T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has launched the AI Olympics — an initiative that goes hand in hand with the EASA AI Days conference taking place in Cologne on September 9 — 10, 2026. If you have a great aviation AI-based system, we are offering you the chance to show the industry what you are developing and to present it at the event.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA updates Safety Information Bulletin on GNSS interference",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-updates-safety-information-bulletin-gnss-interference",\n+ "publishedUtc": "2026-07-02T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has updated its Safety Information Bulletin (SIB) on Global Navigation Satellite System (GNSS) Outage and Alterations Leading to Navigation / Surveillance Degradation. SIB 2022-02 was first published on March 17, 2022 and the last update was Revision 3 on July 5, 2024. Revision 4 was published July 3, 2026.",\n+ "image": null\n+ },\n+ {\n+ "title": "New vacancies published: Administrative Assistant and Aerodromes Expert / Ground Handling Expert",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/new-vacancies-published-administrative-assistant-and-aerodromes-expert-ground-handling-expert",\n+ "publishedUtc": "2026-07-01T21:00Z",\n+ "summary": "The following new vacancy notices have been published on our Careers portal:",\n+ "image": null\n+ },\n+ {\n+ "title": "Aviation Non-CO₂ Expert Network furthers discussions on climate impacts from non-CO₂ emissions",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/aviation-non-co2-expert-network-furthers-discussions-climate-impacts-non-co2-emissions",\n+ "publishedUtc": "2026-06-29T21:00Z",\n+ "summary": "The 7th Plenary Meeting of the Aviation Non-CO₂ Expert Network (ANCEN) brought together sixty leading experts representing the scientific community, academia, aircraft manufacturers and operators, fuel producers, research organisations, air navigation service providers, non-governmental organisations, regulators and policymaking actors to advance the understanding of aviation’s climate impacts from non-CO₂ emissions and support evidence-based discussions and decision-making.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA published Revision from June 2026 of the Easy Access Rules for Unmanned Aircraft Systems",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-published-revision-june-2026-easy-access-rules-unmanned-aircraft-systems",\n+ "publishedUtc": "2026-06-29T21:00Z",\n+ "summary": "European Union Aviation Safety Agency has published the Revision from June of the Easy Access Rules for Unmanned Aircraft Systems available in PDF, machine-readable XML, and in a new improved online version with:",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA publishes new Easy Access Rules for European Technical Standard Orders",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-publishes-new-easy-access-rules-european-technical-standard-orders",\n+ "publishedUtc": "2026-06-24T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has published Amendment 16 and Amendment 15 of the Easy Access Rules (EAR) for European Technical Standard Orders (CS-ETSO) in pdf format. Today's publication also includes corrected versions of the published Amendments 11-14 of the EAR for ETSO, also in pdf.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA Certification Platform goes live",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-certification-platform-goes-live",\n+ "publishedUtc": "2026-06-23T21:00Z",\n+ "summary": "The new EASA Certification Platform goes live! EASA has worked hard on the digitalisation of certification projects, and during the last 10 months has developed and tested a new platform designed to improve efficiency, transparency, and predictability in the way we work with external stakeholders in different Certification areas.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA and EASA pledge cooperation for the new era of aviation",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/press-releases/faa-and-easa-pledge-cooperation-new-era-aviation",\n+ "publishedUtc": "2026-06-17T21:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "A Simpler Digital Experience for MOA and MTOA Organisations",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/simpler-digital-experience-moa-and-mtoa-organisations",\n+ "publishedUtc": "2026-06-17T21:00Z",\n+ "summary": "Maintenance Organisation Approval (MOA) and Maintenance Training Organisation Approval (MTOA) holders can now benefit from a more streamlined and digital way of interacting with EASA through the Organisation Approvals Tool (OA Tool) and the EASA Service Portal.",\n+ "image": null\n+ },\n+ {\n+ "title": "New career opportunity: EASA Study Placement Scheme open for applications",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/new-career-opportunity-easa-study-placement-scheme-open-applications-0",\n+ "publishedUtc": "2026-06-16T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has opened the Study Placement Scheme (SPS) for applications on the EASA Careers Portal. If you need to complete a compulsory placement or write your final thesis as part of your university studies or professional qualification, do not miss out on this opportunity! The call for this year is open until 31 December 2026.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA publishes Article 89 report on aviation safety and socio-economic factors",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/press-releases/easa-publishes-article-89-report-aviation-safety-and-socio",\n+ "publishedUtc": "2026-06-07T21:00Z",\n+ "summary": "COLOGNE, June 8, 2026 — The European Union Aviation Safety Agency (EASA) published an assessment of the interdependencies between aviation safety and socio-economic factors affecting safety-critical personnel in Europe, to ensure that socio-economic dimensions have been adequately integrated into aviation safety analysis.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA releases latest issue of its Concept Paper on Artificial Intelligence for comment",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-releases-latest-issue-its-concept-paper-artificial-intelligence",\n+ "publishedUtc": "2026-06-02T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) released Proposed Issue 03 of its Concept Paper on Artificial Intelligence (AI), now open for a consultation period of 10 weeks. This is the final Concept Paper deliverable foreseen under the EASA Artificial Intelligence Roadmap 2.0.",\n+ "image": null\n+ },\n+ {\n+ "title": "Extended Deadline for CEI 2026.03 — Direct Accreditation of Qualified Entities: Air Traffic Management/Air Navigation Services Systems and Constituents domain experts",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/extended-deadline-cei-202603-direct-accreditation-qualified-entities-air-traffic-managementair",\n+ "publishedUtc": "2026-05-31T21:00Z",\n+ "summary": "EASA has extended the deadline for applications to the call for expression of interest (CEI): CEI Number: 2026.03Categories of Tasks: Air Traffic Management/Air Navigation Services Systems and ConstituentsExtended Closing Date: 14 June 2026 (23:59 CET) EASA seeks to accredit up to eight Qualified Entities.",\n+ "image": null\n+ },\n+ {\n+ "title": "Europe for Aviation strives for more modern and efficient ATM in Europe",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/press-releases/europe-aviation-strives-more-modern-and-efficient-atm-europe",\n+ "publishedUtc": "2026-05-28T21:00Z",\n+ "summary": "LISBON, May 29, 2026 — Nine European institutions showcased their joint vision and progress toward a more modern and effective air traffic management system in Europe at Airspace World, the annual three-day ATM trade fair that took place in Lisbon this week. The nine organisations ran a joint stand under the concept Europe for Aviation (E4A), laying out their complementary activities and joint projects and explaining how, together, they are shaping a more modern and competitive European airspac…",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA publishes new set of enhanced online Easy Access Rules",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-publishes-new-set-enhanced-online-easy-access-rules",\n+ "publishedUtc": "2026-05-20T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has published another four Easy Access Rules enhanced online publications with faster access, speedier performance, permalinks, optimised old features, and improved navigation:",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA and EUROCONTROL release Electronic Conspicuity Use Cases for safer shared airspace",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-and-eurocontrol-release-electronic-conspicuity-use-cases-safer-shared",\n+ "publishedUtc": "2026-05-19T21:00Z",\n+ "summary": "EUROCONTROL and EASA, together with aviation stakeholders, have jointly developed two use case descriptions for the use of Electronic Conspicuity (EC) to enhance traffic awareness for pilots and facilitate integration of drones in shared airspace. The use cases will serve as input to the joint Electronic Conspicuity assessment and validation work performed in Europe and aim to support the safety of manned and unmanned aviation through affordable, interoperable and harmonised solutions.",\n+ "image": null\n+ },\n+ {\n+ "title": "EASA publishes the updated Easy Access Rules for Normal-Category Aeroplanes (CS-23)",\n+ "url": "https://www.easa.europa.eu/newsroom-and-events/news/easa-publishes-updated-easy-access-rules-normal-category-aeroplanes-cs-2",\n+ "publishedUtc": "2026-05-18T21:00Z",\n+ "summary": "The European Union Aviation Safety Agency (EASA) has published the updated Easy Access Rules for Normal-Category Aeroplanes (CS-23 Amendment 6 and AMC & GM to CS-23 Issue 5) — Revision from May 2026, available in PDF, machine-readable XML, and in a new improved online version with:",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,148 @@\n+{\n+ "source": "eurocontrol",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "EUROCONTROL publishes its new Trajectory 2030 Strategy",\n+ "url": "https://www.eurocontrol.int/press-release/eurocontrol-publishes-its-new-trajectory-2030-strategy",\n+ "publishedUtc": "2025-12-16T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2025-12/strategy-refresh-banner.jpg?h=77b5661b&itok=cBok1O8T"\n+ },\n+ {\n+ "title": "Protecting the backbone of aviation: EASA and EUROCONTROL host workshop on Europe’s GNSS interference Action Plan",\n+ "url": "https://www.eurocontrol.int/news/protecting-backbone-aviation-easa-and-eurocontrol-host-workshop-europes-gnss-interference",\n+ "publishedUtc": "2026-07-09T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2025-12/eurocontrol-easa-data4safety-use-case-gate-to-gate-co2-emissions-promo.jpg?h=d1cb525d&itok=gg9xTnks"\n+ },\n+ {\n+ "title": "Performance Review Commission issues a retrospective on the ANS industry’s response to the COVID-19 crisis",\n+ "url": "https://www.eurocontrol.int/news/performance-review-commission-issues-retrospective-ans-industrys-response-covid-19-crisis",\n+ "publishedUtc": "2026-06-17T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-06/ace-1920x1080.jpg?h=d1cb525d&itok=ISgbFSeA"\n+ },\n+ {\n+ "title": "EUROCONTROL’s FlyingGreen platform enters a new phase with major 2026 release",\n+ "url": "https://www.eurocontrol.int/press-release/eurocontrols-flyinggreen-platform-enters-new-phase-major-2026-release",\n+ "publishedUtc": "2026-06-16T12:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "EUROCONTROL launches #thinkNetwork 2026 guide to support operational stakeholders, expanding for the first time to include MET providers",\n+ "url": "https://www.eurocontrol.int/news/eurocontrol-launches-thinknetwork-2026-guide-support-operational-stakeholders-expanding-first",\n+ "publishedUtc": "2026-06-04T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-06/thinknetwork-2026-1920x1080-2x.jpg?h=8f74817f&itok=GXCj1nG4"\n+ },\n+ {\n+ "title": "DECEA and EUROCONTROL publish fifth Brazil-Europe ANS Performance Comparison Report",\n+ "url": "https://www.eurocontrol.int/news/decea-and-eurocontrol-publish-fifth-brazil-europe-ans-performance-comparison-report",\n+ "publishedUtc": "2026-05-29T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2021-08/jointreportcomparingoperationalperformance.jpg?h=d1cb525d&itok=DDVwhTkj"\n+ },\n+ {\n+ "title": "Managing space launches and re-entries from the Network Manager Space Desk",\n+ "url": "https://www.eurocontrol.int/news/managing-space-launches-and-re-entries-network-manager-space-desk",\n+ "publishedUtc": "2026-05-29T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-05/2026ectl-photo-12.jpg?h=9e5817aa&itok=BzQYvFHD"\n+ },\n+ {\n+ "title": "Europe for Aviation strives for more modern and efficient ATM in Europe",\n+ "url": "https://www.eurocontrol.int/news/europe-aviation-strives-more-modern-and-efficient-atm-europe",\n+ "publishedUtc": "2026-05-29T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-05/banner-e4a-partners-1920x1080-02.jpg?h=844e79c9&itok=_oj8fDdC"\n+ },\n+ {\n+ "title": "EUROCONTROL and EASA release Electronic Conspicuity Use Cases for safer shared airspace",\n+ "url": "https://www.eurocontrol.int/news/eurocontrol-and-easa-release-electronic-conspicuity-use-cases-safer-shared-airspace",\n+ "publishedUtc": "2026-05-20T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2025-12/eurocontrol-easa-data4safety-use-case-gate-to-gate-co2-emissions-promo.jpg?h=d1cb525d&itok=gg9xTnks"\n+ },\n+ {\n+ "title": "EUROCONTROL MUAC marks significant milestone in student air traffic controller training",\n+ "url": "https://www.eurocontrol.int/press-release/eurocontrol-muac-marks-significant-milestone-student-air-traffic-controller-training",\n+ "publishedUtc": "2026-05-19T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-05/eurocontrol-muac-image-ai88-at-gate.jpg?h=73fa463f&itok=cMKNkMmx"\n+ },\n+ {\n+ "title": "New call for operational challenges for our user-driven innovation projects",\n+ "url": "https://www.eurocontrol.int/news/accelerating-digital-innovation-european-network-innovation-labs",\n+ "publishedUtc": "2026-05-18T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2025-03/eatin-promote-event-1920x1080.jpg?h=f4f6829e&itok=F4r3zxh7"\n+ },\n+ {\n+ "title": "Supporting interoperability and system-to-system flight data exchange",\n+ "url": "https://www.eurocontrol.int/news/supporting-interoperability-and-system-system-flight-data-exchange",\n+ "publishedUtc": "2026-05-07T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-05/ec-guidelines-specs-promo-1920x1080.jpg?h=d1cb525d&itok=4jHB7vm9"\n+ },\n+ {\n+ "title": "EUROCONTROL and European Commission release enhanced NEATS platform to improve monitoring of the non-CO₂ climate impacts of aviation",\n+ "url": "https://www.eurocontrol.int/press-release/eurocontrol-and-european-commission-release-enhanced-neats-platform-improve",\n+ "publishedUtc": "2026-04-30T12:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "EUROCONTROL advances iNM transformation with new, resilient products for flow management, tower users and aircraft operators",\n+ "url": "https://www.eurocontrol.int/news/eurocontrol-advances-inm-transformation-new-resilient-products-flow-management-tower-users-and",\n+ "publishedUtc": "2026-04-22T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-04/inm-digitalplatform-nologo-1920x1080.jpg?h=d1cb525d&itok=TU6ltLPi"\n+ },\n+ {\n+ "title": "EUROCONTROL hosts 7th AZEA Assembly and unveiling of the Roadmap for Zero-Emission Aviation",\n+ "url": "https://www.eurocontrol.int/news/eurocontrol-hosts-7th-azea-assembly-and-unveiling-roadmap-zero-emission-aviation",\n+ "publishedUtc": "2026-04-20T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-04/azea-7th-general-assembly-group-photo-promo.jpg?h=d1cb525d&itok=f6xV2q5T"\n+ },\n+ {\n+ "title": "EASA and EUROCONTROL publish joint Action Plan to ensure safe operations during GNSS interference events",\n+ "url": "https://www.eurocontrol.int/press-release/easa-and-eurocontrol-publish-joint-action-plan-ensure-safe-operations-during-gnss",\n+ "publishedUtc": "2026-03-26T12:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "EUROCONTROL and partners complete key exercise for future trajectory-based operations (TBO)",\n+ "url": "https://www.eurocontrol.int/news/eurocontrol-and-partners-complete-key-exercise-future-trajectory-based-operations-tbo",\n+ "publishedUtc": "2026-03-25T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-03/sesar-network-trajectory-group1-1920x1080.jpg?h=d1cb525d&itok=-q1x5n83"\n+ },\n+ {\n+ "title": "Europe for Aviation partners join forces at Airspace World 2026",\n+ "url": "https://www.eurocontrol.int/news/europe-aviation-partners-join-forces-airspace-world-2026",\n+ "publishedUtc": "2026-03-02T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-02/airspace-world-2026-promo.jpg?h=d1cb525d&itok=1_Q3IJwW"\n+ },\n+ {\n+ "title": "EASA certifies EUROCONTROL’s Information Security Management Course",\n+ "url": "https://www.eurocontrol.int/news/easa-certifies-eurocontrols-information-security-management-course",\n+ "publishedUtc": "2026-02-20T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-02/lex-isms_860x660.jpg?h=f3e1135c&itok=-7Fvnlfo"\n+ },\n+ {\n+ "title": "EUROCONTROL set to play a key role in the next phase of SESAR projects",\n+ "url": "https://www.eurocontrol.int/press-release/eurocontrol-set-play-key-role-next-phase-sesar-projects",\n+ "publishedUtc": "2026-02-04T12:00Z",\n+ "summary": "",\n+ "image": "https://www.eurocontrol.int/sites/default/files/styles/card_xs/public/2026-02/banner-sesar-projects-press-release.jpg?h=d1cb525d&itok=8QGmI1d_"\n+ }\n+ ]\n+}\n@@ -0,0 +1,78 @@\n+{\n+ "source": "faa",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "Trump’s Transportation Secretary Sean P. Duffy Delivers Nearly $24 million to Improve Family Travel at Dallas Love Field Airport",\n+ "url": "https://www.faa.gov/newsroom/trumps-transportation-secretary-sean-p-duffy-delivers-nearly-24-million-improve-family",\n+ "publishedUtc": "2026-07-23T00:00Z",\n+ "summary": "U.S. Transportation Secretary Sean P. Duffy announced the Federal Aviation Administration (FAA) has invested $23.7 million into Dallas Love Field Airport to enhance the travel experience for families.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Advances Digital Departure Clearances for General and Business Aviation",\n+ "url": "https://www.faa.gov/newsroom/faa-advances-digital-departure-clearances-general-and-business-aviation",\n+ "publishedUtc": "2026-07-23T00:00Z",\n+ "summary": "The FAA is moving forward with Mobile Clearance, a digital tool that allows general and business aviation pilots to request departure clearances through approved flight applications. This tool is part of the FAA's efforts to modernize communications between pilots and controllers.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Proposes $263,750 Fine Against Southern Airways Express for Alleged Operations of an Unairworthy Aircraft",\n+ "url": "https://www.faa.gov/newsroom/faa-proposes-263750-fine-against-southern-airways-express-alleged-operations-unairworthy",\n+ "publishedUtc": "2026-07-22T00:00Z",\n+ "summary": "WASHINGTON — The Federal Aviation Administration (FAA) proposes a $263,750 civil penalty against Southern Airways Express of Addison, Texas, for allegedly operating an aircraft with inoperative required equipment. The FAA alleges that in April 2025 the company operated 29 commercial flights using a Cessna Caravan with a malfunctioning autopilot in violation of its Operations Specifications. Southern Airways Express has 30 days after receiving the FAA’s notice to respond to the agency.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Proposes $50,000 Fine Against Southern Airways Express for Aircraft Weight and Balance Violations",\n+ "url": "https://www.faa.gov/newsroom/faa-proposes-50000-fine-against-southern-airways-express-aircraft-weight-and-balance",\n+ "publishedUtc": "2026-07-22T00:00Z",\n+ "summary": "The Federal Aviation Administration (FAA) proposes a $50,000 civil penalty against Southern Airways Express, LCC of Addison, Texas, for allegedly violating weight-and-balance regulations which caused an incident when an airplane pushed back from the gate.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Statement on 5G",\n+ "url": "https://www.faa.gov/newsroom/faa-statement-5g",\n+ "publishedUtc": "2026-07-22T00:00Z",\n+ "summary": "The FAA will issue a final rule on July 24, 2026, to ensure aircraft radio altimeters can withstand interference from 5G wireless signals in neighboring spectrum bands. It will require altimeters to meet next-generation performance requirements when aircraft equipped with them operate in the U.S.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Proposes $306,000 Civil Penalty Against Simulator Center LLC for Alleged Training Violations",\n+ "url": "https://www.faa.gov/newsroom/faa-proposes-306000-civil-penalty-against-simulator-center-llc-alleged-training-violations",\n+ "publishedUtc": "2026-07-22T00:00Z",\n+ "summary": "The Federal Aviation Administration (FAA) proposes a $306,000 civil penalty against Simulator Center LLC of Sanford, Florida, alleging the pilot training center allowed unqualified instructors to provide flight training and failed to ensure several students completed required training.",\n+ "image": null\n+ },\n+ {\n+ "title": "Successful Counter-Drone Efforts Helped Keep Millions of Fans Safe During FIFA World Cup 2026, With More Than 700 Drones Seized",\n+ "url": "https://www.faa.gov/newsroom/successful-counter-drone-efforts-helped-keep-millions-fans-safe-during-fifa-world-cup-2026",\n+ "publishedUtc": "2026-07-22T00:00Z",\n+ "summary": "Department of Transportation Secretary Sean P. Duffy announced that federal, state and local partners carried out the most comprehensive airspace security and drone mitigation effort in U.S. history during FIFA World Cup 2026.",\n+ "image": null\n+ },\n+ {\n+ "title": "After Months of Safety Review, FAA Allows Boeing to Resume Issuing Certificates for New Airplanes",\n+ "url": "https://www.faa.gov/newsroom/after-months-safety-review-faa-allows-boeing-resume-issuing-certificates-new-airplanes",\n+ "publishedUtc": "2026-07-17T00:00Z",\n+ "summary": "The FAA will allow Boeing to resume issuing airworthiness certificates at the end of the production process for all 737 MAX and 787 airplanes. The decision follows months of review demonstrating consistent production quality.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Proposes $255,000 Fine Against United Airlines for Alleged Drug and Alcohol Violation",\n+ "url": "https://www.faa.gov/newsroom/faa-proposes-255000-fine-against-united-airlines-alleged-drug-and-alcohol-violation",\n+ "publishedUtc": "2026-07-16T00:00Z",\n+ "summary": "WASHINGTON — The Federal Aviation Administration (FAA) proposes a $255,000 civil penalty against United Airlines for allegedly violating the drug and alcohol regulations.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA Announces Major Milestone in eVTOL Technology Use",\n+ "url": "https://www.faa.gov/newsroom/faa-announces-major-milestone-evtol-technology-use",\n+ "publishedUtc": "2026-07-14T00:00Z",\n+ "summary": "The FAA today announced that BETA Technologies and United Therapeutics Corporation, in partnership with the Pennsylvania DOT, flight-tested organ medical-transport capabilities from Virginia to Maryland. This is a major milestone in the eVTOL Integration Pilot Program (eIPP).",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,7 @@\n+{\n+ "source": "flightaware",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": false,\n+ "error": "AEROAPI_KEY not set",\n+ "items": []\n+}\n@@ -0,0 +1,183 @@\n+{\n+ "source": "iata",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "IATA Board Appoints Saadia Zahidi as Director General",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/07-23-iata-board-appoints-saadia-zahidi-as-director-general/",\n+ "publishedUtc": "2026-07-23T04:00Z",\n+ "summary": "IATA announced that Saadia Zahidi has been appointed by IATA’s Board of Directors to the position of Director General from 1 November 2026.",\n+ "image": null\n+ },\n+ {\n+ "title": "Statement on EU Commission Revision of the EU Emissions Trading System",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/07-17-statement-eu-commission-revision-emissions-trading-system/",\n+ "publishedUtc": "2026-07-17T04:00Z",\n+ "summary": "IATA is deeply frustrated at the proposed extension of the European Emissions Trading System beyond Europe’s borders, an approach that was discredited over a decade ago.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Releases 2025 World Air Transport Statistics",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/07-16-iata-releases-2025-world-air-transport-statistics-report/",\n+ "publishedUtc": "2026-07-16T02:00Z",\n+ "summary": "IATA has released the latest edition of the World Air Transport Statistics (WATS), providing comprehensive statistical data through to 2025.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Guidance Supports Clearer and More Consistent In-Cabin Pet Travel",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/07-15-iata-guidance-supports-clearer-more-consistent-in-cabin-pet-travel/",\n+ "publishedUtc": "2026-07-15T04:00Z",\n+ "summary": "IATA has released new operational guidance to support airlines in managing in-cabin pet travel at every stage of the journey from booking to check-in, boarding, the on-board experience, and arrival.",\n+ "image": null\n+ },\n+ {\n+ "title": "Air Passenger Demand Falls 2.2% in May",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-30-air-passenger-demand-falls-may/",\n+ "publishedUtc": "2026-06-30T06:00Z",\n+ "summary": "IATA released data for May 2026 global passenger demand, showing overall contraction as an impact of war in the Middle East.",\n+ "image": null\n+ },\n+ {\n+ "title": "Air Cargo Demand Up 6.0% in May",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-29-air-cargo-demand-up-may/",\n+ "publishedUtc": "2026-06-29T06:00Z",\n+ "summary": "IATA released data for May 2026 global air cargo markets showing growth in demand in all regions except for the Middle East.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA and IATP Collaborate to Support Airline Supply Chain Resilience",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-25-iata-iatp-collaborate-support-airline-supply-chain-resilience/",\n+ "publishedUtc": "2026-06-25T04:00Z",\n+ "summary": "IATA and the International Airlines Technical Pool (IATP) have agreed to cooperate in helping airlines to improve their visibility of, and access to, aircraft parts.",\n+ "image": null\n+ },\n+ {\n+ "title": "Urgent Action Needed to Ease Engine MRO Bottlenecks",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-24-urgent-action-needed-to-ease-engine-mro-bottlenecks/",\n+ "publishedUtc": "2026-06-24T06:00Z",\n+ "summary": "IATA, in collaboration with Emerton, today released a new study examining bottlenecks in the maintenance, repair and overhaul (MRO) of latest-generation single-aisle aircraft engines.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Outlines Four Priorities to Strengthen the Aviation Supply Chain",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-24-iata-outlines-four-priorities-to-strengthen-aviation-supply-chain/",\n+ "publishedUtc": "2026-06-24T03:00Z",\n+ "summary": "IATA identified four priorities to address persistent failures in the aerospace supply chain at the inaugural IATA World Maintenance and Engineering Symposium in Madrid.",\n+ "image": null\n+ },\n+ {\n+ "title": "States and Supporting Partners Join IATA’s Alliance to Expand CORSIA Carbon Credit Supply",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-23-states-supporting-partners-join-iata-alliance-expand-corsia-carbon-credit-supply/",\n+ "publishedUtc": "2026-06-23T03:00Z",\n+ "summary": "IATA announced that the number of entities participating in the Supporting Alliance for CORSIA Eligible Emissions Unit (EEU) Supply has reached 50.",\n+ "image": null\n+ },\n+ {\n+ "title": "EU261 Reform Misses the Mark on Delays and Competitiveness",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-15-eu261-reform-misses-mark-delays-competitiveness/",\n+ "publishedUtc": "2026-06-15T15:00Z",\n+ "summary": "IATA expressed frustration that revisions to EU261 stopped short of the meaningful reform needed to address the regulation’s deep flaws",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Turbulence Aware Coverage of Latin America Boosted",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-09-iata-turbulence-aware-coverage-latin-america-boosted/",\n+ "publishedUtc": "2026-06-09T07:00Z",\n+ "summary": "IATA’s Turbulence Aware coverage of Latin America has been significantly boosted as Aeromexico becomes the latest airline to join the program.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Launches “Save a Life, Not a Bag” Passenger Safety Campaign",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-08-iata-launches-save-a-life-not-a-bag-passenger-safety-campaign/",\n+ "publishedUtc": "2026-06-08T13:00Z",\n+ "summary": "IATA today announced the launch of “Save a Life, Not a Bag”, a passenger safety campaign urging travelers not to take cabin baggage during an aircraft evacuation.",\n+ "image": null\n+ },\n+ {\n+ "title": "Brazil’s Opportunity to Be a SAF Powerhouse",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-08-brazil-opportunity-saf-powerhouse/",\n+ "publishedUtc": "2026-06-08T08:00Z",\n+ "summary": "IATA highlighted Brazil’s opportunity to become a SAF powerhouse as its 82nd Annual General Meeting gathered in Rio de Janeiro, Brazil.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA PaxInsight Survey Offering Strengthens in Trans-Atlantic Market with Inclusion of Virgin Atlantic",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-08-iata-paxinsight-survey-offering-strengthens-in-trans-atlantic-market-with-inclusion-of-virgin-atlantic/",\n+ "publishedUtc": "2026-06-08T06:00Z",\n+ "summary": "IATA has announced a strengthening of its IATA PaxInsight passenger satisfaction benchmarking program with the addition of Virgin Atlantic’s trans-Atlantic routes to the survey.",\n+ "image": null\n+ },\n+ {\n+ "title": "Winners of the 8th Edition of IATA Diversity & Inclusion Awards Announced",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-07-winners-8th-edition-iata-diversity-inclusion-awards-announced/",\n+ "publishedUtc": "2026-06-07T17:00Z",\n+ "summary": "IATA announced the winners of the IATA Diversity & Inclusion Awards 2026.",\n+ "image": null\n+ },\n+ {\n+ "title": "Roberto Alvo Chairs the IATA Board of Directors",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-07-roberto-alvo-chairs-iata-board-of-directors/",\n+ "publishedUtc": "2026-06-07T09:00Z",\n+ "summary": "IATA announced that Roberto Alvo, CEO of LATAM Airlines Group, has taken up duties as Chair of the IATA Board of Directors.",\n+ "image": null\n+ },\n+ {\n+ "title": "Xiamen Airlines to Host 83rd IATA AGM",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-07-xiamen-airlines-host-83rd-iata-agm/",\n+ "publishedUtc": "2026-06-07T07:00Z",\n+ "summary": "IATA announced that Xiamen Airlines will host the 83rd IATA Annual General Meeting (AGM) and World Air Transport Summit (WATS) in Xiamen, China, from 30 May to 1 June 2027.",\n+ "image": null\n+ },\n+ {\n+ "title": "Middle East Disruptions and High Fuel Prices Halve Airline Industry Profitability",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-07-middle-east-disruptions-high-fuel-prices-halve-airline-industry-profitability/",\n+ "publishedUtc": "2026-06-07T06:00Z",\n+ "summary": "IATA released its latest financial outlook for the global airline industry showing a halving of profitability as a result of war-related Middle East disruptions and high fuel prices.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Launches Supporting Alliance for CORSIA EEU Supply",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-06-iata-launches-supporting-alliance-corsia-eeu-supply/",\n+ "publishedUtc": "2026-06-06T09:00Z",\n+ "summary": "IATA launched the Supporting Alliance for CORSIA EEU Supply, regrouping stakeholders across the CORSIA ecosystem in efforts to boost the availability of 225-250 million CORSIA Eligible Emission Units (EEUs) by spring 2027.",\n+ "image": null\n+ },\n+ {\n+ "title": "SAF Production Volumes Still Disappointing",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-06-saf-production-volumes-still-disappointing/",\n+ "publishedUtc": "2026-06-06T08:00Z",\n+ "summary": "IATA released estimates showing that global Sustainable Aviation Fuel (SAF) production is expected to reach around 2.4 million tonnes in 2026, representing just 0.8% of aviation fuel use, at a cost to airlines of $4.3 billion.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Expands Cargo Services in Brazil, Mexico, and Paraguay",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-05-iata-expands-cargo-services-brazil-mexico-paraguay/",\n+ "publishedUtc": "2026-06-05T11:00Z",\n+ "summary": "IATA is expanding the presence of its cargo offerings in Latin America, including the Cargo Accounts Settlement Systems (CASS).",\n+ "image": null\n+ },\n+ {\n+ "title": "Aviation Leaders Gather in Rio de Janeiro for IATA’s 82nd AGM",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-05-aviation-leaders-gather-rio-de-janeiro-iata-82nd-agm/",\n+ "publishedUtc": "2026-06-05T07:00Z",\n+ "summary": "IATA announced that leaders of the global aviation industry are gathering in Rio de Janeiro, Brazil for the 82nd IATA Annual General Meeting (AGM) and World Air Transport Summit (WATS) on 6-8 June 2026.",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA-ICAO Deepen Cooperation on Boosting Sustainable Aviation Fuels",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-02-iata-icao-deepen-cooperation-boosting-sustainable-aviation-fuels/",\n+ "publishedUtc": "2026-06-02T07:00Z",\n+ "summary": "IATA and ICAO announced enhanced cooperation at ICAO Aviation Climate Week today to advance transparency and integrity in tracking progress and accelerating the development and deployment of Sustainable Aviation Fuels (SAF).",\n+ "image": null\n+ },\n+ {\n+ "title": "IATA Launches World Maintenance & Engineering Symposium",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/06-01-iata-launches-world-maintenance-engineering-symposium/",\n+ "publishedUtc": "2026-06-01T06:00Z",\n+ "summary": "IATA announced the launch of the World Maintenance & Engineering Symposium (WMES), in Madrid, Spain, on 24–25 June 2026. The e event will address supply chain failures and other challenges in maintenance and engineering.",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,78 @@\n+{\n+ "source": "icao",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "International Civil Aviation Organization analysis reveals major priorities for air transport…",\n+ "url": "https://www.icao.int/news/international-civil-aviation-organization-analysis-reveals-major-priorities-air-transport",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "ICAO statement on proposed expanded scope of EU Emission Trading System",\n+ "url": "https://www.icao.int/news/icao-statement-proposed-expanded-scope-eu-emission-trading-system",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "States unite as drones increasingly threaten civil aviation",\n+ "url": "https://www.icao.int/news/states-unite-drones-increasingly-threaten-civil-aviation",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Global harmonization through CORSIA remains essential to advance international aviation emissions…",\n+ "url": "https://www.icao.int/news/global-harmonization-through-corsia-remains-essential-advance-international-aviation-emissions",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "International Civil Aviation Organization convenes Extraordinary Assembly",\n+ "url": "https://www.icao.int/news/international-civil-aviation-organization-convenes-extraordinary-assembly",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Aviation leaders strengthen support for ICAO’s global air transport plan",\n+ "url": "https://www.icao.int/news/aviation-leaders-strengthen-support-icaos-global-air-transport-plan",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "New report offers key insights into long-term transformation of global air transport",\n+ "url": "https://www.icao.int/news/new-report-offers-key-insights-long-term-transformation-global-air-transport",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "International community enhances governance of global aviation",\n+ "url": "https://www.icao.int/news/international-community-enhances-governance-global-aviation",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "States seek accelerated decarbonization of air transport",\n+ "url": "https://www.icao.int/news/states-seek-accelerated-decarbonization-air-transport",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "ICAO Aviation Climate Week wraps up with focus on carbon neutral growth",\n+ "url": "https://www.icao.int/news/icao-aviation-climate-week-wraps-focus-carbon-neutral-growth",\n+ "publishedUtc": "2026-07-26T12:09Z",\n+ "summary": "",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,183 @@\n+{\n+ "source": "ntsb",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "Letter to BAAI and Ryanair",\n+ "url": "https://news.google.com/rss/articles/CBMikAFBVV95cUxNX25Wb1JyNXdTQml1WXU1SUFGbXdoV3ZkQU5BZ1M4OGowZjRJN0FEWHl2S1Fzc19CRHI1XzFjWTQ2c3U5WkRSTXJoYm9VdkVIcXRLMk5SdEgxVk5Dbm5NRnhyY3B6ZXg0bmdBZGFHQW1ZcVcwaHh1aEpnRmtZemdlZzJvUWY4eTlFNmphT0JQYjI?oc=5",\n+ "publishedUtc": "2026-07-20T07:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "NTSB to Present and Exhibit at Experimental Aircraft Association (EAA) AirVenture Oshkosh",\n+ "url": "https://news.google.com/rss/articles/CBMiZkFVX3lxTFB4Yjd4YWFfTXYzcFZpQ2diSmRhQUpURmNmdTdhWG5kd0V5NUN3c3dVcHdMdkRwelpTNDZVOE1YNmFGdWJycktGUjg2Mjc4Y0N5OGo1TWZ6UFhqbzZqTjhseno5UHhfZw?oc=5",\n+ "publishedUtc": "2026-07-20T07:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "CAROL Query - NTSB",\n+ "url": "https://news.google.com/rss/articles/CBMieEFVX3lxTFBhY040T195VGxaWVNUZnNnTVJGS0ktcTVIcjdaUE5Cb2ZiSXZpR0s2V2xabzdTaWN4QzRwUWp6aG9iY1Q5dVdiSFB3Sjc1bkZJUy1yamJoaEYybkp1SmNlTjlRdjB0V25VM212UXQzX1N6alZRQlJsMA?oc=5",\n+ "publishedUtc": "2026-07-24T03:51Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "NTSB Events",\n+ "url": "https://news.google.com/rss/articles/CBMiYEFVX3lxTE05NklXVXF0QkFRZzNsTWpZMWZrT2VWTWk5Q2ZaZnhSeXI3WDZUMjh4QjRJTWxhZlYxZjcyRHhrdGd4b2lneEh4Q1FzX3ZxNjRLTnBmc3VoZm40Q1hHQ01IRQ?oc=5",\n+ "publishedUtc": "2021-09-27T16:51Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "CAROL Query - NTSB",\n+ "url": "https://news.google.com/rss/articles/CBMie0FVX3lxTE5paWU0dUFOVF9hRmE2c01RanZNSTNpaU00WERlMXRyRzNWRy1ObmJVSS1ESWtTanVxY1lOcXVQOU41N1oxcVhQZUZUcEJHQmNzYVM2MW1EQUpaVDFtZGg0WjJqSlBkWXFWRmpFakJBUzkycTJSdEZpNW41UQ?oc=5",\n+ "publishedUtc": "2025-08-01T04:29Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Home",\n+ "url": "https://news.google.com/rss/articles/CBMiTEFVX3lxTE9Dd3g5MzR5WnlxelpFdXRIb0tyU19pUVJCVWkzVllpWlhWeFQ2Y3l5cUNnR0RTdWZBSkNZTUkxN0JxQzQxazFKY3dVUTE?oc=5",\n+ "publishedUtc": "2021-09-27T16:38Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "R-23-016 - NTSB",\n+ "url": "https://news.google.com/rss/articles/CBMia0FVX3lxTE00N09aS3JzcFBLdE4xSC1vRW9zeWRLRlJrWU5uNWVqaWVQdmJSNzVzbjNPVXUzMzk5UVdLZkRLcXNwOXNLOE1DbGp3UTBCcTdjR0cyRUZvcVRfUmRfTGlYV3ZuaXQ3NXVMQ0JN?oc=5",\n+ "publishedUtc": "2025-05-23T05:30Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Annual Report to Congress 2025",\n+ "url": "https://news.google.com/rss/articles/CBMihgFBVV95cUxPUElwMkpKTTlfajNoTk9SM3FNVlh5MGtOUmw2SmgyeWF3Z3RvTjVlUGJMd2hwTmFhNF9JOVVzOFZDNXVUOEc1WnVVYWs1RVFrUWhKeXhaQUNzYXFjbHZWNFlSeEZpTG9NNS1uaWktS09ObHI1N1BWUzdUYl9lS1dHNW03dkZNQQ?oc=5",\n+ "publishedUtc": "2026-07-22T07:00Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMiuwFBVV95cUxQdWxHUkoxcUNPUjJGX3lJb3hEZ0NadnhOeVgxOTJrOFRuWFEyZ3dTRXhFSzloamVick9yaHFDZ1dWZE1GSDdpRDRFbmR5YkFhdzZFY2gxRVpIM1RNa1M1Zzk5VXl2cnVQWnJGODlqalVEdDEwYXI2bjdDSVJJVDh1b0RnMUZiOWdHU1hYc1lpVUE3ZGhlUGhhS2FFOTVIZEpuVUhFWEVzRDQ3QUtJZ0VJNXAyTWNLQUNsaWxz?oc=5",\n+ "publishedUtc": "2026-07-22T10:01Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMijwFBVV95cUxPNmhSYjBHMndBRmxsQ3ZDc042Rk1NeVlueVVXRld1UmpWcDZmd2dsdDRIWHNlYUFycGlzMXd3enpDWTl0akxFTTVyeGMzalUxczg5TTJ4MFF3V2xSeWl3UzFhc1V2ZDlySjlrYkJENlF5QlFfclQ2RmhaYU5ydGl5Mk9EMlphWGtDSGNZbEU3SQ?oc=5",\n+ "publishedUtc": "2026-07-26T00:02Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMivAFBVV95cUxQY2FYRWdNVVRpeU1RU1ZtUDJHVzlfT1d6ZEZMR3VxV2RxRE5DVGhxdUtROWxRSTFwZWp1S3RoaGRFVHBraXRlWF9QeVg0ZGE4OHNXdEE0QlM0eHUzY0VjaW9JTkhpR1lVMWoyaUIxLXpYSm81MDdIWXQyXzJyLVh0T3RRNHcwc1dDNHVPUl80SFFfYXlQcUllTVotWlVwYTFYdXY2RlZLeDNSRHFKQU1zMDNqOW10U0NYQlV2eg?oc=5",\n+ "publishedUtc": "2026-07-24T19:03Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMihwJBVV95cUxPV241WWVIRFZBZDl4WDF5VnlGUVdiZVZyN1JnTlZLV0VxSVdCTWp5WHdWS1RaWjI1R1ZQWDgwdzFFZEgxaVFBWlF6RHQtdEFZS09KZW1lM3B2czNZd1NEZXczYnF1NHNrVGtTZ1Fub2YtcGhlWHVVdzNOVGVHcF8tRURaTHN5Q1lfX0l0TnZKNXlONXBnekRXTjYtTWotSHRUTWZ3YWl6VnZtSzhLRE1BT1FQcWp0Snh4MVNhMFdOUzdhbm1qNGtTeFR2ZjdsM3N5emlxMDY5ZEdCWlVKcGpldTlRd2pPUjJlRW1HS1MwWEQ0bFRRWnF5MmtRMXhRSG5DYlMxUGtSWQ?oc=5",\n+ "publishedUtc": "2026-07-26T08:37Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMiowJBVV95cUxOUk1SanFSRFJyaUxaTDNuOXRTOVlCc3RoanliU2p5MUxjSm9nbVB0dEpIdU9rdUV3dzN1WWsyeDY5cU1wdjZNTmQ0NnRRdW9YWkFBNFpYRHBGODQyWnFLUmk1VVVyQ3NCN1RHUll3WTYtNGtQTnY0ejhvM3Q3aEp1bGtZR3RKZ0hnMXhqejdwZ2FrY2Y2ZjZYV1pCbnNYMlI3SklkVnlHbXp5N25kdGxCTWQ2U05QS2ViNVgyWFBoMFZNQkg1clZvNUlXMFpLYy1reklrRWdrUUR2Y0JuaWlBa2liUGc5SVB3Q1pLdUhMbHNhYUd3N2ptN0NZd29fV3pFdEtMXzVIbHpqblNpN3AtQWJmVFJaZFQ2MURldFFpSXZXR2M?oc=5",\n+ "publishedUtc": "2026-07-25T09:33Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMirgFBVV95cUxNYmNoQnVaUHJkaUNJRGJXdFFiYUV0RTdxcF83ZTJqVXJQMng2TEdreUtoU0dWZXNWM2wzR0ZWMjY3SHh4Q0tCUWtUaUtwVEpYa1JvSmw4dGlGbUdLNEUxTXk5WGs1azBnaHc0M3JVWE1sSm51YWp0VjhiWFQ3V1BhRWtyVmJVekp4cWtUb2s0b19LRGpqaXRTYV9weFItYnZjaXNHeGYzYkZYUUJTSnc?oc=5",\n+ "publishedUtc": "2026-07-25T01:57Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMiqAFBVV95cUxNUWhFQjJQekN1Z0Z5NGpYS3lrSDAzbnNfdDRrb3FfQ2twLWFHM2NuZzdYbjdjc1BiU1c0aG9JTm1aM1pLWHVldDJnZFIwdS1wZW5pRjB1Q1BIQXpBdmt5M252QjFJU3d3TlpyVEU0YnZNUHd2bVdvZkJnZzBhcEYzNkU5cWN6enVvWlZzOWpWVUZ4UGo1eFl2WG0yMHROYzEtTWtWbGJBWU8?oc=5",\n+ "publishedUtc": "2026-07-20T00:02Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMisAFBVV95cUxQWUpMX2lFanpYelFlMGl0eXdhX25YeW9iSWFQLXcxTEhkQmZHUVlBeWVzdVNPSFYzX0tuNlVtRklqVENiYldsSGlEd2FaVXN5WXdoSFVlSG9IbEJPbmxxVG1pS2otd1BidExLd0RXbWhnYXUwNi1aRVl2MF9UeDRhaEdEWWdUeFhEelpkREdpTVZ5WFQwb2NXd0J2cnBDLXl6Y0MtOWsyWDZ1ZGZ5d2dScQ?oc=5",\n+ "publishedUtc": "2026-07-25T19:02Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMimwFBVV95cUxPTkRqWEUxWkJSM2dyYnk2X29lUXRWYThfREdHWVc3dVNmTGJFbnluYnFmOHNLemNGT2RTbzJrS05yQkE0cVRoVi1BX01VVEMwNlNZOGlRM0xtLTNXX21FTGhORm5Nc0hJZUhrMFo4ZXZteXhFV24tLWpiNF90amN2WDRTU2RnMURHUnVVTF9XeGozOVJTeXlsZlM3Zw?oc=5",\n+ "publishedUtc": "2026-07-26T01:54Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMixAFBVV95cUxPRjlGaWFlbm5kZ19iSERIOHFxQklfRXFveFRrN2FVOG5rdEh0bnpCbDNtbUVWSnh5SWgycWU0LU5yb0gyOFBzZ3ZUM0NsSDlRMzdmUUlEZEtDR2YxZzVGYm1VVFgySXVKdnV1b0tYY2JaUmNtUzYtTmo2YWIzVGowS2p6UjlOZTBiUmJHVmxfc3pYSlNoQ0dXNFdIdlE1VmF4UlUzT2JBeWx3VWVLczJ0VFNlSEF1ci13NmUzY2pMc1kwRFJh?oc=5",\n+ "publishedUtc": "2026-07-26T02:26Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMipgFBVV95cUxPNWRteTBjVkQ4SmpMc0FrX0praXdkcW1XM3k3Ynp3ZFN3QUNIeWxiMVNmYXlGQlVqRk9sOUNTcWRPQzdqdno3a3N4SDgxQW5qVnpnQnYxbGdwTEVZM1lHX0x1WVlTRjgyZWx6c3drSGZkTDhieFdDNW5qR0IwZXcwZS03QVpMUVRya29LUV9XbnZkdjloN3dJY2UtS2J6ZkI0NWZFSVVB?oc=5",\n+ "publishedUtc": "2026-07-23T21:28Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMiywNBVV95cUxQdzFVZG82MElSelRxOW5TVllwS1U0bzg5V2RTMzVXbkJDQkk1SHVZWktCME5iWnVTbk1lSERyNlFSbTE4YVdOV29IeVYwWER0cnlfS090QUw2ZnZodGVDUWFWWDRIQmJUc0RNYTdpZ0NZU0MyTVJJQVFrYVhyWm1rSEdmNkVWb2x5T0ZWaW0yT25uMElGZTdoLVROejFWU284bUMzMjZKd29xYzVENDZQQ2Vwb1UwQ2VfZEdiNUdHOWdWX2tKeEpjUm9MZmN3a3JlRGIyR3Ridm1Md2VldHE5dXBoSkx4TjNUdjBOUkc1Q0VSTmRkZmFSaE5DbUYyN1hPQ1BSVWZTTERfQ2dvLXdUYVhWN0ozQUN3ckdyQmNuQml0Q0poZTVmVzRzNDNPWHJkY2d2SVhrUDBGLVkyWEttbzl3T2w2bkU0cFZBYTZtWDdFZTF0U2ktM0lLQ0FaOFN2YWtyRk9sV2JSSXhzZEhrUzZtNTk2ZF9mTFBrQnNNdTJKVlVFQUQzeEZjUmtqQlMwWnZLNGt3R0lJMVFDSVBmenNKYUdLUm9ibWI4ZE9pZlB6Q0s5UnFBUUNlV01UTDR2c29fSzNwZzNiMWs?oc=5",\n+ "publishedUtc": "2026-07-25T19:04Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMipgFBVV95cUxQejdnWnFQQ0plMllHX0VlYjJuSjBrX1V6bEFoY3dHbXpmcFBHRzJvdlJvMFQ2ak84bkpUYnVDSGhSeWppQmV3WDAyWWVXTFFGN0NBZVZFVXJGWWFNTnRtemVyZGJSZHFQbG5aS2c1RU5yT2ozMnBQSkRUS3czeklmRkVDYl82TGtidkk2UFBua3Q2aUU4elJBakdjUkk4MnFldjNka3NB?oc=5",\n+ "publishedUtc": "2026-07-26T06:30Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMitwFBVV95cUxPbE1KdlFnR3MtSHdpTVhKSWhqZGNYbS02UWVKZGxYUTFUN05uVmlsTE5rYkp1dFlVaFpTdXpTR3BPTTRYdk1kQlNFNDZoUjl3c1NpdHJUTWhyRE94WEhmVF9oLUl4TVFCMXZBMF90UUVnODA0WWtZWktKRkZhNVdUUXZqMThmMFN0Zk84RkFtU1BtZ2J6VTdTOHRidjNnYVAzbmtkV2drcnFGY3hLeU1WRkVidUNzY3c?oc=5",\n+ "publishedUtc": "2026-07-24T11:04Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "INVESTIGATION PHOTOGRAPHS",\n+ "url": "https://news.google.com/rss/articles/CBMisgFBVV95cUxPSVpZRXBjY2xjNklKM2RqR1FyRWREU1dnVTFtcGhuczl0SnBpcVVIWkh0d09WNTI0LXR3RTd1YUl1Zkc3Sm1NNTl1TFJ5dGFUUDl1clZQeDY0WjVRd0loREM5MVZQVGhocmlvcWctM1dFaVY5Z3VoYWszLXpxR09VclFsNmx3NGNMWHg4N0pHaUtONmZGM0ZiRjlSZFZ2Q3NXdHQ2NGRyTkZVU2MyXzBMTDRR?oc=5",\n+ "publishedUtc": "2026-07-24T08:40Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMiuwFBVV95cUxNS2hFYnlBNzUyMnFzcUU3dlU3MjZxME00ZkxoY0FnZlhrTDFyeGhuSV9lU2Q0azBUcEU3MTBxLW5kU2JMSHJ1eXRONExWLUtvN2oxOTlNYUJlRjJUS3l4NWVvc09IMXJ6d2czdUpkQlFyUGJFZWlIYkdOUzhmSWpQbDJiX0tNSDFweVkyeEFDSmhfdDVfYm9YZjB0dzNPQm9JWl9JOFZYTnFWYkZ2U04yTy1Ba0ZxSFY4UDBr?oc=5",\n+ "publishedUtc": "2026-07-24T11:34Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "- NTSB (.gov)",\n+ "url": "https://news.google.com/rss/articles/CBMiqgJBVV95cUxQMnJrRnpweVF6aUFJanFwaEFGOHpXYTFvbWMwUmJIcVRUck9HZGtuUXF5MmcydGhDaTRRNjBaRG4wV3YwQmMweHBYTFM3cndBdXNJbWdpSXE1OTlJMVFQTDh1Qi15UE16RU4xVnlyZHFlQmtHVXZqeGZzN0FQZ3dKMEZlRkhNUWFxOGhPWXFMVXZaWDVGb2VIUDgtVU5uSWlaT0RWY3lMS3NGN2Y3SFRrbmc3SlM5MTgzWC11Q1hxT1dJV1JhSzhBT2pKamdQWml5WkxpbGdsbGRLdEk5cmZPdGc0NUpscXFxNmZ2UmJSZlEwdmRLVWZtWTBxTTd0X3RIMEZzdkhaU2hQRW1MSW5OVXl2NnpzOEs4MjNKNDh4QXZhR2tDMGUzQzNB?oc=5",\n+ "publishedUtc": "2026-07-23T05:20Z",\n+ "summary": "",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,183 @@\n+{\n+ "source": "reuters",\n+ "fetchedUtc": "2026-07-26T12:09Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "Aerospace fights for young recruits as AI drains talent pool",\n+ "url": "https://news.google.com/rss/articles/CBMinwFBVV95cUxONWh2cUhOaks5TTI5UDNnajlRR2h4MURwQmtQUFhOS0lZVUx6SlhYREZnM2dIY1laZFBGUmpGVDkxeDdVZHNTTU5HLUFrSkRqQUxHaXh0LUlTNDBycjg0WnZJazRENS1qOUkyUUx4RVp6Q050dzBCNkh6X2ZxM19rekRDYnlsNmplTEtBaUtaNld3blRRWUQxVHB4YmZBYUE?oc=5",\n+ "publishedUtc": "2026-07-24T17:20Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Fuel swings turn US airline earnings forecasts into moving targets",\n+ "url": "https://news.google.com/rss/articles/CBMiugFBVV95cUxOalhiV3BKNmx6UGlTM29waXhLZ0VNdHdUY2tSX2NiMUYxRlREVlAydDZFeVNVZmlCd2xCY0NjdUs3SThSMmN1OGNjWjd0ZGZnV21zZndLYk1qZTk5X1VxWnlFM2pDRjVtRnYwa3htWnB5VngxNzJ1LXdFNjY4R3E4VmN1R2YtVXI3dGpESTlzOFhfbUpPN3Bmd21WMjRIUEJoQV9YMnliM1g3NDEwN3FOZFB6WTh5MHpLblE?oc=5",\n+ "publishedUtc": "2026-07-24T18:06Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Embraer's backlog hits record $34.5 billion in the second quarter, rising 16%",\n+ "url": "https://news.google.com/rss/articles/CBMixwFBVV95cUxNeGV6czgyZXJaQXBBRU9vWUtrTWxDMm43dFd1LUFWVDFWbks3U2FxdE5ZY3hsSmVqazRnbS14ZEQwNVNzdDdtYV9OM2djRkVxdjVVR2p6ZDZnb0F3VE00bXZXM3hOMk5FQXAzMFI3MDNoR3hpTGpzREpDbm5TSHFLNHBqb2xLaUp2OTZMWVYyS0JjcmJqT3JfU2RYR2JTZjN5RlRZcU5lT01rUFBaY0JhUlp1RDhQZ2ZuMGFqOWhNWGVRa2stU19N?oc=5",\n+ "publishedUtc": "2026-07-24T21:46Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Zelenskiy says US, Ukraine moving forward on joint drone production",\n+ "url": "https://news.google.com/rss/articles/CBMixAFBVV95cUxQZldCakF6dTQ1RlI3dDdBbjM5NlFnOHRjRVNxdGFRMmF4aUk1RHR3clAzMlpwc19UUXREOVJlVzBxRmRVcHp6ZHd6MDBsdUxfSmJRTmlpZVhYM2lZZkRybElsNTFJUlAxWUJESGtJc3hwdjlubW9VUzVlT0NMbmtzUTFzSHlIZDRWNmFBbEVCNGVKM3hsdjNkZjVtWUNZcG9aVXRkRXlLazhXa0lXUmxPVDd1U1hwd0M3UTNQQVkxcjAzbGt4?oc=5",\n+ "publishedUtc": "2026-07-24T22:32Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Plane and engine makers in tug of war over future jet servicing profits",\n+ "url": "https://news.google.com/rss/articles/CBMiuAFBVV95cUxPckVkMFZyMG9qSlFtU2dNYjhJblBEYjRldTRFUHJPc1h5ZTJSSUFoNkF5TU1wRTJKRXVMbVE4eXFRdEEycWpxbmxRQ0kwSjlSRFdubHc5R0ZVYW9DTHB3OG5WdUlDLS02WE9GN1FxSUlJSlU1amhSZUJfZVRKcVpiNHVoSFRadFRNT1ZxODNRMmdxMWY1YnkyOER5SmR4bVJVSG14UVIwRXlLT211X2dNR3g2al9HMW5u?oc=5",\n+ "publishedUtc": "2026-07-24T18:09Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Ukraine's Zelenskiy: Russia wants to bring in 30,000 North Korean troops to conflict",\n+ "url": "https://news.google.com/rss/articles/CBMi0AFBVV95cUxQYVE3WVotb1k5eTA4eHhrSEtCWkVJM2xUU3lob3R5OGpPUFZnV0NRUTByU1cwTHJCUi0wYTdVSmVHU1V6TzMydk1EVjBZSVp4aldTLWdaVURyYUM1YmN1U0YtcU9RcDZ5Tm1Jc1hPdGNfZ3dUX19vNXhoV2JWekZaS01WRjBMbUotWHFwLTBTVXVyQXhSbjdBaTAwamJiMkFtZ3E1SV9tc0RZckNIZ3JjS2taS1JfclhQSG5WWUtwS3R1bEFLdE5LY3g4WWdjZjlk?oc=5",\n+ "publishedUtc": "2026-07-25T18:43Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Romania shoots down drone in airspace incursion for first time",\n+ "url": "https://news.google.com/rss/articles/CBMixAFBVV95cUxOQkZXbTUyOU1STUtsOVZHUDlnbEswWFlTbklQRFRIY0QyZGFtRXJqMURoWmZnc2hzZ1RyY25pMlFLaEUxOVBEOHl1YXUxR3F5YWNaWC1xZEhGWFZ3V3Ezc3ZmQ3NuN0ZCbDNNdm0yeHRsbEEyOWNUV1FpYktLaHJKTHlLeUZwdlRZbUhSZlluSU93aENHb25jeV9BX1dRLV9sODQzTHZhR0NGTG9RbmhiSG1uRWJrY1JRSXFraUl4ZldUMkhY?oc=5",\n+ "publishedUtc": "2026-07-24T12:51Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Greek-operated air defence system shoots down missiles over Saudi",\n+ "url": "https://news.google.com/rss/articles/CBMixwFBVV95cUxQN3g2TnRFRlEzbjhJTy13dGx3cGszWE0ydEZ2bGlEUlIyR3diOTVmYlJZdDQyY1dhc0dNWjZISm1VOW54akNlQ1FkQTFTZnFLR01adUh6cGlLSV95OVJ2Ym1ydGFtVW5uNnBZaWE2Vzhld0UtdFdRSy05clpSblJHZzdXQXhFcUNseV9zYXFyRUNIRWxLeE92amowZGlMZTg5dGpwTEo1VVF5SzB6b3B6bGt3M3VDREE0M3VHcVFtME40S0JDNHJj?oc=5",\n+ "publishedUtc": "2026-07-25T07:28Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "UK says armed forces ready to defend country after Iran warns over US bombers",\n+ "url": "https://news.google.com/rss/articles/CBMi1gFBVV95cUxOSUtaMHdwUWNQbXpNb0IzUXMxZm5sUFRDVlJGclZmak52YVFySlE1LUV1aTBiRDhqT055V2pIMWFQNmFILW1GSnRCRTBLYi1oSVY0Vm0tRjJPX19CazNmeUQtQkdDUXZmbVU2TFlhcnFKZVNMbVFZS05ZR1VlenRFSXRUa3d2dzhlSTNBeDQyOFA2Z3k1RXBYSWQ1Rm5Bd1FKT3ItT0UzT19nYkEwcmpPQlc0TDZSdlBCT3owXzJGMHFCRmp3dklCU0NJWmNFeTZTVy0yVy1n?oc=5",\n+ "publishedUtc": "2026-07-24T16:15Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Russian missile strike kills 10 at Ukrainian defence event",\n+ "url": "https://news.google.com/rss/articles/CBMitwFBVV95cUxNdmUyM0FtV09VRWkzQ1VGa1F0ZU10ZmtBNGI0eEpZUnR0ZXFjTmdyMS1ZX0hEdU9mUmhQdEJRemhNVnhlLXFGejh0QVA0alpoczFDendxcWRNUlNYdHNHVnNEaFp4aURYZGQwcFhFTWxLdlNTRVpUYk9YS0RnTkdRak9hX1FreDBtX0lBZ2NRTGJJWDlqX243aERMVlZ3VGp2TEtLSTdTcFlVb1JjTGtJUVNkblpySHM?oc=5",\n+ "publishedUtc": "2026-07-24T22:23Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "EXCLUSIVE: Defense tech company Anduril in talks to raise funding at about $100 billion valuation",\n+ "url": "https://news.google.com/rss/articles/CBMi1gFBVV95cUxQQi1vbktDeWx4OVF5U01jenY1R21ZSUxPeGtSU0xBYVBndjA1VFBlU2ZvRG4yb1p6bXJPLWpUcVNYQzk3OGR0d1diSHVhOGVSU1RiT0N5U0p6LU1qMjZ4eXEyNXc3UGJhYzhwOC1xVV9pRHVmMkNDX2NCMDQ0V0hudndUd0NfZURXaHRNUXpXWEczTmtRZXd3TldIS0VQaU9EcmR1ZTQtajNjRWVOc0s5dFlXMlh2SS1wUEZNYkNGMUNkRFlPeVNCbzVIeGZiWThpVjlYZ3R3?oc=5",\n+ "publishedUtc": "2026-07-24T15:50Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "US targets $2.2 billion in rebates to airlines that retrofit planes over wireless interference",\n+ "url": "https://news.google.com/rss/articles/CBMiugFBVV95cUxOdWtIbDE2THI3SnNiTmhvajlZYWFZMmsxaVRpcVRUUXdvdlBKUnZzZlV5WXl4M0I5cW5LNkFBLUxkN21DVDk4SUFXWkFFSjY4eWRFR0xscHJQa2xsakVqdGRTX3I1N2dRY0dOXy1wek5RNmV5U1BSeEV2Y1dFS0pGRDNzamdLTzFKS2ZxbnVjdndYX2xkM0JBYUJCcDhBR2dFaExDSHk1bUFxbGs4XzJta0lDX2t0NXhVSVE?oc=5",\n+ "publishedUtc": "2026-07-24T22:05Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Indian shares log weekly losses as crude climbs, HDFC Bank tumbles",\n+ "url": "https://news.google.com/rss/articles/CBMikAFBVV95cUxPRFQ5dlQ0MFctUmdHMU5BcXNFUTVGN3hnb0owdVpYVW9xSmJra1BYbzRaLUZmLU5wX3BQR2lvVjA1N0o2ZkZrTkJjN3pTeThRUmZETmd5RFdDYnJubjFZT0N5LURVbWJxZ2s5Q2VpSHpwejUtajZJdDZRczlhanhxM3dwbnNxcGphRkJvNVJra2k?oc=5",\n+ "publishedUtc": "2026-07-24T13:42Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Canada, UAE conclude trade talks as Ottawa seeks new markets",\n+ "url": "https://news.google.com/rss/articles/CBMiswFBVV95cUxPdHVyR2JKTldYRE9ieDRsTEtqQ2JBbEFsTUtJYU4zS2tMd253T1BYa3NOTkJWN3pURldoaFFDZGp5YlhwR3B6RWM5T0RwTFItVXp4VjJBZ1F5Rmt6Mjd0VmZZc2xnaHVld2JHSHN1SUdvV1lzRE01ODJXWlZJTk1sZHYya0JGS1JDZExGcEFqMkpJMk5mY3BrMTBueVhrSFhlREQ2YmZ1STk0eU55WW9kd2swTQ?oc=5",\n+ "publishedUtc": "2026-07-24T19:25Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Southern China faces torrential rain, flood risk after Typhoon Noul hits land",\n+ "url": "https://news.google.com/rss/articles/CBMirAFBVV95cUxPei03bzZKTWJfYWxESGxYN0t1OURsLWNyZUllSlFDVFZiWkFJekRUMTM1WnpLU1lmSDB4RlhTOEZrRFZFYnd5Zm0wc29uVXYtRURSdmJRc19fRVM0OW1VLU5YcHdBY3lVR1AxUFdqbF81aXl4NkJQUkhHZGhhVHZ0UVM5ZEZFOEVqYWlvNFBJVThBYnBOdWV2TG84SjRCMUFrSjBNZ01jRi0yUGgx?oc=5",\n+ "publishedUtc": "2026-07-26T00:15Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "From steppe to sea, a Ukrainian drone unit hunts Russian ships",\n+ "url": "https://news.google.com/rss/articles/CBMiogFBVV95cUxOMFJIUEJoeWdFd2VDa3k0V1ZTVWpIZUM3Z3NFM2JjTUM1dS1jT0FSaVhST3VnWEs1b0U4YnV1a2lKeVdVclJtVHN5YUJ5a1lHLTVxaE9BUEFINXFaZEV0dU1Ba3VZZGR5S01pQjR0c05aTmRhQjA2VF9NWERoYUY2Vld4SVZ4cXVPdjBRdkttUVlMeDlrMzAyb21DcWRRblV4VHc?oc=5",\n+ "publishedUtc": "2026-07-24T14:12Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Fuel situation still difficult in some Russian regions - agencies cite deputy PM",\n+ "url": "https://news.google.com/rss/articles/CBMixgFBVV95cUxNb3pYdFFRQmpGREFMOHIxUVRMTXJ5ekxadi0tZDZnN3lQSzlUQU11ZHlQdjNLdkVWdHIxTlE5alNVRm9yMUlHQ2YtY2YzaVVPQkpoMWJPeGdBZmlSdHhhaHFQbGhBQzQ0RE0xc1MzbEhVVzFSUk16VTZXTXhBVEtRVnF2QVk4U3NrQTRXOHdlRVIzVTRKc2JzX0tLdGI2bTZ4ejl6XzR2aFYtVU1FVmVwS1drRk5XNmZPVTVWRTF2b1ZGY3BNMGc?oc=5",\n+ "publishedUtc": "2026-07-25T14:57Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Qualcomm tells customers of double-digit price increases, Bloomberg News reports",\n+ "url": "https://news.google.com/rss/articles/CBMivgFBVV95cUxPLWtCdGgwT09VLW5yWnMzYVlaQTI3UEJrY3h5ZEE0Ni12Ulpod296UTRPb3VtQ1BLeW84NmRoSzU2RDJVZDRrdVhOQ3phSUlGS3JrMnFiRXJTZEJvRndCdUM0TVBtWWZSblgwcVI1dGhUNk9mRkstcTR5eXF0ZDFoZElWLWtFdmthLVk5OXEzUW5GQUZHNHBGZ1pvWXl3ZWxiSDZULXRjaXY2ZjFodENSQ1BibjhHR2dSdU1FTl9R?oc=5",\n+ "publishedUtc": "2026-07-24T21:45Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "DeepSeek tells prospective investors of funding pause, Bloomberg News reports",\n+ "url": "https://news.google.com/rss/articles/CBMivgFBVV95cUxPWExVWVlHMlpBUVQxbV9rU1NVZ0w2LVlSbVk0RjBtS0dtRjc4TW85M3hGT3hscVdkb3J6ZHhIU1NWMEJ2RGlhdXQwdkQ2cHVYN2RpN2Z4THlfZ3NlREhaa2dhNERLU0xKRENLbTgySE1CalYtdTV3VmFOR2g0SkNtaE9pV3RTdWJRSDNWS092dnFUcExFa0ZhRnBtNjhweXJ4TWhLSUFVVUk5WU14bWNvd2NpNHdOWk1BVklxTmxB?oc=5",\n+ "publishedUtc": "2026-07-25T15:44Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Speaking alongside Putin, Kazakh president calls for freeze of Ukraine war",\n+ "url": "https://news.google.com/rss/articles/CBMivgFBVV95cUxPa2tsN01UMi1hZmk2U1piS3ZfM251b1V6a1hYY01GcmlIYVBFcGFsenc0SlZINFNaeFFiZlJHY2tYV2tEWnZCM0hDV1hxTFI2Z00tNEk3aVFid2RvZkJqTUpjUnlDODRTcU1OMUNmTUVOUFhPdUpQZUJYYXJHckhOVGs1VkpYamRTZkoxNTBBOVhQQUhPdFNiMXlmSm1lcVFLSlBJYU1JMFM0VE95UGRsdXlMX3JNRnIwQnlrWTFn?oc=5",\n+ "publishedUtc": "2026-07-25T20:58Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Australia to raise concerns over new tariffs with Trump, PM Albanese says",\n+ "url": "https://news.google.com/rss/articles/CBMiwgFBVV95cUxPemd6N2ZxclRhSXRnQWN1Y3dwNWo1VXU2Qjg3Q2VfaElydmJtd1VmaUcxckpJWlJyaVh6dWoybnpQYUZ3WEtrbjhQa1haeFJia1dCYjR6QzFqQ1BPQWkxLW9SY1AxM1RLbXduMGxRbWQ3bkZXVVFQeDdQMUNfU2xzSzA4dGpOdjQ5MVBPOXFUVVNkQ1RjU0tkbEo2ZGoxZFNmWTlKN085Z2Z5SWJDS1ZvNFYxN3FmMTV6SDNiaFotM3lSdw?oc=5",\n+ "publishedUtc": "2026-07-26T01:29Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Overnight Russian attack triggers fires in Ukrainian capital Kyiv",\n+ "url": "https://news.google.com/rss/articles/CBMisAFBVV95cUxPcXdzREJ5ZV91eFBnWDdFcUI3SHhaSFZnMm9abVlaTjBJcEtfUzVJZEJvRUprZ1gzRHduWVVRN2tTZ0E3WnZzb1RJaW9qenhpRW9nRHVtN3U5RHV2N1JqTEJacGJLWHFSZXIwVlk4VV9Bc2JfN2pBaXFIM3VSRW8tZHpEb3R0QVluOFpxUDVweThnd2pieDRjTzdpZW9DRXNFZnZwSFpONFZDTjNGMUtVUg?oc=5",\n+ "publishedUtc": "2026-07-25T22:51Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Canadian intern at NATO military HQ arrested in Belgium on spying charge",\n+ "url": "https://news.google.com/rss/articles/CBMiswFBVV95cUxQSjhwUHpvTVFodm9TOHNxX3ctYU5lUFNvNURaU1VZemFpak1VZ1R4M2xUblJLMEI0UTM5Si1HZk9XVS1PMjBYaklkUkJvdmhPNGVfdV9GaGoxUmJOVWpQVDZ6QWxoT3dhRU83WERPYnRQTlBoY1NUV3I3emNnMkQxNk1SeEV5RlRmQlozYnRFaHBtQVhVczZXaVJpRjFZU1lya2FwM3ZJdTB1WkxpLU5ScDZkcw?oc=5",\n+ "publishedUtc": "2026-07-25T10:47Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Fire at Russia's Tyumen oil refinery extinguished after drone strike, officials say",\n+ "url": "https://news.google.com/rss/articles/CBMisAFBVV95cUxQU0JlWmlacURnU3BGX1NNdGhGcFRPRzM5TnpsY0JQUGkxWnQ2T08zcmFWZTZKOGV3TmlRQU9xVDZ5R1VyRnk3a1g0M0E0MVZFbXQ0eE1DMXdfTGVBZFpVMTdHQVZTdnF3azk5Nk5SOTBzVWFweXRzd2dRbjJlcUVnV1BzVnJHZHI0MW9OUm4zLUkxeF9EeGtvUHhJLUdKZmwxR2s0d0VBZXE3WloxSzFBUg?oc=5",\n+ "publishedUtc": "2026-07-25T16:34Z",\n+ "summary": "",\n+ "image": null\n+ },\n+ {\n+ "title": "Appeals court rejects Trump bid to halt $100,000 H-1B visa fee ruling",\n+ "url": "https://news.google.com/rss/articles/CBMiqgFBVV95cUxOV3dvS2F0SlRHbng5WWZ4ZzNPeEFNX1VLQXRsMzAwa2FnLXhyZW5BYXNmR0VGSmc0eEw3NXgxeWtLMHVvc19xd0oxeWtGTHpIVGdDRGZxX25SWkxva1MtcFZaUXprQ1o3UWdVNHJkTjQ2cUV3N09nSlZhQzNTUElSbHRNdTlJUzlMYXYxeHFsOXhNRzk2emhHdzk5X2JYN0lQeFFjLTlsQjA0QQ?oc=5",\n+ "publishedUtc": "2026-07-24T23:20Z",\n+ "summary": "",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,132 @@\n+[\n+ {\n+ "key": "faa",\n+ "name": "FAA",\n+ "kind": "official",\n+ "fmt": "HTML",\n+ "url": "https://www.faa.gov",\n+ "cover": {\n+ "zh": "美國聯邦航空總署 — 新聞、AD、法規",\n+ "en": "US regulator — news, ADs, rules"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "ntsb",\n+ "name": "NTSB",\n+ "kind": "official",\n+ "fmt": "RSS",\n+ "url": "https://www.ntsb.gov",\n+ "cover": {\n+ "zh": "美國運輸安全委員會 — 事故調查",\n+ "en": "US accident investigator"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "icao",\n+ "name": "ICAO",\n+ "kind": "official",\n+ "fmt": "HTML",\n+ "url": "https://www.icao.int",\n+ "cover": {\n+ "zh": "國際民航組織 — 標準與環境",\n+ "en": "UN body — standards & environment"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "iata",\n+ "name": "IATA",\n+ "kind": "industry",\n+ "fmt": "RSS",\n+ "url": "https://www.iata.org",\n+ "cover": {\n+ "zh": "產業數據與政策",\n+ "en": "Industry data & policy"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "easa",\n+ "name": "EASA",\n+ "kind": "official",\n+ "fmt": "RSS",\n+ "url": "https://www.easa.europa.eu",\n+ "cover": {\n+ "zh": "歐盟航空安全局 — AD 與安全公告",\n+ "en": "EU safety agency — ADs & bulletins"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "eurocontrol",\n+ "name": "Eurocontrol",\n+ "kind": "official",\n+ "fmt": "HTML",\n+ "url": "https://www.eurocontrol.int",\n+ "cover": {\n+ "zh": "歐洲空管網路狀態",\n+ "en": "European network operations"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "reuters",\n+ "name": "Reuters",\n+ "kind": "media",\n+ "fmt": "RSS",\n+ "url": "https://www.reuters.com",\n+ "cover": {\n+ "zh": "路透社航太與國防版",\n+ "en": "Reuters aerospace & defense"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "avherald",\n+ "name": "The Aviation Herald",\n+ "kind": "media",\n+ "fmt": "RSS",\n+ "url": "https://avherald.com",\n+ "cover": {\n+ "zh": "全球事故事件專業追報",\n+ "en": "Global incident tracking"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ },\n+ {\n+ "key": "flightaware",\n+ "name": "FlightAware",\n+ "kind": "data",\n+ "fmt": "API",\n+ "url": "https://flightaware.com",\n+ "cover": {\n+ "zh": "航班追蹤與延誤數據",\n+ "en": "Flight tracking & delay data"\n+ },\n+ "lastFetchUtc": null,\n+ "ok": false\n+ },\n+ {\n+ "key": "caataiwan",\n+ "name": "CAA Taiwan",\n+ "kind": "official",\n+ "fmt": "RSS",\n+ "url": "https://www.caa.gov.tw",\n+ "cover": {\n+ "zh": "交通部民航局 — 航班異動與公告",\n+ "en": "Taiwan CAA notices"\n+ },\n+ "lastFetchUtc": "2026-07-26T12:09Z",\n+ "ok": true\n+ }\n+]\n@@ -0,0 +1,176 @@\n+# AVWIRE pipeline data contracts\n+\n+Every stage reads/writes JSON under `data/`. All timestamps are UTC in the\n+form `2026-07-26T05:47Z` (see `common.iso_minute`). All files are written with\n+`common.save_json` (UTF-8, 2-space indent, trailing newline).\n+\n+Stage order (each is a standalone script, run from repo root):\n+\n+```\n+python pipeline/fetch.py # sources -> data/raw/*.json + data/sources.json\n+python pipeline/dedupe.py # data/raw -> data/pending.json (new/updated story groups)\n+python pipeline/write.py # data/pending.json -> data/articles/*.json, flashes.json,\n+ # incidents.json, data/seen.json\n+python pipeline/build.py # data/* -> site/ (static HTML)\n+```\n+\n+A stage must NEVER crash the pipeline because one input is missing or one\n+source failed: degrade gracefully, log to stdout, exit 0. Non-zero exit is\n+reserved for programming errors.\n+\n+## data/raw/<source_key>.json (written by fetch.py, read by dedupe.py)\n+\n+```jsonc\n+{\n+ "source": "faa", // key into common.SOURCES\n+ "fetchedUtc": "2026-07-26T05:02Z",\n+ "ok": true, // false when the fetch failed\n+ "error": null, // string when ok=false\n+ "items": [\n+ {\n+ "title": "FAA Statement on ...", // plain text, no HTML\n+ "url": "https://www.faa.gov/...", // original link (Google News links resolved when possible)\n+ "publishedUtc": "2026-07-26T04:41Z", // best-effort; fetchedUtc when unknown\n+ "summary": "First ~500 chars of plain-text summary/description",\n+ "image": null // og:image / enclosure URL when cheaply available\n+ }\n+ ]\n+}\n+```\n+\n+## data/sources.json (written by fetch.py, read by build.py)\n+\n+Array, one entry per key in `common.SOURCES`, in registry order:\n+\n+```jsonc\n+[\n+ {\n+ "key": "faa",\n+ "name": "FAA",\n+ "kind": "official", // official | industry | media | data\n+ "fmt": "RSS",\n+ "url": "https://www.faa.gov",\n+ "cover": {"zh": "...", "en": "..."},\n+ "lastFetchUtc": "2026-07-26T05:02Z", // last SUCCESSFUL fetch (persisted across runs)\n+ "ok": true\n+ }\n+]\n+```\n+\n+## data/pending.json (written by dedupe.py, read + consumed by write.py)\n+\n+Only stories that are NEW this run (not in data/seen.json). Cross-source\n+coverage of one event is merged into a single group.\n+\n+```jsonc\n+{\n+ "generatedUtc": "2026-07-26T05:03Z",\n+ "groups": [\n+ {\n+ "id": "g-20260726-0503-1",\n+ "primarySource": "Reuters", // display name of best source in group\n+ "items": [ /* raw items as above, each with extra "source" (display name) and "sourceKey" */ ]\n+ }\n+ ]\n+}\n+```\n+\n+## data/seen.json (written by write.py ONLY, read by dedupe.py)\n+\n+Dedupe memory. `urls` maps normalized URL -> ISO date first seen. Only\n+write.py updates this file, and only for groups it actually published — so if\n+write.py is skipped (no API key) or fails on a group, the story remains\n+"unseen" and is re-emitted by dedupe.py on the next run. Entries older than\n+21 days are pruned by write.py.\n+\n+```jsonc\n+{ "urls": {"https://www.faa.gov/newsroom/x": "2026-07-26T05:03Z"},\n+ "titles": [["faa statement denver 737", "2026-07-26T05:03Z"]] }\n+```\n+\n+## data/articles/<UTC-hour>.json e.g. data/articles/2026-07-26T05.json\n+(written by write.py, read by build.py; one batch file per pipeline hour,\n+absent when nothing new that hour)\n+\n+```jsonc\n+{\n+ "articles": [\n+ {\n+ "id": "a-20260726-0547-denver737", // a-YYYYMMDD-HHMM-<slug>\n+ "publishedUtc": "2026-07-26T05:47Z",\n+ "cat": "safety", // safety | reg | biz | ops\n+ "primarySource": "Reuters",\n+ "image": null, // og:image URL or null\n+ "zh": {"title": "...", "summary": "<=80字", "body": ["段落1", "段落2"]},\n+ "en": {"title": "...", "summary": "...", "body": ["para1", "para2"]},\n+ "sources": [{"name": "FAA — Statement", "url": "https://..."}] // >=1, REQUIRED\n+ }\n+ ]\n+}\n+```\n+\n+## data/flashes.json (written by write.py, read by build.py)\n+\n+Most recent first, max 10 kept:\n+\n+```jsonc\n+[ {"timeUtc": "2026-07-26T05:47Z", "hot": true, "zh": "...", "en": "...",\n+ "articleId": "a-20260726-0547-denver737"} ]\n+```\n+\n+## data/incidents.json (written by write.py, read by build.py)\n+\n+Most recent first, max 60 kept. Only safety-category events that describe an\n+actual occurrence (ICAO Annex 13):\n+\n+```jsonc\n+[\n+ {\n+ "date": "2026-07-26",\n+ "sev": "ser", // acc | ser | inc\n+ "aircraft": "B737-8",\n+ "operator": "United", // or {"zh": "...", "en": "..."}\n+ "phase": {"zh": "降落", "en": "Landing"},\n+ "location": {"zh": "丹佛", "en": "Denver"},\n+ "desc": {"zh": "...", "en": "..."},\n+ "status": "open", // open | closed | prelim\n+ "sources": ["FAA", "NTSB"],\n+ "articleId": "a-20260726-0547-denver737" // may be null\n+ }\n+]\n+```\n+\n+## data/stats.json (written by write.py — optional FlightAware AeroAPI part; read by build.py)\n+\n+All fields optional; build.py renders "—" for missing numbers and hides\n+sections whose arrays are missing/empty:\n+\n+```jsonc\n+{\n+ "updatedUtc": "2026-07-26T05:47Z",\n+ "flightsTracked": 18742,\n+ "delayRate": 22.4,\n+ "seriousThisWeek": 3, // write.py derives from incidents.json\n+ "notams": 1208\n+}\n+```\n+\n+## site/ (written by build.py)\n+\n+```\n+site/\n+├─ index.html zh home /\n+├─ en/index.html en home /en/\n+├─ news/<id>/index.html zh article /news/<id>/\n+├─ en/news/<id>/index.html en article\n+├─ incidents/index.html + en/incidents/\n+├─ sources/index.html + en/sources/\n+├─ about/index.html + en/about/\n+├─ assets/modernist.css copied from static/\n+├─ assets/site.css\n+├─ assets/app.js\n+├─ 404.html\n+└─ .nojekyll\n+```\n+\n+All internal links are prefixed with `common.BASE_PATH` (default `/avwire`).\n@@ -0,0 +1,646 @@\n+"""build.py — render the AVWIRE static site from data/ JSON (see CONTRACTS.md).\n+\n+Reads (all optional, degrades gracefully): data/articles/*.json, flashes.json,\n+incidents.json, sources.json, stats.json. Writes the site/ tree per\n+CONTRACTS.md: zh at /, en under /en/, one page per article ever published,\n+incidents / sources / about pages, copied assets, 404.html and .nojekyll.\n+A single bad input never crashes the build: bad entries are skipped with a\n+one-line log to stdout and the script exits 0.\n+"""\n+from __future__ import annotations\n+\n+import re\n+import shutil\n+import sys\n+import time\n+from datetime import timedelta, timezone\n+from pathlib import Path\n+from urllib.parse import quote, urlsplit\n+\n+from jinja2 import Environment, FileSystemLoader, StrictUndefined\n+\n+sys.path.insert(0, str(Path(__file__).resolve().parent))\n+from common import (\n+ ARTICLES_DIR,\n+ BASE_PATH,\n+ DATA_DIR,\n+ HOME_FEED_LIMIT,\n+ MAX_FLASHES,\n+ SITE_DIR,\n+ SOURCES,\n+ STATIC_DIR,\n+ TEMPLATES_DIR,\n+ load_json,\n+ now_utc,\n+ parse_iso,\n+)\n+\n+# Asia/Taipei via zoneinfo where tz data exists (Linux CI); the zone has had a\n+# fixed +08:00 offset with no DST since 1980, so a static fallback is exact.\n+try:\n+ from zoneinfo import ZoneInfo\n+\n+ TPE = ZoneInfo("Asia/Taipei")\n+except Exception: # pragma: no cover - Windows without the tzdata package\n+ TPE = timezone(timedelta(hours=8), "UTC+8")\n+\n+_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")\n+\n+# ── i18n — ported verbatim from the design file (L, CATS, SEV, ST, KIND), ────\n+# plus the handful of production-only keys at the end of each block.\n+L = {\n+ "zh": {\n+ "brandZh": "航空快訊", "live": "LIVE 快訊", "updateBadge": "每小時自動更新",\n+ "flash": "即時快訊", "sourceStatus": "來源狀態",\n+ "latest": "最新新聞", "topStory": "頭條", "mainSource": "主要來源",\n+ "readMore": "閱讀全文 →", "back": "← 返回首頁",\n+ "heroImg": "[ 頭條照片 — 由來源圖庫自動帶入,灰階顯示 ]",\n+ "today": "今日全球數據", "statFlights": "目前追蹤航班", "statDelay": "全球延誤率",\n+ "statSerious": "本週嚴重事件", "statNotam": "生效中 NOTAM",\n+ "otp": "準點率排行 · 7月", "fleet": "機隊與訂單追蹤", "fleetDeliv": "2026 上半年交付",\n+ "fleetBacklog": "積壓訂單", "fleetOrders": "本月新訂單",\n+ "delays": "今日延誤熱點機場", "autoCompiled": "自動彙整",\n+ "refreshNote": "本文隨來源每小時更新", "sourcesBox": "資料來源 Sources",\n+ "genNote": "本文由自動化系統彙整生成,內容以原始來源為準。",\n+ "incKicker": "Incident Database", "incTitle": "事故資料庫",\n+ "incSub": "自動彙整全球民航事故與事件,資料來自各國調查機關與專業追報來源,每小時更新。",\n+ "incCountLabel": "筆紀錄",\n+ "incNote": "* 分級依 ICAO Annex 13:事故(Accident)、嚴重事件(Serious incident)、事件(Incident)。",\n+ "thDate": "日期", "thSev": "等級", "thType": "機型", "thOp": "營運人",\n+ "thPhase": "飛行階段", "thLoc": "地點", "thDesc": "摘要", "thStatus": "狀態", "thSrc": "來源",\n+ "srcKicker": "Data Sources", "srcTitle": "資料來源",\n+ "srcSub": "全站僅使用免費公開來源。每小時整點抓取一次,抓取狀態即時顯示於首頁側欄。",\n+ "srcNote": "* 所有來源皆標註於各篇文末;轉載內容僅做事實彙整,原文著作權屬原單位。",\n+ "thName": "來源", "thKind": "類型", "thCover": "涵蓋範圍", "thFmt": "格式",\n+ "thFreq": "頻率", "thLast": "最近抓取", "thState": "狀態",\n+ "aboutKicker": "Methodology", "aboutTitle": "方法論", "aboutPipeTitle": "自動化資料管線",\n+ "aboutAttrTitle": "撰稿與來源歸屬", "aboutSchedTitle": "更新排程",\n+ "aboutRepoTitle": "開放原始碼", "aboutDiscTitle": "免責聲明",\n+ "aboutWhat": "AVWIRE 是全自動航空新聞聚合站:程式每小時抓取 FAA、ICAO、IATA、路透社等可信來源,自動撰寫新聞稿,並在每篇文末完整標註原始出處。無人工編輯介入,原始來源永遠優先。",\n+ "aboutAttr": "每篇報導由語言模型根據多個原始來源撰寫:僅彙整來源中的事實,不加入未經來源支持的推論;同一事件的多來源會先去重合併。文末「資料來源」列出所有引用連結。",\n+ "aboutSched": "GitHub Actions 於每小時整點觸發(cron: 0 * * * *),僅使用免費公開介面:RSS、公開 API 與網頁。單次更新流程約 3–5 分鐘,完成後發布至 GitHub Pages。",\n+ "aboutRepo": "全站程式碼開源,repo 結構如下。歡迎檢視程式碼、回報問題或自行部署一份。",\n+ "aboutDisc": "本站內容為自動生成,可能存在錯誤或遺漏,不構成飛安、法遵或投資建議。任何權威資訊請以官方原始來源為準。",\n+ "footerAbout": "全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源,自動撰寫並發布,每篇文末標註原始出處。本頁為設計原型,內容為示意樣本。",\n+ "footerSources": "資料來源 Data Sources",\n+ "nav": ["最新", "事故資料庫", "來源", "方法論"],\n+ "cats": ["全部", "事故", "法規", "商業", "營運"],\n+ "sevs": ["全部", "事故", "嚴重事件", "事件"],\n+ "themeOpts": ["亮", "暗"],\n+ "pipeSteps": [\n+ "GitHub Actions 每小時整點觸發工作流程",\n+ "抓取 FAA / ICAO / IATA / Reuters 等 RSS 與公開 API",\n+ "相似度去重、跨來源合併,語言模型撰寫新聞稿",\n+ "發布至 GitHub Pages,文末自動附上原始來源連結",\n+ ],\n+ # production-only keys (not in the design dictionary)\n+ "siteName": "AVWIRE 航空快訊",\n+ "siteDesc": "全自動航空新聞聚合站:每小時抓取 FAA、ICAO、IATA、Reuters 等可信來源,自動撰寫雙語新聞並於文末標註原始出處。",\n+ "awaiting": "等待首次管線執行",\n+ "lastBuild": "最後建置",\n+ },\n+ "en": {\n+ "brandZh": "Aviation Wire", "live": "LIVE WIRE", "updateBadge": "Auto-updates hourly",\n+ "flash": "Live flash", "sourceStatus": "Source status",\n+ "latest": "Latest news", "topStory": "Top story", "mainSource": "Primary source",\n+ "readMore": "Read more →", "back": "← Back to front page",\n+ "heroImg": "[ Lead photo — pulled from source library, grayscale ]",\n+ "today": "Global today", "statFlights": "Flights tracked now", "statDelay": "Global delay rate",\n+ "statSerious": "Serious events this week", "statNotam": "Active NOTAMs",\n+ "otp": "On-time ranking · Jul", "fleet": "Fleet & orders", "fleetDeliv": "H1 2026 deliveries",\n+ "fleetBacklog": "Backlog", "fleetOrders": "New orders this month",\n+ "delays": "Delay hotspots today", "autoCompiled": "Auto-compiled",\n+ "refreshNote": "Refreshes hourly with sources", "sourcesBox": "Sources",\n+ "genNote": "This article was compiled automatically; the original sources prevail.",\n+ "incKicker": "Incident Database", "incTitle": "Incident database",\n+ "incSub": "Automatically aggregated civil-aviation accidents and incidents, from state investigators and specialist trackers. Updated hourly.",\n+ "incCountLabel": "records",\n+ "incNote": "* Classes per ICAO Annex 13: Accident, Serious incident, Incident.",\n+ "thDate": "Date", "thSev": "Class", "thType": "Aircraft", "thOp": "Operator",\n+ "thPhase": "Phase", "thLoc": "Location", "thDesc": "Summary", "thStatus": "Status", "thSrc": "Sources",\n+ "srcKicker": "Data Sources", "srcTitle": "Sources",\n+ "srcSub": "Free, public sources only. Fetched at the top of every hour; live fetch status shows on the front page rail.",\n+ "srcNote": "* Every article credits its sources; content is factual aggregation and copyright stays with the originators.",\n+ "thName": "Source", "thKind": "Kind", "thCover": "Coverage", "thFmt": "Format",\n+ "thFreq": "Interval", "thLast": "Last fetch", "thState": "Status",\n+ "aboutKicker": "Methodology", "aboutTitle": "Methodology", "aboutPipeTitle": "Automated pipeline",\n+ "aboutAttrTitle": "Writing & attribution", "aboutSchedTitle": "Schedule",\n+ "aboutRepoTitle": "Open source", "aboutDiscTitle": "Disclaimer",\n+ "aboutWhat": "AVWIRE is a fully automated aviation news aggregator: every hour it fetches trusted sources such as FAA, ICAO, IATA and Reuters, writes the stories automatically, and credits every original source at the end of each article. No human editing; sources always prevail.",\n+ "aboutAttr": "Each story is written by a language model from multiple original sources: it aggregates facts only, adds no unsupported inference, and merges duplicate coverage of the same event. The Sources box lists every reference used.",\n+ "aboutSched": "GitHub Actions triggers at the top of every hour (cron: 0 * * * *), using free public interfaces only: RSS, open APIs and web pages. A full run takes 3–5 minutes and publishes to GitHub Pages.",\n+ "aboutRepo": "The whole site is open source; the repo layout is below. Inspect the code, file issues, or deploy your own copy.",\n+ "aboutDisc": "Content is machine-generated and may contain errors or omissions. It is not flight-safety, compliance or investment advice; defer to official sources.",\n+ "footerAbout": "A fully automated aviation news aggregator. GitHub Actions fetches trusted sources hourly, writes and publishes automatically, and credits originals at the end of every article. Design prototype; sample content.",\n+ "footerSources": "Data Sources",\n+ "nav": ["Latest", "Incident DB", "Sources", "Methodology"],\n+ "cats": ["All", "Safety", "Regulation", "Business", "Operations"],\n+ "sevs": ["All", "Accident", "Serious", "Incident"],\n+ "themeOpts": ["Light", "Dark"],\n+ "pipeSteps": [\n+ "GitHub Actions triggers the workflow at the top of every hour",\n+ "Fetch RSS and open APIs from FAA / ICAO / IATA / Reuters and more",\n+ "De-duplicate, merge across sources, and draft stories with a language model",\n+ "Publish to GitHub Pages with original source links appended to every article",\n+ ],\n+ # production-only keys (not in the design dictionary)\n+ "siteName": "AVWIRE Aviation Wire",\n+ "siteDesc": "A fully automated aviation news aggregator: trusted sources fetched hourly, bilingual stories written automatically, every original source credited.",\n+ "awaiting": "Awaiting first pipeline run",\n+ "lastBuild": "Last build",\n+ },\n+}\n+\n+CATS = {\n+ "safety": {"zh": "事故", "en": "Safety"},\n+ "reg": {"zh": "法規", "en": "Regulation"},\n+ "biz": {"zh": "商業", "en": "Business"},\n+ "ops": {"zh": "營運", "en": "Operations"},\n+}\n+SEV = {\n+ "acc": {"zh": "事故", "en": "Accident", "cls": "tag-accent"},\n+ "ser": {"zh": "嚴重事件", "en": "Serious", "cls": "tag-outline"},\n+ "inc": {"zh": "事件", "en": "Incident", "cls": "tag-neutral"},\n+}\n+ST = {\n+ "open": {"zh": "調查中", "en": "Open"},\n+ "closed": {"zh": "已結案", "en": "Closed"},\n+ "prelim": {"zh": "初報公布", "en": "Prelim out"},\n+}\n+KIND = {\n+ "official": {"zh": "官方機構", "en": "Official"},\n+ "industry": {"zh": "產業組織", "en": "Industry body"},\n+ "media": {"zh": "新聞媒體", "en": "News media"},\n+ "data": {"zh": "數據平台", "en": "Data platform"},\n+}\n+TAGCLS = {"safety": "tag-accent", "reg": "tag-neutral", "biz": "tag-neutral", "ops": "tag-neutral"}\n+\n+TICKER_SEP = " ▪ " # 「 ▪ 」 per the design\n+\n+# Footer source-tag order, exactly as in the design footer.\n+FOOTER_ORDER = ["faa", "icao", "iata", "ntsb", "easa", "eurocontrol",\n+ "reuters", "avherald", "flightaware", "caataiwan"]\n+FOOTER_SOURCES = [{"name": SOURCES[k]["name"], "url": SOURCES[k]["url"]}\n+ for k in FOOTER_ORDER if k in SOURCES]\n+\n+# Favicon: the design's __bundler_thumbnail SVG as a data: URI.\n+_FAVICON_SVG = (\n+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">'\n+ '<rect width="120" height="120" fill="#ec3013"/>'\n+ '<text x="60" y="72" font-family="Archivo,sans-serif" font-weight="800" '\n+ 'font-size="34" fill="#f3f2f2" text-anchor="middle">AW</text></svg>'\n+)\n+FAVICON = "data:image/svg+xml," + quote(_FAVICON_SVG)\n+\n+\n+# ── small helpers ────────────────────────────────────────────────────────────\n+\n+def _bi(value, lang: str, fallback: str = "") -> str:\n+ """Resolve a bilingual field ({zh,en} dict or plain string)."""\n+ if isinstance(value, dict):\n+ other = "en" if lang == "zh" else "zh"\n+ v = value.get(lang) or value.get(other)\n+ return str(v) if v else fallback\n+ if value is None or value == "":\n+ return fallback\n+ return str(value)\n+\n+\n+def fmt_int(v) -> str:\n+ if isinstance(v, bool) or not isinstance(v, (int, float)):\n+ return "—"\n+ return f"{int(v):,}" if float(v).is_integer() else f"{v:,}"\n+\n+\n+def fmt_pct(v) -> str:\n+ if isinstance(v, bool) or not isinstance(v, (int, float)):\n+ return "—"\n+ return f"{v:g}%"\n+\n+\n+def ago(last_utc, now) -> str:\n+ """Relative 'ago' string vs build time: '12 min' / '3 hr' / '2 d' / '—'."""\n+ if not last_utc:\n+ return "—"\n+ try:\n+ dt = parse_iso(str(last_utc))\n+ except (ValueError, TypeError):\n+ return "—"\n+ mins = max(0, int((now - dt).total_seconds() // 60))\n+ if mins < 60:\n+ return f"{mins} min"\n+ if mins < 48 * 60:\n+ return f"{mins // 60} hr"\n+ return f"{mins // (24 * 60)} d"\n+\n+\n+def rel_path(lang: str, sub: str) -> str:\n+ return ("en/" + sub) if lang == "en" else sub\n+\n+\n+def page_url(lang: str, sub: str) -> str:\n+ """URL for a page; sub is '' or 'incidents/' or 'news/<id>/' …"""\n+ return f"{BASE_PATH}/{'en/' if lang == 'en' else ''}{sub}"\n+\n+\n+# ── data loading / preparation ───────────────────────────────────────────────\n+\n+def prep_article(raw):\n+ if not isinstance(raw, dict):\n+ return None\n+ art_id = raw.get("id")\n+ if not isinstance(art_id, str) or not _ID_RE.match(art_id.strip()):\n+ return None\n+ art_id = art_id.strip()\n+ try:\n+ dt = parse_iso(str(raw.get("publishedUtc")))\n+ except (ValueError, TypeError):\n+ return None\n+ zh = raw.get("zh") if isinstance(raw.get("zh"), dict) else {}\n+ en = raw.get("en") if isinstance(raw.get("en"), dict) else {}\n+ if not (zh.get("title") or en.get("title")):\n+ return None\n+\n+ def side(own, other):\n+ body = own.get("body")\n+ if not isinstance(body, (list, str)) or not body:\n+ body = other.get("body")\n+ if isinstance(body, str):\n+ body = [body]\n+ if not isinstance(body, list):\n+ body = []\n+ return {\n+ "title": str(own.get("title") or other.get("title") or ""),\n+ "summary": str(own.get("summary") or other.get("summary") or ""),\n+ "body": [str(p) for p in body if str(p).strip()],\n+ }\n+\n+ sources = []\n+ for s in raw.get("sources") or []:\n+ if isinstance(s, dict) and s.get("url"):\n+ url = str(s["url"])\n+ try:\n+ host = urlsplit(url).netloc\n+ except ValueError:\n+ host = ""\n+ sources.append({"n": f"{len(sources) + 1}.",\n+ "name": str(s.get("name") or host or url),\n+ "url": url, "host": host})\n+\n+ cat = raw.get("cat") if isinstance(raw.get("cat"), str) else "ops"\n+ tpe = dt.astimezone(TPE)\n+ return {\n+ "id": art_id,\n+ "dt": dt,\n+ "cat": cat,\n+ "tag_class": TAGCLS.get(cat, "tag-neutral"),\n+ "image": str(raw["image"]) if raw.get("image") else None,\n+ "source": str(raw.get("primarySource") or (sources[0]["name"] if sources else "—")),\n+ "time": tpe.strftime("%H:%M"),\n+ "meta_ts": tpe.strftime("%Y-%m-%d %H:%M") + " UTC+8",\n+ "zh": side(zh, en),\n+ "en": side(en, zh),\n+ "sources": sources,\n+ }\n+\n+\n+def collect_articles():\n+ arts = []\n+ if ARTICLES_DIR.is_dir():\n+ for path in sorted(ARTICLES_DIR.glob("*.json")):\n+ data = load_json(path, None)\n+ rows = data.get("articles") if isinstance(data, dict) else None\n+ if not isinstance(rows, list):\n+ print(f"build: {path.name}: no articles[] — skipped")\n+ continue\n+ for raw in rows:\n+ art = prep_article(raw)\n+ if art is None:\n+ print(f"build: {path.name}: bad article entry skipped")\n+ else:\n+ arts.append(art)\n+ by_id = {}\n+ for a in arts:\n+ prev = by_id.get(a["id"])\n+ if prev is None or a["dt"] > prev["dt"]:\n+ by_id[a["id"]] = a\n+ return sorted(by_id.values(), key=lambda a: a["dt"], reverse=True)\n+\n+\n+def art_view(a, lang: str):\n+ v = dict(a[lang])\n+ v.update(\n+ id=a["id"], cat=a["cat"], tag_class=a["tag_class"], image=a["image"],\n+ cat_label=_bi(CATS.get(a["cat"]), lang, a["cat"]),\n+ source=a["source"], time=a["time"], meta_ts=a["meta_ts"],\n+ sources=a["sources"], url=page_url(lang, f"news/{a['id']}/"),\n+ )\n+ return v\n+\n+\n+def prep_flashes(raw, known_ids):\n+ out = []\n+ if not isinstance(raw, list):\n+ if raw is not None:\n+ print("build: flashes.json malformed — ignored")\n+ return out\n+ for f in raw[:MAX_FLASHES]:\n+ if not isinstance(f, dict):\n+ continue\n+ try:\n+ dt = parse_iso(str(f.get("timeUtc")))\n+ except (ValueError, TypeError):\n+ continue\n+ zh = str(f.get("zh") or f.get("en") or "").strip()\n+ en = str(f.get("en") or f.get("zh") or "").strip()\n+ if not zh:\n+ continue\n+ aid = f.get("articleId")\n+ out.append({\n+ "time": dt.astimezone(TPE).strftime("%H:%M"),\n+ "hot": bool(f.get("hot")),\n+ "zh": zh, "en": en,\n+ "articleId": aid if isinstance(aid, str) and aid in known_ids else None,\n+ })\n+ return out\n+\n+\n+def flash_view(flashes, lang: str):\n+ return [{\n+ "time": f["time"], "hot": f["hot"], "text": f[lang],\n+ "url": page_url(lang, f"news/{f['articleId']}/") if f["articleId"] else None,\n+ } for f in flashes]\n+\n+\n+def prep_incidents(raw):\n+ if not isinstance(raw, list):\n+ if raw is not None:\n+ print("build: incidents.json malformed — ignored")\n+ return []\n+ return [i for i in raw if isinstance(i, dict) and i.get("date")]\n+\n+\n+def inc_view(incidents, lang: str):\n+ rows = []\n+ for i in incidents:\n+ date = str(i.get("date") or "")\n+ disp = date[5:] if re.match(r"\d{4}-\d{2}-\d{2}\Z", date) else date\n+ sev = i.get("sev") if isinstance(i.get("sev"), str) else ""\n+ srcs = i.get("sources")\n+ src = " · ".join(str(s) for s in srcs) if isinstance(srcs, list) else str(srcs or "")\n+ rows.append({\n+ "d": disp,\n+ "sev_key": sev,\n+ "sev_label": _bi(SEV.get(sev), lang, sev),\n+ "sev_class": SEV.get(sev, {}).get("cls", "tag-neutral"),\n+ "ac": str(i.get("aircraft") or "—"),\n+ "op": _bi(i.get("operator"), lang, "—"),\n+ "ph": _bi(i.get("phase"), lang, "—"),\n+ "loc": _bi(i.get("location"), lang, "—"),\n+ "desc": _bi(i.get("desc"), lang, ""),\n+ "st": _bi(ST.get(i.get("status")), lang, str(i.get("status") or "")),\n+ "src": src,\n+ })\n+ return rows\n+\n+\n+def merged_sources(raw):\n+ """Overlay data/sources.json onto the registry so all sources always show."""\n+ by_key = {}\n+ if isinstance(raw, list):\n+ for s in raw:\n+ if isinstance(s, dict) and s.get("key"):\n+ by_key[s["key"]] = s\n+ out = []\n+ for key, reg in SOURCES.items():\n+ s = by_key.get(key, {})\n+ out.append({\n+ "key": key,\n+ "name": str(s.get("name") or reg["name"]),\n+ "kind": str(s.get("kind") or reg["kind"]),\n+ "fmt": str(s.get("fmt") or reg["fmt"]),\n+ "url": str(s.get("url") or reg["url"]),\n+ "cover": s.get("cover") or reg["cover"],\n+ "lastFetchUtc": s.get("lastFetchUtc"),\n+ "ok": bool(s.get("ok", False)),\n+ })\n+ return out\n+\n+\n+def source_status_view(sources, now):\n+ return [{"name": s["name"], "ok": s["ok"], "ago": ago(s["lastFetchUtc"], now)}\n+ for s in sources]\n+\n+\n+def sources_rows_view(sources, lang: str, now):\n+ return [{\n+ "name": s["name"], "url": s["url"], "fmt": s["fmt"], "ok": s["ok"],\n+ "kind": _bi(KIND.get(s["kind"]), lang, s["kind"]),\n+ "cover": _bi(s["cover"], lang),\n+ "last": ago(s["lastFetchUtc"], now),\n+ } for s in sources]\n+\n+\n+def stats_views(stats, lang: str):\n+ if not isinstance(stats, dict):\n+ stats = {}\n+ tiles = {\n+ "flights": fmt_int(stats.get("flightsTracked")),\n+ "delay": fmt_pct(stats.get("delayRate")),\n+ "serious": fmt_int(stats.get("seriousThisWeek")),\n+ "notams": fmt_int(stats.get("notams")),\n+ }\n+ otp = []\n+ raw_otp = stats.get("otp")\n+ if isinstance(raw_otp, list):\n+ for r in raw_otp:\n+ if not isinstance(r, dict):\n+ continue\n+ try:\n+ pct = float(r.get("pct"))\n+ except (TypeError, ValueError):\n+ continue\n+ label = r.get(lang) or r.get("airline") or r.get("name") or ""\n+ otp.append({\n+ "rank": len(otp) + 1,\n+ "airline": str(label),\n+ "code": str(r.get("code") or ""),\n+ "pct": f"{pct:g}",\n+ # bar width formula ported from the design\n+ "barw": f"{max(0, min(100, round((pct - 78) / 15 * 100)))}%",\n+ })\n+ fleet = None\n+ fr = stats.get("fleet")\n+ if isinstance(fr, dict):\n+ a, b = fr.get("airbus"), fr.get("boeing")\n+ if (isinstance(a, (int, float)) and not isinstance(a, bool)\n+ and isinstance(b, (int, float)) and not isinstance(b, bool)):\n+ mx = max(a, b) or 1\n+ ba, bb = fmt_int(fr.get("backlogAirbus")), fmt_int(fr.get("backlogBoeing"))\n+ fleet = {\n+ "bars": [\n+ {"maker": "Airbus", "n": fmt_int(a),\n+ "w": f"{round(a / mx * 100)}%", "color": "var(--color-accent)"},\n+ {"maker": "Boeing", "n": fmt_int(b),\n+ "w": f"{round(b / mx * 100)}%",\n+ "color": "color-mix(in srgb,var(--color-text) 45%,transparent)"},\n+ ],\n+ "backlog": f"A {ba} · B {bb}" if "—" not in (ba, bb) else "—",\n+ "orders": fmt_int(fr.get("newOrders")),\n+ }\n+ delays = []\n+ raw_delays = stats.get("delays")\n+ if isinstance(raw_delays, list):\n+ for d in raw_delays:\n+ if not isinstance(d, dict) or not d.get("code"):\n+ continue\n+ try:\n+ pct = float(d.get("pct"))\n+ except (TypeError, ValueError):\n+ continue\n+ delays.append({"code": str(d["code"]),\n+ "name": str(d.get(lang) or d.get("name") or ""),\n+ "pct": f"{pct:g}"})\n+ return {"tiles": tiles, "otp": otp, "fleet": fleet, "delays": delays}\n+\n+\n+# ── rendering ────────────────────────────────────────────────────────────────\n+\n+def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\n+ t = L[lang]\n+ nav_defs = [("home", ""), ("incidents", "incidents/"),\n+ ("sources", "sources/"), ("about", "about/")]\n+ return {\n+ "lang": lang, "t": t, "base": BASE_PATH, "favicon": FAVICON,\n+ "html_lang": "zh-Hant" if lang == "zh" else "en",\n+ "title": title, "description": description,\n+ "zh_url": page_url("zh", sub), "en_url": page_url("en", sub),\n+ "hreflang": hreflang,\n+ "home_url": page_url(lang, ""),\n+ "nav": [{"label": t["nav"][i], "url": page_url(lang, s), "active": page == p}\n+ for i, (p, s) in enumerate(nav_defs)],\n+ "ticker_line": ticker, "build": build,\n+ "footer_sources": FOOTER_SOURCES,\n+ }\n+\n+\n+def render(env, template, out_rel, ctx):\n+ html = env.get_template(template).render(**ctx)\n+ out = SITE_DIR / out_rel\n+ out.parent.mkdir(parents=True, exist_ok=True)\n+ out.write_text(html, encoding="utf-8", newline="\n")\n+\n+\n+def main() -> int:\n+ t0 = time.time()\n+ now = now_utc()\n+\n+ articles = collect_articles()\n+ flashes = prep_flashes(load_json(DATA_DIR / "flashes.json", None),\n+ {a["id"] for a in articles})\n+ incidents = prep_incidents(load_json(DATA_DIR / "incidents.json", None))\n+ sources = merged_sources(load_json(DATA_DIR / "sources.json", []))\n+ stats_raw = load_json(DATA_DIR / "stats.json", {})\n+\n+ # clean output tree\n+ if SITE_DIR.exists():\n+ shutil.rmtree(SITE_DIR, ignore_errors=True)\n+ (SITE_DIR / "assets").mkdir(parents=True, exist_ok=True)\n+ if STATIC_DIR.is_dir():\n+ for f in STATIC_DIR.iterdir():\n+ if f.is_file():\n+ shutil.copy2(f, SITE_DIR / "assets" / f.name)\n+ (SITE_DIR / ".nojekyll").write_text("", encoding="utf-8")\n+\n+ env = Environment(\n+ loader=FileSystemLoader(str(TEMPLATES_DIR)),\n+ autoescape=True,\n+ undefined=StrictUndefined,\n+ trim_blocks=True,\n+ lstrip_blocks=True,\n+ )\n+\n+ tpe_now = now.astimezone(TPE)\n+ build = {\n+ "date": tpe_now.strftime("%Y-%m-%d %a").upper(),\n+ "utc_hm": now.strftime("%H:%M"),\n+ "tpe_hm": tpe_now.strftime("%H:%M"),\n+ "stamp": now.strftime("%Y-%m-%d %H:%M"),\n+ }\n+ cat_keys = ["all", "safety", "reg", "biz", "ops"]\n+ sev_keys = ["all", "acc", "ser", "inc"]\n+ pages = 0\n+ failed = 0\n+\n+ for lang in ("zh", "en"):\n+ t = L[lang]\n+ fl = flash_view(flashes, lang)\n+ ticker = TICKER_SEP.join(f"{f['time']} {f['text']}" for f in fl)\n+ views = [art_view(a, lang) for a in articles]\n+ hero = views[0] if views else None\n+ feed = views[1:1 + HOME_FEED_LIMIT]\n+ sv = stats_views(stats_raw, lang)\n+\n+ ctx = base_ctx(lang, "home", "", title=t["siteName"],\n+ description=t["siteDesc"], ticker=ticker, build=build)\n+ ctx.update(hero=hero, feed=feed, flashes=fl,\n+ source_status=source_status_view(sources, now),\n+ stats=sv["tiles"], otp=sv["otp"], fleet=sv["fleet"],\n+ delays=sv["delays"],\n+ cat_seg=list(zip(cat_keys, t["cats"])))\n+ render(env, "home.html", rel_path(lang, "index.html"), ctx)\n+ pages += 1\n+\n+ for a, v in zip(articles, views):\n+ try:\n+ ctx = base_ctx(lang, "home", f"news/{a['id']}/",\n+ title=f"{t['siteName']} — {v['title']}",\n+ description=v["summary"] or t["siteDesc"],\n+ ticker=ticker, build=build)\n+ ctx["a"] = v\n+ render(env, "article.html",\n+ rel_path(lang, f"news/{a['id']}/index.html"), ctx)\n+ pages += 1\n+ except Exception as exc: # degrade: one bad article must not kill the build\n+ failed += 1\n+ print(f"build: article {a['id']} ({lang}) failed: {exc}")\n+\n+ rows = inc_view(incidents, lang)\n+ ctx = base_ctx(lang, "incidents", "incidents/",\n+ title=f"{t['siteName']} — {t['incTitle']}",\n+ description=t["incSub"], ticker=ticker, build=build)\n+ ctx.update(rows=rows, count=len(rows),\n+ sev_seg=list(zip(sev_keys, t["sevs"])))\n+ render(env, "incidents.html", rel_path(lang, "incidents/index.html"), ctx)\n+ pages += 1\n+\n+ ctx = base_ctx(lang, "sources", "sources/",\n+ title=f"{t['siteName']} — {t['srcTitle']}",\n+ description=t["srcSub"], ticker=ticker, build=build)\n+ ctx.update(rows=sources_rows_view(sources, lang, now))\n+ render(env, "sources.html", rel_path(lang, "sources/index.html"), ctx)\n+ pages += 1\n+\n+ ctx = base_ctx(lang, "about", "about/",\n+ title=f"{t['siteName']} — {t['aboutTitle']}",\n+ description=t["aboutWhat"], ticker=ticker, build=build)\n+ ctx.update(pipe_steps=[{"n": f"0{i + 1}", "text": s}\n+ for i, s in enumerate(t["pipeSteps"])])\n+ render(env, "about.html", rel_path(lang, "about/index.html"), ctx)\n+ pages += 1\n+\n+ # 404 (zh chrome, bilingual body), served by GitHub Pages from the root\n+ ctx = base_ctx("zh", "none", "", title=f"{L['zh']['siteName']} — 404",\n+ description=L["zh"]["siteDesc"], ticker="", build=build,\n+ hreflang=False)\n+ render(env, "404.html", "404.html", ctx)\n+ pages += 1\n+\n+ print(f"build: {len(articles)} articles, {len(flashes)} flashes, "\n+ f"{len(incidents)} incidents, {pages} pages, {failed} failed "\n+ f"-> {SITE_DIR} (base='{BASE_PATH}') in {time.time() - t0:.2f}s")\n+ return 0\n+\n+\n+if __name__ == "__main__":\n+ sys.exit(main())\n@@ -0,0 +1,250 @@\n+"""Shared helpers and configuration for the AVWIRE pipeline.\n+\n+Every pipeline stage (fetch -> dedupe -> write -> build) imports from here.\n+Data contracts between stages are documented in pipeline/CONTRACTS.md.\n+"""\n+from __future__ import annotations\n+\n+import json\n+import os\n+import re\n+import unicodedata\n+from datetime import datetime, timezone\n+from pathlib import Path\n+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+# AVWIRE_DATA_DIR lets tests point the pipeline at fixture data.\n+DATA_DIR = Path(os.environ.get("AVWIRE_DATA_DIR", ROOT / "data"))\n+RAW_DIR = DATA_DIR / "raw"\n+ARTICLES_DIR = DATA_DIR / "articles"\n+SITE_DIR = ROOT / "site"\n+TEMPLATES_DIR = ROOT / "templates"\n+STATIC_DIR = ROOT / "static"\n+\n+# GitHub Pages project sites live under /<repo>/ — override with env for\n+# user/organization pages or local preview (set AVWIRE_BASE_PATH="").\n+BASE_PATH = os.environ.get("AVWIRE_BASE_PATH", "/avwire").rstrip("/")\n+\n+USER_AGENT = (\n+ "AVWIREBot/1.0 (automated aviation news aggregator; "\n+ "+https://github.com/avwire) requests"\n+)\n+\n+# Keep at most this many entries per source per fetch, and this many flashes.\n+MAX_ITEMS_PER_SOURCE = 25\n+MAX_FLASHES = 10\n+# Articles shown on the home page feed.\n+HOME_FEED_LIMIT = 30\n+\n+# Master source registry. `key` is the stable identifier used for\n+# data/raw/<key>.json. `fmt` is what we display on the sources page.\n+# `endpoint` is what fetch.py actually requests; `kind`:\n+# official | industry | media | data\n+SOURCES: dict[str, dict] = {\n+ "faa": {\n+ "name": "FAA",\n+ "kind": "official",\n+ "fmt": "HTML",\n+ "url": "https://www.faa.gov",\n+ "endpoint": "https://www.faa.gov/newsroom/press_releases",\n+ "type": "html",\n+ "cover": {\n+ "zh": "美國聯邦航空總署 — 新聞、AD、法規",\n+ "en": "US regulator — news, ADs, rules",\n+ },\n+ },\n+ "ntsb": {\n+ "name": "NTSB",\n+ "kind": "official",\n+ "fmt": "RSS",\n+ "url": "https://www.ntsb.gov",\n+ "endpoint": (\n+ "https://news.google.com/rss/search?"\n+ "q=site:ntsb.gov%20when:7d&hl=en-US&gl=US&ceid=US:en"\n+ ),\n+ "type": "rss",\n+ "cover": {\n+ "zh": "美國運輸安全委員會 — 事故調查",\n+ "en": "US accident investigator",\n+ },\n+ },\n+ "icao": {\n+ "name": "ICAO",\n+ "kind": "official",\n+ "fmt": "HTML",\n+ "url": "https://www.icao.int",\n+ "endpoint": "https://www.icao.int/news",\n+ "type": "html",\n+ "cover": {\n+ "zh": "國際民航組織 — 標準與環境",\n+ "en": "UN body — standards & environment",\n+ },\n+ },\n+ "iata": {\n+ "name": "IATA",\n+ "kind": "industry",\n+ "fmt": "RSS",\n+ "url": "https://www.iata.org",\n+ "endpoint": "https://www.iata.org/api/rss/pressrelease",\n+ "type": "rss",\n+ "cover": {\n+ "zh": "產業數據與政策",\n+ "en": "Industry data & policy",\n+ },\n+ },\n+ "easa": {\n+ "name": "EASA",\n+ "kind": "official",\n+ "fmt": "RSS",\n+ "url": "https://www.easa.europa.eu",\n+ "endpoint": "https://www.easa.europa.eu/en/newsroom-and-events/news/feed.xml",\n+ "type": "rss",\n+ "cover": {\n+ "zh": "歐盟航空安全局 — AD 與安全公告",\n+ "en": "EU safety agency — ADs & bulletins",\n+ },\n+ },\n+ "eurocontrol": {\n+ "name": "Eurocontrol",\n+ "kind": "official",\n+ "fmt": "HTML",\n+ "url": "https://www.eurocontrol.int",\n+ "endpoint": "https://www.eurocontrol.int/newsroom",\n+ "type": "html",\n+ "cover": {\n+ "zh": "歐洲空管網路狀態",\n+ "en": "European network operations",\n+ },\n+ },\n+ "reuters": {\n+ "name": "Reuters",\n+ "kind": "media",\n+ "fmt": "RSS",\n+ "url": "https://www.reuters.com",\n+ # Reuters has no public RSS; per spec use a Google News RSS query.\n+ "endpoint": (\n+ "https://news.google.com/rss/search?"\n+ "q=(aerospace%20OR%20airline%20OR%20aviation)%20site:reuters.com%20when:2d"\n+ "&hl=en-US&gl=US&ceid=US:en"\n+ ),\n+ "type": "rss",\n+ "cover": {\n+ "zh": "路透社航太與國防版",\n+ "en": "Reuters aerospace & defense",\n+ },\n+ },\n+ "avherald": {\n+ "name": "The Aviation Herald",\n+ "kind": "media",\n+ "fmt": "RSS",\n+ "url": "https://avherald.com",\n+ # avherald robots.txt disallows all non-search bots; use Google News\n+ # RSS headlines instead of crawling the site directly.\n+ "endpoint": (\n+ "https://news.google.com/rss/search?"\n+ "q=site:avherald.com%20when:3d&hl=en-US&gl=US&ceid=US:en"\n+ ),\n+ "type": "rss",\n+ "cover": {\n+ "zh": "全球事故事件專業追報",\n+ "en": "Global incident tracking",\n+ },\n+ },\n+ "flightaware": {\n+ "name": "FlightAware",\n+ "kind": "data",\n+ "fmt": "API",\n+ "url": "https://flightaware.com",\n+ "endpoint": "https://aeroapi.flightaware.com/aeroapi",\n+ "type": "api", # optional; used only when AEROAPI_KEY is set\n+ "cover": {\n+ "zh": "航班追蹤與延誤數據",\n+ "en": "Flight tracking & delay data",\n+ },\n+ },\n+ "caataiwan": {\n+ "name": "CAA Taiwan",\n+ "kind": "official",\n+ "fmt": "RSS",\n+ "url": "https://www.caa.gov.tw",\n+ "endpoint": "https://www.caa.gov.tw/RssNewsContent.aspx?a=165&ngid=1&lang=1",\n+ "type": "rss",\n+ "cover": {\n+ "zh": "交通部民航局 — 航班異動與公告",\n+ "en": "Taiwan CAA notices",\n+ },\n+ },\n+}\n+\n+CATEGORIES = ("safety", "reg", "biz", "ops")\n+\n+\n+def now_utc() -> datetime:\n+ return datetime.now(timezone.utc)\n+\n+\n+def iso_minute(dt: datetime) -> str:\n+ """Format per spec: 2026-07-26T05:47Z (minute precision, Z suffix)."""\n+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")\n+\n+\n+def parse_iso(value: str) -> datetime:\n+ """Parse timestamps produced by iso_minute() or full ISO-8601 strings."""\n+ value = value.strip()\n+ if value.endswith("Z"):\n+ value = value[:-1] + "+00:00"\n+ dt = datetime.fromisoformat(value)\n+ if dt.tzinfo is None:\n+ dt = dt.replace(tzinfo=timezone.utc)\n+ return dt\n+\n+\n+def load_json(path: Path, default):\n+ try:\n+ with open(path, encoding="utf-8") as fh:\n+ return json.load(fh)\n+ except (FileNotFoundError, json.JSONDecodeError):\n+ return default\n+\n+\n+def save_json(path: Path, obj) -> None:\n+ path.parent.mkdir(parents=True, exist_ok=True)\n+ tmp = path.with_suffix(path.suffix + ".tmp")\n+ with open(tmp, "w", encoding="utf-8") as fh:\n+ json.dump(obj, fh, ensure_ascii=False, indent=2)\n+ fh.write("\n")\n+ tmp.replace(path)\n+\n+\n+_TRACKING_PARAMS = re.compile(r"^(utm_|fbclid|gclid|mc_cid|mc_eid|ref$|cmpid)")\n+\n+\n+def norm_url(url: str) -> str:\n+ """Normalize a URL for dedupe: lowercase host, drop tracking params,\n+ fragments and trailing slashes."""\n+ try:\n+ parts = urlsplit(url.strip())\n+ except ValueError:\n+ return url.strip().lower()\n+ query = [\n+ (k, v)\n+ for k, v in parse_qsl(parts.query, keep_blank_values=True)\n+ if not _TRACKING_PARAMS.match(k.lower())\n+ ]\n+ return urlunsplit(\n+ (\n+ parts.scheme.lower() or "https",\n+ parts.netloc.lower(),\n+ parts.path.rstrip("/"),\n+ urlencode(query),\n+ "",\n+ )\n+ )\n+\n+\n+def slugify(text: str, max_len: int = 40) -> str:\n+ text = unicodedata.normalize("NFKD", text)\n+ text = text.encode("ascii", "ignore").decode("ascii").lower()\n+ text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")\n+ return text[:max_len].rstrip("-") or "story"\n@@ -0,0 +1,218 @@\n+"""Dedupe stage: data/raw/*.json -> data/pending.json.\n+\n+Reads every raw source snapshot, drops items that are already known\n+(URL match or fuzzy title match against data/seen.json), stale (older\n+than 48 h) or untitled, then merges cross-source coverage of the same\n+story into ranked groups for write.py.\n+\n+data/seen.json is READ-ONLY here — write.py owns it (see CONTRACTS.md).\n+"""\n+from __future__ import annotations\n+\n+import re\n+from datetime import datetime, timedelta, timezone\n+from difflib import SequenceMatcher\n+\n+from common import (\n+ DATA_DIR,\n+ RAW_DIR,\n+ SOURCES,\n+ iso_minute,\n+ load_json,\n+ norm_url,\n+ now_utc,\n+ parse_iso,\n+ save_json,\n+)\n+\n+MAX_GROUPS = 12 # emitted per run; bounds LLM cost in write.py\n+MAX_ITEMS_PER_GROUP = 5\n+MAX_ITEM_AGE_HOURS = 48\n+SEEN_TITLE_DAYS = 21 # only seen.json titles this recent are compared\n+SEEN_TITLE_SIM = 0.75 # vs seen.json titles -> drop as already covered\n+GROUP_TITLE_SIM = 0.60 # between fresh items -> same story group\n+\n+_PUNCT_RE = re.compile(r"[^\w\s]|_")\n+_WS_RE = re.compile(r"\s+")\n+_EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)\n+_SOURCE_ORDER = {key: i for i, key in enumerate(SOURCES)}\n+\n+\n+def norm_title(title: str) -> str:\n+ """Normalize a headline for fuzzy matching: lowercase, punctuation\n+ stripped, whitespace collapsed, trailing " - <source>" dropped."""\n+ text = title.strip()\n+ head, sep, tail = text.rpartition(" - ")\n+ if sep and head.strip() and 0 < len(tail.strip()) <= 40:\n+ text = head\n+ text = _PUNCT_RE.sub(" ", text.lower())\n+ return _WS_RE.sub(" ", text).strip()\n+\n+\n+def _similar(a: str, b: str) -> float:\n+ return SequenceMatcher(None, a, b).ratio()\n+\n+\n+def _published(item: dict) -> datetime | None:\n+ try:\n+ return parse_iso(str(item["publishedUtc"]))\n+ except (KeyError, TypeError, ValueError):\n+ return None\n+\n+\n+def _load_raw_items() -> list[dict]:\n+ """All items from data/raw/<key>.json snapshots with ok=true, each\n+ annotated with the source display name and key."""\n+ items: list[dict] = []\n+ for key, source in SOURCES.items():\n+ snapshot = load_json(RAW_DIR / f"{key}.json", None)\n+ if not isinstance(snapshot, dict) or not snapshot.get("ok"):\n+ continue\n+ raw_items = snapshot.get("items")\n+ if not isinstance(raw_items, list):\n+ continue\n+ for raw in raw_items:\n+ if not isinstance(raw, dict):\n+ continue\n+ item = dict(raw)\n+ item["source"] = source["name"]\n+ item["sourceKey"] = key\n+ items.append(item)\n+ return items\n+\n+\n+def _load_seen(now: datetime) -> tuple[set[str], list[str]]:\n+ """Normalized URLs plus normalized titles (last SEEN_TITLE_DAYS only)\n+ from data/seen.json. Malformed entries are skipped."""\n+ seen = load_json(DATA_DIR / "seen.json", {})\n+ if not isinstance(seen, dict):\n+ seen = {}\n+ urls = {norm_url(str(url)) for url in (seen.get("urls") or {})}\n+ cutoff = now - timedelta(days=SEEN_TITLE_DAYS)\n+ titles: list[str] = []\n+ for entry in seen.get("titles") or []:\n+ try:\n+ title, first_seen = str(entry[0]), parse_iso(str(entry[1]))\n+ except (LookupError, TypeError, ValueError):\n+ continue\n+ normalized = norm_title(title)\n+ if normalized and first_seen >= cutoff:\n+ titles.append(normalized)\n+ return urls, titles\n+\n+\n+def _is_fresh(\n+ item: dict, now: datetime, seen_urls: set[str], seen_titles: list[str]\n+) -> bool:\n+ title = str(item.get("title") or "").strip()\n+ if not title:\n+ return False\n+ url = str(item.get("url") or "").strip()\n+ if url and norm_url(url) in seen_urls:\n+ return False\n+ published = _published(item)\n+ if published is not None and now - published > timedelta(\n+ hours=MAX_ITEM_AGE_HOURS\n+ ):\n+ return False\n+ normalized = norm_title(title)\n+ if normalized:\n+ for seen_title in seen_titles:\n+ if _similar(normalized, seen_title) >= SEEN_TITLE_SIM:\n+ return False\n+ return True\n+\n+\n+def _group(items: list[dict]) -> list[list[dict]]:\n+ """Union-find: same normalized URL or title similarity >= GROUP_TITLE_SIM\n+ puts two items in the same group."""\n+ parent = list(range(len(items)))\n+\n+ def find(i: int) -> int:\n+ while parent[i] != i:\n+ parent[i] = parent[parent[i]]\n+ i = parent[i]\n+ return i\n+\n+ def union(i: int, j: int) -> None:\n+ ri, rj = find(i), find(j)\n+ if ri != rj:\n+ parent[rj] = ri\n+\n+ urls = [norm_url(str(item.get("url") or "")) for item in items]\n+ titles = [norm_title(str(item.get("title") or "")) for item in items]\n+\n+ first_by_url: dict[str, int] = {}\n+ for i, url in enumerate(urls):\n+ if not url:\n+ continue\n+ if url in first_by_url:\n+ union(first_by_url[url], i)\n+ else:\n+ first_by_url[url] = i\n+\n+ for i in range(len(items)):\n+ if not titles[i]:\n+ continue\n+ for j in range(i + 1, len(items)):\n+ if titles[j] and _similar(titles[i], titles[j]) >= GROUP_TITLE_SIM:\n+ union(i, j)\n+\n+ grouped: dict[int, list[dict]] = {}\n+ for i, item in enumerate(items):\n+ grouped.setdefault(find(i), []).append(item)\n+ return list(grouped.values())\n+\n+\n+def _best_first(members: list[dict]) -> list[dict]:\n+ """Longest summary first; ties broken by SOURCES registry order."""\n+ return sorted(\n+ members,\n+ key=lambda item: (\n+ -len(str(item.get("summary") or "")),\n+ _SOURCE_ORDER.get(item.get("sourceKey"), len(_SOURCE_ORDER)),\n+ ),\n+ )\n+\n+\n+def main() -> None:\n+ now = now_utc()\n+ raw_items = _load_raw_items()\n+ seen_urls, seen_titles = _load_seen(now)\n+ fresh = [\n+ item\n+ for item in raw_items\n+ if _is_fresh(item, now, seen_urls, seen_titles)\n+ ]\n+\n+ ranked: list[tuple[bool, datetime, list[dict]]] = []\n+ for members in _group(fresh):\n+ members = _best_first(members)[:MAX_ITEMS_PER_GROUP]\n+ newest = max((_published(m) or _EPOCH for m in members), default=_EPOCH)\n+ multi = len({m["sourceKey"] for m in members}) > 1\n+ ranked.append((multi, newest, members))\n+ ranked.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)\n+\n+ stamp = now.strftime("%Y%m%d-%H%M")\n+ payload = {\n+ "generatedUtc": iso_minute(now),\n+ "groups": [\n+ {\n+ "id": f"g-{stamp}-{n}",\n+ "primarySource": members[0]["source"],\n+ "items": members,\n+ }\n+ for n, (_multi, _newest, members) in enumerate(\n+ ranked[:MAX_GROUPS], start=1\n+ )\n+ ],\n+ }\n+ save_json(DATA_DIR / "pending.json", payload)\n+ print(\n+ f"dedupe: {len(raw_items)} raw items -> {len(fresh)} fresh "\n+ f"-> {len(payload['groups'])} groups"\n+ )\n+\n+\n+if __name__ == "__main__":\n+ main()\n@@ -0,0 +1,534 @@\n+"""Fetch stage: download every source in common.SOURCES into data/raw/.\n+\n+For each registry entry this script performs a polite conditional HTTP\n+request (ETag / Last-Modified persisted in data/raw/_http_cache.json),\n+parses the response into normalized items and writes one snapshot per\n+source plus data/sources.json — see pipeline/CONTRACTS.md.\n+\n+A failing source never aborts the run: it is recorded as ok=false with an\n+error string and the loop continues. The script always exits 0.\n+"""\n+from __future__ import annotations\n+\n+import calendar\n+import os\n+import re\n+import time\n+from datetime import datetime, timezone\n+from email.utils import parsedate_to_datetime\n+from urllib import robotparser\n+from urllib.parse import urljoin, urlsplit\n+\n+import feedparser\n+import requests\n+from bs4 import BeautifulSoup\n+\n+from common import (\n+ DATA_DIR,\n+ MAX_ITEMS_PER_SOURCE,\n+ RAW_DIR,\n+ SOURCES,\n+ USER_AGENT,\n+ iso_minute,\n+ load_json,\n+ now_utc,\n+ parse_iso,\n+ save_json,\n+)\n+\n+TIMEOUT = 20 # seconds per request\n+RETRY_DELAY = 2 # seconds between the two attempts\n+RESOLVE_TIMEOUT = 6 # seconds for google-news link resolution\n+MAX_RESOLVE_FAILURES = 3 # stop resolving after this many misses in a row\n+SUMMARY_MAX = 500\n+GOOGLE_NEWS_HOST = "news.google.com"\n+CACHE_PATH = RAW_DIR / "_http_cache.json"\n+\n+_MONTHS = {\n+ name: num\n+ for num, name in enumerate(\n+ ("january", "february", "march", "april", "may", "june", "july",\n+ "august", "september", "october", "november", "december"),\n+ start=1,\n+ )\n+}\n+\n+# "July 23, 2026" (FAA) or "16 December 2025" (Eurocontrol <time> text).\n+_DATE_MDY = re.compile(r"^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$")\n+_DATE_DMY = re.compile(r"^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})$")\n+# CAA Taiwan RSS titles end with " - (2026-07-23)".\n+_CAA_TITLE_DATE = re.compile(r"\s*-\s*\((\d{4}-\d{2}-\d{2})\)\s*$")\n+\n+\n+class FetchError(Exception):\n+ """Per-source fetch/parse failure — recorded in the raw file, never fatal."""\n+\n+\n+# --------------------------------------------------------------------------\n+# small text/date helpers\n+\n+def _collapse(text: str | None) -> str:\n+ return re.sub(r"\s+", " ", text or "").strip()\n+\n+\n+def _strip_html(value: str | None) -> str:\n+ if not value:\n+ return ""\n+ return _collapse(BeautifulSoup(value, "lxml").get_text(" ", strip=True))\n+\n+\n+def _clip(text: str, limit: int = SUMMARY_MAX) -> str:\n+ if len(text) <= limit:\n+ return text\n+ return text[: limit - 1].rstrip() + "…"\n+\n+\n+def _date_from_text(text: str) -> datetime | None:\n+ text = _collapse(text)\n+ match = _DATE_MDY.match(text)\n+ if match:\n+ month_name, day, year = match.group(1), match.group(2), match.group(3)\n+ else:\n+ match = _DATE_DMY.match(text)\n+ if not match:\n+ return None\n+ day, month_name, year = match.group(1), match.group(2), match.group(3)\n+ month = _MONTHS.get(month_name.lower())\n+ if not month:\n+ return None\n+ try:\n+ return datetime(int(year), month, int(day), tzinfo=timezone.utc)\n+ except ValueError:\n+ return None\n+\n+\n+def _time_tag_datetime(tag) -> datetime | None:\n+ value = tag.get("datetime") or tag.get_text(strip=True)\n+ if not value:\n+ return None\n+ try:\n+ return parse_iso(value)\n+ except ValueError:\n+ return _date_from_text(value)\n+\n+\n+def _entry_datetime(entry) -> datetime | None:\n+ for key in ("published_parsed", "updated_parsed"):\n+ struct = entry.get(key)\n+ if struct:\n+ return datetime.fromtimestamp(calendar.timegm(struct), tz=timezone.utc)\n+ for key in ("published", "updated"):\n+ value = entry.get(key)\n+ if not value:\n+ continue\n+ try:\n+ parsed = parsedate_to_datetime(value)\n+ if parsed.tzinfo is None:\n+ parsed = parsed.replace(tzinfo=timezone.utc)\n+ return parsed\n+ except (TypeError, ValueError):\n+ pass\n+ try:\n+ return parse_iso(value)\n+ except ValueError:\n+ pass\n+ return None\n+\n+\n+def _entry_image(entry) -> str | None:\n+ for link in entry.get("links", []):\n+ if link.get("rel") == "enclosure" and str(link.get("type", "")).startswith("image/"):\n+ return link.get("href")\n+ for key in ("media_thumbnail", "media_content"):\n+ media = entry.get(key)\n+ if media and media[0].get("url"):\n+ return media[0]["url"]\n+ return None\n+\n+\n+# --------------------------------------------------------------------------\n+# HTTP\n+\n+def _request(session: requests.Session, url: str, headers: dict | None = None,\n+ timeout: int = TIMEOUT) -> requests.Response:\n+ """GET with a 20s timeout and one retry. Raises FetchError on failure."""\n+ last_error: FetchError | None = None\n+ for attempt in range(2):\n+ try:\n+ resp = session.get(url, headers=headers or {}, timeout=timeout)\n+ if resp.status_code == 304 or resp.ok:\n+ return resp\n+ last_error = FetchError(f"HTTP {resp.status_code} for {url}")\n+ except requests.RequestException as exc:\n+ last_error = FetchError(f"{type(exc).__name__}: {exc}")\n+ if attempt == 0:\n+ time.sleep(RETRY_DELAY)\n+ raise last_error # type: ignore[misc] # always set after both attempts\n+\n+\n+def _conditional_get(session: requests.Session, key: str, url: str,\n+ cache: dict) -> requests.Response:\n+ """GET with If-None-Match / If-Modified-Since from the validator cache."""\n+ entry = cache.get(key) or {}\n+ headers = {}\n+ if entry.get("url") == url: # ignore validators from an older endpoint\n+ if entry.get("etag"):\n+ headers["If-None-Match"] = entry["etag"]\n+ if entry.get("last_modified"):\n+ headers["If-Modified-Since"] = entry["last_modified"]\n+ resp = _request(session, url, headers)\n+ if resp.status_code == 304 and not (RAW_DIR / f"{key}.json").exists():\n+ # Validator cache survived but the snapshot did not: fetch fresh.\n+ resp = _request(session, url)\n+ if resp.status_code != 304:\n+ validators = {"url": url}\n+ if resp.headers.get("ETag"):\n+ validators["etag"] = resp.headers["ETag"]\n+ if resp.headers.get("Last-Modified"):\n+ validators["last_modified"] = resp.headers["Last-Modified"]\n+ if len(validators) > 1:\n+ cache[key] = validators\n+ else:\n+ cache.pop(key, None)\n+ return resp\n+\n+\n+_ROBOTS: dict[str, robotparser.RobotFileParser] = {}\n+\n+\n+def _robots_ok(session: requests.Session, url: str) -> bool:\n+ """Check robots.txt for HTML sources; fail open when it is unreachable."""\n+ parts = urlsplit(url)\n+ origin = f"{parts.scheme}://{parts.netloc}"\n+ parser = _ROBOTS.get(origin)\n+ if parser is None:\n+ parser = robotparser.RobotFileParser()\n+ try:\n+ resp = session.get(origin + "/robots.txt", timeout=10)\n+ if resp.status_code == 200:\n+ parser.parse(resp.text.splitlines())\n+ else:\n+ parser.allow_all = True\n+ except requests.RequestException:\n+ parser.allow_all = True\n+ _ROBOTS[origin] = parser\n+ return parser.can_fetch(USER_AGENT, url)\n+\n+\n+# --------------------------------------------------------------------------\n+# RSS sources\n+\n+def _clean_gnews_title(title: str, entry) -> str:\n+ """Strip Google News' ' - <publisher>' title suffix; '' for junk entries."""\n+ source_title = str((entry.get("source") or {}).get("title") or "")\n+ for suffix in (f" - {source_title}", " - Reuters"):\n+ if len(suffix) > 3 and title.endswith(suffix):\n+ title = title[: -len(suffix)].rstrip()\n+ break\n+ if title == source_title or title.lower().endswith(".aspx"):\n+ return "" # publisher homepage or bare docket/file link, no headline\n+ return title\n+\n+\n+def _resolve_gnews_link(session: requests.Session, link: str) -> str | None:\n+ """Follow one redirect off news.google.com; None when it stays on Google."""\n+ try:\n+ resp = session.get(link, timeout=RESOLVE_TIMEOUT, allow_redirects=False)\n+ except requests.RequestException:\n+ return None\n+ location = resp.headers.get("Location")\n+ if resp.status_code in (301, 302, 303, 307, 308) and location:\n+ location = urljoin(link, location)\n+ host = urlsplit(location).netloc.lower()\n+ if host and not (host == "google.com" or host.endswith(".google.com")):\n+ return location\n+ return None\n+\n+\n+def _rss_items(session: requests.Session, key: str, entries: list,\n+ endpoint: str) -> list[dict]:\n+ """Normalize feed entries to raw items (published as datetime or None)."""\n+ is_google = GOOGLE_NEWS_HOST in urlsplit(endpoint).netloc\n+ items: list[dict] = []\n+ resolve_failures = 0\n+ for entry in entries:\n+ title = _strip_html(entry.get("title", ""))\n+ link = urljoin(endpoint, (entry.get("link") or "").strip())\n+ if not title or not link:\n+ continue\n+ published = _entry_datetime(entry)\n+ if key == "caataiwan":\n+ match = _CAA_TITLE_DATE.search(title)\n+ if match:\n+ title = title[: match.start()].rstrip()\n+ if published is None:\n+ try:\n+ published = parse_iso(match.group(1) + "T00:00Z")\n+ except ValueError:\n+ pass\n+ if is_google:\n+ title = _clean_gnews_title(title, entry)\n+ if not title:\n+ continue\n+ if (GOOGLE_NEWS_HOST in urlsplit(link).netloc\n+ and resolve_failures < MAX_RESOLVE_FAILURES):\n+ resolved = _resolve_gnews_link(session, link)\n+ if resolved:\n+ link = resolved\n+ resolve_failures = 0\n+ else:\n+ resolve_failures += 1\n+ summary = _strip_html(entry.get("summary") or entry.get("description"))\n+ if is_google and len(summary) <= len(title) + 40:\n+ summary = "" # google news descriptions just repeat the headline\n+ items.append({\n+ "title": title,\n+ "url": link,\n+ "published": published,\n+ "summary": summary,\n+ "image": _entry_image(entry),\n+ })\n+ if len(items) >= MAX_ITEMS_PER_SOURCE:\n+ break\n+ return items\n+\n+\n+# --------------------------------------------------------------------------\n+# HTML sources — one small parser per site\n+\n+def _parse_faa(soup: BeautifulSoup, base: str) -> list[dict]:\n+ """FAA newsroom press releases: Drupal views rows with date + teaser."""\n+ items = []\n+ for row in soup.select("div.views-row"):\n+ anchor = row.select_one(".views-field-title a[href]") or row.find("a", href=True)\n+ if anchor is None:\n+ continue\n+ title = _collapse(anchor.get_text(" ", strip=True))\n+ if not title:\n+ continue\n+ date_el = row.select_one(".views-field-field-effective-date .field-content")\n+ body_el = row.select_one(".views-field-body .field-content")\n+ items.append({\n+ "title": title,\n+ "url": urljoin(base, anchor["href"]),\n+ "published": _date_from_text(date_el.get_text(strip=True)) if date_el else None,\n+ "summary": body_el.get_text(" ", strip=True) if body_el else "",\n+ "image": None,\n+ })\n+ return items\n+\n+\n+def _parse_icao(soup: BeautifulSoup, base: str) -> list[dict]:\n+ """ICAO /news listing: teaser links /news/<slug>; date/image when nearby."""\n+ items, seen = [], set()\n+ for anchor in soup.find_all("a", href=re.compile(r"^/news/[a-z0-9-]{8,}$")):\n+ url = urljoin(base, anchor["href"])\n+ title = _collapse(anchor.get_text(" ", strip=True))\n+ if url in seen or len(title) < 12:\n+ continue\n+ seen.add(url)\n+ published, image = _card_context(anchor, base)\n+ items.append({"title": title, "url": url, "published": published,\n+ "summary": "", "image": image})\n+ return items\n+\n+\n+def _card_context(anchor, base: str):\n+ """Walk up from a teaser link looking for the card's <time> and image."""\n+ node = anchor\n+ for _ in range(4):\n+ node = node.parent\n+ if node is None or node.name == "body":\n+ break\n+ time_tag = node.find("time")\n+ if time_tag is not None:\n+ image = node.find("img", src=True)\n+ return (_time_tag_datetime(time_tag),\n+ urljoin(base, image["src"]) if image else None)\n+ return None, None\n+\n+\n+def _parse_eurocontrol(soup: BeautifulSoup, base: str) -> list[dict]:\n+ """Eurocontrol /newsroom: node--news / node--publication teaser cards."""\n+ items, seen = [], set()\n+ generic = {"learn more", "read more", "more", "find out more"}\n+ for card in soup.find_all("div", class_=re.compile(r"\bnode--(news|publication|press)")):\n+ anchor = card.find("a", href=re.compile(r"^/(news|publication|press-release)/"))\n+ if anchor is None:\n+ continue\n+ url = urljoin(base, anchor["href"])\n+ heading = card.find(["h2", "h3", "h4"])\n+ title = _collapse(heading.get_text(" ", strip=True)) if heading else ""\n+ if not title:\n+ title = _collapse(anchor.get_text(" ", strip=True))\n+ if url in seen or len(title) < 8 or title.lower() in generic:\n+ continue\n+ seen.add(url)\n+ time_tag = card.find("time")\n+ image = card.find("img", src=True)\n+ items.append({\n+ "title": title,\n+ "url": url,\n+ "published": _time_tag_datetime(time_tag) if time_tag else None,\n+ "summary": "",\n+ "image": urljoin(base, image["src"]) if image else None,\n+ })\n+ return items\n+\n+\n+HTML_PARSERS = {\n+ "faa": _parse_faa,\n+ "icao": _parse_icao,\n+ "eurocontrol": _parse_eurocontrol,\n+}\n+\n+\n+# --------------------------------------------------------------------------\n+# FlightAware AeroAPI (optional)\n+\n+def _fetch_flightaware(session: requests.Session, spec: dict) -> dict:\n+ """One cheap AeroAPI call -> best-effort stats object; items stay empty."""\n+ api_key = os.environ.get("AEROAPI_KEY")\n+ if not api_key:\n+ raise FetchError("AEROAPI_KEY not set")\n+ url = spec["endpoint"].rstrip("/") + "/disruption_counts/origin"\n+ resp = _request(session, url, {"x-apikey": api_key})\n+ data = resp.json()\n+ stats: dict = {}\n+ for src_key, dest_key in (\n+ ("total_flights", "flightsTracked"),\n+ ("total_delays_worldwide", "delaysWorldwide"),\n+ ("total_cancellations_worldwide", "cancellationsWorldwide"),\n+ ):\n+ value = data.get(src_key)\n+ if isinstance(value, (int, float)):\n+ stats[dest_key] = value\n+ delays = stats.get("delaysWorldwide")\n+ tracked = stats.get("flightsTracked")\n+ if delays is not None and tracked:\n+ stats["delayRate"] = round(100.0 * delays / tracked, 1)\n+ return stats\n+\n+\n+# --------------------------------------------------------------------------\n+# main loop\n+\n+def _finalize(raw_items: list[dict], fetched_iso: str) -> list[dict]:\n+ items = []\n+ for item in raw_items[:MAX_ITEMS_PER_SOURCE]:\n+ published = item.get("published")\n+ items.append({\n+ "title": item["title"],\n+ "url": item["url"],\n+ "publishedUtc": iso_minute(published) if published else fetched_iso,\n+ "summary": _clip(_collapse(item.get("summary"))),\n+ "image": item.get("image"),\n+ })\n+ return items\n+\n+\n+def _fetch_source(session: requests.Session, key: str, spec: dict,\n+ cache: dict, raw_path, fetched_iso: str) -> bool:\n+ """Fetch one source and write its raw snapshot. Returns success."""\n+ source_type = spec.get("type")\n+ if source_type == "api":\n+ stats = _fetch_flightaware(session, spec)\n+ save_json(raw_path, {"source": key, "fetchedUtc": fetched_iso,\n+ "ok": True, "error": None, "items": [],\n+ "stats": stats})\n+ print(f"[fetch] {key}: ok, stats keys={sorted(stats)}")\n+ return True\n+ if source_type not in ("rss", "html"):\n+ raise FetchError(f"unknown source type: {source_type!r}")\n+\n+ endpoint = spec["endpoint"]\n+ if source_type == "html" and not _robots_ok(session, endpoint):\n+ raise FetchError("blocked by robots.txt")\n+\n+ resp = _conditional_get(session, key, endpoint, cache)\n+ if resp.status_code == 304:\n+ previous = load_json(raw_path, None)\n+ if not isinstance(previous, dict):\n+ raise FetchError("304 without a previous snapshot")\n+ previous["fetchedUtc"] = fetched_iso\n+ save_json(raw_path, previous)\n+ print(f"[fetch] {key}: not modified (304), kept "\n+ f"{len(previous.get('items') or [])} items")\n+ return bool(previous.get("ok"))\n+\n+ if source_type == "rss":\n+ feed = feedparser.parse(resp.content)\n+ if not feed.entries and feed.bozo:\n+ raise FetchError(f"feed parse failed: {getattr(feed, 'bozo_exception', 'bozo')}")\n+ raw_items = _rss_items(session, key, feed.entries, endpoint)\n+ else:\n+ parser = HTML_PARSERS.get(key)\n+ if parser is None:\n+ raise FetchError("no HTML parser registered for this source")\n+ raw_items = parser(BeautifulSoup(resp.content, "lxml"), resp.url or endpoint)\n+ if not raw_items:\n+ raise FetchError("page parsed to 0 items (layout changed?)")\n+\n+ items = _finalize(raw_items, fetched_iso)\n+ save_json(raw_path, {"source": key, "fetchedUtc": fetched_iso,\n+ "ok": True, "error": None, "items": items})\n+ print(f"[fetch] {key}: ok, {len(items)} items")\n+ return True\n+\n+\n+def _write_sources(last_success: dict[str, str | None]) -> None:\n+ """data/sources.json: registry order, lastFetchUtc persisted across runs."""\n+ sources_path = DATA_DIR / "sources.json"\n+ previous = {\n+ entry["key"]: entry\n+ for entry in (load_json(sources_path, []) or [])\n+ if isinstance(entry, dict) and entry.get("key")\n+ }\n+ output = []\n+ for key, spec in SOURCES.items():\n+ fetched = last_success.get(key)\n+ carried = (previous.get(key) or {}).get("lastFetchUtc")\n+ output.append({\n+ "key": key,\n+ "name": spec["name"],\n+ "kind": spec["kind"],\n+ "fmt": spec["fmt"],\n+ "url": spec["url"],\n+ "cover": spec["cover"],\n+ "lastFetchUtc": fetched or carried,\n+ "ok": bool(fetched),\n+ })\n+ save_json(sources_path, output)\n+ ok_count = sum(1 for entry in output if entry["ok"])\n+ print(f"[fetch] sources.json written: {ok_count}/{len(output)} sources ok")\n+\n+\n+def main() -> None:\n+ RAW_DIR.mkdir(parents=True, exist_ok=True)\n+ cache = load_json(CACHE_PATH, {})\n+ if not isinstance(cache, dict):\n+ cache = {}\n+ session = requests.Session()\n+ session.headers["User-Agent"] = USER_AGENT\n+\n+ last_success: dict[str, str | None] = {}\n+ for key, spec in SOURCES.items():\n+ fetched_iso = iso_minute(now_utc())\n+ raw_path = RAW_DIR / f"{key}.json"\n+ try:\n+ success = _fetch_source(session, key, spec, cache, raw_path, fetched_iso)\n+ except Exception as exc: # noqa: BLE001 — one source must never kill the run\n+ error = _collapse(str(exc))[:300] or type(exc).__name__\n+ save_json(raw_path, {"source": key, "fetchedUtc": fetched_iso,\n+ "ok": False, "error": error, "items": []})\n+ cache.pop(key, None) # do not 304 our way past a failure next run\n+ print(f"[fetch] {key}: FAILED - {error}")\n+ success = False\n+ last_success[key] = fetched_iso if success else None\n+\n+ save_json(CACHE_PATH, cache)\n+ _write_sources(last_success)\n+\n+\n+if __name__ == "__main__":\n+ main()\n@@ -0,0 +1,419 @@\n+"""AVWIRE write stage: data/pending.json -> bilingual articles via the Anthropic API.\n+\n+For each pending story group (capped at MAX_GROUPS_PER_RUN per run) one\n+structured-output Messages API call drafts the article, flash and optional\n+incident row. Outputs are appended/prepended to data/articles/<hour>.json,\n+data/flashes.json and data/incidents.json; data/seen.json is updated only for\n+groups that were actually published, so failed groups stay "unseen" and are\n+re-emitted by dedupe.py next run. data/stats.json is always rewritten.\n+\n+Without ANTHROPIC_API_KEY the stage prints a notice, leaves pending.json\n+untouched and still refreshes stats.json. Exit code 0 in all data-driven\n+failure modes; non-zero exits are reserved for programming errors.\n+"""\n+from __future__ import annotations\n+\n+import json\n+import os\n+import re\n+from datetime import timedelta\n+\n+from common import (\n+ ARTICLES_DIR,\n+ DATA_DIR,\n+ MAX_FLASHES,\n+ RAW_DIR,\n+ iso_minute,\n+ load_json,\n+ norm_url,\n+ now_utc,\n+ parse_iso,\n+ save_json,\n+ slugify,\n+)\n+\n+try:\n+ import anthropic\n+except ImportError: # pragma: no cover - anthropic is in requirements.txt\n+ anthropic = None\n+\n+MODEL = os.environ.get("AVWIRE_MODEL", "claude-opus-5")\n+MAX_GROUPS_PER_RUN = 10\n+MAX_INCIDENTS = 60\n+SEEN_MAX_AGE_DAYS = 21\n+SERIOUS_WINDOW_DAYS = 7\n+SOURCE_TITLE_MAX = 60\n+\n+PENDING_PATH = DATA_DIR / "pending.json"\n+SEEN_PATH = DATA_DIR / "seen.json"\n+FLASHES_PATH = DATA_DIR / "flashes.json"\n+INCIDENTS_PATH = DATA_DIR / "incidents.json"\n+STATS_PATH = DATA_DIR / "stats.json"\n+FLIGHTAWARE_RAW_PATH = RAW_DIR / "flightaware.json"\n+\n+_BILINGUAL_SCHEMA = {\n+ "type": "object",\n+ "properties": {"zh": {"type": "string"}, "en": {"type": "string"}},\n+ "required": ["zh", "en"],\n+ "additionalProperties": False,\n+}\n+\n+_LANG_SCHEMA = {\n+ "type": "object",\n+ "properties": {\n+ "title": {"type": "string"},\n+ "summary": {"type": "string"},\n+ "body": {"type": "array", "items": {"type": "string"}},\n+ },\n+ "required": ["title", "summary", "body"],\n+ "additionalProperties": False,\n+}\n+\n+_INCIDENT_SCHEMA = {\n+ "type": "object",\n+ "properties": {\n+ "date": {"type": "string"},\n+ "sev": {"type": "string", "enum": ["acc", "ser", "inc"]},\n+ "aircraft": {"type": "string"},\n+ "operator": {"type": "string"},\n+ "phase": _BILINGUAL_SCHEMA,\n+ "location": _BILINGUAL_SCHEMA,\n+ "desc": _BILINGUAL_SCHEMA,\n+ "status": {"type": "string", "enum": ["open", "closed", "prelim"]},\n+ },\n+ "required": [\n+ "date",\n+ "sev",\n+ "aircraft",\n+ "operator",\n+ "phase",\n+ "location",\n+ "desc",\n+ "status",\n+ ],\n+ "additionalProperties": False,\n+}\n+\n+DRAFT_SCHEMA = {\n+ "type": "object",\n+ "properties": {\n+ "cat": {"type": "string", "enum": ["safety", "reg", "biz", "ops"]},\n+ "zh": _LANG_SCHEMA,\n+ "en": _LANG_SCHEMA,\n+ "flash": {\n+ "type": "object",\n+ "properties": {\n+ "zh": {"type": "string"},\n+ "en": {"type": "string"},\n+ "hot": {"type": "boolean"},\n+ },\n+ "required": ["zh", "en", "hot"],\n+ "additionalProperties": False,\n+ },\n+ "incident": {"anyOf": [_INCIDENT_SCHEMA, {"type": "null"}]},\n+ },\n+ "required": ["cat", "zh", "en", "flash", "incident"],\n+ "additionalProperties": False,\n+}\n+\n+SYSTEM_PROMPT = """\\n+You are the automated news desk writer for AVWIRE, an aviation news wire.\n+You receive one story group (one event, possibly covered by several sources)\n+and produce a bilingual wire story as JSON.\n+\n+STRICT RULES:\n+- Use ONLY facts present in the provided source items. Never invent numbers,\n+ names, aircraft types, registrations, casualty figures or causes. If the\n+ sources do not state something, omit it.\n+- Write two versions: Traditional Chinese with Taiwan usage (zh) and\n+ English (en).\n+- summary: at most 80 characters for zh, at most 200 characters for en.\n+- body: 2 to 4 paragraphs in wire-service register. Plain text only,\n+ no markdown, no headlines inside the body.\n+- cat: classify the story as exactly one of:\n+ safety (事故/飛安), reg (法規/監理), biz (商業/財務), ops (營運/航網).\n+- flash: a one-line news flash in both languages; the zh flash is at most\n+ 40 characters. Set hot=true ONLY for breaking safety events.\n+- incident: fill this object if and only if the story describes an actual\n+ aviation occurrence (accident, serious incident or incident in the sense of\n+ ICAO Annex 13). Otherwise incident must be null.\n+ - date: occurrence date as YYYY-MM-DD, best supported by the sources\n+ - sev: acc (accident), ser (serious incident) or inc (incident),\n+ per ICAO Annex 13 definitions\n+ - aircraft: aircraft type as stated in the sources\n+ - operator: operator name as stated in the sources\n+ - phase, location, desc: bilingual objects with zh and en strings\n+ - status: open, closed or prelim (preliminary report published)\n+"""\n+\n+\n+def _norm_title(title: str) -> str:\n+ """Normalize a title for dedupe memory: lowercase alphanumeric words."""\n+ text = re.sub(r"[^0-9a-zA-Z一-鿿]+", " ", title.lower())\n+ return " ".join(text.split())\n+\n+\n+def group_prompt(group: dict) -> str:\n+ """Render a pending group as the compact user message for the model."""\n+ lines = [\n+ f"Story group {group.get('id', '?')} | primary source: "\n+ f"{group.get('primarySource', '?')}",\n+ "Source items:",\n+ ]\n+ for idx, item in enumerate(group.get("items", []), 1):\n+ lines.append(f"{idx}. [{item.get('source', '?')}] {item.get('title', '')}")\n+ lines.append(f" published: {item.get('publishedUtc', '?')}")\n+ summary = str(item.get("summary") or "").strip()\n+ if summary:\n+ lines.append(f" summary: {summary}")\n+ lines.append(f" url: {item.get('url', '')}")\n+ lines.append("")\n+ lines.append("Write the bilingual wire story now, following the rules strictly.")\n+ return "\n".join(lines)\n+\n+\n+def draft_group(client, group: dict):\n+ """One Messages API call for one group.\n+\n+ Returns the parsed draft dict, or None when the model refused or the\n+ response was truncated. API/network errors propagate to the caller.\n+ """\n+ response = client.messages.create(\n+ model=MODEL,\n+ max_tokens=16000,\n+ system=SYSTEM_PROMPT,\n+ messages=[{"role": "user", "content": group_prompt(group)}],\n+ output_config={"format": {"type": "json_schema", "schema": DRAFT_SCHEMA}},\n+ )\n+ if response.stop_reason == "refusal":\n+ print(f"write: model refused group {group.get('id')}; skipping")\n+ return None\n+ if response.stop_reason == "max_tokens":\n+ print(f"write: response truncated for group {group.get('id')}; skipping")\n+ return None\n+ text = next(\n+ (block.text for block in response.content\n+ if getattr(block, "type", None) == "text"),\n+ None,\n+ )\n+ if text is None:\n+ print(f"write: no text block for group {group.get('id')}; skipping")\n+ return None\n+ return json.loads(text)\n+\n+\n+def build_sources(items: list) -> list:\n+ """Build the article's sources array in code (never from the LLM)."""\n+ sources, seen_urls = [], set()\n+ for item in items:\n+ url = str(item.get("url") or "").strip()\n+ if not url:\n+ continue\n+ key = norm_url(url)\n+ if key in seen_urls:\n+ continue\n+ seen_urls.add(key)\n+ title = str(item.get("title") or "").strip()\n+ if len(title) > SOURCE_TITLE_MAX:\n+ title = title[: SOURCE_TITLE_MAX - 1].rstrip() + "…"\n+ name = str(item.get("source") or "?")\n+ if title:\n+ name = f"{name} — {title}"\n+ sources.append({"name": name, "url": url})\n+ return sources\n+\n+\n+def build_article(draft: dict, group: dict, now, used_ids: set):\n+ """Assemble one article dict, or None when it cannot credit any source."""\n+ items = group.get("items", [])\n+ sources = build_sources(items)\n+ if not sources:\n+ print(f"write: group {group.get('id')} has no usable sources; skipping")\n+ return None\n+ slug = slugify(str(draft.get("en", {}).get("title") or ""))\n+ base_id = f"a-{now.strftime('%Y%m%d-%H%M')}-{slug}"\n+ article_id, n = base_id, 2\n+ while article_id in used_ids:\n+ article_id = f"{base_id}-{n}"\n+ n += 1\n+ used_ids.add(article_id)\n+ image = next((item.get("image") for item in items if item.get("image")), None)\n+ return {\n+ "id": article_id,\n+ "publishedUtc": iso_minute(now),\n+ "cat": draft.get("cat", "ops"),\n+ "primarySource": group.get("primarySource", ""),\n+ "image": image,\n+ "zh": draft.get("zh", {}),\n+ "en": draft.get("en", {}),\n+ "sources": sources,\n+ }\n+\n+\n+def build_flash(draft: dict, article_id: str, now) -> dict:\n+ flash = draft.get("flash") or {}\n+ return {\n+ "timeUtc": iso_minute(now),\n+ "hot": bool(flash.get("hot")),\n+ "zh": str(flash.get("zh") or ""),\n+ "en": str(flash.get("en") or ""),\n+ "articleId": article_id,\n+ }\n+\n+\n+def build_incident(draft: dict, group: dict, article_id: str):\n+ incident = draft.get("incident")\n+ if not isinstance(incident, dict):\n+ return None\n+ names = []\n+ for item in group.get("items", []):\n+ name = item.get("source")\n+ if name and name not in names:\n+ names.append(name)\n+ row = dict(incident)\n+ row["sources"] = names\n+ row["articleId"] = article_id\n+ return row\n+\n+\n+def _is_fresh(stamp, cutoff) -> bool:\n+ try:\n+ return parse_iso(str(stamp)) >= cutoff\n+ except (ValueError, TypeError):\n+ return False\n+\n+\n+def update_seen(published_groups: list, now) -> None:\n+ """Record urls + normalized titles of published groups; prune old entries."""\n+ seen = load_json(SEEN_PATH, {})\n+ if not isinstance(seen, dict):\n+ seen = {}\n+ urls = seen.get("urls") if isinstance(seen.get("urls"), dict) else {}\n+ titles = seen.get("titles") if isinstance(seen.get("titles"), list) else []\n+ stamp = iso_minute(now)\n+ known_titles = {row[0] for row in titles if isinstance(row, list) and row}\n+ for group in published_groups:\n+ for item in group.get("items", []):\n+ url = str(item.get("url") or "").strip()\n+ if url:\n+ urls.setdefault(norm_url(url), stamp)\n+ title = _norm_title(str(item.get("title") or ""))\n+ if title and title not in known_titles:\n+ known_titles.add(title)\n+ titles.append([title, stamp])\n+ cutoff = now - timedelta(days=SEEN_MAX_AGE_DAYS)\n+ urls = {u: d for u, d in urls.items() if _is_fresh(d, cutoff)}\n+ titles = [\n+ row for row in titles\n+ if isinstance(row, list) and len(row) == 2 and _is_fresh(row[1], cutoff)\n+ ]\n+ save_json(SEEN_PATH, {"urls": urls, "titles": titles})\n+\n+\n+def refresh_stats(now) -> None:\n+ """Rewrite stats.json: updatedUtc, seriousThisWeek, optional FlightAware."""\n+ stats = {"updatedUtc": iso_minute(now)}\n+ incidents = load_json(INCIDENTS_PATH, [])\n+ if not isinstance(incidents, list):\n+ incidents = []\n+ cutoff = now - timedelta(days=SERIOUS_WINDOW_DAYS)\n+ serious = 0\n+ for row in incidents:\n+ if isinstance(row, dict) and row.get("sev") in ("acc", "ser") \\n+ and _is_fresh(row.get("date"), cutoff):\n+ serious += 1\n+ stats["seriousThisWeek"] = serious\n+ raw = load_json(FLIGHTAWARE_RAW_PATH, {})\n+ fa_stats = raw.get("stats") if isinstance(raw, dict) else None\n+ if isinstance(fa_stats, dict):\n+ for key in ("flightsTracked", "delayRate", "notams"):\n+ value = fa_stats.get(key)\n+ if isinstance(value, (int, float)) and not isinstance(value, bool):\n+ stats[key] = value\n+ save_json(STATS_PATH, stats)\n+\n+\n+def _write_articles_batch(articles: list, now) -> None:\n+ path = ARTICLES_DIR / (now.strftime("%Y-%m-%dT%H") + ".json")\n+ existing = load_json(path, {})\n+ merged = existing.get("articles") if isinstance(existing, dict) else None\n+ if not isinstance(merged, list):\n+ merged = []\n+ save_json(path, {"articles": merged + articles})\n+\n+\n+def _prepend_capped(path, new_rows: list, cap: int) -> None:\n+ old = load_json(path, [])\n+ if not isinstance(old, list):\n+ old = []\n+ save_json(path, (new_rows + old)[:cap])\n+\n+\n+def main() -> None:\n+ now = now_utc()\n+ pending = load_json(PENDING_PATH, {})\n+ if not isinstance(pending, dict):\n+ pending = {}\n+ groups = pending.get("groups")\n+ if not isinstance(groups, list):\n+ groups = []\n+\n+ if not os.environ.get("ANTHROPIC_API_KEY") or anthropic is None:\n+ print("write: ANTHROPIC_API_KEY not set; skipping article generation")\n+ refresh_stats(now)\n+ return\n+\n+ if not groups:\n+ print("write: no pending groups")\n+ refresh_stats(now)\n+ return\n+\n+ client = anthropic.Anthropic()\n+ used_ids: set = set()\n+ new_articles, new_flashes, new_incidents = [], [], []\n+ published_groups, published_ids = [], set()\n+ skipped = 0\n+\n+ for group in groups[:MAX_GROUPS_PER_RUN]:\n+ try:\n+ draft = draft_group(client, group)\n+ except (anthropic.APIStatusError, anthropic.APIConnectionError) as exc:\n+ print(f"write: API error on group {group.get('id')}: "\n+ f"{type(exc).__name__}; skipping")\n+ skipped += 1\n+ continue\n+ except Exception as exc: # never let one bad group kill the run\n+ print(f"write: unexpected error on group {group.get('id')}: "\n+ f"{type(exc).__name__}: {exc}; skipping")\n+ skipped += 1\n+ continue\n+ if not isinstance(draft, dict):\n+ skipped += 1\n+ continue\n+ article = build_article(draft, group, now, used_ids)\n+ if article is None:\n+ skipped += 1\n+ continue\n+ new_articles.append(article)\n+ new_flashes.append(build_flash(draft, article["id"], now))\n+ incident = build_incident(draft, group, article["id"])\n+ if incident is not None:\n+ new_incidents.append(incident)\n+ published_groups.append(group)\n+ published_ids.add(group.get("id"))\n+\n+ if new_articles:\n+ _write_articles_batch(new_articles, now)\n+ _prepend_capped(FLASHES_PATH, new_flashes, MAX_FLASHES)\n+ _prepend_capped(INCIDENTS_PATH, new_incidents, MAX_INCIDENTS)\n+ update_seen(published_groups, now)\n+ remaining = [g for g in groups if g.get("id") not in published_ids]\n+ save_json(PENDING_PATH, {**pending, "groups": remaining})\n+\n+ refresh_stats(now)\n+ print(f"write: published {len(new_articles)} article(s), "\n+ f"skipped {skipped}, pending {max(len(groups) - len(new_articles), 0)}")\n+\n+\n+if __name__ == "__main__":\n+ main()\n@@ -0,0 +1,6 @@\n+anthropic>=0.90\n+feedparser>=6.0\n+requests>=2.32\n+beautifulsoup4>=4.12\n+lxml>=5.0\n+jinja2>=3.1\n@@ -0,0 +1,66 @@\n+/* AVWIRE client behavior: clocks, theme toggle, client-side filters.\n+ Language switching is pure links — nothing to do here. */\n+(function () {\n+ "use strict";\n+\n+ /* — UTC + TPE clocks, updated every 10 s (per the design) — */\n+ var utcEl = document.getElementById("clock-utc");\n+ var tpeEl = document.getElementById("clock-tpe");\n+ function tick() {\n+ var now = new Date();\n+ if (utcEl) utcEl.textContent = "UTC " + now.toISOString().slice(11, 16);\n+ if (tpeEl) {\n+ tpeEl.textContent = "TPE " + now.toLocaleTimeString("en-GB", {\n+ timeZone: "Asia/Taipei", hour: "2-digit", minute: "2-digit"\n+ });\n+ }\n+ }\n+ tick();\n+ setInterval(tick, 10000);\n+\n+ /* — theme seg: data-theme on <html> + localStorage persistence — */\n+ var themeBtns = Array.prototype.slice.call(document.querySelectorAll("[data-set-theme]"));\n+ function reflectTheme() {\n+ var cur = document.documentElement.dataset.theme === "dark" ? "dark" : "light";\n+ themeBtns.forEach(function (b) {\n+ b.classList.toggle("active", b.dataset.setTheme === cur);\n+ });\n+ }\n+ themeBtns.forEach(function (b) {\n+ b.addEventListener("click", function () {\n+ document.documentElement.dataset.theme = b.dataset.setTheme;\n+ try { localStorage.setItem("avwire-theme", b.dataset.setTheme); } catch (e) { /* private mode */ }\n+ reflectTheme();\n+ });\n+ });\n+ reflectTheme();\n+\n+ /* — home: category filter over feed rows ([data-cat]) — */\n+ var catBtns = Array.prototype.slice.call(document.querySelectorAll("[data-filter-cat]"));\n+ catBtns.forEach(function (b) {\n+ b.addEventListener("click", function () {\n+ var v = b.dataset.filterCat;\n+ catBtns.forEach(function (x) { x.classList.toggle("active", x === b); });\n+ Array.prototype.forEach.call(document.querySelectorAll("[data-cat]"), function (row) {\n+ row.classList.toggle("is-hidden", v !== "all" && row.dataset.cat !== v);\n+ });\n+ });\n+ });\n+\n+ /* — incidents: severity filter over rows ([data-sev]) + live record count — */\n+ var sevBtns = Array.prototype.slice.call(document.querySelectorAll("[data-filter-sev]"));\n+ var countEl = document.getElementById("inc-count");\n+ sevBtns.forEach(function (b) {\n+ b.addEventListener("click", function () {\n+ var v = b.dataset.filterSev;\n+ var n = 0;\n+ sevBtns.forEach(function (x) { x.classList.toggle("active", x === b); });\n+ Array.prototype.forEach.call(document.querySelectorAll("[data-sev]"), function (row) {\n+ var hide = v !== "all" && row.dataset.sev !== v;\n+ row.classList.toggle("is-hidden", hide);\n+ if (!hide) n += 1;\n+ });\n+ if (countEl) countEl.textContent = String(n);\n+ });\n+ });\n+})();\n@@ -0,0 +1,252 @@\n+/* Modernist — design-system tokens and component classes. This file is the source of truth for the system's look; retune it here and see readme.md. */\n+@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&display=swap');\n+\n+:root {\n+ --color-bg: #f3f2f2;\n+ --color-surface: #eae9e9;\n+ --color-text: #201e1d;\n+ --color-accent: #ec3013;\n+ --color-accent-2: #e15b47;\n+ --color-divider: color-mix(in srgb, #201e1d 40%, transparent);\n+\n+ /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the\n+ same step of any role matches the others in visual value. */\n+ --color-neutral-100: #f8f4f4;\n+ --color-neutral-200: #eae7e7;\n+ --color-neutral-300: #d7d3d3;\n+ --color-neutral-400: #bab6b6;\n+ --color-neutral-500: #9b9797;\n+ --color-neutral-600: #7d7979;\n+ --color-neutral-700: #605d5d;\n+ --color-neutral-800: #444141;\n+ --color-neutral-900: #2d2b2b;\n+\n+ --color-accent-100: #fff2ef;\n+ --color-accent-200: #ffe0d9;\n+ --color-accent-300: #ffc4b8;\n+ --color-accent-400: #ff9783;\n+ --color-accent-500: #ff563c;\n+ --color-accent-600: #dd2b0f;\n+ --color-accent-700: #ae1800;\n+ --color-accent-800: #7c1405;\n+ --color-accent-900: #4d170e;\n+\n+ --color-accent-2-100: #fff2ef;\n+ --color-accent-2-200: #ffe0da;\n+ --color-accent-2-300: #ffc4b9;\n+ --color-accent-2-400: #ff9784;\n+ --color-accent-2-500: #ef6853;\n+ --color-accent-2-600: #c94b39;\n+ --color-accent-2-700: #9e3526;\n+ --color-accent-2-800: #71261b;\n+ --color-accent-2-900: #471d16;\n+\n+ --font-heading: "Archivo", system-ui, sans-serif;\n+ --font-heading-weight: 800;\n+ --font-body: "Archivo", system-ui, sans-serif;\n+\n+ --space-1: 4.0px;\n+ --space-2: 8.0px;\n+ --space-3: 12.0px;\n+ --space-4: 16.0px;\n+ --space-6: 24.0px;\n+ --space-8: 32.0px;\n+\n+ --radius-sm: 0px;\n+ --radius-md: 0px;\n+ --radius-lg: 0px;\n+\n+ /* Elevation — derived from the ground: soft ink-tinted shadows on a\n+ light theme, a hairline edge + ambient darkness on a dark one. */\n+ --shadow-sm: 0 1px 2px color-mix(in srgb, #2d2b2b 14%, transparent);\n+ --shadow-md: 0 3px 10px color-mix(in srgb, #2d2b2b 16%, transparent);\n+ --shadow-lg: 0 12px 32px color-mix(in srgb, #2d2b2b 22%, transparent);\n+}\n+\n+body {\n+ background: var(--color-bg);\n+ color: var(--color-text);\n+ font-family: var(--font-body);\n+}\n+h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }\n+\n+.grayscale{filter:grayscale(1) contrast(1.08)}\n+\n+/* ══════════════════════════════════════════════════════════════════════════\n+ Components — built with the tokens above. Plain CSS\n+ on plain HTML: no JavaScript, no build step. Each class is documented in\n+ readme.md and demonstrated in foundations/ and components/.\n+ ══════════════════════════════════════════════════════════════════════ */\n+\n+*, *::before, *::after { box-sizing: border-box; }\n+body { margin: 0; font-size: 15px; line-height: 1.55; font-weight: 400; }\n+h1, h2, h3, h4, h5, h6 {\n+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);\n+ line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);\n+}\n+h1 { font-size: 42px; }\n+h2 { font-size: 32px; }\n+h3 { font-size: 25px; }\n+h4 { font-size: 20px; }\n+h5 { font-size: 16px; }\n+h6 { font-size: 13px; }\n+h6 { letter-spacing: 0.08em; text-transform: uppercase; }\n+p { margin: 0 0 var(--space-3); }\n+a { color: var(--color-accent); text-underline-offset: 3px; }\n+img { display: block; max-width: 100%; }\n+figure { margin: 0; }\n+figcaption {\n+ font-size: 11px; margin-top: var(--space-1);\n+ color: color-mix(in srgb, var(--color-text) 55%, transparent);\n+}\n+.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }\n+:focus { outline: none; }\n+:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }\n+::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }\n+\n+/* — rules — */\n+.hr {\n+ height: 2px; border: 0; margin: var(--space-4) 0;\n+ background: var(--color-divider);\n+}\n+\n+/* — buttons — */\n+.btn {\n+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;\n+ cursor: pointer; text-decoration: none;\n+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);\n+ font-size: 14px; line-height: 1.2; color: var(--color-text); /* matches the .input's 14px —\n+ the pair sits side by side in sign-up rows */\n+ background: transparent; border: 1px solid transparent;\n+ padding: var(--space-2) calc(var(--space-3) * 1.2);\n+ border-radius: var(--radius-md);\n+}\n+.btn svg { display: block; }\n+.btn:disabled { opacity: 0.45; cursor: not-allowed; }\n+.btn-primary { background: var(--color-accent); color: var(--color-bg); }\n+.btn-primary:hover { background: var(--color-accent-600); }\n+.btn-primary:active { background: var(--color-accent-700); }\n+.btn-secondary { border-color: var(--color-divider); }\n+.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }\n+.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }\n+.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }\n+.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }\n+.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }\n+.btn-icon { width: 36px; height: 36px; padding: 0; }\n+.btn-block { width: 100%; margin-top: var(--space-2); justify-content: flex-start; text-align: left; }\n+\n+/* — forms — */\n+.field > label {\n+ display: block; font-size: 12px; margin-bottom: 5px;\n+ color: color-mix(in srgb, var(--color-text) 70%, transparent);\n+}\n+.input {\n+ width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;\n+ font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);\n+ background: var(--color-surface);\n+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);\n+}\n+.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }\n+.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }\n+textarea.input { min-height: 90px; resize: vertical; }\n+.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }\n+.radio input, .seg-opt input {\n+ position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;\n+}\n+.radio .dot {\n+ width: 16px; height: 16px; flex: none; border-radius: 50%;\n+ border: 1.5px solid var(--color-divider);\n+}\n+.radio:hover .dot { border-color: var(--color-accent); }\n+.radio input:checked + .dot {\n+ border-color: var(--color-accent); background: var(--color-accent);\n+ box-shadow: inset 0 0 0 4px var(--color-bg);\n+}\n+.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }\n+.seg {\n+ display: inline-flex; overflow: hidden;\n+ border: 1px solid var(--color-divider); border-radius: var(--radius-md);\n+}\n+.seg-opt {\n+ display: inline-flex; align-items: center; gap: 6px;\n+ padding: 7px 12px; font-size: 13px; cursor: pointer;\n+}\n+.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }\n+.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }\n+.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }\n+.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }\n+\n+/* — cards — */\n+.card {\n+ display: flex; flex-direction: column; gap: var(--space-2);\n+ padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);\n+}\n+.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }\n+.card-title {\n+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);\n+ font-size: 17px; line-height: 1.2;\n+}\n+.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }\n+.card-meta {\n+ display: flex; align-items: center; gap: 6px; font-size: 11px;\n+ color: color-mix(in srgb, var(--color-text) 50%, transparent);\n+}\n+.elev-sm { box-shadow: var(--shadow-sm); }\n+.elev-md { box-shadow: var(--shadow-md); }\n+.elev-lg { box-shadow: var(--shadow-lg); }\n+\n+/* — tags — */\n+.tag {\n+ display: inline-flex; align-items: center; font-size: 11px;\n+ letter-spacing: 0.02em; padding: 3px 10px;\n+ border-radius: calc(var(--radius-md) * 0.75);\n+}\n+.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }\n+.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }\n+.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }\n+.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }\n+\n+/* — navigation — */\n+.nav {\n+ display: flex; align-items: center; gap: var(--space-4);\n+ padding: var(--space-3) var(--space-4);\n+ border-bottom: 2px solid var(--color-divider);\n+}\n+.nav-brand {\n+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);\n+ font-size: 18px; margin-right: auto;\n+}\n+.nav a { color: inherit; text-decoration: none; font-size: 14px; }\n+.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }\n+\n+/* — tables — */\n+.table { width: 100%; border-collapse: collapse; font-size: 14px; }\n+.table th {\n+ text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;\n+ color: color-mix(in srgb, var(--color-text) 60%, transparent);\n+ padding: var(--space-2); border-bottom: 2px solid var(--color-divider);\n+}\n+.table td {\n+ padding: var(--space-2);\n+ border-bottom: 1px solid var(--color-divider);\n+}\n+.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }\n+\n+/* — dialog — */\n+.dialog-backdrop {\n+ position: fixed; inset: 0; display: grid; place-items: center;\n+ padding: var(--space-4);\n+ background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);\n+}\n+.dialog {\n+ width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);\n+ padding: var(--space-4); border-radius: var(--radius-lg);\n+ background: var(--color-surface); box-shadow: var(--shadow-lg);\n+}\n+.dialog-title {\n+ font-family: var(--font-heading); font-weight: var(--font-heading-weight);\n+ font-size: 20px;\n+}\n+.dialog-body { font-size: 14px; opacity: 0.85; }\n+.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }\n+\n@@ -0,0 +1,116 @@\n+/* AVWIRE site styles — everything the design layers on top of modernist.css,\n+ plus classes replacing the design's inline styles where JS needs hooks. */\n+\n+/* — from the design's helmet <style> block — */\n+html, body { margin: 0; background: var(--color-bg); }\n+body, h1, h2, h3, h4, h5, h6, .btn, .card-title, .nav-brand {\n+ font-family: "Archivo", "Noto Sans TC", system-ui, sans-serif;\n+}\n+a:hover { color: var(--color-accent-600); }\n+\n+[data-theme="dark"] {\n+ --color-bg: #171514;\n+ --color-surface: #221f1e;\n+ --color-text: #f3f2f2;\n+ --color-divider: color-mix(in srgb, #f3f2f2 35%, transparent);\n+ --color-neutral-100: #2d2b2b;\n+ --color-neutral-200: #353232;\n+ --color-neutral-300: #444141;\n+ --color-neutral-800: #eae7e7;\n+ --color-neutral-900: #f8f4f4;\n+ --color-accent-100: #43150c;\n+ --color-accent-200: #5c190d;\n+ --color-accent-800: #ffc4b8;\n+ --color-accent-2-100: #43150c;\n+ --color-accent-2-800: #ffc4b9;\n+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, .5);\n+ --shadow-md: 0 3px 10px rgba(0, 0, 0, .5);\n+ --shadow-lg: 0 12px 32px rgba(0, 0, 0, .6);\n+}\n+\n+@keyframes avwire-marquee { from { transform: translateX(0); } to { transform: translateX(-50%); } }\n+@keyframes avwire-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }\n+\n+/* — chrome — */\n+.page { min-height: 100vh; background: var(--color-bg); color: var(--color-text); }\n+.pulse-dot { width: 8px; height: 8px; background: var(--color-accent); animation: avwire-pulse 2s infinite; }\n+.marquee-track { display: flex; width: max-content; animation: avwire-marquee 80s linear infinite; white-space: nowrap; }\n+\n+.nav-link {\n+ cursor: pointer; font-size: 14px; font-weight: 600; padding: 8px 14px;\n+ border-bottom: 2px solid transparent; color: var(--color-text); text-decoration: none;\n+}\n+.nav-link:hover { color: var(--color-accent); }\n+.nav-link.active { color: var(--color-accent); border-bottom-color: var(--color-accent); }\n+\n+/* — segmented controls (language links / theme + filter buttons) — */\n+.seg-btn {\n+ appearance: none; border: 0; margin: 0; background: transparent;\n+ color: var(--color-text); font-family: inherit; font-weight: 400;\n+ line-height: inherit; cursor: pointer; display: inline-flex; align-items: center;\n+ text-decoration: none;\n+}\n+.seg-btn + .seg-btn { border-left: 1px solid var(--color-divider); }\n+.seg-btn.active { background: var(--color-accent); color: var(--color-bg); }\n+a.seg-btn:hover { color: var(--color-text); }\n+a.seg-btn.active:hover { color: var(--color-bg); }\n+.seg-lang .seg-btn { padding: 4px 11px; font-size: 12px; }\n+.seg-cat .seg-btn { padding: 5px 12px; font-size: 12px; }\n+.seg-sev .seg-btn { padding: 6px 14px; font-size: 12.5px; }\n+\n+/* — home grid — */\n+.home-grid {\n+ max-width: 1440px; margin: 0 auto; display: grid;\n+ grid-template-columns: 280px minmax(0, 1fr) 320px; align-items: stretch;\n+}\n+.rail-left { border-right: 2px solid var(--color-divider); padding: 20px; }\n+.center-col { padding: 20px 24px; min-width: 0; }\n+.rail-right { border-left: 2px solid var(--color-divider); padding: 20px; }\n+\n+.flash-row {\n+ padding: 9px 0; border-bottom: 1px solid var(--color-divider);\n+ display: flex; gap: 10px; align-items: baseline;\n+ color: inherit; text-decoration: none;\n+}\n+a.flash-row:hover { color: inherit; }\n+.flash-time {\n+ font-size: 12px; font-weight: 800; flex-shrink: 0;\n+ color: color-mix(in srgb, var(--color-text) 55%, transparent);\n+}\n+.flash-time.hot { color: var(--color-accent); }\n+\n+.dot { width: 7px; height: 7px; background: var(--color-accent); flex-shrink: 0; }\n+.dot.err { background: color-mix(in srgb, var(--color-text) 30%, transparent); }\n+\n+.hero-ph {\n+ height: 280px;\n+ background: repeating-linear-gradient(45deg, color-mix(in srgb, var(--color-text) 7%, transparent) 0 12px, transparent 12px 24px), var(--color-surface);\n+ border: 1px solid var(--color-divider);\n+ display: flex; align-items: flex-end; padding: 10px;\n+}\n+.hero-img { height: 280px; width: 100%; object-fit: cover; border: 1px solid var(--color-divider); }\n+\n+.feed-row {\n+ display: grid; grid-template-columns: 48px 84px minmax(0, 1fr) auto;\n+ gap: 14px; align-items: start; padding: 13px 0;\n+ border-top: 1px solid var(--color-divider);\n+ color: inherit; text-decoration: none; cursor: pointer;\n+}\n+.feed-row:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); color: inherit; }\n+\n+.is-hidden { display: none !important; }\n+\n+/* — footer — */\n+.footer-grid {\n+ max-width: 1440px; margin: 0 auto; padding: 24px 20px 32px;\n+ display: grid; grid-template-columns: 1fr 1.3fr; gap: 32px;\n+}\n+\n+/* — responsive fallback: the design is desktop-first; below 1100px the\n+ three-column home grid simply stacks. — */\n+@media (max-width: 1100px) {\n+ .home-grid { grid-template-columns: minmax(0, 1fr); }\n+ .rail-left { border-right: 0; border-bottom: 2px solid var(--color-divider); }\n+ .rail-right { border-left: 0; border-top: 2px solid var(--color-divider); }\n+ .footer-grid { grid-template-columns: 1fr; }\n+}\n@@ -0,0 +1,9 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <div style="max-width:860px;margin:0 auto;padding:64px 20px 96px;">\n+ <span class="card-kicker">404</span>\n+ <h2 style="margin:8px 0 12px;">找不到頁面 / Page not found</h2>\n+ <p class="text-muted" style="font-size:14.5px;line-height:1.9;">這個網址不存在或已被移除。The address does not exist or has been removed.</p>\n+ <a href="{{ home_url }}" class="btn btn-ghost" style="margin-top:12px;">← 返回首頁 / Front page</a>\n+ </div>\n+{% endblock %}\n@@ -0,0 +1,36 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <div style="max-width:860px;margin:0 auto;padding:28px 20px 64px;">\n+ <span class="card-kicker">{{ t.aboutKicker }}</span>\n+ <h2 style="margin:8px 0 18px;">{{ t.aboutTitle }}</h2>\n+ <p style="font-size:16px;line-height:1.9;">{{ t.aboutWhat }}</p>\n+ <hr class="hr" style="margin:24px 0;">\n+ <h4 style="margin-bottom:14px;">{{ t.aboutPipeTitle }}</h4>\n+ {% for ps in pipe_steps %}\n+ <div style="display:flex;gap:14px;align-items:baseline;padding:10px 0;border-bottom:1px solid var(--color-divider);">\n+ <span style="font-weight:800;color:var(--color-accent);font-size:15px;">{{ ps.n }}</span>\n+ <span style="font-size:14.5px;line-height:1.7;">{{ ps.text }}</span>\n+ </div>\n+ {% endfor %}\n+ <hr class="hr" style="margin:24px 0;">\n+ <h4 style="margin-bottom:10px;">{{ t.aboutAttrTitle }}</h4>\n+ <p style="font-size:14.5px;line-height:1.9;" class="text-muted">{{ t.aboutAttr }}</p>\n+ <h4 style="margin:24px 0 10px;">{{ t.aboutSchedTitle }}</h4>\n+ <p style="font-size:14.5px;line-height:1.9;" class="text-muted">{{ t.aboutSched }}</p>\n+ <h4 style="margin:24px 0 10px;">{{ t.aboutRepoTitle }}</h4>\n+ <p style="font-size:14.5px;line-height:1.9;" class="text-muted">{{ t.aboutRepo }}</p>\n+ <pre style="background:var(--color-surface);border:1px solid var(--color-divider);padding:16px 20px;font-size:12.5px;line-height:1.8;overflow-x:auto;margin:14px 0 0;">avwire/\n+├─ .github/workflows/hourly.yml # cron: 0 * * * *\n+├─ pipeline/\n+│ ├─ fetch.py # RSS / API\n+│ ├─ dedupe.py\n+│ ├─ write.py # LLM + sources\n+│ └─ publish.py # static site\n+├─ data/ # raw snapshots (JSON)\n+└─ site/ # GitHub Pages</pre>\n+ <div style="border:2px solid var(--color-divider);border-left:6px solid var(--color-accent);padding:14px 18px;margin-top:24px;">\n+ <h6 style="margin:0 0 6px;">{{ t.aboutDiscTitle }}</h6>\n+ <p class="text-muted" style="font-size:13px;line-height:1.8;margin:0;">{{ t.aboutDisc }}</p>\n+ </div>\n+ </div>\n+{% endblock %}\n@@ -0,0 +1,29 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <div style="max-width:860px;margin:0 auto;padding:28px 20px 64px;">\n+ <a href="{{ home_url }}" class="btn btn-ghost" style="margin-bottom:18px;">{{ t.back }}</a>\n+ <div>\n+ <span class="tag {{ a.tag_class }}">{{ a.cat_label }}</span>\n+ <h1 style="font-size:38px;line-height:1.18;margin:14px 0 12px;">{{ a.title }}</h1>\n+ <div class="text-muted" style="display:flex;gap:16px;align-items:baseline;font-size:12.5px;padding-bottom:16px;border-bottom:2px solid var(--color-divider);margin-bottom:24px;flex-wrap:wrap;">\n+ <span style="font-weight:800;color:var(--color-accent);">{{ a.meta_ts }}</span>\n+ <span>{{ t.autoCompiled }} · {{ t.mainSource }}: {{ a.source }}</span>\n+ <span style="margin-left:auto;">{{ t.refreshNote }}</span>\n+ </div>\n+ </div>\n+ {% for p in a.body %}\n+ <p style="font-size:16px;line-height:1.95;margin:0 0 18px;">{{ p }}</p>\n+ {% endfor %}\n+ <div style="border:2px solid var(--color-divider);padding:16px 20px 12px;margin-top:28px;">\n+ <h6 style="margin:0 0 10px;">{{ t.sourcesBox }}</h6>\n+ {% for s in a.sources %}\n+ <div style="display:flex;gap:10px;align-items:baseline;font-size:13.5px;padding:4px 0;">\n+ <span class="text-muted" style="font-weight:800;font-size:12px;">{{ s.n }}</span>\n+ <a href="{{ s.url }}" target="_blank" rel="noopener">{{ s.name }}</a>\n+ <span class="text-muted" style="font-size:11.5px;">{{ s.host }}</span>\n+ </div>\n+ {% endfor %}\n+ <div class="text-muted" style="font-size:12px;margin-top:10px;border-top:1px solid var(--color-divider);padding-top:10px;">{{ t.genNote }}</div>\n+ </div>\n+ </div>\n+{% endblock %}\n@@ -0,0 +1,90 @@\n+<!DOCTYPE html>\n+<html lang="{{ html_lang }}">\n+<head>\n+<meta charset="utf-8">\n+<meta name="viewport" content="width=device-width, initial-scale=1">\n+<title>{{ title }}</title>\n+<meta name="description" content="{{ description }}">\n+{% if hreflang %}\n+<link rel="alternate" hreflang="zh-Hant" href="{{ zh_url }}">\n+<link rel="alternate" hreflang="en" href="{{ en_url }}">\n+<link rel="alternate" hreflang="x-default" href="{{ zh_url }}">\n+{% endif %}\n+<link rel="icon" type="image/svg+xml" href="{{ favicon }}">\n+<script>try{var t=localStorage.getItem("avwire-theme");if(t){document.documentElement.dataset.theme=t;}}catch(e){}</script>\n+<link rel="preconnect" href="https://fonts.googleapis.com">\n+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n+<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;600;800&family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet">\n+<link rel="stylesheet" href="{{ base }}/assets/modernist.css">\n+<link rel="stylesheet" href="{{ base }}/assets/site.css">\n+<script src="{{ base }}/assets/app.js" defer></script>\n+</head>\n+<body>\n+<div class="page">\n+ <div style="border-bottom:1px solid var(--color-divider);">\n+ <div style="max-width:1440px;margin:0 auto;padding:6px 20px;display:flex;align-items:center;gap:16px;font-size:12px;">\n+ <span class="text-muted" style="letter-spacing:.06em;">{{ build.date }}</span>\n+ <span id="clock-utc" style="font-weight:600;letter-spacing:.06em;">UTC {{ build.utc_hm }}</span>\n+ <span id="clock-tpe" class="text-muted" style="letter-spacing:.06em;">TPE {{ build.tpe_hm }}</span>\n+ <span style="margin-left:auto;display:flex;align-items:center;gap:7px;">\n+ <span class="pulse-dot"></span>\n+ <span style="letter-spacing:.04em;">{{ t.updateBadge }}</span>\n+ </span>\n+ <span class="seg seg-lang">\n+ <a class="seg-btn{% if lang == 'zh' %} active{% endif %}" href="{{ zh_url }}">中文</a>\n+ <a class="seg-btn{% if lang == 'en' %} active{% endif %}" href="{{ en_url }}">EN</a>\n+ </span>\n+ <span class="seg seg-lang">\n+ <button type="button" class="seg-btn active" data-set-theme="light">{{ t.themeOpts[0] }}</button>\n+ <button type="button" class="seg-btn" data-set-theme="dark">{{ t.themeOpts[1] }}</button>\n+ </span>\n+ </div>\n+ </div>\n+ <div style="border-bottom:2px solid var(--color-divider);">\n+ <div style="max-width:1440px;margin:0 auto;padding:14px 20px;display:flex;align-items:center;gap:28px;">\n+ <a href="{{ home_url }}" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;">\n+ <div style="width:34px;height:34px;background:var(--color-accent);color:var(--color-bg);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;">AW</div>\n+ <div>\n+ <div style="font-weight:800;font-size:20px;line-height:1.05;letter-spacing:-.015em;">AVWIRE <span style="color:var(--color-accent);">{{ t.brandZh }}</span></div>\n+ <div class="text-muted" style="font-size:10px;letter-spacing:.18em;text-transform:uppercase;">Aviation News · Automated Hourly</div>\n+ </div>\n+ </a>\n+ <nav style="display:flex;gap:2px;margin-left:auto;">\n+ {% for n in nav %}\n+ <a class="nav-link{% if n.active %} active{% endif %}" href="{{ n.url }}">{{ n.label }}</a>\n+ {% endfor %}\n+ </nav>\n+ </div>\n+ </div>\n+ {% if ticker_line %}\n+ <div style="border-bottom:2px solid var(--color-divider);display:flex;align-items:stretch;">\n+ <div style="background:var(--color-accent);color:var(--color-bg);font-weight:800;font-size:12px;letter-spacing:.14em;display:flex;align-items:center;padding:7px 16px;flex-shrink:0;text-transform:uppercase;">{{ t.live }}</div>\n+ <div style="overflow:hidden;flex:1;display:flex;align-items:center;">\n+ <div class="marquee-track">\n+ <span style="padding-right:70px;font-size:13px;">{{ ticker_line }}</span>\n+ <span style="padding-right:70px;font-size:13px;">{{ ticker_line }}</span>\n+ </div>\n+ </div>\n+ </div>\n+ {% endif %}\n+{% block content %}{% endblock %}\n+ <div style="border-top:2px solid var(--color-divider);">\n+ <div class="footer-grid">\n+ <div>\n+ <div style="font-weight:800;font-size:16px;">AVWIRE <span style="color:var(--color-accent);">{{ t.brandZh }}</span></div>\n+ <p class="text-muted" style="font-size:12.5px;line-height:1.8;margin-top:8px;">{{ t.footerAbout }}</p>\n+ <p class="text-muted" style="font-size:11.5px;letter-spacing:.04em;margin:8px 0 0;">{{ t.lastBuild }} {{ build.stamp }} UTC</p>\n+ </div>\n+ <div>\n+ <h6 style="margin:0 0 10px;">{{ t.footerSources }}</h6>\n+ <div style="display:flex;flex-wrap:wrap;gap:8px;">\n+ {% for s in footer_sources %}\n+ <a href="{{ s.url }}" target="_blank" rel="noopener" class="tag tag-neutral" style="text-decoration:none;">{{ s.name }}</a>\n+ {% endfor %}\n+ </div>\n+ </div>\n+ </div>\n+ </div>\n+</div>\n+</body>\n+</html>\n@@ -0,0 +1,142 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <div class="home-grid">\n+ <div class="rail-left">\n+ <h6 style="display:flex;justify-content:space-between;align-items:baseline;margin:0 0 12px;">{{ t.flash }} <span style="color:var(--color-accent);font-size:10px;">● LIVE</span></h6>\n+ {% for f in flashes %}\n+ {% if f.url %}\n+ <a class="flash-row" href="{{ f.url }}">\n+ <span class="flash-time{% if f.hot %} hot{% endif %}">{{ f.time }}</span>\n+ <span style="font-size:12.5px;line-height:1.6;">{{ f.text }}</span>\n+ </a>\n+ {% else %}\n+ <div class="flash-row">\n+ <span class="flash-time{% if f.hot %} hot{% endif %}">{{ f.time }}</span>\n+ <span style="font-size:12.5px;line-height:1.6;">{{ f.text }}</span>\n+ </div>\n+ {% endif %}\n+ {% else %}\n+ <div class="flash-row"><span class="text-muted" style="font-size:12.5px;line-height:1.6;">{{ t.awaiting }}</span></div>\n+ {% endfor %}\n+ <h6 style="margin:28px 0 12px;">{{ t.sourceStatus }}</h6>\n+ {% for s in source_status %}\n+ <div style="padding:7px 0;border-bottom:1px solid var(--color-divider);display:flex;align-items:center;gap:8px;font-size:12.5px;">\n+ <span class="dot{% if not s.ok %} err{% endif %}"></span>\n+ <span{% if not s.ok %} class="text-muted"{% endif %}>{{ s.name }}</span>\n+ <span class="text-muted" style="margin-left:auto;font-size:11.5px;">{{ s.ago }}</span>\n+ </div>\n+ {% endfor %}\n+ </div>\n+ <div class="center-col">\n+ {% if hero %}\n+ <a href="{{ hero.url }}" style="display:block;color:inherit;text-decoration:none;cursor:pointer;">\n+ {% if hero.image %}\n+ <img class="grayscale hero-img" src="{{ hero.image }}" alt="">\n+ {% else %}\n+ <div class="grayscale hero-ph">\n+ <span class="text-muted" style="font-family:monospace;font-size:11px;">{{ t.heroImg }}</span>\n+ </div>\n+ {% endif %}\n+ <div style="margin-top:16px;">\n+ <span class="card-kicker" style="font-size:11px;">{{ hero.cat_label }} — {{ t.topStory }}</span>\n+ <h2 style="font-size:34px;line-height:1.15;margin:8px 0 10px;">{{ hero.title }}</h2>\n+ <p class="text-muted" style="font-size:14.5px;line-height:1.75;margin:0;">{{ hero.summary }}</p>\n+ <div style="display:flex;gap:16px;align-items:baseline;margin-top:10px;font-size:12px;">\n+ <span style="font-weight:800;color:var(--color-accent);">{{ hero.time }}</span>\n+ <span class="text-muted">{{ t.mainSource }}: {{ hero.source }}</span>\n+ <span style="margin-left:auto;font-weight:600;">{{ t.readMore }}</span>\n+ </div>\n+ </div>\n+ </a>\n+ {% else %}\n+ <div class="grayscale hero-ph">\n+ <span class="text-muted" style="font-family:monospace;font-size:11px;">{{ t.awaiting }}</span>\n+ </div>\n+ {% endif %}\n+ <hr class="hr" style="margin:20px 0 0;">\n+ <div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;flex-wrap:wrap;gap:10px;">\n+ <h6 style="margin:0;">{{ t.latest }}</h6>\n+ <span class="seg seg-cat">\n+ {% for key, label in cat_seg %}\n+ <button type="button" class="seg-btn{% if key == 'all' %} active{% endif %}" data-filter-cat="{{ key }}">{{ label }}</button>\n+ {% endfor %}\n+ </span>\n+ </div>\n+ {% for item in feed %}\n+ <a class="feed-row" data-cat="{{ item.cat }}" href="{{ item.url }}">\n+ <span style="font-size:13px;font-weight:800;">{{ item.time }}</span>\n+ <span class="tag {{ item.tag_class }}" style="justify-content:flex-start;">{{ item.cat_label }}</span>\n+ <span style="min-width:0;">\n+ <span style="display:block;font-size:15.5px;font-weight:700;line-height:1.4;letter-spacing:-.01em;">{{ item.title }}</span>\n+ <span class="text-muted" style="display:block;font-size:12.5px;line-height:1.65;margin-top:3px;">{{ item.summary }}</span>\n+ </span>\n+ <span class="text-muted" style="font-size:11px;border:1px solid var(--color-divider);padding:3px 9px;white-space:nowrap;">{{ item.source }}</span>\n+ </a>\n+ {% else %}\n+ <div class="text-muted" style="padding:13px 0;border-top:1px solid var(--color-divider);font-size:12.5px;">{{ t.awaiting }}</div>\n+ {% endfor %}\n+ </div>\n+ <div class="rail-right">\n+ <h6 style="margin:0 0 12px;">{{ t.today }}</h6>\n+ <div style="display:grid;grid-template-columns:1fr 1fr;border:1px solid var(--color-divider);">\n+ <div style="padding:12px;border-right:1px solid var(--color-divider);border-bottom:1px solid var(--color-divider);">\n+ <div style="font-size:24px;font-weight:800;color:var(--color-accent);letter-spacing:-.02em;">{{ stats.flights }}</div>\n+ <div class="text-muted" style="font-size:11px;">{{ t.statFlights }}</div>\n+ </div>\n+ <div style="padding:12px;border-bottom:1px solid var(--color-divider);">\n+ <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.delay }}</div>\n+ <div class="text-muted" style="font-size:11px;">{{ t.statDelay }}</div>\n+ </div>\n+ <div style="padding:12px;border-right:1px solid var(--color-divider);">\n+ <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.serious }}</div>\n+ <div class="text-muted" style="font-size:11px;">{{ t.statSerious }}</div>\n+ </div>\n+ <div style="padding:12px;">\n+ <div style="font-size:24px;font-weight:800;letter-spacing:-.02em;">{{ stats.notams }}</div>\n+ <div class="text-muted" style="font-size:11px;">{{ t.statNotam }}</div>\n+ </div>\n+ </div>\n+ {% if otp %}\n+ <h6 style="margin:28px 0 12px;">{{ t.otp }}</h6>\n+ {% for r in otp %}\n+ <div style="padding:8px 0;border-bottom:1px solid var(--color-divider);">\n+ <div style="display:flex;justify-content:space-between;font-size:13px;align-items:baseline;">\n+ <span><span class="text-muted" style="font-weight:800;font-size:11px;">{{ r.rank }}</span> {{ r.airline }} <span class="text-muted" style="font-size:11px;">{{ r.code }}</span></span>\n+ <span style="font-weight:800;">{{ r.pct }}%</span>\n+ </div>\n+ <div style="height:3px;background:color-mix(in srgb,var(--color-text) 10%,transparent);margin-top:5px;"><div style="height:3px;background:var(--color-accent);width:{{ r.barw }};"></div></div>\n+ </div>\n+ {% endfor %}\n+ {% endif %}\n+ {% if fleet %}\n+ <h6 style="margin:28px 0 12px;">{{ t.fleet }}</h6>\n+ <div style="font-size:12.5px;">\n+ <div class="text-muted" style="font-size:11px;margin-bottom:6px;">{{ t.fleetDeliv }}</div>\n+ {% for fb in fleet.bars %}\n+ <div style="display:flex;align-items:center;gap:8px;padding:4px 0;">\n+ <span style="width:52px;font-weight:700;">{{ fb.maker }}</span>\n+ <div style="flex:1;height:14px;background:color-mix(in srgb,var(--color-text) 8%,transparent);"><div style="height:14px;background:{{ fb.color }};width:{{ fb.w }};"></div></div>\n+ <span style="font-weight:800;width:32px;text-align:right;">{{ fb.n }}</span>\n+ </div>\n+ {% endfor %}\n+ <div style="display:flex;justify-content:space-between;border-top:1px solid var(--color-divider);margin-top:8px;padding-top:8px;">\n+ <span class="text-muted">{{ t.fleetBacklog }}</span><span style="font-weight:700;">{{ fleet.backlog }}</span>\n+ </div>\n+ <div style="display:flex;justify-content:space-between;padding-top:4px;">\n+ <span class="text-muted">{{ t.fleetOrders }}</span><span style="font-weight:700;">{{ fleet.orders }}</span>\n+ </div>\n+ </div>\n+ {% endif %}\n+ {% if delays %}\n+ <h6 style="margin:28px 0 12px;">{{ t.delays }}</h6>\n+ {% for d in delays %}\n+ <div style="padding:7px 0;border-bottom:1px solid var(--color-divider);display:flex;align-items:center;gap:10px;font-size:12.5px;">\n+ <span style="font-weight:800;width:36px;">{{ d.code }}</span>\n+ <span class="text-muted" style="flex:1;min-width:0;">{{ d.name }}</span>\n+ <span style="font-weight:700;">{{ d.pct }}%</span>\n+ </div>\n+ {% endfor %}\n+ {% endif %}\n+ </div>\n+ </div>\n+{% endblock %}\n@@ -0,0 +1,41 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <div style="max-width:1440px;margin:0 auto;padding:28px 20px 64px;">\n+ <span class="card-kicker">{{ t.incKicker }}</span>\n+ <h2 style="margin:8px 0 6px;">{{ t.incTitle }}</h2>\n+ <p class="text-muted" style="font-size:14px;max-width:640px;">{{ t.incSub }}</p>\n+ <div style="display:flex;justify-content:space-between;align-items:center;margin:18px 0 0;flex-wrap:wrap;gap:10px;">\n+ <span class="seg seg-sev">\n+ {% for key, label in sev_seg %}\n+ <button type="button" class="seg-btn{% if key == 'all' %} active{% endif %}" data-filter-sev="{{ key }}">{{ label }}</button>\n+ {% endfor %}\n+ </span>\n+ <span class="text-muted" style="font-size:12.5px;"><span id="inc-count">{{ count }}</span> {{ t.incCountLabel }}</span>\n+ </div>\n+ <div style="overflow-x:auto;">\n+ <table class="table" style="margin-top:14px;">\n+ <thead><tr>\n+ <th>{{ t.thDate }}</th><th>{{ t.thSev }}</th><th>{{ t.thType }}</th><th>{{ t.thOp }}</th><th>{{ t.thPhase }}</th><th>{{ t.thLoc }}</th><th>{{ t.thDesc }}</th><th>{{ t.thStatus }}</th><th>{{ t.thSrc }}</th>\n+ </tr></thead>\n+ <tbody>\n+ {% for i in rows %}\n+ <tr data-sev="{{ i.sev_key }}">\n+ <td style="font-weight:700;white-space:nowrap;">{{ i.d }}</td>\n+ <td><span class="tag {{ i.sev_class }}">{{ i.sev_label }}</span></td>\n+ <td style="white-space:nowrap;">{{ i.ac }}</td>\n+ <td>{{ i.op }}</td>\n+ <td>{{ i.ph }}</td>\n+ <td>{{ i.loc }}</td>\n+ <td class="text-muted">{{ i.desc }}</td>\n+ <td style="white-space:nowrap;">{{ i.st }}</td>\n+ <td class="text-muted" style="font-size:12px;white-space:nowrap;">{{ i.src }}</td>\n+ </tr>\n+ {% else %}\n+ <tr><td colspan="9" class="text-muted">{{ t.awaiting }}</td></tr>\n+ {% endfor %}\n+ </tbody>\n+ </table>\n+ </div>\n+ <p class="text-muted" style="font-size:12px;margin-top:14px;">{{ t.incNote }}</p>\n+ </div>\n+{% endblock %}\n@@ -0,0 +1,35 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <div style="max-width:1440px;margin:0 auto;padding:28px 20px 64px;">\n+ <span class="card-kicker">{{ t.srcKicker }}</span>\n+ <h2 style="margin:8px 0 6px;">{{ t.srcTitle }}</h2>\n+ <p class="text-muted" style="font-size:14px;max-width:640px;">{{ t.srcSub }}</p>\n+ <div style="overflow-x:auto;">\n+ <table class="table" style="margin-top:20px;">\n+ <thead><tr>\n+ <th>{{ t.thName }}</th><th>{{ t.thKind }}</th><th>{{ t.thCover }}</th><th>{{ t.thFmt }}</th><th>{{ t.thFreq }}</th><th>{{ t.thLast }}</th><th>{{ t.thState }}</th>\n+ </tr></thead>\n+ <tbody>\n+ {% for s in rows %}\n+ <tr>\n+ <td style="font-weight:700;white-space:nowrap;"><a href="{{ s.url }}" target="_blank" rel="noopener" style="color:inherit;text-decoration:none;">{{ s.name }}</a></td>\n+ <td><span class="tag tag-neutral">{{ s.kind }}</span></td>\n+ <td class="text-muted">{{ s.cover }}</td>\n+ <td style="white-space:nowrap;">{{ s.fmt }}</td>\n+ <td>60 min</td>\n+ <td class="text-muted" style="white-space:nowrap;">{{ s.last }}</td>\n+ <td>\n+ {% if s.ok %}\n+ <span style="display:inline-flex;align-items:center;gap:6px;font-size:12px;"><span class="dot"></span>OK</span>\n+ {% else %}\n+ <span class="text-muted" style="display:inline-flex;align-items:center;gap:6px;font-size:12px;"><span class="dot err"></span>ERR</span>\n+ {% endif %}\n+ </td>\n+ </tr>\n+ {% endfor %}\n+ </tbody>\n+ </table>\n+ </div>\n+ <p class="text-muted" style="font-size:12px;margin-top:14px;">{{ t.srcNote }}</p>\n+ </div>\n+{% endblock %}\n@@ -0,0 +1,50 @@\n+{\n+ "source": "avherald",\n+ "fetchedUtc": "{{H-1}}",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "Emergency balloon landing drill unit one",\n+ "url": "https://avherald.com/h?article=balloon-1",\n+ "publishedUtc": "{{H-7}}",\n+ "summary": "The balloon drill over the alpine valley concluded with every crew reporting nominal telemetry and no injuries recorded anywhere.",\n+ "image": null\n+ },\n+ {\n+ "title": "Emergency balloon landing drill unit two",\n+ "url": "https://avherald.com/h?article=balloon-2",\n+ "publishedUtc": "{{H-7}}",\n+ "summary": "The balloon drill over the alpine valley concluded with every crew reporting nominal telemetry.",\n+ "image": null\n+ },\n+ {\n+ "title": "Emergency balloon landing drill unit three",\n+ "url": "https://avherald.com/h?article=balloon-3",\n+ "publishedUtc": "{{H-7}}",\n+ "summary": "The balloon drill over the alpine valley concluded without incident.",\n+ "image": null\n+ },\n+ {\n+ "title": "Emergency balloon landing drill unit four",\n+ "url": "https://avherald.com/h?article=balloon-4",\n+ "publishedUtc": "{{H-7}}",\n+ "summary": "The balloon drill concluded without incident.",\n+ "image": null\n+ },\n+ {\n+ "title": "Emergency balloon landing drill unit five",\n+ "url": "https://avherald.com/h?article=balloon-5",\n+ "publishedUtc": "{{H-7}}",\n+ "summary": "The drill concluded safely.",\n+ "image": null\n+ },\n+ {\n+ "title": "Emergency balloon landing drill unit six",\n+ "url": "https://avherald.com/h?article=balloon-6",\n+ "publishedUtc": "{{H-7}}",\n+ "summary": "Drill done.",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,15 @@\n+{\n+ "source": "easa",\n+ "fetchedUtc": "{{H-1}}",\n+ "ok": false,\n+ "error": "HTTP 503",\n+ "items": [\n+ {\n+ "title": "EASA phantom item that must be ignored",\n+ "url": "https://www.easa.europa.eu/phantom",\n+ "publishedUtc": "{{H-1}}",\n+ "summary": "Must never appear because ok=false.",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,36 @@\n+{\n+ "source": "faa",\n+ "fetchedUtc": "{{H-1}}",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "FAA statement on Denver 737 MAX runway excursion",\n+ "url": "https://www.faa.gov/newsroom/denver-737-max-statement",\n+ "publishedUtc": "{{H-2}}",\n+ "summary": "Official statement issued.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA statement on winter operations",\n+ "url": "https://www.faa.gov/newsroom/statement-x/?utm_source=rss",\n+ "publishedUtc": "{{H-3}}",\n+ "summary": "Already recorded in seen.json by URL; must be dropped.",\n+ "image": null\n+ },\n+ {\n+ "title": "FAA archives old advisory circular",\n+ "url": "https://www.faa.gov/newsroom/old-advisory",\n+ "publishedUtc": "{{H-72}}",\n+ "summary": "Stale item, three days old; must be dropped.",\n+ "image": null\n+ },\n+ {\n+ "title": " ",\n+ "url": "https://www.faa.gov/newsroom/untitled",\n+ "publishedUtc": "{{H-1}}",\n+ "summary": "Item without a title; must be dropped.",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,15 @@\n+{\n+ "source": "ntsb",\n+ "fetchedUtc": "{{H-1}}",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "NTSB opens investigation into cargo drone incident near Nome Alaska",\n+ "url": "https://www.ntsb.gov/news/press-releases/nome-drone",\n+ "publishedUtc": "{{H-6}}",\n+ "summary": "Fresh unique item; should form its own single-item group.",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,29 @@\n+{\n+ "source": "reuters",\n+ "fetchedUtc": "{{H-1}}",\n+ "ok": true,\n+ "error": null,\n+ "items": [\n+ {\n+ "title": "FAA statement on Denver 737 MAX runway excursion - Reuters",\n+ "url": "https://www.reuters.com/business/aerospace-defense/denver-737-max-runway",\n+ "publishedUtc": "{{H-1}}",\n+ "summary": "Reuters reports the FAA issued a statement after a 737 MAX runway excursion at Denver International Airport, with the NTSB dispatching a go-team.",\n+ "image": "https://www.reuters.com/img/denver.jpg"\n+ },\n+ {\n+ "title": "Boeing delivers 100th 787 to Emirates!",\n+ "url": "https://www.reuters.com/business/aerospace-defense/boeing-787-emirates",\n+ "publishedUtc": "{{H-4}}",\n+ "summary": "Title matches a seen.json title from two days ago; must be dropped.",\n+ "image": null\n+ },\n+ {\n+ "title": "Airbus opens new A320 production line in Toulouse",\n+ "url": "https://www.reuters.com/business/aerospace-defense/airbus-a320-line",\n+ "publishedUtc": "{{H-5}}",\n+ "summary": "Matching seen title is 30 days old, outside the 21-day window, so this survives.",\n+ "image": null\n+ }\n+ ]\n+}\n@@ -0,0 +1,9 @@\n+{\n+ "urls": {\n+ "https://www.faa.gov/newsroom/statement-x": "{{D-1}}"\n+ },\n+ "titles": [\n+ ["boeing delivers 100th 787 to emirates", "{{D-2}}"],\n+ ["airbus opens new a320 production line in toulouse", "{{D-30}}"]\n+ ]\n+}\n@@ -0,0 +1,87 @@\n+{\n+ "articles": [\n+ {\n+ "id": "a-20260725-2330-airbus-deliveries",\n+ "publishedUtc": "2026-07-25T23:30Z",\n+ "cat": "biz",\n+ "primarySource": "Reuters",\n+ "image": null,\n+ "zh": {\n+ "title": "空巴上半年交付 366 架超越波音 A321neo 積壓訂單創新高",\n+ "summary": "空中巴士交付量領先波音的 312 架;窄體機供不應求推升二手 A321 租金至歷史高點。",\n+ "body": [\n+ "空中巴士公布上半年交付366架,超越波音的312架;其中 A321neo 佔交付近六成,積壓訂單突破7,100架創歷史新高。",\n+ "兩家製造商皆受引擎供應與艙內件短缺影響,交付進度落後年度目標;空巴維持全年820架交付目標不變。",\n+ "分析師指出,窄體機供不應求已推升二手 A321 租金至歷史高點,租賃公司議價能力明顯增強。"\n+ ]\n+ },\n+ "en": {\n+ "title": "Airbus outpaces Boeing with 366 first-half deliveries; A321neo backlog at record",\n+ "summary": "Narrowbody scarcity pushes used-A321 lease rates to all-time highs.",\n+ "body": [\n+ "Airbus delivered 366 aircraft in the first half against Boeing's 312, with the A321neo taking nearly 60% of deliveries and the backlog topping 7,100 — a record.",\n+ "Both manufacturers trail annual targets on engine and interiors shortages; Airbus keeps its 820-delivery goal. Analysts note used-A321 lease rates at record highs as narrowbody supply stays tight."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "Reuters — Aerospace & Defense", "url": "https://www.reuters.com/business/aerospace-defense"},\n+ {"name": "Airbus — Orders & Deliveries", "url": "https://www.airbus.com/en/products-services/commercial-aircraft/market/orders-deliveries"}\n+ ]\n+ },\n+ {\n+ "id": "a-20260725-2244-evtol-rule",\n+ "publishedUtc": "2026-07-25T22:44Z",\n+ "cat": "reg",\n+ "primarySource": "FAA",\n+ "image": null,\n+ "zh": {\n+ "title": "FAA 更新 eVTOL 適航審定規則 2027 商業空中計程車可期",\n+ "summary": "動力升力類操作規範與駕駛員訓練要求定案,為首批商業營運鋪路。",\n+ "body": [\n+ "FAA 發布 eVTOL(電動垂直起降航空器)適航審定最終規則更新,明確動力升力類(powered-lift)操作規範與駕駛員訓練要求。",\n+ "新規為2027年啟動商業空中計程車營運鋪路,首批適用業者包括多家已進入型別審定後期的美國新創。",\n+ "ICAO 亦同步推進國際標準協調,避免各國審定規則分歧造成跨境營運障礙。"\n+ ]\n+ },\n+ "en": {\n+ "title": "FAA finalizes updated eVTOL certification rules, clearing a 2027 launch path",\n+ "summary": "Powered-lift operating rules and pilot-training requirements are now set.",\n+ "body": [\n+ "The FAA published its updated final rule for eVTOL airworthiness, defining powered-lift operating rules and pilot-training requirements.",\n+ "The rule paves the way for commercial air-taxi launches from 2027, with several US startups deep in type certification. ICAO is coordinating international standards in parallel."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "FAA — Final Rule", "url": "https://www.faa.gov/newsroom"}\n+ ]\n+ },\n+ {\n+ "id": "a-20260725-2202-typhoon-taoyuan",\n+ "publishedUtc": "2026-07-25T22:02Z",\n+ "cat": "ops",\n+ "primarySource": "CAA Taiwan",\n+ "image": null,\n+ "zh": {\n+ "title": "颱風影響東亞航線 桃園機場今取消 86 班、延誤逾 200 班",\n+ "summary": "民航局啟動應變機制,各航空公司開放免費改票;台北飛航情報區下午流量減少 31%。",\n+ "body": [\n+ "颱風外圍環流影響東亞航線,桃園機場今日取消86班、延誤逾200班;松山、高雄機場亦有零星取消。",\n+ "民航局啟動颱風應變機制,各航空公司開放受影響旅客免費改票一次。FlightAware 數據顯示台北飛航情報區下午流量較平日減少31%。",\n+ "氣象單位預估明日上午風雨趨緩,航班可望逐步恢復,惟機隊調度回穩仍需24至36小時。"\n+ ]\n+ },\n+ "en": {\n+ "title": "Typhoon disrupts East Asia: 86 cancellations at Taoyuan today",\n+ "summary": "Taiwan CAA activates its typhoon response; Taipei FIR afternoon traffic down 31%.",\n+ "body": [\n+ "Outer bands of a typhoon disrupted East Asian routes, with 86 cancellations and 200+ delays at Taoyuan; Songshan and Kaohsiung saw scattered cancellations.",\n+ "Carriers are waiving one rebooking fee under the CAA's typhoon response. FlightAware shows Taipei FIR afternoon traffic down 31% on a normal day. Weather should ease by tomorrow morning, though fleet recovery may take 24–36 hours."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "交通部民航局 — 航班異動公告", "url": "https://www.caa.gov.tw"},\n+ {"name": "FlightAware — MiseryMap", "url": "https://flightaware.com/miserymap"}\n+ ]\n+ }\n+ ]\n+}\n@@ -0,0 +1,60 @@\n+{\n+ "articles": [\n+ {\n+ "id": "a-20260726-0138-atc-strike",\n+ "publishedUtc": "2026-07-26T01:38Z",\n+ "cat": "ops",\n+ "primarySource": "Reuters",\n+ "image": null,\n+ "zh": {\n+ "title": "歐洲空管罷工第三天 週末逾 1,200 航班取消",\n+ "summary": "法義空管工會罷工持續,Eurocontrol 統計平均空域延誤 41 分鐘,巴黎兩大機場削減 30% 班表。",\n+ "body": [\n+ "法國與義大利空管工會罷工進入第三天,Eurocontrol 統計本週末歐洲已取消逾1,200航班,另有平均41分鐘的空域流量延誤。",\n+ "罷工主因為人力編制與排班改革爭議。法國民航總局已要求各航空公司削減巴黎戴高樂與奧利機場30%班表。",\n+ "多家航空公司開放受影響旅客免費改期。Eurocontrol 預估若協商未果,下週影響將擴及跨大西洋航線的歐洲銜接段。"\n+ ]\n+ },\n+ "en": {\n+ "title": "European ATC strike hits day three; 1,200+ weekend cancellations",\n+ "summary": "Eurocontrol logs 41-minute average flow delays; Paris airports cut 30% of schedules.",\n+ "body": [\n+ "Strikes by French and Italian ATC unions entered a third day, with more than 1,200 weekend cancellations across Europe and average flow delays of 41 minutes, per Eurocontrol.",\n+ "The dispute centres on staffing and rostering reform. France's DGAC ordered 30% schedule cuts at Paris CDG and Orly; carriers are offering free rebooking. Eurocontrol warns transatlantic connections could be affected next week if talks fail."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "Eurocontrol — Network Situation", "url": "https://www.eurocontrol.int/network-operations"},\n+ {"name": "Reuters — Aerospace & Defense", "url": "https://www.reuters.com/business/aerospace-defense"}\n+ ]\n+ },\n+ {\n+ "id": "a-20260726-0052-a320neo-ad",\n+ "publishedUtc": "2026-07-26T00:52Z",\n+ "cat": "safety",\n+ "primarySource": "EASA",\n+ "image": null,\n+ "zh": {\n+ "title": "A320neo 高空熄火事件 EASA 令 15 日內檢查特定批次燃油泵",\n+ "summary": "兩起引擎短暫熄火事件後,EASA 發布緊急適航指令,全球約 340 架受影響。",\n+ "body": [\n+ "EASA 發布緊急適航指令(AD),要求營運人於15個飛行日內檢查特定批次 A320neo 系列的燃油泵,起因於近期兩起高空引擎短暫熄火事件。",\n+ "受影響批次涉及全球約340架飛機。指令要求核對泵體序號並更換疑慮批件,多數檢查可於過夜停場完成。",\n+ "空中巴士表示已與供應商確認製程偏差根因,新批件供應無虞;FAA 預計將跟進發布對等指令。"\n+ ]\n+ },\n+ "en": {\n+ "title": "A320neo fuel-pump AD: EASA orders checks within 15 flight days",\n+ "summary": "Two high-altitude flameout events prompt an emergency directive covering about 340 aircraft.",\n+ "body": [\n+ "EASA issued an emergency airworthiness directive requiring operators to inspect fuel pumps from specific A320neo-family batches within 15 flight days, after two brief high-altitude flameout events.",\n+ "About 340 aircraft are affected worldwide. Most checks fit an overnight stop; Airbus says the root cause is a confirmed supplier process deviation and replacement parts are available. The FAA is expected to mirror the directive."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "EASA — AD 2026-0164", "url": "https://ad.easa.europa.eu"},\n+ {"name": "The Aviation Herald — Occurrence log", "url": "https://avherald.com"}\n+ ]\n+ }\n+ ]\n+}\n@@ -0,0 +1,90 @@\n+{\n+ "articles": [\n+ {\n+ "id": "a-20260726-0547-denver737",\n+ "publishedUtc": "2026-07-26T05:47Z",\n+ "cat": "safety",\n+ "primarySource": "Reuters",\n+ "image": "https://picsum.photos/seed/avwire-denver/1200/560",\n+ "zh": {\n+ "title": "737-8 丹佛重落地起落架受損 FAA、NTSB 聯合調查中",\n+ "summary": "一架波音737-8今晨於丹佛國際機場降落時疑因風切重落地,右主起落架受損,機上142人無人受傷。FAA 已調閱塔台通話與飛航資料。",\n+ "body": [\n+ "當地時間今晨7時許,一架執飛芝加哥—丹佛航線的波音737-8在丹佛國際機場34L跑道降落時重落地,右主起落架支柱受損,飛機滑行至滑行道後停止。機上136名乘客與6名機組人員均安全下機,無人受傷。",\n+ "初步資料顯示,落地當時機場周邊出現明顯風切警告,前一架進場航機曾執行重飛。FAA 已調閱塔台通話紀錄與 ADS-B 資料,NTSB 亦派遣現場調查小組。",\n+ "涉事飛機機齡3.2年。航空公司表示將全力配合調查,該機於現場完成初步檢查後拖回維修棚。丹佛機場34L跑道關閉約2小時後重開,連帶影響約60班航班。",\n+ "本起事件初步歸類為「嚴重事件(serious incident)」,調查初步報告預計30天內公布。"\n+ ]\n+ },\n+ "en": {\n+ "title": "Boeing 737-8 gear damaged in Denver hard landing; FAA and NTSB investigating",\n+ "summary": "A 737-8 suffered right main-gear damage in a suspected wind-shear hard landing at Denver this morning. All 142 aboard were unhurt; the FAA has pulled tower tapes and flight data.",\n+ "body": [\n+ "A Boeing 737-8 operating Chicago–Denver made a hard landing on Denver runway 34L around 7 a.m. local time, damaging the right main-gear strut before stopping on a taxiway. All 136 passengers and six crew deplaned safely.",\n+ "Preliminary data show wind-shear alerts around the field at the time; the preceding arrival had gone around. The FAA has secured tower recordings and ADS-B data, and the NTSB dispatched a go-team.",\n+ "The 3.2-year-old aircraft will be hangared after on-site inspection. Runway 34L reopened after about two hours, affecting some 60 flights. The occurrence is preliminarily classed as a serious incident, with an initial report expected within 30 days."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "FAA — Preliminary Statement", "url": "https://www.faa.gov/newsroom"},\n+ {"name": "NTSB — Investigation Docket", "url": "https://www.ntsb.gov/investigations"},\n+ {"name": "Reuters — Aerospace & Defense", "url": "https://www.reuters.com/business/aerospace-defense"}\n+ ]\n+ },\n+ {\n+ "id": "a-20260726-0342-corsia",\n+ "publishedUtc": "2026-07-26T03:42Z",\n+ "cat": "reg",\n+ "primarySource": "ICAO",\n+ "image": null,\n+ "zh": {\n+ "title": "ICAO 通過 CORSIA 2026 修訂指引 SAF 憑證首度納入計算",\n+ "summary": "ICAO 理事會通過國際航空碳抵換與減量計畫修訂版,更新合格碳權清單與 MRV 規範,2027 年 1 月生效。",\n+ "body": [\n+ "ICAO 理事會通過 CORSIA(國際航空碳抵換與減量計畫)2026 年版修訂指引,更新合格排放單位清單與監測、報告及查證(MRV)規範,並訂於2027年1月生效。",\n+ "修訂重點包括收緊合格碳權標準、簡化小型營運人申報流程,並首度納入永續航空燃料(SAF)憑證的計算方法。",\n+ "IATA 對修訂表示歡迎,但提醒各國主管機關需同步更新國內法規,避免營運人面臨雙重申報負擔。"\n+ ]\n+ },\n+ "en": {\n+ "title": "ICAO adopts 2026 CORSIA amendments; SAF credits enter the accounting",\n+ "summary": "The Council updated eligible emissions units and MRV rules, effective January 2027.",\n+ "body": [\n+ "The ICAO Council adopted the 2026 amendments to CORSIA, updating the eligible emissions-units list and monitoring, reporting and verification (MRV) provisions, effective January 2027.",\n+ "Key changes tighten carbon-credit eligibility, simplify filings for small operators, and for the first time define an accounting method for sustainable aviation fuel (SAF) certificates. IATA welcomed the revision while urging states to align national rules to avoid double reporting."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "ICAO — CORSIA 2026 Amendment", "url": "https://www.icao.int/environmental-protection/CORSIA"},\n+ {"name": "IATA — Policy Response", "url": "https://www.iata.org/en/policy"}\n+ ]\n+ },\n+ {\n+ "id": "a-20260726-0215-iata-demand",\n+ "publishedUtc": "2026-07-26T02:15Z",\n+ "cat": "biz",\n+ "primarySource": "IATA",\n+ "image": null,\n+ "zh": {\n+ "title": "IATA:6 月全球客運需求年增 8.2% 亞太線以 11.6% 領漲",\n+ "summary": "6 月 RPK 年增 8.2%,載客率 84.9% 創同期新高;亞太—北美航線表現最強。",\n+ "body": [\n+ "IATA 公布最新數據:6月全球客運需求(RPK)年增8.2%,其中亞太區以11.6%領先所有區域;整體載客率84.9%,創6月同期新高。",\n+ "國際線需求年增9.4%,亞太—北美航線受惠於運能恢復與匯率因素表現最強;國內線市場成長5.1%。",\n+ "IATA 理事長指出,下半年需留意燃油價格波動,以及機隊交付延遲對運能擴張的限制。"\n+ ]\n+ },\n+ "en": {\n+ "title": "IATA: June passenger demand up 8.2%, Asia-Pacific leads at 11.6%",\n+ "summary": "RPKs rose 8.2% year on year with load factor at a June-record 84.9%.",\n+ "body": [\n+ "IATA data show June global passenger demand (RPK) up 8.2% year on year, led by Asia-Pacific at 11.6%; load factor reached 84.9%, a June record.",\n+ "International demand grew 9.4%, strongest across the transpacific, while domestic markets rose 5.1%. IATA flagged fuel-price volatility and delivery delays as the constraints to watch in the second half."\n+ ]\n+ },\n+ "sources": [\n+ {"name": "IATA — Air Passenger Market Analysis, June 2026", "url": "https://www.iata.org/en/publications/economics"}\n+ ]\n+ }\n+ ]\n+}\n@@ -0,0 +1,12 @@\n+[\n+ {"timeUtc": "2026-07-26T06:32Z", "hot": true, "zh": "桃機第二航廈因雷雨暫停地面作業 20 分鐘", "en": "Taoyuan T2 ground ops paused 20 min for thunderstorms", "articleId": null},\n+ {"timeUtc": "2026-07-26T06:18Z", "hot": false, "zh": "UA、DL 宣布加開跨太平洋冬季班表", "en": "UA, DL add transpacific winter frequencies", "articleId": null},\n+ {"timeUtc": "2026-07-26T05:55Z", "hot": false, "zh": "香港機場單日客量重返疫情前水準", "en": "HKG daily traffic back at pre-pandemic levels", "articleId": null},\n+ {"timeUtc": "2026-07-26T05:47Z", "hot": true, "zh": "丹佛 737-8 重落地,34L 跑道一度關閉", "en": "Denver 737-8 hard landing; RWY 34L closed briefly", "articleId": "a-20260726-0547-denver737"},\n+ {"timeUtc": "2026-07-26T05:20Z", "hot": false, "zh": "IATA 下修全年燃油成本預估 4%", "en": "IATA trims full-year fuel cost forecast 4%", "articleId": null},\n+ {"timeUtc": "2026-07-26T04:58Z", "hot": false, "zh": "EASA 對 A320neo 燃油泵發出適航指令", "en": "EASA issues A320neo fuel-pump AD", "articleId": "a-20260726-0052-a320neo-ad"},\n+ {"timeUtc": "2026-07-26T04:31Z", "hot": false, "zh": "成田機場 B 跑道異物檢查短暫關閉", "en": "Narita RWY B briefly closed for FOD check", "articleId": null},\n+ {"timeUtc": "2026-07-26T04:04Z", "hot": false, "zh": "波音 787 產線月產能提升至 7 架", "en": "Boeing lifts 787 output to 7/month", "articleId": null},\n+ {"timeUtc": "2026-07-26T03:42Z", "hot": false, "zh": "ICAO 公布 CORSIA 修訂指引", "en": "ICAO publishes CORSIA amendments", "articleId": "a-20260726-0342-corsia"},\n+ {"timeUtc": "2026-07-26T03:15Z", "hot": false, "zh": "澳航啟動 A350-1000ULR 雪梨—倫敦驗證飛行", "en": "Qantas starts A350-1000ULR SYD–LHR proving flights", "articleId": "a-nonexistent-story"}\n+]\n@@ -0,0 +1,12 @@\n+[\n+ {"date": "2026-07-26", "sev": "ser", "aircraft": "B737-8", "operator": "United", "phase": {"zh": "降落", "en": "Landing"}, "location": {"zh": "丹佛", "en": "Denver"}, "desc": {"zh": "重落地起落架受損", "en": "Hard landing, MLG damaged"}, "status": "open", "sources": ["FAA", "NTSB"], "articleId": "a-20260726-0547-denver737"},\n+ {"date": "2026-07-24", "sev": "inc", "aircraft": "A320neo", "operator": "Wizz Air", "phase": {"zh": "巡航", "en": "Cruise"}, "location": {"zh": "布達佩斯 FIR", "en": "Budapest FIR"}, "desc": {"zh": "高空引擎短暫熄火", "en": "Brief high-altitude flameout"}, "status": "open", "sources": ["EASA"], "articleId": null},\n+ {"date": "2026-07-22", "sev": "inc", "aircraft": "A320neo", "operator": "IndiGo", "phase": {"zh": "巡航", "en": "Cruise"}, "location": {"zh": "德里 FIR", "en": "Delhi FIR"}, "desc": {"zh": "高空引擎短暫熄火", "en": "Brief high-altitude flameout"}, "status": "open", "sources": ["DGCA", "AvHerald"], "articleId": null},\n+ {"date": "2026-07-19", "sev": "inc", "aircraft": "B787-9", "operator": "ANA", "phase": {"zh": "巡航", "en": "Cruise"}, "location": {"zh": "太平洋", "en": "Pacific"}, "desc": {"zh": "嚴重亂流 3 人輕傷", "en": "Severe turbulence, 3 minor injuries"}, "status": "closed", "sources": ["JTSB"], "articleId": null},\n+ {"date": "2026-07-15", "sev": "acc", "aircraft": "ATR72-600", "operator": {"zh": "區域營運人", "en": "Regional operator"}, "phase": {"zh": "起飛", "en": "Takeoff"}, "location": {"zh": "奈洛比", "en": "Nairobi"}, "desc": {"zh": "起飛後失去高度墜地", "en": "Lost height after takeoff"}, "status": "open", "sources": ["AvHerald"], "articleId": null},\n+ {"date": "2026-07-12", "sev": "inc", "aircraft": "A350-900", "operator": "Cathay Pacific", "phase": {"zh": "地面", "en": "Ground"}, "location": {"zh": "香港", "en": "Hong Kong"}, "desc": {"zh": "拖行時擦撞空橋", "en": "Towing contact with jet bridge"}, "status": "closed", "sources": ["HK AAIA"], "articleId": null},\n+ {"date": "2026-07-08", "sev": "inc", "aircraft": "B737-800", "operator": "Ryanair", "phase": {"zh": "進場", "en": "Approach"}, "location": {"zh": "都柏林", "en": "Dublin"}, "desc": {"zh": "不穩定進場重飛後正常落地", "en": "Unstable approach, go-around"}, "status": "closed", "sources": ["AAIU"], "articleId": null},\n+ {"date": "2026-07-05", "sev": "inc", "aircraft": "E190-E2", "operator": "KLM Cityhopper", "phase": {"zh": "降落", "en": "Landing"}, "location": {"zh": "阿姆斯特丹", "en": "Amsterdam"}, "desc": {"zh": "降落偏移至跑道邊緣", "en": "Veered to runway edge on landing"}, "status": "open", "sources": ["DSB"], "articleId": null},\n+ {"date": "2026-07-02", "sev": "ser", "aircraft": "B777F", "operator": "Atlas Air", "phase": {"zh": "起飛", "en": "Takeoff"}, "location": {"zh": "安克拉治", "en": "Anchorage"}, "desc": {"zh": "高速中止起飛", "en": "High-speed rejected takeoff"}, "status": "open", "sources": ["NTSB"], "articleId": null},\n+ {"date": "2026-06-28", "sev": "acc", "aircraft": "C208", "operator": {"zh": "通航營運人", "en": "GA operator"}, "phase": {"zh": "巡航", "en": "Cruise"}, "location": {"zh": "阿拉斯加", "en": "Alaska"}, "desc": {"zh": "山區失聯後尋獲殘骸", "en": "Wreckage found in mountains"}, "status": "prelim", "sources": ["NTSB"], "articleId": null}\n+]\n@@ -0,0 +1,12 @@\n+[\n+ {"key": "faa", "name": "FAA", "kind": "official", "fmt": "RSS", "url": "https://www.faa.gov", "cover": {"zh": "美國聯邦航空總署 — 新聞、AD、法規", "en": "US regulator — news, ADs, rules"}, "lastFetchUtc": "2026-07-26T05:02Z", "ok": true},\n+ {"key": "ntsb", "name": "NTSB", "kind": "official", "fmt": "RSS", "url": "https://www.ntsb.gov", "cover": {"zh": "美國運輸安全委員會 — 事故調查", "en": "US accident investigator"}, "lastFetchUtc": "2026-07-26T04:22Z", "ok": true},\n+ {"key": "icao", "name": "ICAO", "kind": "official", "fmt": "HTML", "url": "https://www.icao.int", "cover": {"zh": "國際民航組織 — 標準與環境", "en": "UN body — standards & environment"}, "lastFetchUtc": "2026-07-26T04:47Z", "ok": true},\n+ {"key": "iata", "name": "IATA", "kind": "industry", "fmt": "RSS", "url": "https://www.iata.org", "cover": {"zh": "產業數據與政策", "en": "Industry data & policy"}, "lastFetchUtc": "2026-07-26T04:33Z", "ok": true},\n+ {"key": "easa", "name": "EASA", "kind": "official", "fmt": "RSS", "url": "https://www.easa.europa.eu", "cover": {"zh": "歐盟航空安全局 — AD 與安全公告", "en": "EU safety agency — ADs & bulletins"}, "lastFetchUtc": "2026-07-26T04:41Z", "ok": true},\n+ {"key": "eurocontrol", "name": "Eurocontrol", "kind": "official", "fmt": "HTML", "url": "https://www.eurocontrol.int", "cover": {"zh": "歐洲空管網路狀態", "en": "European network operations"}, "lastFetchUtc": "2026-07-26T04:54Z", "ok": true},\n+ {"key": "reuters", "name": "Reuters", "kind": "media", "fmt": "RSS", "url": "https://www.reuters.com", "cover": {"zh": "路透社航太與國防版", "en": "Reuters aerospace & defense"}, "lastFetchUtc": "2026-07-26T05:08Z", "ok": true},\n+ {"key": "avherald", "name": "The Aviation Herald", "kind": "media", "fmt": "HTML", "url": "https://avherald.com", "cover": {"zh": "全球事故事件專業追報", "en": "Global incident tracking"}, "lastFetchUtc": "2026-07-25T22:18Z", "ok": false},\n+ {"key": "flightaware", "name": "FlightAware", "kind": "data", "fmt": "API", "url": "https://flightaware.com", "cover": {"zh": "航班追蹤與延誤數據", "en": "Flight tracking & delay data"}, "lastFetchUtc": "2026-07-26T05:05Z", "ok": true},\n+ {"key": "caataiwan", "name": "CAA Taiwan", "kind": "official", "fmt": "HTML", "url": "https://www.caa.gov.tw", "cover": {"zh": "交通部民航局 — 航班異動與公告", "en": "Taiwan CAA notices"}, "lastFetchUtc": null, "ok": false}\n+]\n@@ -0,0 +1,5 @@\n+{\n+ "updatedUtc": "2026-07-26T05:47Z",\n+ "flightsTracked": 18742,\n+ "seriousThisWeek": 3\n+}\n@@ -0,0 +1,11 @@\n+[\n+ {"timeUtc": "2026-07-25T09:00Z", "hot": false, "zh": "old flash 1", "en": "old flash 1", "articleId": "a-20260725-0900-old1"},\n+ {"timeUtc": "2026-07-25T08:00Z", "hot": false, "zh": "old flash 2", "en": "old flash 2", "articleId": "a-20260725-0800-old2"},\n+ {"timeUtc": "2026-07-25T07:00Z", "hot": false, "zh": "old flash 3", "en": "old flash 3", "articleId": "a-20260725-0700-old3"},\n+ {"timeUtc": "2026-07-25T06:00Z", "hot": false, "zh": "old flash 4", "en": "old flash 4", "articleId": "a-20260725-0600-old4"},\n+ {"timeUtc": "2026-07-25T05:00Z", "hot": false, "zh": "old flash 5", "en": "old flash 5", "articleId": "a-20260725-0500-old5"},\n+ {"timeUtc": "2026-07-25T04:00Z", "hot": false, "zh": "old flash 6", "en": "old flash 6", "articleId": "a-20260725-0400-old6"},\n+ {"timeUtc": "2026-07-25T03:00Z", "hot": false, "zh": "old flash 7", "en": "old flash 7", "articleId": "a-20260725-0300-old7"},\n+ {"timeUtc": "2026-07-25T02:00Z", "hot": false, "zh": "old flash 8", "en": "old flash 8", "articleId": "a-20260725-0200-old8"},\n+ {"timeUtc": "2026-07-25T01:00Z", "hot": false, "zh": "old flash 9", "en": "old flash 9", "articleId": "a-20260725-0100-old9"}\n+]\n@@ -0,0 +1,12 @@\n+{\n+ "source": "flightaware",\n+ "fetchedUtc": "2026-07-26T05:02Z",\n+ "ok": true,\n+ "error": null,\n+ "items": [],\n+ "stats": {\n+ "flightsTracked": 18742,\n+ "delayRate": 22.4,\n+ "notams": 1208\n+ }\n+}\n@@ -0,0 +1,38 @@\n+[\n+ {\n+ "date": "__RECENT_DATE__",\n+ "sev": "acc",\n+ "aircraft": "A320",\n+ "operator": "Example Air",\n+ "phase": {"zh": "起飛", "en": "Takeoff"},\n+ "location": {"zh": "測試場", "en": "Testville"},\n+ "desc": {"zh": "既有事故一", "en": "Pre-existing accident one"},\n+ "status": "open",\n+ "sources": ["FAA"],\n+ "articleId": null\n+ },\n+ {\n+ "date": "__OLD_DATE__",\n+ "sev": "ser",\n+ "aircraft": "B777",\n+ "operator": "Sample Airways",\n+ "phase": {"zh": "巡航", "en": "Cruise"},\n+ "location": {"zh": "太平洋", "en": "Pacific"},\n+ "desc": {"zh": "既有嚴重事件", "en": "Pre-existing serious incident"},\n+ "status": "closed",\n+ "sources": ["NTSB"],\n+ "articleId": null\n+ },\n+ {\n+ "date": "__RECENT_DATE__",\n+ "sev": "inc",\n+ "aircraft": "AT76",\n+ "operator": "Demo Air",\n+ "phase": {"zh": "滑行", "en": "Taxi"},\n+ "location": {"zh": "示範機場", "en": "Demo Field"},\n+ "desc": {"zh": "既有事件", "en": "Pre-existing minor incident"},\n+ "status": "prelim",\n+ "sources": ["EASA"],\n+ "articleId": null\n+ }\n+]\n@@ -0,0 +1,68 @@\n+{\n+ "generatedUtc": "2026-07-26T05:03Z",\n+ "groups": [\n+ {\n+ "id": "g-20260726-0503-1",\n+ "primarySource": "NTSB",\n+ "items": [\n+ {\n+ "title": "NTSB investigating United 737 runway excursion at Denver",\n+ "url": "https://www.ntsb.gov/news/press-releases/denver-737?utm_source=rss",\n+ "publishedUtc": "2026-07-25T22:10Z",\n+ "summary": "The NTSB said it is investigating a runway excursion involving a United Airlines Boeing 737-8 at Denver International Airport during landing on July 25. No injuries were reported.",\n+ "image": null,\n+ "source": "NTSB",\n+ "sourceKey": "ntsb"\n+ },\n+ {\n+ "title": "United jet slides off Denver runway, no injuries reported",\n+ "url": "https://www.reuters.com/business/aerospace-defense/united-denver-runway/",\n+ "publishedUtc": "2026-07-25T23:00Z",\n+ "summary": "A United Airlines Boeing 737-8 slid off the runway while landing at Denver International Airport on Friday, the carrier said. All passengers deplaned safely.",\n+ "image": "https://img.example.com/denver-737.jpg",\n+ "source": "Reuters",\n+ "sourceKey": "reuters"\n+ },\n+ {\n+ "title": "United jet slides off Denver runway, no injuries reported (update)",\n+ "url": "https://www.reuters.com/business/aerospace-defense/united-denver-runway",\n+ "publishedUtc": "2026-07-25T23:40Z",\n+ "summary": "Updated wire copy of the Denver runway excursion story.",\n+ "image": null,\n+ "source": "Reuters",\n+ "sourceKey": "reuters"\n+ }\n+ ]\n+ },\n+ {\n+ "id": "g-20260726-0503-2",\n+ "primarySource": "IATA",\n+ "items": [\n+ {\n+ "title": "IATA: global air cargo demand up 8.2% in June",\n+ "url": "https://www.iata.org/en/pressroom/2026-releases/2026-07-24-01/",\n+ "publishedUtc": "2026-07-24T09:00Z",\n+ "summary": "The International Air Transport Association reported that global air cargo demand, measured in cargo tonne-kilometers, rose 8.2% in June 2026 compared with June 2025.",\n+ "image": null,\n+ "source": "IATA",\n+ "sourceKey": "iata"\n+ }\n+ ]\n+ },\n+ {\n+ "id": "g-20260726-0503-3",\n+ "primarySource": "FAA",\n+ "items": [\n+ {\n+ "title": "FAA statement on unruly passenger enforcement",\n+ "url": "https://www.faa.gov/newsroom/unruly-passenger-statement",\n+ "publishedUtc": "2026-07-25T18:00Z",\n+ "summary": "The FAA issued a statement on enforcement actions against unruly passengers.",\n+ "image": null,\n+ "source": "FAA",\n+ "sourceKey": "faa"\n+ }\n+ ]\n+ }\n+ ]\n+}\n@@ -0,0 +1,10 @@\n+{\n+ "urls": {\n+ "https://old.example.com/ancient-story": "__OLD__",\n+ "https://recent.example.com/kept-story": "__RECENT__"\n+ },\n+ "titles": [\n+ ["old seen title", "__OLD__"],\n+ ["recent seen title", "__RECENT__"]\n+ ]\n+}\n@@ -0,0 +1,240 @@\n+"""Tests for pipeline/dedupe.py.\n+\n+No pytest required — run from the repo root with:\n+\n+ py tests\\test_dedupe.py\n+\n+Fixtures under tests/fixtures/dedupe/ use {{H-n}} / {{D-n}} placeholders\n+(n hours / days before now) so the 48-hour and 21-day windows never go\n+stale. Each test materializes the fixtures into a temp AVWIRE_DATA_DIR\n+and runs dedupe.py as a subprocess, never touching the real data/ dir.\n+"""\n+from __future__ import annotations\n+\n+import json\n+import os\n+import re\n+import subprocess\n+import sys\n+import tempfile\n+from datetime import datetime, timedelta, timezone\n+from pathlib import Path\n+\n+REPO = Path(__file__).resolve().parent.parent\n+FIXTURES = REPO / "tests" / "fixtures" / "dedupe"\n+DEDUPE_SCRIPT = REPO / "pipeline" / "dedupe.py"\n+\n+sys.path.insert(0, str(REPO / "pipeline"))\n+import dedupe # noqa: E402 (unit-level access to norm_title etc.)\n+\n+GROUP_ID_RE = re.compile(r"^g-\d{8}-\d{4}-(\d+)$")\n+_PLACEHOLDER_RE = re.compile(r"\{\{(H|D)-(\d+)\}\}")\n+\n+\n+def _stamp(**delta) -> str:\n+ dt = datetime.now(timezone.utc) - timedelta(**delta)\n+ return dt.strftime("%Y-%m-%dT%H:%MZ")\n+\n+\n+def _render(text: str) -> str:\n+ def sub(match: re.Match) -> str:\n+ unit, n = match.group(1), int(match.group(2))\n+ return _stamp(hours=n) if unit == "H" else _stamp(days=n)\n+\n+ return _PLACEHOLDER_RE.sub(sub, text)\n+\n+\n+def _materialize_fixtures(data_dir: Path) -> None:\n+ for src in FIXTURES.rglob("*.json"):\n+ dst = data_dir / src.relative_to(FIXTURES)\n+ dst.parent.mkdir(parents=True, exist_ok=True)\n+ dst.write_text(_render(src.read_text(encoding="utf-8")), encoding="utf-8")\n+\n+\n+def _run_dedupe(data_dir: Path) -> str:\n+ env = dict(os.environ, AVWIRE_DATA_DIR=str(data_dir))\n+ proc = subprocess.run(\n+ [sys.executable, str(DEDUPE_SCRIPT)],\n+ cwd=REPO,\n+ env=env,\n+ capture_output=True,\n+ text=True,\n+ encoding="utf-8",\n+ errors="replace",\n+ )\n+ assert proc.returncode == 0, f"dedupe exited {proc.returncode}: {proc.stderr}"\n+ return proc.stdout.strip()\n+\n+\n+def _load_pending(data_dir: Path) -> dict:\n+ with open(data_dir / "pending.json", encoding="utf-8") as fh:\n+ return json.load(fh)\n+\n+\n+def test_norm_title_and_tiebreak() -> None:\n+ assert dedupe.norm_title("Hello, WORLD!! - Reuters") == "hello world"\n+ assert dedupe.norm_title("A_B - C - The Aviation Herald") == "a b c"\n+ # A long trailing segment is content, not a source suffix.\n+ long_tail = "Engine shut down - crew declared emergency and diverted to Keflavik"\n+ assert "emergency" in dedupe.norm_title(long_tail)\n+ # Equal summary lengths -> SOURCES registry order (faa before reuters).\n+ tie = [\n+ {"title": "x", "summary": "same size", "sourceKey": "reuters", "source": "Reuters"},\n+ {"title": "y", "summary": "same size", "sourceKey": "faa", "source": "FAA"},\n+ ]\n+ assert dedupe._best_first(tie)[0]["sourceKey"] == "faa"\n+\n+\n+def test_filtering_grouping_and_ranking() -> None:\n+ with tempfile.TemporaryDirectory(prefix="avwire-dedupe-") as tmp:\n+ data_dir = Path(tmp)\n+ _materialize_fixtures(data_dir)\n+ seen_before = (data_dir / "seen.json").read_bytes()\n+\n+ stdout = _run_dedupe(data_dir)\n+ # 14 raw (easa ok=false skipped) -> 10 fresh (dropped: seen URL,\n+ # seen title, stale, empty title) -> 4 groups.\n+ assert stdout == "dedupe: 14 raw items -> 10 fresh -> 4 groups", stdout\n+\n+ # dedupe must never write seen.json.\n+ assert (data_dir / "seen.json").read_bytes() == seen_before\n+\n+ pending = _load_pending(data_dir)\n+ assert re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$", pending["generatedUtc"])\n+ groups = pending["groups"]\n+ assert len(groups) == 4\n+\n+ for n, group in enumerate(groups, start=1):\n+ match = GROUP_ID_RE.match(group["id"])\n+ assert match and int(match.group(1)) == n, group["id"]\n+ for item in group["items"]:\n+ assert item["source"] and item["sourceKey"], item\n+\n+ all_titles = [i["title"] for g in groups for i in g["items"]]\n+ for gone in (\n+ "FAA statement on winter operations", # seen URL\n+ "FAA archives old advisory circular", # stale (72 h)\n+ "Boeing delivers 100th 787 to Emirates!", # seen title, 2 days ago\n+ "EASA phantom item that must be ignored", # ok=false snapshot\n+ " ", # empty title\n+ ):\n+ assert gone not in all_titles, gone\n+\n+ # Group 1: the multi-source Denver story ranks first; Reuters has\n+ # the longer summary so it is primary and its item comes first.\n+ denver = groups[0]\n+ assert {i["sourceKey"] for i in denver["items"]} == {"faa", "reuters"}\n+ assert denver["primarySource"] == "Reuters"\n+ assert denver["items"][0]["sourceKey"] == "reuters"\n+ # Raw item fields survive with the two annotations added.\n+ assert set(denver["items"][0]) == {\n+ "title", "url", "publishedUtc", "summary", "image",\n+ "source", "sourceKey",\n+ }\n+ assert denver["items"][0]["image"] == "https://www.reuters.com/img/denver.jpg"\n+\n+ # Remaining single-source groups ranked by newest publishedUtc:\n+ # Airbus (H-5), NTSB (H-6), balloons (H-7).\n+ assert groups[1]["items"][0]["title"] == (\n+ "Airbus opens new A320 production line in Toulouse"\n+ ) # seen-title match is 30 days old -> outside the 21-day window\n+ assert groups[2]["items"][0]["title"].startswith("NTSB opens investigation")\n+\n+ # Balloon group capped at 5, keeping the longest summaries.\n+ balloons = groups[3]\n+ assert balloons["primarySource"] == "The Aviation Herald"\n+ titles = [i["title"] for i in balloons["items"]]\n+ assert len(titles) == 5\n+ assert "Emergency balloon landing drill unit six" not in titles\n+ lengths = [len(i["summary"]) for i in balloons["items"]]\n+ assert lengths == sorted(lengths, reverse=True)\n+\n+\n+def test_group_cap_and_recency_order() -> None:\n+ titles = [\n+ "Quantum kettle recall expands to blue models",\n+ "Harbor tug crews vote on midnight shifts",\n+ "Desert observatory spots rogue comet fragment",\n+ "City council approves rooftop garden fund",\n+ "Vintage tram line reopens after decade",\n+ "Chess prodigy wins winter invitational",\n+ "Glacier survey drones map hidden crevasse",\n+ "Bakery chain debuts rye sourdough club",\n+ "Submarine cable repair finishes ahead early",\n+ "Wind farm output breaks provincial record",\n+ "Museum returns bronze artifacts to island",\n+ "Startup prints coral reef scaffolding tiles",\n+ "Marathon reroute avoids flooded underpass",\n+ "Library digitizes medieval falconry manuscripts",\n+ "Volcano sensors detect faint harmonic tremor",\n+ ]\n+ # Self-check: fixture titles must be pairwise dissimilar (< 0.6).\n+ normed = [dedupe.norm_title(t) for t in titles]\n+ for i in range(len(normed)):\n+ for j in range(i + 1, len(normed)):\n+ sim = dedupe._similar(normed[i], normed[j])\n+ assert sim < dedupe.GROUP_TITLE_SIM, (titles[i], titles[j], sim)\n+\n+ now = datetime.now(timezone.utc)\n+ items = [\n+ {\n+ "title": title,\n+ "url": f"https://www.faa.gov/newsroom/story-{i}",\n+ "publishedUtc": (now - timedelta(minutes=10 * (i + 1))).strftime(\n+ "%Y-%m-%dT%H:%MZ"\n+ ),\n+ "summary": f"Summary for story {i}.",\n+ "image": None,\n+ }\n+ for i, title in enumerate(titles)\n+ ]\n+ with tempfile.TemporaryDirectory(prefix="avwire-dedupe-") as tmp:\n+ data_dir = Path(tmp)\n+ raw_dir = data_dir / "raw"\n+ raw_dir.mkdir(parents=True)\n+ snapshot = {\n+ "source": "faa",\n+ "fetchedUtc": _stamp(hours=0),\n+ "ok": True,\n+ "error": None,\n+ "items": items,\n+ }\n+ (raw_dir / "faa.json").write_text(\n+ json.dumps(snapshot, ensure_ascii=False, indent=2), encoding="utf-8"\n+ )\n+ # No seen.json at all: dedupe must cope with it missing.\n+ stdout = _run_dedupe(data_dir)\n+ assert stdout == "dedupe: 15 raw items -> 15 fresh -> 12 groups", stdout\n+\n+ groups = _load_pending(data_dir)["groups"]\n+ assert len(groups) == dedupe.MAX_GROUPS == 12\n+ assert all(len(g["items"]) == 1 for g in groups)\n+ # Single-source groups rank purely by newest publishedUtc, so the\n+ # 12 newest stories are emitted in recency order.\n+ assert [g["items"][0]["title"] for g in groups] == titles[:12]\n+\n+\n+def test_empty_data_dir() -> None:\n+ with tempfile.TemporaryDirectory(prefix="avwire-dedupe-") as tmp:\n+ data_dir = Path(tmp)\n+ stdout = _run_dedupe(data_dir)\n+ assert stdout == "dedupe: 0 raw items -> 0 fresh -> 0 groups", stdout\n+ pending = _load_pending(data_dir)\n+ assert pending["groups"] == []\n+\n+\n+def main() -> None:\n+ tests = [\n+ test_norm_title_and_tiebreak,\n+ test_filtering_grouping_and_ranking,\n+ test_group_cap_and_recency_order,\n+ test_empty_data_dir,\n+ ]\n+ for test in tests:\n+ test()\n+ print(f"PASS {test.__name__}")\n+ print(f"all {len(tests)} dedupe tests passed")\n+\n+\n+if __name__ == "__main__":\n+ main()\n@@ -0,0 +1,345 @@\n+"""Offline tests for pipeline/write.py (no API key, no network, no pytest).\n+\n+Run from the repo root: py tests\\test_write_offline.py (or python3 on Linux)\n+\n+The tests point the pipeline at a temporary data directory via AVWIRE_DATA_DIR\n+and monkeypatch write.draft_group with canned model output, so no Anthropic\n+API call is ever attempted.\n+"""\n+from __future__ import annotations\n+\n+import json\n+import os\n+import re\n+import shutil\n+import subprocess\n+import sys\n+import tempfile\n+import traceback\n+from datetime import timedelta\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+FIXTURES = Path(__file__).resolve().parent / "fixtures" / "write"\n+TMP_BASE = Path(tempfile.mkdtemp(prefix="avwire-write-test-"))\n+DATA = TMP_BASE / "data"\n+\n+# Must be set BEFORE importing common/write (common.DATA_DIR reads it at import).\n+os.environ["AVWIRE_DATA_DIR"] = str(DATA)\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import common # noqa: E402\n+import write # noqa: E402\n+\n+NOW = common.now_utc()\n+OLD_STAMP = common.iso_minute(NOW - timedelta(days=30))\n+RECENT_STAMP = common.iso_minute(NOW - timedelta(days=3))\n+OLD_DATE = (NOW - timedelta(days=30)).strftime("%Y-%m-%d")\n+RECENT_DATE = (NOW - timedelta(days=2)).strftime("%Y-%m-%d")\n+\n+DRAFT_SAFETY = {\n+ "cat": "safety",\n+ "zh": {\n+ "title": "聯合航空737丹佛衝出跑道",\n+ "summary": "聯合航空一架737-8於丹佛降落時衝出跑道,無人受傷,NTSB已展開調查。",\n+ "body": ["第一段。", "第二段。"],\n+ },\n+ "en": {\n+ "title": "United 737 slides off Denver runway",\n+ "summary": "A United Airlines 737-8 slid off the runway while landing at Denver; no injuries were reported and the NTSB is investigating.",\n+ "body": ["First paragraph.", "Second paragraph."],\n+ },\n+ "flash": {\n+ "zh": "聯航737丹佛衝出跑道 無人受傷",\n+ "en": "United 737 slides off Denver runway; no injuries",\n+ "hot": True,\n+ },\n+ "incident": {\n+ "date": (NOW - timedelta(days=1)).strftime("%Y-%m-%d"),\n+ "sev": "ser",\n+ "aircraft": "B737-8",\n+ "operator": "United",\n+ "phase": {"zh": "降落", "en": "Landing"},\n+ "location": {"zh": "丹佛", "en": "Denver"},\n+ "desc": {"zh": "降落時衝出跑道", "en": "Runway excursion on landing"},\n+ "status": "open",\n+ },\n+}\n+\n+DRAFT_BIZ = {\n+ "cat": "biz",\n+ "zh": {\n+ "title": "IATA:六月全球航空貨運需求年增8.2%",\n+ "summary": "IATA公布六月全球航空貨運需求較去年同期成長8.2%。",\n+ "body": ["第一段。", "第二段。"],\n+ },\n+ "en": {\n+ "title": "IATA: global air cargo demand up 8.2% in June",\n+ "summary": "IATA reported global air cargo demand rose 8.2% in June 2026 year on year.",\n+ "body": ["First paragraph.", "Second paragraph."],\n+ },\n+ "flash": {\n+ "zh": "IATA:六月航空貨運年增8.2%",\n+ "en": "IATA: June air cargo demand up 8.2%",\n+ "hot": False,\n+ },\n+ "incident": None,\n+}\n+\n+_pass = 0\n+_fail = 0\n+\n+\n+def check(cond, label):\n+ global _pass, _fail\n+ if cond:\n+ _pass += 1\n+ else:\n+ _fail += 1\n+ print(f"FAIL: {label}")\n+\n+\n+def load(path):\n+ with open(path, encoding="utf-8") as fh:\n+ return json.load(fh)\n+\n+\n+def reset_data_dir():\n+ """Populate the temp data dir from the static fixtures."""\n+ if DATA.exists():\n+ shutil.rmtree(DATA)\n+ (DATA / "raw").mkdir(parents=True)\n+ (DATA / "articles").mkdir()\n+ shutil.copy(FIXTURES / "pending.json", DATA / "pending.json")\n+ shutil.copy(FIXTURES / "flashes.json", DATA / "flashes.json")\n+ shutil.copy(FIXTURES / "flightaware.json", DATA / "raw" / "flightaware.json")\n+ seen = (FIXTURES / "seen.json").read_text(encoding="utf-8")\n+ seen = seen.replace("__OLD__", OLD_STAMP).replace("__RECENT__", RECENT_STAMP)\n+ (DATA / "seen.json").write_text(seen, encoding="utf-8")\n+ incidents = (FIXTURES / "incidents.json").read_text(encoding="utf-8")\n+ incidents = incidents.replace("__OLD_DATE__", OLD_DATE)\n+ incidents = incidents.replace("__RECENT_DATE__", RECENT_DATE)\n+ (DATA / "incidents.json").write_text(incidents, encoding="utf-8")\n+\n+\n+def all_articles():\n+ rows = []\n+ for path in sorted((DATA / "articles").glob("*.json")):\n+ rows.extend(load(path).get("articles", []))\n+ return rows\n+\n+\n+def test_publish_flow():\n+ """Two groups publish, one is refused; every output file is checked."""\n+ reset_data_dir()\n+ # Pre-seed the current hour's batch file to prove merging works.\n+ hour_path = DATA / "articles" / (NOW.strftime("%Y-%m-%dT%H") + ".json")\n+ dummy = {"articles": [{"id": "a-existing-dummy", "publishedUtc": OLD_STAMP,\n+ "cat": "ops", "primarySource": "FAA", "image": None,\n+ "zh": {}, "en": {}, "sources": [{"name": "x", "url": "y"}]}]}\n+ hour_path.write_text(json.dumps(dummy), encoding="utf-8")\n+\n+ os.environ["ANTHROPIC_API_KEY"] = "test-key-not-real"\n+\n+ def fake_draft(client, group):\n+ if group["id"] == "g-20260726-0503-1":\n+ return DRAFT_SAFETY\n+ if group["id"] == "g-20260726-0503-2":\n+ return DRAFT_BIZ\n+ return None # refusal path: group stays pending + unseen\n+\n+ original = write.draft_group\n+ write.draft_group = fake_draft\n+ try:\n+ write.main()\n+ finally:\n+ write.draft_group = original\n+\n+ # --- articles batch file: schema + merge -------------------------------\n+ articles = all_articles()\n+ check(len(articles) == 3, f"expected 3 articles (1 dummy + 2 new), got {len(articles)}")\n+ check(any(a["id"] == "a-existing-dummy" for a in articles),\n+ "pre-existing article kept after merge")\n+ new = [a for a in articles if a["id"] != "a-existing-dummy"]\n+ safety = next((a for a in new if a["cat"] == "safety"), None)\n+ biz = next((a for a in new if a["cat"] == "biz"), None)\n+ check(safety is not None and biz is not None, "safety + biz articles published")\n+ for art in new:\n+ check(re.fullmatch(r"a-\d{8}-\d{4}-[a-z0-9-]+", art["id"]) is not None,\n+ f"article id format: {art['id']}")\n+ check(re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z", art["publishedUtc"]) is not None,\n+ f"publishedUtc format: {art['publishedUtc']}")\n+ check(isinstance(art["sources"], list) and len(art["sources"]) >= 1,\n+ "article has at least one source")\n+ check(all("name" in s and "url" in s for s in art["sources"]),\n+ "every source has name and url")\n+ check(isinstance(art["zh"]["body"], list) and isinstance(art["en"]["body"], list),\n+ "zh/en bodies are paragraph lists")\n+ check(safety["id"].endswith("united-737-slides-off-denver-runway"),\n+ f"slug derived from en title: {safety['id']}")\n+ # 3 items in g-1, but two Reuters urls normalize to the same address.\n+ check(len(safety["sources"]) == 2,\n+ f"sources deduped by url, got {len(safety['sources'])}")\n+ check(safety["image"] == "https://img.example.com/denver-737.jpg",\n+ "image is first non-null item image")\n+ check(biz["image"] is None, "biz article image is null")\n+ check(safety["primarySource"] == "NTSB", "primarySource carried from group")\n+ check(safety["sources"][0]["name"].startswith("NTSB — "),\n+ "source name = display name + shortened title")\n+\n+ # --- flashes: prepended with articleId, capped at MAX_FLASHES ----------\n+ flashes = load(DATA / "flashes.json")\n+ check(len(flashes) == common.MAX_FLASHES,\n+ f"flashes capped at {common.MAX_FLASHES}, got {len(flashes)}")\n+ check(flashes[0]["articleId"] == safety["id"] and flashes[0]["hot"] is True,\n+ "newest flash first, hot flag preserved")\n+ check(flashes[1]["articleId"] == biz["id"] and flashes[1]["hot"] is False,\n+ "second flash is the biz story")\n+ check(all(f["zh"] != "old flash 9" for f in flashes),\n+ "oldest flash dropped by the cap")\n+\n+ # --- incidents: new row prepended with articleId ------------------------\n+ incidents = load(DATA / "incidents.json")\n+ check(len(incidents) == 4, f"expected 4 incidents, got {len(incidents)}")\n+ check(incidents[0]["aircraft"] == "B737-8" and incidents[0]["sev"] == "ser",\n+ "new incident row prepended")\n+ check(incidents[0]["articleId"] == safety["id"], "incident links to article")\n+ check(incidents[0]["sources"] == ["NTSB", "Reuters"],\n+ f"incident sources from group items, got {incidents[0]['sources']}")\n+\n+ # --- seen.json: only published groups, pruned > 21 days ----------------\n+ seen = load(DATA / "seen.json")\n+ pending_fixture = load(FIXTURES / "pending.json")\n+ g1, g2, g3 = pending_fixture["groups"]\n+ for item in g1["items"] + g2["items"]:\n+ check(common.norm_url(item["url"]) in seen["urls"],\n+ f"published url recorded: {item['url']}")\n+ check(common.norm_url(g3["items"][0]["url"]) not in seen["urls"],\n+ "failed group url NOT recorded in seen.json")\n+ check(common.norm_url("https://recent.example.com/kept-story") in seen["urls"],\n+ "recent pre-existing seen url kept")\n+ check(common.norm_url("https://old.example.com/ancient-story") not in seen["urls"],\n+ "seen url older than 21 days pruned")\n+ title_keys = [row[0] for row in seen["titles"]]\n+ check("recent seen title" in title_keys, "recent seen title kept")\n+ check("old seen title" not in title_keys, "old seen title pruned")\n+ check(any("united jet slides off denver runway" in t for t in title_keys),\n+ "published titles recorded (normalized)")\n+\n+ # --- pending.json: published groups removed, failed group kept ---------\n+ pending = load(DATA / "pending.json")\n+ ids = [g["id"] for g in pending["groups"]]\n+ check(ids == ["g-20260726-0503-3"], f"only failed group left pending, got {ids}")\n+ check(pending["generatedUtc"] == pending_fixture["generatedUtc"],\n+ "pending generatedUtc preserved")\n+\n+ # --- stats.json ----------------------------------------------------------\n+ stats = load(DATA / "stats.json")\n+ check(re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z", stats["updatedUtc"]) is not None,\n+ "stats updatedUtc format")\n+ # fixture recent acc (counts) + new ser (counts); old ser + recent inc don't.\n+ check(stats["seriousThisWeek"] == 2,\n+ f"seriousThisWeek == 2, got {stats['seriousThisWeek']}")\n+ check(stats["flightsTracked"] == 18742 and stats["delayRate"] == 22.4\n+ and stats["notams"] == 1208, "FlightAware stats merged")\n+ print("test_publish_flow: done")\n+\n+\n+def test_group_cap_and_unique_ids():\n+ """Only the first 10 groups are drafted; duplicate slugs get unique ids."""\n+ reset_data_dir()\n+ for name in ("flashes.json", "incidents.json", "seen.json"):\n+ (DATA / name).unlink()\n+ groups = [\n+ {\n+ "id": f"g-cap-{i}",\n+ "primarySource": "FAA",\n+ "items": [{\n+ "title": f"FAA notice number {i}",\n+ "url": f"https://www.faa.gov/newsroom/notice-{i}",\n+ "publishedUtc": "2026-07-26T04:00Z",\n+ "summary": "s",\n+ "image": None,\n+ "source": "FAA",\n+ "sourceKey": "faa",\n+ }],\n+ }\n+ for i in range(12)\n+ ]\n+ (DATA / "pending.json").write_text(\n+ json.dumps({"generatedUtc": "2026-07-26T05:03Z", "groups": groups}),\n+ encoding="utf-8")\n+\n+ os.environ["ANTHROPIC_API_KEY"] = "test-key-not-real"\n+ original = write.draft_group\n+ write.draft_group = lambda client, group: DRAFT_BIZ # same en title every time\n+ try:\n+ write.main()\n+ finally:\n+ write.draft_group = original\n+\n+ articles = all_articles()\n+ check(len(articles) == write.MAX_GROUPS_PER_RUN,\n+ f"cap: {write.MAX_GROUPS_PER_RUN} articles, got {len(articles)}")\n+ ids = [a["id"] for a in articles]\n+ check(len(set(ids)) == len(ids), "colliding slugs got unique article ids")\n+ pending = load(DATA / "pending.json")\n+ check(len(pending["groups"]) == 2,\n+ f"2 groups beyond the cap stay pending, got {len(pending['groups'])}")\n+ seen = load(DATA / "seen.json")\n+ check(len(seen["urls"]) == write.MAX_GROUPS_PER_RUN,\n+ f"seen has exactly the published urls, got {len(seen['urls'])}")\n+ flashes = load(DATA / "flashes.json")\n+ check(len(flashes) == write.MAX_GROUPS_PER_RUN, "flashes written from empty file")\n+ print("test_group_cap_and_unique_ids: done")\n+\n+\n+def test_no_api_key_end_to_end():\n+ """Without ANTHROPIC_API_KEY: exit 0, pending untouched, stats refreshed."""\n+ tmp2 = TMP_BASE / "nokey-data"\n+ (tmp2 / "raw").mkdir(parents=True)\n+ shutil.copy(FIXTURES / "pending.json", tmp2 / "pending.json")\n+ shutil.copy(FIXTURES / "flightaware.json", tmp2 / "raw" / "flightaware.json")\n+ pending_before = (tmp2 / "pending.json").read_bytes()\n+\n+ env = dict(os.environ)\n+ env.pop("ANTHROPIC_API_KEY", None)\n+ env["AVWIRE_DATA_DIR"] = str(tmp2)\n+ env["PYTHONIOENCODING"] = "utf-8"\n+ result = subprocess.run(\n+ [sys.executable, str(ROOT / "pipeline" / "write.py")],\n+ cwd=str(ROOT), env=env, capture_output=True, text=True, timeout=120,\n+ )\n+ check(result.returncode == 0, f"exit code 0 without key, got {result.returncode}: "\n+ f"{result.stderr.strip()}")\n+ check("ANTHROPIC_API_KEY" in result.stdout, "notice printed to stdout")\n+ check((tmp2 / "pending.json").read_bytes() == pending_before,\n+ "pending.json untouched without key")\n+ stats = load(tmp2 / "stats.json")\n+ check("updatedUtc" in stats, "stats.json refreshed without key")\n+ check(stats["seriousThisWeek"] == 0, "seriousThisWeek 0 with no incidents.json")\n+ check(stats["flightsTracked"] == 18742, "FlightAware stats merged without key")\n+ check(not (tmp2 / "seen.json").exists(), "seen.json not created without key")\n+ print("test_no_api_key_end_to_end: done")\n+\n+\n+def main():\n+ tests = [test_publish_flow, test_group_cap_and_unique_ids,\n+ test_no_api_key_end_to_end]\n+ crashed = False\n+ for test in tests:\n+ try:\n+ test()\n+ except Exception:\n+ crashed = True\n+ print(f"ERROR in {test.__name__}:")\n+ traceback.print_exc()\n+ shutil.rmtree(TMP_BASE, ignore_errors=True)\n+ print(f"{_pass} checks passed, {_fail} failed")\n+ if _fail or crashed:\n+ sys.exit(1)\n+ print("OK")\n+\n+\n+if __name__ == "__main__":\n+ main()