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:
co-authored by
Claude Fable 5
parent
6ae2667c58
commit
ba55863cef
+90
-24
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user