Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
- Fade-up entrance animations, sticky frosted header, sticky search bar
- Alert form: toggle between Price Drop and Arbitrage Spread types
- Developers nav link in dashboard header
- AI grading: corner crop preprocessing via sharp (8 magnified crops from front+back for corners subgrade)
- AI grading: all listing images passed to centering/edges/surface (corners uses front+back + crops only)
- eBay image resolution upgrade: s-l500 (500px) to s-l1600 (full resolution)

### Changed
- Dashboard UI synced with casecomp.xyz frontend: Inter Tight + JetBrains Mono fonts, pill-style tabs/hints, ghost view button
Expand All @@ -40,7 +43,9 @@
- Card identity: cleaned up long names (strips pack names, condition text from titles)
- track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults
- Demo condition filter: checks detectedCondition in addition to raw condition field
- Tests: 208 total (92 unit + 76 API + 40 smoke), up from 183
- Tests: 214 total (98 unit + 76 API + 40 smoke), up from 183
- AI grading prompts: full PSA rubric (5-10), perspective correction, per-corner/edge detail, holo-specific surface guidance
- Demo grades re-evaluated with improved prompts (more conservative scores, honest confidence)
- Removed dead code: Redis import from api.js, updateCardField from card-identity.js

## 1.0.0-beta.1 (2026-05-10)
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ lib/
tcgplayer.js TCGPlayer price seeding
grading/
grading.js AI pre-grading (per-subgrade, Claude/OpenAI)
preprocessing.js Corner crop extraction via sharp
psa.js PSA pop reports, cert lookup, grading signal
psaTiers.js PSA submission tier data
data/
Expand All @@ -85,7 +86,7 @@ public/admin/ Admin dashboard (keys, stats, errors)
extension/ Chrome extension: queue auto-join, drop intel
terraform/ GCP infra: Cloud Run ×2, Firestore, LB + CDN, Secret Manager
test/
unit-test.js 92 unit tests
unit-test.js 98 unit tests
api-test.js 76 API integration tests
smoke-test.js 40 Playwright smoke tests (dashboard UI)
```
Expand Down Expand Up @@ -233,7 +234,7 @@ Load unpacked from `extension/` in `chrome://extensions`.

## Tests

208 tests: 92 unit (filters, grading, query builder, card identity, condition detection, demo data, resolveCardIdToQuery, findDemoByNumber) + 76 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport).
214 tests: 98 unit (filters, grading, query builder, card identity, condition detection, demo data, resolveCardIdToQuery, findDemoByNumber, image preprocessing, image resolution) + 76 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, alerts, share pages, demo validation) + 40 Playwright smoke (dashboard UI, detail panel, tabs, PSA stats, arbitrage, mobile viewport).

## Contributing

Expand Down
3 changes: 1 addition & 2 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,7 @@ async function storeGradeLog(record) {
async function gradeItems(items, config, cardName, source) {
return Promise.all(items.map(async (row) => {
try {
const backImg = (row.additionalImages || [])[0];
const extraImages = backImg ? [backImg] : [];
const extraImages = row.additionalImages || [];
const g = await gradeImage(row.imageUrl, config, extraImages);
if (g && !g.error) {
await storeGradeLog({
Expand Down
3 changes: 1 addition & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,7 @@ async function gradeItems(items, config, counters) {
const results = await Promise.all(
items.map(async (row) => {
try {
const backImg = (row.additionalImages || [])[0];
const extraImages = backImg ? [backImg] : [];
const extraImages = row.additionalImages || [];
const g = await gradeImage(row.imageUrl, config, extraImages);
return { row, g, err: null };
} catch (e) {
Expand Down
100 changes: 50 additions & 50 deletions lib/data/demo.js

Large diffs are not rendered by default.

157 changes: 132 additions & 25 deletions lib/grading/grading.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,135 @@
import axios from "axios";
import { sha256 } from "../data/cache.js";
import { cacheGet, cacheSet } from "../data/firestore.js";
import { cropCorners, cornerCropsToImageBlocks } from "./preprocessing.js";

const CACHE_COLLECTION = "cache-grades";
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;

const SUBGRADE_PROMPTS = {
centering: `Grade ONLY the centering of this Pokémon trading card photo. Measure how even the borders are on all sides. PSA 10 requires 55/45 or better front, 75/25 or better back. Look at left vs right and top vs bottom border widths. Be precise — centering is measurable, not subjective.
centering: `Grade ONLY the centering of this Pokémon trading card photo.

PERSPECTIVE CORRECTION: Listing photos are rarely taken perfectly flat. Before assessing centering, identify the camera angle from the card's shape — if edges converge (trapezoid instead of rectangle), the card is tilted. Mentally project the card to a flat top-down view before comparing borders. Do NOT penalize centering for perspective distortion caused by camera angle.

TECHNIQUE: Describe what you observe rather than computing exact ratios. Compare left vs right borders, then top vs bottom borders. Note which direction any shift goes (e.g. "shifted slightly left" or "heavier bottom border").

PSA CENTERING THRESHOLDS (front / back):
- 10 (Gem Mint): 55/45 or better front, 75/25 or better back
- 9 (Mint): 60/40 front, 90/10 back
- 8 (NM-MT): 65/35 front, 90/10 back
- 7 (NM): 70/30 front, 90/10 back
- 6 (EX-MT): 80/20 front, 90/10 back

SCORING GUIDE:
- 10: Borders appear equal on all sides within normal printing tolerance
- 9: Slight shift in one direction, barely noticeable without close inspection
- 8: Noticeable shift — one border clearly wider than its opposite
- 7: Obvious shift — one border roughly 2x its opposite
- 6 or below: Severe shift visible at a glance

If the photo angle is steep or the card is heavily tilted, set confidence below 0.5 — precise centering cannot be reliably assessed from angled photos.

Respond ONLY with valid JSON (no markdown):
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: border ratio observation>"}`,
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: which borders are uneven and by how much, note if angle limits reliability>"}`,

corners: `Grade ONLY the corners of this Pokémon trading card photo. Examine each of the 4 corners individually: top-left, top-right, bottom-left, bottom-right.

WHAT TO LOOK FOR per corner:
- Whitening: white fibers visible along the corner edge (most common defect)
- Rounding: corner lost its sharp point, appears soft or curved
- Dings/dents: physical impact marks, often small indentations
- Lifting: layers of cardstock separating at the corner
- Fuzzing: frayed edge fibers at the corner point

corners: `Grade ONLY the corners of this Pokémon trading card photo. Check all 4 corners for whitening, rounding, dings, or softness. PSA 10 corners must be sharp with no visible wear. If no close-up is available, note reduced confidence but still estimate.
PSA CORNER THRESHOLDS:
- 10 (Gem Mint): All 4 corners sharp and clean, no whitening under magnification
- 9 (Mint): Corners sharp, may have one tiny spot of whitening only visible under magnification
- 8 (NM-MT): Minor whitening on 1-2 corners, still sharp points
- 7 (NM): Slight whitening or softness on 2-3 corners
- 6 (EX-MT): Noticeable whitening or slight rounding on multiple corners
- 5 (EX): Moderate whitening, possible rounding on 1+ corners

DARK-BORDERED CARDS: Whitening is more visible and more harshly penalized on dark/black borders. Grade these more critically.

If the photo lacks close-up detail, note which corners you can and cannot assess clearly, and set confidence accordingly.

Respond ONLY with valid JSON (no markdown):
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: corner condition>"}`,
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: condition of worst corner(s), name which corners are affected>"}`,

edges: `Grade ONLY the edges of this Pokémon trading card photo. Examine each of the 4 edges individually: top, bottom, left, right.

WHAT TO LOOK FOR per edge:
- Whitening: white line along the edge where color has worn away
- Chipping: small chips or flakes missing from the edge
- Nicks: tiny cuts or indentations along the edge
- Roughness: uneven or jagged edge surface
- Peeling: cardstock layers separating along the edge

PSA EDGE THRESHOLDS:
- 10 (Gem Mint): All edges clean and smooth, no whitening or wear
- 9 (Mint): Edges clean, one minor spot of whitening only visible under magnification
- 8 (NM-MT): Minor whitening on 1-2 edges, no chipping
- 7 (NM): Light whitening along 2+ edges, or one small chip
- 6 (EX-MT): Noticeable whitening on multiple edges, minor chipping possible
- 5 (EX): Moderate whitening, possible chipping on 1+ edges

DARK-BORDERED CARDS: Edge whitening is far more visible against dark/black card borders. Apply stricter standards.

edges: `Grade ONLY the edges of this Pokémon trading card photo. Check all 4 edges for whitening, chipping, nicks, or roughness. PSA 10 edges must be clean with no visible wear. If no close-up is available, note reduced confidence but still estimate.
BACK EDGES: If a back image is provided, back edges often show more wear than the front — check carefully.

If the photo lacks close-up detail, note which edges you can assess and set confidence accordingly.

Respond ONLY with valid JSON (no markdown):
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: edge condition>"}`,
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: condition of worst edge(s), name which edges are affected>"}`,

surface: `Grade ONLY the surface of this Pokémon trading card photo. Assess the entire printable area of the card.

WHAT TO LOOK FOR:
- Scratches: linear marks across the surface, often visible when light catches them
- Print lines: factory printing defects, thin lines running through the card
- Ink spots/blotches: spots of excess ink or missing ink
- Dents/indentations: depressions in the cardstock visible as shadows
- Holo wear/scratching: wear patterns on holographic/foil areas
- Surface contamination: fingerprints, residue, sticker marks, or foreign material
- Creasing: any crease, even minor, severely limits grade (PSA 5 max for crease <1 inch)

surface: `Grade ONLY the surface of this Pokémon trading card photo. Check for scratches, dents, print lines, ink spots, holo wear, or surface contamination. PSA 10 surface must be free of visible defects. Holo patterns can mask flaws — note if this limits your assessment.
PSA SURFACE THRESHOLDS:
- 10 (Gem Mint): Surface immaculate, no defects visible even under magnification
- 9 (Mint): Surface clean, one minor print imperfection allowed if not immediately noticeable
- 8 (NM-MT): Minor surface wear or one small print line, no scratches
- 7 (NM): Light surface wear, minor print defects, or one faint scratch
- 6 (EX-MT): Noticeable surface wear, light scratches, or print defects
- 5 (EX): Moderate scratches, print defects, or minor surface damage

HOLOGRAPHIC/FOIL CARDS: Holo surfaces reflect light differently at various angles, which can both mask and reveal scratches. Note when holo patterns limit your assessment — glare in the photo may hide real scratches, so lower confidence if holo area is washed out or reflective. Do NOT assume a clean surface just because glare obscures it.

PHOTO QUALITY: Listing photos are often low-resolution or poorly lit. Surface defects are the hardest to detect from photos. If the image quality prevents confident surface assessment, set confidence below 0.5.

Respond ONLY with valid JSON (no markdown):
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: surface condition>"}`,
{"score": <number 1-10>, "confidence": <number 0-1>, "detail": "<one sentence: specific defects found or clean assessment, note if holo/glare limits visibility>"}`,
};

const GRADING_PROMPT = `You are estimating the PSA grade for a Pokémon trading card based on listing photos. You may receive 1-2 images (front, back, or both). PSA grades cards 1-10 on:
- CENTERING: borders should be even on all sides (PSA 10 ≈ 55/45 or better). Check both front AND back if available — back centering is often the grade limiter.
- CORNERS: should be sharp, no whitening or rounding
- EDGES: should be clean, no whitening or chipping
- SURFACE: no scratches, dents, print defects, or holo wear
const GRADING_PROMPT = `You are estimating the PSA grade for a Pokémon trading card based on listing photos. You may receive 1-2 images (front, back, or both).

PERSPECTIVE CORRECTION: Listing photos are rarely taken flat. If the card appears as a trapezoid rather than a rectangle, mentally project it to a top-down view before assessing centering. Do NOT penalize centering for camera-angle distortion.

Be conservative. eBay listing photos are often poor quality, glare-heavy, or hide defects. When uncertain, grade lower and note low confidence. Most listed raw cards grade between PSA 6-9; PSA 10 is rare.
PSA GRADE SCALE:
- 10 (Gem Mint): Virtually perfect. Centering 55/45+, sharp corners, clean edges, flawless surface.
- 9 (Mint): One minor flaw. Centering 60/40, one tiny whitening spot, or one faint print line.
- 8 (NM-MT): Minor wear. Slight centering shift, whitening on 1-2 corners, minor edge wear.
- 7 (NM): Noticeable wear. Off-center, whitening on 2-3 corners, light edge/surface wear.
- 6 (EX-MT): Moderate wear across multiple attributes.
- 5 (EX) or below: Significant wear, possible creases, heavy whitening.

If only front/back full shots are provided (no corner/edge close-ups), you CAN still grade centering and surface reliably. For corners and edges, grade what you can see but note reduced confidence. Do NOT refuse to grade — give your best estimate.
SUBGRADE GUIDANCE:
- CENTERING: Describe border asymmetry rather than computing exact ratios. Check front AND back — back centering is often the grade limiter.
- CORNERS: Check all 4 individually. Dark-bordered cards show whitening more — grade stricter.
- EDGES: Check all 4 individually. Back edges often worse than front.
- SURFACE: Holo/foil cards may hide scratches under glare — do NOT assume clean when glare obscures. Creases cap at PSA 5.

Be conservative. Most listed raw cards grade PSA 6-9; PSA 10 is rare. When uncertain, grade lower and note low confidence.

If only full shots are provided (no close-ups), you CAN still grade centering and surface reliably. For corners and edges, grade what you can see but lower confidence. Do NOT refuse to grade — give your best estimate.

Respond ONLY with valid JSON in this exact shape (no markdown, no prose):
{
Expand All @@ -44,9 +138,9 @@ Respond ONLY with valid JSON in this exact shape (no markdown, no prose):
"corners": <number 1-10>,
"edges": <number 1-10>,
"surface": <number 1-10>,
"confidence": <number 0-1, lower if photo is bad or missing close-ups>,
"confidence": <number 0-1, lower if photo is bad, angled, or missing close-ups>,
"notes": "<one sentence: main grade-limiting factor>",
"limitations": "<which sub-grades lack close-up detail, or empty string if all are well-covered>"
"limitations": "<which sub-grades lack close-up detail or are affected by photo angle, or empty string>"
}`;

let lastLlmAt = 0;
Expand Down Expand Up @@ -374,15 +468,15 @@ export async function gradeViaSite(imageUrl, config) {
}
}

async function gradeSubgrade(subgrade, imageUrls, config) {
async function gradeSubgrade(subgrade, imageUrls, config, extraBlocks = []) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return null;
await throttleLlm();
const imageBlocks = imageUrls.filter(Boolean).map(url => ({ type: "image", source: { type: "url", url } }));
const body = {
model: config.aiGrading.llm.model,
max_tokens: 200,
messages: [{ role: "user", content: [...imageBlocks, { type: "text", text: SUBGRADE_PROMPTS[subgrade] }] }],
messages: [{ role: "user", content: [...imageBlocks, ...extraBlocks, { type: "text", text: SUBGRADE_PROMPTS[subgrade] }] }],
};
const res = await withLlm429Backoff(() =>
axios.post("https://api.anthropic.com/v1/messages", body, {
Expand All @@ -396,14 +490,26 @@ async function gradeSubgrade(subgrade, imageUrls, config) {
return { score: clampSub(parsed.ok.score), confidence: clampConf(parsed.ok.confidence), detail: parsed.ok.detail || "" };
}

export async function gradeDetailedLLM(frontUrl, backUrl, config) {
const frontImages = [frontUrl].filter(Boolean);
const backImages = [backUrl].filter(Boolean);
const allImages = [...frontImages, ...backImages];
export async function gradeDetailedLLM(frontUrl, backUrl, config, extraImages = []) {
const known = new Set([frontUrl, backUrl].filter(Boolean));
const deduped = extraImages
.map(e => (typeof e === "string" ? e : e?.imageUrl))
.filter(u => u && !known.has(u));
const allImages = [frontUrl, backUrl, ...deduped].filter(Boolean);

let cornerBlocks = [];
try {
const cropJobs = [frontUrl, backUrl].filter(Boolean).map(url => cropCorners(url));
const allCrops = (await Promise.all(cropJobs)).flat();
cornerBlocks = cornerCropsToImageBlocks(allCrops);
} catch (e) {
console.warn(`[grade] corner crop failed, using full images: ${e.message || e}`);
}

const primaryImages = [frontUrl, backUrl].filter(Boolean);
const [centering, corners, edges, surface] = await Promise.all([
gradeSubgrade("centering", allImages, config),
gradeSubgrade("corners", allImages, config),
gradeSubgrade("corners", primaryImages, config, cornerBlocks),
gradeSubgrade("edges", allImages, config),
gradeSubgrade("surface", allImages, config),
]);
Expand Down Expand Up @@ -455,8 +561,9 @@ export async function gradeImage(imageUrl, config, extraImages = []) {
try {
if (config.aiGrading.mode === "llm") {
const backImg = extraImages[0]?.imageUrl || extraImages[0] || null;
const remainingExtras = extraImages.slice(1);
if (backImg && config.aiGrading.llm.provider === "claude") {
result = await gradeDetailedLLM(imageUrl, backImg, config);
result = await gradeDetailedLLM(imageUrl, backImg, config, remainingExtras);
}
if (!result) {
result = await gradeViaLLM(imageUrl, config, extraImages);
Expand Down
53 changes: 53 additions & 0 deletions lib/grading/preprocessing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import sharp from "sharp";
import axios from "axios";

const CORNER_RATIO = 0.20;

export async function cropCorners(imageUrl) {
const res = await axios.get(imageUrl, {
responseType: "arraybuffer",
timeout: 15_000,
maxRedirects: 5,
});

const img = sharp(Buffer.from(res.data));
const { width, height } = await img.metadata();
if (!width || !height) throw new Error("could not read image dimensions");

const cw = Math.round(width * CORNER_RATIO);
const ch = Math.round(height * CORNER_RATIO);

const regions = [
{ name: "top-left", left: 0, top: 0 },
{ name: "top-right", left: width - cw, top: 0 },
{ name: "bottom-left", left: 0, top: height - ch },
{ name: "bottom-right", left: width - cw, top: height - ch },
];

const crops = await Promise.all(
regions.map(async (r) => {
const buf = await sharp(Buffer.from(res.data))
.extract({ left: r.left, top: r.top, width: cw, height: ch })
.jpeg({ quality: 90 })
.toBuffer();
return {
name: r.name,
base64: buf.toString("base64"),
mediaType: "image/jpeg",
};
}),
);

return crops;
}

export function cornerCropsToImageBlocks(crops) {
return crops.map((c) => ({
type: "image",
source: {
type: "base64",
media_type: c.mediaType,
data: c.base64,
},
}));
}
Loading