ittf-pingpong
Version:
Unofficial API to retrieve player rankings and statistics from ITTF (International Table Tennis Federation) affiliated members and events.
409 lines (408 loc) • 23.4 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ittfPingPong = void 0;
const cuimp_1 = require("cuimp");
class ittfPingPong {
constructor() {
this.currentTop100RankingsUrl = 'https://wtt-web-frontdoor-withoutcache-cqakg0andqf5hchn.a01.azurefd.net/ranking/';
this.currentRankingsApi = 'https://wttcmsapigateway-new.azure-api.net/internalttu/RankingsCurrentWeek/CurrentWeek/';
this.playerProfileUrl = 'https://ranking.ittf.com/public/s/player/profile/';
this.allPlayersUrl = 'https://wttcmsapigateway-new.azure-api.net/ttu/Players/GetPlayers?limit=100000';
///For future implementation
this.historicalRankingsUrl = 'https://ranking.ittf.com/public/s/ranking/list?category=SEN&typeGender=M;SINGLES&year=2020&week=49&offset=0&size=10000';
this.allCountriesUrl = 'https://ranking.ittf.com/public/s/countries/list';
this.playerMatchesUrl = 'https://ranking.ittf.com/public/s/player/matches/ittfId?offset=0&size=valueSize&ind=valueInd&dbl=valueDouble';
//The WTT website does not exactly follow the API structure, accommodate where required
this.genderMap = {
SEN: {
M: "MEN'S",
W: "WOMEN'S",
X: "MIXED"
},
YOU: {
M: "BOYS'",
W: "GIRLS'",
X: "MIXED"
}
};
this.ageMap = {
SEN: 'SENIOR',
YOU: 'YOUTH'
};
this.categoryMap = {
S: 'SINGLES',
D: 'DOUBLES',
DI: 'SINGLES'
};
//Hardcoded keys found in the network API call on the website, include with UserAgent to prevent firewall from blocking fetch
this.keys = {
'apikey': '2bf8b222-532c-4c60-8ebe-eb6fdfebe84a',
'secapimkey': 'S_WTT_882jjh7basdj91834783mds8j2jsd81',
};
}
static isValidGender(value) {
//Ensures that only 'M', 'W', 'X' is passed as an input
const normalized = typeof value === 'string' ? value.toUpperCase() : value;
return this.currentGender.includes(normalized);
}
;
static isValidCategory(value) {
//Ensures that only 'S', 'D', 'DI' is passed as an input
const normalized = typeof value === 'string' ? value.toUpperCase() : value;
return this.currentCategory.includes(normalized);
}
;
static isValidType(value) {
//Ensures that only 'YOU' or 'SEN' is passed as an input
const normalized = typeof value === 'string' ? value.toUpperCase() : value;
return this.currentType.includes(normalized);
}
;
isPositiveInteger(n) {
//Ensures that only positive integers are passed as an input
return Number.isInteger(n) && n > 0;
}
;
isAlphabetic(str) {
//Ensures that only Latin Alphabet characters (upper and lower case) and spaces are passed as an input
return /^[A-Za-z\s]+$/.test(str);
}
;
top100WTTFrontdoor(type, gender, category) {
return __awaiter(this, void 0, void 0, function* () {
try {
//Ensures all requests are unique
let uniqueQ = Date.now().toString();
const WTTFrontdoorBaseUrl = new URL(`${this.currentTop100RankingsUrl}${type}_${this.categoryMap[category]}.json`);
WTTFrontdoorBaseUrl.searchParams.set('q', uniqueQ);
//Fetch with response headers to ensure website frontdoor authorizes data extraction
let response = yield fetch(WTTFrontdoorBaseUrl, {
method: 'GET',
headers: {
'Accept': 'application/json, text/plain, */*',
'Referer': 'https://www.worldtabletennis.com',
'Origin': 'https://www.worldtabletennis.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
},
credentials: 'omit'
});
let responseJson = yield response.json();
const top100Json = responseJson.Result;
return top100Json;
}
catch (_a) {
throw new Error(`[ittf-pingpong] API Connection Error: Failed to fetch top 100`);
}
});
}
;
remainingRanksWTTApi(type, gender, category, topN, requestDelay) {
return __awaiter(this, void 0, void 0, function* () {
//Browser TLS fingerprint to get past firewall without requiring browser automation like Playwright or Puppeteer
const scraper = (0, cuimp_1.createCuimpHttp)({
descriptor: {
browser: 'chrome',
version: '120'
}
});
// Configurable throttle (ms) to avoid IP blocks
const throttleMs = requestDelay;
const baseUrlSuffix = category === 'D' ? 'GetRankingPairs' : 'GetRankingIndividuals';
const WTTApiBaseUrl = new URL(`${this.currentRankingsApi}${baseUrlSuffix}`);
WTTApiBaseUrl.searchParams.set('CategoryCode', type);
WTTApiBaseUrl.searchParams.set('SubEventCode', gender + category);
let allResults = [];
let currentStart = 101;
let shouldContinue = true;
try {
while (shouldContinue) {
// Generate 5 chunks at a time
const start = currentStart;
const end = currentStart + 99;
const batchUrl = new URL(WTTApiBaseUrl.href);
batchUrl.searchParams.set('StartRank', start.toString());
batchUrl.searchParams.set('EndRank', end.toString());
batchUrl.searchParams.set('q', '1');
// Perform the fetch
const response = yield scraper.get(batchUrl.href, {
headers: Object.assign(Object.assign({}, this.keys), { 'Origin': 'https://www.worldtabletennis.com', 'Referer': 'https://www.worldtabletennis.com/', 'Accept-Language': 'en-US,en;q=0.9' })
});
const requestId = response['headers']['x-ms-middleware-request-id'];
if (response.status === 401 && !requestId) {
throw new Error(`[ittf-pingpong] Request blocked by API Firewall: ${response.status} ${response.statusText} at range ${start}-${end}. This usually happens due to high frequency. Try increasing requestDelay.`);
}
let responseJson = response.data;
const remainingRanks = responseJson.Result;
// If the fetch request for this chunk is empty stop the while loop
if (remainingRanks.length === 0) {
shouldContinue = false;
break;
}
allResults.push(...remainingRanks);
// If the user requested a specific number and we reached it
if (typeof topN === 'number' && allResults.length >= topN - 100) {
shouldContinue = false;
break;
}
if (shouldContinue) {
currentStart += 100;
yield new Promise(resolve => setTimeout(resolve, throttleMs));
}
}
return allResults;
}
catch (err) {
console.error(`Fetch failed:`, err);
throw err;
}
});
}
;
/**
* Fetch the current rankings.
* @param {string} type - The type of rankings ('YOU' | 'SEN') All 3 youth competition types lumped together (U15, U18, U21), Seniors
* @param {string} gender - Gender ('M' | 'W' | 'X') Man, woman, mixed (doubles only)
* @param {string} category - Category ('S' | 'D' | 'DI') Singles, doubles ranking (pairs), doubles ranking (individual)
* @param {number | string} topN - Only positive integers (e.g., 1, 2, 3, ...) or 'all'. Defaults to 100.
* @param {number} requestDelay - Only positive integers. Defaults to 2000 (2 second delay)
*
* @returns {Promise<Rankings>}
*
* @throws {Error} if invalid inputs/invalid input combinations are used (gender 'X' can only be used with category 'D' & 'DI') or fetch/server errors occur
*
* @example
*
* // Valid :
* await currentRankings('SEN', 'M', 'S', 10);
* // output: Array of top 10 ranked senior male players in the singles category
* await currentRankings('YOU', 'W', 'D');
* // output: Array of top 100 ranked youth female players in the doubles category
*
*/
currentRankings(type_1, gender_1, category_1) {
return __awaiter(this, arguments, void 0, function* (type, gender, category, topN = 100, requestDelay = 2000) {
try {
// Normalize inputs to uppercase immediately
const normalizedType = typeof type === 'string' ? type.toUpperCase() : type;
const normalizedGender = typeof gender === 'string' ? gender.toUpperCase() : gender;
const normalizedCategory = typeof category === 'string' ? category.toUpperCase() : category;
const normalizedTopN = typeof topN === 'string' ? topN.toLowerCase() : topN;
// Validate type
if (!ittfPingPong.isValidType(type) && !ittfPingPong.isValidType(normalizedType)) {
throw new Error(`[ittf-pingpong] Invalid type: ${type}. Valid options are: ${ittfPingPong.currentType.join(', ')}`);
}
// Validate gender
if (!ittfPingPong.isValidGender(gender) && !ittfPingPong.isValidType(normalizedGender)) {
throw new Error(`[ittf-pingpong] Invalid gender: ${gender}. Valid options are: ${ittfPingPong.currentGender.join(', ')}`);
}
// Validate category
if (!ittfPingPong.isValidCategory(category) && !ittfPingPong.isValidType(normalizedCategory)) {
throw new Error(`[ittf-pingpong] Invalid category: ${category}. Valid options are: ${ittfPingPong.currentCategory.join(', ')}`);
}
// Validate topN
if (normalizedTopN !== 'all' && topN !== 'all') {
if (!this.isPositiveInteger(topN)) {
throw new Error('[ittf-pingpong] topN must be a positive integer or "all"');
}
}
if (!this.isPositiveInteger(requestDelay)) {
throw new Error('[ittf-pingpong] requestDelay must be a positive integer');
}
// Check for invalid gender and category combination
if (gender === 'X' && !['D', 'DI'].includes(category)) {
throw new Error("[ittf-pingpong] Mixed gender ('X') requires a doubles category ('D' or 'DI').");
}
let topRanks = [];
let top100AllTabsJson = yield this.top100WTTFrontdoor(normalizedType, normalizedGender, normalizedCategory);
const top100Json = top100AllTabsJson.filter((player) => { return player.SubEventCode === `${normalizedGender}${normalizedCategory}`; });
if (typeof normalizedTopN === 'number' && normalizedTopN <= 100) {
//Only data from WTT frontdoor is required
const topNJson = top100Json.slice(0, normalizedTopN);
topRanks.push(...topNJson);
return topRanks;
}
else {
const remainderJson = yield this.remainingRanksWTTApi(normalizedType, normalizedGender, normalizedCategory, normalizedTopN, requestDelay);
if (remainderJson)
topRanks.push(...top100Json, ...remainderJson);
if (normalizedTopN !== 'all') {
//Remove ranks below specified value
topRanks = topRanks.slice(0, normalizedTopN);
}
return topRanks;
}
}
catch (err) {
throw new Error(`${err}`);
}
});
}
;
/**
* Fetch the player's IttfId given their name.
* @param {object} searchName The name parameters, all mutually exclusive - you can only enter one of playerFullName, playerGivenName, or playerFamilyName.
* @param {FullName} [searchName.playerFullName] Player's family name and given name. Ensure that the family name is placed before the given name. (e.g. "FAN Zhendong", "CALDERANO Hugo")
* @param {GivenName} [searchName.playerGivenName] Player's given name(s) only.
* @param {FamilyName} [searchName.playerFamilyName] Player's family name.
*
* @returns {Promise<Array<PlayerId>>} Returns an Array containing PlayerID objects {IttfId : string, PlayerFamilyNameFirst : string}
* @throws {Error} When multiple search methods are used together or fetch/server errors occur.
*
* @example
*
* // Valid:
* playerIttfId({ playerFullName: 'FAN Zhendong' });
* // output: {IttfId:"121404", PlayerFamilyNameFirst: "FAN Zhendong"}
* playerIttfId({ playerFamilyName: 'Lebrun'});
* // output: [{IttfId:"132992", PlayerFamilyNameFirst:"LEBRUN Alexis"},{IttfId:"135977", PlayerGivenName:"LEBRUN Felix"}]
*
*
* // Invalid:
* playerIttfId({ fullName: 'FAN', givenName: 'Zhendong' });
* Input the full name with the family name, then the given name. (e.g. "FAN Zhendong", "LEBRUN Alexis")
*
*/
playerIttfId(searchName) {
return __awaiter(this, void 0, void 0, function* () {
try {
//Ensures that only a single search parameter is used at a time
if (Object.values(searchName).length > 1) {
throw new Error("[ittf-pingpong] Only one of playerFullName, playerGivenName, or playerFamilyName should be provided");
}
//Ensures that only Latin alphabet characters and spaces are input for name searches
if (!this.isAlphabetic(Object.values(searchName))) {
throw new Error("[ittf-pingpong] Input cannot contain any numbers or special characters, only roman alphabet characters A-Z/a-z.");
}
let response = yield fetch(this.allPlayersUrl);
let responseJson = yield response.json();
let output = responseJson.Result;
//It is possible that multiple players will have the exact same Full Name ( e.g. John Smith), so all 3 searchName types return an Array
if ('playerFullName' in searchName) {
try {
let searchResult = output.filter((entries) => entries.PlayerFamilyNameFirst === searchName.playerFullName.replace(/^(\S+)\s+(.+)$/, (_, lastname, firstname) => `${lastname.toUpperCase()} ${firstname[0].toUpperCase()}${firstname.slice(1).toLowerCase()}`));
let filteredSearchResult = searchResult.map(({ IttfId, PlayerFamilyNameFirst }) => ({ IttfId, PlayerFamilyNameFirst }));
if (filteredSearchResult.length === 0) {
throw Error;
}
return filteredSearchResult;
}
catch (err) {
// console.log(err);
throw new Error(`[ittf-pingpong] Cannot find player's full name in the database. Ensure that family name comes first, followed by given name, and that there is a ranked ITTF player with this name (e.g. "ARUNA Quadri", and not "QUADRI Aruna")`);
}
}
else if ('playerGivenName' in searchName) {
try {
let searchResult = output.filter((entries) => { var _a; return entries.PlayerGivenName === ((_a = searchName.playerGivenName) === null || _a === void 0 ? void 0 : _a.charAt(0).toUpperCase()) + searchName.playerGivenName.slice(1).toLowerCase(); });
let filteredSearchResult = searchResult.map(({ IttfId, PlayerFamilyNameFirst }) => ({ IttfId, PlayerFamilyNameFirst }));
if (filteredSearchResult.length === 0) {
throw Error;
}
return filteredSearchResult;
}
catch (err) {
// console.log(err);
throw new Error(`[ittf-pingpong] Cannot find given name in the database. Ensure that the spelling is correct and that there is a ranked ITTF player with this given name (e.g. "Hugo", for Hugo Calderano, Hugo Hanashiro, etc.)`);
}
}
else {
try {
let searchResult = output.filter((entries) => { var _a; return entries.PlayerFamilyName === ((_a = searchName.playerFamilyName) === null || _a === void 0 ? void 0 : _a.toUpperCase()); });
let filteredSearchResult = searchResult.map(({ IttfId, PlayerFamilyNameFirst }) => ({ IttfId, PlayerFamilyNameFirst }));
if (filteredSearchResult.length === 0) {
throw Error;
}
return filteredSearchResult;
}
catch (err) {
throw new Error(`[ittf-pingpong] Cannot find family name in the database. Ensure that the spelling is correct and that there is a ranked ITTF player with this family name (e.g. "Lebrun", for Alexis Lebrun, Félix Lebrun, etc.)`);
}
}
}
catch (err) {
throw err;
}
});
}
;
/**
* Fetch the player's yearly match totals.
* @param {object} searchMethod The search parameters, mutually exclusive - you can only enter one of playerFullName or playerIttfId.
* @param {PlayerFullName} [searchMethod.playerFullName] Player's family name and given name. Ensure that the family name is capitalized and placed before the given name. (e.g. "FAN Zhendong", "CALDERANO Hugo")
* @param {IttfId} [searchMethod.playerIttfId] 0-6 digit integer. (e.g. 121404 - FAN Zhendong)
* @param {boolean} [options.includeExtendedDetails] If set to true, provides full player profile details rather than the base properties. Default value false.
*
* @returns {Promise<Stats>} Returns a player Stats object {player : PlayerProfile | PlayerExtendedProfile, ranking : RankingHistory, stats : GameStatistics}
* @throws {Error} When multiple search methods are used together or fetch/server errors occur. Only input the playerFullName or the playerIttfId - not both.
*
* @example
* // Valid:
* playerProfile({ playerFullName: 'FAN Zhendong' });
* // output: {player: {IttfId:"121404", Org:"CHN", Gender:"M",...}, ranking: {LastPos:[{...}], BestPos:[{...},{...},...]}, stats: {total:{[...]}, indiv:{[...]}, doubles:{[...]}}}
* playerProfile({ playerIttfId: 121404});
* // output: {player: {IttfId:"121404", Org:"CHN", Gender:"M",...}, ranking: {LastPos:[{...}], BestPos:[{...},{...},...]}, stats: {total:{[...]}, indiv:{[...]}, doubles:{[...]}}}
*
*/
playerProfile(searchMethod_1) {
return __awaiter(this, arguments, void 0, function* (searchMethod, options = { includeExtendedDetails: false }) {
try {
let profileUrl = this.playerProfileUrl;
let playerId;
//Ensures that only a single search parameter is used at a time
if (Object.values(searchMethod).length > 1) {
throw new Error("[ittf-pingpong] Only one of playerFullName or IttfId should be provided");
}
//Ensures that only Latin alphabet characters and spaces are input for name searches
if ('playerFullName' in searchMethod) {
if (!this.isAlphabetic(searchMethod.playerFullName)) {
throw new Error("[ittf-pingpong] Name cannot contain any numbers or special characters, only roman alphabet characters A-Z/a-z.");
}
const playerArray = yield this.playerIttfId({ playerFullName: searchMethod.playerFullName.replace(/^(\S+)\s+(.+)$/, (_, lastname, firstname) => `${lastname.toUpperCase()} ${firstname[0].toUpperCase()}${firstname.slice(1).toLowerCase()}`) });
if (playerArray.length === 1) {
playerId = playerArray[0].IttfId;
profileUrl += playerId;
}
}
//Ensures that only positive integers are input for ID searches
else {
if (!this.isPositiveInteger(searchMethod.playerIttfId)) {
throw new Error('[ittf-pingpong] Invalid ittfID! Format must be 0-6 digit integer.');
}
playerId = searchMethod.playerIttfId.toString();
profileUrl += playerId;
}
let response = yield fetch(profileUrl);
let output = yield response.json();
if (!output.player) {
throw new Error(`[ittf-pingpong] Cannot find player's IttfId in the database. Search using the player's Full Name or find for their IttfId with the playerIttfId method first.`);
}
if (options.includeExtendedDetails === true) {
let extraResponse = yield fetch(this.allPlayersUrl);
let extraJson = yield extraResponse.json();
let extrasAll = extraJson.Result;
let details = extrasAll.filter((entries) => entries.IttfId === playerId)[0];
output.player = details;
}
return output;
}
catch (err) {
throw err;
}
});
}
;
}
exports.ittfPingPong = ittfPingPong;
ittfPingPong.currentGender = ['M', 'W', 'X']; // Man, woman, mixed (doubles only)
ittfPingPong.currentCategory = ['S', 'D', 'DI']; // Singles, doubles ranking (pairs), doubles ranking (individual)
ittfPingPong.currentType = ['YOU', 'SEN']; // All 3 youth competition types lumped together, Seniors
;