s3si.ts/s3si.ts

379 lines
9.7 KiB
TypeScript
Raw Normal View History

2022-10-19 04:56:18 -04:00
import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
import { APIError } from "./APIError.ts";
2022-10-20 09:45:59 -04:00
import { flags, MultiProgressBar, Mutex } from "./deps.ts";
2022-10-18 09:16:51 -04:00
import { DEFAULT_STATE, State } from "./state.ts";
2022-10-20 09:45:59 -04:00
import { checkToken, getBattleDetail, getBattleList } from "./splatnet3.ts";
import { BattleExporter, VsHistoryDetail } from "./types.ts";
import { Cache, FileCache, MemoryCache } from "./cache.ts";
import { StatInkExporter } from "./exporter/stat.ink.ts";
import { readline } from "./utils.ts";
import { FileExporter } from "./exporter/file.ts";
2022-10-18 08:08:26 -04:00
2022-10-18 09:16:51 -04:00
type Opts = {
2022-10-20 09:45:59 -04:00
profilePath: string;
exporter: string;
2022-10-20 10:05:58 -04:00
noProgress: boolean;
2022-10-18 09:16:51 -04:00
help?: boolean;
};
2022-10-20 09:45:59 -04:00
const DEFAULT_OPTS: Opts = {
profilePath: "./profile.json",
exporter: "stat.ink",
2022-10-20 10:05:58 -04:00
noProgress: false,
2022-10-18 09:16:51 -04:00
help: false,
};
2022-10-20 09:45:59 -04:00
/**
* Fetch battle and cache it.
*/
class BattleFetcher {
state: State;
cache: Cache;
lock: Record<string, Mutex | undefined> = {};
constructor(
{ cache = new MemoryCache(), state }: { state: State; cache?: Cache },
) {
this.state = state;
this.cache = cache;
}
getLock(id: string): Mutex {
let cur = this.lock[id];
if (!cur) {
cur = new Mutex();
this.lock[id] = cur;
}
return cur;
}
fetchBattle(id: string): Promise<VsHistoryDetail> {
const lock = this.getLock(id);
return lock.use(async () => {
const cached = await this.cache.read<VsHistoryDetail>(id);
if (cached) {
return cached;
}
const detail = (await getBattleDetail(this.state, id))
.vsHistoryDetail;
await this.cache.write(id, detail);
return detail;
});
}
}
type Progress = {
current: number;
total: number;
};
2022-10-18 09:16:51 -04:00
class App {
state: State = DEFAULT_STATE;
2022-10-20 09:45:59 -04:00
2022-10-18 09:16:51 -04:00
constructor(public opts: Opts) {
if (this.opts.help) {
console.log(
`Usage: deno run --allow-net --allow-read --allow-write ${Deno.mainModule} [options]
Options:
2022-10-20 09:45:59 -04:00
--profile-path <path>, -p Path to config file (default: ./profile.json)
2022-10-20 10:05:58 -04:00
--exporter <exporter>, -e Exporter list to use (default: stat.ink)
Multiple exporters can be separated by commas
(e.g. "stat.ink,file")
2022-10-20 09:45:59 -04:00
--no-progress, -n Disable progress bar
--help Show this help message and exit`,
2022-10-18 09:16:51 -04:00
);
Deno.exit(0);
}
}
2022-10-20 09:45:59 -04:00
async writeState(newState: State) {
this.state = newState;
2022-10-18 09:16:51 -04:00
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(this.state, undefined, 2));
2022-10-20 09:45:59 -04:00
const swapPath = `${this.opts.profilePath}.swap`;
2022-10-18 20:36:58 -04:00
await Deno.writeFile(swapPath, data);
2022-10-20 09:45:59 -04:00
await Deno.rename(swapPath, this.opts.profilePath);
2022-10-18 09:16:51 -04:00
}
async readState() {
const decoder = new TextDecoder();
try {
2022-10-20 09:45:59 -04:00
const data = await Deno.readFile(this.opts.profilePath);
2022-10-18 09:16:51 -04:00
const json = JSON.parse(decoder.decode(data));
2022-10-18 20:36:58 -04:00
this.state = {
...DEFAULT_STATE,
...json,
};
2022-10-18 09:16:51 -04:00
} catch (e) {
console.warn(
`Failed to read config file, create new config file. (${e})`,
);
2022-10-20 09:45:59 -04:00
await this.writeState(DEFAULT_STATE);
2022-10-18 09:16:51 -04:00
}
}
2022-10-20 09:45:59 -04:00
async getExporters(): Promise<BattleExporter<VsHistoryDetail>[]> {
const exporters = this.opts.exporter.split(",");
const out: BattleExporter<VsHistoryDetail>[] = [];
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 (!key) {
console.error("API key is required.");
Deno.exit(1);
}
await this.writeState({
...this.state,
statInkApiKey: key,
});
}
out.push(new StatInkExporter(this.state.statInkApiKey!));
}
if (exporters.includes("file")) {
out.push(new FileExporter(this.state.fileExportPath));
}
return out;
}
2022-10-18 09:16:51 -04:00
async run() {
await this.readState();
2022-10-18 20:36:58 -04:00
2022-10-20 10:05:58 -04:00
const bar = !this.opts.noProgress
2022-10-20 09:45:59 -04:00
? new MultiProgressBar({
title: "Export battles",
2022-10-20 14:11:08 -04:00
display: "[:bar] :text :percent :time eta: :eta :completed/:total",
2022-10-20 09:45:59 -04:00
})
: undefined;
const exporters = await this.getExporters();
2022-10-18 20:36:58 -04:00
try {
if (!this.state.loginState?.sessionToken) {
const sessionToken = await loginManually();
2022-10-20 09:45:59 -04:00
await this.writeState({
...this.state,
loginState: {
...this.state.loginState,
sessionToken,
},
});
2022-10-18 20:36:58 -04:00
}
2022-10-20 09:45:59 -04:00
const sessionToken = this.state.loginState!.sessionToken!;
2022-10-18 20:36:58 -04:00
2022-10-20 09:45:59 -04:00
console.log("Checking token...");
2022-10-19 07:46:03 -04:00
if (!await checkToken(this.state)) {
console.log("Token expired, refetch tokens.");
2022-10-18 20:36:58 -04:00
const { webServiceToken, userCountry, userLang } = await getGToken({
fApi: this.state.fGen,
sessionToken,
});
const bulletToken = await getBulletToken({
webServiceToken,
userLang,
userCountry,
appUserAgent: this.state.appUserAgent,
});
2022-10-20 09:45:59 -04:00
await this.writeState({
2022-10-19 04:56:18 -04:00
...this.state,
loginState: {
...this.state.loginState,
gToken: webServiceToken,
bulletToken,
},
2022-10-20 09:45:59 -04:00
userLang: this.state.userLang ?? userLang,
userCountry: this.state.userCountry ?? userCountry,
});
2022-10-18 20:36:58 -04:00
}
2022-10-19 04:56:18 -04:00
2022-10-20 09:45:59 -04:00
const fetcher = new BattleFetcher({
cache: new FileCache(this.state.cacheDir),
state: this.state,
});
console.log("Fetching battle list...");
2022-10-19 07:46:03 -04:00
const battleList = await getBattleList(this.state);
2022-10-20 09:45:59 -04:00
2022-10-20 14:11:08 -04:00
await this.prepareBattles({
bar,
battleList,
fetcher,
exporters,
});
const allProgress: Record<string, Progress> = {};
2022-10-20 09:45:59 -04:00
const redraw = (name: string, progress: Progress) => {
allProgress[name] = progress;
bar?.render(
Object.entries(allProgress).map(([name, progress]) => ({
completed: progress.current,
total: progress.total,
text: name,
})),
);
};
2022-10-20 14:11:08 -04:00
const stats: Record<string, number> = Object.fromEntries(
exporters.map((e) => [e.name, 0]),
);
2022-10-20 09:45:59 -04:00
await Promise.all(
exporters.map((e) =>
2022-10-20 10:05:58 -04:00
this.exportBattleList({
2022-10-20 09:45:59 -04:00
fetcher,
2022-10-20 10:05:58 -04:00
exporter: e,
2022-10-20 09:45:59 -04:00
battleList,
2022-10-20 10:05:58 -04:00
onStep: (progress) => redraw(e.name, progress),
})
2022-10-20 14:11:08 -04:00
.then((count) => {
stats[e.name] = count;
})
.catch((err) => {
console.error(`\nFailed to export ${e.name}:`, err);
})
2022-10-20 09:45:59 -04:00
),
);
2022-10-20 14:11:08 -04:00
console.log("\nDone.", stats);
2022-10-18 20:36:58 -04:00
} catch (e) {
if (e instanceof APIError) {
console.error(`APIError: ${e.message}`, e.response, e.json);
} else {
console.error(e);
}
2022-10-18 09:16:51 -04:00
}
}
2022-10-20 14:11:08 -04:00
async prepareBattles({
bar,
exporters,
battleList,
fetcher,
}: {
bar?: MultiProgressBar;
exporters: BattleExporter<VsHistoryDetail>[];
battleList: string[];
fetcher: BattleFetcher;
}) {
let prepared = 0;
bar?.render([{
text: "preparing",
completed: prepared,
total: battleList.length,
}]);
const latestBattleTimes = await Promise.all(
exporters.map((e) => e.getLatestBattleTime()),
);
const latestBattleTime = latestBattleTimes.reduce(
(a, b) => a > b ? b : a,
new Date(0),
);
for (const battleId of battleList) {
const battle = await fetcher.fetchBattle(battleId);
const playedTime = new Date(battle.playedTime);
prepared += 1;
bar?.render([{
text: "preparing",
completed: prepared,
total: battleList.length,
}]);
// if battle is older than latest battle, break
if (playedTime <= latestBattleTime) {
break;
}
}
}
2022-10-20 09:45:59 -04:00
/**
* Export battle list.
*
* @param fetcher BattleFetcher
* @param exporter BattleExporter
* @param battleList ID list of battles, sorted by date, newest first
* @param onStep Callback function called when a battle is exported
*/
async exportBattleList(
2022-10-20 10:05:58 -04:00
{
fetcher,
exporter,
battleList,
2022-10-20 14:11:08 -04:00
onStep,
2022-10-20 10:05:58 -04:00
}: {
2022-10-20 14:11:08 -04:00
fetcher: BattleFetcher;
exporter: BattleExporter<VsHistoryDetail>;
battleList: string[];
onStep?: (progress: Progress) => void;
},
): Promise<number> {
const latestBattleTime = await exporter.getLatestBattleTime();
let toUpload = 0;
let exported = 0;
for (const battleId of battleList) {
const battle = await fetcher.fetchBattle(battleId);
const playedTime = new Date(battle.playedTime);
// if battle is older than latest battle, break
if (playedTime <= latestBattleTime) {
break;
}
toUpload += 1;
}
const workQueue = battleList.slice(0, toUpload).reverse();
if (workQueue.length === 0) {
return 0;
2022-10-20 10:05:58 -04:00
}
2022-10-20 09:45:59 -04:00
const step = async (battle: string) => {
const detail = await fetcher.fetchBattle(battle);
await exporter.exportBattle(detail);
2022-10-20 14:11:08 -04:00
exported += 1;
2022-10-20 09:45:59 -04:00
onStep?.({
2022-10-20 14:11:08 -04:00
current: exported,
2022-10-20 09:45:59 -04:00
total: workQueue.length,
});
};
onStep?.({
2022-10-20 14:11:08 -04:00
current: exported,
2022-10-20 09:45:59 -04:00
total: workQueue.length,
});
for (const battle of workQueue) {
await step(battle);
}
2022-10-20 14:11:08 -04:00
return exported;
2022-10-20 09:45:59 -04:00
}
2022-10-18 09:16:51 -04:00
}
const parseArgs = (args: string[]) => {
const parsed = flags.parse(args, {
2022-10-20 09:45:59 -04:00
string: ["profilePath", "exporter"],
2022-10-20 10:05:58 -04:00
boolean: ["help", "noProgress"],
2022-10-18 09:16:51 -04:00
alias: {
"help": "h",
2022-10-20 09:45:59 -04:00
"profilePath": ["p", "profile-path"],
"exporter": ["e"],
2022-10-20 10:05:58 -04:00
"noProgress": ["n", "no-progress"],
2022-10-20 09:45:59 -04:00
},
default: {
progress: true,
2022-10-18 09:16:51 -04:00
},
});
return parsed;
};
const app = new App({
...DEFAULT_OPTS,
...parseArgs(Deno.args),
});
await app.run();