nxapi/src/api/splatnet2.ts
Samuel Elliott 5f6004bd57
Move authentication functions
app no longer imports from cli
2022-05-21 23:47:41 +01:00

352 lines
12 KiB
TypeScript

import fetch from 'node-fetch';
import createDebug from 'debug';
import { v4 as uuidgen } from 'uuid';
import { WebServiceToken } from './znc-types.js';
import { NintendoAccountUser } from './na.js';
import { ErrorResponse } from './util.js';
import ZncApi from './znc.js';
import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingSummary } from './splatnet2-types.js';
const debug = createDebug('nxapi:api:splatnet2');
export const SPLATNET2_WEBSERVICE_ID = '5741031244955648';
export const SPLATNET2_WEBSERVICE_URL = 'https://app.splatoon2.nintendo.net/';
export const SPLATNET2_WEBSERVICE_USERAGENT = 'Mozilla/5.0 (Linux; Android 8.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36';
const SPLATNET2_URL = SPLATNET2_WEBSERVICE_URL + 'api';
const XPOWERRANKING_SEASON = /^(\d{2})(\d{2})01T00_(\d{2})(\d{2})01T00$/;
const LEAGUE_ID = /^(\d{2})(\d{2})(\d{2})(\d{2})(T|P)$/;
export const updateIksmSessionLastUsed: {
handler?: ((iksm_session: string) => void);
} = {};
export default class SplatNet2Api {
constructor(
public iksm_session: string,
public useragent: string
) {}
async fetch<T = unknown>(url: string, method = 'GET', body?: string | FormData, headers?: object) {
const response = await fetch(SPLATNET2_URL + url, {
method: method,
headers: Object.assign({
'Upgrade-Insecure-Requests': '1',
'User-Agent': this.useragent,
'Cookie': 'iksm_session=' + encodeURIComponent(this.iksm_session),
'dnt': '1',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-GB,en-US;q=0.8',
'X-Requested-With': 'com.nintendo.znca',
}, headers),
body: body,
});
debug('fetch %s %s, response %s', method, url, response.status);
if (response.status !== 200) {
throw new ErrorResponse('[splatnet2] Unknown error', response, await response.text());
}
updateIksmSessionLastUsed.handler?.call(null, this.iksm_session);
const data = await response.json() as T | WebServiceError;
if ('code' in data) {
throw new ErrorResponse('[splatnet2] ' + data.message, response, data);
}
return data;
}
async getRecords() {
return this.fetch<Records>('/records');
}
async getStages() {
return this.fetch<Stages>('/data/stages');
}
async getActiveFestivals() {
return this.fetch<ActiveFestivals>('/festivals/active');
}
async getTimeline() {
return this.fetch<Timeline>('/timeline');
}
async getUserNicknameAndIcon(ids: string[]) {
return this.fetch<NicknameAndIcons>('/nickname_and_icon?' +
ids.map(id => 'id=' + encodeURIComponent(id)).join('&'));
}
async getSchedules() {
return this.fetch<Schedules>('/schedules');
}
async getHeroRecords() {
return this.fetch<HeroRecords>('/records/hero');
}
async getXPowerRankingSummary(season: string | Date) {
if (season instanceof Date) {
season = toSeasonId(season.getUTCFullYear(), season.getUTCMonth() + 1);
}
let match = season.match(/^(\d+)-(\d{2})$/);
if (match) {
season = toSeasonId(parseInt(match[1]), parseInt(match[2]));
}
if (!season.match(XPOWERRANKING_SEASON)) {
throw new Error('Invalid season ID');
}
return this.fetch<XPowerRankingSummary>('/x_power_ranking/' + season + '/summary');
}
async getXPowerRankingLeaderboard(season: string | Date, rule: XPowerRankingRule, page: number = 1) {
if (season instanceof Date) {
season = toSeasonId(season.getUTCFullYear(), season.getUTCMonth() + 1);
}
let match = season.match(/^(\d+)-(\d{2})$/);
if (match) {
season = toSeasonId(parseInt(match[1]), parseInt(match[2]));
}
if (!season.match(XPOWERRANKING_SEASON)) {
throw new Error('Invalid season ID');
}
return this.fetch<unknown>('/x_power_ranking/' + season + '/' + rule + '?page=' + page);
}
async getPastFestivals() {
return this.fetch<PastFestivals>('/festivals/pasts');
}
async getLeagueMatchRanking(id: string, region: LeagueRegion) {
if (!id.match(LEAGUE_ID)) {
throw new Error('Invalid league ID');
}
return this.fetch<PastFestivals>('/league_match_ranking/' + id + '/' + region);
}
async getResults() {
return this.fetch<Results>('/results');
}
async getResult(id: string | number) {
return this.fetch<Result>('/results/' + id);
}
async getCoopResults() {
return this.fetch<CoopResults>('/coop_results');
}
async getCoopResult(id: number) {
return this.fetch<CoopResult>('/coop_results/' + id);
}
async getCoopSchedules() {
return this.fetch<CoopSchedules>('/coop_schedules');
}
async getShopMerchandises() {
return this.fetch<ShopMerchandises>('/onlineshop/merchandises');
}
async shareProfile(stage: string, colour: ShareColour) {
const boundary = uuidgen();
const data = `--${boundary}
Content-Disposition: form-data; name="stage"
${stage}
--${boundary}
Content-Disposition: form-data; name="color"
${colour}
--${boundary}--
`.replace(/\r?\n/g, '\r\n');
return this.fetch<ShareResponse>('/share/profile', 'POST', data, {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Referer': 'https://app.splatoon2.nintendo.net/home',
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://app.splatoon2.nintendo.net',
});
}
async shareChallenge(id: string, season: 1 | 2 = 1) {
const url = '/share/challenges' + (season === 2 ? '_season_2' : '') + '/' + id;
return this.fetch<ShareResponse>(url, 'POST', '', {
'Referer': 'https://app.splatoon2.nintendo.net/records/challenge' + (season === 2 ? '_season_2' : ''),
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://app.splatoon2.nintendo.net',
});
}
async shareResultsSummary() {
return this.fetch<ShareResponse>('/share/results/summary', 'POST', '', {
'Referer': 'https://app.splatoon2.nintendo.net/results',
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://app.splatoon2.nintendo.net',
});
}
async shareResult(id: string | number) {
return this.fetch<ShareResponse>('/share/results/' + id, 'POST', '', {
'Referer': 'https://app.splatoon2.nintendo.net/results/' + id,
'X-Requested-With': 'XMLHttpRequest',
'Origin': 'https://app.splatoon2.nintendo.net',
});
}
static async createWithZnc(nso: ZncApi, user: NintendoAccountUser) {
const data = await this.loginWithZnc(nso, user);
return {
splatnet: new this(data.iksm_session, data.useragent),
data,
};
}
static async loginWithZnc(nso: ZncApi, user: NintendoAccountUser) {
const webserviceToken = await nso.getWebServiceToken(SPLATNET2_WEBSERVICE_ID);
return this.loginWithWebServiceToken(webserviceToken.result, user);
}
static async loginWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser) {
const url = new URL(SPLATNET2_WEBSERVICE_URL);
url.search = new URLSearchParams({
lang: user.language,
na_country: user.country,
na_lang: user.language,
}).toString();
const response = await fetch(url.toString(), {
headers: {
'Upgrade-Insecure-Requests': '1',
'User-Agent': SPLATNET2_WEBSERVICE_USERAGENT,
'x-gamewebtoken': webserviceToken.accessToken,
'dnt': '1',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-GB,en-US;q=0.8',
'X-Requested-With': 'com.nintendo.znca',
},
});
debug('fetch %s %s, response %s', 'GET', url, response.status);
const body = await response.text();
if (response.status !== 200) {
throw new ErrorResponse('[splatnet2] Unknown error', response, body);
}
const cookies = response.headers.get('Set-Cookie');
const match = cookies?.match(/\biksm_session=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i);
if (!match) {
throw new ErrorResponse('[splatnet2] Response didn\'t include iksm_session cookie', response, body);
}
const iksm_session = decodeURIComponent(match[1]);
// Nintendo sets the expires field to an invalid timestamp - browsers don't care but Data.parse does
const expires = decodeURIComponent(match[8] || '')
.replace(/(\b)(\d{1,2})-([a-z]{3})-(\d{4})(\b)/gi, '$1$2 $3 $4$5');
debug('iksm_session %s, expires %s', iksm_session, expires);
const expires_at = expires ? Date.parse(expires) : Date.now() + 24 * 60 * 60 * 1000;
const ml = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+lang=(?:"([^"]*)"|([^\s>]*))/i);
const mr = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+data-region=(?:"([^"]*)"|([^\s>]*))/i);
const mu = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+data-unique-id=(?:"([^"]*)"|([^\s>]*))/i);
const mn = body.match(/<html(?:\s+[a-z0-9-]+(?:=(?:"[^"]*"|[^\s>]*))?)*\s+data-nsa-id=(?:"([^"]*)"|([^\s>]*))/i);
const [language, region, user_id, nsa_id] = [ml, mr, mu, mn].map(m => m?.[1] || m?.[2] || null);
debug('SplatNet 2 user', {language, region, user_id, nsa_id});
return {
webserviceToken,
url: url.toString(),
cookies: cookies!,
body,
language,
region,
user_id,
nsa_id,
iksm_session,
expires_at,
useragent: SPLATNET2_WEBSERVICE_USERAGENT,
};
}
}
export function toSeasonId(year: number, month: number) {
const nextyear = month === 12 ? year + 1 : year;
const nextmonth = month === 12 ? 1 : month + 1;
if (year < 2000) throw new Error('Invalid season ID');
if (nextyear >= 2100) throw new Error('Invalid season ID');
return ('' + (year - 2000)).padStart(2, '0') +
('' + month).padStart(2, '0') +
'01T00_' +
('' + (nextyear - 2000)).padStart(2, '0') +
('' + nextmonth).padStart(2, '0') +
'01T00';
}
export enum XPowerRankingRule {
SPLAT_ZONES = 'splat_zones',
TOWER_CONTROL = 'tower_control',
RAINMAKER = 'rainmaker',
CLAM_BLITZ = 'clam_blitz',
}
export function toLeagueId(date: Date, type: LeagueType) {
const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1;
const day = date.getUTCDate();
const hour = Math.floor(date.getUTCHours() / 2) * 2;
if (year < 2000) throw new Error('Invalid league ID');
if (year >= 2100) throw new Error('Invalid league ID');
return ('' + (year - 2000)).padStart(2, '0') +
('' + month).padStart(2, '0') +
('' + day).padStart(2, '0') +
('' + hour).padStart(2, '0') +
type;
}
export enum LeagueType {
TEAM = 'T',
PAIR = 'P',
}
export enum LeagueRegion {
ALL_REGIONS = 'ALL',
JAPAN = 'JP',
NA_AU_NZ = 'US',
EUROPE = 'EU',
}
export enum ShareColour {
PINK = 'pink',
GREEN = 'green',
YELLOW = 'yellow',
PURPLE = 'purple',
BLUE = 'blue',
SUN_YELLOW = 'sun-yellow',
}