Add command to fetch Splatoon 3 seed data

This commit is contained in:
Samuel Elliott 2025-08-19 20:06:24 +01:00
parent aecf9556ef
commit b683dca8b4
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
4 changed files with 164 additions and 0 deletions

7
package-lock.json generated
View File

@ -18,6 +18,7 @@
"dotenv-expand": "^11.0.6",
"env-paths": "^3.0.0",
"express": "^4.19.2",
"murmurhash": "^2.0.1",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.3",
"read": "^3.0.1",
@ -7522,6 +7523,12 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/murmurhash": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-2.0.1.tgz",
"integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz",

View File

@ -45,6 +45,7 @@
"dotenv-expand": "^11.0.6",
"env-paths": "^3.0.0",
"express": "^4.19.2",
"murmurhash": "^2.0.1",
"node-notifier": "^10.0.1",
"node-persist": "^3.1.3",
"read": "^3.0.1",

View File

@ -10,6 +10,7 @@ export * as presenceEmbedServer from './presence-embed-server.js';
export * as logArchive from './log-archive.js';
export * as decryptLogArchive from './decrypt-log-archive.js';
export * as status from './status.js';
export * as splatoon3Seed from './splatoon3-seed.js';
export * as updateS2sToken from './update-s2s-token.js';
export * as updateS3sToken from './update-s3s-token.js';
export * as updateS3siToken from './update-s3si-token.js';

View File

@ -0,0 +1,155 @@
import createDebug from 'debug';
import murmurhash from 'murmurhash';
import { ResultTypes } from 'splatnet3-types/splatnet3';
import type { Arguments as ParentArguments } from './index.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:util:splatoon3-seed');
export const command = 'splatoon3-seed [id]';
export const desc = 'Fetches data for https://leanny.github.io/splat3seedchecker/';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.positional('id', {
describe: 'Replay code',
type: 'string',
}).option('znc-proxy-url', {
describe: 'URL of Nintendo Switch Online app API proxy server to use',
type: 'string',
default: process.env.ZNC_PROXY_URL,
}).option('auto-update-session', {
describe: 'Automatically obtain and refresh the SplatNet 3 access token',
type: 'boolean',
default: true,
}).option('user', {
describe: 'Nintendo Account ID',
type: 'string',
}).option('token', {
describe: 'Nintendo Account session token',
type: 'string',
}).option('include-gear', {
describe: 'Fetch all gear from SplatNet 3',
type: 'boolean',
}).option('json', {
describe: 'Output raw JSON',
type: 'boolean',
}).option('json-pretty-print', {
describe: 'Output pretty-printed JSON',
type: 'boolean',
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
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, data} = await getBulletToken(storage, token, argv.zncProxyUrl, argv.autoUpdateSession);
splatnet.getCurrentFest();
splatnet.getConfigureAnalytics();
const npln_user_id = await getNplnUserId(splatnet, argv.id);
debug('NPLN user ID', npln_user_id);
const hash = murmurhash.v3(npln_user_id);
debug('hash', hash);
const key = Buffer.from(npln_user_id);
for (let i = 0; i < key.length; i++) {
key[i] ^= hash & 0xff;
}
const key_str = key.toString('base64');
debug('key', key_str);
if (argv.json || argv.jsonPrettyPrint) {
let equipment;
if (argv.includeGear ?? true) {
debug('Fetching equipment');
equipment = await splatnet.getEquipment();
}
console.log(JSON.stringify({
key: key_str,
h: hash,
timestamp: Math.floor(Date.now() / 1000),
gear: equipment,
}, null, argv.jsonPrettyPrint ? 4 : 0));
return;
}
console.log('Key %s', key.toString('hex'));
console.log('Hash %d', hash);
}
const REPLAY_CODE_REGEX = /^[A-Z0-9]{16}$/;
function getNplnUserId(splatnet: SplatNet3Api, id?: string) {
if (id) {
if (id.replace(/-/g, '').match(REPLAY_CODE_REGEX)) {
return getNplnUserIdFromReplayCode(splatnet, id.replace(/-/g, ''));
}
throw new Error('Invalid argument "' + id + '"');
}
return getNplnUserIdSelf(splatnet);
}
async function getNplnUserIdSelf(splatnet: SplatNet3Api) {
debug('Fetching outfits');
const outfits = await splatnet.getMyOutfits();
if (outfits.data.myOutfits.edges.length) {
const outfit = await splatnet.getMyOutfitDetail(outfits.data.myOutfits.edges[0].node.id);
}
const outfit_id = outfits.data.myOutfits.edges[0]?.node.id;
if (outfit_id) {
const id_str = Buffer.from(outfit_id, 'base64').toString();
const match = id_str.match(/^MyOutfit-(u-[0-9a-z]{20}):(\d+)$/);
if (match) return match[1];
}
debug('Failed to get NPLN user ID from outfits, fetching battle histories');
const [player, battles, battles_regular, battles_anarchy, battles_private] = await Promise.all([
splatnet.getBattleHistoryCurrentPlayer(),
splatnet.getLatestBattleHistories(),
splatnet.getRegularBattleHistories(),
splatnet.getBankaraBattleHistories(),
splatnet.getPrivateBattleHistories(),
]);
const latest_id = battles.data.latestBattleHistories.historyGroupsOnlyFirst.nodes[0]?.historyDetails.nodes[0].id;
if (latest_id) {
const id_str = Buffer.from(latest_id, 'base64').toString();
const match = id_str.match(/^VsHistoryDetail-(u-[0-9a-z]{20}):([A-Z]+):((\d{8,}T\d{6})_([0-9a-f-]{36}))$/);
if (match) return match[1];
}
throw new Error('Failed to get NPLN user ID, try creating an outfit or play an online battle');
}
async function getNplnUserIdFromReplayCode(splatnet: SplatNet3Api, code: string) {
debug('Fetching replay');
const replays = await splatnet.getReplays();
const replay = await splatnet.getReplaySearchResult(code);
const id_str = Buffer.from(replay.data.replay.id, 'base64').toString();
debug('replay', id_str);
const match = id_str.match(/^Replay-(u-[0-9a-z]{20}):([0-9A-Z]{16})$/);
if (!match) throw new Error('Unable to find NPLN user ID from replay ID');
return match[1];
}