'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); });