feat: require source-backed descriptions for article images (#166)
Co-authored-by: avwire-bot <bot@users.noreply.github.com>
完整差異
@@ -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",