import { writeFileSync } from 'fs'; import 'dotenv/config'; import { pinnacleRequest, getPsteryRelations, updateBaseEvents, notifyException } from "./libs/pinnacleClient.js"; import { Logs } from "./libs/logs.js"; const cacheFilePath = 'data/gamesCache.json'; const GLOBAL_DATA = { filtedLeagues: [], filtedGames: [], gamesMap: {}, straightFixturesVersion: 0, straightFixturesCount: 0, specialFixturesVersion: 0, straightOddsVersion: 0, specialsOddsVersion: 0, requestErrorCount: 0, loopActive: false, }; /** * 获取指定时区当前日期或时间 * @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 updateFiltedGames = async () => { return getPsteryRelations() .then(res => { if (res.statusCode !== 200) { throw new Error(`Failed to update filted leagues: ${res.message}`); } Logs.outDev('res', res); const games = res.data.map(game => { const { eventId, leagueId } = game?.rel?.ps ?? {}; return { eventId, leagueId, }; }); GLOBAL_DATA.filtedLeagues = [...new Set(games.map(game => game.leagueId).filter(leagueId => leagueId))]; GLOBAL_DATA.filtedGames = games.map(game => game.eventId).filter(eventId => eventId); }) .catch(err => { Logs.err(err.message); }) .finally(() => { setTimeout(updateFiltedGames, 1000 * 30); }); } const getStraightFixtures = async () => { const leagueIds = GLOBAL_DATA.filtedLeagues.join(','); let since = GLOBAL_DATA.straightFixturesVersion; if (GLOBAL_DATA.straightFixturesCount > 12) { since = 0; GLOBAL_DATA.straightFixturesCount = 0; } GLOBAL_DATA.straightFixturesCount++; 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 () => { const leagueIds = GLOBAL_DATA.filtedLeagues.join(','); const since = GLOBAL_DATA.straightOddsVersion; 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) ?? {}; 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 () => { const leagueIds = GLOBAL_DATA.filtedLeagues.join(','); const since = GLOBAL_DATA.specialFixturesVersion; 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; }); return specials ?? []; }); } const updateSpecialFixtures = async () => { return getSpecialFixtures() .then(specials => { 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) { Logs.out('delete winningMargin', localSpecials.winningMargin); delete localSpecials.winningMargin; } else if (!localSpecials.winningMargin && remoteSpecials.winningMargin) { // Logs.out('add winningMargin', remoteSpecials.winningMargin); localSpecials.winningMargin = remoteSpecials.winningMargin; } if (localSpecials.exactTotalGoals && !remoteSpecials.exactTotalGoals) { Logs.out('delete exactTotalGoals', localSpecials.exactTotalGoals); delete localSpecials.exactTotalGoals; } else if (!localSpecials.exactTotalGoals && remoteSpecials.exactTotalGoals) { // Logs.out('add exactTotalGoals', remoteSpecials.exactTotalGoals); localSpecials.exactTotalGoals = remoteSpecials.exactTotalGoals; } }); } }); } const getSpecialsOdds = async () => { const leagueIds = GLOBAL_DATA.filtedLeagues.join(','); const since = GLOBAL_DATA.specialsOddsVersion; 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 parsePeriod = (period, wm) => { const { cutoff='', status=0, spreads=[], moneyline={} } = 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)); 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(() => { GLOBAL_DATA.requestErrorCount = 0; 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); Logs.outDev('games data', data); writeFileSync(cacheFilePath, JSON.stringify(GLOBAL_DATA.gamesMap, null, 2)); }) .catch(err => { GLOBAL_DATA.requestErrorCount++; if (GLOBAL_DATA.requestErrorCount > 10) { GLOBAL_DATA.loopActive = false; notifyException('Pinnacle API request errors have reached the limit.'); } Logs.err(err.message, err.source); }) .finally(() => { if (!GLOBAL_DATA.loopActive) { return; } setTimeout(pinnacleDataLoop, 1000 * 5); }); } (() => { if (!process.env.PINNACLE_USERNAME || !process.env.PINNACLE_PASSWORD) { Logs.err('USERNAME or PASSWORD is not set'); return; } GLOBAL_DATA.loopActive = true; updateFiltedGames().then(pinnacleDataLoop); })();