Parcourir la source

支持获取 pinnacle ior详情

flyzto il y a 4 semaines
Parent
commit
23906051cd

+ 12 - 0
pinnacle/libs/auth.js

@@ -0,0 +1,12 @@
+import crypto from 'node:crypto';
+
+export const safeEqual = (a = '', b = '') => {
+  const aBuffer = Buffer.from(a);
+  const bBuffer = Buffer.from(b);
+
+  if (aBuffer.length !== bBuffer.length) {
+    return false;
+  }
+
+  return crypto.timingSafeEqual(aBuffer, bBuffer);
+};

+ 1 - 1
pinnacle/libs/getAccount.js

@@ -1,4 +1,4 @@
-import { Logs } from "./logs.js";
+import Logs from "./logs.js";
 import { getData, setData } from "./cache.js";
 
 const accountOptionCacheFile = 'data/accountOptionCache.json';

+ 1 - 1
pinnacle/libs/logs.js

@@ -1,6 +1,6 @@
 import dayjs from 'dayjs';
 
-export class Logs {
+export default class Logs {
 
   static out(...args) {
     const timeString = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');

+ 352 - 0
pinnacle/libs/parseGameData.js

@@ -0,0 +1,352 @@
+/**
+ * 解析让球方
+ */
+const ratioAccept = (ratio) => {
+  if (ratio > 0) {
+    return 'a';
+  }
+  return '';
+}
+
+/**
+ * 将比率转换为字符串
+ * @param {*} ratio
+ * @returns
+ */
+const ratioString = (ratio) => {
+  ratio = Math.abs(ratio);
+  ratio = ratio.toString();
+  ratio = ratio.replace(/\./, '');
+  return ratio;
+}
+
+/**
+ * 解析胜平负盘口
+ * @param {*} moneyline
+ * @returns
+ */
+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 },
+  }
+}
+
+/**
+ * 解析让分盘口
+ * @param {*} spreads
+ * @param {*} wm
+ * @returns
+ */
+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;
+}
+
+/**
+ * 解析大小球盘口
+ * @param {*} totals
+ * @returns
+ */
+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;
+}
+
+/**
+ * 解析直赛盘口
+ * @param {*} straight
+ * @param {*} wm
+ * @returns
+ */
+const parseStraight = (straight, wm) => {
+  if (!straight) {
+    return null;
+  }
+  const { cutoff='', status=0, spreads=[], moneyline={}, totals=[] } = straight;
+  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;
+}
+
+/**
+ * 解析净胜球盘口
+ * @param {*} winningMargin
+ * @param {*} home
+ * @param {*} away
+ * @returns
+ */
+const parseWinningMargin = (winningMargin, home, away) => {
+  if (!winningMargin) {
+    return null;
+  }
+  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;
+}
+
+/**
+ * 解析进球数盘口
+ * @param {*} exactTotalGoals
+ * @returns
+ */
+const parseExactTotalGoals = (exactTotalGoals) => {
+  if (!exactTotalGoals) {
+    return null;
+  }
+  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;
+}
+
+/**
+ * 解析滚球场次状态
+ * @param {*} state
+ * @returns
+ */
+const parseRbState = (state) => {
+  let stage = null;
+  if (state == 1) {
+    stage = '1H';
+  }
+  else if (state == 2) {
+    stage = 'HT';
+  }
+  else if (state == 3) {
+    stage = '2H';
+  }
+  else if (state >= 4) {
+    stage = 'ET';
+  }
+  return stage;
+}
+
+/**
+ * 解析比赛数据
+ * @param {*} game
+ * @returns
+ */
+export const parseGame = (game, filtedGames) => {
+  const { eventId=0, originId=0, periods={}, specials={}, home, away, marketType, state, elapsed, homeScore=0, awayScore=0 } = game;
+  const { straight, straight1st } = periods;
+  const { winningMargin={}, exactTotalGoals={}, winningMargin1st={}, exactTotalGoals1st={} } = specials;
+  const filtedGamesSet = new Set(filtedGames);
+  const wm = homeScore - awayScore;
+  const score = `${homeScore}-${awayScore}`;
+  const events = parseStraight(straight, 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));
+  const gameInfos = [];
+  gameInfos.push({ eventId, originId, events, evtime, stage, retime, score, wm, marketType });
+  const halfEventId = eventId * -1;
+  if (filtedGamesSet.has(halfEventId)) {
+    const events = parseStraight(straight1st, wm) ?? {};
+    Object.assign(events, parseWinningMargin(winningMargin1st, home, away));
+    Object.assign(events, parseExactTotalGoals(exactTotalGoals1st));
+    gameInfos.push({ eventId: halfEventId, originId, events, evtime, stage, retime, score, wm, marketType });
+  }
+  return gameInfos;
+}
+
+/*
+* 解析比率信息
+*/
+const parseRatio = (ratioString) => {
+ if (!ratioString) {
+   return null;
+ }
+ return parseFloat(`${ratioString[0]}.${ratioString.slice(1)}`);
+}
+
+/**
+ * 解析盘口信息
+ * @param {*} ior
+ * @returns
+ */
+const parseIor = (ior) => {
+ const iorMatch = ior.match(/ior_(m|r|ou|wm|ot)([ao])?([hcn])?_?(\d+)?/);
+ if (!iorMatch) {
+   return null;
+ }
+ const [, type, action, side, ratio] = iorMatch;
+ return { type, action, side, ratio };
+}
+
+/**
+ * 解析盘口详情
+ * @param {*} ior
+ * @param {*} id
+ * @param {*} gamesMap
+ * @returns
+ */
+export const parseIorDetail = (ior, id, gamesMap) => {
+  const gamesData = gamesMap[id];
+  if (!gamesData) {
+    return { ior, id, message: 'games data not found', cause: 400 };
+  }
+  const iorOptions = parseIor(ior);
+  if (!iorOptions) {
+    return { ior, id, message: 'ior options not found', cause: 400 };
+  }
+  const { type, action, side, ratio } = iorOptions;
+
+  const { leagueId, id: eventId, home: homeTeamName, away: awayTeamName, periods={}, specials={}} = gamesData;
+
+  const straightData = periods.straight ?? {};
+  const { lineId: straightLineId, moneyline, spreads, totals } = straightData;
+  const { winningMargin, exactTotalGoals } = specials;
+
+  if (type === 'm' && moneyline && !action && !ratio) {
+    const sideKey = side === 'h' ? 'home' : side === 'c' ? 'away' : 'draw';
+    const team = side === 'h' ? 'TEAM1' : side === 'c' ? 'TEAM2' : 'DRAW';
+    const odds = moneyline[sideKey];
+    return { leagueId, eventId, betType: 'MONEYLINE', team, lineId: straightLineId, odds };
+  }
+
+  else if (type === 'r' && spreads) {
+    let ratioDirection = 1;
+    if (side === 'c' && action === 'a' || side === 'h' && !action) {
+      ratioDirection = -1;
+    }
+    const ratioKey = parseRatio(ratio) * ratioDirection;
+    const itemSpread = spreads.find(spread => spread.hdp == ratioKey);
+    if (!itemSpread) {
+      return { ior, id, message: 'item spread not found', cause: 400 };
+    }
+    const { altLineId=null, home, away } = itemSpread;
+    const odds = side === 'h' ? home : away;
+    const team = side === 'h' ? 'TEAM1' : 'TEAM2';
+    const handicap = ratioKey * (side === 'h' ? 1 : -1);
+    return { leagueId, eventId, handicap, betType: 'SPREAD', team, lineId: straightLineId, altLineId, odds };
+  }
+
+  else if (type === 'ou' && totals) {
+    const ratioKey = parseRatio(ratio);
+    const itemTotal = totals.find(total => total.points == ratioKey);
+    if (!itemTotal) {
+      return { ior, id, message: 'item total not found', cause: 400 };
+    }
+    const { altLineId=null, over, under } = itemTotal;
+    const odds = side === 'c' ? over : under;
+    const sideKey = side === 'c' ? 'OVER' : 'UNDER';
+    return { leagueId, eventId, handicap: ratioKey, betType: 'TOTAL_POINTS', side: sideKey, lineId: straightLineId, altLineId, odds };
+  }
+
+  else if (type === 'wm' && winningMargin) {
+    const ratioKey = parseRatio(ratio);
+    const { id: specialId } = winningMargin;
+    const wmName = side === 'h' ? `${homeTeamName} By ${ratioKey}` : side === 'c' ? `${awayTeamName} By ${ratioKey}` : '';
+    const wmItem = winningMargin.contestants.find(contestant => contestant.name == wmName);
+    if (!wmItem) {
+      return { ior, id, message: 'item winning margin not found', cause: 400 };
+    }
+    const { id: contestantId, lineId, price } = wmItem;
+    return { leagueId, eventId, specialId, contestantId, lineId, odds: price };
+  }
+
+  else if (type === 'ot' && exactTotalGoals) {
+    const ratioKey = parseRatio(ratio);
+    const { id: specialId } = exactTotalGoals;
+    const otItem = exactTotalGoals.contestants.find(contestant => contestant.name == ratioKey);
+    if (!otItem) {
+      Logs.outDev('pinnacle item exact total goals not found', id, type, action, side, ratio);
+      return { ior, id, message: 'item exact total goals not found', cause: 400 };
+    }
+    const { id: contestantId, lineId, price } = otItem;
+    return { leagueId, eventId, specialId, contestantId, lineId, odds: price };
+  }
+
+  else {
+    return { ior, id, message: 'ior type not found', cause: 400 };
+  }
+}

+ 19 - 12
pinnacle/libs/pinnacleClient.js

@@ -2,7 +2,7 @@ import dotenv from 'dotenv';
 import https from 'https';
 import axios from "axios";
 import { HttpsProxyAgent } from "https-proxy-agent";
-import { Logs } from "./logs.js";
+import Logs from "./logs.js";
 import { getAccountInfo } from "./getAccount.js";
 
 dotenv.config();
@@ -48,9 +48,26 @@ export const pinnacleRequest = async (options, channel) => {
   else if (localAddress) {
     axiosConfig.httpsAgent = new https.Agent({ localAddress });
   }
-  return axios(axiosConfig).then(res => {
+  return axios(axiosConfig)
+  .then(res => {
     // Logs.out('pinnacle request', url, axiosConfig, res.data);
     return res.data;
+  })
+  .catch(err => {
+    let user_name = '';
+    if (channel) {
+      user_name = Buffer.from(channel, "base64").toString().split(':')[0];
+    }
+    else {
+      user_name = username;
+    }
+    const source = { url, params, username: user_name };
+    if (err?.response?.data) {
+      const data = err.response.data;
+      Object.assign(source, { data });
+    }
+    err.source = source;
+    return Promise.reject(err);
   });
 }
 
@@ -65,16 +82,6 @@ export const pinnacleGet = async (url, params, channel) => {
     url,
     params
   }, channel)
-  .catch(err => {
-    const { username } = getAccountInfo() ?? {};
-    const source = { url, params, username };
-    if (err?.response?.data) {
-      const data = err.response.data;
-      Object.assign(source, { data });
-    }
-    err.source = source;
-    return Promise.reject(err);
-  });
 }
 
 /**

+ 759 - 0
pinnacle/libs/syncData.js

@@ -0,0 +1,759 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { pinnacleGet, getPsteryRelations, updateBaseEvents, notifyException } from "./pinnacleClient.js";
+import { parseGame, parseIorDetail } from "./parseGameData.js";
+import Logs from "./logs.js";
+import { getData, setData } from "./cache.js";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const gamesMapCacheFile = path.join(__dirname, '../data/gamesCache.json');
+const globalDataCacheFile = path.join(__dirname, '../data/globalDataCache.json');
+const TP = 'ps_9_9_1';
+const IS_DEV = process.env.NODE_ENV == 'development';
+
+/**
+ * 全局数据
+ * GLOBAL_DATA
+ */
+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 += 1;
+  GLOBAL_DATA.specialFixturesCount += 1;
+}
+
+
+/**
+ * 获取指定时区当前日期或时间
+ * @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}`;
+}
+
+/**
+ * 判断两个数组是否相等
+ * @param {*} arr1
+ * @param {*} arr2
+ * @returns
+ */
+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]);
+}
+
+/**
+ * 获取已过滤比赛列表
+ * @returns {Promise<void>}
+ */
+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();
+  });
+}
+
+/**
+ * 获取胜平负盘口数据
+ * @returns {Promise<Object>}
+ */
+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 };
+  });
+}
+
+/**
+ * 更新胜平负盘口数据
+ * @returns {Promise<void>}
+ */
+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];
+        }
+      });
+    }
+  });
+}
+
+/**
+ * 获取胜平负赔率数据
+ * @returns {Promise<Array>}
+ */
+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('/v4/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);
+      const straight1st = periods?.find(period => period.number == 1);
+      const gameInfo = rest;
+      if (straight || straight1st) {
+        gameInfo.periods = {};
+      }
+      if (straight) {
+        gameInfo.periods.straight = straight;
+      }
+      if (straight1st) {
+        gameInfo.periods.straight1st = straight1st;
+      }
+      return gameInfo;
+    }) ?? [];
+  });
+}
+
+/**
+ * 更新胜平负赔率数据
+ * @returns {Promise<void>}
+ */
+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);
+          }
+        }
+      });
+    }
+  });
+}
+
+/**
+ * 获取特殊盘口列表数据
+ * @returns {Promise<Object>}
+ */
+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 { specials: [], update: 'increment' };
+    }
+    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 {Promise<void>}
+ */
+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;
+        }
+        else if (special.name == 'Winning Margin 1st Half') {
+          gamesSpecialsMap[eventId].winningMargin1st = special;
+        }
+        else if (special.name == 'Exact Total Goals 1st Half') {
+          gamesSpecialsMap[eventId].exactTotalGoals1st = 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');
+        mergeSpecials(localSpecials, remoteSpecials, 'winningMargin1st');
+        mergeSpecials(localSpecials, remoteSpecials, 'exactTotalGoals1st');
+
+      });
+    }
+  });
+}
+
+/**
+ * 获取特殊盘口赔率数据
+ * @returns {Promise<Array>}
+ */
+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);
+  });
+}
+
+/**
+ * 更新特殊盘口赔率数据
+ * @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;
+      });
+    }
+  });
+}
+
+/**
+ * 获取滚球数据
+ * @returns {Promise<Array>}
+ */
+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);
+  });
+}
+
+/**
+ * 更新滚球数据
+ * @returns {Promise<void>}
+ */
+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 });
+      }
+    });
+  });
+}
+
+/**
+ * 获取盘口数据
+ * @returns {Object}
+ */
+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 { filtedGames } = GLOBAL_DATA;
+      parseGame(game, filtedGames)?.forEach(gameInfo => {
+        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 timestamp = Date.now();
+    const games = getGames();
+    const data = { games, timestamp, tp: TP };
+
+    updateBaseEvents(data);
+
+    if (IS_DEV) {
+      setData(gamesMapCacheFile, GLOBAL_DATA.gamesMap);
+    }
+
+  })
+  .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');
+      const exceptionList = ['Pinnacle API paused']
+      if (exceptionMessage) {
+        exceptionList.push(exceptionMessage);
+      }
+      if (err.source?.data?.message) {
+        exceptionList.push(err.source.data.message);
+      }
+      else if (err.message) {
+        exceptionList.push(err.message);
+      }
+      if (err.source?.username) {
+        exceptionList.push(err.source.username);
+      }
+      notifyException(exceptionList.join('. '));
+    }
+  })
+  .finally(() => {
+    const { loopActive } = GLOBAL_DATA;
+    let loopDelay = 1000 * 5;
+    if (!loopActive) {
+      loopDelay = 1000 * 60;
+      resetVersionsCount();
+    }
+    else {
+      incrementVersionsCount();
+    }
+    setTimeout(pinnacleDataLoop, loopDelay);
+  });
+}
+
+/**
+ * 缓存GLOBAL_DATA数据到文件
+ * @returns {Promise<void>}
+ */
+const saveGlobalDataToCache = async () => {
+  return setData(globalDataCacheFile, GLOBAL_DATA);
+}
+
+/**
+ * 从文件加载GLOBAL_DATA数据
+ * @returns {Promise<void>}
+ */
+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数据
+ * @param {*} code
+ */
+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);
+});
+
+/**
+ * 启动同步盘口数据
+ */
+export const startSyncMarketsData = () => {
+  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);
+}
+
+/**
+ * 获取盘口详情
+ * @param {*} ior
+ * @param {*} id
+ * @returns
+ */
+export const getIorInfo = async(ior, id) => {
+  if (!id || !ior) {
+    return Promise.reject({ cause: 400, message: 'id and ior are required', data: { id, ior } });
+  }
+  const { gamesMap } = GLOBAL_DATA;
+  const iorInfo = parseIorDetail(ior, id, gamesMap);
+  if (iorInfo.cause === 400) {
+    return Promise.reject({ cause: 400, message: iorInfo.message, data: { id, ior } });
+  }
+  return Promise.resolve(iorInfo);
+}

+ 51 - 833
pinnacle/main.js

@@ -1,855 +1,73 @@
-import { pinnacleGet, getPsteryRelations, updateBaseEvents, notifyException } from "./libs/pinnacleClient.js";
-import { Logs } from "./libs/logs.js";
-import { getData, setData } from "./libs/cache.js";
+import 'dotenv/config';
 
+import express from 'express';
 
-const gamesMapCacheFile = 'data/gamesCache.json';
-const globalDataCacheFile = 'data/globalDataCache.json';
-const TP = 'ps_9_9_1';
-const IS_DEV = process.env.NODE_ENV == 'development';
+import Logs from "./libs/logs.js";
+import requireInternalToken from './middleware/requireInternalToken.js';
+import tradingRoutes from './routes/trading.js';
 
-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 app = express();
 
-const resetVersionsCount = () => {
-  GLOBAL_DATA.straightFixturesVersion = 0;
-  GLOBAL_DATA.specialFixturesVersion = 0;
-  GLOBAL_DATA.straightOddsVersion = 0;
-  GLOBAL_DATA.specialsOddsVersion = 0;
+app.use((req, res, next) => {
+  const origin = req.headers.origin;
+  res.header('Access-Control-Allow-Origin', origin || '*');
+  res.header('Access-Control-Allow-Credentials', 'true');
+  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Internal-Token');
+  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+  res.header('Vary', 'Origin');
 
-  GLOBAL_DATA.straightFixturesCount = 0;
-  GLOBAL_DATA.specialFixturesCount = 0;
-}
-
-const incrementVersionsCount = () => {
-  GLOBAL_DATA.straightFixturesCount += 1;
-  GLOBAL_DATA.specialFixturesCount += 1;
-}
-
-
-/**
- * 获取指定时区当前日期或时间
- * @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}`;
+  if (req.method === 'OPTIONS') {
+    return res.sendStatus(200);
   }
+  next();
+});
 
-  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 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);
+app.use(express.json({ limit: '10mb' }));
 
-        resolve();
-        setTimeout(updateFiltedGames, 1000 * 60);
-      })
-      .catch(err => {
-        Logs.err('failed to update filted games:', err.message);
-        setTimeout(updateFiltedGames, 1000 * 5);
-      });
+app.use((req, res, next) => {
+  res.badRequest = (data, msg) => {
+    if (!msg && typeof data === 'string') {
+      msg = data;
+      data = undefined;
     }
-    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 res.status(400).json({ statusCode: 400, code: -1, message: msg ?? 'Bad Request', data });
   }
-  return pinnacleGet('/v3/fixtures', { sportId: 29, leagueIds, since })
-  .then(data => {
-    const { league, last } = data;
-    if (!last) {
-      return {};
+  res.serverError = (data, msg) => {
+    if (!msg && typeof data === 'string') {
+      msg = data;
+      data = undefined;
     }
-    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 res.status(500).json({ statusCode: 500, code: -1, message: msg ?? 'Internal Server Error', data });
   }
-  return pinnacleGet('/v4/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);
-      const straight1st = periods?.find(period => period.number == 1);
-      const gameInfo = rest;
-      if (straight || straight1st) {
-        gameInfo.periods = {};
-      }
-      if (straight) {
-        gameInfo.periods.straight = straight;
-      }
-      if (straight1st) {
-        gameInfo.periods.straight1st = straight1st;
-      }
-      return gameInfo;
-    }) ?? [];
-  });
-}
-
-
-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);
-          }
-        }
-      });
+  res.unauthorized = (data, msg) => {
+    if (!msg && typeof data === 'string') {
+      msg = data;
+      data = undefined;
     }
-  });
-}
-
-
-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 res.status(401).json({ statusCode: 401, code: -1, message: msg ?? 'Unauthorized', data });
   }
-  return pinnacleGet('/v2/fixtures/special', { sportId: 29, leagueIds, since })
-  .then(data => {
-    const { leagues, last } = data;
-    if (!last) {
-      return { specials: [], update: 'increment' };
-    }
-    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 };
-  });
-}
-
-
-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);
+  res.sendSuccess = (data, msg) => {
+    const response = { statusCode: 200, code: 0, message: msg ?? 'OK' }
+    if (data !== undefined) {
+      response.data = data;
     }
-  });
-  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 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);
+    return res.status(200).json(response);
   }
-  else if (remoteSpecials[specialName]) {
-    localSpecials[specialName] = remoteSpecials[specialName];
-  }
-}
-
-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;
-        }
-        else if (special.name == 'Winning Margin 1st Half') {
-          gamesSpecialsMap[eventId].winningMargin1st = special;
-        }
-        else if (special.name == 'Exact Total Goals 1st Half') {
-          gamesSpecialsMap[eventId].exactTotalGoals1st = 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');
-        mergeSpecials(localSpecials, remoteSpecials, 'winningMargin1st');
-        mergeSpecials(localSpecials, remoteSpecials, 'exactTotalGoals1st');
-
-      });
+  res.sendError = (err) => {
+    if (err.cause === 400 || err.status === 400) {
+      return res.badRequest(err.data, err.message);
     }
-  });
-}
-
-
-const getSpecialsOdds = async () => {
-  if (!GLOBAL_DATA.filtedLeagues.length) {
-    return Promise.reject(new Error('no filted leagues'));
+    return res.serverError(err.data, err.message);
   }
-  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 parseStraight = (straight, wm) => {
-  if (!straight) {
-    return null;
-  }
-  const { cutoff='', status=0, spreads=[], moneyline={}, totals=[] } = straight;
-  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) => {
-  if (!winningMargin) {
-    return null;
-  }
-  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) => {
-  if (!exactTotalGoals) {
-    return null;
-  }
-  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';
-  }
-  else if (state >= 4) {
-    stage = 'ET';
-  }
-  return stage;
-}
-
-const parseGame = (game) => {
-  const { eventId=0, originId=0, periods={}, specials={}, home, away, marketType, state, elapsed, homeScore=0, awayScore=0 } = game;
-  const { straight, straight1st } = periods;
-  const { winningMargin={}, exactTotalGoals={}, winningMargin1st={}, exactTotalGoals1st={} } = specials;
-  const filtedGamesSet = new Set(GLOBAL_DATA.filtedGames);
-  const wm = homeScore - awayScore;
-  const score = `${homeScore}-${awayScore}`;
-  const events = parseStraight(straight, 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));
-  const gameInfos = [];
-  gameInfos.push({ eventId, originId, events, evtime, stage, retime, score, wm, marketType });
-  const halfEventId = eventId * -1;
-  if (filtedGamesSet.has(halfEventId)) {
-    const events = parseStraight(straight1st, wm) ?? {};
-    Object.assign(events, parseWinningMargin(winningMargin1st, home, away));
-    Object.assign(events, parseExactTotalGoals(exactTotalGoals1st));
-    gameInfos.push({ eventId: halfEventId, originId, events, evtime, stage, retime, score, wm, marketType });
-  }
-  return gameInfos;
-}
-
-
-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) {
-      parseGame(game)?.forEach(gameInfo => {
-        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 timestamp = Date.now();
-    const games = getGames();
-    const data = { games, timestamp, tp: TP };
-
-    updateBaseEvents(data);
-
-    if (IS_DEV) {
-      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');
-      const exceptionList = ['Pinnacle API paused']
-      if (exceptionMessage) {
-        exceptionList.push(exceptionMessage);
-      }
-      if (err.source?.data?.message) {
-        exceptionList.push(err.source.data.message);
-      }
-      else if (err.message) {
-        exceptionList.push(err.message);
-      }
-      if (err.source?.username) {
-        exceptionList.push(err.source.username);
-      }
-      notifyException(exceptionList.join('. '));
-    }
-  })
-  .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);
+  next();
 });
 
+app.get('/health', (req, res) => {
+  res.sendSuccess({ service: 'polymarket', status: 'ok' });
+});
 
-(() => {
-  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);
-})();
-
+app.use('/api/trading', requireInternalToken, tradingRoutes);
 
+// 启动服务
+const PORT = process.env.PORT || 9056;
+app.listen(PORT, () => Logs.out(`Pinnacle service running on port ${PORT}`));

+ 19 - 0
pinnacle/middleware/requireInternalToken.js

@@ -0,0 +1,19 @@
+import { safeEqual } from '../libs/auth.js';
+
+const getBearerToken = (authorization = '') => {
+  const [scheme, token] = authorization.split(' ');
+  return scheme?.toLowerCase() === 'bearer' ? token : '';
+};
+
+const requireInternalToken = (req, res, next) => {
+  const expectedToken = process.env.PPAI_INTERNAL_API_TOKEN;
+  const providedToken = req.get('x-internal-token') || getBearerToken(req.get('authorization'));
+
+  if (expectedToken && safeEqual(providedToken, expectedToken)) {
+    return next();
+  }
+
+  return res.unauthorized('Invalid internal token');
+};
+
+export default requireInternalToken;

+ 737 - 4
pinnacle/package-lock.json

@@ -1,20 +1,59 @@
 {
-  "name": "pinnacle_api_test",
-  "version": "1.1.4",
+  "name": "pinnacle",
+  "version": "2.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "pinnacle_api_test",
-      "version": "1.1.4",
+      "name": "pinnacle",
+      "version": "2.0.0",
       "license": "ISC",
       "dependencies": {
         "axios": "^1.12.2",
         "dayjs": "^1.11.18",
         "dotenv": "^17.2.3",
+        "express": "^5.2.1",
         "https-proxy-agent": "^7.0.6"
       }
     },
+    "node_modules/accepts": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
+      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "^3.0.0",
+        "negotiator": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/accepts/node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/accepts/node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/agent-base": {
       "version": "7.1.4",
       "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
@@ -41,6 +80,39 @@
         "proxy-from-env": "^1.1.0"
       }
     },
+    "node_modules/body-parser": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz",
+      "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "^3.1.2",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.3",
+        "http-errors": "^2.0.0",
+        "iconv-lite": "^0.7.0",
+        "on-finished": "^2.4.1",
+        "qs": "^6.14.1",
+        "raw-body": "^3.0.1",
+        "type-is": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
       "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -54,6 +126,22 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
     "node_modules/combined-stream": {
       "version": "1.0.8",
       "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -66,6 +154,46 @@
         "node": ">= 0.8"
       }
     },
+    "node_modules/content-disposition": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz",
+      "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
+      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.6.0"
+      }
+    },
     "node_modules/dayjs": {
       "version": "1.11.18",
       "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.18.tgz",
@@ -98,6 +226,15 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/dotenv": {
       "version": "17.2.3",
       "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz",
@@ -124,6 +261,21 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/es-define-property": {
       "version": "1.0.1",
       "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -169,6 +321,110 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz",
+      "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "^2.0.0",
+        "body-parser": "^2.2.1",
+        "content-disposition": "^1.0.0",
+        "content-type": "^1.0.5",
+        "cookie": "^0.7.1",
+        "cookie-signature": "^1.2.1",
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "finalhandler": "^2.1.0",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.0",
+        "merge-descriptors": "^2.0.0",
+        "mime-types": "^3.0.0",
+        "on-finished": "^2.4.1",
+        "once": "^1.4.0",
+        "parseurl": "^1.3.3",
+        "proxy-addr": "^2.0.7",
+        "qs": "^6.14.0",
+        "range-parser": "^1.2.1",
+        "router": "^2.2.0",
+        "send": "^1.1.0",
+        "serve-static": "^2.2.0",
+        "statuses": "^2.0.1",
+        "type-is": "^2.0.1",
+        "vary": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/express/node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express/node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz",
+      "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "on-finished": "^2.4.1",
+        "parseurl": "^1.3.3",
+        "statuses": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 18.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/follow-redirects": {
       "version": "1.15.11",
       "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
@@ -205,6 +461,24 @@
         "node": ">= 6"
       }
     },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
+      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/function-bind": {
       "version": "1.1.2",
       "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
@@ -302,6 +576,26 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
     "node_modules/https-proxy-agent": {
       "version": "7.0.6",
       "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -315,6 +609,43 @@
         "node": ">= 14"
       }
     },
+    "node_modules/iconv-lite": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
+      "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/is-promise": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz",
+      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+      "license": "MIT"
+    },
     "node_modules/math-intrinsics": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -324,6 +655,27 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/mime-db": {
       "version": "1.52.0",
       "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
@@ -351,11 +703,392 @@
       "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
       "license": "MIT"
     },
+    "node_modules/negotiator": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
+      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "8.4.2",
+      "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+      "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
       "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
       "license": "MIT"
+    },
+    "node_modules/qs": {
+      "version": "6.15.2",
+      "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz",
+      "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.7.0",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/router": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
+      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.0",
+        "depd": "^2.0.0",
+        "is-promise": "^4.0.0",
+        "parseurl": "^1.3.3",
+        "path-to-regexp": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      }
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "etag": "^1.8.1",
+        "fresh": "^2.0.0",
+        "http-errors": "^2.0.1",
+        "mime-types": "^3.0.2",
+        "ms": "^2.1.3",
+        "on-finished": "^2.4.1",
+        "range-parser": "^1.2.1",
+        "statuses": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/send/node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/send/node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/serve-static": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz",
+      "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "^2.0.0",
+        "escape-html": "^1.0.3",
+        "parseurl": "^1.3.3",
+        "send": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz",
+      "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+      "license": "MIT",
+      "dependencies": {
+        "content-type": "^2.0.0",
+        "media-typer": "^1.1.0",
+        "mime-types": "^3.0.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/content-type": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz",
+      "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/type-is/node_modules/mime-db": {
+      "version": "1.54.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
+      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/type-is/node_modules/mime-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz",
+      "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "^1.54.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
     }
   }
 }

+ 1 - 0
pinnacle/package.json

@@ -17,6 +17,7 @@
     "axios": "^1.12.2",
     "dayjs": "^1.11.18",
     "dotenv": "^17.2.3",
+    "express": "^5.2.1",
     "https-proxy-agent": "^7.0.6"
   }
 }

+ 20 - 0
pinnacle/routes/trading.js

@@ -0,0 +1,20 @@
+import express from 'express';
+
+import { startSyncMarketsData, getIorInfo } from "../libs/syncData.js";
+
+const router = express.Router();
+
+router.get('/get_ior_info/:id/:ior', (req, res) => {
+  const { id, ior } = req.params;
+  getIorInfo(ior, id)
+  .then(iorInfo => {
+    res.sendSuccess(iorInfo);
+  })
+  .catch(err => {
+    res.sendError(err);
+  });
+});
+
+startSyncMarketsData();
+
+export default router;