splatoon3.ink/app/splatnet/SplatNet3Client.mjs
Matt Isenhower f45a4654d1 Extract shared cache expiry calculation to utility function
Deduplicate identical _calculateCacheExpiry methods from
SplatNet3Client and NsoClient into a shared calculateCacheExpiry
function in util.mjs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:04:23 -08:00

251 lines
6.8 KiB
JavaScript

import fs from 'fs/promises';
import pLimit from 'p-limit';
import ValueCache from '../common/ValueCache.mjs';
import prefixedConsole from '../common/prefixedConsole.mjs';
import { calculateCacheExpiry } from '../common/util.mjs';
export const SPLATNET3_WEB_SERVICE_ID = '4834290508791808';
// Concurrent request limit
const limit = pLimit(5);
export default class SplatNet3Client
{
baseUrl = 'https://api.lp1.av5ja.srv.nintendo.net';
bulletToken = null;
queryHashes = null;
constructor(nsoClient, acceptLanguage = 'en-US') {
this.console = prefixedConsole('SplatNet', nsoClient.region);
this.nsoClient = nsoClient;
this.acceptLanguage = acceptLanguage;
}
get hasSession() {
return !!this.bulletToken;
}
// Query hashes
async _loadQueryHashes() {
if (!this.queryHashes) {
// Data from: https://github.com/imink-app/SplatNet3/blob/master/Data/splatnet3_webview_data.json
let data = await fs.readFile(new URL('./queryHashes.json', import.meta.url));
this.queryHashes = JSON.parse(data);
}
}
async _webViewVersion() {
await this._loadQueryHashes();
return this.queryHashes?.version;
}
async _queryHash(name) {
await this._loadQueryHashes();
return this.queryHashes?.graphql?.hash_map?.[name];
}
// Bullet token
async _startSession() {
this.bulletToken = await this.getBulletToken();
}
async _maybeStartSession() {
if (!this.hasSession) {
await this._startSession();
}
}
async getBulletToken(useCache = true) {
let bulletTokenCache = new ValueCache(`${this.nsoClient.cachePrefix}.bulletToken`);
let bulletToken = useCache
? await bulletTokenCache.getData()
: null;
if (!bulletToken) {
let webServiceToken = await this.nsoClient.getWebServiceToken(SPLATNET3_WEB_SERVICE_ID);
bulletToken = await this._createBulletToken(webServiceToken, bulletTokenCache);
}
return bulletToken;
}
async _createBulletToken(webServiceToken, bulletTokenCache) {
this.console.info('Creating bullet token...');
let response = await fetch(this.baseUrl + '/api/bullet_tokens', {
method: 'POST',
headers: {
'X-Web-View-Ver': await this._webViewVersion(),
'X-NACOUNTRY': 'US', // TODO
'X-GameWebToken': webServiceToken,
'Accept-Language': this.acceptLanguage,
'User-Agent': process.env.USER_AGENT,
},
});
if (!response.ok) {
throw new Error(`Invalid bullet token response code: ${response.status}`);
}
let bulletToken = await response.json();
// We can assume the token expires after 7200 seconds
let expiry = calculateCacheExpiry(7200);
await bulletTokenCache.setData(bulletToken, expiry);
this.console.debug(`Caching bullet token until: ${expiry}`);
return bulletToken;
}
// GraphQL
async getGraphQL(body = {}) {
await this._maybeStartSession();
let webViewVersion = await this._webViewVersion();
let response = await limit(() => fetch(this.baseUrl + '/api/graphql', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.bulletToken.bulletToken}`,
'X-Web-View-Ver': webViewVersion,
'Content-Type': 'application/json',
'Accept-Language': this.acceptLanguage,
},
body: JSON.stringify(body),
}));
if (!response.ok) {
throw new Error(`Invalid GraphQL response code: ${response.status}`);
}
return await response.json();
}
async getGraphQLPersistedQuery(version, query, variables = {}) {
let sha256Hash = await this._queryHash(query) ?? query;
let body = {
extensions: { persistedQuery: { version, sha256Hash } },
variables,
};
return await this.getGraphQL(body);
}
// Specific queries
getStageScheduleData() {
return this.getGraphQLPersistedQuery(1, 'StageScheduleQuery');
}
getStageRecordData() {
return this.getGraphQLPersistedQuery(1, 'StageRecordQuery');
}
getGesotownData() {
return this.getGraphQLPersistedQuery(1, 'GesotownQuery');
}
getCoopHistoryData() {
return this.getGraphQLPersistedQuery(1, 'CoopHistoryQuery');
}
getFestRecordDataPage(cursor = null) {
return this.getGraphQLPersistedQuery(1, 'FestRecordPaginationQuery', { cursor, first: 100 });
}
getFestDetailData(festId) {
return this.getGraphQLPersistedQuery(1, 'DetailFestRecordDetailQuery', { festId });
}
getFestRankingData(festId) {
return this.getGraphQLPersistedQuery(1, 'DetailRankingQuery', { festId });
}
getFestRankingPage(teamId, cursor) {
return this.getGraphQLPersistedQuery(1, 'RankingHoldersFestTeamRankingHoldersPaginationQuery', { cursor, first: 25, id: teamId });
}
getCurrentFestData() {
return this.getGraphQLPersistedQuery(1, 'useCurrentFestQuery');
}
getXRankingData(region) {
return this.getGraphQLPersistedQuery(1, 'XRankingQuery', { region });
}
getXRankingDetailQueryTypes() {
return [
{
name: 'Splat Zones Leaderboard',
key: 'splatzones',
id: 'DetailTabViewXRankingArRefetchQuery',
dataKey: 'xRankingAr',
},
{
name: 'Splat Zones Top Weapons',
key: 'splatzones.weapons',
id: 'DetailTabViewWeaponTopsArRefetchQuery',
dataKey: 'weaponTopsAr',
},
{
name: 'Clam Blitz Leaderboard',
key: 'clamblitz',
id: 'DetailTabViewXRankingClRefetchQuery',
dataKey: 'xRankingCl',
},
{
name: 'Clam Blitz Top Weapons',
key: 'clamblitz.weapons',
id: 'DetailTabViewWeaponTopsClRefetchQuery',
dataKey: 'weaponTopsCl',
},
{
name: 'Rainmaker Leaderboard',
key: 'rainmaker',
id: 'DetailTabViewXRankingGlRefetchQuery',
dataKey: 'xRankingGl',
},
{
name: 'Rainmaker Top Weapons',
key: 'rainmaker.weapons',
id: 'DetailTabViewWeaponTopsGlRefetchQuery',
dataKey: 'weaponTopsGl',
},
{
name: 'Tower Control Leaderboard',
key: 'towercontrol',
id: 'DetailTabViewXRankingLfRefetchQuery',
dataKey: 'xRankingLf',
},
{
name: 'Tower Control Top Weapons',
key: 'towercontrol.weapons',
id: 'DetailTabViewWeaponTopsLfRefetchQuery',
dataKey: 'weaponTopsLf',
},
];
}
getXRankingDetail(type, id, page = 1) {
// The API splits this data into 5 pages of 100 results (param: "page").
// Each page is segmented into smaller 25-record chunks (by default) as well (param: "first").
// We can just pass a larger number (up to 100) into the "first" param
// to retrieve all results for that page.
let first = 100;
return this.getGraphQLPersistedQuery(1, type.id, { id, first, page });
}
async *getXRankingDetailPages(type, id) {
for (let page = 1; page <= 5; page++) {
yield await this.getXRankingDetail(type, id, page);
}
}
}