From f00def58614f8ea40486b03dbcf7a712700e5d8a Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Tue, 12 May 2026 04:00:15 +0530 Subject: [PATCH] feat: condition detection + filter, outlier flagging, detail tabs, demo fixes - Condition detection: auto-detects Mint/NM/LP/MP/HP from EN + JP markers - Condition filter: ?condition=nm works across eBay, magi, SNKRDUNK - Price outlier flagging: listings >40% below median get _priceOutlier - Detail panel tabs: Grade / Prices (reduces scrolling) - Removed magi Umbreon demo listings (condition unknown, skewed arbitrage) - Demo results now include detectedCondition - Demo rate limit bumped to 360/min - 143 tests (81 unit + 62 API) - READMEs + CHANGELOG updated --- CHANGELOG.md | 10 +++++-- README.md | 5 ++-- api.js | 23 +++++++++++++- lib/demo.js | 27 ++--------------- lib/search/filters.js | 48 +++++++++++++++++++++++++++++ public/app.js | 33 ++++++++++++++++---- public/style.css | 6 ++++ test/api-test.js | 17 +++++++++++ test/unit-test.js | 70 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 203 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index accd27e..76fe7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) @@ -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 diff --git a/README.md b/README.md index d1b1075..0848656 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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 diff --git a/api.js b/api.js index ce768f6..6bfaf40 100644 --- a/api.js +++ b/api.js @@ -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"; @@ -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); } @@ -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); diff --git a/lib/demo.js b/lib/demo.js index aca2665..81f302f 100644 --- a/lib/demo.js +++ b/lib/demo.js @@ -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: [ { @@ -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: [ @@ -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": { diff --git a/lib/search/filters.js b/lib/search/filters.js index 3b4d956..bb28247 100644 --- a/lib/search/filters.js +++ b/lib/search/filters.js @@ -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 }; + }); +} diff --git a/public/app.js b/public/app.js index ca20d90..c4a9975 100644 --- a/public/app.js +++ b/public/app.js @@ -336,6 +336,9 @@ function selectItem(itemId) { ? `
View on ${sourceName} →
` : ""; + const hasGrade = !!grade; + const defaultTab = hasGrade ? "grade" : "prices"; + detailPanel.innerHTML = `
${esc(item.title)}
${mainImg} @@ -345,16 +348,34 @@ function selectItem(itemId) { ${fieldsHtml} - ${gradeHtml} - -