Add a command to monitor SplatNet 3 records

This commit is contained in:
Samuel Elliott 2022-10-26 14:20:54 +01:00
parent a33a36c4f5
commit 8f6ae71061
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
7 changed files with 463 additions and 49 deletions

14
package-lock.json generated
View File

@ -22,7 +22,7 @@
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.1.20221021222422",
"splatnet3-types": "^0.1.20221026095800",
"supports-color": "^8.1.1",
"tslib": "^2.4.0",
"uuid": "^8.3.2",
@ -4363,9 +4363,9 @@
"dev": true
},
"node_modules/splatnet3-types": {
"version": "0.1.20221021222422",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221021222422.tgz",
"integrity": "sha512-eAzmc5x4/tVeFGXs0yASlw/fCte4YTV/BmeimkJvH83Y38mOV7J9fghcaKJyDMMvRTcp0hVr3v3feOCzh90o+g=="
"version": "0.1.20221026095800",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221026095800.tgz",
"integrity": "sha512-2PEFKno7ZYfMqLxZbEU9UCRZV9YV+a3j9P8otn189qPHlURfwTQC5uoQ5s5x2+IEkacYmo2P9ex+RZWzy1q6kA=="
},
"node_modules/sprintf-js": {
"version": "1.1.2",
@ -8394,9 +8394,9 @@
"dev": true
},
"splatnet3-types": {
"version": "0.1.20221021222422",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221021222422.tgz",
"integrity": "sha512-eAzmc5x4/tVeFGXs0yASlw/fCte4YTV/BmeimkJvH83Y38mOV7J9fghcaKJyDMMvRTcp0hVr3v3feOCzh90o+g=="
"version": "0.1.20221026095800",
"resolved": "https://registry.npmjs.org/splatnet3-types/-/splatnet3-types-0.1.20221026095800.tgz",
"integrity": "sha512-2PEFKno7ZYfMqLxZbEU9UCRZV9YV+a3j9P8otn189qPHlURfwTQC5uoQ5s5x2+IEkacYmo2P9ex+RZWzy1q6kA=="
},
"sprintf-js": {
"version": "1.1.2",

View File

@ -48,7 +48,7 @@
"node-notifier": "^10.0.1",
"node-persist": "^3.1.0",
"read": "^1.0.7",
"splatnet3-types": "^0.1.20221021222422",
"splatnet3-types": "^0.1.20221026095800",
"supports-color": "^8.1.1",
"tslib": "^2.4.0",
"uuid": "^8.3.2",

View File

@ -266,30 +266,54 @@ export default class SplatNet3Api {
/** / -> /challenge -> /challenge/{id} */
async getChallengeJourney(id: string) {
return this.persistedQuery(RequestId.JourneyQuery, {
const result = await this.persistedQuery(RequestId.JourneyQuery, {
id,
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /challenge -> /challenge/{id} -> pull-to-refresh */
async getChallengeJourneyRefetch(id: string) {
return this.persistedQuery(RequestId.JourneyRefetchQuery, {
const result = await this.persistedQuery(RequestId.JourneyRefetchQuery, {
id,
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /challenge -> /challenge/{id} -> /challenge/{id}/*s */
async getChallengeJourneyChallenges(id: string) {
return this.persistedQuery(RequestId.JourneyChallengeDetailQuery, {
const result = await this.persistedQuery(RequestId.JourneyChallengeDetailQuery, {
journeyId: id,
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> pull-to-refresh */
async getChallengeJourneyChallengesRefetch(id: string) {
return this.persistedQuery(RequestId.JourneyChallengeDetailRefetchQuery, {
const result = await this.persistedQuery(RequestId.JourneyChallengeDetailRefetchQuery, {
journeyId: id,
});
if (!result.data.journey) {
throw new ErrorResponse('[splatnet3] Journey not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /challenge -> /challenge/{id} -> /challenge/{id}/* -> support */
@ -315,30 +339,54 @@ export default class SplatNet3Api {
/** / -> /fest_record/{id} */
async getFestDetail(id: string) {
return this.persistedQuery(RequestId.DetailFestRecordDetailQuery, {
const result = await this.persistedQuery(RequestId.DetailFestRecordDetailQuery, {
festId: id,
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /fest_record -> /fest_record/{id} -> pull-to-refresh */
async getFestDetailRefetch(id: string) {
return this.persistedQuery(RequestId.DetailFestRefethQuery, {
const result = await this.persistedQuery(RequestId.DetailFestRefethQuery, {
festId: id,
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} */
async getFestVotingStatus(id: string) {
return this.persistedQuery(RequestId.DetailVotingStatusQuery, {
const result = await this.persistedQuery(RequestId.DetailVotingStatusQuery, {
festId: id,
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} -> pull-to-refresh */
async getFestVotingStatusRefetch(id: string) {
return this.persistedQuery(RequestId.DetailFestVotingStatusRefethQuery, {
const result = await this.persistedQuery(RequestId.DetailFestVotingStatusRefethQuery, {
festId: id,
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /fest_record -> /fest_record/{id} - not closed -> /fest_record/voting_status/{id} - not voted in game */
@ -350,9 +398,15 @@ export default class SplatNet3Api {
/** / -> /fest_record -> /fest_record/{id} - closed -> /fest_record/ranking/{id} */
async getFestRanking(id: string) {
return this.persistedQuery(RequestId.DetailRankingQuery, {
const result = await this.persistedQuery(RequestId.DetailRankingQuery, {
festId: id,
});
if (!result.data.fest) {
throw new ErrorResponse('[splatnet3] Fest not found', result[ResponseSymbol], result);
}
return result;
}
//
@ -371,9 +425,15 @@ export default class SplatNet3Api {
/** / -> /gesotown -> /gesotown/{id} */
async getSaleGearDetail(id: string) {
return this.persistedQuery(RequestId.SaleGearDetailQuery, {
const result = await this.persistedQuery(RequestId.SaleGearDetailQuery, {
saleGearId: id,
});
if (!result.data.saleGear) {
throw new ErrorResponse('[splatnet3] Sale gear not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /gesotown -> /gesotown/{id} -> order */
@ -402,18 +462,24 @@ export default class SplatNet3Api {
/** / -> /my_outfits -> /my_outfits/{id} */
async getMyOutfitDetail(id: string) {
return this.persistedQuery(RequestId.MyOutfitDetailQuery, {
const result = await this.persistedQuery(RequestId.MyOutfitDetailQuery, {
myOutfitId: id,
});
if (!result.data.myOutfit) {
throw new ErrorResponse('[splatnet3] My outfit not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /my_outfits -> /my_outfits/{id / create} */
async getEquipmentFilters(id: string) {
async getEquipmentFilters() {
return this.persistedQuery(RequestId.MyOutfitCommonDataFilteringConditionQuery, {});
}
/** / -> /my_outfits -> /my_outfits/{id / create} */
async getEquipment(id: string) {
async getEquipment() {
return this.persistedQuery(RequestId.MyOutfitCommonDataEquipmentsQuery, {});
}
@ -459,9 +525,15 @@ export default class SplatNet3Api {
async getReplaySearchResult(code: string) {
if (!REPLAY_CODE_REGEX.test(code)) throw new Error('Invalid replay code');
return this.persistedQuery(RequestId.DownloadSearchReplayQuery, {
const result = await this.persistedQuery(RequestId.DownloadSearchReplayQuery, {
code,
});
if (!result.data.replay) {
throw new ErrorResponse('[splatnet3] Replay not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /replay -> enter code -> download */
@ -487,33 +559,73 @@ export default class SplatNet3Api {
return this.persistedQuery(RequestId.LatestBattleHistoriesQuery, {});
}
/** / -> /history -> /history/latest -> pull-to-refresh */
async getLatestBattleHistoriesRefetch() {
return this.persistedQuery(RequestId.LatestBattleHistoriesRefetchQuery, {
fetchCurrentPlayer: true,
});
}
/** / -> /history */
async getRegularBattleHistories() {
return this.persistedQuery(RequestId.RegularBattleHistoriesQuery, {});
}
/** / -> /history -> /history/regular -> pull-to-refresh */
async getRegularBattleHistoriesRefetch() {
return this.persistedQuery(RequestId.RegularBattleHistoriesRefetchQuery, {
fetchCurrentPlayer: true,
});
}
/** / -> /history */
async getBankaraBattleHistories() {
return this.persistedQuery(RequestId.BankaraBattleHistoriesQuery, {});
}
/** / -> /history -> /history/bankara -> pull-to-refresh */
async getBankaraBattleHistoriesRefetch() {
return this.persistedQuery(RequestId.BankaraBattleHistoriesRefetchQuery, {
fetchCurrentPlayer: true,
});
}
/** / -> /history */
async getPrivateBattleHistories() {
return this.persistedQuery(RequestId.PrivateBattleHistoriesQuery, {});
}
/** / -> /history -> /history/private -> pull-to-refresh */
async getPrivateBattleHistoriesRefetch() {
return this.persistedQuery(RequestId.PrivateBattleHistoriesRefetchQuery, {
fetchCurrentPlayer: true,
});
}
/** / -> /history -> /history/detail/{id} */
async getBattleHistoryDetail(id: string) {
return this.persistedQuery(RequestId.VsHistoryDetailQuery, {
const result = await this.persistedQuery(RequestId.VsHistoryDetailQuery, {
vsResultId: id,
});
if (!result.data.vsHistoryDetail) {
throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /history -> /history/detail/{id} -> pull-to-refresh */
async getBattleHistoryDetailPagerRefetch(id: string) {
return this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, {
const result = await this.persistedQuery(RequestId.VsHistoryDetailPagerRefetchQuery, {
vsResultId: id,
});
if (!result.data.vsHistoryDetail) {
throw new ErrorResponse('[splatnet3] Battle history not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /history -> /history/detail/* -> latest */
@ -548,16 +660,28 @@ export default class SplatNet3Api {
/** / -> /coop -> /coop/{id} */
async getCoopHistoryDetail(id: string) {
return this.persistedQuery(RequestId.CoopHistoryDetailQuery, {
const result = await this.persistedQuery(RequestId.CoopHistoryDetailQuery, {
coopHistoryDetailId: id,
});
if (!result.data.coopHistoryDetail) {
throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /coop -> /coop/{id} -> pull-to-refresh */
async getCoopHistoryDetailRefetch(id: string) {
return this.persistedQuery(RequestId.CoopHistoryDetailRefetchQuery, {
const result = await this.persistedQuery(RequestId.CoopHistoryDetailRefetchQuery, {
id,
});
if (!result.data.node) {
throw new ErrorResponse('[splatnet3] Co-op history not found', result[ResponseSymbol], result);
}
return result;
}
/** / -> /coop -> /coop/* -> latest */

View File

@ -52,7 +52,7 @@ export async function dumpAlbumPhotos(
refresh: PhotoAlbumResult | boolean = false
) {
debug('Fetching photo album items');
console.warn('Fetching photo album items');
if (typeof refresh !== 'object') console.warn('Fetching photo album items');
const results = refresh ?
await splatnet.getPhotoAlbumRefetch() :

View File

@ -2,13 +2,14 @@ import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import createDebug from 'debug';
import mkdirp from 'mkdirp';
import { RequestId } from 'splatnet3-types/splatnet3';
import { BankaraBattleHistoriesRefetchResult, CoopHistoryResult, LatestBattleHistoriesRefetchResult, LatestBattleHistoriesResult, PrivateBattleHistoriesRefetchResult, RegularBattleHistoriesRefetchResult, 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 { dumpCatalogRecords, dumpHistoryRecords } from './dump-records.js';
const debug = createDebug('cli:splatnet3:dump-results');
@ -31,12 +32,27 @@ export function builder(yargs: Argv<ParentArguments>) {
}).option('coop', {
describe: 'Include coop (Salmon Run) results',
type: 'boolean',
}).option('include-history', {
describe: 'Include history records',
type: 'boolean',
default: false,
}).option('include-catalog', {
describe: 'Include catalog records',
type: 'boolean',
default: false,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const _all = [argv.battles, argv.coop];
const _default = !_all.find(f => f === true);
if (!_all.some(f => f ?? _default)) {
throw new Error('Enable one of --battles or --coop');
}
const storage = await initStorage(argv.dataPath);
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
@ -48,28 +64,34 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
await mkdirp(directory);
const _default = typeof argv.battles !== 'boolean' && typeof argv.coop !== 'boolean';
if (argv.battles ?? _default) {
await dumpResults(splatnet, directory);
}
if (argv.coop ?? _default) {
await dumpCoopResults(splatnet, directory);
}
if (argv.includeHistory) {
await dumpHistoryRecords(splatnet, directory);
}
if (argv.includeCatalog) {
await dumpCatalogRecords(splatnet, directory);
}
}
export async function dumpResults(
splatnet: SplatNet3Api, directory: string
splatnet: SplatNet3Api, directory: string,
refresh: LatestBattleHistoriesResult | boolean = false
) {
debug('Fetching battle results');
console.warn('Fetching battle results');
const [player, battles, battles_regular, battles_anarchy, battles_private] = await Promise.all([
splatnet.getBattleHistoryCurrentPlayer(),
splatnet.getLatestBattleHistories(),
splatnet.getRegularBattleHistories(),
splatnet.getBankaraBattleHistories(),
splatnet.getPrivateBattleHistories(),
refresh ? null : splatnet.getBattleHistoryCurrentPlayer(),
refresh ? splatnet.getLatestBattleHistoriesRefetch() : splatnet.getLatestBattleHistories(),
refresh ? splatnet.getRegularBattleHistoriesRefetch() : splatnet.getRegularBattleHistories(),
refresh ? splatnet.getBankaraBattleHistoriesRefetch() : splatnet.getBankaraBattleHistories(),
refresh ? splatnet.getPrivateBattleHistoriesRefetch() : splatnet.getPrivateBattleHistories(),
]);
const filename = 'splatnet3-results-summary-' + Date.now() + '.json';
@ -77,40 +99,49 @@ export async function dumpResults(
debug('Writing %s', filename);
await fs.writeFile(file, JSON.stringify({
player: {
player: player ? {
result: player.data.currentPlayer,
query: RequestId.BattleHistoryCurrentPlayerQuery,
be_version: player[ResponseSymbol].headers.get('x-be-version'),
},
} : undefined,
latestBattleHistories: {
result: battles.data.latestBattleHistories,
fest: battles.data.currentFest,
query: RequestId.LatestBattleHistoriesQuery,
player: 'currentPlayer' in battles.data ?
(battles.data as LatestBattleHistoriesRefetchResult).currentPlayer : undefined,
query: refresh ? RequestId.LatestBattleHistoriesRefetchQuery : RequestId.LatestBattleHistoriesQuery,
be_version: battles[ResponseSymbol].headers.get('x-be-version'),
},
regularBattleHistories: {
result: battles_regular.data.regularBattleHistories,
query: RequestId.RegularBattleHistoriesQuery,
player: 'currentPlayer' in battles_regular.data ?
(battles_regular.data as RegularBattleHistoriesRefetchResult).currentPlayer : undefined,
query: refresh ? RequestId.RegularBattleHistoriesRefetchQuery : RequestId.RegularBattleHistoriesQuery,
be_version: battles_regular[ResponseSymbol].headers.get('x-be-version'),
},
bankaraBattleHistories: {
result: battles_anarchy.data.bankaraBattleHistories,
query: RequestId.BankaraBattleHistoriesQuery,
player: 'currentPlayer' in battles_anarchy.data ?
(battles_anarchy.data as BankaraBattleHistoriesRefetchResult).currentPlayer : undefined,
query: refresh ? RequestId.BankaraBattleHistoriesRefetchQuery : RequestId.BankaraBattleHistoriesQuery,
be_version: battles_anarchy[ResponseSymbol].headers.get('x-be-version'),
},
privateBattleHistories: {
result: battles_private.data.privateBattleHistories,
query: RequestId.PrivateBattleHistoriesQuery,
player: 'currentPlayer' in battles_private.data ?
(battles_private.data as PrivateBattleHistoriesRefetchResult).currentPlayer : undefined,
query: refresh ? RequestId.PrivateBattleHistoriesRefetchQuery : RequestId.PrivateBattleHistoriesQuery,
be_version: battles_private[ResponseSymbol].headers.get('x-be-version'),
},
app_version: splatnet.version,
}, null, 4) + '\n', 'utf-8');
const downloaded = [];
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()) {
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 match = id_str.match(/^VsHistoryDetail-(u-[0-9a-z]{20}):([A-Z]+):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
const id = match ? match[1] + '-' + match[3] : id_str;
@ -134,6 +165,8 @@ export async function dumpResults(
app_version: splatnet.version,
be_version: result[ResponseSymbol].headers.get('x-be-version'),
}, null, 4) + '\n', 'utf-8');
downloaded.push(item.id);
}
}
}
@ -142,12 +175,27 @@ export async function dumpResults(
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);
}
return {
player,
battles,
battles_regular,
battles_anarchy,
battles_private,
downloaded,
};
}
export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string) {
export async function dumpCoopResults(
splatnet: SplatNet3Api, directory: string,
refresh: CoopHistoryResult | boolean = false
) {
debug('Fetching coop results');
console.warn('Fetching coop results');
const results = await splatnet.getCoopHistory();
const results = refresh ?
await splatnet.getCoopHistoryRefetch() :
await splatnet.getCoopHistory();
const filename = 'splatnet3-coop-summary-' + Date.now() + '.json';
const file = path.join(directory, filename);
@ -155,16 +203,17 @@ export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string)
debug('Writing %s', filename);
await fs.writeFile(file, JSON.stringify({
result: results.data.coopResult,
query: RequestId.CoopHistoryQuery,
query: refresh ? RequestId.RefetchableCoopHistory_CoopResultQuery : RequestId.CoopHistoryQuery,
app_version: splatnet.version,
be_version: results[ResponseSymbol].headers.get('x-be-version'),
}, null, 4) + '\n', 'utf-8');
const downloaded = [];
const skipped = [];
// Reverse coop history order so oldest records are downloaded first
for (const group of results.data.coopResult.historyGroups.nodes.reverse()) {
for (const item of group.historyDetails.nodes.reverse()) {
for (const group of [...results.data.coopResult.historyGroups.nodes].reverse()) {
for (const item of [...group.historyDetails.nodes].reverse()) {
const id_str = Buffer.from(item.id, 'base64').toString() || item.id;
const match = id_str.match(/^CoopHistoryDetail-(u-[0-9a-z]{20}):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
const id = match ? match[1] + '-' + match[2] : id_str;
@ -187,6 +236,8 @@ export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string)
app_version: splatnet.version,
be_version: result[ResponseSymbol].headers.get('x-be-version'),
}, null, 4) + '\n', 'utf-8');
downloaded.push(item.id);
}
}
}
@ -195,4 +246,9 @@ export async function dumpCoopResults(splatnet: SplatNet3Api, directory: string)
if (skipped.length === 1) debug('Skipped co-op result %s, file already exist', skipped[0]);
else debug('Skipped %d co-op results, files already exist', skipped.length);
}
return {
results,
downloaded,
};
}

View File

@ -9,3 +9,4 @@ 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';
export * as monitor from './monitor.js';

View File

@ -0,0 +1,233 @@
import * as path from 'node:path';
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 { dumpCatalogRecords, dumpHistoryRecords, dumpStageStats, dumpWeaponStats } from './dump-records.js';
import { dumpCoopResults, dumpResults } from './dump-results.js';
import { dumpAlbumPhotos } from './dump-album.js';
import SplatNet3Api from '../../api/splatnet3.js';
const debug = createDebug('cli:splatnet3:monitor');
export const command = 'monitor [directory]';
export const desc = 'Monitor SplatNet 3 for new battle and coop results and photo album items';
export function builder(yargs: Argv<ParentArguments>) {
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',
}).option('coop', {
describe: 'Include coop (Salmon Run) results',
type: 'boolean',
}).option('album', {
describe: 'Include photo album items',
type: 'boolean',
}).option('include-history', {
describe: 'Include history records',
type: 'boolean',
default: false,
}).option('include-catalog', {
describe: 'Include catalog records',
type: 'boolean',
default: false,
}).option('include-stage', {
describe: 'Include stage stats',
type: 'boolean',
default: false,
}).option('include-weapon', {
describe: 'Include weapon stats',
type: 'boolean',
default: false,
}).option('update-interval', {
describe: 'Update interval in seconds',
type: 'number',
// 15 minutes
default: 15 * 60,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
const _all = [argv.battles, argv.coop, argv.album];
const _default = !_all.find(f => f === true);
if (!_all.some(f => f ?? _default)) {
throw new Error('Enable one of --battles, --coop or --album');
}
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);
let vs: (ReturnType<typeof dumpResults> extends Promise<infer T> ? T : never) | null = null;
let coop: (ReturnType<typeof dumpCoopResults> extends Promise<infer T> ? T : never) | null = null;
let album: (ReturnType<typeof dumpAlbumPhotos> extends Promise<infer T> ? T : never) | null = null;
if (argv.battles ?? _default) {
vs = await dumpResults(splatnet, directory);
}
if (argv.coop ?? _default) {
coop = await dumpCoopResults(splatnet, directory);
}
if (argv.album ?? _default) {
album = await dumpAlbumPhotos(splatnet, directory);
}
if (argv.includeHistory) {
await dumpHistoryRecords(splatnet, directory);
}
if (argv.includeCatalog) {
await dumpCatalogRecords(splatnet, directory);
}
if (argv.includeStage) {
await dumpStageStats(splatnet, directory);
}
if (argv.includeWeapon) {
await dumpWeaponStats(splatnet, directory);
}
console.warn('Monitoring for new data');
if (vs) {
const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0].historyDetails.nodes[0].id;
// If we already had the latest battle result, fetch it again now to match the behavour of Nintendo's app
if (!vs.downloaded.includes(latest_id)) {
const id_str = Buffer.from(latest_id, 'base64').toString() || latest_id;
const match = id_str.match(/^VsHistoryDetail-(u-[0-9a-z]{20}):([A-Z]+):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
const id = match ? match[1] + '-' + match[3] : id_str;
debug('Fetching latest battle result %s', id);
const result = await splatnet.getBattleHistoryDetail(latest_id);
const pager = await splatnet.getBattleHistoryDetailPagerRefetch(latest_id);
}
}
if (coop) {
const latest_id = coop.results.data.coopResult.historyGroups.nodes[0].historyDetails.nodes[0].id;
// If we already had the latest coop result, fetch it again now to match the behavour of Nintendo's app
if (!coop.downloaded.includes(latest_id)) {
const id_str = Buffer.from(latest_id, 'base64').toString() || latest_id;
const match = id_str.match(/^CoopHistoryDetail-(u-[0-9a-z]{20}):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
const id = match ? match[1] + '-' + match[2] : id_str;
debug('Fetching latest coop result %s', id);
const result = await splatnet.getCoopHistoryDetail(latest_id);
}
}
let updating = false;
let should_exit = false;
let sleep_timeout: NodeJS.Timeout | null = null;
let sleep_resolve: ((value: void) => void) | null = null;
const exit = () => {
if (updating) {
console.warn('Waiting for the current update to complete before exiting');
}
should_exit = true;
sleep_resolve?.call(null);
clearTimeout(sleep_timeout!);
process.removeListener('SIGINT' as any, exit);
process.removeListener('SIGTERM' as any, exit);
};
process.on('SIGINT', exit);
process.on('SIGTERM', exit);
try {
await new Promise(rs => sleep_timeout = setTimeout(sleep_resolve = rs, argv.updateInterval * 1000));
while (!should_exit) {
updating = true;
[vs, coop, album] = await update(argv, splatnet, directory, vs, coop, album);
updating = false;
if (should_exit) continue;
await new Promise(rs => sleep_timeout = setTimeout(sleep_resolve = rs, argv.updateInterval * 1000));
}
} finally {
process.removeListener('SIGINT' as any, exit);
process.removeListener('SIGTERM' as any, exit);
}
}
async function update(
argv: ArgumentsCamelCase<Arguments>,
splatnet: SplatNet3Api,
directory: string,
vs: (ReturnType<typeof dumpResults> extends Promise<infer T> ? T : never) | null,
coop: (ReturnType<typeof dumpCoopResults> extends Promise<infer T> ? T : never) | null,
album: (ReturnType<typeof dumpAlbumPhotos> extends Promise<infer T> ? T : never) | null,
) {
debug('Checking for new data');
let updated_vs = false;
let updated_coop = false;
if (vs) {
const latest_id = vs.battles.data.latestBattleHistories.historyGroups.nodes[0].historyDetails.nodes[0].id;
const pager = await splatnet.getBattleHistoryDetailPagerRefetch(latest_id);
if (pager.data.vsHistoryDetail.nextHistoryDetail) {
// New battle results available
debug('New battle result', pager.data.vsHistoryDetail.nextHistoryDetail);
vs = await dumpResults(splatnet, directory, vs.battles.data);
updated_vs = true;
}
}
if (coop) {
const latest_id = coop.results.data.coopResult.historyGroups.nodes[0].historyDetails.nodes[0].id;
const pager = await splatnet.getCoopHistoryDetailRefetch(latest_id);
if (pager.data.node.nextHistoryDetail) {
// New coop results available
debug('New coop result', pager.data.node.nextHistoryDetail);
coop = await dumpCoopResults(splatnet, directory, coop.results.data);
updated_coop = true;
}
}
if (album) {
await dumpAlbumPhotos(splatnet, directory, album);
}
if (argv.includeHistory && (updated_vs || updated_coop)) {
await dumpHistoryRecords(splatnet, directory, true);
}
if (argv.includeCatalog && (updated_vs || updated_coop)) {
await dumpCatalogRecords(splatnet, directory, true);
}
if (argv.includeStage && updated_vs) {
await dumpStageStats(splatnet, directory, true);
}
if (argv.includeWeapon && updated_vs) {
await dumpWeaponStats(splatnet, directory, true);
}
return [vs, coop, album] as const;
}