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
+90 -24
View File
@@ -363,14 +363,40 @@ class PlaywrightUploader:
raise
return dialog
def _select_version(self, dialog, version_name: str) -> bool:
"""Page through the version sub-view matching the canonical version
NAME (the list has no search box). Returns False — with the sub-view
cancelled — when the name never shows up."""
# Verified 2026-08-06 against the live picker: version rows are the
# <li>s carrying a thumbnail (the paging <li>s are not), and paging is
# an AngularJS <ul class="pagination"> of anchors — NOT buttons named
# "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()
pattern = re.compile(re.escape(version_name), re.I)
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:
# a version resolve found on BGG can't be missing from the
# 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 "
"changed; attempt is retryable"
) from err
for _ in range(MAX_VERSION_PAGES):
items = dialog.get_by_role("listitem").filter(has_text=pattern)
if items.count():
items.first.click()
return True
# Pagination controls are UNVERIFIED (docs/bgg-upload-flow.md);
# best guess is a next-page button, stopping when absent/disabled.
nxt = dialog.get_by_role("button", name=re.compile("next|", re.I)).first
if nxt.count() == 0 or nxt.is_disabled():
break # genuine end of list: added_no_version is honest
nxt.click()
self._page.wait_for_timeout(2_000) # etiquette: paginating hits BGG too
wanted = f"({version_name})".casefold()
# BGG's API name can carry a printing qualifier the picker omits
# ("English edition 2018-2" vs "(English edition) (2018)")
relaxed = f"({self._TRAILING_YEAR.sub('', version_name).strip()})".casefold()
exact: list[tuple[int, str]] = []
loose: list[tuple[int, str]] = []
pages = 0
for page_ix in range(MAX_VERSION_PAGES):
pages = page_ix + 1
for text in self._rows_on_page(dialog):
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:
# never saw the end of the list: "not in picker" would be a false
# verdict frozen into DONE_STATUSES
@@ -397,10 +431,37 @@ class PlaywrightUploader:
f"hit MAX_VERSION_PAGES ({MAX_VERSION_PAGES}) without "
"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
# the main dialog's.
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]:
self._ensure_logged_in()
@@ -417,9 +478,13 @@ class PlaywrightUploader:
# match resolves to both checkboxes (strict-mode violation)
dialog.get_by_role("checkbox", name="Own", exact=True).check()
status, note = "added", ""
if job.version_name and not self._select_version(dialog, job.version_name):
status = "added_no_version"
note = f"version {job.version_name!r} not in picker; added without version"
if job.version_name:
picked, why = self._select_version(dialog, job.version_name)
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()
# The dialog is hidden after save, not removed from the DOM.
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
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.
dialog.get_by_role("button", name="Cancel").first.click()
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.wait_for(state="hidden", timeout=15_000)