From f677c7ce65b6992e97a6376ae4056270f7b0c282 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Thu, 6 Aug 2026 18:34:23 -0400 Subject: [PATCH] bggpipe export: the library as static pages for any site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eric wanted the library on his blog; the spec always called games.json "the seed for a future web frontend." The new export stage renders it as self-contained static pages — an index with search, one page per game with facts, chips, the owner's edition and the description — that drop into any static host (Hugo's static/ folder included). No server, no build step, no external requests from the published pages. Public pages carry obligations a localhost app doesn't. Cover art is downloaded once from BGG's CDN instead of hotlinked (0.3s between fetches — a guest, not a crawler; part-file writes so a failure never leaves a truncated image; re-runs skip what exists, so the export is idempotent and resumable like every stage). The footer shows a Powered-by-BGG badge per BGG's public-app policy — text by default, upgraded to the official logo when the owner saves it from their registered-application page as data/powered-by-bgg.png — plus the trademark attribution. And one privacy rule, tested: shelf photos are never exported; they picture the inside of the owner's home. Covers and hand-added local art only, per Eric's explicit choice. Slugs are deterministic and collision-stable (two editions of one game get -2 suffixes in sorted-key order) so re-exports keep every URL. Descriptions un-double-encode BGG's entities. First real run: 136 pages, 254 covers, 64MB, live on the blog's static directory. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g --- CLAUDE.md | 1 + README.md | 2 +- docs/guide.md | 23 +++ src/bggpipe/cli.py | 19 ++ src/bggpipe/export.py | 431 ++++++++++++++++++++++++++++++++++++++++++ tests/test_export.py | 166 ++++++++++++++++ 6 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 src/bggpipe/export.py create mode 100644 tests/test_export.py diff --git a/CLAUDE.md b/CLAUDE.md index 4211517..a9462da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co | 4 | `bggpipe diff` | diff approved matches against the existing BGG collection | working | | 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) | | 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working | +| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working | Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`. diff --git a/README.md b/README.md index 50b5707..4d74c64 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ BGG has no bulk import and no write API. Cataloging a few hundred games by hand 3. **review** — A local review step for ambiguous matches: pick the right game/version, or leave the version blank. Wrong guesses never reach your collection. 4. **diff** — Your existing BGG collection is fetched and compared, per copy (owning one edition of a game doesn't hide a second edition you also own). 5. **upload** — A Playwright browser session logs into your BGG account and adds each game (with its version, when known) politely and slowly. Dry-run mode, per-game logging, and resumability included. -6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves. +6. **enrich** — Full metadata for every game (designers, player counts, weight, rank, mechanics, artwork, version details) lands in `data/games.json`, feeding a browsable library of your shelves — which `bggpipe export` can publish as [static pages on your own site](docs/guide.md#publishing-your-library-on-your-own-site). Everything runs locally, every stage survives being killed mid-run, and all artifacts are flat CSV/JSON files you can inspect and edit. You can drive it from the terminal or from a local web app: diff --git a/docs/guide.md b/docs/guide.md index e088f44..2a4e1f7 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -109,6 +109,29 @@ BGG application approval can take a week or more. Until then: `extract` works im - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1` - `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion` +## Publishing your library on your own site + +```sh +bggpipe export --out ~/my-site/static/library --title "My Game Shelves" +``` + +Writes the library as self-contained static pages: an index with search, one +page per game, and a local copy of every cover (public pages must not hotlink +BGG's image CDN). No server, no build step — drop the directory into any +static host, Hugo/Jekyll `static/` folder included. Re-runs are idempotent: +already-downloaded covers are kept, failed downloads are retried, and page +URLs stay stable. + +What's included follows a privacy rule: cover art, stats, your editions, and +your hand-added local games — **never your shelf photos** (they picture the +inside of your home; they stay in the private app). + +BGG's API policy asks public-facing apps to display the "Powered by BGG" +badge. The footer carries a text badge automatically; to show the official +logo, save it from your registered application page at +[boardgamegeek.com/applications](https://boardgamegeek.com/applications) as +`data/powered-by-bgg.png` and re-export. + ## Keeping your data safe from git The [README's quick start](../README.md#quick-start) — installed as a tool, run in a directory of your own — is the only supported way to use bggpipe on your collection. Everything the pipeline produces lives where you run it, and `uv tool upgrade bggpipe` picks up fixes without going anywhere near your data. diff --git a/src/bggpipe/cli.py b/src/bggpipe/cli.py index 6a8cc6a..1e13bab 100644 --- a/src/bggpipe/cli.py +++ b/src/bggpipe/cli.py @@ -178,6 +178,25 @@ def upload( ) +@app.command() +def export( + out: Annotated[ + Path, + typer.Option("--out", help="Directory to write the static site into"), + ], + title: Annotated[ + str, + typer.Option("--title", help="Site title shown on every page"), + ] = "My Game Library", + config: ConfigOpt = None, +) -> None: + """Export the library as self-contained static pages (for a blog etc.).""" + from bggpipe.export import run_export + + cfg = load_config(config) + run_export(cfg, out, title=title) + + @app.command() def enrich( refresh: Annotated[ diff --git a/src/bggpipe/export.py b/src/bggpipe/export.py new file mode 100644 index 0000000..6c53673 --- /dev/null +++ b/src/bggpipe/export.py @@ -0,0 +1,431 @@ +"""Export the library as self-contained static pages for any web host. + +Reads games.json (the enrich output) and writes an index page, one page +per game, and a local copy of every cover image — no server, no build +step, no external requests from the published pages. Cover art is +downloaded once from BGG's CDN (public pages must not hotlink it) and +re-runs skip images already on disk, so the export is idempotent and +resumable like every other stage. + +Shelf photos are deliberately NOT exported: they picture the inside of +the owner's home. Covers and hand-added local art only. +""" + +from __future__ import annotations + +import html +import json +import re +import shutil +import time +from pathlib import Path +from urllib.parse import urlsplit + +import httpx +import typer + +from bggpipe.config import Config + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + +# BGG's API policy: public-facing apps must display the "Powered by BGG" +# logo linking back. The official asset comes from the owner's registered +# application page — save it as data/powered-by-bgg.png and the export +# ships the image badge; until then, a text badge keeps the link. +BADGE_NAME = "powered-by-bgg.png" + + +def _powered_by(rel: str, badge_present: bool) -> str: + inner = ( + f'Powered by BGG' + if badge_present + else "Powered by BGG" + ) + return f'{inner}' + + +def _slug(name: str) -> str: + return _SLUG_RE.sub("-", (name or "game").casefold()).strip("-") or "game" + + +def _slugs(games: dict[str, dict]) -> dict[str, str]: + """Deterministic, unique, human-readable page names. Collisions (two + editions of one game share a name) get -2, -3... in sorted-key order + so re-exports keep every URL stable.""" + out: dict[str, str] = {} + taken: set[str] = set() + for key in sorted(games): + base = _slug(games[key].get("name", "")) + slug, n = base, 2 + while slug in taken: + slug = f"{base}-{n}" + n += 1 + taken.add(slug) + out[key] = slug + return out + + +def _art_name(key: str, url: str, *, thumb: bool) -> str: + ext = Path(urlsplit(url).path).suffix.lower() or ".jpg" + safe = _SLUG_RE.sub("-", key.casefold()).strip("-") + return f"{safe}-thumb{ext}" if thumb else f"{safe}{ext}" + + +def _fetch_art( + games: dict[str, dict], + art_dir: Path, + cfg: Config, + fetch: httpx.Client, + sleep, +) -> tuple[dict[str, dict[str, str]], int, list[str]]: + """Local filename per game for thumb/full art. Downloads only what is + missing; a failed download is reported and the page ships without + that image — never a broken half-written file.""" + art: dict[str, dict[str, str]] = {} + downloaded = 0 + failures: list[str] = [] + art_dir.mkdir(parents=True, exist_ok=True) + for key, game in games.items(): + entry: dict[str, str] = {} + image = game.get("image") or "" + thumb = game.get("thumbnail") or "" + if image.startswith("/local-art/"): + # hand-added cover: already on disk, just carried across + src = cfg.local_art_dir / Path(image).name + if src.exists(): + shutil.copyfile(src, art_dir / src.name) + entry["full"] = entry["thumb"] = src.name + else: + for url, is_thumb in ((image, False), (thumb, True)): + if not url.startswith("http"): + continue + name = _art_name(key, url, thumb=is_thumb) + target = art_dir / name + if not target.exists(): + try: + resp = fetch.get(url) + resp.raise_for_status() + except httpx.HTTPError as err: + failures.append(f"{game.get('name', key)}: {err}") + continue + tmp = target.with_suffix(target.suffix + ".part") + tmp.write_bytes(resp.content) + tmp.replace(target) + downloaded += 1 + sleep(0.3) # a guest on BGG's CDN, not a crawler + entry["thumb" if is_thumb else "full"] = name + if "thumb" not in entry and "full" in entry: + entry["thumb"] = entry["full"] + art[key] = entry + return art, downloaded, failures + + +_CSS = """ +:root { --board:#faf3e3; --ink:#2a2438; --ink-soft:#6b6478; --line:#d8cdb4; + --sky:#cfe4f5; --navy:#2f3d5c; --accent:#7a4a9e; } +* { box-sizing:border-box; } +body { margin:0; font:16px/1.55 system-ui, sans-serif; color:var(--ink); + background:var(--sky); } +main { max-width:72rem; margin:0 auto; padding:1.2rem; } +header.site { background:var(--navy); color:#fff; padding:.9rem 1.2rem; } +header.site a { color:#fff; text-decoration:none; font-weight:700; } +header.site .sub { color:#cfe4f5; font-size:.85rem; } +.controls { display:flex; gap:.5rem; flex-wrap:wrap; margin:1rem 0; } +.controls input[type=search] { flex:1 1 14rem; padding:.45rem .6rem; + border:2px solid var(--line); border-radius:.5rem; font:inherit; } +.controls select, .controls button { font:inherit; padding:.4rem .7rem; + border:2px solid var(--line); border-radius:.5rem; background:var(--board); + cursor:pointer; } +.controls button[aria-pressed=true] { background:var(--accent); color:#fff; } +.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(11.5rem,1fr)); + gap:1rem; } +a.game { background:var(--board); border:1px solid var(--line); + border-radius:.8rem; overflow:hidden; text-decoration:none; color:inherit; + display:flex; flex-direction:column; box-shadow:0 2px 5px rgba(42,36,56,.15); } +a.game:hover { outline:3px solid var(--accent); } +a.game img { width:100%; aspect-ratio:1; object-fit:cover; display:block; + background:#e8ddc5; } +a.game .noart { width:100%; aspect-ratio:1; display:flex; align-items:center; + justify-content:center; font-size:3rem; font-weight:700; color:var(--navy); + background:#e8ddc5; } +a.game .info { padding:.55rem .7rem .7rem; } +.gname { font-weight:700; } +.meta { color:var(--ink-soft); font-size:.82rem; } +.chip { display:inline-block; font-size:.7rem; border:1px solid var(--accent); + color:var(--accent); border-radius:1rem; padding:0 .5rem; } +.detail { display:flex; gap:1.4rem; flex-wrap:wrap; align-items:flex-start; + margin-top:1rem; } +.detail .art { flex:0 0 clamp(11rem,26vw,17rem); } +.detail .art img { width:100%; border-radius:.8rem; border:1px solid var(--line); } +.facts { flex:1 1 18rem; background:var(--board); border:1px solid var(--line); + border-radius:.8rem; padding:.9rem 1.1rem; } +.factrow { margin:.45rem 0; } +.factrow b { font-size:.72rem; text-transform:uppercase; letter-spacing:.06em; + color:var(--ink-soft); margin-right:.4rem; } +.card { background:var(--board); border:1px solid var(--line); + border-radius:.8rem; padding:.9rem 1.1rem; margin-top:1rem; } +.desc { white-space:pre-wrap; } +footer { margin:2rem 0 1rem; color:var(--ink-soft); font-size:.8rem; + text-align:center; } +footer a { color:var(--accent); } +a.pbb { font-weight:700; } +""" + + +def _chips(label: str, values: list | None) -> str: + if not values: + return "" + spans = " ".join(f'{html.escape(str(v))}' for v in values) + return f'
{html.escape(label)} {spans}
' + + +def _fact(label: str, value) -> str: + if value in (None, "", []): + return "" + return ( + f'
{html.escape(label)} ' + f"{html.escape(str(value))}
" + ) + + +def _players(g: dict) -> str: + lo, hi = g.get("min_players"), g.get("max_players") + lo, hi = lo or hi, hi or lo + if not lo: + return "" + span = str(lo) if lo == hi else f"{lo}–{hi}" + noun = "player" if lo == hi == 1 else "players" + best = g.get("best_player_counts") or [] + return f"{span} {noun}" + (f" (best at {', '.join(best)})" if best else "") + + +def _playtime(g: dict) -> str: + lo, hi = g.get("min_playtime"), g.get("max_playtime") + if lo and hi and lo != hi: + return f"{lo}–{hi} min" + minutes = g.get("playtime") or lo + return f"{minutes} min" if minutes else "" + + +def _footer(title: str, rel: str, badge_present: bool) -> str: + return f"""
+

{_powered_by(rel, badge_present)}

+

{html.escape(title)} · cataloged from shelf photos by + bggpipe

+

Game data and cover images from BoardGameGeek. BoardGameGeek and BGG are trademarks of + BoardGameGeek, LLC; this page is an independent project, not affiliated + with or endorsed by BoardGameGeek.

+
""" + + +def _page( + title: str, + body: str, + *, + rel: str, + site_title: str, + badge_present: bool, +) -> str: + return f""" + + + +{html.escape(title)} + +
{html.escape(site_title)} + a board game library
+
+{body} +{_footer(site_title, rel, badge_present)} +
+""" + + +def _card(key: str, g: dict, slug: str, art: dict[str, str]) -> str: + img = ( + f'' + if art.get("thumb") + else '
' + + html.escape((g.get("name") or "?")[0].upper()) + + "
" + ) + year = f' ({g["year"]})' if g.get("year") else "" + bits = " · ".join( + b + for b in ( + _players(g), + _playtime(g), + f"rank {g['rank']}" if g.get("rank") else "", + ) + if b + ) + kind = "" + if g.get("type") == "rpgitem": + kind = ' RPG' + elif g.get("type") == "localgame": + kind = ' not on BGG' + name = html.escape(g.get("name") or "?") + return f""" + {img} +
{name}{year}{kind}
+
{html.escape(bits)}
+
""" + + +def _detail_body(g: dict, art: dict[str, str]) -> str: + art_html = ( + f'' + if art.get("full") + else "" + ) + bgg_link = "" + if g.get("bgg_id") and g.get("type") != "localgame": + site = ( + f"https://rpggeek.com/rpgitem/{g['bgg_id']}" + if g.get("type") == "rpgitem" + else f"https://boardgamegeek.com/boardgame/{g['bgg_id']}" + ) + label = "RPGGeek" if g.get("type") == "rpgitem" else "BGG" + bgg_link = f'view on {label} ↗' + facts = "".join( + ( + _fact("players", _players(g)), + _fact("playing time", _playtime(g)), + _fact("ages", f"{g['min_age']}+" if g.get("min_age") else ""), + _fact("weight", f"{g['weight']:.2f} / 5" if g.get("weight") else ""), + _fact("BGG rank", g.get("rank")), + _fact("BGG rating", f"{g['rating']:.2f}" if g.get("rating") else ""), + _chips("designers", g.get("designers")), + _chips("artists", (g.get("artists") or [])[:8]), + _chips("publishers", (g.get("publishers") or [])[:6]), + _chips("categories", g.get("categories")), + _chips("mechanics", g.get("mechanics")), + ) + ) + version = "" + if g.get("version"): + v = g["version"] + vyear = f" · {v['year']}" if v.get("year") else "" + version = ( + '
' + f"my copy {html.escape(v.get('name') or '—')}{vyear}
" + + _chips("publishers", v.get("publishers")) + + _chips("languages", v.get("languages")) + + "
" + ) + desc = ( + # BGG descriptions arrive with entities still encoded (’ etc); + # unescape their layer, then escape once for this page + f'
{html.escape(html.unescape(g["description"]))}
' + if g.get("description") + else "" + ) + year = f' ({g["year"]})' if g.get("year") else "" + return f"""

← the whole library

+

{html.escape(g.get("name") or "?")}{year} {bgg_link}

+
+
{art_html}
+
{facts}
+
+{version} +{desc}""" + + +_INDEX_JS = """ + +""" + + +def run_export( + cfg: Config, + out_dir: Path, + *, + title: str = "My Game Library", + fetch: httpx.Client | None = None, + sleep=time.sleep, +) -> dict: + if not cfg.games_path.exists(): + raise typer.Exit( + typer.secho( + f"{cfg.games_path} not found — run `bggpipe enrich` first.", err=True + ) + or 1 + ) + games: dict[str, dict] = json.loads(cfg.games_path.read_text()) + slugs = _slugs(games) + out_dir.mkdir(parents=True, exist_ok=True) + own_client = fetch is None + fetch = fetch or httpx.Client( + timeout=30, follow_redirects=True, headers={"User-Agent": "bggpipe-export"} + ) + try: + art, downloaded, failures = _fetch_art( + games, out_dir / "art", cfg, fetch, sleep + ) + finally: + if own_client: + fetch.close() + + (out_dir / "style.css").write_text(_CSS) + # the official badge, when the owner has saved it from their BGG + # application page; the footer falls back to a text badge without it + badge_src = cfg.data_dir / BADGE_NAME + badge_present = badge_src.exists() + if badge_present: + shutil.copyfile(badge_src, out_dir / "art" / BADGE_NAME) + else: + typer.echo( + " note: no data/powered-by-bgg.png — footer uses a text " + '"Powered by BGG" link; save the official badge from ' + "boardgamegeek.com/applications to upgrade it" + ) + ordered = sorted(games, key=lambda k: (games[k].get("name") or "").casefold()) + cards = "\n".join(_card(k, games[k], slugs[k], art.get(k, {})) for k in ordered) + index_body = ( + f"

{html.escape(title)}

" + f'

{len(games)} games, cataloged from shelf photos.

' + '
' + f'
{cards}
{_INDEX_JS}' + ) + (out_dir / "index.html").write_text( + _page(title, index_body, rel="", site_title=title, badge_present=badge_present) + ) + for key, game in games.items(): + page_dir = out_dir / slugs[key] + page_dir.mkdir(exist_ok=True) + (page_dir / "index.html").write_text( + _page( + f"{game.get('name') or 'game'} · {title}", + _detail_body(game, art.get(key, {})), + rel="../", + site_title=title, + badge_present=badge_present, + ) + ) + for line in failures: + typer.echo(f" warning: cover not fetched — {line}") + typer.echo( + f"exported {len(games)} game page(s) to {out_dir} " + f"({downloaded} cover(s) downloaded, " + f"{len(failures)} failed — re-run to retry)" + ) + return { + "games": len(games), + "downloaded": downloaded, + "failures": len(failures), + } diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..ac2fa3f --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,166 @@ +"""Static export: pages, art downloads, badge slot, privacy. Offline — +cover downloads run against a mock CDN transport.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest + +from bggpipe.config import Config +from bggpipe.export import run_export + +GAMES = { + "240:24621": { + "bgg_id": 240, + "type": "boardgame", + "name": "Britannia", + "year": 1986, + "description": "A game of & settlement.", + "image": "https://cf.geekdo-images.com/orig/pic1.jpg", + "thumbnail": "https://cf.geekdo-images.com/thumb/pic1.jpg", + "min_players": 3, + "max_players": 5, + "best_player_counts": ["4"], + "playtime": 240, + "designers": ["Lewis Pulsipher"], + "rank": 1210, + "weight": 3.16, + "version": { + "version_id": 24621, + "name": "Avalon Hill second edition", + "year": 1987, + "publishers": ["Avalon Hill"], + "languages": ["English"], + }, + "source_photos": ["IMG_1.jpeg"], + }, + "311654": { + "bgg_id": 311654, + "type": "rpgitem", + "name": "Alice is Missing", + "year": 2020, + "image": "https://cf.geekdo-images.com/orig/pic2.png", + "thumbnail": "", + "source_photos": ["IMG_2.jpeg"], + }, + "local:homebrew:x.jpg": { + "type": "localgame", + "name": "Britannia", # deliberate name collision with 240:24621 + "image": "/local-art/abc123.jpg", + "source_photos": ["x.jpg"], + }, +} + + +def _cfg(tmp_path: Path) -> Config: + cfg = Config(data_dir=tmp_path / "data") + cfg.data_dir.mkdir(parents=True) + cfg.games_path.write_text(json.dumps(GAMES)) + cfg.local_art_dir.mkdir() + (cfg.local_art_dir / "abc123.jpg").write_bytes(b"\xff\xd8local") + return cfg + + +def _cdn(requests_log: list[str]) -> httpx.Client: + def handler(request: httpx.Request) -> httpx.Response: + requests_log.append(str(request.url)) + return httpx.Response(200, content=b"\xff\xd8fake-image") + + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def test_export_writes_pages_art_and_no_shelf_photos(tmp_path): + cfg = _cfg(tmp_path) + out = tmp_path / "site" + log: list[str] = [] + summary = run_export( + cfg, out, title="Test Shelves", fetch=_cdn(log), sleep=lambda s: None + ) + assert summary == {"games": 3, "downloaded": 3, "failures": 0} + + index = (out / "index.html").read_text() + assert "Test Shelves" in index and "Powered by BGG" in index + assert "trademarks of" in index + + # name collision -> distinct stable slugs + assert (out / "britannia" / "index.html").exists() + assert (out / "britannia-2" / "index.html").exists() + assert (out / "alice-is-missing" / "index.html").exists() + + detail = (out / "britannia" / "index.html").read_text() + assert "Avalon Hill second edition" in detail + assert "<invasions> & settlement" in detail # escaped, not raw + # BGG double-encodes entities; the page shows the character, not ’ + assert "&rsquo" not in detail + assert "boardgamegeek.com/boardgame/240" in detail + rpg = (out / "alice-is-missing" / "index.html").read_text() + assert "rpggeek.com/rpgitem/311654" in rpg + + # covers landed locally and pages never hotlink the CDN + assert len(list((out / "art").glob("*"))) == 4 # 2 full + 1 thumb + local art + all_html = index + detail + rpg + assert "geekdo-images.com" not in all_html + + # the privacy rule: shelf photo names appear nowhere in the output + assert "IMG_1.jpeg" not in all_html and "IMG_2.jpeg" not in all_html + + +def test_export_is_idempotent_and_offline_on_rerun(tmp_path): + cfg = _cfg(tmp_path) + out = tmp_path / "site" + run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) + + def refuse(request: httpx.Request) -> httpx.Response: + raise AssertionError(f"re-run fetched {request.url}") + + summary = run_export( + cfg, + out, + fetch=httpx.Client(transport=httpx.MockTransport(refuse)), + sleep=lambda s: None, + ) + assert summary["downloaded"] == 0 + + +def test_failed_cover_reports_and_ships_page_anyway(tmp_path): + cfg = _cfg(tmp_path) + + def flaky(request: httpx.Request) -> httpx.Response: + if "pic2" in str(request.url): + return httpx.Response(503) + return httpx.Response(200, content=b"img") + + summary = run_export( + cfg, + tmp_path / "site", + fetch=httpx.Client(transport=httpx.MockTransport(flaky)), + sleep=lambda s: None, + ) + assert summary["failures"] == 1 + page = (tmp_path / "site" / "alice-is-missing" / "index.html").read_text() + assert "Alice is Missing" in page # the page exists, just coverless + # nothing half-written left behind to poison the idempotent re-run + assert not list((tmp_path / "site" / "art").glob("*.part")) + + +def test_official_badge_upgrades_the_footer(tmp_path): + cfg = _cfg(tmp_path) + (cfg.data_dir / "powered-by-bgg.png").write_bytes(b"\x89PNG-badge") + out = tmp_path / "site" + run_export(cfg, out, fetch=_cdn([]), sleep=lambda s: None) + assert (out / "art" / "powered-by-bgg.png").exists() + index = (out / "index.html").read_text() + assert 'img src="art/powered-by-bgg.png" alt="Powered by BGG"' in index + detail = (out / "britannia" / "index.html").read_text() + assert 'img src="../art/powered-by-bgg.png"' in detail + + +def test_export_without_games_json_exits_with_guidance(tmp_path): + cfg = Config(data_dir=tmp_path / "data") + import typer + + with pytest.raises(typer.Exit): + run_export(cfg, tmp_path / "site", fetch=_cdn([]), sleep=lambda s: None)