{
  "sha": "c2170da78841897571b9c13353f890b40b071631",
  "message": "Add official aircraft and engine manufacturer sources (#15)\n\nAdd official manufacturer news feeds with public/private source visibility and non-crawlable official social metadata.",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-28T11:29:43Z",
  "files": [
    {
      "filename": "README.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 0,
      "changes": 4
    },
    {
      "filename": "config/companion_sources.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 1,
      "changes": 5
    },
    {
      "filename": "config/manufacturer_sources.json",
      "previous_filename": "",
      "status": "added",
      "additions": 358,
      "deletions": 0,
      "changes": 358
    },
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 8,
      "deletions": 0,
      "changes": 8
    },
    {
      "filename": "pipeline/briefing.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 0,
      "changes": 4
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 1,
      "changes": 7
    },
    {
      "filename": "pipeline/common.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 49,
      "deletions": 0,
      "changes": 49
    },
    {
      "filename": "pipeline/fetch.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 40,
      "deletions": 0,
      "changes": 40
    },
    {
      "filename": "pipeline/fulltext.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 14,
      "deletions": 0,
      "changes": 14
    },
    {
      "filename": "tests/test_manufacturer_sources.py",
      "previous_filename": "",
      "status": "added",
      "additions": 143,
      "deletions": 0,
      "changes": 143
    }
  ],
  "diff": "@@ -118,6 +118,10 @@ ADSBdb 公開呼號端點補充 ICAO／IATA 班號切換與起迄機場；兩者\\n key。航線查詢會在訪客瀏覽器內去重、限制併發並快取，不儲存即時座標；\\n 建立任何標記前會排除軍事、PIA、LADD、緊急狀態、非 ICAO 位址與位置過期\\n 資料。此頁僅供航空資訊參考，不是航管雷達，航線資料查不到時不會猜測。\\n+製造商官方新聞來源另由 `config/manufacturer_sources.json` 管理；Airbus 與\\n+Boeing 顯示於公開來源狀態，其餘製造商來源只供新聞管線使用。官方社群帳號\\n+只保存為未來整合用的中繼資料，不繞過平台 robots 或自動化存取限制。\\n+\\n 資料來源授權：Airplanes.live 社群資料（非商業用途；本站無廣告無營利）、\\n ADSB.lol 開放資料（ODbL，本站僅擷取事件摘要並保留出處標註，不鏡像資料庫）。\\n \\n@@ -6,7 +6,10 @@\\n     \"theguardian.com\", \"cnn.com\", \"npr.org\", \"seattletimes.com\",\\n     \"aviationweek.com\", \"flightglobal.com\", \"ch-aviation.com\",\\n     \"aerotime.aero\", \"airwaysmag.com\", \"thepointsguy.com\",\\n-    \"faa.gov\", \"ntsb.gov\", \"boeing.com\", \"airbus.com\", \"easa.europa.eu\",\\n+    \"faa.gov\", \"ntsb.gov\", \"boeing.com\", \"boeing.mediaroom.com\",\\n+    \"airbus.com\", \"embraer.com\", \"rolls-royce.com\", \"geaerospace.com\",\\n+    \"prattwhitney.com\", \"cfmaeroengines.com\", \"atr-aircraft.com\",\\n+    \"safran-group.com\", \"bombardier.com\", \"comac.cc\", \"easa.europa.eu\",\\n     \"cna.com.tw\", \"focustaiwan.tw\", \"udn.com\", \"ltn.com.tw\", \"ettoday.net\"\\n   ],\\n   \"max_items_per_group\": 3,\\n@@ -0,0 +1,358 @@\\n+{\\n+  \"_comment\": \"製造商官方新聞來源。feeds 會進入新聞管線；public=false 仍供 AI 撰稿使用，但不顯示於首頁、來源頁或每日簡報的來源狀態。socialAccounts 只保存官方帳號供日後整合，不直接抓取或作為新聞證據。\",\\n+  \"schemaVersion\": 1,\\n+  \"feeds\": [\\n+    {\\n+      \"key\": \"airbus\",\\n+      \"name\": \"Airbus\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"HTML\",\\n+      \"url\": \"https://www.airbus.com/en/newsroom\",\\n+      \"endpoint\": \"https://www.airbus.com/en/newsroom\",\\n+      \"type\": \"html\",\\n+      \"public\": true,\\n+      \"cover\": {\\n+        \"zh\": \"Airbus 官方新聞、新聞稿與產品動態\",\\n+        \"en\": \"Official Airbus news, releases and product updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"boeing\",\\n+      \"name\": \"Boeing\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.boeing.com/\",\\n+      \"endpoint\": \"https://boeing.mediaroom.com/news-releases-statements?pagetemplate=rss\",\\n+      \"type\": \"rss\",\\n+      \"public\": true,\\n+      \"cover\": {\\n+        \"zh\": \"Boeing 官方新聞稿與公司動態\",\\n+        \"en\": \"Official Boeing releases and company updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"embraer\",\\n+      \"name\": \"Embraer 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.embraer.com/media-center/en\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Aembraer.com%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"Embraer 官方新聞與產品動態\",\\n+        \"en\": \"Official Embraer news and product updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"rollsroyce\",\\n+      \"name\": \"Rolls-Royce 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.rolls-royce.com/media/press-releases.aspx\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Arolls-royce.com%2Fmedia%2Fpress-releases%20%28aerospace%20OR%20aircraft%20OR%20aviation%20OR%20engine%20OR%20Trent%20OR%20Pearl%20OR%20UltraFan%29%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"Rolls-Royce 航太與飛機發動機官方新聞\",\\n+        \"en\": \"Official Rolls-Royce aerospace and aircraft-engine news\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"geaerospace\",\\n+      \"name\": \"GE Aerospace 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.geaerospace.com/news/\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Ageaerospace.com%2Fnews%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"GE Aerospace 官方新聞與發動機動態\",\\n+        \"en\": \"Official GE Aerospace news and engine updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"prattwhitney\",\\n+      \"name\": \"Pratt & Whitney 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.prattwhitney.com/en/newsroom\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Aprattwhitney.com%2Fen%2Fnewsroom%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"Pratt & Whitney 官方新聞與發動機動態\",\\n+        \"en\": \"Official Pratt & Whitney news and engine updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"iae\",\\n+      \"name\": \"International Aero Engines 官方資訊\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://links.prattwhitney.com/i-a-e/\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=%22International%20Aero%20Engines%22%20site%3Aprattwhitney.com%20when%3A14d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"IAE／V2500 官方資訊與合作夥伴公告\",\\n+        \"en\": \"Official IAE/V2500 information and partner announcements\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"cfm\",\\n+      \"name\": \"CFM International 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.cfmaeroengines.com/news\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Acfmaeroengines.com%2Fnews%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"CFM International 官方新聞與發動機動態\",\\n+        \"en\": \"Official CFM International news and engine updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"atr\",\\n+      \"name\": \"ATR 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.atr-aircraft.com/pressroom/\",\\n+      \"endpoint\": \"https://www.atr-aircraft.com/presspost/feed/\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"ATR 官方新聞稿與渦槳機產品動態\",\\n+        \"en\": \"Official ATR releases and turboprop product updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"safran\",\\n+      \"name\": \"Safran 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://www.safran-group.com/pressroom\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Asafran-group.com%2Fpressroom%20%28aircraft%20OR%20aviation%20OR%20aerospace%20OR%20engine%20OR%20propulsion%20OR%20CFM%20OR%20LEAP%29%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"Safran 航太、推進與飛機設備官方新聞\",\\n+        \"en\": \"Official Safran aerospace, propulsion and aircraft-equipment news\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"bombardier\",\\n+      \"name\": \"Bombardier 官方新聞室\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://bombardier.com/en/media/news\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Abombardier.com%2Fen%2Fmedia%2Fnews%20when%3A7d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"Bombardier 官方新聞與商務機動態\",\\n+        \"en\": \"Official Bombardier news and business-aircraft updates\"\\n+      }\\n+    },\\n+    {\\n+      \"key\": \"comac\",\\n+      \"name\": \"COMAC 官方新聞\",\\n+      \"kind\": \"official\",\\n+      \"fmt\": \"RSS\",\\n+      \"url\": \"https://english.comac.cc/news/\",\\n+      \"endpoint\": \"https://news.google.com/rss/search?q=site%3Acomac.cc%20%28aircraft%20OR%20C919%20OR%20C909%20OR%20C929%29%20when%3A14d&hl=en-US&gl=US&ceid=US%3Aen\",\\n+      \"type\": \"rss\",\\n+      \"public\": false,\\n+      \"cover\": {\\n+        \"zh\": \"中國商飛官方新聞與產品動態\",\\n+        \"en\": \"Official COMAC news and product updates\"\\n+      }\\n+    }\\n+  ],\\n+  \"socialAccounts\": [\\n+    {\\n+      \"manufacturer\": \"Airbus\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/airbus\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Airbus\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/airbus\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Airbus\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/airbus\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Boeing\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/Boeing\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Boeing\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/boeing\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Boeing\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/Boeing\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Embraer\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/Embraer\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Embraer\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/embraer\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Embraer\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/embraer\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Rolls-Royce\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/RollsRoyceGroup\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Rolls-Royce\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/rollsroycegroup\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Rolls-Royce\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/RollsRoyce\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"GE Aerospace\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/ge_aerospace\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"GE Aerospace\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/ge_aerospace\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Pratt & Whitney\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/prattandwhitney\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Pratt & Whitney\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/dependableengines\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Pratt & Whitney\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/prattandwhitney\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"CFM International\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/CFMengines\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"CFM International\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/cfm_engines\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"ATR\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/atraircraft\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"ATR\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/atraircraft\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"ATR\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/ATRaircraft\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Safran\",\\n+      \"platform\": \"facebook\",\\n+      \"url\": \"https://www.facebook.com/GroupeSafran\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Safran\",\\n+      \"platform\": \"instagram\",\\n+      \"url\": \"https://www.instagram.com/safran_group\",\\n+      \"crawl\": false,\\n+      \"reason\": \"platform_restricted\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Safran\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/SAFRAN\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    },\\n+    {\\n+      \"manufacturer\": \"Bombardier\",\\n+      \"platform\": \"x\",\\n+      \"url\": \"https://x.com/Bombardier\",\\n+      \"crawl\": false,\\n+      \"reason\": \"robots_disallow\"\\n+    }\\n+  ]\\n+}\\n@@ -56,12 +56,20 @@ Array, one entry per key in `common.SOURCES`, in registry order:\\n     \"fmt\": \"RSS\",\\n     \"url\": \"https://www.faa.gov\",\\n     \"cover\": {\"zh\": \"...\", \"en\": \"...\"},\\n+    \"public\": true,                      // false = editorial input only; hidden from public source-status UI\\n     \"lastFetchUtc\": \"2026-07-26T05:02Z\",  // last SUCCESSFUL fetch (persisted across runs)\\n     \"ok\": true\\n   }\\n ]\\n ```\\n \\n+Aircraft/engine manufacturer feeds are maintained in\\n+`config/manufacturer_sources.json` and merged into `common.SOURCES` at\\n+startup.  Their official social-account URLs are metadata only and are never\\n+fetched or passed to the model as evidence.  A hidden (`public=false`) feed\\n+still produces `data/raw/<key>.json` and can supply a story group to\\n+`write.py`; only its source-status row is suppressed.\\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@@ -663,6 +663,10 @@ def checked_sources_snapshot() -> tuple[list[dict], list[str]]:\\n     for src in load_json(DATA_DIR / \"sources.json\", []) or []:\\n         if not isinstance(src, dict):\\n             continue\\n+        if src.get(\"public\") is False:\\n+            # Hidden manufacturer discovery feeds can contribute articles,\\n+            # but are not part of the public source-status inventory.\\n+            continue\\n         if src.get(\"kind\") == \"data\":\\n             # Data platforms (e.g. the optional FlightAware stats API)\\n             # never feed news candidates; their status is not briefing\\n@@ -1039,7 +1039,7 @@ def inc_view(incidents, lang: str):\\n \\n \\n def merged_sources(raw):\\n-    \"\"\"Overlay data/sources.json onto the registry so all sources always show.\"\"\"\\n+    \"\"\"Overlay persisted status onto sources intended for public display.\"\"\"\\n     by_key = {}\\n     if isinstance(raw, list):\\n         for s in raw:\\n@@ -1048,6 +1048,11 @@ def merged_sources(raw):\\n     out = []\\n     for key, reg in SOURCES.items():\\n         s = by_key.get(key, {})\\n+        # Manufacturer feeds marked private still supply candidate stories\\n+        # to write.py.  They are deliberately absent from the homepage and\\n+        # public Sources page; Airbus and Boeing opt in via public=true.\\n+        if not bool(s.get(\"public\", reg.get(\"public\", True))):\\n+            continue\\n         out.append({\\n             \"key\": key,\\n             \"name\": str(s.get(\"name\") or reg[\"name\"]),\\n@@ -130,6 +130,9 @@ def group_has_material(group: dict) -> bool:\\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+# `public` defaults to true.  Hidden manufacturer feeds still enter the\\n+# editorial pipeline, but build.py and briefing.py omit them from public\\n+# source-status displays.\\n SOURCES: dict[str, dict] = {\\n     \"faa\": {\\n         \"name\": \"FAA\",\\n@@ -340,6 +343,52 @@ def group_has_material(group: dict) -> bool:\\n     },\\n }\\n \\n+\\n+def _load_manufacturer_source_catalog() -> dict:\\n+    \"\"\"Load the checked-in, no-secret manufacturer discovery registry.\\n+\\n+    Malformed entries fail closed: a broken optional feed must not prevent\\n+    the established source registry from loading or make an arbitrary URL\\n+    reachable by fetch.py.\\n+    \"\"\"\\n+    path = ROOT / \"config\" / \"manufacturer_sources.json\"\\n+    try:\\n+        with open(path, encoding=\"utf-8\") as fh:\\n+            catalog = json.load(fh)\\n+    except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:\\n+        print(f\"config: manufacturer sources unavailable ({exc})\")\\n+        return {\"schemaVersion\": 1, \"feeds\": [], \"socialAccounts\": []}\\n+    if not isinstance(catalog, dict):\\n+        print(\"config: manufacturer sources must be an object\")\\n+        return {\"schemaVersion\": 1, \"feeds\": [], \"socialAccounts\": []}\\n+    return catalog\\n+\\n+\\n+MANUFACTURER_SOURCE_CATALOG = _load_manufacturer_source_catalog()\\n+_SOURCE_REQUIRED = {\"key\", \"name\", \"kind\", \"fmt\", \"url\", \"endpoint\",\\n+                    \"type\", \"cover\"}\\n+for _manufacturer_source in MANUFACTURER_SOURCE_CATALOG.get(\"feeds\") or []:\\n+    if not isinstance(_manufacturer_source, dict):\\n+        print(\"config: ignored non-object manufacturer feed\")\\n+        continue\\n+    _manufacturer_key = str(_manufacturer_source.get(\"key\") or \"\").strip()\\n+    _manufacturer_urls = (\\n+        str(_manufacturer_source.get(\"url\") or \"\"),\\n+        str(_manufacturer_source.get(\"endpoint\") or \"\"),\\n+    )\\n+    if (not _manufacturer_key\\n+            or not _SOURCE_REQUIRED.issubset(_manufacturer_source)\\n+            or _manufacturer_source.get(\"type\") not in {\"rss\", \"html\"}\\n+            or any(urlsplit(url).scheme != \"https\"\\n+                   or not urlsplit(url).netloc\\n+                   for url in _manufacturer_urls)\\n+            or _manufacturer_key in SOURCES):\\n+        print(f\"config: ignored invalid/duplicate manufacturer feed \"\\n+              f\"{_manufacturer_key!r}\")\\n+        continue\\n+    SOURCES[_manufacturer_key] = _manufacturer_source\\n+\\n+\\n CATEGORIES = (\"safety\", \"reg\", \"biz\", \"ops\", \"mil\")\\n \\n # Site-wide subject gate.  A military label does not by itself make a story\\n@@ -386,10 +386,49 @@ def _parse_eurocontrol(soup: BeautifulSoup, base: str) -> list[dict]:\\n     return items\\n \\n \\n+def _parse_airbus(soup: BeautifulSoup, base: str) -> list[dict]:\\n+    \"\"\"Airbus newsroom cards: official stories and press releases.\\n+\\n+    Events and evergreen navigation cards share much of the same markup, so\\n+    require both a newsroom story/release URL and a source-provided <time>.\\n+    \"\"\"\\n+    items, seen = [], set()\\n+    path_re = re.compile(\\n+        r\"^/en/newsroom/(?:press-releases|stories)/[a-z0-9][a-z0-9-]+$\")\\n+    for card in soup.select(\"article.awx-card\"):\\n+        anchor = card.find(\"a\", href=path_re)\\n+        time_tag = card.find(\"time\")\\n+        if anchor is None or time_tag is None:\\n+            continue\\n+        url = urljoin(base, anchor[\"href\"])\\n+        if url in seen:\\n+            continue\\n+        title_el = card.select_one(\".awx-card__title\")\\n+        title = _collapse(\\n+            title_el.get_text(\" \", strip=True) if title_el else\\n+            anchor.get(\"title\") or anchor.get_text(\" \", strip=True)\\n+        )\\n+        if len(title) < 8:\\n+            continue\\n+        seen.add(url)\\n+        summary_el = card.select_one(\".awx-card__summary\")\\n+        image = card.find(\"img\", src=True)\\n+        items.append({\\n+            \"title\": title,\\n+            \"url\": url,\\n+            \"published\": _time_tag_datetime(time_tag),\\n+            \"summary\": (summary_el.get_text(\" \", strip=True)\\n+                        if summary_el else \"\"),\\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+    \"airbus\": _parse_airbus,\\n }\\n \\n \\n@@ -552,6 +591,7 @@ def _write_sources(last_success: dict[str, str | None]) -> None:\\n             \"fmt\": spec[\"fmt\"],\\n             \"url\": spec[\"url\"],\\n             \"cover\": spec[\"cover\"],\\n+            \"public\": bool(spec.get(\"public\", True)),\\n             \"lastFetchUtc\": fetched or carried,\\n             \"ok\": bool(fetched),\\n         })\\n@@ -63,6 +63,20 @@\\n     # CNA articles arrive via CNA's own official feed with direct links;\\n     # robots.txt is still honored per fetch\\n     \"www.cna.com.tw\", \"cna.com.tw\",\\n+    # aircraft and engine manufacturers.  Their newsroom listings are in\\n+    # common.SOURCES; every article request still passes robots.txt and the\\n+    # final redirect-host check below.\\n+    \"www.airbus.com\", \"airbus.com\",\\n+    \"www.boeing.com\", \"boeing.com\", \"boeing.mediaroom.com\",\\n+    \"www.embraer.com\", \"embraer.com\",\\n+    \"www.rolls-royce.com\", \"rolls-royce.com\",\\n+    \"www.geaerospace.com\", \"geaerospace.com\",\\n+    \"www.prattwhitney.com\", \"prattwhitney.com\", \"links.prattwhitney.com\",\\n+    \"www.cfmaeroengines.com\", \"cfmaeroengines.com\",\\n+    \"www.atr-aircraft.com\", \"atr-aircraft.com\",\\n+    \"www.safran-group.com\", \"safran-group.com\",\\n+    \"www.bombardier.com\", \"bombardier.com\",\\n+    \"english.comac.cc\", \"www.comac.cc\", \"comac.cc\",\\n }\\n # NEVER add simpleflying.com or other Valnet properties: their robots.txt\\n # terms explicitly prohibit automated text/data mining and AI use of page\\n@@ -0,0 +1,143 @@\\n+\"\"\"Offline checks for official manufacturer sources and UI visibility.\\n+\\n+Run from the repository root:\\n+\\n+    python tests/test_manufacturer_sources.py\\n+\"\"\"\\n+from __future__ import annotations\\n+\\n+import json\\n+import os\\n+import shutil\\n+import sys\\n+import tempfile\\n+from pathlib import Path\\n+from urllib.parse import urlsplit\\n+\\n+from bs4 import BeautifulSoup\\n+\\n+REPO = Path(__file__).resolve().parent.parent\\n+TMP = Path(tempfile.mkdtemp(prefix=\"avwire-manufacturers-\"))\\n+os.environ[\"AVWIRE_DATA_DIR\"] = str(TMP)\\n+sys.path.insert(0, str(REPO / \"pipeline\"))\\n+\\n+import briefing  # noqa: E402\\n+import build  # noqa: E402\\n+import common  # noqa: E402\\n+import fetch  # noqa: E402\\n+import fulltext  # noqa: E402\\n+\\n+CHECKS = 0\\n+FAILED = 0\\n+\\n+\\n+def check(name, condition):\\n+    global CHECKS, FAILED\\n+    CHECKS += 1\\n+    print((\"PASS \" if condition else \"FAIL \") + name)\\n+    if not condition:\\n+        FAILED += 1\\n+\\n+\\n+expected = {\\n+    \"airbus\", \"boeing\", \"embraer\", \"rollsroyce\", \"geaerospace\",\\n+    \"prattwhitney\", \"iae\", \"cfm\", \"atr\", \"safran\", \"bombardier\", \"comac\",\\n+}\\n+feeds = {\\n+    row[\"key\"]: row\\n+    for row in common.MANUFACTURER_SOURCE_CATALOG.get(\"feeds\", [])\\n+}\\n+\\n+check(\"manufacturer catalog has version 1\",\\n+      common.MANUFACTURER_SOURCE_CATALOG.get(\"schemaVersion\") == 1)\\n+check(\"requested aircraft and engine manufacturers are registered\",\\n+      expected == set(feeds) and expected.issubset(common.SOURCES))\\n+check(\"Airbus and Boeing are public sources\",\\n+      all(feeds[key].get(\"public\") is True for key in (\"airbus\", \"boeing\")))\\n+check(\"other manufacturer feeds are hidden from public source status\",\\n+      all(feeds[key].get(\"public\") is False\\n+          for key in expected - {\"airbus\", \"boeing\"}))\\n+check(\"all manufacturer feed URLs use HTTPS\",\\n+      all(urlsplit(row[field]).scheme == \"https\"\\n+          for row in feeds.values() for field in (\"url\", \"endpoint\")))\\n+\\n+social_hosts = {\"x.com\", \"twitter.com\", \"facebook.com\", \"www.facebook.com\",\\n+                \"instagram.com\", \"www.instagram.com\"}\\n+social = common.MANUFACTURER_SOURCE_CATALOG.get(\"socialAccounts\", [])\\n+check(\"official social accounts are metadata-only\",\\n+      bool(social)\\n+      and all(row.get(\"crawl\") is False for row in social)\\n+      and all(urlsplit(row.get(\"url\", \"\")).netloc.lower() in social_hosts\\n+              for row in social))\\n+check(\"social platforms are never fetch endpoints\",\\n+      not any(urlsplit(row[\"endpoint\"]).netloc.lower() in social_hosts\\n+              for row in feeds.values()))\\n+check(\"social platforms are excluded from full-text allowlist\",\\n+      fulltext.ALLOWED_HOSTS.isdisjoint(social_hosts))\\n+\\n+# Airbus has a direct, source-owned HTML parser rather than an aggregator.\\n+airbus_html = \"\"\"\\n+<article class=\"awx-card\">\\n+  <a href=\"/en/newsroom/press-releases/2026-07-example\"\\n+     title=\"Example release\">\\n+    <h3 class=\"awx-card__title\">Example Airbus release</h3>\\n+    <p class=\"awx-card__summary\">Airbus announced a verifiable new update.</p>\\n+    <time datetime=\"2026-07-28T08:00:00Z\">28 July 2026</time>\\n+    <img src=\"/media/example.jpg\">\\n+  </a>\\n+</article>\\n+<article class=\"awx-card\">\\n+  <a href=\"/en/newsroom/events/example\">\\n+    <h3 class=\"awx-card__title\">Event card must not enter news</h3>\\n+    <time datetime=\"2026-07-29T08:00:00Z\">29 July 2026</time>\\n+  </a>\\n+</article>\\n+\"\"\"\\n+parsed = fetch._parse_airbus(\\n+    BeautifulSoup(airbus_html, \"lxml\"), feeds[\"airbus\"][\"endpoint\"])\\n+check(\"Airbus parser keeps dated stories/releases only\",\\n+      len(parsed) == 1\\n+      and parsed[0][\"title\"] == \"Example Airbus release\"\\n+      and parsed[0][\"summary\"].startswith(\"Airbus announced\")\\n+      and parsed[0][\"url\"].startswith(\"https://www.airbus.com/\"))\\n+\\n+# Persisted source status carries visibility, and every public consumer\\n+# applies it.  Existing rows with no public field remain visible.\\n+success = {\"airbus\": \"2026-07-28T08:00Z\", \"boeing\": \"2026-07-28T08:00Z\"}\\n+fetch._write_sources(success)\\n+source_rows = json.loads((TMP / \"sources.json\").read_text(encoding=\"utf-8\"))\\n+by_key = {row[\"key\"]: row for row in source_rows}\\n+check(\"sources.json persists manufacturer visibility\",\\n+      by_key[\"airbus\"][\"public\"] is True\\n+      and by_key[\"boeing\"][\"public\"] is True\\n+      and by_key[\"embraer\"][\"public\"] is False)\\n+\\n+public_rows = build.merged_sources(source_rows)\\n+public_keys = {row[\"key\"] for row in public_rows}\\n+check(\"homepage/sources-page data shows Airbus and Boeing\",\\n+      {\"airbus\", \"boeing\"}.issubset(public_keys))\\n+check(\"homepage/sources-page data hides other manufacturers\",\\n+      not (expected - {\"airbus\", \"boeing\"}) & public_keys)\\n+\\n+brief_rows, _warnings = briefing.checked_sources_snapshot()\\n+brief_names = {row[\"name\"] for row in brief_rows}\\n+check(\"briefing source status follows the same visibility rule\",\\n+      feeds[\"airbus\"][\"name\"] in brief_names\\n+      and feeds[\"boeing\"][\"name\"] in brief_names\\n+      and feeds[\"embraer\"][\"name\"] not in brief_names)\\n+check(\"legacy source rows without public remain compatible\",\\n+      any(row[\"key\"] == \"faa\"\\n+          for row in build.merged_sources([{\"key\": \"faa\", \"ok\": True}])))\\n+\\n+official_hosts = {\\n+    \"airbus.com\", \"boeing.mediaroom.com\", \"embraer.com\",\\n+    \"rolls-royce.com\", \"geaerospace.com\", \"prattwhitney.com\",\\n+    \"cfmaeroengines.com\", \"atr-aircraft.com\", \"safran-group.com\",\\n+    \"bombardier.com\", \"comac.cc\",\\n+}\\n+check(\"official manufacturer article hosts may be full-text enriched\",\\n+      official_hosts.issubset(fulltext.ALLOWED_HOSTS))\\n+\\n+shutil.rmtree(TMP, ignore_errors=True)\\n+print(f\"\\n{CHECKS - FAILED}/{CHECKS} checks passed\")\\n+raise SystemExit(1 if FAILED else 0)"
}
