fn-stats-cli-jnc
Version:
Command-line tool to pull Fortnite player statistics using Epic Games' API
491 lines (412 loc) • 23 kB
JavaScript
import { EpicClient } from './src/epicWrapper.js';
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { seasonTimestamps } from './src/getSeasonTimes.js';
import { filterRawFortniteStats, getSortedStats, getModifiedTimes,
filterByStatPatterns, getLatestModifiedTimestamp,
getStatsDifference } from './src/fortniteRawStatOps.js';
import { statPattern } from './src/fortniteModeConstants.js';
import { createFortniteStatObject, addFortniteRateStats,
transformToTRNFormat } from './src/fortniteStructuredStatOps.js';
import { parseFilterArgs, parseTimeWindowArg, parseStartEndTimeArgs } from './src/argParser.js';
import { getFortniteStats, authenticateClient } from './src/statsRetriever.js';
import { formatTime, adjustToEndOfDay, getMidnightGMT} from './src/dateUtils.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Conditional logging function that respects quiet mode
* @param {string} message - Message to log
* @param {boolean} quietMode - Whether quiet mode is enabled
*/
function conditionalLog(message, quietMode) {
if (!quietMode) {
console.log(message);
}
}
/**
* Retrieves and processes Fortnite stats for a player in a specific time window
* @param {string} playerName - Epic Games display name
* @param {Object} timeWindow - Time window {startTime, endTime}
* @param {Array<string>} filters - Game mode filters to apply
* @param {Array<string>} statPatternKeys - Optional stat pattern keys to filter by
* @param {boolean} showRawStats - Whether to show raw stats instead of nested structure
* @param {boolean} useTRNFormat - Whether to use TRN-style format for team sizes
* @param {boolean} useAdvancedMethod - Whether to use the advanced triple API call technique
* @param {boolean} quietMode - Whether to suppress logs
* @param {EpicClient} [epicClient] - Optional existing authenticated client
*/
async function retrieveAndProcessFortniteStats(playerName, timeWindow, filters = [], statPatternKeys = [],
showRawStats = false, useTRNFormat = false, useAdvancedMethod = true,
quietMode = false, epicClient = null) {
try {
// Get raw stats
const rawStats = await getFortniteStats(playerName, timeWindow, useAdvancedMethod, quietMode, epicClient);
// Display number of stats returned
const statCount = Object.keys(rawStats.stats || {}).length;
conditionalLog(`Retrieved ${statCount} raw stats`, quietMode);
// Apply game mode filters first
let filteredStats = rawStats;
if (filters.length > 0) {
filteredStats = filterRawFortniteStats(rawStats, filters);
conditionalLog(`Applied mode filters: ${filters.join(', ')}`, quietMode);
conditionalLog(`Stats count after mode filtering: ${Object.keys(filteredStats.stats || {}).length}`, quietMode);
}
// Then apply stat pattern filters if provided
if (statPatternKeys.length > 0) {
const statsBeforePatternFilter = Object.keys(filteredStats.stats || {}).length;
filteredStats.stats = filterByStatPatterns(filteredStats.stats, statPatternKeys);
// Display patterns being applied
const patternStrings = statPatternKeys.map(key =>
`'${key}' (${statPattern[key]})`
).join(', ');
conditionalLog(`Applied stat pattern filters: ${patternStrings}`, quietMode);
conditionalLog(`Stats count: ${statsBeforePatternFilter} → ${Object.keys(filteredStats.stats || {}).length} (always includes lastmodified)`, quietMode);
}
const filteredCount = Object.keys(filteredStats.stats || {}).length;
// Build filter description for output
const filterDesc = [
...(filters.length > 0 ? [`modes: ${filters.join(', ')}`] : []),
...(statPatternKeys.length > 0 ? [`stats: ${statPatternKeys.join(', ')}`] : []),
].join(', ');
if (showRawStats) {
// Raw stats mode - display the already filtered raw stats
const headerText = filterDesc ?
`\nFiltered Raw Stats (${filterDesc}):` :
"\nAll Raw Stats (alphabetically sorted):";
if (!quietMode) console.log(headerText);
if (filteredCount === 0) {
console.log(" No matching stats found");
} else {
if (!quietMode) console.log(` Found ${filteredCount} matching stats`);
const sortedStats = getSortedStats(filteredStats.stats);
console.dir(sortedStats, { depth: null });
// Show lastmodified dates for stats
const lastModifiedTimes = getModifiedTimes(filteredStats.stats, { ascending: true });
if (Object.keys(lastModifiedTimes).length > 0) {
console.log("\nLast Modified Times (GMT):");
console.dir(lastModifiedTimes, { depth: null });
}
}
} else {
// Nested structure mode - create structure from already filtered raw stats
let nestedStatsHeader;
if (useTRNFormat) {
nestedStatsHeader = filterDesc ?
`\nTRN-Style Stats (${filterDesc}, bots excluded):` :
"\nTRN-Style Stats (bots excluded):";
} else {
nestedStatsHeader = filterDesc ?
`\nNested Stat Structure (${filterDesc}):` :
"\nNested Stat Structure:";
}
if (!quietMode) console.log(nestedStatsHeader);
if (filteredCount === 0) {
console.log(" No matching stats found");
} else {
// Check if bots should be included (explicitly requested in filters)
const includeBots = filters.includes('bots');
// Create nested structure from filtered raw stats
let nestedStats = createFortniteStatObject(filteredStats, includeBots);
// If TRN format is requested, transform the stats before adding rates
if (useTRNFormat) {
// First transform to TRN format (just raw stats)
nestedStats = transformToTRNFormat(nestedStats);
}
// Add rate calculations to either the normal structure or the TRN structure
const statsWithRates = addFortniteRateStats(nestedStats);
// Display the final structure
console.dir(statsWithRates, { depth: null });
}
}
} catch (error) {
console.error("Error retrieving stats:", error);
}
}
/**
* Search back through time to find stat changes
* @param {string} playerName - Epic Games display name
* @param {Array<string>} filters - Game mode filters to apply
*/
async function searchStatsHistory(playerName, filters = []) {
const epicClient = new EpicClient();
const accumulatedStats = [];
const statDifferences = [];
try {
// Authenticate
await authenticateClient(epicClient);
// Get player ID
const playerInfo = await epicClient.getAccountByDisplayName(playerName);
console.log(`Found player: ${playerInfo.displayName} (${playerInfo.id})\n`);
// Get ch1s1 start (earliest date to search from)
const ch1s1Start = seasonTimestamps.ch1s1.startTime;
// Get ch5s1 start (earliest date to go back to)
const ch5s1Start = seasonTimestamps.ch5s1.startTime;
// Calculate tonight's midnight in GMT
const now = new Date();
now.setUTCDate(now.getUTCDate() + 1); // tomorrow
now.setUTCHours(0, 0, 0, 0); // midnight
const tonightMidnight = Math.floor(now.getTime() / 1000);
// Initial time window
let endTime = tonightMidnight;
let step = 1;
// Loop until we reach ch5s1 start
while (true) {
console.log(`\n=== Step ${step}: Retrieving stats from ${formatTime(ch1s1Start)} to ${formatTime(endTime)} ===`);
// Get stats for time window
const timeWindow = { startTime: ch1s1Start, endTime: endTime };
const rawStats = await epicClient.fortnite.getStats(playerInfo.id, timeWindow);
// Filter stats if filters provided
let filteredStats = rawStats;
if (filters.length > 0) {
filteredStats = filterRawFortniteStats(rawStats, filters);
console.log(`Applied filters: ${filters.join(', ')}`);
}
// Get sorted stats
const sortedStats = getSortedStats(filteredStats.stats || {});
// Add to accumulated stats
accumulatedStats.push({
endTime: endTime,
endTimeFormatted: formatTime(endTime),
stats: sortedStats
});
// Calculate differences if not first step
if (step > 1) {
const prevStats = accumulatedStats[step - 2].stats;
const currentStats = sortedStats;
const diff = getStatsDifference(currentStats, prevStats);
statDifferences.push({
endTime: endTime,
endTimeFormatted: formatTime(endTime),
differences: diff
});
// Display differences
console.log(`\nStat changes at this step (${Object.keys(diff).length} changes):`);
if (Object.keys(diff).length === 0) {
console.log(" No changes detected");
} else {
// Display the raw differences
console.dir(diff, { depth: null });
}
}
// Show 5 most recent lastmodified times
const recentModified = getModifiedTimes(sortedStats, { limit: 5 });
console.log("\n5 most recent lastmodified times:");
if (Object.keys(recentModified).length === 0) {
console.log(" No lastmodified entries found");
} else {
console.dir(recentModified, { depth: null });
}
// Find latest lastmodified time for next step
const latestTimestamp = getLatestModifiedTimestamp(sortedStats);
// If no lastmodified times found or we've reached ch5s1, stop
if (latestTimestamp === 0 || latestTimestamp <= ch5s1Start) {
console.log("\nReached end of search (no earlier lastmodified times or hit ch5s1 start date)");
break;
}
// Set next end time to midnight GMT of the latest lastmodified date
endTime = getMidnightGMT(latestTimestamp);
// Break if we've gone back too far
if (endTime <= ch5s1Start) {
console.log("\nReached ch5s1 start date, stopping");
break;
}
console.log(`\nWaiting 5 seconds before next step...`);
await new Promise(resolve => setTimeout(resolve, 5000));
step++;
}
// Save results to JSON file
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${playerName}_stat_history_${timestamp}.json`;
const filePath = join(__dirname, filename);
const result = {
playerName: playerInfo.displayName,
playerId: playerInfo.id,
filters: filters,
generatedAt: new Date().toISOString(),
accumulatedStats: accumulatedStats,
statDifferences: statDifferences
};
writeFileSync(filePath, JSON.stringify(result, null, 2));
console.log(`\nSaved results to ${filename}`);
} catch (error) {
console.error("Error searching stats history:", error);
}
}
// Parse command line args
const args = process.argv.slice(2);
if (args.length < 1) {
console.log("Usage:");
console.log(" Normal mode: node fortniteRawStatDebug.js <playerName> [season|timeWindow] [...filters] [...statPatterns]");
console.log(" History search: node fortniteRawStatDebug.js --history <playerName> [...filters]");
console.log(" Raw stats mode: node fortniteRawStatDebug.js --raw <playerName> [season|timeWindow] [...filters] [...statPatterns]");
console.log(" TRN format: node fortniteRawStatDebug.js --TRN <playerName> [season|timeWindow] [...filters] [...statPatterns]");
console.log(" Direct API mode: node fortniteRawStatDebug.js --direct <playerName> [season|timeWindow] [...filters] [...statPatterns]");
console.log(" Quiet mode: node fortniteRawStatDebug.js --quiet <playerName> [season|timeWindow] [...filters] [...statPatterns]");
console.log(" Combined modes: Can combine flags (e.g., --direct --raw --quiet <playerName>)");
console.log("\nExamples:");
console.log(" Season: node fortniteRawStatDebug.js PlayerName ch5s2 zeroBuild solo ranked kills wins");
console.log(" Special season: node fortniteRawStatDebug.js PlayerName og zeroBuild duo pubs");
console.log(" Custom time window: node fortniteRawStatDebug.js PlayerName starttime=\"2023-01-01\" endtime=\"2023-01-31\" zeroBuild");
console.log(" Last N days: node fortniteRawStatDebug.js PlayerName lastday=3 zeroBuild solo");
console.log(" Last N weeks: node fortniteRawStatDebug.js PlayerName lastweek=2 duo pubs");
console.log(" Last N months: node fortniteRawStatDebug.js PlayerName lastmonth=1 squad");
console.log(" History search: node fortniteRawStatDebug.js --history PlayerName zeroBuild solo");
console.log(" Raw stats: node fortniteRawStatDebug.js --raw PlayerName ch6s2 zeroBuild");
console.log(" TRN-style: node fortniteRawStatDebug.js --TRN PlayerName ch6s2 solo");
console.log(" Direct API: node fortniteRawStatDebug.js --direct PlayerName ch6s2 zeroBuild");
console.log(" Quiet output: node fortniteRawStatDebug.js --quiet PlayerName ch6s2 zeroBuild");
console.log("\nAvailable filters:");
console.log(" Team sizes: solo, duo, trio, squad");
console.log(" Build modes: zeroBuild, build");
console.log(" Competitive modes: pubs, ranked, bots");
console.log(" Game modes: regular, reload");
console.log("\nAvailable stat patterns (can use multiple):");
console.log(" matches, kills, wins, top3, top5, top6, top10, top12, top25, minutes");
console.log("\nAvailable seasons:");
console.log(" " + Object.keys(seasonTimestamps).join(", "));
console.log("\nDefault season: ch6s2");
process.exit(1);
}
// Check for special modes
if (args[0] === '--history') {
if (args.length < 2) {
console.log("Error: Player name required for history search");
process.exit(1);
}
const playerName = args[1];
// Extract mode filters
const { modeFilters } = parseFilterArgs(args.slice(2));
// Run history search
searchStatsHistory(playerName, modeFilters);
} else {
// Check for special modes - use includes() to allow flags in any position
const showRawStats = args.includes('--raw') || args.includes('-r');
const useTRNFormat = args.includes('--TRN') || args.includes('-t');
let useDirectMethod = args.includes('--direct') || args.includes('-d');
const quietMode = args.includes('--quiet') || args.includes('-q');
// Remove all option flags to get the real arguments
const realArgs = args.filter(arg =>
arg !== '--raw' && arg !== '-r' &&
arg !== '--TRN' && arg !== '-t' &&
arg !== '--direct' && arg !== '-d' &&
arg !== '--quiet' && arg !== '-q'
);
// Ensure there's at least one real argument (player name)
if (realArgs.length < 1) {
console.log("Error: Player name required");
process.exit(1);
}
const playerName = realArgs[0];
let timeWindow = {};
let filters = [];
let statPatternKeys = [];
// Process remaining arguments after player name
const offsetArgs = realArgs.slice(1);
// Get default time window (ch6s2)
const defaultSeason = 'ch6s2';
const defaultTimeWindow = {
startTime: useDirectMethod ?
seasonTimestamps[defaultSeason].startTime :
adjustToEndOfDay(seasonTimestamps[defaultSeason].startTime),
endTime: adjustToEndOfDay(seasonTimestamps[defaultSeason].endTime)
};
// First check for starttime= and endtime= parameters
const { timeWindow: customTimeWindow, foundCustomTime, remainingArgs: argsAfterCustomTime } = parseStartEndTimeArgs(offsetArgs, quietMode);
// Then check for time window shortcuts if custom time wasn't found
let timeWindowFound = foundCustomTime;
let argsToProcess = argsAfterCustomTime;
if (foundCustomTime) {
// Use the custom time window, filling in any missing parts with defaults
timeWindow = {
startTime: customTimeWindow.startTime !== null ? customTimeWindow.startTime : defaultTimeWindow.startTime,
endTime: customTimeWindow.endTime !== null ? customTimeWindow.endTime : defaultTimeWindow.endTime
};
} else {
// Check for lastday/lastweek/lastmonth time shortcuts
const remainingArgsAfterShortcuts = [];
for (const arg of argsAfterCustomTime) {
const parsedTimeWindow = parseTimeWindowArg(arg);
if (parsedTimeWindow) {
timeWindow = parsedTimeWindow;
timeWindowFound = true;
conditionalLog(`Using time window: ${formatTime(timeWindow.startTime)} to ${formatTime(timeWindow.endTime)}`, quietMode);
} else {
remainingArgsAfterShortcuts.push(arg);
}
}
argsToProcess = remainingArgsAfterShortcuts;
}
// Only process season args if no time window was found yet
if (!timeWindowFound) {
// Check if the first argument is a valid season name
if (argsToProcess.length >= 1 && seasonTimestamps[argsToProcess[0]]) {
const season = argsToProcess[0];
// For season queries, only adjust startTime to end-of-day when using advanced method
timeWindow = {
// Only adjust startTime to end-of-day when using advanced method
startTime: useDirectMethod ?
seasonTimestamps[season].startTime :
adjustToEndOfDay(seasonTimestamps[season].startTime),
endTime: adjustToEndOfDay(seasonTimestamps[season].endTime)
};
const startTimeAdjustMsg = useDirectMethod ?
"adjusted end time to end of day" :
"adjusted start and end times to end of day";
conditionalLog(`Using season ${season} (${startTimeAdjustMsg})`, quietMode);
// Remove season from args
argsToProcess = argsToProcess.slice(1);
} else {
// Default to ch6s2
timeWindow = defaultTimeWindow;
const startTimeAdjustMsg = useDirectMethod ?
"adjusted end time to end of day" :
"adjusted start and end times to end of day";
conditionalLog(`No time window specified. Using default season ${defaultSeason} (${startTimeAdjustMsg}).`, quietMode);
}
}
// Process remaining arguments as filters and stat patterns
if (argsToProcess.length > 0) {
const { modeFilters, statPatterns } = parseFilterArgs(argsToProcess);
filters = modeFilters;
statPatternKeys = statPatterns;
}
// Handle special case: If either startTime or endTime is 0, use ch1s1 startTime
if (timeWindow.startTime === 0) {
timeWindow.startTime = seasonTimestamps.ch1s1.startTime;
conditionalLog(`Using Ch1S1 start time (${formatTime(timeWindow.startTime)}) for startTime=0`, quietMode);
}
if (timeWindow.endTime === 0) {
timeWindow.endTime = seasonTimestamps.ch1s1.startTime;
conditionalLog(`Using Ch1S1 start time (${formatTime(timeWindow.endTime)}) for endTime=0`, quietMode);
}
// Force direct method if startTime is lifetime stats (≤ ch1s1 start time)
if (timeWindow.startTime <= seasonTimestamps.ch1s1.startTime) {
conditionalLog(`Start time (${formatTime(timeWindow.startTime)}) is at or before Ch1S1 start time (${formatTime(seasonTimestamps.ch1s1.startTime)})`, quietMode);
conditionalLog("Automatically using direct API method for lifetime stats", quietMode);
useDirectMethod = true;
}
// Add debug information
conditionalLog(`Filters parsed: ${JSON.stringify(filters)}`, quietMode);
conditionalLog(`Stat patterns parsed: ${JSON.stringify(statPatternKeys)}`, quietMode);
// Report on which methods are being used
conditionalLog(useDirectMethod ? "Using direct API call method (single API call)" : "Using advanced triple API call technique", quietMode);
conditionalLog(showRawStats ? "Showing raw stats output" : "", quietMode && !showRawStats);
conditionalLog(useTRNFormat ? "Using TRN-style format" : "", quietMode && !useTRNFormat);
conditionalLog(quietMode ? "Quiet mode enabled - suppressing logs" : "", quietMode);
// Create a single Epic client for this session
const epicClient = new EpicClient();
await authenticateClient(epicClient, quietMode);
// Run the stats retrieval with appropriate flags
retrieveAndProcessFortniteStats(
playerName,
timeWindow,
filters,
statPatternKeys,
showRawStats,
useTRNFormat,
!useDirectMethod,
quietMode,
epicClient // Pass the authenticated client
);
}