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
@@ -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,
|
||||||
|
|||||||
|
@@ -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.
|
||||||
|
|||||||
+89
-23
@@ -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:
|
||||||
|
picked, why = self._select_version(dialog, job.version_name)
|
||||||
|
if picked:
|
||||||
|
note = why.strip()
|
||||||
|
else:
|
||||||
status = "added_no_version"
|
status = "added_no_version"
|
||||||
note = f"version {job.version_name!r} not in picker; added without 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user