2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

feat: require source-backed descriptions for article images (#166) Co-authored-by: avwire-bot <bot@users.noreply.github.com>

SHA
9521e35962e12e6237599a285d6d96689f343d88
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (13)

  • .github/workflows/hourly.yml modified · +2 / −0
  • .github/workflows/manual-article-deploy.yml modified · +2 / −0
  • data/image-captions.json added · +2005 / −0
  • pipeline/build.py modified · +28 / −13
  • pipeline/image_captions.py added · +189 / −0
  • pipeline/image_fallbacks.py modified · +1 / −0
  • pipeline/images.py modified · +1 / −0
  • static/manual.js modified · +18 / −3
  • templates/article.html modified · +1 / −1
  • templates/home.html modified · +1 / −1
  • templates/manual.html modified · +4 / −0
  • tests/test_image_captions.py added · +127 / −0
  • tests/test_images.py modified · +5 / −3

Full diff

@@ -85,6 +85,8 @@ jobs:\n           python pipeline/airport_codes.py\n           python pipeline/images.py\n           python pipeline/image_fallbacks.py\n+          python pipeline/image_captions.py\n+          python tests/test_image_captions.py\n           python pipeline/image_qa.py\n           # The saved Taipei date makes this one LLM-assisted refresh per day.\n           # Missing/invalid model output falls back to published article titles.\n@@ -58,6 +58,8 @@ jobs:\n         run: |\n           python pipeline/summary_guard.py\n           python pipeline/airport_codes.py\n+          python pipeline/image_captions.py\n+          python tests/test_image_captions.py\n           python pipeline/build.py\n \n       - name: Scan publishable output\n\n@@ -55,6 +55,13 @@\n     MODEL_DISPLAY_NAMES,\n     MODEL_ORDER,\n )\n+from image_captions import clean_description\n+\n+_caption_cache = load_json(DATA_DIR / "image-captions.json", {})\n+IMAGE_CAPTIONS = (_caption_cache.get("images", {})\n+                  if isinstance(_caption_cache, dict) else {})\n+if not isinstance(IMAGE_CAPTIONS, dict):\n+    IMAGE_CAPTIONS = {}\n \n PUBLIC_DIR = Path(__file__).resolve().parent.parent / "public"\n \n@@ -975,37 +982,43 @@ def _public_direct_url(url: str) -> bool:\n     return bool(value) and _safe_web_url(value) and not is_google_news_url(value)\n \n \n-def normalize_image(raw_img):\n+def normalize_image(raw_img, titles=()):\n     """Article image -> {url, link, credit, license, provider, kind} or None.\n \n-    Accepts the images.py match dict or a plain URL string (legacy).\n+    Accepts the images.py match dict or a plain URL string (legacy) with an\n+    exact-URL cached description. Uncaptioned photos use the brand fallback.\n     Only http(s) URLs survive; a credited external embed keeps its\n     attribution and backlink so CC/photographer terms are honored.\n     """\n+    if isinstance(raw_img, str):\n+        raw_img = {"url": raw_img}\n     if isinstance(raw_img, dict):\n         url = str(raw_img.get("url") or "")\n         if not url or not _safe_web_url(url):\n             return None\n-        link = str(raw_img.get("link") or "")\n+        cached = IMAGE_CAPTIONS.get(url) or {}\n+        if not isinstance(cached, dict):\n+            cached = {}\n+        subject = (clean_description(raw_img.get("description"))\n+                   or clean_description(raw_img.get("subject"))\n+                   or clean_description(cached.get("subject")))\n+        if not subject or subject.casefold() in {\n+                str(title).strip().casefold() for title in titles if title}:\n+            return None\n+        link = str(raw_img.get("link") or cached.get("link") or "")\n         return {\n             "url": url,\n             "link": link if link and _safe_web_url(link) else None,\n             "credit": str(raw_img.get("credit") or "") or None,\n             "license": str(raw_img.get("license") or "") or None,\n-            "provider": str(raw_img.get("provider") or "") or None,\n-            "subject": str(raw_img.get("subject") or "") or None,\n+            "provider": str(raw_img.get("provider") or cached.get("provider") or "") or None,\n+            "subject": subject,\n             "kind": (raw_img.get("kind")\n                      if raw_img.get("kind") in ("airframe_photo",\n                                                 "file_photo",\n                                                 "event_photo")\n                      else "file_photo"),\n         }\n-    if raw_img:\n-        url = str(raw_img)\n-        if not _safe_web_url(url):\n-            return None\n-        return {"url": url, "link": None, "credit": None, "license": None,\n-                "provider": None, "subject": None, "kind": "file_photo"}\n     return None\n \n \n@@ -1153,7 +1166,8 @@ def side(own, other):\n     ]\n     if not available_languages:\n         available_languages = ["zh", "en"]\n-    image = normalize_image(raw.get("image"))\n+    image = normalize_image(raw.get("image"), titles=[\n+        (raw.get(lang) or {}).get("title") for lang in ("zh", "en")])\n     # Display the SOURCE's newest publication time when the write stage\n     # recorded one; the generation time (dt) is used for ordering only, so\n     # a days-old official release is never presented as breaking news.\n@@ -1399,7 +1413,8 @@ def _hero_image_caption(image, lang: str) -> str:\n         )\n     parts = []\n     if image.get("subject"):\n-        parts.append(str(image["subject"]))\n+        parts.append(("圖中主體:" if lang == "zh" else "Pictured: ")\n+                     + str(image["subject"]))\n     kind = str(image.get("kind") or "")\n     kind_label = L[lang]["photoKind"].get(kind)\n     if kind_label:\n@@ -0,0 +1,189 @@\n+"""Recover descriptions of the exact source image, never from story titles.\n+\n+The URL-keyed cache also serves legacy URL-only images at build time. Missing\n+metadata is retried after six hours; the renderer uses the named brand fallback\n+until a description exists. No model, filename guess, or network call in build.\n+"""\n+from __future__ import annotations\n+\n+import argparse\n+from concurrent.futures import ThreadPoolExecutor\n+from datetime import timedelta\n+from html import unescape\n+import re\n+import sys\n+from pathlib import Path\n+from urllib.parse import unquote, urljoin, urlsplit\n+\n+import requests\n+from bs4 import BeautifulSoup\n+\n+sys.path.insert(0, str(Path(__file__).resolve().parent))\n+from common import ARTICLES_DIR, DATA_DIR, USER_AGENT, load_json, now_utc, parse_iso, save_json\n+\n+CACHE_PATH = DATA_DIR / "image-captions.json"\n+MAX_LOOKUPS = 80\n+_GENERIC = re.compile(\n+    r"^(?:image|photo|picture|thumbnail|featured image|file photo|stock photo|"\n+    r"untitled|圖片|照片|資料照片|示意圖|示意照片)(?:\s*\d+)?$", re.I)\n+\n+\n+def clean_description(value) -> str:\n+    if not isinstance(value, str):\n+        return ""\n+    text = " ".join(BeautifulSoup(unescape(value), "html.parser").stripped_strings)\n+    text = re.sub(r"\s+", " ", text).strip()\n+    if (len(text) < 4 or len(text) > 300 or _GENERIC.fullmatch(text)\n+            or re.search(r"\b(?:untitled design|screenshot|gallery[-_ ]*\d|adobestock[_-]?\d)", text, re.I)\n+            or re.match(r"^(?:credit|photo credit|copyright|©)\s*[::]?", text, re.I)\n+            or re.fullmatch(r"[\W\d_]+", text)\n+            or re.search(r"\.(?:jpe?g|png|webp|gif)$", text, re.I)):\n+        return ""\n+    return text\n+\n+\n+def image_identity(url: str, base: str = "") -> tuple[str, str]:\n+    """Ignore CDN resize parameters, but never match by basename alone."""\n+    try:\n+        parts = urlsplit(urljoin(base, unescape(url)))\n+        if parts.scheme not in ("http", "https") or not parts.hostname:\n+            return "", ""\n+        path = unquote(parts.path)\n+        path = re.sub(r"-(?:\d{2,4}x\d{2,4}|scaled)(?=\.[^.]+$)", "", path)\n+        return parts.hostname.lower(), path\n+    except ValueError:\n+        return "", ""\n+\n+\n+def source_description(html: str, image_url: str, source_url: str,\n+                       titles=()) -> dict | None:\n+    """Only alt/figcaption tied to this image can describe the photograph."""\n+    target = image_identity(image_url)\n+    if not target[0]:\n+        return None\n+    soup = BeautifulSoup(html, "html.parser")\n+    blocked = {str(t).strip().casefold() for t in titles if t}\n+    blocked.update(node.get_text(" ", strip=True).casefold()\n+                   for node in soup.select("h1, title"))\n+\n+    def usable(value):\n+        text = clean_description(value)\n+        return text if text.casefold() not in blocked else ""\n+\n+    for img in soup.find_all("img"):\n+        urls = [img.get(k, "") for k in\n+                ("src", "data-src", "data-lazy-src", "data-img-url")]\n+        for key in ("srcset", "data-srcset"):\n+            urls.extend(part.strip().split()[0] for part in\n+                        str(img.get(key, "")).split(",") if part.strip())\n+        if not any(image_identity(u, source_url) == target for u in urls):\n+            continue\n+        subject = usable(img.get("alt", ""))\n+        figure = img.find_parent("figure")\n+        caption = figure.find("figcaption") if figure else None\n+        if not subject and caption:\n+            # Credit alone is attribution, not a description of the subject.\n+            text = caption.get_text(" ", strip=True)\n+            subject = usable(re.split(r"\b(?:Photo )?Credit\s*:", text,\n+                                      flags=re.I)[0])\n+        if subject:\n+            return {"subject": subject, "link": source_url,\n+                    "captionSource": "source-image-metadata"}\n+    og_images = soup.find_all("meta", property="og:image")\n+    if any(image_identity(m.get("content", ""), source_url) == target\n+           for m in og_images):\n+        alt = soup.find("meta", property="og:image:alt")\n+        subject = usable(alt.get("content", "")) if alt else ""\n+        if subject:\n+            return {"subject": subject, "link": source_url,\n+                    "captionSource": "og:image:alt"}\n+    return None\n+\n+\n+def resolve_description(image_url: str, sources: list, titles: list) -> dict | None:\n+    for source in sources[:3]:\n+        url = str(source.get("url") or "")\n+        if not image_identity(url)[0]:\n+            continue\n+        try:\n+            response = requests.get(url, timeout=(5, 15),\n+                                    headers={"User-Agent": USER_AGENT})\n+            if response.status_code != 200:\n+                continue\n+            result = source_description(response.text, image_url, url, titles)\n+            if result:\n+                result["provider"] = str(source.get("name") or\n+                                         urlsplit(url).hostname).split(" — ")[0]\n+                return result\n+        except requests.RequestException:\n+            continue\n+    return None\n+\n+\n+def main(*, all_articles=False, max_lookups=MAX_LOOKUPS) -> int:\n+    now = now_utc()\n+    cache = load_json(CACHE_PATH, {})\n+    entries = cache.get("images", {}) if isinstance(cache, dict) else {}\n+    if not isinstance(entries, dict):\n+        entries = {}\n+    candidates = {}\n+    for path in sorted(ARTICLES_DIR.glob("*.json"), reverse=True):\n+        for article in (load_json(path, {}) or {}).get("articles", []):\n+            if not isinstance(article, dict):\n+                continue\n+            if not all_articles:\n+                try:\n+                    if parse_iso(article.get("publishedUtc", "")) < now - timedelta(days=7):\n+                        continue\n+                except (TypeError, ValueError):\n+                    continue\n+            raw = article.get("image")\n+            if isinstance(raw, dict) and clean_description(raw.get("subject")):\n+                continue\n+            url = raw.get("url") if isinstance(raw, dict) else raw\n+            if not isinstance(url, str) or not image_identity(url)[0]:\n+                continue\n+            old = entries.get(url) or {}\n+            if clean_description(old.get("subject")):\n+                continue\n+            try:\n+                if old.get("retryUtc") and parse_iso(old["retryUtc"]) > now:\n+                    continue\n+            except (TypeError, ValueError):\n+                pass\n+            candidate = candidates.setdefault(url, {"sources": [], "titles": []})\n+            for source in article.get("sources") or []:\n+                if isinstance(source, dict) and source not in candidate["sources"]:\n+                    candidate["sources"].append(source)\n+            candidate["titles"].extend((article.get(lang) or {}).get("title")\n+                                       for lang in ("zh", "en"))\n+    jobs = list(candidates.items())[:max_lookups]\n+\n+    def lookup(job):\n+        url, context = job\n+        return url, resolve_description(url, context["sources"], context["titles"])\n+\n+    found = 0\n+    with ThreadPoolExecutor(max_workers=6) as pool:\n+        for url, result in pool.map(lookup, jobs):\n+            entry = {"checkedUtc": now.isoformat()}\n+            if result:\n+                entry.update(result)\n+                found += 1\n+            else:\n+                entry["retryUtc"] = (now + timedelta(hours=6)).isoformat()\n+            entries[url] = entry\n+    payload = {"images": entries}\n+    if payload != cache:\n+        save_json(CACHE_PATH, payload)\n+    print(f"image captions: {len(jobs)} lookup(s), {found} descriptions, "\n+          f"{len(jobs) - found} awaiting source metadata", flush=True)\n+    return 0\n+\n+\n+if __name__ == "__main__":\n+    parser = argparse.ArgumentParser()\n+    parser.add_argument("--all", action="store_true")\n+    parser.add_argument("--limit", type=int, default=MAX_LOOKUPS)\n+    args = parser.parse_args()\n+    raise SystemExit(main(all_articles=args.all, max_lookups=args.limit))\n@@ -217,6 +217,7 @@ def lookup_commons_airport(airport: str) -> dict | None:\n                 "kind": "file_photo",\n                 "matched": f"airport:{airport}",\n                 "subject": f"{airport} 資料照片",\n+                "description": title.removeprefix("File:").rsplit(".", 1)[0].replace("_", " "),\n             }\n     return None\n \n@@ -716,6 +716,7 @@ def lookup_commons(query: str, require_tokens: list[str],\n                 "kind": "file_photo",\n                 "matched": query,\n                 "subject": subject or None,\n+                "description": title.removeprefix("File:").rsplit(".", 1)[0].replace("_", " "),\n                 "photoYear": photo_year,\n             }\n             if not prefer_recent:\n@@ -20,7 +20,7 @@\n     "publication-period", "manual-order-panel", "sort-follow-publication",\n     "sort-time-fields", "sort-date", "sort-hour", "sort-minute",\n     "sort-period",\n-    "image-input", "image-list", "upload-zone", "article-type", "language",\n+    "image-input", "image-list", "image-description", "upload-zone", "article-type", "language",\n     "model", "reasoning-tier", "chat-history", "instruction", "clear-chat",\n     "generate", "form-error", "security-state", "job-state", "metric-model",\n     "metric-reasoning", "metric-fallback", "metric-duration",\n@@ -659,6 +659,17 @@\n     els.attempts.innerHTML = '<p class="muted">尚無資料</p>';\n   }\n \n+  function manualImageSubject() {\n+    const subject = els["image-description"].value.trim();\n+    if (subject.length < 4 || subject.length > 240\n+        || /^(圖片|照片|資料照片|示意圖|image|photo|file photo)$/i.test(subject)\n+        || subject === els["manual-title-primary"].value.trim()\n+        || subject === els["manual-title-secondary"].value.trim()) {\n+      throw new Error("請填寫首張配圖的具體主體,不能只寫資料照片或直接使用新聞標題。");\n+    }\n+    return subject;\n+  }\n+\n   function validateForm() {\n     if (!validToken) return "私人網址權杖無效";\n     if (!validPat(els["github-token"].value.trim())) {\n@@ -670,6 +681,9 @@\n       return "來源網址只能使用 http:// 或 https://";\n     }\n     if (workbenchMode === "manual") {\n+      if (images.length) {\n+        try { manualImageSubject(); } catch (error) { return error.message; }\n+      }\n       if (!els["manual-title-primary"].value.trim()) {\n         return "請輸入文章標題";\n       }\n@@ -770,7 +784,7 @@\n       image: imageUrls[0] ? {\n         url: imageUrls[0],\n         provider: "AVWIRE manual upload",\n-        subject: zh.title || en.title,\n+        subject: manualImageSubject(),\n         kind: "file_photo",\n       } : null,\n       zh,\n@@ -1475,12 +1489,13 @@\n     try {\n       let latestCommit = "";\n       if (lastResult.operatorAuthored && images.length) {\n+        const imageSubject = manualImageSubject();\n         const imageUrls = imageUrlsForArticle(article.id);\n         latestCommit = await uploadManualImages(article.id);\n         article.image = {\n           url: imageUrls[0],\n           provider: "AVWIRE manual upload",\n-          subject: article.zh.title || article.en.title,\n+          subject: imageSubject,\n           kind: "file_photo",\n         };\n         article.attachments = imageUrls.map((url, index) => ({\n@@ -29,7 +29,7 @@ <h1 class="article-title">{{ a.title }}</h1>\n     <figure style="margin:0 0 22px;">\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 data-image-description style="font-weight:800;color:var(--color-text);">{% if html_lang.startswith('zh') %}圖中主體:{% else %}Pictured: {% endif %}{{ a.image.subject }}</span>\n         <span>{{ t.photoKind[a.image.kind] }}</span>\n         {% if a.image.credit %}<span>© {{ a.image.credit }}</span>{% endif %}\n         {% if a.image.license %}<span>{{ a.image.license }}</span>{% endif %}\n@@ -47,7 +47,7 @@ <h6 style="margin:28px 0 12px;">{{ t.sourceStatus }}</h6>\n         <img id="hero-story-image" class="hero-img"\n              data-fallback-src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}"\n              src="{{ hero.image.url }}" alt="{{ hero.image.subject or '' }}">\n-        <div id="hero-image-caption" class="text-muted" style="font-size:10.5px;margin-top:4px;">{% if hero.image.subject %}<b style="color:var(--color-text);">{{ hero.image.subject }}</b> · {% endif %}{{ t.photoKind[hero.image.kind] }}{% if hero.image.credit %} · © {{ hero.image.credit }}{% endif %}{% if hero.image.license %} · {{ hero.image.license }}{% endif %}{% if hero.image.provider %} · {{ hero.image.provider }}{% endif %}</div>\n+        <div id="hero-image-caption" class="text-muted" style="font-size:10.5px;margin-top:4px;"><b data-image-description style="color:var(--color-text);">{% if html_lang.startswith('zh') %}圖中主體:{% else %}Pictured: {% endif %}{{ hero.image.subject }}</b> · {{ t.photoKind[hero.image.kind] }}{% if hero.image.credit %} · © {{ hero.image.credit }}{% endif %}{% if hero.image.license %} · {{ hero.image.license }}{% endif %}{% if hero.image.provider %} · {{ hero.image.provider }}{% endif %}</div>\n         {% else %}\n         <img id="hero-story-image" class="hero-img" data-image-fallback="true"\n              data-fallback-src="{{ base }}/assets/skytical-social.png?v={{ asset_version }}"\n@@ -107,6 +107,10 @@ <h2>補稿輸入</h2>\n           <span>點選、拖入,或直接 Ctrl+V 貼上;最多 4 張。</span>\n           <div id="image-list" class="image-list"></div>\n         </div>\n+        <label>首張配圖圖說(手動發布上傳圖片時必填)\n+          <input id="image-description" type="text" maxlength="240"\n+                 placeholder="例如:中華航空 Boeing 777-300ER 客機停靠桃園機場;只填照片可確認的內容。">\n+        </label>\n         <label class="toggle-field">\n           <input id="image-direct-relation" type="checkbox" checked>\n           <span>配圖與本篇新聞直接相關</span>\n@@ -0,0 +1,127 @@\n+"""Offline regression checks for evidence-backed, visible image descriptions."""\n+from datetime import timedelta\n+import json\n+from pathlib import Path\n+import sys\n+import tempfile\n+import unittest\n+from unittest.mock import patch\n+\n+ROOT = Path(__file__).resolve().parents[1]\n+sys.path.insert(0, str(ROOT / "pipeline"))\n+import image_captions as captions\n+import build\n+\n+IMAGE = "https://images.example/photos/aircraft.jpg"\n+SOURCE = "https://news.example/story/"\n+\n+\n+class ImageCaptionTests(unittest.TestCase):\n+    def test_exact_image_alt_and_resize_variants(self):\n+        html = '<img src="https://other.example/photos/aircraft.jpg" alt="Wrong plane">' \\n+               '<img data-src="https://images.example/photos/aircraft-1280x720.jpg?w=900" ' \\n+               'alt="Virgin Atlantic Airbus A350">'\n+        result = captions.source_description(html, IMAGE, SOURCE)\n+        self.assertEqual(result["subject"], "Virgin Atlantic Airbus A350")\n+        self.assertEqual(result["link"], SOURCE)\n+\n+    def test_no_story_title_filename_or_unrelated_caption_guess(self):\n+        for html in (\n+            '<h1>Airline cuts five routes</h1><img src="' + IMAGE + '" alt="Airline cuts five routes">',\n+            '<meta property="og:description" content="Airline cuts routes">',\n+            '<img src="https://images.example/other.jpg" alt="Airbus A350">',\n+            '<img src="' + IMAGE + '" alt="photo">',\n+            '<img src="' + IMAGE + '" alt="aircraft.jpg">',\n+        ):\n+            self.assertIsNone(captions.source_description(html, IMAGE, SOURCE))\n+\n+    def test_figcaption_and_credit_only(self):\n+        html = '<figure><img src="' + IMAGE + '"><figcaption>Airbus A350 at Heathrow. Credit: Jane</figcaption></figure>'\n+        self.assertEqual(captions.source_description(html, IMAGE, SOURCE)["subject"],\n+                         "Airbus A350 at Heathrow.")\n+        self.assertIsNone(captions.source_description(\n+            html.replace("Airbus A350 at Heathrow. ", ""), IMAGE, SOURCE))\n+\n+    def test_og_alt_requires_matching_image(self):\n+        html = '<meta property="og:image" content="' + IMAGE + '">' \\n+               '<meta property="og:image:alt" content="Boeing 777 freighter">'\n+        self.assertEqual(captions.source_description(html, IMAGE, SOURCE)["subject"],\n+                         "Boeing 777 freighter")\n+        self.assertIsNone(captions.source_description(html, IMAGE + "other", SOURCE))\n+\n+    def test_generic_or_invalid_descriptions_are_not_publishable(self):\n+        for value in (None, {}, "資料照片", "123456", "Credit: Someone", "x" * 301):\n+            self.assertEqual(captions.clean_description(value), "")\n+\n+    def test_build_legacy_cache_and_no_fake_event_claim(self):\n+        with patch.object(build, "IMAGE_CAPTIONS", {IMAGE: {\n+                "subject": "Virgin Atlantic Airbus A350", "link": SOURCE,\n+                "provider": "Simple Flying"}}):\n+            image = build.normalize_image(IMAGE)\n+            self.assertEqual(image["subject"], "Virgin Atlantic Airbus A350")\n+            self.assertEqual(image["kind"], "file_photo")\n+            self.assertEqual(image["link"], SOURCE)\n+            self.assertIn("圖中主體:Virgin Atlantic Airbus A350",\n+                          build._hero_image_caption(image, "zh"))\n+            self.assertIn("Pictured: Virgin Atlantic Airbus A350",\n+                          build._hero_image_caption(image, "en"))\n+            self.assertIn("非事件現場照片", build._hero_image_caption(image, "zh"))\n+\n+    def test_uncaptioned_and_headline_copied_images_use_brand_fallback(self):\n+        with patch.object(build, "IMAGE_CAPTIONS", {}):\n+            self.assertIsNone(build.normalize_image(IMAGE))\n+            self.assertIsNone(build.normalize_image({"url": IMAGE, "subject": "資料照片"}))\n+            self.assertIsNone(build.normalize_image(\n+                {"url": IMAGE, "subject": "Airline cuts routes"}, ["Airline cuts routes"]))\n+\n+    def test_explicit_description_preserves_attribution(self):\n+        raw = {"url": IMAGE, "subject": "Virgin Atlantic",\n+               "description": "Virgin Atlantic Airbus A350 at Heathrow",\n+               "credit": "Jane", "license": "CC BY 4.0", "kind": "airframe_photo"}\n+        normalized = build.normalize_image(raw)\n+        self.assertEqual(normalized["subject"], raw["description"])\n+        for key in ("credit", "license", "kind"):\n+            self.assertEqual(normalized[key], raw[key])\n+\n+    def test_cache_retry_and_url_change(self):\n+        with tempfile.TemporaryDirectory() as temp:\n+            root = Path(temp)\n+            articles = root / "articles"\n+            articles.mkdir()\n+            now = captions.now_utc()\n+            path = articles / "one.json"\n+            row = {"id": "a", "publishedUtc": now.isoformat(), "image": IMAGE,\n+                   "sources": [{"url": SOURCE}], "zh": {"title": "航線調整"}}\n+            path.write_text(json.dumps({"articles": [row]}), encoding="utf-8")\n+            with patch.object(captions, "ARTICLES_DIR", articles), \\n+                 patch.object(captions, "CACHE_PATH", root / "captions.json"), \\n+                 patch.object(captions, "now_utc", return_value=now), \\n+                 patch.object(captions, "resolve_description", return_value=None) as resolve:\n+                captions.main()\n+                captions.main()\n+                self.assertEqual(resolve.call_count, 1)\n+                with patch.object(captions, "now_utc", return_value=now + timedelta(hours=7)):\n+                    captions.main()\n+                self.assertEqual(resolve.call_count, 2)\n+                row["image"] = IMAGE + "?different=1"\n+                path.write_text(json.dumps({"articles": [row]}), encoding="utf-8")\n+                captions.main()\n+                self.assertEqual(resolve.call_count, 3)\n+\n+    def test_all_public_surfaces_and_manual_upload_have_description(self):\n+        for name in ("article", "home"):\n+            html = (ROOT / "templates" / f"{name}.html").read_text(encoding="utf-8")\n+            self.assertIn("data-image-description", html)\n+            self.assertIn("圖中主體:", html)\n+        script = (ROOT / "static" / "manual.js").read_text(encoding="utf-8")\n+        self.assertNotIn("subject: zh.title || en.title", script)\n+        self.assertNotIn("subject: article.zh.title || article.en.title", script)\n+        self.assertIn("subject: manualImageSubject()", script)\n+        for workflow in ("hourly", "manual-article-deploy"):\n+            text = (ROOT / ".github" / "workflows" / f"{workflow}.yml").read_text(encoding="utf-8")\n+            self.assertLess(text.index("python pipeline/image_captions.py"),\n+                            text.index("python pipeline/build.py"))\n+\n+\n+if __name__ == "__main__":\n+    unittest.main()\n@@ -606,6 +606,7 @@ def _boom(url, **kw):\n # ── build-side normalization & honest labels ─────────────────────────────────\n \n img = build.normalize_image({"url": "https://upload.wikimedia.org/a.jpg",\n+                             "subject": "China Airlines Boeing 777-300ER",\n                              "link": "https://commons.wikimedia.org/w/1",\n                              "credit": "P", "license": "CC BY 4.0",\n                              "provider": "Wikimedia Commons",\n@@ -616,15 +617,16 @@ def _boom(url, **kw):\n check("normalize_image rejects non-http urls",\n       build.normalize_image({"url": "javascript:alert(1)"}) is None\n       and build.normalize_image("data:text/html,x") is None)\n-check("legacy string image still accepted",\n-      build.normalize_image("https://x.example/y.jpg")["kind"]\n-      == "file_photo")\n+check("uncaptioned legacy string uses brand fallback",\n+      build.normalize_image("https://x.example/y.jpg") is None)\n check("unknown kind coerced to file_photo (never fake 'event photo')",\n       build.normalize_image({"url": "https://x.example/y.jpg",\n+                             "subject": "Airbus A350 aircraft",\n                              "kind": "unverified_event"})["kind"]\n       == "file_photo")\n check("verified official event-photo kind is preserved",\n       build.normalize_image({"url": "https://www.caa.gov.tw/photo.jpg",\n+                             "subject": "民航局防災演練現場",\n                              "kind": "event_photo"})["kind"]\n       == "event_photo")\n check("honest file-photo labels present in both languages",

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.