DEV Community

Cover image for I built a Pokémon card grader
Mike Printz
Mike Printz

Posted on Edited on

I built a Pokémon card grader

Grading a Pokémon card at PSA costs about $65 to $85 once you add shipping, and it takes months. A lot of cards come back worth less than that. So before sending anything off, collectors squint at their cards, guess at the centering, look up a few sold listings and hope.

I wanted that guess to be a measurement. The result is Should I Grade It?: you photograph a card, it estimates the PSA grade, finds what the card sells for raw and at that grade, and tells you whether grading pays after fees.

This post covers how it works, and more usefully, the things that broke once real people and real crawlers showed up.

The stack

  • The app is one HTML file with no framework and no build step, about 4,000 lines. All the image measurement happens in the browser with canvas, so photos never have to leave the phone to be measured.
  • A Cloudflare Worker handles everything that needs a server: card identification, prices, accounts, Stripe and 25,000 server-rendered price pages.
  • Workers KV stores the card catalog, refreshed nightly by a cron trigger.
  • Data: CardSight AI identifies the card from the photo, TCGCSV mirrors TCGplayer's catalog and market prices, and PokemonPriceTracker supplies recent eBay sales of graded copies.
  • Static pages are on Cloudflare Pages.

Measuring centering with canvas

Centering is the one part of a grade you can actually measure from a photo, and it caps everything else. PSA publishes the limits: a 10 needs the front borders within 55/45, a 9 within 60/40, and so on down the scale.

// PSA's front centering limits: [the larger side of the split, the best grade it allows]
const PSA_CENTER = [[55,10],[60,9],[65,8],[70,7],[75,6],[80,5],[85,4],[90,3]];

function centeringCap(left, right) {
  const big = Math.max(left, right) / (left + right) * 100;   // 55.4 for a 55.4/44.6 split
  for (const [limit, grade] of PSA_CENTER) if (big <= limit) return grade;
  return 2;
}
Enter fullscreen mode Exit fullscreen mode

The hard part is getting left and right from a phone photo. Most vintage cards have a yellow border, which makes a good target: convert to HSV, keep the pixels in the yellow hue band, take the largest shape, fit four corners and warp the card flat. Then walk inward from each edge until the yellow stops and the artwork box starts. Those four distances are the borders.

Phone photos fight you at every step. Glare washes out the border, sleeves add a second edge, and a card shot at a slight angle reads as off-center when it isn't. I spent more time on rejecting bad photos than on measuring good ones.

Don't trust your own measurement

An automatic measurement that's 2% off can be the difference between a 9 and a 10, so I added an inspector: the straightened card at full resolution, with four draggable lines placed where the app measured. You can zoom to 8x, run a magnifier along the edge, and switch to an edge-detection filter that makes print lines and whitening jump out. Drag a line and the split and grade update live.

It worked perfectly on the standalone centering tool page. In the app, it opened to a black screen, and my test suite passed anyway, because the test only checked that the overlay opened with the right title.

The cause was one line of CSS. The app processes photos on hidden canvases, so its stylesheet had:

canvas { display: none; }
Enter fullscreen mode Exit fullscreen mode

which also hid the inspector's canvas, the one thing on that screen meant to be seen. The fix was a single display: block !important in the inspector's own styles. The lesson cost more: my test now screenshots the inspector and checks that the middle of the screen is actually showing a card.

The day KV ran out

The day after I launched the card price pages, Cloudflare emailed: I'd used the free tier's 100,000 KV reads for the day, and every read would fail until midnight UTC.

I counted reads per request in a test harness. Each card page cost 19 reads: the set list, the catalog state, the set's cards, two months of price history, and then the same card number in up to 12 other sets for an "also printed in" section. Google and Bing were crawling thousands of pages a day from the sitemap.

The fix was to stop re-reading data that every request needs. A Worker isolate stays warm between requests, so a small in-memory cache with a short lifetime goes a long way:

const MEMO = new Map(), MEMO_MAX = 60;

async function kvJSON(env, key, ttlMs = 600000) {
  const m = MEMO.get(key);
  if (m && m.exp > Date.now()) return m.v;
  const raw = await env.CARDS.get(key, { cacheTtl: 3600 });
  const v = raw ? JSON.parse(raw) : null;
  MEMO.set(key, { v, exp: Date.now() + ttlMs });
  if (MEMO.size > MEMO_MAX) MEMO.delete(MEMO.keys().next().value);   // oldest first
  return v;
}
Enter fullscreen mode Exit fullscreen mode

cacheTtl doesn't save you here: a read served from Cloudflare's cache still counts against the KV allowance. The memo does. I also dropped the other-sets lookup to links built from the set list already in memory, and put the homepage's live data behind the Workers Cache API for an hour.

Before After
One crawled card page 19 reads 1.1 reads
20 homepage visits about 80 reads 3 reads

Then I upgraded to the $5 Workers Paid plan anyway. Ten million reads a month is cheap insurance, and the efficiency work means I'll rarely touch it.

eBay says no

Lots of people decide whether to buy a card from an eBay listing, so I added "paste a listing link": the Worker fetches the page, pulls the photo URLs and sorts them into front and back.

eBay answers a Worker with a 403. The photos themselves download fine from eBay's image CDN; it's the listing page that's protected. I could have routed around the block through a third-party scraping proxy, but eBay blocks automated readers deliberately, and anything built on getting past that breaks without warning.

The visitor's own browser, though, is already looking at the listing. So the answer was a bookmarklet: drag "Grade this listing" to the bookmarks bar, click it on any listing, and it reads the photo URLs from the page you're viewing and opens the app with them.

The first version grabbed 88 photos from a page whose listing had ended, because eBay had redirected to a page of other sellers' items. The fix was to read only the listing's own gallery and refuse anything that isn't a single listing:

const onEbay = /(^|\.)ebay\.[a-z.]{2,8}$/i.test(location.hostname);
if (onEbay && !/\/itm\//.test(location.pathname)) {
  alert("Open a single eBay listing first, then click this again.");
  return;
}
// only the listing's own gallery, never similar items or recommendations
const scope = document.querySelector('[data-testid="x-photos"], #PicturePanel, .ux-image-carousel-container') || document;
Enter fullscreen mode Exit fullscreen mode

On a live listing that took exactly the 12 gallery photos and skipped the other 30 eBay images on the page.

A memory leak hiding in plain sight

Every time the app read a photo, to sort it, measure it or draw a thumbnail, it did this:

img.src = URL.createObjectURL(file);
Enter fullscreen mode Exit fullscreen mode

and never called revokeObjectURL. Each call pins the whole file in memory until the page closes. A phone photo is 3 to 5 MB, a scan reads each photo several times, and thumbnails were recreated on every redraw. Scanning one 12-photo listing left 44 copies in memory, which is how you get Safari quietly reloading the tab on an iPhone.

const u = URL.createObjectURL(file);
img.onload = () => { URL.revokeObjectURL(u); resolve(img); };   // decoded, so the copy can go
img.src = u;
Enter fullscreen mode Exit fullscreen mode

Thumbnails now reuse one URL per file from a WeakMap. Same 12-photo scan: 12 copies held, one per thumbnail, all of them needed.

25,000 pages and what Google thinks of them

Every card in the catalog gets a server-rendered price page from the Worker: the raw price, a 30-day sparkline drawn as inline SVG, graded values and printings. Soon after launch, Search Console said 811 indexed and 24,100 not indexed. Opening each reason showed which parts were fixable:

  • 22,304 "discovered, not indexed". Google found them in the sitemap and hasn't visited yet. That's the crawl budget of a new domain, and only time and links fix it.
  • 451 "page with redirect". Not card pages at all: every card page linked to /app?q=..., so Google was crawling thousands of variations of one app page. Those links are now rel="nofollow" and robots.txt disallows /app?.
  • 1,274 "crawled, not indexed". Mostly 25-cent commons with near-identical pages, some with broken images for cards that have no photo.

So the sitemap now lists only cards worth a dollar or more, pages for cards without a price ask not to be indexed, missing photos get a placeholder instead of a broken image, and each page gets an "at a glance" section of facts unique to that card: its rank in the set, its 30-day range, and its most valuable printing.

Testing a framework-free app

There's no framework, so there's no framework test runner either. What I use:

  • Plain Playwright scripts for each feature, with the Worker mocked at the network layer, about 140 checks in all. Each runs at phone and desktop sizes and fails on any page error.
  • TypeScript as a linter for plain JavaScript. tsc --allowJs --checkJs --noUnusedLocals over the extracted scripts, filtered to the errors that matter: undefined names, duplicate declarations, unused code. It found nine pieces of dead code, including a call to the most expensive function in the app whose result was thrown away on every photo.
  • The real security headers in tests. A small local server applies the same _headers file Cloudflare Pages uses, so a Content Security Policy that blocks something fails a test rather than a user.

Where it is now

It's live at shouldigradeit.app, free for 7 scans a month. The centering tool and grading calculator are free with no account. PSA reopened its cheaper Value tier this week, which meant updating the grading-cost maths from one flat fee to the tier each card's value requires, since a $5,000 card can't go through a tier capped at $500.

If you've worked on measuring things from phone photos, I'd love to hear how you handled glare and perspective. And if you collect, tell me what it gets wrong.

Top comments (0)