diff --git a/src/cli/splatnet3/dump-album.ts b/src/cli/splatnet3/dump-album.ts new file mode 100644 index 0000000..4789423 --- /dev/null +++ b/src/cli/splatnet3/dump-album.ts @@ -0,0 +1,127 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import createDebug from 'debug'; +import mkdirp from 'mkdirp'; +import fetch from 'node-fetch'; +import { PhotoAlbumResult, RequestId } from 'splatnet3-types/splatnet3'; +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'; +import { ResponseSymbol } from '../../api/util.js'; +import { timeoutSignal } from '../../util/misc.js'; + +const debug = createDebug('cli:splatnet3:dump-album'); + +export const command = 'dump-album [directory]'; +export const desc = 'Download all album photos'; + +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', + }); +} + +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); + + await dumpAlbumPhotos(splatnet, directory); +} + +export async function dumpAlbumPhotos( + splatnet: SplatNet3Api, directory: string, + refresh: PhotoAlbumResult | boolean = false +) { + debug('Fetching photo album items'); + console.warn('Fetching photo album items'); + + const results = refresh ? + await splatnet.getPhotoAlbumRefetch() : + await splatnet.getPhotoAlbum(); + + if (typeof refresh !== 'object' || + results.data.photoAlbum.items.nodes[0].id !== refresh.photoAlbum.items.nodes[0].id + ) { + const filename = 'splatnet3-photoalbum-' + Date.now() + '.json'; + const file = path.join(directory, filename); + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result: results.data.photoAlbum, + query: refresh ? RequestId.PhotoAlbumRefetchQuery : RequestId.PhotoAlbumQuery, + app_version: splatnet.version, + be_version: results[ResponseSymbol].headers.get('x-be-version'), + }, null, 4) + '\n', 'utf-8'); + } + + for (const item of [...results.data.photoAlbum.items.nodes].reverse()) { + const id_str = Buffer.from(item.id, 'base64').toString() || item.id; + const match = id_str.match(/^PhotoAlbumItem-(\d+)$/); + const id = match ? match[1] : id_str; + + const thumbnail_filename = 'splatnet3-photothumbnail-' + id + '.jpeg'; + const thumbnail_file = path.join(directory, thumbnail_filename); + + try { + await fs.stat(thumbnail_file); + } catch (err) { + debug('Fetching photo thumbnail %s', id, item.uploadedTime); + console.warn('Fetching photo thumbnail %s', id, item.uploadedTime); + + const [signal, cancel] = timeoutSignal(); + const response = await fetch(item.thumbnail.url, { + headers: { + 'User-Agent': splatnet.useragent, + }, + signal, + }).finally(cancel); + const data = new Uint8Array(await response.arrayBuffer()); + + debug('Writing %s', thumbnail_filename); + await fs.writeFile(thumbnail_file, data); + } + + const filename = 'splatnet3-photo-' + id + '.jpeg'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + } catch (err) { + debug('Fetching photo %s', id, item.uploadedTime); + console.warn('Fetching photo %s', id, item.uploadedTime); + + const [signal, cancel] = timeoutSignal(); + const response = await fetch(item.photo.url, { + headers: { + 'User-Agent': splatnet.useragent, + }, + signal, + }).finally(cancel); + const data = new Uint8Array(await response.arrayBuffer()); + + debug('Writing %s', filename); + await fs.writeFile(file, data); + } + } + + return results.data; +} diff --git a/src/cli/splatnet3/dump-fests.ts b/src/cli/splatnet3/dump-fests.ts new file mode 100644 index 0000000..a6f6859 --- /dev/null +++ b/src/cli/splatnet3/dump-fests.ts @@ -0,0 +1,173 @@ +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import createDebug from 'debug'; +import mkdirp from 'mkdirp'; +import { FestState, Fest_detail, RequestId } from 'splatnet3-types/splatnet3'; +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'; +import { ResponseSymbol } from '../../api/util.js'; + +const debug = createDebug('cli:splatnet3:dump-records'); + +export const command = 'dump-fests [directory]'; +export const desc = 'Download all Splatfest records'; + +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('include-rankings', { + describe: 'Include fest rankings', + type: 'boolean', + default: false, + }); +} + +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); + + await dumpFestRecords(splatnet, directory, argv.includeRankings); +} + +export async function dumpFestRecords(splatnet: SplatNet3Api, directory: string, include_rankings = false) { + debug('Fetching fest records'); + console.warn('Fetching fest records'); + + const records = await splatnet.getFestRecords(); + + const filename = 'splatnet3-fests-' + Date.now() + '.json'; + const file = path.join(directory, filename); + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result: records.data.festRecords, + player: records.data.currentPlayer, + query: RequestId.FestRecordQuery, + app_version: splatnet.version, + be_version: records[ResponseSymbol].headers.get('x-be-version'), + }, null, 4) + '\n', 'utf-8'); + + const skipped = []; + + for (const fest_record of [...records.data.festRecords.nodes].reverse()) { + // Fest-EU:JUEA-00001 + const id_str = Buffer.from(fest_record.id, 'base64').toString() || fest_record.id; + const match = id_str.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/); + const id = match ? match[1] + '-' + match[2] : id_str; + + const filename = 'splatnet3-fest-' + id + '-' + + (fest_record.state !== FestState.CLOSED ? Date.now() + '-' : '') + + RequestId.DetailFestRecordDetailQuery + '.json'; + const file = path.join(directory, filename); + + let record: Fest_detail | null = null; + + try { + await fs.stat(file); + // skipped.push(id); + } catch (err) { + debug('Fetching fest record %s', id); + console.warn('Fetching fest record %s', id); + + const result = await splatnet.getFestDetail(fest_record.id); + record = result.data.fest; + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result: result.data.fest, + player: result.data.currentPlayer, + query: RequestId.DetailFestRecordDetailQuery, + app_version: splatnet.version, + be_version: result[ResponseSymbol].headers.get('x-be-version'), + }, null, 4) + '\n', 'utf-8'); + } + + if (fest_record.state !== FestState.CLOSED) { + const filename = 'splatnet3-festvotes-' + id + '-' + Date.now() + '-' + + RequestId.DetailVotingStatusQuery + '.json'; + const file = path.join(directory, filename); + + // Fetch this now to match the behavour of Nintendo's app + // If state !== closed it shouldn't be possible to get here + if (!record) { + const result = await splatnet.getFestDetail(fest_record.id); + record = result.data.fest; + } + + debug('Fetching fest voting status %s', id); + console.warn('Fetching fest voting status %s', id); + const result = await splatnet.getFestVotingStatus(fest_record.id); + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result: result.data.fest, + query: RequestId.DetailVotingStatusQuery, + app_version: splatnet.version, + be_version: result[ResponseSymbol].headers.get('x-be-version'), + }, null, 4) + '\n', 'utf-8'); + } + + if (include_rankings) { + const filename = 'splatnet3-festranking-' + id + '-' + RequestId.DetailRankingQuery + '.json'; + const file = path.join(directory, filename); + + try { + await fs.stat(file); + } catch (err) { + // Fetch this now to match the behavour of Nintendo's app + if (!record) { + const result = await splatnet.getFestDetail(fest_record.id); + record = result.data.fest; + } + + const rankings_available = record.state === FestState.CLOSED && + !!record.teams[0].result; + + if (rankings_available) { + debug('Fetching fest rankings %s', id); + console.warn('Fetching fest rankings %s', id); + const result = await splatnet.getFestRanking(fest_record.id); + + debug('Writing %s', filename); + await fs.writeFile(file, JSON.stringify({ + result: result.data.fest, + query: RequestId.DetailFestRecordDetailQuery, + app_version: splatnet.version, + be_version: result[ResponseSymbol].headers.get('x-be-version'), + }, null, 4) + '\n', 'utf-8'); + } else { + debug('Skipping downloading rankings for %s, not yet available', id); + } + } + } + + if (!record) { + skipped.push(id); + } + } + + if (skipped.length) { + if (skipped.length === 1) debug('Skipped fest %s, file already exists', skipped[0]); + else debug('Skipped %d fests, files already exist', skipped.length); + } +} diff --git a/src/cli/splatnet3/dump-records.ts b/src/cli/splatnet3/dump-records.ts index 4d6e812..fce62ec 100644 --- a/src/cli/splatnet3/dump-records.ts +++ b/src/cli/splatnet3/dump-records.ts @@ -2,20 +2,18 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import createDebug from 'debug'; import mkdirp from 'mkdirp'; -import fetch from 'node-fetch'; -import { FestState, Fest_detail, PhotoAlbumResult, RequestId } from 'splatnet3-types/splatnet3'; +import { RequestId } from 'splatnet3-types/splatnet3'; 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'; import { ResponseSymbol } from '../../api/util.js'; -import { timeoutSignal } from '../../util/misc.js'; const debug = createDebug('cli:splatnet3:dump-records'); export const command = 'dump-records [directory]'; -export const desc = 'Download all player history/hero/fest/catalog records, stage/weapon stats and album photos'; +export const desc = 'Download all player history/hero/catalog records and stage/weapon stats'; export function builder(yargs: Argv) { return yargs.positional('directory', { @@ -33,13 +31,6 @@ export function builder(yargs: Argv) { }).option('hero', { describe: 'Include hero records', type: 'boolean', - }).option('fest', { - describe: 'Include fest records', - type: 'boolean', - }).option('fest-rankings', { - describe: 'Include fest rankings (requires --fest)', - type: 'boolean', - default: false, }).option('catalog', { describe: 'Include catalog records', type: 'boolean', @@ -49,22 +40,17 @@ export function builder(yargs: Argv) { }).option('weapon', { describe: 'Include weapon stats', type: 'boolean', - }).option('album', { - describe: 'Include photo album', - type: 'boolean', }); } type Arguments = YargsArguments>; export async function handler(argv: ArgumentsCamelCase) { - const _all = [argv.history, argv.hero, argv.fest, argv.catalog, argv.stage, argv.weapon, argv.album]; + const _all = [argv.history, argv.hero, argv.catalog, argv.stage, argv.weapon]; const _default = !_all.find(f => f === true); - debug('default', _all, _default, _all.some(f => f ?? _default)); - if (!_all.some(f => f ?? _default)) { - throw new Error('Enable one of --history, --hero, --fest, --catalog, --stage, --weapon or --album'); + throw new Error('Enable one of --history, --hero, --catalog, --stage or --weapon'); } const storage = await initStorage(argv.dataPath); @@ -84,9 +70,6 @@ export async function handler(argv: ArgumentsCamelCase) { if (argv.hero ?? _default) { await dumpHeroRecords(splatnet, directory); } - if (argv.fest ?? _default) { - await dumpFestRecords(splatnet, directory, argv.festRankings); - } if (argv.catalog ?? _default) { await dumpCatalogRecords(splatnet, directory); } @@ -96,9 +79,6 @@ export async function handler(argv: ArgumentsCamelCase) { if (argv.weapon ?? _default) { await dumpWeaponStats(splatnet, directory); } - if (argv.album ?? _default) { - await dumpAlbumPhotos(splatnet, directory); - } } export async function dumpHistoryRecords(splatnet: SplatNet3Api, directory: string, refresh = false) { @@ -140,129 +120,6 @@ export async function dumpHeroRecords(splatnet: SplatNet3Api, directory: string) }, null, 4) + '\n', 'utf-8'); } -export async function dumpFestRecords(splatnet: SplatNet3Api, directory: string, include_rankings = false) { - debug('Fetching fest records'); - console.warn('Fetching fest records'); - - const records = await splatnet.getFestRecords(); - - const filename = 'splatnet3-fests-' + Date.now() + '.json'; - const file = path.join(directory, filename); - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result: records.data.festRecords, - player: records.data.currentPlayer, - query: RequestId.FestRecordQuery, - app_version: splatnet.version, - be_version: records[ResponseSymbol].headers.get('x-be-version'), - }, null, 4) + '\n', 'utf-8'); - - const skipped = []; - - for (const fest_record of [...records.data.festRecords.nodes].reverse()) { - // Fest-EU:JUEA-00001 - const id_str = Buffer.from(fest_record.id, 'base64').toString() || fest_record.id; - const match = id_str.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/); - const id = match ? match[1] + '-' + match[2] : id_str; - - const filename = 'splatnet3-fest-' + id + '-' + - (fest_record.state !== FestState.CLOSED ? Date.now() + '-' : '') + - RequestId.DetailFestRecordDetailQuery + '.json'; - const file = path.join(directory, filename); - - let record: Fest_detail | null = null; - - try { - await fs.stat(file); - // skipped.push(id); - } catch (err) { - debug('Fetching fest record %s', id); - console.warn('Fetching fest record %s', id); - - const result = await splatnet.getFestDetail(fest_record.id); - record = result.data.fest; - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result: result.data.fest, - player: result.data.currentPlayer, - query: RequestId.DetailFestRecordDetailQuery, - app_version: splatnet.version, - be_version: result[ResponseSymbol].headers.get('x-be-version'), - }, null, 4) + '\n', 'utf-8'); - } - - if (fest_record.state !== FestState.CLOSED) { - const filename = 'splatnet3-festvotes-' + id + '-' + Date.now() + '-' + - RequestId.DetailVotingStatusQuery + '.json'; - const file = path.join(directory, filename); - - // Fetch this now to match the behavour of Nintendo's app - // If state !== closed it shouldn't be possible to get here - if (!record) { - const result = await splatnet.getFestDetail(fest_record.id); - record = result.data.fest; - } - - debug('Fetching fest voting status %s', id); - console.warn('Fetching fest voting status %s', id); - const result = await splatnet.getFestVotingStatus(fest_record.id); - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result: result.data.fest, - query: RequestId.DetailVotingStatusQuery, - app_version: splatnet.version, - be_version: result[ResponseSymbol].headers.get('x-be-version'), - }, null, 4) + '\n', 'utf-8'); - } - - if (include_rankings) { - const filename = 'splatnet3-festranking-' + id + '-' + RequestId.DetailRankingQuery + '.json'; - const file = path.join(directory, filename); - - try { - await fs.stat(file); - } catch (err) { - // Fetch this now to match the behavour of Nintendo's app - if (!record) { - const result = await splatnet.getFestDetail(fest_record.id); - record = result.data.fest; - } - - const rankings_available = record.state === FestState.CLOSED && - !!record.teams[0].result; - - if (rankings_available) { - debug('Fetching fest rankings %s', id); - console.warn('Fetching fest rankings %s', id); - const result = await splatnet.getFestRanking(fest_record.id); - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result: result.data.fest, - query: RequestId.DetailFestRecordDetailQuery, - app_version: splatnet.version, - be_version: result[ResponseSymbol].headers.get('x-be-version'), - }, null, 4) + '\n', 'utf-8'); - } else { - debug('Skipping downloading rankings for %s, not yet available', id); - } - } - } - - if (!record) { - skipped.push(id); - } - } - - if (skipped.length) { - if (skipped.length === 1) debug('Skipped fest %s, file already exists', skipped[0]); - else debug('Skipped %d fests, files already exist', skipped.length); - } -} - export async function dumpCatalogRecords(splatnet: SplatNet3Api, directory: string, refresh = false) { debug('Fetching catalog records'); console.warn('Fetching catalog records'); @@ -322,82 +179,3 @@ export async function dumpWeaponStats(splatnet: SplatNet3Api, directory: string, be_version: results[ResponseSymbol].headers.get('x-be-version'), }, null, 4) + '\n', 'utf-8'); } - -export async function dumpAlbumPhotos( - splatnet: SplatNet3Api, directory: string, - refresh: PhotoAlbumResult | boolean = false -) { - debug('Fetching photo album items'); - console.warn('Fetching photo album items'); - - const results = refresh ? - await splatnet.getPhotoAlbumRefetch() : - await splatnet.getPhotoAlbum(); - - if (typeof refresh !== 'object' || - results.data.photoAlbum.items.nodes[0].id !== refresh.photoAlbum.items.nodes[0].id - ) { - const filename = 'splatnet3-photoalbum-' + Date.now() + '.json'; - const file = path.join(directory, filename); - - debug('Writing %s', filename); - await fs.writeFile(file, JSON.stringify({ - result: results.data.photoAlbum, - query: refresh ? RequestId.PhotoAlbumRefetchQuery : RequestId.PhotoAlbumQuery, - app_version: splatnet.version, - be_version: results[ResponseSymbol].headers.get('x-be-version'), - }, null, 4) + '\n', 'utf-8'); - } - - for (const item of [...results.data.photoAlbum.items.nodes].reverse()) { - const id_str = Buffer.from(item.id, 'base64').toString() || item.id; - const match = id_str.match(/^PhotoAlbumItem-(\d+)$/); - const id = match ? match[1] : id_str; - - const thumbnail_filename = 'splatnet3-photothumbnail-' + id + '.jpeg'; - const thumbnail_file = path.join(directory, thumbnail_filename); - - try { - await fs.stat(thumbnail_file); - } catch (err) { - debug('Fetching photo thumbnail %s', id, item.uploadedTime); - console.warn('Fetching photo thumbnail %s', id, item.uploadedTime); - - const [signal, cancel] = timeoutSignal(); - const response = await fetch(item.thumbnail.url, { - headers: { - 'User-Agent': splatnet.useragent, - }, - signal, - }).finally(cancel); - const data = new Uint8Array(await response.arrayBuffer()); - - debug('Writing %s', thumbnail_filename); - await fs.writeFile(thumbnail_file, data); - } - - const filename = 'splatnet3-photo-' + id + '.jpeg'; - const file = path.join(directory, filename); - - try { - await fs.stat(file); - } catch (err) { - debug('Fetching photo %s', id, item.uploadedTime); - console.warn('Fetching photo %s', id, item.uploadedTime); - - const [signal, cancel] = timeoutSignal(); - const response = await fetch(item.photo.url, { - headers: { - 'User-Agent': splatnet.useragent, - }, - signal, - }).finally(cancel); - const data = new Uint8Array(await response.arrayBuffer()); - - debug('Writing %s', filename); - await fs.writeFile(file, data); - } - } - - return results.data; -} diff --git a/src/cli/splatnet3/index.ts b/src/cli/splatnet3/index.ts index c93f853..b1aef27 100644 --- a/src/cli/splatnet3/index.ts +++ b/src/cli/splatnet3/index.ts @@ -6,4 +6,6 @@ export * as festivals from './festivals.js'; export * as festival from './festival.js'; export * as battles from './battles.js'; export * as dumpRecords from './dump-records.js'; +export * as dumpFests from './dump-fests.js'; +export * as dumpAlbum from './dump-album.js'; export * as dumpResults from './dump-results.js';