{
  "sha": "f17b5452fb283bbfce5786728c2fc68eeea9be06",
  "message": "Add complete site changelog and footer link (#17)\n\nPublish the bilingual curated Git history with validated commit links and a language-correct footer button on every page.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-28T14:00:59Z",
  "files": [
    {
      "filename": "config/changelog.json",
      "previous_filename": "",
      "status": "added",
      "additions": 351,
      "deletions": 0,
      "changes": 351
    },
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 11,
      "deletions": 0,
      "changes": 11
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 103,
      "deletions": 1,
      "changes": 104
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 56,
      "deletions": 0,
      "changes": 56
    },
    {
      "filename": "templates/base.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "templates/changelog.html",
      "previous_filename": "",
      "status": "added",
      "additions": 52,
      "deletions": 0,
      "changes": 52
    },
    {
      "filename": "tests/test_changelog.py",
      "previous_filename": "",
      "status": "added",
      "additions": 106,
      "deletions": 0,
      "changes": 106
    }
  ],
  "diff": "@@ -0,0 +1,351 @@\\n+{\\n+  \"schemaVersion\": 1,\\n+  \"repository\": \"https://github.com/nnneeeooo-nnneeeooo/avwire\",\\n+  \"historyStart\": \"2026-07-26\",\\n+  \"updatedThrough\": \"2026-07-28\",\\n+  \"entries\": [\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": null,\\n+      \"zh\": \"新增雙語網站更新紀錄頁與全站頁尾入口\",\\n+      \"en\": \"Added a bilingual site changelog and a footer entry point on every page\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"3d06ac0de74de8ac9f4b7fea8e8952fed695ad72\",\\n+      \"zh\": \"修正民航財報因次要國防內容而誤分類為軍事新聞\",\\n+      \"en\": \"Fixed civil-company results being misclassified as military because of secondary defence content\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"c2170da78841897571b9c13353f890b40b071631\",\\n+      \"zh\": \"加入飛機與發動機製造商官方消息來源\",\\n+      \"en\": \"Added official aircraft and engine manufacturer news sources\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"616fa8c191dd31db313c4fc24f380b22f565db17\",\\n+      \"zh\": \"調整撰稿模型署名並在用量頁分列 GPT-5.6 Sol 理論成本\",\\n+      \"en\": \"Refined writer credits and separated GPT-5.6 Sol reference costs on the usage page\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"51d2778c0400bf491a8fc94c4e31dd92834a5c42\",\\n+      \"zh\": \"人工覆核並發布瑞航 LX16 班戈煙霧改降事件\",\\n+      \"en\": \"Manually reviewed and published the SWISS LX16 Bangor smoke diversion\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"design\",\\n+      \"commit\": \"0e1f146504babf44d1323e06074ded9b9ca2e86b\",\\n+      \"zh\": \"簡化文章文末的自動生成聲明與模型署名\",\\n+      \"en\": \"Simplified the automated-generation notice and model credit in article footers\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"d4ce44d46577bc5a9ae728bf7926e46a2e7129ca\",\\n+      \"zh\": \"撰稿流程加入可追溯的受控歷史脈絡與短訊降級\",\\n+      \"en\": \"Added traceable verified historical context and controlled brief fallback to drafting\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-28\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"155cb67854fd3ae8aaab70ee98ed24f64f73d45e\",\\n+      \"zh\": \"航班雷達加入 ICAO／IATA 呼號切換與起迄航點\",\\n+      \"en\": \"Added ICAO/IATA callsign switching and origin-destination routes to flight radar\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"1089c36017707b5e8458f8063ce70b30b4b91af1\",\\n+      \"zh\": \"新增台灣民航 ADS-B 航班雷達\",\\n+      \"en\": \"Added the Taiwan civil ADS-B flight radar\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"62f691b95e5a01f55f05b5fd3290fa10df670a10\",\\n+      \"zh\": \"排除與航空或運輸無直接關係的軍事新聞\",\\n+      \"en\": \"Filtered military stories without a direct aviation or transport subject\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"9883b8bb937428b9254c729d6fa6bdbc08eb4753\",\\n+      \"zh\": \"加強離題新聞過濾並支援文章封存\",\\n+      \"en\": \"Strengthened off-topic filtering and added article archiving\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"dbbb0472e8aa507b232eb214225aa0cad5dea29e\",\\n+      \"zh\": \"加入完整新聞稿最低字數與段落要求\",\\n+      \"en\": \"Enforced minimum length and paragraph requirements for full articles\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"e7f04f4d2f37d3d0414c2b81a073f37e42db06aa\",\\n+      \"zh\": \"修正 Gatwick 的台灣慣用譯名\",\\n+      \"en\": \"Corrected the Taiwan rendering of Gatwick\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"867faafe27770f851b703ea1c2249ae8d06df1db\",\\n+      \"zh\": \"新增軍事新聞分類與前端標籤\",\\n+      \"en\": \"Added the military news category and front-end label\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"883d47b7d80e3d181c7b00f2f62601e951d5110e\",\\n+      \"zh\": \"每小時建置前同步最新快報資料，避免部署競態覆蓋\",\\n+      \"en\": \"Synced current briefing data before hourly builds to prevent deployment races\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"design\",\\n+      \"commit\": \"ffe2f63113f1697931e685bcae93781d2d6c66a0\",\\n+      \"zh\": \"快報來源異常改用讀者可理解的中性說明\",\\n+      \"en\": \"Replaced quota-oriented briefing errors with neutral reader-facing outage copy\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"e3c6b6a954684780aff9e05dfc8b08fd01bb1078\",\\n+      \"zh\": \"在渲染階段同步執行快報與正式新聞的分流規則\",\\n+      \"en\": \"Enforced briefing-versus-formal-article separation during rendering\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"b309c745abd04e031cc4db5e5698242152d9cc60\",\\n+      \"zh\": \"快報改為獨立條列速報，不再鏡像正式新聞\",\\n+      \"en\": \"Made briefings standalone bulletins instead of mirrors of formal articles\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"bd65912044d9c89e91884b2033896a093e5f3dfd\",\\n+      \"zh\": \"共機艦系列加入本站已驗證統計作為比較脈絡\",\\n+      \"en\": \"Added verified AVWIRE statistics as comparison context for PLA activity reports\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"design\",\\n+      \"commit\": \"8fb37161b09fbe42fa5b76e725593b68173cb344\",\\n+      \"zh\": \"首頁 LIVE 快訊跑馬燈可直接開啟對應新聞\",\\n+      \"en\": \"Made every LIVE ticker item link directly to its story\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"d157938c8b1357901ae3607aa47982b7068144d9\",\\n+      \"zh\": \"僅有短摘要的來源改作發現索引，不直接寫成完整新聞\",\\n+      \"en\": \"Turned excerpt-only feeds into discovery indexes instead of full-article evidence\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"ca02039bd967e5f171183f38a1bfda588224b44b\",\\n+      \"zh\": \"新增杜撰偵測防線並誠實處理資訊不足的衍生報導\",\\n+      \"en\": \"Added a fabrication tripwire and safer handling of thin derivative reports\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"c89ebbe19866e0acbbe653bf96f177bbbf213ab4\",\\n+      \"zh\": \"加入機器強制的專有名詞台灣譯名表\",\\n+      \"en\": \"Added a machine-enforced glossary for Taiwan proper-name renderings\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"1440c88c94e3d3fd7084fedd03dd78084a5cf286\",\\n+      \"zh\": \"搜尋型快報失敗紀錄保留回應內容以利除錯\",\\n+      \"en\": \"Included response bodies in grounded-briefing failure diagnostics\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"2d31eaf309de8a3726fe96030646554d1f074069\",\\n+      \"zh\": \"避免空白搜尋模型環境變數清除預設模型名稱\",\\n+      \"en\": \"Prevented an empty grounded-model setting from erasing the default model ID\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"3c569d96f84dd2b35e8486c13f6f967e3e0dc25d\",\\n+      \"zh\": \"快報 Beta 加入經核准的搜尋型資料掃描\",\\n+      \"en\": \"Added the approved grounded-search sweep to briefing Beta\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"9e13218902589b08ad93116d8dca2fc261bf4580\",\\n+      \"zh\": \"快報升級為指定版型並納入台灣軍航與交通來源\",\\n+      \"en\": \"Upgraded briefings to the requested format with Taiwan military-air and transport sources\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"7f4234edf5a42e6e85b6180a29cb007c4075b698\",\\n+      \"zh\": \"稀有航機觀測可通過機器審核後自動發布\",\\n+      \"en\": \"Allowed rare-aircraft observations to auto-publish through a machine gate\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"design\",\\n+      \"commit\": \"a7e8d762dff5ad4d64996183307e17dd5734b019\",\\n+      \"zh\": \"新聞照片改為完整比例、全彩顯示並標示主體\",\\n+      \"en\": \"Changed news photos to full-frame colour display with subject labels\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"c207832fa560f119bcaa45a3e0040917f5d97315\",\\n+      \"zh\": \"照片配對加入機場別名與主管機關備援\",\\n+      \"en\": \"Added airport aliases and agency fallback to photo matching\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"7969b78c9ec99a472e62c21785cbbca56b490b06\",\\n+      \"zh\": \"新增不公開連結的 API 用量與理論成本儀表板\",\\n+      \"en\": \"Added a private API-usage and theoretical-cost dashboard\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"e568151159066eee529e4201baeb22be237072c0\",\\n+      \"zh\": \"官方網頁全文可進入撰稿流程以支援完整新聞\",\\n+      \"en\": \"Fed official-page full text into drafting to support full-length articles\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"e817e0b43b3fdf43d1ce5fad41023e52e1440213\",\\n+      \"zh\": \"以免費無金鑰來源自動配對可授權新聞照片\",\\n+      \"en\": \"Added free keyless matching of reusable news photos\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"54cc76080f33b526718d025ad1c5852309811c59\",\\n+      \"zh\": \"新增依臺北固定時段產生的每日三次航空與交通快報\",\\n+      \"en\": \"Added three daily air-and-transport briefings on fixed Taipei windows\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"6dc975bce396593d514966c5fd21e0c90191b5b7\",\\n+      \"zh\": \"以完整編輯原則重寫方法論頁\",\\n+      \"en\": \"Rewrote the methodology page with the full editorial charter\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"design\",\\n+      \"commit\": \"6661e3dd0ccc9af247c1d9f27f795ab260437d87\",\\n+      \"zh\": \"文章文末開始標示實際撰稿模型\",\\n+      \"en\": \"Started crediting the actual drafting model in article footers\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"f2a0da18d7de53380a2715f194289c86a077019b\",\\n+      \"zh\": \"加入台灣稀有航機抵達偵測與來源文字修正\",\\n+      \"en\": \"Added Taiwan rare-aircraft arrival detection and source-copy correction\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"467952288fff0c71ffd70b3cadcb3214e22b8b17\",\\n+      \"zh\": \"修正模型推理設定批次的 12 項對抗審查問題\",\\n+      \"en\": \"Fixed all 12 adversarial-review findings in the reasoning-profile batch\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"09ee870a83354273e10b4bdf2f4ad02f5e98a715\",\\n+      \"zh\": \"加入模型分級推理設定、DeepSeek V4 Pro 備援與可觀測性\",\\n+      \"en\": \"Added per-model reasoning profiles, a DeepSeek V4 Pro slot and improved observability\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"fix\",\\n+      \"commit\": \"68142fffc729e4b98fbc6a6e5e4ec3f37b45238f\",\\n+      \"zh\": \"完整性檢查淘汰的內容不再占用每輪撰稿上限\",\\n+      \"en\": \"Stopped completeness-check rejections from consuming the per-run drafting cap\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"7251de3c986e1b501af2dc66caa19b2073d2f62a\",\\n+      \"zh\": \"新聞新鮮度時間窗改為可調整設定\",\\n+      \"en\": \"Made the news freshness window configurable\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"editorial\",\\n+      \"commit\": \"8984f056b3da29b59dc31ac7990b7aafbd4a29f9\",\\n+      \"zh\": \"撰稿流程開始消化編輯拒絕結果並預先檢查資料完整性\",\\n+      \"en\": \"Added editorial-rejection consumption and a code-level completeness pre-check\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"af752fc79dcaa64c8f79c5176ade3723e937ef75\",\\n+      \"zh\": \"在 CI 中對應既有 NVIDIA NIM 金鑰名稱\",\\n+      \"en\": \"Mapped the owner-named NVIDIA NIM secret in CI\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"56059538a166460009ff9d32aa7bfc8dd21ce8b2\",\\n+      \"zh\": \"依 API 政策建立模型層級備援路由\",\\n+      \"en\": \"Added model-level failover routing under the configured API policy\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"feature\",\\n+      \"commit\": \"17124bbc9be731d4556850e196d8dd1905a2309b\",\\n+      \"zh\": \"撰稿系統升級為多供應商模型與編輯驗證引擎 v2\",\\n+      \"en\": \"Upgraded drafting to a multi-provider model and editorial verification engine v2\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-27\",\\n+      \"kind\": \"design\",\\n+      \"commit\": \"0f78821364783e21c35ddc00a926c6444d85bb9d\",\\n+      \"zh\": \"網站改為置中 1280px 內容版面與側邊留白\",\\n+      \"en\": \"Changed the site to a centred 1280px content sheet with side gutters\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-26\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"c6907c960518d5980fc0340b01ce4f1968ff49da\",\\n+      \"zh\": \"加入免費無 API 標題聚合模式並完成 18 項審查修正\",\\n+      \"en\": \"Added free no-API headline aggregation and resolved 18 review findings\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-26\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"75ce323e9e3c05054599e9e710b8e515c2e04774\",\\n+      \"zh\": \"匯入首批真實抓取資料並完成空狀態網站建置\",\\n+      \"en\": \"Seeded the first real fetched data and completed the empty-state site build\"\\n+    },\\n+    {\\n+      \"date\": \"2026-07-26\",\\n+      \"kind\": \"system\",\\n+      \"commit\": \"f60f59f7844d75447706038d5ad6a2f57c7b71fa\",\\n+      \"zh\": \"建立 AVWIRE：抓取、去重、撰稿、建置管線與每小時自動部署\",\\n+      \"en\": \"Created AVWIRE with fetch, dedupe, drafting, build and hourly deployment pipelines\"\\n+    }\\n+  ]\\n+}\\n@@ -18,6 +18,14 @@ 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+`config/changelog.json` is a checked-in, bilingual history of meaningful\\n+site changes. It is curated from the GitHub `main` history and deliberately\\n+omits automated `hourly update`, briefing and flight-state data commits.\\n+Every historical row carries a full 40-character commit SHA; build.py derives\\n+the public GitHub URL from that validated SHA rather than accepting arbitrary\\n+links from the file. The one current changelog-page row may use `null` until\\n+it has a stable historical commit.\\n+\\n ## data/raw/<source_key>.json  (written by fetch.py, read by dedupe.py)\\n \\n ```jsonc\\n@@ -268,6 +276,7 @@ site/\\n ├─ incidents/index.html        + en/incidents/\\n ├─ sources/index.html          + en/sources/\\n ├─ about/index.html            + en/about/\\n+├─ changelog/index.html        + en/changelog/\\n ├─ assets/modernist.css        copied from static/\\n ├─ assets/site.css\\n ├─ assets/app.js\\n@@ -276,6 +285,8 @@ site/\\n ```\\n \\n All internal links are prefixed with `common.BASE_PATH` (default `/avwire`).\\n+Every page rendered through `base.html` includes a language-correct footer\\n+button linking to its changelog page.\\n \\n ## data/briefings/  (written by briefing.py, read by build.py)\\n \\n@@ -3,7 +3,8 @@\\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+incidents / sources / methodology / changelog pages, copied assets, 404.html\\n+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@@ -84,8 +85,19 @@\\n         \"thName\": \"來源\", \"thKind\": \"類型\", \"thCover\": \"涵蓋範圍\", \"thFmt\": \"格式\",\\n         \"thFreq\": \"頻率\", \"thLast\": \"最近抓取\", \"thState\": \"狀態\",\\n         \"aboutKicker\": \"Methodology\", \"aboutTitle\": \"方法論\",\\n+        \"changeKicker\": \"Site Changelog\", \"changeTitle\": \"網站更新紀錄\",\\n+        \"changeSub\": \"依 GitHub 主分支提交紀錄整理，涵蓋 AVWIRE 建站至最近一次網站功能變更。\",\\n+        \"changeNotice\": \"每小時新聞、每日快報與航機狀態等自動資料更新不列入，避免機器提交淹沒真正的網站變更。\",\\n+        \"changeCount\": \"筆網站變更\", \"changeStart\": \"紀錄起點\",\\n+        \"changeLatest\": \"更新至\", \"changeSource\": \"查看完整 GitHub 提交紀錄\",\\n+        \"changeCommit\": \"提交\",\\n+        \"changeKinds\": {\\n+            \"feature\": \"功能\", \"fix\": \"修正\", \"system\": \"系統\",\\n+            \"editorial\": \"編輯\", \"design\": \"介面\",\\n+        },\\n         \"footerAbout\": \"全自動航空新聞聚合站。GitHub Actions 每小時抓取各可信來源，自動撰寫並發布，每篇文末標註原始出處。本頁為設計原型，內容為示意樣本。\",\\n         \"footerSources\": \"資料來源 Data Sources\",\\n+        \"footerChangelog\": \"網站更新紀錄\",\\n         \"nav\": [\"最新\", \"快報\", \"航班雷達\", \"事故資料庫\", \"來源\", \"方法論\"],\\n         \"cats\": [\"全部\", \"事故\", \"法規\", \"商業\", \"營運\", \"軍事\"],\\n         \"radarKicker\": \"Taiwan Flight Radar\",\\n@@ -159,8 +171,20 @@\\n         \"thName\": \"Source\", \"thKind\": \"Kind\", \"thCover\": \"Coverage\", \"thFmt\": \"Format\",\\n         \"thFreq\": \"Interval\", \"thLast\": \"Last fetch\", \"thState\": \"Status\",\\n         \"aboutKicker\": \"Methodology\", \"aboutTitle\": \"Methodology\",\\n+        \"changeKicker\": \"Site Changelog\", \"changeTitle\": \"Site changelog\",\\n+        \"changeSub\": \"Compiled from the GitHub main-branch history, covering AVWIRE from its creation through the latest site change.\",\\n+        \"changeNotice\": \"Automated hourly news, daily briefing and flight-state data commits are omitted so operational refreshes do not bury real site changes.\",\\n+        \"changeCount\": \"site changes\", \"changeStart\": \"History starts\",\\n+        \"changeLatest\": \"Updated through\",\\n+        \"changeSource\": \"View the complete GitHub commit history\",\\n+        \"changeCommit\": \"commit\",\\n+        \"changeKinds\": {\\n+            \"feature\": \"Feature\", \"fix\": \"Fix\", \"system\": \"System\",\\n+            \"editorial\": \"Editorial\", \"design\": \"Design\",\\n+        },\\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+        \"footerChangelog\": \"Site changelog\",\\n         \"nav\": [\"Latest\", \"Briefings\", \"Flight Radar\", \"Incident DB\", \"Sources\",\\n                 \"Methodology\"],\\n         \"cats\": [\"All\", \"Safety\", \"Regulation\", \"Business\", \"Operations\",\\n@@ -1392,6 +1416,70 @@ def non_api_reference_rows(prices: dict):\\n     return rows\\n \\n \\n+_CHANGELOG_DATE_RE = re.compile(r\"\\d{4}-\\d{2}-\\d{2}\\Z\")\\n+_CHANGELOG_COMMIT_RE = re.compile(r\"[0-9a-f]{40}\\Z\")\\n+_CHANGELOG_KINDS = {\"feature\", \"fix\", \"system\", \"editorial\", \"design\"}\\n+_CHANGELOG_REPOSITORY = \"https://github.com/nnneeeooo-nnneeeooo/avwire\"\\n+\\n+\\n+def changelog_view(raw, lang: str, labels: dict) -> dict:\\n+    \"\"\"Validate checked-in changelog data and prepare date-grouped rows.\\n+\\n+    URLs are derived from validated full commit SHAs instead of accepting\\n+    arbitrary href values from the data file.\\n+    \"\"\"\\n+    if not isinstance(raw, dict):\\n+        raw = {}\\n+    rows = []\\n+    seen_commits = set()\\n+    for order, item in enumerate(raw.get(\"entries\") or []):\\n+        if not isinstance(item, dict):\\n+            continue\\n+        date = str(item.get(\"date\") or \"\")\\n+        kind = str(item.get(\"kind\") or \"\")\\n+        title = str(item.get(lang) or \"\").strip()\\n+        commit = item.get(\"commit\")\\n+        if (not _CHANGELOG_DATE_RE.fullmatch(date)\\n+                or kind not in _CHANGELOG_KINDS or not title):\\n+            continue\\n+        if commit is not None:\\n+            commit = str(commit).lower()\\n+            if (not _CHANGELOG_COMMIT_RE.fullmatch(commit)\\n+                    or commit in seen_commits):\\n+                continue\\n+            seen_commits.add(commit)\\n+        rows.append({\\n+            \"date\": date,\\n+            \"kind\": kind,\\n+            \"kind_label\": str(labels.get(kind) or kind),\\n+            \"title\": title,\\n+            \"commit\": commit,\\n+            \"short_commit\": commit[:7] if commit else \"\",\\n+            \"url\": (f\"{_CHANGELOG_REPOSITORY}/commit/{commit}\"\\n+                    if commit else \"\"),\\n+            \"order\": order,\\n+        })\\n+    rows.sort(key=lambda row: (row[\"date\"], -row[\"order\"]), reverse=True)\\n+    groups = []\\n+    for row in rows:\\n+        if not groups or groups[-1][\"date\"] != row[\"date\"]:\\n+            groups.append({\"date\": row[\"date\"], \"entries\": []})\\n+        groups[-1][\"entries\"].append(row)\\n+    history_start = str(raw.get(\"historyStart\") or \"\")\\n+    updated_through = str(raw.get(\"updatedThrough\") or \"\")\\n+    if not _CHANGELOG_DATE_RE.fullmatch(history_start):\\n+        history_start = groups[-1][\"date\"] if groups else \"—\"\\n+    if not _CHANGELOG_DATE_RE.fullmatch(updated_through):\\n+        updated_through = groups[0][\"date\"] if groups else \"—\"\\n+    return {\\n+        \"groups\": groups,\\n+        \"count\": len(rows),\\n+        \"history_start\": history_start,\\n+        \"updated_through\": updated_through,\\n+        \"repository_url\": f\"{_CHANGELOG_REPOSITORY}/commits/main/\",\\n+    }\\n+\\n+\\n def render_usage_dashboard(env, build) -> int:\\n     \"\"\"Private dashboard at /u/<token>/ - only when the secret is set.\"\"\"\\n     token = os.environ.get(\"AVWIRE_USAGE_TOKEN\", \"\").strip()\\n@@ -1444,6 +1532,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\\n         \"hreflang\": hreflang,\\n         \"home_url\": page_url(lang, \"\"),\\n         \"radar_url\": page_url(lang, \"radar/\"),\\n+        \"changelog_url\": page_url(lang, \"changelog/\"),\\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_items\": ticker or [], \"build\": build,\\n@@ -1474,6 +1563,9 @@ def main() -> int:\\n         DATA_DIR / \"briefings\" / \"index.json\", {}).get(\"briefings\") or [])\\n         if isinstance(r, dict)]\\n     latest_row = latest_briefing(brief_rows)\\n+    changelog_raw = load_json(\\n+        Path(__file__).resolve().parent.parent\\n+        / \"config\" / \"changelog.json\", {})\\n \\n     # clean output tree\\n     if SITE_DIR.exists():\\n@@ -1658,6 +1750,16 @@ def main() -> int:\\n         render(env, \"about.html\", rel_path(lang, \"about/index.html\"), ctx)\\n         pages += 1\\n \\n+        ctx = base_ctx(\\n+            lang, \"changelog\", \"changelog/\",\\n+            title=f\"{t['siteName']} — {t['changeTitle']}\",\\n+            description=t[\"changeSub\"], ticker=ticker, build=build)\\n+        ctx.update(changelog=changelog_view(\\n+            changelog_raw, lang, t[\"changeKinds\"]))\\n+        render(env, \"changelog.html\",\\n+               rel_path(lang, \"changelog/index.html\"), ctx)\\n+        pages += 1\\n+\\n     pages += render_usage_dashboard(env, build)\\n \\n     # 404 (zh chrome, bilingual body), served by GitHub Pages from the root\\n@@ -237,6 +237,54 @@ a.flash-row:hover { color: inherit; }\\n   padding: 24px 20px 32px;\\n   display: grid; grid-template-columns: 1fr 1.3fr; gap: 32px;\\n }\\n+.footer-changelog-button { margin-top: 14px; font-size: 12px; }\\n+\\n+/* — site changelog — */\\n+.changelog-page {\\n+  max-width: 960px; margin: 0 auto; padding: 32px 20px 72px;\\n+}\\n+.changelog-page > h1 {\\n+  margin: 7px 0 10px; font-size: clamp(32px, 5vw, 52px);\\n+}\\n+.changelog-sub { max-width: 720px; font-size: 15px; line-height: 1.75; }\\n+.changelog-summary {\\n+  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));\\n+  border: 2px solid var(--color-divider); margin: 24px 0 16px;\\n+}\\n+.changelog-summary > div { padding: 16px 18px; }\\n+.changelog-summary > div + div { border-left: 1px solid var(--color-divider); }\\n+.changelog-summary strong { display: block; font-size: 20px; }\\n+.changelog-summary span {\\n+  display: block; margin-top: 3px; color: color-mix(in srgb, var(--color-text) 55%, transparent);\\n+  font-size: 11px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase;\\n+}\\n+.changelog-notice {\\n+  border-left: 5px solid var(--color-accent); background: var(--color-surface);\\n+  padding: 13px 16px; font-size: 12.5px; line-height: 1.7;\\n+}\\n+.changelog-notice p { margin: 0 0 3px; }\\n+.changelog-history { margin-top: 34px; }\\n+.changelog-group {\\n+  display: grid; grid-template-columns: 112px minmax(0, 1fr);\\n+  gap: 22px; padding: 24px 0; border-top: 2px solid var(--color-divider);\\n+}\\n+.changelog-group > time {\\n+  font-weight: 800; font-size: 14px; letter-spacing: .04em;\\n+}\\n+.changelog-entry {\\n+  display: flex; justify-content: space-between; gap: 20px;\\n+  padding: 0 0 17px; margin: 0 0 17px;\\n+  border-bottom: 1px solid var(--color-divider);\\n+}\\n+.changelog-entry:last-child { margin-bottom: 0; }\\n+.changelog-entry-copy { min-width: 0; }\\n+.changelog-entry h2 {\\n+  margin: 7px 0 0; font-size: 16px; line-height: 1.45;\\n+}\\n+.changelog-commit {\\n+  flex-shrink: 0; align-self: center; font-size: 11px; font-weight: 800;\\n+  letter-spacing: .04em; text-decoration: none;\\n+}\\n \\n /* — responsive fallback: the design is desktop-first; below 1100px the\\n      three-column home grid simply stacks. — */\\n@@ -253,6 +301,7 @@ a.flash-row:hover { color: inherit; }\\n   }\\n   .radar-layout { grid-template-columns: minmax(0, 1fr) 280px; }\\n   .radar-notes { grid-template-columns: 1fr; gap: 8px; }\\n+  .changelog-group { grid-template-columns: 96px minmax(0, 1fr); }\\n }\\n \\n @media (max-width: 640px) {\\n@@ -276,4 +325,11 @@ a.flash-row:hover { color: inherit; }\\n   .seg-cat .seg-btn:nth-child(n + 4) {\\n     border-top: 1px solid var(--color-divider);\\n   }\\n+  .changelog-page { padding: 24px 14px 48px; }\\n+  .changelog-summary { grid-template-columns: 1fr; }\\n+  .changelog-summary > div + div {\\n+    border-left: 0; border-top: 1px solid var(--color-divider);\\n+  }\\n+  .changelog-group { grid-template-columns: 1fr; gap: 14px; }\\n+  .changelog-entry { align-items: flex-start; }\\n }\\n@@ -86,6 +86,7 @@\\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+        <a class=\"btn btn-secondary footer-changelog-button\" href=\"{{ changelog_url }}\">{{ t.footerChangelog }} →</a>\\n       </div>\\n       <div>\\n         <h6 style=\"margin:0 0 10px;\">{{ t.footerSources }}</h6>\\n@@ -0,0 +1,52 @@\\n+{% extends \"base.html\" %}\\n+{% block content %}\\n+  <main class=\"changelog-page\">\\n+    <span class=\"card-kicker\">{{ t.changeKicker }}</span>\\n+    <h1>{{ t.changeTitle }}</h1>\\n+    <p class=\"text-muted changelog-sub\">{{ t.changeSub }}</p>\\n+\\n+    <div class=\"changelog-summary\" aria-label=\"{{ t.changeTitle }}\">\\n+      <div>\\n+        <strong>{{ changelog.count }}</strong>\\n+        <span>{{ t.changeCount }}</span>\\n+      </div>\\n+      <div>\\n+        <strong>{{ changelog.history_start }}</strong>\\n+        <span>{{ t.changeStart }}</span>\\n+      </div>\\n+      <div>\\n+        <strong>{{ changelog.updated_through }}</strong>\\n+        <span>{{ t.changeLatest }}</span>\\n+      </div>\\n+    </div>\\n+\\n+    <div class=\"changelog-notice\">\\n+      <p>{{ t.changeNotice }}</p>\\n+      <a href=\"{{ changelog.repository_url }}\" target=\"_blank\" rel=\"noopener\">{{ t.changeSource }} ↗</a>\\n+    </div>\\n+\\n+    <div class=\"changelog-history\">\\n+      {% for group in changelog.groups %}\\n+      <section class=\"changelog-group\" aria-labelledby=\"changelog-{{ group.date }}\">\\n+        <time id=\"changelog-{{ group.date }}\" datetime=\"{{ group.date }}\">{{ group.date }}</time>\\n+        <div class=\"changelog-entries\">\\n+          {% for entry in group.entries %}\\n+          <article class=\"changelog-entry\">\\n+            <div class=\"changelog-entry-copy\">\\n+              <span class=\"tag tag-neutral\">{{ entry.kind_label }}</span>\\n+              <h2>{{ entry.title }}</h2>\\n+            </div>\\n+            {% if entry.url %}\\n+            <a class=\"changelog-commit\" href=\"{{ entry.url }}\" target=\"_blank\" rel=\"noopener\"\\n+               aria-label=\"{{ t.changeCommit }} {{ entry.short_commit }}\">\\n+              {{ entry.short_commit }} ↗\\n+            </a>\\n+            {% endif %}\\n+          </article>\\n+          {% endfor %}\\n+        </div>\\n+      </section>\\n+      {% endfor %}\\n+    </div>\\n+  </main>\\n+{% endblock %}\\n@@ -0,0 +1,106 @@\\n+\"\"\"Offline checks for the bilingual, source-traceable site changelog.\"\"\"\\n+from __future__ import annotations\\n+\\n+import json\\n+import re\\n+import sys\\n+from pathlib import Path\\n+\\n+from jinja2 import Environment, FileSystemLoader, StrictUndefined\\n+\\n+ROOT = Path(__file__).resolve().parent.parent\\n+sys.path.insert(0, str(ROOT / \"pipeline\"))\\n+\\n+import build  # noqa: E402\\n+\\n+\\n+def main() -> None:\\n+    raw = json.loads(\\n+        (ROOT / \"config\" / \"changelog.json\").read_text(encoding=\"utf-8\"))\\n+    assert raw[\"schemaVersion\"] == 1\\n+    assert raw[\"historyStart\"] == \"2026-07-26\"\\n+    assert raw[\"updatedThrough\"] == \"2026-07-28\"\\n+    assert len(raw[\"entries\"]) == 49\\n+\\n+    historical = [row[\"commit\"] for row in raw[\"entries\"]\\n+                  if row[\"commit\"] is not None]\\n+    assert len(historical) == 48\\n+    assert len(set(historical)) == len(historical)\\n+    assert all(re.fullmatch(r\"[0-9a-f]{40}\", sha) for sha in historical)\\n+    assert sum(row[\"commit\"] is None for row in raw[\"entries\"]) == 1\\n+\\n+    dates = [row[\"date\"] for row in raw[\"entries\"]]\\n+    assert dates == sorted(dates, reverse=True)\\n+    assert {row[\"kind\"] for row in raw[\"entries\"]} \\\\n+        <= build._CHANGELOG_KINDS\\n+    assert all(row[\"zh\"].strip() and row[\"en\"].strip()\\n+               for row in raw[\"entries\"])\\n+\\n+    zh = build.changelog_view(\\n+        raw, \"zh\", build.L[\"zh\"][\"changeKinds\"])\\n+    en = build.changelog_view(\\n+        raw, \"en\", build.L[\"en\"][\"changeKinds\"])\\n+    assert zh[\"count\"] == en[\"count\"] == 49\\n+    assert [group[\"date\"] for group in zh[\"groups\"]] \\\\n+        == [\"2026-07-28\", \"2026-07-27\", \"2026-07-26\"]\\n+    assert zh[\"groups\"][0][\"entries\"][0][\"title\"] \\\\n+        == \"新增雙語網站更新紀錄頁與全站頁尾入口\"\\n+    assert en[\"groups\"][-1][\"entries\"][-1][\"title\"].startswith(\\n+        \"Created AVWIRE\")\\n+    linked = [entry for group in en[\"groups\"] for entry in group[\"entries\"]\\n+              if entry[\"commit\"]]\\n+    assert all(entry[\"url\"] ==\\n+               f\"{build._CHANGELOG_REPOSITORY}/commit/{entry['commit']}\"\\n+               for entry in linked)\\n+\\n+    malformed = {\\n+        \"entries\": [\\n+            {\"date\": \"not-a-date\", \"kind\": \"feature\", \"commit\": None,\\n+             \"zh\": \"bad\", \"en\": \"bad\"},\\n+            {\"date\": \"2026-07-28\", \"kind\": \"unknown\", \"commit\": None,\\n+             \"zh\": \"bad\", \"en\": \"bad\"},\\n+            {\"date\": \"2026-07-28\", \"kind\": \"feature\",\\n+             \"commit\": \"javascript:alert(1)\", \"zh\": \"bad\", \"en\": \"bad\"},\\n+            {\"date\": \"2026-07-28\", \"kind\": \"feature\",\\n+             \"commit\": historical[0], \"zh\": \"ok\", \"en\": \"ok\"},\\n+            {\"date\": \"2026-07-28\", \"kind\": \"feature\",\\n+             \"commit\": historical[0], \"zh\": \"duplicate\", \"en\": \"duplicate\"},\\n+        ]\\n+    }\\n+    safe = build.changelog_view(\\n+        malformed, \"zh\", build.L[\"zh\"][\"changeKinds\"])\\n+    assert safe[\"count\"] == 1\\n+    assert safe[\"groups\"][0][\"entries\"][0][\"title\"] == \"ok\"\\n+\\n+    env = Environment(\\n+        loader=FileSystemLoader(str(ROOT / \"templates\")),\\n+        autoescape=True,\\n+        undefined=StrictUndefined,\\n+        trim_blocks=True,\\n+        lstrip_blocks=True,\\n+    )\\n+    fake_build = {\\n+        \"date\": \"2026-07-28 TUE\", \"utc_hm\": \"14:00\",\\n+        \"tpe_hm\": \"22:00\", \"stamp\": \"2026-07-28 14:00\",\\n+    }\\n+    for lang, view in ((\"zh\", zh), (\"en\", en)):\\n+        t = build.L[lang]\\n+        ctx = build.base_ctx(\\n+            lang, \"changelog\", \"changelog/\",\\n+            title=f\"{t['siteName']} — {t['changeTitle']}\",\\n+            description=t[\"changeSub\"], ticker=[], build=fake_build)\\n+        ctx[\"changelog\"] = view\\n+        html = env.get_template(\"changelog.html\").render(**ctx)\\n+        expected_path = (\"/avwire/changelog/\" if lang == \"zh\"\\n+                         else \"/avwire/en/changelog/\")\\n+        assert f'href=\"{expected_path}\"' in html\\n+        assert t[\"footerChangelog\"] in html\\n+        assert t[\"changeNotice\"] in html\\n+        assert len(re.findall(r'class=\"changelog-entry\"', html)) == 49\\n+        assert \"javascript:alert\" not in html\\n+\\n+    print(\"test_changelog: OK\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    main()"
}
