{
  "sha": "9521e35962e12e6237599a285d6d96689f343d88",
  "message": "feat: require source-backed descriptions for article images (#166)\n\nCo-authored-by: avwire-bot <bot@users.noreply.github.com>",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-09-06T10:15:27Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": ".github/workflows/manual-article-deploy.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 0,
      "changes": 2
    },
    {
      "filename": "data/image-captions.json",
      "previous_filename": "",
      "status": "added",
      "additions": 2005,
      "deletions": 0,
      "changes": 2005
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 28,
      "deletions": 13,
      "changes": 41
    },
    {
      "filename": "pipeline/image_captions.py",
      "previous_filename": "",
      "status": "added",
      "additions": 189,
      "deletions": 0,
      "changes": 189
    },
    {
      "filename": "pipeline/image_fallbacks.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "pipeline/images.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 0,
      "changes": 1
    },
    {
      "filename": "static/manual.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 18,
      "deletions": 3,
      "changes": 21
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 1,
      "deletions": 1,
      "changes": 2
    },
    {
      "filename": "templates/manual.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 0,
      "changes": 4
    },
    {
      "filename": "tests/test_image_captions.py",
      "previous_filename": "",
      "status": "added",
      "additions": 127,
      "deletions": 0,
      "changes": 127
    },
    {
      "filename": "tests/test_images.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 3,
      "changes": 8
    }
  ],
  "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\","
}
