From daca9cfb1c27abaec9f92ef4ba9d2f0fc92a0f6e Mon Sep 17 00:00:00 2001 From: spacemeowx2 Date: Wed, 19 Oct 2022 16:56:18 +0800 Subject: [PATCH] feat: add HomeQuery type definition --- APIError.ts | 14 ++++++ constant.ts | 7 +++ iksm.ts | 32 ++++--------- s3si.ts | 23 ++++++--- splatnet3.ts | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++ stat.ink.ts | 0 state.ts | 2 + utils.ts | 19 ++++++++ 8 files changed, 197 insertions(+), 30 deletions(-) create mode 100644 APIError.ts create mode 100644 constant.ts create mode 100644 splatnet3.ts create mode 100644 stat.ink.ts diff --git a/APIError.ts b/APIError.ts new file mode 100644 index 0000000..c7cc1a5 --- /dev/null +++ b/APIError.ts @@ -0,0 +1,14 @@ +export class APIError extends Error { + response: Response; + json: unknown; + constructor( + { response, message }: { + response: Response; + json?: unknown; + message?: string; + }, + ) { + super(message); + this.response = response; + } +} diff --git a/constant.ts b/constant.ts new file mode 100644 index 0000000..e44bcf6 --- /dev/null +++ b/constant.ts @@ -0,0 +1,7 @@ +export const DEFAULT_APP_USER_AGENT = + "Mozilla/5.0 (Linux; Android 11; Pixel 5) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/94.0.4606.61 Mobile Safari/537.36"; +export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net"; +export const SPLATNET3_ENDPOINT = + "https://api.lp1.av5ja.srv.nintendo.net/api/graphql"; diff --git a/iksm.ts b/iksm.ts index 3adb896..02c2b8b 100644 --- a/iksm.ts +++ b/iksm.ts @@ -1,21 +1,8 @@ import { CookieJar, wrapFetch } from "./deps.ts"; -import { readline, retry, urlBase64Encode } from "./utils.ts"; +import { cache, readline, retry, urlBase64Encode } from "./utils.ts"; import { NSOAPP_VERSION, USERAGENT } from "./version.ts"; - -export class APIError extends Error { - response: Response; - json: unknown; - constructor( - { response, message }: { - response: Response; - json?: unknown; - message?: string; - }, - ) { - super(message); - this.response = response; - } -} +import { DEFAULT_APP_USER_AGENT, SPLATNET3_URL } from "./constant.ts"; +import { APIError } from "./APIError.ts"; export async function loginManually(): Promise { const cookieJar = new CookieJar(); @@ -240,9 +227,7 @@ export async function getGToken( }; } -const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net"; - -async function getWebViewVer(): Promise { +async function _getWebViewVer(): Promise { const splatnet3Home = await (await fetch(SPLATNET3_URL)).text(); const mainJS = /src="(\/.*?\.js)"/.exec(splatnet3Home)?.[1]; @@ -261,12 +246,11 @@ async function getWebViewVer(): Promise { throw new Error("No version and revision found"); } - return `${version}-${revision.substring(0, 8)}`; -} + const ver = `${version}-${revision.substring(0, 8)}`; -const DEFAULT_APP_USER_AGENT = "Mozilla/5.0 (Linux; Android 11; Pixel 5) " + - "AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/94.0.4606.61 Mobile Safari/537.36"; + return ver; +} +export const getWebViewVer = cache(_getWebViewVer); export async function getBulletToken( { diff --git a/s3si.ts b/s3si.ts index d0f5ca4..0cc65d3 100644 --- a/s3si.ts +++ b/s3si.ts @@ -1,6 +1,8 @@ -import { APIError, getBulletToken, getGToken, loginManually } from "./iksm.ts"; +import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; +import { APIError } from "./APIError.ts"; import { flags } from "./deps.ts"; import { DEFAULT_STATE, State } from "./state.ts"; +import { checkToken } from "./splatnet3.ts"; type Opts = { configPath: string; @@ -63,7 +65,9 @@ Options: } const sessionToken = this.state.loginState.sessionToken!; - if (!this.state.loginState?.gToken) { + if ( + !this.state.loginState?.gToken || !this.state.loginState.bulletToken + ) { const { webServiceToken, userCountry, userLang } = await getGToken({ fApi: this.state.fGen, sessionToken, @@ -76,14 +80,21 @@ Options: appUserAgent: this.state.appUserAgent, }); - this.state.loginState = { - ...this.state.loginState, - gToken: webServiceToken, - bulletToken, + this.state = { + ...this.state, + loginState: { + ...this.state.loginState, + gToken: webServiceToken, + bulletToken, + }, + userLang, + userCountry, }; await this.writeState(); } + + await checkToken(this.state); } catch (e) { if (e instanceof APIError) { console.error(`APIError: ${e.message}`, e.response, e.json); diff --git a/splatnet3.ts b/splatnet3.ts new file mode 100644 index 0000000..10a2a4a --- /dev/null +++ b/splatnet3.ts @@ -0,0 +1,130 @@ +import { getWebViewVer } from "./iksm.ts"; +import { State } from "./state.ts"; +import { DEFAULT_APP_USER_AGENT, SPLATNET3_ENDPOINT } from "./constant.ts"; +import { APIError } from "./APIError.ts"; + +enum Queries { + HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3", + LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00", + RegularBattleHistoriesQuery = "f6e7e0277e03ff14edfef3b41f70cd33", + BankaraBattleHistoriesQuery = "c1553ac75de0a3ea497cdbafaa93e95b", + PrivateBattleHistoriesQuery = "38e0529de8bc77189504d26c7a14e0b8", + VsHistoryDetailQuery = "2b085984f729cd51938fc069ceef784a", + CoopHistoryQuery = "817618ce39bcf5570f52a97d73301b30", + CoopHistoryDetailQuery = "f3799a033f0a7ad4b1b396f9a3bafb1e", +} +type VarsMap = { + [Queries.HomeQuery]: Record; + [Queries.LatestBattleHistoriesQuery]: Record; + [Queries.RegularBattleHistoriesQuery]: Record; + [Queries.BankaraBattleHistoriesQuery]: Record; + [Queries.PrivateBattleHistoriesQuery]: Record; + [Queries.VsHistoryDetailQuery]: { + vsResultId: string; + }; + [Queries.CoopHistoryQuery]: Record; + [Queries.CoopHistoryDetailQuery]: { + coopHistoryDetailId: string; + }; +}; + +type Image = { + url: string; + width?: number; + height?: number; +}; +type RespMap = { + [Queries.HomeQuery]: { + currentPlayer: { + weapon: { + image: Image; + id: string; + }; + }; + banners: { image: Image; message: string; jumpTo: string }[]; + friends: { + nodes: { + id: number; + nickname: string; + userIcon: Image; + }[]; + totalCount: number; + }; + footerMessages: unknown[]; + }; + [Queries.LatestBattleHistoriesQuery]: Record; + [Queries.RegularBattleHistoriesQuery]: Record; + [Queries.BankaraBattleHistoriesQuery]: Record; + [Queries.PrivateBattleHistoriesQuery]: Record; + [Queries.VsHistoryDetailQuery]: Record; + [Queries.CoopHistoryQuery]: Record; + [Queries.CoopHistoryDetailQuery]: Record; +}; +type GraphQLResponse = { + data: T; +} | { + errors: unknown[]; +}; + +async function request( + state: State, + query: Q, + variables: VarsMap[Q], +): Promise { + const body = { + extensions: { + persistedQuery: { + sha256Hash: query, + version: 1, + }, + }, + variables, + }; + const resp = await fetch(SPLATNET3_ENDPOINT, { + method: "POST", + headers: { + "Authorization": `Bearer ${state.loginState?.bulletToken}`, + "Accept-Language": state.userLang ?? "en-US", + "User-Agent": state.appUserAgent ?? DEFAULT_APP_USER_AGENT, + "X-Web-View-Ver": await getWebViewVer(), + "Content-Type": "application/json", + "Accept": "*/*", + "Origin": "https://api.lp1.av5ja.srv.nintendo.net", + "X-Requested-With": "com.nintendo.znca", + "Referer": + `https://api.lp1.av5ja.srv.nintendo.net/?lang=${state.userLang}&na_country=${state.userCountry}&na_lang=${state.userLang}`, + "Accept-Encoding": "gzip, deflate", + "Cookie": `_gtoken: ${state.loginState?.gToken}`, + }, + body: JSON.stringify(body), + }); + if (resp.status !== 200) { + throw new APIError({ + response: resp, + message: "Splatnet3 request failed", + }); + } + + const json: GraphQLResponse = await resp.json(); + if ("errors" in json) { + throw new APIError({ + response: resp, + json, + message: "Splatnet3 request failed", + }); + } + return json.data; +} + +export async function checkToken(state: State) { + if ( + !state.loginState?.sessionToken || !state.loginState?.bulletToken || + !state.loginState?.gToken + ) { + return false; + } + + await request(state, Queries.HomeQuery, {}); + + return true; +} diff --git a/stat.ink.ts b/stat.ink.ts new file mode 100644 index 0000000..e69de29 diff --git a/state.ts b/state.ts index a017180..569c170 100644 --- a/state.ts +++ b/state.ts @@ -7,6 +7,8 @@ export type State = { loginState?: LoginState; fGen: string; appUserAgent?: string; + userLang?: string; + userCountry?: string; }; export const DEFAULT_STATE: State = { diff --git a/utils.ts b/utils.ts index d31a348..bc21937 100644 --- a/utils.ts +++ b/utils.ts @@ -40,3 +40,22 @@ export async function retry Promise>( } throw lastError; } + +const GLOBAL_CACHE: Record = {}; +export function cache Promise>( + f: F, + { key = f.name, expireIn = 3600 }: { key?: string; expireIn?: number } = {}, +): () => Promise> { + return async () => { + const cached = GLOBAL_CACHE[key]; + if (cached && cached.ts + expireIn * 1000 > Date.now()) { + return cached.value as PromiseReturnType; + } + const value = await f(); + GLOBAL_CACHE[key] = { + ts: Date.now(), + value, + }; + return value as PromiseReturnType; + }; +}