mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
TCGs
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* archidekt.ts
|
||||
* ---------------------------------------------------------------------------
|
||||
* Server-side helper for pulling Greg's public MTG decks from Archidekt into
|
||||
* the TCGs page. The site is SSR (output: 'server'), so this runs on the
|
||||
* server — no CORS or API key required (Archidekt's decks list endpoint is
|
||||
* public JSON).
|
||||
*
|
||||
* Endpoint: GET https://archidekt.com/api/decks/v3/?ownerUsername=<username>
|
||||
* -> { count, results: [{ id, name, featured, colors, size, deckFormat,
|
||||
* updatedAt, private, unlisted, theorycrafted, ... }] }
|
||||
*
|
||||
* Decks are cached to a filesystem file for `CACHE_TTL_MS` (default 1 hour) so
|
||||
* repeat page loads are instant and we're gentle on Archidekt. The cache is
|
||||
* stale-while-revalidate: if a refresh fails, we serve the stale data rather
|
||||
* than erroring. Every failure path returns an empty list — the page renders a
|
||||
* fallback link and never breaks.
|
||||
*/
|
||||
|
||||
/** Greg's Archidekt username. Change here to point at a different account. */
|
||||
export const ARCHIDEKT_USERNAME = 'Patiun';
|
||||
|
||||
/** How long the cached deck list is considered fresh. Default: 1 hour. */
|
||||
export const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const API_BASE = 'https://archidekt.com/api/decks/v3';
|
||||
const USER_AGENT = 'GregKilmerProfileSite/1.0 (+https://gregkilmer.com)';
|
||||
|
||||
/** Known Archidekt deckFormat ids → labels. Add as needed. */
|
||||
const FORMAT_LABELS: Record<number, string> = {
|
||||
1: 'Casual',
|
||||
2: 'Modern',
|
||||
3: 'Commander',
|
||||
5: 'Standard',
|
||||
7: 'Legacy',
|
||||
10: 'Pauper',
|
||||
14: 'Historic',
|
||||
16: 'Pioneer',
|
||||
37: 'Oathbreaker',
|
||||
45: 'Brawl',
|
||||
};
|
||||
|
||||
export interface ArchidektDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
/** Featured card art URL (may be empty). */
|
||||
featured: string;
|
||||
/** Mana color counts present in the deck. */
|
||||
colors: { W: number; U: number; B: number; R: number; G: number };
|
||||
/** Total card count. */
|
||||
size: number;
|
||||
/** Archidekt format id. */
|
||||
formatId: number;
|
||||
/** Human-readable format label (e.g. "Commander"). */
|
||||
formatLabel: string;
|
||||
/** ISO timestamp of last update. */
|
||||
updatedAt: string;
|
||||
/** Direct link to the deck on Archidekt. */
|
||||
href: string;
|
||||
theorycrafted: boolean;
|
||||
}
|
||||
|
||||
export const formatLabel = (id: number): string => FORMAT_LABELS[id] ?? 'Custom';
|
||||
|
||||
interface RawDeck {
|
||||
id: number;
|
||||
name: string;
|
||||
featured?: string;
|
||||
customFeatured?: string;
|
||||
colors?: { W?: number; U?: number; B?: number; R?: number; G?: number };
|
||||
size?: number;
|
||||
deckFormat?: number;
|
||||
updatedAt?: string;
|
||||
private?: boolean;
|
||||
unlisted?: boolean;
|
||||
theorycrafted?: boolean;
|
||||
}
|
||||
|
||||
const fileURL = (username: string): string =>
|
||||
// node_modules/.cache is gitignored and survives across dev reloads.
|
||||
new URL(
|
||||
`../node_modules/.cache/archidekt-${username}.json`,
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/**
|
||||
* Reads the cached decks for `username` if still fresh, else null.
|
||||
* Best-effort: any read/parse error returns null (treated as a miss).
|
||||
*/
|
||||
async function readCache(username: string): Promise<ArchidektDeck[] | null> {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const raw = await readFile(fileURL(username), 'utf8');
|
||||
const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] };
|
||||
if (!data?.fetchedAt || !Array.isArray(data.decks)) return null;
|
||||
if (Date.now() - data.fetchedAt > CACHE_TTL_MS) return null;
|
||||
return data.decks;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the cache. Failures are non-fatal (best-effort). */
|
||||
async function writeCache(username: string, decks: ArchidektDeck[]): Promise<void> {
|
||||
try {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises');
|
||||
const { dirname } = await import('node:path');
|
||||
const { fileURLToPath } = await import('node:url');
|
||||
const path = fileURLToPath(fileURL(username));
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify({ fetchedAt: Date.now(), decks }, null, 2));
|
||||
} catch {
|
||||
/* cache write is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads stale cache regardless of TTL (used as a fallback on fetch failure). */
|
||||
async function readStale(username: string): Promise<ArchidektDeck[] | null> {
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const raw = await readFile(fileURL(username), 'utf8');
|
||||
const data = JSON.parse(raw) as { fetchedAt: number; decks: ArchidektDeck[] };
|
||||
return Array.isArray(data?.decks) ? data.decks : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Greg's public Archidekt decks, using a ~1h filesystem cache.
|
||||
* Never throws — on any failure returns an empty list (page shows a fallback).
|
||||
*/
|
||||
export async function getDecks(username = ARCHIDEKT_USERNAME): Promise<ArchidektDeck[]> {
|
||||
// 1. Fresh cache hit.
|
||||
const cached = await readCache(username);
|
||||
if (cached) return cached;
|
||||
|
||||
// 2. Live fetch.
|
||||
const url = `${API_BASE}/?ownerUsername=${encodeURIComponent(username)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
// Don't let a slow Archidekt hang the page render.
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Archidekt responded ${res.status}`);
|
||||
const data = (await res.json()) as { results?: RawDeck[] };
|
||||
const decks: ArchidektDeck[] = (data.results ?? [])
|
||||
.filter((d) => !d.private && !d.unlisted)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
featured: d.customFeatured || d.featured || '',
|
||||
colors: {
|
||||
W: d.colors?.W ?? 0,
|
||||
U: d.colors?.U ?? 0,
|
||||
B: d.colors?.B ?? 0,
|
||||
R: d.colors?.R ?? 0,
|
||||
G: d.colors?.G ?? 0,
|
||||
},
|
||||
size: d.size ?? 0,
|
||||
formatId: d.deckFormat ?? 0,
|
||||
formatLabel: formatLabel(d.deckFormat ?? 0),
|
||||
updatedAt: d.updatedAt ?? '',
|
||||
href: `https://archidekt.com/fabric/decks/${d.id}`,
|
||||
theorycrafted: Boolean(d.theorycrafted),
|
||||
}));
|
||||
|
||||
await writeCache(username, decks);
|
||||
return decks;
|
||||
} catch {
|
||||
// 3. Stale-while-revalidate fallback, else empty.
|
||||
return (await readStale(username)) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The Archidekt search-URL for the full decks list (used in fallback links). */
|
||||
export const archidektSearchUrl = (username = ARCHIDEKT_USERNAME): string =>
|
||||
`https://archidekt.com/search/decks?ownerUsername=${encodeURIComponent(username)}`;
|
||||
Reference in New Issue
Block a user