cs-prices: full ByMykel catalog, every config, daily multi-source prices, item pages with working charts
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
This commit is contained in:
2026-10-26 08:47:18 +00:00
parent 9d79dfb43a
commit 9e89a615c6
16 changed files with 44371 additions and 99 deletions

View File

@@ -0,0 +1,101 @@
CREATE TABLE "cs"."daily_prices" (
"item_id" integer,
"stream_id" smallint,
"day" date,
"price_cents" integer NOT NULL,
"volume" integer,
CONSTRAINT "daily_prices_pkey" PRIMARY KEY("item_id","stream_id","day")
);
--> statement-breakpoint
CREATE TABLE "cs"."fx_rates" (
"day" date PRIMARY KEY,
"usd_eur" numeric(12,8) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cs"."price_windows" (
"item_id" integer,
"source" text,
"currency" text NOT NULL,
"span" text,
"median_cents" integer,
"avg_cents" integer,
"min_cents" integer,
"max_cents" integer,
"volume" integer,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "price_windows_pkey" PRIMARY KEY("item_id","source","span")
);
--> statement-breakpoint
CREATE TABLE "cs"."skins" (
"id" text PRIMARY KEY,
"slug" text NOT NULL,
"name" text NOT NULL,
"kind" text NOT NULL,
"weapon" text,
"pattern" text,
"category" text,
"rarity" text,
"rarity_color" text,
"image" text,
"min_float" real,
"max_float" real,
"collections" text[],
"source" text DEFAULT 'bymykel' NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DROP INDEX "cs"."items_hash_idx";--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "skin_id" text;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "phase" text;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "wear" text;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "stattrak" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD COLUMN "souvenir" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "id" smallint GENERATED BY DEFAULT AS IDENTITY (sequence name "cs"."price_streams_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 32767 START WITH 1 CACHE 1);--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "label" text;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_fetched_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_rows" integer;--> statement-breakpoint
ALTER TABLE "cs"."price_streams" ADD COLUMN "last_error" text;--> statement-breakpoint
CREATE INDEX "daily_prices_day_idx" ON "cs"."daily_prices" ("day","stream_id");--> statement-breakpoint
CREATE UNIQUE INDEX "items_config_idx" ON "cs"."items" ("market_hash_name",coalesce("phase", ''));--> statement-breakpoint
CREATE INDEX "items_skin_idx" ON "cs"."items" ("skin_id");--> statement-breakpoint
CREATE UNIQUE INDEX "price_streams_id_idx" ON "cs"."price_streams" ("id");--> statement-breakpoint
CREATE UNIQUE INDEX "skins_slug_idx" ON "cs"."skins" ("slug");--> statement-breakpoint
CREATE INDEX "skins_name_idx" ON "cs"."skins" ("name");--> statement-breakpoint
CREATE INDEX "skins_kind_idx" ON "cs"."skins" ("kind");--> statement-breakpoint
ALTER TABLE "cs"."daily_prices" ADD CONSTRAINT "daily_prices_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "cs"."items" ADD CONSTRAINT "items_skin_id_skins_id_fkey" FOREIGN KEY ("skin_id") REFERENCES "cs"."skins"("id") ON DELETE SET NULL;--> statement-breakpoint
ALTER TABLE "cs"."price_windows" ADD CONSTRAINT "price_windows_item_id_items_id_fkey" FOREIGN KEY ("item_id") REFERENCES "cs"."items"("id") ON DELETE CASCADE;--> statement-breakpoint
-- Facets for the listings that already exist (the daily job keeps these up to date).
UPDATE cs.items SET
stattrak = market_hash_name LIKE '%StatTrak™ %',
souvenir = market_hash_name LIKE 'Souvenir %',
wear = substring(market_hash_name from '\((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$');
--> statement-breakpoint
-- Price streams the daily job writes (one row per config per stream per day).
INSERT INTO cs.price_streams (source, currency, kind, label) VALUES
('skinport', 'EUR', 'lowest-listing', 'Skinport lowest listing'),
('skinport', 'EUR', 'sale-median-24h', 'Skinport sales, 24h median'),
('steam-market', 'USD', 'sale-avg', 'Steam Market average sale (24h, else 7d)'),
('csfloat', 'USD', 'lowest-listing', 'CSFloat lowest listing'),
('buff163', 'USD', 'lowest-listing', 'Buff163 lowest listing')
ON CONFLICT (source, currency, kind) DO UPDATE SET label = EXCLUDED.label;
--> statement-breakpoint
UPDATE cs.price_streams SET label = 'Steam Market lowest listing'
WHERE source = 'steam-market' AND currency = 'USD' AND kind = 'lowest-listing' AND label IS NULL;
--> statement-breakpoint
-- Backfill: the one Steam lowest-listing sweep from Sep 2026 becomes the first daily point.
INSERT INTO cs.daily_prices (item_id, stream_id, day, price_cents, volume)
SELECT DISTINCT ON (p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date)
p.item_id, s.id, (p.observed_at AT TIME ZONE 'UTC')::date, p.price_cents, p.volume
FROM cs.price_observations p
JOIN cs.price_streams s ON s.source = p.source AND s.currency = p.currency AND s.kind = p.kind
ORDER BY p.item_id, (p.observed_at AT TIME ZONE 'UTC')::date, p.observed_at DESC
ON CONFLICT DO NOTHING;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON cs.skins, cs.daily_prices, cs.price_windows, cs.fx_rates TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cs TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@
"wildlife:refresh": "scripts/wildlife-refresh.sh",
"db:seed:cod": "bun scripts/seed-cod-catalog.ts",
"cs-prices:ingest": "bun scripts/ingest-cs-prices.ts",
"cs-prices:daily": "bun scripts/cs-prices.ts daily",
"house:worker": "bun scripts/house-worker.ts",
"house": "bun scripts/house-listing.ts",
"house:add": "bun scripts/house-listing.ts add",

624
scripts/cs-prices.ts Normal file
View File

@@ -0,0 +1,624 @@
/**
* 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&currency=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&currency=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();
}

View File

@@ -1,5 +1,6 @@
/**
* Steam Community Market ingestion. Run after db:migrate:cs-prices.
* Legacy Steam Community Market ingestion (per item, rate limited). The daily archive is
* scripts/cs-prices.ts; this stays for one-off Steam history imports with a logged-in session.
*
* bun scripts/ingest-cs-prices.ts catalog [--limit=500] [--start=0]
* STEAM_SESSIONID=... STEAM_LOGIN_SECURE=... bun scripts/ingest-cs-prices.ts history [--limit=100] [--start=0]
@@ -87,10 +88,10 @@ async function itemId(
const [row] = await sql<{ id: number }[]>`
INSERT INTO cs.items (market_hash_name, name, icon_url, item_type)
VALUES (${name}, ${displayName}, ${iconUrl ?? null}, ${itemType ?? null})
ON CONFLICT (market_hash_name) DO UPDATE SET
ON CONFLICT (market_hash_name, (coalesce(phase, ''))) DO UPDATE SET
name = EXCLUDED.name,
icon_url = COALESCE(EXCLUDED.icon_url, cs_market_items.icon_url),
item_type = COALESCE(EXCLUDED.item_type, cs_market_items.item_type),
icon_url = COALESCE(EXCLUDED.icon_url, cs.items.icon_url),
item_type = COALESCE(EXCLUDED.item_type, cs.items.item_type),
last_seen_at = now()
RETURNING id`;
const inferred = dimensionsFromName(name);
@@ -281,7 +282,7 @@ try {
else if (mode === 'history') await history();
else if (mode === 'import' && process.argv[3]) await importFile(process.argv[3]);
else throw new Error('Usage: ingest-cs-prices.ts catalog|history|import <file>');
await sql`ANALYZE cs_market_items, cs_item_dimensions, cs_price_observations`;
await sql`ANALYZE cs.items, cs.item_dimensions, cs.price_observations`;
} finally {
await sql.end();
}

View File

@@ -2,13 +2,30 @@
import { onMount } from 'svelte';
import type { ECharts } from 'echarts/core';
export type PriceSeries = { key: string; name: string; points: [number, number][] };
let { series, kind, currency }: { series: PriceSeries[]; kind: string; currency: string } =
$props();
export type PriceSeries = {
key: string;
name: string;
points: [number, number][];
color?: string;
dashed?: boolean;
};
let {
series,
kind,
currency,
height = 'min(62vh, 560px)',
zoom = true
}: {
series: PriceSeries[];
kind: string;
currency: string;
height?: string;
zoom?: boolean;
} = $props();
let element: HTMLDivElement;
let chart: ECharts | undefined;
let ready = $state(false);
let money = $derived(new Intl.NumberFormat('en-US', { style: 'currency', currency }));
let money = $derived(new Intl.NumberFormat('nl-NL', { style: 'currency', currency }));
const escapeHtml = (value: string) =>
value.replace(
/[&<>"']/g,
@@ -22,15 +39,53 @@
const foreground = style.getPropertyValue('--site-text').trim();
const muted = style.getPropertyValue('--site-muted').trim();
const border = style.getPropertyValue('--site-border').trim();
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
const many = series.length > 100;
const legend = series.length > 1 && series.length <= 12;
const surface = style.getPropertyValue('--site-surface').trim();
// A few days of daily data would otherwise be stretched into hour ticks; pad to a week.
let first = Infinity;
let last = -Infinity;
for (const line of series)
for (const [t] of line.points) {
if (t < first) first = t;
if (t > last) last = t;
}
const day = 86_400_000;
const short = Number.isFinite(first) && last - first < 7 * day;
const pad = short ? (7 * day - (last - first)) / 2 : 0;
chart.setOption(
{
animation: false,
backgroundColor: 'transparent',
grid: { left: 78, right: 20, top: 28, bottom: 72 },
grid: {
left: 64,
right: 14,
top: legend ? 40 : 20,
bottom: zoom ? 64 : 28,
containLabel: false
},
legend: legend
? {
top: 0,
left: 0,
icon: 'roundRect',
itemWidth: 10,
itemHeight: 4,
textStyle: { color: muted, fontSize: 11 }
}
: { show: false },
xAxis: {
type: 'time',
min: short ? first - pad : undefined,
max: short ? last + pad : undefined,
minInterval: day,
axisLine: { lineStyle: { color: border } },
axisLabel: { color: muted },
axisLabel: {
color: muted,
hideOverlap: true,
formatter: { year: '{yyyy}', month: '{MMM}', day: '{d} {MMM}', hour: '{d} {MMM}' }
},
splitLine: { show: false }
},
yAxis: {
@@ -39,44 +94,71 @@
axisLabel: { color: muted, formatter: (value: number) => money.format(value / 100) },
splitLine: { lineStyle: { color: border } }
},
dataZoom: [
{ type: 'inside' },
{
type: 'slider',
bottom: 12,
height: 18,
borderColor: border,
textStyle: { color: muted },
fillerColor: 'rgba(127,127,127,.15)'
}
],
tooltip:
series.length > 100
? { show: false }
dataZoom: zoom
? [
{ type: 'inside' },
{
type: 'slider',
bottom: 10,
height: 18,
borderColor: border,
textStyle: { color: muted },
fillerColor: 'rgba(127,127,127,.15)'
}
]
: [],
tooltip: many
? { show: false }
: legend || series.length === 1
? {
trigger: 'axis',
backgroundColor: surface,
borderColor: border,
textStyle: { color: foreground, fontSize: 12 },
formatter: (
params: {
seriesName: string;
value: [number, number];
color: string;
}[]
) =>
params.length
? `${new Date(params[0].value[0]).toLocaleDateString('nl-NL')}<br>` +
params
.map(
(p) =>
`<span style="color:${p.color}">●</span> ${escapeHtml(p.seriesName)} · <b>${money.format(p.value[1] / 100)}</b>`
)
.join('<br>')
: ''
}
: {
trigger: 'item',
backgroundColor: style.getPropertyValue('--site-surface').trim(),
backgroundColor: surface,
borderColor: border,
textStyle: { color: foreground },
formatter: (params: { seriesName: string; value: [number, number] }) =>
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString()} · ${money.format(params.value[1] / 100)}`
`${escapeHtml(params.seriesName)}<br>${new Date(params.value[0]).toLocaleDateString('nl-NL')} · ${money.format(params.value[1] / 100)}`
},
series: series.map((item, index) => ({
name: item.name,
type: 'line',
data: item.points,
showSymbol: false,
// Few points (a new archive, or one sweep) would be invisible as a bare line.
showSymbol: item.points.length <= 60,
symbolSize: item.points.length <= 3 ? 7 : 4,
connectNulls: false,
silent: series.length > 100,
silent: many,
lineStyle: {
width: series.length > 100 ? 0.7 : 1.8,
opacity: series.length > 100 ? 0.42 : 0.9
width: many ? 0.7 : 1.8,
opacity: many ? 0.42 : 0.9,
type: item.dashed ? 'dashed' : 'solid'
},
itemStyle: {
color: `hsl(${(index * 137.508) % 360} 65% ${style.getPropertyValue('--site-bg').trim() === '#000' ? '68%' : '38%'})`
color: item.color ?? `hsl(${(index * 137.508) % 360} 65% ${dark ? '68%' : '38%'})`
},
emphasis: { focus: 'series', lineStyle: { width: 3, opacity: 1 } },
large: true
large: many
}))
},
true
@@ -90,7 +172,7 @@
import('echarts/core').then(async (echarts) => {
const [
{ LineChart },
{ GridComponent, TooltipComponent, DataZoomComponent },
{ GridComponent, TooltipComponent, DataZoomComponent, LegendComponent },
{ CanvasRenderer }
] = await Promise.all([
import('echarts/charts'),
@@ -98,7 +180,14 @@
import('echarts/renderers')
]);
if (disposed) return;
echarts.use([LineChart, GridComponent, TooltipComponent, DataZoomComponent, CanvasRenderer]);
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
DataZoomComponent,
LegendComponent,
CanvasRenderer
]);
chart = echarts.init(element, undefined, { renderer: 'canvas' });
ready = true;
update();
@@ -107,7 +196,7 @@
theme = new MutationObserver(update);
theme.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
attributeFilter: ['data-theme', 'class']
});
});
return () => {
@@ -118,12 +207,16 @@
};
});
$effect(() => {
// Track the props so a new selection redraws.
void series;
void currency;
if (ready) update();
});
</script>
<div
class="chart"
style:height
bind:this={element}
role="img"
aria-label={`${series.length} ${kind} price series chart`}
@@ -132,7 +225,6 @@
<style>
.chart {
width: 100%;
height: min(62vh, 560px);
min-height: 320px;
min-height: 260px;
}
</style>

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { configRank, parseConfig, slugify } from './catalog';
describe('parseConfig', () => {
it('splits knives, StatTrak and wear', () => {
expect(parseConfig('★ StatTrak™ Karambit | Doppler (Factory New)')).toEqual({
starred: true,
stattrak: true,
souvenir: false,
wear: 'Factory New',
base: 'Karambit | Doppler',
display: '★ Karambit | Doppler'
});
});
it('handles souvenirs and wearless items', () => {
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').souvenir).toBe(true);
expect(parseConfig('Souvenir AWP | Dragon Lore (Field-Tested)').base).toBe('AWP | Dragon Lore');
expect(parseConfig('Sticker | Heroic (Holo) | Rio 2022').base).toBe(
'Sticker | Heroic (Holo) | Rio 2022'
);
expect(parseConfig('Paris 2023 Mirage Souvenir Package').souvenir).toBe(false);
expect(parseConfig('StatTrak™ Music Kit | Daniel Sadowski, Crimson Assault').base).toBe(
'Music Kit | Daniel Sadowski, Crimson Assault'
);
});
});
describe('slugify', () => {
it('makes stable slugs', () => {
expect(slugify('★ Karambit | Doppler')).toBe('karambit-doppler');
expect(slugify('AK-47 | Redline')).toBe('ak-47-redline');
});
});
describe('configRank', () => {
it('sorts normal, then StatTrak, by wear and phase', () => {
const a = configRank({ stattrak: false, souvenir: false, wear: 'Factory New', phase: 'Ruby' });
const b = configRank({ stattrak: false, souvenir: false, wear: 'Minimal Wear', phase: null });
const c = configRank({ stattrak: true, souvenir: false, wear: 'Factory New', phase: null });
expect(a).toBeLessThan(b);
expect(b).toBeLessThan(c);
});
});

View File

@@ -0,0 +1,87 @@
// Pure helpers shared by scripts/cs-prices.ts and the item pages: how a market hash name maps to
// its skin (the page that groups every wear, StatTrak, Souvenir and phase config).
export const wearOrder = [
'Factory New',
'Minimal Wear',
'Field-Tested',
'Well-Worn',
'Battle-Scarred'
] as const;
export const wearShort: Record<string, string> = {
'Factory New': 'FN',
'Minimal Wear': 'MW',
'Field-Tested': 'FT',
'Well-Worn': 'WW',
'Battle-Scarred': 'BS'
};
export const phaseOrder = [
'Phase 1',
'Phase 2',
'Phase 3',
'Phase 4',
'Ruby',
'Sapphire',
'Black Pearl',
'Emerald'
] as const;
const wearSuffix = / \((Factory New|Minimal Wear|Field-Tested|Well-Worn|Battle-Scarred)\)$/;
export type Config = {
starred: boolean;
stattrak: boolean;
souvenir: boolean;
wear: string | null;
/** Name without ★, StatTrak™, Souvenir or wear: the skin grouping key. */
base: string;
/** Display name of the skin: base with ★ for knives and gloves. */
display: string;
};
export function parseConfig(marketHashName: string): Config {
let name = marketHashName.trim();
const starred = name.startsWith('★ ');
if (starred) name = name.slice(2);
let stattrak = false;
let souvenir = false;
if (name.startsWith('StatTrak™ ')) {
stattrak = true;
name = name.slice('StatTrak™ '.length);
}
if (name.startsWith('Souvenir ') && name.includes(' | ')) {
souvenir = true;
name = name.slice('Souvenir '.length);
}
const wear = wearSuffix.exec(name)?.[1] ?? null;
if (wear) name = name.replace(wearSuffix, '');
return { starred, stattrak, souvenir, wear, base: name, display: starred ? `★ ${name}` : name };
}
export function slugify(value: string) {
return (
value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/★/g, '')
.replace(/™/g, '')
.toLowerCase()
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 120) || 'item'
);
}
/** Order configs: normal before StatTrak before Souvenir, then wear, then phase. */
export function configRank(row: {
stattrak: boolean;
souvenir: boolean;
wear: string | null;
phase: string | null;
}) {
const variant = row.souvenir ? 2 : row.stattrak ? 1 : 0;
const wear = row.wear ? wearOrder.indexOf(row.wear as (typeof wearOrder)[number]) + 1 : 0;
const phase = row.phase ? phaseOrder.indexOf(row.phase as (typeof phaseOrder)[number]) + 1 : 0;
return variant * 1000 + wear * 20 + phase;
}

275
src/lib/server/cs-prices.ts Normal file
View File

@@ -0,0 +1,275 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import { configRank, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
// Read side of the CS2 price archive (cs.*): skin search, a skin's configs with their latest
// price per source, and one config's daily history. USD streams are shown in EUR using the
// ECB rate of that day (cs.fx_rates); `native` keeps the source's own currency.
export type Stream = { id: number; source: string; currency: string; kind: string; label: string };
export const kinds = [
['weapon', 'Weapons'],
['knife', 'Knives'],
['gloves', 'Gloves'],
['sticker', 'Stickers'],
['container', 'Cases'],
['agent', 'Agents'],
['charm', 'Charms'],
['patch', 'Patches'],
['music-kit', 'Music kits'],
['graffiti', 'Graffiti'],
['collectible', 'Pins & coins'],
['key', 'Keys']
] as const;
export async function streams(): Promise<Stream[]> {
const rows = await db.execute(sql`SELECT id, source, currency, kind,
coalesce(label, source || ' ' || replace(kind, '-', ' ')) AS label
FROM cs.price_streams WHERE id IS NOT NULL ORDER BY id`);
return (rows as unknown as Stream[]).map((row) => ({ ...row, id: Number(row.id) }));
}
export async function overview() {
const [row] = (await db.execute(sql`SELECT
(SELECT count(*)::int FROM cs.skins) AS skins,
(SELECT count(*)::int FROM cs.items) AS configs,
(SELECT max(day)::text FROM cs.daily_prices) AS latest_day,
(SELECT count(*)::int FROM cs.daily_prices
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_rows,
(SELECT count(DISTINCT item_id)::int FROM cs.daily_prices
WHERE day = (SELECT max(day) FROM cs.daily_prices)) AS latest_configs,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class
WHERE oid = 'cs.daily_prices'::regclass)::bigint AS rows,
(SELECT min(day)::text FROM cs.daily_prices) AS earliest_day,
(SELECT max(last_fetched_at) FROM cs.price_streams) AS fetched_at`)) as unknown as {
skins: number;
configs: number;
latest_day: string | null;
latest_rows: number;
latest_configs: number;
rows: string;
earliest_day: string | null;
fetched_at: Date | null;
}[];
return row;
}
export type SkinHit = {
slug: string;
name: string;
kind: string;
image: string | null;
rarity: string | null;
rarity_color: string | null;
configs: number;
from_cents: number | null;
listings: number | null;
};
/** Skins matching every word of `q`, with today's cheapest EUR listing across their configs. */
export async function searchSkins(q: string, kind: string, sort: string, limit = 60, offset = 0) {
const words = q
.trim()
.slice(0, 100)
.split(/\s+/)
.filter(Boolean)
.slice(0, 6)
.map(
(word) => `%${word.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`
);
const filter = words.length
? sql.join(
words.map((word) => sql`s.name ILIKE ${word} ESCAPE '\\'`),
sql` AND `
)
: sql`true`;
const kindFilter =
kind && kinds.some(([value]) => value === kind) ? sql`s.kind = ${kind}` : sql`true`;
const order =
sort === 'price'
? sql`p.from_cents DESC NULLS LAST, s.name`
: sort === 'name'
? sql`s.name`
: sql`p.listings DESC NULLS LAST, s.name`;
const rows = await db.execute(sql`
WITH listing AS (
SELECT id FROM cs.price_streams WHERE source = 'skinport' AND kind = 'lowest-listing'
), latest AS (
SELECT max(day) AS day FROM cs.daily_prices WHERE stream_id = (SELECT id FROM listing)
), p AS (
SELECT i.skin_id, min(dp.price_cents)::int AS from_cents, sum(dp.volume)::int AS listings
FROM cs.daily_prices dp JOIN cs.items i ON i.id = dp.item_id
WHERE dp.stream_id = (SELECT id FROM listing) AND dp.day = (SELECT day FROM latest)
GROUP BY i.skin_id
)
SELECT s.slug, s.name, s.kind, s.image, s.rarity, s.rarity_color, p.from_cents, p.listings,
(SELECT count(*)::int FROM cs.items i WHERE i.skin_id = s.id) AS configs
FROM cs.skins s LEFT JOIN p ON p.skin_id = s.id
WHERE ${filter} AND ${kindFilter}
ORDER BY ${words.length ? sql`(s.name ILIKE ${words[0].slice(1)} ESCAPE '\\') DESC,` : sql``} ${order}
LIMIT ${Math.min(120, Math.max(1, limit))} OFFSET ${Math.min(50_000, Math.max(0, offset))}`);
return rows as unknown as SkinHit[];
}
export type ConfigRow = {
id: number;
market_hash_name: string;
phase: string | null;
wear: string | null;
stattrak: boolean;
souvenir: boolean;
icon_url: string | null;
prices: {
stream: number;
cents: number;
eur_cents: number;
volume: number | null;
day: string;
}[];
};
export type Variant = 'normal' | 'stattrak' | 'souvenir';
export const variantOf = (row: { stattrak: boolean; souvenir: boolean }): Variant =>
row.souvenir ? 'souvenir' : row.stattrak ? 'stattrak' : 'normal';
const shortToWear = Object.fromEntries(Object.entries(wearShort).map(([wear, s]) => [s, wear]));
export async function loadSkin(slug: string) {
const [skin] = (await db.execute(sql`SELECT id, slug, name, kind, weapon, pattern, category,
rarity, rarity_color, image, min_float, max_float, collections
FROM cs.skins WHERE slug = ${slug}`)) as unknown as {
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;
}[];
if (!skin) return null;
// Latest price per config and stream within the last 10 days, converted to EUR.
const configs = (await db.execute(sql`
WITH fx AS (SELECT usd_eur FROM cs.fx_rates ORDER BY day DESC LIMIT 1)
SELECT i.id, i.market_hash_name, i.phase, i.wear, i.stattrak, i.souvenir, i.icon_url,
coalesce((SELECT json_agg(json_build_object('stream', l.stream_id, 'cents', l.price_cents,
'eur_cents', CASE WHEN st.currency = 'USD'
THEN round(l.price_cents * coalesce((SELECT usd_eur FROM fx), 0.92))::int
ELSE l.price_cents END,
'volume', l.volume, 'day', l.day::text) ORDER BY l.stream_id)
FROM (SELECT DISTINCT ON (dp.stream_id) dp.stream_id, dp.price_cents, dp.volume, dp.day
FROM cs.daily_prices dp WHERE dp.item_id = i.id AND dp.day >= current_date - 10
ORDER BY dp.stream_id, dp.day DESC) l
JOIN cs.price_streams st ON st.id = l.stream_id), '[]'::json) AS prices
FROM cs.items i WHERE i.skin_id = ${skin.id}`)) as unknown as ConfigRow[];
configs.sort((a, b) => configRank(a) - configRank(b));
return { skin, configs };
}
/** Pick the config from ?v=&wear=&phase=, falling back to the cheapest priced normal config. */
export function pickConfig(configs: ConfigRow[], params: URLSearchParams) {
const variant = params.get('v') as Variant | null;
const wear = shortToWear[params.get('wear') ?? ''] ?? null;
const phase = params.get('phase') || null;
// Default to the most traded normal config (most listings/sales across sources).
const liquidity = (row: ConfigRow) => row.prices.reduce((sum, p) => sum + (p.volume ?? 0), 0);
const fallback = () =>
configs
.filter((row) => row.prices.length && !row.phase && variantOf(row) === 'normal')
.sort((a, b) => liquidity(b) - liquidity(a))[0] ??
configs.find((row) => row.prices.length) ??
configs[0] ??
null;
if (!variant && !params.has('wear') && !params.has('phase')) return fallback();
const exact = configs.find(
(row) =>
(!variant || variantOf(row) === variant) &&
(params.has('wear') ? row.wear === wear : true) &&
(params.has('phase') ? row.phase === phase : !row.phase)
);
if (exact) return exact;
const near = configs.find(
(row) => (!variant || variantOf(row) === variant) && (!wear || row.wear === wear)
);
return near ?? fallback();
}
export type SeriesPoint = [string, number];
/** Daily EUR series per stream for one config. Doppler phases also get the phase-blind
* listing's streams they lack (e.g. Steam has no phases), labelled as "any phase". */
export async function loadSeries(config: ConfigRow, from: string | null) {
const ids = [config.id];
if (config.phase) {
const [base] = (await db.execute(sql`SELECT id FROM cs.items
WHERE market_hash_name = ${config.market_hash_name} AND phase IS NULL`)) as unknown as {
id: number;
}[];
if (base) ids.push(base.id);
}
const rows = (await db.execute(sql`
SELECT dp.item_id, dp.stream_id, dp.day::text AS day, dp.volume,
CASE WHEN st.currency = 'USD' THEN round(dp.price_cents * coalesce(
(SELECT usd_eur FROM cs.fx_rates f WHERE f.day <= dp.day ORDER BY f.day DESC LIMIT 1),
(SELECT usd_eur FROM cs.fx_rates ORDER BY day LIMIT 1), 0.92))::int
ELSE dp.price_cents END AS eur_cents
FROM cs.daily_prices dp JOIN cs.price_streams st ON st.id = dp.stream_id
WHERE dp.item_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`
)}) ${from ? sql`AND dp.day >= ${from}::date` : sql``}
ORDER BY dp.day`)) as unknown as {
item_id: number;
stream_id: number;
day: string;
volume: number | null;
eur_cents: number;
}[];
const own = new Set(rows.filter((r) => r.item_id === config.id).map((r) => r.stream_id));
const series = new Map<string, { stream: number; anyPhase: boolean; points: SeriesPoint[] }>();
for (const row of rows) {
const anyPhase = row.item_id !== config.id;
if (anyPhase && own.has(row.stream_id)) continue;
const key = `${row.stream_id}:${anyPhase}`;
const entry = series.get(key) ?? { stream: Number(row.stream_id), anyPhase, points: [] };
entry.points.push([row.day, row.eur_cents]);
series.set(key, entry);
}
const windows = (await db.execute(sql`SELECT item_id, source, currency, span, median_cents,
avg_cents, min_cents, max_cents, volume FROM cs.price_windows
WHERE item_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`
)})`)) as unknown as {
item_id: number;
source: string;
currency: string;
span: string;
median_cents: number | null;
avg_cents: number | null;
min_cents: number | null;
max_cents: number | null;
volume: number | null;
}[];
const [fx] = (await db.execute(
sql`SELECT usd_eur::float AS rate, day::text FROM cs.fx_rates ORDER BY day DESC LIMIT 1`
)) as unknown as { rate: number; day: string }[];
return {
series: [...series.values()],
windows: windows
.filter(
(w) =>
w.item_id === config.id ||
!windows.some((o) => o.item_id === config.id && o.source === w.source)
)
.map((w) => ({ ...w, anyPhase: w.item_id !== config.id })),
fx: fx ?? null
};
}
export { wearOrder, wearShort };

View File

@@ -5,11 +5,45 @@ import {
text,
timestamp,
uniqueIndex,
bigint
bigint,
boolean,
date,
real,
smallint,
numeric
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { csSchema } from './schemas';
import { id } from './helpers';
// One finish or item, grouping all of its market configs (wear, StatTrak, Souvenir, phase).
// Ids come from ByMykel/CSGO-API (skin_id / item id); items only seen in price feeds get `mkt-<slug>`.
export const csSkins = csSchema.table(
'skins',
{
id: text('id').primaryKey(),
slug: text('slug').notNull(),
name: text('name').notNull(),
kind: text('kind').notNull(),
weapon: text('weapon'),
pattern: text('pattern'),
category: text('category'),
rarity: text('rarity'),
rarityColor: text('rarity_color'),
image: text('image'),
minFloat: real('min_float'),
maxFloat: real('max_float'),
collections: text('collections').array(),
source: text('source').notNull().default('bymykel'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('skins_slug_idx').on(table.slug),
index('skins_name_idx').on(table.name),
index('skins_kind_idx').on(table.kind)
]
);
// A Steam market hash name identifies a listing, including wear and special variants.
export const csMarketItems = csSchema.table(
'items',
@@ -19,12 +53,20 @@ export const csMarketItems = csSchema.table(
name: text('name').notNull(),
iconUrl: text('icon_url'),
itemType: text('item_type'),
// Catalog link and config facets. Doppler phases share a market hash name, so a phase
// row is its own config next to the phase-less listing.
skinId: text('skin_id').references(() => csSkins.id, { onDelete: 'set null' }),
phase: text('phase'),
wear: text('wear'),
stattrak: boolean('stattrak').notNull().default(false),
souvenir: boolean('souvenir').notNull().default(false),
firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
uniqueIndex('items_hash_idx').on(table.marketHashName),
index('items_name_idx').on(table.name)
uniqueIndex('items_config_idx').on(table.marketHashName, sql`coalesce(${table.phase}, '')`),
index('items_name_idx').on(table.name),
index('items_skin_idx').on(table.skinId)
]
);
@@ -82,9 +124,64 @@ export const csPriceStreams = csSchema.table(
{
source: text('source').notNull(),
currency: text('currency').notNull(),
kind: text('kind').notNull()
kind: text('kind').notNull(),
id: smallint('id').generatedByDefaultAsIdentity(),
label: text('label'),
lastFetchedAt: timestamp('last_fetched_at', { withTimezone: true }),
lastRows: integer('last_rows'),
lastError: text('last_error')
},
(table) => [
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] })
primaryKey({ name: 'price_streams_pkey', columns: [table.source, table.currency, table.kind] }),
uniqueIndex('price_streams_id_idx').on(table.id)
]
);
// One price per config per stream per UTC day (idempotent upsert). Prices stay in the stream's
// own currency; fx_rates converts USD streams to EUR for display.
export const csDailyPrices = csSchema.table(
'daily_prices',
{
itemId: integer('item_id')
.notNull()
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
streamId: smallint('stream_id').notNull(),
day: date('day').notNull(),
priceCents: integer('price_cents').notNull(),
volume: integer('volume')
},
(table) => [
primaryKey({ name: 'daily_prices_pkey', columns: [table.itemId, table.streamId, table.day] }),
index('daily_prices_day_idx').on(table.day, table.streamId)
]
);
// Rolling sale aggregates (24h/7d/30d/90d) as reported by the source, latest snapshot only.
export const csPriceWindows = csSchema.table(
'price_windows',
{
itemId: integer('item_id')
.notNull()
.references(() => csMarketItems.id, { onDelete: 'cascade' }),
source: text('source').notNull(),
currency: text('currency').notNull(),
span: text('span').notNull(),
medianCents: integer('median_cents'),
avgCents: integer('avg_cents'),
minCents: integer('min_cents'),
maxCents: integer('max_cents'),
volume: integer('volume'),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
},
(table) => [
primaryKey({
name: 'price_windows_pkey',
columns: [table.itemId, table.source, table.span]
})
]
);
export const csFxRates = csSchema.table('fx_rates', {
day: date('day').primaryKey(),
usdEur: numeric('usd_eur', { precision: 12, scale: 8 }).notNull()
});

View File

@@ -1,26 +1,24 @@
import { db } from '#lib/server/db/index.js';
import { sql } from 'drizzle-orm';
import { kinds, overview, searchSkins, streams } from '#lib/server/cs-prices.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [stats, dimensions, markets] = await Promise.all([
db.execute(sql`SELECT
(SELECT count(*)::int FROM cs.items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs.price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs.price_observations) AS earliest,
(SELECT max(observed_at) FROM cs.price_observations) AS latest`),
export const load: PageServerLoad = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const kind = url.searchParams.get('kind') ?? '';
const sort = url.searchParams.get('sort') ?? 'popular';
const [stats, dimensions, markets, skins] = await Promise.all([
overview(),
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
FROM cs.item_dimensions GROUP BY key ORDER BY key`),
db.execute(sql`SELECT source, currency, kind FROM cs.price_streams ORDER BY 1, 2, 3`)
streams(),
searchSkins(q, kind, sort)
]);
return {
stats: stats[0] as {
items: number;
observations: string;
earliest: Date | null;
latest: Date | null;
},
stats,
dimensions: dimensions as unknown as { key: string; values: number }[],
markets: markets as unknown as { source: string; currency: string; kind: string }[]
markets,
kinds: kinds.map(([value, label]) => ({ value, label })),
browse: { q, kind, sort, skins }
};
};

View File

@@ -9,6 +9,70 @@
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// Browse: every skin, sticker, case, agent, … with its cheapest Skinport listing today.
const euro = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
type Hit = (typeof data.browse.skins)[number];
// svelte-ignore state_referenced_locally
let browseQuery = $state(data.browse.q);
// svelte-ignore state_referenced_locally
let browseKind = $state(data.browse.kind);
// svelte-ignore state_referenced_locally
let browseSort = $state(data.browse.sort);
// svelte-ignore state_referenced_locally
let hits = $state<Hit[]>(data.browse.skins);
let browsing = $state(false);
let moreLeft = $state(true);
let firstBrowse = true;
async function fetchHits(offset: number, signal?: AbortSignal) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
params.set('offset', String(offset));
const response = await fetch(`/projects/cs-prices/api/skins?${params}`, { signal });
if (!response.ok) throw new Error(`Search failed (${response.status})`);
return (await response.json()).skins as Hit[];
}
$effect(() => {
void browseQuery;
void browseKind;
void browseSort;
if (firstBrowse) {
firstBrowse = false;
return;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
browsing = true;
try {
hits = await fetchHits(0, controller.signal);
moreLeft = hits.length >= 60;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams();
if (browseQuery) params.set('q', browseQuery);
if (browseKind) params.set('kind', browseKind);
if (browseSort !== 'popular') params.set('sort', browseSort);
history.replaceState(history.state, '', params.size ? `?${params}` : location.pathname);
} catch {
/* aborted or failed; keep the last results */
} finally {
if (!controller.signal.aborted) browsing = false;
}
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
});
async function loadMore() {
browsing = true;
try {
const next = await fetchHits(hits.length);
hits = [...hits, ...next];
moreLeft = next.length >= 60;
} finally {
browsing = false;
}
}
type Result = {
id?: number;
name?: string;
@@ -40,15 +104,12 @@
};
const number = new Intl.NumberFormat('en-US');
let dimension = $state('item');
let sourceChoice = $state('steam-market|USD');
let kind = $state('sale-median');
let sourceChoice = $state('skinport|EUR');
let kind = $state('lowest-listing');
let markets = $derived(
data.markets.length
? data.markets
: [
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
]
: [{ source: 'skinport', currency: 'EUR', kind: 'lowest-listing' }]
);
let sourceOptions = $derived([
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
@@ -90,7 +151,7 @@
sourceChoice = value;
kind =
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
'sale-median';
'lowest-listing';
}
function add(row: Result) {
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
@@ -115,8 +176,8 @@
}
const to = new SvelteDate();
const from =
range === 'all' && data.stats.earliest
? new SvelteDate(data.stats.earliest)
range === 'all' && data.stats.earliest_day
? new SvelteDate(data.stats.earliest_day)
: new SvelteDate(to);
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
if (range === '24h') from.setTime(to.getTime() - 86_400_000);
@@ -124,10 +185,12 @@
if (range === '30d') from.setUTCDate(from.getUTCDate() - 30);
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === '5y') from.setUTCFullYear(from.getUTCFullYear() - 5);
if (range === 'all' && !data.stats.earliest) from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === 'all' && !data.stats.earliest_day) from.setUTCFullYear(from.getUTCFullYear() - 1);
return { from: from.toISOString(), to: to.toISOString() };
}
function bucketLabel(seconds: number) {
if (seconds >= 7 * 86400) return 'weekly';
if (seconds >= 86400) return 'daily';
return seconds < 3600 ? `${seconds / 60} minute` : `${Math.round(seconds / 3600)} hour`;
}
@@ -256,19 +319,73 @@
<p class="eyebrow">market archive / counter-strike</p>
<h1>CS prices</h1>
<p class="lede">
Explore price histories for individual listings or compare groups of items over time.
Every CS2 skin, knife, glove, sticker, case and more, in every wear, StatTrak, Souvenir and
Doppler phase. Prices from Skinport, Steam, CSFloat and Buff163 are archived daily.
</p>
</div>
<div class="stats">
<div><strong>{number.format(data.stats.items)}</strong><span>listings indexed</span></div>
<div><strong>{number.format(data.stats.skins)}</strong><span>items</span></div>
<div><strong>{number.format(data.stats.configs)}</strong><span>configs</span></div>
<div>
<strong>≈{number.format(Number(data.stats.observations))}</strong><span
>stored observations</span
<strong>{number.format(data.stats.latest_rows ?? 0)}</strong><span
>prices {data.stats.latest_day ?? ''}</span
>
</div>
</div>
</header>
<section class="browse" aria-label="Browse items">
<div class="browse-bar">
<Input
type="search"
bind:value={browseQuery}
placeholder="Search AK-47 Redline, Karambit Doppler, Katowice 2014…"
/>
<NativeSelect bind:value={browseSort}
><option value="popular">Most listed</option><option value="price">Most expensive</option
><option value="name">Name</option></NativeSelect
>
</div>
<div class="kinds" role="group" aria-label="Type">
<button class:on={!browseKind} onclick={() => (browseKind = '')}>All</button>
{#each data.kinds as k (k.value)}<button
class:on={browseKind === k.value}
onclick={() => (browseKind = browseKind === k.value ? '' : k.value)}>{k.label}</button
>{/each}
</div>
<div class="hits" class:dim={browsing}>
{#each hits as hit (hit.slug)}
<a
class="hit"
href={`/projects/cs-prices/${hit.slug}`}
style:--rarity={hit.rarity_color ?? 'transparent'}
>
{#if hit.image}<img
src={hit.image}
alt=""
loading="lazy"
width="96"
height="72"
/>{:else}<span class="noimg"></span>{/if}
<span class="hit-name">{hit.name}</span>
<span class="hit-meta"
>{hit.from_cents != null ? `from ${euro.format(hit.from_cents / 100)}` : 'no listings'} ·
{hit.configs}
config{hit.configs === 1 ? '' : 's'}</span
>
</a>
{:else}
<p class="none">{browsing ? 'Searching…' : 'Nothing matches.'}</p>
{/each}
</div>
{#if moreLeft && hits.length >= 60}<div class="more">
<Button variant="outline" size="sm" onclick={loadMore} disabled={browsing}>Show more</Button
>
</div>{/if}
</section>
<h2 class="section-title">Compare over time</h2>
<div class="workspace">
<aside class="picker" aria-label="Series selection">
<div class="panel-heading">
@@ -357,11 +474,10 @@
></label
><label
>Range<NativeSelect bind:value={range}
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
value="7d">7 days</option
><option value="30d">30 days</option><option value="1y">1 year</option><option
value="5y">5 years</option
><option value="all">All history</option><option value="custom">Custom</option
><option value="7d">7 days</option><option value="30d">30 days</option><option
value="1y">1 year</option
><option value="5y">5 years</option><option value="all">All history</option><option
value="custom">Custom</option
></NativeSelect
></label
>
@@ -394,9 +510,9 @@
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
'-',
' '
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
Group lines average each listing equally within a bucket; raw observations remain in the
database.
)} · {bucketSeconds ? `${bucketLabel(bucketSeconds)} points` : 'choose a series'}. Group
lines average each config equally per point. Open an item above for its own page with every
config.
</p>
</section>
</div>
@@ -436,6 +552,95 @@
h3 {
font-size: 0.85rem;
}
.browse {
margin-bottom: 2.5rem;
}
.browse-bar {
display: grid;
grid-template-columns: 1fr 170px;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.kinds {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.9rem;
}
.kinds button {
border: 1px solid var(--site-border);
background: var(--site-surface);
color: var(--site-muted);
border-radius: 999px;
padding: 0.25rem 0.7rem;
font-size: 0.74rem;
}
.kinds button:hover {
color: var(--site-text);
border-color: var(--site-text);
}
.kinds button.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.hits {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 0.5rem;
transition: opacity 0.15s;
}
.hits.dim {
opacity: 0.55;
}
.hit {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.6rem 0.7rem 0.7rem;
border: 1px solid var(--site-border);
border-bottom: 2px solid var(--rarity);
background: var(--site-surface);
border-radius: 8px;
color: var(--site-text);
text-decoration: none;
transition:
transform 0.15s,
border-color 0.15s;
}
.hit:hover {
transform: translateY(-2px);
border-color: var(--site-text);
border-bottom-color: var(--rarity);
}
.hit img,
.noimg {
width: 100%;
height: 82px;
object-fit: contain;
}
.hit-name {
font-size: 0.78rem;
font-weight: 600;
line-height: 1.25;
}
.hit-meta {
color: var(--site-muted);
font-size: 0.68rem;
}
.none {
color: var(--site-muted);
font-size: 0.8rem;
}
.more {
display: flex;
justify-content: center;
margin-top: 0.8rem;
}
.section-title {
font-size: 1.25rem;
margin-bottom: 0.8rem;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
@@ -637,6 +842,15 @@
}
}
@media (max-width: 600px) {
.browse-bar {
grid-template-columns: 1fr 130px;
}
.hits {
grid-template-columns: repeat(2, 1fr);
}
.stats {
gap: 1.2rem;
}
.intro {
display: block;
}

View File

@@ -0,0 +1,23 @@
import { error } from '@sveltejs/kit';
import { loadSeries, loadSkin, pickConfig, streams } from '#lib/server/cs-prices.js';
import type { PageServerLoad } from './$types';
// One skin (or sticker, case, agent, …) with every market config it has: wear, StatTrak,
// Souvenir and Doppler phase. The selected config comes from ?v=&wear=&phase= so every config
// has its own URL; its full daily history (EUR) is loaded here and the range is picked client-side.
export const load: PageServerLoad = async ({ params, url }) => {
const found = await loadSkin(params.slug);
if (!found) error(404, 'Not Found');
const config = pickConfig(found.configs, url.searchParams);
const [streamList, history] = await Promise.all([
streams(),
config ? loadSeries(config, null) : Promise.resolve(null)
]);
return {
skin: found.skin,
configs: found.configs,
selected: config?.id ?? null,
streams: streamList,
history
};
};

View File

@@ -0,0 +1,649 @@
<script lang="ts">
import PriceChart, { type PriceSeries } from '#lib/cs-prices/PriceChart.svelte';
import { phaseOrder, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Config = (typeof data.configs)[number];
type Variant = 'normal' | 'stattrak' | 'souvenir';
const colors: Record<string, string> = {
'skinport:lowest-listing': '#3b82f6',
'skinport:sale-median-24h': '#22c55e',
'steam-market:sale-avg': '#a855f7',
'steam-market:lowest-listing': '#d946ef',
'csfloat:lowest-listing': '#f59e0b',
'buff163:lowest-listing': '#ef4444'
};
const sourceNames: Record<string, string> = {
skinport: 'Skinport',
'steam-market': 'Steam',
csfloat: 'CSFloat',
buff163: 'Buff163'
};
const kindNames: Record<string, string> = {
'lowest-listing': 'lowest listing',
'sale-median-24h': 'sold, 24h median',
'sale-avg': 'sold, average'
};
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
const count = new Intl.NumberFormat('nl-NL');
const money = (cents: number | null | undefined) =>
cents == null ? '–' : eur.format(cents / 100);
let streamById = $derived(new Map(data.streams.map((s) => [s.id, s])));
let selected = $derived(data.configs.find((c) => c.id === data.selected) ?? null);
const variantOf = (c: { stattrak: boolean; souvenir: boolean }): Variant =>
c.souvenir ? 'souvenir' : c.stattrak ? 'stattrak' : 'normal';
let variants = $derived(
(['normal', 'stattrak', 'souvenir'] as Variant[]).filter((v) =>
data.configs.some((c) => variantOf(c) === v)
)
);
let currentVariant = $derived(selected ? variantOf(selected) : 'normal');
let wears = $derived(
wearOrder.filter((w) =>
data.configs.some((c) => c.wear === w && variantOf(c) === currentVariant)
)
);
let phases = $derived(
phaseOrder.filter((p) =>
data.configs.some(
(c) =>
c.phase === p && variantOf(c) === currentVariant && c.wear === (selected?.wear ?? null)
)
)
);
/** Best price to show for a config: lowest EUR listing, else the latest sale. */
function best(config: Config) {
const listings = config.prices.filter(
(p) => streamById.get(p.stream)?.kind === 'lowest-listing'
);
const pool = listings.length ? listings : config.prices;
return pool.length ? Math.min(...pool.map((p) => p.eur_cents)) : null;
}
function find(v: Variant, wear: string | null, phase: string | null) {
return data.configs.find((c) => variantOf(c) === v && c.wear === wear && c.phase === phase);
}
function href(config: Config | undefined | null) {
if (!config) return '?';
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams();
params.set('v', variantOf(config));
if (config.wear) params.set('wear', wearShort[config.wear]);
if (config.phase) params.set('phase', config.phase);
return `?${params}`;
}
function forVariant(v: Variant) {
return (
find(v, selected?.wear ?? null, selected?.phase ?? null) ??
find(v, selected?.wear ?? null, null) ??
data.configs.find((c) => variantOf(c) === v && c.prices.length) ??
data.configs.find((c) => variantOf(c) === v)
);
}
function forWear(w: string) {
return find(currentVariant, w, selected?.phase ?? null) ?? find(currentVariant, w, null);
}
function configLabel(c: Config) {
const parts = [];
if (c.souvenir) parts.push('Souvenir');
if (c.stattrak) parts.push('StatTrak™');
if (c.wear) parts.push(c.wear);
if (c.phase) parts.push(c.phase);
return parts.join(' · ') || 'Standard';
}
function streamLabel(id: number, anyPhase = false) {
const s = streamById.get(id);
if (!s) return `Stream ${id}`;
return `${sourceNames[s.source] ?? s.source} ${kindNames[s.kind] ?? s.kind.replaceAll('-', ' ')}${anyPhase ? ' (any phase)' : ''}`;
}
let range = $state('all');
let cutoff = $derived.by(() => {
if (range === 'all') return 0;
const days = { '30d': 30, '90d': 90, '1y': 365 }[range] ?? 0;
return Date.now() - days * 86_400_000;
});
let series = $derived<PriceSeries[]>(
(data.history?.series ?? [])
.map((s) => {
const stream = streamById.get(s.stream);
return {
key: `${s.stream}:${s.anyPhase}`,
name: streamLabel(s.stream, s.anyPhase),
color: stream ? colors[`${stream.source}:${stream.kind}`] : undefined,
dashed: s.anyPhase,
points: s.points
.map(([day, cents]) => [Date.parse(day), cents] as [number, number])
.filter(([t]) => t >= cutoff)
};
})
.filter((s) => s.points.length)
);
let days = $derived(new Set(series.flatMap((s) => s.points.map(([t]) => t))).size);
let current = $derived(
(selected?.prices ?? [])
.map((p) => ({ ...p, stream: streamById.get(p.stream), id: p.stream }))
.sort((a, b) => a.eur_cents - b.eur_cents)
);
const spanOrder = ['24h', '7d', '30d', '90d'];
let windows = $derived(
[...(data.history?.windows ?? [])].sort(
(a, b) =>
a.source.localeCompare(b.source) || spanOrder.indexOf(a.span) - spanOrder.indexOf(b.span)
)
);
let windowSources = $derived([...new Set(windows.map((w) => w.source))]);
const fxCents = (cents: number | null, currency: string) =>
cents == null
? null
: currency === 'USD'
? Math.round(cents * (data.history?.fx?.rate ?? 0.92))
: cents;
let title = $derived(
selected && selected.market_hash_name !== data.skin.name
? `${selected.market_hash_name}${selected.phase ? ` (${selected.phase})` : ''}`
: data.skin.name
);
let image = $derived(selected?.icon_url ?? data.skin.image);
</script>
<svelte:head>
<title>{title} · CS prices · Stan Runge</title>
<meta
name="description"
content={`Daily ${data.skin.name} prices for every wear, StatTrak, Souvenir and phase, from Skinport, Steam, CSFloat and Buff163.`}
/>
</svelte:head>
<div class="cs-item">
<a class="back" href="/projects/cs-prices">← CS prices</a>
<header class="head">
{#if image}<img src={image} alt="" width="160" height="120" loading="eager" />{/if}
<div class="title">
<p class="eyebrow">
<span class="rarity" style:--rarity={data.skin.rarity_color ?? 'var(--site-muted)'}></span>
{[data.skin.rarity, data.skin.category ?? data.skin.kind].filter(Boolean).join(' · ')}
</p>
<h1>{data.skin.name}</h1>
<p class="meta">
{#if data.skin.min_float != null}Float {data.skin.min_float.toFixed(
2
)}–{data.skin.max_float?.toFixed(2)} ·
{/if}{data.configs.length} config{data.configs.length === 1 ? '' : 's'}
{#if data.skin.collections?.length}
· {data.skin.collections.join(', ')}{/if}
</p>
</div>
{#if selected}<div class="price">
<strong>{money(best(selected))}</strong><span>{configLabel(selected)}</span>
</div>{/if}
</header>
{#if data.configs.length > 1}
<nav class="configs" aria-label="Config">
{#if variants.length > 1}
<div class="row">
{#each variants as v (v)}
{@const target = forVariant(v)}
<a
class="chip"
class:on={currentVariant === v}
class:st={v === 'stattrak'}
class:sv={v === 'souvenir'}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
>{v === 'normal' ? 'Normal' : v === 'stattrak' ? 'StatTrak™' : 'Souvenir'}</a
>
{/each}
</div>
{/if}
{#if wears.length}
<div class="row">
{#each wears as w (w)}
{@const target = forWear(w)}
<a
class="chip wear"
class:on={selected?.wear === w}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
title={w}><b>{wearShort[w]}</b><small>{target ? money(best(target)) : '–'}</small></a
>
{/each}
</div>
{/if}
{#if phases.length}
{@const any = find(currentVariant, selected?.wear ?? null, null)}
<div class="row">
{#if any}<a
class="chip"
class:on={!selected?.phase}
href={href(any)}
data-sveltekit-reset="false"
data-sveltekit-replacestate>Any phase</a
>{/if}
{#each phases as p (p)}
{@const target = find(currentVariant, selected?.wear ?? null, p)}
<a
class="chip phase"
class:on={selected?.phase === p}
data-phase={p}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
><b>{p.replace('Phase ', 'P')}</b><small>{target ? money(best(target)) : '–'}</small
></a
>
{/each}
</div>
{/if}
</nav>
{/if}
{#if current.length}
<section class="now" aria-label="Latest prices">
{#each current as p (p.id)}
<div
class="tile"
style:--accent={p.stream ? colors[`${p.stream.source}:${p.stream.kind}`] : undefined}
>
<span>{streamLabel(p.id)}</span>
<strong>{money(p.eur_cents)}</strong>
<small
>{p.stream?.currency === 'USD' ? `${usd.format(p.cents / 100)} · ` : ''}{p.volume !=
null
? `${count.format(p.volume)} ${p.stream?.kind === 'lowest-listing' ? 'listed' : 'sold'} · `
: ''}{p.day}</small
>
</div>
{/each}
</section>
{/if}
<section class="card chart-card" aria-label="Price history">
<div class="toolbar">
<h2>Price history <small>EUR · daily</small></h2>
<div class="ranges" role="group" aria-label="Range">
{#each ['30d', '90d', '1y', 'all'] as r (r)}
<button class:on={range === r} onclick={() => (range = r)}
>{r === 'all' ? 'All' : r}</button
>
{/each}
</div>
</div>
{#if series.length}
<PriceChart {series} kind="daily" currency="EUR" height="min(52vh, 420px)" zoom={days > 30} />
<p class="note">
{days} day{days === 1 ? '' : 's'} of history · USD sources at the ECB rate of each day{data
.history?.fx
? ` (now ${data.history.fx.rate.toFixed(4)} €/$)`
: ''} · dashed = price for any phase
</p>
{:else}
<div class="empty">
<strong>No prices yet for this config</strong>
<p>Prices are collected daily for every config; untraded ones stay empty.</p>
</div>
{/if}
</section>
{#if windows.length}
<section class="card" aria-label="Recent sales">
<h2>Recent sales</h2>
<div class="table-wrap">
<table>
<thead
><tr
><th>Source</th>{#each spanOrder as s (s)}<th>{s}</th>{/each}</tr
></thead
>
<tbody>
{#each windowSources as source (source)}
<tr>
<th
>{sourceNames[source] ?? source}{windows.find((w) => w.source === source)
?.anyPhase
? ' (any phase)'
: ''}</th
>
{#each spanOrder as span (span)}
{@const w = windows.find((x) => x.source === source && x.span === span)}
<td>
{#if w}
{money(fxCents(w.median_cents ?? w.avg_cents, w.currency))}
{#if w.volume}<small>{count.format(w.volume)} sold</small>{/if}
{:else}–{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="note">Skinport: median sale price and sales count. Steam: average sale price.</p>
</section>
{/if}
{#if data.configs.length > 1}
<section class="card" aria-label="All configs">
<h2>All configs</h2>
<div class="all">
{#each data.configs as c (c.id)}
<a
class="cfg"
class:on={c.id === data.selected}
href={href(c)}
data-sveltekit-reset="false"
data-sveltekit-replacestate><span>{configLabel(c)}</span><b>{money(best(c))}</b></a
>
{/each}
</div>
</section>
{/if}
</div>
<style>
.cs-item {
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(0.9rem, 4vw, 2.5rem) 4rem;
color: var(--site-text);
}
.back {
color: var(--site-muted);
font-size: 0.8rem;
text-decoration: none;
}
.back:hover {
color: var(--site-text);
}
.head {
display: flex;
align-items: center;
gap: 1.2rem;
margin: 1rem 0 1.2rem;
}
.head img {
width: 128px;
height: auto;
object-fit: contain;
flex: none;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.35));
}
.title {
flex: 1;
min-width: 0;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.66rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.45rem;
}
.rarity {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--rarity);
box-shadow: 0 0 10px var(--rarity);
}
h1 {
font-size: clamp(1.6rem, 4.5vw, 2.6rem);
line-height: 1.05;
letter-spacing: -0.05em;
margin: 0.35rem 0 0.4rem;
}
h2 {
font-size: 1rem;
letter-spacing: -0.03em;
margin-bottom: 0.7rem;
}
h2 small {
color: var(--site-muted);
font-weight: 500;
font-size: 0.72rem;
margin-left: 0.35rem;
}
.meta {
color: var(--site-muted);
font-size: 0.8rem;
}
.price {
text-align: right;
display: flex;
flex-direction: column;
}
.price strong {
font-size: 1.7rem;
letter-spacing: -0.05em;
}
.price span {
color: var(--site-muted);
font-size: 0.72rem;
}
.configs {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
color: var(--site-text);
border-radius: 6px;
padding: 0.4rem 0.65rem;
font-size: 0.78rem;
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
background 0.15s;
}
.chip small {
color: var(--site-muted);
font-size: 0.7rem;
}
.chip:hover {
border-color: var(--site-text);
transform: translateY(-1px);
}
.chip.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.chip.on small {
color: inherit;
opacity: 0.75;
}
.chip.st:not(.on) {
color: #f97316;
}
.chip.sv:not(.on) {
color: #eab308;
}
.chip.phase[data-phase='Ruby'] b {
color: #ef4444;
}
.chip.phase[data-phase='Sapphire'] b {
color: #3b82f6;
}
.chip.phase[data-phase='Black Pearl'] b {
color: #a78bfa;
}
.chip.phase[data-phase='Emerald'] b {
color: #22c55e;
}
.chip.on.phase b {
color: inherit !important;
}
.now {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.tile {
border: 1px solid var(--site-border);
border-left: 3px solid var(--accent, var(--site-border));
background: var(--site-surface);
border-radius: 8px;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.tile span,
.tile small {
color: var(--site-muted);
font-size: 0.68rem;
}
.tile strong {
font-size: 1.15rem;
letter-spacing: -0.03em;
}
.card {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.ranges {
display: flex;
gap: 0.25rem;
margin-bottom: 0.7rem;
}
.ranges button {
border: 1px solid var(--site-border);
background: transparent;
color: var(--site-muted);
border-radius: 5px;
padding: 0.2rem 0.55rem;
font-size: 0.72rem;
cursor: pointer;
}
.ranges button:hover {
color: var(--site-text);
}
.ranges button.on {
background: var(--site-text);
color: var(--site-bg);
}
.note {
color: var(--site-muted);
font-size: 0.7rem;
margin-top: 0.5rem;
}
.empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
text-align: center;
}
.empty p {
color: var(--site-muted);
font-size: 0.78rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
th,
td {
text-align: right;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
th:first-child {
text-align: left;
}
thead th {
color: var(--site-muted);
font-weight: 600;
font-size: 0.7rem;
}
td small {
display: block;
color: var(--site-muted);
font-size: 0.66rem;
}
.all {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.3rem;
}
.cfg {
display: flex;
justify-content: space-between;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 6px;
font-size: 0.76rem;
color: var(--site-text);
text-decoration: none;
transition: border-color 0.15s;
}
.cfg:hover {
border-color: var(--site-text);
}
.cfg.on {
background: var(--site-hover);
border-color: var(--site-text);
}
.cfg span {
color: var(--site-muted);
}
@media (max-width: 640px) {
.head {
flex-wrap: wrap;
}
.head img {
width: 96px;
}
.price {
text-align: left;
width: 100%;
flex-direction: row;
align-items: baseline;
gap: 0.5rem;
}
.now {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -5,12 +5,14 @@ import type { RequestHandler } from './$types';
const maxSeries = 1000;
const maxDays = 365 * 20;
// Compare explorer: daily prices (cs.daily_prices) for up to 1000 configs or groups, bucketed per
// day, or per week for ranges over 400 days. Group lines average each config equally per bucket.
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
const kind = typeof body.kind === 'string' ? body.kind : 'sale-median';
const source = typeof body.source === 'string' ? body.source : 'steam-market';
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
const kind = typeof body.kind === 'string' ? body.kind : 'lowest-listing';
const source = typeof body.source === 'string' ? body.source : 'skinport';
const currency = typeof body.currency === 'string' ? body.currency : 'EUR';
const ids = Array.isArray(body.ids) ? body.ids : [];
const values = Array.isArray(body.values) ? body.values : [];
if (
@@ -25,13 +27,13 @@ export const POST: RequestHandler = async ({ request }) => {
)
return Response.json({ error: 'Invalid selection' }, { status: 400 });
if (dimension === 'item' ? !ids.length : !values.length)
return Response.json({ series: [], bucketSeconds: 0 });
return Response.json({ points: [], bucketSeconds: 0 });
const to = new Date(typeof body.to === 'string' ? body.to : Date.now());
const from = new Date(typeof body.from === 'string' ? body.from : to.getTime() - 365 * 86400_000);
if (Number.isNaN(+from) || Number.isNaN(+to) || from >= to || +to - +from > maxDays * 86400_000)
return Response.json({ error: 'Invalid date range' }, { status: 400 });
// Aim for at most 240 buckets per series. Raw observations remain untouched.
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
const weekly = +to - +from > 400 * 86400_000;
const seconds = weekly ? 7 * 86400 : 86400;
const selection =
dimension === 'item'
? sql`JOIN cs.items i ON i.id = p.item_id AND i.id IN (${sql.join(
@@ -44,22 +46,22 @@ export const POST: RequestHandler = async ({ request }) => {
sql`,`
)})`;
const seriesKey = dimension === 'item' ? sql`i.id::text` : sql`d.value`;
const bucket = weekly ? sql`date_trunc('week', p.day)::date` : sql`p.day`;
const rows = await db.execute(sql`
WITH per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id,
date_bin(make_interval(secs => ${seconds}), p.observed_at,
TIMESTAMPTZ '1970-01-01') AS bucket,
avg(p.price_cents) AS price_cents,
sum(p.volume) AS volume,
min(p.price_cents) AS min_cents,
max(p.price_cents) AS max_cents
FROM cs.price_observations p ${selection}
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
AND p.observed_at >= ${from.toISOString()}::timestamptz
AND p.observed_at <= ${to.toISOString()}::timestamptz
WITH stream AS (
SELECT id FROM cs.price_streams
WHERE source = ${source} AND currency = ${currency} AND kind = ${kind}
), per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id, ${bucket} AS bucket,
avg(p.price_cents) AS price_cents, sum(p.volume) AS volume,
min(p.price_cents) AS min_cents, max(p.price_cents) AS max_cents
FROM cs.daily_prices p ${selection}
WHERE p.stream_id = (SELECT id FROM stream)
AND p.day >= ${from.toISOString().slice(0, 10)}::date
AND p.day <= ${to.toISOString().slice(0, 10)}::date
GROUP BY 1, 2, 3
)
SELECT series_key, bucket, round(avg(price_cents))::int AS price_cents,
SELECT series_key, bucket::text AS bucket, round(avg(price_cents))::int AS price_cents,
count(*)::int AS items, sum(volume)::bigint AS volume,
min(min_cents)::int AS min_cents, max(max_cents)::int AS max_cents
FROM per_item GROUP BY 1, 2 ORDER BY 1, 2`);

View File

@@ -0,0 +1,13 @@
import { searchSkins } from '#lib/server/cs-prices.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const skins = await searchSkins(
url.searchParams.get('q') ?? '',
url.searchParams.get('kind') ?? '',
url.searchParams.get('sort') ?? 'popular',
Number(url.searchParams.get('limit') ?? 60) || 60,
Number(url.searchParams.get('offset') ?? 0) || 0
);
return Response.json({ skins });
};