feat: refetch token when 401 close #5

main
spacemeowx2 2022-10-24 20:46:21 +08:00
parent 390b7ce279
commit 3e2eede47c
4 changed files with 153 additions and 83 deletions

View File

@ -1,11 +1,16 @@
import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
import { MultiProgressBar, Mutex } from "../deps.ts"; import { MultiProgressBar, Mutex } from "../deps.ts";
import { DEFAULT_STATE, FileStateBackend, State, StateBackend } from "./state.ts";
import { import {
checkToken, DEFAULT_STATE,
FileStateBackend,
State,
StateBackend,
} from "./state.ts";
import {
getBankaraBattleHistories, getBankaraBattleHistories,
getBattleDetail, getBattleDetail,
getBattleList, getBattleList,
isTokenExpired,
} from "./splatnet3.ts"; } from "./splatnet3.ts";
import { import {
BattleExporter, BattleExporter,
@ -17,7 +22,14 @@ import {
import { Cache, FileCache, MemoryCache } from "./cache.ts"; import { Cache, FileCache, MemoryCache } 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 { battleId, delay, readline, showError } from "./utils.ts"; import {
battleId,
delay,
readline,
RecoverableError,
retryRecoverableError,
showError,
} from "./utils.ts";
export type Opts = { export type Opts = {
profilePath: string; profilePath: string;
@ -159,9 +171,19 @@ type Progress = {
export class App { export class App {
state: State = DEFAULT_STATE; state: State = DEFAULT_STATE;
stateBackend: StateBackend; stateBackend: StateBackend;
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 ?? new FileStateBackend(opts.profilePath); this.stateBackend = opts.stateBackend ??
new FileStateBackend(opts.profilePath);
} }
async writeState(newState: State) { async writeState(newState: State) {
this.state = newState; this.state = newState;
@ -212,7 +234,10 @@ export class App {
return out; return out;
} }
async exportOnce() { exportOnce() {
return retryRecoverableError(() => this._exportOnce(), this.recoveryToken);
}
async _exportOnce() {
const bar = !this.opts.noProgress const bar = !this.opts.noProgress
? new MultiProgressBar({ ? new MultiProgressBar({
title: "Export battles", title: "Export battles",
@ -220,58 +245,62 @@ export class App {
}) })
: undefined; : undefined;
const exporters = await this.getExporters(); try {
const exporters = await this.getExporters();
const fetcher = new BattleFetcher({ const fetcher = new BattleFetcher({
cache: this.opts.cache ?? new FileCache(this.state.cacheDir), cache: this.opts.cache ?? new FileCache(this.state.cacheDir),
state: this.state, state: this.state,
}); });
console.log("Fetching battle list..."); console.log("Fetching battle list...");
const battleList = await getBattleList(this.state); const battleList = await getBattleList(this.state);
const allProgress: Record<string, Progress> = {}; const allProgress: Record<string, Progress> = {};
const redraw = (name: string, progress: Progress) => { const redraw = (name: string, progress: Progress) => {
allProgress[name] = progress; allProgress[name] = progress;
bar?.render( bar?.render(
Object.entries(allProgress).map(([name, progress]) => ({ Object.entries(allProgress).map(([name, progress]) => ({
completed: progress.current, completed: progress.current,
total: progress.total, total: progress.total,
text: name, text: name,
})), })),
);
};
const stats: Record<string, number> = Object.fromEntries(
exporters.map((e) => [e.name, 0]),
); );
};
const stats: Record<string, number> = Object.fromEntries(
exporters.map((e) => [e.name, 0]),
);
await Promise.all( await Promise.all(
exporters.map((e) => exporters.map((e) =>
showError( showError(
this.exportBattleList({ this.exportBattleList({
fetcher, fetcher,
exporter: e, exporter: e,
battleList, battleList,
onStep: (progress) => redraw(e.name, progress), onStep: (progress) => redraw(e.name, progress),
}) })
.then((count) => { .then((count) => {
stats[e.name] = count; stats[e.name] = count;
}), }),
) )
.catch((err) => { .catch((err) => {
console.error(`\nFailed to export to ${e.name}:`, err); console.error(`\nFailed to export to ${e.name}:`, err);
}) })
), ),
); );
bar?.end(); bar?.end();
console.log( console.log(
`Exported ${ `Exported ${
Object.entries(stats) Object.entries(stats)
.map(([name, count]) => `${name}: ${count}`) .map(([name, count]) => `${name}: ${count}`)
.join(", ") .join(", ")
}`, }`,
); );
} finally {
bar?.end();
}
} }
async monitor() { async monitor() {
while (true) { while (true) {
@ -295,6 +324,36 @@ 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.readState();
@ -309,35 +368,6 @@ export class App {
}, },
}); });
} }
const sessionToken = this.state.loginState!.sessionToken!;
console.log("Checking token...");
if (!await checkToken(this.state)) {
console.log("Token expired, refetch tokens.");
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,
});
}
if (this.opts.monitor) { if (this.opts.monitor) {
await this.monitor(); await this.monitor();

View File

@ -1,7 +1,7 @@
import type { StatInkPostBody, VsHistoryDetail } from "./types.ts"; import type { StatInkPostBody, VsHistoryDetail } from "./types.ts";
export const AGENT_NAME = "s3si.ts"; export const AGENT_NAME = "s3si.ts";
export const S3SI_VERSION = "0.1.6"; export const S3SI_VERSION = "0.1.7";
export const NSOAPP_VERSION = "2.3.1"; export const NSOAPP_VERSION = "2.3.1";
export const WEB_VIEW_VERSION = "1.0.0-216d0219"; export const WEB_VIEW_VERSION = "1.0.0-216d0219";
export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts" export const S3SI_LINK = "https://github.com/spacemeowx2/s3si.ts"

View File

@ -66,6 +66,14 @@ async function request<Q extends Queries>(
return json.data; return json.data;
} }
export const isTokenExpired = (e: unknown) => {
if (e instanceof APIError) {
return e.response.status === 401;
} else {
return false;
}
};
export async function checkToken(state: State) { export async function checkToken(state: State) {
if ( if (
!state.loginState?.sessionToken || !state.loginState?.bulletToken || !state.loginState?.sessionToken || !state.loginState?.bulletToken ||

View File

@ -63,9 +63,9 @@ export function cache<F extends () => Promise<unknown>>(
}; };
} }
export async function showError(p: Promise<void>) { export async function showError<T>(p: Promise<T>): Promise<T> {
try { try {
await p; return await p;
} catch (e) { } catch (e) {
if (e instanceof APIError) { if (e instanceof APIError) {
console.error( console.error(
@ -116,3 +116,35 @@ export function parseVsHistoryDetailId(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;
}
}
}