import 'dotenv/config'; import { pinnacleRequest, getPsteryRelations, updateBaseEvents, notifyException } from "./libs/pinnacleClient.js"; import { Logs } from "./libs/logs.js"; import { getData, setData } from "./libs/cache.js"; const gamesMapCacheFile = 'data/gamesCache.json'; const globalDataCacheFile = 'data/globalDataCache.json'; const GLOBAL_DATA = { filtedLeagues: [], filtedGames: [], gamesMap: {}, straightFixturesVersion: 0, straightFixturesCount: 0, specialFixturesVersion: 0, specialFixturesCount: 0, straightOddsVersion: 0, // straightOddsCount: 0, specialsOddsVersion: 0, // specialsOddsCount: 0, requestErrorCount: 0, loopActive: false, loopResultTime: 0, }; 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 = 0; GLOBAL_DATA.specialFixturesCount = 0; } /** * 获取指定时区当前日期或时间 * @param {number} offsetHours - 时区相对 UTC 的偏移(例如:-4 表示 GMT-4) * @param {boolean} [withTime=false] - 是否返回完整时间(默认只返回日期) * @returns {string} 格式化的日期或时间字符串 */ const getDateInTimezone = (offsetHours, withTime = false) => { const nowUTC = new Date(); const targetTime = new Date(nowUTC.getTime() + offsetHours * 60 * 60 * 1000); const year = targetTime.getUTCFullYear(); const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0'); const day = String(targetTime.getUTCDate()).padStart(2, '0'); if (!withTime) { return `${year}-${month}-${day}`; } const hours = String(targetTime.getUTCHours()).padStart(2, '0'); const minutes = String(targetTime.getUTCMinutes()).padStart(2, '0'); const seconds = String(targetTime.getUTCSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } const pinnacleGet = async (endpoint, params) => { return pinnacleRequest({ endpoint, params, username: process.env.PINNACLE_USERNAME, password: process.env.PINNACLE_PASSWORD, proxy: process.env.NODE_HTTP_PROXY, }) .catch(err => { const source = { endpoint, params }; if (err?.response?.data) { const data = err.response.data; Object.assign(source, { data }); } err.source = source; return Promise.reject(err); }); }; // const pinnaclePost = async(endpoint, data) => { // return pinnacleRequest({ // endpoint, // data, // method: 'POST', // username: process.env.PINNACLE_USERNAME, // password: process.env.PINNACLE_PASSWORD, // proxy: process.env.NODE_HTTP_PROXY, // }); // }; const isArrayEqualUnordered = (arr1, arr2) => { if (arr1.length !== arr2.length) return false; const s1 = [...arr1].sort(); const s2 = [...arr2].sort(); return s1.every((v, i) => v === s2[i]); } const getFiltedGames = () => { return new Promise(resolve => { const updateFiltedGames = () => { getPsteryRelations() .then(res => { if (res.statusCode !== 200) { throw new Error(res.message); } // Logs.outDev('update filted games', res.data); const games = res.data.map(game => { const { eventId, leagueId } = game?.rel?.ps ?? {}; return { eventId, leagueId, }; }); const newFiltedLeagues = [...new Set(games.map(game => game.leagueId).filter(leagueId => leagueId))]; if (!isArrayEqualUnordered(newFiltedLeagues, GLOBAL_DATA.filtedLeagues)) { Logs.outDev('filted leagues changed', newFiltedLeagues); resetVersionsCount(); GLOBAL_DATA.filtedLeagues = newFiltedLeagues; } GLOBAL_DATA.filtedGames = games.map(game => game.eventId).filter(eventId => eventId); resolve(); setTimeout(updateFiltedGames, 1000 * 60); }) .catch(err => { Logs.err('failed to update filted games:', err.message); setTimeout(updateFiltedGames, 1000 * 5); }); } updateFiltedGames(); }); } const getStraightFixtures = async () => { if (!GLOBAL_DATA.filtedLeagues.length) { return Promise.reject(new Error('no filted leagues')); } const leagueIds = GLOBAL_DATA.filtedLeagues.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 }; }); } 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]; } }); } }); } const getStraightOdds = async () => { if (!GLOBAL_DATA.filtedLeagues.length) { return Promise.reject(new Error('no filted leagues')); } const leagueIds = GLOBAL_DATA.filtedLeagues.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?.map(item => { const { periods, ...rest } = item; const period = periods?.find(period => period.number == 0); if (!period) { return rest; } return { ...rest, period }; }) ?? []; }); } const updateStraightOdds = async () => { return getStraightOdds() .then(games => { if (games.length) { const { gamesMap } = GLOBAL_DATA; games.forEach(game => { const { id, ...rest } = game; const localGame = gamesMap[id]; if (localGame) { Object.assign(localGame, rest); } }); } }); } const getSpecialFixtures = async () => { if (!GLOBAL_DATA.filtedLeagues.length) { return Promise.reject(new Error('no filted leagues')); } const leagueIds = GLOBAL_DATA.filtedLeagues.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 }, ...rest } = special ?? { event: {} }; return { eventId, ...rest }; }) ?? []; }) .flat() .filter(special => { if (special.name != 'Winning Margin' && special.name != 'Exact Total Goals') { return false; } return true; }) ?? []; const update = since == 0 ? 'full' : 'increment'; return { specials, update }; }); } 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; } 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]; if (localSpecials.winningMargin && remoteSpecials.winningMargin) { const { contestants: winningMarginContestants, ...winningMarginRest } = remoteSpecials.winningMargin; Object.assign(localSpecials.winningMargin, winningMarginRest); mergeContestants(localSpecials.winningMargin.contestants, winningMarginContestants); } else if (localSpecials.winningMargin && !remoteSpecials.winningMargin) { Logs.outDev('delete winningMargin', localSpecials.winningMargin); delete localSpecials.winningMargin; } else if (remoteSpecials.winningMargin) { localSpecials.winningMargin = remoteSpecials.winningMargin; } if (localSpecials.exactTotalGoals && remoteSpecials.exactTotalGoals) { const { contestants: exactTotalGoalsContestants, ...exactTotalGoalsRest } = remoteSpecials.exactTotalGoals; Object.assign(localSpecials.exactTotalGoals, exactTotalGoalsRest); mergeContestants(localSpecials.exactTotalGoals.contestants, exactTotalGoalsContestants); } else if (localSpecials.exactTotalGoals && !remoteSpecials.exactTotalGoals) { Logs.outDev('delete exactTotalGoals', localSpecials.exactTotalGoals); delete localSpecials.exactTotalGoals; } else if (remoteSpecials.exactTotalGoals) { localSpecials.exactTotalGoals = remoteSpecials.exactTotalGoals; } }); } }); } const getSpecialsOdds = async () => { if (!GLOBAL_DATA.filtedLeagues.length) { return Promise.reject(new Error('no filted leagues')); } const leagueIds = GLOBAL_DATA.filtedLeagues.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); }); } 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; }); } }); } 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 => { const { id } = league; const filtedLeaguesSet = new Set(GLOBAL_DATA.filtedLeagues); return filtedLeaguesSet.has(id); }).flatMap(league => league.events); }); } const updateInRunning = async () => { return getInRunning() .then(games => { if (!games.length) { return; } const { gamesMap } = GLOBAL_DATA; games.forEach(game => { const { id, state, elapsed } = game; const localGame = gamesMap[id]; if (localGame) { Object.assign(localGame, { state, elapsed }); } }); }); } const ratioAccept = (ratio) => { if (ratio > 0) { return 'a'; } return ''; } const ratioString = (ratio) => { ratio = Math.abs(ratio); ratio = ratio.toString(); ratio = ratio.replace(/\./, ''); return ratio; } const parseSpreads = (spreads, wm) => { // 让分盘 if (!spreads?.length) { return null; } const events = {}; spreads.forEach(spread => { const { hdp, home, away } = spread; if (!(hdp % 1) || !!(hdp % 0.5)) { // 整数或不能被0.5整除的让分盘不处理 return; } const ratio_ro = hdp; const ratio_r = ratio_ro - wm; events[`ior_r${ratioAccept(ratio_r)}h_${ratioString(ratio_r)}`] = { v: home, r: wm != 0 ? `ior_r${ratioAccept(ratio_ro)}h_${ratioString(ratio_ro)}` : undefined }; events[`ior_r${ratioAccept(-ratio_r)}c_${ratioString(ratio_r)}`] = { v: away, r: wm != 0 ? `ior_r${ratioAccept(-ratio_ro)}c_${ratioString(ratio_ro)}` : undefined }; }); return events; } const parseMoneyline = (moneyline) => { // 胜平负 if (!moneyline) { return null; } const { home, away, draw } = moneyline; return { 'ior_mh': { v: home }, 'ior_mc': { v: away }, 'ior_mn': { v: draw }, } } const parseTotals = (totals) => { // 大小球盘 if (!totals?.length) { return null; } const events = {}; totals.forEach(total => { const { points, over, under } = total; events[`ior_ouc_${ratioString(points)}`] = { v: over }; events[`ior_ouh_${ratioString(points)}`] = { v: under }; }); return events; } const parsePeriod = (period, wm) => { const { cutoff='', status=0, spreads=[], moneyline={}, totals=[] } = period; const cutoffTime = new Date(cutoff).getTime(); const nowTime = Date.now(); if (status != 1 || cutoffTime < nowTime) { return null; } const events = {}; Object.assign(events, parseSpreads(spreads, wm)); Object.assign(events, parseMoneyline(moneyline)); Object.assign(events, parseTotals(totals)); return events; } const parseWinningMargin = (winningMargin, home, away) => { const { cutoff='', status='', contestants=[] } = winningMargin; const cutoffTime = new Date(cutoff).getTime(); const nowTime = Date.now(); if (status != 'O' || cutoffTime < nowTime || !contestants?.length) { return null; } const events = {}; contestants.forEach(contestant => { const { name, price } = contestant; const nr = name.match(/\d+$/)?.[0]; if (!nr) { return; } let side; if (name.startsWith(home)) { side = 'h'; } else if (name.startsWith(away)) { side = 'c'; } else { return; } events[`ior_wm${side}_${nr}`] = { v: price }; }); return events; } const parseExactTotalGoals = (exactTotalGoals) => { const { cutoff='', status='', contestants=[] } = exactTotalGoals; const cutoffTime = new Date(cutoff).getTime(); const nowTime = Date.now(); if (status != 'O' || cutoffTime < nowTime || !contestants?.length) { return null; } const events = {}; contestants.forEach(contestant => { const { name, price } = contestant; if (+name >= 1 && +name <= 7) { events[`ior_ot_${name}`] = { v: price }; } }); return events; } const parseRbState = (state) => { let stage = null; if (state == 1) { stage = '1H'; } else if (state == 2) { stage = 'HT'; } else if (state == 3) { stage = '2H'; } return stage; } const parseGame = (game) => { const { eventId=0, originId=0, period={}, specials={}, home, away, marketType, state, elapsed, homeScore=0, awayScore=0 } = game; const { winningMargin={}, exactTotalGoals={} } = specials; const wm = homeScore - awayScore; const score = `${homeScore}-${awayScore}`; const events = parsePeriod(period, wm) ?? {}; const stage = parseRbState(state); const retime = elapsed ? `${elapsed}'` : ''; const evtime = Date.now(); Object.assign(events, parseWinningMargin(winningMargin, home, away)); Object.assign(events, parseExactTotalGoals(exactTotalGoals)); return { eventId, originId, events, evtime, stage, retime, score, wm, marketType }; } const getGames = () => { const { filtedGames, gamesMap } = GLOBAL_DATA; const filtedGamesSet = new Set(filtedGames); 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 && filtedGamesSet.has(id)) { actived = true; // 在赛前列表中 game.eventId = id; game.originId = 0; } else if (liveStatus == 1 && filtedGamesSet.has(parentId)) { actived = true; // 在滚球列表中 game.eventId = parentId; game.originId = id; } if (actived) { const gameInfo = parseGame(game); const { marketType, ...rest } = gameInfo; if (!gamesData[marketType]) { gamesData[marketType] = []; } gamesData[marketType].push(rest); } }); return gamesData; } 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.loopResultTime; GLOBAL_DATA.loopResultTime = nowTime; if (loopDuration > 15000) { Logs.out('loop duration is too long', loopDuration); } else { Logs.outDev('loop duration', loopDuration); } const { straightFixturesVersion: sfv, specialFixturesVersion: pfv, straightOddsVersion: sov, specialsOddsVersion: pov } = GLOBAL_DATA; const timestamp = Math.max(sfv, pfv, sov, pov); const games = getGames(); const data = { games, timestamp }; updateBaseEvents(data); setData(gamesMapCacheFile, GLOBAL_DATA.gamesMap) .then(() => { Logs.outDev('games map saved'); }) .catch(err => { Logs.err('failed to save games map', err.message); }); }) .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]; } }); }); } // 监听进程退出事件,保存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); }); (() => { if (!process.env.PINNACLE_USERNAME || !process.env.PINNACLE_PASSWORD) { Logs.err('USERNAME or PASSWORD is not set'); return; } loadGlobalDataFromCache() .then(() => { Logs.out('global data loaded'); }) .catch(err => { Logs.err('failed to load global data', err.message); }) .finally(() => { GLOBAL_DATA.loopResultTime = Date.now(); GLOBAL_DATA.loopActive = true; return getFiltedGames(); }) .then(pinnacleDataLoop); })();