diff --git a/.gitignore b/.gitignore index a62cf7a..e9ccc16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.json .vscode/ +export/ +cache/ diff --git a/APIError.ts b/APIError.ts index c7cc1a5..7f71505 100644 --- a/APIError.ts +++ b/APIError.ts @@ -1,14 +1,14 @@ -export class APIError extends Error { - response: Response; - json: unknown; - constructor( - { response, message }: { - response: Response; - json?: unknown; - message?: string; - }, - ) { - super(message); - this.response = response; - } -} +export class APIError extends Error { + response: Response; + json: unknown; + constructor( + { response, message }: { + response: Response; + json?: unknown; + message?: string; + }, + ) { + super(message); + this.response = response; + } +} diff --git a/cache.ts b/cache.ts new file mode 100644 index 0000000..f445d06 --- /dev/null +++ b/cache.ts @@ -0,0 +1,62 @@ +// deno-lint-ignore-file require-await +import { path } from "./deps.ts"; + +export type Cache = { + read: (key: string) => Promise; + write: (key: string, value: T) => Promise; +}; + +export class MemoryCache implements Cache { + private cache: Record = {}; + + async read(key: string): Promise { + return this.cache[key] as T; + } + + async write(key: string, value: T): Promise { + 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 { + 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(key: string): Promise { + 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(key: string, value: T): Promise { + 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); + } +} diff --git a/constant.ts b/constant.ts index 4dc1904..8beeec1 100644 --- a/constant.ts +++ b/constant.ts @@ -1,11 +1,11 @@ -export const S3SI_VERSION = "0.1.0"; -export const NSOAPP_VERSION = "2.3.1"; -export const USERAGENT = `s3si.ts/${S3SI_VERSION}`; -export const DEFAULT_APP_USER_AGENT = - "Mozilla/5.0 (Linux; Android 11; Pixel 5) " + - "AppleWebKit/537.36 (KHTML, like Gecko) " + - "Chrome/94.0.4606.61 Mobile Safari/537.36"; -export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net"; -export const SPLATNET3_ENDPOINT = - "https://api.lp1.av5ja.srv.nintendo.net/api/graphql"; -export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1"; +export const S3SI_VERSION = "0.1.0"; +export const NSOAPP_VERSION = "2.3.1"; +export const USERAGENT = `s3si.ts/${S3SI_VERSION}`; +export const DEFAULT_APP_USER_AGENT = + "Mozilla/5.0 (Linux; Android 11; Pixel 5) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/94.0.4606.61 Mobile Safari/537.36"; +export const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net"; +export const SPLATNET3_ENDPOINT = + "https://api.lp1.av5ja.srv.nintendo.net/api/graphql"; +export const S3SI_NAMESPACE = "63941e1c-e32e-4b56-9a1d-f6fbe19ef6e1"; diff --git a/deps.ts b/deps.ts index 3d57a95..0b944a7 100644 --- a/deps.ts +++ b/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 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 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"; diff --git a/exporter/file.ts b/exporter/file.ts new file mode 100644 index 0000000..b28c601 --- /dev/null +++ b/exporter/file.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 { + 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)); + } +} diff --git a/exporter/stat.ink.ts b/exporter/stat.ink.ts new file mode 100644 index 0000000..7340314 --- /dev/null +++ b/exporter/stat.ink.ts @@ -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 { + 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(); + } +} diff --git a/iksm.ts b/iksm.ts index 502f9b7..0accaa3 100644 --- a/iksm.ts +++ b/iksm.ts @@ -58,7 +58,7 @@ export async function loginManually(): Promise { '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) { throw new Error("No login URL provided"); } diff --git a/s3si.ts b/s3si.ts index 29cd8e0..35a7d5f 100644 --- a/s3si.ts +++ b/s3si.ts @@ -1,44 +1,103 @@ import { getBulletToken, getGToken, loginManually } from "./iksm.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 { 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 = { - configPath: string; + profilePath: string; + exporter: string; + progress: boolean; help?: boolean; }; -const DEFAULT_OPTS = { - configPath: "./config.json", +const DEFAULT_OPTS: Opts = { + profilePath: "./profile.json", + exporter: "stat.ink", + progress: true, help: false, }; +/** + * Fetch battle and cache it. + */ +class BattleFetcher { + state: State; + cache: Cache; + lock: Record = {}; + + 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 { + const lock = this.getLock(id); + + return lock.use(async () => { + const cached = await this.cache.read(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 { state: State = DEFAULT_STATE; + constructor(public opts: Opts) { if (this.opts.help) { console.log( `Usage: deno run --allow-net --allow-read --allow-write ${Deno.mainModule} [options] Options: - --config-path Path to config file (default: ./config.json) - --help Show this help message and exit`, + --profile-path , -p Path to config file (default: ./profile.json) + --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); } } - async writeState() { + async writeState(newState: State) { + this.state = newState; const encoder = new TextEncoder(); 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.rename(swapPath, this.opts.configPath); + await Deno.rename(swapPath, this.opts.profilePath); } async readState() { const decoder = new TextDecoder(); try { - const data = await Deno.readFile(this.opts.configPath); + const data = await Deno.readFile(this.opts.profilePath); const json = JSON.parse(decoder.decode(data)); this.state = { ...DEFAULT_STATE, @@ -48,23 +107,60 @@ Options: console.warn( `Failed to read config file, create new config file. (${e})`, ); - await this.writeState(); + await this.writeState(DEFAULT_STATE); } } + async getExporters(): Promise[]> { + const exporters = this.opts.exporter.split(","); + const out: BattleExporter[] = []; + + 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() { await this.readState(); + const bar = this.opts.progress + ? new MultiProgressBar({ + title: "Export battles", + }) + : undefined; + const exporters = await this.getExporters(); + try { if (!this.state.loginState?.sessionToken) { 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)) { console.log("Token expired, refetch tokens."); @@ -80,22 +176,51 @@ Options: appUserAgent: this.state.appUserAgent, }); - this.state = { + await this.writeState({ ...this.state, loginState: { ...this.state.loginState, gToken: webServiceToken, bulletToken, }, - userLang, - userCountry, - }; - - await this.writeState(); + userLang: this.state.userLang ?? userLang, + userCountry: this.state.userCountry ?? userCountry, + }); } + const fetcher = new BattleFetcher({ + cache: new FileCache(this.state.cacheDir), + state: this.state, + }); + console.log("Fetching battle list..."); const battleList = await getBattleList(this.state); - console.log(battleList); + + const allProgress: Record = 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) { if (e instanceof APIError) { 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, + 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 parsed = flags.parse(args, { - string: ["configPath"], - boolean: ["help"], + string: ["profilePath", "exporter"], + boolean: ["help", "progress"], + negatable: ["progress"], alias: { "help": "h", - "configPath": ["c", "config-path"], + "profilePath": ["p", "profile-path"], + "exporter": ["e"], + "progress": ["n"], + }, + default: { + progress: true, }, }); return parsed; diff --git a/splatnet3.ts b/splatnet3.ts index c7d69cc..d9ee140 100644 --- a/splatnet3.ts +++ b/splatnet3.ts @@ -1,123 +1,123 @@ -import { getWebViewVer } from "./iksm.ts"; -import { State } from "./state.ts"; -import { DEFAULT_APP_USER_AGENT, SPLATNET3_ENDPOINT } from "./constant.ts"; -import { APIError } from "./APIError.ts"; -import { - BattleListType, - GraphQLResponse, - HistoryGroups, - Queries, - RespMap, - VarsMap, -} from "./types.ts"; - -async function request( - state: State, - query: Q, - ...rest: VarsMap[Q] -): Promise { - const variables = rest?.[0] ?? {}; - const body = { - extensions: { - persistedQuery: { - sha256Hash: query, - version: 1, - }, - }, - variables, - }; - const resp = await fetch(SPLATNET3_ENDPOINT, { - method: "POST", - headers: { - "Authorization": `Bearer ${state.loginState?.bulletToken}`, - "Accept-Language": state.userLang ?? "en-US", - "User-Agent": state.appUserAgent ?? DEFAULT_APP_USER_AGENT, - "X-Web-View-Ver": await getWebViewVer(), - "Content-Type": "application/json", - "Accept": "*/*", - "Origin": "https://api.lp1.av5ja.srv.nintendo.net", - "X-Requested-With": "com.nintendo.znca", - "Referer": - `https://api.lp1.av5ja.srv.nintendo.net/?lang=${state.userLang}&na_country=${state.userCountry}&na_lang=${state.userLang}`, - "Accept-Encoding": "gzip, deflate", - "Cookie": `_gtoken: ${state.loginState?.gToken}`, - }, - body: JSON.stringify(body), - }); - if (resp.status !== 200) { - throw new APIError({ - response: resp, - message: "Splatnet3 request failed", - }); - } - - const json: GraphQLResponse = await resp.json(); - if ("errors" in json) { - throw new APIError({ - response: resp, - json, - message: `Splatnet3 request failed(${json.errors?.[0].message})`, - }); - } - return json.data; -} - -export async function checkToken(state: State) { - if ( - !state.loginState?.sessionToken || !state.loginState?.bulletToken || - !state.loginState?.gToken - ) { - return false; - } - - try { - await request(state, Queries.HomeQuery); - return true; - } catch (_e) { - return false; - } -} - -function getIdsFromGroups({ historyGroups }: { historyGroups: HistoryGroups }) { - return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) => - i.id - ); -} - -const BATTLE_LIST_TYPE_MAP: Record< - BattleListType, - (state: State) => Promise -> = { - [BattleListType.Latest]: (state: State) => - request(state, Queries.LatestBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.latestBattleHistories)), - [BattleListType.Regular]: (state: State) => - request(state, Queries.RegularBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.regularBattleHistories)), - [BattleListType.Bankara]: (state: State) => - request(state, Queries.BankaraBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.bankaraBattleHistories)), - [BattleListType.Private]: (state: State) => - request(state, Queries.PrivateBattleHistoriesQuery) - .then((r) => getIdsFromGroups(r.privateBattleHistories)), -}; - -export async function getBattleList( - state: State, - battleListType: BattleListType = BattleListType.Latest, -) { - return await BATTLE_LIST_TYPE_MAP[battleListType](state); -} - -export function getBattleDetail( - state: State, - id: string, -) { - return request( - state, - Queries.VsHistoryDetailQuery, - { - vsResultId: id, - }, - ); -} +import { getWebViewVer } from "./iksm.ts"; +import { State } from "./state.ts"; +import { DEFAULT_APP_USER_AGENT, SPLATNET3_ENDPOINT } from "./constant.ts"; +import { APIError } from "./APIError.ts"; +import { + BattleListType, + GraphQLResponse, + HistoryGroups, + Queries, + RespMap, + VarsMap, +} from "./types.ts"; + +async function request( + state: State, + query: Q, + ...rest: VarsMap[Q] +): Promise { + const variables = rest?.[0] ?? {}; + const body = { + extensions: { + persistedQuery: { + sha256Hash: query, + version: 1, + }, + }, + variables, + }; + const resp = await fetch(SPLATNET3_ENDPOINT, { + method: "POST", + headers: { + "Authorization": `Bearer ${state.loginState?.bulletToken}`, + "Accept-Language": state.userLang ?? "en-US", + "User-Agent": state.appUserAgent ?? DEFAULT_APP_USER_AGENT, + "X-Web-View-Ver": await getWebViewVer(), + "Content-Type": "application/json", + "Accept": "*/*", + "Origin": "https://api.lp1.av5ja.srv.nintendo.net", + "X-Requested-With": "com.nintendo.znca", + "Referer": + `https://api.lp1.av5ja.srv.nintendo.net/?lang=${state.userLang}&na_country=${state.userCountry}&na_lang=${state.userLang}`, + "Accept-Encoding": "gzip, deflate", + "Cookie": `_gtoken: ${state.loginState?.gToken}`, + }, + body: JSON.stringify(body), + }); + if (resp.status !== 200) { + throw new APIError({ + response: resp, + message: "Splatnet3 request failed", + }); + } + + const json: GraphQLResponse = await resp.json(); + if ("errors" in json) { + throw new APIError({ + response: resp, + json, + message: `Splatnet3 request failed(${json.errors?.[0].message})`, + }); + } + return json.data; +} + +export async function checkToken(state: State) { + if ( + !state.loginState?.sessionToken || !state.loginState?.bulletToken || + !state.loginState?.gToken + ) { + return false; + } + + try { + await request(state, Queries.HomeQuery); + return true; + } catch (_e) { + return false; + } +} + +function getIdsFromGroups({ historyGroups }: { historyGroups: HistoryGroups }) { + return historyGroups.nodes.flatMap((i) => i.historyDetails.nodes).map((i) => + i.id + ); +} + +const BATTLE_LIST_TYPE_MAP: Record< + BattleListType, + (state: State) => Promise +> = { + [BattleListType.Latest]: (state: State) => + request(state, Queries.LatestBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.latestBattleHistories)), + [BattleListType.Regular]: (state: State) => + request(state, Queries.RegularBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.regularBattleHistories)), + [BattleListType.Bankara]: (state: State) => + request(state, Queries.BankaraBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.bankaraBattleHistories)), + [BattleListType.Private]: (state: State) => + request(state, Queries.PrivateBattleHistoriesQuery) + .then((r) => getIdsFromGroups(r.privateBattleHistories)), +}; + +export async function getBattleList( + state: State, + battleListType: BattleListType = BattleListType.Latest, +) { + return await BATTLE_LIST_TYPE_MAP[battleListType](state); +} + +export function getBattleDetail( + state: State, + id: string, +) { + return request( + state, + Queries.VsHistoryDetailQuery, + { + vsResultId: id, + }, + ); +} diff --git a/stat.ink.ts b/stat.ink.ts deleted file mode 100644 index e69de29..0000000 diff --git a/state.ts b/state.ts index 569c170..90570b7 100644 --- a/state.ts +++ b/state.ts @@ -9,8 +9,16 @@ export type State = { appUserAgent?: string; userLang?: string; userCountry?: string; + + cacheDir: string; + + // Exporter config + statInkApiKey?: string; + fileExportPath: string; }; export const DEFAULT_STATE: State = { + cacheDir: "./cache", fGen: "https://api.imink.app/f", + fileExportPath: "./export", }; diff --git a/types.ts b/types.ts index 77c08f9..f262a7c 100644 --- a/types.ts +++ b/types.ts @@ -1,96 +1,123 @@ -export enum Queries { - HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3", - LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00", - RegularBattleHistoriesQuery = "f6e7e0277e03ff14edfef3b41f70cd33", - BankaraBattleHistoriesQuery = "c1553ac75de0a3ea497cdbafaa93e95b", - PrivateBattleHistoriesQuery = "38e0529de8bc77189504d26c7a14e0b8", - VsHistoryDetailQuery = "2b085984f729cd51938fc069ceef784a", - CoopHistoryQuery = "817618ce39bcf5570f52a97d73301b30", - CoopHistoryDetailQuery = "f3799a033f0a7ad4b1b396f9a3bafb1e", -} -export type VarsMap = { - [Queries.HomeQuery]: []; - [Queries.LatestBattleHistoriesQuery]: []; - [Queries.RegularBattleHistoriesQuery]: []; - [Queries.BankaraBattleHistoriesQuery]: []; - [Queries.PrivateBattleHistoriesQuery]: []; - [Queries.VsHistoryDetailQuery]: [{ - vsResultId: string; - }]; - [Queries.CoopHistoryQuery]: []; - [Queries.CoopHistoryDetailQuery]: [{ - coopHistoryDetailId: string; - }]; -}; - -export type Image = { - url: string; - width?: number; - height?: number; -}; -export type HistoryGroups = { - nodes: { - historyDetails: { - nodes: { - id: string; - }[]; - }; - }[]; -}; -export type RespMap = { - [Queries.HomeQuery]: { - currentPlayer: { - weapon: { - image: Image; - id: string; - }; - }; - banners: { image: Image; message: string; jumpTo: string }[]; - friends: { - nodes: { - id: number; - nickname: string; - userIcon: Image; - }[]; - totalCount: number; - }; - footerMessages: unknown[]; - }; - [Queries.LatestBattleHistoriesQuery]: { - latestBattleHistories: { - historyGroups: HistoryGroups; - }; - }; - [Queries.RegularBattleHistoriesQuery]: { - regularBattleHistories: { - historyGroups: HistoryGroups; - }; - }; - [Queries.BankaraBattleHistoriesQuery]: { - bankaraBattleHistories: { - historyGroups: HistoryGroups; - }; - }; - [Queries.PrivateBattleHistoriesQuery]: { - privateBattleHistories: { - historyGroups: HistoryGroups; - }; - }; - [Queries.VsHistoryDetailQuery]: Record; - [Queries.CoopHistoryQuery]: Record; - [Queries.CoopHistoryDetailQuery]: Record; -}; -export type GraphQLResponse = { - data: T; -} | { - errors: { - message: string; - }[]; -}; - -export enum BattleListType { - Latest, - Regular, - Bankara, - Private, -} +export enum Queries { + HomeQuery = "dba47124d5ec3090c97ba17db5d2f4b3", + LatestBattleHistoriesQuery = "7d8b560e31617e981cf7c8aa1ca13a00", + RegularBattleHistoriesQuery = "f6e7e0277e03ff14edfef3b41f70cd33", + BankaraBattleHistoriesQuery = "c1553ac75de0a3ea497cdbafaa93e95b", + PrivateBattleHistoriesQuery = "38e0529de8bc77189504d26c7a14e0b8", + VsHistoryDetailQuery = "2b085984f729cd51938fc069ceef784a", + CoopHistoryQuery = "817618ce39bcf5570f52a97d73301b30", + CoopHistoryDetailQuery = "f3799a033f0a7ad4b1b396f9a3bafb1e", +} +export type VarsMap = { + [Queries.HomeQuery]: []; + [Queries.LatestBattleHistoriesQuery]: []; + [Queries.RegularBattleHistoriesQuery]: []; + [Queries.BankaraBattleHistoriesQuery]: []; + [Queries.PrivateBattleHistoriesQuery]: []; + [Queries.VsHistoryDetailQuery]: [{ + vsResultId: string; + }]; + [Queries.CoopHistoryQuery]: []; + [Queries.CoopHistoryDetailQuery]: [{ + coopHistoryDetailId: string; + }]; +}; + +export type Image = { + url: string; + width?: number; + height?: number; +}; +export type HistoryGroups = { + nodes: { + historyDetails: { + nodes: { + id: string; + }[]; + }; + }[]; +}; +export type VsHistoryDetail = { + id: string; + vsRule: { + name: string; + id: string; + rule: "TURF_WAR" | "AREA" | "LOFT" | "GOAL" | "CLAM" | "TRI_COLOR"; + }; + vsMode: { + id: string; + mode: "REGULAR" | "BANKARA" | "PRIVATE" | "FEST"; + }; + vsStage: { + id: string; + name: string; + image: Image; + }; + playedTime: string; // 2021-01-01T00:00:00Z +}; + +export type BattleExporter = { + name: string; + getLatestBattleTime: () => Promise; + exportBattle: (detail: D) => Promise; +}; + +export type RespMap = { + [Queries.HomeQuery]: { + currentPlayer: { + weapon: { + image: Image; + id: string; + }; + }; + banners: { image: Image; message: string; jumpTo: string }[]; + friends: { + nodes: { + id: number; + nickname: string; + userIcon: Image; + }[]; + totalCount: number; + }; + footerMessages: unknown[]; + }; + [Queries.LatestBattleHistoriesQuery]: { + latestBattleHistories: { + historyGroups: HistoryGroups; + }; + }; + [Queries.RegularBattleHistoriesQuery]: { + regularBattleHistories: { + historyGroups: HistoryGroups; + }; + }; + [Queries.BankaraBattleHistoriesQuery]: { + bankaraBattleHistories: { + historyGroups: HistoryGroups; + }; + }; + [Queries.PrivateBattleHistoriesQuery]: { + privateBattleHistories: { + historyGroups: HistoryGroups; + }; + }; + [Queries.VsHistoryDetailQuery]: { + vsHistoryDetail: VsHistoryDetail; + }; + [Queries.CoopHistoryQuery]: Record; + [Queries.CoopHistoryDetailQuery]: Record; +}; +export type GraphQLResponse = { + data: T; +} | { + errors: { + message: string; + }[]; +}; + +export enum BattleListType { + Latest, + Regular, + Bankara, + Private, +} diff --git a/utils.ts b/utils.ts index bc21937..a435078 100644 --- a/utils.ts +++ b/utils.ts @@ -23,6 +23,7 @@ export async function readline() { return line; } } + throw new Error("EOF"); } type PromiseReturnType = T extends () => Promise ? R : never;