refactor: splatnet3 (#22)
* refactor: make Splatnet3 a class * feat: use in memory state backend when no '-p' * feat: avoid race * build: bump version * fix: rankStatemain
parent
a296ae24a4
commit
e60b3f98a8
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1
deps.ts
1
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 * 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";
|
||||||
|
|
|
||||||
61
initRank.ts
61
initRank.ts
|
|
@ -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.");
|
||||||
|
|
|
||||||
2
s3si.ts
2
s3si.ts
|
|
@ -41,4 +41,4 @@ const app = new App({
|
||||||
...DEFAULT_OPTS,
|
...DEFAULT_OPTS,
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
await showError(app.run());
|
await showError(app.env, app.run());
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
186
src/app.ts
186
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 { 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(", ")
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
13
src/iksm.ts
13
src/iksm.ts
|
|
@ -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:',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
257
src/splatnet3.ts
257
src/splatnet3.ts
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
src/state.ts
64
src/state.ts
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
54
src/utils.ts
54
src/utils.ts
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue