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();
|