Skip to content
Open
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
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
"dex:audit:report": "bun scripts/pokedexIdFixer/audit-dex-ids.ts --report",
"dex:fix:dry-run": "bun scripts/pokedexIdFixer/fix-dex-ids.ts --dry-run",
"dex:fix:apply": "bun scripts/pokedexIdFixer/fix-dex-ids.ts --apply",
"dex:lint": "bun scripts/pokedexIdFixer/lint-dex-ids.ts"
"dex:lint": "bun scripts/pokedexIdFixer/lint-dex-ids.ts",

"pricing:update": "bun scripts/updatePricing.ts",
"pricing:update:debug": "bun scripts/updatePricing.ts --write-debug-artifact",
},
"devDependencies": {
"@dzeio/object-util": "^1.9.2",
Expand All @@ -22,4 +25,4 @@
"ts-node": "^10.0.0",
"typescript": "^5.0.0"
}
}
}
14 changes: 14 additions & 0 deletions scripts/preloadTCGPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ try {
await fs.writeFile(`${folder}/${id}.json`, JSON.stringify(products))
}

// Load prices (used as TCGCSV fallback by updatePricingFromTCGTracking)
console.log('Loading prices...')
const pricesFolder = baseFolder + '/prices'
await fs.mkdir(pricesFolder, { recursive: true })

for (const id of ids) {
console.log('Loading prices', id)
const prices = await fetch(`https://tcgcsv.com/tcgplayer/3/${id}/prices`, {
headers: { 'User-Agent': userAgent }
})
.then((it) => it.json())
await fs.writeFile(`${pricesFolder}/${id}.json`, JSON.stringify(prices))
}

console.log('done')

} catch (error) {
Expand Down
35 changes: 35 additions & 0 deletions scripts/pricing/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
interface CacheEntry<V> {
value: V
expiresAt: number
}

export class TTLCache<K, V> {
private map = new Map<K, CacheEntry<V>>()

set(key: K, value: V, ttlMs: number): void {
this.map.set(key, { value, expiresAt: Date.now() + ttlMs })
}

get(key: K): V | undefined {
const entry = this.map.get(key)

if (!entry) {
return undefined
}

if (Date.now() > entry.expiresAt) {
this.map.delete(key)
return undefined
}

return entry.value
}

has(key: K): boolean {
return this.get(key) !== undefined
}

delete(key: K): void {
this.map.delete(key)
}
}
26 changes: 26 additions & 0 deletions scripts/pricing/sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { TCGTrackingProvider } from './tcgtracking'
import { TCGPlayerProvider } from './tcgplayer'
import type { PricingProvider, PricingSourceName } from './types'

const PROVIDER_FACTORIES: Record<PricingSourceName, () => PricingProvider> = {
tcgtracking: () => new TCGTrackingProvider(),
tcgplayer: () => new TCGPlayerProvider(),
}

/**
* Returns pricing providers in the order defined by TCGDEX_PRICING_SOURCES.
*
* Default: tcgtracking,tcgplayer
*
* The first provider to return prices for a product wins. Providers that
* fail to load or have no data for a product are silently skipped.
*/
export function getPricingProvidersFromEnv(): PricingProvider[] {
const raw = process.env.TCGDEX_PRICING_SOURCES ?? 'tcgtracking,tcgplayer'

return raw
.split(',')
.map((s) => s.trim() as PricingSourceName)
.filter((name) => name in PROVIDER_FACTORIES)
.map((name) => PROVIDER_FACTORIES[name]())
}
124 changes: 124 additions & 0 deletions scripts/pricing/tcgplayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { TTLCache } from './cache'
import type { PricingProvider, ResolvedPrice } from './types'

// Same TTL as TCGTracking — prices update daily
const PRICING_TTL_MS = 12 * 60 * 60 * 1000

interface TCGCSVPricesResult {
productId: number
lowPrice: number | null
marketPrice: number | null
subTypeName: string
}

interface TCGCSVPricesFile {
success: boolean
results: TCGCSVPricesResult[]
}

type ProductPriceMap = Map<string, { low?: number; market?: number }>

export class TCGPlayerProvider implements PricingProvider {
readonly name = 'tcgplayer' as const

// productId → normalised-price-type → prices
private cache = new TTLCache<number, ProductPriceMap>()
// Deduplicate concurrent fetches for the same set
private inflight = new Map<number, Promise<void>>()

async getPrices(input: {
categoryId: number
tcgplayerSetId: number
tcgplayerProductId: number
}): Promise<ResolvedPrice[]> {
const productPricing = await this.loadProduct(
input.tcgplayerSetId,
input.tcgplayerProductId,
)

if (!productPricing) {
return []
}

const result: ResolvedPrice[] = []

for (const [priceType, price] of productPricing) {
if (typeof price.low === 'number' || typeof price.market === 'number') {
result.push({
source: 'tcgplayer',
productId: input.tcgplayerProductId,
priceType,
low: price.low,
market: price.market,
})
}
}

return result
}

private async loadProduct(
tcgplayerSetId: number,
tcgplayerProductId: number,
): Promise<ProductPriceMap | null> {
if (this.cache.has(tcgplayerProductId)) {
return this.cache.get(tcgplayerProductId) ?? null
}

// Fetch the whole set so all products in it are cached together
let pending = this.inflight.get(tcgplayerSetId)

if (!pending) {
pending = this.fetchSet(tcgplayerSetId)
this.inflight.set(tcgplayerSetId, pending)
}

try {
await pending
} finally {
this.inflight.delete(tcgplayerSetId)
}

return this.cache.get(tcgplayerProductId) ?? null
}

private async fetchSet(tcgplayerSetId: number): Promise<void> {
const userAgent = process.env.TCGCSV_USER_AGENT

if (!userAgent) {
return
}

try {
const res = await fetch(
`https://tcgcsv.com/tcgplayer/3/${tcgplayerSetId}/prices`,
{ headers: { 'User-Agent': userAgent } },
)

if (!res.ok) {
console.warn(
`TCGPlayer: set ${tcgplayerSetId} returned ${res.status} ${res.statusText}`,
)
return
}

const data = (await res.json()) as TCGCSVPricesFile

for (const result of data.results ?? []) {
const priceType = result.subTypeName.toLowerCase().replaceAll(' ', '-')
const map: ProductPriceMap = this.cache.get(result.productId) ?? new Map()

map.set(priceType, {
low: result.lowPrice ?? undefined,
market: result.marketPrice ?? undefined,
})

this.cache.set(result.productId, map, PRICING_TTL_MS)
}
} catch (error) {
console.warn(
`TCGPlayer: failed to load set ${tcgplayerSetId}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
126 changes: 126 additions & 0 deletions scripts/pricing/tcgtracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import {
fetchTCGTrackingSetPricing,
type TCGTrackingCategoryId,
type TCGTrackingProductPricing,
} from '../utils-data/tcgtracking'
import { TTLCache } from './cache'
import type { PricingProvider, ResolvedPrice } from './types'

// Prices update daily ~8 AM EST; 12-hour TTL means at most one stale window per day
const PRICING_TTL_MS = 12 * 60 * 60 * 1000

/**
* TCGTracking sometimes exposes a set under a different ID than the
* existing TCGDex / TCGPlayer set ID.
*
* Key: existing TCGDex / TCGPlayer set ID
* Value: TCGTracking set ID used for API fetching
*
* Lookups are still keyed by the original TCGDex ID so getPrices()
* stays compatible with existing card data.
*/
const TCGTRACKING_SET_ID_OVERRIDES: Record<number, number> = {
// Black Bolt: TCGDex/TCGPlayer ID 22325, TCGTracking ID 24325
22325: 24325,
}

interface SetPricingResponse {
set_id: number
updated: string
prices: Record<string, TCGTrackingProductPricing>
}

export class TCGTrackingProvider implements PricingProvider {
readonly name = 'tcgtracking' as const

private pricingCache = new TTLCache<string, SetPricingResponse>()
private inflight = new Map<string, Promise<SetPricingResponse | null>>()

async getPrices(input: {
categoryId: number
tcgplayerSetId: number
tcgplayerProductId: number
}): Promise<ResolvedPrice[]> {
const setPricing = await this.loadSet(
input.categoryId as TCGTrackingCategoryId,
input.tcgplayerSetId,
)

if (!setPricing) {
return []
}

const productPricing = setPricing.prices[String(input.tcgplayerProductId)]

if (!productPricing?.tcg) {
return []
}

return Object.entries(productPricing.tcg)
.map(([priceType, price]) => ({
source: 'tcgtracking' as const,
productId: input.tcgplayerProductId,
priceType,
low: price.low,
market: price.market,
}))
.filter(
(price) =>
typeof price.low === 'number' || typeof price.market === 'number',
)
}

private async loadSet(
categoryId: TCGTrackingCategoryId,
tcgplayerSetId: number,
): Promise<SetPricingResponse | null> {
const cacheKey = `${categoryId}:${tcgplayerSetId}`
const cached = this.pricingCache.get(cacheKey)

if (cached !== undefined) {
return cached
}

let pending = this.inflight.get(cacheKey)

if (!pending) {
pending = this.fetchSet(categoryId, tcgplayerSetId, cacheKey)
this.inflight.set(cacheKey, pending)
}

try {
return await pending
} finally {
this.inflight.delete(cacheKey)
}
}

private async fetchSet(
categoryId: TCGTrackingCategoryId,
tcgplayerSetId: number,
cacheKey: string,
): Promise<SetPricingResponse | null> {
const fetchSetId = TCGTRACKING_SET_ID_OVERRIDES[tcgplayerSetId] ?? tcgplayerSetId

try {
const data = (await fetchTCGTrackingSetPricing(
categoryId,
fetchSetId,
)) as SetPricingResponse

this.pricingCache.set(cacheKey, data, PRICING_TTL_MS)

return data
} catch (error) {
console.warn(
`TCGTracking: failed to load pricing for category ${categoryId}, set ${fetchSetId}: ${getErrorMessage(error)}`,
)

return null
}
}
}

function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
18 changes: 18 additions & 0 deletions scripts/pricing/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type PricingSourceName = 'tcgtracking' | 'tcgplayer'

export interface ResolvedPrice {
source: PricingSourceName
productId: number
priceType: string
low?: number
market?: number
}

export interface PricingProvider {
name: PricingSourceName
getPrices(input: {
categoryId: number
tcgplayerSetId: number
tcgplayerProductId: number
}): Promise<ResolvedPrice[]>
}
Loading
Loading