import fetch from 'node-fetch'; import createDebug from 'debug'; import { v4 as uuidgen } from 'uuid'; import { WebServiceToken } from './coral-types.js'; import { NintendoAccountUser } from './na.js'; import { ErrorResponse } from './util.js'; import CoralApi from './coral.js'; import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, LeagueMatchRankings, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingRecords, XPowerRankingSummary } from './splatnet2-types.js'; import { timeoutSignal } from '../util/misc.js'; import { toSeasonId, Rule as XPowerRankingRule, Season } from './splatnet2-xrank.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 { protected constructor( public iksm_session: string, public unique_id: string, public useragent: string, ) {} async fetch(url: string, method = 'GET', body?: string | FormData, headers?: object) { const [signal, cancel] = timeoutSignal(); const response = await fetch(SPLATNET2_URL + url, { method, headers: Object.assign({ 'User-Agent': this.useragent, 'Cookie': 'iksm_session=' + encodeURIComponent(this.iksm_session), 'Accept': '*/*', 'Accept-Language': 'en-GB,en-US;q=0.8', 'Referrer': 'https://app.splatoon2.nintendo.net/home', 'X-Requested-With': 'XMLHttpRequest', // 'X-Timezone-Offset': (new Date()).getTimezoneOffset().toString(), 'X-Timezone-Offset': '0', 'X-Unique-Id': this.unique_id, }, headers), body, signal, }).finally(cancel); debug('fetch %s %s, response %s', method, url, response.status); if (response.status !== 200) { throw new ErrorResponse('[splatnet2] Non-200 status code', 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'); } async getStages() { return this.fetch('/data/stages'); } async getActiveFestivals() { return this.fetch('/festivals/active'); } async getTimeline() { return this.fetch('/timeline'); } async getUserNicknameAndIcon(ids: string[]) { return this.fetch('/nickname_and_icon?' + ids.map(id => 'id=' + encodeURIComponent(id)).join('&')); } async getSchedules() { return this.fetch('/schedules'); } async getHeroRecords() { return this.fetch('/records/hero'); } async getXPowerRankingSummary(season: string | Date | Season) { if (typeof season === 'object' && 'start' in season) { season = season.id; } 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('/x_power_ranking/' + season + '/summary'); } async getXPowerRankingLeaderboard(season: string | Date | Season, rule: XPowerRankingRule, page: number = 1) { if (typeof season === 'object' && 'start' in season) { season = season.id; } 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('/x_power_ranking/' + season + '/' + rule + '?page=' + page); } async getPastFestivals() { return this.fetch('/festivals/pasts'); } async getLeagueMatchRanking(id: string, region: LeagueRegion): Promise async getLeagueMatchRanking(date: Date, type: LeagueType, region: LeagueRegion): Promise async getLeagueMatchRanking(id: string | Date, arg1: LeagueRegion | LeagueType, arg2?: LeagueRegion) { const region = id instanceof Date ? arg2! : arg1 as LeagueRegion; if (id instanceof Date) { id = toLeagueId(id, arg1 as LeagueType); } if (!id.match(LEAGUE_ID)) { throw new Error('Invalid league ID'); } return this.fetch('/league_match_ranking/' + id + '/' + region); } async getResults() { return this.fetch('/results'); } async getResult(id: string | number) { return this.fetch('/results/' + id); } async getCoopResults() { return this.fetch('/coop_results'); } async getCoopResult(id: number) { return this.fetch('/coop_results/' + id); } async getCoopSchedules() { return this.fetch('/coop_schedules'); } async getShopMerchandises() { return this.fetch('/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('/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(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('/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('/share/results/' + id, 'POST', '', { 'Referer': 'https://app.splatoon2.nintendo.net/results/' + id, 'X-Requested-With': 'XMLHttpRequest', 'Origin': 'https://app.splatoon2.nintendo.net', }); } static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) { const data = await this.loginWithCoral(nso, user); return {splatnet: this.createWithSavedToken(data), data}; } static createWithSavedToken(data: SplatNet2AuthData) { return new this( data.iksm_session, data.user_id, data.useragent, ); } static createWithCliTokenData(data: SplatNet2CliTokenData) { return new this( data.iksm_session, data.user_id, SPLATNET2_WEBSERVICE_USERAGENT, ); } static createWithIksmSession(iksm_session: string, unique_id: string) { return new this( iksm_session, unique_id, SPLATNET2_WEBSERVICE_USERAGENT, ); } static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) { const webserviceToken = await nso.getWebServiceToken(SPLATNET2_WEBSERVICE_ID); return this.loginWithWebServiceToken(webserviceToken.result, user); } static async loginWithWebServiceToken( webserviceToken: WebServiceToken, user: NintendoAccountUser ): Promise { const url = new URL(SPLATNET2_WEBSERVICE_URL); url.search = new URLSearchParams({ lang: user.language, na_country: user.country, na_lang: user.language, }).toString(); const [signal, cancel] = timeoutSignal(); const response = await fetch(url.toString(), { headers: { 'Upgrade-Insecure-Requests': '1', 'User-Agent': SPLATNET2_WEBSERVICE_USERAGENT, 'x-appcolorscheme': 'DARK', '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', }, signal, }).finally(cancel); 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.replace(/^(.{6}).*/, '$1****'), expires); const expires_at = expires ? Date.parse(expires) : Date.now() + 24 * 60 * 60 * 1000; const ml = body.match(/]*))?)*\s+lang=(?:"([^"]*)"|([^\s>]*))/i); const mr = body.match(/]*))?)*\s+data-region=(?:"([^"]*)"|([^\s>]*))/i); const mu = body.match(/]*))?)*\s+data-unique-id=(?:"([^"]*)"|([^\s>]*))/i); const mn = body.match(/]*))?)*\s+data-nsa-id=(?:"([^"]*)"|([^\s>]*))/i); const [language, region, user_id, nsa_id] = [ml, mr, mu, mn].map(m => m?.[1] || m?.[2] || null); if (!language) throw new Error('[splatnet2] Invalid language in response'); if (!region) throw new Error('[splatnet2] Invalid region in response'); if (!user_id) throw new Error('[splatnet2] Invalid unique player ID in response'); if (!nsa_id) throw new Error('[splatnet2] Invalid NSA ID in response'); debug('SplatNet 2 user', { language, region, user_id: user_id?.replace(/^(.{6}).*/, '$1****'), nsa_id: nsa_id?.replace(/^(.{6}).*/, '$1****'), }); return { webserviceToken, url: url.toString(), cookies: cookies!, body, language, region, user_id, nsa_id, iksm_session, expires_at, useragent: SPLATNET2_WEBSERVICE_USERAGENT, }; } } export interface SplatNet2AuthData { webserviceToken: WebServiceToken; url: string; cookies: string; body: string; language: string; region: string; /** Splatoon 2 player ID aka. unique_id */ user_id: string; nsa_id: string; iksm_session: string; expires_at: number; useragent: string; } export interface SplatNet2CliTokenData { iksm_session: string; language: string; region: string; user_id: string; nsa_id: string; } 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', }