bggpipe export: the library as static pages for any site
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
e778452dd1
commit
f677c7ce65
@@ -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 <invasions> & 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)
|
||||
Reference in New Issue
Block a user