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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ images/**/*.spdx.json
.DS_Store
*.swp
*.swo
coverage/
64 changes: 49 additions & 15 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { searchSnkrdunk } from "./lib/sources/snkrdunk.js";
import { searchMagi } from "./lib/sources/magi.js";
import { searchYahooAuctions } from "./lib/sources/yahooauctions.js";
import { getPsaGradingSignal } from "./lib/grading/psa.js";
import { gradeImage } from "./lib/grading/grading.js";
import { gradeImage, medianGrade } from "./lib/grading/grading.js";
import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers, filterRelevantResults, isGradedCard } from "./lib/search/filters.js";
import { buildEbaySearchQuery } from "./lib/search/listingQuery.js";
import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js";
Expand Down Expand Up @@ -438,19 +438,39 @@ app.get("/api/psa", apiAuthMiddleware, (req, res, next) => { req._errorType = "p

// POST /api/grade
app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "grade"; next(); }, async (req, res) => {
const { imageUrl, extraImages, provider, model, cardName, cardId, source, listingId, listingPrice, condition, centeringHint } = req.body;
const { imageUrl, extraImages, provider, model, cardName, cardId, source, listingId, listingPrice, condition, centeringHint, passes: rawPasses } = req.body;
if (!imageUrl) return res.status(400).json({ error: "Missing required field: imageUrl" });
const passes = Math.min(3, Math.max(1, Number(rawPasses) || 1));
try {
const config = {
aiGrading: {
enabled: true,
mode: "llm",
llm: { provider: provider || "claude", model: model || "claude-opus-4-7", maxTokens: 500 },
cacheGrades: true,
},
};
const extras = (extraImages || []).map(u => ({ imageUrl: u }));
const grade = await gradeImage(imageUrl, config, extras, centeringHint);

let grade;
if (passes > 1) {
const results = [];
for (let i = 0; i < passes; i++) {
const config = {
aiGrading: {
enabled: true,
mode: "llm",
llm: { provider: provider || "claude", model: model || "claude-opus-4-7", maxTokens: 500 },
cacheGrades: false,
},
};
const r = await gradeImage(imageUrl, config, extras, centeringHint);
if (r && !r.error) results.push(r);
}
grade = results.length ? medianGrade(results) : { error: "All passes failed" };
} else {
const config = {
aiGrading: {
enabled: true,
mode: "llm",
llm: { provider: provider || "claude", model: model || "claude-opus-4-7", maxTokens: 500 },
cacheGrades: true,
},
};
grade = await gradeImage(imageUrl, config, extras, centeringHint);
}

let gradeId = null;
if (grade && !grade.error) {
Expand All @@ -464,12 +484,17 @@ app.post("/api/grade", authMiddleware, (req, res, next) => { req._errorType = "g
listingId: listingId || null,
imageUrl,
extraImages: extraImages || [],
provider: config.aiGrading.llm.provider,
model: config.aiGrading.llm.model,
provider: (provider || "claude"),
model: (model || "claude-opus-4-7"),
grade,
listingPrice: listingPrice || null,
condition: condition || null,
});
if (passes === 1) {
const { cacheGrade } = await import("./lib/grading/grading.js");
const cacheConfig = { aiGrading: { mode: "llm", llm: { provider: provider || "claude", model: model || "claude-opus-4-7" }, cacheGrades: true } };
await cacheGrade(imageUrl, cacheConfig, grade).catch(() => {});
}
}

res.json({ grade, gradeId, stored: !!(grade && !grade.error) });
Expand Down Expand Up @@ -2007,7 +2032,16 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => {
alertCards = [...new Set(alerts.map(a => a.query).filter(Boolean))];
} catch {}

const cards = req.body?.cards || [...new Set([...defaultCards, ...alertCards])];
let portfolioQueries = [];
try {
const userIds = await listPortfolioUserIds();
for (const uid of userIds.slice(0, 100)) {
const pCards = await getPortfolio(uid);
portfolioQueries.push(...pCards.map(c => c.query).filter(Boolean));
}
} catch {}

const cards = req.body?.cards || [...new Set([...defaultCards, ...alertCards, ...portfolioQueries])];
const hasEbay = !!(clientId && clientSecret);
const results = [];
for (const card of cards) {
Expand Down Expand Up @@ -2138,7 +2172,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => {
}

refreshCardDatabase().catch(() => {});
res.json({ tracked: results.length, results, portfolioSnapshots, portfolioWarmed, frequencyWarmed });
res.json({ tracked: results.length, results, portfolioSnapshots, portfolioWarmed, portfolioCardsTracked: portfolioQueries.length, frequencyWarmed });
});

// GET /api/arbitrage — cross-source price comparison for a card
Expand Down
74 changes: 74 additions & 0 deletions lib/grading/grading.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,11 @@
const m = String(imageUrl).match(EBAY_SIZE_RE);
if (m) return parseInt(m[1], 10);
try {
const res = await axios.head(imageUrl, {
timeout: 10_000,
maxRedirects: 5,
validateStatus: () => true,
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
const wh =
res.headers["x-image-width"] ||
res.headers["image-width"] ||
Expand Down Expand Up @@ -599,6 +599,80 @@
return result;
}

function median(arr) {
const s = [...arr].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}

function stddev(arr) {
const avg = arr.reduce((a, b) => a + b, 0) / arr.length;
return Math.sqrt(arr.reduce((s, v) => s + (v - avg) ** 2, 0) / arr.length);
}

const SUBGRADE_KEYS = ["centering_front", "centering_back", "corners_front", "corners_back", "edges_front", "edges_back", "surface_front", "surface_back"];

export function medianGrade(grades) {
if (!grades.length) return null;
if (grades.length === 1) return grades[0];

const medianScores = {};
const consistency = {};
for (const key of SUBGRADE_KEYS) {
const scores = grades.map(g => g.subgradeDetails?.[key]?.score).filter(s => s != null);
if (scores.length) {
medianScores[key] = median(scores);
consistency[key] = Math.round(stddev(scores) * 100) / 100;
}
}

const frontKeys = ["centering_front", "corners_front", "edges_front", "surface_front"];
const backKeys = ["centering_back", "corners_back", "edges_back", "surface_back"];
const frontAvg = frontKeys.reduce((s, k) => s + (medianScores[k] || 0), 0) / frontKeys.length;
const backAvg = backKeys.reduce((s, k) => s + (medianScores[k] || 0), 0) / backKeys.length;
const rawOverall = frontAvg * 0.6 + backAvg * 0.4;
const lowestScore = Math.min(...Object.values(medianScores));
const overall = roundGrade(Math.min(rawOverall, lowestScore + 1));

const avgConf = grades.reduce((s, g) => s + (g.confidence || 0), 0) / grades.length;
const totalInput = grades.reduce((s, g) => s + (g.tokenUsage?.input || 0), 0);
const totalOutput = grades.reduce((s, g) => s + (g.tokenUsage?.output || 0), 0);

const subgradeDetails = {};
for (const key of SUBGRADE_KEYS) {
const details = grades.map(g => g.subgradeDetails?.[key]).filter(Boolean);
if (details.length) {
const medScore = medianScores[key];
const closest = details.reduce((a, b) => Math.abs(b.score - medScore) < Math.abs(a.score - medScore) ? b : a);
subgradeDetails[key] = { ...closest, score: medScore };
}
}

const lowestKey = Object.entries(medianScores).sort((a, b) => a[1] - b[1])[0];

return {
provider: grades[0].provider,
mode: "llm-detailed-v3",
overall,
frontOverall: roundGrade(frontAvg),
backOverall: roundGrade(backAvg),
centering: Math.min(medianScores.centering_front || 0, medianScores.centering_back || 0),
corners: Math.min(medianScores.corners_front || 0, medianScores.corners_back || 0),
edges: Math.min(medianScores.edges_front || 0, medianScores.edges_back || 0),
surface: Math.min(medianScores.surface_front || 0, medianScores.surface_back || 0),
confidence: clampConf(avgConf),
notes: `Median of ${grades.length} passes. Grade limiter: ${lowestKey?.[0]} (${lowestKey?.[1]})`,
limitations: grades[0].limitations || "",
subgradeDetails,
gradeDistribution: computeGradeDistribution(overall, clampConf(avgConf)),
cardDetection: grades[0].cardDetection,
tokenUsage: { input: totalInput, output: totalOutput },
estimatedCost: (totalInput * 3 + totalOutput * 15) / 1_000_000,
passes: grades.length,
consistency,
};
}

export function computeGradeDistribution(overall, confidence) {
const grades = [10, 9.5, 9, 8.5, 8, 7.5, 7, 6.5, 6, 5.5, 5];
const idx = grades.indexOf(overall);
Expand Down
2 changes: 1 addition & 1 deletion lib/grading/psa.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function tokenize(str) {
}

// ── API response parsing ─────────────────────────────────────────────
function parseSpecPopItem(json) {
export function parseSpecPopItem(json) {
const pop = json?.PSAPop;
if (!pop) return null;
return { pop10: pop.Grade10 ?? null, pop9: pop.Grade9 ?? null, popTotal: pop.Total ?? null };
Expand Down
6 changes: 3 additions & 3 deletions lib/sources/ebay.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ function upgradeEbayImageRes(url) {
return url.replace(/\/s-l\d+\.(jpg|png|webp)/gi, "/s-l1600.$1");
}

function normalizeBrowseItem(item) {
export function normalizeBrowseItem(item) {
const priceVal = parseFloat(item.price?.value ?? "NaN");
const currency = item.price?.currency ?? "USD";
let shippingCost = 0;
Expand Down Expand Up @@ -332,7 +332,7 @@ function normalizeBrowseItem(item) {
};
}

function insightsToSold(entry) {
export function insightsToSold(entry) {
const price =
parseFloat(
entry.lastSoldPrice?.value ??
Expand Down Expand Up @@ -753,7 +753,7 @@ function sanitizeScrapedListingTitle(title) {
.trim();
}

function parseSoldTilesFromHtml(html) {
export function parseSoldTilesFromHtml(html) {
const $ = cheerio.load(html);
const items = [];

Expand Down
4 changes: 2 additions & 2 deletions lib/sources/magi.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,12 @@ function magiUrl(keyword, { status = "presented", sort = "price_asc", page = 1 }
return `${MAGI_BASE}?${p}`;
}

function parseJPY(text) {
export function parseJPY(text) {
const m = text?.match(/¥\s*([\d,]+)/);
return m ? parseInt(m[1].replace(/,/g, ""), 10) : null;
}

function gradeFromTitle(title) {
export function gradeFromTitle(title) {
const m1 = title?.match(/【\s*([A-Za-z]+)\s*(\d+(?:\.\d+)?)/);
if (m1) return `${m1[1].toUpperCase()} ${m1[2]}`;
const m2 = title?.match(/\b(PSA|BGS|CGC|TAG|SGC|HGA|ACE)\s?(\d+(?:\.\d+)?)\b/i);
Expand Down
2 changes: 1 addition & 1 deletion lib/sources/snkrdunk.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ async function fetchListingImages(listingUID) {
try { return JSON.parse(`{${m[0]}}`).imageUrls; } catch { return []; }
}

function normalizeActive(raw, productName) {
export function normalizeActive(raw, productName) {
const info = conditionInfo(raw.condition);
const desc = (raw.description || "").trim();
const title = desc || productName || "";
Expand Down
Loading
Loading