完整差異
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"]