improve Taiwan aviation news SEO (#95)
Add sitemaps, structured data, topic hubs, editorial policy, bilingual metadata, and SEO regression coverage.
Full diff
@@ -19,6 +19,7 @@\n import sys\n import time\n import unicodedata\n+import xml.etree.ElementTree as ET\n from datetime import timedelta, timezone\n from functools import lru_cache\n from pathlib import Path\n@@ -174,7 +175,17 @@ def static_asset_version() -> str:\n "themeOpts": ["亮", "暗"],\n # production-only keys (not in the design dictionary)\n "siteName": "SKYTICAL",\n- "siteDesc": "全自動航空新聞聚合站:每小時抓取 FAA、ICAO、IATA、Reuters 等可信來源,自動撰寫雙語新聞並於文末標註原始出處。",\n+ "siteDesc": "SKYTICAL 提供臺灣與全球航空新聞、即時航空快訊、航班與機場營運、飛安及航空產業動態,所有報導均附可追溯資料來源。",\n+ "articleByline": "SKYTICAL 編輯系統",\n+ "articleBylineLabel": "發布者",\n+ "publishedLabel": "發布",\n+ "modifiedLabel": "更新",\n+ "sourceTimeLabel": "來源時間",\n+ "metaColon": ":",\n+ "topicKicker": "Aviation Topics",\n+ "topicCount": "則相關新聞",\n+ "topicNavLabel": "航空新聞主題",\n+ "policyLink": "編輯、更正與聯絡",\n "awaiting": "等待首次管線執行",\n "lastBuild": "最後建置",\n "aggNote": "純聚合模式:顯示來源原文標題,點擊前往原始報導",\n@@ -287,7 +298,17 @@ def static_asset_version() -> str:\n "themeOpts": ["Light", "Dark"],\n # production-only keys (not in the design dictionary)\n "siteName": "SKYTICAL",\n- "siteDesc": "A fully automated aviation news aggregator: trusted sources fetched hourly, bilingual stories written automatically, every original source credited.",\n+ "siteDesc": "SKYTICAL covers Taiwan and global aviation news, breaking updates, airline and airport operations, safety and the aviation industry, with traceable sources.",\n+ "articleByline": "SKYTICAL Editorial System",\n+ "articleBylineLabel": "Published by",\n+ "publishedLabel": "Published",\n+ "modifiedLabel": "Updated",\n+ "sourceTimeLabel": "Source time",\n+ "metaColon": ": ",\n+ "topicKicker": "Aviation Topics",\n+ "topicCount": "related stories",\n+ "topicNavLabel": "Aviation news topics",\n+ "policyLink": "Editorial, corrections and contact",\n "awaiting": "Awaiting first pipeline run",\n "lastBuild": "Last build",\n "aggNote": "Aggregation mode: original source headlines, linking to the original stories",\n@@ -301,6 +322,129 @@ def static_asset_version() -> str:\n "ops": {"zh": "營運", "en": "Operations"},\n "mil": {"zh": "軍事", "en": "Military"},\n }\n+\n+# Permanent, crawlable topic hubs. These turn the home-page filters into\n+# durable landing pages for common Taiwan aviation-news search intents.\n+TOPICS = {\n+ "taiwan-aviation": {\n+ "cats": None,\n+ "terms": (\n+ "台灣", "臺灣", "桃園", "松山", "高雄", "臺中", "台中",\n+ "中華航空", "華航", "長榮航空", "星宇航空", "台灣虎航",\n+ "Taiwan", "Taoyuan", "Taipei", "Songshan", "Kaohsiung",\n+ "China Airlines", "EVA Air", "STARLUX", "Tigerair Taiwan",\n+ "TPE", "TSA", "KHH", "RMQ",\n+ ),\n+ "zh": {\n+ "label": "臺灣航空",\n+ "title": "臺灣航空新聞",\n+ "description": "彙整臺灣航空新聞、國籍航空公司、桃園與松山等機場、航班異動、飛安及民航政策的最新報導。",\n+ "intro": "追蹤華航、長榮、星宇、台灣虎航,以及桃園、松山、高雄等臺灣機場的重要消息與航班影響。",\n+ },\n+ "en": {\n+ "label": "Taiwan Aviation",\n+ "title": "Taiwan Aviation News",\n+ "description": "Latest Taiwan aviation news covering local airlines, airports, flight disruptions, safety and civil aviation policy.",\n+ "intro": "Coverage of Taiwan airlines and airports, including China Airlines, EVA Air, STARLUX, Tigerair Taiwan, Taoyuan, Songshan and Kaohsiung.",\n+ },\n+ },\n+ "flight-safety": {\n+ "cats": ("safety",), "terms": (),\n+ "zh": {"label": "飛安事件", "title": "飛安與航空事故新聞",\n+ "description": "全球飛安新聞、航空事故、嚴重事件、返航與緊急狀況的最新可追溯報導。",\n+ "intro": "整理事故調查機關、民航主管機關與可信來源發布的飛安事件及後續調查進度。"},\n+ "en": {"label": "Flight Safety", "title": "Flight Safety and Accident News",\n+ "description": "Traceable reporting on aviation safety, accidents, serious incidents, diversions and emergencies worldwide.",\n+ "intro": "Updates from accident investigators, civil aviation authorities and other traceable aviation sources."},\n+ },\n+ "airline-industry": {\n+ "cats": ("biz",), "terms": (),\n+ "zh": {"label": "航空產業", "title": "航空公司與航空產業新聞",\n+ "description": "航空公司、飛機製造商、機隊、航線、訂單與航空產業市場的最新新聞。",\n+ "intro": "掌握航空公司營運策略、機隊與訂單、製造商交付及全球航空市場變化。"},\n+ "en": {"label": "Airline Industry", "title": "Airline and Aviation Industry News",\n+ "description": "Latest news on airlines, aircraft manufacturers, fleets, routes, orders and the global aviation market.",\n+ "intro": "Airline strategy, fleet and order developments, aircraft deliveries and aviation-market updates."},\n+ },\n+ "flight-airport-operations": {\n+ "cats": ("ops",), "terms": (),\n+ "zh": {"label": "航班與機場", "title": "航班與機場營運新聞",\n+ "description": "航班延誤、取消、改道、機場營運、新航線與航空公司班表異動的最新新聞。",\n+ "intro": "追蹤航班異動、機場作業、新航線、班表及天候對航空運輸的影響。"},\n+ "en": {"label": "Flights and Airports", "title": "Flight and Airport Operations News",\n+ "description": "Latest news on flight delays, cancellations, diversions, airport operations, new routes and schedule changes.",\n+ "intro": "Flight disruptions, airport operations, route launches, schedule changes and weather impacts."},\n+ },\n+ "aviation-regulation": {\n+ "cats": ("reg",), "terms": (),\n+ "zh": {"label": "航空法規", "title": "航空法規與民航政策新聞",\n+ "description": "臺灣與國際航空法規、民航政策、適航指令、認證及主管機關公告的最新消息。",\n+ "intro": "彙整民航主管機關、國際組織與航空安全機構的重要規範及政策變化。"},\n+ "en": {"label": "Regulation", "title": "Aviation Regulation and Policy News",\n+ "description": "Latest aviation regulation, civil aviation policy, airworthiness, certification and authority updates.",\n+ "intro": "Regulatory and policy developments from civil aviation authorities, international bodies and safety agencies."},\n+ },\n+ "military-aviation": {\n+ "cats": ("mil",), "terms": (),\n+ "zh": {"label": "軍事航空", "title": "軍事航空新聞",\n+ "description": "臺灣與全球軍事航空、軍機動態、國防航空計畫及相關空域消息。",\n+ "intro": "聚焦與航空及空域直接相關的軍機、國防航空計畫與官方通報。"},\n+ "en": {"label": "Military Aviation", "title": "Military Aviation News",\n+ "description": "Taiwan and global military aviation, aircraft activity, defence aviation programs and airspace updates.",\n+ "intro": "Officially sourced coverage of military aircraft, defence aviation programs and relevant airspace activity."},\n+ },\n+}\n+\n+EDITORIAL_POLICY = {\n+ "zh": {\n+ "kicker": "Transparency",\n+ "title": "編輯、更正與聯絡政策",\n+ "description": "SKYTICAL 的新聞來源、AI 自動化、編輯責任、更正流程與聯絡管道。",\n+ "intro": "SKYTICAL 是航空新聞與公開資料彙整服務。本頁說明內容如何產生、如何標註來源,以及讀者如何提出更正。",\n+ "sections": [\n+ {"title": "發布者與編輯責任", "body": [\n+ "本站以 SKYTICAL 名義發布內容。文章可能由自動化與 AI 輔助系統整理,並在文末揭露使用方式;模型本身不被列為新聞來源。",\n+ "各篇文章均保留可開啟的原始資料來源。涉及航班操作、法規、事故調查或緊急狀況時,應以主管機關、航空公司、機場或調查單位的最新公告為準。",\n+ ]},\n+ {"title": "來源與更新", "body": [\n+ "優先採用民航主管機關、事故調查機構、國際航空組織、航空公司與機場等可追溯來源;其他媒體內容用於補充時,同樣保留出處。",\n+ "文章顯示首次發布時間;內容實質更新時會保留修改時間。網站不會只為製造新鮮感而任意更改發布日期。",\n+ ]},\n+ {"title": "更正政策", "body": [\n+ "若標題、時間、航班、機型、地點、引述或其他事實有誤,SKYTICAL 會依可驗證來源修正、補充或撤回內容。重要變更應反映於文章內容與最後修改時間。",\n+ "提出更正時,請附上文章網址、需要修正的段落、建議內容及可驗證來源。",\n+ ]},\n+ {"title": "聯絡方式", "body": [\n+ "目前公開聯絡與更正管道為 SKYTICAL 的 GitHub Issues。請勿在公開表單提交個人資料、訂位紀錄、證件、付款資料或其他敏感內容。",\n+ ]},\n+ ],\n+ "contactLabel": "提出內容更正或聯絡 SKYTICAL",\n+ },\n+ "en": {\n+ "kicker": "Transparency",\n+ "title": "Editorial, Corrections and Contact Policy",\n+ "description": "How SKYTICAL handles sources, AI-assisted automation, editorial responsibility, corrections and reader contact.",\n+ "intro": "SKYTICAL is an aviation-news and public-data compilation service. This page explains how stories are produced, sourced and corrected.",\n+ "sections": [\n+ {"title": "Publisher and editorial responsibility", "body": [\n+ "Content is published under the SKYTICAL name. Stories may be prepared with automation and AI assistance, disclosed at the end of each article; a model is never treated as a news source.",\n+ "Every article retains links to its underlying sources. For flight operations, regulation, accident investigation or emergencies, the latest notice from the responsible authority, airline, airport or investigator prevails.",\n+ ]},\n+ {"title": "Sources and updates", "body": [\n+ "SKYTICAL prioritizes traceable civil aviation authorities, accident investigators, international aviation bodies, airlines and airports. Supplemental reporting also retains attribution.",\n+ "Articles show their original publication time and carry a modified time after substantive updates. Dates are not changed merely to make old material appear new.",\n+ ]},\n+ {"title": "Corrections", "body": [\n+ "When a headline, time, flight, aircraft type, location, quotation or other fact is wrong, SKYTICAL may correct, expand or withdraw the material using verifiable sources. Material changes should be reflected in the story and its modified time.",\n+ "A correction request should include the article URL, the passage at issue, the proposed correction and a verifiable source.",\n+ ]},\n+ {"title": "Contact", "body": [\n+ "SKYTICAL currently accepts public contact and correction requests through GitHub Issues. Do not submit personal information, booking records, identity documents, payment details or other sensitive material in a public issue.",\n+ ]},\n+ ],\n+ "contactLabel": "Request a correction or contact SKYTICAL",\n+ },\n+}\n SEV = {\n "acc": {"zh": "事故", "en": "Accident", "cls": "tag-accent"},\n "ser": {"zh": "嚴重事件", "en": "Serious", "cls": "tag-outline"},\n@@ -708,6 +852,11 @@ def _pre(x):\n \n FAVICON = f"{BASE_PATH}/assets/skytical-mark.svg?v=20260810-clean-mark"\n \n+SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9"\n+NEWS_NS = "http://www.google.com/schemas/sitemap-news/0.9"\n+ET.register_namespace("", SITEMAP_NS)\n+ET.register_namespace("news", NEWS_NS)\n+\n \n # ── small helpers ────────────────────────────────────────────────────────────\n \n@@ -816,6 +965,16 @@ def page_url(lang: str, sub: str) -> str:\n return f"{BASE_PATH}/{'en/' if lang == 'en' else ''}{sub}"\n \n \n+def absolute_page_url(lang: str, sub: str) -> str:\n+ return SITE_ORIGIN + page_url(lang, sub)\n+\n+\n+def iso_timestamp(dt) -> str:\n+ """Stable W3C timestamp for structured data and XML sitemaps."""\n+ return (dt.astimezone(timezone.utc).isoformat(timespec="seconds")\n+ .replace("+00:00", "Z"))\n+\n+\n # ── data loading / preparation ───────────────────────────────────────────────\n \n # Article-footer model credit: writer id -> short public model name.\n@@ -882,6 +1041,13 @@ def prep_article(raw):\n except (ValueError, TypeError):\n return None\n dt = publication_dt\n+ modified_dt = publication_dt\n+ if raw.get("updatedUtc"):\n+ try:\n+ modified_dt = max(publication_dt,\n+ parse_iso(str(raw["updatedUtc"])))\n+ except (ValueError, TypeError):\n+ pass\n if raw.get("sortUtc"):\n try:\n dt = parse_iso(str(raw["sortUtc"]))\n@@ -943,6 +1109,8 @@ def side(own, other):\n except (ValueError, TypeError):\n pass\n tpe = display_dt.astimezone(TPE)\n+ publication_tpe = publication_dt.astimezone(TPE)\n+ modified_tpe = modified_dt.astimezone(TPE)\n today_tpe = now_utc().astimezone(TPE).date()\n # Date-only source stamps (FAA/CAA give no clock time -> 00:00Z) must\n # not render as a precise-looking "08:00" TPE: show the date instead.\n@@ -958,6 +1126,14 @@ def side(own, other):\n return {\n "id": art_id,\n "dt": dt,\n+ "published_dt": publication_dt,\n+ "modified_dt": modified_dt,\n+ "published_iso": iso_timestamp(publication_dt),\n+ "modified_iso": iso_timestamp(modified_dt),\n+ "published_meta_ts": (\n+ f"{publication_tpe:%Y-%m-%d} {clock_12(publication_tpe)} UTC+8"),\n+ "modified_meta_ts": (\n+ f"{modified_tpe:%Y-%m-%d} {clock_12(modified_tpe)} UTC+8"),\n "cat": cat,\n "tag_class": TAGCLS.get(cat, "tag-neutral"),\n "image": image,\n@@ -1086,6 +1262,10 @@ def art_view(a, lang: str):\n sources=a["sources"], url=page_url(lang, f"news/{a['id']}/"),\n writer_model=a["writer_model"],\n article_format=a["article_format"],\n+ published_iso=a["published_iso"],\n+ modified_iso=a["modified_iso"],\n+ published_meta_ts=a["published_meta_ts"],\n+ modified_meta_ts=a["modified_meta_ts"],\n external=False,\n )\n return v\n@@ -1097,6 +1277,34 @@ def normalize_search_text(value) -> str:\n return re.sub(r"[^\w]+", " ", text, flags=re.UNICODE).strip()\n \n \n+def topic_articles(articles, slug: str):\n+ """Return articles belonging to a durable editorial topic hub."""\n+ topic = TOPICS[slug]\n+ cats = topic.get("cats")\n+ if cats:\n+ return [article for article in articles if article["cat"] in cats]\n+\n+ terms = topic.get("terms") or ()\n+ matched = []\n+ for article in articles:\n+ text = " ".join([\n+ article["zh"]["title"], article["zh"]["summary"],\n+ *article["zh"]["body"], article["en"]["title"],\n+ article["en"]["summary"], *article["en"]["body"],\n+ ]).casefold()\n+ for term in terms:\n+ needle = term.casefold()\n+ if re.fullmatch(r"[a-z0-9 ]+", needle):\n+ if re.search(rf"(?<![a-z0-9]){re.escape(needle)}(?![a-z0-9])",\n+ text):\n+ matched.append(article)\n+ break\n+ elif needle in text:\n+ matched.append(article)\n+ break\n+ return matched\n+\n+\n def load_search_alias_groups() -> list[tuple[str, ...]]:\n """Load bounded, duplicate-free alias groups used to expand the index."""\n path = Path(__file__).resolve().parent.parent / "config" / "search_aliases.json"\n@@ -2366,8 +2574,10 @@ def render_manual_workbench(env, build) -> int:\n \n # ── rendering ────────────────────────────────────────────────────────────────\n \n-def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\n+def base_ctx(lang, page, sub, *, title, description, ticker, build,\n+ hreflang=True, alternate_languages=None):\n t = dict(L[lang])\n+ alternate_languages = set(alternate_languages or ("zh", "en"))\n prompts = list(header_search_placeholders(lang))\n t["headerSearchPlaceholders"] = prompts\n t["headerSearchPlaceholder"] = prompts[0]\n@@ -2378,15 +2588,23 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\n return {\n "lang": lang, "t": t, "base": BASE_PATH, "favicon": FAVICON,\n "asset_version": ASSET_VERSION,\n- "html_lang": "zh-Hant" if lang == "zh" else "en",\n+ "html_lang": "zh-Hant-TW" if lang == "zh" else "en",\n "title": title, "description": description,\n "zh_url": page_url("zh", sub), "en_url": page_url("en", sub),\n "zh_abs_url": SITE_ORIGIN + page_url("zh", sub),\n "en_abs_url": SITE_ORIGIN + page_url("en", sub),\n+ "has_zh_alternate": "zh" in alternate_languages,\n+ "has_en_alternate": "en" in alternate_languages,\n+ "x_default_abs_url": SITE_ORIGIN + page_url(\n+ "zh" if "zh" in alternate_languages else "en", sub),\n "self_abs_url": SITE_ORIGIN + page_url(lang, sub),\n "social_image_abs_url": (\n f"{SITE_ORIGIN}{BASE_PATH}/assets/skytical-social.png?v=20260810-clean-mark"\n ),\n+ "social_image_alt": "SKYTICAL — SKYLINE TO AVIATION NEWS",\n+ "og_type": "website",\n+ "structured_data": None,\n+ "robots_meta": "index,follow,max-image-preview:large",\n "hreflang": hreflang,\n "home_url": page_url(lang, ""),\n "search_url": page_url(lang, "search/"),\n@@ -2395,7 +2613,14 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=Tru\n if BASE_PATH else "/search-aliases.json"\n ),\n "radar_url": page_url(lang, "radar/"),\n+ "about_url": page_url(lang, "about/"),\n+ "policy_url": page_url(lang, "editorial-policy/"),\n "changelog_url": page_url(lang, "changelog/"),\n+ "topic_links": [\n+ {"label": topic[lang]["label"],\n+ "url": page_url(lang, f"topics/{slug}/")}\n+ for slug, topic in TOPICS.items()\n+ ],\n "nav": [{"label": t["nav"][i], "url": page_url(lang, s), "active": page == p}\n for i, (p, s) in enumerate(nav_defs)],\n "ticker_items": ticker or [], "build": build,\n@@ -2410,6 +2635,150 @@ def render(env, template, out_rel, ctx):\n out.write_text(html, encoding="utf-8", newline="\n")\n \n \n+def website_structured_data(lang: str) -> dict:\n+ """Homepage entities used by Google for the site name and publisher."""\n+ home = absolute_page_url(lang, "")\n+ return {\n+ "@context": "https://schema.org",\n+ "@graph": [\n+ {\n+ "@type": "Organization",\n+ "@id": f"{SITE_ORIGIN}{BASE_PATH}/#organization",\n+ "name": "SKYTICAL",\n+ "url": home,\n+ "logo": {\n+ "@type": "ImageObject",\n+ "url": f"{SITE_ORIGIN}{BASE_PATH}/assets/skytical-mark.svg",\n+ },\n+ },\n+ {\n+ "@type": "WebSite",\n+ "@id": f"{home}#website",\n+ "url": home,\n+ "name": "SKYTICAL",\n+ "alternateName": "SKYLINE TO AVIATION NEWS",\n+ "description": L[lang]["siteDesc"],\n+ "inLanguage": "zh-Hant-TW" if lang == "zh" else "en",\n+ "publisher": {\n+ "@id": f"{SITE_ORIGIN}{BASE_PATH}/#organization",\n+ },\n+ },\n+ ],\n+ }\n+\n+\n+def article_structured_data(lang: str, article: dict) -> dict:\n+ """Google-supported NewsArticle fields, grounded in rendered content."""\n+ article_url = absolute_page_url(lang, f"news/{article['id']}/")\n+ publisher_id = f"{SITE_ORIGIN}{BASE_PATH}/#organization"\n+ schema = {\n+ "@context": "https://schema.org",\n+ "@type": "NewsArticle",\n+ "@id": f"{article_url}#article",\n+ "mainEntityOfPage": {\n+ "@type": "WebPage",\n+ "@id": article_url,\n+ },\n+ "url": article_url,\n+ "headline": article["title"],\n+ "description": article["summary"] or L[lang]["siteDesc"],\n+ "datePublished": article["published_iso"],\n+ "dateModified": article["modified_iso"],\n+ "inLanguage": "zh-Hant-TW" if lang == "zh" else "en",\n+ "isAccessibleForFree": True,\n+ "articleSection": article["cat_label"],\n+ "author": {\n+ "@type": "Organization",\n+ "name": "SKYTICAL",\n+ "url": absolute_page_url(lang, "about/"),\n+ },\n+ "publisher": {\n+ "@type": "Organization",\n+ "@id": publisher_id,\n+ "name": "SKYTICAL",\n+ "url": absolute_page_url(lang, ""),\n+ "logo": {\n+ "@type": "ImageObject",\n+ "url": f"{SITE_ORIGIN}{BASE_PATH}/assets/skytical-mark.svg",\n+ },\n+ },\n+ }\n+ if article.get("image"):\n+ schema["image"] = [article["image"]["url"]]\n+ return schema\n+\n+\n+def _write_xml(path: Path, root: ET.Element) -> None:\n+ tree = ET.ElementTree(root)\n+ ET.indent(tree, space=" ")\n+ tree.write(path, encoding="utf-8", xml_declaration=True,\n+ short_empty_elements=True)\n+\n+\n+def write_search_discovery_files(articles, briefings, now) -> None:\n+ """Write generic/news sitemaps plus a crawler policy for the public site."""\n+ urlset = ET.Element(ET.QName(SITEMAP_NS, "urlset"))\n+ seen = set()\n+\n+ def add_url(lang: str, sub: str, modified=None) -> None:\n+ loc = absolute_page_url(lang, sub)\n+ if loc in seen:\n+ return\n+ seen.add(loc)\n+ node = ET.SubElement(urlset, ET.QName(SITEMAP_NS, "url"))\n+ ET.SubElement(node, ET.QName(SITEMAP_NS, "loc")).text = loc\n+ if modified is not None:\n+ ET.SubElement(node, ET.QName(SITEMAP_NS, "lastmod")).text = (\n+ iso_timestamp(modified))\n+\n+ public_sections = ("", "briefings/", "radar/", "incidents/", "sources/",\n+ "about/", "editorial-policy/", "changelog/")\n+ for lang in ("zh", "en"):\n+ for sub in public_sections:\n+ add_url(lang, sub)\n+ for slug in TOPICS:\n+ add_url(lang, f"topics/{slug}/")\n+ for briefing in briefings:\n+ modified = _tpe_dt(briefing.get("generated_at")\n+ or briefing.get("cutoff_time"))\n+ for lang in ("zh", "en"):\n+ add_url(lang, f"briefings/{briefing.get('briefing_id')}/",\n+ modified)\n+ for article in articles:\n+ for lang in article["available_languages"]:\n+ add_url(lang, f"news/{article['id']}/", article["modified_dt"])\n+ _write_xml(SITE_DIR / "sitemap.xml", urlset)\n+\n+ news_urlset = ET.Element(ET.QName(SITEMAP_NS, "urlset"))\n+ cutoff = now - timedelta(days=2)\n+ for article in articles:\n+ if article["published_dt"] < cutoff:\n+ continue\n+ for lang in article["available_languages"]:\n+ url_node = ET.SubElement(news_urlset, ET.QName(SITEMAP_NS, "url"))\n+ ET.SubElement(url_node, ET.QName(SITEMAP_NS, "loc")).text = (\n+ absolute_page_url(lang, f"news/{article['id']}/"))\n+ news = ET.SubElement(url_node, ET.QName(NEWS_NS, "news"))\n+ publication = ET.SubElement(\n+ news, ET.QName(NEWS_NS, "publication"))\n+ ET.SubElement(publication, ET.QName(NEWS_NS, "name")).text = (\n+ "SKYTICAL")\n+ ET.SubElement(publication, ET.QName(NEWS_NS, "language")).text = (\n+ "zh-tw" if lang == "zh" else "en")\n+ ET.SubElement(news, ET.QName(\n+ NEWS_NS, "publication_date")).text = article["published_iso"]\n+ ET.SubElement(news, ET.QName(\n+ NEWS_NS, "title")).text = article[lang]["title"]\n+ _write_xml(SITE_DIR / "news-sitemap.xml", news_urlset)\n+\n+ sitemap_url = f"{SITE_ORIGIN}{BASE_PATH}/sitemap.xml"\n+ news_sitemap_url = f"{SITE_ORIGIN}{BASE_PATH}/news-sitemap.xml"\n+ (SITE_DIR / "robots.txt").write_text(\n+ "User-agent: *\nAllow: /\n\n"\n+ f"Sitemap: {sitemap_url}\nSitemap: {news_sitemap_url}\n",\n+ encoding="utf-8", newline="\n")\n+\n+\n def main() -> int:\n t0 = time.time()\n now = now_utc()\n@@ -2506,8 +2875,12 @@ def main() -> int:\n (bv for bv in bviews\n if bv["id"] == latest_row.get("briefing_id")), None)\n \n- ctx = base_ctx(lang, "home", "", title=t["siteName"],\n+ home_title = ("SKYTICAL 航空新聞|臺灣與全球即時航空快訊"\n+ if lang == "zh"\n+ else "SKYTICAL Aviation News | Taiwan and Global Updates")\n+ ctx = base_ctx(lang, "home", "", title=home_title,\n description=t["siteDesc"], ticker=ticker, build=build)\n+ ctx["structured_data"] = website_structured_data(lang)\n ctx.update(hero=hero, feed=feed, flashes=fl, agg=agg,\n source_status=source_status_view(sources, now),\n stats=sv["tiles"], otp=sv["otp"], fleet=sv["fleet"],\n@@ -2517,10 +2890,24 @@ def main() -> int:\n render(env, "home.html", rel_path(lang, "index.html"), ctx)\n pages += 1\n \n+ for slug, topic in TOPICS.items():\n+ topic_rows = topic_articles(lang_articles, slug)\n+ topic_views = [art_view(article, lang) for article in topic_rows]\n+ copy = topic[lang]\n+ ctx = base_ctx(\n+ lang, "topics", f"topics/{slug}/",\n+ title=f"{copy['title']}|{t['siteName']}",\n+ description=copy["description"], ticker=ticker, build=build)\n+ ctx.update(topic=copy, articles=topic_views, count=len(topic_views))\n+ render(env, "topic.html",\n+ rel_path(lang, f"topics/{slug}/index.html"), ctx)\n+ pages += 1\n+\n ctx = base_ctx(\n lang, "search", "search/",\n title=f"{t['siteName']} — {t['searchTitle']}",\n description=t["searchSub"], ticker=ticker, build=build)\n+ ctx["robots_meta"] = "noindex,follow"\n ctx["search_index_url"] = (\n f"{BASE_PATH}/search-index.json"\n if BASE_PATH else "/search-index.json")\n@@ -2616,9 +3003,16 @@ def main() -> int:\n for a, v in zip(lang_articles, views):\n try:\n ctx = base_ctx(lang, "home", f"news/{a['id']}/",\n- title=f"{t['siteName']} — {v['title']}",\n+ title=f"{v['title']}|{t['siteName']}",\n description=v["summary"] or t["siteDesc"],\n- ticker=ticker, build=build)\n+ ticker=ticker, build=build,\n+ alternate_languages=a["available_languages"])\n+ ctx["og_type"] = "article"\n+ if v.get("image"):\n+ ctx["social_image_abs_url"] = v["image"]["url"]\n+ ctx["social_image_alt"] = (\n+ v["image"].get("subject") or v["title"])\n+ ctx["structured_data"] = article_structured_data(lang, v)\n ctx["a"] = v\n render(env, "article.html",\n rel_path(lang, f"news/{a['id']}/index.html"), ctx)\n@@ -2650,6 +3044,18 @@ def main() -> int:\n render(env, "about.html", rel_path(lang, "about/index.html"), ctx)\n pages += 1\n \n+ policy = EDITORIAL_POLICY[lang]\n+ ctx = base_ctx(\n+ lang, "policy", "editorial-policy/",\n+ title=f"{policy['title']}|{t['siteName']}",\n+ description=policy["description"], ticker=ticker, build=build)\n+ ctx.update(policy=policy,\n+ contact_url=("https://github.com/nnneeeooo-nnneeeooo/"\n+ "avwire/issues/new"))\n+ render(env, "editorial-policy.html",\n+ rel_path(lang, "editorial-policy/index.html"), ctx)\n+ pages += 1\n+\n ctx = base_ctx(\n lang, "changelog", "changelog/",\n title=f"{t['siteName']} — {t['changeTitle']}",\n@@ -2667,9 +3073,12 @@ def main() -> int:\n ctx = base_ctx("zh", "none", "", title=f"{L['zh']['siteName']} — 404",\n description=L["zh"]["siteDesc"], ticker="", build=build,\n hreflang=False)\n+ ctx["robots_meta"] = "noindex,follow"\n render(env, "404.html", "404.html", ctx)\n pages += 1\n \n+ write_search_discovery_files(articles, briefings, now)\n+\n print(f"build: {len(articles)} articles, {len(flashes)} flashes, "\n f"{len(incidents)} incidents, {pages} pages, {failed} failed "\n f"-> {SITE_DIR} (base='{BASE_PATH}') in {time.time() - t0:.2f}s")\n@@ -1,4 +1,8 @@\n {% extends "base.html" %}\n+{% block head_extra %}\n+<meta property="article:published_time" content="{{ a.published_iso }}">\n+<meta property="article:modified_time" content="{{ a.modified_iso }}">\n+{% endblock %}\n {% block content %}\n <div class="article-page">\n <a href="{{ home_url }}" class="btn btn-ghost" style="margin-bottom:18px;">{{ t.back }}</a>\n@@ -9,14 +13,17 @@\n {% endif %}\n <h1 class="article-title">{{ a.title }}</h1>\n <div class="article-meta text-muted">\n- <span style="font-weight:800;color:var(--color-accent);">{{ a.meta_ts }}</span>\n+ <time datetime="{{ a.published_iso }}" style="font-weight:800;color:var(--color-accent);">{{ t.publishedLabel }}{{ t.metaColon }}{{ a.published_meta_ts }}</time>\n+ {% if a.modified_iso != a.published_iso %}<time datetime="{{ a.modified_iso }}">{{ t.modifiedLabel }}{{ t.metaColon }}{{ a.modified_meta_ts }}</time>{% endif %}\n+ <span>{{ t.sourceTimeLabel }}{{ t.metaColon }}{{ a.meta_ts }}</span>\n+ <span>{{ t.articleBylineLabel }}{{ t.metaColon }}<a href="{{ about_url }}">{{ t.articleByline }}</a></span>\n <span>{{ t.autoCompiled }} · {{ t.mainSource }}: {{ a.source }}</span>\n <span style="margin-left:auto;">{{ t.refreshNote }}</span>\n </div>\n </div>\n {% if a.image %}\n <figure style="margin:0 0 22px;">\n- <img src="{{ a.image.url }}" alt="{{ a.image.subject or a.title }}" loading="lazy" style="width:100%;height:auto;max-height:560px;object-fit:contain;background:color-mix(in srgb,var(--color-text) 6%,var(--color-surface));border:1px solid var(--color-divider);">\n+ <img src="{{ a.image.url }}" alt="{{ a.image.subject or a.title }}" fetchpriority="high" decoding="async" style="width:100%;height:auto;max-height:560px;object-fit:contain;background:color-mix(in srgb,var(--color-text) 6%,var(--color-surface));border:1px solid var(--color-divider);">\n <figcaption class="text-muted" style="font-size:11.5px;margin-top:6px;display:flex;gap:10px;flex-wrap:wrap;">\n {% if a.image.subject %}<span style="font-weight:800;color:var(--color-text);">{{ a.image.subject }}</span>{% endif %}\n <span>{{ t.photoKind[a.image.kind] }}</span>\n@@ -5,22 +5,30 @@\n <meta name="viewport" content="width=device-width, initial-scale=1">\n <title>{{ title }}</title>\n <meta name="description" content="{{ description }}">\n-<meta property="og:type" content="website">\n+<meta name="robots" content="{{ robots_meta }}">\n+<meta property="og:type" content="{{ og_type }}">\n <meta property="og:site_name" content="SKYTICAL">\n <meta property="og:title" content="{{ title }}">\n <meta property="og:description" content="{{ description }}">\n <meta property="og:url" content="{{ self_abs_url }}">\n <meta property="og:image" content="{{ social_image_abs_url }}">\n-<meta property="og:image:alt" content="SKYTICAL — SKYLINE TO AVIATION NEWS">\n+<meta property="og:image:alt" content="{{ social_image_alt }}">\n <meta name="twitter:card" content="summary_large_image">\n <meta name="twitter:title" content="{{ title }}">\n <meta name="twitter:description" content="{{ description }}">\n <meta name="twitter:image" content="{{ social_image_abs_url }}">\n {% if hreflang %}\n <link rel="canonical" href="{{ self_abs_url }}">\n-<link rel="alternate" hreflang="zh-Hant" href="{{ zh_abs_url }}">\n+{% if has_zh_alternate %}\n+<link rel="alternate" hreflang="zh-Hant-TW" href="{{ zh_abs_url }}">\n+{% endif %}\n+{% if has_en_alternate %}\n <link rel="alternate" hreflang="en" href="{{ en_abs_url }}">\n-<link rel="alternate" hreflang="x-default" href="{{ zh_abs_url }}">\n+{% endif %}\n+<link rel="alternate" hreflang="x-default" href="{{ x_default_abs_url }}">\n+{% endif %}\n+{% if structured_data %}\n+<script type="application/ld+json">{{ structured_data | tojson }}</script>\n {% endif %}\n <link rel="icon" type="image/svg+xml" href="{{ favicon }}">\n <script>try{var t=localStorage.getItem("avwire-theme");if(t){document.documentElement.dataset.theme=t;}}catch(e){}document.documentElement.classList.add("js");</script>\n@@ -122,6 +130,7 @@\n <p class="text-muted" style="font-size:12.5px;line-height:1.8;margin-top:8px;">{{ t.footerAbout }}</p>\n <p class="text-muted" style="font-size:11.5px;letter-spacing:.04em;margin:8px 0 0;">{{ t.lastBuild }} {{ build.stamp }} UTC</p>\n <a class="btn btn-secondary footer-changelog-button" href="{{ changelog_url }}">{{ t.footerChangelog }} →</a>\n+ <a class="btn btn-secondary footer-changelog-button" href="{{ policy_url }}">{{ t.policyLink }} →</a>\n </div>\n <div>\n <h6 style="margin:0 0 10px;">{{ t.footerSources }}</h6>\n@@ -130,6 +139,12 @@ <h6 style="margin:0 0 10px;">{{ t.footerSources }}</h6>\n <a href="{{ s.url }}" target="_blank" rel="noopener" class="tag tag-neutral" style="text-decoration:none;">{{ s.name }}</a>\n {% endfor %}\n </div>\n+ <h6 style="margin:20px 0 10px;">{{ t.topicNavLabel }}</h6>\n+ <nav aria-label="{{ t.topicNavLabel }}" style="display:flex;flex-wrap:wrap;gap:8px;">\n+ {% for topic_link in topic_links %}\n+ <a href="{{ topic_link.url }}" class="tag tag-neutral" style="text-decoration:none;">{{ topic_link.label }}</a>\n+ {% endfor %}\n+ </nav>\n </div>\n </div>\n </div>\n@@ -0,0 +1,19 @@\n+{% extends "base.html" %}\n+{% block content %}\n+ <main class="article-page">\n+ <span class="card-kicker">{{ policy.kicker }}</span>\n+ <h1 class="article-title">{{ policy.title }}</h1>\n+ <p class="text-muted" style="font-size:15px;line-height:1.8;">{{ policy.intro }}</p>\n+ {% for section in policy.sections %}\n+ <section style="border-top:1px solid var(--color-divider);padding-top:20px;margin-top:24px;">\n+ <h2 style="font-size:22px;margin:0 0 12px;">{{ section.title }}</h2>\n+ {% for paragraph in section.body %}\n+ <p style="font-size:15px;line-height:1.9;margin:0 0 12px;">{{ paragraph }}</p>\n+ {% endfor %}\n+ </section>\n+ {% endfor %}\n+ <p style="margin-top:28px;">\n+ <a class="btn btn-primary" href="{{ contact_url }}" target="_blank" rel="noopener">{{ policy.contactLabel }} ↗</a>\n+ </p>\n+ </main>\n+{% endblock %}\n@@ -0,0 +1,27 @@\n+{% extends "base.html" %}\n+{% from "_summary.html" import notification_summary %}\n+{% block content %}\n+ <main class="search-page topic-page">\n+ <header class="search-header">\n+ <span class="card-kicker">{{ t.topicKicker }}</span>\n+ <h1>{{ topic.title }}</h1>\n+ <p class="text-muted">{{ topic.intro }}</p>\n+ <p class="text-muted" style="font-size:12.5px;">{{ count }} {{ t.topicCount }}</p>\n+ </header>\n+ <section aria-label="{{ topic.title }}">\n+ {% for item in articles %}\n+ <a class="feed-row" href="{{ item.url }}">\n+ <span class="feed-time">{{ item.time }}</span>\n+ <span class="feed-category tag {{ item.tag_class }}">{{ item.cat_label }}</span>\n+ <span class="feed-copy">\n+ <span style="display:block;font-size:15.5px;font-weight:700;line-height:1.4;letter-spacing:-.01em;">{{ item.title }}</span>\n+ {{ notification_summary(item.display_summary, t.summaryLabel, 'feed') }}\n+ </span>\n+ <span class="feed-source text-muted">{{ item.source }}</span>\n+ </a>\n+ {% else %}\n+ <p class="text-muted">{{ t.awaiting }}</p>\n+ {% endfor %}\n+ </section>\n+ </main>\n+{% endblock %}\n@@ -0,0 +1,120 @@\n+"""Offline regression checks for SKYTICAL search and Google News metadata."""\n+from __future__ import annotations\n+\n+import json\n+import re\n+import sys\n+import xml.etree.ElementTree as ET\n+from datetime import timedelta\n+from pathlib import Path\n+\n+ROOT = Path(__file__).resolve().parent.parent\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+\n+import build # noqa: E402\n+\n+\n+def _json_ld(html: str) -> list[dict]:\n+ return [json.loads(value) for value in re.findall(\n+ r'<script type="application/ld\+json">(.*?)</script>', html,\n+ flags=re.DOTALL)]\n+\n+\n+def main() -> None:\n+ assert build.main() == 0\n+\n+ sitemap = ET.parse(ROOT / "site" / "sitemap.xml").getroot()\n+ ns = {"sm": build.SITEMAP_NS, "news": build.NEWS_NS}\n+ locations = [node.text for node in sitemap.findall("sm:url/sm:loc", ns)]\n+ assert len(locations) == len(set(locations))\n+ assert f"{build.SITE_ORIGIN}{build.BASE_PATH}/" in locations\n+ assert f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/" in locations\n+ assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/editorial-policy/"\n+ in locations)\n+ for slug in build.TOPICS:\n+ assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/topics/{slug}/"\n+ in locations)\n+ assert (f"{build.SITE_ORIGIN}{build.BASE_PATH}/en/topics/{slug}/"\n+ in locations)\n+\n+ news_sitemap = ET.parse(ROOT / "site" / "news-sitemap.xml").getroot()\n+ news_rows = news_sitemap.findall("sm:url", ns)\n+ assert news_rows and len(news_rows) <= 1000\n+ cutoff = build.now_utc() - timedelta(days=2, minutes=1)\n+ for row in news_rows:\n+ assert row.findtext("sm:loc", namespaces=ns).startswith(\n+ f"{build.SITE_ORIGIN}{build.BASE_PATH}/")\n+ assert row.findtext("news:news/news:publication/news:name",\n+ namespaces=ns) == "SKYTICAL"\n+ assert row.findtext("news:news/news:publication/news:language",\n+ namespaces=ns) in {"zh-tw", "en"}\n+ published = build.parse_iso(row.findtext(\n+ "news:news/news:publication_date", namespaces=ns))\n+ assert published >= cutoff\n+ assert row.findtext("news:news/news:title", namespaces=ns)\n+\n+ robots = (ROOT / "site" / "robots.txt").read_text(encoding="utf-8")\n+ assert "User-agent: *\nAllow: /" in robots\n+ assert f"Sitemap: {build.SITE_ORIGIN}{build.BASE_PATH}/sitemap.xml" in robots\n+ assert (f"Sitemap: {build.SITE_ORIGIN}{build.BASE_PATH}/news-sitemap.xml"\n+ in robots)\n+\n+ home = (ROOT / "site" / "index.html").read_text(encoding="utf-8")\n+ home_schema = _json_ld(home)\n+ assert home_schema and home_schema[0]["@context"] == "https://schema.org"\n+ assert {node["@type"] for node in home_schema[0]["@graph"]} == {\n+ "Organization", "WebSite"}\n+ assert '<html lang="zh-Hant-TW">' in home\n+ assert '<meta name="robots" content="index,follow,max-image-preview:large">' in home\n+ assert '<link rel="alternate" hreflang="zh-Hant-TW"' in home\n+ assert "SKYTICAL 航空新聞|臺灣與全球即時航空快訊" in home\n+\n+ article_paths = list((ROOT / "site" / "news").glob("*/index.html"))\n+ article = article_paths[0].read_text(encoding="utf-8")\n+ article_schema = _json_ld(article)[0]\n+ assert article_schema["@type"] == "NewsArticle"\n+ assert article_schema["headline"]\n+ assert article_schema["datePublished"].endswith("Z")\n+ assert article_schema["dateModified"].endswith("Z")\n+ assert article_schema["inLanguage"] == "zh-Hant-TW"\n+ assert article_schema["author"]["name"] == "SKYTICAL"\n+ assert article_schema["publisher"]["name"] == "SKYTICAL"\n+ assert '<meta property="og:type" content="article">' in article\n+ assert '<meta property="article:published_time"' in article\n+ assert f'<time datetime="{article_schema["datePublished"]}"' in article\n+ assert "發布:" in article and "來源時間:" in article\n+ assert "SKYTICAL 編輯系統" in article\n+ pictured_article = next(\n+ path.read_text(encoding="utf-8") for path in article_paths\n+ if "<figure" in path.read_text(encoding="utf-8"))\n+ pictured_schema = _json_ld(pictured_article)[0]\n+ assert 'fetchpriority="high" decoding="async"' in pictured_article\n+ assert 'loading="lazy"' not in re.search(\n+ r'<figure.*?</figure>', pictured_article, flags=re.DOTALL).group(0)\n+ assert (f'<meta property="og:image" content="{pictured_schema["image"][0]}">'\n+ in pictured_article)\n+\n+ search = (ROOT / "site" / "search" / "index.html").read_text(\n+ encoding="utf-8")\n+ not_found = (ROOT / "site" / "404.html").read_text(encoding="utf-8")\n+ assert '<meta name="robots" content="noindex,follow">' in search\n+ assert '<meta name="robots" content="noindex,follow">' in not_found\n+\n+ taiwan_hub = (ROOT / "site" / "topics" / "taiwan-aviation" /\n+ "index.html").read_text(encoding="utf-8")\n+ assert "<h1>臺灣航空新聞</h1>" in taiwan_hub\n+ assert 'href="/avwire/news/' in taiwan_hub\n+ for slug in build.TOPICS:\n+ assert f'href="/avwire/topics/{slug}/"' in article\n+\n+ policy = (ROOT / "site" / "editorial-policy" / "index.html").read_text(\n+ encoding="utf-8")\n+ assert "編輯、更正與聯絡政策" in policy\n+ assert "avwire/issues/new" in policy\n+ assert 'href="/avwire/editorial-policy/"' in article\n+\n+ print("test_seo: OK")\n+\n+\n+if __name__ == "__main__":\n+ main()