mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-07-06 20:35:26 -05:00
Add commands to download SplatNet 2 data
This commit is contained in:
parent
0515a781f7
commit
86fb372722
681
src/api/splatnet2-types.ts
Normal file
681
src/api/splatnet2-types.ts
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
export interface WebServiceError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** GET /records */
|
||||
export interface Records {
|
||||
challenges: Challenges;
|
||||
festivals: unknown[];
|
||||
records: {
|
||||
recent_disconnect_count: number;
|
||||
recent_win_count: number;
|
||||
stage_stats: unknown;
|
||||
update_time: number;
|
||||
recent_lose_count: number;
|
||||
league_stats: {
|
||||
team: LeagueStats;
|
||||
pair: LeagueStats;
|
||||
};
|
||||
fes_results: unknown;
|
||||
unique_id: string;
|
||||
weapon_stats: Record<string | number, WeaponStats>;
|
||||
win_count: number;
|
||||
lose_count: number;
|
||||
total_paint_point_octa: number;
|
||||
player: Player;
|
||||
start_time: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Challenges {
|
||||
next_challenge: Challenge;
|
||||
archived_challenges_octa: OctaChallenge[];
|
||||
total_paint_point: number;
|
||||
rewards: ChallengeReward[];
|
||||
total_paint_point_octa: number;
|
||||
rewards_octa: ChallengeReward[];
|
||||
archived_challenges: Challenge[];
|
||||
next_challenge_octa: OctaChallenge;
|
||||
}
|
||||
interface Challenge {
|
||||
image: string;
|
||||
key: string;
|
||||
name: string;
|
||||
paint_points: number;
|
||||
}
|
||||
interface OctaChallenge {
|
||||
key: string;
|
||||
paint_points: number;
|
||||
url: null;
|
||||
url_message: null;
|
||||
name: string;
|
||||
is_last: boolean;
|
||||
image: string;
|
||||
}
|
||||
interface ChallengeReward {
|
||||
id: string;
|
||||
images: {
|
||||
thumbnail: string;
|
||||
url: string;
|
||||
}[];
|
||||
paint_points: number;
|
||||
}
|
||||
|
||||
interface LeagueStats {
|
||||
gold_count: number;
|
||||
bronze_count: number;
|
||||
silver_count: number;
|
||||
no_medal_count: number;
|
||||
}
|
||||
|
||||
interface WeaponStats {
|
||||
win_meter: number;
|
||||
total_paint_point: number;
|
||||
last_use_time: number;
|
||||
lose_count: number;
|
||||
weapon: Weapon;
|
||||
win_count: number;
|
||||
max_win_meter: number;
|
||||
}
|
||||
|
||||
interface Weapon {
|
||||
name: string;
|
||||
id: string;
|
||||
thumbnail: string;
|
||||
sub: SubWeapon;
|
||||
special: SpecialWeapon;
|
||||
image: string;
|
||||
}
|
||||
interface SubWeapon {
|
||||
image_b: string;
|
||||
name: string;
|
||||
id: string;
|
||||
image_a: string;
|
||||
}
|
||||
interface SpecialWeapon {
|
||||
image_a: string;
|
||||
id: string;
|
||||
name: string;
|
||||
image_b: string;
|
||||
}
|
||||
|
||||
interface Player {
|
||||
clothes: Gear & {kind: 'clothes'};
|
||||
star_rank: number;
|
||||
head_skills: Skills;
|
||||
nickname: string;
|
||||
shoes_skills: Skills;
|
||||
weapon: Weapon;
|
||||
player_rank: number;
|
||||
max_league_point_team: number;
|
||||
shoes: Gear & {kind: 'shoes'};
|
||||
clothes_skills: Skills;
|
||||
udemae_tower: Rank;
|
||||
udemae_clam: Rank;
|
||||
udemae_rainmaker: Rank;
|
||||
player_type: PlayerType;
|
||||
head: Gear & {kind: 'head'};
|
||||
max_league_point_pair: number;
|
||||
principal_id: string;
|
||||
udemae_zones: Rank;
|
||||
}
|
||||
|
||||
interface Gear {
|
||||
name: string;
|
||||
brand: Brand;
|
||||
id: string;
|
||||
rarity: number;
|
||||
image: string;
|
||||
kind: 'clothes' | 'shoes' | 'head';
|
||||
thumbnail: string;
|
||||
}
|
||||
interface Brand {
|
||||
image: string;
|
||||
frequent_skill?: Skill;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
interface Skills {
|
||||
subs: (Skill | null)[];
|
||||
main: Skill;
|
||||
}
|
||||
interface Skill {
|
||||
name: string;
|
||||
id: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
interface Rank {
|
||||
name: null;
|
||||
s_plus_number: null;
|
||||
is_x: boolean;
|
||||
number: number;
|
||||
is_number_reached: boolean;
|
||||
}
|
||||
|
||||
interface PlayerType {
|
||||
species: PlayerSpecies;
|
||||
style: PlayerStyle;
|
||||
}
|
||||
enum PlayerSpecies {
|
||||
INKLING = 'inklings',
|
||||
OCTOLING = 'octolings',
|
||||
}
|
||||
enum PlayerStyle {
|
||||
GIRL = 'girl',
|
||||
BOY = 'boy',
|
||||
}
|
||||
|
||||
/** GET /data/stages */
|
||||
export interface Stages {
|
||||
stages: Stage[];
|
||||
}
|
||||
|
||||
interface Stage {
|
||||
image: string;
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** GET /festivals/active */
|
||||
export interface ActiveFestivals {
|
||||
festivals: unknown[];
|
||||
}
|
||||
|
||||
/** GET /timeline */
|
||||
export interface Timeline {
|
||||
onlineshop: {
|
||||
importance: number;
|
||||
merchandise: ShopMerchandise;
|
||||
};
|
||||
udemae: {
|
||||
importance: number;
|
||||
};
|
||||
coop: {
|
||||
importance: number;
|
||||
schedule: CoopSchedule;
|
||||
reward_gear: CoopRewardGear;
|
||||
};
|
||||
stats: {
|
||||
importance: number;
|
||||
recents: MatchResults[];
|
||||
};
|
||||
challenge: {
|
||||
next_challenge: Challenge;
|
||||
total_paint_point: number;
|
||||
importance: number;
|
||||
last_archived_challenge: Challenge;
|
||||
};
|
||||
schedule: {
|
||||
schedules: {
|
||||
gachi: ScheduleItem[];
|
||||
league: ScheduleItem[];
|
||||
regular: ScheduleItem[];
|
||||
};
|
||||
importance: number;
|
||||
};
|
||||
weapon_availability: {
|
||||
availabilities: unknown[];
|
||||
importance: number;
|
||||
};
|
||||
fes_winners: {
|
||||
importance: number;
|
||||
};
|
||||
fes_event_match_result: {
|
||||
importance: number;
|
||||
};
|
||||
unique_id: string;
|
||||
download_contents: {
|
||||
is_available: boolean;
|
||||
importance: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShopMerchandise {
|
||||
skill: Skill;
|
||||
end_time: number;
|
||||
gear: Gear;
|
||||
id: string;
|
||||
price: number;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface CoopSchedule {
|
||||
weapons: CoopWeapon[];
|
||||
stage: {
|
||||
image: string;
|
||||
name: string;
|
||||
};
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
type CoopWeapon = CoopStandardWeapon | CoopSpecialWeapon;
|
||||
interface CoopStandardWeapon {
|
||||
weapon: Omit<Weapon, 'sub' | 'special'>;
|
||||
id: string;
|
||||
}
|
||||
interface CoopSpecialWeapon {
|
||||
id: string;
|
||||
coop_special_weapon: {
|
||||
image: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CoopRewardGear {
|
||||
available_time: number;
|
||||
gear: Gear;
|
||||
}
|
||||
|
||||
/** GET /nickname_and_icon?id={...} */
|
||||
export interface NicknameAndIcons {
|
||||
nickname_and_icons: NicknameAndIcon[];
|
||||
}
|
||||
|
||||
export interface NicknameAndIcon {
|
||||
thumbnail_url: string;
|
||||
nickname: string;
|
||||
nsa_id: string;
|
||||
}
|
||||
|
||||
/** GET /schedules */
|
||||
export interface Schedules {
|
||||
regular: ScheduleItem[];
|
||||
league: ScheduleItem[];
|
||||
gachi: ScheduleItem[];
|
||||
}
|
||||
|
||||
interface ScheduleItem {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
stage_b: Stage;
|
||||
rule: Rule;
|
||||
id: number;
|
||||
game_mode: GameMode;
|
||||
stage_a: Stage;
|
||||
}
|
||||
|
||||
/** GET /records/hero */
|
||||
export interface HeroRecords {
|
||||
stage_infos: StageInfo[];
|
||||
summary: {
|
||||
weapon_cleared_info: Record<string, boolean>;
|
||||
honor: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
clear_rate: number;
|
||||
};
|
||||
weapon_map: Record<string, HeroWeapon>;
|
||||
}
|
||||
|
||||
interface StageInfo {
|
||||
clear_weapons: Record<string, StageCleared>;
|
||||
stage: HeroStage;
|
||||
}
|
||||
interface StageCleared {
|
||||
clear_time: number;
|
||||
weapon_category: string;
|
||||
weapon_level: number;
|
||||
}
|
||||
interface HeroStage {
|
||||
id: string;
|
||||
is_boss: boolean;
|
||||
area: string;
|
||||
}
|
||||
interface HeroWeapon {
|
||||
category: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
/** GET /x_power_ranking/{season}/summary */
|
||||
export interface XPowerRankingSummary {
|
||||
clam_blitz: XPowerRankingRecords;
|
||||
rainmaker: XPowerRankingRecords;
|
||||
tower_control: XPowerRankingRecords;
|
||||
splat_zones: XPowerRankingRecords;
|
||||
}
|
||||
|
||||
interface XPowerRankingRecords {
|
||||
weapon_ranking: null;
|
||||
season_id: string;
|
||||
top_rankings_count: number;
|
||||
top_rankings: XPowerRankingRecordsRanking[];
|
||||
status: string;
|
||||
rule: Rule;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
my_ranking: null;
|
||||
}
|
||||
interface XPowerRankingRecordsRanking {
|
||||
name: string;
|
||||
principal_id: string;
|
||||
weapon: Weapon;
|
||||
rank: number;
|
||||
unique_id: string;
|
||||
x_power: number;
|
||||
rank_change: null;
|
||||
cheater: boolean;
|
||||
}
|
||||
|
||||
/** GET /festivals/pasts */
|
||||
export interface PastFestivals {
|
||||
festivals: Festival[];
|
||||
results: FestivalResults[];
|
||||
}
|
||||
|
||||
interface Festival {
|
||||
colors: {
|
||||
middle: InkColour;
|
||||
bravo: InkColour;
|
||||
alpha: InkColour;
|
||||
};
|
||||
festival_id: number;
|
||||
names: {
|
||||
bravo_short: string;
|
||||
bravo_long: string;
|
||||
alpha_short: string;
|
||||
alpha_long: string;
|
||||
};
|
||||
images: {
|
||||
bravo: string;
|
||||
alpha: string;
|
||||
panel: string;
|
||||
};
|
||||
times: {
|
||||
start: number;
|
||||
announce: number;
|
||||
end: number;
|
||||
result: number;
|
||||
};
|
||||
special_stage: {
|
||||
name: string;
|
||||
id: string;
|
||||
image: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InkColour {
|
||||
g: number;
|
||||
a: number;
|
||||
r: number;
|
||||
b: number;
|
||||
css_rgb: string;
|
||||
}
|
||||
|
||||
type FestivalResults = FestivalResults1 | FestivalResults2;
|
||||
interface FestivalResults2 {
|
||||
contribution_alpha: FestivalResultsContribution;
|
||||
contribution_bravo: FestivalResultsContribution;
|
||||
rates: {
|
||||
regular: FestivalResultsRates;
|
||||
challenge: FestivalResultsRates;
|
||||
vote: FestivalResultsRates;
|
||||
},
|
||||
festival_version: 2;
|
||||
summary: {
|
||||
regular: number;
|
||||
challenge: number;
|
||||
total: number;
|
||||
vote: number;
|
||||
};
|
||||
festival_id: number;
|
||||
}
|
||||
interface FestivalResultsContribution {
|
||||
regular: number;
|
||||
challenge: number;
|
||||
}
|
||||
interface FestivalResultsRates {
|
||||
alpha: number;
|
||||
bravo: number;
|
||||
}
|
||||
interface FestivalResults1 {
|
||||
festival_id: number;
|
||||
summary: {
|
||||
team: number;
|
||||
vote: number;
|
||||
total: number;
|
||||
solo: number;
|
||||
},
|
||||
festival_version: 1;
|
||||
rates: {
|
||||
team: FestivalResultsRates;
|
||||
solo: FestivalResultsRates;
|
||||
vote: FestivalResultsRates;
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /league_match_ranking/{league}/ALL */
|
||||
export interface LeagueMatchRankings {
|
||||
start_time: number;
|
||||
league_type: {
|
||||
name: string;
|
||||
key: string;
|
||||
};
|
||||
league_ranking_region: {
|
||||
code: string;
|
||||
id: number;
|
||||
};
|
||||
rankings: LeagueMatchRanking[];
|
||||
league_id: string;
|
||||
}
|
||||
|
||||
interface LeagueMatchRanking {
|
||||
tag_members: LeagueTagMember[];
|
||||
point: number;
|
||||
tag_id: string;
|
||||
cheater: boolean;
|
||||
rank: number;
|
||||
}
|
||||
interface LeagueTagMember {
|
||||
weapon: Weapon;
|
||||
unique_id: string;
|
||||
principal_id: string;
|
||||
}
|
||||
|
||||
/** GET /results */
|
||||
export interface Results {
|
||||
results: MatchResults[];
|
||||
unique_id: string;
|
||||
summary: {
|
||||
kill_count_average: number;
|
||||
victory_count: number;
|
||||
count: number;
|
||||
defeat_count: number;
|
||||
special_count_average: number;
|
||||
victory_rate: number;
|
||||
death_count_average: number;
|
||||
assist_count_average: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface MatchResults {
|
||||
battle_number: string;
|
||||
my_team_percentage: number;
|
||||
type: string;
|
||||
start_time: number;
|
||||
win_meter: number;
|
||||
player_result: PlayerResult;
|
||||
rule: Rule;
|
||||
star_rank: number;
|
||||
stage: Stage;
|
||||
other_team_percentage: number;
|
||||
other_team_result: TeamResult;
|
||||
weapon_paint_point: number;
|
||||
player_rank: number;
|
||||
game_mode: GameMode;
|
||||
my_team_result: TeamResult;
|
||||
}
|
||||
interface PlayerResult {
|
||||
kill_count: number;
|
||||
death_count: number;
|
||||
player: Omit<Player, 'max_league_point_team' | 'udemae_tower' | 'udemae_clam' | 'udemae_rainmaker' | 'max_league_point_pair' | 'udemae_zones'>;
|
||||
special_count: number;
|
||||
assist_count: number;
|
||||
sort_score: number;
|
||||
game_paint_point: number;
|
||||
}
|
||||
interface Rule {
|
||||
multiline_name: string;
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
interface GameMode {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
interface TeamResult {
|
||||
key: 'victory' | 'defeat';
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** GET /results/1 */
|
||||
export interface Result extends MatchResults {
|
||||
other_team_members: PlayerResult[];
|
||||
my_team_members: PlayerResult[];
|
||||
}
|
||||
|
||||
/** GET /coop_results */
|
||||
export interface CoopResults {
|
||||
results: CoopResultsResult[];
|
||||
summary: {
|
||||
stats: CoopSummaryResult[];
|
||||
card: CoopSummaryCard;
|
||||
};
|
||||
reward_gear: Gear;
|
||||
}
|
||||
|
||||
interface CoopResultsResult {
|
||||
grade_point: number;
|
||||
job_score: number;
|
||||
my_result: CoopPlayerResult;
|
||||
grade: CoopGrade;
|
||||
job_rate: number;
|
||||
job_id: number;
|
||||
start_time: number;
|
||||
boss_counts: Record<string, CoopBossCount>;
|
||||
end_time: number;
|
||||
danger_rate: number;
|
||||
play_time: number;
|
||||
player_type: PlayerType;
|
||||
job_result: CoopJobResult;
|
||||
schedule: CoopSchedule;
|
||||
wave_details: CoopWave[];
|
||||
kuma_point: number;
|
||||
grade_point_delta: number;
|
||||
}
|
||||
|
||||
interface CoopPlayerResult {
|
||||
special: SpecialWeapon;
|
||||
name: string;
|
||||
help_count: number;
|
||||
golden_ikura_num: number;
|
||||
dead_count: number;
|
||||
pid: string;
|
||||
boss_kill_counts: Record<string, CoopBossCount>;
|
||||
weapon_list: CoopWeapon[];
|
||||
ikura_num: number;
|
||||
special_counts: number[];
|
||||
player_type: PlayerType;
|
||||
}
|
||||
interface CoopGrade {
|
||||
id: string;
|
||||
long_name: string;
|
||||
short_name: string;
|
||||
name: string;
|
||||
}
|
||||
interface CoopBossCount {
|
||||
boss: CoopBoss;
|
||||
count: number;
|
||||
}
|
||||
interface CoopBoss {
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
type CoopJobResult = CoopJobResultSuccess | CoopJobResultFailure;
|
||||
interface CoopJobResultSuccess {
|
||||
is_clear: true;
|
||||
failure_reason: null;
|
||||
failure_wave: null;
|
||||
}
|
||||
interface CoopJobResultFailure {
|
||||
failure_wave: number;
|
||||
failure_reason: string;
|
||||
is_clear: false;
|
||||
}
|
||||
interface CoopWave {
|
||||
ikura_num: number;
|
||||
event_type: CoopWaveType;
|
||||
golden_ikura_num: number;
|
||||
golden_ikura_pop_num: number;
|
||||
water_level: CoopWaveWaterLevel;
|
||||
quota_num: number;
|
||||
}
|
||||
interface CoopWaveType {
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
interface CoopWaveWaterLevel {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CoopSummaryResult {
|
||||
schedule: CoopSchedule;
|
||||
my_ikura_total: number;
|
||||
kuma_point_total: number;
|
||||
clear_num: number;
|
||||
end_time: number;
|
||||
job_num: number;
|
||||
team_golden_ikura_total: number;
|
||||
failure_counts: number[];
|
||||
grade: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
team_ikura_total: number;
|
||||
help_total: number;
|
||||
start_time: number;
|
||||
dead_total: number;
|
||||
grade_point: number;
|
||||
my_golden_ikura_total: number;
|
||||
}
|
||||
interface CoopSummaryCard {
|
||||
kuma_point_total: number;
|
||||
golden_ikura_total: number;
|
||||
help_total: number;
|
||||
ikura_total: number;
|
||||
kuma_point: number;
|
||||
job_num: number;
|
||||
}
|
||||
|
||||
/** GET /coop_results/1 */
|
||||
export interface CoopResult extends CoopResultsResult {
|
||||
other_results: CoopPlayerResult[];
|
||||
}
|
||||
|
||||
/** GET /coop_schedules */
|
||||
export interface CoopSchedules {
|
||||
details: CoopSchedule[];
|
||||
schedules: CoopScheduleTimes[];
|
||||
}
|
||||
|
||||
interface CoopScheduleTimes {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
}
|
||||
|
||||
/** GET /onlineshop/merchandises */
|
||||
export interface ShopMerchandises {
|
||||
merchandises: ShopMerchandise[];
|
||||
ordered_info: null;
|
||||
}
|
||||
|
||||
/** POST /share/profile, POST /share/results/summary, POST /share/results/1 */
|
||||
export interface ShareResponse {
|
||||
url: string;
|
||||
hashtags: string[];
|
||||
text: string;
|
||||
}
|
||||
333
src/api/splatnet2.ts
Normal file
333
src/api/splatnet2.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
import fetch from 'node-fetch';
|
||||
import createDebug from 'debug';
|
||||
import { v4 as uuidgen } from 'uuid';
|
||||
import { WebServiceToken } from './znc-types.js';
|
||||
import { NintendoAccountUser } from './na.js';
|
||||
import { ErrorResponse } from './util.js';
|
||||
import ZncApi from './znc.js';
|
||||
import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingSummary } from './splatnet2-types.js';
|
||||
|
||||
const debug = createDebug('api:splatnet2');
|
||||
|
||||
export const SPLATNET2_WEBSERVICE_ID = '5741031244955648';
|
||||
export const SPLATNET2_WEBSERVICE_URL = 'https://app.splatoon2.nintendo.net/';
|
||||
export const SPLATNET2_WEBSERVICE_USERAGENT = 'Mozilla/5.0 (Linux; Android 8.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36';
|
||||
|
||||
const SPLATNET2_URL = SPLATNET2_WEBSERVICE_URL + 'api';
|
||||
|
||||
const XPOWERRANKING_SEASON = /^(\d{2})(\d{2})01T00_(\d{2})(\d{2})01T00$/;
|
||||
const LEAGUE_ID = /^(\d{2})(\d{2})(\d{2})(\d{2})(T|P)$/;
|
||||
|
||||
export interface SavedIksmSessionToken {
|
||||
webserviceToken: WebServiceToken;
|
||||
url: string;
|
||||
cookies: string;
|
||||
|
||||
iksm_session: string;
|
||||
expires_at: number;
|
||||
useragent: string;
|
||||
}
|
||||
|
||||
export default class SplatNet2Api {
|
||||
constructor(
|
||||
public iksm_session: string,
|
||||
public useragent: string
|
||||
) {}
|
||||
|
||||
async fetch<T = unknown>(url: string, method = 'GET', body?: string | FormData, headers?: object) {
|
||||
const response = await fetch(SPLATNET2_URL + url, {
|
||||
method: method,
|
||||
headers: Object.assign({
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'User-Agent': this.useragent,
|
||||
'Cookie': 'iksm_session=' + encodeURIComponent(this.iksm_session),
|
||||
'dnt': '1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-GB,en-US;q=0.8',
|
||||
'X-Requested-With': 'com.nintendo.znca',
|
||||
}, headers),
|
||||
body: body,
|
||||
});
|
||||
|
||||
debug('fetch %s %s, response %s', method, url, response.status);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ?
|
||||
await response.json() : await response.text();
|
||||
|
||||
throw new ErrorResponse('[splatnet2] Unknown error', response, data);
|
||||
}
|
||||
|
||||
const data = await response.json() as T | WebServiceError;
|
||||
|
||||
if ('code' in data) {
|
||||
throw new ErrorResponse('[splatnet2] ' + data.message, response, data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getRecords() {
|
||||
return this.fetch<Records>('/records');
|
||||
}
|
||||
|
||||
async getStages() {
|
||||
return this.fetch<Stages>('/data/stages');
|
||||
}
|
||||
|
||||
async getActiveFestivals() {
|
||||
return this.fetch<ActiveFestivals>('/festivals/active');
|
||||
}
|
||||
|
||||
async getTimeline() {
|
||||
return this.fetch<Timeline>('/timeline');
|
||||
}
|
||||
|
||||
async getUserNicknameAndIcon(ids: string[]) {
|
||||
return this.fetch<NicknameAndIcons>('/nickname_and_icon?' +
|
||||
ids.map(id => 'id=' + encodeURIComponent(id)).join('&'));
|
||||
}
|
||||
|
||||
async getSchedules() {
|
||||
return this.fetch<Schedules>('/schedules');
|
||||
}
|
||||
|
||||
async getHeroRecords() {
|
||||
return this.fetch<HeroRecords>('/records/hero');
|
||||
}
|
||||
|
||||
async getXPowerRankingSummary(season: string | Date) {
|
||||
if (season instanceof Date) {
|
||||
season = toSeasonId(season.getUTCFullYear(), season.getUTCMonth() + 1);
|
||||
}
|
||||
|
||||
let match = season.match(/^(\d+)-(\d{2})$/);
|
||||
if (match) {
|
||||
season = toSeasonId(parseInt(match[1]), parseInt(match[2]));
|
||||
}
|
||||
|
||||
if (!season.match(XPOWERRANKING_SEASON)) {
|
||||
throw new Error('Invalid season ID');
|
||||
}
|
||||
|
||||
return this.fetch<XPowerRankingSummary>('/x_power_ranking/' + season + '/summary');
|
||||
}
|
||||
|
||||
async getXPowerRankingLeaderboard(season: string | Date, rule: XPowerRankingRule, page: number = 1) {
|
||||
if (season instanceof Date) {
|
||||
season = toSeasonId(season.getUTCFullYear(), season.getUTCMonth() + 1);
|
||||
}
|
||||
|
||||
let match = season.match(/^(\d+)-(\d{2})$/);
|
||||
if (match) {
|
||||
season = toSeasonId(parseInt(match[1]), parseInt(match[2]));
|
||||
}
|
||||
|
||||
if (!season.match(XPOWERRANKING_SEASON)) {
|
||||
throw new Error('Invalid season ID');
|
||||
}
|
||||
|
||||
return this.fetch<unknown>('/x_power_ranking/' + season + '/' + rule + '?page=' + page);
|
||||
}
|
||||
|
||||
async getPastFestivals() {
|
||||
return this.fetch<PastFestivals>('/festivals/pasts');
|
||||
}
|
||||
|
||||
async getLeagueMatchRanking(id: string, region: LeagueRegion) {
|
||||
if (!id.match(LEAGUE_ID)) {
|
||||
throw new Error('Invalid league ID');
|
||||
}
|
||||
|
||||
return this.fetch<PastFestivals>('/league_match_ranking/' + id + '/' + region);
|
||||
}
|
||||
|
||||
async getResults() {
|
||||
return this.fetch<Results>('/results');
|
||||
}
|
||||
|
||||
async getResult(id: string | number) {
|
||||
return this.fetch<Result>('/results/' + id);
|
||||
}
|
||||
|
||||
async getCoopResults() {
|
||||
return this.fetch<CoopResults>('/coop_results');
|
||||
}
|
||||
|
||||
async getCoopResult(id: number) {
|
||||
return this.fetch<CoopResult>('/coop_results/' + id);
|
||||
}
|
||||
|
||||
async getCoopSchedules() {
|
||||
return this.fetch<CoopSchedules>('/coop_schedules');
|
||||
}
|
||||
|
||||
async getShopMerchandises() {
|
||||
return this.fetch<ShopMerchandises>('/onlineshop/merchandises');
|
||||
}
|
||||
|
||||
async shareProfile(stage: string, colour: ShareColour) {
|
||||
const boundary = uuidgen();
|
||||
|
||||
const data = `--${boundary}
|
||||
Content-Disposition: form-data; name="stage"
|
||||
|
||||
${stage}
|
||||
--${boundary}
|
||||
Content-Disposition: form-data; name="color"
|
||||
|
||||
${colour}
|
||||
--${boundary}--
|
||||
`.replace(/\r?\n/g, '\r\n');
|
||||
|
||||
return this.fetch<ShareResponse>('/share/profile', 'POST', data, {
|
||||
'Content-Type': 'multipart/form-data; boundary=' + boundary,
|
||||
'Referer': 'https://app.splatoon2.nintendo.net/home',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://app.splatoon2.nintendo.net',
|
||||
});
|
||||
}
|
||||
|
||||
async shareResultsSummary() {
|
||||
return this.fetch<ShareResponse>('/share/results/summary', 'POST', '', {
|
||||
'Referer': 'https://app.splatoon2.nintendo.net/results',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://app.splatoon2.nintendo.net',
|
||||
});
|
||||
}
|
||||
|
||||
async shareResult(id: string | number) {
|
||||
return this.fetch<ShareResponse>('/share/results/' + id, 'POST', '', {
|
||||
'Referer': 'https://app.splatoon2.nintendo.net/results/' + id,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': 'https://app.splatoon2.nintendo.net',
|
||||
});
|
||||
}
|
||||
|
||||
static async createWithZnc(nso: ZncApi, user: NintendoAccountUser) {
|
||||
const data = await this.loginWithZnc(nso, user);
|
||||
|
||||
return {
|
||||
splatnet: new this(data.iksm_session, data.useragent),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
static async loginWithZnc(nso: ZncApi, user: NintendoAccountUser) {
|
||||
const webserviceToken = await nso.getWebServiceToken(SPLATNET2_WEBSERVICE_ID);
|
||||
|
||||
return this.loginWithWebServiceToken(webserviceToken.result, user);
|
||||
}
|
||||
|
||||
static async loginWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser): Promise<SavedIksmSessionToken> {
|
||||
const url = new URL(SPLATNET2_WEBSERVICE_URL);
|
||||
url.search = new URLSearchParams({
|
||||
lang: user.language,
|
||||
na_country: user.country,
|
||||
na_lang: user.language,
|
||||
}).toString();
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
'Upgrade-Insecure-Requests': '1',
|
||||
'User-Agent': SPLATNET2_WEBSERVICE_USERAGENT,
|
||||
'x-gamewebtoken': webserviceToken.accessToken,
|
||||
'dnt': '1',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'en-GB,en-US;q=0.8',
|
||||
'X-Requested-With': 'com.nintendo.znca',
|
||||
},
|
||||
});
|
||||
|
||||
debug('fetch %s %s, response %s', 'GET', url, response.status);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new ErrorResponse('Unknown error', response);
|
||||
}
|
||||
|
||||
const cookies = response.headers.get('Set-Cookie');
|
||||
const match = cookies?.match(/\biksm_session=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i);
|
||||
|
||||
if (!match) {
|
||||
throw new ErrorResponse('Response didn\'t include iksm_session cookie', response);
|
||||
}
|
||||
|
||||
const iksm_session = decodeURIComponent(match[1]);
|
||||
// Nintendo sets the expires field to an invalid timestamp - browsers don't care but Data.parse does
|
||||
const expires = decodeURIComponent(match[8] || '')
|
||||
.replace(/(\b)(\d{1,2})-([a-z]{3})-(\d{4})(\b)/gi, '$1$2 $3 $4$5');
|
||||
|
||||
debug('iksm_session %s, expires %s', iksm_session, expires);
|
||||
|
||||
const expires_at = expires ? Date.parse(expires) : Date.now() + 24 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
webserviceToken,
|
||||
url: url.toString(),
|
||||
cookies: cookies!,
|
||||
|
||||
iksm_session,
|
||||
expires_at,
|
||||
useragent: SPLATNET2_WEBSERVICE_USERAGENT,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function toSeasonId(year: number, month: number) {
|
||||
const nextyear = month === 12 ? year + 1 : year;
|
||||
const nextmonth = month === 12 ? 1 : month + 1;
|
||||
|
||||
if (year < 2000) throw new Error('Invalid season ID');
|
||||
if (nextyear >= 2100) throw new Error('Invalid season ID');
|
||||
|
||||
return ('' + (year - 2000)).padStart(2, '0') +
|
||||
('' + month).padStart(2, '0') +
|
||||
'01T00_' +
|
||||
('' + (nextyear - 2000)).padStart(2, '0') +
|
||||
('' + nextmonth).padStart(2, '0') +
|
||||
'01T00';
|
||||
}
|
||||
|
||||
export enum XPowerRankingRule {
|
||||
SPLAT_ZONES = 'splat_zones',
|
||||
TOWER_CONTROL = 'tower_control',
|
||||
RAINMAKER = 'rainmaker',
|
||||
CLAM_BLITZ = 'clam_blitz',
|
||||
}
|
||||
|
||||
export function toLeagueId(date: Date, type: LeagueType) {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = date.getUTCMonth() + 1;
|
||||
const day = date.getUTCDate();
|
||||
const hour = Math.floor(date.getUTCHours() / 2) * 2;
|
||||
|
||||
if (year < 2000) throw new Error('Invalid league ID');
|
||||
if (year >= 2100) throw new Error('Invalid league ID');
|
||||
|
||||
return ('' + (year - 2000)).padStart(2, '0') +
|
||||
('' + month).padStart(2, '0') +
|
||||
('' + day).padStart(2, '0') +
|
||||
('' + hour).padStart(2, '0') +
|
||||
type;
|
||||
}
|
||||
|
||||
export enum LeagueType {
|
||||
TEAM = 'T',
|
||||
PAIR = 'P',
|
||||
}
|
||||
|
||||
export enum LeagueRegion {
|
||||
ALL_REGIONS = 'ALL',
|
||||
JAPAN = 'JP',
|
||||
NA_AU_NZ = 'US',
|
||||
EUROPE = 'EU',
|
||||
}
|
||||
|
||||
export enum ShareColour {
|
||||
PINK = 'pink',
|
||||
GREEN = 'green',
|
||||
YELLOW = 'yellow',
|
||||
PURPLE = 'purple',
|
||||
BLUE = 'blue',
|
||||
SUN_YELLOW = 'sun-yellow',
|
||||
}
|
||||
|
|
@ -73,6 +73,7 @@ export interface Game {
|
|||
imageUri: string;
|
||||
shopUri: string;
|
||||
totalPlayTime: number;
|
||||
/** 0 if never played before */
|
||||
firstPlayedAt: number;
|
||||
sysDescription: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,17 +72,17 @@ export default class ZncApi {
|
|||
return this.fetch<CurrentUser>('/v3/User/ShowSelf', 'POST', '{"parameter":{}}');
|
||||
}
|
||||
|
||||
async getWebServiceToken(id: string, nintendoAccountToken: string) {
|
||||
async getWebServiceToken(id: string) {
|
||||
const uuid = uuidgen();
|
||||
const timestamp = '' + Math.floor(Date.now() / 1000);
|
||||
|
||||
const data = process.env.ZNCA_API_URL ?
|
||||
await genfc(process.env.ZNCA_API_URL + '/f', nintendoAccountToken, timestamp, uuid, FlapgIid.APP) :
|
||||
await flapg(nintendoAccountToken, timestamp, uuid, FlapgIid.APP);
|
||||
await genfc(process.env.ZNCA_API_URL + '/f', this.token, timestamp, uuid, FlapgIid.APP) :
|
||||
await flapg(this.token, timestamp, uuid, FlapgIid.APP);
|
||||
|
||||
const req = {
|
||||
id,
|
||||
registrationToken: nintendoAccountToken,
|
||||
registrationToken: this.token,
|
||||
f: data.f,
|
||||
requestId: uuid,
|
||||
timestamp,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export * as users from './users.js';
|
||||
export * as nso from './nso.js';
|
||||
export * as splatnet2 from './splatnet2.js';
|
||||
export * as pctl from './pctl.js';
|
||||
export * as androidZncaApiServerFrida from './android-znca-api-server-frida.js';
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
|
||||
app.get('/api/znc/webservice/:id/token', nsoAuth, async (req, res) => {
|
||||
try {
|
||||
const response = await req.znc!.getWebServiceToken(req.params.id, req.zncAuth!.credential.accessToken);
|
||||
const response = await req.znc!.getWebServiceToken(req.params.id);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -175,88 +175,6 @@ class ZncDiscordPresence extends ZncNotifications {
|
|||
(online && 'name' in presence.game) ||
|
||||
(this.argv.showConsoleOnline && presence?.state === PresenceState.INACTIVE);
|
||||
|
||||
if (show_presence) {
|
||||
const fc = this.argv.friendCode === '' || this.argv.friendCode === '-' ? friendcode : this.forceFriendCode;
|
||||
const discordpresence = 'name' in presence.game ?
|
||||
getDiscordPresence(presence.game, fc) :
|
||||
getInactiveDiscordPresence(fc);
|
||||
|
||||
if (this.rpc && this.rpc.id !== discordpresence.id) {
|
||||
const client = this.rpc.client;
|
||||
this.rpc = null;
|
||||
await client.destroy();
|
||||
}
|
||||
|
||||
if (!this.rpc) {
|
||||
const client = new DiscordRPC.Client({transport: 'ipc'});
|
||||
let attempts = 0;
|
||||
let connected = false;
|
||||
|
||||
while (attempts < 10) {
|
||||
if (attempts === 0) debugDiscord('RPC connecting');
|
||||
else debugDiscord('RPC connecting, attempt %d', attempts + 1);
|
||||
|
||||
try {
|
||||
await client.connect(discordpresence.id);
|
||||
debugDiscord('RPC connected');
|
||||
connected = true;
|
||||
break;
|
||||
} catch (err) {}
|
||||
|
||||
attempts++;
|
||||
await new Promise(rs => setTimeout(rs, 5000));
|
||||
}
|
||||
|
||||
if (!connected) throw new Error('Failed to connect to Discord');
|
||||
|
||||
// @ts-expect-error
|
||||
client.transport.on('close', async () => {
|
||||
if (this.rpc?.client !== client) return;
|
||||
|
||||
console.warn('[discordrpc] RPC client disconnected, attempting to reconnect');
|
||||
debugDiscord('RPC client disconnected');
|
||||
let attempts = 0;
|
||||
let connected = false;
|
||||
|
||||
while (attempts < 10) {
|
||||
if (this.rpc?.client !== client) return;
|
||||
|
||||
debugDiscord('RPC reconnecting, attempt %d', attempts + 1);
|
||||
try {
|
||||
await client.connect(discordpresence.id);
|
||||
console.warn('[discordrpc] RPC reconnected');
|
||||
debugDiscord('RPC reconnected');
|
||||
connected = true;
|
||||
break;
|
||||
} catch (err) {}
|
||||
|
||||
attempts++;
|
||||
await new Promise(rs => setTimeout(rs, 5000));
|
||||
}
|
||||
|
||||
if (!connected) throw new Error('Failed to reconnect to Discord');
|
||||
|
||||
throw new Error('Discord disconnected');
|
||||
});
|
||||
|
||||
this.rpc = {client, id: discordpresence.id};
|
||||
}
|
||||
|
||||
if (discordpresence.title) {
|
||||
if (discordpresence.title !== this.title?.id) {
|
||||
this.title = {id: discordpresence.title, since: Date.now()};
|
||||
}
|
||||
|
||||
if (discordpresence.showTimestamp) {
|
||||
discordpresence.activity.startTimestamp = this.title.since;
|
||||
}
|
||||
} else {
|
||||
this.title = null;
|
||||
}
|
||||
|
||||
this.rpc.client.setActivity(discordpresence.activity);
|
||||
}
|
||||
|
||||
if (!presence || !show_presence) {
|
||||
if (this.rpc) {
|
||||
const client = this.rpc.client;
|
||||
|
|
@ -265,7 +183,86 @@ class ZncDiscordPresence extends ZncNotifications {
|
|||
}
|
||||
|
||||
this.title = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const fc = this.argv.friendCode === '' || this.argv.friendCode === '-' ? friendcode : this.forceFriendCode;
|
||||
const discordpresence = 'name' in presence.game ?
|
||||
getDiscordPresence(presence.game, fc) :
|
||||
getInactiveDiscordPresence(fc);
|
||||
|
||||
if (this.rpc && this.rpc.id !== discordpresence.id) {
|
||||
const client = this.rpc.client;
|
||||
this.rpc = null;
|
||||
await client.destroy();
|
||||
}
|
||||
|
||||
if (!this.rpc) {
|
||||
const client = new DiscordRPC.Client({transport: 'ipc'});
|
||||
let attempts = 0;
|
||||
let connected = false;
|
||||
|
||||
while (attempts < 10) {
|
||||
if (attempts === 0) debugDiscord('RPC connecting');
|
||||
else debugDiscord('RPC connecting, attempt %d', attempts + 1);
|
||||
|
||||
try {
|
||||
await client.connect(discordpresence.id);
|
||||
debugDiscord('RPC connected');
|
||||
connected = true;
|
||||
break;
|
||||
} catch (err) {}
|
||||
|
||||
attempts++;
|
||||
await new Promise(rs => setTimeout(rs, 5000));
|
||||
}
|
||||
|
||||
if (!connected) throw new Error('Failed to connect to Discord');
|
||||
|
||||
// @ts-expect-error
|
||||
client.transport.on('close', async () => {
|
||||
if (this.rpc?.client !== client) return;
|
||||
|
||||
debugDiscord('RPC client disconnected, attempting to reconnect');
|
||||
let attempts = 0;
|
||||
let connected = false;
|
||||
|
||||
while (attempts < 10) {
|
||||
if (this.rpc?.client !== client) return;
|
||||
|
||||
debugDiscord('RPC reconnecting, attempt %d', attempts + 1);
|
||||
try {
|
||||
await client.connect(discordpresence.id);
|
||||
debugDiscord('RPC reconnected');
|
||||
connected = true;
|
||||
break;
|
||||
} catch (err) {}
|
||||
|
||||
attempts++;
|
||||
await new Promise(rs => setTimeout(rs, 5000));
|
||||
}
|
||||
|
||||
if (!connected) throw new Error('Failed to reconnect to Discord');
|
||||
|
||||
throw new Error('Discord disconnected');
|
||||
});
|
||||
|
||||
this.rpc = {client, id: discordpresence.id};
|
||||
}
|
||||
|
||||
if (discordpresence.title) {
|
||||
if (discordpresence.title !== this.title?.id) {
|
||||
this.title = {id: discordpresence.title, since: Date.now()};
|
||||
}
|
||||
|
||||
if (discordpresence.showTimestamp) {
|
||||
discordpresence.activity.startTimestamp = this.title.since;
|
||||
}
|
||||
} else {
|
||||
this.title = null;
|
||||
}
|
||||
|
||||
this.rpc.client.setActivity(discordpresence.activity);
|
||||
}
|
||||
|
||||
async update() {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
throw new Error('Invalid web service');
|
||||
}
|
||||
|
||||
const webserviceToken = await nso.getWebServiceToken(argv.id, data.credential.accessToken);
|
||||
const webserviceToken = await nso.getWebServiceToken(argv.id);
|
||||
|
||||
// https://app.splatoon2.nintendo.net/?lang=en-GB&na_country=GB&na_lang=en-GB
|
||||
const url = new URL(webservice.uri);
|
||||
|
|
|
|||
|
|
@ -43,11 +43,19 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
|
||||
const devices = await moon.getDevices();
|
||||
|
||||
for (const device of argv.device ?? devices.items.map(d => d.deviceId)) {
|
||||
console.warn('Downloading summaries for device %s', device);
|
||||
for (const id of argv.device ?? []) {
|
||||
if (!devices.items.find(d => d.deviceId === id)) {
|
||||
console.warn('Device %s does not exist or is not linked to the authenticated user');
|
||||
}
|
||||
}
|
||||
|
||||
await dumpMonthlySummariesForDevice(moon, argv.directory, '' + device);
|
||||
await dumpDailySummariesForDevice(moon, argv.directory, '' + device);
|
||||
for (const device of devices.items) {
|
||||
if (argv.device && !argv.device.includes(device.deviceId)) continue;
|
||||
|
||||
console.warn('Downloading summaries for device %s (%s)', device.label, device.deviceId);
|
||||
|
||||
await dumpMonthlySummariesForDevice(moon, argv.directory, device.deviceId);
|
||||
await dumpDailySummariesForDevice(moon, argv.directory, device.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
27
src/cli/splatnet2.ts
Normal file
27
src/cli/splatnet2.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import createDebug from 'debug';
|
||||
import type { Arguments as ParentArguments } from '../cli.js';
|
||||
import { Argv, YargsArguments } from '../util.js';
|
||||
import * as commands from './splatnet2/index.js';
|
||||
|
||||
const debug = createDebug('cli:splatnet2');
|
||||
|
||||
export const command = 'splatnet2 <command>';
|
||||
export const desc = 'SplatNet 2';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
for (const command of Object.values(commands)) {
|
||||
// @ts-expect-error
|
||||
yargs.command(command);
|
||||
}
|
||||
|
||||
return yargs.option('znc-proxy-url', {
|
||||
describe: 'URL of Nintendo Switch Online app API proxy server to use',
|
||||
type: 'string',
|
||||
}).option('auto-update-iksm-session', {
|
||||
describe: 'Automatically obtain and refresh the iksm_session cookie',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
143
src/cli/splatnet2/dump-records.ts
Normal file
143
src/cli/splatnet2/dump-records.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import createDebug from 'debug';
|
||||
import mkdirp from 'mkdirp';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import type { Arguments as ParentArguments } from '../splatnet2.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getIksmToken } from './util.js';
|
||||
import SplatNet2Api, { ShareColour } from '../../api/splatnet2.js';
|
||||
import { NicknameAndIcon } from '../../api/splatnet2-types.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const debug = createDebug('cli:splatnet2:dump-records');
|
||||
|
||||
export const command = 'dump-records <directory>';
|
||||
export const desc = 'Download all other records';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('directory', {
|
||||
describe: 'Directory to write record data to',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}).option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('user-records', {
|
||||
describe: 'Include user records',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
}).option('profile-image', {
|
||||
describe: 'Include profile image',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('favourite-stage', {
|
||||
describe: 'Favourite stage to include on profile image',
|
||||
type: 'string',
|
||||
}).option('favourite-colour', {
|
||||
describe: 'Favourite colour to include on profile image',
|
||||
type: 'string',
|
||||
}).option('hero-records', {
|
||||
describe: 'Include hero (Octo Canyon) records',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
}).option('timeline', {
|
||||
describe: 'Include timeline records',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
|
||||
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
|
||||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid);
|
||||
const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession);
|
||||
|
||||
await mkdirp(argv.directory);
|
||||
|
||||
const [records, stages, activefestivals, timeline] = await Promise.all([
|
||||
splatnet.getRecords(),
|
||||
splatnet.getStages(),
|
||||
splatnet.getActiveFestivals(),
|
||||
splatnet.getTimeline(),
|
||||
]);
|
||||
const nickname_and_icons = await splatnet.getUserNicknameAndIcon([records.records.player.principal_id]);
|
||||
|
||||
if (argv.userRecords) {
|
||||
const filename = 'splatnet2-records-' + records.records.unique_id + '-' + Date.now() + '.json';
|
||||
const file = path.join(argv.directory, filename);
|
||||
|
||||
debug('Writing records %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify(records, null, 4) + '\n', 'utf-8');
|
||||
|
||||
const ni_filename = 'splatnet2-ni-' + records.records.unique_id + '-' + Date.now() + '.json';
|
||||
const ni_file = path.join(argv.directory, ni_filename);
|
||||
|
||||
debug('Writing records %s', ni_filename);
|
||||
await fs.writeFile(ni_file, JSON.stringify(nickname_and_icons.nickname_and_icons[0], null, 4) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
if (argv.profileImage) {
|
||||
const filename = 'splatnet2-profile-' + records.records.unique_id + '-' + Date.now() + '.json';
|
||||
const file = path.join(argv.directory, filename);
|
||||
const image_filename = 'splatnet2-profile-' + records.records.unique_id + '-' + Date.now() + '.png';
|
||||
const image_file = path.join(argv.directory, image_filename);
|
||||
|
||||
const stage = argv.favouriteStage ?
|
||||
stages.stages.find(s => s.id === argv.favouriteStage ||
|
||||
s.name.toLowerCase() === argv.favouriteStage?.toLowerCase()) :
|
||||
stages.stages[0];
|
||||
|
||||
if (!stage) {
|
||||
debug('Invalid favourite stage "%s"', argv.favouriteStage);
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (!Object.values(ShareColour).includes(argv.favouriteColour)) {
|
||||
argv.favouriteColour = ShareColour.PINK;
|
||||
}
|
||||
|
||||
debug('Fetching profile image URL');
|
||||
const share = await splatnet.shareProfile(stage?.id ?? stages.stages[0].id, argv.favouriteColour as ShareColour);
|
||||
|
||||
debug('Fetching profile image');
|
||||
const image_response = await fetch(share.url);
|
||||
const image = await image_response.buffer();
|
||||
|
||||
debug('Writing profile image data %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
share,
|
||||
stage: stage ?? stages.stages[0],
|
||||
colour: argv.favouriteColour,
|
||||
}, null, 4) + '\n', 'utf-8');
|
||||
|
||||
debug('Writing profile image %s', filename);
|
||||
await fs.writeFile(image_file, image);
|
||||
}
|
||||
|
||||
if (argv.heroRecords) {
|
||||
debug('Fetching hero records');
|
||||
const hero = await splatnet.getHeroRecords();
|
||||
|
||||
const filename = 'splatnet2-hero-' + records.records.unique_id + '-' + Date.now() + '.json';
|
||||
const file = path.join(argv.directory, filename);
|
||||
|
||||
debug('Writing hero records %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify(hero, null, 4) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
if (argv.timeline) {
|
||||
const filename = 'splatnet2-timeline-' + records.records.unique_id + '-' + Date.now() + '.json';
|
||||
const file = path.join(argv.directory, filename);
|
||||
|
||||
debug('Writing timeline %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify(records, null, 4) + '\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
194
src/cli/splatnet2/dump-results.ts
Normal file
194
src/cli/splatnet2/dump-results.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import createDebug from 'debug';
|
||||
import mkdirp from 'mkdirp';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import type { Arguments as ParentArguments } from '../splatnet2.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getIksmToken } from './util.js';
|
||||
import SplatNet2Api from '../../api/splatnet2.js';
|
||||
import { NicknameAndIcon } from '../../api/splatnet2-types.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const debug = createDebug('cli:splatnet2:dump-results');
|
||||
|
||||
export const command = 'dump-results <directory>';
|
||||
export const desc = 'Download all battle and coop results';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.positional('directory', {
|
||||
describe: 'Directory to write record data to',
|
||||
type: 'string',
|
||||
demandOption: true,
|
||||
}).option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
}).option('battles', {
|
||||
describe: 'Include regular/ranked/private/festival battle results',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
}).option('battle-summary-image', {
|
||||
describe: 'Include regular/ranked/private/festival battle summary image',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('battle-images', {
|
||||
describe: 'Include regular/ranked/private/festival battle result images',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
}).option('coop', {
|
||||
describe: 'Include coop (Salmon Run) results',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
|
||||
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
|
||||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid);
|
||||
const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession);
|
||||
|
||||
await mkdirp(argv.directory);
|
||||
|
||||
const records = argv.coop ? await splatnet.getRecords() : null;
|
||||
|
||||
if (argv.battles) {
|
||||
await dumpResults(splatnet, argv.directory, argv.battleImages, argv.battleSummaryImage);
|
||||
}
|
||||
if (argv.coop) {
|
||||
await dumpCoopResults(splatnet, argv.directory, records!.records.unique_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function dumpResults(splatnet: SplatNet2Api, directory: string, images = false, summary_image = images) {
|
||||
debug('Fetching battle results');
|
||||
const results = await splatnet.getResults();
|
||||
|
||||
const summary_filename = 'splatnet2-results-summary-' + results.unique_id + '-' + Date.now() + '.json';
|
||||
const summary_file = path.join(directory, summary_filename);
|
||||
|
||||
debug('Writing summary %s', summary_filename);
|
||||
await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8');
|
||||
|
||||
if (summary_image) {
|
||||
const filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + Date.now() + '.json';
|
||||
const file = path.join(directory, filename);
|
||||
const image_filename = 'splatnet2-results-summary-image-' + results.unique_id + '-' + Date.now() + '.png';
|
||||
const image_file = path.join(directory, image_filename);
|
||||
|
||||
debug('Fetching battle results summary image URL');
|
||||
const share = await splatnet.shareResultsSummary();
|
||||
|
||||
debug('Fetching battle results summary image');
|
||||
const image_response = await fetch(share.url);
|
||||
const image = await image_response.buffer();
|
||||
|
||||
debug('Writing battle results summary image data %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
share,
|
||||
}, null, 4) + '\n', 'utf-8');
|
||||
|
||||
debug('Writing battle results summary image %s', filename);
|
||||
await fs.writeFile(image_file, image);
|
||||
}
|
||||
|
||||
for (const item of results.results) {
|
||||
const filename = 'splatnet2-result-' + results.unique_id + '-' + item.battle_number + '-' + item.type + '.json';
|
||||
const file = path.join(directory, filename);
|
||||
|
||||
try {
|
||||
await fs.stat(file);
|
||||
debug('Skipping battle result %d, file already exists', item.battle_number);
|
||||
} catch (err) {
|
||||
debug('Fetching battle result %d', item.battle_number);
|
||||
const result = await splatnet.getResult(item.battle_number);
|
||||
|
||||
const nickname_and_icons: NicknameAndIcon[] = [];
|
||||
|
||||
for (const playerresult of [result.player_result, ...result.my_team_members, ...result.other_team_members]) {
|
||||
const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.player.principal_id]);
|
||||
nickname_and_icons.push(...nickname_and_icon.nickname_and_icons);
|
||||
}
|
||||
|
||||
debug('Writing %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
result,
|
||||
nickname_and_icons,
|
||||
}, null, 4) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
if (images) {
|
||||
const filename = 'splatnet2-result-image-' + results.unique_id + '-' +
|
||||
item.battle_number + '-' + item.type + '.json';
|
||||
const file = path.join(directory, filename);
|
||||
const image_filename = 'splatnet2-result-image-' + results.unique_id + '-' +
|
||||
item.battle_number + '-' + item.type + '.png';
|
||||
const image_file = path.join(directory, image_filename);
|
||||
|
||||
try {
|
||||
await fs.stat(file);
|
||||
await fs.stat(image_file);
|
||||
debug('Skipping battle result image %d, file already exists', item.battle_number);
|
||||
} catch (err) {
|
||||
debug('Fetching battle results summary image URL');
|
||||
const share = await splatnet.shareResult(item.battle_number);
|
||||
|
||||
debug('Fetching battle results summary image');
|
||||
const image_response = await fetch(share.url);
|
||||
const image = await image_response.buffer();
|
||||
|
||||
debug('Writing battle results summary image data %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
share,
|
||||
}, null, 4) + '\n', 'utf-8');
|
||||
|
||||
debug('Writing battle results summary image %s', filename);
|
||||
await fs.writeFile(image_file, image);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function dumpCoopResults(splatnet: SplatNet2Api, directory: string, user_id: string) {
|
||||
debug('Fetching coop results');
|
||||
const results = await splatnet.getCoopResults();
|
||||
|
||||
const summary_filename = 'splatnet2-coop-summary-' + user_id + '-' + Date.now() + '.json';
|
||||
const summary_file = path.join(directory, summary_filename);
|
||||
|
||||
debug('Writing summary %s', summary_filename);
|
||||
await fs.writeFile(summary_file, JSON.stringify(results, null, 4) + '\n', 'utf-8');
|
||||
|
||||
for (const item of results.results) {
|
||||
const filename = 'splatnet2-coop-result-' + user_id + '-' + item.job_id + '.json';
|
||||
const file = path.join(directory, filename);
|
||||
|
||||
try {
|
||||
await fs.stat(file);
|
||||
debug('Skipping coop result %d, file already exists', item.job_id);
|
||||
continue;
|
||||
} catch (err) {}
|
||||
|
||||
debug('Fetching coop result %d', item.job_id);
|
||||
const result = await splatnet.getCoopResult(item.job_id);
|
||||
|
||||
const nickname_and_icons: NicknameAndIcon[] = [];
|
||||
|
||||
for (const playerresult of [result.my_result, ...result.other_results]) {
|
||||
const nickname_and_icon = await splatnet.getUserNicknameAndIcon([playerresult.pid]);
|
||||
nickname_and_icons.push(...nickname_and_icon.nickname_and_icons);
|
||||
}
|
||||
|
||||
debug('Writing %s', filename);
|
||||
await fs.writeFile(file, JSON.stringify({
|
||||
result,
|
||||
nickname_and_icons,
|
||||
}, null, 4) + '\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
3
src/cli/splatnet2/index.ts
Normal file
3
src/cli/splatnet2/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * as user from './user.js';
|
||||
export * as dumpResults from './dump-results.js';
|
||||
export * as dumpRecords from './dump-records.js';
|
||||
45
src/cli/splatnet2/user.ts
Normal file
45
src/cli/splatnet2/user.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import createDebug from 'debug';
|
||||
import type { Arguments as ParentArguments } from '../splatnet2.js';
|
||||
import { ArgumentsCamelCase, Argv, initStorage, YargsArguments } from '../../util.js';
|
||||
import { getIksmToken } from './util.js';
|
||||
|
||||
const debug = createDebug('cli:splatnet2:user');
|
||||
|
||||
export const command = 'user';
|
||||
export const desc = 'Get the authenticated Nintendo Account\'s player record';
|
||||
|
||||
export function builder(yargs: Argv<ParentArguments>) {
|
||||
return yargs.option('user', {
|
||||
describe: 'Nintendo Account ID',
|
||||
type: 'string',
|
||||
}).option('token', {
|
||||
describe: 'Nintendo Account session token',
|
||||
type: 'string',
|
||||
});
|
||||
}
|
||||
|
||||
type Arguments = YargsArguments<ReturnType<typeof builder>>;
|
||||
|
||||
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
||||
const storage = await initStorage(argv.dataPath);
|
||||
|
||||
const usernsid = argv.user ?? await storage.getItem('SelectedUser');
|
||||
const token: string = argv.token ||
|
||||
await storage.getItem('NintendoAccountToken.' + usernsid);
|
||||
const {splatnet} = await getIksmToken(storage, token, argv.zncProxyUrl, argv.autoUpdateIksmSession);
|
||||
|
||||
const [records, stages, activefestivals, timeline] = await Promise.all([
|
||||
splatnet.getRecords(),
|
||||
splatnet.getStages(),
|
||||
splatnet.getActiveFestivals(),
|
||||
splatnet.getTimeline(),
|
||||
]);
|
||||
const nickname_and_icons = await splatnet.getUserNicknameAndIcon([records.records.player.principal_id]);
|
||||
|
||||
console.log('Player %s (Splatoon 2 ID %s, NSA ID %s) level %d',
|
||||
records.records.player.nickname,
|
||||
records.records.unique_id,
|
||||
records.records.player.principal_id,
|
||||
records.records.player.player_rank,
|
||||
records.records.player.player_type);
|
||||
}
|
||||
42
src/cli/splatnet2/util.ts
Normal file
42
src/cli/splatnet2/util.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import createDebug from 'debug';
|
||||
import persist from 'node-persist';
|
||||
import { getToken } from '../../util.js';
|
||||
import SplatNet2Api, { SavedIksmSessionToken } from '../../api/splatnet2.js';
|
||||
|
||||
const debug = createDebug('cli:splatnet2');
|
||||
|
||||
export async function getIksmToken(storage: persist.LocalStorage, token: string, proxy_url?: string, allow_fetch_token = false) {
|
||||
if (!token) {
|
||||
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.');
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const existingToken: SavedIksmSessionToken | undefined = await storage.getItem('IksmToken.' + token);
|
||||
|
||||
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
||||
if (!allow_fetch_token) {
|
||||
throw new Error('No valid iksm_session cookie');
|
||||
}
|
||||
|
||||
console.warn('Authenticating to SplatNet 2');
|
||||
debug('Authenticating to SplatNet 2');
|
||||
|
||||
const {nso, data} = await getToken(storage, token, proxy_url);
|
||||
|
||||
const existingToken = await SplatNet2Api.loginWithZnc(nso, data.user);
|
||||
|
||||
await storage.setItem('IksmToken.' + token, existingToken);
|
||||
|
||||
return {
|
||||
splatnet: new SplatNet2Api(existingToken.iksm_session, existingToken.useragent),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
|
||||
debug('Using existing token');
|
||||
|
||||
return {
|
||||
splatnet: new SplatNet2Api(existingToken.iksm_session, existingToken.useragent),
|
||||
data: existingToken,
|
||||
};
|
||||
}
|
||||
19
src/index.ts
Normal file
19
src/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export { default as ZncApi } from './api/znc.js';
|
||||
export { default as ZncProxyApi } from './api/znc-proxy.js';
|
||||
export * as znc from './api/znc-types.js';
|
||||
export { default as MoonApi } from './api/moon.js';
|
||||
export * as moon from './api/moon-types.js';
|
||||
export * as na from './api/na.js';
|
||||
export * as f from './api/f.js';
|
||||
|
||||
export {
|
||||
default as SplatNet2Api,
|
||||
XPowerRankingRule as SplatNet2XPowerRankingRule,
|
||||
LeagueType as SplatNet2LeagueType,
|
||||
LeagueRegion as SplatNet2LeagueRegion,
|
||||
ShareColour as SplatNet2ProfileColour,
|
||||
} from './api/splatnet2.js';
|
||||
export * as splatnet2 from './api/splatnet2-types.js';
|
||||
|
||||
export { getTitleIdFromEcUrl } from './util.js';
|
||||
export { ErrorResponse } from './api/util.js';
|
||||
|
|
@ -140,7 +140,7 @@ export function getDiscordPresence(game: Game, friendcode?: CurrentUser['links']
|
|||
|
||||
if (game.totalPlayTime >= 60) {
|
||||
text.push('Played for ' + hrduration(game.totalPlayTime) +
|
||||
' since ' + new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB'));
|
||||
' since ' + (game.firstPlayedAt ? new Date(game.firstPlayedAt * 1000).toLocaleDateString('en-GB') : 'now'));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user