main.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. import { writeFileSync } from 'fs';
  2. import 'dotenv/config';
  3. import { pinnacleRequest, getPsteryRelations, updateBaseEvents, notifyException } from "./libs/pinnacleClient.js";
  4. import { Logs } from "./libs/logs.js";
  5. const cacheFilePath = 'data/gamesCache.json';
  6. const GLOBAL_DATA = {
  7. filtedLeagues: [],
  8. filtedGames: [],
  9. gamesMap: {},
  10. straightFixturesVersion: 0,
  11. straightFixturesCount: 0,
  12. specialFixturesVersion: 0,
  13. straightOddsVersion: 0,
  14. specialsOddsVersion: 0,
  15. requestErrorCount: 0,
  16. loopActive: false,
  17. };
  18. /**
  19. * 获取指定时区当前日期或时间
  20. * @param {number} offsetHours - 时区相对 UTC 的偏移(例如:-4 表示 GMT-4)
  21. * @param {boolean} [withTime=false] - 是否返回完整时间(默认只返回日期)
  22. * @returns {string} 格式化的日期或时间字符串
  23. */
  24. const getDateInTimezone = (offsetHours, withTime = false) => {
  25. const nowUTC = new Date();
  26. const targetTime = new Date(nowUTC.getTime() + offsetHours * 60 * 60 * 1000);
  27. const year = targetTime.getUTCFullYear();
  28. const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
  29. const day = String(targetTime.getUTCDate()).padStart(2, '0');
  30. if (!withTime) {
  31. return `${year}-${month}-${day}`;
  32. }
  33. const hours = String(targetTime.getUTCHours()).padStart(2, '0');
  34. const minutes = String(targetTime.getUTCMinutes()).padStart(2, '0');
  35. const seconds = String(targetTime.getUTCSeconds()).padStart(2, '0');
  36. return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  37. }
  38. const pinnacleGet = async (endpoint, params) => {
  39. return pinnacleRequest({
  40. endpoint,
  41. params,
  42. username: process.env.PINNACLE_USERNAME,
  43. password: process.env.PINNACLE_PASSWORD,
  44. proxy: process.env.NODE_HTTP_PROXY,
  45. })
  46. .catch(err => {
  47. const source = { endpoint, params };
  48. if (err?.response?.data) {
  49. const data = err.response.data;
  50. Object.assign(source, data);
  51. }
  52. err.source = source;
  53. return Promise.reject(err);
  54. });
  55. };
  56. // const pinnaclePost = async(endpoint, data) => {
  57. // return pinnacleRequest({
  58. // endpoint,
  59. // data,
  60. // method: 'POST',
  61. // username: process.env.PINNACLE_USERNAME,
  62. // password: process.env.PINNACLE_PASSWORD,
  63. // proxy: process.env.NODE_HTTP_PROXY,
  64. // });
  65. // };
  66. const updateFiltedGames = async () => {
  67. return getPsteryRelations()
  68. .then(res => {
  69. if (res.statusCode !== 200) {
  70. throw new Error(`Failed to update filted leagues: ${res.message}`);
  71. }
  72. Logs.outDev('res', res);
  73. const games = res.data.map(game => {
  74. const { eventId, leagueId } = game?.rel?.ps ?? {};
  75. return {
  76. eventId,
  77. leagueId,
  78. };
  79. });
  80. GLOBAL_DATA.filtedLeagues = [...new Set(games.map(game => game.leagueId).filter(leagueId => leagueId))];
  81. GLOBAL_DATA.filtedGames = games.map(game => game.eventId).filter(eventId => eventId);
  82. })
  83. .catch(err => {
  84. Logs.err(err.message);
  85. })
  86. .finally(() => {
  87. setTimeout(updateFiltedGames, 1000 * 30);
  88. });
  89. }
  90. const getStraightFixtures = async () => {
  91. const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
  92. let since = GLOBAL_DATA.straightFixturesVersion;
  93. if (GLOBAL_DATA.straightFixturesCount > 12) {
  94. since = 0;
  95. GLOBAL_DATA.straightFixturesCount = 0;
  96. }
  97. GLOBAL_DATA.straightFixturesCount++;
  98. return pinnacleGet('/v3/fixtures', { sportId: 29, leagueIds, since })
  99. .then(data => {
  100. const { league, last } = data;
  101. if (!last) {
  102. return {};
  103. }
  104. GLOBAL_DATA.straightFixturesVersion = last;
  105. const games = league?.map(league => {
  106. const { id: leagueId, events } = league;
  107. return events?.map(event => {
  108. const { starts } = event;
  109. const timestamp = new Date(starts).getTime();
  110. return { leagueId, ...event, timestamp };
  111. });
  112. })
  113. .flat() ?? [];
  114. const update = since == 0 ? 'full' : 'increment';
  115. return { games, update };
  116. });
  117. }
  118. const updateStraightFixtures = async () => {
  119. return getStraightFixtures()
  120. .then(data => {
  121. const { games, update } = data;
  122. const { gamesMap } = GLOBAL_DATA;
  123. if (games?.length) {
  124. games.forEach(game => {
  125. const { id } = game;
  126. if (!gamesMap[id]) {
  127. gamesMap[id] = game;
  128. }
  129. else {
  130. Object.assign(gamesMap[id], game);
  131. }
  132. });
  133. }
  134. if (update && update == 'full') {
  135. const gamesSet = new Set(games.map(game => game.id));
  136. Object.keys(gamesMap).forEach(key => {
  137. if (!gamesSet.has(+key)) {
  138. delete gamesMap[key];
  139. }
  140. });
  141. }
  142. });
  143. }
  144. const getStraightOdds = async () => {
  145. const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
  146. const since = GLOBAL_DATA.straightOddsVersion;
  147. return pinnacleGet('/v3/odds', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since })
  148. .then(data => {
  149. const { leagues, last } = data;
  150. if (!last) {
  151. return [];
  152. }
  153. GLOBAL_DATA.straightOddsVersion = last;
  154. const games = leagues?.flatMap(league => league.events);
  155. return games?.map(item => {
  156. const { periods, ...rest } = item;
  157. const period = periods?.find(period => period.number == 0) ?? {};
  158. return { ...rest, period };
  159. }) ?? [];
  160. });
  161. }
  162. const updateStraightOdds = async () => {
  163. return getStraightOdds()
  164. .then(games => {
  165. if (games.length) {
  166. const { gamesMap } = GLOBAL_DATA;
  167. games.forEach(game => {
  168. const { id, ...rest } = game;
  169. const localGame = gamesMap[id];
  170. if (localGame) {
  171. Object.assign(localGame, rest);
  172. }
  173. });
  174. }
  175. });
  176. }
  177. const getSpecialFixtures = async () => {
  178. const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
  179. const since = GLOBAL_DATA.specialFixturesVersion;
  180. return pinnacleGet('/v2/fixtures/special', { sportId: 29, leagueIds, since })
  181. .then(data => {
  182. const { leagues, last } = data;
  183. if (!last) {
  184. return [];
  185. }
  186. GLOBAL_DATA.specialFixturesVersion = last;
  187. const specials = leagues?.map(league => {
  188. const { specials } = league;
  189. return specials?.filter(special => special.event)
  190. .map(special => {
  191. const { event: { id: eventId }, ...rest } = special ?? { event: {} };
  192. return { eventId, ...rest };
  193. }) ?? [];
  194. })
  195. .flat()
  196. .filter(special => {
  197. if (special.name != 'Winning Margin' && special.name != 'Exact Total Goals') {
  198. return false;
  199. }
  200. return true;
  201. });
  202. return specials ?? [];
  203. });
  204. }
  205. const updateSpecialFixtures = async () => {
  206. return getSpecialFixtures()
  207. .then(specials => {
  208. if (specials.length) {
  209. const { gamesMap } = GLOBAL_DATA;
  210. const gamesSpecialsMap = {};
  211. specials.forEach(special => {
  212. const { eventId } = special;
  213. if (!gamesSpecialsMap[eventId]) {
  214. gamesSpecialsMap[eventId] = {};
  215. }
  216. if (special.name == 'Winning Margin') {
  217. gamesSpecialsMap[eventId].winningMargin = special;
  218. } else if (special.name == 'Exact Total Goals') {
  219. gamesSpecialsMap[eventId].exactTotalGoals = special;
  220. }
  221. });
  222. Object.keys(gamesSpecialsMap).forEach(eventId => {
  223. if (!gamesMap[eventId]) {
  224. return;
  225. }
  226. if (!gamesMap[eventId].specials) {
  227. gamesMap[eventId].specials = {};
  228. }
  229. const localSpecials = gamesMap[eventId].specials;
  230. const remoteSpecials = gamesSpecialsMap[eventId];
  231. if (localSpecials.winningMargin && !remoteSpecials.winningMargin) {
  232. Logs.out('delete winningMargin', localSpecials.winningMargin);
  233. delete localSpecials.winningMargin;
  234. }
  235. else if (!localSpecials.winningMargin && remoteSpecials.winningMargin) {
  236. // Logs.out('add winningMargin', remoteSpecials.winningMargin);
  237. localSpecials.winningMargin = remoteSpecials.winningMargin;
  238. }
  239. if (localSpecials.exactTotalGoals && !remoteSpecials.exactTotalGoals) {
  240. Logs.out('delete exactTotalGoals', localSpecials.exactTotalGoals);
  241. delete localSpecials.exactTotalGoals;
  242. }
  243. else if (!localSpecials.exactTotalGoals && remoteSpecials.exactTotalGoals) {
  244. // Logs.out('add exactTotalGoals', remoteSpecials.exactTotalGoals);
  245. localSpecials.exactTotalGoals = remoteSpecials.exactTotalGoals;
  246. }
  247. });
  248. }
  249. });
  250. }
  251. const getSpecialsOdds = async () => {
  252. const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
  253. const since = GLOBAL_DATA.specialsOddsVersion;
  254. return pinnacleGet('/v2/odds/special', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since })
  255. .then(data => {
  256. const { leagues, last } = data;
  257. if (!last) {
  258. return [];
  259. }
  260. GLOBAL_DATA.specialsOddsVersion = last;
  261. return leagues?.flatMap(league => league.specials);
  262. });
  263. }
  264. const updateSpecialsOdds = async () => {
  265. return getSpecialsOdds()
  266. .then(specials => {
  267. if (specials.length) {
  268. const { gamesMap } = GLOBAL_DATA;
  269. const contestants = Object.values(gamesMap)
  270. .filter(game => game.specials)
  271. .map(game => {
  272. const { specials } = game;
  273. const contestants = Object.values(specials).map(special => {
  274. const { contestants } = special;
  275. return contestants.map(contestant => [contestant.id, contestant]);
  276. });
  277. return contestants;
  278. }).flat(2);
  279. const contestantsMap = new Map(contestants);
  280. const lines = specials.flatMap(special => special.contestantLines);
  281. lines.forEach(line => {
  282. const { id, handicap, lineId, max, price } = line;
  283. const contestant = contestantsMap.get(id);
  284. if (!contestant) {
  285. return;
  286. }
  287. contestant.handicap = handicap;
  288. contestant.lineId = lineId;
  289. contestant.max = max;
  290. contestant.price = price;
  291. });
  292. }
  293. });
  294. }
  295. const getInRunning = async () => {
  296. return pinnacleGet('/v2/inrunning')
  297. .then(data => {
  298. const sportId = 29;
  299. const leagues = data.sports?.find(sport => sport.id == sportId)?.leagues ?? [];
  300. return leagues.filter(league => {
  301. const { id } = league;
  302. const filtedLeaguesSet = new Set(GLOBAL_DATA.filtedLeagues);
  303. return filtedLeaguesSet.has(id);
  304. }).flatMap(league => league.events);
  305. });
  306. }
  307. const updateInRunning = async () => {
  308. return getInRunning()
  309. .then(games => {
  310. if (!games.length) {
  311. return;
  312. }
  313. const { gamesMap } = GLOBAL_DATA;
  314. games.forEach(game => {
  315. const { id, state, elapsed } = game;
  316. const localGame = gamesMap[id];
  317. if (localGame) {
  318. Object.assign(localGame, { state, elapsed });
  319. }
  320. });
  321. });
  322. }
  323. const ratioAccept = (ratio) => {
  324. if (ratio > 0) {
  325. return 'a'
  326. }
  327. return ''
  328. }
  329. const ratioString = (ratio) => {
  330. ratio = Math.abs(ratio);
  331. ratio = ratio.toString();
  332. ratio = ratio.replace(/\./, '');
  333. return ratio;
  334. }
  335. const parseSpreads = (spreads, wm) => {
  336. // 让分盘
  337. if (!spreads?.length) {
  338. return null;
  339. }
  340. const events = {};
  341. spreads.forEach(spread => {
  342. const { hdp, home, away } = spread;
  343. if (!(hdp % 1) || !!(hdp % 0.5)) {
  344. // 整数或不能被0.5整除的让分盘不处理
  345. return;
  346. }
  347. const ratio_ro = hdp;
  348. const ratio_r = ratio_ro - wm;
  349. events[`ior_r${ratioAccept(ratio_r)}h_${ratioString(ratio_r)}`] = {
  350. v: home,
  351. r: wm != 0 ? `ior_r${ratioAccept(ratio_ro)}h_${ratioString(ratio_ro)}` : undefined
  352. };
  353. events[`ior_r${ratioAccept(-ratio_r)}c_${ratioString(ratio_r)}`] = {
  354. v: away,
  355. r: wm != 0 ? `ior_r${ratioAccept(-ratio_ro)}c_${ratioString(ratio_ro)}` : undefined
  356. };
  357. });
  358. return events;
  359. }
  360. const parseMoneyline = (moneyline) => {
  361. // 胜平负
  362. if (!moneyline) {
  363. return null;
  364. }
  365. const { home, away, draw } = moneyline;
  366. return {
  367. 'ior_mh': { v: home },
  368. 'ior_mc': { v: away },
  369. 'ior_mn': { v: draw },
  370. }
  371. }
  372. const parsePeriod = (period, wm) => {
  373. const { cutoff='', status=0, spreads=[], moneyline={} } = period;
  374. const cutoffTime = new Date(cutoff).getTime();
  375. const nowTime = Date.now();
  376. if (status != 1 || cutoffTime < nowTime) {
  377. return null;
  378. }
  379. const events = {};
  380. Object.assign(events, parseSpreads(spreads, wm));
  381. Object.assign(events, parseMoneyline(moneyline));
  382. return events;
  383. }
  384. const parseWinningMargin = (winningMargin, home, away) => {
  385. const { cutoff='', status='', contestants=[] } = winningMargin;
  386. const cutoffTime = new Date(cutoff).getTime();
  387. const nowTime = Date.now();
  388. if (status != 'O' || cutoffTime < nowTime || !contestants?.length) {
  389. return null;
  390. }
  391. const events = {};
  392. contestants.forEach(contestant => {
  393. const { name, price } = contestant;
  394. const nr = name.match(/\d+$/)?.[0];
  395. if (!nr) {
  396. return;
  397. }
  398. let side;
  399. if (name.startsWith(home)) {
  400. side = 'h';
  401. }
  402. else if (name.startsWith(away)) {
  403. side = 'c';
  404. }
  405. else {
  406. return;
  407. }
  408. events[`ior_wm${side}_${nr}`] = { v: price };
  409. });
  410. return events;
  411. }
  412. const parseExactTotalGoals = (exactTotalGoals) => {
  413. const { cutoff='', status='', contestants=[] } = exactTotalGoals;
  414. const cutoffTime = new Date(cutoff).getTime();
  415. const nowTime = Date.now();
  416. if (status != 'O' || cutoffTime < nowTime || !contestants?.length) {
  417. return null;
  418. }
  419. const events = {};
  420. contestants.forEach(contestant => {
  421. const { name, price } = contestant;
  422. if (+name >= 1 && +name <= 7) {
  423. events[`ior_ot_${name}`] = { v: price };
  424. }
  425. });
  426. return events;
  427. }
  428. const parseRbState = (state) => {
  429. let stage = null;
  430. if (state == 1) {
  431. stage = '1H';
  432. }
  433. else if (state == 2) {
  434. stage = 'HT';
  435. }
  436. else if (state == 3) {
  437. stage = '2H';
  438. }
  439. return stage;
  440. }
  441. const parseGame = (game) => {
  442. const { eventId=0, originId=0, period={}, specials={}, home, away, marketType, state, elapsed, homeScore=0, awayScore=0 } = game;
  443. const { winningMargin={}, exactTotalGoals={} } = specials;
  444. const wm = homeScore - awayScore;
  445. const score = `${homeScore}-${awayScore}`;
  446. const events = parsePeriod(period, wm) ?? {};
  447. const stage = parseRbState(state);
  448. const retime = elapsed ? `${elapsed}'` : '';
  449. const evtime = Date.now();
  450. Object.assign(events, parseWinningMargin(winningMargin, home, away));
  451. Object.assign(events, parseExactTotalGoals(exactTotalGoals));
  452. return { eventId, originId, events, evtime, stage, retime, score, wm, marketType };
  453. }
  454. const getGames = () => {
  455. const { filtedGames, gamesMap } = GLOBAL_DATA;
  456. const filtedGamesSet = new Set(filtedGames);
  457. const nowTime = Date.now();
  458. const gamesData = {};
  459. Object.values(gamesMap).forEach(game => {
  460. const { id, liveStatus, parentId, resultingUnit, timestamp } = game;
  461. if (resultingUnit !== 'Regular') {
  462. return false; // 非常规赛事不处理
  463. }
  464. const gmtMinus4Date = getDateInTimezone(-4);
  465. const todayEndTime = new Date(`${gmtMinus4Date} 23:59:59 GMT-4`).getTime();
  466. const tomorrowEndTime = todayEndTime + 24 * 60 * 60 * 1000;
  467. if (liveStatus == 1 && timestamp < nowTime) {
  468. game.marketType = 2; // 滚球赛事
  469. }
  470. else if (liveStatus != 1 && timestamp > nowTime && timestamp <= todayEndTime) {
  471. game.marketType = 1; // 今日赛事
  472. }
  473. else if (liveStatus != 1 && timestamp > todayEndTime && timestamp <= tomorrowEndTime) {
  474. game.marketType = 0; // 明日早盘赛事
  475. }
  476. else {
  477. game.marketType = -1; // 非近期赛事
  478. }
  479. if (game.marketType < 0) {
  480. return false; // 非近期赛事不处理
  481. }
  482. let actived = false;
  483. if (liveStatus != 1 && filtedGamesSet.has(id)) {
  484. actived = true; // 在赛前列表中
  485. game.eventId = id;
  486. game.originId = 0;
  487. }
  488. else if (liveStatus == 1 && filtedGamesSet.has(parentId)) {
  489. actived = true; // 在滚球列表中
  490. game.eventId = parentId;
  491. game.originId = id;
  492. }
  493. if (actived) {
  494. const gameInfo = parseGame(game);
  495. const { marketType, ...rest } = gameInfo;
  496. if (!gamesData[marketType]) {
  497. gamesData[marketType] = [];
  498. }
  499. gamesData[marketType].push(rest);
  500. }
  501. });
  502. return gamesData;
  503. }
  504. const pinnacleDataLoop = () => {
  505. updateStraightFixtures()
  506. .then(() => {
  507. return Promise.all([
  508. updateStraightOdds(),
  509. updateSpecialFixtures(),
  510. updateInRunning(),
  511. ]);
  512. })
  513. .then(() => {
  514. return updateSpecialsOdds();
  515. })
  516. .then(() => {
  517. GLOBAL_DATA.requestErrorCount = 0;
  518. const { straightFixturesVersion: sfv, specialFixturesVersion: pfv, straightOddsVersion: sov, specialsOddsVersion: pov } = GLOBAL_DATA;
  519. const timestamp = Math.max(sfv, pfv, sov, pov);
  520. const games = getGames();
  521. const data = { games, timestamp };
  522. updateBaseEvents(data);
  523. Logs.outDev('games data', data);
  524. writeFileSync(cacheFilePath, JSON.stringify(GLOBAL_DATA.gamesMap, null, 2));
  525. })
  526. .catch(err => {
  527. GLOBAL_DATA.requestErrorCount++;
  528. if (GLOBAL_DATA.requestErrorCount > 10) {
  529. GLOBAL_DATA.loopActive = false;
  530. notifyException('Pinnacle API request errors have reached the limit.');
  531. }
  532. Logs.err(err.message, err.source);
  533. })
  534. .finally(() => {
  535. if (!GLOBAL_DATA.loopActive) {
  536. return;
  537. }
  538. setTimeout(pinnacleDataLoop, 1000 * 5);
  539. });
  540. }
  541. (() => {
  542. if (!process.env.PINNACLE_USERNAME || !process.env.PINNACLE_PASSWORD) {
  543. Logs.err('USERNAME or PASSWORD is not set');
  544. return;
  545. }
  546. GLOBAL_DATA.loopActive = true;
  547. updateFiltedGames().then(pinnacleDataLoop);
  548. })();