{
  "sha": "228aef5a95a2c480cda8ce067071be537e398c30",
  "message": "加入 Aerospace Global News 資料來源\n\n新增主動抓取來源、原站新聞解析器與回歸測試。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-07-29T16:46:25Z",
  "files": [
    {
      "filename": "pipeline/common.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 12,
      "deletions": 0,
      "changes": 12
    },
    {
      "filename": "pipeline/fetch.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 54,
      "deletions": 0,
      "changes": 54
    },
    {
      "filename": "tests/test_aerospace_global_news.py",
      "previous_filename": "",
      "status": "added",
      "additions": 96,
      "deletions": 0,
      "changes": 96
    }
  ],
  "diff": "@@ -243,6 +243,18 @@ def group_has_material(group: dict) -> bool:\\n             \"en\": \"Global incident tracking\",\\n         },\\n     },\\n+    \"aerospaceglobalnews\": {\\n+        \"name\": \"Aerospace Global News\",\\n+        \"kind\": \"media\",\\n+        \"fmt\": \"HTML\",\\n+        \"url\": \"https://aerospaceglobalnews.com/\",\\n+        \"endpoint\": \"https://aerospaceglobalnews.com/latest-news/\",\\n+        \"type\": \"html\",\\n+        \"cover\": {\\n+            \"zh\": \"全球航太、民航、國防、太空與技術新聞\",\\n+            \"en\": \"Global aerospace, air transport, defence, space & technology news\",\\n+        },\\n+    },\\n     \"flightaware\": {\\n         \"name\": \"FlightAware\",\\n         \"kind\": \"data\",\\n@@ -386,6 +386,59 @@ def _parse_eurocontrol(soup: BeautifulSoup, base: str) -> list[dict]:\\n     return items\\n \\n \\n+def _agn_date_after(anchor, base: str) -> datetime | None:\\n+    \"\"\"Find the date following an AGN listing link without crossing cards.\"\"\"\\n+    current_url = urljoin(base, str(anchor.get(\"href\") or \"\"))\\n+    for index, node in enumerate(anchor.next_elements):\\n+        if index >= 60:\\n+            break\\n+        if getattr(node, \"name\", None) == \"a\":\\n+            href = str(node.get(\"href\") or \"\")\\n+            next_url = urljoin(base, href)\\n+            if (re.fullmatch(r\"/news/[a-z0-9][a-z0-9-]+/?\",\\n+                             urlsplit(next_url).path)\\n+                    and next_url != current_url):\\n+                break\\n+        if isinstance(node, str):\\n+            published = _date_from_text(node)\\n+            if published is not None:\\n+                return published\\n+    return None\\n+\\n+\\n+def _parse_aerospaceglobalnews(soup: BeautifulSoup, base: str) -> list[dict]:\\n+    \"\"\"Aerospace Global News latest-news listing.\\n+\\n+    The page repeats some article links for the image and headline.  Keep one\\n+    dated /news/<slug>/ entry per URL and ignore navigation, topic and\\n+    undated recommendation links.\\n+    \"\"\"\\n+    items, seen = [], set()\\n+    path_re = re.compile(r\"^/news/[a-z0-9][a-z0-9-]+/?$\")\\n+    base_host = urlsplit(base).netloc.lower()\\n+    for anchor in soup.find_all(\"a\", href=True):\\n+        url = urljoin(base, str(anchor[\"href\"]))\\n+        parts = urlsplit(url)\\n+        if parts.netloc.lower() != base_host or not path_re.fullmatch(parts.path):\\n+            continue\\n+        title = _collapse(anchor.get(\"title\") or anchor.get_text(\" \", strip=True))\\n+        if url in seen or len(title) < 12 or title.lower() == \"news\":\\n+            continue\\n+        published = _agn_date_after(anchor, base)\\n+        if published is None:\\n+            continue\\n+        seen.add(url)\\n+        image = anchor.find(\"img\", src=True)\\n+        items.append({\\n+            \"title\": title,\\n+            \"url\": url,\\n+            \"published\": published,\\n+            \"summary\": \"\",\\n+            \"image\": urljoin(base, image[\"src\"]) if image else None,\\n+        })\\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@@ -428,6 +481,7 @@ def _parse_airbus(soup: BeautifulSoup, base: str) -> list[dict]:\\n     \"faa\": _parse_faa,\\n     \"icao\": _parse_icao,\\n     \"eurocontrol\": _parse_eurocontrol,\\n+    \"aerospaceglobalnews\": _parse_aerospaceglobalnews,\\n     \"airbus\": _parse_airbus,\\n }\\n \\n@@ -0,0 +1,96 @@\\n+import sys\\n+import unittest\\n+from pathlib import Path\\n+\\n+from bs4 import BeautifulSoup\\n+\\n+ROOT = Path(__file__).resolve().parent.parent\\n+sys.path.insert(0, str(ROOT / \"pipeline\"))\\n+\\n+import common  # noqa: E402\\n+import fetch  # noqa: E402\\n+\\n+\\n+AGN_HTML = \"\"\"\\n+<main>\\n+  <article>\\n+    <a href=\"/news/ga-ats-gives-the-do228-a-new-lease-of-life-with-nxt-launch/\">\\n+      GA-ATS gives the Do228 a new lease of life with NXT launch\\n+    </a>\\n+    <a href=\"/topic/do228-nxt/\">Do228 NXT</a>\\n+    <span>July 29, 2026</span>\\n+  </article>\\n+  <article>\\n+    <a href=\"/news/ga-ats-gives-the-do228-a-new-lease-of-life-with-nxt-launch/\">\\n+      GA-ATS gives the Do228 a new lease of life with NXT launch\\n+    </a>\\n+    <span>July 29, 2026</span>\\n+  </article>\\n+  <article>\\n+    <a href=\"/news/china-c919-high-altitude-variant-completes-maiden-flight/\">\\n+      China C919 high-altitude variant completes maiden flight\\n+    </a>\\n+    <span>July 28, 2026</span>\\n+  </article>\\n+  <aside>\\n+    <a href=\"/news/undated-recommendation/\">Undated recommendation article</a>\\n+  </aside>\\n+</main>\\n+\"\"\"\\n+\\n+\\n+class AerospaceGlobalNewsTests(unittest.TestCase):\\n+    def test_source_is_public_direct_html_feed(self):\\n+        source = common.SOURCES[\"aerospaceglobalnews\"]\\n+\\n+        self.assertEqual(source[\"name\"], \"Aerospace Global News\")\\n+        self.assertEqual(source[\"kind\"], \"media\")\\n+        self.assertEqual(source[\"fmt\"], \"HTML\")\\n+        self.assertEqual(\\n+            source[\"endpoint\"],\\n+            \"https://aerospaceglobalnews.com/latest-news/\",\\n+        )\\n+        self.assertEqual(source[\"type\"], \"html\")\\n+        self.assertTrue(source.get(\"public\", True))\\n+        self.assertIs(\\n+            fetch.HTML_PARSERS[\"aerospaceglobalnews\"],\\n+            fetch._parse_aerospaceglobalnews,\\n+        )\\n+\\n+    def test_latest_news_parser_keeps_unique_dated_articles(self):\\n+        items = fetch._parse_aerospaceglobalnews(\\n+            BeautifulSoup(AGN_HTML, \"lxml\"),\\n+            \"https://aerospaceglobalnews.com/latest-news/\",\\n+        )\\n+\\n+        self.assertEqual(\\n+            [item[\"title\"] for item in items],\\n+            [\\n+                \"GA-ATS gives the Do228 a new lease of life with NXT launch\",\\n+                \"China C919 high-altitude variant completes maiden flight\",\\n+            ],\\n+        )\\n+        self.assertEqual(\\n+            [item[\"url\"] for item in items],\\n+            [\\n+                (\\n+                    \"https://aerospaceglobalnews.com/news/\"\\n+                    \"ga-ats-gives-the-do228-a-new-lease-of-life-with-nxt-launch/\"\\n+                ),\\n+                (\\n+                    \"https://aerospaceglobalnews.com/news/\"\\n+                    \"china-c919-high-altitude-variant-completes-maiden-flight/\"\\n+                ),\\n+            ],\\n+        )\\n+        self.assertEqual(\\n+            [item[\"published\"].isoformat() for item in items],\\n+            [\\n+                \"2026-07-29T00:00:00+00:00\",\\n+                \"2026-07-28T00:00:00+00:00\",\\n+            ],\\n+        )\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    unittest.main()"
}
