257 lines
6.8 KiB
JavaScript
257 lines
6.8 KiB
JavaScript
|
|
import Redis from 'ioredis';
|
||
|
|
import { sql } from '../sql.js';
|
||
|
|
import { isPriceStale } from './card-price.js';
|
||
|
|
import { buildOutboundLinks } from './outbound-links.js';
|
||
|
|
import { recordPriceSnapshots } from './price-history.js';
|
||
|
|
import { getTcgdexBaseUrl, parseTcgdexPricing } from './tcgdex-client.js';
|
||
|
|
import { fetchFolioPriceIndex, matchFolioPrice } from './foolio-client.js';
|
||
|
|
|
||
|
|
const REFRESH_LOCK_TTL_SEC = 60;
|
||
|
|
const REFRESH_LOCK_PREFIX = 'deckhearth:pricing-refresh:';
|
||
|
|
|
||
|
|
let redisClient = null;
|
||
|
|
|
||
|
|
function getRedis() {
|
||
|
|
const url = process.env.REDIS_URL;
|
||
|
|
if (!url) return null;
|
||
|
|
if (!redisClient) {
|
||
|
|
redisClient = new Redis(url, {
|
||
|
|
maxRetriesPerRequest: 1,
|
||
|
|
enableOfflineQueue: false,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return redisClient;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} cardId
|
||
|
|
* @returns {Promise<boolean>}
|
||
|
|
*/
|
||
|
|
async function acquireRefreshLock(cardId) {
|
||
|
|
const redis = getRedis();
|
||
|
|
if (!redis) return true;
|
||
|
|
try {
|
||
|
|
const result = await redis.set(
|
||
|
|
`${REFRESH_LOCK_PREFIX}${cardId}`,
|
||
|
|
'1',
|
||
|
|
'EX',
|
||
|
|
REFRESH_LOCK_TTL_SEC,
|
||
|
|
'NX'
|
||
|
|
);
|
||
|
|
return result === 'OK';
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[pricing-service] lock acquire failed', err);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} cardId
|
||
|
|
*/
|
||
|
|
async function releaseRefreshLock(cardId) {
|
||
|
|
const redis = getRedis();
|
||
|
|
if (!redis) return;
|
||
|
|
try {
|
||
|
|
await redis.del(`${REFRESH_LOCK_PREFIX}${cardId}`);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('[pricing-service] lock release failed', err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {string} scryfallId
|
||
|
|
*/
|
||
|
|
async function fetchScryfallPrices(scryfallId) {
|
||
|
|
const response = await fetch(`https://api.scryfall.com/cards/${scryfallId}`, {
|
||
|
|
headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' },
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Scryfall API error: ${response.status}`);
|
||
|
|
}
|
||
|
|
const card = await response.json();
|
||
|
|
let currentPrice = null;
|
||
|
|
let marketPrice = null;
|
||
|
|
if (card.prices) {
|
||
|
|
currentPrice = card.prices.usd ? parseFloat(card.prices.usd) : null;
|
||
|
|
marketPrice = card.prices.usd_foil ? parseFloat(card.prices.usd_foil) : null;
|
||
|
|
}
|
||
|
|
return { currentPrice, marketPrice, currency: 'USD' };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {{ set_code?: string, card_number?: string, name?: string }} card
|
||
|
|
*/
|
||
|
|
async function fetchPokemonPrices(card) {
|
||
|
|
const setCode = card.set_code;
|
||
|
|
if (!setCode) return null;
|
||
|
|
|
||
|
|
const base = getTcgdexBaseUrl();
|
||
|
|
if (!card.card_number) return null;
|
||
|
|
|
||
|
|
const paths = [
|
||
|
|
`/cards/${encodeURIComponent(setCode)}-${encodeURIComponent(card.card_number)}`,
|
||
|
|
];
|
||
|
|
|
||
|
|
for (const path of paths) {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`${base}${path}`, {
|
||
|
|
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
|
||
|
|
});
|
||
|
|
if (!response.ok) continue;
|
||
|
|
const tcgCard = await response.json();
|
||
|
|
const parsed = parseTcgdexPricing(tcgCard);
|
||
|
|
if (parsed.currentPrice != null || parsed.marketPrice != null) {
|
||
|
|
return {
|
||
|
|
currentPrice: parsed.currentPrice,
|
||
|
|
marketPrice: parsed.marketPrice,
|
||
|
|
currency: parsed.currency ?? 'USD',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// try next path
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {{ name?: string, set_code?: string, scryfall_id?: string }} card
|
||
|
|
*/
|
||
|
|
async function fetchLorcanaPrices(card) {
|
||
|
|
const index = await fetchFolioPriceIndex();
|
||
|
|
const matched = matchFolioPrice(index, {
|
||
|
|
name: card.name,
|
||
|
|
setCode: card.set_code,
|
||
|
|
scryfallId: card.scryfall_id,
|
||
|
|
});
|
||
|
|
if (!matched) return null;
|
||
|
|
return {
|
||
|
|
currentPrice: matched.currentPrice,
|
||
|
|
marketPrice: matched.marketPrice,
|
||
|
|
currency: matched.currency ?? 'USD',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {Record<string, unknown>} card
|
||
|
|
*/
|
||
|
|
async function refreshLivePrices(card) {
|
||
|
|
const game = String(card.game || '').toLowerCase();
|
||
|
|
let prices = null;
|
||
|
|
|
||
|
|
if (game === 'mtg' && card.scryfall_id) {
|
||
|
|
prices = await fetchScryfallPrices(card.scryfall_id);
|
||
|
|
} else if (game === 'pokemon') {
|
||
|
|
prices = await fetchPokemonPrices(card);
|
||
|
|
} else if (game === 'lorcana') {
|
||
|
|
prices = await fetchLorcanaPrices(card);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!prices || (prices.currentPrice == null && prices.marketPrice == null)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
await sql`
|
||
|
|
UPDATE cards
|
||
|
|
SET
|
||
|
|
current_price = ${prices.currentPrice},
|
||
|
|
market_price = ${prices.marketPrice},
|
||
|
|
prices_updated_at = CURRENT_TIMESTAMP,
|
||
|
|
updated_at = CURRENT_TIMESTAMP
|
||
|
|
WHERE id = ${card.id}
|
||
|
|
`;
|
||
|
|
|
||
|
|
const snapshots = [];
|
||
|
|
if (prices.currentPrice != null) {
|
||
|
|
snapshots.push({
|
||
|
|
cardId: card.id,
|
||
|
|
source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
|
||
|
|
priceType: 'market',
|
||
|
|
currency: prices.currency ?? 'USD',
|
||
|
|
amount: prices.currentPrice,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (prices.marketPrice != null && prices.marketPrice !== prices.currentPrice) {
|
||
|
|
snapshots.push({
|
||
|
|
cardId: card.id,
|
||
|
|
source: game === 'mtg' ? 'scryfall' : game === 'pokemon' ? 'tcgdex' : 'foilio',
|
||
|
|
priceType: 'foil',
|
||
|
|
currency: prices.currency ?? 'USD',
|
||
|
|
amount: prices.marketPrice,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
if (snapshots.length) {
|
||
|
|
await recordPriceSnapshots(snapshots);
|
||
|
|
}
|
||
|
|
|
||
|
|
return prices;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {number} cardId
|
||
|
|
* @returns {Promise<null | {
|
||
|
|
* prices: { current: number | null, market: number | null, currency: string },
|
||
|
|
* pricesUpdatedAt: string | null,
|
||
|
|
* stale: boolean,
|
||
|
|
* outbound: { tcgplayer: string, ebay: string },
|
||
|
|
* }>}
|
||
|
|
*/
|
||
|
|
export async function getCardPricing(cardId) {
|
||
|
|
const result = await sql`
|
||
|
|
SELECT
|
||
|
|
id, name, game, set_name, set_code, card_number,
|
||
|
|
current_price, market_price, prices_updated_at, scryfall_id
|
||
|
|
FROM cards
|
||
|
|
WHERE id = ${cardId}
|
||
|
|
`;
|
||
|
|
|
||
|
|
if (result.rows.length === 0) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
let card = result.rows[0];
|
||
|
|
let stale = isPriceStale(card.prices_updated_at);
|
||
|
|
|
||
|
|
if (stale) {
|
||
|
|
const gotLock = await acquireRefreshLock(cardId);
|
||
|
|
if (gotLock) {
|
||
|
|
try {
|
||
|
|
await refreshLivePrices(card).catch((err) => {
|
||
|
|
console.error('[pricing-service] live refresh failed', err);
|
||
|
|
});
|
||
|
|
|
||
|
|
const refreshed = await sql`
|
||
|
|
SELECT
|
||
|
|
id, name, game, set_name, set_code, card_number,
|
||
|
|
current_price, market_price, prices_updated_at, scryfall_id
|
||
|
|
FROM cards
|
||
|
|
WHERE id = ${cardId}
|
||
|
|
`;
|
||
|
|
if (refreshed.rows.length) {
|
||
|
|
card = refreshed.rows[0];
|
||
|
|
stale = isPriceStale(card.prices_updated_at);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
await releaseRefreshLock(cardId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const current = card.current_price != null ? parseFloat(card.current_price) : null;
|
||
|
|
const market = card.market_price != null ? parseFloat(card.market_price) : null;
|
||
|
|
|
||
|
|
return {
|
||
|
|
prices: {
|
||
|
|
current: Number.isFinite(current) ? current : null,
|
||
|
|
market: Number.isFinite(market) ? market : null,
|
||
|
|
currency: 'USD',
|
||
|
|
},
|
||
|
|
pricesUpdatedAt: card.prices_updated_at
|
||
|
|
? new Date(card.prices_updated_at).toISOString()
|
||
|
|
: null,
|
||
|
|
stale,
|
||
|
|
outbound: buildOutboundLinks(card),
|
||
|
|
};
|
||
|
|
}
|