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
main
imspace 2022-11-17 19:19:11 +08:00 committed by GitHub
parent a296ae24a4
commit e60b3f98a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 522 additions and 454 deletions

View File

@ -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

View File

@ -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

View File

@ -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";

View File

@ -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.");

View File

@ -41,4 +41,4 @@ const app = new App({
...DEFAULT_OPTS,
...opts,
});
await showError(app.run());
await showError(app.env, app.run());

View File

@ -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<boolean> => {
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;
}
}
};
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 parseArgs = (args: string[]) => {
const parsed = flags.parse(args, {
string: ["profilePath"],
alias: {
"help": "h",
"profilePath": ["p", "profile-path"],
},
});
return parsed;
};
const opts = parseArgs(Deno.args);
if (opts.help) {
console.log(
`Usage: deno run -A ${Deno.mainModule} [options]
Options:
--profile-path <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(

View File

@ -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<ReturnType<typeof getGearPower>>["gearPowers"]["nodes"]
Awaited<ReturnType<Splatnet3["getGearPower"]>>["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) => ({

View File

@ -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<CoopListNode>["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<VsInfo> {
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<CoopInfo> {
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);

View File

@ -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<string, number>) {
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<GameExporter[]> {
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<string, number>) {
this.env.logger.log(
`Exported ${
Object.entries(stats)
.map(([name, count]) => `${name}: ${count}`)
.join(", ")
}`,
);
}
}

View File

@ -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";

64
src/env.ts Normal file
View File

@ -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<Response>;
post(
opts: { url: string; body: BodyInit; headers?: Headers },
): Promise<Response>;
};
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<string>;
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,
});
},
};
},
};

View File

@ -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<string> {
export async function loginManually(
{ logger, readline }: Env,
): Promise<string> {
const cookieJar = new CookieJar();
const fetch = wrapFetch({ cookieJar });
@ -52,9 +55,9 @@ export async function loginManually(): Promise<string> {
},
);
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:',
);

View File

@ -1,4 +1,4 @@
import { State } from "./state.ts";
import { Profile } from "./state.ts";
import {
DEFAULT_APP_USER_AGENT,
SPLATNET3_ENDPOINT,
@ -13,12 +13,24 @@ import {
RespMap,
VarsMap,
} from "./types.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { getBulletToken, getGToken } from "./iksm.ts";
async function request<Q extends Queries>(
state: State,
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<Q extends Queries>(
query: Q,
...rest: VarsMap[Q]
): Promise<RespMap[Q]> {
const doRequest = async () => {
const state = this.profile.state;
const variables = rest?.[0] ?? {};
const body = {
extensions: {
@ -63,17 +75,74 @@ async function request<Q extends Queries>(
});
}
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) {
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,
},
userLang: state.userLang ?? userLang,
userCountry: state.userCountry ?? userCountry,
});
}
protected BATTLE_LIST_TYPE_MAP: Record<
BattleListType,
() => Promise<string[]>
> = {
[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)),
};
async checkToken() {
const state = this.profile.state;
if (
!state.loginState?.sessionToken || !state.loginState?.bulletToken ||
!state.loginState?.gToken
@ -82,13 +151,78 @@ export async function checkToken(state: State) {
}
try {
await request(state, Queries.HomeQuery);
await this.request(Queries.HomeQuery);
return true;
} catch (_e) {
return false;
}
}
async getBattleList(
battleListType: BattleListType = BattleListType.Latest,
) {
return await this.BATTLE_LIST_TYPE_MAP[battleListType]();
}
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;
}
}
function getIdsFromGroups<T extends { id: string }>(
{ historyGroups }: { historyGroups: HistoryGroups<T> },
) {
@ -97,95 +231,10 @@ function getIdsFromGroups<T extends { id: string }>(
);
}
const BATTLE_LIST_TYPE_MAP: Record<
BattleListType,
(state: State) => Promise<string[]>
> = {
[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 isTokenExpired(e: unknown) {
if (e instanceof APIError) {
return e.response.status === 401;
} else {
return false;
}
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;
}

View File

@ -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<void>;
};
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<State> {
async read(): Promise<DeepReadonly<State>> {
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<State> {
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);
}
}
}

View File

@ -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> = T extends () => Promise<infer R> ? R : never;
export async function retry<F extends () => Promise<unknown>>(
f: F,
@ -65,12 +53,12 @@ export function cache<F extends () => Promise<unknown>>(
};
}
export async function showError<T>(p: Promise<T>): Promise<T> {
export async function showError<T>(env: Env, p: Promise<T>): Promise<T> {
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<T>(p: Promise<T>): Promise<T> {
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<void>((resolve) => setTimeout(resolve, ms));
export type RecoverableError = {
name: string;
is: (err: unknown) => boolean;
recovery: () => Promise<void>;
retryTimes?: number;
delayTime?: number;
};
export async function retryRecoverableError<F extends () => Promise<unknown>>(
f: F,
...errors: RecoverableError[]
): Promise<PromiseReturnType<F>> {
const retryTimes: Record<string, number> = Object.fromEntries(
errors.map(({ name, retryTimes }) => [name, retryTimes ?? 1]),
);
while (true) {
try {
return await f() as PromiseReturnType<F>;
} 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;
}
}
}