games ingest: send dates as ISO strings (the app pool uses drizzle's date serializers), pause Leetify on 429 and resume next poll, FACEIT 403 from the server is a skip (history imported by backfill), weekly FACEIT
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
import { fetchJson } from './http';
|
import { fetchJson } from './http';
|
||||||
import { addSnapshot, hoursSince, lastSnapshot, upsertMatch } from './store';
|
import { addSnapshot, hoursSince, lastSnapshot, upsertMatch } from './store';
|
||||||
import { int, num, sleep, type Account, type Db, type MatchInput, type RunOptions } from './types';
|
import {
|
||||||
|
int,
|
||||||
|
num,
|
||||||
|
sleep,
|
||||||
|
SourceError,
|
||||||
|
type Account,
|
||||||
|
type Db,
|
||||||
|
type MatchInput,
|
||||||
|
type RunOptions
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// FACEIT match history from the stats service faceit.com itself uses (no key needed):
|
// FACEIT match history from the stats service faceit.com itself uses (no key needed):
|
||||||
// /api/stats/v1/stats/time/users/{playerId}/games/{game}?size&page lists every FACEIT
|
// /api/stats/v1/stats/time/users/{playerId}/games/{game}?size&page lists every FACEIT
|
||||||
@@ -90,12 +99,22 @@ export async function pollFaceit(sql: Db, account: Account, opts: RunOptions) {
|
|||||||
for (const game of games) {
|
for (const game of games) {
|
||||||
const size = opts.backfill ? 100 : 20;
|
const size = opts.backfill ? 100 : 20;
|
||||||
for (let page = 0; page < (opts.backfill ? 50 : 1); page++) {
|
for (let page = 0; page < (opts.backfill ? 50 : 1); page++) {
|
||||||
const { body } = await fetchJson<Row[]>(
|
let body: Row[] | null;
|
||||||
|
try {
|
||||||
|
({ body } = await fetchJson<Row[]>(
|
||||||
`${BASE}/time/users/${id}/games/${game}?size=${size}&page=${page}`,
|
`${BASE}/time/users/${id}/games/${game}?size=${size}&page=${page}`,
|
||||||
{
|
{ headers: { 'User-Agent': 'Mozilla/5.0 (stanrunge.dev games tracker)' } }
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0 (stanrunge.dev games tracker)' }
|
));
|
||||||
|
} catch (e) {
|
||||||
|
// faceit.com (Cloudflare) answers 403 to datacenter IPs such as the server; the
|
||||||
|
// history is closed and was imported from elsewhere, so this is not an error.
|
||||||
|
if (e instanceof SourceError && e.status === 403)
|
||||||
|
return {
|
||||||
|
skipped: true,
|
||||||
|
note: 'faceit.com blocks the server IP (403); history imported by backfill'
|
||||||
|
};
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
);
|
|
||||||
const rows = Array.isArray(body) ? body : [];
|
const rows = Array.isArray(body) ? body : [];
|
||||||
for (const r of rows) {
|
for (const r of rows) {
|
||||||
const m = parseFaceitRow(r);
|
const m = parseFaceitRow(r);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
int,
|
int,
|
||||||
num,
|
num,
|
||||||
sleep,
|
sleep,
|
||||||
|
SourceError,
|
||||||
type Account,
|
type Account,
|
||||||
type Db,
|
type Db,
|
||||||
type GameEnv,
|
type GameEnv,
|
||||||
@@ -131,10 +132,15 @@ export async function pollLeetify(sql: Db, account: Account, env: GameEnv, opts:
|
|||||||
await fetchJson<LeetifyMatch>(`${BASE}/v2/matches/${encodeURIComponent(m.id)}`, { headers })
|
await fetchJson<LeetifyMatch>(`${BASE}/v2/matches/${encodeURIComponent(m.id)}`, { headers })
|
||||||
).body;
|
).body;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// Rate limited (no key): stop here; oldest-first, so the next poll resumes at this match.
|
||||||
|
if (e instanceof SourceError && e.status === 429) {
|
||||||
|
opts.log?.(`[games] leetify rate limited after ${added} new matches; resuming next poll`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
opts.log?.(`[games] leetify details ${m.id}: ${(e as Error).message}`);
|
opts.log?.(`[games] leetify details ${m.id}: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
if (await upsertMatch(sql, parseLeetifyMatch(m, steamId, details))) added++;
|
if (await upsertMatch(sql, parseLeetifyMatch(m, steamId, details))) added++;
|
||||||
await sleep(env.LEETIFY_API_KEY ? 250 : 1100);
|
await sleep(env.LEETIFY_API_KEY ? 250 : 2500);
|
||||||
}
|
}
|
||||||
// Profile (ranks, ratings, totals) once a day, as a dated snapshot.
|
// Profile (ranks, ratings, totals) once a day, as a dated snapshot.
|
||||||
const last = await lastSnapshot(sql, 'cs2', 'leetify', 'profile');
|
const last = await lastSnapshot(sql, 'cs2', 'leetify', 'profile');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const SOURCE_ORDER = ['leetify', 'faceit', 'steam', 'pubg', 'ubisoft'] as
|
|||||||
/** Minimum hours between polls per source (the poller ticks every 20 minutes). */
|
/** Minimum hours between polls per source (the poller ticks every 20 minutes). */
|
||||||
const DUE_HOURS: Record<string, number> = {
|
const DUE_HOURS: Record<string, number> = {
|
||||||
leetify: 0.3,
|
leetify: 0.3,
|
||||||
faceit: 6,
|
faceit: 24 * 7, // FACEIT history is closed (last match 2023); weekly is plenty
|
||||||
steam: 0.3,
|
steam: 0.3,
|
||||||
pubg: 0.3,
|
pubg: 0.3,
|
||||||
ubisoft: 0.3
|
ubisoft: 0.3
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function upsertMatch(sql: Db, m: MatchInput): Promise<boolean> {
|
|||||||
score_opp, placement, team_count, kills, deaths, assists, damage, headshots, rating,
|
score_opp, placement, team_count, kills, deaths, assists, damage, headshots, rating,
|
||||||
rating_label, match_count, share_code, extra, raw
|
rating_label, match_count, share_code, extra, raw
|
||||||
) values (
|
) values (
|
||||||
${m.game}, ${m.source}, ${m.externalId}, ${m.playedAt}, ${m.durationS ?? null},
|
${m.game}, ${m.source}, ${m.externalId}, ${m.playedAt.toISOString()}, ${m.durationS ?? null},
|
||||||
${m.mode ?? null}, ${m.map ?? null}, ${m.result ?? null}, ${m.scoreOwn ?? null},
|
${m.mode ?? null}, ${m.map ?? null}, ${m.result ?? null}, ${m.scoreOwn ?? null},
|
||||||
${m.scoreOpp ?? null}, ${m.placement ?? null}, ${m.teamCount ?? null}, ${m.kills ?? null},
|
${m.scoreOpp ?? null}, ${m.placement ?? null}, ${m.teamCount ?? null}, ${m.kills ?? null},
|
||||||
${m.deaths ?? null}, ${m.assists ?? null}, ${m.damage ?? null}, ${m.headshots ?? null},
|
${m.deaths ?? null}, ${m.assists ?? null}, ${m.damage ?? null}, ${m.headshots ?? null},
|
||||||
@@ -103,7 +103,7 @@ export async function addSnapshot(
|
|||||||
insert into games.snapshots (game, source, kind, data)
|
insert into games.snapshots (game, source, kind, data)
|
||||||
values (${game}, ${source}, ${kind}, ${sql.json(data as never)})
|
values (${game}, ${source}, ${kind}, ${sql.json(data as never)})
|
||||||
returning id, taken_at as "takenAt"`;
|
returning id, taken_at as "takenAt"`;
|
||||||
return row;
|
return { ...row, takenAt: new Date(row.takenAt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function lastSnapshot(sql: Db, game: string, source: string, kind: string) {
|
export async function lastSnapshot(sql: Db, game: string, source: string, kind: string) {
|
||||||
@@ -111,7 +111,7 @@ export async function lastSnapshot(sql: Db, game: string, source: string, kind:
|
|||||||
select id, taken_at as "takenAt", data from games.snapshots
|
select id, taken_at as "takenAt", data from games.snapshots
|
||||||
where game = ${game} and source = ${source} and kind = ${kind}
|
where game = ${game} and source = ${source} and kind = ${kind}
|
||||||
order by taken_at desc limit 1`;
|
order by taken_at desc limit 1`;
|
||||||
return row ?? null;
|
return row ? { ...row, takenAt: new Date(row.takenAt) } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveState(sql: Db, account: Account, patch: Record<string, unknown>) {
|
export async function saveState(sql: Db, account: Account, patch: Record<string, unknown>) {
|
||||||
@@ -119,4 +119,7 @@ export async function saveState(sql: Db, account: Account, patch: Record<string,
|
|||||||
await sql`update games.accounts set state = ${sql.json(account.state as never)} where id = ${account.id}`;
|
await sql`update games.accounts set state = ${sql.json(account.state as never)} where id = ${account.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const hoursSince = (d: Date | null) => (d ? (Date.now() - d.getTime()) / 3.6e6 : Infinity);
|
// Timestamps come back as strings on the app pool (drizzle swaps the date parsers) and as
|
||||||
|
// Dates on a plain postgres.js client; Date params are sent as ISO strings for the same reason.
|
||||||
|
export const hoursSince = (d: Date | string | null) =>
|
||||||
|
d ? (Date.now() - new Date(d).getTime()) / 3.6e6 : Infinity;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export type Account = {
|
|||||||
externalId: string;
|
externalId: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
state: Record<string, unknown>;
|
state: Record<string, unknown>;
|
||||||
lastSuccessAt: Date | null;
|
lastSuccessAt: Date | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlayerInput = {
|
export type PlayerInput = {
|
||||||
|
|||||||
Reference in New Issue
Block a user