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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,16 @@ Initial public beta.
### Added
- Consumer dashboard at /dashboard: search, arbitrage, price history, grade breakdown
- Admin dashboard at /admin: stats KPIs, developer key CRUD, error log viewer
- Cross-source arbitrage: /api/arbitrage compares prices across eBay, magi, SNKRDUNK
- Cross-source arbitrage: /api/arbitrage compares prices across eBay, magi, Yahoo, SNKRDUNK
- Condition detection: auto-detects NM/LP/MP from EN + JP markers (状態A/美品)
- Condition filter: ?condition=nm works across all sources
- Price outlier flagging: listings >40% below median flagged
- Card identity: /api/card with canonical IDs, set resolution from card numbers
- Price history: /api/price-history tracks sold comp prices over time
- TCGPlayer integration: seeds price history when no data exists
- Scheduled price tracking: /api/track-prices for Cloud Scheduler
- Developer API key management: create, rotate, revoke, delete via Firestore
- Detail panel tabs: Grade / Prices to reduce scrolling
- Multi-source slab search: compare PSA 10 prices across eBay, magi.camp, Yahoo Auctions
- Per-subgrade AI grading: centering, corners, edges, surface graded independently in parallel
- Front + back image analysis with subgradeDetails (score, confidence, detail per attribute)
Expand All @@ -22,7 +28,7 @@ Initial public beta.
- eBay sold scrape retry with backoff on 503
- OAuth token pre-fetched on server startup
- Security: helmet headers, error sanitization, request IDs, trust proxy
- 105 tests (63 unit + 42 API integration)
- 143 tests (81 unit + 62 API integration)
- GitHub Actions CI on push/PR, auto-deploy on merge to main
- Chrome extension: queue auto-join for Pokemon Center, Walmart, Costco, Target
- Claude Code `/casecomp` skill for plain-English card search
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Search any Pokemon card across four marketplaces in one query. Get live prices,

- **Multi-source search** — eBay, magi.camp, Yahoo Auctions JP, SNKRDUNK in one query
- **Cross-source arbitrage** — compares lowest prices across sources, highlights spread
- **Condition detection** — auto-detects card condition across sources (EN: NM/LP/MP, JP: 状態A/美品)
- **AI pre-grading** — per-subgrade analysis (centering, corners, edges, surface) from listing photos
- **Price history** — sold comp tracking over time with line charts and stats
- **PSA grading signals** — population data, difficulty, gem 10%, recommended submission tier with reasoning
Expand Down Expand Up @@ -167,7 +168,7 @@ node index.js --grade-decision "Umbreon ex 217/187" # PSA break-even
| `--sold` | `10` | Sold comps count |
| `--grade` | | AI pre-grading |
| `--grade-decision` | | PSA break-even table |
| `--condition` | `A`, `B`, `C`, `D` | SNKRDUNK condition |
| `--condition` | `A`, `nm`, `lp`, `mp` | Condition filter (SNKRDUNK A/B/C/D, eBay NM/LP, magi 状態A/美品) |
| `--refresh` | | Clear cache |
| `--parallel` | | Concurrent card search |

Expand Down Expand Up @@ -212,7 +213,7 @@ Load unpacked from `extension/` in `chrome://extensions`.

## Tests

105 tests: 63 unit (filters, grading, query builder, demo data integrity) + 42 API (health, drops, webhooks, search, sold, PSA, grade, auth, demo validation).
143 tests: 81 unit (filters, grading, query builder, card identity, condition detection, demo data) + 62 API (health, drops, webhooks, search, sold, PSA, grade, auth, admin keys, arbitrage, price-history, condition, demo validation).

## Contributing

Expand Down
23 changes: 22 additions & 1 deletion api.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ 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 { parseListingLanguagesFromInput } from "./lib/search/filters.js";
import { parseListingLanguagesFromInput, filterByCondition, detectCondition } from "./lib/search/filters.js";
import { buildEbaySearchQuery } from "./lib/search/listingQuery.js";
import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js";
import { getRedisStatus, sha256 } from "./lib/data/redis-cache.js";
Expand Down Expand Up @@ -223,6 +223,11 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType =
const wantDemo = req.query.demo === "true" || (!clientId && !clientSecret);
if (wantDemo) {
const demoResult = getDemoSearchResult(q, { source: req.query.source, condition: req.query.condition });
for (const country of Object.keys(demoResult.activeByCountry || {})) {
demoResult.activeByCountry[country] = demoResult.activeByCountry[country].map(item => ({
...item, detectedCondition: item.detectedCondition || detectCondition(item),
}));
}
if (demoResult.sold?.length) recordSoldPrices(q, demoResult.sold, demoResult.source).catch(() => {});
return res.json(demoResult);
}
Expand Down Expand Up @@ -273,6 +278,22 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType =
}
}

// Add detected condition to each listing
for (const country of Object.keys(result.activeByCountry || {})) {
result.activeByCountry[country] = result.activeByCountry[country].map(item => ({
...item,
detectedCondition: item.detectedCondition || detectCondition(item),
}));
}

// Filter by condition if requested
if (config.condition && result.activeByCountry) {
for (const country of Object.keys(result.activeByCountry)) {
result.activeByCountry[country] = filterByCondition(result.activeByCountry[country], config.condition);
}
result.counts.activeTotal = Object.values(result.activeByCountry).reduce((n, arr) => n + arr.length, 0);
}

if (result.sold?.length) recordSoldPrices(q, result.sold, result.source).catch(() => {});
getOrCreateCard(q, { source: result.source, lang: config.language }).catch(() => {});
res.json(result);
Expand Down
27 changes: 2 additions & 25 deletions lib/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,9 @@ const DEMO_CARDS = {

"umbreon ex sar 217/187": {
query: "Umbreon ex SAR 217/187 sv8a Terastal Festival ex",
source: "multi",
source: "ebay",
listingFormat: "raw",
lang: "jp",
listingDescription: "eBay + magi.camp — raw JP (¥157/USD)",
activeByCountry: {
US: [
{
Expand Down Expand Up @@ -160,28 +159,6 @@ const DEMO_CARDS = {
],
grade: { overall: 7.5, centering: 8.0, corners: 8.0, edges: 8.5, surface: 7.5, confidence: 0.68, mode: "llm-detailed", notes: "Grade limiter: surface — Visible scratch near bottom-right of artwork.", limitations: "", subgradeDetails: { centering: { score: 8.0, confidence: 0.70, detail: "Centering acceptable ~56/44 front. Back appears slightly off." }, corners: { score: 8.0, confidence: 0.65, detail: "Corner whitening on back top-left. Other corners appear clean." }, edges: { score: 8.5, confidence: 0.68, detail: "Edges mostly clean with minor wear visible on right edge." }, surface: { score: 7.5, confidence: 0.72, detail: "Visible scratch near bottom-right of artwork. Photo compression may exaggerate but defect is clear." } } },
},
// magi
{
itemId: "magi-umbreon-001", itemWebUrl: "https://magi.camp/items/magi-umbreon-001",
title: "ブラッキーex SAR 217/187 1枚",
price: 223.21, priceCurrency: "USD", priceJPY: 35000, shippingLabel: "—", totalCost: 223.21, condition: "",
imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg",
additionalImages: [], grade: null,
},
{
itemId: "magi-umbreon-002", itemWebUrl: "https://magi.camp/items/magi-umbreon-002",
title: "ブラッキーex SAR 217/187 1枚",
price: 255.09, priceCurrency: "USD", priceJPY: 40000, shippingLabel: "—", totalCost: 255.09, condition: "",
imageUrl: "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg",
additionalImages: [], grade: null,
},
{
itemId: "magi-umbreon-003", itemWebUrl: "https://magi.camp/items/magi-umbreon-003",
title: "ブラッキーex SAR 217/187 1枚",
price: 262.18, priceCurrency: "USD", priceJPY: 41111, shippingLabel: "—", totalCost: 262.18, condition: "",
imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l500.jpg",
additionalImages: [], grade: null,
},
],
},
sold: [
Expand All @@ -192,7 +169,7 @@ const DEMO_CARDS = {
],
soldSource: "scrape",
psaSignal: { certNumber: null, totalPop: 3420, pop10: 1890, pop9: 1105, difficulty: "easy", gem10Pct: 55.3, grade10Chance: "high", avgDaysToGrade: 30, tier: "Regular", estCost: "$50", tierReason: "PSA 10 comps at $750+, above the $499 Value cap. Submitting at Value risks an upcharge to Regular anyway." },
counts: { activeTotal: 8, sold: 4 },
counts: { activeTotal: 5, sold: 4 },
},

"pikachu ex sar 234/193 psa 10": {
Expand Down
48 changes: 48 additions & 0 deletions lib/search/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -693,3 +693,51 @@ export function filterByListingFormat(results, config) {
}
return results;
}

const CONDITION_MAP = {
"mint": ["mint", "nm", "near mint", "a — mint", "a — mint", "状態a", "美品", "gem"],
"nm": ["nm", "near mint", "a — mint", "状態a", "美品", "excellent"],
"lp": ["lp", "lightly played", "b", "状態b", "良品"],
"mp": ["mp", "moderately played", "c", "状態c"],
"hp": ["hp", "heavily played", "d", "状態d", "damaged"],
};

export function filterByCondition(items, condition) {
if (!condition) return items;
const c = condition.toLowerCase().trim();

const keywords = CONDITION_MAP[c] || [c];

return items.filter(i => {
const title = (i.title || "").toLowerCase();
const cond = (i.condition || "").toLowerCase();
return keywords.some(kw => cond.includes(kw) || title.includes(kw));
});
}

export function detectCondition(item) {
const title = (item.title || "").toLowerCase();
const cond = (item.condition || "").toLowerCase();
const text = `${cond} ${title}`;

if (/状態a-|状態A−/i.test(text)) return "NM";
if (/状態a|a — mint|美品|gem mint|mint condition/i.test(text)) return "Mint";
if (/\bnm\b|near mint|excellent/i.test(text)) return "NM";
if (/\blp\b|lightly played|状態b|良品/i.test(text)) return "LP";
if (/\bmp\b|moderately played|状態c/i.test(text)) return "MP";
if (/\bhp\b|heavily played|状態d|damaged/i.test(text)) return "HP";
if (/ungraded/i.test(text)) return "Ungraded";
if (/graded|psa|bgs|cgc/i.test(text)) return "Graded";
return null;
}

export function flagPriceOutliers(items, { threshold = 0.4 } = {}) {
const prices = items.map(i => i.totalCost || i.price).filter(Boolean).sort((a, b) => a - b);
if (prices.length < 3) return items;
const median = prices[Math.floor(prices.length / 2)];
const cutoff = median * (1 - threshold);
return items.map(i => {
const p = i.totalCost || i.price;
return { ...i, _priceOutlier: p < cutoff };
});
}
33 changes: 27 additions & 6 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,9 @@ function selectItem(itemId) {
? `<div class="detail-actions"><a href="${esc(item.itemWebUrl)}" target="_blank" rel="noopener">View on ${sourceName} &rarr;</a></div>`
: "";

const hasGrade = !!grade;
const defaultTab = hasGrade ? "grade" : "prices";

detailPanel.innerHTML = `
<div class="detail-title">${esc(item.title)}</div>
${mainImg}
Expand All @@ -345,16 +348,34 @@ function selectItem(itemId) {
${fieldsHtml}
<div id="card-identity" class="card-identity hidden"></div>
</div>
${gradeHtml}
<div id="arbitrage-container" class="arbitrage-container hidden"></div>
<div id="price-chart-container" class="price-chart-container hidden">
<div class="detail-grade-section-label">Price History</div>
<canvas id="price-chart" height="120"></canvas>
<div id="price-chart-stats" class="price-chart-stats"></div>
<div class="detail-tabs">
${hasGrade ? `<button class="detail-tab${defaultTab === "grade" ? " active" : ""}" data-dtab="grade">Grade</button>` : ""}
<button class="detail-tab${defaultTab === "prices" ? " active" : ""}" data-dtab="prices">Prices</button>
</div>
<div class="detail-tab-panel${defaultTab === "grade" ? "" : " hidden"}" data-dtpanel="grade">
${gradeHtml}
</div>
<div class="detail-tab-panel${defaultTab === "prices" ? "" : " hidden"}" data-dtpanel="prices">
<div id="arbitrage-container" class="arbitrage-container hidden"></div>
<div id="price-chart-container" class="price-chart-container hidden">
<div class="detail-grade-section-label">Price History</div>
<canvas id="price-chart" height="120"></canvas>
<div id="price-chart-stats" class="price-chart-stats"></div>
</div>
</div>
${linkHtml}
`;

detailPanel.querySelectorAll(".detail-tab").forEach(tab => {
tab.addEventListener("click", () => {
detailPanel.querySelectorAll(".detail-tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
detailPanel.querySelectorAll(".detail-tab-panel").forEach(p => p.classList.add("hidden"));
const panel = detailPanel.querySelector(`[data-dtpanel="${tab.dataset.dtab}"]`);
if (panel) panel.classList.remove("hidden");
});
});

detailPanel.querySelectorAll(".detail-images img").forEach(img => {
img.addEventListener("click", () => {
const main = document.getElementById("detail-main-img");
Expand Down
6 changes: 6 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,12 @@ main {
.detail-meta-row .detail-grid {
margin-bottom: 0;
}
.detail-tabs { display: flex; gap: 4px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.detail-tab { background: none; border: none; color: var(--muted); font-family: 'Space Grotesk', system-ui, sans-serif; font-size: 12px; font-weight: 600; padding: 4px 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s; }
.detail-tab:hover { color: var(--text); }
.detail-tab.active { color: var(--gold); background: var(--gold-dim); }
.detail-tab-panel { }

.card-identity { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.card-id-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.card-id-badge { font-family: monospace; font-size: 12px; color: var(--gold); background: var(--gold-dim); padding: 3px 8px; border-radius: 4px; font-weight: 600; }
Expand Down
17 changes: 17 additions & 0 deletions test/api-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,23 @@ async function run() {
assert(res.status === 404);
});

// ── Condition + detection ──

console.log("\n\x1b[1m=== condition ===\x1b[0m");

await test("Search results include detectedCondition", async () => {
const { body } = await jsonNoAuth("/api/search?q=Mega+Greninja+ex+SAR&demo=true&source=snkrdunk&condition=A");
const items = body.activeByCountry?.US || [];
assert(items.length > 0, "expected items");
assert(items.every(i => i.detectedCondition), "expected detectedCondition on all items");
});

await test("Condition filter reduces results", async () => {
const { body: all } = await jsonNoAuth("/api/search?q=Mega+Greninja+ex+SAR&demo=true");
const { body: filtered } = await jsonNoAuth("/api/search?q=Mega+Greninja+ex+SAR&demo=true&condition=A");
assert(filtered.counts.activeTotal <= all.counts.activeTotal, "filtered should be <= all");
});

// ── Card identity ──

console.log("\n\x1b[1m=== api/card ===\x1b[0m");
Expand Down
70 changes: 70 additions & 0 deletions test/unit-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { parseGradeJSON } from "../lib/grading/grading.js";
import { buildEbaySearchQuery, describeListingSearch } from "../lib/search/listingQuery.js";
import {
filterByCondition,
detectCondition,
flagPriceOutliers,
detectLanguage,
tokenizeQuery,
extractPokemonName,
Expand Down Expand Up @@ -493,6 +496,73 @@ test("PSA signals have tier + reason", () => {
}
});

// ── Condition detection ──

console.log("\n\x1b[1m=== detectCondition ===\x1b[0m");

test("detects Mint from SNKRDUNK", () => {
eq(detectCondition({ condition: "A — Mint", title: "" }), "Mint");
});

test("detects NM from eBay", () => {
eq(detectCondition({ condition: "", title: "Umbreon ex SAR NM Japanese" }), "NM");
});

test("detects condition from Japanese title", () => {
eq(detectCondition({ condition: "", title: "〔状態A-〕メガゲッコウガex SAR" }), "NM");
});

test("detects Ungraded", () => {
eq(detectCondition({ condition: "Ungraded", title: "" }), "Ungraded");
});

test("returns null for unknown", () => {
eq(detectCondition({ condition: "", title: "Pokemon card" }), null);
});

// ── Condition filter ──

console.log("\n\x1b[1m=== filterByCondition ===\x1b[0m");

test("filters to NM", () => {
const items = [
{ title: "Card NM", condition: "" },
{ title: "Card LP", condition: "" },
{ title: "Card", condition: "A — Mint" },
];
const r = filterByCondition(items, "nm");
eq(r.length, 2);
});

test("returns all if no condition", () => {
const items = [{ title: "a" }, { title: "b" }];
eq(filterByCondition(items, "").length, 2);
eq(filterByCondition(items, null).length, 2);
});

// ── Price outliers ──

console.log("\n\x1b[1m=== flagPriceOutliers ===\x1b[0m");

test("flags items below 40% of median", () => {
const items = [
{ price: 100 },
{ price: 400 },
{ price: 410 },
{ price: 420 },
{ price: 430 },
];
const r = flagPriceOutliers(items);
assert(r[0]._priceOutlier === true, "100 should be outlier");
assert(r[1]._priceOutlier === false, "400 should not be outlier");
});

test("no outliers with less than 3 items", () => {
const items = [{ price: 100 }, { price: 500 }];
const r = flagPriceOutliers(items);
assert(!r[0]._priceOutlier);
});

// ── Card identity ──

console.log("\n\x1b[1m=== parseCardIdentity ===\x1b[0m");
Expand Down