{
  "sha": "a7e8d762dff5ad4d64996183307e17dd5734b019",
  "message": "News photos: full frame, full color, labelled subject\n\nPer the owner's direction, article photos must make their relation to\nthe story obvious at a glance:\n\n- No more cropping: hero and article images render the FULL photo\n  (height auto + object-fit contain with a max height and a neutral\n  letterbox background). The FAA headquarters sign - the part that\n  identifies the photo - is no longer cut off.\n- No more grayscale on real news photos; the grayscale treatment stays\n  only on the empty placeholder art.\n- Every matched photo now carries a zh subject label rendered ahead of\n  the credit line (e.g. 澎湖馬公機場 / 美國聯邦航空總署（FAA）總部 /\n  註冊號 B-XXXXX), stored on the image dict by images.py at match time.\n- Re-resolved both published articles with subjects attached.\n\ntests/test_images.py: 26 checks.\n\nCo-Authored-By: Claude Fable 5 <noreply@anthropic.com>",
  "author": {
    "login": "avwire",
    "name": "avwire",
    "email": ""
  },
  "committer": {
    "login": "avwire",
    "name": "avwire",
    "email": ""
  },
  "created_at": "2026-07-26T22:19:59Z",
  "files": [
    {
      "filename": "data/articles/2026-07-26T17.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "data/articles/2026-07-26T18.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "data/images.json",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 4,
      "changes": 10
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "pipeline/images.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 44,
      "deletions": 36,
      "changes": 80
    },
    {
      "filename": "static/site.css",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 1,
      "changes": 7
    },
    {
      "filename": "templates/article.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 1,
      "changes": 3
    },
    {
      "filename": "templates/home.html",
      "previous_filename": "",
      "status": "modified",
      "additions": 2,
      "deletions": 2,
      "changes": 4
    },
    {
      "filename": "tests/test_images.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 5,
      "deletions": 3,
      "changes": 8
    }
  ],
  "diff": "@@ -12,7 +12,8 @@\\n         \"license\": \"CC BY-SA 4.0\",\\n         \"provider\": \"Wikimedia Commons\",\\n         \"kind\": \"file_photo\",\\n-        \"matched\": \"Magong Airport Penghu\"\\n+        \"matched\": \"Magong Airport Penghu\",\\n+        \"subject\": \"澎湖馬公機場\"\\n       },\\n       \"zh\": {\\n         \"title\": \"民航局公布中秋連假離島加班 1340架次7/27開訂\",\\n@@ -12,7 +12,8 @@\\n         \"license\": \"CC BY-SA 4.0\",\\n         \"provider\": \"Wikimedia Commons\",\\n         \"kind\": \"file_photo\",\\n-        \"matched\": \"Federal Aviation Administration headquarters\"\\n+        \"matched\": \"Federal Aviation Administration headquarters\",\\n+        \"subject\": \"美國聯邦航空總署（FAA）總部\"\\n       },\\n       \"zh\": {\\n         \"title\": \"FAA宣布世足賽反無人機行動成果 逾700架無人機被查扣\",\\n@@ -9,9 +9,10 @@\\n         \"license\": \"CC BY-SA 4.0\",\\n         \"provider\": \"Wikimedia Commons\",\\n         \"kind\": \"file_photo\",\\n-        \"matched\": \"Federal Aviation Administration headquarters\"\\n+        \"matched\": \"Federal Aviation Administration headquarters\",\\n+        \"subject\": \"美國聯邦航空總署（FAA）總部\"\\n       },\\n-      \"checked_utc\": \"2026-07-26T22:05:12.562330+00:00\"\\n+      \"checked_utc\": \"2026-07-26T22:18:30.459690+00:00\"\\n     },\\n     \"a-20260726-1748-caa-taiwan-announces-1-340-extra-flights\": {\\n       \"status\": \"matched\",\\n@@ -22,9 +23,10 @@\\n         \"license\": \"CC BY-SA 4.0\",\\n         \"provider\": \"Wikimedia Commons\",\\n         \"kind\": \"file_photo\",\\n-        \"matched\": \"Magong Airport Penghu\"\\n+        \"matched\": \"Magong Airport Penghu\",\\n+        \"subject\": \"澎湖馬公機場\"\\n       },\\n-      \"checked_utc\": \"2026-07-26T22:05:12.562330+00:00\"\\n+      \"checked_utc\": \"2026-07-26T22:18:30.459690+00:00\"\\n     }\\n   }\\n }\\n@@ -631,6 +631,7 @@ def normalize_image(raw_img):\\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             \"kind\": (raw_img.get(\"kind\")\\n                      if raw_img.get(\"kind\") in (\"airframe_photo\",\\n                                                 \"file_photo\")\\n@@ -641,7 +642,7 @@ def normalize_image(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, \"kind\": \"file_photo\"}\\n+                \"provider\": None, \"subject\": None, \"kind\": \"file_photo\"}\\n     return None\\n \\n \\n@@ -71,38 +71,40 @@\\n # routes say 澎湖/金門/馬祖, never the airport's registry name, so the\\n # config-name match below rarely fires on its own.\\n _AIRPORT_ALIASES = (\\n-    (\"桃園機場\", \"Taoyuan International Airport\", (\"Taoyuan\",)),\\n-    (\"桃園國際機場\", \"Taoyuan International Airport\", (\"Taoyuan\",)),\\n-    (\"松山機場\", \"Taipei Songshan Airport\", (\"Songshan\",)),\\n-    (\"小港機場\", \"Kaohsiung International Airport\", (\"Kaohsiung\",)),\\n-    (\"高雄國際機場\", \"Kaohsiung International Airport\", (\"Kaohsiung\",)),\\n-    (\"澎湖\", \"Magong Airport Penghu\", (\"Magong\",)),\\n-    (\"馬公\", \"Magong Airport Penghu\", (\"Magong\",)),\\n-    (\"金門\", \"Kinmen Airport\", (\"Kinmen\",)),\\n-    (\"馬祖\", \"Matsu Nangan Airport\", (\"Nangan\", \"Matsu\")),\\n-    (\"南竿\", \"Matsu Nangan Airport\", (\"Nangan\", \"Matsu\")),\\n-    (\"北竿\", \"Matsu Beigan Airport\", (\"Beigan\", \"Matsu\")),\\n-    (\"花蓮機場\", \"Hualien Airport\", (\"Hualien\",)),\\n-    (\"臺東機場\", \"Taitung Airport\", (\"Taitung\",)),\\n-    (\"台東機場\", \"Taitung Airport\", (\"Taitung\",)),\\n+    (\"桃園機場\", \"Taoyuan International Airport\", (\"Taoyuan\",), \"桃園國際機場\"),\\n+    (\"桃園國際機場\", \"Taoyuan International Airport\", (\"Taoyuan\",), \"桃園國際機場\"),\\n+    (\"松山機場\", \"Taipei Songshan Airport\", (\"Songshan\",), \"臺北松山機場\"),\\n+    (\"小港機場\", \"Kaohsiung International Airport\", (\"Kaohsiung\",), \"高雄國際機場\"),\\n+    (\"高雄國際機場\", \"Kaohsiung International Airport\", (\"Kaohsiung\",), \"高雄國際機場\"),\\n+    (\"澎湖\", \"Magong Airport Penghu\", (\"Magong\",), \"澎湖馬公機場\"),\\n+    (\"馬公\", \"Magong Airport Penghu\", (\"Magong\",), \"澎湖馬公機場\"),\\n+    (\"金門\", \"Kinmen Airport\", (\"Kinmen\",), \"金門機場\"),\\n+    (\"馬祖\", \"Matsu Nangan Airport\", (\"Nangan\", \"Matsu\"), \"馬祖南竿機場\"),\\n+    (\"南竿\", \"Matsu Nangan Airport\", (\"Nangan\", \"Matsu\"), \"馬祖南竿機場\"),\\n+    (\"北竿\", \"Matsu Beigan Airport\", (\"Beigan\", \"Matsu\"), \"馬祖北竿機場\"),\\n+    (\"花蓮機場\", \"Hualien Airport\", (\"Hualien\",), \"花蓮機場\"),\\n+    (\"臺東機場\", \"Taitung Airport\", (\"Taitung\",), \"臺東機場\"),\\n+    (\"台東機場\", \"Taitung Airport\", (\"Taitung\",), \"臺東機場\"),\\n )\\n \\n # Official-agency fallback: a story about a regulator with no aircraft/\\n # airport entity gets an honest agency file photo. ASCII keys match on\\n # word boundaries; CJK keys as substrings.\\n _ORG_QUERIES = (\\n     (\"faa\", \"Federal Aviation Administration headquarters\",\\n-     (\"Federal Aviation\",)),\\n+     (\"Federal Aviation\",), \"美國聯邦航空總署（FAA）總部\"),\\n     (\"ntsb\", \"National Transportation Safety Board\",\\n-     (\"NTSB\", \"National Transportation Safety\")),\\n+     (\"NTSB\", \"National Transportation Safety\"), \"美國國家運輸安全委員會（NTSB）\"),\\n     (\"icao\", \"International Civil Aviation Organization headquarters\",\\n-     (\"ICAO\", \"International Civil Aviation\")),\\n+     (\"ICAO\", \"International Civil Aviation\"), \"國際民航組織（ICAO）總部\"),\\n     (\"iata\", \"International Air Transport Association\",\\n-     (\"IATA\",)),\\n+     (\"IATA\",), \"國際航空運輸協會（IATA）\"),\\n     (\"easa\", \"European Union Aviation Safety Agency\",\\n-     (\"EASA\",)),\\n-    (\"eurocontrol\", \"Eurocontrol headquarters\", (\"Eurocontrol\",)),\\n-    (\"民用航空局\", \"交通部民用航空局\", (\"民用航空局\", \"Civil Aeronautics\")),\\n+     (\"EASA\",), \"歐盟航空安全局（EASA）\"),\\n+    (\"eurocontrol\", \"Eurocontrol headquarters\", (\"Eurocontrol\",),\\n+     \"歐洲空管組織（Eurocontrol）總部\"),\\n+    (\"民用航空局\", \"交通部民用航空局\", (\"民用航空局\", \"Civil Aeronautics\"),\\n+     \"交通部民用航空局\"),\\n )\\n \\n _airlines = [\\n@@ -183,32 +185,32 @@ def find_aircraft_type(article: dict) -> str | None:\\n \\n \\n def find_airport(article: dict):\\n-    \"\"\"(commons_query, required_title_tokens) for a mentioned airport.\"\"\"\\n+    \"\"\"(commons_query, required_title_tokens, subject_zh) for an airport.\"\"\"\\n     text = _article_text(article)\\n     low = text.casefold()\\n-    for marker, query, tokens in _AIRPORT_ALIASES:\\n+    for marker, query, tokens, subject in _AIRPORT_ALIASES:\\n         if marker in text:\\n-            return query, list(tokens)\\n+            return query, list(tokens), subject\\n     for ap in _tw_airports:\\n         for key in (\"name_zh\", \"name_en\"):\\n             val = str(ap.get(key) or \"\")\\n             if val and (val in text or val.casefold() in low):\\n                 name = str(ap.get(\"name_en\") or val)\\n-                return name, [name.split()[0]]\\n+                return name, [name.split()[0]], str(ap.get(\"name_zh\") or name)\\n     return None\\n \\n \\n def find_org(article: dict):\\n-    \"\"\"(commons_query, required_title_tokens) for an official agency.\"\"\"\\n+    \"\"\"(commons_query, required_title_tokens, subject_zh) for an agency.\"\"\"\\n     text = _article_text(article)\\n     low = text.casefold()\\n-    for marker, query, tokens in _ORG_QUERIES:\\n+    for marker, query, tokens, subject in _ORG_QUERIES:\\n         if re.search(r\"[一-鿿]\", marker):\\n             hit = marker in text\\n         else:\\n             hit = re.search(rf\"\\b{re.escape(marker)}\\b\", low) is not None\\n         if hit:\\n-            return query, list(tokens)\\n+            return query, list(tokens), subject\\n     return None\\n \\n \\n@@ -239,11 +241,13 @@ def lookup_planespotters(reg: str) -> dict | None:\\n             \"provider\": \"Planespotters.net\",\\n             \"kind\": \"airframe_photo\",\\n             \"matched\": reg,\\n+            \"subject\": f\"註冊號 {reg}\",\\n         }\\n     return None\\n \\n \\n-def lookup_commons(query: str, require_tokens: list[str]) -> dict | None:\\n+def lookup_commons(query: str, require_tokens: list[str],\\n+                   subject: str | None = None) -> dict | None:\\n     \"\"\"First freely-licensed Commons bitmap matching the query.\\n \\n     require_tokens: at least one must appear in the file title, so a\\n@@ -289,6 +293,7 @@ def lookup_commons(query: str, require_tokens: list[str]) -> dict | None:\\n                 \"provider\": \"Wikimedia Commons\",\\n                 \"kind\": \"file_photo\",\\n                 \"matched\": query,\\n+                \"subject\": subject or None,\\n             }\\n     return None\\n \\n@@ -306,19 +311,22 @@ def resolve_image(article: dict) -> dict | None:\\n     if airline and actype:\\n         # family token (\"A350\") so Commons titles with any sub-variant match\\n         type_token = actype.split()[-1].split(\"-\")[0]\\n-        return lookup_commons(f'{airline} {actype}', [type_token, airline])\\n+        return lookup_commons(f'{airline} {actype}', [type_token, airline],\\n+                              subject=f\"{airline} {actype}\")\\n     if actype:\\n         type_token = actype.split()[-1].split(\"-\")[0]\\n-        return lookup_commons(f'{actype} aircraft', [type_token])\\n+        return lookup_commons(f'{actype} aircraft', [type_token],\\n+                              subject=actype)\\n     if airline:\\n-        return lookup_commons(f'{airline} aircraft', [airline])\\n+        return lookup_commons(f'{airline} aircraft', [airline],\\n+                              subject=airline)\\n     if airport:\\n-        query, tokens = airport\\n-        return lookup_commons(query, tokens)\\n+        query, tokens, subject = airport\\n+        return lookup_commons(query, tokens, subject=subject)\\n     org = find_org(article)\\n     if org:\\n-        query, tokens = org\\n-        return lookup_commons(query, tokens)\\n+        query, tokens, subject = org\\n+        return lookup_commons(query, tokens, subject=subject)\\n     return None  # nothing visual verified in this article -> no image\\n \\n \\n@@ -100,7 +100,12 @@ a.flash-row:hover { color: inherit; }\\n   border: 1px solid var(--color-divider);\\n   display: flex; align-items: flex-end; padding: 10px;\\n }\\n-.hero-img { height: 280px; width: 100%; object-fit: cover; border: 1px solid var(--color-divider); }\\n+/* real news photos: full frame, full color — never crop away the subject */\\n+.hero-img {\\n+  width: 100%; height: auto; max-height: 420px; object-fit: contain;\\n+  background: color-mix(in srgb, var(--color-text) 6%, var(--color-surface));\\n+  border: 1px solid var(--color-divider);\\n+}\\n \\n .feed-row {\\n   display: grid; grid-template-columns: 48px 84px minmax(0, 1fr) auto;\\n@@ -13,8 +13,9 @@ <h1 style=\"font-size:38px;line-height:1.18;margin:14px 0 12px;\">{{ a.title }}</h\\n     </div>\\n     {% if a.image %}\\n     <figure style=\"margin:0 0 22px;\">\\n-      <img class=\"grayscale\" src=\"{{ a.image.url }}\" alt=\"{{ a.title }}\" loading=\"lazy\" style=\"width:100%;max-height:420px;object-fit:cover;border:1px solid var(--color-divider);\">\\n+      <img src=\"{{ a.image.url }}\" alt=\"{{ a.image.subject or a.title }}\" loading=\"lazy\" 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>{{ 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@@ -38,8 +38,8 @@ <h6 style=\"margin:28px 0 12px;\">{{ t.sourceStatus }}</h6>\\n       {% if hero %}\\n       <a href=\"{{ hero.url }}\"{% if hero.external %} target=\"_blank\" rel=\"noopener\"{% endif %} style=\"display:block;color:inherit;text-decoration:none;cursor:pointer;\">\\n         {% if hero.image %}\\n-        <img class=\"grayscale hero-img\" src=\"{{ hero.image.url }}\" alt=\"\">\\n-        <div class=\"text-muted\" style=\"font-size:10.5px;margin-top:4px;\">{{ 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+        <img class=\"hero-img\" src=\"{{ hero.image.url }}\" alt=\"{{ hero.image.subject or '' }}\">\\n+        <div 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         {% else %}\\n         <div class=\"grayscale hero-ph\">\\n           <span class=\"text-muted\" style=\"font-family:monospace;font-size:11px;\">{{ t.heroImg }}</span>\\n@@ -154,13 +154,13 @@ def reset():\\n        and images.resolve_image(make_article(\\n            \"a\", \"Global travel demand keeps rising\")) is None\\n        and fake.calls == []))\\n-check(\"island wording maps to the airport photo query\",\\n+check(\"island wording maps to the airport photo query with zh subject\",\\n       images.find_airport(make_article(\"a\", \"澎湖離島航線加班\"))\\n-      == (\"Magong Airport Penghu\", [\"Magong\"]))\\n+      == (\"Magong Airport Penghu\", [\"Magong\"], \"澎湖馬公機場\"))\\n check(\"agency stories fall back to an official-agency photo query\",\\n       images.find_org(make_article(\"a\", \"FAA announces new drone rule\"))\\n       == (\"Federal Aviation Administration headquarters\",\\n-          [\"Federal Aviation\"]))\\n+          [\"Federal Aviation\"], \"美國聯邦航空總署（FAA）總部\"))\\n check(\"substring 'faa' inside a word never triggers the org match\",\\n       images.find_org(make_article(\"a\", \"shortfaall of capacity\")) is None)\\n \\n@@ -218,6 +218,8 @@ def reset():\\n check(\"agency fallback attaches the building photo, never the logo\",\\n       art[\"image\"][\"url\"] == \"https://upload.wikimedia.org/faa-hq.jpg\"\\n       and \"logo\" not in art[\"image\"][\"url\"])\\n+check(\"photo carries its zh subject caption\",\\n+      art[\"image\"][\"subject\"] == \"美國聯邦航空總署（FAA）總部\")\\n \\n # ── cache behaviour ──────────────────────────────────────────────────────────\\n "
}
