From 203a54be448020f342c95be0df9ec5d898b2d0da Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Fri, 9 Sep 2022 21:53:46 +0100 Subject: [PATCH] Add a command to download all battle results from SplatNet 3 --- src/api/splatnet3-types.ts | 14 +++++ src/api/splatnet3.ts | 16 ++++- src/cli/splatnet3/dump-results.ts | 99 +++++++++++++++++++++++++++++++ src/cli/splatnet3/index.ts | 1 + 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/cli/splatnet3/dump-results.ts diff --git a/src/api/splatnet3-types.ts b/src/api/splatnet3-types.ts index a0d3c2f..763cf13 100644 --- a/src/api/splatnet3-types.ts +++ b/src/api/splatnet3-types.ts @@ -655,6 +655,20 @@ interface HomeBanner { jumpTo: string; } +/** 994cf141e55213e6923426caf37a1934 VsHistoryDetailPagerRefetchQuery */ +export interface VsHistoryDetailPagerRefetchQueryResult { + vsHistoryDetail: { + __typename: 'VsHistoryDetail'; + nextHistoryDetail: { + id: string; + } | null; + previousHistoryDetail: { + id: string; + } | null; + id: string; + }; +} + /** cd82f2ade8aca7687947c5f3210805a6 VsHistoryDetailQuery */ export interface VsHistoryDetailResult { vsHistoryDetail: VsHistoryDetail; diff --git a/src/api/splatnet3.ts b/src/api/splatnet3.ts index 4f717ac..68bcbe2 100644 --- a/src/api/splatnet3.ts +++ b/src/api/splatnet3.ts @@ -110,16 +110,30 @@ export default class SplatNet3Api { return this.persistedQuery(RequestId.RegularBattleHistoriesQuery, {}); } + async getBankaraBattleHistories() { + return this.persistedQuery(RequestId.BankaraBattleHistoriesQuery, {}); + } + async getPrivateBattleHistories() { return this.persistedQuery(RequestId.PrivateBattleHistoriesQuery, {}); } - async getVsHistoryDetail(id: string) { + async getBattleHistoryDetail(id: string) { return this.persistedQuery(RequestId.VsHistoryDetailQuery, { vsResultId: id, }); } + async getBattleHistoryDetailPagerRefetch(id: string) { + return this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, { + vsResultId: id, + }); + } + + async getCoopHistory() { + return this.persistedQuery(RequestId.CoopHistoryQuery, {}); + } + static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) { const data = await this.loginWithCoral(nso, user); return {splatnet: this.createWithSavedToken(data), data}; diff --git a/src/cli/splatnet3/dump-results.ts b/src/cli/splatnet3/dump-results.ts new file mode 100644 index 0000000..2f5a9e6 --- /dev/null +++ b/src/cli/splatnet3/dump-results.ts @@ -0,0 +1,99 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import createDebug from 'debug'; +import mkdirp from 'mkdirp'; +import type { Arguments as ParentArguments } from '../splatnet3.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; +import { initStorage } from '../../util/storage.js'; +import { getBulletToken } from '../../common/auth/splatnet3.js'; +import SplatNet3Api from '../../api/splatnet3.js'; + +const debug = createDebug('cli:splatnet3:dump-results'); + +export const command = 'dump-results [directory]'; +export const desc = 'Download all battle and coop results'; + +export function builder(yargs: Argv) { + return yargs.positional('directory', { + describe: 'Directory to write record data to', + type: 'string', + }).option('user', { + describe: 'Nintendo Account ID', + type: 'string', + }).option('token', { + describe: 'Nintendo Account session token', + type: 'string', + }).option('battles', { + describe: 'Include regular/ranked/private/festival battle results', + type: 'boolean', + default: true, + }).option('coop', { + describe: 'Include coop (Salmon Run) results', + type: 'boolean', + default: true, + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const storage = await initStorage(argv.dataPath); + + const usernsid = argv.user ?? await storage.getItem('SelectedUser'); + const token: string = argv.token || + await storage.getItem('NintendoAccountToken.' + usernsid); + const {splatnet} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession); + + const directory = argv.directory ?? path.join(argv.dataPath, 'splatnet3'); + + await mkdirp(directory); + + if (argv.battles) { + await dumpResults(splatnet, directory); + } + // if (argv.coop) { + // await dumpCoopResults(splatnet, directory); + // } +} + +export async function dumpResults( + splatnet: SplatNet3Api, directory: string +) { + debug('Fetching battle results'); + const player = await splatnet.getBattleHistoryCurrentPlayer(); + const battles = await splatnet.getLatestBattleHistories(); + const battles_regular = await splatnet.getRegularBattleHistories(); + const battles_anarchy = await splatnet.getBankaraBattleHistories(); + const battles_private = await splatnet.getPrivateBattleHistories(); + + const skipped = []; + + // Reverse battle history order so oldest records are downloaded first + for (const group of battles.data.latestBattleHistories.historyGroups.nodes.reverse()) { + for (const item of group.historyDetails.nodes.reverse()) { + const id_str = Buffer.from(item.id, 'base64').toString() || item.id; + + const filename = 'splatnet3-result-' + id_str + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + skipped.push(item.id); + } catch (err) { + debug('Fetching battle result %s', id_str); + const result = await splatnet.getBattleHistoryDetail(item.id); + const pager = await splatnet.getBattleHistoryDetailPagerRefetch(item.id); + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result: result.data.vsHistoryDetail, + }, null, 4) + '\n', 'utf-8'); + } + } + } + + if (skipped.length) { + if (skipped.length === 1) debug('Skipped battle result %s, file already exists', skipped[0]); + else debug('Skipped %d battle results, files already exist', skipped.length); + } +} diff --git a/src/cli/splatnet3/index.ts b/src/cli/splatnet3/index.ts index 93f623d..4a2b7fb 100644 --- a/src/cli/splatnet3/index.ts +++ b/src/cli/splatnet3/index.ts @@ -2,3 +2,4 @@ export * as user from './user.js'; export * as token from './token.js'; export * as schedule from './schedule.js'; export * as battles from './battles.js'; +export * as dumpResults from './dump-results.js';