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
8 changes: 7 additions & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -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
<!-- Only list things CI can't check: visual changes, external services, UX -->
- [ ]
45 changes: 41 additions & 4 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 });
Expand Down
30 changes: 19 additions & 11 deletions lib/data/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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." },
Expand Down
8 changes: 6 additions & 2 deletions lib/sources/tcgplayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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];
Expand Down
81 changes: 78 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
}
});
});

Expand Down Expand Up @@ -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,.]+/);
Expand Down Expand Up @@ -572,17 +581,66 @@ function loadGradingRoi(item) {
? `${gemPct}% gem rate at ${esc(psa.estCost)} grading — favorable odds`
: `${gemPct}% gem rate — high risk of PSA 9 or lower`}</span>
</div>
${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 `
<div class="roi-expected-outcome">
<div class="roi-expected-label">Expected Outcome</div>
<div class="roi-expected-row">
<span class="roi-expected-grade">Likely PSA ${expectedPsa}</span>
${popStr ? `<span class="roi-expected-sep"></span><span class="roi-expected-pop">${popStr} exist</span>` : ""}
${scarcityLabel ? `<span class="roi-expected-sep"></span><span class="roi-expected-scarcity ${scarcityClass}">${scarcityLabel}</span>` : ""}
</div>
</div>`;
}

async function loadPriceChart(query) {
const container = document.getElementById("price-chart-container");
const canvas = document.getElementById("price-chart");
const statsEl = document.getElementById("price-chart-stats");
if (!container || !canvas) return;

try {
const res = await fetch(`/api/price-history?q=${encodeURIComponent(query)}&days=90`);
const res = await fetch(`/api/price-history?q=${encodeURIComponent(query)}&days=90&demo=true`);
if (!res.ok) return;
const data = await res.json();
if (!data.history?.length) return;
Expand All @@ -593,9 +651,10 @@ async function loadPriceChart(query) {
.filter(h => h.price > 0)
.sort((a, b) => new Date(a.recordedAt) - new Date(b.recordedAt));

if (!points.length) return;
if (points.length < 3) return;

drawPriceChart(canvas, points);
pendingChartPoints = points;
if (canvas.parentElement.clientWidth > 0) drawPriceChart(canvas, points);

if (data.stats) {
statsEl.innerHTML = `
Expand All @@ -605,6 +664,22 @@ async function loadPriceChart(query) {
<span>${data.stats.count} sales</span>
`;
}

if (data.tcgplayer?.marketPrice) {
const tcg = data.tcgplayer;
const tcgEl = document.createElement("div");
tcgEl.className = "tcg-market-ref";
tcgEl.innerHTML = `
<div class="tcg-market-label">TCGPlayer Market</div>
<div class="tcg-market-row">
<span class="tcg-market-price">${formatPrice(tcg.marketPrice, "USD")}</span>
${tcg.listedMedianPrice ? `<span class="tcg-market-median">Median: ${formatPrice(tcg.listedMedianPrice, "USD")}</span>` : ""}
</div>
${tcg.printingType ? `<div class="tcg-market-meta">${esc(tcg.printingType)}</div>` : ""}
`;
container.appendChild(tcgEl);
}

} catch {}
}

Expand Down
Loading