refactor: change interface, add stat.ink types
parent
b73769618d
commit
3b79bc39dc
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -38,17 +38,20 @@ export class FileExporter implements BattleExporter<VsHistoryDetail> {
|
|||
|
||||
await Deno.writeTextFile(filepath, JSON.stringify(body));
|
||||
}
|
||||
async getLatestBattleTime() {
|
||||
await Deno.mkdir(this.exportPath, { recursive: true });
|
||||
async notExported(list: string[]): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
|
||||
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)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps.reduce((a, b) => (a > b ? a : b), new Date(0));
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<string> {
|
||||
return uuid.v5.generate(S3SI_NAMESPACE, new TextEncoder().encode(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporter to stat.ink.
|
||||
|
|
@ -23,13 +40,56 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
|
|||
};
|
||||
}
|
||||
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",
|
||||
});
|
||||
}
|
||||
async getLatestBattleTime(): Promise<Date> {
|
||||
const uuids = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", {
|
||||
|
||||
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 notExported(list: string[]): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
s3si.ts
92
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,7 +148,6 @@ Options:
|
|||
: undefined;
|
||||
const exporters = await this.getExporters();
|
||||
|
||||
try {
|
||||
if (!this.state.loginState?.sessionToken) {
|
||||
const sessionToken = await loginManually();
|
||||
|
||||
|
|
@ -198,13 +196,6 @@ Options:
|
|||
console.log("Fetching battle list...");
|
||||
const battleList = await getBattleList(this.state);
|
||||
|
||||
await this.prepareBattles({
|
||||
bar,
|
||||
battleList,
|
||||
fetcher,
|
||||
exporters,
|
||||
});
|
||||
|
||||
const allProgress: Record<string, Progress> = {};
|
||||
const redraw = (name: string, progress: Progress) => {
|
||||
allProgress[name] = progress;
|
||||
|
|
@ -222,6 +213,7 @@ Options:
|
|||
|
||||
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.error(`\nFailed to export to ${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<VsHistoryDetail>[];
|
||||
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),
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Export battle list.
|
||||
|
|
@ -310,27 +253,14 @@ Options:
|
|||
onStep?: (progress: Progress) => void;
|
||||
},
|
||||
): Promise<number> {
|
||||
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());
|
||||
|
|
|
|||
83
types.ts
83
types.ts
|
|
@ -58,7 +58,7 @@ export type VsHistoryDetail = {
|
|||
|
||||
export type BattleExporter<D> = {
|
||||
name: string;
|
||||
getLatestBattleTime: () => Promise<Date>;
|
||||
notExported: (list: string[]) => Promise<string[]>;
|
||||
exportBattle: (detail: D) => Promise<void>;
|
||||
};
|
||||
|
||||
|
|
@ -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<string, string>;
|
||||
automated: "yes";
|
||||
start_at: number; // the battle starts at e.g. 1599577200
|
||||
end_at: number;
|
||||
};
|
||||
|
|
|
|||
20
utils.ts
20
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<F extends () => Promise<unknown>>(
|
|||
return value as PromiseReturnType<F>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function showError(p: Promise<void>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue