2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

Add official aircraft and engine manufacturer sources (#15) Add official manufacturer news feeds with public/private source visibility and non-crawlable official social metadata.

SHA
c2170da78841897571b9c13353f890b40b071631
提交時間
作者
nnneeeooo-nnneeeooo
提交者
web-flow

變更檔案 (10)

  • README.md modified · +4 / −0
  • config/companion_sources.json modified · +4 / −1
  • config/manufacturer_sources.json added · +358 / −0
  • pipeline/CONTRACTS.md modified · +8 / −0
  • pipeline/briefing.py modified · +4 / −0
  • pipeline/build.py modified · +6 / −1
  • pipeline/common.py modified · +49 / −0
  • pipeline/fetch.py modified · +40 / −0
  • pipeline/fulltext.py modified · +14 / −0
  • tests/test_manufacturer_sources.py added · +143 / −0

完整差異

@@ -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)

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。