diff --git a/scripts/export-file-to-stat-ink.ts b/scripts/export-file-to-stat-ink.ts new file mode 100644 index 0000000..b406428 --- /dev/null +++ b/scripts/export-file-to-stat-ink.ts @@ -0,0 +1,151 @@ +/** + * Upload file exporter battles to stat.ink. + * Make sure you have already logged in. + */ +import { flags } from "../deps.ts"; +import { FileCache } from "../src/cache.ts"; +import { DEFAULT_ENV } from "../src/env.ts"; +import { FileExporter } from "../src/exporters/file.ts"; +import { StatInkExporter } from "../src/exporters/stat.ink.ts"; +import { GameFetcher } from "../src/GameFetcher.ts"; +import { loginManually } from "../src/iksm.ts"; +import { Splatnet3 } from "../src/splatnet3.ts"; +import { FileStateBackend, Profile } from "../src/state.ts"; +import { Game } from "../src/types.ts"; +import { parseHistoryDetailId } from "../src/utils.ts"; + +async function exportType( + { statInkExporter, fileExporter, type, gameFetcher }: { + statInkExporter: StatInkExporter; + fileExporter: FileExporter; + gameFetcher: GameFetcher; + type: Game["type"]; + }, +) { + const gameList = await fileExporter.exportedGames({ uid, type }); + + const workQueue = [ + ...await statInkExporter.notExported({ + type, + list: gameList.map((i) => i.id), + }), + ] + .reverse().map((id) => gameList.find((i) => i.id === id)!); + + console.log(`Exporting ${workQueue.length} ${type} games`); + + let exported = 0; + for (const { getContent } of workQueue) { + const detail = await getContent(); + let resultUrl: string | undefined; + try { + const { url } = await statInkExporter.exportGame(detail); + resultUrl = url; + } catch (e) { + console.log("Failed to export game", e); + // try to re-export using cached data + const cachedDetail = + (await gameFetcher.fetch(type, detail.detail.id)).detail; + const { detail: _, ...rest } = detail; + // @ts-ignore the type must be the same + const { url } = await statInkExporter.exportGame({ + ...rest, + detail: cachedDetail, + }); + resultUrl = url; + } + exported += 1; + if (resultUrl) { + console.log(`Exported ${resultUrl} (${exported}/${workQueue.length})`); + } + } +} + +const parseArgs = (args: string[]) => { + const parsed = flags.parse(args, { + string: ["profilePath", "type"], + alias: { + "help": "h", + "profilePath": ["p", "profile-path"], + }, + }); + + return parsed; +}; + +const opts = parseArgs(Deno.args); +if (opts.help) { + console.log( + `Usage: deno run -A ${Deno.mainModule} [options] + + Options: + --type Type of game to export. Can be vs, coop, or all. (default: coop) + --profile-path , -p Path to config file (default: ./profile.json) + --help Show this help message and exit`, + ); + Deno.exit(0); +} + +const env = DEFAULT_ENV; +const stateBackend = new FileStateBackend(opts.profilePath ?? "./profile.json"); +const profile = new Profile({ stateBackend, env }); +await profile.readState(); + +// for cache +const gameFetcher = new GameFetcher({ + cache: new FileCache(profile.state.cacheDir), + state: profile.state, +}); + +if (!profile.state.loginState?.sessionToken) { + const sessionToken = await loginManually(env); + + await profile.writeState({ + ...profile.state, + loginState: { + ...profile.state.loginState, + sessionToken, + }, + }); +} + +const splatnet = new Splatnet3({ profile, env }); + +console.log("Fetching uid..."); +const { latestBattleHistories: { historyGroups } } = await splatnet + .getLatestBattleHistoriesQuery(); + +const id = historyGroups.nodes?.[0].historyDetails.nodes?.[0].id; + +if (!id) { + console.log("No battle history found"); + Deno.exit(0); +} + +const { uid } = parseHistoryDetailId(id); + +const fileExporter = new FileExporter(profile.state.fileExportPath); +const statInkExporter = new StatInkExporter({ + statInkApiKey: profile.state.statInkApiKey!, + uploadMode: "Manual", + env, +}); +const type = (opts.type ?? "coop").replace("all", "vs,coop"); + +if (type.includes("vs")) { + await exportType({ + type: "VsInfo", + fileExporter, + statInkExporter, + gameFetcher, + }); +} + +if (type.includes("coop")) { + await exportType({ + type: "CoopInfo", + fileExporter, + statInkExporter, + gameFetcher, + }); +} diff --git a/src/GameFetcher.ts b/src/GameFetcher.ts index 0684d37..05eaae0 100644 --- a/src/GameFetcher.ts +++ b/src/GameFetcher.ts @@ -16,29 +16,36 @@ import { RankTracker } from "./RankTracker.ts"; /** * Fetch game and cache it. It also fetches bankara match challenge info. + * if splatnet is not given, it will use cache only */ export class GameFetcher { - splatnet: Splatnet3; - cache: Cache; - rankTracker: RankTracker; + private _splatnet?: Splatnet3; + private cache: Cache; + private rankTracker: RankTracker; - lock: Record = {}; - bankaraLock = new Mutex(); - bankaraHistory?: HistoryGroups["nodes"]; - coopLock = new Mutex(); - coopHistory?: CoopHistoryGroups["nodes"]; + private lock: Record = {}; + private bankaraLock = new Mutex(); + private bankaraHistory?: HistoryGroups["nodes"]; + private coopLock = new Mutex(); + private coopHistory?: CoopHistoryGroups["nodes"]; constructor( { cache = new MemoryCache(), splatnet, state }: { - splatnet: Splatnet3; + splatnet?: Splatnet3; state: State; cache?: Cache; }, ) { - this.splatnet = splatnet; + this._splatnet = splatnet; this.cache = cache; this.rankTracker = new RankTracker(state.rankState); } + private get splatnet() { + if (!this._splatnet) { + throw new Error("splatnet is not set"); + } + return this._splatnet; + } private getLock(id: string): Mutex { let cur = this.lock[id]; @@ -94,7 +101,7 @@ export class GameFetcher { }); } async getCoopMetaById(id: string): Promise> { - const coopHistory = await this.getCoopHistory(); + const coopHistory = this._splatnet ? await this.getCoopHistory() : []; const group = coopHistory.find((i) => i.historyDetails.nodes.some((i) => i.id === id) ); @@ -119,7 +126,7 @@ export class GameFetcher { } async getBattleMetaById(id: string): Promise> { const gid = await gameId(id); - const bankaraHistory = await this.getBankaraHistory(); + const bankaraHistory = this._splatnet ? await this.getBankaraHistory() : []; const gameIdMap = new Map(); for (const i of bankaraHistory) { @@ -175,7 +182,7 @@ export class GameFetcher { rankBeforeState: before ?? null, }; } - cacheDetail( + private cacheDetail( id: string, getter: () => Promise, ): Promise { @@ -204,7 +211,7 @@ export class GameFetcher { throw new Error(`Unknown game type: ${type}`); } } - async fetchBattle(id: string): Promise { + private async fetchBattle(id: string): Promise { const detail = await this.cacheDetail( id, () => this.splatnet.getBattleDetail(id).then((r) => r.vsHistoryDetail), @@ -218,7 +225,7 @@ export class GameFetcher { return game; } - async fetchCoop(id: string): Promise { + private async fetchCoop(id: string): Promise { const detail = await this.cacheDetail( id, () => this.splatnet.getCoopDetail(id).then((r) => r.coopHistoryDetail), diff --git a/src/exporters/file.ts b/src/exporters/file.ts index 5803a02..ea6f3c7 100644 --- a/src/exporters/file.ts +++ b/src/exporters/file.ts @@ -1,4 +1,4 @@ -import { CoopInfo, GameExporter, VsInfo } from "../types.ts"; +import { CoopInfo, Game, GameExporter, VsInfo } from "../types.ts"; import { path } from "../../deps.ts"; import { NSOAPP_VERSION, S3SI_VERSION } from "../constant.ts"; import { parseHistoryDetailId, urlSimplify } from "../utils.ts"; @@ -37,7 +37,53 @@ export class FileExporter implements GameExporter { return `${uid}_${timestamp}Z.json`; } - async exportGame(info: VsInfo | CoopInfo) { + /** + * Get all exported files + */ + async exportedGames( + { uid, type }: { uid: string; type: Game["type"] }, + ): Promise<{ id: string; getContent: () => Promise }[]> { + const out: { id: string; filepath: string; timestamp: string }[] = []; + + for await (const entry of Deno.readDir(this.exportPath)) { + const filename = entry.name; + const [fileUid, timestamp] = filename.split("_", 2); + if (!entry.isFile || fileUid !== uid) { + continue; + } + + const filepath = path.join(this.exportPath, filename); + const content = await Deno.readTextFile(filepath); + const body = JSON.parse(content) as FileExporterType; + + if (body.type === "VS" && type === "VsInfo") { + out.push({ + id: body.data.detail.id, + filepath, + timestamp, + }); + } else if (body.type === "COOP" && type === "CoopInfo") { + out.push({ + id: body.data.detail.id, + filepath, + timestamp, + }); + } + } + + return out.sort((a, b) => b.timestamp.localeCompare(a.timestamp)).map(( + { id, filepath }, + ) => ({ + id, + getContent: async () => { + const content = await Deno.readTextFile(filepath); + const body = JSON.parse(content) as FileExporterType; + + return body.data; + }, + })); + } + async exportGame(info: Game) { await Deno.mkdir(this.exportPath, { recursive: true }); const filename = this.getFilenameById(info.detail.id); diff --git a/src/exporters/stat.ink.ts b/src/exporters/stat.ink.ts index 831584f..345678a 100644 --- a/src/exporters/stat.ink.ts +++ b/src/exporters/stat.ink.ts @@ -29,7 +29,13 @@ import { } from "../types.ts"; import { msgpack, Mutex } from "../../deps.ts"; import { APIError } from "../APIError.ts"; -import { b64Number, gameId, nonNullable, s3siGameId } from "../utils.ts"; +import { + b64Number, + gameId, + nonNullable, + s3siGameId, + urlSimplify, +} from "../utils.ts"; import { Env } from "../env.ts"; import { KEY_DICT } from "../dict/stat.ink.ts"; @@ -458,9 +464,17 @@ export class StatInkExporter implements GameExporter { return result; } isRandomWeapon(image: Image | null): boolean { - return (image?.url.includes( - "473fffb2442075078d8bb7125744905abdeae651b6a5b7453ae295582e45f7d1", - )) ?? false; + const RANDOM_WEAPON_FILENAME = + "473fffb2442075078d8bb7125744905abdeae651b6a5b7453ae295582e45f7d1"; + // file exporter will replace url to { pathname: string } | string + const url = image?.url as ReturnType | undefined | null; + if (typeof url === "string") { + return url.includes(RANDOM_WEAPON_FILENAME); + } else if (url === undefined || url === null) { + return false; + } else { + return url.pathname.includes(RANDOM_WEAPON_FILENAME); + } } async mapCoopWeapon( { name, image }: { name: string; image: Image | null }, diff --git a/src/types.ts b/src/types.ts index 9132548..53860f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,7 +31,7 @@ export type VarsMap = { }; export type Image = { - url: string; + url?: string; width?: number; height?: number; };