Refactor: split server.js monolith into modular architecture
Extract 3410-line server.js into 12 focused modules:
- src/db.js: database init and migrations
- src/models/{player,course}.js: DB helper functions
- src/scrapers/{browser,player-http,player-puppeteer,course-puppeteer}.js
- src/services/{player-service,rating-calculator}.js
- src/routes/{players,courses,pages}.js
Remove dead code: duplicate saveRatingHistoryToDB, legacy
getPlayerCompetitionRatings/getPredictedRating/getAllRatingsWithScraping,
unused getCourseFromDB/getLatestOfficialRoundDate/testPDGARateLimit,
legacy cache Map, and POST /api/predicted-rating route.
Consolidate 5 duplicated Puppeteer launch blocks into launchBrowser().
server.js is now 28 lines: imports, middleware, mount routers, bootstrap.
This commit is contained in:
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.cache/
|
.cache/
|
||||||
*.log
|
*.log
|
||||||
|
*.db
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
|
||||||
|
const dbPath = process.env.DB_PATH || './ratings.db';
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
|
function initializeDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS players (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
pdga_number INTEGER UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
current_rating INTEGER,
|
||||||
|
rating_change INTEGER,
|
||||||
|
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_round_update DATETIME DEFAULT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.get("PRAGMA table_info(players)", (err, info) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error checking table schema:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.all("PRAGMA table_info(players)", (err, columns) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error getting table info:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasLastRoundUpdate = columns.some(col => col.name === 'last_round_update');
|
||||||
|
const hasPredictedRating = columns.some(col => col.name === 'predicted_rating');
|
||||||
|
const hasStdDev = columns.some(col => col.name === 'std_dev');
|
||||||
|
|
||||||
|
if (!hasLastRoundUpdate) {
|
||||||
|
console.log('Adding last_round_update column to players table...');
|
||||||
|
db.run(`ALTER TABLE players ADD COLUMN last_round_update DATETIME DEFAULT NULL`, (err) => {
|
||||||
|
if (err) console.error('Error adding last_round_update column:', err.message);
|
||||||
|
else console.log('Successfully added last_round_update column');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasStdDev) {
|
||||||
|
console.log('Adding std_dev column to players table...');
|
||||||
|
db.run(`ALTER TABLE players ADD COLUMN std_dev INTEGER DEFAULT NULL`, (err) => {
|
||||||
|
if (err) console.error('Error adding std_dev column:', err.message);
|
||||||
|
else console.log('Successfully added std_dev column');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS round_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
competition_name TEXT NOT NULL,
|
||||||
|
rating INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (player_id) REFERENCES players (id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rating_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
rating INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (player_id) REFERENCES players (id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS courses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
link TEXT UNIQUE NOT NULL,
|
||||||
|
city TEXT,
|
||||||
|
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS layouts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
course_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
par INTEGER NOT NULL,
|
||||||
|
mean_rating INTEGER,
|
||||||
|
rating_count INTEGER DEFAULT 0,
|
||||||
|
last_calculated DATETIME,
|
||||||
|
FOREIGN KEY (course_id) REFERENCES courses (id),
|
||||||
|
UNIQUE(course_id, name, par)
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
db.run(`ALTER TABLE layouts ADD COLUMN mean_rating INTEGER`, () => {
|
||||||
|
db.run(`ALTER TABLE layouts ADD COLUMN rating_count INTEGER DEFAULT 0`, () => {
|
||||||
|
db.run(`ALTER TABLE layouts ADD COLUMN last_calculated DATETIME`, () => {
|
||||||
|
db.run(`ALTER TABLE layouts ADD COLUMN last_played DATE`, () => {
|
||||||
|
console.log('Database initialized successfully');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAndPopulateDatabase() {
|
||||||
|
const fs = require('fs');
|
||||||
|
const { scrapePDGARating } = require('./services/player-service');
|
||||||
|
|
||||||
|
try {
|
||||||
|
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')
|
||||||
|
.map(num => num.trim())
|
||||||
|
.filter(num => num);
|
||||||
|
|
||||||
|
console.log(`Found ${pdgaNumbers.length} PDGA numbers in file`);
|
||||||
|
|
||||||
|
if (pdgaNumbers.length === 0) {
|
||||||
|
console.log('⚠ No PDGA numbers found in file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Populating database with players from file...');
|
||||||
|
|
||||||
|
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 ${playerData.name}`);
|
||||||
|
|
||||||
|
if (i < pdgaNumbers.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ✗ Failed to add PDGA ${pdgaNumber}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== Database population complete ===');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during database population check:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { db, initializeDatabase, checkAndPopulateDatabase };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { db } = require('../db');
|
||||||
|
|
||||||
|
function saveCourseToDB(courseData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO courses (name, link, city, last_updated)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))`,
|
||||||
|
[courseData.name, courseData.link, courseData.city],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this.lastID);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllCoursesFromDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM courses ORDER BY name ASC',
|
||||||
|
[],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLayoutToDB(courseId, layoutData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR IGNORE INTO layouts (course_id, name, par)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
[courseId, layoutData.name, layoutData.par],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this.lastID);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLayoutsForCourse(courseId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM layouts WHERE course_id = ? ORDER BY last_played DESC, name ASC',
|
||||||
|
[courseId],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayoutRating(courseId, layoutName, par, meanRating, ratingCount, lastPlayed = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`UPDATE layouts
|
||||||
|
SET mean_rating = ?, rating_count = ?, last_calculated = datetime('now'), last_played = ?
|
||||||
|
WHERE course_id = ? AND name = ? AND par = ?`,
|
||||||
|
[meanRating, ratingCount, lastPlayed, courseId, layoutName, par],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this.changes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
saveCourseToDB,
|
||||||
|
getAllCoursesFromDB,
|
||||||
|
saveLayoutToDB,
|
||||||
|
getLayoutsForCourse,
|
||||||
|
updateLayoutRating
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
const { db } = require('../db');
|
||||||
|
const { parseDate } = require('../services/rating-calculator');
|
||||||
|
|
||||||
|
function getPlayerFromDB(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT * FROM players WHERE pdga_number = ?',
|
||||||
|
[pdgaNumber],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePlayerToDB(playerData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR REPLACE INTO players (pdga_number, name, current_rating, rating_change, last_updated)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||||
|
[playerData.pdgaNumber, playerData.name, playerData.rating, playerData.ratingChange],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(this.lastID);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRatingHistoryFromDB(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!player) return resolve(null);
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM rating_history WHERE player_id = ? ORDER BY date ASC',
|
||||||
|
[player.id],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRatingHistoryToDB(pdgaNumber, ratingHistory) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!player) return reject(new Error('Player not found'));
|
||||||
|
|
||||||
|
db.run('DELETE FROM rating_history WHERE player_id = ?', [player.id], (err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
if (ratingHistory.length === 0) {
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
let completed = 0;
|
||||||
|
const total = ratingHistory.length;
|
||||||
|
|
||||||
|
ratingHistory.forEach(entry => {
|
||||||
|
const parsedDate = parseDate(entry.date);
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'INSERT INTO rating_history (player_id, date, rating) VALUES (?, ?, ?)',
|
||||||
|
[player.id, parsedDate.toISOString().split('T')[0], entry.rating],
|
||||||
|
(err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
|
||||||
|
completed++;
|
||||||
|
if (completed === total) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoundHistoryFromDB(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!player) return resolve([]);
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM round_history WHERE player_id = ? ORDER BY date DESC',
|
||||||
|
[player.id],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastRoundUpdateDate(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT last_round_update FROM players WHERE pdga_number = ?',
|
||||||
|
[pdgaNumber],
|
||||||
|
(err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row ? row.last_round_update : null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLastRoundUpdateDate(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'UPDATE players SET last_round_update = CURRENT_TIMESTAMP WHERE pdga_number = ?',
|
||||||
|
[pdgaNumber],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRoundHistoryToDB(pdgaNumber, roundData, isIncremental = false) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT id FROM players WHERE pdga_number = ?', [pdgaNumber], (err, player) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
if (!player) return reject(new Error('Player not found'));
|
||||||
|
|
||||||
|
const processRounds = () => {
|
||||||
|
if (roundData.length === 0) {
|
||||||
|
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare('INSERT OR REPLACE INTO round_history (player_id, date, competition_name, rating) VALUES (?, ?, ?, ?)');
|
||||||
|
|
||||||
|
for (const round of roundData) {
|
||||||
|
stmt.run([player.id, round.date.toISOString().split('T')[0], round.competition || 'Unknown', round.rating]);
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt.finalize((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
db.run('UPDATE players SET last_round_update = datetime("now") WHERE pdga_number = ?', [pdgaNumber], (updateErr) => {
|
||||||
|
if (updateErr) reject(updateErr);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isIncremental) {
|
||||||
|
db.run('DELETE FROM round_history WHERE player_id = ?', [player.id], (err) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
processRounds();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
processRounds();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePredictedRatingToDB(pdgaNumber, predictedRating, stdDev = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(
|
||||||
|
'UPDATE players SET predicted_rating = ?, std_dev = ? WHERE pdga_number = ?',
|
||||||
|
[predictedRating, stdDev, pdgaNumber],
|
||||||
|
function(err) {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPlayerFromDB,
|
||||||
|
savePlayerToDB,
|
||||||
|
getRatingHistoryFromDB,
|
||||||
|
saveRatingHistoryToDB,
|
||||||
|
getRoundHistoryFromDB,
|
||||||
|
getLastRoundUpdateDate,
|
||||||
|
updateLastRoundUpdateDate,
|
||||||
|
saveRoundHistoryToDB,
|
||||||
|
savePredictedRatingToDB
|
||||||
|
};
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { db } = require('../db');
|
||||||
|
const { getAllCoursesFromDB, getLayoutsForCourse, updateLayoutRating } = require('../models/course');
|
||||||
|
const { launchBrowser } = require('../scrapers/browser');
|
||||||
|
const { layoutEventCache, scrapeCourseDirectory, scrapeCourseLayouts, scrapeEventResults } = require('../scrapers/course-puppeteer');
|
||||||
|
|
||||||
|
// Request locking to prevent concurrent scrapes of the same resource
|
||||||
|
const activeScrapes = new Map();
|
||||||
|
|
||||||
|
router.get('/api/courses', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const courses = await getAllCoursesFromDB();
|
||||||
|
res.json(courses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courses:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch courses' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/api/layouts/:courseId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const layouts = await getLayoutsForCourse(courseId);
|
||||||
|
res.json(layouts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching layouts:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch layouts' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/scrape-courses', async (req, res) => {
|
||||||
|
req.setTimeout(600000);
|
||||||
|
res.setTimeout(600000);
|
||||||
|
|
||||||
|
let browser = null;
|
||||||
|
try {
|
||||||
|
console.log('Starting course directory scraping...');
|
||||||
|
|
||||||
|
browser = await launchBrowser();
|
||||||
|
|
||||||
|
const courses = await scrapeCourseDirectory(browser);
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
browser = null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
coursesFound: courses.length,
|
||||||
|
message: `Successfully scraped ${courses.length} courses`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping courses:', error.message);
|
||||||
|
if (browser) {
|
||||||
|
try { await browser.close(); } catch (e) {}
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Failed to scrape courses' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/scrape-layouts/:courseId', async (req, res) => {
|
||||||
|
req.setTimeout(600000);
|
||||||
|
res.setTimeout(600000);
|
||||||
|
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const lockKey = `layout-${courseId}`;
|
||||||
|
|
||||||
|
if (activeScrapes.has(lockKey)) {
|
||||||
|
console.log(`⚠️ Scrape already in progress for course ${courseId}`);
|
||||||
|
return res.status(409).json({
|
||||||
|
error: 'Scrape already in progress for this course',
|
||||||
|
message: 'Please wait for the current scrape to complete'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let browser = null;
|
||||||
|
|
||||||
|
const scrapePromise = (async () => {
|
||||||
|
try {
|
||||||
|
const course = await new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT * FROM courses WHERE id = ?', [courseId], (err, row) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(row);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
throw new Error('Course not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Starting layout scraping for course: ${course.name}`);
|
||||||
|
|
||||||
|
browser = await launchBrowser();
|
||||||
|
|
||||||
|
const layouts = await scrapeCourseLayouts(browser, course.link, courseId);
|
||||||
|
|
||||||
|
console.log(`\n=== Starting event results scraping for ${course.name} ===`);
|
||||||
|
|
||||||
|
const courseIdInt = parseInt(courseId);
|
||||||
|
const layoutData = layoutEventCache.get(courseIdInt);
|
||||||
|
|
||||||
|
if (!layoutData || layoutData.length === 0) {
|
||||||
|
console.log('No event data found in cache, skipping event results scraping');
|
||||||
|
await browser.close();
|
||||||
|
browser = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
layoutsFound: layouts.length,
|
||||||
|
message: `Successfully scraped ${layouts.length} layouts for ${course.name} (no events found)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventGroups = {};
|
||||||
|
layoutData.forEach(layout => {
|
||||||
|
if (layout.eventUrl) {
|
||||||
|
if (!eventGroups[layout.eventUrl]) {
|
||||||
|
eventGroups[layout.eventUrl] = [];
|
||||||
|
}
|
||||||
|
eventGroups[layout.eventUrl].push(layout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allLayoutRatings = {};
|
||||||
|
|
||||||
|
let eventCount = 0;
|
||||||
|
for (const eventUrl in eventGroups) {
|
||||||
|
eventCount++;
|
||||||
|
const eventLayouts = eventGroups[eventUrl];
|
||||||
|
|
||||||
|
const results = await scrapeEventResults(browser, eventUrl, eventLayouts);
|
||||||
|
|
||||||
|
for (const layoutKey in results) {
|
||||||
|
const layoutDataResult = results[layoutKey];
|
||||||
|
|
||||||
|
if (!allLayoutRatings[layoutKey]) {
|
||||||
|
allLayoutRatings[layoutKey] = {
|
||||||
|
name: layoutDataResult.name,
|
||||||
|
par: layoutDataResult.par,
|
||||||
|
allRatings: [],
|
||||||
|
latestDate: layoutDataResult.eventDate
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (layoutDataResult.eventDate && (!allLayoutRatings[layoutKey].latestDate ||
|
||||||
|
new Date(layoutDataResult.eventDate) > new Date(allLayoutRatings[layoutKey].latestDate))) {
|
||||||
|
allLayoutRatings[layoutKey].latestDate = layoutDataResult.eventDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allLayoutRatings[layoutKey].allRatings.push(...layoutDataResult.ratings);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== Calculating final ratings for all layouts ===`);
|
||||||
|
|
||||||
|
let savedCount = 0;
|
||||||
|
for (const layoutKey in allLayoutRatings) {
|
||||||
|
const layoutDataResult = allLayoutRatings[layoutKey];
|
||||||
|
|
||||||
|
if (layoutDataResult.allRatings.length > 0) {
|
||||||
|
const meanRating = Math.round(
|
||||||
|
layoutDataResult.allRatings.reduce((sum, r) => sum + r, 0) / layoutDataResult.allRatings.length
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Layout: ${layoutDataResult.name} (Par ${layoutDataResult.par})`);
|
||||||
|
console.log(` Total ratings collected: ${layoutDataResult.allRatings.length}`);
|
||||||
|
console.log(` Mean rating: ${meanRating}`);
|
||||||
|
console.log(` Last played: ${layoutDataResult.latestDate || 'Unknown'}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changes = await updateLayoutRating(
|
||||||
|
courseIdInt,
|
||||||
|
layoutDataResult.name,
|
||||||
|
layoutDataResult.par,
|
||||||
|
meanRating,
|
||||||
|
layoutDataResult.allRatings.length,
|
||||||
|
layoutDataResult.latestDate
|
||||||
|
);
|
||||||
|
if (changes > 0) {
|
||||||
|
console.log(` ✓ Updated in database`);
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Error updating layout ${layoutDataResult.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
browser = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
layoutsFound: layouts.length,
|
||||||
|
eventsProcessed: Object.keys(eventGroups).length,
|
||||||
|
layoutsWithRatings: savedCount,
|
||||||
|
message: `Successfully scraped ${layouts.length} layouts and processed ${Object.keys(eventGroups).length} events for ${course.name}`
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping layouts:', error.message);
|
||||||
|
if (browser) {
|
||||||
|
try { await browser.close(); } catch (e) {}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
activeScrapes.set(lockKey, scrapePromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await scrapePromise;
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to scrape layouts',
|
||||||
|
message: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
activeScrapes.delete(lockKey);
|
||||||
|
console.log(`✓ Released lock for course ${courseId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/scrape-event-results/:courseId', async (req, res) => {
|
||||||
|
req.setTimeout(600000);
|
||||||
|
res.setTimeout(600000);
|
||||||
|
|
||||||
|
let browser = null;
|
||||||
|
try {
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const courseIdInt = parseInt(courseId);
|
||||||
|
|
||||||
|
const layoutData = layoutEventCache.get(courseIdInt);
|
||||||
|
|
||||||
|
if (!layoutData || layoutData.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: 'No layout data found in cache. Please scrape layouts first.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
browser = await launchBrowser();
|
||||||
|
|
||||||
|
const eventGroups = {};
|
||||||
|
layoutData.forEach(layout => {
|
||||||
|
if (layout.eventUrl) {
|
||||||
|
if (!eventGroups[layout.eventUrl]) {
|
||||||
|
eventGroups[layout.eventUrl] = [];
|
||||||
|
}
|
||||||
|
eventGroups[layout.eventUrl].push(layout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const allLayoutRatings = {};
|
||||||
|
|
||||||
|
let eventCount = 0;
|
||||||
|
for (const eventUrl in eventGroups) {
|
||||||
|
eventCount++;
|
||||||
|
const eventLayouts = eventGroups[eventUrl];
|
||||||
|
|
||||||
|
const results = await scrapeEventResults(browser, eventUrl, eventLayouts);
|
||||||
|
|
||||||
|
for (const layoutKey in results) {
|
||||||
|
const ld = results[layoutKey];
|
||||||
|
|
||||||
|
if (!allLayoutRatings[layoutKey]) {
|
||||||
|
allLayoutRatings[layoutKey] = {
|
||||||
|
name: ld.name,
|
||||||
|
par: ld.par,
|
||||||
|
allRatings: [],
|
||||||
|
latestDate: ld.eventDate
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (ld.eventDate && (!allLayoutRatings[layoutKey].latestDate ||
|
||||||
|
new Date(ld.eventDate) > new Date(allLayoutRatings[layoutKey].latestDate))) {
|
||||||
|
allLayoutRatings[layoutKey].latestDate = ld.eventDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allLayoutRatings[layoutKey].allRatings.push(...ld.ratings);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
browser = null;
|
||||||
|
|
||||||
|
console.log(`\n=== Calculating final ratings for all layouts ===`);
|
||||||
|
|
||||||
|
let savedCount = 0;
|
||||||
|
for (const layoutKey in allLayoutRatings) {
|
||||||
|
const ld = allLayoutRatings[layoutKey];
|
||||||
|
|
||||||
|
if (ld.allRatings.length > 0) {
|
||||||
|
const meanRating = Math.round(
|
||||||
|
ld.allRatings.reduce((sum, r) => sum + r, 0) / ld.allRatings.length
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Layout: ${ld.name} (Par ${ld.par})`);
|
||||||
|
console.log(` Total ratings collected: ${ld.allRatings.length}`);
|
||||||
|
console.log(` Mean rating: ${meanRating}`);
|
||||||
|
console.log(` Last played: ${ld.latestDate || 'Unknown'}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changes = await updateLayoutRating(
|
||||||
|
courseIdInt,
|
||||||
|
ld.name,
|
||||||
|
ld.par,
|
||||||
|
meanRating,
|
||||||
|
ld.allRatings.length,
|
||||||
|
ld.latestDate
|
||||||
|
);
|
||||||
|
if (changes > 0) {
|
||||||
|
console.log(` ✓ Updated in database`);
|
||||||
|
savedCount++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Error updating layout ${ld.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
eventsProcessed: Object.keys(eventGroups).length,
|
||||||
|
uniqueLayouts: Object.keys(allLayoutRatings).length,
|
||||||
|
layoutsSaved: savedCount,
|
||||||
|
message: `Processed ${Object.keys(eventGroups).length} events, updated ${savedCount} layouts`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping event results:', error.message);
|
||||||
|
if (browser) {
|
||||||
|
try { await browser.close(); } catch (e) {}
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Failed to scrape event results' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../../index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/courses.html', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../../courses.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
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 { calculatePredictedRating } = require('../services/rating-calculator');
|
||||||
|
|
||||||
|
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.post('/api/populate-database', (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
});
|
||||||
|
|
||||||
|
const progressCallback = (progress) => {
|
||||||
|
res.write(`data: ${JSON.stringify(progress)}\n\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('=== Starting database population from database players ===');
|
||||||
|
|
||||||
|
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);
|
||||||
|
res.write(`data: ${JSON.stringify({ status: 'error', message: error.message })}\n\n`);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/api/database-status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
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({
|
||||||
|
playersInDB: playerCount,
|
||||||
|
needsPopulation: playerCount === 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: 'Failed to check database status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/api/load-all-players', (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`);
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshAllPlayersInDB(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) {
|
||||||
|
console.log(`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;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching rating history for PDGA ${pdgaNumber}...`);
|
||||||
|
const html = await fetchRatingHistory(pdgaNumber);
|
||||||
|
const history = parseRatingHistory(html);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||||
|
console.log(`Saved rating history for PDGA ${pdgaNumber} to database`);
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error(`Failed to save rating history to database:`, dbErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
pdgaNumber: parseInt(pdgaNumber),
|
||||||
|
history
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.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) {
|
||||||
|
console.error('Error clearing database cache:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to clear database cache' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Database cache cleared - all players will be refreshed on next request');
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Cache cleared - database reset'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.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;
|
||||||
|
console.log(`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) {
|
||||||
|
console.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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`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);
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/api/refresh-player/:pdgaNumber', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { pdgaNumber } = req.params;
|
||||||
|
console.log(`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) {
|
||||||
|
console.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;
|
||||||
|
console.log(`=== Manually refreshing rating history for PDGA ${pdgaNumber} ===`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const html = await fetchRatingHistory(pdgaNumber);
|
||||||
|
const fetchTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
console.log(`HTML fetch completed in ${fetchTime}ms, received ${html.length} bytes`);
|
||||||
|
|
||||||
|
const parseStartTime = Date.now();
|
||||||
|
const history = parseRatingHistory(html);
|
||||||
|
const parseTime = Date.now() - parseStartTime;
|
||||||
|
|
||||||
|
console.log(`Parsing completed in ${parseTime}ms, found ${history.length} history entries`);
|
||||||
|
|
||||||
|
if (history.length > 0) {
|
||||||
|
console.log('Sample history entries:', history.slice(0, 3));
|
||||||
|
} else {
|
||||||
|
console.log('No history entries found. HTML sample:', html.substring(0, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbStartTime = Date.now();
|
||||||
|
await saveRatingHistoryToDB(pdgaNumber, history);
|
||||||
|
const dbTime = Date.now() - dbStartTime;
|
||||||
|
|
||||||
|
console.log(`Database save completed in ${dbTime}ms`);
|
||||||
|
|
||||||
|
const formattedHistory = history.map(entry => ({
|
||||||
|
date: entry.date,
|
||||||
|
rating: entry.rating,
|
||||||
|
displayDate: entry.displayDate
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`=== Rating history refresh completed for PDGA ${pdgaNumber} ===`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
history: formattedHistory
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`=== Error refreshing rating history for PDGA ${req.params.pdgaNumber} ===`);
|
||||||
|
console.error('Error type:', error.constructor.name);
|
||||||
|
console.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;
|
||||||
|
|
||||||
|
console.log(`${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) {
|
||||||
|
console.error('Failed to fetch official history:', historyError.message);
|
||||||
|
officialHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let allRounds = [];
|
||||||
|
try {
|
||||||
|
console.log(`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);
|
||||||
|
console.log(`✓ Saved ${allRounds.length} rounds using optimized approach`);
|
||||||
|
|
||||||
|
await updateLastRoundUpdateDate(pdgaNumber);
|
||||||
|
} else {
|
||||||
|
console.log('ℹ No rounds found');
|
||||||
|
}
|
||||||
|
} catch (detailsError) {
|
||||||
|
console.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) {
|
||||||
|
console.error(`=== Error refreshing round history for PDGA ${pdgaNumber} ===`);
|
||||||
|
console.error('Error type:', error.constructor.name);
|
||||||
|
console.error('Error message:', error.message);
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
await browser.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
console.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;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
|
||||||
|
async function launchBrowser() {
|
||||||
|
try {
|
||||||
|
return 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'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { launchBrowser };
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
const { saveCourseToDB, saveLayoutToDB } = require('../models/course');
|
||||||
|
|
||||||
|
// In-memory cache for layout-division-event mapping
|
||||||
|
const layoutEventCache = new Map();
|
||||||
|
|
||||||
|
function getLayoutEventCache() {
|
||||||
|
return layoutEventCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeCourseDirectory(browser) {
|
||||||
|
console.log('=== Scraping Swedish courses from PDGA course directory ===');
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const allCourses = [];
|
||||||
|
let pageNumber = 0;
|
||||||
|
let hasMorePages = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (hasMorePages) {
|
||||||
|
const url = `https://www.pdga.com/course-directory/advanced?title=&field_course_location_country=SE&field_course_location_locality=&field_course_location_administrative_area=All&field_course_location_postal_code=&field_course_type_value=All&rating_value=All&field_course_holes_value=18-100&field_course_total_length_value=All&field_course_target_type_value=All&field_course_tee_type_value=All&field_location_type_value=All&field_course_camping_value=All&field_course_facilities_value=All&field_course_fees_value=All&field_course_handicap_value=All&field_course_private_value=All&field_course_signage_value=All&field_cart_friendly_value=All&page=${pageNumber}`;
|
||||||
|
|
||||||
|
console.log(`Scraping page ${pageNumber}...`);
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const courses = await page.evaluate(() => {
|
||||||
|
const courseData = [];
|
||||||
|
const rows = document.querySelectorAll('table tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const titleCell = row.querySelector('td.views-field-title');
|
||||||
|
const locationCell = row.querySelector('td.views-field-field-course-location');
|
||||||
|
|
||||||
|
if (titleCell) {
|
||||||
|
const link = titleCell.querySelector('a');
|
||||||
|
if (link) {
|
||||||
|
courseData.push({
|
||||||
|
name: link.innerText.trim(),
|
||||||
|
link: 'https://www.pdga.com' + link.getAttribute('href'),
|
||||||
|
city: locationCell ? locationCell.innerText.trim() : 'Unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return courseData;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (courses.length === 0) {
|
||||||
|
console.log(`No courses found on page ${pageNumber}, stopping pagination`);
|
||||||
|
hasMorePages = false;
|
||||||
|
} else {
|
||||||
|
console.log(`Found ${courses.length} courses on page ${pageNumber}`);
|
||||||
|
allCourses.push(...courses);
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
try {
|
||||||
|
await saveCourseToDB(course);
|
||||||
|
console.log(`✓ Saved course: ${course.name} (${course.city})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error saving course ${course.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageNumber++;
|
||||||
|
|
||||||
|
if (hasMorePages) {
|
||||||
|
console.log('Waiting 2s before next page...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Total courses scraped: ${allCourses.length} across ${pageNumber} pages`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping course directory:', error.message);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return allCourses;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeCourseLayouts(browser, courseLink, courseId) {
|
||||||
|
console.log(`\n=== Scraping layouts from: ${courseLink} ===`);
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const layouts = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(courseLink, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const layoutsTabClicked = await page.evaluate(() => {
|
||||||
|
const selectors = [
|
||||||
|
'a.quicktabs-tab-course_node-2',
|
||||||
|
'li.quicktabs-tab-course_node-2 a',
|
||||||
|
'a[href*="layouts"]',
|
||||||
|
'.quicktabs-tabs a',
|
||||||
|
'ul.quicktabs-tabs a',
|
||||||
|
'.quicktabs-wrapper a'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const tabs = document.querySelectorAll(selector);
|
||||||
|
for (const tab of tabs) {
|
||||||
|
const text = tab.innerText?.trim();
|
||||||
|
if (text && (text.includes('Layouts') || text.includes('Layout'))) {
|
||||||
|
tab.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (layoutsTabClicked) {
|
||||||
|
console.log('✓ Layouts tab found and clicked');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Layouts tab not found - may be on a single-layout course page');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedLayouts = await page.evaluate(() => {
|
||||||
|
const layoutData = [];
|
||||||
|
const tournamentsDiv = document.querySelector('div.tournaments');
|
||||||
|
|
||||||
|
if (!tournamentsDiv) {
|
||||||
|
return layoutData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournamentCourses = tournamentsDiv.querySelectorAll('details.tournament-course');
|
||||||
|
|
||||||
|
tournamentCourses.forEach((details) => {
|
||||||
|
const resultsDiv = details.querySelector('div.results');
|
||||||
|
const resultsLink = resultsDiv ? resultsDiv.querySelector('a') : null;
|
||||||
|
const eventUrl = resultsLink ? resultsLink.getAttribute('href') : null;
|
||||||
|
const fullEventUrl = eventUrl ? 'https://www.pdga.com' + eventUrl : null;
|
||||||
|
|
||||||
|
const layoutsDiv = details.querySelector('div.layouts');
|
||||||
|
if (!layoutsDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutDivs = layoutsDiv.querySelectorAll('div.layout');
|
||||||
|
|
||||||
|
layoutDivs.forEach((layoutDiv) => {
|
||||||
|
const h4WithClass = layoutDiv.querySelector('h4.title');
|
||||||
|
const h4Any = layoutDiv.querySelector('h4');
|
||||||
|
|
||||||
|
let layoutName = '';
|
||||||
|
if (h4WithClass) {
|
||||||
|
layoutName = (h4WithClass.textContent || h4WithClass.innerText || '').trim();
|
||||||
|
} else if (h4Any) {
|
||||||
|
layoutName = (h4Any.textContent || h4Any.innerText || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allText = layoutDiv.textContent || layoutDiv.innerText || '';
|
||||||
|
|
||||||
|
const parPatterns = [
|
||||||
|
/Par[:\s]+(\d+)/i,
|
||||||
|
/Par\s*=\s*(\d+)/i,
|
||||||
|
/\(Par\s+(\d+)\)/i,
|
||||||
|
/Total Par:\s*(\d+)/i
|
||||||
|
];
|
||||||
|
|
||||||
|
let par = null;
|
||||||
|
for (const pattern of parPatterns) {
|
||||||
|
const match = allText.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
par = parseInt(match[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisionsLi = layoutDiv.querySelector('li.divisions');
|
||||||
|
let divisions = [];
|
||||||
|
if (divisionsLi) {
|
||||||
|
const divisionsText = (divisionsLi.textContent || '').replace('Divisions:', '').trim();
|
||||||
|
divisions = divisionsText.split(/[,\s]+/).filter(d => d.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutName && par && !isNaN(par) && par > 0) {
|
||||||
|
layoutData.push({
|
||||||
|
name: layoutName,
|
||||||
|
par: par,
|
||||||
|
divisions: divisions,
|
||||||
|
eventUrl: fullEventUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return layoutData;
|
||||||
|
});
|
||||||
|
|
||||||
|
layouts.push(...extractedLayouts);
|
||||||
|
|
||||||
|
const courseIdInt = typeof courseId === 'string' ? parseInt(courseId) : courseId;
|
||||||
|
layoutEventCache.set(courseIdInt, layouts);
|
||||||
|
|
||||||
|
console.log(`✓ Successfully parsed ${layouts.length} layouts from course page`);
|
||||||
|
|
||||||
|
const uniqueLayouts = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const layout of layouts) {
|
||||||
|
const key = `${layout.name}|${layout.par}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueLayouts.push(layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueLayouts.length < layouts.length) {
|
||||||
|
console.log(`ℹ️ Deduplicated to ${uniqueLayouts.length} unique layouts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const layout of uniqueLayouts) {
|
||||||
|
try {
|
||||||
|
await saveLayoutToDB(courseId, layout);
|
||||||
|
console.log(` ✓ Saved layout: ${layout.name} (Par ${layout.par})`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` ✗ Error saving layout ${layout.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping course layouts:', error.message);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return layouts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapeEventResults(browser, eventUrl, layoutsWithDivisions) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const layoutRatings = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(eventUrl, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const eventDateRaw = await page.evaluate(() => {
|
||||||
|
const allText = document.body.textContent;
|
||||||
|
const datePattern = /\d{1,2}-[A-Z][a-z]{2}-\d{4}/;
|
||||||
|
const match = allText.match(datePattern);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
let eventDate = null;
|
||||||
|
if (eventDateRaw) {
|
||||||
|
try {
|
||||||
|
const parsedDate = new Date(eventDateRaw);
|
||||||
|
if (!isNaN(parsedDate.getTime())) {
|
||||||
|
eventDate = parsedDate.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore date parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const layout of layoutsWithDivisions) {
|
||||||
|
const layoutKey = `${layout.name}|${layout.par}`;
|
||||||
|
const ratingsForLayout = [];
|
||||||
|
|
||||||
|
for (const division of layout.divisions) {
|
||||||
|
const divisionData = await page.evaluate((divisionName, targetPar) => {
|
||||||
|
const divisionH3 = document.querySelector(`h3#${divisionName}`);
|
||||||
|
if (!divisionH3) {
|
||||||
|
return { found: false, ratings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailsTag = divisionH3.closest('details');
|
||||||
|
if (!detailsTag) {
|
||||||
|
return { found: false, ratings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = detailsTag.querySelector('table.results');
|
||||||
|
if (!table) {
|
||||||
|
return { found: false, ratings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratings = [];
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const roundCells = row.querySelectorAll('td.round');
|
||||||
|
|
||||||
|
roundCells.forEach(roundCell => {
|
||||||
|
const scoreText = (roundCell.textContent || '').trim();
|
||||||
|
const scoreMatch = scoreText.match(/^(\d+)$/);
|
||||||
|
|
||||||
|
if (scoreMatch) {
|
||||||
|
const scoreValue = parseInt(scoreMatch[1]);
|
||||||
|
|
||||||
|
if (scoreValue === targetPar) {
|
||||||
|
const ratingCell = roundCell.nextElementSibling;
|
||||||
|
|
||||||
|
if (ratingCell && ratingCell.classList.contains('round-rating')) {
|
||||||
|
const ratingText = (ratingCell.textContent || '').trim();
|
||||||
|
const rating = parseInt(ratingText);
|
||||||
|
|
||||||
|
if (!isNaN(rating) && rating > 0) {
|
||||||
|
ratings.push(rating);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { found: true, ratings: ratings };
|
||||||
|
}, division, layout.par);
|
||||||
|
|
||||||
|
if (divisionData.found && divisionData.ratings.length > 0) {
|
||||||
|
ratingsForLayout.push(...divisionData.ratings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ratingsForLayout.length > 0) {
|
||||||
|
const meanRating = ratingsForLayout.reduce((sum, r) => sum + r, 0) / ratingsForLayout.length;
|
||||||
|
layoutRatings[layoutKey] = {
|
||||||
|
name: layout.name,
|
||||||
|
par: layout.par,
|
||||||
|
ratings: ratingsForLayout,
|
||||||
|
count: ratingsForLayout.length,
|
||||||
|
meanRating: Math.round(meanRating),
|
||||||
|
eventDate: eventDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scraping event results:', error.message);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return layoutRatings;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
layoutEventCache,
|
||||||
|
getLayoutEventCache,
|
||||||
|
scrapeCourseDirectory,
|
||||||
|
scrapeCourseLayouts,
|
||||||
|
scrapeEventResults
|
||||||
|
};
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
async function fetchPlayerDataHTTP(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: 'www.pdga.com',
|
||||||
|
port: 443,
|
||||||
|
path: `/player/${pdgaNumber}`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
resolve(data);
|
||||||
|
} else {
|
||||||
|
const rateLimitInfo = {
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
headers: res.headers
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`PDGA Response Status for #${pdgaNumber}: ${res.statusCode}`);
|
||||||
|
console.log('Response Headers:', JSON.stringify(res.headers, null, 2));
|
||||||
|
|
||||||
|
if (res.headers['retry-after']) {
|
||||||
|
console.log(`Retry-After header: ${res.headers['retry-after']}`);
|
||||||
|
}
|
||||||
|
if (res.headers['x-ratelimit-limit']) {
|
||||||
|
console.log(`Rate Limit: ${res.headers['x-ratelimit-limit']}`);
|
||||||
|
}
|
||||||
|
if (res.headers['x-ratelimit-remaining']) {
|
||||||
|
console.log(`Rate Limit Remaining: ${res.headers['x-ratelimit-remaining']}`);
|
||||||
|
}
|
||||||
|
if (res.headers['x-ratelimit-reset']) {
|
||||||
|
console.log(`Rate Limit Reset: ${res.headers['x-ratelimit-reset']}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(`HTTP ${res.statusCode}`);
|
||||||
|
error.rateLimitInfo = rateLimitInfo;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.log(`Request error for PDGA #${pdgaNumber}:`, error.code, error.message);
|
||||||
|
if (error.code === 'ECONNRESET') {
|
||||||
|
console.log('Connection reset - likely rate limited by PDGA');
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(30000);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePlayerData(html, pdgaNumber) {
|
||||||
|
try {
|
||||||
|
const nameMatch = html.match(/<title>([^<]+?)\s*\|\s*Professional Disc Golf Association/i);
|
||||||
|
const name = nameMatch ? nameMatch[1].trim() : 'Unknown';
|
||||||
|
|
||||||
|
const ratingMatch = html.match(/Current Rating:[^>]*>\s*(\d+)/i);
|
||||||
|
const rating = ratingMatch ? parseInt(ratingMatch[1]) : 0;
|
||||||
|
|
||||||
|
const changeMatch = html.match(/Current Rating:[\s\S]*?([+\-]\d+)[\s\S]*?\(as of/i);
|
||||||
|
const ratingChange = changeMatch ? parseInt(changeMatch[1]) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pdgaNumber,
|
||||||
|
name: name.replace(/\s*#\d+$/, ''),
|
||||||
|
rating,
|
||||||
|
ratingChange,
|
||||||
|
predictedRating: null
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error parsing data for PDGA ${pdgaNumber}:`, error.message);
|
||||||
|
return {
|
||||||
|
pdgaNumber,
|
||||||
|
name: 'Error',
|
||||||
|
rating: 0,
|
||||||
|
ratingChange: null,
|
||||||
|
predictedRating: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRatingHistory(pdgaNumber) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: 'www.pdga.com',
|
||||||
|
port: 443,
|
||||||
|
path: `/player/${pdgaNumber}/history`,
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
},
|
||||||
|
timeout: 30000
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Fetching rating history for PDGA #${pdgaNumber} from: https://www.pdga.com/player/${pdgaNumber}/history`);
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
console.log(`Rating history request successful for PDGA #${pdgaNumber}`);
|
||||||
|
resolve(data);
|
||||||
|
} else {
|
||||||
|
console.log(`Rating History Error for PDGA #${pdgaNumber}:`);
|
||||||
|
console.log(`Status: ${res.statusCode}`);
|
||||||
|
console.log('Response Headers:', JSON.stringify(res.headers, null, 2));
|
||||||
|
|
||||||
|
if (res.headers['retry-after']) {
|
||||||
|
console.log(`Retry-After: ${res.headers['retry-after']} seconds`);
|
||||||
|
}
|
||||||
|
if (res.headers['x-ratelimit-limit']) {
|
||||||
|
console.log(`Rate Limit: ${res.headers['x-ratelimit-limit']}`);
|
||||||
|
}
|
||||||
|
if (res.headers['x-ratelimit-remaining']) {
|
||||||
|
console.log(`Rate Limit Remaining: ${res.headers['x-ratelimit-remaining']}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
console.log(`Partial response received (${data.length} bytes):`, data.substring(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(`HTTP ${res.statusCode} for rating history`);
|
||||||
|
error.statusCode = res.statusCode;
|
||||||
|
error.headers = res.headers;
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
console.log(`Rating history request error for PDGA #${pdgaNumber}:`, {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
errno: error.errno,
|
||||||
|
syscall: error.syscall
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error.code === 'ECONNRESET') {
|
||||||
|
console.log('Connection reset on rating history - likely rate limited by PDGA');
|
||||||
|
}
|
||||||
|
if (error.code === 'ECONNREFUSED') {
|
||||||
|
console.log('Connection refused - PDGA server may be blocking requests');
|
||||||
|
}
|
||||||
|
if (error.code === 'ETIMEDOUT') {
|
||||||
|
console.log('Request timed out - server may be overloaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
console.log(`Rating history request timeout for PDGA #${pdgaNumber} after 30s`);
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timeout'));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.setTimeout(30000);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRatingHistory(html) {
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
const rowMatches = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi);
|
||||||
|
|
||||||
|
if (rowMatches) {
|
||||||
|
for (const row of rowMatches) {
|
||||||
|
if (row.includes('<th') || !row.includes('<td')) continue;
|
||||||
|
|
||||||
|
const cellMatches = row.match(/<td[^>]*>(.*?)<\/td>/gi);
|
||||||
|
|
||||||
|
if (cellMatches && cellMatches.length >= 2) {
|
||||||
|
const dateText = cellMatches[0].replace(/<[^>]*>/g, '').trim();
|
||||||
|
const ratingText = cellMatches[1].replace(/<[^>]*>/g, '').trim();
|
||||||
|
|
||||||
|
const dateMatch = dateText.match(/(\d{1,2})-([A-Za-z]{3})-(\d{4})/);
|
||||||
|
if (dateMatch && !isNaN(parseInt(ratingText))) {
|
||||||
|
const [, day, month, year] = dateMatch;
|
||||||
|
const monthMap = {
|
||||||
|
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||||
|
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||||||
|
};
|
||||||
|
|
||||||
|
const date = new Date(parseInt(year), monthMap[month], parseInt(day));
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
date: date.toISOString().split('T')[0],
|
||||||
|
rating: parseInt(ratingText),
|
||||||
|
displayDate: dateText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return history.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fetchPlayerDataHTTP, parsePlayerData, fetchRatingHistory, parseRatingHistory };
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
const { parseDate } = require('../services/rating-calculator');
|
||||||
|
|
||||||
|
async function getOfficialRatingHistory(browser, pdgaNumber) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
let ratingHistory = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://www.pdga.com/player/${pdgaNumber}/history`;
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
ratingHistory = await page.evaluate(() => {
|
||||||
|
const history = [];
|
||||||
|
|
||||||
|
const selectors = [
|
||||||
|
'table tbody tr',
|
||||||
|
'table tr',
|
||||||
|
'.view-content tbody tr'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of selectors) {
|
||||||
|
const rows = document.querySelectorAll(selector);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
if (cells.length >= 3) {
|
||||||
|
const dateText = cells[0]?.innerText?.trim();
|
||||||
|
const ratingText = cells[1]?.innerText?.trim();
|
||||||
|
|
||||||
|
if (dateText && ratingText && /^\d{4}-\d{2}-\d{2}$|^\d{1,2}-\w{3}-\d{4}$|^\w{3} \d{1,2}, \d{4}$/.test(dateText)) {
|
||||||
|
const rating = parseInt(ratingText);
|
||||||
|
if (!isNaN(rating) && rating > 800 && rating < 1200) {
|
||||||
|
history.push({
|
||||||
|
date: dateText,
|
||||||
|
rating: rating,
|
||||||
|
tournament: cells[2]?.innerText?.trim() || 'Unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (history.length > 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return history;
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching official rating history:', error.message);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratingHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPlayerTournamentDetails(browser, pdgaNumber) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
let tournamentRounds = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://www.pdga.com/player/${pdgaNumber}/details`;
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2', timeout: 45000 });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
tournamentRounds = await page.evaluate(() => {
|
||||||
|
const rounds = [];
|
||||||
|
const rows = document.querySelectorAll('table tbody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
|
||||||
|
if (cells.length >= 4) {
|
||||||
|
const cellTexts = Array.from(cells).map(cell => cell.innerText.trim());
|
||||||
|
|
||||||
|
let tournamentName = '';
|
||||||
|
let dateText = '';
|
||||||
|
let rating = 0;
|
||||||
|
let division = '';
|
||||||
|
|
||||||
|
cellTexts.forEach((text, index) => {
|
||||||
|
if (/\d{1,2}(-\w{3})?(\s+to\s+)\d{1,2}-\w{3}-\d{4}/.test(text) || /\d{1,2}-\w{3}-\d{4}/.test(text)) {
|
||||||
|
dateText = text;
|
||||||
|
}
|
||||||
|
if (/^\d{3,4}$/.test(text) && parseInt(text) >= 800 && parseInt(text) <= 1200) {
|
||||||
|
rating = parseInt(text);
|
||||||
|
}
|
||||||
|
if (/^M[A-Z]\d*$|^F[A-Z]\d*$/.test(text)) {
|
||||||
|
division = text;
|
||||||
|
}
|
||||||
|
if (index === 0) {
|
||||||
|
tournamentName = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tournamentName && dateText && rating > 0) {
|
||||||
|
rounds.push({
|
||||||
|
tournament: tournamentName,
|
||||||
|
dateText: dateText,
|
||||||
|
rating: rating,
|
||||||
|
division: division,
|
||||||
|
competition: `${tournamentName} (${division})`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rounds;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixedRounds = tournamentRounds.map(round => {
|
||||||
|
let validDate = new Date();
|
||||||
|
if (round.dateText) {
|
||||||
|
try {
|
||||||
|
const pdgaParsed = parseDate(round.dateText);
|
||||||
|
if (pdgaParsed instanceof Date && !isNaN(pdgaParsed.getTime())) {
|
||||||
|
validDate = pdgaParsed;
|
||||||
|
} else {
|
||||||
|
const nativeParsed = new Date(round.dateText);
|
||||||
|
if (!isNaN(nativeParsed.getTime())) {
|
||||||
|
validDate = nativeParsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Date parsing failed for "${round.dateText}": ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tournament: round.tournament,
|
||||||
|
date: validDate,
|
||||||
|
rating: round.rating,
|
||||||
|
division: round.division,
|
||||||
|
competition: round.competition
|
||||||
|
};
|
||||||
|
});
|
||||||
|
tournamentRounds = fixedRounds;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tournament details:', error.message);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return tournamentRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getNewTournamentRounds(browser, pdgaNumber, afterDate) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
let newRounds = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://www.pdga.com/player/${pdgaNumber}`;
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle2' });
|
||||||
|
|
||||||
|
console.log(`Looking for tournaments after ${afterDate.toDateString()}...`);
|
||||||
|
|
||||||
|
const newTournamentUrls = await page.evaluate((afterTimestamp) => {
|
||||||
|
const afterDate = new Date(afterTimestamp);
|
||||||
|
const tables = document.querySelectorAll('table[id*="player-results"]');
|
||||||
|
const urls = [];
|
||||||
|
|
||||||
|
tables.forEach(table => {
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const dateCell = row.querySelector('.dates');
|
||||||
|
const tournamentCell = row.querySelector('.tournament a');
|
||||||
|
|
||||||
|
if (dateCell && tournamentCell) {
|
||||||
|
const dateText = dateCell.innerText.trim();
|
||||||
|
const dateMatch = dateText.match(/\d{1,2}-[A-Za-z]{3}-\d{4}/);
|
||||||
|
|
||||||
|
if (dateMatch) {
|
||||||
|
const dateStr = dateMatch[0];
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
|
||||||
|
if (date > afterDate) {
|
||||||
|
const href = tournamentCell.getAttribute('href');
|
||||||
|
if (href) {
|
||||||
|
urls.push({
|
||||||
|
url: `https://www.pdga.com${href}`,
|
||||||
|
date: dateStr,
|
||||||
|
name: tournamentCell.innerText.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}, afterDate.getTime());
|
||||||
|
|
||||||
|
console.log(`Found ${newTournamentUrls.length} new tournaments after ${afterDate.toDateString()}`);
|
||||||
|
|
||||||
|
for (const tournamentData of newTournamentUrls) {
|
||||||
|
try {
|
||||||
|
console.log(`Scraping new tournament: ${tournamentData.name} (${tournamentData.date})`);
|
||||||
|
|
||||||
|
await page.goto(tournamentData.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const roundRatings = await page.evaluate((pdgaNum) => {
|
||||||
|
const rows = document.querySelectorAll('tr');
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td');
|
||||||
|
const hasPlayerNumber = Array.from(cells).some(cell =>
|
||||||
|
cell.innerText && cell.innerText.includes(pdgaNum.toString())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPlayerNumber) {
|
||||||
|
const roundRatingCells = row.querySelectorAll('td.round-rating');
|
||||||
|
const ratings = [];
|
||||||
|
|
||||||
|
roundRatingCells.forEach(cell => {
|
||||||
|
const rating = parseInt(cell.innerText.trim());
|
||||||
|
if (!isNaN(rating) && rating > 0) {
|
||||||
|
ratings.push(rating);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ratings;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, pdgaNumber);
|
||||||
|
|
||||||
|
if (roundRatings.length > 0) {
|
||||||
|
const parsedDate = parseDate(tournamentData.date);
|
||||||
|
roundRatings.forEach(rating => {
|
||||||
|
newRounds.push({
|
||||||
|
rating,
|
||||||
|
date: parsedDate,
|
||||||
|
competition: tournamentData.name
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Found ${roundRatings.length} round ratings for ${tournamentData.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error scraping tournament ${tournamentData.name}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting new tournament rounds for PDGA ${pdgaNumber}:`, error);
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOptimizedPlayerRounds(browser, pdgaNumber) {
|
||||||
|
console.log(`=== Optimized Round Collection for PDGA ${pdgaNumber} ===`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Step 1: Getting official rating rounds from /details page...');
|
||||||
|
const officialRounds = await getPlayerTournamentDetails(browser, pdgaNumber);
|
||||||
|
|
||||||
|
if (officialRounds.length === 0) {
|
||||||
|
console.log('No official rounds found in details page');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Found ${officialRounds.length} official rating rounds`);
|
||||||
|
|
||||||
|
const sortedRounds = officialRounds.sort((a, b) => b.date - a.date);
|
||||||
|
const latestOfficialDate = sortedRounds[0].date;
|
||||||
|
console.log(`Latest official round: ${latestOfficialDate.toDateString()}`);
|
||||||
|
|
||||||
|
console.log('Step 2: Looking for NEW tournaments since latest official round...');
|
||||||
|
const newRounds = await getNewTournamentRounds(browser, pdgaNumber, latestOfficialDate);
|
||||||
|
|
||||||
|
if (newRounds.length > 0) {
|
||||||
|
console.log(`✓ Found ${newRounds.length} new round ratings`);
|
||||||
|
} else {
|
||||||
|
console.log('ℹ No new tournaments found since latest official round');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allRounds = [
|
||||||
|
...officialRounds.map(round => ({
|
||||||
|
rating: round.rating,
|
||||||
|
date: round.date,
|
||||||
|
competition: round.competition,
|
||||||
|
source: 'official'
|
||||||
|
})),
|
||||||
|
...newRounds.map(round => ({
|
||||||
|
rating: round.rating,
|
||||||
|
date: round.date,
|
||||||
|
competition: round.competition,
|
||||||
|
source: 'new'
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
|
||||||
|
allRounds.sort((a, b) => a.date - b.date);
|
||||||
|
|
||||||
|
console.log(`=== Summary: ${officialRounds.length} official + ${newRounds.length} new = ${allRounds.length} total rounds ===`);
|
||||||
|
|
||||||
|
return allRounds;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in optimized round collection:', error.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOfficialRatingHistory,
|
||||||
|
getPlayerTournamentDetails,
|
||||||
|
getNewTournamentRounds,
|
||||||
|
getOptimizedPlayerRounds
|
||||||
|
};
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
const { db } = require('../db');
|
||||||
|
const { getPlayerFromDB, getRoundHistoryFromDB, savePredictedRatingToDB, savePlayerToDB } = require('../models/player');
|
||||||
|
const { fetchPlayerDataHTTP, parsePlayerData } = require('../scrapers/player-http');
|
||||||
|
const { calculatePredictedRating } = require('./rating-calculator');
|
||||||
|
|
||||||
|
async function getPlayerDataFromDB(pdgaNumber) {
|
||||||
|
try {
|
||||||
|
const cachedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||||
|
if (cachedPlayer) {
|
||||||
|
console.log(`Loading PDGA ${pdgaNumber} from DB (source of truth)`);
|
||||||
|
|
||||||
|
let predictedRating = cachedPlayer.predicted_rating;
|
||||||
|
let stdDev = cachedPlayer.std_dev;
|
||||||
|
if (!predictedRating || predictedRating === 0) {
|
||||||
|
predictedRating = await getPredictedRatingFromDB(pdgaNumber);
|
||||||
|
const updatedPlayer = await getPlayerFromDB(pdgaNumber);
|
||||||
|
stdDev = updatedPlayer?.std_dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pdgaNumber: cachedPlayer.pdga_number,
|
||||||
|
name: cachedPlayer.name,
|
||||||
|
rating: cachedPlayer.current_rating,
|
||||||
|
ratingChange: cachedPlayer.rating_change,
|
||||||
|
predictedRating: predictedRating > 0 ? predictedRating : null,
|
||||||
|
stdDev: stdDev > 0 ? stdDev : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Database error for PDGA ${pdgaNumber}:`, err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrapePDGARating(pdgaNumber, retries = 3) {
|
||||||
|
console.log(`=== Refreshing PDGA ${pdgaNumber} from PDGA website ===`);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
console.log(`Attempt ${attempt}/${retries} for PDGA ${pdgaNumber} (using HTTP)`);
|
||||||
|
|
||||||
|
const html = await fetchPlayerDataHTTP(pdgaNumber);
|
||||||
|
const result = parsePlayerData(html, pdgaNumber);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await savePlayerToDB(result);
|
||||||
|
console.log(`Saved PDGA ${pdgaNumber} to database`);
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error(`Failed to save PDGA ${pdgaNumber} to database:`, dbErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully scraped PDGA ${pdgaNumber} on attempt ${attempt}`);
|
||||||
|
return result;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Attempt ${attempt}/${retries} failed for PDGA ${pdgaNumber}:`, error.message);
|
||||||
|
|
||||||
|
if (attempt === retries) {
|
||||||
|
return {
|
||||||
|
pdgaNumber,
|
||||||
|
name: 'Error',
|
||||||
|
rating: 0,
|
||||||
|
ratingChange: null,
|
||||||
|
predictedRating: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let retryDelay = 2000 * attempt;
|
||||||
|
|
||||||
|
if (error.rateLimitInfo) {
|
||||||
|
const retryAfter = error.rateLimitInfo.headers['retry-after'];
|
||||||
|
if (retryAfter) {
|
||||||
|
retryDelay = Math.max(retryDelay, (parseInt(retryAfter) + 1) * 1000);
|
||||||
|
console.log(`Using Retry-After header: waiting ${retryDelay/1000}s`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'ECONNRESET') {
|
||||||
|
retryDelay = Math.max(retryDelay, 10000);
|
||||||
|
console.log(`Connection reset detected: waiting ${retryDelay/1000}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPredictedRatingFromDB(pdgaNumber) {
|
||||||
|
try {
|
||||||
|
const roundHistory = await getRoundHistoryFromDB(pdgaNumber);
|
||||||
|
if (roundHistory.length > 0) {
|
||||||
|
console.log(`Using ${roundHistory.length} cached rounds for PDGA ${pdgaNumber} prediction`);
|
||||||
|
|
||||||
|
const roundRatings = roundHistory.map(round => ({
|
||||||
|
rating: round.rating,
|
||||||
|
date: new Date(round.date),
|
||||||
|
competition: round.competition_name || 'Unknown'
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = calculatePredictedRating(roundRatings);
|
||||||
|
|
||||||
|
await savePredictedRatingToDB(pdgaNumber, result.rating, result.stdDev);
|
||||||
|
|
||||||
|
return result.rating;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error getting predicted rating from DB for ${pdgaNumber}:`, err.message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllRatingsFromDB(progressCallback = null) {
|
||||||
|
try {
|
||||||
|
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 = allPlayers.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < allPlayers.length; i++) {
|
||||||
|
const player = allPlayers[i];
|
||||||
|
const pdgaNumber = player.pdga_number;
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
pdgaNumber,
|
||||||
|
status: 'loading'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const playerData = await getPlayerDataFromDB(pdgaNumber);
|
||||||
|
|
||||||
|
if (playerData) {
|
||||||
|
ratings.push(playerData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
pdgaNumber,
|
||||||
|
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: player.name || 'Database Error',
|
||||||
|
rating: player.current_rating,
|
||||||
|
ratingChange: player.rating_change,
|
||||||
|
predictedRating: null
|
||||||
|
};
|
||||||
|
ratings.push(errorData);
|
||||||
|
|
||||||
|
if (progressCallback) {
|
||||||
|
progressCallback({
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
pdgaNumber,
|
||||||
|
status: 'error',
|
||||||
|
name: player.name || 'Database Error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratings.sort((a, b) => (b.rating || 0) - (a.rating || 0));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading players from database:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAllPlayersInDB(progressCallback = null) {
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPlayerDataFromDB,
|
||||||
|
scrapePDGARating,
|
||||||
|
getPredictedRatingFromDB,
|
||||||
|
getAllRatingsFromDB,
|
||||||
|
refreshAllPlayersInDB
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
function parseDate(dateStr) {
|
||||||
|
const multiDayMatch = dateStr.match(/^(\d{1,2})(-([A-Za-z]{3}))?(\s+to\s+)(\d{1,2})-([A-Za-z]{3})-(\d{4})$/);
|
||||||
|
if (multiDayMatch) {
|
||||||
|
const day = parseInt(multiDayMatch[1]);
|
||||||
|
const month = multiDayMatch[3] || multiDayMatch[6];
|
||||||
|
const year = parseInt(multiDayMatch[7]);
|
||||||
|
|
||||||
|
const monthMap = {
|
||||||
|
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||||
|
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Date(year, monthMap[month], day);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formats = [
|
||||||
|
/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/,
|
||||||
|
/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
const match = dateStr.match(format);
|
||||||
|
if (match) {
|
||||||
|
if (format === formats[0]) {
|
||||||
|
const monthMap = {
|
||||||
|
'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
|
||||||
|
'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
|
||||||
|
};
|
||||||
|
const day = parseInt(match[1]);
|
||||||
|
const month = monthMap[match[2]];
|
||||||
|
const year = parseInt(match[3]);
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(dateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextPDGAUpdateDate() {
|
||||||
|
const today = new Date();
|
||||||
|
const currentMonth = today.getMonth();
|
||||||
|
const currentYear = today.getFullYear();
|
||||||
|
|
||||||
|
const firstDayOfMonth = new Date(currentYear, currentMonth, 1);
|
||||||
|
const firstTuesday = new Date(firstDayOfMonth);
|
||||||
|
|
||||||
|
const daysUntilTuesday = (2 - firstDayOfMonth.getDay() + 7) % 7;
|
||||||
|
firstTuesday.setDate(1 + daysUntilTuesday);
|
||||||
|
|
||||||
|
const secondTuesday = new Date(firstTuesday);
|
||||||
|
secondTuesday.setDate(firstTuesday.getDate() + 7);
|
||||||
|
|
||||||
|
if (today <= secondTuesday) {
|
||||||
|
return secondTuesday;
|
||||||
|
} else {
|
||||||
|
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
|
||||||
|
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
|
||||||
|
|
||||||
|
const firstDayNextMonth = new Date(nextYear, nextMonth, 1);
|
||||||
|
const firstTuesdayNext = new Date(firstDayNextMonth);
|
||||||
|
|
||||||
|
const daysUntilTuesdayNext = (2 - firstDayNextMonth.getDay() + 7) % 7;
|
||||||
|
firstTuesdayNext.setDate(1 + daysUntilTuesdayNext);
|
||||||
|
|
||||||
|
const secondTuesdayNext = new Date(firstTuesdayNext);
|
||||||
|
secondTuesdayNext.setDate(firstTuesdayNext.getDate() + 7);
|
||||||
|
|
||||||
|
return secondTuesdayNext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateStandardDeviation(ratings) {
|
||||||
|
if (!ratings || ratings.length === 0) return 0;
|
||||||
|
|
||||||
|
const mean = ratings.reduce((sum, r) => sum + r, 0) / ratings.length;
|
||||||
|
const variance = ratings.reduce((sum, r) => sum + Math.pow(r - mean, 2), 0) / ratings.length;
|
||||||
|
|
||||||
|
return Math.sqrt(variance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePredictedRating(roundRatings) {
|
||||||
|
const debugLog = [];
|
||||||
|
debugLog.push('=== PDGA RATING CALCULATION (Following Official Rules) ===');
|
||||||
|
|
||||||
|
if (!roundRatings || roundRatings.length === 0) {
|
||||||
|
debugLog.push('❌ No rounds provided for prediction');
|
||||||
|
return { rating: 0, debugLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog.push(`📊 Starting with ${roundRatings.length} total rounds`);
|
||||||
|
|
||||||
|
const nextUpdateDate = getNextPDGAUpdateDate();
|
||||||
|
debugLog.push(`🎯 PDGA Update Simulation: Next update date is ${nextUpdateDate.toDateString()}`);
|
||||||
|
debugLog.push(` Only including rounds played before ${nextUpdateDate.toDateString()}`);
|
||||||
|
|
||||||
|
const allSortedRounds = roundRatings
|
||||||
|
.filter(r => r.rating > 0 && r.date < nextUpdateDate)
|
||||||
|
.sort((a, b) => b.date - a.date);
|
||||||
|
|
||||||
|
if (allSortedRounds.length === 0) {
|
||||||
|
debugLog.push('❌ No valid rounds after filtering for update date');
|
||||||
|
return { rating: 0, debugLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog.push(`📊 After update date filter: ${allSortedRounds.length} rounds`);
|
||||||
|
|
||||||
|
const twelveMonthsBeforeUpdate = new Date(nextUpdateDate);
|
||||||
|
twelveMonthsBeforeUpdate.setFullYear(twelveMonthsBeforeUpdate.getFullYear() - 1);
|
||||||
|
|
||||||
|
const mostRecentDate = allSortedRounds[0].date;
|
||||||
|
debugLog.push(`📅 Most recent round: ${mostRecentDate.toDateString()}`);
|
||||||
|
debugLog.push(`📅 12-month cutoff: ${twelveMonthsBeforeUpdate.toDateString()} (1 year before update)`);
|
||||||
|
|
||||||
|
let eligibleRounds = allSortedRounds.filter(r => r.date >= twelveMonthsBeforeUpdate);
|
||||||
|
|
||||||
|
debugLog.push('🗓️ 12-MONTH FILTERING:');
|
||||||
|
debugLog.push(`✅ Rounds in last 12 months: ${eligibleRounds.length}`);
|
||||||
|
|
||||||
|
if (eligibleRounds.length < 8) {
|
||||||
|
const twentyFourMonthsBeforeUpdate = new Date(nextUpdateDate);
|
||||||
|
twentyFourMonthsBeforeUpdate.setFullYear(twentyFourMonthsBeforeUpdate.getFullYear() - 2);
|
||||||
|
|
||||||
|
eligibleRounds = allSortedRounds.filter(r => r.date >= twentyFourMonthsBeforeUpdate);
|
||||||
|
debugLog.push(`⚠️ Extended to 24 months before update (${twentyFourMonthsBeforeUpdate.toDateString()}) - now ${eligibleRounds.length} rounds`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eligibleRounds.length === 0) {
|
||||||
|
debugLog.push('❌ No eligible rounds found');
|
||||||
|
return { rating: 0, debugLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog.push(`📈 ELIGIBLE ROUNDS: ${eligibleRounds.length}`);
|
||||||
|
eligibleRounds.forEach((round, index) => {
|
||||||
|
debugLog.push(` ${index + 1}. ${round.date.toDateString()}: ${round.rating} (${round.competition})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let workingRounds = [...eligibleRounds];
|
||||||
|
let workingRatings = workingRounds.map(r => r.rating);
|
||||||
|
|
||||||
|
if (workingRatings.length >= 7) {
|
||||||
|
debugLog.push('🔍 OUTLIER EXCLUSION (≥7 rounds available):');
|
||||||
|
|
||||||
|
const mean = workingRatings.reduce((sum, r) => sum + r, 0) / workingRatings.length;
|
||||||
|
const stdDev = calculateStandardDeviation(workingRatings);
|
||||||
|
|
||||||
|
debugLog.push(` Mean: ${mean.toFixed(1)}`);
|
||||||
|
debugLog.push(` Std Dev: ${stdDev.toFixed(1)}`);
|
||||||
|
|
||||||
|
const stdDevCutoff = mean - 2.5 * stdDev;
|
||||||
|
const hundredPointCutoff = mean - 100;
|
||||||
|
|
||||||
|
debugLog.push(` 2.5σ cutoff: ${stdDevCutoff.toFixed(1)}`);
|
||||||
|
debugLog.push(` 100-point cutoff: ${hundredPointCutoff.toFixed(1)}`);
|
||||||
|
|
||||||
|
const filteredRatings = workingRatings.filter(rating =>
|
||||||
|
rating >= stdDevCutoff && rating >= hundredPointCutoff
|
||||||
|
);
|
||||||
|
|
||||||
|
const stdDevOutliers = workingRatings.filter(rating => rating < stdDevCutoff);
|
||||||
|
const hundredPointOutliers = workingRatings.filter(rating => rating < hundredPointCutoff && rating >= stdDevCutoff);
|
||||||
|
|
||||||
|
if (stdDevOutliers.length > 0) {
|
||||||
|
debugLog.push(` ❌ 2.5σ outliers removed: ${stdDevOutliers.length} rounds`);
|
||||||
|
stdDevOutliers.forEach(rating => {
|
||||||
|
const round = workingRounds.find(r => r.rating === rating);
|
||||||
|
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hundredPointOutliers.length > 0) {
|
||||||
|
debugLog.push(` ❌ 100-point outliers removed: ${hundredPointOutliers.length} rounds`);
|
||||||
|
hundredPointOutliers.forEach(rating => {
|
||||||
|
const round = workingRounds.find(r => r.rating === rating);
|
||||||
|
debugLog.push(` - ${rating} (${round.date.toDateString()}: ${round.competition})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdDevOutliers.length === 0 && hundredPointOutliers.length === 0) {
|
||||||
|
debugLog.push(` ✅ No outliers detected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredRatings.length >= 4) {
|
||||||
|
workingRounds = workingRounds.filter(round =>
|
||||||
|
round.rating >= stdDevCutoff && round.rating >= hundredPointCutoff
|
||||||
|
);
|
||||||
|
workingRatings = filteredRatings;
|
||||||
|
debugLog.push(` ✅ Using ${filteredRatings.length} rounds after outlier removal`);
|
||||||
|
} else {
|
||||||
|
debugLog.push(` ⚠️ Too few rounds after outlier removal (${filteredRatings.length}), keeping all rounds`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugLog.push(`⏭️ OUTLIER EXCLUSION SKIPPED (only ${workingRatings.length} rounds, need ≥7)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog.push('⚖️ WEIGHTING (Most recent 25% count double if ≥9 rounds):');
|
||||||
|
const weightedRatings = [];
|
||||||
|
|
||||||
|
if (workingRatings.length >= 9) {
|
||||||
|
const recentCount = Math.round(workingRatings.length * 0.25);
|
||||||
|
debugLog.push(` ✅ Double-weighting most recent ${recentCount} rounds`);
|
||||||
|
|
||||||
|
weightedRatings.push(...workingRatings);
|
||||||
|
|
||||||
|
for (let i = 0; i < recentCount; i++) {
|
||||||
|
weightedRatings.push(workingRatings[i]);
|
||||||
|
const round = workingRounds[i];
|
||||||
|
debugLog.push(` 2x weight: ${workingRatings[i]} (${round.date.toDateString()}: ${round.competition})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugLog.push(` 📊 Total values: ${workingRatings.length} + ${recentCount} double-weighted = ${weightedRatings.length}`);
|
||||||
|
} else {
|
||||||
|
debugLog.push(` ➡️ No double weighting (${workingRatings.length} rounds, need ≥9)`);
|
||||||
|
weightedRatings.push(...workingRatings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sum = weightedRatings.reduce((sum, r) => sum + r, 0);
|
||||||
|
const average = sum / weightedRatings.length;
|
||||||
|
const finalRating = Math.round(average);
|
||||||
|
|
||||||
|
const stdDev = calculateStandardDeviation(weightedRatings);
|
||||||
|
|
||||||
|
debugLog.push('🎯 FINAL CALCULATION:');
|
||||||
|
debugLog.push(` Sum: ${sum}`);
|
||||||
|
debugLog.push(` Count: ${weightedRatings.length}`);
|
||||||
|
debugLog.push(` Average: ${average.toFixed(1)}`);
|
||||||
|
debugLog.push(` Standard Deviation: ${stdDev.toFixed(1)}`);
|
||||||
|
debugLog.push(` Final Rating: ${finalRating}`);
|
||||||
|
debugLog.push('=== END PDGA CALCULATION ===');
|
||||||
|
|
||||||
|
return { rating: finalRating, stdDev: Math.round(stdDev), debugLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseDate, getNextPDGAUpdateDate, calculatePredictedRating, calculateStandardDeviation };
|
||||||
Reference in New Issue
Block a user