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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
214
s3si.ts
|
|
@ -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());
|
||||||
|
|
|
||||||
83
types.ts
83
types.ts
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
20
utils.ts
20
utils.ts
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue