UNPKG

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
"use strict"; 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 ;