2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

fix image recovery for verified aircraft

SHA
e909a099593c2bbc17e7b879dcf37caca848ff11
提交時間
作者
nnneeeooo-nnneeeooo
提交者
nnneeeooo-nnneeeooo

變更檔案 (6)

  • pipeline/image_selection.py modified · +18 / −2
  • pipeline/images.py modified · +90 / −17
  • pipeline/source_images.py modified · +61 / −1
  • tests/test_image_selection.py modified · +40 / −0
  • tests/test_images.py modified · +18 / −0
  • tests/test_source_images.py modified · +36 / −0

完整差異

@@ -110,6 +110,16 @@ def model_matches(model, text):\n     return False\n \n \n+def verified_cargo_model(article):\n+    """True only when structured entities name a freighter subtype."""\n+    entities = article.get("entities") or {}\n+    models = (entities.get("aircraft_models")\n+              if isinstance(entities, dict) else []) or []\n+    return any(re.search(\n+        r"(?:P2F|BCF|BDSF|ERF|freighter|cargo)|(?:[/ -]|\d)C?F\)?$",\n+        str(model), re.I) for model in models)\n+\n+\n def rejection_reason(article, raw, captions=None):\n     from image_policy import (article_context_text, article_headline_text,\n                               article_is_airport_operations, article_is_cabin_story,\n@@ -135,7 +145,10 @@ def rejection_reason(article, raw, captions=None):\n             return "registration-mismatch"\n         if article_is_airport_operations(article) and not article_is_incident(article):\n             return "airframe-not-airport-facility"\n-        if re.search(r"彩繪|藝術機|塗裝|\blivery\b|客艙|座椅|貨機|freighter", head, re.I):\n+        if re.search(r"彩繪|藝術機|塗裝|\blivery\b|客艙|座椅", head, re.I):\n+            return "airframe-role-unverified"\n+        if (re.search(r"貨機|貨運|freighter|cargo", head, re.I)\n+                and not verified_cargo_model(article)):\n             return "airframe-role-unverified"\n         carrier = images.find_airline(article)\n         if carrier and not any(phrase(alias, ev) for alias in images._airline_aliases(carrier)):\n@@ -160,7 +173,10 @@ def rejection_reason(article, raw, captions=None):\n     matched = str(im.get("matched") or "")\n     # Exact event caption can describe seats/ceremony without repeating model.\n     if source_bound and im.get("kind") == "event_photo":\n-        if airline and not any(phrase(a, ev) for a in images._airline_aliases(airline)):\n+        event_evidence = f"{ev} {im.get('subject') or ''}"\n+        if airline and not any(\n+                phrase(a, event_evidence)\n+                for a in images._airline_aliases(airline)):\n             return "source-airline-unverified"\n         return None\n     headline_model = images.find_aircraft_type({"en": {"title": head},\n@@ -73,9 +73,11 @@\n TIMEOUT = (10, 30)\n HEADERS = {"User-Agent": USER_AGENT}\n \n-# Politeness caps per run; negative matches back off but keep retrying while\n-# the article remains in the user-facing seven-day window.\n-MAX_LOOKUPS_PER_RUN = 6\n+# Politeness caps per run; scale within a bounded range when due no-image\n+# articles accumulate, then back off negative matches while they remain fresh.\n+BASE_LOOKUPS_PER_RUN = 6\n+MAX_LOOKUPS_PER_RUN = 18\n+LOOKUP_BACKLOG_DIVISOR = 3\n FAST_RETRY_ATTEMPTS = 3\n RETRY_AFTER_HOURS = 6\n LONG_RETRY_AFTER_HOURS = 24\n@@ -121,7 +123,10 @@ def _retry_after_hours(attempts: int) -> int:\n # letter suffix or 4-5 digits, JA####, and common hyphenated prefixes.\n _REG_RE = re.compile(\n     r"\b(B-\d{4,5}|N\d{2,4}[A-Z]{1,2}|N\d{4,5}|JA\d{3,4}[A-Z]?"\n-    r"|(?:HL|VH|HS|9V|9M|D|F|G|PH|EI|OE|VT|A6|A7|TC)-[A-Z]{3,4})\b")\n+    r"|(?:HL|VH|HS|9S|9V|9M|D|F|G|PH|EI|OE|VT|A6|A7|TC)-[A-Z]{3,4})\b")\n+_ENTITY_REG_RE = re.compile(\n+    r"^(?:B-\d{4,5}|N\d{2,5}[A-Z]{0,2}|JA\d{3,4}[A-Z]?|"\n+    r"[A-Z0-9]{1,2}-[A-Z0-9]{3,5})$", re.I)\n \n # Location wording -> Taiwan airport photo query. Articles about island\n # routes say 澎湖/金門/馬祖, never the airport's registry name, so the\n@@ -200,7 +205,18 @@ def _article_text(article: dict) -> str:\n \n \n def find_registration(article: dict) -> str | None:\n-    match = _REG_RE.search(article_lead_text(article))\n+    lead = article_lead_text(article)\n+    entities = article.get("entities") or {}\n+    registrations = (entities.get("registration_numbers")\n+                     if isinstance(entities, dict) else []) or []\n+    for value in registrations:\n+        candidate = str(value or "").strip().upper()\n+        if (_ENTITY_REG_RE.fullmatch(candidate)\n+                and re.search(\n+                    rf"(?<![A-Z0-9]){re.escape(candidate)}(?![A-Z0-9])",\n+                    lead, re.I)):\n+            return candidate\n+    match = _REG_RE.search(lead)\n     return match.group(1) if match else None\n \n \n@@ -513,6 +529,9 @@ def lookup_planespotters(reg: str, *, article=None) -> dict | None:\n         link = photo.get("link")\n         if not thumb or not link:\n             continue\n+        description = " ".join(str(photo.get(key) or "").strip()\n+                               for key in ("airline", "aircraft", "reg"))\n+        description = " ".join(description.split())\n         candidate = {\n             "url": str(thumb),\n             "link": str(link),\n@@ -521,7 +540,8 @@ def lookup_planespotters(reg: str, *, article=None) -> dict | None:\n             "provider": "Planespotters.net",\n             "kind": "airframe_photo",\n             "matched": reg,\n-            "subject": f"註冊號 {reg}",\n+            "subject": description or f"註冊號 {reg}",\n+            "description": description,\n         }\n         if article is None or existing_image_matches(article, candidate):\n             return candidate\n@@ -607,26 +627,40 @@ def lookup_commons(query: str, require_tokens: list[str],\n     return max(candidates, key=lambda row: row[:3])[3] if candidates else None\n \n \n-def resolve_image(article: dict, *, usage=None) -> dict | None:\n+def resolve_image(article: dict, *, usage=None, diagnostics=None) -> dict | None:\n     """Try independently failing providers; never relax subject constraints."""\n     if article.get("articleFormat") == "roundup":\n         return None\n     def attempt(fn, *args, **kwargs):\n+        label = getattr(fn, "__name__", "provider")\n         try:\n             photo = fn(*args, **kwargs)\n-            if photo and existing_image_matches(article, photo):\n+            if not photo:\n+                if diagnostics is not None:\n+                    diagnostics.append(f"{label}:no-result")\n+                return None\n+            reason = rejection_reason(article, photo)\n+            if not reason:\n                 if photo.get("kind") == "file_photo" and (usage or {}).get(image_key(photo), 0) >= MAX_STOCK_REUSE:\n+                    if diagnostics is not None:\n+                        diagnostics.append(f"{label}:stock-reuse-limit")\n                     return None\n                 return photo\n+            if diagnostics is not None:\n+                diagnostics.append(f"{label}:{reason}")\n         except (RequestException, OSError, ValueError, TypeError, KeyError) as exc:\n-            print(f"images: {getattr(fn, "__name__", "provider")}: {type(exc).__name__}")\n+            if diagnostics is not None:\n+                diagnostics.append(\n+                    f"{label}:lookup-error:{type(exc).__name__}")\n+            print(f"images: {label}: {type(exc).__name__}")\n         return None\n     photo = attempt(lookup_official_source_photo, article)\n     if photo:\n         return photo\n     reg = find_registration(article)\n     if reg:\n-        photo = attempt(lookup_planespotters, reg, article=article)\n+        # Validate once in ``attempt`` so the precise reason is retained.\n+        photo = attempt(lookup_planespotters, reg)\n         if photo:\n             return photo\n     airline, actype, airport = find_airline(article), find_aircraft_type(article), find_airport(article)\n@@ -682,12 +716,32 @@ def _recent_batches():\n             yield path, batch, articles\n \n \n+def _lookup_budget(articles, entries, now) -> int:\n+    """Scale lookups with due no-image backlog without becoming unbounded."""\n+    due = 0\n+    for article in articles:\n+        if article.get("articleFormat") == "roundup" or article.get("image"):\n+            continue\n+        entry = entries.get(str(article.get("id") or "")) or {}\n+        try:\n+            if (entry.get("status") == "none" and entry.get("next_retry_utc")\n+                    and parse_iso(entry["next_retry_utc"]) > now):\n+                continue\n+        except (TypeError, ValueError):\n+            pass\n+        due += 1\n+    base = min(BASE_LOOKUPS_PER_RUN, MAX_LOOKUPS_PER_RUN)\n+    demand = (due + LOOKUP_BACKLOG_DIVISOR - 1) // LOOKUP_BACKLOG_DIVISOR\n+    return min(MAX_LOOKUPS_PER_RUN, max(base, demand))\n+\n+\n def main() -> int:\n     if not ARTICLES_DIR.is_dir():\n         print("images: no articles yet")\n         return 0\n     enforce_recent()\n-    usage = stock_usage([a for _, _, aa in _recent_batches() for a in aa])\n+    recent_batches = list(_recent_batches())\n+    usage = stock_usage([a for _, _, aa in recent_batches for a in aa])\n     cache = load_json(CACHE_PATH, {})\n     entries = cache.get("articles")\n     if not isinstance(entries, dict):\n@@ -696,9 +750,11 @@ def main() -> int:\n     cache = {"articles": entries, "sources": source_entries}\n     now = now_utc()\n     cutoff = now - timedelta(days=MAX_ARTICLE_AGE_DAYS)\n+    lookup_budget = _lookup_budget(\n+        [a for _, _, aa in recent_batches for a in aa], entries, now)\n     lookups = attached = source_lookups = 0\n \n-    for path, batch, articles in _recent_batches():\n+    for path, batch, articles in recent_batches:\n         changed = False\n         for article in articles:\n             art_id = str(article.get("id") or "")\n@@ -714,12 +770,16 @@ def main() -> int:\n                     changed = True\n                 entries.pop(art_id, None)\n                 continue\n+            rejection_reasons = []\n             # Exact-source caption recovery outranks Commons, but a caption\n             # still has to identify this article's primary visual subject.\n             for candidate in (article.get("sourceImageCandidates") or []) if can_upgrade(article) else []:\n                 bound = dict(article, sources=candidate.get("sources") or [])\n                 source_image = prepare_image(bound, candidate.get("url"))\n-                if (source_image and existing_image_matches(article, source_image)\n+                reason = rejection_reason(bound, candidate.get("url"))\n+                if reason:\n+                    rejection_reasons.append(f"source-candidate:{reason}")\n+                if (source_image and not reason\n                         and usage[image_key(source_image)] < MAX_STOCK_REUSE):\n                     if source_image != article.get("image"):\n                         article["image"] = source_image\n@@ -740,16 +800,25 @@ def main() -> int:\n                     retry_due = parse_iso(source_entry["next_retry_utc"]) <= now\n                 except (KeyError, TypeError, ValueError):\n                     pass\n-                if not source_image and retry_due and source_lookups < MAX_LOOKUPS_PER_RUN:\n+                if not source_image and retry_due and source_lookups < lookup_budget:\n                     source_lookups += 1\n+                    source_reason = "no-event-photo"\n                     try:\n                         source_image = lookup_source_photo(article)\n                     except Exception as exc:\n+                        source_reason = f"lookup-error:{type(exc).__name__}"\n                         print(f"images: source lookup failed for {art_id}: {type(exc).__name__}")\n+                    if source_image:\n+                        source_reason = rejection_reason(article, source_image)\n+                        if source_reason:\n+                            source_image = None\n                     source_entries[source_url] = {\n                         "image": source_image,\n+                        "rejection_reason": source_reason,\n                         "next_retry_utc": (now + timedelta(hours=6)).isoformat(),\n                     }\n+                    if source_reason:\n+                        rejection_reasons.append(f"source-photo:{source_reason}")\n                 if source_image and existing_image_matches(article, source_image):\n                     article["image"] = source_image\n                     entries[art_id] = {"status": "matched", "image": source_image,\n@@ -784,11 +853,12 @@ def main() -> int:\n                     continue\n                 entries.pop(art_id, None)\n                 entry = {}\n-            if lookups >= MAX_LOOKUPS_PER_RUN:\n+            if lookups >= lookup_budget:\n                 continue\n             lookups += 1\n             try:\n-                image = resolve_image(article, usage=usage)\n+                image = resolve_image(article, usage=usage,\n+                                      diagnostics=rejection_reasons)\n             except Exception as exc:  # enhancement only: never fail the run\n                 print(f"images: lookup failed for {art_id}: "\n                       f"{type(exc).__name__}: {exc}")\n@@ -806,6 +876,8 @@ def main() -> int:\n                 attempts = entry.get("attempts", 0) + 1\n                 entries[art_id] = {\n                     "status": "none", "attempts": attempts,\n+                    "rejection_reasons": list(dict.fromkeys(\n+                        rejection_reasons or ["no-suitable-image"])),\n                     "checked_utc": now.isoformat(),\n                     "next_retry_utc":\n                         (now + timedelta(hours=_retry_after_hours(attempts)))\n@@ -816,7 +888,8 @@ def main() -> int:\n \n     if cache != load_json(CACHE_PATH, {}):\n         save_json(CACHE_PATH, cache)\n-    print(f"images: {lookups} stock lookup(s), {source_lookups} source lookup(s), "\n+    print(f"images: budget {lookup_budget}; {lookups} stock lookup(s), "\n+          f"{source_lookups} source lookup(s), "\n           f"{attached} article(s) got a photo")\n     return 0\n \n@@ -22,6 +22,9 @@ def supported_source(article):\n                 {"www.cna.com.tw", "cna.com.tw"}\n                 and re.fullmatch(r"/news/[a-z]+/\d+\.aspx", parts.path)):\n             return url\n+        if (parts.scheme == "https" and parts.hostname == "www.aerotime.aero"\n+                and re.fullmatch(r"/articles/[a-z0-9-]+/?", parts.path)):\n+            return url\n     return None\n \n \n@@ -78,6 +81,58 @@ def parse_cna_photo(html, source_url):\n     return None\n \n \n+def _aerotime_image_key(url):\n+    parts = urlsplit(url)\n+    if (parts.scheme != "https" or parts.hostname != "www.aerotime.aero"\n+            or not re.fullmatch(\n+                r"/images/\d{4}/\d{2}/[^/]+\.(?:jpe?g|png|webp)",\n+                parts.path, re.I)):\n+        return None\n+    path = re.sub(r"-\d{2,4}x\d{2,4}(?=\.[^.]+$)", "", parts.path)\n+    return re.sub(r"\.webp$", "", path, flags=re.I)\n+\n+\n+def _aerotime_subject(article):\n+    """Describe an exact article-bound event photo with verified entities."""\n+    entities = article.get("entities") or {}\n+    values = []\n+    for key in ("airlines", "aircraft_models", "registration_numbers"):\n+        group = entities.get(key) if isinstance(entities, dict) else []\n+        if group:\n+            value = clean_description(str(group[0]))\n+            if value and value not in values:\n+                values.append(value)\n+    return " ".join(values)\n+\n+\n+def parse_aerotime_photo(html, source_url, article):\n+    """Return AeroTime's credited lead event image, never an unbound OG ad."""\n+    soup = BeautifulSoup(html, "html.parser")\n+    lead = soup.find("meta", property="og:image")\n+    url = lead.get("content", "") if lead else ""\n+    key = _aerotime_image_key(url)\n+    if not key:\n+        return None\n+    for figure in soup.select("figure.cs-entry__post-media"):\n+        img = figure.find("img")\n+        caption = figure.find("figcaption")\n+        if (not img or not caption\n+                or _aerotime_image_key(img.get("src", "")) != key):\n+            continue\n+        credit = clean_description(" ".join(caption.stripped_strings))\n+        subject = _aerotime_subject(article)\n+        if not credit or not subject:\n+            continue\n+        return {\n+            "url": url, "link": source_url,\n+            "subject": subject, "sourceCaption": credit,\n+            "credit": credit, "license": None,\n+            "provider": "AeroTime", "kind": "event_photo",\n+            "matched": "source:aerotime",\n+        }\n+    return None\n+\n+\n def lookup_source_photo(article):\n     url = supported_source(article)\n     if not url:\n@@ -88,4 +143,9 @@ def lookup_source_photo(article):\n     # Do not accept a redirect to a different article or publisher.\n     if response.url.rstrip("/") != url.rstrip("/"):\n         return None\n-    return parse_cna_photo(response.text, url)\n+    host = urlsplit(url).hostname\n+    if host in {"www.cna.com.tw", "cna.com.tw"}:\n+        return parse_cna_photo(response.text, url)\n+    if host == "www.aerotime.aero":\n+        return parse_aerotime_photo(response.text, url, article)\n+    return None\n@@ -59,6 +59,12 @@ def test_background_registration_is_not_primary(self):\n         a["en"]["body"] = ["Earnings increased.", "A historical flight used N512DN."]\n         self.assertIsNone(images.find_registration(a))\n \n+    def test_verified_entity_registration_supports_9s_prefix(self):\n+        a = article(\n+            "Trans Air Cargo Service DC-8-73CF 9S-AJO runway excursion",\n+            entities={"registration_numbers": ["9S-AJO", "OB-2231P"]})\n+        self.assertEqual(images.find_registration(a), "9S-AJO")\n+\n     def test_country_is_not_airline(self):\n         self.assertIsNone(images.find_airline(article("美國公布無人機新規則")))\n \n@@ -136,6 +142,40 @@ def test_airframe_registration_does_not_attest_new_operator(self):\n               "kind": "airframe_photo", "matched": "N512DN", "link": "https://www.planespotters.net/photo/1"}\n         self.assertEqual(policy.rejection_reason(a, im), "airframe-operator-unverified")\n \n+    def test_exact_airframe_and_verified_freighter_subtype_are_allowed(self):\n+        a = article(\n+            "Trans Air Cargo Service DC-8 freighter 9S-AJO runway excursion",\n+            entities={\n+                "airlines": ["Trans Air Cargo Service"],\n+                "aircraft_models": ["DC-8-73CF", "DC-8"],\n+                "registration_numbers": ["9S-AJO"],\n+            })\n+        im = {\n+            "url": "https://cdn.planespotters.net/9s-ajo.jpg",\n+            "provider": "Planespotters.net", "kind": "airframe_photo",\n+            "matched": "9S-AJO",\n+            "description": "Trans Air Cargo Service DC-8-73CF 9S-AJO",\n+            "link": "https://www.planespotters.net/photo/9s-ajo-trans-air-cargo-service",\n+        }\n+        self.assertIsNone(policy.rejection_reason(a, im))\n+        a["entities"]["aircraft_models"] = ["DC-8"]\n+        self.assertEqual(\n+            policy.rejection_reason(a, im), "airframe-role-unverified")\n+\n+    def test_bound_aerotime_event_uses_verified_subject(self):\n+        url = "https://www.aerotime.aero/articles/dc-8-runway-excursion"\n+        a = article(\n+            "Trans Air Cargo Service DC-8 freighter 9S-AJO runway excursion",\n+            sources=[{"url": url}],\n+            entities={"airlines": ["Trans Air Cargo Service"]})\n+        im = {\n+            "url": "https://www.aerotime.aero/images/2026/09/dc8.jpeg",\n+            "link": url, "provider": "AeroTime", "kind": "event_photo",\n+            "matched": "source:aerotime", "sourceCaption": "Bystander video",\n+            "subject": "Trans Air Cargo Service DC-8-73CF 9S-AJO",\n+        }\n+        self.assertIsNone(policy.rejection_reason(a, im))\n+\n     def test_url_identity_handles_resize_but_not_attachment_id(self):\n         original = "https://upload.wikimedia.org/wikipedia/commons/a/ab/Test.jpg"\n         thumb = "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ab/Test.jpg/1280px-Test.jpg?utm_source=x"\n@@ -142,6 +142,11 @@ def reset():\n check("registration found in article text",\n       images.find_registration(make_article(\n           "a", "Delta A350 registration N512DN arrived")) == "N512DN")\n+congo_dc8 = make_article(\n+    "a", "Trans Air Cargo Service DC-8-73CF 9S-AJO runway excursion")\n+congo_dc8["entities"]["registration_numbers"] = ["9S-AJO", "OB-2231P"]\n+check("verified entity registration supports 9S prefix",\n+      images.find_registration(congo_dc8) == "9S-AJO")\n check("plain N95 is never treated as a registration",\n       images.find_registration(make_article(\n           "a", "Airport staff wore N95 masks")) is None)\n@@ -560,6 +565,19 @@ def reset():\n images.main()\n check("negative result is not retried before the retry window",\n       fake.calls == [] and read_batch(p)[0]["image"] is None)\n+negative_entry = json.loads(\n+    (TMP / "images.json").read_text(encoding="utf-8"))["articles"]["a-none"]\n+check("negative cache records provider rejection reasons",\n+      bool(negative_entry.get("rejection_reasons"))\n+      and any("no-result" in reason\n+              for reason in negative_entry["rejection_reasons"]))\n+\n+due_articles = [make_article(str(i), "Delta Air Lines Airbus A350")\n+                for i in range(40)]\n+check("lookup budget scales with backlog and remains bounded",\n+      images._lookup_budget(due_articles, {}, now_utc()) == 14\n+      and images._lookup_budget(due_articles * 10, {}, now_utc())\n+      == images.MAX_LOOKUPS_PER_RUN)\n \n # A negative cache entry must never become permanent. After the initial\n # six-hour retries, a newly available image is still attached on a later\n@@ -13,6 +13,20 @@\n <figure class="floatImg center"><img src="{PHOTO.replace('/1024/', '/800/')}">\n <figcaption>華航名古屋航線限定頭墊紙。(華航提供)中央社記者傳真 115年9月7日</figcaption>\n </figure>'''\n+AEROTIME_URL = "https://www.aerotime.aero/articles/dc-8-runway-excursion"\n+AEROTIME_PHOTO = "https://www.aerotime.aero/images/2026/09/dc8congo.jpeg"\n+AEROTIME_HTML = f'''<meta property="og:image" content="{AEROTIME_PHOTO}">\n+<figure class="cs-entry__post-media post-media">\n+<img src="{AEROTIME_PHOTO.replace('.jpeg', '-800x500.jpeg')}">\n+<figcaption class="cs-entry__caption-text">Bystander video</figcaption>\n+</figure>'''\n+AEROTIME_ARTICLE = {\n+    "entities": {\n+        "airlines": ["Trans Air Cargo Service"],\n+        "aircraft_models": ["DC-8-73CF"],\n+        "registration_numbers": ["9S-AJO"],\n+    }\n+}\n \n \n def test_captioned_body_photo_and_resize_variants():\n@@ -30,6 +44,28 @@ def test_reject_unrelated_og_and_uncredited_photo():\n     assert source.parse_cna_photo(f'<meta property="og:image" content="{PHOTO}">', URL) is None\n \n \n+def test_aerotime_exact_lead_event_photo_with_credit_and_entities():\n+    photo = source.parse_aerotime_photo(\n+        AEROTIME_HTML, AEROTIME_URL, AEROTIME_ARTICLE)\n+    assert photo["url"] == AEROTIME_PHOTO\n+    assert photo["subject"] == \\n+        "Trans Air Cargo Service DC-8-73CF 9S-AJO"\n+    assert photo["credit"] == "Bystander video"\n+    assert photo["license"] is None\n+    assert photo["kind"] == "event_photo"\n+    assert source.supported_source({\n+        "sources": [{"url": AEROTIME_URL}]}) == AEROTIME_URL\n+\n+\n+def test_aerotime_rejects_unbound_or_uncredited_lead_image():\n+    assert source.parse_aerotime_photo(\n+        AEROTIME_HTML.replace("dc8congo-800x500", "different-800x500"),\n+        AEROTIME_URL, AEROTIME_ARTICLE) is None\n+    assert source.parse_aerotime_photo(\n+        AEROTIME_HTML.replace("Bystander video", "Image"),\n+        AEROTIME_URL, AEROTIME_ARTICLE) is None\n+\n+\n def test_protect_manual_and_exact_airframe_images():\n     for image in ["https://example.com/manual.jpg", {"provider": "manual"},\n                   {"provider": "Planespotters", "kind": "airframe_photo"}]:

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。