Stage 5 upload: Playwright-driven adds and version updates

Queue from to_add/to_update minus upload_log.csv (append-per-attempt,
so runs resume); per-game failure isolation with 2-4s pacing;
--dry-run/--verify/--retry-failed/--limit; stub-fixture marker blocks
real runs, dry-run warns. Headed browser by default: live recon showed
Cloudflare Turnstile hard-blocks headless, and BGG never reaches
networkidle. Login selectors verified anonymously; version-picker
pagination and the collection-row update flow remain unverified until
real data exists. Client collection fetches gain a refresh passthrough
so --verify sees the live collection, not cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 19:03:25 -04:00
co-authored by Claude Fable 5
parent 196862e243
commit 3ca7e7f650
8 changed files with 968 additions and 19 deletions
+13 -5
View File
@@ -157,17 +157,25 @@ class BGGClient:
username: str,
subtype: str | None = None,
version: bool = True,
*,
refresh: bool = False,
) -> list[CollectionItem]:
params = {"username": username, "own": "1"}
if subtype:
params["subtype"] = subtype
if version:
params["version"] = "1"
return parse_collection(self.get_xml("collection", params))
return parse_collection(self.get_xml("collection", params, refresh=refresh))
def collection_full(self, username: str) -> list[CollectionItem]:
"""Owned items incl. expansions (excluded from the default subtype)."""
base = self.collection(username)
expansions = self.collection(username, subtype="boardgameexpansion")
def collection_full(
self, username: str, *, refresh: bool = False
) -> list[CollectionItem]:
"""Owned items incl. expansions (excluded from the default subtype).
refresh=True bypasses the cache — upload --verify must see the live
collection, not the snapshot resolve ran against."""
base = self.collection(username, refresh=refresh)
expansions = self.collection(
username, subtype="boardgameexpansion", refresh=refresh
)
seen = {item.coll_id for item in base}
return base + [e for e in expansions if e.coll_id not in seen]
+30 -11
View File
@@ -20,13 +20,6 @@ ConfigOpt = Annotated[
]
def _not_implemented(stage: str, build_order: int) -> None:
typer.echo(
f"bggpipe {stage}: not implemented yet (build-order step {build_order})."
)
raise typer.Exit(code=1)
@app.command()
def extract(
only: Annotated[
@@ -89,13 +82,39 @@ def diff(config: ConfigOpt = None) -> None:
@app.command()
def upload(
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
verify: Annotated[bool, typer.Option("--verify")] = False,
retry_failed: Annotated[bool, typer.Option("--retry-failed")] = False,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Show the queue without a browser")
] = False,
verify: Annotated[
bool, typer.Option("--verify", help="Re-fetch the collection and cross-check")
] = False,
retry_failed: Annotated[
bool, typer.Option("--retry-failed", help="Re-attempt previously failed games")
] = False,
limit: Annotated[
int | None, typer.Option("--limit", help="Upload at most N games this run")
] = None,
headless: Annotated[
bool,
typer.Option(
"--headless",
help="Run the browser headless (Cloudflare may block login)",
),
] = False,
config: ConfigOpt = None,
) -> None:
"""Stage 5: add games to the BGG collection via Playwright."""
_not_implemented("upload", 5)
from bggpipe.upload import run_upload
cfg = load_config(config)
run_upload(
cfg,
dry_run=dry_run,
verify=verify,
retry_failed=retry_failed,
limit=limit,
headless=headless,
)
@app.command()
+507
View File
@@ -0,0 +1,507 @@
"""Stage 5 — upload: add games / set versions on boardgamegeek.com via Playwright.
BGG has no write API, so this stage drives the real website with a logged-in
browser session. Etiquette (spec + bgg-api skill):
- 2-4 s randomized delay between games;
- credentials only from BGG_USERNAME / BGG_PASSWORD env vars, never disk/logs;
- browser storage state persists locally (gitignored) so login is rare;
- every attempt is appended to data/upload_log.csv immediately, so a killed
run loses nothing and re-runs skip completed work;
- refuses to touch the site while data/bgg_cache/STUB_FIXTURES.marker exists
(stub-resolved version ids must never reach BGG); --dry-run still works,
loudly labeled as synthetic.
Cloudflare: BGG fronts the site with a Turnstile check that blocks headless
browsers outright (verified 2026-08-01 — headless shell never gets past
"Just a moment..."). The stage therefore runs HEADED by default; a first
login may need one human click on the challenge widget. BGG pages also never
reach Playwright's networkidle (ad/analytics polling), so navigation waits on
domcontentloaded plus explicit element waits.
"""
from __future__ import annotations
import contextlib
import csv
import os
import random
import re
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Protocol
import typer
from bggpipe.bgg_client import BGGAuthError, BGGClient
from bggpipe.config import Config
from bggpipe.models import CollectionItem
BGG = "https://boardgamegeek.com"
STORAGE_STATE_PATH = Path("storage_state.json") # gitignored, credential-adjacent
UPLOAD_LOG_COLUMNS = [
"action",
"bgg_id",
"collid",
"name",
"version_id",
"status",
"timestamp",
"error",
]
DONE_STATUSES = {"added", "updated", "already_present"}
MAX_VERSION_PAGES = 40
@dataclass(frozen=True)
class UploadJob:
action: str # "add" | "update"
bgg_id: str
name: str
version_id: str = ""
version_name: str = ""
collid: str = ""
@property
def key(self) -> tuple[str, str, str]:
# A second copy of the same game (different version) is a distinct
# add; updates are keyed by the physical copy they amend.
if self.action == "update":
return ("update", self.collid, "")
return ("add", self.bgg_id, self.version_id)
def _row_key(row: dict) -> tuple[str, str, str]:
if row["action"] == "update":
return ("update", row["collid"], "")
return ("add", row["bgg_id"], row["version_id"])
def _read_csv(path: Path) -> list[dict]:
if not path.exists():
return []
with path.open(newline="") as f:
return list(csv.DictReader(f))
def append_log_row(path: Path, row: dict) -> None:
"""Append one attempt, creating the file with a header on first write.
One row per attempt, flushed immediately — the log is the resume point."""
new = not path.exists()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=UPLOAD_LOG_COLUMNS, extrasaction="ignore")
if new:
writer.writeheader()
writer.writerow(row)
def build_queue(
to_add: list[dict],
to_update: list[dict],
log_rows: list[dict],
*,
retry_failed: bool = False,
) -> tuple[list[UploadJob], int, int]:
"""Turn the diff outputs into pending jobs, minus work the log says is
done. Returns (jobs, skipped_done, skipped_failed)."""
latest: dict[tuple[str, str, str], str] = {}
for row in log_rows:
latest[_row_key(row)] = row["status"]
candidates = [
UploadJob(
action="add",
bgg_id=row["bgg_id"],
name=row["bgg_name"],
version_id=row.get("version_id", ""),
version_name=row.get("version_name", ""),
)
for row in to_add
] + [
UploadJob(
action="update",
bgg_id=row["bgg_id"],
name=row["bgg_name"],
version_id=row["version_id"],
version_name=row["version_name"],
collid=row["collid"],
)
for row in to_update
]
jobs: list[UploadJob] = []
skipped_done = skipped_failed = 0
for job in candidates:
status = latest.get(job.key)
if status in DONE_STATUSES:
skipped_done += 1
elif status == "failed" and not retry_failed:
skipped_failed += 1
else:
jobs.append(job)
return jobs, skipped_done, skipped_failed
def _scrub(text: str) -> str:
"""Credentials must never reach the log, even via a selector error that
echoes filled form values."""
for key in ("BGG_PASSWORD", "BGG_USERNAME"):
value = os.environ.get(key)
if value:
text = text.replace(value, "***")
return text
class Uploader(Protocol):
def add_game(self, job: UploadJob) -> tuple[str, str]: ...
def update_entry(self, job: UploadJob) -> tuple[str, str]: ...
class PlaywrightUploader:
"""Drives the real site. Selector documentation: docs/bgg-upload-flow.md.
Verified selectors (2026-08-01): the login form (#inputUsername /
#inputPassword / "Sign In") and the Add-to-Collection dialog structure.
UNVERIFIED: version-picker pagination, post-save behavior, and the whole
collection-row edit flow for updates — expect first-real-run adjustments.
"""
def __init__(
self,
username: str,
storage_state: Path = STORAGE_STATE_PATH,
headless: bool = False,
) -> None:
self._username = username
self._storage_state = storage_state
self._headless = headless
self._authed = False
def __enter__(self) -> PlaywrightUploader:
from playwright.sync_api import TimeoutError as PWTimeoutError
from playwright.sync_api import sync_playwright
self._timeout_error = PWTimeoutError
self._pw = sync_playwright().start()
self._browser = self._pw.chromium.launch(headless=self._headless)
state = str(self._storage_state) if self._storage_state.exists() else None
self._context = self._browser.new_context(storage_state=state)
self._page = self._context.new_page()
return self
def __exit__(self, *exc: object) -> None:
self._context.close()
self._browser.close()
self._pw.stop()
def _goto(self, url: str) -> None:
# networkidle never arrives on BGG (ad polling) — domcontentloaded
# plus per-element auto-waiting is the reliable pattern.
self._page.goto(url, wait_until="domcontentloaded")
def _signed_out(self) -> bool:
# Heuristic: the header shows a "Sign In" link only when logged out.
return (
self._page.get_by_role("link", name=re.compile(r"^sign in$", re.I)).count()
> 0
)
def _ensure_logged_in(self) -> None:
if self._authed:
return
page = self._page
self._goto(f"{BGG}/")
if self._signed_out():
user = os.environ.get("BGG_USERNAME", "")
password = os.environ.get("BGG_PASSWORD", "")
if not (user and password):
raise RuntimeError(
"BGG_USERNAME and BGG_PASSWORD env vars are required to log in"
)
self._goto(f"{BGG}/login")
# Generous timeout: in headed mode a human may need to click the
# Cloudflare Turnstile widget before the form renders.
page.locator("#inputUsername").wait_for(timeout=120_000)
page.locator("#inputUsername").fill(user)
page.locator("#inputPassword").fill(password)
page.get_by_role("button", name="Sign In").click()
page.wait_for_url(lambda url: "/login" not in url, timeout=120_000)
self._context.storage_state(path=str(self._storage_state))
self._authed = True
def _open_dialog(self, opener) -> object:
"""Click an opener that may no-op right after page load (hydration
race) and wait for the dialog to actually show."""
dialog = self._page.get_by_role("dialog")
for attempt in (1, 2):
opener.click()
try:
dialog.wait_for(state="visible", timeout=5_000)
return dialog
except self._timeout_error:
if attempt == 2:
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."""
dialog.get_by_role("button", name="Set version/edition").click()
pattern = re.compile(re.escape(version_name), re.I)
with contextlib.suppress(self._timeout_error): # empty list is legal
dialog.get_by_role("listitem").first.wait_for(timeout=10_000)
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
nxt.click()
# 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
def add_game(self, job: UploadJob) -> tuple[str, str]:
self._ensure_logged_in()
page = self._page
# /boardgame/<id> redirects to the canonical slug for any subtype.
self._goto(f"{BGG}/boardgame/{job.bgg_id}/")
add_btn = page.get_by_role("button", name="Add To").first
dialog = self._open_dialog(add_btn)
# Content settles when the game-name heading replaces "Loading...".
dialog.get_by_role(
"heading", name=re.compile(re.escape(job.name), re.I)
).wait_for(timeout=15_000)
dialog.get_by_label("Own").check()
note = ""
if job.version_name and not self._select_version(dialog, job.version_name):
note = f"version {job.version_name!r} not in picker; 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)
return "added", note
def update_entry(self, job: UploadJob) -> tuple[str, str]:
"""Set the version on an EXISTING entry — strictly additive.
UNVERIFIED FLOW: the collection table can't target a collid directly,
so this filters by game and opens the row's status link. With several
copies of one game the wrong row could open — acceptable only while
every 2018 entry is version-less; revisit after the first real run.
"""
self._ensure_logged_in()
page = self._page
self._goto(
f"{BGG}/collection/user/{self._username}?objectid={job.bgg_id}&own=1"
)
row = (
page.get_by_role("row")
.filter(has_text=re.compile(re.escape(job.name), re.I))
.first
)
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):
# 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"
)
dialog.get_by_role("button", name="Save").click()
dialog.wait_for(state="hidden", timeout=15_000)
return "updated", "row-edit flow is unverified; confirm with --verify"
def _process(
uploader: Uploader,
jobs: list[UploadJob],
log_path: Path,
*,
sleep: Callable[[float], None],
rng: random.Random,
now: Callable[[], str],
) -> list[dict]:
results = []
for i, job in enumerate(jobs):
if i:
sleep(rng.uniform(2.0, 4.0)) # polite pacing between games (spec)
try:
if job.action == "add":
status, note = uploader.add_game(job)
else:
status, note = uploader.update_entry(job)
except Exception as exc: # per-game isolation: log it, keep going
status, note = "failed", _scrub(f"{type(exc).__name__}: {exc}")
row = {
"action": job.action,
"bgg_id": job.bgg_id,
"collid": job.collid,
"name": job.name,
"version_id": job.version_id,
"status": status,
"timestamp": now(),
"error": note if status == "failed" else note,
}
append_log_row(log_path, row)
results.append(row)
suffix = f"{note}" if note else ""
typer.echo(f" {job.name}: {status}{suffix}")
return results
def verify_uploads(log_rows: list[dict], collection: list[CollectionItem]) -> list[str]:
"""Cross-check the log's successes against a fresh collection fetch."""
by_object: dict[int, list[CollectionItem]] = {}
by_collid: dict[int, CollectionItem] = {}
for item in collection:
by_object.setdefault(item.object_id, []).append(item)
by_collid[item.coll_id] = item
problems = []
latest: dict[tuple[str, str, str], dict] = {}
for row in log_rows:
latest[_row_key(row)] = row
for row in latest.values():
if row["status"] == "added":
copies = by_object.get(int(row["bgg_id"]), [])
if not copies:
problems.append(f"{row['name']}: logged added but not in collection")
elif row["version_id"] and not any(
str(c.version_id or "") == row["version_id"] for c in copies
):
problems.append(
f"{row['name']}: in collection but no copy has "
f"version {row['version_id']}"
)
elif row["status"] == "updated":
item = by_collid.get(int(row["collid"]))
if item is None:
problems.append(
f"{row['name']}: collid {row['collid']} not in collection"
)
elif str(item.version_id or "") != row["version_id"]:
problems.append(
f"{row['name']}: collid {row['collid']} does not carry "
f"version {row['version_id']}"
)
return problems
def run_upload(
cfg: Config,
*,
dry_run: bool = False,
verify: bool = False,
retry_failed: bool = False,
limit: int | None = None,
headless: bool = False,
uploader: Uploader | None = None,
client: BGGClient | None = None,
storage_state: Path = STORAGE_STATE_PATH,
sleep: Callable[[float], None] = time.sleep,
rng: random.Random | None = None,
now: Callable[[], str] | None = None,
) -> list[dict]:
rng = rng or random.Random()
now = now or (lambda: datetime.now(UTC).isoformat(timespec="seconds"))
marker = cfg.cache_dir / "STUB_FIXTURES.marker"
if marker.exists():
if not dry_run:
typer.echo(
f"Refusing to upload: {marker} exists — every resolved "
"version_id is a synthetic stub placeholder. Once "
"BGG_API_TOKEN arrives: delete both cache dirs, re-record "
"fixtures, `resolve --force`, re-review, re-diff."
)
raise typer.Exit(code=1)
typer.echo(
"WARNING: stub fixtures active — the ids below are SYNTHETIC "
"placeholders, not real BGG data.\n"
)
log_path = cfg.data_dir / "upload_log.csv"
to_add = _read_csv(cfg.data_dir / "to_add.csv")
to_update = _read_csv(cfg.data_dir / "to_update.csv")
log_rows = _read_csv(log_path)
jobs, skipped_done, skipped_failed = build_queue(
to_add, to_update, log_rows, retry_failed=retry_failed
)
if limit is not None:
jobs = jobs[:limit]
skip_note = ""
if skipped_done or skipped_failed:
skip_note = (
f" (skipping {skipped_done} already done, {skipped_failed} "
"previously failed — use --retry-failed)"
)
results: list[dict] = []
if dry_run:
typer.echo(f"Dry run: {len(jobs)} job(s) pending{skip_note}")
for job in jobs:
if job.action == "add":
version = f" [version {job.version_name}]" if job.version_name else ""
typer.echo(f" would add {job.name} ({job.bgg_id}){version}")
else:
typer.echo(
f" would set version {job.version_name!r} on existing "
f"entry collid {job.collid} ({job.name})"
)
elif jobs:
typer.echo(f"Uploading {len(jobs)} job(s){skip_note}")
if uploader is None:
if not (os.environ.get("BGG_USERNAME") and os.environ.get("BGG_PASSWORD")):
typer.echo(
"BGG_USERNAME and BGG_PASSWORD env vars are required "
"(direnv loads them from .env)."
)
raise typer.Exit(code=1)
with PlaywrightUploader(
cfg.bgg_username, storage_state=storage_state, headless=headless
) as real:
results = _process(real, jobs, log_path, sleep=sleep, rng=rng, now=now)
else:
results = _process(uploader, jobs, log_path, sleep=sleep, rng=rng, now=now)
counts: dict[str, int] = {}
for row in results:
counts[row["status"]] = counts.get(row["status"], 0) + 1
summary = " · ".join(f"{n} {status}" for status, n in sorted(counts.items()))
typer.echo(f"\n{summary or 'nothing to do'}")
else:
typer.echo(f"Nothing to upload{skip_note}.")
if verify and not dry_run:
_run_verify(cfg, client, log_path)
return results
def _run_verify(cfg: Config, client: BGGClient | None, log_path: Path) -> None:
if not cfg.bgg_username:
typer.echo("--verify needs bgg_username (config.toml or BGG_USERNAME).")
return
client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
try:
collection = client.collection_full(cfg.bgg_username, refresh=True)
except BGGAuthError as exc:
typer.echo(f"--verify skipped: {exc}")
return
problems = verify_uploads(_read_csv(log_path), collection)
if problems:
typer.echo("\nVerification problems:")
for line in problems:
typer.echo(f" - {line}")
else:
typer.echo("\nVerification OK: every logged success is in the collection.")