10 KiB
Card Grader
Self-hosted web app that estimates PSA trading card grades from photos, using
vision-capable LLMs (Claude and/or GPT) as the grading engine. Runs as a
single Docker container on the user's Unraid home server, exposed at
hippofam.com/cards through Nginx Proxy Manager.
No framework: the whole backend is Python stdlib
(http.server.ThreadingHTTPServer) plus sqlite3. Optional third-party
packages are anthropic, openai, and Pillow (image processing only —
never required for the server to boot).
Architecture
app.py— HTTP routing/handlers. Reads Basic Auth username forwarded by the reverse proxy for attribution only (who graded what), never for access control — auth itself is enforced by Nginx Proxy Manager in front of the container via htpasswd. Admin-only settings are gated byADMIN_USERNAME(CARD_GRADER_ADMIN_USERenv var). Request bodies are capped (MAX_BODY_BYTES); oversized/malformed bodies get a 413 and the connection is closed rather than trusting a client-supplied length.vision.py— Provider abstraction over Anthropic and OpenAI vision APIs (_call_anthropic/_call_openai, dispatched from a common entrypoint).MODELSholds per-model pricing/capabilities.GRADING_SYSTEMis the large system prompt encoding PSA's actual grading rubric (centering tolerances, corner/edge/surface criteria, qualifiers — MC/OC/PD/ST/MK, half-grades, Altered Authentic) — verified against psacard.com, beckett.com, and cgccards.com, not guessed. Self-reported "high" confidence is capped to "medium" when neither centering nor edge whitening was independently measurable from the image geometry.cardimage.py— Pillow-based image pipeline: card boundary detection, corner/edge/surface crop generation, centering/edge-whitening/aspect-ratio measurement from pixels (not just left to the model's eye). Includes holder/toploader-aware detection (detect_card_box): a combinatorial search over candidate edge positions constrained to standard card aspect ratios, so the app can locate the actual card inside a slab/toploader rather than just detecting the holder's outline. When a holder is detected, edge-whitening measurement is refused (the holder's own inner edge corrupts that specific measurement) but centering still runs (border-width geometry, unaffected by clear plastic), and aspect-ratio measurement deliberately uses the unrefined box to avoid circularity (refinement selects for standard-ratio rectangles, so measuring the refined box's ratio would trivially always look "normal").store.py— SQLite (WAL mode) persistence.gradesandgrade_eventstables, JSON columns for structured data, schema migrations via idempotentALTER TABLEinstore.init(). List/detail API responses explicitly excludesource_images_jsonto keep payloads small. DB path overridable viaCARD_GRADER_DB_PATH.static/— Vanilla JS/CSS/HTML frontend, PWA-enabled (manifest, service worker). "PSA slab" visual theme.__BASE__is templated at request time so the app works correctly when served from a sub-path (/cards) behind the proxy.
Local development
python3 app.py
Needs ANTHROPIC_API_KEY and/or OPENAI_API_KEY in the environment to
actually grade; the server itself has no required third-party deps. A local
grades.db is gitignored and is scratch/test data only — it is not the
production database (that lives on the server, see below).
Deployment
Target: Unraid server, container path /mnt/user/appdata/card-grader,
built from Dockerfile / docker-compose.yml in this repo. Non-root
container user (99:100 / nobody:users), healthcheck, log rotation,
init: true. Reverse-proxied by Nginx Proxy Manager at hippofam.com/cards
with per-user HTTP Basic Auth.
Current deploy mechanism (live as of 2026-08-25): Forgejo push-to-deploy.
Self-hosted Forgejo (soft-fork of Gitea) runs as its own container on the
same Unraid box (/mnt/user/appdata/forgejo), proxied at
https://git.hippofam.com via Nginx Proxy Manager (wildcard Let's Encrypt
cert issued via Porkbun DNS-01 challenge — Cox blocks inbound port 80, so
the default HTTP-01 challenge doesn't work on this network; DNS challenge
sidesteps that entirely). Forgejo Actions is enabled instance-wide, with a
self-hosted runner (/mnt/user/appdata/forgejo-runner, container name
forgejo-runner) registered globally (not repo-scoped — see tradeoff
below).
This repo has a forgejo remote (https://git.hippofam.com/ninja_hippo/card-grader.git,
HTTPS not SSH — see note below) and pushing to main triggers
.forgejo/workflows/deploy.yml, which rsyncs the checkout into
/mnt/user/appdata/card-grader (excluding .git, .env, data/ — those
stay server-side only) and runs docker compose up -d --build. Typical
deploy completes in well under a minute.
Why HTTPS, not SSH, for git: Forgejo's git-SSH listens on host port
2222 (port 22 was already taken by Unraid's own sshd), but the router
only forwards 80/443 to this box — 2222 was never opened externally, and
setting that up requires the user's own router admin access, which Claude
doesn't have. Git auth instead uses a Forgejo access token stored in macOS
Keychain via git config --global credential.helper osxkeychain — pushing
just works, no per-push prompt.
Runner internals, only useful if the pipeline breaks: the runner's job
containers are the catthehacker/ubuntu:act-latest image (has git/Node
bundled for the actions/checkout JS action, but not rsync — installed
as an explicit workflow step each run) with the host Docker socket
automounted (container.docker_host: automount in the runner's
config.yaml) and /mnt/user/appdata bind-mounted into every job
container at the identical path (container.options), which also required
adding /mnt/user/appdata to container.valid_volumes (empty by default —
Forgejo silently drops any bind mount not explicitly allow-listed there,
with only a debug-log line, no visible job failure). The identical-path
mount trick matters because docker compose's relative volume paths are
resolved by the host daemon (Docker-outside-of-Docker via the automounted
socket), so a mismatched path silently bind-mounts the wrong location.
Known tradeoff, accepted, not yet resolved: the runner is registered
globally rather than scoped per-repo, and it has full Docker socket access
— a workflow file pushed to any repo on this Forgejo instance can control
any container on the box, not just its own. The access boundary is "who
can push a workflow file to a repo on this instance," not anything
narrower. This runner also now serves three other projects on the same
box — lunch-notifier, n64-tracker, arr-summary — each with the
identical deploy pattern (see their own CLAUDE.mds). Revisit with a
docker-socket-proxy (scoped per compose project) if this needs tightening
later; not done yet because the basic pipeline mattered more first.
Superseded, fully torn down: git-shell restricted deploy. A
carddeploy service account (login shell git-shell, one bare repo, a
post-receive hook triggering a single root-owned deploy script via one
narrowly-scoped sudo rule) was built, tested, and verified working
end-to-end — then explicitly torn down in favor of Forgejo. Nothing from it
remains: account, sudoers rule, bare repo, /boot/config/go persistence
block, and the sshd_config AllowUsers addition were all removed and
verified reverted. If this pattern (restricted git-shell account +
sudo-scoped deploy script) is useful as a reference for scoping other
access requests later, it's a solid model — abandoned for tooling reasons
(moving to a real git host), not because it didn't work.
All server access for this migration was narrowly scoped and time-boxed: a
temporary root SSH key was generated solely to do the Forgejo/runner/NPM
setup work above, then removed from the server's authorized_keys and
deleted locally once the pipeline was verified end-to-end working — checked
by confirming the key no longer authenticates. Treat that as the standing
expectation for any future server-access ask on this project — default to
the smallest scope that does the job, time-box it, and confirm
teardown/persistence explicitly rather than leaving it open-ended.
Conventions and constraints established for this project
- PSA grading rules must be verified against a real source (PSA's own site,
Beckett, CGC) before being encoded into
GRADING_SYSTEM— this rubric has had real accuracy bugs (e.g. a self-contradictory centering tolerance, a missing 5% front-centering leeway rule for grades 7+) found and fixed through actual verification, not assumption. - The vision prompt's
cannot_assessescape hatch is deliberately narrow: surface condition genuinely can't be separated from dust/scratches on a toploader/slab's plastic, so it keeps the escape hatch. Corner and edge geometry (sharpness, whitening, chipping) does read through clear plastic and must be judged normally — an earlier prompt version gave the model an escape hatch for corners/edges too, which caused false "cannot tell from photo" results on cards that were plainly visible through the holder. Don't reintroduce that. - No pre-flight/"precheck" step before a paid grading call. This was tried and explicitly rejected by the user — their workflow is screenshots that can't be pulled out of the toploader, so a pre-check step doesn't fit and was reverted.
- No browser automation (Playwright or otherwise) to scrape eBay listings.
Tried a plain
curl-based fetch once (not Playwright) and got an immediate 403; iterating on headers/fingerprinting to get past that would be bot-detection evasion regardless of which tool performs it, and is out of scope. This feature is paused; if revisited, the legitimate path is a client-side bookmarklet/extension that uses the user's own authenticated browser session rather than a server-side fetch. - Never trust the reverse proxy's forwarded auth header for anything beyond attribution (whose name to log against a grade) — access control is the proxy's job, not the app's.