diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1078294 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + biggame-xml-proxy: + image: node:20-alpine + container_name: biggame-xml-proxy + working_dir: /app + volumes: + - ./:/app + command: sh -c "npm install --silent && node server.js" + ports: + - "3737:3737" + environment: + BG_BEARER_TOKEN: "bg_live_k_K1zO7Jh-fMkRttp8.uoD4OzE3L_SrLXu0tTsS_4nDdL8DkhzRV6eOSXSkivY" + BG_EVENT_ID: "1" + BG_RANKING: "stableford_gross" + POLL_INTERVAL_MS: "10000" + STALE_THRESHOLD_MS: "60000" + PORT: "3737" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3737/ping"] + interval: 15s + timeout: 5s + retries: 3 diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d840b8 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "biggame-xml-proxy", + "version": "1.0.0", + "description": "JSON-to-XML proxy bridging Big Game Golf API to Ross XPression DataLinq", + "main": "server.js", + "scripts": { "start": "node server.js" }, + "dependencies": { + "express": "^4.19.2", + "fast-xml-parser": "^4.4.1" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..6b3b72c --- /dev/null +++ b/server.js @@ -0,0 +1,96 @@ +'use strict'; +const express = require('express'); +const https = require('https'); +const { XMLBuilder } = require('fast-xml-parser'); +const CONFIG = { + BG_BASE_URL: 'https://api.biggamegolf.com/api/integrations/v1', + BG_BEARER_TOKEN: process.env.BG_BEARER_TOKEN || 'MISSING', + BG_EVENT_ID: process.env.BG_EVENT_ID || '1', + BG_RANKING: process.env.BG_RANKING || 'stableford_gross', + POLL_INTERVAL_MS: parseInt(process.env.POLL_INTERVAL_MS || '10000'), + STALE_THRESHOLD_MS: parseInt(process.env.STALE_THRESHOLD_MS || '60000'), + PORT: parseInt(process.env.PORT || '3737'), +}; +const state = { summaryXml:null, playerCache:{}, lastUpdated:null, lastError:null, fetchCount:0, errorCount:0 }; +function bgFetch(path) { + return new Promise((resolve,reject) => { + const url=`${CONFIG.BG_BASE_URL}${path}`; + https.get(url,{headers:{Authorization:`Bearer ${CONFIG.BG_BEARER_TOKEN}`,Accept:'application/json'}},(res)=>{ + let body=''; + res.on('data',(c)=>body+=c); + res.on('end',()=>{ + if(res.statusCode===200){try{resolve(JSON.parse(body));}catch(e){reject(new Error(e.message));}} + else{reject(new Error(`HTTP ${res.statusCode}: ${body}`));} + }); + }).on('error',reject); + }); +} +const builder=new XMLBuilder({ignoreAttributes:false,attributeNamePrefix:'@_',format:true,suppressEmptyNode:true}); +function fmtPar(v){if(v===null||v===undefined)return '';if(v===0)return 'E';return v>0?`+${v}`:String(v);} +function isStale(){if(!state.lastUpdated)return true;return(Date.now()-state.lastUpdated.getTime())>CONFIG.STALE_THRESHOLD_MS;} +function buildSummaryXml(data){ + const{leaderboard,players}=data; + const now=new Date().toISOString(); + const xmlPlayers=players.map((p)=>{ + const r1=(p.rounds||[]).find(r=>r.round===1)||{}; + const r2=(p.rounds||[]).find(r=>r.round===2)||{}; + return{'@_rank':p.standing.position,'@_tied':p.standing.isTied?1:0,'@_sortOrder':p.standing.sortOrder, + '@_playerId':p.playerId,'@_firstName':p.firstName,'@_lastName':p.lastName,'@_displayName':p.displayName,'@_profilePicUrl':p.profilePicUrl||'', + '@_points':p.summary.points,'@_strokes':p.summary.strokes,'@_toPar':p.summary.toPar,'@_toParDisplay':fmtPar(p.summary.toPar), + '@_holesPlayed':p.summary.holesPlayed,'@_thru':p.summary.thru, + '@_r1Points':r1.points??'','@_r1Strokes':r1.strokes??'','@_r1ToPar':r1.toPar??'','@_r1ToParDisplay':fmtPar(r1.toPar),'@_r1Status':r1.status||'','@_r1HolesPlayed':r1.holesPlayed??'', + '@_r2Points':r2.points??'','@_r2Strokes':r2.strokes??'','@_r2ToPar':r2.toPar??'','@_r2ToParDisplay':fmtPar(r2.toPar),'@_r2Status':r2.status||'','@_r2HolesPlayed':r2.holesPlayed??''}; + }); + const rds=(leaderboard.eventRounds||[]).map(r=>({'@_round':r.round,'@_label':r.label,'@_date':r.date,'@_playedHoles':r.playedHoles,'@_facility':r.course?.facility||'','@_course':r.course?.name||'','@_location':r.course?.location||''})); + const doc={leaderboard:{'@_eventId':leaderboard.eventId,'@_eventName':leaderboard.name,'@_eventStart':leaderboard.eventDates?.start||'','@_eventEnd':leaderboard.eventDates?.end||'','@_ranking':`${leaderboard.ranking?.pointSystem}_${leaderboard.ranking?.handicapMode}`,'@_updated':now,'@_stale':isStale()?1:0,rounds:{round:rds},players:{player:xmlPlayers}}}; + return '\n'+builder.build(doc); +} +function buildPlayerXml(data){ + const{leaderboard,player,standing,summary,detail}=data; + const now=new Date().toISOString(); + const rds=(detail.rounds||[]).map(r=>({'@_round':r.round,'@_label':r.label,'@_date':r.date,'@_status':r.status,'@_points':r.points??'','@_strokes':r.strokes??'','@_toPar':r.toPar??'','@_toParDisplay':fmtPar(r.toPar),'@_holesPlayed':r.holesPlayed??''})); + const scs=(detail.scorecards||[]).map(sc=>{const holes=(sc.holes||[]).map(h=>({'@_hole':h.hole,'@_par':h.par??'','@_strokes':h.strokes??'','@_points':h.points??'','@_scoreX':h.scoreX?1:0}));return{'@_round':sc.round,'@_date':sc.date,'@_pointsOut':sc.points?.out??'','@_pointsIn':sc.points?.in??'','@_pointsTotal':sc.points?.total??'','@_strokesOut':sc.strokes?.out??'','@_strokesIn':sc.strokes?.in??'','@_strokesTotal':sc.strokes?.total??'',hole:holes};}); + const doc={playerDetail:{'@_eventId':leaderboard.eventId,'@_eventName':leaderboard.name,'@_updated':now,'@_stale':isStale()?1:0,player:{'@_playerId':player.playerId,'@_firstName':player.firstName,'@_lastName':player.lastName,'@_displayName':player.displayName,'@_profilePicUrl':player.profilePicUrl||'','@_rank':standing.position,'@_tied':standing.isTied?1:0,'@_points':summary.points,'@_strokes':summary.strokes,'@_toPar':summary.toPar,'@_toParDisplay':fmtPar(summary.toPar),'@_holesPlayed':summary.holesPlayed,'@_thru':summary.thru},rounds:{round:rds},scorecards:{scorecard:scs}}}; + return '\n'+builder.build(doc); +} +async function pollSummary(){ + try{ + console.log(`[poll] Fetching event ${CONFIG.BG_EVENT_ID}...`); + const data=await bgFetch(`/leaderboards/${CONFIG.BG_EVENT_ID}/summary/?ranking=${CONFIG.BG_RANKING}`); + state.summaryXml=buildSummaryXml(data); + state.lastUpdated=new Date(); + state.lastError=null; + state.fetchCount++; + console.log(`[poll] OK - ${data.players?.length??0} players`); + }catch(err){ + state.lastError=err.message; + state.errorCount++; + console.error(`[poll] ERROR: ${err.message}`); + } +} +const app=express(); +app.get('/leaderboard',(req,res)=>{ + if(!state.summaryXml)return res.status(503).send('not_ready'); + res.set('Content-Type','application/xml').send(state.summaryXml); +}); +app.get('/player/:id',async(req,res)=>{ + const id=req.params.id; + const c=state.playerCache[id]; + if(c&&(Date.now()-c.ts)${err.message}`); + } +}); +app.get('/status',(req,res)=>res.json({ok:!!state.summaryXml&&!isStale(),stale:isStale(),lastUpdated:state.lastUpdated,lastError:state.lastError,fetchCount:state.fetchCount,errorCount:state.errorCount,eventId:CONFIG.BG_EVENT_ID,cachedPlayers:Object.keys(state.playerCache)})); +app.get('/ping',(req,res)=>res.send('pong')); +app.listen(CONFIG.PORT,()=>{ + console.log(`[proxy] Big Game Golf XML Proxy on :${CONFIG.PORT}`); + pollSummary(); + setInterval(pollSummary,CONFIG.POLL_INTERVAL_MS); +});