feat: add file exporter and progress

main
spacemeowx2 2022-10-20 21:45:59 +08:00
parent 4d7b8ad33b
commit 52efc01cad
14 changed files with 618 additions and 274 deletions

2
.gitignore vendored
View File

@ -1,2 +1,4 @@
*.json *.json
.vscode/ .vscode/
export/
cache/

View File

@ -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;
} }
} }

62
cache.ts Normal file
View File

@ -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);
}
}

View File

@ -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";

View File

@ -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";

53
exporter/file.ts Normal file
View File

@ -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));
}
}

21
exporter/stat.ink.ts Normal file
View File

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

View File

@ -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
View File

@ -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;

View File

@ -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,
}, },
); );
} }

View File

View File

@ -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
View File

@ -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,
}

View File

@ -23,6 +23,7 @@ export async function readline() {
return line; return line;
} }
} }
throw new Error("EOF");
} }
type PromiseReturnType<T> = T extends () => Promise<infer R> ? R : never; type PromiseReturnType<T> = T extends () => Promise<infer R> ? R : never;