import path from "path"; import { fileURLToPath } from "url"; import Logs from "./libs/logs.js"; import { getData, setData } from "./libs/cache.js"; import getDateInTimezone from "./libs/getDateInTimezone.js"; import { pinnacleGet, pinnacleWebLeagues, pinnacleWebGames, platformPost, platformGet, notifyException, } from "./libs/pinnacleClient.js"; import parseGame from "./libs/parseGameData.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const gamesMapCacheFile = path.join(__dirname, './cache/pinnacleGamesCache.json'); const globalDataCacheFile = path.join(__dirname, './cache/pinnacleGlobalDataCache.json'); const GLOBAL_DATA = { filteredLeagues: [], filteredGames: [], gamesMap: {}, straightFixturesVersion: 0, straightFixturesCount: 0, specialFixturesVersion: 0, specialFixturesCount: 0, straightOddsVersion: 0, // straightOddsCount: 0, specialsOddsVersion: 0, // specialsOddsCount: 0, requestErrorCount: 0, loopActive: false, loopLastResultTime: 0, wsClientData: null, }; const resetVersionsCount = () => { GLOBAL_DATA.straightFixturesVersion = 0; GLOBAL_DATA.specialFixturesVersion = 0; GLOBAL_DATA.straightOddsVersion = 0; GLOBAL_DATA.specialsOddsVersion = 0; GLOBAL_DATA.straightFixturesCount = 0; GLOBAL_DATA.specialFixturesCount = 0; } const incrementVersionsCount = () => { GLOBAL_DATA.straightFixturesCount += 1; GLOBAL_DATA.specialFixturesCount += 1; } /** * 更新Web联赛列表 */ const updateWebLeagues = async () => { const getWebLeaguesToday = pinnacleWebLeagues(1); const getWebLeaguesTomorrow = pinnacleWebLeagues(0); return Promise.all([getWebLeaguesToday, getWebLeaguesTomorrow]) .then(data => { const [webLeaguesToday, webLeaguesTomorrow] = data; const leaguesMap = new Map(webLeaguesToday.concat(webLeaguesTomorrow)); return Array.from(leaguesMap.values()).sort((a, b) => a.id - b.id); }) .then(leagues => { Logs.outDev('update leagues list', leagues); return platformPost('/api/platforms/update_leagues', { platform: 'pinnacle', leagues }); }) .then(() => { Logs.outDev('leagues list updated'); return Promise.resolve(60); }); } /** * 定时更新Web联赛列表 */ const updateWebLeaguesLoop = () => { updateWebLeagues() .then(delay => { setTimeout(() => { updateWebLeaguesLoop(); }, 1000 * delay); }) .catch(err => { Logs.err('failed to update leagues list', err.message); setTimeout(() => { updateWebLeaguesLoop(); }, 1000 * 5); }); } /** * 更新Web比赛列表 */ const updateWebGames = async () => { if (!GLOBAL_DATA.filteredLeagues.length) { return Promise.resolve(5); } const getWebGamesToday = pinnacleWebGames(GLOBAL_DATA.filteredLeagues, 1); const getWebGamesTomorrow = pinnacleWebGames(GLOBAL_DATA.filteredLeagues, 0); return Promise.all([getWebGamesToday, getWebGamesTomorrow]) .then(data => { const [webGamesToday, webGamesTomorrow] = data; return webGamesToday.concat(webGamesTomorrow).sort((a, b) => a.timestamp - b.timestamp); }) .then(games => { Logs.outDev('update games list', games); return platformPost('/api/platforms/update_games', { platform: 'pinnacle', games }); }) .then(() => { Logs.outDev('games list updated'); return Promise.resolve(60); }); } /** * 定时更新Web比赛列表 */ const updateWebGamesLoop = () => { updateWebGames() .then(delay => { setTimeout(() => { updateWebGamesLoop(); }, 1000 * delay); }) .catch(err => { Logs.err('failed to update games list', err.message); setTimeout(() => { updateWebGamesLoop(); }, 1000 * 5); }); } /** * 获取过滤后的联赛数据 * @returns */ const updateFilteredLeagues = () => { platformGet('/api/platforms/get_filtered_leagues', { platform: 'pinnacle' }) .then(res => { const { data: filteredLeagues } = res; GLOBAL_DATA.filteredLeagues = filteredLeagues.map(item => item.id); }) .catch(error => { Logs.err('failed to update filtered leagues', error.message); }) .finally(() => { setTimeout(() => { updateFilteredLeagues(); }, 1000 * 10); }); } /** * 获取过滤后的比赛数据 * @returns */ const updateFilteredGames = () => { platformGet('/api/platforms/get_filtered_games', { platform: 'pinnacle' }) .then(res => { const { data: filteredGames } = res; GLOBAL_DATA.filteredGames = filteredGames.map(item => item.id); }) .catch(error => { Logs.err('failed to update filtered games', error.message); }) .finally(() => { setTimeout(() => { updateFilteredGames(); }, 1000 * 10); }); } /** * 获取直赛数据 * @returns */ const getStraightFixtures = async () => { if (!GLOBAL_DATA.filteredLeagues.length) { resetVersionsCount(); return Promise.reject(new Error('no filtered leagues', { cause: 400 })); } const leagueIds = GLOBAL_DATA.filteredLeagues.join(','); let since = GLOBAL_DATA.straightFixturesVersion; if (GLOBAL_DATA.straightFixturesCount >= 12) { since = 0; GLOBAL_DATA.straightFixturesCount = 0; } if (since == 0) { Logs.outDev('full update straight fixtures'); } return pinnacleGet('/v3/fixtures', { sportId: 29, leagueIds, since }) .then(data => { const { league, last } = data; if (!last) { return {}; } GLOBAL_DATA.straightFixturesVersion = last; const games = league?.map(league => { const { id: leagueId, events } = league; return events?.map(event => { const { starts } = event; const timestamp = new Date(starts).getTime(); return { leagueId, ...event, timestamp }; }); }) .flat() ?? []; const update = since == 0 ? 'full' : 'increment'; return { games, update }; }); } /** * 更新直赛数据 * @returns */ const updateStraightFixtures = async () => { return getStraightFixtures() .then(data => { const { games, update } = data ?? {}; const { gamesMap } = GLOBAL_DATA; if (games?.length) { games.forEach(game => { const { id } = game; if (!gamesMap[id]) { gamesMap[id] = game; } else { Object.assign(gamesMap[id], game); } }); } if (update && update == 'full') { const gamesSet = new Set(games.map(game => game.id)); Object.keys(gamesMap).forEach(key => { if (!gamesSet.has(+key)) { delete gamesMap[key]; } }); } return Promise.resolve('StraightFixtures'); }); } /** * 获取直赛赔率数据 * @returns */ const getStraightOdds = async () => { if (!GLOBAL_DATA.filteredLeagues.length) { resetVersionsCount(); return Promise.reject(new Error('no filtered leagues', { cause: 400 })); } const leagueIds = GLOBAL_DATA.filteredLeagues.join(','); const since = GLOBAL_DATA.straightOddsVersion; // if (GLOBAL_DATA.straightOddsCount >= 27) { // since = 0; // GLOBAL_DATA.straightOddsCount = 3; // } if (since == 0) { Logs.outDev('full update straight odds'); } return pinnacleGet('/v3/odds', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since }) .then(data => { const { leagues, last } = data; if (!last) { return []; } GLOBAL_DATA.straightOddsVersion = last; const games = leagues?.flatMap(league => league.events); // return games; return games?.map(item => { const { periods, ...rest } = item; const straight = periods?.find(period => period.number == 0); return { ...rest, periods: { straight }}; }) ?? []; }); } /** * 更新直赛赔率数据 * @returns */ const updateStraightOdds = async () => { return getStraightOdds() .then(games => { if (games?.length) { const { gamesMap } = GLOBAL_DATA; games.forEach(game => { const { id, periods, ...rest } = game; const localGame = gamesMap[id]; if (localGame) { Object.assign(localGame, rest); if (!localGame.periods && periods) { localGame.periods = periods; } else if (periods) { Object.assign(localGame.periods, periods); } } }); } return Promise.resolve('StraightOdds'); }); } /** * 获取特殊赛数据 * @returns */ const getSpecialFixtures = async () => { if (!GLOBAL_DATA.filteredLeagues.length) { resetVersionsCount(); return Promise.reject(new Error('no filtered leagues', { cause: 400 })); } const leagueIds = GLOBAL_DATA.filteredLeagues.join(','); let since = GLOBAL_DATA.specialFixturesVersion; if (GLOBAL_DATA.specialFixturesCount >= 18) { since = 0; GLOBAL_DATA.specialFixturesCount = 6; } if (since == 0) { Logs.outDev('full update special fixtures'); } return pinnacleGet('/v2/fixtures/special', { sportId: 29, leagueIds, since }) .then(data => { const { leagues, last } = data; if (!last) { return []; } GLOBAL_DATA.specialFixturesVersion = last; const specials = leagues?.map(league => { const { specials } = league; return specials?.filter(special => special.event) .map(special => { const { event: { id: eventId, periodNumber }, ...rest } = special ?? { event: {} }; return { eventId, periodNumber, ...rest }; }) ?? []; }) .flat() .filter(special => { if (special.name.includes('Winning Margin') || special.name.includes('Exact Total Goals')) { return true; } return false; }) ?? []; const update = since == 0 ? 'full' : 'increment'; return { specials, update }; }); } /** * 合并特殊赛盘口数据 * @param {*} localContestants * @param {*} remoteContestants * @returns */ const mergeContestants = (localContestants=[], remoteContestants=[]) => { const localContestantsMap = new Map(localContestants.map(contestant => [contestant.id, contestant])); remoteContestants.forEach(contestant => { const localContestant = localContestantsMap.get(contestant.id); if (localContestant) { Object.assign(localContestant, contestant); } else { localContestants.push(contestant); } }); const remoteContestantsMap = new Map(remoteContestants.map(contestant => [contestant.id, contestant])); for (let i = localContestants.length - 1; i >= 0; i--) { if (!remoteContestantsMap.has(localContestants[i].id)) { localContestants.splice(i, 1); } } return localContestants; } /** * 合并特殊赛数据 * @param {*} localSpecials * @param {*} remoteSpecials * @param {*} specialName * @returns */ const mergeSpecials = (localSpecials, remoteSpecials, specialName) => { if (localSpecials[specialName] && remoteSpecials[specialName]) { const { contestants: specialContestants, ...specialRest } = remoteSpecials[specialName]; Object.assign(localSpecials[specialName], specialRest); mergeContestants(localSpecials[specialName].contestants, specialContestants); } else if (remoteSpecials[specialName]) { localSpecials[specialName] = remoteSpecials[specialName]; } } /** * 更新特殊赛数据 * @returns */ const updateSpecialFixtures = async () => { return getSpecialFixtures() .then(data => { const { specials, update } = data ?? {}; if (specials?.length) { const { gamesMap } = GLOBAL_DATA; const gamesSpecialsMap = {}; specials.forEach(special => { const { eventId } = special; if (!gamesSpecialsMap[eventId]) { gamesSpecialsMap[eventId] = {}; } if (special.name == 'Winning Margin') { gamesSpecialsMap[eventId].winningMargin = special; } else if (special.name == 'Exact Total Goals') { gamesSpecialsMap[eventId].exactTotalGoals = special; } }); Object.keys(gamesSpecialsMap).forEach(eventId => { if (!gamesMap[eventId]) { return; } if (!gamesMap[eventId].specials) { gamesMap[eventId].specials = {}; } const localSpecials = gamesMap[eventId].specials; const remoteSpecials = gamesSpecialsMap[eventId]; mergeSpecials(localSpecials, remoteSpecials, 'winningMargin'); mergeSpecials(localSpecials, remoteSpecials, 'exactTotalGoals'); }); } return Promise.resolve('SpecialFixtures'); }); } /** * 获取特殊赛赔率数据 * @returns */ const getSpecialsOdds = async () => { if (!GLOBAL_DATA.filteredLeagues.length) { resetVersionsCount(); return Promise.reject(new Error('no filtered leagues', { cause: 400 })); } const leagueIds = GLOBAL_DATA.filteredLeagues.join(','); const since = GLOBAL_DATA.specialsOddsVersion; // if (GLOBAL_DATA.specialsOddsCount >= 33) { // since = 0; // GLOBAL_DATA.specialsOddsCount = 9; // } if (since == 0) { Logs.outDev('full update specials odds'); } return pinnacleGet('/v2/odds/special', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since }) .then(data => { const { leagues, last } = data; if (!last) { return []; } GLOBAL_DATA.specialsOddsVersion = last; return leagues?.flatMap(league => league.specials); }); } /** * 更新特殊赛赔率数据 * @returns */ const updateSpecialsOdds = async () => { return getSpecialsOdds() .then(specials => { if (specials?.length) { const { gamesMap } = GLOBAL_DATA; const contestants = Object.values(gamesMap) .filter(game => game.specials) .map(game => { const { specials } = game; const contestants = Object.values(specials).map(special => { const { contestants } = special; return contestants.map(contestant => [contestant.id, contestant]); }); return contestants; }).flat(2); const contestantsMap = new Map(contestants); const lines = specials.flatMap(special => special.contestantLines); lines.forEach(line => { const { id, handicap, lineId, max, price } = line; const contestant = contestantsMap.get(id); if (!contestant) { return; } contestant.handicap = handicap; contestant.lineId = lineId; contestant.max = max; contestant.price = price; }); } return Promise.resolve('SpecialsOdds'); }); } /** * 获取滚球数据 * @returns */ const getInRunning = async () => { return pinnacleGet('/v2/inrunning') .then(data => { const sportId = 29; const leagues = data.sports?.find(sport => sport.id == sportId)?.leagues ?? []; return leagues.filter(league => { if (!GLOBAL_DATA.filteredLeagues.length) { return true; } const { id } = league; const filteredLeaguesSet = new Set(GLOBAL_DATA.filteredLeagues); return filteredLeaguesSet.has(id); }).flatMap(league => league.events); }); } /** * 更新滚球数据 * @returns */ const updateInRunning = async () => { return getInRunning() .then(games => { if (!games.length) { return Promise.resolve('InRunning'); } const { gamesMap } = GLOBAL_DATA; games.forEach(game => { const { id, state, elapsed } = game; const localGame = gamesMap[id]; if (localGame) { Object.assign(localGame, { state, elapsed }); } }); return Promise.resolve('InRunning'); }); } /** * 获取比赛盘口赔率数据 * @returns {Object} */ const getGamesEvents = () => { const { filteredGames, gamesMap, straightFixturesVersion: sfv, specialFixturesVersion: pfv, straightOddsVersion: sov, specialsOddsVersion: pov } = GLOBAL_DATA; const filteredGamesSet = new Set(filteredGames); const nowTime = Date.now(); const gamesData = {}; Object.values(gamesMap).forEach(game => { const { id, liveStatus, parentId, resultingUnit, timestamp } = game; if (resultingUnit !== 'Regular') { return false; // 非常规赛事不处理 } const gmtMinus4Date = getDateInTimezone(-4); const todayEndTime = new Date(`${gmtMinus4Date} 23:59:59 GMT-4`).getTime(); const tomorrowEndTime = todayEndTime + 24 * 60 * 60 * 1000; if (liveStatus == 1 && timestamp < nowTime) { game.marketType = 2; // 滚球赛事 } else if (liveStatus != 1 && timestamp > nowTime && timestamp <= todayEndTime) { game.marketType = 1; // 今日赛事 } else if (liveStatus != 1 && timestamp > todayEndTime && timestamp <= tomorrowEndTime) { game.marketType = 0; // 明日早盘赛事 } else { game.marketType = -1; // 非近期赛事 } if (game.marketType < 0) { return false; // 非近期赛事不处理 } let actived = false; if (liveStatus != 1 && (filteredGamesSet.has(id) || !filteredGames.length)) { actived = true; // 在赛前列表中 game.id = id; game.originId = 0; } else if (liveStatus == 1 && (filteredGamesSet.has(parentId) || !filteredGames.length)) { actived = true; // 在滚球列表中 game.id = parentId; game.originId = id; } if (actived) { const { marketType, ...rest } = parseGame(game); if (!gamesData[marketType]) { gamesData[marketType] = []; } gamesData[marketType].push(rest); } }); const games = Object.values(gamesData).flat(); const timestamp = Math.max(sfv, pfv, sov, pov); const data = { games, timestamp }; return data; } /** * 更新赔率数据 */ const updateOdds = async () => { const { games, timestamp } = getGamesEvents(); return platformPost('/api/platforms/update_odds', { platform: 'pinnacle', games, timestamp }); } const pinnacleDataLoop = () => { updateStraightFixtures() .then(() => { return Promise.all([ updateStraightOdds(), updateSpecialFixtures(), updateInRunning(), ]); }) .then(() => { return updateSpecialsOdds(); }) .then(() => { if (!GLOBAL_DATA.loopActive) { GLOBAL_DATA.loopActive = true; Logs.out('loop active'); notifyException('Pinnacle API startup.'); } if (GLOBAL_DATA.requestErrorCount > 0) { GLOBAL_DATA.requestErrorCount = 0; Logs.out('request error count reset'); } const nowTime = Date.now(); const loopDuration = nowTime - GLOBAL_DATA.loopLastResultTime; GLOBAL_DATA.loopLastResultTime = nowTime; if (loopDuration > 15000) { Logs.out('loop duration is too long', loopDuration); } else { Logs.outDev('loop duration', loopDuration); } setData(gamesMapCacheFile, GLOBAL_DATA.gamesMap); return updateOdds(); }) .catch(err => { Logs.err(err.message, err.source); GLOBAL_DATA.requestErrorCount++; if (GLOBAL_DATA.loopActive && GLOBAL_DATA.requestErrorCount > 5) { const exceptionMessage = 'request errors have reached the limit'; Logs.out(exceptionMessage); GLOBAL_DATA.loopActive = false; Logs.out('loop inactive'); notifyException(`Pinnacle API paused. ${exceptionMessage}. ${err.message}`); } }) .finally(() => { const { loopActive } = GLOBAL_DATA; let loopDelay = 1000 * 5; if (!loopActive) { loopDelay = 1000 * 60; resetVersionsCount(); } else { incrementVersionsCount(); } setTimeout(pinnacleDataLoop, loopDelay); }); } /** * 缓存GLOBAL_DATA数据到文件 */ const saveGlobalDataToCache = async () => { return setData(globalDataCacheFile, GLOBAL_DATA); } const loadGlobalDataFromCache = async () => { return getData(globalDataCacheFile) .then(data => { if (!data) { return; } Object.keys(GLOBAL_DATA).forEach(key => { if (key in data) { GLOBAL_DATA[key] = data[key]; } }); }); } const main = () => { if (!process.env.PINNACLE_USERNAME || !process.env.PINNACLE_PASSWORD) { Logs.err('USERNAME or PASSWORD is not set'); return; } updateFilteredLeagues(); updateFilteredGames(); loadGlobalDataFromCache() .then(() => { Logs.out('global data loaded'); }) .then(() => { updateWebLeaguesLoop(); updateWebGamesLoop(); pinnacleDataLoop(); }) .catch(err => { Logs.err('failed to load global data', err.message); }) .finally(() => { GLOBAL_DATA.loopLastResultTime = Date.now(); GLOBAL_DATA.loopActive = true; }); } // 监听进程退出事件,保存GLOBAL_DATA数据 const saveExit = (code) => { saveGlobalDataToCache() .then(() => { Logs.out('global data saved'); }) .catch(err => { Logs.err('failed to save global data', err.message); }) .finally(() => { process.exit(code); }); } process.on('SIGINT', () => { saveExit(0); }); process.on('SIGTERM', () => { saveExit(0); }); process.on('SIGUSR2', () => { saveExit(0); }); main();