diff --git a/data/titles.json b/data/titles.json index 3ff923c..ffc2ac3 100644 --- a/data/titles.json +++ b/data/titles.json @@ -249,26 +249,26 @@ "title_normalized": "exploding kittens" }, { - "title_raw": "Herbaceous", - "confidence": "high", + "title_raw": "Hebarceos", + "confidence": "low", "publisher_hint": "", "edition_hint": "", "year_hint": null, - "language_hint": "English", - "art_notes": "Colorful spine with illustrated herb/plant imagery, 'A player's game of...' text visible", + "language_hint": "", + "art_notes": "yellow box with floral/plant illustration", "source_photos": [ "IMG_4502.jpeg" ], - "title_normalized": "herbaceous" + "title_normalized": "hebarceos" }, { "title_raw": "Wiz-War", - "confidence": "high", + "confidence": "medium", "publisher_hint": "", "edition_hint": "", "year_hint": null, "language_hint": "English", - "art_notes": "Dark blue/black box, partial text 'board game of...battle and treasures'", + "art_notes": "dark blue box, text 'board game of...battle and treasures'", "source_photos": [ "IMG_4502.jpeg" ], @@ -281,7 +281,7 @@ "edition_hint": "", "year_hint": null, "language_hint": "English", - "art_notes": "Bright pink/magenta box with striped pattern, tagline 'Doodle your way to disaster'", + "art_notes": "pink and purple striped box, subtitle 'Doodle your way to disaster'", "source_photos": [ "IMG_4502.jpeg" ], @@ -292,9 +292,9 @@ "confidence": "high", "publisher_hint": "", "edition_hint": "", - "year_hint": 1942, + "year_hint": null, "language_hint": "English", - "art_notes": "Blue spine, subtitle 'A GAME of Carrier Battles in the Pacific • 1942'", + "art_notes": "light blue spine, subtitle 'A GAME of Carrier Battles in the Pacific • 1942'", "source_photos": [ "IMG_4502.jpeg" ], @@ -303,11 +303,11 @@ { "title_raw": "Diplomacy", "confidence": "high", - "publisher_hint": "Avalon Hill", + "publisher_hint": "", "edition_hint": "", "year_hint": null, "language_hint": "English", - "art_notes": "Black spine, subtitle 'The Exciting Game of International Intrigue - For 2 to 7 Players'", + "art_notes": "black spine, subtitle 'The Exciting Game of International Intrigue, For 2 to 7 Players'", "source_photos": [ "IMG_4502.jpeg" ], @@ -316,11 +316,11 @@ { "title_raw": "Age of Renaissance", "confidence": "high", - "publisher_hint": "Avalon Hill", + "publisher_hint": "AH (Avalon Hill)", "edition_hint": "", "year_hint": null, "language_hint": "English", - "art_notes": "Tan/brown spine with gold diamond emblem, subtitle about building dominant civilization of Europe", + "art_notes": "tan/brown spine with gold emblem, subtitle about building dominant civilization of Europe", "source_photos": [ "IMG_4502.jpeg" ], @@ -329,24 +329,24 @@ { "title_raw": "History of the World", "confidence": "high", - "publisher_hint": "Avalon Hill", + "publisher_hint": "AH (Avalon Hill)", "edition_hint": "", "year_hint": null, "language_hint": "English", - "art_notes": "Blue/purple spine, subtitle 'Game of World Conquest'", + "art_notes": "light blue/lavender spine, subtitle 'Game of World Conquest'", "source_photos": [ "IMG_4502.jpeg" ], "title_normalized": "history of world" }, { - "title_raw": "Dungeon!", + "title_raw": "DUNGEON!", "confidence": "high", "publisher_hint": "TSR", "edition_hint": "", "year_hint": null, "language_hint": "English", - "art_notes": "Purple box bottom with fantasy dungeon-themed artwork, visible at base of stack", + "art_notes": "purple/dark spine with stone dungeon texture illustration", "source_photos": [ "IMG_4502.jpeg" ], diff --git a/scripts/write_photo_fixtures.py b/scripts/write_photo_fixtures.py index 37b34a3..24feb60 100644 --- a/scripts/write_photo_fixtures.py +++ b/scripts/write_photo_fixtures.py @@ -84,6 +84,9 @@ SEARCHES: dict[str, list[tuple]] = { (172242, "Exploding Kittens: NSFW Edition", 2015, BG, "primary"), ], "Herbaceous": [(195314, "Herbaceous", 2017, BG, "primary")], + # vision misread of Herbaceous on one run — search finds nothing; + # review's free-text re-search ("f Herbaceous") rescues it + "Hebarceos": [], "Wiz-War": [ (1218, "Wiz-War", 1983, BG, "primary"), (104710, "Wiz-War", 2012, BG, "alternate"), # primary: Wiz-War (Eighth Edition) @@ -94,6 +97,7 @@ SEARCHES: dict[str, list[tuple]] = { "Age of Renaissance": [(26, "Age of Renaissance", 1996, BG, "primary")], "History of the World": [(224, "History of the World", 1991, BG, "primary")], "Dungeon!": [(1339, "Dungeon!", 1975, BG, "primary")], + "DUNGEON!": [(1339, "Dungeon!", 1975, BG, "primary")], # spelling drift } # ids-param -> [(bgg_id, name, year, type, owned, rank, [publishers])] diff --git a/scripts/write_stub_fixtures.py b/scripts/write_stub_fixtures.py index 3baabfd..8d9a786 100644 --- a/scripts/write_stub_fixtures.py +++ b/scripts/write_stub_fixtures.py @@ -54,6 +54,7 @@ SEARCHES = { + search_item(205398, "Citadels", 2016, "boardgame") ), "Blorvath: Quest of the Zzyzx": "", + "Blorvath": "", # truncation-retry head of the nonsense title } THINGS = { diff --git a/src/bggpipe/extract.py b/src/bggpipe/extract.py index a445964..eed245d 100644 --- a/src/bggpipe/extract.py +++ b/src/bggpipe/extract.py @@ -47,7 +47,11 @@ Respond with ONLY a JSON array, no prose, where each element is: "publisher_hint": "publisher name or logo if legible, else null", "edition_hint": "edition wording if visible ('2nd Edition', 'Deluxe', 'Big Box', anniversary marks), else null", - "year_hint": copyright/print year as an integer if legible, else null, + "year_hint": copyright or publication year as an integer, ONLY if printed + as publishing info (copyright line, edition year). NEVER use + a year that is part of the game's title, theme, or subject + matter — a wargame about 1942 is not published in 1942. + When unsure whether a year is thematic, use null, "language_hint": "language of the box text if determinable, else null", "art_notes": "distinctive box-art notes (colorway, artwork style) that could identify the edition, else null" diff --git a/src/bggpipe/models.py b/src/bggpipe/models.py index 911be4b..bd2f64a 100644 --- a/src/bggpipe/models.py +++ b/src/bggpipe/models.py @@ -38,6 +38,7 @@ class ThingDetails: type: str owned: int | None = None rank: int | None = None + publishers: tuple[str, ...] = field(default=()) versions: tuple[GameVersion, ...] = field(default=()) @@ -124,6 +125,10 @@ def parse_things(xml_text: str) -> list[ThingDetails]: type=item.get("type", "boardgame"), owned=_attr_int(item.find(".//ratings/owned")), rank=_attr_int(rank_elem), + publishers=tuple( + link.get("value", "") + for link in item.findall("link[@type='boardgamepublisher']") + ), versions=tuple(versions), ) ) diff --git a/src/bggpipe/resolve.py b/src/bggpipe/resolve.py index eedfeea..a9606cf 100644 --- a/src/bggpipe/resolve.py +++ b/src/bggpipe/resolve.py @@ -11,6 +11,7 @@ from __future__ import annotations import csv import json import os +import re from dataclasses import dataclass, field from pathlib import Path @@ -76,6 +77,7 @@ class Candidate: fuzzy: float owned: int | None = None rank: int | None = None + publishers: list[str] = field(default_factory=list) def as_json(self) -> dict: return { @@ -151,15 +153,55 @@ def load_titles(path: Path) -> list[TitleEntry]: return entries -def _plausible_candidates(client: BGGClient, entry: TitleEntry) -> list[Candidate]: - """Search BGG and keep exact-normalized or fuzzy>=90 candidates, one per id.""" +_SEPARATORS = (" — ", " – ", " - ", ": ", "; ") +_GAME_WORD = re.compile(r"\s+(?:the\s+|a\s+)?game\b", re.IGNORECASE) + + +def _truncation_heads(title_raw: str) -> list[str]: + """Progressively shorter heads for box titles whose printed subtitle + defeats search ("CIVILIZATION Game of the Heroic Age - ..."): text + before the first subtitle separator, before a "(The) Game ..." + descriptor, then the first two words as a last resort.""" + heads: list[str] = [] + sep_head = next( + (title_raw.split(sep)[0] for sep in _SEPARATORS if sep in title_raw), None + ) + if sep_head: + heads.append(sep_head) + match = _GAME_WORD.search(title_raw) + if match and match.start() > 0: + heads.append(title_raw[: match.start()]) + # last resort: first two words — of the pre-subtitle part, so a title + # like "Blorvath: Quest of the Zzyzx" never yields "Blorvath: Quest" + words = (sep_head or title_raw).split() + if len(words) > 2: + heads.append(" ".join(words[:2])) + + seen: set[str] = {normalize_title(title_raw)} + unique: list[str] = [] + for head in heads: + norm = normalize_title(head) + if norm and norm not in seen: + seen.add(norm) + unique.append(head) + return unique[:3] + + +def _plausible_candidates( + client: BGGClient, entry: TitleEntry, query: str, head_normalized: str = "" +) -> list[Candidate]: + """Search BGG and keep plausible candidates, one per id: exact-normalized + or fuzzy>=90 against the FULL title, or — on truncated retries — exact + (only exact: truncation must stay conservative) against the head.""" by_id: dict[int, Candidate] = {} - for result in client.search(entry.title_raw): + for result in client.search(query): norm = normalize_title(result.name) exact = norm == entry.title_normalized fuzzy = fuzz.token_sort_ratio(norm, entry.title_normalized) if not exact and fuzzy < FUZZY_THRESHOLD: - continue + if not (head_normalized and norm == head_normalized): + continue + exact = True # head-exact counts as strong, nothing weaker does candidate = Candidate( bgg_id=result.bgg_id, name=result.name, @@ -194,8 +236,9 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M if c.bgg_id in stats: c.owned = stats[c.bgg_id].owned c.rank = stats[c.bgg_id].rank + c.publishers = list(stats[c.bgg_id].publishers) row.candidates = top - chosen = _dominant(top) + chosen = _publisher_pick(entry, top) or _dominant(top) if chosen is None: row.match_status = "ambiguous" return row @@ -209,6 +252,23 @@ def _classify(client: BGGClient, entry: TitleEntry, cands: list[Candidate]) -> M return row +def _publisher_pick(entry: TitleEntry, top: list[Candidate]) -> Candidate | None: + """When a publisher was legible on the box, and exactly one exact-named + candidate is from that publisher, that's the game.""" + if not entry.publisher_hint: + return None + hint = normalize_title(entry.publisher_hint) + matches = [ + c + for c in top + if c.exact + and any( + fuzz.partial_ratio(hint, normalize_title(p)) >= 85 for p in c.publishers + ) + ] + return matches[0] if len(matches) == 1 else None + + def _dominant(top: list[Candidate]) -> Candidate | None: """The single clear winner among plausible candidates, if any. @@ -291,7 +351,16 @@ def _resolve_version(client: BGGClient, entry: TitleEntry, row: MatchRow) -> Non def resolve_entry(client: BGGClient, entry: TitleEntry) -> MatchRow: - row = _classify(client, entry, _plausible_candidates(client, entry)) + cands = _plausible_candidates(client, entry, entry.title_raw) + if not cands: + # Long transcribed box titles ("CIVILIZATION Game of the Heroic + # Age - ...") defeat search: retry with progressively shorter heads + # instead of loosening the fuzzy threshold. + for head in _truncation_heads(entry.title_raw): + cands = _plausible_candidates(client, entry, head, normalize_title(head)) + if cands: + break + row = _classify(client, entry, cands) if row.match_status == "auto": _resolve_version(client, entry, row) return row diff --git a/tests/fixtures/bgg_cache/search_query=Blorvath-type=boardgame-2Cboardgameexpansion_48feb4349d.xml b/tests/fixtures/bgg_cache/search_query=Blorvath-type=boardgame-2Cboardgameexpansion_48feb4349d.xml new file mode 100644 index 0000000..ba791c3 --- /dev/null +++ b/tests/fixtures/bgg_cache/search_query=Blorvath-type=boardgame-2Cboardgameexpansion_48feb4349d.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/bgg_cache/search_query=DUNGEON-21-type=boardgame-2Cboardgameexpansion_f71cb1cc0c.xml b/tests/fixtures/bgg_cache/search_query=DUNGEON-21-type=boardgame-2Cboardgameexpansion_f71cb1cc0c.xml new file mode 100644 index 0000000..3814e39 --- /dev/null +++ b/tests/fixtures/bgg_cache/search_query=DUNGEON-21-type=boardgame-2Cboardgameexpansion_f71cb1cc0c.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/fixtures/bgg_cache/search_query=Hebarceos-type=boardgame-2Cboardgameexpansion_b1b7b72de1.xml b/tests/fixtures/bgg_cache/search_query=Hebarceos-type=boardgame-2Cboardgameexpansion_b1b7b72de1.xml new file mode 100644 index 0000000..ba791c3 --- /dev/null +++ b/tests/fixtures/bgg_cache/search_query=Hebarceos-type=boardgame-2Cboardgameexpansion_b1b7b72de1.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/test_resolve.py b/tests/test_resolve.py index c518d47..95197db 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -187,3 +187,110 @@ def test_score_version_no_overlap(): languages=("English",), ) assert _score_version(entry, version) == 0 + + +# -- progressive title truncation (long transcribed box titles) --------- + +from bggpipe.normalize import normalize_title # noqa: E402 +from bggpipe.resolve import _truncation_heads # noqa: E402 + +CIV_TITLE = ( + "CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC" +) +AH_HINT = "The Avalon Hill Game Company, Baltimore, Maryland" + + +def _entry(title, **kw): + return TitleEntry(title_raw=title, title_normalized=normalize_title(title), **kw) + + +def test_truncation_heads_order_and_dedupe(): + heads = _truncation_heads(CIV_TITLE) + assert heads[0] == "CIVILIZATION Game of the Heroic Age" # before separator + assert heads[1] == "CIVILIZATION" # before the "Game ..." descriptor + assert len(heads) <= 3 + + +def test_truncation_heads_game_word_without_separator(): + assert _truncation_heads("SORCERER The Game of Magical Conflict")[0] == "SORCERER" + + +def test_truncation_heads_two_word_fallback(): + heads = _truncation_heads( + "STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century" + ) + assert heads[-1] == "STARFORCE ALPHA" + + +def test_short_titles_get_no_heads(): + assert _truncation_heads("Catan") == [] + assert _truncation_heads("Herbaceous") == [] + + +def test_civilization_resolves_via_truncation(client): + entry = _entry( + CIV_TITLE, + publisher_hint=AH_HINT, + edition_hint="Bookcase Game", + language_hint="English", + ) + row = resolve_entry(client, entry) + assert (row.match_status, row.bgg_id) == ("auto", 71) + # publisher cue picks the Avalon Hill version, not Hartland/Gibsons + assert row.version_status == "version_auto" + assert row.version_id == 71001 + + +def test_advanced_civilization_resolves_as_expansion(client): + entry = _entry( + "ADVANCED CIVILIZATION Game Expansion of the Heroic Age - " + "Featuring New Civilization, Commodity, and Calamity Cards", + publisher_hint=AH_HINT, + edition_hint="Bookcase Game", + language_hint="English", + ) + row = resolve_entry(client, entry) + assert (row.match_status, row.bgg_id, row.type) == ( + "auto", + 177, + "boardgameexpansion", + ) + assert row.version_status == "version_auto" + + +def test_sorcerer_publisher_tiebreak_beats_owned_dominance(client): + """Head search finds TWO games named 'Sorcerer' (SPI 1975 vs White + Wizard 2019, which has far more owners) — the SPI publisher cue must + pick the SPI game, and the edition cue the Designer's Edition.""" + entry = _entry( + "SORCERER The Game of Magical Conflict", + publisher_hint="Simulations Publications Incorporated (SPI)", + edition_hint="Designer's Edition", + language_hint="English", + ) + row = resolve_entry(client, entry) + assert (row.match_status, row.bgg_id) == ("auto", 3585) + assert row.version_status == "version_auto" + assert row.version_name == "SPI Designer's Edition" + + +def test_starforce_two_word_head_matches_full_title(client): + entry = _entry( + "STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century", + publisher_hint="Simulations Publications Incorporated (SPI)", + edition_hint="Designer's Edition", + language_hint="English", + ) + row = resolve_entry(client, entry) + assert (row.match_status, row.bgg_id) == ("auto", 2524) + assert row.version_status == "version_auto" + + +def test_wrong_year_hint_never_drives_a_version(client): + """Flat Top's box says 1942 (the theme, not the print year): version + scoring must not pick any version off the back of it.""" + entry = _entry("FLAT TOP", year_hint=1942, language_hint="English") + row = resolve_entry(client, entry) + assert (row.match_status, row.bgg_id) == ("auto", 2529) + assert row.version_status == "version_unknown" + assert row.version_id is None