448 lines
15 KiB
JavaScript
448 lines
15 KiB
JavaScript
const express = require('express');
|
||
const router = express.Router();
|
||
const { db } = require('../db');
|
||
const { getPlayerFromDB, savePlayerToDB, getRatingHistoryFromDB, saveRatingHistoryToDB, getRoundHistoryFromDB, getLastRoundUpdateDate, updateLastRoundUpdateDate, saveRoundHistoryToDB, savePredictedRatingToDB } = require('../models/player');
|
||
const { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHistory } = require('../scrapers/player-http');
|
||
const { getOfficialRatingHistory, getOptimizedPlayerRounds } = require('../scrapers/player-puppeteer');
|
||
const { launchBrowser } = require('../scrapers/browser');
|
||
const { getPlayerDataFromDB, scrapePDGARating, getAllRatingsFromDB, refreshAllPlayersInDB, getPredictedRatingFromDB } = require('../services/player-service');
|
||
const { getTopbarLocals } = require('../services/topbar-service');
|
||
const { calculatePredictedRating } = require('../services/rating-calculator');
|
||
const logger = require('../logger');
|
||
|
||
let refreshInProgress = false;
|
||
|
||
router.post('/api/refresh-all', async (req, res, next) => {
|
||
if (refreshInProgress) {
|
||
logger.info('refresh-all already in progress, rejecting');
|
||
return res.status(409).json({ error: 'Refresh already in progress' });
|
||
}
|
||
refreshInProgress = true;
|
||
try {
|
||
try {
|
||
await refreshAllPlayersInDB();
|
||
} catch (err) {
|
||
logger.error({ err }, 'refresh-all failed');
|
||
}
|
||
const page = req.body?.page === 'courses' ? 'courses' : 'players';
|
||
const locals = await getTopbarLocals();
|
||
res.render('../partials/topbar', { activePage: page, ...locals });
|
||
} catch (err) {
|
||
next(err);
|
||
} finally {
|
||
refreshInProgress = false;
|
||
}
|
||
});
|
||
|
||
router.get('/partials/ratings-table', async (req, res) => {
|
||
try {
|
||
const ratings = await getAllRatingsFromDB();
|
||
res.render('../partials/ratings-table', { ratings });
|
||
} catch (error) {
|
||
res.status(500).send('<p>Error loading ratings. Please try again.</p>');
|
||
}
|
||
});
|
||
|
||
router.get('/partials/player-history/:pdgaNumber', async (req, res) => {
|
||
try {
|
||
const { pdgaNumber } = req.params;
|
||
|
||
let history = await getRatingHistoryFromDB(pdgaNumber);
|
||
if (!history || history.length === 0) {
|
||
const html = await fetchRatingHistory(pdgaNumber);
|
||
history = parseRatingHistory(html);
|
||
try {
|
||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||
} catch (dbErr) {
|
||
logger.error('Failed to save rating history:', dbErr.message);
|
||
}
|
||
}
|
||
|
||
const formattedHistory = (history || []).map(row => ({
|
||
date: row.date,
|
||
rating: row.rating,
|
||
displayDate: new Date(row.date).toLocaleDateString('en-US', { day: '2-digit', month: 'short', year: 'numeric' })
|
||
}));
|
||
|
||
const player = await getPlayerDataFromDB(pdgaNumber);
|
||
res.render('../partials/player-history', { pdgaNumber, history: formattedHistory, player });
|
||
} catch (error) {
|
||
logger.error('Error loading player history:', error.message);
|
||
res.status(500).send('<div class="loading-chart">Error loading rating history</div>');
|
||
}
|
||
});
|
||
|
||
router.get('/api/ratings', async (req, res) => {
|
||
try {
|
||
const ratings = await getAllRatingsFromDB();
|
||
res.json(ratings);
|
||
} catch (error) {
|
||
res.status(500).json({ error: 'Failed to fetch ratings' });
|
||
}
|
||
});
|
||
|
||
router.get('/api/ratings/progress', (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream',
|
||
'Cache-Control': 'no-cache',
|
||
'Connection': 'keep-alive',
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Headers': 'Cache-Control'
|
||
});
|
||
|
||
const progressCallback = (progress) => {
|
||
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||
};
|
||
|
||
getAllRatingsFromDB(progressCallback).then(ratings => {
|
||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`);
|
||
res.end();
|
||
}).catch(error => {
|
||
res.write(`data: ${JSON.stringify({ status: 'error', error: error.message })}\n\n`);
|
||
res.end();
|
||
});
|
||
|
||
req.on('close', () => {
|
||
res.end();
|
||
});
|
||
});
|
||
|
||
router.get('/api/rating-history/:pdgaNumber', async (req, res) => {
|
||
try {
|
||
const { pdgaNumber } = req.params;
|
||
|
||
const cachedHistory = await getRatingHistoryFromDB(pdgaNumber);
|
||
if (cachedHistory && cachedHistory.length > 0) {
|
||
logger.info(`Using cached rating history from DB for PDGA ${pdgaNumber}`);
|
||
const formattedHistory = cachedHistory.map(row => ({
|
||
date: row.date,
|
||
rating: row.rating,
|
||
displayDate: new Date(row.date).toLocaleDateString('en-US', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric'
|
||
})
|
||
}));
|
||
|
||
res.json({
|
||
pdgaNumber: parseInt(pdgaNumber),
|
||
history: formattedHistory
|
||
});
|
||
return;
|
||
}
|
||
|
||
logger.info(`Fetching rating history for PDGA ${pdgaNumber}...`);
|
||
const html = await fetchRatingHistory(pdgaNumber);
|
||
const history = parseRatingHistory(html);
|
||
|
||
try {
|
||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||
logger.info(`Saved rating history for PDGA ${pdgaNumber} to database`);
|
||
} catch (dbErr) {
|
||
logger.error(`Failed to save rating history to database:`, dbErr.message);
|
||
}
|
||
|
||
res.json({
|
||
pdgaNumber: parseInt(pdgaNumber),
|
||
history
|
||
});
|
||
} catch (error) {
|
||
logger.error('Error fetching rating history:', error.message);
|
||
res.status(500).json({ error: 'Failed to fetch rating history' });
|
||
}
|
||
});
|
||
|
||
router.post('/api/clear-cache', (req, res) => {
|
||
try {
|
||
db.run('UPDATE players SET last_updated = datetime("now", "-25 hours"), last_round_update = NULL', (err) => {
|
||
if (err) {
|
||
logger.error('Error clearing database cache:', err);
|
||
res.status(500).json({ error: 'Failed to clear database cache' });
|
||
return;
|
||
}
|
||
|
||
logger.info('Database cache cleared - all players will be refreshed on next request');
|
||
res.json({
|
||
success: true,
|
||
message: 'Cache cleared - database reset'
|
||
});
|
||
});
|
||
} catch (error) {
|
||
logger.error('Error clearing cache:', error);
|
||
res.status(500).json({ error: 'Failed to clear cache' });
|
||
}
|
||
});
|
||
|
||
router.get('/api/search-player/:pdgaNumber', async (req, res) => {
|
||
try {
|
||
const { pdgaNumber } = req.params;
|
||
logger.info(`Searching for player with PDGA number ${pdgaNumber}`);
|
||
|
||
const existingPlayer = await getPlayerFromDB(pdgaNumber);
|
||
if (existingPlayer) {
|
||
return res.json({
|
||
alreadyExists: true,
|
||
player: {
|
||
pdgaNumber: existingPlayer.pdga_number,
|
||
name: existingPlayer.name,
|
||
rating: existingPlayer.current_rating,
|
||
ratingChange: existingPlayer.rating_change
|
||
}
|
||
});
|
||
}
|
||
|
||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||
const playerData = parsePlayerData(html, pdgaNumber);
|
||
|
||
if (playerData.name === 'Unknown' || !playerData.name) {
|
||
return res.status(404).json({ error: 'Player not found' });
|
||
}
|
||
|
||
res.json({
|
||
alreadyExists: false,
|
||
player: playerData
|
||
});
|
||
} catch (error) {
|
||
logger.error('Error searching for player:', error.message);
|
||
res.status(500).json({ error: 'Failed to search for player' });
|
||
}
|
||
});
|
||
|
||
router.post('/api/add-player', async (req, res) => {
|
||
try {
|
||
const { pdgaNumber } = req.body;
|
||
|
||
if (!pdgaNumber) {
|
||
return res.status(400).json({ error: 'PDGA number is required' });
|
||
}
|
||
|
||
logger.info(`Adding player with PDGA number ${pdgaNumber}`);
|
||
|
||
const existingPlayer = await getPlayerFromDB(pdgaNumber);
|
||
if (existingPlayer) {
|
||
return res.status(409).json({
|
||
error: 'Player already exists',
|
||
player: {
|
||
pdgaNumber: existingPlayer.pdga_number,
|
||
name: existingPlayer.name,
|
||
rating: existingPlayer.current_rating
|
||
}
|
||
});
|
||
}
|
||
|
||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||
const playerData = parsePlayerData(html, pdgaNumber);
|
||
|
||
if (playerData.name === 'Unknown' || !playerData.name) {
|
||
return res.status(404).json({ error: 'Player not found' });
|
||
}
|
||
|
||
await savePlayerToDB(playerData);
|
||
|
||
logger.info(`Successfully added player: ${playerData.name} (#${pdgaNumber})`);
|
||
|
||
res.json({
|
||
success: true,
|
||
player: playerData
|
||
});
|
||
} catch (error) {
|
||
logger.error('Error adding player:', error.message);
|
||
res.status(500).json({ error: 'Failed to add player' });
|
||
}
|
||
});
|
||
|
||
router.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
|
||
try {
|
||
const { pdgaNumber } = req.params;
|
||
logger.info(`Manually refreshing player data for PDGA ${pdgaNumber}`);
|
||
|
||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||
const playerData = parsePlayerData(html, pdgaNumber);
|
||
|
||
await savePlayerToDB(playerData);
|
||
|
||
res.json({
|
||
success: true,
|
||
player: playerData
|
||
});
|
||
} catch (error) {
|
||
logger.error('Error refreshing player data:', error.message);
|
||
res.status(500).json({ error: 'Failed to refresh player data' });
|
||
}
|
||
});
|
||
|
||
router.post('/api/refresh-rating-history/:pdgaNumber', async (req, res) => {
|
||
try {
|
||
const { pdgaNumber } = req.params;
|
||
logger.info(`=== Manually refreshing rating history for PDGA ${pdgaNumber} ===`);
|
||
|
||
const startTime = Date.now();
|
||
const html = await fetchRatingHistory(pdgaNumber);
|
||
const fetchTime = Date.now() - startTime;
|
||
|
||
logger.info(`HTML fetch completed in ${fetchTime}ms, received ${html.length} bytes`);
|
||
|
||
const parseStartTime = Date.now();
|
||
const history = parseRatingHistory(html);
|
||
const parseTime = Date.now() - parseStartTime;
|
||
|
||
logger.info(`Parsing completed in ${parseTime}ms, found ${history.length} history entries`);
|
||
|
||
if (history.length > 0) {
|
||
logger.debug({ entries: history.slice(0, 3) }, 'Sample history entries');
|
||
} else {
|
||
logger.debug({ htmlSample: html.substring(0, 500) }, 'No history entries found');
|
||
}
|
||
|
||
const dbStartTime = Date.now();
|
||
await saveRatingHistoryToDB(pdgaNumber, history);
|
||
const dbTime = Date.now() - dbStartTime;
|
||
|
||
logger.info(`Database save completed in ${dbTime}ms`);
|
||
|
||
const formattedHistory = history.map(entry => ({
|
||
date: entry.date,
|
||
rating: entry.rating,
|
||
displayDate: entry.displayDate
|
||
}));
|
||
|
||
logger.info(`=== Rating history refresh completed for PDGA ${pdgaNumber} ===`);
|
||
|
||
res.json({
|
||
success: true,
|
||
history: formattedHistory
|
||
});
|
||
} catch (error) {
|
||
logger.error(`=== Error refreshing rating history for PDGA ${req.params.pdgaNumber} ===`);
|
||
logger.error('Error type:', error.constructor.name);
|
||
logger.error('Error message:', error.message);
|
||
|
||
res.status(500).json({
|
||
error: 'Failed to refresh rating history',
|
||
details: error.message,
|
||
code: error.code
|
||
});
|
||
}
|
||
});
|
||
|
||
router.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
|
||
req.setTimeout(600000);
|
||
res.setTimeout(600000);
|
||
|
||
let browser = null;
|
||
const { pdgaNumber } = req.params;
|
||
try {
|
||
const lastRoundUpdate = await getLastRoundUpdateDate(pdgaNumber);
|
||
const sinceDate = lastRoundUpdate ? new Date(lastRoundUpdate) : null;
|
||
|
||
if (sinceDate) {
|
||
const hoursSinceUpdate = (Date.now() - sinceDate.getTime()) / (1000 * 60 * 60);
|
||
if (hoursSinceUpdate < 24) {
|
||
const hoursRemaining = Math.ceil(24 - hoursSinceUpdate);
|
||
return res.status(429).json({
|
||
error: 'Rate limit exceeded',
|
||
message: `Prediction can only be refreshed once every 24 hours. Please try again in ${hoursRemaining} hour(s).`,
|
||
lastUpdate: sinceDate.toISOString(),
|
||
hoursRemaining: hoursRemaining
|
||
});
|
||
}
|
||
}
|
||
|
||
const isIncremental = !!sinceDate;
|
||
|
||
logger.info(`${isIncremental ? 'Incrementally updating' : 'Fully refreshing'} round history for PDGA ${pdgaNumber}${sinceDate ? ` since ${sinceDate.toDateString()}` : ''}`);
|
||
|
||
browser = await launchBrowser();
|
||
|
||
let officialHistory;
|
||
try {
|
||
officialHistory = await getOfficialRatingHistory(browser, pdgaNumber);
|
||
if (officialHistory.length > 0) {
|
||
await saveRatingHistoryToDB(pdgaNumber, officialHistory);
|
||
}
|
||
} catch (historyError) {
|
||
logger.error('Failed to fetch official history:', historyError.message);
|
||
officialHistory = [];
|
||
}
|
||
|
||
let allRounds = [];
|
||
try {
|
||
logger.info(`Using optimized approach: /details + new tournaments only for PDGA ${pdgaNumber}...`);
|
||
allRounds = await getOptimizedPlayerRounds(browser, pdgaNumber);
|
||
|
||
if (allRounds.length > 0) {
|
||
const roundsForDB = allRounds.map(round => ({
|
||
rating: round.rating,
|
||
date: round.date,
|
||
competition: round.competition
|
||
}));
|
||
|
||
await saveRoundHistoryToDB(pdgaNumber, roundsForDB, false);
|
||
logger.info(`✓ Saved ${allRounds.length} rounds using optimized approach`);
|
||
|
||
await updateLastRoundUpdateDate(pdgaNumber);
|
||
} else {
|
||
logger.info('ℹ No rounds found');
|
||
}
|
||
} catch (detailsError) {
|
||
logger.error('Failed to fetch rounds using optimized approach:', detailsError.message);
|
||
allRounds = [];
|
||
}
|
||
|
||
await browser.close();
|
||
browser = null;
|
||
|
||
const dbRounds = await getRoundHistoryFromDB(pdgaNumber);
|
||
const roundsForPrediction = dbRounds.map(round => ({
|
||
rating: round.rating,
|
||
date: new Date(round.date),
|
||
competition: round.competition_name
|
||
}));
|
||
|
||
const result = calculatePredictedRating(roundsForPrediction);
|
||
|
||
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
|
||
|
||
const officialCount = allRounds.filter(r => r.source === 'official').length;
|
||
const newCount = allRounds.filter(r => r.source === 'new').length;
|
||
|
||
res.json({
|
||
success: true,
|
||
predictedRating: result.rating,
|
||
stdDev: result.stdDev,
|
||
debugLog: result.debugLog,
|
||
totalRounds: roundsForPrediction.length,
|
||
officialRounds: officialCount,
|
||
newRounds: newCount,
|
||
approach: 'optimized',
|
||
message: `Used /details (${officialCount} rounds) + new tournaments (${newCount} rounds)`
|
||
});
|
||
} catch (error) {
|
||
logger.error(`=== Error refreshing round history for PDGA ${pdgaNumber} ===`);
|
||
logger.error('Error type:', error.constructor.name);
|
||
logger.error('Error message:', error.message);
|
||
|
||
if (browser) {
|
||
try {
|
||
await browser.close();
|
||
} catch (closeError) {
|
||
logger.error('Error closing browser:', closeError.message);
|
||
}
|
||
}
|
||
|
||
res.status(500).json({
|
||
error: 'Failed to refresh round history',
|
||
details: error.message,
|
||
errorType: error.constructor.name,
|
||
timestamp: new Date().toISOString(),
|
||
suggestion: error.message.includes('socket hang up') ?
|
||
'Rate limited by PDGA - try again in a few minutes.' :
|
||
error.message.includes('timeout') ?
|
||
'PDGA pages are loading slowly - try again later.' :
|
||
'Tournament scraping failed - check server logs for details'
|
||
});
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|