mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-03-21 17:54:13 -05:00
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>
251 lines
6.8 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|