@benhawley7/fut-search
Version:
Module for searching for players in FIFA Ultimate Team CSV Data.
236 lines (216 loc) • 8.47 kB
text/typescript
/**
* @package fut-search
* @author Ben Hawley
* @file Contains class for streaming and searching FUT CSV data
* @copyright Ben Hawley 2020
*/
import * as fs from "fs";
import * as path from "path";
import { parse } from "@fast-csv/parse";
/**
* Interface for a fully parsed FIFA player
* @interface
*/
export interface Player {
[key: string]: string | number;
name: string;
club: string;
position: string;
revision: string;
league: string;
rating: number;
pace: number;
shooting: number;
passing: number;
dribbling: number;
defending: number;
physicality: number;
}
/**
* Interface for a player with partial stats
* @interface
*/
export interface PartialPlayer {
[key: string]: string | number | undefined;
name?: string;
club?: string;
position?: string;
revision?: string;
league?: string;
rating?: number;
pace?: number;
shooting?: number;
passing?: number;
dribbling?: number;
defending?: number;
physicality?: number;
}
/**
* Interface for listPlayersBatch flags
* @interface
*/
interface ListPlayersBatchOptions {
firstMatchOnly?: boolean;
}
/**
* Class to search FUT CSV data for Players
* @class
*/
export class FUTSearch {
/**
* Path to the CSV Data
* By default, we look three directories up -
* as this will take us to the project root from project/node_modules/@benhawley7/fut-search
* @private
*/
private _dataPath: string = path.join(__dirname, "../../../", "data", "FIFA20.csv");
/**
* Returns the path to the current CSV we are accessing
*/
get dataPath() {
return this._dataPath;
}
/**
* Sets the path to the CSV we wish to query, errors if file does not exist
* @param val where to look for our FUT CSV data
*/
set dataPath(val: string) {
const isCSV = val.endsWith(".csv");
if (isCSV === false) {
throw new Error("dataPath must point to a CSV file");
}
this._dataPath = val;
}
/**
* Create an instance of FUT
* @param dataPath set where the CSV is located
*/
constructor(dataPath?: string) {
if (dataPath) {
this.dataPath = dataPath;
}
}
/**
* Parse a CSV record as a Player
* @param record the csv row
* @returns parsed Player
*/
static parsePlayer(record: any): Player {
const formattedRecord: any = {};
for (const [key, value] of Object.entries(record)) {
formattedRecord[key.toLowerCase()] = value;
}
return {
name: String(formattedRecord.name),
club: String(formattedRecord.club),
position: String(formattedRecord.position),
revision: String(formattedRecord.revision),
league: String(formattedRecord.league),
rating: parseInt(formattedRecord.rating, 10),
pace: parseInt(formattedRecord.pace, 10),
shooting: parseInt(formattedRecord.shooting, 10),
passing: parseInt(formattedRecord.passing, 10),
dribbling: parseInt(formattedRecord.dribbling, 10),
defending: parseInt(formattedRecord.defending, 10),
physicality: parseInt(formattedRecord.physicality, 10)
};
}
/**
* Compare an incomplete player record to a complete player
* @param partial an incomplete player
* @param player a complete player
* @returns are they are a potential match?
*/
static playerMatch(partial: PartialPlayer, player: Player): boolean {
for (const [key, value] of Object.entries(partial)) {
// For numbers, we simply check the values match
if (typeof value === "number") {
if (partial[key] !== player[key]) {
return false;
}
} else {
const partialValueLowerCase = String(partial[key])
.toLowerCase()
.normalize("NFKD")
.replace(/[^\w\s.-_\/]/g, '');
const playerValueLowerCase = String(player[key])
.toLowerCase()
.normalize("NFKD")
.replace(/[^\w\s.-_\/]/g, '');
if (playerValueLowerCase.includes(partialValueLowerCase) === false) {
return false;
}
}
}
return true;
}
/**
* Finding matching players for a batch of partial players
* @param partialPlayers array of partials to find
* @param options flags for search
* @param options.firstMatchOnly if true we return only the first match for each partial
* @returns array of matching players for each partial supplied
*/
async listPlayersBatch(partialPlayers: PartialPlayer[], options: ListPlayersBatchOptions = {firstMatchOnly: false}): Promise<Player[][]> {
return new Promise((res, rej) => {
// We store an array of each partial containing its player matches
const matchingPlayers: Player[][] = partialPlayers.map(() => []);
// To find a the matching player, we need to read the CSV. These CSVs have about 15,000
// rows, so loading the entire CSV into memory doesn't seem sensible.
// Instead, we can read the CSV as a stream and check each row as it comes in
const csvReadStream = fs.createReadStream(this.dataPath)
.on("error", (error) => {
rej(error);
});
// The parse and match CSV Parser Stream will have the CSV stream piped into it, for each row of data
// it will parse the data into our Player interface and will check if it is a match on our
// input name and club. Upon completion, it will resolve the matching player, or reject with an error.
const parseAndMatch = parse({ headers: true })
.on('error', error => rej(new Error(`Error parsing CSV: ${error.message}`)))
.on('data', row => {
// With the first match only flag, we resolve early if we have one match for every player
const canEarlyResolve = options.firstMatchOnly && matchingPlayers.every(playerMatch => playerMatch.length > 0);
if (canEarlyResolve) {
// Resolve the early matches
res(matchingPlayers);
// Destroy the streams
csvReadStream.destroy();
parseAndMatch.destroy();
return;
}
// Parse the current row as a complete player
const player = FUTSearch.parsePlayer(row);
for (const [index, partial] of partialPlayers.entries()) {
const isMatch = FUTSearch.playerMatch(partial, player);
const partialComplete = options.firstMatchOnly && matchingPlayers[index].length > 0;
if (isMatch === true && partialComplete === false) {
matchingPlayers[index].push(player);
}
}
})
.on('end', () => {
res(matchingPlayers);
});
// Pipe the CSV into the parseAndMatch CSV Parser Stream
csvReadStream.pipe(parseAndMatch);
});
}
/**
* Finding matching players for a supplied partial player
* @param playerDetails partial player stats
* @returns array of matching players
*/
async listPlayers(playerDetails: PartialPlayer = {}): Promise<Player[]> {
const [players] = await this.listPlayersBatch([playerDetails]);
return players;
}
/**
* Find a player with the supplied stats
* @param playerDetails partial player stats
* @returns player
*/
async findPlayer(playerDetails: PartialPlayer): Promise<Player|undefined> {
const [[player]] = await this.listPlayersBatch([playerDetails], {firstMatchOnly: true});
return player;
}
}