Add user self-registration and implement rate limiting for predictions
Allow users to add themselves to the player database through a web form, eliminating the need for manual pdga-numbers.txt updates. Implement 24-hour rate limiting on prediction refreshes to prevent abuse while maintaining reasonable update frequency. Key changes: - Add player self-registration with PDGA number lookup and confirmation - Store predicted ratings in database for persistence across restarts - Implement 24-hour rate limit on prediction refresh endpoint - Make database the single source of truth (text file only for initial seed) - Remove "Scrape All Layouts" bulk operation button - Update "Load All" to refresh existing players instead of text file 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+69
-41
@@ -87,6 +87,29 @@
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.search-container {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: #007bff;
|
||||
}
|
||||
.search-results-info {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -251,13 +274,21 @@
|
||||
<a href="/courses.html" style="color: #333;">Courses</a>
|
||||
</div>
|
||||
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
id="course-search"
|
||||
placeholder="Search courses by name or city..."
|
||||
oninput="searchCourses()"
|
||||
/>
|
||||
</div>
|
||||
<div id="search-results-info" class="search-results-info"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn" onclick="scrapeCourses()" id="scrape-courses-btn">
|
||||
<i class="fas fa-sync-alt"></i> Scrape Courses
|
||||
</button>
|
||||
<button class="btn" onclick="scrapeAllLayouts()" id="scrape-all-layouts-btn">
|
||||
<i class="fas fa-layer-group"></i> Scrape All Layouts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none;">Loading courses...</div>
|
||||
@@ -265,6 +296,8 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allCourses = [];
|
||||
|
||||
async function loadCourses() {
|
||||
const loading = document.getElementById('loading');
|
||||
const tableDiv = document.getElementById('courses-table');
|
||||
@@ -274,10 +307,11 @@
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/courses');
|
||||
const courses = await response.json();
|
||||
allCourses = await response.json();
|
||||
|
||||
loading.style.display = 'none';
|
||||
displayCourses(courses);
|
||||
displayCourses(allCourses);
|
||||
updateSearchInfo(allCourses.length, allCourses.length);
|
||||
} catch (error) {
|
||||
console.error('Error loading courses:', error);
|
||||
loading.style.display = 'none';
|
||||
@@ -285,6 +319,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
function searchCourses() {
|
||||
const searchInput = document.getElementById('course-search');
|
||||
const searchTerm = searchInput.value.toLowerCase().trim();
|
||||
|
||||
if (!searchTerm) {
|
||||
displayCourses(allCourses);
|
||||
updateSearchInfo(allCourses.length, allCourses.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = allCourses.filter(course => {
|
||||
return course.name.toLowerCase().includes(searchTerm) ||
|
||||
course.city.toLowerCase().includes(searchTerm);
|
||||
});
|
||||
|
||||
displayCourses(filtered);
|
||||
updateSearchInfo(filtered.length, allCourses.length);
|
||||
}
|
||||
|
||||
function updateSearchInfo(showing, total) {
|
||||
const infoDiv = document.getElementById('search-results-info');
|
||||
if (showing === total) {
|
||||
infoDiv.textContent = `Showing all ${total} courses`;
|
||||
} else {
|
||||
infoDiv.textContent = `Showing ${showing} of ${total} courses`;
|
||||
}
|
||||
}
|
||||
|
||||
function displayCourses(courses) {
|
||||
const tableDiv = document.getElementById('courses-table');
|
||||
|
||||
@@ -490,7 +552,8 @@
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
loadCourses(); // Reload the courses list
|
||||
await loadCourses(); // Reload the courses list
|
||||
searchCourses(); // Reapply search filter if any
|
||||
} else {
|
||||
alert('Failed to scrape courses');
|
||||
}
|
||||
@@ -542,41 +605,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function scrapeAllLayouts() {
|
||||
const btn = document.getElementById('scrape-all-layouts-btn');
|
||||
|
||||
if (!confirm('This will scrape layouts for all courses. This may take several minutes. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-sync-alt spinning"></i> Scraping...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scrape-all-layouts', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
// Clear all loaded states to force reload
|
||||
document.querySelectorAll('.layouts-container').forEach(container => {
|
||||
container.dataset.loaded = 'false';
|
||||
});
|
||||
} else {
|
||||
alert('Failed to scrape layouts');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error scraping all layouts:', error);
|
||||
alert('Error scraping all layouts');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-layer-group"></i> Scrape All Layouts';
|
||||
}
|
||||
}
|
||||
|
||||
// Load courses on page load
|
||||
loadCourses();
|
||||
</script>
|
||||
|
||||
+325
-1
@@ -311,6 +311,123 @@
|
||||
.debug-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
.add-player-section {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
.add-player-section h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
font-size: 18px;
|
||||
}
|
||||
.add-player-form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pdga-input {
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
width: 250px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.pdga-input:focus {
|
||||
border-color: #007bff;
|
||||
}
|
||||
.btn-add {
|
||||
background-color: #28a745;
|
||||
}
|
||||
.btn-add:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10001;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
.modal-header {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #007bff;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
color: #495057;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.modal-footer {
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 15px;
|
||||
font-size: 28px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
.btn-cancel {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
.btn-confirm {
|
||||
background-color: #28a745;
|
||||
}
|
||||
.btn-confirm:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.add-player-section {
|
||||
padding: 15px;
|
||||
}
|
||||
.add-player-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
.pdga-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -320,6 +437,23 @@
|
||||
<a href="/" style="margin: 0 15px; color: #333; text-decoration: none; font-weight: bold;">Player Ratings</a>
|
||||
<a href="/courses.html" style="margin: 0 15px; color: #007bff; text-decoration: none; font-weight: bold;">Courses</a>
|
||||
</div>
|
||||
|
||||
<!-- Add Player Section -->
|
||||
<div class="add-player-section">
|
||||
<h3>Add Yourself to Tracked Players</h3>
|
||||
<div class="add-player-form">
|
||||
<input
|
||||
type="number"
|
||||
id="pdga-number-input"
|
||||
class="pdga-input"
|
||||
placeholder="Enter your PDGA number"
|
||||
min="1"
|
||||
/>
|
||||
<button class="btn btn-add" onclick="searchAndAddPlayer()">
|
||||
<i class="fas fa-user-plus"></i> Add Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position: absolute; top: 10px; right: 15px;">
|
||||
<a href="#" onclick="loadAllPlayers(); return false;" style="color: #007bff; font-size: 11px; text-decoration: none; margin-right: 10px;" title="Load all player data" id="load-all-btn">Load All</a>
|
||||
<a href="#" onclick="clearCache(); return false;" style="color: #ccc; font-size: 12px; text-decoration: none; opacity: 0.3;" title="Clear cache"><i class="fas fa-cog"></i></a>
|
||||
@@ -343,6 +477,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Player Confirmation Modal -->
|
||||
<div id="add-player-modal" class="modal" onclick="closeAddPlayerModal(event)">
|
||||
<div class="modal-content" onclick="event.stopPropagation()">
|
||||
<button class="modal-close" onclick="closeAddPlayerModal()">×</button>
|
||||
<div class="modal-header" id="add-player-modal-header">Confirm Player</div>
|
||||
<div class="modal-body" id="add-player-modal-body">Loading...</div>
|
||||
<div class="modal-footer" id="add-player-modal-footer">
|
||||
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Cancel</button>
|
||||
<button class="btn btn-confirm" id="confirm-add-btn" onclick="confirmAddPlayer()">Add Player</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function fetchRatingsWithProgress() {
|
||||
const progressSection = document.getElementById('progress-section');
|
||||
@@ -783,6 +930,11 @@
|
||||
// If error message is JSON, parse it
|
||||
const errorData = JSON.parse(error.message);
|
||||
errorMessage = errorData.error || errorMessage;
|
||||
|
||||
// Special handling for rate limit errors
|
||||
if (errorData.message) {
|
||||
errorDetails = errorData.message;
|
||||
} else {
|
||||
errorDetails = errorData.details || '';
|
||||
if (errorData.suggestion) {
|
||||
errorDetails += '\n\nSuggestion: ' + errorData.suggestion;
|
||||
@@ -793,12 +945,13 @@
|
||||
if (errorData.timestamp) {
|
||||
errorDetails += '\n\nTime: ' + new Date(errorData.timestamp).toLocaleString();
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// If not JSON, just use the error message
|
||||
errorDetails = error.message;
|
||||
}
|
||||
|
||||
const fullMessage = errorDetails ? errorMessage + '\n\nDetails:\n' + errorDetails : errorMessage;
|
||||
const fullMessage = errorDetails ? errorMessage + '\n\n' + errorDetails : errorMessage;
|
||||
alert(fullMessage);
|
||||
} finally {
|
||||
icon.classList.remove('spinning');
|
||||
@@ -970,6 +1123,177 @@
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Add Player Functions
|
||||
let pendingPlayerData = null;
|
||||
|
||||
async function searchAndAddPlayer() {
|
||||
const input = document.getElementById('pdga-number-input');
|
||||
const pdgaNumber = input.value.trim();
|
||||
|
||||
if (!pdgaNumber) {
|
||||
alert('Please enter a PDGA number');
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.querySelector('.btn-add');
|
||||
const originalHTML = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="fas fa-sync-alt spinning"></i> Searching...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/search-player/${pdgaNumber}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
showErrorModal(data.error || 'Player not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.alreadyExists) {
|
||||
showInfoModal(`${data.player.name} is already being tracked!`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store player data and show confirmation modal
|
||||
pendingPlayerData = data.player;
|
||||
showConfirmationModal(data.player);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error searching for player:', error);
|
||||
showErrorModal('Failed to search for player. Please try again.');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
|
||||
function showConfirmationModal(player) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
const header = document.getElementById('add-player-modal-header');
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
|
||||
header.textContent = 'Confirm Player';
|
||||
body.innerHTML = `
|
||||
<p>Is this the correct player you want to add?</p>
|
||||
<div style="background-color: #f8f9fa; padding: 15px; border-radius: 4px; margin-top: 15px;">
|
||||
<strong style="font-size: 18px; color: #007bff;">${player.name}</strong><br>
|
||||
<span style="color: #6c757d;">PDGA #${player.pdgaNumber}</span><br>
|
||||
${player.rating ? `<span style="color: #28a745; font-weight: bold;">Current Rating: ${player.rating}</span>` : '<span style="color: #999;">No rating available</span>'}
|
||||
</div>
|
||||
`;
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Cancel</button>
|
||||
<button class="btn btn-confirm" onclick="confirmAddPlayer()">Add Player</button>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showErrorModal(message) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
const header = document.getElementById('add-player-modal-header');
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
|
||||
header.textContent = 'Player Not Found';
|
||||
body.innerHTML = `
|
||||
<p style="color: #dc3545;">
|
||||
<i class="fas fa-exclamation-circle"></i> ${message}
|
||||
</p>
|
||||
<p style="margin-top: 10px; color: #6c757d; font-size: 14px;">
|
||||
Please check the PDGA number and try again.
|
||||
</p>
|
||||
`;
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Close</button>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
function showInfoModal(message) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
const header = document.getElementById('add-player-modal-header');
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
|
||||
header.textContent = 'Information';
|
||||
body.innerHTML = `
|
||||
<p style="color: #007bff;">
|
||||
<i class="fas fa-info-circle"></i> ${message}
|
||||
</p>
|
||||
`;
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Close</button>
|
||||
`;
|
||||
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
async function confirmAddPlayer() {
|
||||
if (!pendingPlayerData) {
|
||||
closeAddPlayerModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
const body = document.getElementById('add-player-modal-body');
|
||||
const footer = document.getElementById('add-player-modal-footer');
|
||||
|
||||
// Show loading state
|
||||
body.innerHTML = '<p style="text-align: center;"><i class="fas fa-sync-alt spinning"></i> Adding player...</p>';
|
||||
footer.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/add-player', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ pdgaNumber: pendingPlayerData.pdgaNumber })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to add player');
|
||||
}
|
||||
|
||||
// Success! Show success message
|
||||
body.innerHTML = `
|
||||
<p style="color: #28a745; text-align: center;">
|
||||
<i class="fas fa-check-circle" style="font-size: 48px; margin-bottom: 15px;"></i><br>
|
||||
<strong>${data.player.name}</strong> has been added successfully!
|
||||
</p>
|
||||
`;
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-confirm" onclick="closeAddPlayerModal(); location.reload();">OK</button>
|
||||
`;
|
||||
|
||||
// Clear input
|
||||
document.getElementById('pdga-number-input').value = '';
|
||||
pendingPlayerData = null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding player:', error);
|
||||
body.innerHTML = `
|
||||
<p style="color: #dc3545;">
|
||||
<i class="fas fa-exclamation-circle"></i> ${error.message}
|
||||
</p>
|
||||
`;
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-cancel" onclick="closeAddPlayerModal()">Close</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAddPlayerModal(event) {
|
||||
const modal = document.getElementById('add-player-modal');
|
||||
modal.style.display = 'none';
|
||||
pendingPlayerData = null;
|
||||
}
|
||||
|
||||
fetchRatingsWithProgress();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -9,6 +9,7 @@ const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(express.static('public'));
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize SQLite database
|
||||
const dbPath = process.env.DB_PATH || './ratings.db';
|
||||
@@ -44,7 +45,7 @@ function initializeDatabase() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if column exists by querying table info
|
||||
// Check if columns exist by querying table info
|
||||
db.all("PRAGMA table_info(players)", (err, columns) => {
|
||||
if (err) {
|
||||
console.error('Error getting table info:', err);
|
||||
@@ -52,6 +53,7 @@ function initializeDatabase() {
|
||||
}
|
||||
|
||||
const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update');
|
||||
const hasPredictedRating = columns.some(col => col.name === 'predicted_rating');
|
||||
|
||||
if (!hasLastRoundUpdate) {
|
||||
console.log('Adding last_round_update column to players table...');
|
||||
@@ -65,6 +67,19 @@ function initializeDatabase() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasPredictedRating) {
|
||||
console.log('Adding predicted_rating column to players table...');
|
||||
db.run(`
|
||||
ALTER TABLE players ADD COLUMN predicted_rating INTEGER DEFAULT NULL
|
||||
`, (err) => {
|
||||
if (err) {
|
||||
console.error('Error adding predicted_rating column:', err.message);
|
||||
} else {
|
||||
console.log('Successfully added predicted_rating column');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,10 +155,24 @@ function initializeDatabase() {
|
||||
});
|
||||
}
|
||||
|
||||
// Check and populate database from PDGA numbers file at startup
|
||||
// Check and populate database from PDGA numbers file at startup (only if DB is empty)
|
||||
async function checkAndPopulateDatabase() {
|
||||
try {
|
||||
console.log('=== Checking database population against PDGA numbers file ===');
|
||||
// Check if database has any players
|
||||
const playerCount = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM players', [], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row.count);
|
||||
});
|
||||
});
|
||||
|
||||
if (playerCount > 0) {
|
||||
console.log(`✓ Database already has ${playerCount} players - skipping text file import`);
|
||||
console.log('📝 Note: pdga-numbers.txt is only used when database is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('=== Database is empty - populating from PDGA numbers file ===');
|
||||
|
||||
const pdgaNumbers = fs.readFileSync('pdga-numbers.txt', 'utf-8')
|
||||
.split('\n')
|
||||
@@ -152,40 +181,27 @@ async function checkAndPopulateDatabase() {
|
||||
|
||||
console.log(`Found ${pdgaNumbers.length} PDGA numbers in file`);
|
||||
|
||||
const missingPlayers = [];
|
||||
|
||||
// Check which players are missing from database
|
||||
for (const pdgaNumber of pdgaNumbers) {
|
||||
const player = await getPlayerFromDB(pdgaNumber);
|
||||
if (!player) {
|
||||
missingPlayers.push(pdgaNumber);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPlayers.length === 0) {
|
||||
console.log('✓ All players from PDGA numbers file are already in database');
|
||||
if (pdgaNumbers.length === 0) {
|
||||
console.log('⚠ No PDGA numbers found in file');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${missingPlayers.length} missing players: [${missingPlayers.join(', ')}]`);
|
||||
console.log('=== Starting automatic population of missing players ===');
|
||||
console.log('Populating database with players from file...');
|
||||
|
||||
// Populate missing players
|
||||
for (let i = 0; i < missingPlayers.length; i++) {
|
||||
const pdgaNumber = missingPlayers[i];
|
||||
console.log(`[${i + 1}/${missingPlayers.length}] Scraping missing player PDGA ${pdgaNumber}...`);
|
||||
for (let i = 0; i < pdgaNumbers.length; i++) {
|
||||
const pdgaNumber = pdgaNumbers[i];
|
||||
console.log(`[${i + 1}/${pdgaNumbers.length}] Adding PDGA ${pdgaNumber}...`);
|
||||
|
||||
try {
|
||||
const playerData = await scrapePDGARating(pdgaNumber);
|
||||
console.log(`✓ Added PDGA ${pdgaNumber}: ${playerData.name}`);
|
||||
console.log(` ✓ Added ${playerData.name}`);
|
||||
|
||||
// Delay between requests to be respectful to PDGA
|
||||
if (i < missingPlayers.length - 1) {
|
||||
console.log('Waiting 2s before next request...');
|
||||
if (i < pdgaNumbers.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to add PDGA ${pdgaNumber}: ${error.message}`);
|
||||
console.error(` ✗ Failed to add PDGA ${pdgaNumber}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,7 +530,12 @@ async function getPlayerDataFromDB(pdgaNumber) {
|
||||
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||
if (cachedPlayer) {
|
||||
console.log(`Loading PDGA ${pdgaNumber} from DB (source of truth)`);
|
||||
const predictedRating = await getPredictedRatingFromDB(pdgaNumber);
|
||||
|
||||
// Use stored predicted_rating if available, otherwise calculate it from round history
|
||||
let predictedRating = cachedPlayer.predicted_rating;
|
||||
if (!predictedRating || predictedRating === 0) {
|
||||
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
|
||||
}
|
||||
|
||||
return {
|
||||
pdgaNumber: cachedPlayer.pdga_number,
|
||||
@@ -630,6 +651,10 @@ async function getPredictedRatingFromDB(pdgaNumber) {
|
||||
}));
|
||||
|
||||
const result = calculatePredictedRating(roundRatings);
|
||||
|
||||
// Save the calculated prediction to database
|
||||
await savePredictedRatingToDB(pdgaNumber, result.rating);
|
||||
|
||||
return result.rating;
|
||||
}
|
||||
return 0;
|
||||
@@ -639,6 +664,19 @@ async function getPredictedRatingFromDB(pdgaNumber) {
|
||||
}
|
||||
}
|
||||
|
||||
function savePredictedRatingToDB(pdgaNumber, predictedRating) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(
|
||||
'UPDATE players SET predicted_rating = ? WHERE pdga_number = ?',
|
||||
[predictedRating, pdgaNumber],
|
||||
function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function getOfficialRatingHistory(browser, pdgaNumber) {
|
||||
const page = await browser.newPage();
|
||||
let ratingHistory = [];
|
||||
@@ -1913,17 +1951,26 @@ async function scrapeEventResults(browser, eventUrl, layoutsWithDivisions) {
|
||||
|
||||
async function getAllRatingsFromDB(progressCallback = null) {
|
||||
try {
|
||||
const pdgaNumbers = fs.readFileSync('pdga-numbers.txt', 'utf-8')
|
||||
.split('\n')
|
||||
.map(num => num.trim())
|
||||
.filter(num => num);
|
||||
// Get all players from database instead of text file
|
||||
const allPlayers = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT pdga_number, name, current_rating, rating_change FROM players ORDER BY pdga_number',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Loading ${allPlayers.length} players from database...`);
|
||||
|
||||
const ratings = [];
|
||||
const total = pdgaNumbers.length;
|
||||
const total = allPlayers.length;
|
||||
|
||||
for (let i = 0; i < pdgaNumbers.length; i++) {
|
||||
const pdgaNumber = pdgaNumbers[i];
|
||||
console.log(`Loading PDGA ${pdgaNumber} from database... (${i + 1}/${total})`);
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
const player = allPlayers[i];
|
||||
const pdgaNumber = player.pdga_number;
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
@@ -1935,14 +1982,11 @@ async function getAllRatingsFromDB(progressCallback = null) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Load from database only (source of truth)
|
||||
// Load full player data from database
|
||||
const playerData = await getPlayerDataFromDB(pdgaNumber);
|
||||
|
||||
if (playerData) {
|
||||
ratings.push(playerData);
|
||||
} else {
|
||||
console.log(`PDGA ${pdgaNumber} not found in DB - skipping (page load)`);
|
||||
// Skip players not in DB for page loads
|
||||
}
|
||||
|
||||
if (progressCallback) {
|
||||
@@ -1950,17 +1994,17 @@ async function getAllRatingsFromDB(progressCallback = null) {
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: playerData ? 'completed' : 'skipped',
|
||||
name: playerData ? playerData.name : 'Not in DB'
|
||||
status: 'completed',
|
||||
name: playerData ? playerData.name : player.name
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load PDGA ${pdgaNumber} from database:`, error.message);
|
||||
const errorData = {
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
name: 'Database Error',
|
||||
rating: null,
|
||||
ratingChange: null,
|
||||
name: player.name || 'Database Error',
|
||||
rating: player.current_rating,
|
||||
ratingChange: player.rating_change,
|
||||
predictedRating: null
|
||||
};
|
||||
ratings.push(errorData);
|
||||
@@ -1971,7 +2015,7 @@ async function getAllRatingsFromDB(progressCallback = null) {
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'error',
|
||||
name: 'Database Error'
|
||||
name: player.name || 'Database Error'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1979,7 +2023,7 @@ async function getAllRatingsFromDB(progressCallback = null) {
|
||||
|
||||
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
|
||||
} catch (error) {
|
||||
console.error('Error reading PDGA numbers:', error);
|
||||
console.error('Error loading players from database:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -2040,12 +2084,12 @@ app.post('/api/populate-database', (req, res) => {
|
||||
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
};
|
||||
|
||||
console.log('=== Starting database population from PDGA numbers file ===');
|
||||
console.log('=== Starting database population from database players ===');
|
||||
|
||||
// Use the scraping function to populate database
|
||||
getAllRatingsWithScraping(progressCallback).then(ratings => {
|
||||
console.log(`=== Database population complete: ${ratings.length} players added ===`);
|
||||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings, message: `Successfully populated database with ${ratings.length} players` })}\n\n`);
|
||||
// Populate database by refreshing all players in database
|
||||
refreshAllPlayersInDB(progressCallback).then(ratings => {
|
||||
console.log(`=== Database population complete: ${ratings.length} players refreshed ===`);
|
||||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings, message: `Successfully refreshed ${ratings.length} players` })}\n\n`);
|
||||
res.end();
|
||||
}).catch(error => {
|
||||
console.error('Error populating database:', error);
|
||||
@@ -2054,25 +2098,19 @@ app.post('/api/populate-database', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Simple endpoint to check if database needs population
|
||||
// Simple endpoint to check database status
|
||||
app.get('/api/database-status', async (req, res) => {
|
||||
try {
|
||||
const pdgaNumbers = fs.readFileSync('pdga-numbers.txt', 'utf-8')
|
||||
.split('\n')
|
||||
.map(num => num.trim())
|
||||
.filter(num => num);
|
||||
|
||||
let playersInDB = 0;
|
||||
for (const pdgaNumber of pdgaNumbers) {
|
||||
const player = await getPlayerFromDB(pdgaNumber);
|
||||
if (player) playersInDB++;
|
||||
}
|
||||
const playerCount = await new Promise((resolve, reject) => {
|
||||
db.get('SELECT COUNT(*) as count FROM players', [], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row.count);
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
totalExpected: pdgaNumbers.length,
|
||||
playersInDB: playersInDB,
|
||||
needsPopulation: playersInDB === 0,
|
||||
populationProgress: Math.round((playersInDB / pdgaNumbers.length) * 100)
|
||||
playersInDB: playerCount,
|
||||
needsPopulation: playerCount === 0
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to check database status' });
|
||||
@@ -2092,8 +2130,8 @@ app.get('/api/load-all-players', (req, res) => {
|
||||
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||||
};
|
||||
|
||||
// Use the original scraping function for bulk loading
|
||||
getAllRatingsWithScraping(progressCallback).then(ratings => {
|
||||
// Refresh all players currently in database
|
||||
refreshAllPlayersInDB(progressCallback).then(ratings => {
|
||||
res.write(`data: ${JSON.stringify({ status: 'complete', ratings })}\n\n`);
|
||||
res.end();
|
||||
}).catch(error => {
|
||||
@@ -2176,6 +2214,87 @@ async function getAllRatingsWithScraping(progressCallback = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh all players currently in database
|
||||
async function refreshAllPlayersInDB(progressCallback = null) {
|
||||
try {
|
||||
// Get all players from database
|
||||
const allPlayers = await new Promise((resolve, reject) => {
|
||||
db.all(
|
||||
'SELECT pdga_number, name FROM players ORDER BY pdga_number',
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows || []);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`Refreshing ${allPlayers.length} players from database...`);
|
||||
|
||||
const ratings = [];
|
||||
const total = allPlayers.length;
|
||||
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
const player = allPlayers[i];
|
||||
const pdgaNumber = player.pdga_number;
|
||||
|
||||
console.log(`Refreshing PDGA ${pdgaNumber}... (${i + 1}/${total})`);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'loading'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const playerData = await scrapePDGARating(pdgaNumber);
|
||||
ratings.push(playerData);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'completed',
|
||||
name: playerData.name
|
||||
});
|
||||
}
|
||||
|
||||
// Delay between PDGA scraping requests to be respectful
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh PDGA ${pdgaNumber}:`, error.message);
|
||||
const errorData = {
|
||||
pdgaNumber: parseInt(pdgaNumber),
|
||||
name: player.name || 'Error',
|
||||
rating: 0,
|
||||
ratingChange: null,
|
||||
predictedRating: null
|
||||
};
|
||||
ratings.push(errorData);
|
||||
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
current: i + 1,
|
||||
total,
|
||||
pdgaNumber,
|
||||
status: 'error',
|
||||
name: player.name || 'Error'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
|
||||
} catch (error) {
|
||||
console.error('Error refreshing all players:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRatingHistory(pdgaNumber) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
@@ -2380,6 +2499,93 @@ app.post('/api/clear-cache', (req, res) => {
|
||||
});
|
||||
|
||||
// Individual player refresh endpoints
|
||||
// Search for a player (check if exists in DB and fetch from PDGA)
|
||||
app.get('/api/search-player/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
console.log(`Searching for player with PDGA number ${pdgaNumber}`);
|
||||
|
||||
// Check if player already exists in database
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch player data from PDGA
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const playerData = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
// Check if player was found (name shouldn't be 'Unknown')
|
||||
if (playerData.name === 'Unknown' || !playerData.name) {
|
||||
return res.status(404).json({ error: 'Player not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
alreadyExists: false,
|
||||
player: playerData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error searching for player:', error.message);
|
||||
res.status(500).json({ error: 'Failed to search for player' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add a new player to the database
|
||||
app.post('/api/add-player', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.body;
|
||||
|
||||
if (!pdgaNumber) {
|
||||
return res.status(400).json({ error: 'PDGA number is required' });
|
||||
}
|
||||
|
||||
console.log(`Adding player with PDGA number ${pdgaNumber}`);
|
||||
|
||||
// Check if player already exists
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch player data from PDGA
|
||||
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||
const playerData = parsePlayerData(html, pdgaNumber);
|
||||
|
||||
// Verify player was found
|
||||
if (playerData.name === 'Unknown' || !playerData.name) {
|
||||
return res.status(404).json({ error: 'Player not found' });
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await savePlayerToDB(playerData);
|
||||
|
||||
console.log(`Successfully added player: ${playerData.name} (#${pdgaNumber})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
player: playerData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding player:', error.message);
|
||||
res.status(500).json({ error: 'Failed to add player' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
|
||||
try {
|
||||
const { pdgaNumber } = req.params;
|
||||
@@ -2473,6 +2679,21 @@ app.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
|
||||
// Check when we last updated rounds for this player
|
||||
const lastRoundUpdate = await getLastRoundUpdateDate(pdgaNumber);
|
||||
const sinceDate = lastRoundUpdate ? new Date(lastRoundUpdate) : null;
|
||||
|
||||
// Rate limit: Only allow refresh once every 24 hours
|
||||
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;
|
||||
|
||||
console.log(`${isIncremental ? 'Incrementally updating' : 'Fully refreshing'} round history for PDGA ${pdgaNumber}${sinceDate ? ` since ${sinceDate.toDateString()}` : ''}`);
|
||||
@@ -2551,6 +2772,9 @@ app.post('/api/refresh-round-history/:pdgaNumber', async (req, res) => {
|
||||
|
||||
const result = calculatePredictedRating(roundsForPrediction);
|
||||
|
||||
// Save the predicted rating to database for persistence
|
||||
await savePredictedRatingToDB(pdgaNumber, result.rating);
|
||||
|
||||
// Count official vs new rounds
|
||||
const officialCount = allRounds.filter(r => r.source === 'official').length;
|
||||
const newCount = allRounds.filter(r => r.source === 'new').length;
|
||||
@@ -3041,72 +3265,6 @@ app.post('/api/scrape-event-results/:courseId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/scrape-all-layouts', async (req, res) => {
|
||||
// Increase timeout for bulk scraping operations
|
||||
req.setTimeout(1800000); // 30 minutes for bulk operations
|
||||
res.setTimeout(1800000);
|
||||
|
||||
let browser = null;
|
||||
try {
|
||||
console.log('Starting bulk layout scraping for all courses...');
|
||||
|
||||
const courses = await getAllCoursesFromDB();
|
||||
console.log(`Found ${courses.length} courses to scrape`);
|
||||
|
||||
browser = await puppeteer.launch({
|
||||
headless: "new",
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-accelerated-2d-canvas',
|
||||
'--no-first-run',
|
||||
'--no-zygote',
|
||||
'--disable-gpu'
|
||||
]
|
||||
});
|
||||
|
||||
let totalLayouts = 0;
|
||||
for (let i = 0; i < courses.length; i++) {
|
||||
const course = courses[i];
|
||||
console.log(`[${i + 1}/${courses.length}] Scraping layouts for: ${course.name}`);
|
||||
|
||||
try {
|
||||
const layouts = await scrapeCourseLayouts(browser, course.link, course.id);
|
||||
totalLayouts += layouts.length;
|
||||
|
||||
// Delay between requests to be respectful
|
||||
if (i < courses.length - 1) {
|
||||
console.log('Waiting 2s before next request...');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scraping layouts for ${course.name}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
browser = null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
coursesProcessed: courses.length,
|
||||
totalLayouts: totalLayouts,
|
||||
message: `Successfully scraped layouts for ${courses.length} courses (${totalLayouts} total layouts)`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error scraping all layouts:', error.message);
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (closeError) {
|
||||
console.error('Error closing browser:', closeError.message);
|
||||
}
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to scrape all layouts' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/predicted-rating/:pdgaNumber', async (req, res) => {
|
||||
let browser = null;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user