refactor: change interface, add stat.ink types

main
spacemeowx2 2022-10-21 06:50:25 +08:00
parent b73769618d
commit 3b79bc39dc
6 changed files with 256 additions and 162 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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