feat: add file exporter and progress
parent
4d7b8ad33b
commit
52efc01cad
|
|
@ -1,2 +1,4 @@
|
||||||
*.json
|
*.json
|
||||||
.vscode/
|
.vscode/
|
||||||
|
export/
|
||||||
|
cache/
|
||||||
|
|
|
||||||
28
APIError.ts
28
APIError.ts
|
|
@ -1,14 +1,14 @@
|
||||||
export class APIError extends Error {
|
export class APIError extends Error {
|
||||||
response: Response;
|
response: Response;
|
||||||
json: unknown;
|
json: unknown;
|
||||||
constructor(
|
constructor(
|
||||||
{ response, message }: {
|
{ response, message }: {
|
||||||
response: Response;
|
response: Response;
|
||||||
json?: unknown;
|
json?: unknown;
|
||||||
message?: string;
|
message?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.response = response;
|
this.response = response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
// deno-lint-ignore-file require-await
|
||||||
|
import { path } from "./deps.ts";
|
||||||
|
|
||||||
|
export type Cache = {
|
||||||
|
read: <T>(key: string) => Promise<T | undefined>;
|
||||||
|
write: <T>(key: string, value: T) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MemoryCache implements Cache {
|
||||||
|
private cache: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
async read<T>(key: string): Promise<T | undefined> {
|
||||||
|
return this.cache[key] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async write<T>(key: string, value: T): Promise<void> {
|
||||||
|
this.cache[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File Cache stores data in a folder. Each file is named by the sha256 of its key.
|
||||||
|
*/
|
||||||
|
export class FileCache implements Cache {
|
||||||
|
constructor(private path: string) {}
|
||||||
|
|
||||||
|
private async getPath(key: string): Promise<string> {
|
||||||
|
await Deno.mkdir(this.path, { recursive: true });
|
||||||
|
|
||||||
|
const hash = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(key),
|
||||||
|
);
|
||||||
|
const hashHex = Array.from(new Uint8Array(hash))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return path.join(this.path, hashHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
async read<T>(key: string): Promise<T | undefined> {
|
||||||
|
const path = await this.getPath(key);
|
||||||
|
try {
|
||||||
|
const data = await Deno.readTextFile(path);
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Deno.errors.NotFound) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async write<T>(key: string, value: T): Promise<void> {
|
||||||
|
const path = await this.getPath(key);
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(JSON.stringify(value));
|
||||||
|
const swapPath = `${path}.swap`;
|
||||||
|
await Deno.writeFile(swapPath, data);
|
||||||
|
await Deno.rename(swapPath, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
constant.ts
22
constant.ts
|
|
@ -1,11 +1,11 @@
|
||||||
export const S3SI_VERSION = "0.1.0";
|
export const S3SI_VERSION = "0.1.0";
|
||||||
export const NSOAPP_VERSION = "2.3.1";
|
export const NSOAPP_VERSION = "2.3.1";
|
||||||
export const USERAGENT = `s3si.ts/${S3SI_VERSION}`;
|
export const USERAGENT = `s3si.ts/${S3SI_VERSION}`;
|
||||||
export const DEFAULT_APP_USER_AGENT =
|
export const DEFAULT_APP_USER_AGENT =
|
||||||
"Mozilla/5.0 (Linux; Android 11; Pixel 5) " +
|
"Mozilla/5.0 (Linux; Android 11; Pixel 5) " +
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
"Chrome/94.0.4606.61 Mobile Safari/537.36";
|
"Chrome/94.0.4606.61 Mobile Safari/537.36";
|
||||||
export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net";
|
export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net";
|
||||||
export const SPLATNET3_ENDPOINT =
|
export const SPLATNET3_ENDPOINT =
|
||||||
"https://api.lp1.av5ja.srv.nintendo.net/api/graphql";
|
"https://api.lp1.av5ja.srv.nintendo.net/api/graphql";
|
||||||
export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1";
|
export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1";
|
||||||
|
|
|
||||||
4
deps.ts
4
deps.ts
|
|
@ -8,3 +8,7 @@ export * as flags from "https://deno.land/std@0.160.0/flags/mod.ts";
|
||||||
export * as io from "https://deno.land/std@0.160.0/io/mod.ts";
|
export * as io from "https://deno.land/std@0.160.0/io/mod.ts";
|
||||||
export * as uuid from "https://deno.land/std@0.160.0/uuid/mod.ts";
|
export * as uuid from "https://deno.land/std@0.160.0/uuid/mod.ts";
|
||||||
export * as msgpack from "https://deno.land/x/msgpack@v1.4/mod.ts";
|
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 datetime from "https://deno.land/std@0.160.0/datetime/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";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { BattleExporter, VsHistoryDetail } from "../types.ts";
|
||||||
|
import { datetime, path } from "../deps.ts";
|
||||||
|
import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts";
|
||||||
|
|
||||||
|
const FILENAME_FORMAT = "yyyyMMddHHmmss";
|
||||||
|
|
||||||
|
type FileExporterType = {
|
||||||
|
type: "VS" | "COOP";
|
||||||
|
nsoVersion: string;
|
||||||
|
s3siVersion: string;
|
||||||
|
exportTime: string;
|
||||||
|
data: VsHistoryDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporter to file.
|
||||||
|
*
|
||||||
|
* This is useful for debugging. It will write each battle detail to a file.
|
||||||
|
* Timestamp is used as filename. Example: 2021-01-01T00:00:00.000Z.json
|
||||||
|
*/
|
||||||
|
export class FileExporter implements BattleExporter<VsHistoryDetail> {
|
||||||
|
name = "file";
|
||||||
|
constructor(private exportPath: string) {
|
||||||
|
}
|
||||||
|
async exportBattle(detail: VsHistoryDetail) {
|
||||||
|
await Deno.mkdir(this.exportPath, { recursive: true });
|
||||||
|
|
||||||
|
const playedTime = new Date(detail.playedTime);
|
||||||
|
const filename = `${datetime.format(playedTime, FILENAME_FORMAT)}.json`;
|
||||||
|
const filepath = path.join(this.exportPath, filename);
|
||||||
|
|
||||||
|
const body: FileExporterType = {
|
||||||
|
type: "VS",
|
||||||
|
nsoVersion: NSOAPP_VERSION,
|
||||||
|
s3siVersion: S3SI_VERSION,
|
||||||
|
exportTime: new Date().toISOString(),
|
||||||
|
data: detail,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Deno.writeTextFile(filepath, JSON.stringify(body));
|
||||||
|
}
|
||||||
|
async getLatestBattleTime() {
|
||||||
|
const dirs: Deno.DirEntry[] = [];
|
||||||
|
for await (const i of Deno.readDir(this.exportPath)) dirs.push(i);
|
||||||
|
|
||||||
|
const files = dirs.filter((i) => i.isFile).map((i) => i.name);
|
||||||
|
const timestamps = files.map((i) => i.replace(/\.json$/, "")).map((i) =>
|
||||||
|
datetime.parse(i, FILENAME_FORMAT)
|
||||||
|
);
|
||||||
|
|
||||||
|
return timestamps.reduce((a, b) => (a > b ? a : b), new Date(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { BattleExporter, VsHistoryDetail } from "../types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporter to stat.ink.
|
||||||
|
*
|
||||||
|
* This is the default exporter. It will upload each battle detail to stat.ink.
|
||||||
|
*/
|
||||||
|
export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
|
||||||
|
name = "stat.ink";
|
||||||
|
constructor(private statInkApiKey: string) {
|
||||||
|
if (statInkApiKey.length !== 43) {
|
||||||
|
throw new Error("Invalid stat.ink API key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async exportBattle(detail: VsHistoryDetail) {
|
||||||
|
throw new Error("Function not implemented.");
|
||||||
|
}
|
||||||
|
async getLatestBattleTime() {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
}
|
||||||
2
iksm.ts
2
iksm.ts
|
|
@ -58,7 +58,7 @@ export async function loginManually(): Promise<string> {
|
||||||
'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:',
|
||||||
);
|
);
|
||||||
|
|
||||||
const login = await readline();
|
const login = (await readline()).trim();
|
||||||
if (!login) {
|
if (!login) {
|
||||||
throw new Error("No login URL provided");
|
throw new Error("No login URL provided");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
224
s3si.ts
224
s3si.ts
|
|
@ -1,44 +1,103 @@
|
||||||
import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
|
import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
|
||||||
import { APIError } from "./APIError.ts";
|
import { APIError } from "./APIError.ts";
|
||||||
import { flags } from "./deps.ts";
|
import { flags, MultiProgressBar, Mutex } from "./deps.ts";
|
||||||
import { DEFAULT_STATE, State } from "./state.ts";
|
import { DEFAULT_STATE, State } from "./state.ts";
|
||||||
import { checkToken, getBattleList } from "./splatnet3.ts";
|
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";
|
||||||
|
|
||||||
type Opts = {
|
type Opts = {
|
||||||
configPath: string;
|
profilePath: string;
|
||||||
|
exporter: string;
|
||||||
|
progress: boolean;
|
||||||
help?: boolean;
|
help?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_OPTS = {
|
const DEFAULT_OPTS: Opts = {
|
||||||
configPath: "./config.json",
|
profilePath: "./profile.json",
|
||||||
|
exporter: "stat.ink",
|
||||||
|
progress: true,
|
||||||
help: false,
|
help: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
|
||||||
class App {
|
class App {
|
||||||
state: State = DEFAULT_STATE;
|
state: State = DEFAULT_STATE;
|
||||||
|
|
||||||
constructor(public opts: Opts) {
|
constructor(public opts: Opts) {
|
||||||
if (this.opts.help) {
|
if (this.opts.help) {
|
||||||
console.log(
|
console.log(
|
||||||
`Usage: deno run --allow-net --allow-read --allow-write ${Deno.mainModule} [options]
|
`Usage: deno run --allow-net --allow-read --allow-write ${Deno.mainModule} [options]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--config-path <path> Path to config file (default: ./config.json)
|
--profile-path <path>, -p Path to config file (default: ./profile.json)
|
||||||
--help Show this help message and exit`,
|
--exporter <exporter>, -e Exporter to use (default: stat.ink), available: stat.ink,file
|
||||||
|
--no-progress, -n Disable progress bar
|
||||||
|
--help Show this help message and exit`,
|
||||||
);
|
);
|
||||||
Deno.exit(0);
|
Deno.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async writeState() {
|
async writeState(newState: State) {
|
||||||
|
this.state = newState;
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(JSON.stringify(this.state, undefined, 2));
|
const data = encoder.encode(JSON.stringify(this.state, undefined, 2));
|
||||||
const swapPath = `${this.opts.configPath}.swap`;
|
const swapPath = `${this.opts.profilePath}.swap`;
|
||||||
await Deno.writeFile(swapPath, data);
|
await Deno.writeFile(swapPath, data);
|
||||||
await Deno.rename(swapPath, this.opts.configPath);
|
await Deno.rename(swapPath, this.opts.profilePath);
|
||||||
}
|
}
|
||||||
async readState() {
|
async readState() {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
try {
|
try {
|
||||||
const data = await Deno.readFile(this.opts.configPath);
|
const data = await Deno.readFile(this.opts.profilePath);
|
||||||
const json = JSON.parse(decoder.decode(data));
|
const json = JSON.parse(decoder.decode(data));
|
||||||
this.state = {
|
this.state = {
|
||||||
...DEFAULT_STATE,
|
...DEFAULT_STATE,
|
||||||
|
|
@ -48,23 +107,60 @@ Options:
|
||||||
console.warn(
|
console.warn(
|
||||||
`Failed to read config file, create new config file. (${e})`,
|
`Failed to read config file, create new config file. (${e})`,
|
||||||
);
|
);
|
||||||
await this.writeState();
|
await this.writeState(DEFAULT_STATE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
async run() {
|
async run() {
|
||||||
await this.readState();
|
await this.readState();
|
||||||
|
|
||||||
|
const bar = this.opts.progress
|
||||||
|
? new MultiProgressBar({
|
||||||
|
title: "Export battles",
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
const exporters = await this.getExporters();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.state.loginState?.sessionToken) {
|
if (!this.state.loginState?.sessionToken) {
|
||||||
const sessionToken = await loginManually();
|
const sessionToken = await loginManually();
|
||||||
this.state.loginState = {
|
|
||||||
...this.state.loginState,
|
|
||||||
sessionToken,
|
|
||||||
};
|
|
||||||
await this.writeState();
|
|
||||||
}
|
|
||||||
const sessionToken = this.state.loginState.sessionToken!;
|
|
||||||
|
|
||||||
|
await this.writeState({
|
||||||
|
...this.state,
|
||||||
|
loginState: {
|
||||||
|
...this.state.loginState,
|
||||||
|
sessionToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sessionToken = this.state.loginState!.sessionToken!;
|
||||||
|
|
||||||
|
console.log("Checking token...");
|
||||||
if (!await checkToken(this.state)) {
|
if (!await checkToken(this.state)) {
|
||||||
console.log("Token expired, refetch tokens.");
|
console.log("Token expired, refetch tokens.");
|
||||||
|
|
||||||
|
|
@ -80,22 +176,51 @@ Options:
|
||||||
appUserAgent: this.state.appUserAgent,
|
appUserAgent: this.state.appUserAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.state = {
|
await this.writeState({
|
||||||
...this.state,
|
...this.state,
|
||||||
loginState: {
|
loginState: {
|
||||||
...this.state.loginState,
|
...this.state.loginState,
|
||||||
gToken: webServiceToken,
|
gToken: webServiceToken,
|
||||||
bulletToken,
|
bulletToken,
|
||||||
},
|
},
|
||||||
userLang,
|
userLang: this.state.userLang ?? userLang,
|
||||||
userCountry,
|
userCountry: this.state.userCountry ?? userCountry,
|
||||||
};
|
});
|
||||||
|
|
||||||
await this.writeState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetcher = new BattleFetcher({
|
||||||
|
cache: new FileCache(this.state.cacheDir),
|
||||||
|
state: this.state,
|
||||||
|
});
|
||||||
|
console.log("Fetching battle list...");
|
||||||
const battleList = await getBattleList(this.state);
|
const battleList = await getBattleList(this.state);
|
||||||
console.log(battleList);
|
|
||||||
|
const allProgress: Record<string, Progress> = Object.fromEntries(
|
||||||
|
exporters.map((i) => [i.name, {
|
||||||
|
current: 0,
|
||||||
|
total: 1,
|
||||||
|
}]),
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await Promise.all(
|
||||||
|
exporters.map((e) =>
|
||||||
|
this.exportBattleList(
|
||||||
|
fetcher,
|
||||||
|
e,
|
||||||
|
battleList,
|
||||||
|
(progress) => redraw(e.name, progress),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof APIError) {
|
if (e instanceof APIError) {
|
||||||
console.error(`APIError: ${e.message}`, e.response, e.json);
|
console.error(`APIError: ${e.message}`, e.response, e.json);
|
||||||
|
|
@ -104,15 +229,56 @@ Options:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
fetcher: BattleFetcher,
|
||||||
|
exporter: BattleExporter<VsHistoryDetail>,
|
||||||
|
battleList: string[],
|
||||||
|
onStep?: (progress: Progress) => void,
|
||||||
|
) {
|
||||||
|
const workQueue = battleList;
|
||||||
|
let done = 0;
|
||||||
|
|
||||||
|
const step = async (battle: string) => {
|
||||||
|
const detail = await fetcher.fetchBattle(battle);
|
||||||
|
await exporter.exportBattle(detail);
|
||||||
|
done += 1;
|
||||||
|
onStep?.({
|
||||||
|
current: done,
|
||||||
|
total: workQueue.length,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onStep?.({
|
||||||
|
current: done,
|
||||||
|
total: workQueue.length,
|
||||||
|
});
|
||||||
|
for (const battle of workQueue) {
|
||||||
|
await step(battle);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseArgs = (args: string[]) => {
|
const parseArgs = (args: string[]) => {
|
||||||
const parsed = flags.parse(args, {
|
const parsed = flags.parse(args, {
|
||||||
string: ["configPath"],
|
string: ["profilePath", "exporter"],
|
||||||
boolean: ["help"],
|
boolean: ["help", "progress"],
|
||||||
|
negatable: ["progress"],
|
||||||
alias: {
|
alias: {
|
||||||
"help": "h",
|
"help": "h",
|
||||||
"configPath": ["c", "config-path"],
|
"profilePath": ["p", "profile-path"],
|
||||||
|
"exporter": ["e"],
|
||||||
|
"progress": ["n"],
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
progress: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|
|
||||||
246
splatnet3.ts
246
splatnet3.ts
|
|
@ -1,123 +1,123 @@
|
||||||
import { getWebViewVer } from "./iksm.ts";
|
import { getWebViewVer } from "./iksm.ts";
|
||||||
import { State } from "./state.ts";
|
import { State } from "./state.ts";
|
||||||
import { DEFAULT_APP_USER_AGENT, SPLATNET3_ENDPOINT } from "./constant.ts";
|
import { DEFAULT_APP_USER_AGENT, SPLATNET3_ENDPOINT } from "./constant.ts";
|
||||||
import { APIError } from "./APIError.ts";
|
import { APIError } from "./APIError.ts";
|
||||||
import {
|
import {
|
||||||
BattleListType,
|
BattleListType,
|
||||||
GraphQLResponse,
|
GraphQLResponse,
|
||||||
HistoryGroups,
|
HistoryGroups,
|
||||||
Queries,
|
Queries,
|
||||||
RespMap,
|
RespMap,
|
||||||
VarsMap,
|
VarsMap,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
async function request<Q extends Queries>(
|
async function request<Q extends Queries>(
|
||||||
state: State,
|
state: State,
|
||||||
query: Q,
|
query: Q,
|
||||||
...rest: VarsMap[Q]
|
...rest: VarsMap[Q]
|
||||||
): Promise<RespMap[Q]> {
|
): Promise<RespMap[Q]> {
|
||||||
const variables = rest?.[0] ?? {};
|
const variables = rest?.[0] ?? {};
|
||||||
const body = {
|
const body = {
|
||||||
extensions: {
|
extensions: {
|
||||||
persistedQuery: {
|
persistedQuery: {
|
||||||
sha256Hash: query,
|
sha256Hash: query,
|
||||||
version: 1,
|
version: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variables,
|
variables,
|
||||||
};
|
};
|
||||||
const resp = await fetch(SPLATNET3_ENDPOINT, {
|
const resp = await fetch(SPLATNET3_ENDPOINT, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${state.loginState?.bulletToken}`,
|
"Authorization": `Bearer ${state.loginState?.bulletToken}`,
|
||||||
"Accept-Language": state.userLang ?? "en-US",
|
"Accept-Language": state.userLang ?? "en-US",
|
||||||
"User-Agent": state.appUserAgent ?? DEFAULT_APP_USER_AGENT,
|
"User-Agent": state.appUserAgent ?? DEFAULT_APP_USER_AGENT,
|
||||||
"X-Web-View-Ver": await getWebViewVer(),
|
"X-Web-View-Ver": await getWebViewVer(),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
"Origin": "https://api.lp1.av5ja.srv.nintendo.net",
|
"Origin": "https://api.lp1.av5ja.srv.nintendo.net",
|
||||||
"X-Requested-With": "com.nintendo.znca",
|
"X-Requested-With": "com.nintendo.znca",
|
||||||
"Referer":
|
"Referer":
|
||||||
`https://api.lp1.av5ja.srv.nintendo.net/?lang=${state.userLang}&na_country=${state.userCountry}&na_lang=${state.userLang}`,
|
`https://api.lp1.av5ja.srv.nintendo.net/?lang=${state.userLang}&na_country=${state.userCountry}&na_lang=${state.userLang}`,
|
||||||
"Accept-Encoding": "gzip, deflate",
|
"Accept-Encoding": "gzip, deflate",
|
||||||
"Cookie": `_gtoken: ${state.loginState?.gToken}`,
|
"Cookie": `_gtoken: ${state.loginState?.gToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (resp.status !== 200) {
|
if (resp.status !== 200) {
|
||||||
throw new APIError({
|
throw new APIError({
|
||||||
response: resp,
|
response: resp,
|
||||||
message: "Splatnet3 request failed",
|
message: "Splatnet3 request failed",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const json: GraphQLResponse<RespMap[Q]> = await resp.json();
|
const json: GraphQLResponse<RespMap[Q]> = await resp.json();
|
||||||
if ("errors" in json) {
|
if ("errors" in json) {
|
||||||
throw new APIError({
|
throw new APIError({
|
||||||
response: resp,
|
response: resp,
|
||||||
json,
|
json,
|
||||||
message: `Splatnet3 request failed(${json.errors?.[0].message})`,
|
message: `Splatnet3 request failed(${json.errors?.[0].message})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return json.data;
|
return json.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ||
|
||||||
!state.loginState?.gToken
|
!state.loginState?.gToken
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request(state, Queries.HomeQuery);
|
await request(state, Queries.HomeQuery);
|
||||||
return true;
|
return true;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIdsFromGroups({ historyGroups }: { historyGroups: HistoryGroups }) {
|
function getIdsFromGroups({ historyGroups }: { historyGroups: HistoryGroups }) {
|
||||||
return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) =>
|
return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) =>
|
||||||
i.id
|
i.id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const BATTLE_LIST_TYPE_MAP: Record<
|
const BATTLE_LIST_TYPE_MAP: Record<
|
||||||
BattleListType,
|
BattleListType,
|
||||||
(state: State) => Promise<string[]>
|
(state: State) => Promise<string[]>
|
||||||
> = {
|
> = {
|
||||||
[BattleListType.Latest]: (state: State) =>
|
[BattleListType.Latest]: (state: State) =>
|
||||||
request(state, Queries.LatestBattleHistoriesQuery)
|
request(state, Queries.LatestBattleHistoriesQuery)
|
||||||
.then((r) => getIdsFromGroups(r.latestBattleHistories)),
|
.then((r) => getIdsFromGroups(r.latestBattleHistories)),
|
||||||
[BattleListType.Regular]: (state: State) =>
|
[BattleListType.Regular]: (state: State) =>
|
||||||
request(state, Queries.RegularBattleHistoriesQuery)
|
request(state, Queries.RegularBattleHistoriesQuery)
|
||||||
.then((r) => getIdsFromGroups(r.regularBattleHistories)),
|
.then((r) => getIdsFromGroups(r.regularBattleHistories)),
|
||||||
[BattleListType.Bankara]: (state: State) =>
|
[BattleListType.Bankara]: (state: State) =>
|
||||||
request(state, Queries.BankaraBattleHistoriesQuery)
|
request(state, Queries.BankaraBattleHistoriesQuery)
|
||||||
.then((r) => getIdsFromGroups(r.bankaraBattleHistories)),
|
.then((r) => getIdsFromGroups(r.bankaraBattleHistories)),
|
||||||
[BattleListType.Private]: (state: State) =>
|
[BattleListType.Private]: (state: State) =>
|
||||||
request(state, Queries.PrivateBattleHistoriesQuery)
|
request(state, Queries.PrivateBattleHistoriesQuery)
|
||||||
.then((r) => getIdsFromGroups(r.privateBattleHistories)),
|
.then((r) => getIdsFromGroups(r.privateBattleHistories)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getBattleList(
|
export async function getBattleList(
|
||||||
state: State,
|
state: State,
|
||||||
battleListType: BattleListType = BattleListType.Latest,
|
battleListType: BattleListType = BattleListType.Latest,
|
||||||
) {
|
) {
|
||||||
return await BATTLE_LIST_TYPE_MAP[battleListType](state);
|
return await BATTLE_LIST_TYPE_MAP[battleListType](state);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBattleDetail(
|
export function getBattleDetail(
|
||||||
state: State,
|
state: State,
|
||||||
id: string,
|
id: string,
|
||||||
) {
|
) {
|
||||||
return request(
|
return request(
|
||||||
state,
|
state,
|
||||||
Queries.VsHistoryDetailQuery,
|
Queries.VsHistoryDetailQuery,
|
||||||
{
|
{
|
||||||
vsResultId: id,
|
vsResultId: id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
state.ts
8
state.ts
|
|
@ -9,8 +9,16 @@ export type State = {
|
||||||
appUserAgent?: string;
|
appUserAgent?: string;
|
||||||
userLang?: string;
|
userLang?: string;
|
||||||
userCountry?: string;
|
userCountry?: string;
|
||||||
|
|
||||||
|
cacheDir: string;
|
||||||
|
|
||||||
|
// Exporter config
|
||||||
|
statInkApiKey?: string;
|
||||||
|
fileExportPath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_STATE: State = {
|
export const DEFAULT_STATE: State = {
|
||||||
|
cacheDir: "./cache",
|
||||||
fGen: "https://api.imink.app/f",
|
fGen: "https://api.imink.app/f",
|
||||||
|
fileExportPath: "./export",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
219
types.ts
219
types.ts
|
|
@ -1,96 +1,123 @@
|
||||||
export enum Queries {
|
export enum Queries {
|
||||||
HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3",
|
HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3",
|
||||||
LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00",
|
LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00",
|
||||||
RegularBattleHistoriesQuery = "f6e7e0277e03ff14edfef3b41f70cd33",
|
RegularBattleHistoriesQuery = "f6e7e0277e03ff14edfef3b41f70cd33",
|
||||||
BankaraBattleHistoriesQuery = "c1553ac75de0a3ea497cdbafaa93e95b",
|
BankaraBattleHistoriesQuery = "c1553ac75de0a3ea497cdbafaa93e95b",
|
||||||
PrivateBattleHistoriesQuery = "38e0529de8bc77189504d26c7a14e0b8",
|
PrivateBattleHistoriesQuery = "38e0529de8bc77189504d26c7a14e0b8",
|
||||||
VsHistoryDetailQuery = "2b085984f729cd51938fc069ceef784a",
|
VsHistoryDetailQuery = "2b085984f729cd51938fc069ceef784a",
|
||||||
CoopHistoryQuery = "817618ce39bcf5570f52a97d73301b30",
|
CoopHistoryQuery = "817618ce39bcf5570f52a97d73301b30",
|
||||||
CoopHistoryDetailQuery = "f3799a033f0a7ad4b1b396f9a3bafb1e",
|
CoopHistoryDetailQuery = "f3799a033f0a7ad4b1b396f9a3bafb1e",
|
||||||
}
|
}
|
||||||
export type VarsMap = {
|
export type VarsMap = {
|
||||||
[Queries.HomeQuery]: [];
|
[Queries.HomeQuery]: [];
|
||||||
[Queries.LatestBattleHistoriesQuery]: [];
|
[Queries.LatestBattleHistoriesQuery]: [];
|
||||||
[Queries.RegularBattleHistoriesQuery]: [];
|
[Queries.RegularBattleHistoriesQuery]: [];
|
||||||
[Queries.BankaraBattleHistoriesQuery]: [];
|
[Queries.BankaraBattleHistoriesQuery]: [];
|
||||||
[Queries.PrivateBattleHistoriesQuery]: [];
|
[Queries.PrivateBattleHistoriesQuery]: [];
|
||||||
[Queries.VsHistoryDetailQuery]: [{
|
[Queries.VsHistoryDetailQuery]: [{
|
||||||
vsResultId: string;
|
vsResultId: string;
|
||||||
}];
|
}];
|
||||||
[Queries.CoopHistoryQuery]: [];
|
[Queries.CoopHistoryQuery]: [];
|
||||||
[Queries.CoopHistoryDetailQuery]: [{
|
[Queries.CoopHistoryDetailQuery]: [{
|
||||||
coopHistoryDetailId: string;
|
coopHistoryDetailId: string;
|
||||||
}];
|
}];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Image = {
|
export type Image = {
|
||||||
url: string;
|
url: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
export type HistoryGroups = {
|
export type HistoryGroups = {
|
||||||
nodes: {
|
nodes: {
|
||||||
historyDetails: {
|
historyDetails: {
|
||||||
nodes: {
|
nodes: {
|
||||||
id: string;
|
id: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
export type RespMap = {
|
export type VsHistoryDetail = {
|
||||||
[Queries.HomeQuery]: {
|
id: string;
|
||||||
currentPlayer: {
|
vsRule: {
|
||||||
weapon: {
|
name: string;
|
||||||
image: Image;
|
id: string;
|
||||||
id: string;
|
rule: "TURF_WAR" | "AREA" | "LOFT" | "GOAL" | "CLAM" | "TRI_COLOR";
|
||||||
};
|
};
|
||||||
};
|
vsMode: {
|
||||||
banners: { image: Image; message: string; jumpTo: string }[];
|
id: string;
|
||||||
friends: {
|
mode: "REGULAR" | "BANKARA" | "PRIVATE" | "FEST";
|
||||||
nodes: {
|
};
|
||||||
id: number;
|
vsStage: {
|
||||||
nickname: string;
|
id: string;
|
||||||
userIcon: Image;
|
name: string;
|
||||||
}[];
|
image: Image;
|
||||||
totalCount: number;
|
};
|
||||||
};
|
playedTime: string; // 2021-01-01T00:00:00Z
|
||||||
footerMessages: unknown[];
|
};
|
||||||
};
|
|
||||||
[Queries.LatestBattleHistoriesQuery]: {
|
export type BattleExporter<D> = {
|
||||||
latestBattleHistories: {
|
name: string;
|
||||||
historyGroups: HistoryGroups;
|
getLatestBattleTime: () => Promise<Date>;
|
||||||
};
|
exportBattle: (detail: D) => Promise<void>;
|
||||||
};
|
};
|
||||||
[Queries.RegularBattleHistoriesQuery]: {
|
|
||||||
regularBattleHistories: {
|
export type RespMap = {
|
||||||
historyGroups: HistoryGroups;
|
[Queries.HomeQuery]: {
|
||||||
};
|
currentPlayer: {
|
||||||
};
|
weapon: {
|
||||||
[Queries.BankaraBattleHistoriesQuery]: {
|
image: Image;
|
||||||
bankaraBattleHistories: {
|
id: string;
|
||||||
historyGroups: HistoryGroups;
|
};
|
||||||
};
|
};
|
||||||
};
|
banners: { image: Image; message: string; jumpTo: string }[];
|
||||||
[Queries.PrivateBattleHistoriesQuery]: {
|
friends: {
|
||||||
privateBattleHistories: {
|
nodes: {
|
||||||
historyGroups: HistoryGroups;
|
id: number;
|
||||||
};
|
nickname: string;
|
||||||
};
|
userIcon: Image;
|
||||||
[Queries.VsHistoryDetailQuery]: Record<never, never>;
|
}[];
|
||||||
[Queries.CoopHistoryQuery]: Record<never, never>;
|
totalCount: number;
|
||||||
[Queries.CoopHistoryDetailQuery]: Record<never, never>;
|
};
|
||||||
};
|
footerMessages: unknown[];
|
||||||
export type GraphQLResponse<T> = {
|
};
|
||||||
data: T;
|
[Queries.LatestBattleHistoriesQuery]: {
|
||||||
} | {
|
latestBattleHistories: {
|
||||||
errors: {
|
historyGroups: HistoryGroups;
|
||||||
message: string;
|
};
|
||||||
}[];
|
};
|
||||||
};
|
[Queries.RegularBattleHistoriesQuery]: {
|
||||||
|
regularBattleHistories: {
|
||||||
export enum BattleListType {
|
historyGroups: HistoryGroups;
|
||||||
Latest,
|
};
|
||||||
Regular,
|
};
|
||||||
Bankara,
|
[Queries.BankaraBattleHistoriesQuery]: {
|
||||||
Private,
|
bankaraBattleHistories: {
|
||||||
}
|
historyGroups: HistoryGroups;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
[Queries.PrivateBattleHistoriesQuery]: {
|
||||||
|
privateBattleHistories: {
|
||||||
|
historyGroups: HistoryGroups;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
[Queries.VsHistoryDetailQuery]: {
|
||||||
|
vsHistoryDetail: VsHistoryDetail;
|
||||||
|
};
|
||||||
|
[Queries.CoopHistoryQuery]: Record<never, never>;
|
||||||
|
[Queries.CoopHistoryDetailQuery]: Record<never, never>;
|
||||||
|
};
|
||||||
|
export type GraphQLResponse<T> = {
|
||||||
|
data: T;
|
||||||
|
} | {
|
||||||
|
errors: {
|
||||||
|
message: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum BattleListType {
|
||||||
|
Latest,
|
||||||
|
Regular,
|
||||||
|
Bankara,
|
||||||
|
Private,
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue