From e60b3f98a8a5910df8921ebb8cb9e1f7f16bbae3 Mon Sep 17 00:00:00 2001 From: imspace Date: Thu, 17 Nov 2022 19:19:11 +0800 Subject: [PATCH] refactor: splatnet3 (#22) * refactor: make Splatnet3 a class * feat: use in memory state backend when no '-p' * feat: avoid race * build: bump version * fix: rankState --- .github/workflows/ci.yaml | 2 +- CHANGELOG | 4 + deps.ts | 1 + initRank.ts | 61 ++---- s3si.ts | 2 +- scripts/export-geardata.ts | 101 +++++----- scripts/generate-gear-map.ts | 27 ++- src/GameFetcher.ts | 32 ++- src/app.ts | 186 ++++++------------ src/constant.ts | 2 +- src/env.ts | 64 ++++++ src/iksm.ts | 13 +- src/splatnet3.ts | 363 ++++++++++++++++++++--------------- src/state.ts | 64 +++++- src/utils.ts | 54 +----- 15 files changed, 522 insertions(+), 454 deletions(-) create mode 100644 src/env.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2d40576..83b7294 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,7 +6,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - deno: [1.x, "1.21.x", canary] + deno: [1.x, "1.22.x", canary] steps: - uses: actions/checkout@v3 - uses: denoland/setup-deno@v1 diff --git a/CHANGELOG b/CHANGELOG index 432a2da..c6b3d05 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +## 0.1.20 + +refactor: splatnet3 is a class now + ## 0.1.19 fix: don't set rank_exp_change if isUdemaeUp is true diff --git a/deps.ts b/deps.ts index e57fe2c..cfa84c6 100644 --- a/deps.ts +++ b/deps.ts @@ -11,3 +11,4 @@ export * as msgpack from "https://deno.land/x/msgpack@v1.4/mod.ts"; export * as path from "https://deno.land/std@0.160.0/path/mod.ts"; export { MultiProgressBar } from "https://deno.land/x/progress@v1.2.8/mod.ts"; export { Mutex } from "https://deno.land/x/semaphore@v1.1.1/mod.ts"; +export type { DeepReadonly } from "https://deno.land/x/ts_essentials@v9.1.2/mod.ts"; diff --git a/initRank.ts b/initRank.ts index 57d13ff..7052fa9 100644 --- a/initRank.ts +++ b/initRank.ts @@ -2,12 +2,12 @@ * If rankState in profile.json is not defined, it will be initialized. */ import { flags } from "./deps.ts"; -import { getBulletToken, getGToken } from "./src/iksm.ts"; -import { checkToken, getBattleDetail, getBattleList } from "./src/splatnet3.ts"; -import { gameId, readline } from "./src/utils.ts"; -import { FileStateBackend } from "./src/state.ts"; +import { Splatnet3 } from "./src/splatnet3.ts"; +import { gameId } from "./src/utils.ts"; +import { FileStateBackend, Profile } from "./src/state.ts"; import { BattleListType } from "./src/types.ts"; import { RANK_PARAMS } from "./src/RankTracker.ts"; +import { DEFAULT_ENV } from "./src/env.ts"; const parseArgs = (args: string[]) => { const parsed = flags.parse(args, { @@ -32,52 +32,26 @@ if (opts.help) { Deno.exit(0); } +const env = DEFAULT_ENV; const stateBackend = new FileStateBackend(opts.profilePath ?? "./profile.json"); -let state = await stateBackend.read(); +const profile = new Profile({ stateBackend, env }); +await profile.readState(); -if (state.rankState) { +if (profile.state.rankState) { console.log("rankState is already initialized."); Deno.exit(0); } -if (!await checkToken(state)) { - const sessionToken = state.loginState?.sessionToken; +const splatnet = new Splatnet3({ profile, env }); - if (!sessionToken) { - throw new Error("Session token is not set."); - } - - const { webServiceToken, userCountry, userLang } = await getGToken({ - fApi: state.fGen, - sessionToken, - }); - - const bulletToken = await getBulletToken({ - webServiceToken, - userLang, - userCountry, - appUserAgent: state.appUserAgent, - }); - - state = { - ...state, - loginState: { - ...state.loginState, - gToken: webServiceToken, - bulletToken, - }, - userLang: state.userLang ?? userLang, - userCountry: state.userCountry ?? userCountry, - }; - await stateBackend.write(state); -} - -const battleList = await getBattleList(state, BattleListType.Bankara); +const battleList = await splatnet.getBattleList(BattleListType.Bankara); if (battleList.length === 0) { console.log("No anarchy battle found. Did you play anarchy?"); Deno.exit(0); } -const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]); +const { vsHistoryDetail: detail } = await splatnet.getBattleDetail( + battleList[0], +); console.log( `Your latest anarchy battle is played at ${ @@ -86,7 +60,7 @@ console.log( ); while (true) { - const userInput = await readline(); + const userInput = await env.readline(); const [rank, point] = userInput.split(","); const pointNumber = parseInt(point); @@ -95,18 +69,17 @@ while (true) { } else if (isNaN(pointNumber)) { console.log("Invalid point. Please enter again:"); } else { - state = { - ...state, + profile.writeState({ + ...profile.state, rankState: { gameId: await gameId(detail.id), rank, rankPoint: pointNumber, }, - }; + }); break; } } -await stateBackend.write(state); console.log("rankState is initialized."); diff --git a/s3si.ts b/s3si.ts index b7e6bdc..f7121ed 100644 --- a/s3si.ts +++ b/s3si.ts @@ -41,4 +41,4 @@ const app = new App({ ...DEFAULT_OPTS, ...opts, }); -await showError(app.run()); +await showError(app.env, app.run()); diff --git a/scripts/export-geardata.ts b/scripts/export-geardata.ts index 67b737a..9e1d8e6 100644 --- a/scripts/export-geardata.ts +++ b/scripts/export-geardata.ts @@ -7,14 +7,17 @@ */ import Murmurhash3 from "https://deno.land/x/murmurhash@v1.0.0/mod.ts"; -import { base64 } from "../deps.ts"; -import { getBulletToken, getGToken, loginManually } from "../src/iksm.ts"; -import { getGears, getLatestBattleHistoriesQuery } from "../src/splatnet3.ts"; -import { DEFAULT_STATE, FileStateBackend, State } from "../src/state.ts"; +import { base64, flags } from "../deps.ts"; +import { DEFAULT_ENV } from "../src/env.ts"; +import { loginManually } from "../src/iksm.ts"; +import { Splatnet3 } from "../src/splatnet3.ts"; +import { + FileStateBackend, + InMemoryStateBackend, + Profile, +} from "../src/state.ts"; import { parseHistoryDetailId } from "../src/utils.ts"; -const PROFILE_PATH = "./profile.json"; - function encryptKey(uid: string) { const hasher = new Murmurhash3(); hasher.hash(uid); @@ -29,55 +32,53 @@ function encryptKey(uid: string) { }; } -// https://stackoverflow.com/questions/56658114/how-can-one-check-if-a-file-or-directory-exists-using-deno -const exists = async (filename: string): Promise => { - try { - await Deno.stat(filename); - // successful, file or directory must exist - return true; - } catch (error) { - if (error instanceof Deno.errors.NotFound) { - // file or directory does not exist - return false; - } else { - // unexpected error, maybe permissions, pass it along - throw error; - } - } +const parseArgs = (args: string[]) => { + const parsed = flags.parse(args, { + string: ["profilePath"], + alias: { + "help": "h", + "profilePath": ["p", "profile-path"], + }, + }); + return parsed; }; -let state: State; - -if (await exists(PROFILE_PATH)) { - state = await new FileStateBackend(PROFILE_PATH).read(); -} else { - const sessionToken = await loginManually(); - - const { webServiceToken, userCountry, userLang } = await getGToken({ - fApi: DEFAULT_STATE.fGen, - sessionToken, - }); - - const bulletToken = await getBulletToken({ - webServiceToken, - userLang, - userCountry, - }); - - state = { - ...DEFAULT_STATE, - loginState: { - sessionToken, - gToken: webServiceToken, - bulletToken, - }, - }; +const opts = parseArgs(Deno.args); +if (opts.help) { + console.log( + `Usage: deno run -A ${Deno.mainModule} [options] + + Options: + --profile-path , -p Path to config file (default: null, login token will be dropped) + --help Show this help message and exit`, + ); + Deno.exit(0); } -const [latest, gears] = [getLatestBattleHistoriesQuery(state), getGears(state)]; +const env = DEFAULT_ENV; +const stateBackend = opts.profilePath + ? new FileStateBackend(opts.profilePath) + : new InMemoryStateBackend(); +const profile = new Profile({ stateBackend, env }); +await profile.readState(); + +if (!profile.state.loginState?.sessionToken) { + const sessionToken = await loginManually(env); + + await profile.writeState({ + ...profile.state, + loginState: { + ...profile.state.loginState, + sessionToken, + }, + }); +} + +const splatnet = new Splatnet3({ profile, env }); console.log("Fetching uid..."); -const { latestBattleHistories: { historyGroups } } = await latest; +const { latestBattleHistories: { historyGroups } } = await splatnet + .getLatestBattleHistoriesQuery(); const id = historyGroups.nodes?.[0].historyDetails.nodes?.[0].id; @@ -89,7 +90,7 @@ if (!id) { const { uid } = parseHistoryDetailId(id); console.log("Fetching gears..."); -const data = await gears; +const data = await splatnet.getGears(); const timestamp = Math.floor(new Date().getTime() / 1000); await Deno.writeTextFile( diff --git a/scripts/generate-gear-map.ts b/scripts/generate-gear-map.ts index 1ac6a08..46b722c 100644 --- a/scripts/generate-gear-map.ts +++ b/scripts/generate-gear-map.ts @@ -4,15 +4,26 @@ * This script get token from `./profile.json`, and replace `userLang` with each language to get the full map * Make sure to update token before running this script. */ -import { getGearPower } from "../src/splatnet3.ts"; -import { FileStateBackend } from "../src/state.ts"; +import { Splatnet3 } from "../src/splatnet3.ts"; +import { + FileStateBackend, + InMemoryStateBackend, + Profile, +} from "../src/state.ts"; import { StatInkAbility } from "../src/types.ts"; console.log("Getting keys from stat.ink"); const abilityResponse = await fetch("https://stat.ink/api/v3/ability"); const abilityKeys: StatInkAbility = await abilityResponse.json(); -const state = await new FileStateBackend("./profile.json").read(); +const stateBackend = new FileStateBackend("./profile.json"); +const profile = new Profile({ stateBackend }); +await profile.readState(); +const splatnet = new Splatnet3({ profile }); +if (!await splatnet.checkToken()) { + await splatnet.fetchToken(); +} +const state = profile.state; const LANGS = [ "de-DE", "en-GB", @@ -32,15 +43,21 @@ const LANGS = [ const langsResult: Record< string, - Awaited>["gearPowers"]["nodes"] + Awaited>["gearPowers"]["nodes"] > = {}; + for (const lang of LANGS) { const langState = { ...state, userLang: lang, }; console.log(`Getting ${lang}...`); - langsResult[lang] = (await getGearPower(langState)).gearPowers.nodes; + + const stateBackend = new InMemoryStateBackend(langState); + const profile = new Profile({ stateBackend }); + await profile.readState(); + const splatnet = new Splatnet3({ profile }); + langsResult[lang] = (await splatnet.getGearPower()).gearPowers.nodes; } const result: StatInkAbility = abilityKeys.map((i, idx) => ({ diff --git a/src/GameFetcher.ts b/src/GameFetcher.ts index e20ab83..363a914 100644 --- a/src/GameFetcher.ts +++ b/src/GameFetcher.ts @@ -1,11 +1,6 @@ import { Mutex } from "../deps.ts"; import { RankState, State } from "./state.ts"; -import { - getBankaraBattleHistories, - getBattleDetail, - getCoopDetail, - getCoopHistories, -} from "./splatnet3.ts"; +import { Splatnet3 } from "./splatnet3.ts"; import { BattleListNode, ChallengeProgress, @@ -23,7 +18,7 @@ import { RankTracker } from "./RankTracker.ts"; * Fetch game and cache it. It also fetches bankara match challenge info. */ export class GameFetcher { - state: State; + splatnet: Splatnet3; cache: Cache; rankTracker: RankTracker; @@ -34,9 +29,13 @@ export class GameFetcher { coopHistory?: HistoryGroups["nodes"]; constructor( - { cache = new MemoryCache(), state }: { state: State; cache?: Cache }, + { cache = new MemoryCache(), splatnet, state }: { + splatnet: Splatnet3; + state: State; + cache?: Cache; + }, ) { - this.state = state; + this.splatnet = splatnet; this.cache = cache; this.rankTracker = new RankTracker(state.rankState); } @@ -72,10 +71,8 @@ export class GameFetcher { return this.bankaraHistory; } - const { bankaraBattleHistories: { historyGroups } } = - await getBankaraBattleHistories( - this.state, - ); + const { bankaraBattleHistories: { historyGroups } } = await this.splatnet + .getBankaraBattleHistories(); this.bankaraHistory = historyGroups.nodes; @@ -88,9 +85,8 @@ export class GameFetcher { return this.coopHistory; } - const { coopResult: { historyGroups } } = await getCoopHistories( - this.state, - ); + const { coopResult: { historyGroups } } = await this.splatnet + .getCoopHistories(); this.coopHistory = historyGroups.nodes; @@ -208,7 +204,7 @@ export class GameFetcher { async fetchBattle(id: string): Promise { const detail = await this.cacheDetail( id, - () => getBattleDetail(this.state, id).then((r) => r.vsHistoryDetail), + () => this.splatnet.getBattleDetail(id).then((r) => r.vsHistoryDetail), ); const metadata = await this.getBattleMetaById(id); @@ -222,7 +218,7 @@ export class GameFetcher { async fetchCoop(id: string): Promise { const detail = await this.cacheDetail( id, - () => getCoopDetail(this.state, id).then((r) => r.coopHistoryDetail), + () => this.splatnet.getCoopDetail(id).then((r) => r.coopHistoryDetail), ); const metadata = await this.getCoopMetaById(id); diff --git a/src/app.ts b/src/app.ts index 513cc11..677b39f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,24 +1,14 @@ -import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; +import { loginManually } from "./iksm.ts"; import { MultiProgressBar } from "../deps.ts"; -import { - DEFAULT_STATE, - FileStateBackend, - State, - StateBackend, -} from "./state.ts"; -import { getBattleList, isTokenExpired } from "./splatnet3.ts"; +import { FileStateBackend, Profile, StateBackend } from "./state.ts"; +import { Splatnet3 } from "./splatnet3.ts"; import { BattleListType, Game, GameExporter } from "./types.ts"; import { Cache, FileCache } from "./cache.ts"; import { StatInkExporter } from "./exporters/stat.ink.ts"; import { FileExporter } from "./exporters/file.ts"; -import { - delay, - readline, - RecoverableError, - retryRecoverableError, - showError, -} from "./utils.ts"; +import { delay, showError } from "./utils.ts"; import { GameFetcher } from "./GameFetcher.ts"; +import { DEFAULT_ENV, Env } from "./env.ts"; export type Opts = { profilePath: string; @@ -28,6 +18,7 @@ export type Opts = { skipMode?: string; cache?: Cache; stateBackend?: StateBackend; + env: Env; }; export const DEFAULT_OPTS: Opts = { @@ -35,6 +26,7 @@ export const DEFAULT_OPTS: Opts = { exporter: "stat.ink", noProgress: false, monitor: false, + env: DEFAULT_ENV, }; type Progress = { @@ -43,51 +35,20 @@ type Progress = { total: number; }; -function printStats(stats: Record) { - console.log( - `Exported ${ - Object.entries(stats) - .map(([name, count]) => `${name}: ${count}`) - .join(", ") - }`, - ); -} - export class App { - state: State = DEFAULT_STATE; - stateBackend: StateBackend; - recoveryToken: RecoverableError = { - name: "Refetch Token", - is: isTokenExpired, - recovery: async () => { - console.log("Token expired, refetch tokens."); - - await this.fetchToken(); - }, - }; + profile: Profile; + env: Env; constructor(public opts: Opts) { - this.stateBackend = opts.stateBackend ?? + const stateBackend = opts.stateBackend ?? new FileStateBackend(opts.profilePath); + this.profile = new Profile({ + stateBackend, + env: opts.env, + }); + this.env = opts.env; } - async writeState(newState: State) { - this.state = newState; - await this.stateBackend.write(newState); - } - async readState() { - try { - const json = await this.stateBackend.read(); - this.state = { - ...DEFAULT_STATE, - ...json, - }; - } catch (e) { - console.warn( - `Failed to read config file, create new config file. (${e})`, - ); - await this.writeState(DEFAULT_STATE); - } - } + getSkipMode(): ("vs" | "coop")[] { const mode = this.opts.skipMode; if (mode === "vs") { @@ -98,39 +59,37 @@ export class App { return []; } async getExporters(): Promise { + const state = this.profile.state; const exporters = this.opts.exporter.split(","); const out: GameExporter[] = []; if (exporters.includes("stat.ink")) { - if (!this.state.statInkApiKey) { - console.log("stat.ink API key is not set. Please enter below."); - const key = (await readline()).trim(); + if (!state.statInkApiKey) { + this.env.logger.log("stat.ink API key is not set. Please enter below."); + const key = (await this.env.readline()).trim(); if (!key) { - console.error("API key is required."); + this.env.logger.error("API key is required."); Deno.exit(1); } - await this.writeState({ - ...this.state, + await this.profile.writeState({ + ...state, statInkApiKey: key, }); } out.push( new StatInkExporter({ - statInkApiKey: this.state.statInkApiKey!, + statInkApiKey: state.statInkApiKey!, uploadMode: this.opts.monitor ? "Monitoring" : "Manual", }), ); } if (exporters.includes("file")) { - out.push(new FileExporter(this.state.fileExportPath)); + out.push(new FileExporter(state.fileExportPath)); } return out; } - async exportOnce() { - await retryRecoverableError(() => this._exportOnce(), this.recoveryToken); - } exporterProgress(title: string) { const bar = !this.opts.noProgress ? new MultiProgressBar({ @@ -151,7 +110,7 @@ export class App { })), ); } else if (progress.currentUrl) { - console.log( + this.env.logger.log( `Battle exported to ${progress.currentUrl} (${progress.current}/${progress.total})`, ); } @@ -162,7 +121,8 @@ export class App { return { redraw, endBar }; } - private async _exportOnce() { + private async exportOnce() { + const splatnet = new Splatnet3({ profile: this.profile, env: this.env }); const exporters = await this.getExporters(); const initStats = () => Object.fromEntries( @@ -173,15 +133,16 @@ export class App { const errors: unknown[] = []; if (skipMode.includes("vs")) { - console.log("Skip exporting VS games."); + this.env.logger.log("Skip exporting VS games."); } else { - console.log("Fetching battle list..."); - const gameList = await getBattleList(this.state); + this.env.logger.log("Fetching battle list..."); + const gameList = await splatnet.getBattleList(); const { redraw, endBar } = this.exporterProgress("Export vs games"); const fetcher = new GameFetcher({ - cache: this.opts.cache ?? new FileCache(this.state.cacheDir), - state: this.state, + cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir), + state: this.profile.state, + splatnet, }); const finalRankState = await fetcher.updateRank(); @@ -189,6 +150,7 @@ export class App { await Promise.all( exporters.map((e) => showError( + this.env, this.exportGameList({ type: "VsInfo", fetcher, @@ -205,22 +167,22 @@ export class App { ) .catch((err) => { errors.push(err); - console.error(`\nFailed to export to ${e.name}:`, err); + this.env.logger.error(`\nFailed to export to ${e.name}:`, err); }) ), ); endBar(); - printStats(stats); + this.printStats(stats); if (errors.length > 0) { throw errors[0]; } // save rankState only if all exporters succeeded fetcher.setRankState(finalRankState); - await this.writeState({ - ...this.state, + await this.profile.writeState({ + ...this.profile.state, rankState: finalRankState, }); } @@ -230,23 +192,24 @@ export class App { // TODO: remove this filter when stat.ink support coop export const coopExporter = exporters.filter((e) => e.name !== "stat.ink"); if (skipMode.includes("coop") || coopExporter.length === 0) { - console.log("Skip exporting Coop games."); + this.env.logger.log("Skip exporting coop games."); } else { - console.log("Fetching coop battle list..."); - const coopBattleList = await getBattleList( - this.state, + this.env.logger.log("Fetching coop battle list..."); + const coopBattleList = await splatnet.getBattleList( BattleListType.Coop, ); const { redraw, endBar } = this.exporterProgress("Export coop games"); const fetcher = new GameFetcher({ - cache: this.opts.cache ?? new FileCache(this.state.cacheDir), - state: this.state, + cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir), + state: this.profile.state, + splatnet, }); await Promise.all( coopExporter.map((e) => showError( + this.env, this.exportGameList({ type: "CoopInfo", fetcher, @@ -263,14 +226,14 @@ export class App { ) .catch((err) => { errors.push(err); - console.error(`\nFailed to export to ${e.name}:`, err); + this.env.logger.error(`\nFailed to export to ${e.name}:`, err); }) ), ); endBar(); - printStats(stats); + this.printStats(stats); if (errors.length > 0) { throw errors[0]; } @@ -279,7 +242,7 @@ export class App { async monitor() { while (true) { await this.exportOnce(); - await this.countDown(this.state.monitorInterval); + await this.countDown(this.profile.state.monitorInterval); } } async countDown(sec: number) { @@ -298,46 +261,16 @@ export class App { } bar?.end(); } - async fetchToken() { - const sessionToken = this.state.loginState?.sessionToken; - - if (!sessionToken) { - throw new Error("Session token is not set."); - } - - const { webServiceToken, userCountry, userLang } = await getGToken({ - fApi: this.state.fGen, - sessionToken, - }); - - const bulletToken = await getBulletToken({ - webServiceToken, - userLang, - userCountry, - appUserAgent: this.state.appUserAgent, - }); - - await this.writeState({ - ...this.state, - loginState: { - ...this.state.loginState, - gToken: webServiceToken, - bulletToken, - }, - userLang: this.state.userLang ?? userLang, - userCountry: this.state.userCountry ?? userCountry, - }); - } async run() { - await this.readState(); + await this.profile.readState(); - if (!this.state.loginState?.sessionToken) { - const sessionToken = await loginManually(); + if (!this.profile.state.loginState?.sessionToken) { + const sessionToken = await loginManually(this.env); - await this.writeState({ - ...this.state, + await this.profile.writeState({ + ...this.profile.state, loginState: { - ...this.state.loginState, + ...this.profile.state.loginState, sessionToken, }, }); @@ -413,4 +346,13 @@ export class App { return exported; } + printStats(stats: Record) { + this.env.logger.log( + `Exported ${ + Object.entries(stats) + .map(([name, count]) => `${name}: ${count}`) + .join(", ") + }`, + ); + } } diff --git a/src/constant.ts b/src/constant.ts index 16017ca..ca785bc 100644 --- a/src/constant.ts +++ b/src/constant.ts @@ -1,7 +1,7 @@ import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; export const AGENT_NAME = "s3si.ts"; -export const S3SI_VERSION = "0.1.19"; +export const S3SI_VERSION = "0.1.20"; export const NSOAPP_VERSION = "2.3.1"; export const WEB_VIEW_VERSION = "1.0.0-5644e7a2"; export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"; diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..af14d5c --- /dev/null +++ b/src/env.ts @@ -0,0 +1,64 @@ +import { CookieJar, wrapFetch } from "../deps.ts"; +import { io } from "../deps.ts"; + +const stdinLines = io.readLines(Deno.stdin); +export async function readline( + { skipEmpty = true }: { skipEmpty?: boolean } = {}, +) { + for await (const line of stdinLines) { + if (!skipEmpty || line !== "") { + return line; + } + } + throw new Error("EOF"); +} + +export type Fetcher = { + get(opts: { url: string; headers?: Headers }): Promise; + post( + opts: { url: string; body: BodyInit; headers?: Headers }, + ): Promise; +}; + +export type Logger = { + debug: (...msg: unknown[]) => void; + log: (...msg: unknown[]) => void; + warn: (...msg: unknown[]) => void; + error: (...msg: unknown[]) => void; +}; + +export type Env = { + logger: Logger; + readline: () => Promise; + newFetcher: () => Fetcher; +}; + +export const DEFAULT_ENV: Env = { + logger: { + debug: console.debug, + log: console.log, + warn: console.warn, + error: console.error, + }, + readline, + newFetcher: () => { + const cookieJar = new CookieJar(); + const fetch = wrapFetch({ cookieJar }); + + return { + async get({ url, headers }) { + return await fetch(url, { + method: "GET", + headers, + }); + }, + async post({ url, body, headers }) { + return await fetch(url, { + method: "POST", + headers, + body, + }); + }, + }; + }, +}; diff --git a/src/iksm.ts b/src/iksm.ts index 21a50b8..ac42a67 100644 --- a/src/iksm.ts +++ b/src/iksm.ts @@ -1,5 +1,5 @@ import { CookieJar, wrapFetch } from "../deps.ts"; -import { readline, retry, urlBase64Encode } from "./utils.ts"; +import { retry, urlBase64Encode } from "./utils.ts"; import { DEFAULT_APP_USER_AGENT, NSOAPP_VERSION, @@ -7,8 +7,11 @@ import { WEB_VIEW_VERSION, } from "./constant.ts"; import { APIError } from "./APIError.ts"; +import { Env } from "./env.ts"; -export async function loginManually(): Promise { +export async function loginManually( + { logger, readline }: Env, +): Promise { const cookieJar = new CookieJar(); const fetch = wrapFetch({ cookieJar }); @@ -52,9 +55,9 @@ export async function loginManually(): Promise { }, ); - console.log("Navigate to this URL in your browser:"); - console.log(res.url); - console.log( + logger.log("Navigate to this URL in your browser:"); + logger.log(res.url); + logger.log( 'Log in, right click the "Select this account" button, copy the link address, and paste it below:', ); diff --git a/src/splatnet3.ts b/src/splatnet3.ts index b681f98..9de6320 100644 --- a/src/splatnet3.ts +++ b/src/splatnet3.ts @@ -1,4 +1,4 @@ -import { State } from "./state.ts"; +import { Profile } from "./state.ts"; import { DEFAULT_APP_USER_AGENT, SPLATNET3_ENDPOINT, @@ -13,79 +13,213 @@ import { RespMap, VarsMap, } from "./types.ts"; +import { DEFAULT_ENV, Env } from "./env.ts"; +import { getBulletToken, getGToken } from "./iksm.ts"; -async function request( - state: State, - query: Q, - ...rest: VarsMap[Q] -): Promise { - const variables = rest?.[0] ?? {}; - const body = { - extensions: { - persistedQuery: { - sha256Hash: query, - version: 1, +export class Splatnet3 { + protected profile: Profile; + protected env: Env; + + constructor({ profile, env = DEFAULT_ENV }: { profile: Profile; env?: Env }) { + this.profile = profile; + this.env = env; + } + + protected async request( + query: Q, + ...rest: VarsMap[Q] + ): Promise { + const doRequest = async () => { + const state = this.profile.state; + const variables = rest?.[0] ?? {}; + 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": WEB_VIEW_VERSION, + "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(${json.errors?.[0].message})`, + }); + } + return json.data; + }; + + try { + return await doRequest(); + } catch (e) { + if (isTokenExpired(e)) { + await this.fetchToken(); + return await doRequest(); + } + throw e; + } + } + + async fetchToken() { + const state = this.profile.state; + const sessionToken = state.loginState?.sessionToken; + + if (!sessionToken) { + throw new Error("Session token is not set."); + } + + const { webServiceToken, userCountry, userLang } = await getGToken({ + fApi: state.fGen, + sessionToken, + }); + + const bulletToken = await getBulletToken({ + webServiceToken, + userLang, + userCountry, + appUserAgent: state.appUserAgent, + }); + + await this.profile.writeState({ + ...state, + loginState: { + ...state.loginState, + gToken: webServiceToken, + bulletToken, }, - }, - variables, + userLang: state.userLang ?? userLang, + userCountry: state.userCountry ?? userCountry, + }); + } + + protected BATTLE_LIST_TYPE_MAP: Record< + BattleListType, + () => Promise + > = { + [BattleListType.Latest]: () => + this.request(Queries.LatestBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.latestBattleHistories)), + [BattleListType.Regular]: () => + this.request(Queries.RegularBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.regularBattleHistories)), + [BattleListType.Bankara]: () => + this.request(Queries.BankaraBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.bankaraBattleHistories)), + [BattleListType.Private]: () => + this.request(Queries.PrivateBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.privateBattleHistories)), + [BattleListType.Coop]: () => + this.request(Queries.CoopHistoryQuery) + .then((r) => getIdsFromGroups(r.coopResult)), }; - 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": WEB_VIEW_VERSION, - "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", - }); + + async checkToken() { + const state = this.profile.state; + if ( + !state.loginState?.sessionToken || !state.loginState?.bulletToken || + !state.loginState?.gToken + ) { + return false; + } + + try { + await this.request(Queries.HomeQuery); + return true; + } catch (_e) { + return false; + } } - const json: GraphQLResponse = await resp.json(); - if ("errors" in json) { - throw new APIError({ - response: resp, - json, - message: `Splatnet3 request failed(${json.errors?.[0].message})`, - }); - } - return json.data; -} - -export const isTokenExpired = (e: unknown) => { - if (e instanceof APIError) { - return e.response.status === 401; - } else { - return false; - } -}; - -export async function checkToken(state: State) { - if ( - !state.loginState?.sessionToken || !state.loginState?.bulletToken || - !state.loginState?.gToken + async getBattleList( + battleListType: BattleListType = BattleListType.Latest, ) { - return false; + return await this.BATTLE_LIST_TYPE_MAP[battleListType](); } - try { - await request(state, Queries.HomeQuery); - return true; - } catch (_e) { - return false; + getBattleDetail( + id: string, + ) { + return this.request( + Queries.VsHistoryDetailQuery, + { + vsResultId: id, + }, + ); + } + + getCoopDetail( + id: string, + ) { + return this.request( + Queries.CoopHistoryDetailQuery, + { + coopHistoryDetailId: id, + }, + ); + } + + async getBankaraBattleHistories() { + const resp = await this.request(Queries.BankaraBattleHistoriesQuery); + + return resp; + } + + async getCoopHistories() { + const resp = await this.request(Queries.CoopHistoryQuery); + + return resp; + } + + async getGearPower() { + const resp = await this.request( + Queries.myOutfitCommonDataFilteringConditionQuery, + ); + + return resp; + } + + async getLatestBattleHistoriesQuery() { + const resp = await this.request( + Queries.LatestBattleHistoriesQuery, + ); + + return resp; + } + + async getGears() { + const resp = await this.request( + Queries.myOutfitCommonDataEquipmentsQuery, + ); + + return resp; } } @@ -97,95 +231,10 @@ function getIdsFromGroups( ); } -const BATTLE_LIST_TYPE_MAP: Record< - BattleListType, - (state: State) => Promise -> = { - [BattleListType.Latest]: (state: State) => - request(state, Queries.LatestBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.latestBattleHistories)), - [BattleListType.Regular]: (state: State) => - request(state, Queries.RegularBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.regularBattleHistories)), - [BattleListType.Bankara]: (state: State) => - request(state, Queries.BankaraBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.bankaraBattleHistories)), - [BattleListType.Private]: (state: State) => - request(state, Queries.PrivateBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.privateBattleHistories)), - [BattleListType.Coop]: (state: State) => - request(state, Queries.CoopHistoryQuery) - .then((r) => getIdsFromGroups(r.coopResult)), -}; - -export async function getBattleList( - state: State, - battleListType: BattleListType = BattleListType.Latest, -) { - return await BATTLE_LIST_TYPE_MAP[battleListType](state); -} - -export function getBattleDetail( - state: State, - id: string, -) { - return request( - state, - Queries.VsHistoryDetailQuery, - { - vsResultId: id, - }, - ); -} - -export function getCoopDetail( - state: State, - id: string, -) { - return request( - state, - Queries.CoopHistoryDetailQuery, - { - coopHistoryDetailId: id, - }, - ); -} - -export async function getBankaraBattleHistories(state: State) { - const resp = await request(state, Queries.BankaraBattleHistoriesQuery); - - return resp; -} - -export async function getCoopHistories(state: State) { - const resp = await request(state, Queries.CoopHistoryQuery); - - return resp; -} - -export async function getGearPower(state: State) { - const resp = await request( - state, - Queries.myOutfitCommonDataFilteringConditionQuery, - ); - - return resp; -} - -export async function getLatestBattleHistoriesQuery(state: State) { - const resp = await request( - state, - Queries.LatestBattleHistoriesQuery, - ); - - return resp; -} - -export async function getGears(state: State) { - const resp = await request( - state, - Queries.myOutfitCommonDataEquipmentsQuery, - ); - - return resp; +export function isTokenExpired(e: unknown) { + if (e instanceof APIError) { + return e.response.status === 401; + } else { + return false; + } } diff --git a/src/state.ts b/src/state.ts index 332f023..9a07a02 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,3 +1,6 @@ +import { DeepReadonly } from "../deps.ts"; +import { DEFAULT_ENV, Env } from "./env.ts"; + export type LoginState = { sessionToken?: string; gToken?: string; @@ -41,10 +44,27 @@ export type StateBackend = { write: (newState: State) => Promise; }; +export class InMemoryStateBackend implements StateBackend { + state: State; + + constructor(state?: State) { + this.state = state ?? DEFAULT_STATE; + } + + read() { + return Promise.resolve(this.state); + } + + write(newState: State) { + this.state = newState; + return Promise.resolve(); + } +} + export class FileStateBackend implements StateBackend { constructor(private path: string) {} - async read(): Promise { + async read(): Promise> { const data = await Deno.readTextFile(this.path); const json = JSON.parse(data); return json; @@ -57,3 +77,45 @@ export class FileStateBackend implements StateBackend { await Deno.rename(swapPath, this.path); } } + +export class Profile { + protected _state?: State; + protected stateBackend: StateBackend; + protected env: Env; + + constructor( + { stateBackend, env = DEFAULT_ENV }: { + stateBackend: StateBackend; + env?: Env; + }, + ) { + this.stateBackend = stateBackend; + this.env = env; + } + + get state(): DeepReadonly { + if (!this._state) { + throw new Error("state is not initialized"); + } + return this._state; + } + + async writeState(newState: State) { + this._state = newState; + await this.stateBackend.write(newState); + } + async readState() { + try { + const json = await this.stateBackend.read(); + this._state = { + ...DEFAULT_STATE, + ...json, + }; + } catch (e) { + this.env.logger.warn( + `Failed to read config file, create new config file. (${e})`, + ); + await this.writeState(DEFAULT_STATE); + } + } +} diff --git a/src/utils.ts b/src/utils.ts index de5619d..81ca3ac 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,7 @@ import { APIError } from "./APIError.ts"; import { S3S_NAMESPACE } from "./constant.ts"; -import { base64, io, uuid } from "../deps.ts"; - -const stdinLines = io.readLines(Deno.stdin); +import { base64, uuid } from "../deps.ts"; +import { Env } from "./env.ts"; export function urlBase64Encode(data: ArrayBuffer) { return base64.encode(data) @@ -19,17 +18,6 @@ export function urlBase64Decode(data: string) { ); } -export async function readline( - { skipEmpty = true }: { skipEmpty?: boolean } = {}, -) { - for await (const line of stdinLines) { - if (!skipEmpty || line !== "") { - return line; - } - } - throw new Error("EOF"); -} - type PromiseReturnType = T extends () => Promise ? R : never; export async function retry Promise>( f: F, @@ -65,12 +53,12 @@ export function cache Promise>( }; } -export async function showError(p: Promise): Promise { +export async function showError(env: Env, p: Promise): Promise { try { return await p; } catch (e) { if (e instanceof APIError) { - console.error( + env.logger.error( `\n\nAPIError: ${e.message}`, "\nResponse: ", e.response, @@ -78,7 +66,7 @@ export async function showError(p: Promise): Promise { e.json, ); } else { - console.error(e); + env.logger.error(e); } throw e; } @@ -133,35 +121,3 @@ export function parseHistoryDetailId(id: string) { export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export type RecoverableError = { - name: string; - is: (err: unknown) => boolean; - recovery: () => Promise; - retryTimes?: number; - delayTime?: number; -}; -export async function retryRecoverableError Promise>( - f: F, - ...errors: RecoverableError[] -): Promise> { - const retryTimes: Record = Object.fromEntries( - errors.map(({ name, retryTimes }) => [name, retryTimes ?? 1]), - ); - while (true) { - try { - return await f() as PromiseReturnType; - } catch (e) { - const error = errors.find((error) => error.is(e)); - if (error) { - if (retryTimes[error.name] > 0) { - retryTimes[error.name]--; - await error.recovery(); - await delay(error.delayTime ?? 1000); - continue; - } - } - throw e; - } - } -}