Add official aircraft and engine manufacturer sources (#15)
Add official manufacturer news feeds with public/private source visibility and non-crawlable official social metadata.
Full 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)