Full 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()