scripts/archive is a CLI that exports BeatLeader and ScoreSaber scores, replays, score stats, leaderboard snapshots and BeatSaver map zips into an append-only archive. Anchor the .gitignore 'archive' rule to the repo root so it no longer hides scripts/archive. Record phase 1 results in the plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
3.2 KiB
TypeScript
77 lines
3.2 KiB
TypeScript
// Shared configuration for the plebleader archive CLI.
|
|
// Runs directly under Node >= 22 (type stripping), no build step.
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
|
|
export const USER_AGENT = 'plebsaber-archive/0.1';
|
|
export const BL_API = 'https://api.beatleader.com';
|
|
export const MAIN_PLAYER_ID = '76561199407393962';
|
|
export const LEGACY_PLAYER_ID = '76561197962107242';
|
|
|
|
export const PLAYER_IDS = [MAIN_PLAYER_ID, LEGACY_PLAYER_ID];
|
|
|
|
/** Scores, replays, score stats and profile data: all history by default. */
|
|
export const DEFAULT_SINCE = 'all';
|
|
/**
|
|
* BeatSaver map files (JSON + zip) only for maps played in the last two years. Older maps can
|
|
* be fetched later with --maps-since all (existing files are skipped).
|
|
*/
|
|
export const DEFAULT_MAPS_SINCE = '2024-11-08';
|
|
|
|
/** Where ss-scraper already downloaded ScoreSaber replays (<playerId>/<scoreId>_<name>.dat). */
|
|
export const SS_SCRAPER_REPLAYS = process.env.SS_SCRAPER_REPLAYS ?? path.join(process.env.HOME ?? '', 'ops/beatsaber/ss-scraper/replays');
|
|
|
|
/** Parse --since: YYYY-MM-DD (UTC), unix seconds, or "all". */
|
|
export function parseSince(value: string | undefined, fallback = DEFAULT_SINCE): number {
|
|
const v = value ?? fallback;
|
|
if (v === 'all') return 0;
|
|
if (/^\d+$/.test(v)) return Number(v);
|
|
const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(v) ? `${v}T00:00:00Z` : v);
|
|
if (Number.isNaN(ms)) throw new Error(`Invalid --since: ${v}`);
|
|
return Math.floor(ms / 1000);
|
|
}
|
|
|
|
/**
|
|
* Root of the plebleader state (archive/, auth/, db/, ...).
|
|
* Production: /var/lib/plebleader. Local default: ./.data/plebleader (gitignored).
|
|
*/
|
|
export const ROOT = path.resolve(process.env.PLEBLEADER_DIR ?? path.join(process.cwd(), '.data', 'plebleader'));
|
|
export const ARCHIVE_DIR = path.join(ROOT, 'archive');
|
|
export const AUTH_DIR = path.join(ROOT, 'auth');
|
|
export const TOKENS_FILE = path.join(AUTH_DIR, 'beatleader_tokens.json');
|
|
|
|
/** Load KEY=VALUE (optionally `export KEY=VALUE`) lines from .env without overriding the real environment. */
|
|
export function loadDotEnv(file = path.join(process.cwd(), '.env')): void {
|
|
if (!fs.existsSync(file)) return;
|
|
for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
|
|
const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
if (!m) continue;
|
|
const [, key, raw] = m;
|
|
const value = raw.replace(/^(['"])(.*)\1$/, '$2');
|
|
if (process.env[key] === undefined) process.env[key] = value;
|
|
}
|
|
}
|
|
|
|
export function clientCredentials(): { clientId: string; clientSecret: string } {
|
|
const clientId = process.env.BL_CLIENT_ID;
|
|
const clientSecret = process.env.BL_CLIENT_SECRET;
|
|
if (!clientId || !clientSecret) {
|
|
throw new Error('BL_CLIENT_ID and BL_CLIENT_SECRET must be set (environment or .env).');
|
|
}
|
|
return { clientId, clientSecret };
|
|
}
|
|
|
|
/** Write a file atomically: temp file in the same directory, fsync, rename. */
|
|
export function writeFileAtomic(file: string, data: string | Uint8Array, mode = 0o644): void {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
const fd = fs.openSync(tmp, 'w', mode);
|
|
try {
|
|
fs.writeSync(fd, typeof data === 'string' ? Buffer.from(data, 'utf-8') : data);
|
|
fs.fsyncSync(fd);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
fs.renameSync(tmp, file);
|
|
}
|