2026-10-27 TUE UTC 5:36 AM TPE 1:36 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

fix: use channel-neutral public article URLs and metadata (#391)

SHA
ac772a5ae99192a99bdac451d31c3af7d1a3cea6
Commit date
Author
neo · 125815713+nnneeeooo-nnneeeooo@users.noreply.github.com
Committer
GitHub · noreply@github.com

Changed files (11)

  • .github/workflows/hourly.yml modified · +2 / −0
  • pipeline/build.py modified · +40 / −6
  • pipeline/manual_draft.py modified · +1 / −1
  • pipeline/public_article.py added · +47 / −0
  • static/manual.js modified · +5 / −5
  • templates/article-redirect.html added · +12 / −0
  • tests/test_home_sidebar.py modified · +3 / −2
  • tests/test_manual_draft.py modified · +2 / −1
  • tests/test_public_articles.py added · +90 / −0
  • tests/test_radar.py modified · +3 / −3
  • tests/test_search.py modified · +2 / −0

Full diff

diff --git a/.github/workflows/hourly.yml b/.github/workflows/hourly.yml\nindex e2fefaee6..09cf8e172 100644\n--- a/.github/workflows/hourly.yml\n+++ b/.github/workflows/hourly.yml\n@@ -19,6 +19,7 @@ on:\n       - "config/free_provider_probe.json"\n       - "pipeline/article_schema.py"\n       - "pipeline/build.py"\n+      - "pipeline/public_article.py"\n       - "pipeline/common.py"\n       - "pipeline/dedupe.py"\n       - "pipeline/fulltext.py"\n@@ -77,6 +78,7 @@ jobs:\n           python tests/test_provider_health.py\n           python tests/test_free_provider_probe.py\n           python tests/test_build_resilience.py\n+          python tests/test_public_articles.py\n           python tests/test_text_spacing.py\n           node tests/test_text_spacing_client.js\n           python tests/test_home_sidebar.py\ndiff --git a/pipeline/build.py b/pipeline/build.py\nindex af9694a91..8fdd69991 100644\n--- a/pipeline/build.py\n+++ b/pipeline/build.py\n@@ -59,6 +59,9 @@ from model_config import (\n from image_captions import clean_description\n from article_schema import normalize_article_record\n from text_spacing import compact_html\n+from public_article import (\n+    public_article_id, public_news_path, public_asset_url, public_site_url,\n+)\n \n _caption_cache = load_json(DATA_DIR / "image-captions.json", {})\n IMAGE_CAPTIONS = (_caption_cache.get("images", {})\n@@ -159,9 +162,9 @@ L = {\n         "statSerious": "本週嚴重事件",\n         "otp": "準點率排行 · 7月", "fleet": "機隊與訂單追蹤", "fleetDeliv": "2026 上半年交付",\n         "fleetBacklog": "積壓訂單", "fleetOrders": "本月新訂單",\n-        "delays": "今日延誤熱點機場", "autoCompiled": "自動彙整",\n+        "delays": "今日延誤熱點機場", "autoCompiled": "SKYTICAL整理",\n         "refreshNote": "本文隨來源每小時更新", "sourcesBox": "資料來源 Sources",\n-        "genNote": "本文由自動化系統彙整生成,內容以原始來源為準",\n+        "genNote": "本文由SKYTICAL整理,內容以原始來源為準",\n         "genNotePunct": "。",\n         "photoKind": {"airframe_photo": "同機資料照片",\n                       "file_photo": "資料照片(非事件現場照片)",\n@@ -312,9 +315,9 @@ L = {\n         "statSerious": "Serious events this week",\n         "otp": "On-time ranking · Jul", "fleet": "Fleet & orders", "fleetDeliv": "H1 2026 deliveries",\n         "fleetBacklog": "Backlog", "fleetOrders": "New orders this month",\n-        "delays": "Delay hotspots today", "autoCompiled": "Auto-compiled",\n+        "delays": "Delay hotspots today", "autoCompiled": "Compiled by SKYTICAL",\n         "refreshNote": "Refreshes hourly with sources", "sourcesBox": "Sources",\n-        "genNote": "This article was compiled automatically; the original sources prevail",\n+        "genNote": "This article was compiled by SKYTICAL; the original sources prevail",\n         "genNotePunct": ".",\n         "photoKind": {"airframe_photo": "File photo of this airframe",\n                       "file_photo": "File photo (not from this event)",\n@@ -1123,11 +1126,13 @@ def normalize_image(raw_img, titles=()):\n \n \n def rel_path(lang: str, sub: str) -> str:\n+    sub = public_news_path(sub)\n     return ("en/" + sub) if lang == "en" else sub\n \n \n def page_url(lang: str, sub: str) -> str:\n     """URL for a page; sub is '' or 'incidents/' or 'news/<id>/' …"""\n+    sub = public_news_path(sub)\n     return f"{BASE_PATH}/{'en/' if lang == 'en' else ''}{sub}"\n \n \n@@ -1256,7 +1261,7 @@ def prep_article(raw):\n     sources = []\n     for s in raw.get("sources") or []:\n         if isinstance(s, dict) and s.get("url"):\n-            url = str(s["url"])\n+            url = public_site_url(str(s["url"]), SITE_ORIGIN, BASE_PATH)\n             if (not _safe_web_url(url) or is_google_news_url(url)):\n                 # Google News is a discovery aggregator, not an original\n                 # source URL that should be exposed or credited publicly.\n@@ -1295,6 +1300,12 @@ def prep_article(raw):\n         selected_image = None\n     image = normalize_image(selected_image, titles=[\n         (raw.get(lang) or {}).get("title") for lang in ("zh", "en")])\n+    if image:\n+        image["url"] = public_asset_url(image["url"], SITE_ORIGIN, BASE_PATH)\n+        if image.get("link"):\n+            image["link"] = public_site_url(image["link"], SITE_ORIGIN, BASE_PATH)\n+        if image.get("provider") == "SKYTICAL manual upload":\n+            image["provider"] = "SKYTICAL"\n     # Feeds use first site publication; source chronology stays separate.\n     display_dt = publication_dt\n     source_dt = None\n@@ -1382,6 +1393,12 @@ def collect_articles():\n         prev = by_id.get(a["id"])\n         if prev is None or a["dt"] > prev["dt"]:\n             by_id[a["id"]] = a\n+    public_ids = {}\n+    for internal_id in by_id:\n+        public_id = public_article_id(internal_id)\n+        if public_id in public_ids:\n+            raise ValueError(f"public article URL collision: {public_id}")\n+        public_ids[public_id] = internal_id\n     return sorted(by_id.values(),\n                   key=lambda a: (a["news_dt"], a["id"]), reverse=True)\n \n@@ -2093,7 +2110,7 @@ def search_index_item(article, alias_groups, prompt_aliases=(),\n         for lang in article["available_languages"]\n     }\n     return {\n-        "id": article["id"],\n+        "id": public_article_id(article["id"]),\n         "title": {lang: article[lang]["title"] for lang in ("zh", "en")},\n         "summary": {\n             lang: article[lang]["summary"] for lang in ("zh", "en")\n@@ -4029,6 +4046,13 @@ def main() -> int:\n         for f in STATIC_DIR.iterdir():\n             if f.is_file():\n                 shutil.copy2(f, SITE_DIR / "assets" / f.name)\n+                if f.name.startswith("a-"):\n+                    public_name = public_article_id(f.name)\n+                    if public_name != f.name:\n+                        existing = STATIC_DIR / public_name\n+                        if existing.exists() and existing.read_bytes() != f.read_bytes():\n+                            raise ValueError(f"public image URL collision: {public_name}")\n+                        shutil.copy2(f, SITE_DIR / "assets" / public_name)\n     if PUBLIC_DIR.is_dir():\n         for f in PUBLIC_DIR.iterdir():\n             if f.is_file():\n@@ -4348,6 +4372,16 @@ def main() -> int:\n                 ctx["a"] = v\n                 render(env, "article.html",\n                        rel_path(lang, f"news/{a['id']}/index.html"), ctx)\n+                if public_article_id(a["id"]) != a["id"]:\n+                    target = page_url(lang, f"news/{a['id']}/")\n+                    # Bypass rel_path: the legacy path is a redirect only.\n+                    legacy_path = (("en/" if lang == "en" else "")\n+                                   + f"news/{a['id']}/index.html")\n+                    render(env, "article-redirect.html", legacy_path, {\n+                        "html_lang": "zh-Hant-TW" if lang == "zh" else "en",\n+                        "target": target,\n+                        "target_abs": SITE_ORIGIN + target,\n+                    })\n                 pages += 1\n             except Exception as exc:  # degrade: one bad article must not kill the build\n                 failed += 1\ndiff --git a/pipeline/manual_draft.py b/pipeline/manual_draft.py\nindex d50b453b3..111d01898 100644\n--- a/pipeline/manual_draft.py\n+++ b/pipeline/manual_draft.py\n@@ -872,7 +872,7 @@ def _operator_publication_bundle(job_id: str, payload: dict,\n         if not any(row["url"] == url for row in sources):\n             sources.append({"name": source, "url": url})\n     article_id = (\n-        f"a-{fallback_time:%Y%m%d-%H%M}-manual-{job_id[:6]}"\n+        f"a-{fallback_time:%Y%m%d-%H%M}-{job_id[:12]}"\n     )\n     writer_models = payload["writerModels"]\n     languages = (\ndiff --git a/pipeline/public_article.py b/pipeline/public_article.py\nnew file mode 100644\nindex 000000000..4caed9abf\n--- /dev/null\n+++ b/pipeline/public_article.py\n@@ -0,0 +1,47 @@\n+"""Public article identities must not encode the private publishing channel."""\n+import hashlib\n+import re\n+from urllib.parse import urlsplit, urlunsplit\n+\n+\n+def public_article_id(article_id: str) -> str:\n+    parts = article_id.split("-")\n+    if "manual" not in [part.lower() for part in parts]:\n+        return article_id\n+    # Keep established dates and story slugs; never expose workflow markers.\n+    result = "-".join(part for part in parts\n+                      if part.lower() not in {"manual", "recovery"})\n+    if result in {"", "a"}:\n+        return "a-" + hashlib.sha256(article_id.encode()).hexdigest()[:16]\n+    return result if result.startswith("a-") else "a-" + result\n+\n+\n+def public_news_path(path: str) -> str:\n+    return re.sub(r"^(news/)([^/]+)(/|$)",\n+                  lambda match: match[1] + public_article_id(match[2]) + match[3],\n+                  path)\n+\n+\n+def public_asset_url(url: str, origin: str, base: str = "") -> str:\n+    parsed = urlsplit(url)\n+    if parsed.netloc and parsed.netloc != urlsplit(origin).netloc:\n+        return url\n+    prefix = base + "/assets/"\n+    if not parsed.path.startswith(prefix):\n+        return url\n+    name = parsed.path[len(prefix):]\n+    if not name.startswith("a-") or "/" in name:\n+        return url\n+    return urlunsplit(parsed._replace(path=prefix + public_article_id(name)))\n+\n+\n+def public_site_url(url: str, origin: str, base: str = "") -> str:\n+    url = public_asset_url(url, origin, base)\n+    parsed = urlsplit(url)\n+    if parsed.netloc and parsed.netloc != urlsplit(origin).netloc:\n+        return url\n+    for prefix in (base + "/", base + "/en/"):\n+        if parsed.path.startswith(prefix + "news/"):\n+            path = prefix + public_news_path(parsed.path[len(prefix):])\n+            return urlunsplit(parsed._replace(path=path))\n+    return url\ndiff --git a/static/manual.js b/static/manual.js\nindex d25925d98..705417f3c 100644\n--- a/static/manual.js\n+++ b/static/manual.js\n@@ -756,7 +756,7 @@\n     const compact = now.toISOString()\n       .replace(/[-:]/g, "").slice(0, 13).replace("T", "-");\n     const articleId = fixedArticleId\n-      || `a-${compact}-manual-${jobId.slice(0, 6)}`;\n+      || `a-${compact}-${jobId.slice(0, 12)}`;\n     const sources = sourceUrls().map(url => {\n       let name = url;\n       try {\n@@ -782,10 +782,10 @@\n       publishedUtc: publicationTime,\n       sortUtc: sortTime,\n       cat: category,\n-      primarySource: sources[0]?.name || "Manual source",\n+      primarySource: sources[0]?.name || "SKYTICAL",\n       image: imageUrls[0] ? {\n         url: imageUrls[0],\n-        provider: "SKYTICAL manual upload",\n+        provider: "SKYTICAL",\n         subject: manualImageSubject(),\n         kind: "file_photo",\n       } : null,\n@@ -1382,7 +1382,7 @@\n     const jobId = makeJobId();\n     const previewId = `a-${new Date().toISOString()\n       .replace(/[-:]/g, "").slice(0, 13).replace("T", "-")}`\n-      + `-manual-${jobId.slice(0, 6)}`;\n+      + `-${jobId.slice(0, 12)}`;\n     const imageUrls = imageUrlsForArticle(previewId);\n     const article = manualArticle(jobId, imageUrls, previewId);\n     const articlePath = `data/articles/manual-${jobId}.json`;\n@@ -1496,7 +1496,7 @@\n         latestCommit = await uploadManualImages(article.id);\n         article.image = {\n           url: imageUrls[0],\n-          provider: "SKYTICAL manual upload",\n+          provider: "SKYTICAL",\n           subject: imageSubject,\n           kind: "file_photo",\n         };\ndiff --git a/templates/article-redirect.html b/templates/article-redirect.html\nnew file mode 100644\nindex 000000000..92527ea27\n--- /dev/null\n+++ b/templates/article-redirect.html\n@@ -0,0 +1,12 @@\n+<!doctype html>\n+<html lang="{{ html_lang }}">\n+<head>\n+<meta charset="utf-8">\n+<meta name="robots" content="noindex,follow">\n+<link rel="canonical" href="{{ target_abs }}">\n+<meta http-equiv="refresh" content="0;url={{ target }}">\n+<title>SKYTICAL</title>\n+<script>location.replace({{ target | tojson }} + location.search + location.hash);</script>\n+</head>\n+<body><a href="{{ target }}">{% if html_lang.startswith('zh') %}前往新聞{% else %}Continue to article{% endif %}</a></body>\n+</html>\ndiff --git a/tests/test_home_sidebar.py b/tests/test_home_sidebar.py\nindex 77bf2884b..68040b546 100644\n--- a/tests/test_home_sidebar.py\n+++ b/tests/test_home_sidebar.py\n@@ -68,10 +68,11 @@ def test_compact_attribution_preserves_article_references():\n     for prefix in ("", "en/"):\n         home = BeautifulSoup((build.SITE_DIR / prefix / "index.html").read_text(\n             encoding="utf-8"), "html.parser")\n-        row = home.select_one(f'.feed-row[href="/{prefix}news/{article["id"]}/"]')\n+        public_id = build.public_article_id(article["id"])\n+        row = home.select_one(f'.feed-row[href="/{prefix}news/{public_id}/"]')\n         if row:  # This report eventually ages out of the homepage feed.\n             assert row.select_one(".feed-source").text.strip() == "Flightradar24"\n-        page = build.SITE_DIR / prefix / "news" / article["id"] / "index.html"\n+        page = build.SITE_DIR / prefix / "news" / public_id / "index.html"\n         soup = BeautifulSoup(page.read_text(encoding="utf-8"), "html.parser")\n         for source in article["sources"]:\n             assert soup.find("a", href=source["url"])\ndiff --git a/tests/test_manual_draft.py b/tests/test_manual_draft.py\nindex bf55f9767..c7d6a4768 100644\n--- a/tests/test_manual_draft.py\n+++ b/tests/test_manual_draft.py\n@@ -235,7 +235,8 @@ def test_publication_detection_prompt_and_bundle():\n         and bundle["article"]["publishedUtc"] == "2026-07-30T02:11:12Z"\n         and bundle["article"]["sortUtc"] == "2026-07-29T12:00:00Z"\n         and bundle["flash"]["timeUtc"] == "2026-07-29T12:00:00Z"\n-        and bundle["flash"]["articleId"] == bundle["article"]["id"],\n+        and bundle["flash"]["articleId"] == bundle["article"]["id"]\n+        and "manual" not in bundle["article"]["id"],\n         "publish bundle links article and flash with independent sort time")\n \n \ndiff --git a/tests/test_public_articles.py b/tests/test_public_articles.py\nnew file mode 100644\nindex 000000000..9b1ed00d1\n--- /dev/null\n+++ b/tests/test_public_articles.py\n@@ -0,0 +1,90 @@\n+"""Legacy channel-marked URLs redirect; every public discovery URL is neutral."""\n+import json\n+import re\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+ROOT = Path(__file__).resolve().parents[1]\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+import build\n+from public_article import public_article_id, public_asset_url, public_site_url\n+\n+\n+def test_public_identity():\n+    assert public_article_id("a-20261024-1855-manual-tsa") == "a-20261024-1855-tsa"\n+    assert public_article_id("a-manual-recovery-story") == "a-story"\n+    assert public_article_id("a-20261024-standard-story") == "a-20261024-standard-story"\n+    assert public_article_id("manual").startswith("a-")\n+    assert public_article_id(public_article_id("a-manual-story")) == "a-story"\n+    assert build.page_url("en", "news/a-manual-story/") == "/en/news/a-story/"\n+    assert build.rel_path("zh", "news/a-manual-story/index.html") == "news/a-story/index.html"\n+    assert public_asset_url("https://example.com/assets/a-manual-photo.jpg",\n+                            build.SITE_ORIGIN).endswith("a-manual-photo.jpg")\n+    assert public_asset_url("https://skytical.tech/assets/a-manual-photo.jpg",\n+                            build.SITE_ORIGIN).endswith("a-photo.jpg")\n+    assert public_site_url("https://skytical.tech/en/news/a-manual-story/?utm_source=fb#source",\n+                           build.SITE_ORIGIN) == "https://skytical.tech/en/news/a-story/?utm_source=fb#source"\n+\n+\n+def verify_published_site():\n+    test_public_identity()\n+    assert build.main() == 0\n+    articles = build.collect_articles()\n+    migrated = [a for a in articles if public_article_id(a["id"]) != a["id"]]\n+    assert migrated\n+    index = json.loads((build.SITE_DIR / "search-index.json").read_text(encoding="utf-8"))\n+    indexed = {row["id"]: row for row in index["items"]}\n+    count = 0\n+    for article in migrated:\n+        public_id = public_article_id(article["id"])\n+        assert article["id"] not in indexed\n+        assert public_id in indexed\n+        for lang in article["available_languages"]:\n+            prefix = "en/" if lang == "en" else ""\n+            target = build.page_url(lang, f"news/{article['id']}/")\n+            page = (build.SITE_DIR / prefix / "news" / public_id / "index.html").read_text(encoding="utf-8")\n+            soup = BeautifulSoup(page, "html.parser")\n+            assert soup.h1\n+            assert soup.select_one('link[rel="canonical"]')["href"] == build.SITE_ORIGIN + target\n+            assert "SKYTICAL manual upload" not in page\n+            assert article["id"] not in page\n+            assert indexed[public_id]["url"][lang] == target\n+            for source in article["sources"]:\n+                assert soup.find("a", href=source["url"])\n+            legacy = (build.SITE_DIR / prefix / "news" / article["id"] / "index.html").read_text(encoding="utf-8")\n+            redirect = BeautifulSoup(legacy, "html.parser")\n+            assert redirect.select_one('meta[name="robots"]')["content"] == "noindex,follow"\n+            assert redirect.select_one('link[rel="canonical"]')["href"] == build.SITE_ORIGIN + target\n+            assert redirect.select_one('meta[http-equiv="refresh"]')["content"] == "0;url=" + target\n+            assert "location.search + location.hash" in legacy\n+            assert not redirect.h1\n+            if article["image"]:\n+                image_url = article["image"]["url"]\n+                assert "-manual-" not in image_url\n+                if urlsplit(image_url).netloc == urlsplit(build.SITE_ORIGIN).netloc:\n+                    assert (build.SITE_DIR / urlsplit(image_url).path.lstrip("/")).is_file()\n+            count += 1\n+    leak = re.compile(r'(?:href|src|content)="[^"\n]*(?:/news/|/assets/a-)[^"\n]*-manual-')\n+    for path in build.SITE_DIR.rglob("*.html"):\n+        assert not leak.search(path.read_text(encoding="utf-8")), path\n+    for path in [*build.SITE_DIR.glob("*sitemap*.xml"), *build.SITE_DIR.rglob("feed.xml")]:\n+        assert not re.search(r"/news/[^<]*-manual-", path.read_text(encoding="utf-8")), path\n+    print(f"test_public_articles: OK ({len(migrated)} articles, {count} legacy redirects)")\n+\n+\n+def main():\n+    original_site = build.SITE_DIR\n+    with tempfile.TemporaryDirectory() as directory:\n+        try:\n+            build.SITE_DIR = Path(directory) / "site"\n+            verify_published_site()\n+        finally:\n+            build.SITE_DIR = original_site\n+\n+\n+if __name__ == "__main__":\n+    main()\ndiff --git a/tests/test_radar.py b/tests/test_radar.py\nindex 41998862c..85b49cec9 100644\n--- a/tests/test_radar.py\n+++ b/tests/test_radar.py\n@@ -265,17 +265,17 @@ def main() -> None:\n     assert "if: needs.build.outputs.should_deploy == 'true'" in radar_workflow\n \n     assert any(\n-        "本文由自動化系統彙整生成,內容以原始來源為準• "\n+        "本文由SKYTICAL整理,內容以原始來源為準• "\n         "Nemotron 3 Super" in page\n         for page in zh_articles\n     )\n     assert any(\n-        "本文由自動化系統彙整生成,內容以原始來源為準• "\n+        "本文由SKYTICAL整理,內容以原始來源為準• "\n         "Nemotron 3 Ultra" in page\n         for page in zh_articles\n     )\n     assert any(\n-        "This article was compiled automatically; the original sources "\n+        "This article was compiled by SKYTICAL; the original sources "\n         "prevail • Nemotron 3 Super" in page\n         for page in en_articles\n     )\ndiff --git a/tests/test_search.py b/tests/test_search.py\nindex 5cd8f14cb..683b38edf 100644\n--- a/tests/test_search.py\n+++ b/tests/test_search.py\n@@ -207,6 +207,7 @@ def main() -> None:\n     prompt_rows = build.daily_search_prompt_rows()\n     assert len(prompt_rows) == len(zh_placeholders) == len(en_placeholders)\n     for article_id, zh_prompt, en_prompt in prompt_rows:\n+        article_id = build.public_article_id(article_id)\n         assert article_id in items_by_id\n         indexed = items_by_id[article_id]["search"]\n         assert normalize_search_text(zh_prompt) in indexed\n@@ -222,6 +223,7 @@ def main() -> None:\n     assert "維珍澳洲航班乘客事件" in historical["search"]\n     assert "virgin australia passenger incident" in historical["search"]\n     for article_id, zh_prompt, en_prompt in alias_rows:\n+        article_id = build.public_article_id(article_id)\n         if article_id not in items_by_id:\n             continue\n         indexed = items_by_id[article_id]["search"]

You found a secret theme!

8-bit mode is unlocked. Refresh to apply it now. You can switch themes anytime using the theme selector.