News photos: full frame, full color, labelled subject
Per the owner's direction, article photos must make their relation to
the story obvious at a glance:
- No more cropping: hero and article images render the FULL photo
(height auto + object-fit contain with a max height and a neutral
letterbox background). The FAA headquarters sign - the part that
identifies the photo - is no longer cut off.
- No more grayscale on real news photos; the grayscale treatment stays
only on the empty placeholder art.
- Every matched photo now carries a zh subject label rendered ahead of
the credit line (e.g. 澎湖馬公機場 / 美國聯邦航空總署(FAA)總部 /
註冊號 B-XXXXX), stored on the image dict by images.py at match time.
- Re-resolved both published articles with subjects attached.
tests/test_images.py: 26 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
完整差異
@@ -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