From 771b34bb4b0c1a65955ad5955c53a8697e30b5a0 Mon Sep 17 00:00:00 2001 From: Christopher Koch Date: Tue, 4 Aug 2026 12:34:55 -0400 Subject: [PATCH] Fill developer, publisher, year and description from Wikipedia The library carried titles, systems and genres but almost nothing else: developer was 3.8% filled, publisher 2.9%, description 0%. These are columns the app has always had and never been able to populate. enrich_metadata.py reads them off the same articles the cover fetcher locates. Only empty fields are touched unless --overwrite is given. year 79% -> 96% developer 3.8% -> 95% publisher 2.9% -> 96% description 0% -> 96% Parsing infoboxes needed several guards, each found by checking output rather than trusting the first pass: * "Infobox video game" is a substring of "Infobox video game series", so the loose test resolved Banjo-Kazooie to the series overview. Now rejected, which also fixes the cover fetcher's article resolution. * An article spans every release and its date block leads with the original, so year is only filled when the article covers that platform. Otherwise a DS port inherits the SNES original's year. * A search hit that neither covers the platform nor closely matches the title is discarded: "Dragon Ball Z Budokai" surfaces "Shin Budokai", a different game on a different console. Left blank instead. * Values are grouped under bold platform headings, tagged with region codes, annotated with the platform in parentheses, and wrapped in templates whose named parameters leak through. Each of those read as the developer or publisher before being handled. Developer, publisher and year are facts and written verbatim. Descriptions are article summaries under CC BY-SA, stored with an attribution line. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 2 +- README.md | 50 +- .../__pycache__/fetch_art.cpython-314.pyc | Bin 22485 -> 0 bytes .../enrich_metadata.cpython-314.pyc | Bin 0 -> 21892 bytes .../__pycache__/fetch_art.cpython-314.pyc | Bin 0 -> 30592 bytes tools/library/enrich_metadata.py | 436 ++++++++++++++++++ tools/{cover-art => library}/fetch_art.py | 24 +- 7 files changed, 503 insertions(+), 9 deletions(-) delete mode 100644 tools/cover-art/__pycache__/fetch_art.cpython-314.pyc create mode 100644 tools/library/__pycache__/enrich_metadata.cpython-314.pyc create mode 100644 tools/library/__pycache__/fetch_art.cpython-314.pyc create mode 100644 tools/library/enrich_metadata.py rename tools/{cover-art => library}/fetch_art.py (96%) diff --git a/.gitignore b/.gitignore index 7edce68..7158012 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ Thumbs.db *.swp # Cached libretro directory listings (regenerated on demand) -tools/cover-art/.cache/ +tools/library/.cache/ diff --git a/README.md b/README.md index 6453a85..e8e723b 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ cd backend && dotnet build # 0 warnings expected ### Cover art -`tools/cover-art/fetch_art.py` fills in box art, pushing each image through the +`tools/library/fetch_art.py` fills in box art, pushing each image through the app's own `POST /api/images` so it gets the same validation and WebP re-encoding as a manual upload. Standard library only — no virtualenv, and neither source needs an account. @@ -95,7 +95,7 @@ It tries two sources in order: one per second and cached; the API returns 429 if pushed harder. ```bash -cd tools/cover-art +cd tools/library python3 fetch_art.py --password '...' --dry-run # report matches, change nothing python3 fetch_art.py --password '...' # download and attach python3 fetch_art.py --password '...' --overwrite # also replace existing art @@ -123,6 +123,52 @@ Fix the system field and re-run with `--overwrite` to correct them. Art is publisher copyright. Fetching it for a private collection is ordinary practice for library software; redistributing it is a different question. +### Metadata enrichment + +`tools/library/enrich_metadata.py` fills developer, publisher, year and +description from the same Wikipedia articles the cover fetcher locates. Only +empty fields are touched unless `--overwrite` is passed — anything typed by hand +outranks anything derived here. + +```bash +cd tools/library +python3 enrich_metadata.py --password '...' --dry-run +python3 enrich_metadata.py --password '...' +python3 enrich_metadata.py --password '...' --fields developer,publisher +``` + +Coverage went from this to this: + +| Field | Before | After | +| --- | --- | --- | +| year | 79% | 96% | +| developer | 3.8% | 95% | +| publisher | 2.9% | 96% | +| description | 0% | 96% | + +Developer, publisher and year are facts, written verbatim. Descriptions are +article summaries, which are CC BY-SA, so each is stored with an attribution +line naming the source article. + +Parsing an infobox is messier than it looks, and the guards matter: + +- **Series articles are rejected.** A substring test for `Infobox video game` + also matches `Infobox video game series`, which resolved Banjo-Kazooie to the + series overview instead of the 1998 game. +- **Year is only filled when the article covers that platform.** An article + spans every release, and its date block leads with the original — so a DS + port would otherwise be dated to the SNES original. +- **A search hit that neither covers the platform nor closely matches the title + is discarded.** Our "Dragon Ball Z Budokai" surfaces "Dragon Ball Z: Shin + Budokai", a different game on a different console. A blank field beats a + confidently wrong one. +- Platform headings (`'''PlayStation'''`), region codes (`JP`, `NA`), trailing + platform annotations (`Rare (N64)`) and named template parameters (`title=`) + are all stripped, since each one otherwise reads as the value itself. + +Four games have no usable article: a typo'd title (`Brett Hull Hocky 95`), +`Dragon Ball Z Budokai`, and two niche releases. + ### Database changes ```bash diff --git a/tools/cover-art/__pycache__/fetch_art.cpython-314.pyc b/tools/cover-art/__pycache__/fetch_art.cpython-314.pyc deleted file mode 100644 index 25af11d566f2b322230f8ddf4919b214f3d677bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22485 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 diff --git a/tools/library/__pycache__/enrich_metadata.cpython-314.pyc b/tools/library/__pycache__/enrich_metadata.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..71da68eceeca954ac883ec7c122e1d4de5ff48bb GIT binary patch literal 21892 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 EUy$4id;kCd literal 0 HcmV?d00001 diff --git a/tools/library/__pycache__/fetch_art.cpython-314.pyc b/tools/library/__pycache__/fetch_art.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..879d25254f34185b3abdd98465f82589cf445556 GIT binary patch literal 30592 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 NwRr$xTG?;c{|DUGPkjIY literal 0 HcmV?d00001 diff --git a/tools/library/enrich_metadata.py b/tools/library/enrich_metadata.py new file mode 100644 index 0000000..2151e22 --- /dev/null +++ b/tools/library/enrich_metadata.py @@ -0,0 +1,436 @@ +#!/usr/bin/env python3 +"""Fill in developer, publisher, year and description from Wikipedia. + +The library's own data is thin in exactly these columns — the 2018 dump carried +titles, systems and genres, but almost no developer or publisher and no +descriptions at all. This reads the values off the same English Wikipedia +articles the cover fetcher already locates. + +Developer, publisher and release year are facts and are written verbatim. +Descriptions come from the article summary, which is CC BY-SA text, so each one +is stored with an attribution line naming the source article. + +Only empty fields are filled unless --overwrite is given: anything typed by hand +outranks anything guessed here. + + python3 enrich_metadata.py --password '...' --dry-run + python3 enrich_metadata.py --password '...' +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import re +import sys +import urllib.parse +from pathlib import Path + +# The cover fetcher already owns the throttled, cached Wikipedia client. +from fetch_art import ( + WIKI_UA, api_json, http, is_game_article, normalise, wiki_api, wiki_article, +) + +FIELDS = ("developer", "publisher", "year", "description") + +# Infobox values for multi-platform games are frequently grouped under a bold +# platform heading ("'''PlayStation'''{{vgrelease|JP|Square|...}}") or annotated +# with the platform in parentheses ("Rare (N64)
Nintendo (DS)"). Both would +# otherwise be read as the developer or publisher itself. +PLATFORM_WORDS = ( + "nes", "snes", "super nes", "super nintendo", "nintendo entertainment system", + "n64", "nintendo 64", "gamecube", "gc", "wii", "wii u", "switch", + "nintendo switch", "game boy", "game boy color", "game boy advance", "gb", + "gbc", "gba", "nintendo ds", "ds", "3ds", "nintendo 3ds", + "playstation", "ps1", "ps2", "ps3", "ps4", "ps5", "playstation 2", + "playstation 3", "playstation 4", "playstation 5", "psp", + "playstation portable", "playstation vita", "vita", + "xbox", "xbox 360", "xbox one", "xbox series x", "x360", + "windows", "win", "pc", "ms-dos", "dos", "mac", "macos", "osx", "linux", + "ios", "android", "arcade", "arc", "genesis", "mega drive", "md", + "dreamcast", "saturn", "amiga", "psx", "xbla", "wiiu", "ngc", "3do", + "steam", "microsoft windows", +) + +# Whether an article covers the platform a row claims. Used to keep a port's +# entry from inheriting the original release's year. +PLATFORM_PATTERNS = { + "NES": r"Nintendo Entertainment System|\bNES\b", + "SNES": r"Super Nintendo|Super NES|\bSNES\b", + "N64": r"Nintendo 64", + "GB": r"Game Boy(?! Advance)", # plain Game Boy or Game Boy Color + "GBA": r"Game Boy Advance", + "DS": r"Nintendo DS", + "GC": r"GameCube", + "WII": r"\bWii\b(?! ?U)", + "PS1": r"PlayStation(?!\s*(?:2|3|4|5|Portable|Vita))", + "PS2": r"PlayStation 2", + "PSP": r"PlayStation Portable|\bPSP\b", + "360": r"Xbox 360", +} + + +# Release templates tag each entry with a region, so those codes sit between the +# heading and the value we actually want. +REGION_CODES = { + "jp", "na", "eu", "au", "ww", "uk", "us", "ca", "kr", "cn", "br", "ru", "in", + "sea", "int", "pal", "ntsc", "row", "as", "hk", "tw", "mx", "nz", "fr", "de", + "es", "it", "nl", "se", "no", "dk", "fi", "pt", "pl", +} + +DATE_LIKE = re.compile( + r"^\s*(?:\d{1,2}\s+)?(?:January|February|March|April|May|June|July|August|" + r"September|October|November|December)\b|^\s*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\s*$" + r"|^\s*(?:19|20)\d{2}\s*$", + re.I, +) + + +def is_platform_label(text: str) -> bool: + return text.strip().lower().strip(":;,") in PLATFORM_WORDS + + +def is_noise_entry(text: str) -> bool: + """Region tags, dates and platform headings are scaffolding, not values.""" + stripped = text.strip().lower().strip(":;,") + return ( + not stripped + or stripped in REGION_CODES + or is_platform_label(stripped) + or bool(DATE_LIKE.match(text)) + ) + + +def article_covers_system(wikitext: str, system: str | None) -> bool: + """True when the article's platform list mentions the row's system.""" + if not system: + return False + pattern = PLATFORM_PATTERNS.get(system.upper()) + if not pattern: + return False + + platforms = infobox_field(wikitext, "platforms") or infobox_field(wikitext, "platform") or "" + return re.search(pattern, platforms, flags=re.I) is not None + + +# -------------------------------------------------------------------------- +# Article resolution +# -------------------------------------------------------------------------- + +def resolve_article(game: dict, cache_dir: Path) -> tuple[str, str] | None: + """Find the article about this specific game. + + Direct title guesses first, then a search. The search matters for titles + whose bare name is the series overview rather than a game — "Mortal Kombat" + and "SimCity" both are, with the games at "(1992 video game)" and + "(1989 video game)" — and for small punctuation differences, such as our + "Dragon Ball Z Budokai" against the catalogued "Dragon Ball Z: Budokai". + """ + title, year, system = game["title"], game.get("year"), game.get("system") + + attempts = [title, f"{title} (video game)"] + if year: + attempts.append(f"{title} ({year} video game)") + + for candidate in attempts: + found = wiki_article(candidate, cache_dir) + if found and is_game_article(found[1]): + return found + + try: + results = wiki_api(list="search", srsearch=f"{title} video game", srlimit=5) + titles = [r["title"] for r in results.get("query", {}).get("search", [])] + except Exception: # noqa: BLE001 + return None + + target = normalise(title) + scored: list[tuple[bool, float, tuple[str, str]]] = [] + + for candidate in titles: + found = wiki_article(candidate, cache_dir) + if not found or not is_game_article(found[1]): + continue + + # Strip the disambiguator before comparing: "Mortal Kombat (1992 video + # game)" should read as "Mortal Kombat". + bare = re.sub(r"\s*\([^)]*\)\s*$", "", found[0]) + similarity = difflib.SequenceMatcher(None, target, normalise(bare)).ratio() + scored.append((article_covers_system(found[1], system), similarity, found)) + + if not scored: + return None + + scored.sort(key=lambda s: (s[0], s[1]), reverse=True) + covers, similarity, article = scored[0] + + # A search hit that neither covers the platform nor closely matches the name + # is more likely a sibling in the series than this game — our "Dragon Ball Z + # Budokai" surfaces "Dragon Ball Z: Shin Budokai", a different game on a + # different console. A blank field beats a confidently wrong one. + if not covers and similarity < 0.90: + return None + + return article + + +# -------------------------------------------------------------------------- +# Wikitext cleaning +# -------------------------------------------------------------------------- + +def clean_value(value: str) -> str: + """Reduce an infobox field to plain text. + + Infobox values are wikitext, so a developer might arrive as + ``[[Rare (company)|Rare]]`` or as a ``{{ubl|...}}`` list with references + attached. This unwraps the common forms and keeps the first entry. + """ + text = value + + # Footnotes and comments carry nothing we want. + text = re.sub(r"]*/>", "", text) + text = re.sub(r"", "", text, flags=re.S | re.I) + text = re.sub(r"", "", text, flags=re.S) + + # List and release templates carry the value we want as their arguments — + # {{vgrelease|JP|Square|NA|Square Electronic Arts}} holds the publisher — + # so keep the payload and drop only the wrapper. + text = re.sub( + r"\{\{\s*(?:ubl|unbulleted list|plainlist|flatlist|hlist|" + r"vgrelease|vgr|video game release|collapsible list|nobold|nowrap)\s*\|", + "|", text, flags=re.I) + # Anything still wrapped in braces is scaffolding for these fields. + text = re.sub(r"\{\{[^{}]*\}\}", "|", text) + text = text.replace("{{", "|").replace("}}", "|") + + # [[Target|Label]] -> Label, [[Target]] -> Target + text = re.sub(r"\[\[([^\]|]+)\|([^\]]+)\]\]", r"\2", text) + text = re.sub(r"\[\[([^\]]+)\]\]", r"\1", text) + + text = re.sub(r"", "|", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + text = text.replace("'''", "").replace("''", "") + + # Take the first real entry of whatever list survived, skipping any leading + # platform heading that was grouping the list. + parts = [p.strip(" *\n\t") for p in re.split(r"[|\n]", text)] + parts = [re.sub(r"\s+", " ", p).strip(" ,;") for p in parts if p.strip(" *\n\t")] + + # A named template parameter leaks through unwrapping as "title=Foo"; + # keep the value, drop the key. + parts = [re.sub(r"^[a-z_][a-z0-9_]*\s*=\s*", "", p, flags=re.I) for p in parts] + parts = [p for p in parts if p] + + first = next((p for p in parts if not is_noise_entry(p)), "") + + # Drop a trailing platform annotation: "Rare (N64)" -> "Rare". + match = re.match(r"^(.*?)\s*\(([^()]*)\)$", first) + if match and is_platform_label(match.group(2)): + first = match.group(1).strip() + + return first.strip(" ,;") + + +def infobox_field(wikitext: str, field: str) -> str | None: + """Raw value of one infobox field, brace- and bracket-aware.""" + match = re.search(rf"^\s*\|\s*{field}\s*=", wikitext, flags=re.M | re.I) + if not match: + return None + + index = match.end() + depth_brace = depth_bracket = 0 + out: list[str] = [] + + while index < len(wikitext): + two = wikitext[index:index + 2] + if two == "{{": + depth_brace += 1 + elif two == "}}": + depth_brace -= 1 + elif two == "[[": + depth_bracket += 1 + elif two == "]]": + depth_bracket -= 1 + + char = wikitext[index] + # A pipe or newline-pipe at depth zero ends this field. + if depth_brace <= 0 and depth_bracket <= 0 and char == "|": + break + if depth_brace <= 0 and depth_bracket <= 0 and char == "\n" and \ + re.match(r"\s*[|}]", wikitext[index:index + 3]): + break + + out.append(char) + index += 1 + + return "".join(out).strip() or None + + +def extract_year(value: str) -> str | None: + """First plausible release year in a {{Video game release}} block.""" + years = re.findall(r"\b(19[5-9]\d|20[0-4]\d)\b", value) + return years[0] if years else None + + +# -------------------------------------------------------------------------- +# Summary +# -------------------------------------------------------------------------- + +def wiki_summary(page_title: str) -> str | None: + """Short article summary via the REST endpoint, which is built for reuse.""" + quoted = urllib.parse.quote(page_title.replace(" ", "_"), safe="") + raw = http(f"https://en.wikipedia.org/api/rest_v1/page/summary/{quoted}", + headers={"User-Agent": WIKI_UA}) + extract = (json.loads(raw) or {}).get("extract") + if not extract: + return None + + text = re.sub(r"\s+", " ", extract).strip() + # CC BY-SA requires attribution, so it travels with the text. + return f"{text}\n\nSource: Wikipedia — {page_title}" + + +# -------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--api", default="http://localhost:8080") + parser.add_argument("--user", default="ckoch") + parser.add_argument("--password", required=True) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--overwrite", action="store_true", + help="replace fields that already have a value") + parser.add_argument("--fields", default=",".join(FIELDS), + help=f"comma-separated subset of: {','.join(FIELDS)}") + parser.add_argument("--cache", default=str(Path(__file__).parent / ".cache")) + args = parser.parse_args() + + wanted = [f.strip() for f in args.fields.split(",") if f.strip() in FIELDS] + if not wanted: + print("No valid fields requested.", file=sys.stderr) + return 2 + + base = args.api.rstrip("/") + cache_dir = Path(args.cache) + + print("Signing in…") + auth = api_json(base, "/api/auth/login", + data={"userName": args.user, "password": args.password}) + token = auth["token"] + + print("Fetching library…") + games: list[dict] = [] + page = 1 + while True: + result = api_json(base, f"/api/games?page={page}&pageSize=100", token) + games.extend(result["items"]) + if page >= result["totalPages"] or not result["items"]: + break + page += 1 + print(f" {len(games)} games\n") + + filled = {field: 0 for field in wanted} + touched = failed = 0 + no_article: list[dict] = [] + skipped_year: list[dict] = [] + + for game in sorted(games, key=lambda g: g["title"].lower()): + missing = [f for f in wanted if args.overwrite or not game.get(f)] + if not missing: + continue + + try: + article = resolve_article(game, cache_dir) + except Exception as exc: # noqa: BLE001 + print(f" ! {game['title'][:44]:46} lookup failed: {exc}") + article = None + + if not article: + no_article.append(game) + continue + + page_title, wikitext = article + updates: dict[str, str] = {} + + for field in missing: + if field == "description": + try: + summary = wiki_summary(page_title) + except Exception: # noqa: BLE001 + summary = None + if summary: + updates["description"] = summary + continue + + if field == "year": + # An article covers every platform a game shipped on, and its + # release block leads with the original. Taking that year for a + # later port would date a DS re-release to the SNES original, so + # only fill it when the article actually covers this platform. + if not article_covers_system(wikitext, game.get("system")): + skipped_year.append(game) + continue + raw = infobox_field(wikitext, "released") + value = extract_year(raw) if raw else None + else: + raw = infobox_field(wikitext, field) + value = clean_value(raw) if raw else None + + if value: + updates[field] = value + + if not updates: + continue + + summary_line = ", ".join( + f"{k}={v[:26]}…" if len(v) > 26 else f"{k}={v}" + for k, v in updates.items() if k != "description") + if "description" in updates: + summary_line = (summary_line + ", " if summary_line else "") + "description" + + print(f" + {game['system'] or '-':4} {game['title'][:40]:42} {summary_line[:60]}") + + for field in updates: + filled[field] += 1 + touched += 1 + + if args.dry_run: + continue + + payload = {k: game.get(k) for k in ( + "title", "system", "genre", "year", "developer", "publisher", + "art", "description", "own", "dumped", "played", "finished")} + payload.update(updates) + + try: + api_json(base, f"/api/games/{game['id']}", token, data=payload, method="PUT") + except Exception as exc: # noqa: BLE001 + print(f" -> FAILED: {exc}") + failed += 1 + touched -= 1 + + print("\n" + "=" * 72) + verb = "would update" if args.dry_run else "updated" + print(f"{verb}: {touched} games failed: {failed}") + for field in wanted: + print(f" {field:12} {filled[field]:3}") + + if skipped_year: + print(f"\nYear left alone — the article does not cover this platform, so its " + f"release date belongs to a different version ({len(skipped_year)}):") + for game in skipped_year[:15]: + print(f" {game['system'] or '-':4} {game['title']}") + + if no_article: + print(f"\nNo Wikipedia article found ({len(no_article)}):") + for game in no_article[:20]: + print(f" {game['system'] or '-':4} {game['title']}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/cover-art/fetch_art.py b/tools/library/fetch_art.py similarity index 96% rename from tools/cover-art/fetch_art.py rename to tools/library/fetch_art.py index 5703524..363d5e2 100644 --- a/tools/cover-art/fetch_art.py +++ b/tools/library/fetch_art.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Fetch box art for the library and attach it to each game. -Art comes from libretro-thumbnails (https://thumbnails.libretro.com), a -community archive of boxart named to the No-Intro / Redump conventions. It needs -no API key, but covers retro consoles only — Xbox 360 has no thumbnail set, so -those titles are reported as unsupported rather than mismatched. +Two sources, tried in order, neither needing an account: + + 1. libretro-thumbnails (https://thumbnails.libretro.com) — scanned retail + boxes named to the No-Intro / Redump conventions. Best art where it has + any, but its coverage is the retro consoles. + 2. English Wikipedia — a cover on essentially every notable game article, + which is what fills the Xbox 360 shelf. Images are pushed through the app's own POST /api/images endpoint, so they get the same validation, WebP re-encoding and per-user filing as a manual upload. @@ -33,7 +36,6 @@ from pathlib import Path THUMBNAIL_HOST = "https://thumbnails.libretro.com" # Our system codes to libretro's DAT-derived directory names. -# Xbox 360 is deliberately absent: libretro-thumbnails has no set for it. # Several directories may back one system code. Our "GB" covers both Game Boy # and Game Boy Color titles — Pokemon Trading Card Game, for instance, is only in # the Color set — so both are searched and the better match wins. @@ -230,6 +232,16 @@ def wiki_api(**params): return json.loads(raw) +def is_game_article(wikitext: str) -> bool: + """True for an article about a single game. + + A substring test for "Infobox video game" also matches "Infobox video game + series", which would resolve Banjo-Kazooie to the series overview rather + than the 1998 game — and give the wrong cover and the wrong summary. + """ + return re.search(r"\{\{\s*Infobox video game\b(?!\s*series)", wikitext, re.I) is not None + + def wiki_article(title: str, cache_dir: Path) -> tuple[str, str] | None: """(resolved title, wikitext) for a page, or None when it does not exist.""" safe = re.sub(r"[^A-Za-z0-9._ -]", "_", title)[:120] @@ -265,7 +277,7 @@ def wiki_cover_url(game: dict, cache_dir: str | Path) -> tuple[str, str] | None: article = None for candidate in attempts: found = wiki_article(candidate, cache_dir) - if found and "Infobox video game" in found[1]: + if found and is_game_article(found[1]): article = found break