All checks were successful
Deploy production / deploy (push) Successful in 1m18s
- cs.skins catalog (weapons, knives, gloves, stickers, cases, agents, charms, …) refreshed weekly - every market config: wears in float range, StatTrak, Souvenir, Doppler/Gamma phases - cs.daily_prices (one row per config/stream/day, idempotent) from Skinport, Steam, CSFloat, Buff163 - cs.price_windows for 24h/7d/30d/90d sale aggregates, cs.fx_rates (ECB) - scripts/cs-prices.ts daily|catalog|prices; legacy ingest fixed for the new unique index - skin browser + /projects/cs-prices/[slug] with variant/wear/phase switcher and real charts - charts show points for short histories and pad sparse time axes
625 lines
23 KiB
TypeScript
625 lines
23 KiB
TypeScript
/**
|
|
* CS2 catalog + daily prices (all configs) into cs.*. Runs inside the app container:
|
|
*
|
|
* bun scripts/cs-prices.ts daily catalog if older than 6 days, then today's prices
|
|
* bun scripts/cs-prices.ts catalog refresh the full catalog from ByMykel/CSGO-API
|
|
* bun scripts/cs-prices.ts prices today's prices from every bulk source
|
|
*
|
|
* One row per config per stream per UTC day (cs.daily_prices, idempotent upsert), so a rerun on
|
|
* the same day just refreshes that day. Bulk endpoints only, no per-item Steam scraping:
|
|
* - Skinport /v1/items (EUR, lowest listing + quantity, Doppler phases via `version`)
|
|
* - Skinport /v1/sales/history (EUR, 24h/7d/30d/90d sale aggregates)
|
|
* - prices.csgotrader.app (USD): Steam Market average sales (24h, else 7d), CSFloat and
|
|
* Buff163 lowest listings (with Doppler phases)
|
|
* USD→EUR comes from the ECB rate (Frankfurter) and is stored per day in cs.fx_rates.
|
|
*/
|
|
import postgres from 'postgres';
|
|
import { dimensionsFromName } from '../src/lib/cs-prices/taxonomy';
|
|
import { parseConfig, slugify } from '../src/lib/cs-prices/catalog';
|
|
|
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
|
const sql = postgres(process.env.DB_URL, { max: 2, onnotice: () => {} });
|
|
const mode = process.argv[2] ?? 'daily';
|
|
const day = new Date().toISOString().slice(0, 10);
|
|
const ua = { 'User-Agent': 'stanrunge.dev cs-prices/2.0 (daily personal price archive)' };
|
|
const bymykel = 'https://raw.githubusercontent.com/ByMykel/CSGO-API/main/public/api/en';
|
|
|
|
type Skin = {
|
|
id: string;
|
|
slug: string;
|
|
name: string;
|
|
kind: string;
|
|
weapon: string | null;
|
|
pattern: string | null;
|
|
category: string | null;
|
|
rarity: string | null;
|
|
rarity_color: string | null;
|
|
image: string | null;
|
|
min_float: number | null;
|
|
max_float: number | null;
|
|
collections: string[] | null;
|
|
source: string;
|
|
};
|
|
type Item = {
|
|
market_hash_name: string;
|
|
phase: string | null;
|
|
skin_id: string;
|
|
name: string;
|
|
icon_url: string | null;
|
|
item_type: string | null;
|
|
wear: string | null;
|
|
stattrak: boolean;
|
|
souvenir: boolean;
|
|
};
|
|
|
|
async function getJson<T>(
|
|
url: string,
|
|
headers: Record<string, string> = {},
|
|
tries = 4
|
|
): Promise<T> {
|
|
let last: unknown;
|
|
for (let attempt = 0; attempt < tries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: { ...ua, ...headers },
|
|
signal: AbortSignal.timeout(180_000)
|
|
});
|
|
if (response.ok) return (await response.json()) as T;
|
|
last = new Error(`HTTP ${response.status} for ${new URL(url).host}${new URL(url).pathname}`);
|
|
if (response.status !== 429 && response.status < 500) break;
|
|
} catch (cause) {
|
|
last = cause;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 5000 * 2 ** attempt));
|
|
}
|
|
throw last;
|
|
}
|
|
|
|
const configKey = (hash: string, phase: string | null) => `${hash}\u0000${phase ?? ''}`;
|
|
const cents = (value: unknown) =>
|
|
typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < 21_000_000
|
|
? Math.round(value * 100)
|
|
: null;
|
|
|
|
function kindFor(file: string, category?: string): string {
|
|
if (file === 'skins_not_grouped') {
|
|
if (category === 'Knives') return 'knife';
|
|
if (category === 'Gloves') return 'gloves';
|
|
return 'weapon';
|
|
}
|
|
return (
|
|
{
|
|
stickers: 'sticker',
|
|
crates: 'container',
|
|
agents: 'agent',
|
|
keychains: 'charm',
|
|
collectibles: 'collectible',
|
|
graffiti: 'graffiti',
|
|
patches: 'patch',
|
|
music_kits: 'music-kit',
|
|
keys: 'key'
|
|
}[file] ?? 'other'
|
|
);
|
|
}
|
|
|
|
/** Feed-only names (e.g. sticker slabs, highlight charms) still get a skin page. */
|
|
function kindFromName(hash: string) {
|
|
const { base, starred } = parseConfig(hash);
|
|
const head = base.split(' | ')[0];
|
|
if (starred) return /Gloves|Wraps/.test(head) ? 'gloves' : 'knife';
|
|
if (/^Sticker Slab$|^Charm$|Highlight/.test(head) || /^Souvenir Charm/.test(hash)) return 'charm';
|
|
if (/^Sticker$/.test(head)) return 'sticker';
|
|
if (/^(Sealed )?Graffiti$/.test(head)) return 'graffiti';
|
|
if (/^Patch$/.test(head)) return 'patch';
|
|
if (/^Music Kit$/.test(head)) return 'music-kit';
|
|
if (/Case|Capsule|Package|Container|Collection/.test(base)) return 'container';
|
|
if (dimensionsFromName(hash).category === 'weapon') return 'weapon';
|
|
return 'other';
|
|
}
|
|
|
|
async function upsertSkins(skins: Skin[]) {
|
|
for (let offset = 0; offset < skins.length; offset += 2000) {
|
|
const chunk = skins.slice(offset, offset + 2000);
|
|
await sql`
|
|
INSERT INTO cs.skins (id, slug, name, kind, weapon, pattern, category, rarity, rarity_color,
|
|
image, min_float, max_float, collections, source, updated_at)
|
|
SELECT id, slug, name, kind, weapon, pattern, category, rarity, rarity_color, image,
|
|
min_float, max_float, collections, source, now()
|
|
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(id text, slug text, name text,
|
|
kind text, weapon text, pattern text, category text, rarity text, rarity_color text,
|
|
image text, min_float real, max_float real, collections text[], source text)
|
|
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, kind = EXCLUDED.kind,
|
|
weapon = coalesce(EXCLUDED.weapon, cs.skins.weapon),
|
|
pattern = coalesce(EXCLUDED.pattern, cs.skins.pattern),
|
|
category = coalesce(EXCLUDED.category, cs.skins.category),
|
|
rarity = coalesce(EXCLUDED.rarity, cs.skins.rarity),
|
|
rarity_color = coalesce(EXCLUDED.rarity_color, cs.skins.rarity_color),
|
|
image = coalesce(EXCLUDED.image, cs.skins.image),
|
|
min_float = coalesce(EXCLUDED.min_float, cs.skins.min_float),
|
|
max_float = coalesce(EXCLUDED.max_float, cs.skins.max_float),
|
|
collections = coalesce(EXCLUDED.collections, cs.skins.collections),
|
|
source = CASE WHEN EXCLUDED.source = 'bymykel' THEN 'bymykel' ELSE cs.skins.source END,
|
|
updated_at = CASE WHEN EXCLUDED.source = 'bymykel' THEN now() ELSE cs.skins.updated_at END`;
|
|
}
|
|
}
|
|
|
|
async function upsertItems(items: Item[]) {
|
|
const ids = new Map<string, number>();
|
|
for (let offset = 0; offset < items.length; offset += 2000) {
|
|
const chunk = items.slice(offset, offset + 2000);
|
|
const rows = await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
|
|
INSERT INTO cs.items (market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
|
|
stattrak, souvenir)
|
|
SELECT market_hash_name, phase, skin_id, name, icon_url, item_type, wear,
|
|
coalesce(stattrak, false), coalesce(souvenir, false)
|
|
FROM jsonb_to_recordset(${sql.json(chunk as never)}::jsonb) AS x(market_hash_name text,
|
|
phase text, skin_id text, name text, icon_url text, item_type text, wear text,
|
|
stattrak boolean, souvenir boolean)
|
|
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
|
|
skin_id = EXCLUDED.skin_id,
|
|
name = EXCLUDED.name,
|
|
icon_url = coalesce(EXCLUDED.icon_url, cs.items.icon_url),
|
|
item_type = coalesce(EXCLUDED.item_type, cs.items.item_type),
|
|
wear = EXCLUDED.wear, stattrak = EXCLUDED.stattrak, souvenir = EXCLUDED.souvenir,
|
|
last_seen_at = now()
|
|
RETURNING id, market_hash_name, phase`;
|
|
for (const row of rows) ids.set(configKey(row.market_hash_name, row.phase), row.id);
|
|
// Facets for the /projects/cs-prices compare explorer.
|
|
const dims: { item_id: number; key: string; value: string }[] = [];
|
|
const byKey = new Map(
|
|
chunk.map((item) => [configKey(item.market_hash_name, item.phase), item])
|
|
);
|
|
for (const row of rows) {
|
|
const item = byKey.get(configKey(row.market_hash_name, row.phase));
|
|
const facets = dimensionsFromName(row.market_hash_name);
|
|
if (row.phase) facets.phase = row.phase;
|
|
if (item?.item_type) facets.steam_type = item.item_type;
|
|
for (const [key, value] of Object.entries(facets))
|
|
if (value) dims.push({ item_id: row.id, key, value });
|
|
}
|
|
if (dims.length)
|
|
await sql`
|
|
INSERT INTO cs.item_dimensions (item_id, key, value)
|
|
SELECT item_id, key, value FROM jsonb_to_recordset(${sql.json(dims as never)}::jsonb)
|
|
AS x(item_id int, key text, value text)
|
|
ON CONFLICT (item_id, key) DO UPDATE SET value = EXCLUDED.value`;
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
type Raw = Record<string, unknown> & {
|
|
id: string;
|
|
name: string;
|
|
market_hash_name?: string | null;
|
|
image?: string | null;
|
|
phase?: string | null;
|
|
skin_id?: string;
|
|
weapon?: { name?: string } | null;
|
|
pattern?: { name?: string } | null;
|
|
category?: { name?: string } | null;
|
|
rarity?: { name?: string; color?: string } | null;
|
|
min_float?: number | null;
|
|
max_float?: number | null;
|
|
collections?: { name?: string }[] | null;
|
|
type?: string | null;
|
|
};
|
|
|
|
async function catalog() {
|
|
const files = [
|
|
'skins_not_grouped',
|
|
'stickers',
|
|
'crates',
|
|
'agents',
|
|
'keychains',
|
|
'collectibles',
|
|
'graffiti',
|
|
'patches',
|
|
'music_kits',
|
|
'keys'
|
|
];
|
|
const skins = new Map<string, Skin>();
|
|
const items = new Map<string, Item>();
|
|
const slugOwner = new Map<string, string>();
|
|
const existing = await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`;
|
|
for (const row of existing) slugOwner.set(row.slug, row.id);
|
|
const skinFor = (base: string) => {
|
|
// The grouping key is the base name; the id stays stable once assigned.
|
|
const slug = slugify(base);
|
|
return slugOwner.get(slug) ?? slug;
|
|
};
|
|
for (const file of files) {
|
|
const rows = await getJson<Raw[]>(`${bymykel}/${file}.json`);
|
|
console.log(`ByMykel ${file}: ${rows.length}`);
|
|
for (const raw of rows) {
|
|
if (!raw.market_hash_name) continue;
|
|
const config = parseConfig(raw.market_hash_name);
|
|
const kind = kindFor(file, raw.category?.name);
|
|
const id = skinFor(config.base);
|
|
slugOwner.set(slugify(config.base), id);
|
|
const current = skins.get(id);
|
|
const preferImage = !raw.phase || raw.phase === 'Phase 2';
|
|
if (!current)
|
|
skins.set(id, {
|
|
id,
|
|
slug: slugify(config.base),
|
|
name: config.display,
|
|
kind,
|
|
weapon: raw.weapon?.name ?? null,
|
|
pattern: raw.pattern?.name ?? null,
|
|
category: raw.category?.name ?? raw.type ?? null,
|
|
rarity: raw.rarity?.name ?? null,
|
|
rarity_color: raw.rarity?.color ?? null,
|
|
image: preferImage ? (raw.image ?? null) : null,
|
|
min_float: raw.min_float ?? null,
|
|
max_float: raw.max_float ?? null,
|
|
collections: raw.collections?.map((c) => c.name ?? '').filter(Boolean) ?? null,
|
|
source: 'bymykel'
|
|
});
|
|
else if (!current.image && preferImage && raw.image) current.image = raw.image;
|
|
const add = (phase: string | null) =>
|
|
items.set(configKey(raw.market_hash_name!, phase), {
|
|
market_hash_name: raw.market_hash_name!,
|
|
phase,
|
|
skin_id: id,
|
|
name: phase ? `${raw.market_hash_name} - ${phase}` : raw.market_hash_name!,
|
|
icon_url: raw.image ?? null,
|
|
item_type: raw.category?.name ?? raw.type ?? kind,
|
|
wear: config.wear,
|
|
stattrak: config.stattrak,
|
|
souvenir: config.souvenir
|
|
});
|
|
add(raw.phase ?? null);
|
|
// The phase-less listing carries Steam's and other phase-blind prices.
|
|
if (raw.phase && !items.has(configKey(raw.market_hash_name, null))) add(null);
|
|
}
|
|
}
|
|
const itemList = [...items.values()];
|
|
for (const skin of skins.values())
|
|
if (!skin.image)
|
|
skin.image =
|
|
itemList.find((item) => item.skin_id === skin.id && item.icon_url)?.icon_url ?? null;
|
|
// Phase-less rows of Doppler listings should not show one phase's image.
|
|
for (const item of items.values())
|
|
if (!item.phase && /Doppler/.test(item.name)) item.icon_url = null;
|
|
await upsertSkins([...skins.values()]);
|
|
const ids = await upsertItems([...items.values()]);
|
|
// Older Steam-only rows: link them to a skin by name.
|
|
await sql`UPDATE cs.items i SET skin_id = s.id FROM cs.skins s
|
|
WHERE i.skin_id IS NULL AND s.slug = regexp_replace(regexp_replace(lower(regexp_replace(
|
|
regexp_replace(regexp_replace(i.market_hash_name, '^★ ', ''), '^(StatTrak™ |Souvenir )', ''),
|
|
' \\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\\)$', '')),
|
|
'[^a-z0-9]+', '-', 'g'), '^-+|-+$', '', 'g')`;
|
|
console.log(`Catalog: ${skins.size} skins, ${ids.size} configs`);
|
|
}
|
|
|
|
type Row = { hash: string; phase: string | null; cents: number; volume: number | null };
|
|
|
|
async function ensureItems(rows: Row[]) {
|
|
const known = new Map<string, number>();
|
|
for (const row of await sql<{ id: number; market_hash_name: string; phase: string | null }[]>`
|
|
SELECT id, market_hash_name, phase FROM cs.items`)
|
|
known.set(configKey(row.market_hash_name, row.phase), row.id);
|
|
const missing = new Map<string, Item>();
|
|
const newSkins = new Map<string, Skin>();
|
|
const slugs = new Map(
|
|
(await sql<{ id: string; slug: string }[]>`SELECT id, slug FROM cs.skins`).map((r) => [
|
|
r.slug,
|
|
r.id
|
|
])
|
|
);
|
|
for (const row of rows) {
|
|
const key = configKey(row.hash, row.phase);
|
|
if (known.has(key) || missing.has(key)) continue;
|
|
// Untranslated tokens and truncated names in the feeds are not real listings.
|
|
if (/^#|\|\s*$|^\s*$/.test(row.hash) || row.hash.length > 200) continue;
|
|
const config = parseConfig(row.hash);
|
|
const slug = slugify(config.base);
|
|
let skinId = slugs.get(slug);
|
|
if (!skinId) {
|
|
skinId = slug;
|
|
slugs.set(slug, skinId);
|
|
newSkins.set(skinId, {
|
|
id: skinId,
|
|
slug,
|
|
name: config.display,
|
|
kind: kindFromName(row.hash),
|
|
weapon: null,
|
|
pattern: null,
|
|
category: null,
|
|
rarity: null,
|
|
rarity_color: null,
|
|
image: null,
|
|
min_float: null,
|
|
max_float: null,
|
|
collections: null,
|
|
source: 'market'
|
|
});
|
|
}
|
|
missing.set(key, {
|
|
market_hash_name: row.hash,
|
|
phase: row.phase,
|
|
skin_id: skinId,
|
|
name: row.phase ? `${row.hash} - ${row.phase}` : row.hash,
|
|
icon_url: null,
|
|
item_type: null,
|
|
wear: config.wear,
|
|
stattrak: config.stattrak,
|
|
souvenir: config.souvenir
|
|
});
|
|
}
|
|
if (newSkins.size) await upsertSkins([...newSkins.values()]);
|
|
if (missing.size) {
|
|
for (const [key, id] of await upsertItems([...missing.values()])) known.set(key, id);
|
|
console.log(`New configs from price feeds: ${missing.size} (${newSkins.size} new skins)`);
|
|
}
|
|
return known;
|
|
}
|
|
|
|
async function streamId(source: string, currency: string, kind: string) {
|
|
const [row] = await sql<{ id: number }[]>`
|
|
INSERT INTO cs.price_streams (source, currency, kind) VALUES (${source}, ${currency}, ${kind})
|
|
ON CONFLICT (source, currency, kind) DO UPDATE SET source = EXCLUDED.source RETURNING id`;
|
|
return row.id;
|
|
}
|
|
|
|
async function saveDaily(source: string, currency: string, kind: string, rows: Row[]) {
|
|
const stream = await streamId(source, currency, kind);
|
|
const ids = await ensureItems(rows);
|
|
const dedup = new Map<number, Row>();
|
|
for (const row of rows) {
|
|
const id = ids.get(configKey(row.hash, row.phase));
|
|
if (id) dedup.set(id, row);
|
|
}
|
|
const entries = [...dedup.entries()];
|
|
for (let offset = 0; offset < entries.length; offset += 5000) {
|
|
const chunk = entries.slice(offset, offset + 5000);
|
|
await sql`
|
|
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
|
SELECT item_id, ${stream}::smallint, ${day}::date, price_cents, volume
|
|
FROM unnest(${chunk.map(([id]) => id)}::int[], ${chunk.map(([, r]) => r.cents)}::int[],
|
|
${chunk.map(([, r]) => r.volume)}::int[]) AS x(item_id, price_cents, volume)
|
|
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
|
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
|
}
|
|
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${entries.length},
|
|
last_error = NULL WHERE id = ${stream}`;
|
|
console.log(`${source} ${kind}: ${entries.length} configs for ${day}`);
|
|
return ids;
|
|
}
|
|
|
|
type Window = {
|
|
item: number;
|
|
span: string;
|
|
median: number | null;
|
|
avg: number | null;
|
|
min: number | null;
|
|
max: number | null;
|
|
volume: number | null;
|
|
};
|
|
async function saveWindows(source: string, currency: string, windows: Window[]) {
|
|
for (let offset = 0; offset < windows.length; offset += 5000) {
|
|
const chunk = windows.slice(offset, offset + 5000);
|
|
await sql`
|
|
INSERT INTO cs.price_windows (item_id, source, currency, span, median_cents, avg_cents,
|
|
min_cents, max_cents, volume, updated_at)
|
|
SELECT item_id, ${source}, ${currency}, span, median_cents, avg_cents, min_cents, max_cents,
|
|
volume, now()
|
|
FROM unnest(${chunk.map((w) => w.item)}::int[], ${chunk.map((w) => w.span)}::text[],
|
|
${chunk.map((w) => w.median)}::int[], ${chunk.map((w) => w.avg)}::int[],
|
|
${chunk.map((w) => w.min)}::int[], ${chunk.map((w) => w.max)}::int[],
|
|
${chunk.map((w) => w.volume)}::int[])
|
|
AS x(item_id, span, median_cents, avg_cents, min_cents, max_cents, volume)
|
|
ON CONFLICT (item_id, source, span) DO UPDATE SET median_cents = EXCLUDED.median_cents,
|
|
avg_cents = EXCLUDED.avg_cents, min_cents = EXCLUDED.min_cents,
|
|
max_cents = EXCLUDED.max_cents, volume = EXCLUDED.volume, updated_at = now()`;
|
|
}
|
|
}
|
|
|
|
async function fx() {
|
|
for (const url of [
|
|
'https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR',
|
|
'https://api.frankfurter.app/latest?from=USD&to=EUR'
|
|
]) {
|
|
try {
|
|
const data = await getJson<{ rates?: { EUR?: number } }>(url, {}, 2);
|
|
if (data.rates?.EUR) {
|
|
await sql`INSERT INTO cs.fx_rates (day, usd_eur) VALUES (${day}, ${data.rates.EUR})
|
|
ON CONFLICT (day) DO UPDATE SET usd_eur = EXCLUDED.usd_eur`;
|
|
console.log(`FX USD→EUR ${data.rates.EUR}`);
|
|
return;
|
|
}
|
|
} catch (cause) {
|
|
console.warn(`FX ${new URL(url).host}: ${String(cause)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function guarded(
|
|
source: string,
|
|
currency: string,
|
|
kinds: string[],
|
|
run: () => Promise<void>
|
|
) {
|
|
try {
|
|
await run();
|
|
return true;
|
|
} catch (cause) {
|
|
console.error(`${source} failed: ${String(cause)}`);
|
|
for (const kind of kinds)
|
|
await sql`UPDATE cs.price_streams SET last_error = ${String(cause).slice(0, 500)}
|
|
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}`;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const spans = { last_24_hours: '24h', last_7_days: '7d', last_30_days: '30d', last_90_days: '90d' };
|
|
|
|
async function prices() {
|
|
await fx();
|
|
const results: boolean[] = [];
|
|
const br = { 'Accept-Encoding': 'br' };
|
|
results.push(
|
|
await guarded('skinport', 'EUR', ['lowest-listing'], async () => {
|
|
const items = await getJson<
|
|
{
|
|
market_hash_name: string;
|
|
version: string | null;
|
|
min_price: number | null;
|
|
quantity: number;
|
|
}[]
|
|
>('https://api.skinport.com/v1/items?app_id=730¤cy=EUR', br);
|
|
await saveDaily(
|
|
'skinport',
|
|
'EUR',
|
|
'lowest-listing',
|
|
items.flatMap((item) => {
|
|
const value = cents(item.min_price);
|
|
return value == null
|
|
? []
|
|
: [
|
|
{
|
|
hash: item.market_hash_name,
|
|
phase: item.version || null,
|
|
cents: value,
|
|
volume: item.quantity ?? null
|
|
}
|
|
];
|
|
})
|
|
);
|
|
})
|
|
);
|
|
results.push(
|
|
await guarded('skinport', 'EUR', ['sale-median-24h'], async () => {
|
|
type Agg = {
|
|
min: number | null;
|
|
max: number | null;
|
|
avg: number | null;
|
|
median: number | null;
|
|
volume: number;
|
|
};
|
|
const history = await getJson<
|
|
(Record<string, Agg> & { market_hash_name: string; version: string | null })[]
|
|
>('https://api.skinport.com/v1/sales/history?app_id=730¤cy=EUR', br);
|
|
const rows: Row[] = [];
|
|
for (const entry of history) {
|
|
const day24 = entry.last_24_hours;
|
|
const value = cents(day24?.median);
|
|
if (value != null)
|
|
rows.push({
|
|
hash: entry.market_hash_name,
|
|
phase: entry.version || null,
|
|
cents: value,
|
|
volume: day24.volume
|
|
});
|
|
}
|
|
const ids = await saveDaily('skinport', 'EUR', 'sale-median-24h', rows);
|
|
const windows: Window[] = [];
|
|
const allIds = await ensureItems(
|
|
history.map((e) => ({
|
|
hash: e.market_hash_name,
|
|
phase: e.version || null,
|
|
cents: 0,
|
|
volume: 0
|
|
}))
|
|
);
|
|
for (const entry of history) {
|
|
const item = (ids.get(configKey(entry.market_hash_name, entry.version || null)) ??
|
|
allIds.get(configKey(entry.market_hash_name, entry.version || null)))!;
|
|
if (!item) continue;
|
|
for (const [field, span] of Object.entries(spans)) {
|
|
const agg = entry[field];
|
|
if (!agg || !agg.volume) continue;
|
|
windows.push({
|
|
item,
|
|
span,
|
|
median: cents(agg.median),
|
|
avg: cents(agg.avg),
|
|
min: cents(agg.min),
|
|
max: cents(agg.max),
|
|
volume: agg.volume
|
|
});
|
|
}
|
|
}
|
|
await saveWindows('skinport', 'EUR', windows);
|
|
console.log(`skinport windows: ${windows.length}`);
|
|
})
|
|
);
|
|
results.push(
|
|
await guarded('steam-market', 'USD', ['sale-avg'], async () => {
|
|
const steam = await getJson<Record<string, Record<string, number | null>>>(
|
|
'https://prices.csgotrader.app/latest/steam.json'
|
|
);
|
|
const rows: Row[] = [];
|
|
for (const [hash, value] of Object.entries(steam)) {
|
|
// Latest average sale price: the last 24 hours, else the last 7 days.
|
|
const price = cents(value?.last_24h ?? value?.last_7d);
|
|
if (price != null) rows.push({ hash, phase: null, cents: price, volume: null });
|
|
}
|
|
const ids = await saveDaily('steam-market', 'USD', 'sale-avg', rows);
|
|
const all = await ensureItems(
|
|
Object.keys(steam).map((hash) => ({ hash, phase: null, cents: 0, volume: 0 }))
|
|
);
|
|
const windows: Window[] = [];
|
|
for (const [hash, value] of Object.entries(steam)) {
|
|
const item = ids.get(configKey(hash, null)) ?? all.get(configKey(hash, null));
|
|
if (!item || !value) continue;
|
|
for (const span of ['24h', '7d', '30d', '90d']) {
|
|
const avg = cents(value[`last_${span}`]);
|
|
if (avg != null)
|
|
windows.push({ item, span, median: null, avg, min: null, max: null, volume: null });
|
|
}
|
|
}
|
|
await saveWindows('steam-market', 'USD', windows);
|
|
})
|
|
);
|
|
for (const [source, file] of [
|
|
['csfloat', 'csfloat'],
|
|
['buff163', 'buff163']
|
|
] as const) {
|
|
results.push(
|
|
await guarded(source, 'USD', ['lowest-listing'], async () => {
|
|
type Entry = {
|
|
price?: number;
|
|
starting_at?: { price?: number | null; doppler?: Record<string, number> } | null;
|
|
doppler?: Record<string, number>;
|
|
};
|
|
const feed = await getJson<Record<string, Entry>>(
|
|
`https://prices.csgotrader.app/latest/${file}.json`
|
|
);
|
|
const rows: Row[] = [];
|
|
for (const [hash, entry] of Object.entries(feed)) {
|
|
const base = cents(entry?.starting_at?.price ?? entry?.price);
|
|
if (base != null) rows.push({ hash, phase: null, cents: base, volume: null });
|
|
const phases = entry?.starting_at?.doppler ?? entry?.doppler;
|
|
for (const [phase, value] of Object.entries(phases ?? {})) {
|
|
const price = cents(value);
|
|
if (price != null) rows.push({ hash, phase, cents: price, volume: null });
|
|
}
|
|
}
|
|
await saveDaily(source, 'USD', 'lowest-listing', rows);
|
|
})
|
|
);
|
|
}
|
|
if (!results.some(Boolean)) throw new Error('Every price source failed');
|
|
const [summary] = await sql<{ rows: number; items: number }[]>`
|
|
SELECT count(*)::int AS rows, count(DISTINCT item_id)::int AS items
|
|
FROM cs.daily_prices WHERE day = ${day}`;
|
|
console.log(`Today (${day}): ${summary.rows} price rows across ${summary.items} configs`);
|
|
}
|
|
|
|
try {
|
|
if (mode === 'catalog') await catalog();
|
|
else if (mode === 'prices') await prices();
|
|
else if (mode === 'daily') {
|
|
const [state] = await sql<{ fresh: boolean }[]>`
|
|
SELECT coalesce(max(updated_at) > now() - interval '6 days', false) AS fresh
|
|
FROM cs.skins WHERE source = 'bymykel'`;
|
|
if (!state.fresh) await catalog();
|
|
await prices();
|
|
} else throw new Error('Usage: cs-prices.ts daily|catalog|prices');
|
|
await sql`ANALYZE cs.items`;
|
|
await sql`ANALYZE cs.daily_prices`;
|
|
} finally {
|
|
await sql.end();
|
|
}
|