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