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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
## Unreleased

### Added
- Card autocomplete: GET /api/autocomplete with TCGdex EN+JP database (29K cards), card preview images, EN→JP name mapping
- Search filters: format (raw/slab), multi-select source pills, condition dropdown, slab provider+grade selectors
- Autocomplete dropdown on dashboard: card thumbnails, card preview panel on hover, keyboard navigation
- Lazy PSA loading: search returns results without waiting for PSA, frontend fetches PSA separately
- Pre-warm cache: track-prices scheduler pre-caches active listings + PSA for tracked cards
- Fast card-first search: autocomplete → card share → demo search → render in 2-3s (was 30s)
- eBay relevance filtering: blocklist expanded (art case, sleeves, playmat, booster, etc.), applied to active+sold
- Arbitrage alerts: notify when cross-source spread exceeds threshold (POST /api/alerts with type "arbitrage")
- Price drop alerts: notify when price falls below target (POST /api/alerts with type "price")
- check-alerts endpoint (owner-only): evaluates all active alerts against live data
Expand Down Expand Up @@ -51,7 +58,7 @@
- Card identity: cleaned up long names (strips pack names, condition text from titles)
- track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults
- Demo condition filter: checks detectedCondition in addition to raw condition field
- Tests: 238 total (118 unit + 80 API + 40 smoke), up from 183
- Tests: 271 total (128 unit + 80 API + 63 smoke), up from 183
- AI grading prompts: full PSA rubric (5-10), perspective correction, per-corner/edge detail, holo-specific surface guidance
- Demo grades re-evaluated with improved prompts (more conservative scores, honest confidence)
- Removed dead code: Redis import from api.js, updateCardField from card-identity.js
Expand Down
45 changes: 34 additions & 11 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { recordSoldPrices, getPriceHistory } from "./lib/data/price-history.js";
import { sendAlertEmail } from "./lib/data/email.js";
import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js";
import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/data/card-identity.js";
import { initCardDatabase, searchCards, refreshCardDatabase } from "./lib/data/card-database.js";
import { fileURLToPath } from "url";
import path from "path";

Expand Down Expand Up @@ -253,22 +254,27 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType =
const cp = cachePrefix(req);
config._cachePrefix = cp;
const soldTimeout = (p) => Promise.race([p, new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000))]);
const [activeRes, soldRes, psaSignal] = await Promise.all([
const [activeRes, soldRes] = await Promise.all([
searchActive({ query: ebayQuery, relevanceQuery: q, deliveryCountries: config.deliveryCountries, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401 }),
soldTimeout(searchSold({ query: ebayQuery, relevanceQuery: q, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401, soldBrowser: false })),
getPsaGradingSignal(q, { _cachePrefix: cp }),
]);
const filteredByCountry = {};
for (const [country, items] of Object.entries(activeRes.itemsByCountry || {})) {
filteredByCountry[country] = filterRelevantResults(items, q).filtered;
}
const filteredSold = filterRelevantResults(soldRes.items || [], q).filtered;
getPsaGradingSignal(q, { _cachePrefix: cp }).catch(() => null);
result = {
query: q,
source: "ebay",
listingFormat: config.listingFormat,
activeByCountry: activeRes.itemsByCountry || {},
sold: soldRes.items || [],
activeByCountry: filteredByCountry,
sold: filteredSold,
soldSource: soldRes.source,
psaSignal,
psaSignal: null,
counts: {
activeTotal: Object.values(activeRes.itemsByCountry || {}).reduce((n, arr) => n + arr.length, 0),
sold: (soldRes.items || []).length,
activeTotal: Object.values(filteredByCountry).reduce((n, arr) => n + arr.length, 0),
sold: filteredSold.length,
},
};
}
Expand Down Expand Up @@ -354,7 +360,7 @@ app.get("/api/sold", apiAuthMiddleware, (req, res, next) => { req._errorType = "
});

// GET /api/psa
app.get("/api/psa", authMiddleware, (req, res, next) => { req._errorType = "psa"; next(); }, async (req, res) => {
app.get("/api/psa", apiAuthMiddleware, (req, res, next) => { req._errorType = "psa"; next(); }, async (req, res) => {
const { q } = req.query;
if (!validateQuery(q, res)) return;
try {
Expand Down Expand Up @@ -452,6 +458,16 @@ app.get("/api/health", async (req, res) => {
});
});

// GET /api/autocomplete
app.get("/api/autocomplete", (req, res) => {
const q = (req.query.q || "").trim();
if (!q || q.length < 2) return res.status(400).json({ error: "Query must be at least 2 characters" });
if (q.length > 100) return res.status(400).json({ error: "Query too long (max 100 characters)" });
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 8));
const results = searchCards(q, limit);
res.json({ results, count: results.length, query: q });
});

// ============ V1 API — Drop Intelligence ============

async function authMiddleware(req, res, next) {
Expand Down Expand Up @@ -1381,9 +1397,14 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => {
if (hasEbay) {
try {
const ebayQuery = buildEbaySearchQuery(card, {});
const soldRes = await Promise.race([
searchSold({ query: ebayQuery, relevanceQuery: card, languages: [], config: {}, refresh: false, noEbay: false, getToken, on401, soldBrowser: false }),
new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000)),
const warmConfig = { deliveryCountries: ["US", "IN"], languages: [], _cachePrefix: "" };
const [soldRes] = await Promise.all([
Promise.race([
searchSold({ query: ebayQuery, relevanceQuery: card, languages: [], config: {}, refresh: false, noEbay: false, getToken, on401, soldBrowser: false }),
new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000)),
]),
searchActive({ query: ebayQuery, relevanceQuery: card, deliveryCountries: warmConfig.deliveryCountries, languages: warmConfig.languages, config: warmConfig, refresh: false, noEbay: false, getToken, on401 }).catch(() => null),
getPsaGradingSignal(card, { _cachePrefix: "" }).catch(() => null),
]);
ebaySold = soldRes.items || [];
if (ebaySold.length) {
Expand Down Expand Up @@ -1451,6 +1472,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => {
}
} catch {}

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

Expand Down Expand Up @@ -1660,4 +1682,5 @@ app.listen(PORT, async () => {
console.warn(`eBay token warmup failed: ${e.message}`);
}
}
initCardDatabase().catch(() => {});
});
213 changes: 213 additions & 0 deletions lib/data/card-database.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { SET_NAME_MAP, SET_TOTAL_MAP } from "./card-identity.js";

const JA_TO_EN = {
"ピカチュウ": "Pikachu", "リザードン": "Charizard", "ブラッキー": "Umbreon",
"ゲッコウガ": "Greninja", "ミュウツー": "Mewtwo", "ミュウ": "Mew",
"ルカリオ": "Lucario", "ゲンガー": "Gengar", "レックウザ": "Rayquaza",
"カイリュー": "Dragonite", "ギャラドス": "Gyarados", "カメックス": "Blastoise",
"フシギバナ": "Venusaur", "エーフィ": "Espeon", "ニンフィア": "Sylveon",
"サーナイト": "Gardevoir", "ルギア": "Lugia", "ホウオウ": "Ho-Oh",
"パルキア": "Palkia", "ディアルガ": "Dialga", "ギラティナ": "Giratina",
"アルセウス": "Arceus", "ゼルネアス": "Xerneas", "イベルタル": "Yveltal",
"ソルガレオ": "Solgaleo", "ルナアーラ": "Lunala", "ザシアン": "Zacian",
"ザマゼンタ": "Zamazenta", "コライドン": "Koraidon", "ミライドン": "Miraidon",
"リーフィア": "Leafeon", "グレイシア": "Glaceon", "ブースター": "Flareon",
"シャワーズ": "Vaporeon", "サンダース": "Jolteon", "テラパゴス": "Terapagos",
"ドラパルト": "Dragapult", "セグレイブ": "Baxcalibur", "ドドゲザン": "Kingambit",
"マスカーニャ": "Meowscarada", "ラウドボーン": "Skeledirge", "ウェーニバル": "Quaquaval",
"オーガポン": "Ogerpon", "テツノカイナ": "Iron Hands", "トドロクツキ": "Roaring Moon",
"イーブイ": "Eevee", "ロトム": "Rotom", "ピジョット": "Pidgeot",
"フーディン": "Alakazam", "カイリキー": "Machamp", "ハッサム": "Scizor",
"バンギラス": "Tyranitar", "ボーマンダ": "Salamence", "メタグロス": "Metagross",
"ガブリアス": "Garchomp", "エルレイド": "Gallade", "トゲキッス": "Togekiss",
"ゾロアーク": "Zoroark", "サザンドラ": "Hydreigon", "バシャーモ": "Blaziken",
"ジュカイン": "Sceptile", "ラグラージ": "Swampert", "ミミッキュ": "Mimikyu",
"ドラゴンクロー": "Dragon Claw", "メガゲッコウガ": "Mega Greninja",
"メガリザードン": "Mega Charizard", "メガミュウツー": "Mega Mewtwo",
"メガレックウザ": "Mega Rayquaza", "メガルカリオ": "Mega Lucario",
"メガゲンガー": "Mega Gengar", "メガハッサム": "Mega Scizor",
};

function translateJaName(jaName) {
if (!jaName) return null;
const sorted = Object.keys(JA_TO_EN).sort((a, b) => b.length - a.length);
for (const ja of sorted) {
if (jaName.startsWith(ja)) {
const suffix = jaName.slice(ja.length);
const enSuffix = suffix ? ` ${suffix}` : "";
return `${JA_TO_EN[ja]}${enSuffix}`;
}
}
return null;
}

let cardIndex = [];

async function fetchCards(lang, timeout = 15000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(`https://api.tcgdex.net/v2/${lang}/cards`, { signal: controller.signal });
if (!res.ok) throw new Error(`TCGdex ${lang} returned ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
}
}

function parseSetCode(id) {
const m = id.match(/^([^-]+)-/);
return m ? m[1] : null;
}

const setCodeToTotal = new Map();
for (const [total, code] of Object.entries(SET_TOTAL_MAP)) {
setCodeToTotal.set(code, total);
}

function buildCanonicalCardId(setCode, localId) {
if (!setCode || !localId) return null;
const code = setCode.toLowerCase();
const total = setCodeToTotal.get(code);
if (!total) return null;
return `${code}/${localId}-${total}`;
}

function buildImageUrl(image) {
if (image) return `${image}/low.png`;
return null;
}

export async function initCardDatabase() {
const [enCards, jaCards] = await Promise.all([
fetchCards("en").catch(() => []),
fetchCards("ja").catch(() => []),
]);

const jaMap = new Map();
for (const card of jaCards) {
jaMap.set(card.id, card);
}

const seen = new Set();
const index = [];

for (const card of enCards) {
seen.add(card.id);
const setCode = parseSetCode(card.id);
const jaCard = jaMap.get(card.id);
index.push({
id: card.id,
name: card.name,
nameJa: jaCard?.name || null,
localId: card.localId,
setCode,
imageUrl: buildImageUrl(card.image),
});
}

for (const card of jaCards) {
if (seen.has(card.id)) continue;
const setCode = parseSetCode(card.id);
const enName = translateJaName(card.name);
index.push({
id: card.id,
name: enName || card.name,
nameJa: card.name,
localId: card.localId,
setCode,
imageUrl: buildImageUrl(card.image),
});
}

cardIndex = index;
console.log(`Card database loaded: ${cardIndex.length} cards`);
return cardIndex.length;
}

export async function refreshCardDatabase() {
return initCardDatabase();
}

export function getCardCount() {
return cardIndex.length;
}

export function matchesQuery(card, query) {
if (!query || query.length < 2) return 0;
const q = query.toLowerCase().trim();
const tokens = q.split(/\s+/).filter(Boolean);
if (!tokens.length) return 0;

const nameLower = (card.name || "").toLowerCase();
const nameJa = card.nameJa || "";
const idLower = (card.id || "").toLowerCase();
const localId = card.localId || "";

if (tokens.length === 1) {
const t = tokens[0];
if (nameLower.startsWith(t)) return 3;
if (nameJa.startsWith(t)) return 3;
if (nameLower.includes(t)) return 2;
if (nameJa.includes(t)) return 2;
if (localId.startsWith(t)) return 1;
if (idLower.includes(t)) return 1;
return 0;
}

const nameFields = `${nameLower} ${nameJa.toLowerCase()}`;
const combined = `${nameFields} ${idLower} ${localId}`.toLowerCase();

const allInName = tokens.every(t => nameFields.includes(t));
if (allInName) {
return nameLower.startsWith(tokens[0]) || nameJa.startsWith(tokens[0]) ? 4 : 3;
}

const allInCombined = tokens.every(t => combined.includes(t));
if (allInCombined) {
const hasLocalIdMatch = tokens.some(t => localId === t || localId.startsWith(t));
const hasNameMatch = tokens.some(t => nameLower.startsWith(t) || nameJa.startsWith(t));
if (hasLocalIdMatch && hasNameMatch) return 5;
return 1;
}

const nameMatched = tokens.filter(t => nameFields.includes(t));
if (nameMatched.length >= 2 && (nameLower.startsWith(tokens[0]) || nameJa.startsWith(tokens[0]))) {
return 1 + Math.min(nameMatched.length / tokens.length, 1);
}

if (nameMatched.length >= 1 && tokens.some(t => nameLower.startsWith(t) || nameJa.startsWith(t))) {
return 1 + Math.min(nameMatched.length / tokens.length, 0.5);
}

return 0;
}

export function searchCards(query, limit = 8) {
if (!query || query.length < 2) return [];

const scored = [];
for (const card of cardIndex) {
const score = matchesQuery(card, query);
if (score > 0) scored.push({ card, score });
}

scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aImg = a.card.imageUrl ? 0 : 1;
const bImg = b.card.imageUrl ? 0 : 1;
if (aImg !== bImg) return aImg - bImg;
return (a.card.name || "").localeCompare(b.card.name || "");
});

return scored.slice(0, limit).map(({ card }) => ({
id: card.id,
cardId: buildCanonicalCardId(card.setCode, card.localId),
name: card.name,
nameJa: card.nameJa,
setCode: card.setCode,
setName: (card.setCode && SET_NAME_MAP[card.setCode.toLowerCase()]) || card.setCode || null,
localId: card.localId,
imageUrl: card.imageUrl,
}));
}
2 changes: 1 addition & 1 deletion lib/data/card-identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ function extractCardNumber(text) {
return m ? `${m[1]}/${m[2]}` : null;
}

const SET_TOTAL_MAP = {
export const SET_TOTAL_MAP = {
// Scarlet & Violet era (JP)
"78": "sv5k", // Wild Force
"81": "sv5m", // Cyber Judge (sub)
Expand Down
18 changes: 18 additions & 0 deletions lib/search/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ const BLOCKLIST = [
"art card",
"metal card",
"oversized",
"art case",
"extended art case",
"card case",
"case card",
"alloy case",
"acrylic case",
"display case",
"deck box",
"start deck",
"sleeves",
"playmat",
"binder",
"booster box",
"booster pack",
"etb",
"elite trainer",
"collection box",
"tin box",
];

/**
Expand Down
Loading
Loading