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
19 changes: 18 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
branches: [main]

jobs:
test:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
Expand All @@ -15,3 +15,20 @@ jobs:
node-version: 24
- run: npm install
- run: node test/unit-test.js

smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
- run: npm install
- run: npx playwright install chromium --with-deps
- run: node test/smoke-test.js

test:
runs-on: ubuntu-latest
needs: [unit, smoke]
steps:
- run: echo "All tests passed"
41 changes: 27 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,24 +56,37 @@ yarn api # dashboard on localhost:3000
api.js Express API + static dashboard (public/)
index.js CLI entry point
lib/
ebay.js eBay Browse API, OAuth, ship-to filtering
grading.js AI pre-grading (Claude/OpenAI), validation, caching
psa.js PSA pop reports, cert lookup, grading signal
magi.js magi.camp scraper + Haiku JP translation
yahooauctions.js Yahoo Auctions JP scraper
snkrdunk.js SNKRDUNK JSON API
filters.js Language, relevance, slab detection, blocklist
listingQuery.js eBay search query builder
firestore.js Firestore: grade logs, drops, webhooks, cache
demo.js Sample data (3 cards with real listings)
api-keys.js Firestore-backed developer key management
price-history.js Sold comp price tracking over time
sources/
ebay.js eBay Browse API, OAuth, ship-to filtering
magi.js magi.camp scraper (fetch + cheerio)
snkrdunk.js SNKRDUNK JSON API
yahooauctions.js Yahoo Auctions JP scraper
tcgplayer.js TCGPlayer price seeding
grading/
grading.js AI pre-grading (per-subgrade, Claude/OpenAI)
psa.js PSA pop reports, cert lookup, grading signal
psaTiers.js PSA submission tier data
data/
firestore.js Firestore: grade logs, drops, webhooks, cache
api-keys.js Developer key management
card-identity.js Card identity: canonical IDs, set resolution
price-history.js Sold comp price tracking
demo.js Sample data (3 multi-source cards)
cache.js File-based cache (legacy)
redis-cache.js Redis cache (optional)
search/
filters.js Language, relevance, condition detection, outlier flagging
listingQuery.js eBay search query builder
ebayCategories.js eBay category IDs
output.js Markdown/JSON formatters (CLI)
swagger.js OpenAPI 3.0.3 spec
public/ Root dashboard (search, grade, arbitrage, price history)
public/admin/ Admin dashboard (keys, stats, errors)
extension/ Chrome extension: queue auto-join, drop intel
terraform/ GCP infra: Cloud Run ×2, Firestore, LB + CDN, Secret Manager
test/
unit-test.js 63 unit tests (filters, grading, query, demo data)
api-test.js 42 API integration tests
unit-test.js 81 unit tests
api-test.js 62 API integration tests
```

## Web Dashboard
Expand Down
2 changes: 1 addition & 1 deletion api.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ 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";
import { saveGradeLog, getGradeLogs, saveDrop, getDrops, getDrop, saveWebhook, getWebhooks, deleteWebhook, getFirestoreStatus, saveAlert, saveErrorLog, getErrorLogs, clearErrorLogs } from "./lib/data/firestore.js";
import { getDemoSearchResult, listDemoCards } from "./lib/demo.js";
import { getDemoSearchResult, listDemoCards } from "./lib/data/demo.js";
import { createApiKey, listApiKeys, getApiKey, updateApiKey, deleteApiKey, rotateApiKey, validateApiKey } from "./lib/data/api-keys.js";
import { recordSoldPrices, getPriceHistory } from "./lib/data/price-history.js";
import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js";
Expand Down
6 changes: 3 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@ import {
testGradingProvider,
printSiteGradingHelp,
} from "./lib/grading/grading.js";
import { writeMarkdown, writeJson, writePerCardJson, appendCombinedMarkdown, printSummary, mergeAndWrite } from "./lib/output.js";
import { writeMarkdown, writeJson, writePerCardJson, appendCombinedMarkdown, printSummary, mergeAndWrite } from "./lib/search/output.js";
import { buildEbaySearchQuery, describeListingSearch } from "./lib/search/listingQuery.js";
import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js";
import { searchMagi } from "./lib/sources/magi.js";
import { searchYahooAuctions } from "./lib/sources/yahooauctions.js";
import { searchSnkrdunk } from "./lib/sources/snkrdunk.js";
import { getPsaGradingSignal } from "./lib/grading/psa.js";
import { getDemoSearchResult, listDemoCards } from "./lib/demo.js";
import { getDemoSearchResult, listDemoCards } from "./lib/data/demo.js";

export const CARDS = [
"Giratina V Alt Art"
Expand Down Expand Up @@ -681,5 +681,5 @@ export {
getCachedGrade,
cacheGrade,
} from "./lib/grading/grading.js";
export { writeMarkdown, writeJson, writePerCardJson, appendCombinedMarkdown, printSummary } from "./lib/output.js";
export { writeMarkdown, writeJson, writePerCardJson, appendCombinedMarkdown, printSummary } from "./lib/search/output.js";
export { buildEbaySearchQuery, describeListingSearch } from "./lib/search/listingQuery.js";
File renamed without changes.
4 changes: 2 additions & 2 deletions lib/output.js → lib/search/output.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { GRADING_COMPANY_TIERS, PSA_TIERS } from "./grading/psaTiers.js";
import { GRADING_COMPANY_TIERS, PSA_TIERS } from "../grading/psaTiers.js";

const __dirname = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const __dirname = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const OUTPUT_DIR = path.join(__dirname, "output");

// results.json / results.md stay in root; everything else goes in output/
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test:unit": "node test/unit-test.js",
"test:api": "node test/api-test.js",
"test:live": "API_URL=https://api.casecomp.xyz node test/api-test.js",
"test:smoke": "node test/smoke-test.js",
"scan": "node scan.js",
"psa": "node scripts/psa-report.js",
"deploy": "gcloud builds submit --config=cloudbuild.yml --project casecomp-495718 && gcloud run deploy casecomp-api --image gcr.io/casecomp-495718/casecomp-api --region asia-south1 --project casecomp-495718 --port 3000 --allow-unauthenticated",
Expand Down
47 changes: 44 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let allItems = [];
let allActive = [];
let allSold = [];
let activeSourceFilter = "all";
let currentPsaSignal = null;

document.querySelectorAll(".hint").forEach(h => {
h.addEventListener("click", () => {
Expand Down Expand Up @@ -110,6 +111,7 @@ function render(data) {
${noteHtml}
`;

currentPsaSignal = data.psaSignal || null;
renderPsa(data.psaSignal);

allActive = active;
Expand Down Expand Up @@ -317,13 +319,16 @@ function selectItem(itemId) {
`;

const fields = [];
if (item.condition) fields.push({ label: "Condition", value: item.condition });
const condVal = item.condition
? (slabLabel ? `${item.condition} <span class="graded-badge">Graded</span>` : item.condition)
: (slabLabel ? `<span class="graded-badge">Graded</span>` : "");
if (condVal) fields.push({ label: "Condition", value: condVal, raw: true });
if (item.soldDate || item.endedDate) fields.push({ label: "Sold", value: item.soldDate || item.endedDate });
if (item.priceJPY) fields.push({ label: "JPY", value: `¥${item.priceJPY.toLocaleString()}` });
if (item.priceJPY) fields.push({ label: "Price (JPY)", value: `¥${item.priceJPY.toLocaleString()}` });
if (item.totalCost && item.totalCost !== item.price) fields.push({ label: "Item Price", value: formatPrice(item.price, item.priceCurrency) });

const fieldsHtml = fields.length ? `<div class="detail-grid">${fields.map(f => `
<div class="detail-field"><div class="detail-label">${esc(f.label)}</div><div class="detail-value">${esc(f.value)}</div></div>
<div class="detail-field"><div class="detail-label">${esc(f.label)}</div><div class="detail-value">${f.raw ? f.value : esc(f.value)}</div></div>
`).join("")}</div>` : "";

const gradeHtml = renderGradeDetail(grade);
Expand Down Expand Up @@ -356,6 +361,7 @@ function selectItem(itemId) {
${gradeHtml}
</div>
<div class="detail-tab-panel${defaultTab === "prices" ? "" : " hidden"}" data-dtpanel="prices">
${renderPsaInline(currentPsaSignal)}
<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>
Expand Down Expand Up @@ -552,6 +558,36 @@ function drawPriceChart(canvas, points) {
});
}

function renderPsaInline(psa) {
if (!psa) return "";
const diffClass = psa.difficulty === "easy" ? "easy" : psa.difficulty === "hard" || psa.difficulty === "brutal" ? "hard" : "moderate";
const gemPct = psa.gem10Pct != null ? psa.gem10Pct : null;
const tierLabel = psa.tier || "—";
const costLabel = psa.estCost ? `<span class="psa-inline-cost">${esc(psa.estCost)}</span>` : "";

return `<div class="psa-inline">
<div class="psa-inline-stat">
<div class="psa-inline-label">Gem</div>
<div class="gem-bar">
<span class="psa-inline-value">${gemPct != null ? gemPct + "%" : "—"}</span>
${gemPct != null ? `<div class="gem-bar-track"><div class="gem-bar-fill" style="width: ${gemPct}%"></div></div>` : ""}
</div>
</div>
<div class="psa-inline-stat">
<div class="psa-inline-label">Pop</div>
<div class="psa-inline-value">${psa.totalPop != null ? psa.totalPop.toLocaleString() : "—"}</div>
</div>
<div class="psa-inline-stat">
<div class="psa-inline-label">Difficulty</div>
<div class="psa-inline-value ${diffClass}">${esc(psa.difficulty || "—")}</div>
</div>
<div class="psa-inline-stat">
<div class="psa-inline-label">Tier</div>
<div class="psa-inline-value">${esc(tierLabel)}${costLabel}</div>
</div>
</div>`;
}

function renderGradeDetail(grade) {
if (!grade) return "";

Expand Down Expand Up @@ -619,6 +655,11 @@ function esc(s) {
return d.innerHTML;
}

const fadeObserver = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { e.target.classList.add("visible"); fadeObserver.unobserve(e.target); } });
}, { threshold: 0.1 });
document.querySelectorAll(".fade-up").forEach(el => fadeObserver.observe(el));

alertForm.addEventListener("submit", async (e) => {
e.preventDefault();
const email = document.getElementById("alert-email").value.trim();
Expand Down
37 changes: 20 additions & 17 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Casecomp — Pokemon TCG Card Research</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="icon" href="/logos/casecomp-logo.svg" type="image/svg+xml">
</head>
Expand All @@ -16,6 +16,7 @@
<div class="logo">
<img src="/logos/casecomp-logo.svg" alt="Casecomp" width="36" height="36">
<span>Casecomp</span>
<span class="beta-badge">Beta</span>
</div>
<nav>
<a href="/docs" class="nav-link">API Docs</a>
Expand All @@ -24,20 +25,22 @@

<main>
<section class="hero">
<h1>Research any Pokemon card<br>in seconds</h1>
<p class="subtitle">Live prices from eBay, magi.camp, Yahoo Auctions &amp; SNKRDUNK. PSA grading signals. No account needed.</p>
<h1 class="fade-up">Research any Pokemon card<br>in seconds</h1>
<p class="subtitle fade-up">Live prices from eBay, magi.camp, Yahoo Auctions &amp; SNKRDUNK. PSA grading signals. No account needed.</p>

<form id="search-form" autocomplete="off">
<div class="search-bar">
<input type="text" id="search-input" placeholder="Umbreon VMAX Alt Art, Charizard Base Set..." autofocus>
<button type="submit" id="search-btn">Search</button>
</div>
<div class="search-hints">
Try: <button type="button" class="hint" data-q="Pikachu ex SAR 234/193 PSA 10">Pikachu ex SAR PSA 10 (Multi-Source Slab)</button>
<button type="button" class="hint" data-q="Mega Greninja ex SAR" data-source="snkrdunk" data-condition="A">Mega Greninja ex SAR (SNKRDUNK + AI Grade)</button>
<button type="button" class="hint" data-q="Umbreon ex SAR 217/187">Umbreon ex SAR 217/187 (eBay JP + AI Grade)</button>
</div>
</form>
<div class="search-sticky">
<form id="search-form" autocomplete="off">
<div class="search-bar">
<input type="text" id="search-input" placeholder="Umbreon VMAX Alt Art, Charizard Base Set..." autofocus>
<button type="submit" id="search-btn">Search</button>
</div>
<div class="search-hints fade-up">
Try: <button type="button" class="hint" data-q="Pikachu ex SAR 234/193 PSA 10">Pikachu ex SAR PSA 10 (Multi-Source Slab)</button>
<button type="button" class="hint" data-q="Mega Greninja ex SAR" data-source="snkrdunk" data-condition="A">Mega Greninja ex SAR (SNKRDUNK + AI Grade)</button>
<button type="button" class="hint" data-q="Umbreon ex SAR 217/187">Umbreon ex SAR 217/187 (eBay JP + AI Grade)</button>
</div>
</form>
</div>
</section>

<section id="results" class="results hidden">
Expand Down Expand Up @@ -75,17 +78,17 @@ <h3>Get price alerts</h3>

<section id="empty-state" class="empty-state">
<div class="features">
<div class="feature">
<div class="feature fade-up" style="--i: 0">
<div class="feature-icon">$</div>
<h3>Live Prices</h3>
<p>Active listings and recent sold comps from eBay, magi.camp, Yahoo Auctions, and SNKRDUNK.</p>
</div>
<div class="feature">
<div class="feature fade-up" style="--i: 1">
<div class="feature-icon">PSA</div>
<h3>Grading Signal</h3>
<p>PSA population data, difficulty ratings, and PSA 10 probability to help you decide if grading is worth it.</p>
</div>
<div class="feature">
<div class="feature fade-up" style="--i: 2">
<div class="feature-icon">AI</div>
<h3>AI Pre-Grade</h3>
<p>Upload a photo and get an estimated PSA grade using AI analysis of centering, corners, edges, and surface.</p>
Expand Down
Loading