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: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-latest, windows-latest, macos-latest]
deno: [1.x, "1.21.x", canary] deno: [1.x, "1.22.x", canary]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: denoland/setup-deno@v1 - uses: denoland/setup-deno@v1

View File

@ -1,3 +1,7 @@
## 0.1.20
refactor: splatnet3 is a class now
## 0.1.19 ## 0.1.19
fix: don't set rank_exp_change if isUdemaeUp is true 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 * 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 { 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 { 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. * If rankState in profile.json is not defined, it will be initialized.
*/ */
import { flags } from "./deps.ts"; import { flags } from "./deps.ts";
import { getBulletToken, getGToken } from "./src/iksm.ts"; import { Splatnet3 } from "./src/splatnet3.ts";
import { checkToken, getBattleDetail, getBattleList } from "./src/splatnet3.ts"; import { gameId } from "./src/utils.ts";
import { gameId, readline } from "./src/utils.ts"; import { FileStateBackend, Profile } from "./src/state.ts";
import { FileStateBackend } from "./src/state.ts";
import { BattleListType } from "./src/types.ts"; import { BattleListType } from "./src/types.ts";
import { RANK_PARAMS } from "./src/RankTracker.ts"; import { RANK_PARAMS } from "./src/RankTracker.ts";
import { DEFAULT_ENV } from "./src/env.ts";
const parseArgs = (args: string[]) => { const parseArgs = (args: string[]) => {
const parsed = flags.parse(args, { const parsed = flags.parse(args, {
@ -32,52 +32,26 @@ if (opts.help) {
Deno.exit(0); Deno.exit(0);
} }
const env = DEFAULT_ENV;
const stateBackend = new FileStateBackend(opts.profilePath ?? "./profile.json"); 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."); console.log("rankState is already initialized.");
Deno.exit(0); Deno.exit(0);
} }
if (!await checkToken(state)) { const splatnet = new Splatnet3({ profile, env });
const sessionToken = state.loginState?.sessionToken;
if (!sessionToken) { const battleList = await splatnet.getBattleList(BattleListType.Bankara);
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);
if (battleList.length === 0) { if (battleList.length === 0) {
console.log("No anarchy battle found. Did you play anarchy?"); console.log("No anarchy battle found. Did you play anarchy?");
Deno.exit(0); Deno.exit(0);
} }
const { vsHistoryDetail: detail } = await getBattleDetail(state, battleList[0]); const { vsHistoryDetail: detail } = await splatnet.getBattleDetail(
battleList[0],
);
console.log( console.log(
`Your latest anarchy battle is played at ${ `Your latest anarchy battle is played at ${
@ -86,7 +60,7 @@ console.log(
); );
while (true) { while (true) {
const userInput = await readline(); const userInput = await env.readline();
const [rank, point] = userInput.split(","); const [rank, point] = userInput.split(",");
const pointNumber = parseInt(point); const pointNumber = parseInt(point);
@ -95,18 +69,17 @@ while (true) {
} else if (isNaN(pointNumber)) { } else if (isNaN(pointNumber)) {
console.log("Invalid point. Please enter again:"); console.log("Invalid point. Please enter again:");
} else { } else {
state = { profile.writeState({
...state, ...profile.state,
rankState: { rankState: {
gameId: await gameId(detail.id), gameId: await gameId(detail.id),
rank, rank,
rankPoint: pointNumber, rankPoint: pointNumber,
}, },
}; });
break; break;
} }
} }
await stateBackend.write(state);
console.log("rankState is initialized."); console.log("rankState is initialized.");

View File

@ -41,4 +41,4 @@ const app = new App({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
...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 Murmurhash3 from "https://deno.land/x/murmurhash@v1.0.0/mod.ts";
import { base64 } from "../deps.ts"; import { base64, flags } from "../deps.ts";
import { getBulletToken, getGToken, loginManually } from "../src/iksm.ts"; import { DEFAULT_ENV } from "../src/env.ts";
import { getGears, getLatestBattleHistoriesQuery } from "../src/splatnet3.ts"; import { loginManually } from "../src/iksm.ts";
import { DEFAULT_STATE, FileStateBackend, State } from "../src/state.ts"; import { Splatnet3 } from "../src/splatnet3.ts";
import {
FileStateBackend,
InMemoryStateBackend,
Profile,
} from "../src/state.ts";
import { parseHistoryDetailId } from "../src/utils.ts"; import { parseHistoryDetailId } from "../src/utils.ts";
const PROFILE_PATH = "./profile.json";
function encryptKey(uid: string) { function encryptKey(uid: string) {
const hasher = new Murmurhash3(); const hasher = new Murmurhash3();
hasher.hash(uid); 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 parseArgs = (args: string[]) => {
const exists = async (filename: string): Promise<boolean> => { const parsed = flags.parse(args, {
try { string: ["profilePath"],
await Deno.stat(filename); alias: {
// successful, file or directory must exist "help": "h",
return true; "profilePath": ["p", "profile-path"],
} catch (error) { },
if (error instanceof Deno.errors.NotFound) { });
// file or directory does not exist return parsed;
return false;
} else {
// unexpected error, maybe permissions, pass it along
throw error;
}
}
}; };
let state: State; const opts = parseArgs(Deno.args);
if (opts.help) {
console.log(
`Usage: deno run -A ${Deno.mainModule} [options]
if (await exists(PROFILE_PATH)) { Options:
state = await new FileStateBackend(PROFILE_PATH).read(); --profile-path <path>, -p Path to config file (default: null, login token will be dropped)
} else { --help Show this help message and exit`,
const sessionToken = await loginManually(); );
Deno.exit(0);
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 [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..."); console.log("Fetching uid...");
const { latestBattleHistories: { historyGroups } } = await latest; const { latestBattleHistories: { historyGroups } } = await splatnet
.getLatestBattleHistoriesQuery();
const id = historyGroups.nodes?.[0].historyDetails.nodes?.[0].id; const id = historyGroups.nodes?.[0].historyDetails.nodes?.[0].id;
@ -89,7 +90,7 @@ if (!id) {
const { uid } = parseHistoryDetailId(id); const { uid } = parseHistoryDetailId(id);
console.log("Fetching gears..."); console.log("Fetching gears...");
const data = await gears; const data = await splatnet.getGears();
const timestamp = Math.floor(new Date().getTime() / 1000); const timestamp = Math.floor(new Date().getTime() / 1000);
await Deno.writeTextFile( 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 * 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. * Make sure to update token before running this script.
*/ */
import { getGearPower } from "../src/splatnet3.ts"; import { Splatnet3 } from "../src/splatnet3.ts";
import { FileStateBackend } from "../src/state.ts"; import {
FileStateBackend,
InMemoryStateBackend,
Profile,
} from "../src/state.ts";
import { StatInkAbility } from "../src/types.ts"; import { StatInkAbility } from "../src/types.ts";
console.log("Getting keys from stat.ink"); console.log("Getting keys from stat.ink");
const abilityResponse = await fetch("https://stat.ink/api/v3/ability"); const abilityResponse = await fetch("https://stat.ink/api/v3/ability");
const abilityKeys: StatInkAbility = await abilityResponse.json(); 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 = [ const LANGS = [
"de-DE", "de-DE",
"en-GB", "en-GB",
@ -32,15 +43,21 @@ const LANGS = [
const langsResult: Record< const langsResult: Record<
string, string,
Awaited<ReturnType<typeof getGearPower>>["gearPowers"]["nodes"] Awaited<ReturnType<Splatnet3["getGearPower"]>>["gearPowers"]["nodes"]
> = {}; > = {};
for (const lang of LANGS) { for (const lang of LANGS) {
const langState = { const langState = {
...state, ...state,
userLang: lang, userLang: lang,
}; };
console.log(`Getting ${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) => ({ const result: StatInkAbility = abilityKeys.map((i, idx) => ({

View File

@ -1,11 +1,6 @@
import { Mutex } from "../deps.ts"; import { Mutex } from "../deps.ts";
import { RankState, State } from "./state.ts"; import { RankState, State } from "./state.ts";
import { import { Splatnet3 } from "./splatnet3.ts";
getBankaraBattleHistories,
getBattleDetail,
getCoopDetail,
getCoopHistories,
} from "./splatnet3.ts";
import { import {
BattleListNode, BattleListNode,
ChallengeProgress, ChallengeProgress,
@ -23,7 +18,7 @@ import { RankTracker } from "./RankTracker.ts";
* Fetch game and cache it. It also fetches bankara match challenge info. * Fetch game and cache it. It also fetches bankara match challenge info.
*/ */
export class GameFetcher { export class GameFetcher {
state: State; splatnet: Splatnet3;
cache: Cache; cache: Cache;
rankTracker: RankTracker; rankTracker: RankTracker;
@ -34,9 +29,13 @@ export class GameFetcher {
coopHistory?: HistoryGroups<CoopListNode>["nodes"]; coopHistory?: HistoryGroups<CoopListNode>["nodes"];
constructor( 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.cache = cache;
this.rankTracker = new RankTracker(state.rankState); this.rankTracker = new RankTracker(state.rankState);
} }
@ -72,10 +71,8 @@ export class GameFetcher {
return this.bankaraHistory; return this.bankaraHistory;
} }
const { bankaraBattleHistories: { historyGroups } } = const { bankaraBattleHistories: { historyGroups } } = await this.splatnet
await getBankaraBattleHistories( .getBankaraBattleHistories();
this.state,
);
this.bankaraHistory = historyGroups.nodes; this.bankaraHistory = historyGroups.nodes;
@ -88,9 +85,8 @@ export class GameFetcher {
return this.coopHistory; return this.coopHistory;
} }
const { coopResult: { historyGroups } } = await getCoopHistories( const { coopResult: { historyGroups } } = await this.splatnet
this.state, .getCoopHistories();
);
this.coopHistory = historyGroups.nodes; this.coopHistory = historyGroups.nodes;
@ -208,7 +204,7 @@ export class GameFetcher {
async fetchBattle(id: string): Promise<VsInfo> { async fetchBattle(id: string): Promise<VsInfo> {
const detail = await this.cacheDetail( const detail = await this.cacheDetail(
id, id,
() => getBattleDetail(this.state, id).then((r) => r.vsHistoryDetail), () => this.splatnet.getBattleDetail(id).then((r) => r.vsHistoryDetail),
); );
const metadata = await this.getBattleMetaById(id); const metadata = await this.getBattleMetaById(id);
@ -222,7 +218,7 @@ export class GameFetcher {
async fetchCoop(id: string): Promise<CoopInfo> { async fetchCoop(id: string): Promise<CoopInfo> {
const detail = await this.cacheDetail( const detail = await this.cacheDetail(
id, id,
() => getCoopDetail(this.state, id).then((r) => r.coopHistoryDetail), () => this.splatnet.getCoopDetail(id).then((r) => r.coopHistoryDetail),
); );
const metadata = await this.getCoopMetaById(id); 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 { MultiProgressBar } from "../deps.ts";
import { import { FileStateBackend, Profile, StateBackend } from "./state.ts";
DEFAULT_STATE, import { Splatnet3 } from "./splatnet3.ts";
FileStateBackend,
State,
StateBackend,
} from "./state.ts";
import { getBattleList, isTokenExpired } from "./splatnet3.ts";
import { BattleListType, Game, GameExporter } from "./types.ts"; import { BattleListType, Game, GameExporter } from "./types.ts";
import { Cache, FileCache } from "./cache.ts"; import { Cache, FileCache } from "./cache.ts";
import { StatInkExporter } from "./exporters/stat.ink.ts"; import { StatInkExporter } from "./exporters/stat.ink.ts";
import { FileExporter } from "./exporters/file.ts"; import { FileExporter } from "./exporters/file.ts";
import { import { delay, showError } from "./utils.ts";
delay,
readline,
RecoverableError,
retryRecoverableError,
showError,
} from "./utils.ts";
import { GameFetcher } from "./GameFetcher.ts"; import { GameFetcher } from "./GameFetcher.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
export type Opts = { export type Opts = {
profilePath: string; profilePath: string;
@ -28,6 +18,7 @@ export type Opts = {
skipMode?: string; skipMode?: string;
cache?: Cache; cache?: Cache;
stateBackend?: StateBackend; stateBackend?: StateBackend;
env: Env;
}; };
export const DEFAULT_OPTS: Opts = { export const DEFAULT_OPTS: Opts = {
@ -35,6 +26,7 @@ export const DEFAULT_OPTS: Opts = {
exporter: "stat.ink", exporter: "stat.ink",
noProgress: false, noProgress: false,
monitor: false, monitor: false,
env: DEFAULT_ENV,
}; };
type Progress = { type Progress = {
@ -43,51 +35,20 @@ type Progress = {
total: number; total: number;
}; };
function printStats(stats: Record<string, number>) {
console.log(
`Exported ${
Object.entries(stats)
.map(([name, count]) => `${name}: ${count}`)
.join(", ")
}`,
);
}
export class App { export class App {
state: State = DEFAULT_STATE; profile: Profile;
stateBackend: StateBackend; env: Env;
recoveryToken: RecoverableError = {
name: "Refetch Token",
is: isTokenExpired,
recovery: async () => {
console.log("Token expired, refetch tokens.");
await this.fetchToken();
},
};
constructor(public opts: Opts) { constructor(public opts: Opts) {
this.stateBackend = opts.stateBackend ?? const stateBackend = opts.stateBackend ??
new FileStateBackend(opts.profilePath); 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")[] { getSkipMode(): ("vs" | "coop")[] {
const mode = this.opts.skipMode; const mode = this.opts.skipMode;
if (mode === "vs") { if (mode === "vs") {
@ -98,39 +59,37 @@ export class App {
return []; return [];
} }
async getExporters(): Promise<GameExporter[]> { async getExporters(): Promise<GameExporter[]> {
const state = this.profile.state;
const exporters = this.opts.exporter.split(","); const exporters = this.opts.exporter.split(",");
const out: GameExporter[] = []; const out: GameExporter[] = [];
if (exporters.includes("stat.ink")) { if (exporters.includes("stat.ink")) {
if (!this.state.statInkApiKey) { if (!state.statInkApiKey) {
console.log("stat.ink API key is not set. Please enter below."); this.env.logger.log("stat.ink API key is not set. Please enter below.");
const key = (await readline()).trim(); const key = (await this.env.readline()).trim();
if (!key) { if (!key) {
console.error("API key is required."); this.env.logger.error("API key is required.");
Deno.exit(1); Deno.exit(1);
} }
await this.writeState({ await this.profile.writeState({
...this.state, ...state,
statInkApiKey: key, statInkApiKey: key,
}); });
} }
out.push( out.push(
new StatInkExporter({ new StatInkExporter({
statInkApiKey: this.state.statInkApiKey!, statInkApiKey: state.statInkApiKey!,
uploadMode: this.opts.monitor ? "Monitoring" : "Manual", uploadMode: this.opts.monitor ? "Monitoring" : "Manual",
}), }),
); );
} }
if (exporters.includes("file")) { if (exporters.includes("file")) {
out.push(new FileExporter(this.state.fileExportPath)); out.push(new FileExporter(state.fileExportPath));
} }
return out; return out;
} }
async exportOnce() {
await retryRecoverableError(() => this._exportOnce(), this.recoveryToken);
}
exporterProgress(title: string) { exporterProgress(title: string) {
const bar = !this.opts.noProgress const bar = !this.opts.noProgress
? new MultiProgressBar({ ? new MultiProgressBar({
@ -151,7 +110,7 @@ export class App {
})), })),
); );
} else if (progress.currentUrl) { } else if (progress.currentUrl) {
console.log( this.env.logger.log(
`Battle exported to ${progress.currentUrl} (${progress.current}/${progress.total})`, `Battle exported to ${progress.currentUrl} (${progress.current}/${progress.total})`,
); );
} }
@ -162,7 +121,8 @@ export class App {
return { redraw, endBar }; 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 exporters = await this.getExporters();
const initStats = () => const initStats = () =>
Object.fromEntries( Object.fromEntries(
@ -173,15 +133,16 @@ export class App {
const errors: unknown[] = []; const errors: unknown[] = [];
if (skipMode.includes("vs")) { if (skipMode.includes("vs")) {
console.log("Skip exporting VS games."); this.env.logger.log("Skip exporting VS games.");
} else { } else {
console.log("Fetching battle list..."); this.env.logger.log("Fetching battle list...");
const gameList = await getBattleList(this.state); const gameList = await splatnet.getBattleList();
const { redraw, endBar } = this.exporterProgress("Export vs games"); const { redraw, endBar } = this.exporterProgress("Export vs games");
const fetcher = new GameFetcher({ const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.state.cacheDir), cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir),
state: this.state, state: this.profile.state,
splatnet,
}); });
const finalRankState = await fetcher.updateRank(); const finalRankState = await fetcher.updateRank();
@ -189,6 +150,7 @@ export class App {
await Promise.all( await Promise.all(
exporters.map((e) => exporters.map((e) =>
showError( showError(
this.env,
this.exportGameList({ this.exportGameList({
type: "VsInfo", type: "VsInfo",
fetcher, fetcher,
@ -205,22 +167,22 @@ export class App {
) )
.catch((err) => { .catch((err) => {
errors.push(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(); endBar();
printStats(stats); this.printStats(stats);
if (errors.length > 0) { if (errors.length > 0) {
throw errors[0]; throw errors[0];
} }
// save rankState only if all exporters succeeded // save rankState only if all exporters succeeded
fetcher.setRankState(finalRankState); fetcher.setRankState(finalRankState);
await this.writeState({ await this.profile.writeState({
...this.state, ...this.profile.state,
rankState: finalRankState, rankState: finalRankState,
}); });
} }
@ -230,23 +192,24 @@ export class App {
// TODO: remove this filter when stat.ink support coop export // TODO: remove this filter when stat.ink support coop export
const coopExporter = exporters.filter((e) => e.name !== "stat.ink"); const coopExporter = exporters.filter((e) => e.name !== "stat.ink");
if (skipMode.includes("coop") || coopExporter.length === 0) { if (skipMode.includes("coop") || coopExporter.length === 0) {
console.log("Skip exporting Coop games."); this.env.logger.log("Skip exporting coop games.");
} else { } else {
console.log("Fetching coop battle list..."); this.env.logger.log("Fetching coop battle list...");
const coopBattleList = await getBattleList( const coopBattleList = await splatnet.getBattleList(
this.state,
BattleListType.Coop, BattleListType.Coop,
); );
const { redraw, endBar } = this.exporterProgress("Export coop games"); const { redraw, endBar } = this.exporterProgress("Export coop games");
const fetcher = new GameFetcher({ const fetcher = new GameFetcher({
cache: this.opts.cache ?? new FileCache(this.state.cacheDir), cache: this.opts.cache ?? new FileCache(this.profile.state.cacheDir),
state: this.state, state: this.profile.state,
splatnet,
}); });
await Promise.all( await Promise.all(
coopExporter.map((e) => coopExporter.map((e) =>
showError( showError(
this.env,
this.exportGameList({ this.exportGameList({
type: "CoopInfo", type: "CoopInfo",
fetcher, fetcher,
@ -263,14 +226,14 @@ export class App {
) )
.catch((err) => { .catch((err) => {
errors.push(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(); endBar();
printStats(stats); this.printStats(stats);
if (errors.length > 0) { if (errors.length > 0) {
throw errors[0]; throw errors[0];
} }
@ -279,7 +242,7 @@ export class App {
async monitor() { async monitor() {
while (true) { while (true) {
await this.exportOnce(); await this.exportOnce();
await this.countDown(this.state.monitorInterval); await this.countDown(this.profile.state.monitorInterval);
} }
} }
async countDown(sec: number) { async countDown(sec: number) {
@ -298,46 +261,16 @@ export class App {
} }
bar?.end(); 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() { async run() {
await this.readState(); await this.profile.readState();
if (!this.state.loginState?.sessionToken) { if (!this.profile.state.loginState?.sessionToken) {
const sessionToken = await loginManually(); const sessionToken = await loginManually(this.env);
await this.writeState({ await this.profile.writeState({
...this.state, ...this.profile.state,
loginState: { loginState: {
...this.state.loginState, ...this.profile.state.loginState,
sessionToken, sessionToken,
}, },
}); });
@ -413,4 +346,13 @@ export class App {
return exported; 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"; import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "s3si.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 NSOAPP_VERSION = "2.3.1";
export const WEB_VIEW_VERSION = "1.0.0-5644e7a2"; export const WEB_VIEW_VERSION = "1.0.0-5644e7a2";
export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"; 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 { CookieJar, wrapFetch } from "../deps.ts";
import { readline, retry, urlBase64Encode } from "./utils.ts"; import { retry, urlBase64Encode } from "./utils.ts";
import { import {
DEFAULT_APP_USER_AGENT, DEFAULT_APP_USER_AGENT,
NSOAPP_VERSION, NSOAPP_VERSION,
@ -7,8 +7,11 @@ import {
WEB_VIEW_VERSION, WEB_VIEW_VERSION,
} from "./constant.ts"; } from "./constant.ts";
import { APIError } from "./APIError.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 cookieJar = new CookieJar();
const fetch = wrapFetch({ cookieJar }); const fetch = wrapFetch({ cookieJar });
@ -52,9 +55,9 @@ export async function loginManually(): Promise<string> {
}, },
); );
console.log("Navigate to this URL in your browser:"); logger.log("Navigate to this URL in your browser:");
console.log(res.url); logger.log(res.url);
console.log( logger.log(
'Log in, right click the "Select this account" button, copy the link address, and paste it below:', '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 { import {
DEFAULT_APP_USER_AGENT, DEFAULT_APP_USER_AGENT,
SPLATNET3_ENDPOINT, SPLATNET3_ENDPOINT,
@ -13,12 +13,24 @@ import {
RespMap, RespMap,
VarsMap, VarsMap,
} from "./types.ts"; } from "./types.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
import { getBulletToken, getGToken } from "./iksm.ts";
async function request<Q extends Queries>( export class Splatnet3 {
state: State, 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, query: Q,
...rest: VarsMap[Q] ...rest: VarsMap[Q]
): Promise<RespMap[Q]> { ): Promise<RespMap[Q]> {
const doRequest = async () => {
const state = this.profile.state;
const variables = rest?.[0] ?? {}; const variables = rest?.[0] ?? {};
const body = { const body = {
extensions: { extensions: {
@ -63,17 +75,74 @@ async function request<Q extends Queries>(
}); });
} }
return json.data; return json.data;
} };
export const isTokenExpired = (e: unknown) => { try {
if (e instanceof APIError) { return await doRequest();
return e.response.status === 401; } catch (e) {
} else { if (isTokenExpired(e)) {
return false; await this.fetchToken();
return await doRequest();
}
throw e;
}
} }
};
export async function checkToken(state: State) { 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 ( if (
!state.loginState?.sessionToken || !state.loginState?.bulletToken || !state.loginState?.sessionToken || !state.loginState?.bulletToken ||
!state.loginState?.gToken !state.loginState?.gToken
@ -82,11 +151,76 @@ export async function checkToken(state: State) {
} }
try { try {
await request(state, Queries.HomeQuery); await this.request(Queries.HomeQuery);
return true; return true;
} catch (_e) { } catch (_e) {
return false; 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 }>( function getIdsFromGroups<T extends { id: string }>(
@ -97,95 +231,10 @@ function getIdsFromGroups<T extends { id: string }>(
); );
} }
const BATTLE_LIST_TYPE_MAP: Record< export function isTokenExpired(e: unknown) {
BattleListType, if (e instanceof APIError) {
(state: State) => Promise<string[]> return e.response.status === 401;
> = { } else {
[BattleListType.Latest]: (state: State) => return false;
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;
} }

View File

@ -1,3 +1,6 @@
import { DeepReadonly } from "../deps.ts";
import { DEFAULT_ENV, Env } from "./env.ts";
export type LoginState = { export type LoginState = {
sessionToken?: string; sessionToken?: string;
gToken?: string; gToken?: string;
@ -41,10 +44,27 @@ export type StateBackend = {
write: (newState: State) => Promise<void>; 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 { export class FileStateBackend implements StateBackend {
constructor(private path: string) {} constructor(private path: string) {}
async read(): Promise<State> { async read(): Promise<DeepReadonly<State>> {
const data = await Deno.readTextFile(this.path); const data = await Deno.readTextFile(this.path);
const json = JSON.parse(data); const json = JSON.parse(data);
return json; return json;
@ -57,3 +77,45 @@ export class FileStateBackend implements StateBackend {
await Deno.rename(swapPath, this.path); 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 { APIError } from "./APIError.ts";
import { S3S_NAMESPACE } from "./constant.ts"; import { S3S_NAMESPACE } from "./constant.ts";
import { base64, io, uuid } from "../deps.ts"; import { base64, uuid } from "../deps.ts";
import { Env } from "./env.ts";
const stdinLines = io.readLines(Deno.stdin);
export function urlBase64Encode(data: ArrayBuffer) { export function urlBase64Encode(data: ArrayBuffer) {
return base64.encode(data) 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; type PromiseReturnType<T> = T extends () => Promise<infer R> ? R : never;
export async function retry<F extends () => Promise<unknown>>( export async function retry<F extends () => Promise<unknown>>(
f: F, 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 { try {
return await p; return await p;
} catch (e) { } catch (e) {
if (e instanceof APIError) { if (e instanceof APIError) {
console.error( env.logger.error(
`\n\nAPIError: ${e.message}`, `\n\nAPIError: ${e.message}`,
"\nResponse: ", "\nResponse: ",
e.response, e.response,
@ -78,7 +66,7 @@ export async function showError<T>(p: Promise<T>): Promise<T> {
e.json, e.json,
); );
} else { } else {
console.error(e); env.logger.error(e);
} }
throw e; throw e;
} }
@ -133,35 +121,3 @@ export function parseHistoryDetailId(id: string) {
export const delay = (ms: number) => export const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms)); 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;
}
}
}