Add the plebleader daily sync and package the archive CLI

`sync` refreshes the token chain first, then fetches new BeatLeader and
ScoreSaber scores (with a day of overlap), their replays, score stats,
leaderboards and map zips, and re-snapshots every played hash on Sundays.
The flake's plebleader-archive package runs the CLI with Node's type
stripping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 07:55:58 -08:00
co-authored by Claude Opus 5.5
parent 51c1e6d3d1
commit d182fd808c
4 changed files with 119 additions and 17 deletions
+14 -8
View File
@@ -3,11 +3,11 @@
import * as path from 'node:path';
import { BL_API } from './config.ts';
import { get, limiters } from './http.ts';
import { archivePath, eachWithProgress, exists, listFiles, readJson, save, saveSnapshotIfChanged, today, updateState } from './store.ts';
import { archivePath, eachWithProgress, exists, listFiles, readJson, readState, save, saveSnapshotIfChanged, today, updateState } from './store.ts';
const bl = (...p: string[]) => archivePath('beatleader', ...p);
type BLScore = { id: number; timepost: number; replay?: string | null; leaderboard: { song: { hash: string } } };
export type BLScore = { id: number; playerId: string; timepost: number; replay?: string | null; leaderboard: { song: { hash: string } } };
async function blGet(pathAndQuery: string, accept?: number[]) {
return get(`${BL_API}${pathAndQuery}`, { limiter: limiters.beatleader, accept });
@@ -39,12 +39,12 @@ export async function exportProfile(playerId: string): Promise<void> {
}
}
/** Page through best scores newest-first from `sinceUnix`, saving each score object. */
export async function exportScores(playerId: string, sinceUnix: number): Promise<void> {
/** Page through best scores newest-first from `sinceUnix`, saving each score object. Returns the new ones. */
export async function exportScores(playerId: string, sinceUnix: number): Promise<BLScore[]> {
console.log(`BeatLeader scores for ${playerId} since ${new Date(sinceUnix * 1000).toISOString()}`);
let added = 0;
const added: BLScore[] = [];
let seen = 0;
let newest = 0;
let newest = Number(readState()[`beatleader:${playerId}`]?.newestTimepost ?? 0);
for (let page = 1; ; page++) {
const qs = `count=100&page=${page}&sortBy=date&order=desc&includeIO=true&time_from=${sinceUnix}`;
const res = await blGet(`/player/${playerId}/scores?${qs}`);
@@ -56,12 +56,18 @@ export async function exportScores(playerId: string, sinceUnix: number): Promise
if (exists(file)) continue;
// The page is parsed and each element re-serialized: same content, no reshaping.
save(file, JSON.stringify(score));
added++;
added.push(score);
}
console.log(` page ${page}: ${seen}/${json.metadata.total} seen, ${added} new`);
console.log(` page ${page}: ${seen}/${json.metadata.total} seen, ${added.length} new`);
if (json.data.length < 100 || seen >= json.metadata.total) break;
}
updateState(`beatleader:${playerId}`, { scoresSince: sinceUnix, newestTimepost: newest, lastScoresRun: new Date().toISOString() });
return added;
}
/** Newest archived score time for a player (unix seconds), 0 when there is none. */
export function newestScoreTime(playerId: string): number {
return archivedScores().reduce((max, s) => (s.playerId === playerId ? Math.max(max, s.timepost) : max), 0);
}
export async function exportReplays(scores: BLScore[]): Promise<void> {