diff --git a/README.md b/README.md index 5249ef8..44a41fa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Export your battles from SplatNet to stat.ink 1. Install deno 2. Run - `deno run --allow-net --allow-read --allow-write --allow-env https://raw.githubusercontent.com/spacemeowx2/s3si.ts/master/s3si.ts` + `deno run --allow-net --allow-read --allow-write --allow-env https://raw.githubusercontent.com/spacemeowx2/s3si.ts/main/s3si.ts` ## Credits diff --git a/exporter/file.ts b/exporter/file.ts index 2382780..1bbaf88 100644 --- a/exporter/file.ts +++ b/exporter/file.ts @@ -38,17 +38,20 @@ export class FileExporter implements BattleExporter { await Deno.writeTextFile(filepath, JSON.stringify(body)); } - async getLatestBattleTime() { - await Deno.mkdir(this.exportPath, { recursive: true }); + async notExported(list: string[]): Promise { + const out: string[] = []; - const dirs: Deno.DirEntry[] = []; - for await (const i of Deno.readDir(this.exportPath)) dirs.push(i); + for (const id of list) { + const filename = `${id}.json`; + const filepath = path.join(this.exportPath, filename); + const isFile = await Deno.stat(filepath).then((f) => f.isFile).catch(() => + false + ); + if (isFile) { + out.push(id); + } + } - 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)); + return out; } } diff --git a/exporter/stat.ink.ts b/exporter/stat.ink.ts index 6392f16..e5a75a3 100644 --- a/exporter/stat.ink.ts +++ b/exporter/stat.ink.ts @@ -1,8 +1,25 @@ -// deno-lint-ignore-file no-unused-vars require-await -import { USERAGENT } from "../constant.ts"; +import { S3SI_NAMESPACE, USERAGENT } from "../constant.ts"; import { BattleExporter, VsHistoryDetail } from "../types.ts"; +import { base64, msgpack, uuid } from "../deps.ts"; +import { APIError } from "../APIError.ts"; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const S3S_NAMESPACE = "b3a2dbf5-2c09-4792-b78c-00b548b70aeb"; + +/** + * generate s3s uuid + * + * @param id ID from SplatNet3 + * @returns id generated from s3s + */ +function s3sUuid(id: string): Promise { + const fullId = base64.decode(id); + const tsUuid = fullId.slice(fullId.length - 52, fullId.length); + return uuid.v5.generate(S3S_NAMESPACE, tsUuid); +} + +function battleId(id: string): Promise { + return uuid.v5.generate(S3SI_NAMESPACE, new TextEncoder().encode(id)); +} /** * Exporter to stat.ink. @@ -23,13 +40,56 @@ export class StatInkExporter implements BattleExporter { }; } async exportBattle(detail: VsHistoryDetail) { - await sleep(1000); + const body = { + test: "yes", + }; + + const resp = await fetch("https://stat.ink/api/v3/battle", { + method: "POST", + headers: { + ...this.requestHeaders(), + "Content-Type": "application/x-msgpack", + }, + body: msgpack.encode(body), + }); + + if (resp.status !== 200 && resp.status !== 201) { + throw new APIError({ + response: resp, + message: "Failed to export battle", + }); + } + + const json: { + error?: unknown; + } = await resp.json(); + + if (json.error) { + throw new APIError({ + response: resp, + message: "Failed to export battle", + json, + }); + } + + throw new Error("abort"); } - async getLatestBattleTime(): Promise { - const uuids = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", { + async notExported(list: string[]): Promise { + const uuid = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", { headers: this.requestHeaders(), })).json(); - console.log("\n\n uuid:", uuids); - throw new Error("Not implemented"); + + const out: string[] = []; + + for (const id of list) { + const s3sId = await s3sUuid(id); + const s3siId = await battleId(id); + + if (!uuid.includes(s3sId) && !uuid.includes(s3siId)) { + out.push(id); + } + } + + return out; } } diff --git a/s3si.ts b/s3si.ts index c66d515..3400968 100644 --- a/s3si.ts +++ b/s3si.ts @@ -1,12 +1,11 @@ import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; -import { APIError } from "./APIError.ts"; import { flags, MultiProgressBar, Mutex } from "./deps.ts"; import { DEFAULT_STATE, State } from "./state.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 { readline, showError } from "./utils.ts"; import { FileExporter } from "./exporter/file.ts"; type Opts = { @@ -149,79 +148,72 @@ Options: : undefined; const exporters = await this.getExporters(); - try { - if (!this.state.loginState?.sessionToken) { - const sessionToken = await loginManually(); + if (!this.state.loginState?.sessionToken) { + const sessionToken = await loginManually(); - 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."); - - const { webServiceToken, userCountry, userLang } = await getGToken({ - fApi: this.state.fGen, + await this.writeState({ + ...this.state, + loginState: { + ...this.state.loginState, sessionToken, - }); - - const bulletToken = await getBulletToken({ - webServiceToken, - userLang, - userCountry, - appUserAgent: this.state.appUserAgent, - }); - - await this.writeState({ - ...this.state, - loginState: { - ...this.state.loginState, - gToken: webServiceToken, - bulletToken, - }, - 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); + } + const sessionToken = this.state.loginState!.sessionToken!; - await this.prepareBattles({ - bar, - battleList, - fetcher, - exporters, + console.log("Checking token..."); + if (!await checkToken(this.state)) { + console.log("Token expired, refetch tokens."); + + const { webServiceToken, userCountry, userLang } = await getGToken({ + fApi: this.state.fGen, + sessionToken, }); - const allProgress: Record = {}; - 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, - })), - ); - }; - const stats: Record = Object.fromEntries( - exporters.map((e) => [e.name, 0]), + const bulletToken = await getBulletToken({ + webServiceToken, + userLang, + userCountry, + appUserAgent: this.state.appUserAgent, + }); + + await this.writeState({ + ...this.state, + loginState: { + ...this.state.loginState, + gToken: webServiceToken, + bulletToken, + }, + 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); + + const allProgress: Record = {}; + 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, + })), ); + }; + const stats: Record = Object.fromEntries( + exporters.map((e) => [e.name, 0]), + ); - await Promise.all( - exporters.map((e) => + await Promise.all( + exporters.map((e) => + showError( this.exportBattleList({ fetcher, exporter: e, @@ -230,64 +222,15 @@ Options: }) .then((count) => { stats[e.name] = count; - }) - .catch((err) => { - console.error(`\nFailed to export ${e.name}:`, err); - }) - ), - ); - - console.log("\nDone.", stats); - } catch (e) { - if (e instanceof APIError) { - console.error(`APIError: ${e.message}`, e.response, e.json); - } else { - console.error(e); - } - } - } - async prepareBattles({ - bar, - exporters, - battleList, - fetcher, - }: { - bar?: MultiProgressBar; - exporters: BattleExporter[]; - battleList: string[]; - fetcher: BattleFetcher; - }) { - let prepared = 0; - bar?.render([{ - text: "preparing", - completed: prepared, - total: battleList.length, - }]); - - const latestBattleTimes = await Promise.all( - exporters.map((e) => e.getLatestBattleTime()), - ); - const latestBattleTime = latestBattleTimes.reduce( - (a, b) => a > b ? b : a, - new Date(0), + }), + ) + .catch((err) => { + console.error(`\nFailed to export to ${e.name}:`, err); + }) + ), ); - for (const battleId of battleList) { - const battle = await fetcher.fetchBattle(battleId); - const playedTime = new Date(battle.playedTime); - - prepared += 1; - bar?.render([{ - text: "preparing", - completed: prepared, - total: battleList.length, - }]); - - // if battle is older than latest battle, break - if (playedTime <= latestBattleTime) { - break; - } - } + console.log("\nDone.", stats); } /** * Export battle list. @@ -310,27 +253,14 @@ Options: onStep?: (progress: Progress) => void; }, ): Promise { - const latestBattleTime = await exporter.getLatestBattleTime(); - let toUpload = 0; let exported = 0; - for (const battleId of battleList) { - const battle = await fetcher.fetchBattle(battleId); - const playedTime = new Date(battle.playedTime); + onStep?.({ + current: 0, + total: 1, + }); - // if battle is older than latest battle, break - if (playedTime <= latestBattleTime) { - break; - } - - toUpload += 1; - } - - const workQueue = battleList.slice(0, toUpload).reverse(); - - if (workQueue.length === 0) { - return 0; - } + const workQueue = [...await exporter.notExported(battleList)].reverse(); const step = async (battle: string) => { const detail = await fetcher.fetchBattle(battle); @@ -375,4 +305,4 @@ const app = new App({ ...DEFAULT_OPTS, ...parseArgs(Deno.args), }); -await app.run(); +await showError(app.run()); diff --git a/types.ts b/types.ts index f262a7c..8692ccb 100644 --- a/types.ts +++ b/types.ts @@ -58,7 +58,7 @@ export type VsHistoryDetail = { export type BattleExporter = { name: string; - getLatestBattleTime: () => Promise; + notExported: (list: string[]) => Promise; exportBattle: (detail: D) => Promise; }; @@ -121,3 +121,84 @@ export enum BattleListType { Bankara, Private, } + +export type StatInkPlayer = { + me: "yes" | "no"; + rank_in_team: number; + name: string; + number: string; + splashtag_title: string; + weapon: string; + inked: number; + kill: number; + assist: number; + kill_or_assist: number; + death: number; + special: number; + disconnected: "yes" | "no"; +}; + +export type StatInkPostBody = { + test: "yes" | "no"; + uuid: string; + lobby: + | "regular" + | "bankara_challenge" + | "bankara_open" + | "splatfest_challenge" + | "splatfest_open" + | "private"; + rule: "nawabari" | "area" | "hoko" | "yagura" | "asari"; + stage: string; + weapon: string; + result: "win" | "lose" | "draw" | "exempted_lose"; + knockout: "yes" | "no" | null; // for TW, set null or not sending + rank_in_team: 1 | 2 | 3 | 4; // position in scoreboard + kill: number; + assist: number; + kill_or_assist: number; // equals to kill + assist if you know them + death: number; + special: number; // use count + inked: number; // not including bonus + medals: string[]; // 0-3 elements + our_team_inked: number; // TW, not including bonus + their_team_inked: number; // TW, not including bonus + our_team_percent: number; // TW + their_team_percent: number; // TW + our_team_count: number; // Anarchy + their_team_count: number; // Anarchy + level_before: number; + level_after: number; + rank_before: string; // one of c- ... s+, lowercase only /^[abcs][+-]?$/ except s- + rank_before_s_plus: number; + rank_before_exp: number; + rank_after: string; + rank_after_s_plus: number; + rank_after_exp: number; + rank_exp_change: number; // Set rank_after_exp - rank_before_exp. It can be negative. Set only this value if you don't know their exact values. + rank_up_battle: "yes" | "no"; // Set "yes" if now "Rank-up Battle" mode. + challenge_win: number; // Win count for Anarchy (Series) If rank_up_battle is truthy("yes"), the value range is limited to [0, 3]. + challenge_lose: number; + fest_power: number; // Splatfest Power (Pro) + fest_dragon?: + | "10x" + | "decuple" + | "100x" + | "dragon" + | "333x" + | "double_dragon"; + clout_before: number; // Splatfest Clout, before the battle + clout_after: number; // Splatfest Clout, after the battle + clout_change: number; // Splatfest Clout, equals to clout_after - clout_before if you know them + cash_before?: number; + cash_after?: number; + our_team_players: StatInkPlayer[]; + their_team_players: StatInkPlayer[]; + + agent: string; + agent_version: string; + agent_variables: Record; + automated: "yes"; + start_at: number; // the battle starts at e.g. 1599577200 + end_at: number; +}; diff --git a/utils.ts b/utils.ts index a435078..debc1e7 100644 --- a/utils.ts +++ b/utils.ts @@ -1,3 +1,4 @@ +import { APIError } from "./APIError.ts"; import { base64, io } from "./deps.ts"; const stdinLines = io.readLines(Deno.stdin); @@ -60,3 +61,22 @@ export function cache Promise>( return value as PromiseReturnType; }; } + +export async function showError(p: Promise) { + try { + await p; + } catch (e) { + if (e instanceof APIError) { + console.error( + `\n\nAPIError: ${e.message}`, + "\nResponse: ", + e.response, + "\nBody: ", + e.json, + ); + } else { + console.error(e); + } + throw e; + } +}