{
  "sha": "ac772a5ae99192a99bdac451d31c3af7d1a3cea6",
  "redacted": false,
  "message": "fix: use channel-neutral public article URLs and metadata (#391)",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "neo",
    "email": "125815713+nnneeeooo-nnneeeooo@users.noreply.github.com"
  },
  "committer": {
    "login": "web-flow",
    "name": "GitHub",
    "email": "noreply@github.com"
  },
  "created_at": "2026-10-25T10:19:33Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 40,
      "deletions": 6,
      "changes": 46
    },
    {
      "filename": "pipeline/manual_draft.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "pipeline/public_article.py",
      "previous_filename": "",
      "status": "added",
      "additions": 47,
      "deletions": 0,
      "changes": 47
    },
    {
      "filename": "static/manual.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 5,
      "changes": 10
    },
    {
      "filename": "templates/article-redirect.html",
      "previous_filename": "",
      "status": "added",
      "additions": 12,
      "deletions": 0,
      "changes": 12
    },
    {
      "filename": "tests/test_home_sidebar.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 2,
      "changes": 5
    },
    {
      "filename": "tests/test_manual_draft.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "tests/test_public_articles.py",
      "previous_filename": "",
      "status": "added",
      "additions": 90,
      "deletions": 0,
      "changes": 90
    },
    {
      "filename": "tests/test_radar.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 3,
      "changes": 6
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    }
  ],
  "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\"]"
}
