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 1. Install deno
2. Run 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 ## Credits

View File

@ -38,17 +38,20 @@ export class FileExporter implements BattleExporter<VsHistoryDetail> {
await Deno.writeTextFile(filepath, JSON.stringify(body)); await Deno.writeTextFile(filepath, JSON.stringify(body));
} }
async getLatestBattleTime() { async notExported(list: string[]): Promise<string[]> {
await Deno.mkdir(this.exportPath, { recursive: true }); const out: string[] = [];
const dirs: Deno.DirEntry[] = []; for (const id of list) {
for await (const i of Deno.readDir(this.exportPath)) dirs.push(i); 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); return out;
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));
} }
} }

View File

@ -1,8 +1,25 @@
// deno-lint-ignore-file no-unused-vars require-await import { S3SI_NAMESPACE, USERAGENT } from "../constant.ts";
import { USERAGENT } from "../constant.ts";
import { BattleExporter, VsHistoryDetail } from "../types.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. * Exporter to stat.ink.
@ -23,13 +40,56 @@ export class StatInkExporter implements BattleExporter<VsHistoryDetail> {
}; };
} }
async exportBattle(detail: 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",
});
}
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<Date> { async notExported(list: string[]): Promise<string[]> {
const uuids = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", { const uuid = await (await fetch("https://stat.ink/api/v3/s3s/uuid-list", {
headers: this.requestHeaders(), headers: this.requestHeaders(),
})).json(); })).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;
} }
} }

214
s3si.ts
View File

@ -1,12 +1,11 @@
import { getBulletToken, getGToken, loginManually } from "./iksm.ts"; import { getBulletToken, getGToken, loginManually } from "./iksm.ts";
import { APIError } from "./APIError.ts";
import { flags, MultiProgressBar, Mutex } from "./deps.ts"; import { flags, MultiProgressBar, Mutex } from "./deps.ts";
import { DEFAULT_STATE, State } from "./state.ts"; import { DEFAULT_STATE, State } from "./state.ts";
import { checkToken, getBattleDetail, getBattleList } from "./splatnet3.ts"; import { checkToken, getBattleDetail, getBattleList } from "./splatnet3.ts";
import { BattleExporter, VsHistoryDetail } from "./types.ts"; import { BattleExporter, VsHistoryDetail } from "./types.ts";
import { Cache, FileCache, MemoryCache } from "./cache.ts"; import { Cache, FileCache, MemoryCache } from "./cache.ts";
import { StatInkExporter } from "./exporter/stat.ink.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"; import { FileExporter } from "./exporter/file.ts";
type Opts = { type Opts = {
@ -149,79 +148,72 @@ Options:
: undefined; : undefined;
const exporters = await this.getExporters(); const exporters = await this.getExporters();
try { if (!this.state.loginState?.sessionToken) {
if (!this.state.loginState?.sessionToken) { const sessionToken = await loginManually();
const sessionToken = await loginManually();
await this.writeState({ await this.writeState({
...this.state, ...this.state,
loginState: { loginState: {
...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,
sessionToken, 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({ console.log("Checking token...");
bar, if (!await checkToken(this.state)) {
battleList, console.log("Token expired, refetch tokens.");
fetcher,
exporters, const { webServiceToken, userCountry, userLang } = await getGToken({
fApi: this.state.fGen,
sessionToken,
}); });
const allProgress: Record<string, Progress> = {}; const bulletToken = await getBulletToken({
const redraw = (name: string, progress: Progress) => { webServiceToken,
allProgress[name] = progress; userLang,
bar?.render( userCountry,
Object.entries(allProgress).map(([name, progress]) => ({ appUserAgent: this.state.appUserAgent,
completed: progress.current, });
total: progress.total,
text: name, await this.writeState({
})), ...this.state,
); loginState: {
}; ...this.state.loginState,
const stats: Record<string, number> = Object.fromEntries( gToken: webServiceToken,
exporters.map((e) => [e.name, 0]), 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<string, Progress> = {};
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<string, number> = Object.fromEntries(
exporters.map((e) => [e.name, 0]),
);
await Promise.all( await Promise.all(
exporters.map((e) => exporters.map((e) =>
showError(
this.exportBattleList({ this.exportBattleList({
fetcher, fetcher,
exporter: e, exporter: e,
@ -230,64 +222,15 @@ Options:
}) })
.then((count) => { .then((count) => {
stats[e.name] = count; stats[e.name] = count;
}) }),
.catch((err) => { )
console.error(`\nFailed to export ${e.name}:`, err); .catch((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) { console.log("\nDone.", stats);
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. * Export battle list.
@ -310,27 +253,14 @@ Options:
onStep?: (progress: Progress) => void; onStep?: (progress: Progress) => void;
}, },
): Promise<number> { ): Promise<number> {
const latestBattleTime = await exporter.getLatestBattleTime();
let toUpload = 0;
let exported = 0; let exported = 0;
for (const battleId of battleList) { onStep?.({
const battle = await fetcher.fetchBattle(battleId); current: 0,
const playedTime = new Date(battle.playedTime); total: 1,
});
// if battle is older than latest battle, break const workQueue = [...await exporter.notExported(battleList)].reverse();
if (playedTime <= latestBattleTime) {
break;
}
toUpload += 1;
}
const workQueue = battleList.slice(0, toUpload).reverse();
if (workQueue.length === 0) {
return 0;
}
const step = async (battle: string) => { const step = async (battle: string) => {
const detail = await fetcher.fetchBattle(battle); const detail = await fetcher.fetchBattle(battle);
@ -375,4 +305,4 @@ const app = new App({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
...parseArgs(Deno.args), ...parseArgs(Deno.args),
}); });
await app.run(); await showError(app.run());

View File

@ -58,7 +58,7 @@ export type VsHistoryDetail = {
export type BattleExporter<D> = { export type BattleExporter<D> = {
name: string; name: string;
getLatestBattleTime: () => Promise<Date>; notExported: (list: string[]) => Promise<string[]>;
exportBattle: (detail: D) => Promise<void>; exportBattle: (detail: D) => Promise<void>;
}; };
@ -121,3 +121,84 @@ export enum BattleListType {
Bankara, Bankara,
Private, 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"; import { base64, io } from "./deps.ts";
const stdinLines = io.readLines(Deno.stdin); const stdinLines = io.readLines(Deno.stdin);
@ -60,3 +61,22 @@ export function cache<F extends () => Promise<unknown>>(
return value as PromiseReturnType<F>; 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;
}
}