cs-prices: price history backfill (Steam daily median since 2013 from the public listing page, CSFloat daily averages, ECB FX history) in separate *-history streams; labelled on item charts
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
This commit is contained in:
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
11
drizzle/20261026091249_cs_history/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "cs"."history_fetches" (
|
||||||
|
"item_id" integer,
|
||||||
|
"source" text,
|
||||||
|
"fetched_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"points" integer DEFAULT 0 NOT NULL,
|
||||||
|
"first_day" date,
|
||||||
|
"error" text,
|
||||||
|
CONSTRAINT "history_fetches_pkey" PRIMARY KEY("item_id","source")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "cs"."history_fetches" ADD CONSTRAINT "history_fetches_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;
|
||||||
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
42538
drizzle/20261026091249_cs_history/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
399
scripts/cs-history.ts
Normal file
399
scripts/cs-history.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* CS2 price history backfill into cs.daily_prices, under separate `*-history` streams so it never
|
||||||
|
* mixes with the daily scrape. Resumable (cs.history_fetches) and throttled; run in the background:
|
||||||
|
*
|
||||||
|
* bun scripts/cs-history.ts fx ECB USD→EUR for every day since 2013 (Frankfurter),
|
||||||
|
* weekends/holidays carry the last rate forward
|
||||||
|
* bun scripts/cs-history.ts steam [max] Steam Community Market daily median sale price +
|
||||||
|
* volume since listing, read from the public market
|
||||||
|
* listing page (no login). One page holds every wear /
|
||||||
|
* StatTrak / Souvenir sibling, so it is ~1 request per
|
||||||
|
* skin, paced ~9 s apart with backoff on 429.
|
||||||
|
* bun scripts/cs-history.ts csfloat-queue print CSFloat targets (NDJSON) for an off-box fetcher
|
||||||
|
* bun scripts/cs-history.ts csfloat-import read CSFloat graph NDJSON on stdin and store it
|
||||||
|
* bun scripts/cs-history.ts status coverage per history stream
|
||||||
|
*
|
||||||
|
* Steam prices arrive in the page's wallet currency (EUR here, `ecurrency` 3) and are stored as
|
||||||
|
* is; USD streams (CSFloat) are converted per day with cs.fx_rates on read.
|
||||||
|
*/
|
||||||
|
import postgres from 'postgres';
|
||||||
|
|
||||||
|
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] ?? 'status';
|
||||||
|
const ua = 'Mozilla/5.0';
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const log = (...args: unknown[]) => console.log(new Date().toISOString().slice(0, 19), ...args);
|
||||||
|
|
||||||
|
const streams = {
|
||||||
|
steam: {
|
||||||
|
source: 'steam-market',
|
||||||
|
kind: 'sale-median-history',
|
||||||
|
label: 'Steam Market daily median sale (history)'
|
||||||
|
},
|
||||||
|
csfloat: {
|
||||||
|
source: 'csfloat',
|
||||||
|
kind: 'sale-avg-history',
|
||||||
|
label: 'CSFloat daily average sale (history)'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
async function streamId(source: string, currency: string, kind: string, label: string) {
|
||||||
|
const [row] = await sql<{ id: number }[]>`
|
||||||
|
INSERT INTO cs.price_streams (source, currency, kind, label)
|
||||||
|
VALUES (${source}, ${currency}, ${kind}, ${label})
|
||||||
|
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label RETURNING id`;
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Day = { day: string; cents: number; volume: number | null };
|
||||||
|
async function store(stream: number, item: number, days: Day[]) {
|
||||||
|
for (let offset = 0; offset < days.length; offset += 5000) {
|
||||||
|
const chunk = days.slice(offset, offset + 5000);
|
||||||
|
await sql`
|
||||||
|
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
|
||||||
|
SELECT ${item}::int, ${stream}::smallint, day, price_cents, volume
|
||||||
|
FROM unnest(${chunk.map((d) => d.day)}::date[], ${chunk.map((d) => d.cents)}::int[],
|
||||||
|
${chunk.map((d) => d.volume)}::int[]) AS x(day, price_cents, volume)
|
||||||
|
ON CONFLICT (item_id, stream_id, day) DO UPDATE SET
|
||||||
|
price_cents = EXCLUDED.price_cents, volume = EXCLUDED.volume`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function mark(item: number, source: string, days: Day[], error: string | null = null) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO cs.history_fetches (item_id, source, fetched_at, points, first_day, error)
|
||||||
|
VALUES (${item}, ${source}, now(), ${days.length}, ${days[0]?.day ?? null}, ${error})
|
||||||
|
ON CONFLICT (item_id, source) DO UPDATE SET fetched_at = now(), points = EXCLUDED.points,
|
||||||
|
first_day = EXCLUDED.first_day, error = EXCLUDED.error`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fx() {
|
||||||
|
const from = '2013-01-01';
|
||||||
|
const to = new Date().toISOString().slice(0, 10);
|
||||||
|
let rates: Record<string, { EUR: number }> | undefined;
|
||||||
|
for (const url of [
|
||||||
|
`https://api.frankfurter.dev/v1/${from}..${to}?base=USD&symbols=EUR`,
|
||||||
|
`https://api.frankfurter.app/${from}..${to}?from=USD&to=EUR`
|
||||||
|
]) {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||||
|
if (response.ok) {
|
||||||
|
rates = ((await response.json()) as { rates: typeof rates }).rates;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!rates) throw new Error('No FX history');
|
||||||
|
const days: string[] = [];
|
||||||
|
const values: number[] = [];
|
||||||
|
let last: number | null = null;
|
||||||
|
for (let t = Date.parse(from); t <= Date.parse(to); t += 86_400_000) {
|
||||||
|
const day = new Date(t).toISOString().slice(0, 10);
|
||||||
|
last = rates[day]?.EUR ?? last;
|
||||||
|
if (last != null) {
|
||||||
|
days.push(day);
|
||||||
|
values.push(last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Existing rows (the daily scrape's own rate) win.
|
||||||
|
const result = await sql`
|
||||||
|
INSERT INTO cs.fx_rates (day, usd_eur)
|
||||||
|
SELECT * FROM unnest(${days}::date[], ${values}::numeric[]) ON CONFLICT (day) DO NOTHING`;
|
||||||
|
log(
|
||||||
|
`FX: ${Object.keys(rates).length} ECB fixings, ${result.count} days inserted (${days[0]}..${to})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every pricehistory query embedded in the listing page's SSR state. */
|
||||||
|
function pageHistories(html: string) {
|
||||||
|
const match = html.match(
|
||||||
|
/<script type="application\/json" id="valve-ssr-data"[^>]*>([\s\S]*?)<\/script>/
|
||||||
|
);
|
||||||
|
if (!match) return null;
|
||||||
|
const found = new Map<
|
||||||
|
string,
|
||||||
|
{ currency: number; prices: { time: number; price_median: number; purchases: number }[] }
|
||||||
|
>();
|
||||||
|
const walk = (node: unknown): void => {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
if (node.length > 2 && (node[0] === '{' || node[0] === '[')) {
|
||||||
|
try {
|
||||||
|
walk(JSON.parse(node));
|
||||||
|
} catch {
|
||||||
|
/* not JSON */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node)) return node.forEach(walk);
|
||||||
|
if (node && typeof node === 'object') {
|
||||||
|
const record = node as Record<string, unknown>;
|
||||||
|
const key = record.queryKey;
|
||||||
|
if (
|
||||||
|
Array.isArray(key) &&
|
||||||
|
key[0] === 'market' &&
|
||||||
|
key[1] === 'pricehistory' &&
|
||||||
|
key[2] === 730
|
||||||
|
) {
|
||||||
|
const data = (record.state as { data?: { ecurrency?: number; prices?: [] } } | undefined)
|
||||||
|
?.data;
|
||||||
|
if (data && Array.isArray(data.prices))
|
||||||
|
found.set(String(key[3]), { currency: data.ecurrency ?? 0, prices: data.prices });
|
||||||
|
}
|
||||||
|
for (const value of Object.values(record)) walk(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(JSON.parse(match[1]));
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hourly points (last month) collapse to one row per UTC day, volume-weighted. */
|
||||||
|
function daily(prices: { time: number; price_median: number; purchases: number }[]): Day[] {
|
||||||
|
const byDay = new Map<string, { sum: number; weight: number; volume: number }>();
|
||||||
|
for (const p of prices) {
|
||||||
|
if (!Number.isFinite(p.price_median) || p.price_median <= 0) continue;
|
||||||
|
const day = new Date(p.time * 1000).toISOString().slice(0, 10);
|
||||||
|
const weight = Math.max(1, p.purchases || 0);
|
||||||
|
const entry = byDay.get(day) ?? { sum: 0, weight: 0, volume: 0 };
|
||||||
|
entry.sum += p.price_median * weight;
|
||||||
|
entry.weight += weight;
|
||||||
|
entry.volume += p.purchases || 0;
|
||||||
|
byDay.set(day, entry);
|
||||||
|
}
|
||||||
|
return [...byDay.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([day, e]) => ({ day, cents: Math.round((e.sum / e.weight) * 100), volume: e.volume }))
|
||||||
|
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function curlPage(url: string) {
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
['curl', '-sL', '--compressed', '--max-time', '60', '-A', ua, '-w', '\n%{http_code}', url],
|
||||||
|
{ stdout: 'pipe', stderr: 'ignore' }
|
||||||
|
);
|
||||||
|
const out = await new Response(proc.stdout).text();
|
||||||
|
await proc.exited;
|
||||||
|
const cut = out.lastIndexOf('\n');
|
||||||
|
return { status: Number(out.slice(cut + 1)) || 0, body: out.slice(0, cut) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindOrder = [
|
||||||
|
'knife',
|
||||||
|
'gloves',
|
||||||
|
'weapon',
|
||||||
|
'container',
|
||||||
|
'agent',
|
||||||
|
'key',
|
||||||
|
'music-kit',
|
||||||
|
'patch',
|
||||||
|
'collectible',
|
||||||
|
'sticker',
|
||||||
|
'graffiti',
|
||||||
|
'charm',
|
||||||
|
'other'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function steam(max: number) {
|
||||||
|
const currencies: Record<number, string> = { 1: 'USD', 3: 'EUR' };
|
||||||
|
const ids: Record<string, number> = {};
|
||||||
|
const idFor = async (currency: string) =>
|
||||||
|
(ids[currency] ??= await streamId(
|
||||||
|
streams.steam.source,
|
||||||
|
currency,
|
||||||
|
streams.steam.kind,
|
||||||
|
streams.steam.label
|
||||||
|
));
|
||||||
|
await idFor('EUR');
|
||||||
|
// One representative unfetched phase-less config per skin, popular kinds/skins first.
|
||||||
|
const queue = await sql<{ skin_id: string; item_id: number; hash: string }[]>`
|
||||||
|
WITH todo AS (
|
||||||
|
SELECT i.id, i.skin_id, i.market_hash_name, s.kind,
|
||||||
|
coalesce((SELECT max(w.volume) FROM cs.price_windows w
|
||||||
|
WHERE w.item_id = i.id AND w.source = 'skinport' AND w.span = '90d'), 0) AS vol
|
||||||
|
FROM cs.items i JOIN cs.skins s ON s.id = i.skin_id
|
||||||
|
WHERE i.phase IS NULL AND NOT EXISTS (SELECT 1 FROM cs.history_fetches f
|
||||||
|
WHERE f.item_id = i.id AND f.source = 'steam')
|
||||||
|
), skins AS (
|
||||||
|
SELECT skin_id, kind, sum(vol) AS vol FROM todo GROUP BY 1, 2
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (s.vol, s.skin_id) s.skin_id, t.id AS item_id, t.market_hash_name AS hash
|
||||||
|
FROM skins s JOIN todo t ON t.skin_id = s.skin_id
|
||||||
|
ORDER BY s.vol DESC, s.skin_id, t.vol DESC, t.id`;
|
||||||
|
const kinds = new Map(
|
||||||
|
(await sql<{ id: string; kind: string }[]>`SELECT id, kind FROM cs.skins`).map((r) => [
|
||||||
|
r.id,
|
||||||
|
r.kind
|
||||||
|
])
|
||||||
|
);
|
||||||
|
const rank = (skin: string) => {
|
||||||
|
const index = kindOrder.indexOf(kinds.get(skin) ?? 'other');
|
||||||
|
return index < 0 ? kindOrder.length : index;
|
||||||
|
};
|
||||||
|
const ordered = queue
|
||||||
|
.map((row, index) => ({ ...row, index }))
|
||||||
|
.sort((a, b) => rank(a.skin_id) - rank(b.skin_id) || a.index - b.index);
|
||||||
|
log(`Steam: ${ordered.length} skins with unfetched configs`);
|
||||||
|
let delay = 9000;
|
||||||
|
let strikes = 0;
|
||||||
|
let requests = 0;
|
||||||
|
let stored = 0;
|
||||||
|
for (let i = 0; i < ordered.length; i++) {
|
||||||
|
const row = ordered[i];
|
||||||
|
if (requests >= max) break;
|
||||||
|
// Skip when an earlier page already covered this skin's representative.
|
||||||
|
const [done] =
|
||||||
|
await sql`SELECT 1 FROM cs.history_fetches WHERE item_id = ${row.item_id} AND source = 'steam'`;
|
||||||
|
if (done) continue;
|
||||||
|
const siblings = await sql<{ id: number; hash: string }[]>`
|
||||||
|
SELECT id, market_hash_name AS hash FROM cs.items WHERE skin_id = ${row.skin_id} AND phase IS NULL`;
|
||||||
|
const byHash = new Map(siblings.map((s) => [s.hash, s.id]));
|
||||||
|
const url = `https://steamcommunity.com/market/listings/730/${encodeURIComponent(row.hash)}`;
|
||||||
|
requests++;
|
||||||
|
// curl with a bare UA and no Accept-Language: Steam answers Bun's fetch, and any request
|
||||||
|
// with Accept-Language, with 429 but serves this normally.
|
||||||
|
let page: { status: number; body: string };
|
||||||
|
try {
|
||||||
|
page = await curlPage(url);
|
||||||
|
} catch (cause) {
|
||||||
|
log(`net error ${row.hash}: ${String(cause)}`);
|
||||||
|
await sleep(60_000);
|
||||||
|
i--; // retry the same skin
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (page.status === 429 || page.status >= 500 || page.status === 0) {
|
||||||
|
strikes++;
|
||||||
|
delay = Math.min(30_000, delay + 1000);
|
||||||
|
const pause = Math.min(30 * 60_000, 60_000 * strikes);
|
||||||
|
log(
|
||||||
|
`HTTP ${page.status} (strike ${strikes}), pausing ${pause / 1000}s, delay now ${delay / 1000}s`
|
||||||
|
);
|
||||||
|
await sleep(pause);
|
||||||
|
i--; // retry the same skin
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
strikes = 0;
|
||||||
|
if (page.status !== 200) {
|
||||||
|
await mark(row.item_id, 'steam', [], `HTTP ${page.status}`);
|
||||||
|
await sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const histories = pageHistories(page.body);
|
||||||
|
if (!histories) {
|
||||||
|
await mark(row.item_id, 'steam', [], 'no ssr data');
|
||||||
|
log(`no SSR data for ${row.hash}`);
|
||||||
|
await sleep(delay);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pageRows = 0;
|
||||||
|
for (const [hash, history] of histories) {
|
||||||
|
const item =
|
||||||
|
byHash.get(hash) ??
|
||||||
|
(
|
||||||
|
await sql<{ id: number }[]>`SELECT id FROM cs.items WHERE market_hash_name = ${hash}
|
||||||
|
AND phase IS NULL`
|
||||||
|
)[0]?.id;
|
||||||
|
if (!item) continue;
|
||||||
|
const currency = currencies[history.currency];
|
||||||
|
if (!currency) {
|
||||||
|
await mark(item, 'steam', [], `currency ${history.currency}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const days = daily(history.prices);
|
||||||
|
if (days.length) await store(await idFor(currency), item, days);
|
||||||
|
await mark(item, 'steam', days);
|
||||||
|
pageRows += days.length;
|
||||||
|
}
|
||||||
|
if (!histories.has(row.hash)) await mark(row.item_id, 'steam', [], 'not on page');
|
||||||
|
stored += pageRows;
|
||||||
|
if (requests % 25 === 0 || requests <= 3)
|
||||||
|
log(
|
||||||
|
`#${requests} ${row.hash}: ${histories.size} configs, ${pageRows} days (total ${stored})`
|
||||||
|
);
|
||||||
|
await sleep(delay + Math.random() * 3000);
|
||||||
|
}
|
||||||
|
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${stored}
|
||||||
|
WHERE source = ${streams.steam.source} AND kind = ${streams.steam.kind}`;
|
||||||
|
log(`Steam done: ${requests} requests, ${stored} day rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Targets for the CSFloat fetcher: phase-less configs plus Doppler phases (paint index added by
|
||||||
|
* the fetcher), most traded first, skipping ones already fetched. */
|
||||||
|
async function csfloatQueue() {
|
||||||
|
const rows = await sql<{ item_id: number; hash: string; phase: string | null; vol: number }[]>`
|
||||||
|
SELECT i.id AS item_id, i.market_hash_name AS hash, i.phase,
|
||||||
|
coalesce((SELECT max(w.volume) FROM cs.price_windows w WHERE w.item_id = i.id
|
||||||
|
AND w.span = '90d'), 0)::int AS vol
|
||||||
|
FROM cs.items i
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM cs.history_fetches f WHERE f.item_id = i.id AND f.source = 'csfloat')
|
||||||
|
ORDER BY vol DESC, i.id`;
|
||||||
|
for (const row of rows) process.stdout.write(JSON.stringify(row) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function csfloatImport() {
|
||||||
|
const stream = await streamId(
|
||||||
|
streams.csfloat.source,
|
||||||
|
'USD',
|
||||||
|
streams.csfloat.kind,
|
||||||
|
streams.csfloat.label
|
||||||
|
);
|
||||||
|
let items = 0;
|
||||||
|
let rows = 0;
|
||||||
|
let buffer = '';
|
||||||
|
const handle = async (line: string) => {
|
||||||
|
if (!line.trim()) return;
|
||||||
|
const entry = JSON.parse(line) as {
|
||||||
|
item_id: number;
|
||||||
|
error?: string;
|
||||||
|
graph?: { day: string; avg_price: number; count: number }[];
|
||||||
|
};
|
||||||
|
const days = (entry.graph ?? [])
|
||||||
|
.map((g) => ({
|
||||||
|
day: g.day.slice(0, 10),
|
||||||
|
cents: Math.round(g.avg_price),
|
||||||
|
volume: g.count ?? null
|
||||||
|
}))
|
||||||
|
.filter((d) => d.cents > 0 && d.cents < 2_000_000_000)
|
||||||
|
.sort((a, b) => a.day.localeCompare(b.day));
|
||||||
|
if (days.length) await store(stream, entry.item_id, days);
|
||||||
|
await mark(entry.item_id, 'csfloat', days, entry.error ?? null);
|
||||||
|
items++;
|
||||||
|
rows += days.length;
|
||||||
|
};
|
||||||
|
for await (const chunk of Bun.stdin.stream()) {
|
||||||
|
buffer += new TextDecoder().decode(chunk);
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
for (const line of lines) await handle(line);
|
||||||
|
}
|
||||||
|
await handle(buffer);
|
||||||
|
await sql`UPDATE cs.price_streams SET last_fetched_at = now(), last_rows = ${rows} WHERE id = ${stream}`;
|
||||||
|
log(`CSFloat import: ${items} configs, ${rows} day rows`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status() {
|
||||||
|
const rows = await sql`
|
||||||
|
SELECT st.source, st.kind, st.currency, count(*)::int AS rows, count(DISTINCT dp.item_id)::int AS configs,
|
||||||
|
min(dp.day)::text AS first_day, max(dp.day)::text AS last_day
|
||||||
|
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
|
||||||
|
WHERE st.kind LIKE '%-history' GROUP BY 1, 2, 3 ORDER BY 1`;
|
||||||
|
console.table(rows);
|
||||||
|
const [cov] = await sql`
|
||||||
|
WITH h AS (SELECT dp.item_id, count(DISTINCT dp.day) AS days FROM cs.daily_prices dp
|
||||||
|
JOIN cs.price_streams st ON st.id = dp.stream_id WHERE st.kind LIKE '%-history' GROUP BY 1)
|
||||||
|
SELECT (SELECT count(*) FROM cs.items)::int AS configs, count(*)::int AS with_history,
|
||||||
|
count(*) FILTER (WHERE days > 30)::int AS over_30_days FROM h`;
|
||||||
|
console.log(cov);
|
||||||
|
console.table(
|
||||||
|
await sql`SELECT source, count(*)::int AS fetched, sum(points)::int AS points,
|
||||||
|
count(*) FILTER (WHERE points > 0)::int AS with_points, max(fetched_at) AS last
|
||||||
|
FROM cs.history_fetches GROUP BY 1`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'fx') await fx();
|
||||||
|
else if (mode === 'steam') await steam(Number(process.argv[3] ?? Infinity));
|
||||||
|
else if (mode === 'csfloat-queue') await csfloatQueue();
|
||||||
|
else if (mode === 'csfloat-import') await csfloatImport();
|
||||||
|
else if (mode === 'status') await status();
|
||||||
|
else throw new Error('Usage: cs-history.ts fx|steam [max]|csfloat-queue|csfloat-import|status');
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
@@ -185,3 +185,21 @@ export const csFxRates = csSchema.table('fx_rates', {
|
|||||||
day: date('day').primaryKey(),
|
day: date('day').primaryKey(),
|
||||||
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
|
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Backfill bookkeeping (scripts/cs-history.ts): which config was fetched from which history
|
||||||
|
// source, so the slow, throttled backfill can resume. History rows themselves live in
|
||||||
|
// daily_prices under their own streams (kind ending in `-history`).
|
||||||
|
export const csHistoryFetches = csSchema.table(
|
||||||
|
'history_fetches',
|
||||||
|
{
|
||||||
|
itemId: integer('item_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
|
||||||
|
source: text('source').notNull(),
|
||||||
|
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
points: integer('points').notNull().default(0),
|
||||||
|
firstDay: date('first_day'),
|
||||||
|
error: text('error')
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ name: 'history_fetches_pkey', columns: [table.itemId, table.source] })]
|
||||||
|
);
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
'steam-market:sale-avg': '#a855f7',
|
'steam-market:sale-avg': '#a855f7',
|
||||||
'steam-market:lowest-listing': '#d946ef',
|
'steam-market:lowest-listing': '#d946ef',
|
||||||
'csfloat:lowest-listing': '#f59e0b',
|
'csfloat:lowest-listing': '#f59e0b',
|
||||||
'buff163:lowest-listing': '#ef4444'
|
'buff163:lowest-listing': '#ef4444',
|
||||||
|
'steam-market:sale-median-history': '#14b8a6',
|
||||||
|
'csfloat:sale-avg-history': '#f97316'
|
||||||
};
|
};
|
||||||
const sourceNames: Record<string, string> = {
|
const sourceNames: Record<string, string> = {
|
||||||
skinport: 'Skinport',
|
skinport: 'Skinport',
|
||||||
@@ -24,7 +26,9 @@
|
|||||||
const kindNames: Record<string, string> = {
|
const kindNames: Record<string, string> = {
|
||||||
'lowest-listing': 'lowest listing',
|
'lowest-listing': 'lowest listing',
|
||||||
'sale-median-24h': 'sold, 24h median',
|
'sale-median-24h': 'sold, 24h median',
|
||||||
'sale-avg': 'sold, average'
|
'sale-avg': 'sold, average',
|
||||||
|
'sale-median-history': 'sold, daily median (history)',
|
||||||
|
'sale-avg-history': 'sold, daily average (history)'
|
||||||
};
|
};
|
||||||
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
|
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
|
||||||
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
|
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
|
||||||
|
|||||||
Reference in New Issue
Block a user