Version picker: real pagination, and matching that declines to guess

Five games uploaded; two landed version-less. Neither was the picker's
fault: paging is an AngularJS <ul class="pagination"> of anchors, not
buttons named "next", so the old guess found no control and quit after
page one — and BGG's API version names carry printing qualifiers the
picker omits ("English edition 2018-2" vs "(English edition) (2018)").

_select_version now scans the WHOLE list (verified selectors: rows are
<li>s with a thumbnail; a[title="Next Page"] advances; the parent <li>
disables at the end), collects every candidate, then decides: one exact
match wins; failing that, one match after stripping a trailing year
qualifier wins and says so; several matches are refused outright rather
than guessed, and the reason reaches upload_log.csv. Both call sites
carry the reason through.

docs/bgg-upload-flow.md records what the live site actually does —
including that every login-gate selector the doc called "verified" was
wrong, while the "unverified" dialog structure was right.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 22:36:48 -04:00
co-authored by Claude Fable 5
parent 6ae2667c58
commit ba55863cef
4 changed files with 253 additions and 24 deletions
+5
View File
@@ -2,3 +2,8 @@ action,bgg_id,collid,name,version_id,second_copy,status,timestamp,error
add,334011,,A Gentle Rain,701315,,failed,2026-08-06T02:15:34+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""A\ Gentle\ Rain"", re.IGNORECASE)) to be visible" add,334011,,A Gentle Rain,701315,,failed,2026-08-06T02:15:34+00:00,"TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role(""dialog"").get_by_role(""heading"", name=re.compile(r""A\ Gentle\ Rain"", re.IGNORECASE)) to be visible"
add,589,,Wiz-War,27352,,failed,2026-08-06T02:20:33+00:00,"Error: Locator.check: Error: strict mode violation: get_by_role(""dialog"").get_by_label(""Own"") resolved to 2 elements: 1) <input type=""checkbox"" ng-model=""item.status.own"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Own"", exact=True) 2) <input type=""checkbox"" ng-model=""item.status.prevowned"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Prev. Owned"") Call log: - waiting for get_by_role(""dialog"").get_by_label(""Own"")" add,589,,Wiz-War,27352,,failed,2026-08-06T02:20:33+00:00,"Error: Locator.check: Error: strict mode violation: get_by_role(""dialog"").get_by_label(""Own"") resolved to 2 elements: 1) <input type=""checkbox"" ng-model=""item.status.own"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Own"", exact=True) 2) <input type=""checkbox"" ng-model=""item.status.prevowned"" class=""ng-pristine ng-untouched ng-valid ng-empty""/> aka get_by_role(""checkbox"", name=""Prev. Owned"") Call log: - waiting for get_by_role(""dialog"").get_by_label(""Own"")"
add,334011,,A Gentle Rain,701315,,added,2026-08-06T02:21:32+00:00, add,334011,,A Gentle Rain,701315,,added,2026-08-06T02:21:32+00:00,
add,125921,,Catan: Junior,476263,,added,2026-08-06T02:24:22+00:00,
add,31260,,Agricola,297589,,added,2026-08-06T02:24:28+00:00,
add,181304,,Mysterium,536289,,added_no_version,2026-08-06T02:24:34+00:00,version 'English edition 2018-2' not in picker; added without version
add,312786,,Poetry for Neanderthals,514050,,added_no_version,2026-08-06T02:24:40+00:00,version 'English edition 2020' not in picker; added without version
add,273240,,The Red Dragon Inn Smorgasbox,446069,,added,2026-08-06T02:24:46+00:00,
1 action bgg_id collid name version_id second_copy status timestamp error
2 add 334011 A Gentle Rain 701315 failed 2026-08-06T02:15:34+00:00 TimeoutError: Locator.wait_for: Timeout 15000ms exceeded. Call log: - waiting for get_by_role("dialog").get_by_role("heading", name=re.compile(r"A\ Gentle\ Rain", re.IGNORECASE)) to be visible
3 add 589 Wiz-War 27352 failed 2026-08-06T02:20:33+00:00 Error: Locator.check: Error: strict mode violation: get_by_role("dialog").get_by_label("Own") resolved to 2 elements: 1) <input type="checkbox" ng-model="item.status.own" class="ng-pristine ng-untouched ng-valid ng-empty"/> aka get_by_role("checkbox", name="Own", exact=True) 2) <input type="checkbox" ng-model="item.status.prevowned" class="ng-pristine ng-untouched ng-valid ng-empty"/> aka get_by_role("checkbox", name="Prev. Owned") Call log: - waiting for get_by_role("dialog").get_by_label("Own")
4 add 334011 A Gentle Rain 701315 added 2026-08-06T02:21:32+00:00
5 add 125921 Catan: Junior 476263 added 2026-08-06T02:24:22+00:00
6 add 31260 Agricola 297589 added 2026-08-06T02:24:28+00:00
7 add 181304 Mysterium 536289 added_no_version 2026-08-06T02:24:34+00:00 version 'English edition 2018-2' not in picker; added without version
8 add 312786 Poetry for Neanderthals 514050 added_no_version 2026-08-06T02:24:40+00:00 version 'English edition 2020' not in picker; added without version
9 add 273240 The Red Dragon Inn Smorgasbox 446069 added 2026-08-06T02:24:46+00:00
+36
View File
@@ -100,3 +100,39 @@ the existing one).
page but did not overlay the form in the probe. page but did not overlay the form in the probe.
- Logged-in detection heuristic (unverified): the header shows a "Sign In" - Logged-in detection heuristic (unverified): the header shows a "Sign In"
link only when logged out. link only when logged out.
## Verified against the live site (2026-08-06, first real uploads)
The add flow works end to end; every failure on the way was in code the
earlier walkthrough had marked *verified*, and the parts marked
*unverified* were mostly right. Corrections:
- **Sign In is an `<a class="btn">` with no `href`.** It therefore has no
implicit `link` role: `get_by_role("link", name="Sign In")` matches
nothing in any state. The header also hydrates after
`domcontentloaded`, so for a moment neither Sign In nor Sign Out
exists — a check resting on one absence silently concludes "signed in"
and browses anonymously. Poll until one control or the other proves the
state; treat "neither, after 30s" as an error.
- **`get_by_label("Own")` also matches "Prev. Owned."** Use
`get_by_role("checkbox", name="Own", exact=True)`.
- **Version rows** are the `<li>`s carrying a thumbnail:
`li:has(.summary-item-thumbnail)`. Plain `listitem` also catches the
paging `<li>`s ("First", "Prev", "1", "…").
- **Paging is an AngularJS `<ul class="pagination">` of anchors**, not
buttons: `a[title="Next Page"]`, `a[title="First Page"]`, with the
parent `<li>` gaining `disabled` at the end. The numbered anchors
render twice (mobile + desktop), so step with Next rather than
clicking a number. Paging is client-side over an already-loaded list —
no request per page.
- **Row text is `<game name> (<version name>) (<year>)`**, and the game
name is localized (a Czech edition's row starts "Spící bohové"). Match
the version name inside its parentheses.
- **The API's version name is not always the picker's string.** BGG's
XML gives e.g. `English edition 2018-2` where the picker shows
`(English edition) (2018)`. Match the full name first, then retry with
a trailing year/printing qualifier stripped — and if that relaxed match
hits more than one row, refuse and add version-less (never guess).
- **An owned game's page has no "Add To" button**; it reads
"In Collections (Own…)". That is the update flow's entry point.
+90 -24
View File
@@ -363,14 +363,40 @@ class PlaywrightUploader:
raise raise
return dialog return dialog
def _select_version(self, dialog, version_name: str) -> bool: # Verified 2026-08-06 against the live picker: version rows are the
"""Page through the version sub-view matching the canonical version # <li>s carrying a thumbnail (the paging <li>s are not), and paging is
NAME (the list has no search box). Returns False — with the sub-view # an AngularJS <ul class="pagination"> of anchors — NOT buttons named
cancelled — when the name never shows up.""" # "next", which is why the old guess quit after page one.
_VERSION_ROWS = "li:has(.summary-item-thumbnail)"
_NEXT_PAGE = 'ul.pagination a[title="Next Page"]'
_FIRST_PAGE = 'ul.pagination a[title="First Page"]'
# Rows read "<game name> (<version name>) (<year>)", so the version name
# is matched inside its parentheses — bare substrings would let
# "English edition" match "English edition, second printing".
_TRAILING_YEAR = re.compile(r"\s+\d{4}(?:-\d+)?$")
def _rows_on_page(self, dialog) -> list[str]:
return [
" ".join(t.split())
for t in dialog.locator(self._VERSION_ROWS).all_text_contents()
]
def _has_next_page(self, dialog) -> bool:
nxt = dialog.locator(self._NEXT_PAGE)
if nxt.count() == 0:
return False # single-page list: the container is ng-show'd off
return "disabled" not in (
nxt.first.evaluate("e => e.closest('li').className") or ""
)
def _select_version(self, dialog, version_name: str) -> tuple[bool, str]:
"""Pick the version whose name matches, paging the whole list first
so a decision is made against every candidate. Returns (picked,
reason); on failure the sub-view is cancelled and the caller adds
the game version-less — never a guessed edition (spec)."""
dialog.get_by_role("button", name="Set version/edition").click() dialog.get_by_role("button", name="Set version/edition").click()
pattern = re.compile(re.escape(version_name), re.I)
try: try:
dialog.get_by_role("listitem").first.wait_for(timeout=15_000) dialog.locator(self._VERSION_ROWS).first.wait_for(timeout=15_000)
except self._timeout_error as err: except self._timeout_error as err:
# a version resolve found on BGG can't be missing from the # a version resolve found on BGG can't be missing from the
# picker: an unrendered list means a slow page or changed markup # picker: an unrendered list means a slow page or changed markup
@@ -378,18 +404,26 @@ class PlaywrightUploader:
"version picker never rendered — site slow or markup " "version picker never rendered — site slow or markup "
"changed; attempt is retryable" "changed; attempt is retryable"
) from err ) from err
for _ in range(MAX_VERSION_PAGES):
items = dialog.get_by_role("listitem").filter(has_text=pattern) wanted = f"({version_name})".casefold()
if items.count(): # BGG's API name can carry a printing qualifier the picker omits
items.first.click() # ("English edition 2018-2" vs "(English edition) (2018)")
return True relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
# Pagination controls are UNVERIFIED (docs/bgg-upload-flow.md); exact: list[tuple[int, str]] = []
# best guess is a next-page button, stopping when absent/disabled. loose: list[tuple[int, str]] = []
nxt = dialog.get_by_role("button", name=re.compile("next|", re.I)).first pages = 0
if nxt.count() == 0 or nxt.is_disabled(): for page_ix in range(MAX_VERSION_PAGES):
break # genuine end of list: added_no_version is honest pages = page_ix + 1
nxt.click() for text in self._rows_on_page(dialog):
self._page.wait_for_timeout(2_000) # etiquette: paginating hits BGG too folded = text.casefold()
if wanted in folded:
exact.append((page_ix, text))
elif relaxed != wanted and relaxed in folded:
loose.append((page_ix, text))
if not self._has_next_page(dialog):
break
dialog.locator(self._NEXT_PAGE).first.click()
self._page.wait_for_timeout(400) # client-side paging: no request
else: else:
# never saw the end of the list: "not in picker" would be a false # never saw the end of the list: "not in picker" would be a false
# verdict frozen into DONE_STATUSES # verdict frozen into DONE_STATUSES
@@ -397,10 +431,37 @@ class PlaywrightUploader:
f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without " f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without "
"finding the version or the end of the list — retryable" "finding the version or the end of the list — retryable"
) )
hits = exact or loose
if len(hits) == 1:
page_ix, text = hits[0]
self._goto_picker_page(dialog, page_ix)
dialog.locator(self._VERSION_ROWS).filter(
has_text=re.compile(re.escape(text), re.I)
).first.click()
how = "" if exact else f" (matched loosely as {text!r})"
return True, how
# Two-level dismissal: the sub-view has its own Cancel distinct from # Two-level dismissal: the sub-view has its own Cancel distinct from
# the main dialog's. # the main dialog's.
dialog.get_by_role("button", name="Cancel").first.click() dialog.get_by_role("button", name="Cancel").first.click()
return False if not hits:
return False, f"not offered in the picker ({pages} page(s) scanned)"
return False, (
f"{len(hits)} versions match {version_name!r} "
f"({', '.join(t for _, t in hits[:3])}…) — refusing to guess"
)
def _goto_picker_page(self, dialog, page_ix: int) -> None:
"""Back to page 1, then forward — the numbered anchors render twice
(mobile + desktop variants), so stepping is the unambiguous route."""
first = dialog.locator(self._FIRST_PAGE)
if first.count():
first.first.click()
self._page.wait_for_timeout(400)
for _ in range(page_ix):
dialog.locator(self._NEXT_PAGE).first.click()
self._page.wait_for_timeout(400)
def add_game(self, job: UploadJob) -> tuple[str, str]: def add_game(self, job: UploadJob) -> tuple[str, str]:
self._ensure_logged_in() self._ensure_logged_in()
@@ -417,9 +478,13 @@ class PlaywrightUploader:
# match resolves to both checkboxes (strict-mode violation) # match resolves to both checkboxes (strict-mode violation)
dialog.get_by_role("checkbox", name="Own", exact=True).check() dialog.get_by_role("checkbox", name="Own", exact=True).check()
status, note = "added", "" status, note = "added", ""
if job.version_name and not self._select_version(dialog, job.version_name): if job.version_name:
status = "added_no_version" picked, why = self._select_version(dialog, job.version_name)
note = f"version {job.version_name!r} not in picker; added without version" if picked:
note = why.strip()
else:
status = "added_no_version"
note = f"version {job.version_name!r}: {why}; added without version"
dialog.get_by_role("button", name="Save").click() dialog.get_by_role("button", name="Save").click()
# The dialog is hidden after save, not removed from the DOM. # The dialog is hidden after save, not removed from the DOM.
dialog.wait_for(state="hidden", timeout=15_000) dialog.wait_for(state="hidden", timeout=15_000)
@@ -445,11 +510,12 @@ class PlaywrightUploader:
) )
opener = row.get_by_role("link", name=re.compile("own", re.I)).first opener = row.get_by_role("link", name=re.compile("own", re.I)).first
dialog = self._open_dialog(opener) dialog = self._open_dialog(opener)
if not self._select_version(dialog, job.version_name): picked, why = self._select_version(dialog, job.version_name)
if not picked:
# Never guess a version: close without touching the entry. # Never guess a version: close without touching the entry.
dialog.get_by_role("button", name="Cancel").first.click() dialog.get_by_role("button", name="Cancel").first.click()
raise RuntimeError( raise RuntimeError(
f"version {job.version_name!r} not in picker — entry left untouched" f"version {job.version_name!r}: {why} — entry left untouched"
) )
dialog.get_by_role("button", name="Save").click() dialog.get_by_role("button", name="Save").click()
dialog.wait_for(state="hidden", timeout=15_000) dialog.wait_for(state="hidden", timeout=15_000)
+122
View File
@@ -545,3 +545,125 @@ def test_undeterminable_state_raises_instead_of_guessing(monkeypatch):
up = _uploader_with([{"out": 0, "in": 0}]) up = _uploader_with([{"out": 0, "in": 0}])
with pytest.raises(LoginError, match="could not tell"): with pytest.raises(LoginError, match="could not tell"):
up._signed_out() up._signed_out()
# -- version picker: pagination + never-guess matching -------------------
class _FakePicker:
"""Minimal stand-in for the picker sub-view: pages of row texts, a
Next anchor that disables on the last page."""
def __init__(self, pages):
self.pages = pages
self.page = 0
self.clicked = None
self.cancelled = False
# -- locator plumbing the uploader uses ---------------------------
def locator(self, selector):
picker = self
class _Loc:
def __init__(self, texts=None):
self.texts = texts
def all_text_contents(self):
return picker.pages[picker.page]
def count(self):
if "pagination" in selector:
return 1
return len(picker.pages[picker.page])
@property
def first(self):
return self
def wait_for(self, **kw):
return None
def evaluate(self, _js):
last = picker.page >= len(picker.pages) - 1
return "ng-scope disabled" if last else "ng-scope"
def click(self):
if "Next Page" in selector:
picker.page += 1
elif "First Page" in selector:
picker.page = 0
def filter(self, has_text=None):
picker.clicked = has_text.pattern if has_text else None
return self
return _Loc()
def get_by_role(self, role, name=None, **kw):
picker = self
class _Btn:
@property
def first(self):
return self
def click(self):
if name == "Cancel":
picker.cancelled = True
return _Btn()
def _picker_uploader(pages):
from bggpipe.upload import PlaywrightUploader
up = PlaywrightUploader("someone")
class _Page:
def wait_for_timeout(self, ms):
pass
up._page = _Page()
return up, _FakePicker(pages)
def test_version_found_on_a_later_page():
up, dialog = _picker_uploader(
[
["Sleeping Gods (Czech edition) (2023)"],
["Sleeping Gods (English Gamefound edition) (2023)"],
]
)
picked, why = up._select_version(dialog, "English Gamefound edition")
assert picked is True # the old code gave up after page one
assert "Gamefound" in dialog.clicked # regex-escaped row text
assert why == ""
def test_api_name_with_printing_qualifier_matches_loosely():
up, dialog = _picker_uploader([["Mysterium (English edition) (2018)"]])
picked, why = up._select_version(dialog, "English edition 2018-2")
assert picked is True
assert "matched loosely" in why
def test_ambiguous_versions_are_refused_not_guessed():
up, dialog = _picker_uploader(
[
[
"Munchkin (English edition) (2009)",
"Munchkin (English edition) (2015)",
]
]
)
picked, why = up._select_version(dialog, "English edition 2009")
assert picked is False
assert "refusing to guess" in why
assert dialog.cancelled is True
def test_absent_version_reports_pages_scanned():
up, dialog = _picker_uploader([["Catan (German edition) (2015)"]])
picked, why = up._select_version(dialog, "English edition")
assert picked is False
assert "not offered" in why and "1 page(s)" in why