diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0d261e6..1db2ede 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,12 @@ ## Summary +## Breaking changes +None / _describe API, schema, or config changes that affect consumers_ + +## Demo data +- [ ] Sample data still works (`?demo=true`) +- [ ] No new fields missing from demo cards + ## Manual verification - - [ ] diff --git a/api.js b/api.js index 9b0c5a6..ee5afc6 100644 --- a/api.js +++ b/api.js @@ -652,12 +652,43 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { const { q } = req.query; if (!validateQuery(q, res)) return; const days = Math.min(365, Math.max(1, Number(req.query.days) || 90)); + + const wantDemo = req.query.demo === "true" || (!clientId && !clientSecret); + if (wantDemo) { + const demoResult = getDemoSearchResult(q, {}); + const sold = demoResult.sold || []; + const history = sold.filter(s => s.soldDate && s.price).map(s => ({ + price: s.price, + recordedAt: s.soldDate, + source: s.itemWebUrl?.includes("magi") ? "magi" : s.itemWebUrl?.includes("yahoo") ? "yahoo" : s.itemWebUrl?.includes("snkrdunk") ? "snkrdunk" : "ebay", + })); + const prices = history.map(h => h.price); + const stats = prices.length ? { + min: Math.min(...prices), + max: Math.max(...prices), + avg: Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100, + count: prices.length, + } : null; + return res.json({ query: q, days, history, stats, _demo: true }); + } + try { let history = await getPriceHistory(q, { days }); + let tcgData = null; + + const tcg = await seedFromTCGPlayer(q); + if (tcg) { + tcgData = { + productId: tcg.productId, + name: tcg.name, + setName: tcg.setName, + marketPrice: tcg.price, + listedMedianPrice: tcg.listedMedianPrice, + printingType: tcg.printingType, + source: "tcgplayer", + }; - if (!history.length) { - const tcg = await seedFromTCGPlayer(q); - if (tcg) { + if (!history.length) { await recordSoldPrices(q, [{ itemId: `tcg_${tcg.productId}`, price: tcg.price, @@ -676,7 +707,13 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { avg: Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100, count: prices.length, } : null; - res.json({ query: q, days, history, stats }); + + if (tcgData && stats) { + const ratio = tcgData.marketPrice / stats.avg; + if (ratio < 0.3 || ratio > 3) tcgData = null; + } + + res.json({ query: q, days, history, stats, tcgplayer: tcgData }); } catch (e) { logError("price-history", e.message, req.originalUrl, req.requestId); res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); diff --git a/lib/data/demo.js b/lib/data/demo.js index ef87b81..bd3b78f 100644 --- a/lib/data/demo.js +++ b/lib/data/demo.js @@ -93,11 +93,16 @@ const DEMO_CARDS = { ], }, sold: [ - { itemId: "magi-greninja-s1", itemWebUrl: "https://magi.camp/items/magi-greninja-s1", title: "メガゲッコウガex SAR 114/083 1枚", price: 298.46, priceCurrency: "USD", priceJPY: 46800, listingGradeLabel: null, endedDate: "—", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s1", itemWebUrl: "https://magi.camp/items/magi-greninja-s1", title: "メガゲッコウガex SAR 114/083 1枚", price: 298.46, priceCurrency: "USD", priceJPY: 46800, soldDate: "2026-05-10", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s2", itemWebUrl: "https://magi.camp/items/magi-greninja-s2", title: "メガゲッコウガex SAR 114/083 美品", price: 318.47, priceCurrency: "USD", priceJPY: 50000, soldDate: "2026-05-06", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "snkr-greninja-s1", itemWebUrl: "https://snkrdunk.com/products/greninja-s1", title: "MEGA Greninja ex SAR [A — Mint]", price: 345.00, priceCurrency: "USD", soldDate: "2026-04-28", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s3", itemWebUrl: "https://magi.camp/items/magi-greninja-s3", title: "メガゲッコウガex SAR 114/083 1枚", price: 280.25, priceCurrency: "USD", priceJPY: 44000, soldDate: "2026-04-20", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "snkr-greninja-s2", itemWebUrl: "https://snkrdunk.com/products/greninja-s2", title: "MEGA Greninja ex SAR [A — Mint]", price: 310.00, priceCurrency: "USD", soldDate: "2026-04-14", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s4", itemWebUrl: "https://magi.camp/items/magi-greninja-s4", title: "メガゲッコウガex SAR 114/083 1枚", price: 265.92, priceCurrency: "USD", priceJPY: 41750, soldDate: "2026-04-08", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, ], soldSource: "multi", psaSignal: { certNumber: null, totalPop: 285, pop10: 142, pop9: 98, difficulty: "moderate", gem10Pct: 49.8, grade10Chance: "moderate", avgDaysToGrade: 65, tier: "Value", estCost: "$25", tierReason: "Raw at $333–$427, under the $499 Value cap. PSA 10 market still forming — low upcharge risk." }, - counts: { activeTotal: 8, sold: 1 }, + counts: { activeTotal: 8, sold: 6 }, }, "umbreon ex sar 217/187": { @@ -182,13 +187,16 @@ const DEMO_CARDS = { }, sold: [ { itemId: "v1|397899646795|sold1", itemWebUrl: "https://www.ebay.com/itm/397899646795", title: "Umbreon ex SAR 217/187 SV8a Terastal Festival Japanese Pokemon TCG", price: 385.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-08", imageUrl: "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg" }, - { itemId: "v1|177832326093|sold1", itemWebUrl: "https://www.ebay.com/itm/177832326093", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a Pokemon Card Japanese NM", price: 392.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-07", imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l500.jpg" }, - { itemId: "v1|178048869261|sold1", itemWebUrl: "https://www.ebay.com/itm/178048869261", title: "Pokemon Umbreon Ex sv8a 217/187 SAR Special Art Rare Japanese", price: 370.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-06", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" }, - { itemId: "v1|397643034526|sold1", itemWebUrl: "https://www.ebay.com/itm/397643034526", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card", price: 405.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-05", imageUrl: "https://i.ebayimg.com/images/g/8fIAAeSwny1pnSPT/s-l500.jpg" }, + { itemId: "v1|177832326093|sold1", itemWebUrl: "https://www.ebay.com/itm/177832326093", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a Pokemon Card Japanese NM", price: 392.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-04", imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l500.jpg" }, + { itemId: "v1|178048869261|sold1", itemWebUrl: "https://www.ebay.com/itm/178048869261", title: "Pokemon Umbreon Ex sv8a 217/187 SAR Special Art Rare Japanese", price: 370.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-28", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" }, + { itemId: "v1|397643034526|sold1", itemWebUrl: "https://www.ebay.com/itm/397643034526", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card", price: 405.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-22", imageUrl: "https://i.ebayimg.com/images/g/8fIAAeSwny1pnSPT/s-l500.jpg" }, + { itemId: "v1|397467499018|sold1", itemWebUrl: "https://www.ebay.com/itm/397467499018", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a Pokemon Card", price: 358.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-15", imageUrl: "https://i.ebayimg.com/images/g/MK8AAeSwqEVpXH5z/s-l500.jpg" }, + { itemId: "magi-umb-sold1", itemWebUrl: "https://magi.camp/item/umb-sold-001", title: "ブラッキーex SAR 217/187 sv8a 1枚", price: 342.36, priceCurrency: "USD", priceJPY: 53800, soldDate: "2026-04-10", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" }, + { itemId: "v1|397899646795|sold2", itemWebUrl: "https://www.ebay.com/itm/397899646795", title: "Umbreon ex SAR 217/187 SV8a Terastal Festival Pokemon", price: 375.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-03", imageUrl: "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg" }, ], 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: 8, sold: 7 }, }, "pikachu ex sar 234/193 psa 10": { @@ -254,11 +262,11 @@ const DEMO_CARDS = { ], }, sold: [ - { itemId: "1978552265", itemWebUrl: "https://magi.camp/items/1978552265", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 785.61, priceCurrency: "USD", priceJPY: 121770, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/zkYAAeSwNqNpiCOx/s-l500.jpg" }, - { itemId: "j1228450383", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/j1228450383", title: "☆ PSA10 ポケモンカード ピカチュウex 234/193 SAR ☆", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, - { itemId: "w1228860729", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/w1228860729", title: "ポケモンカード ピカチュウex SAR PSA10 GEM MT 234/193 M2a JP", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, - { itemId: "p1227663240", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/p1227663240", title: "ポケモンカード ピカチュウex 234/193 SAR PSA10 PSA鑑定品", price: 819.41, priceCurrency: "USD", priceJPY: 127009, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, - { itemId: "695466231-sold", itemWebUrl: "https://magi.camp/items/695466231", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 741, priceCurrency: "USD", priceJPY: 114855, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, + { itemId: "1978552265", itemWebUrl: "https://magi.camp/items/1978552265", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 785.61, priceCurrency: "USD", priceJPY: 121770, listingGradeLabel: "PSA 10", soldDate: "2026-05-10", imageUrl: "https://i.ebayimg.com/images/g/zkYAAeSwNqNpiCOx/s-l500.jpg" }, + { itemId: "j1228450383", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/j1228450383", title: "☆ PSA10 ポケモンカード ピカチュウex 234/193 SAR ☆", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", soldDate: "2026-05-05", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, + { itemId: "w1228860729", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/w1228860729", title: "ポケモンカード ピカチュウex SAR PSA10 GEM MT 234/193 M2a JP", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", soldDate: "2026-04-28", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, + { itemId: "p1227663240", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/p1227663240", title: "ポケモンカード ピカチュウex 234/193 SAR PSA10 PSA鑑定品", price: 819.41, priceCurrency: "USD", priceJPY: 127009, listingGradeLabel: "PSA 10", soldDate: "2026-04-20", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, + { itemId: "695466231-sold", itemWebUrl: "https://magi.camp/items/695466231", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 741, priceCurrency: "USD", priceJPY: 114855, listingGradeLabel: "PSA 10", soldDate: "2026-04-12", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, ], soldSource: "multi", psaSignal: { certNumber: null, totalPop: 8920, pop10: 5870, pop9: 2215, difficulty: "easy", gem10Pct: 65.8, grade10Chance: "high", avgDaysToGrade: 15, tier: "Express", estCost: "$75", tierReason: "PSA 10 comps at $741–$848. Express ($75) halves turnaround vs Regular ($50) — worth $25 on a $700+ card." }, diff --git a/lib/sources/tcgplayer.js b/lib/sources/tcgplayer.js index 83e7b6a..68a3f3c 100644 --- a/lib/sources/tcgplayer.js +++ b/lib/sources/tcgplayer.js @@ -3,7 +3,7 @@ const SEARCH_URL = "https://mp-search-api.tcgplayer.com/v1/search/request?q=QUER const PRICE_URL = "https://mpapi.tcgplayer.com/v2/product/PRODUCT_ID/pricepoints"; export async function searchTCGPlayer(cardName, { limit = 3 } = {}) { - const q = encodeURIComponent(cardName.replace(/\d{3}\/\d{3}/g, "").replace(/SAR|SR|AR/gi, "").trim()); + const q = encodeURIComponent(cardName.trim()); try { const res = await fetch(SEARCH_URL.replace("QUERY", q), { method: "POST", @@ -54,7 +54,11 @@ export async function getTCGPlayerPrice(productId) { } export async function seedFromTCGPlayer(cardName) { - const products = await searchTCGPlayer(cardName, { limit: 1 }); + let products = await searchTCGPlayer(cardName, { limit: 1 }); + if (!products.length) { + const simpler = cardName.replace(/\d{3}\/\d{3}/g, "").replace(/SAR|SR|AR/gi, "").trim(); + if (simpler !== cardName.trim()) products = await searchTCGPlayer(simpler, { limit: 1 }); + } if (!products.length) return null; const product = products[0]; diff --git a/public/app.js b/public/app.js index 1bd75f3..b046f6a 100644 --- a/public/app.js +++ b/public/app.js @@ -23,6 +23,8 @@ let allSold = []; let activeSourceFilter = "all"; let currentSort = "price-asc"; let currentPsaSignal = null; +let currentMagiMarket = null; +let pendingChartPoints = null; document.querySelectorAll(".hint").forEach(h => { h.addEventListener("click", () => { @@ -432,6 +434,10 @@ function selectItem(itemId) { 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"); + if (tab.dataset.dtab === "prices" && pendingChartPoints) { + const c = document.getElementById("price-chart"); + if (c && c.parentElement.clientWidth > 0) drawPriceChart(c, pendingChartPoints); + } }); }); @@ -490,6 +496,9 @@ async function loadArbitrage(query) { container.classList.remove("hidden"); const arb = data.arbitrage; + const magiData = sources.magi || sources.Magi; + currentMagiMarket = magiData ? { lowest: magiData.lowest, priceJPY: magiData.priceJPY, count: magiData.count } : null; + const sorted = names.sort((a, b) => sources[a].lowest - sources[b].lowest); const savingsHtml = arb ? (() => { const match = arb.summary.match(/\$[\d,.]+/); @@ -572,9 +581,58 @@ function loadGradingRoi(item) { ? `${gemPct}% gem rate at ${esc(psa.estCost)} grading — favorable odds` : `${gemPct}% gem rate — high risk of PSA 9 or lower`} + ${buildExpectedOutcome(item, psa)} `; } +function buildExpectedOutcome(item, psa) { + const grade = item.grade && !item.grade.error ? item.grade : null; + if (!grade || grade.overall == null) return ""; + + const aiScore = grade.overall; + let expectedPsa, popAtGrade; + if (aiScore >= 9.5) { + expectedPsa = 10; + popAtGrade = psa.pop10; + } else if (aiScore >= 8.5) { + expectedPsa = 9; + popAtGrade = psa.pop9; + } else if (aiScore >= 7.5) { + expectedPsa = 8; + popAtGrade = null; + } else { + expectedPsa = Math.floor(aiScore); + popAtGrade = null; + } + + const popStr = popAtGrade != null ? popAtGrade.toLocaleString() : null; + + let scarcityLabel = ""; + let scarcityClass = ""; + if (popAtGrade != null) { + if (popAtGrade < 100) { + scarcityLabel = "Scarce"; + scarcityClass = "scarce"; + } else if (popAtGrade <= 1000) { + scarcityLabel = "Moderate scarcity"; + scarcityClass = "moderate"; + } else { + scarcityLabel = "Common"; + scarcityClass = "common"; + } + } + + return ` +