mirror of
https://github.com/nprasad2077/NBA_Go.git
synced 2026-09-22 14:05:13 +00:00
auto-fix missing games
This commit is contained in:
@@ -160,18 +160,18 @@ Services will be available at:
|
|||||||
|
|
||||||
### Importing Data
|
### Importing Data
|
||||||
|
|
||||||
The application has a dual-mode entry point. To run data imports (migrations + scraping):
|
The application provides a dual-mode entry point. To run data migrations and imports:
|
||||||
|
|
||||||
#### 1. Local CLI Execution (Recommended for targeted imports)
|
#### 1. Local CLI Execution (Recommended for automated & targeted imports)
|
||||||
```bash
|
```bash
|
||||||
# Export environment variables from .env
|
# Export environment variables from .env (e.g. remote or local database)
|
||||||
export $(grep -v '^#' .env | xargs)
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
|
||||||
# Run the import pipeline
|
# Run the import pipeline
|
||||||
go run . import-data
|
go run . import-data
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 2. Local Docker Stack
|
#### 2. Local Docker Development Stack
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker-compose.local.yml run --rm db-init
|
docker-compose -f docker-compose.local.yml run --rm db-init
|
||||||
```
|
```
|
||||||
@@ -185,13 +185,52 @@ docker compose --profile init run --rm db-init
|
|||||||
|
|
||||||
### Ingestion Pipeline & Scraping Architecture
|
### Ingestion Pipeline & Scraping Architecture
|
||||||
|
|
||||||
The data import engine (`import.go` & `services/`) features a robust, resilient ingestion workflow designed to safely handle thousands of games:
|
The data import engine (`import.go` & `services/`) features a resilient, multi-stage ingestion workflow designed to safely scrape and persist NBA statistics without rate limits or data loss:
|
||||||
|
|
||||||
- **20-Day Temporal Session Chunks**: Large date ranges (such as full seasons) are automatically partitioned into 20-day sliding windows (~80–120 games per chunk).
|
#### 1. Auto-Detect Missing Box Scores Engine (`importMissingBoxScores`)
|
||||||
- **Immediate Incremental Persistence**: Scraped data (`line_scores`, `player_game_basic_stats`, `player_game_adv_stats`, `team_game_basic_stats`, `team_game_adv_stats`) is immediately committed and upserted into PostgreSQL at the end of each chunk rather than held in memory until the end of the run.
|
- **Dynamic Database Discovery**: Automatically queries PostgreSQL for any games in the `games` table that lack corresponding records in `line_scores` (`WHERE game_id NOT IN (SELECT DISTINCT game_id FROM line_scores WHERE deleted_at IS NULL)`).
|
||||||
- **Inter-Chunk Cool-Off Period**: Enforces a 20-second pause ($\pm 25\%$ jitter) between chunks to avoid rate limiting and IP blocks from upstream sources.
|
- **Zero Hardcoding**: Eliminates the need to manually configure date ranges or game IDs when fixing missing data across multiple historical seasons (e.g., 2008, 2013, 2017).
|
||||||
- **Smart Skip & Idempotent Resumption**: Automatically checks `WHERE game_id NOT IN (SELECT DISTINCT game_id FROM line_scores WHERE deleted_at IS NULL)` so completed games/chunks are instantly skipped.
|
- **20-Game Batches**: Groups detected missing games into safe 20-game chunks with 2 concurrent workers and staggered worker starts.
|
||||||
- **Graceful Interrupt Handling (`Ctrl+C`)**: Captures `SIGINT`/`SIGTERM` via `context.Context`. If interrupted, in-flight scraped games in the active chunk are flushed to the database before cleanly exiting without data loss.
|
- **Immediate Incremental Persistence**: Scraped data (`line_scores`, `player_game_basic_stats`, `player_game_adv_stats`, `team_game_basic_stats`, `team_game_adv_stats`) is immediately committed and upserted into PostgreSQL after every batch.
|
||||||
|
- **Inter-Batch Cool-Off**: Enforces a 20-second pause ($\pm 25\%$ jitter) between batches to maintain compliant request rates against upstream sources.
|
||||||
|
- **Defensive URL Construction**: Automatically constructs `/boxscores/{gameID}.html` if a game record has an empty `box_score_url`.
|
||||||
|
|
||||||
|
#### 2. Date-Range Chunked Ingestion (`importBoxScores`)
|
||||||
|
- **20-Day Temporal Session Chunks**: Large date spans (such as an entire 9-month season) are partitioned into 20-day sliding windows (~80–120 games per chunk).
|
||||||
|
- **Off-Season Smart Skipping**: Summer months (July–October) with 0 games are automatically identified and skipped in milliseconds without triggering scraping pauses.
|
||||||
|
|
||||||
|
#### 3. Fault Tolerance & Safety Guarantees
|
||||||
|
- **Graceful Interrupt Handling (`Ctrl+C`)**: Captures `SIGINT` and `SIGTERM` via `context.Context`. If interrupted, all data from completed batches is safely preserved in PostgreSQL.
|
||||||
|
- **Idempotent Resumption**: Re-running `go run . import-data` automatically discovers only the remaining pending games, skipping all previously completed games.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Database Verification Queries
|
||||||
|
|
||||||
|
Run the following SQL queries in PostgreSQL to verify data completeness and monitor ingestion progress:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Check count of remaining missing games (returns 0 when fully complete)
|
||||||
|
SELECT count(*) AS remaining_missing_games
|
||||||
|
FROM games g
|
||||||
|
LEFT JOIN line_scores ls ON g.game_id = ls.game_id AND ls.deleted_at IS NULL
|
||||||
|
WHERE ls.game_id IS NULL AND g.deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- 2. Inspect recently imported line scores
|
||||||
|
SELECT ls.game_id, g.date, ls.team, ls.q1, ls.q2, ls.q3, ls.q4, ls.ot1, ls.total
|
||||||
|
FROM line_scores ls
|
||||||
|
JOIN games g ON ls.game_id = g.game_id
|
||||||
|
ORDER BY ls.updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
-- 3. Verify total games vs total line scores
|
||||||
|
SELECT
|
||||||
|
(SELECT count(*) FROM games WHERE deleted_at IS NULL) AS total_games,
|
||||||
|
(SELECT count(DISTINCT game_id) FROM line_scores WHERE deleted_at IS NULL) AS games_with_boxscores,
|
||||||
|
(SELECT count(*) FROM line_scores WHERE deleted_at IS NULL) AS total_line_scores;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Stopping
|
### Stopping
|
||||||
|
|
||||||
@@ -273,3 +312,20 @@ go run loadtest.go -n 100 -c 10 -url "http://localhost:8080/api/playeradvancedst
|
|||||||
| Containerization | Docker + Docker Compose |
|
| Containerization | Docker + Docker Compose |
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
|
### 1. Ingesting a New Season from Scratch
|
||||||
|
1. Set the target season in [`import.go`](file:///Volumes/ROG_BLACK/code/update/NBA_Go/import.go) for `importPlayerTotalsScrape`, `importPlayerAdvanced`, `importGameSchedules`, and `importMarkPlayoffGames`.
|
||||||
|
2. Enable schedule and season totals imports in [`main.go`](file:///Volumes/ROG_BLACK/code/update/NBA_Go/main.go).
|
||||||
|
3. Run `go run . import-data` to ingest schedules and season totals.
|
||||||
|
4. Run `importMissingBoxScores` to automatically ingest all game box scores and line scores in 20-game chunks.
|
||||||
|
|
||||||
|
### 2. Auto-Detecting & Filling Data Gaps
|
||||||
|
1. Ensure `importMissingBoxScores(db)` is active in [`main.go`](file:///Volumes/ROG_BLACK/code/update/NBA_Go/main.go).
|
||||||
|
2. Run `go run . import-data`.
|
||||||
|
3. The engine automatically finds any missing games in PostgreSQL across all seasons, splits them into 20-game batches, and ingests them with immediate DB commits.
|
||||||
|
|
||||||
|
### 3. Local Development & API Testing
|
||||||
|
1. Start the local stack with `make up` or `docker-compose -f docker-compose.local.yml up -d`.
|
||||||
|
2. Open Swagger documentation at `http://localhost:8081/swagger/index.html`.
|
||||||
|
3. View Grafana metrics dashboards at `http://localhost:3001` (login: `admin` / `testing`).
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
// importPlayerAdvanced fetches and stores advanced stats for seasons
|
// importPlayerAdvanced fetches and stores advanced stats for seasons
|
||||||
func importPlayerAdvanced(db *gorm.DB) {
|
func importPlayerAdvanced(db *gorm.DB) {
|
||||||
for season := 2001; season <= 2001; season++ {
|
for season := 2008; season <= 2009; season++ {
|
||||||
if err := services.FetchAndStorePlayerAdvancedScrapedStats(db, season, false); err != nil {
|
if err := services.FetchAndStorePlayerAdvancedScrapedStats(db, season, false); err != nil {
|
||||||
log.Printf("advanced import failed for %d: %v", season, err)
|
log.Printf("advanced import failed for %d: %v", season, err)
|
||||||
}
|
}
|
||||||
@@ -28,7 +28,7 @@ func importPlayerAdvanced(db *gorm.DB) {
|
|||||||
|
|
||||||
// importPlayerAdvancedPlayoffs fetches and stores advanced stats for playoffs seasons
|
// importPlayerAdvancedPlayoffs fetches and stores advanced stats for playoffs seasons
|
||||||
func importPlayerAdvancedPlayoffs(db *gorm.DB) {
|
func importPlayerAdvancedPlayoffs(db *gorm.DB) {
|
||||||
for season := 2001; season <= 2001; season++ {
|
for season := 2008; season <= 2009; season++ {
|
||||||
if err := services.FetchAndStorePlayerAdvancedScrapedStats(db, season, true); err != nil {
|
if err := services.FetchAndStorePlayerAdvancedScrapedStats(db, season, true); err != nil {
|
||||||
log.Printf("advanced import failed for %d: %v", season, err)
|
log.Printf("advanced import failed for %d: %v", season, err)
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func importPlayerAdvancedPlayoffs(db *gorm.DB) {
|
|||||||
|
|
||||||
// importPlayerTotalsScrape fetches & stores scraped regular-season total stats
|
// importPlayerTotalsScrape fetches & stores scraped regular-season total stats
|
||||||
func importPlayerTotalsScrape(db *gorm.DB) {
|
func importPlayerTotalsScrape(db *gorm.DB) {
|
||||||
for season := 2001; season <= 2001; season++ {
|
for season := 2008; season <= 2009; season++ {
|
||||||
if err := services.FetchAndStorePlayerTotalScrapedStats(db, season, false); err != nil {
|
if err := services.FetchAndStorePlayerTotalScrapedStats(db, season, false); err != nil {
|
||||||
log.Printf("scraped totals import failed for %d: %v", season, err)
|
log.Printf("scraped totals import failed for %d: %v", season, err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ func importPlayerTotalsScrape(db *gorm.DB) {
|
|||||||
|
|
||||||
// importPlayerPlayoffsScrape fetches & stores scraped playoff total stats
|
// importPlayerPlayoffsScrape fetches & stores scraped playoff total stats
|
||||||
func importPlayerTotalsPlayoffsScrape(db *gorm.DB) {
|
func importPlayerTotalsPlayoffsScrape(db *gorm.DB) {
|
||||||
for season := 2001; season <= 2001; season++ {
|
for season := 2008; season <= 2009; season++ {
|
||||||
if err := services.FetchAndStorePlayerTotalScrapedStats(db, season, true); err != nil {
|
if err := services.FetchAndStorePlayerTotalScrapedStats(db, season, true); err != nil {
|
||||||
log.Printf("scraped playoffs import failed for %d: %v", season, err)
|
log.Printf("scraped playoffs import failed for %d: %v", season, err)
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func importPlayerTotalsPlayoffsScrape(db *gorm.DB) {
|
|||||||
func importGameSchedules(db *gorm.DB) {
|
func importGameSchedules(db *gorm.DB) {
|
||||||
months := []string{"october", "november", "december", "january", "february", "march", "april", "may", "june"}
|
months := []string{"october", "november", "december", "january", "february", "march", "april", "may", "june"}
|
||||||
|
|
||||||
for season := 2001; season <= 2001; season++ {
|
for season := 2008; season <= 2009; season++ {
|
||||||
log.Printf("--- Starting Game Schedule Import for Season: %d ---", season)
|
log.Printf("--- Starting Game Schedule Import for Season: %d ---", season)
|
||||||
for _, month := range months {
|
for _, month := range months {
|
||||||
if err := services.FetchAndStoreGameSchedule(db, season, month); err != nil {
|
if err := services.FetchAndStoreGameSchedule(db, season, month); err != nil {
|
||||||
@@ -86,8 +86,8 @@ func importBoxScores(db *gorm.DB) {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
from := time.Date(2000, time.October, 31, 0, 0, 0, 0, time.UTC)
|
from := time.Date(2008, time.February, 28, 0, 0, 0, 0, time.UTC)
|
||||||
to := time.Date(2001, time.June, 30, 23, 59, 59, 0, time.UTC)
|
to := time.Date(2008, time.December, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
|
||||||
chunkDays := 20 // 20-day session chunks
|
chunkDays := 20 // 20-day session chunks
|
||||||
coolOff := 20 * time.Second // 20s cool-off with jitter between chunks
|
coolOff := 20 * time.Second // 20s cool-off with jitter between chunks
|
||||||
@@ -104,6 +104,26 @@ func importBoxScores(db *gorm.DB) {
|
|||||||
log.Println("--- Finished Box Score Data Import ---")
|
log.Println("--- Finished Box Score Data Import ---")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importMissingBoxScores auto-detects games in the database lacking line scores and scrapes all missing box scores
|
||||||
|
// in safe 20-game chunks with worker rate limiting, cool-off pauses, and immediate database upserts.
|
||||||
|
func importMissingBoxScores(db *gorm.DB) {
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
batchSize := 20 // 20 games per batch chunk
|
||||||
|
coolOff := 20 * time.Second // 20s cool-off with jitter between batches
|
||||||
|
|
||||||
|
if err := services.FetchAndStoreMissingBoxScores(ctx, db, batchSize, coolOff); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
log.Println("👋 Missing box score import interrupted by user. Safe to resume anytime!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Fatalf("Missing box score import failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("--- Finished Missing Box Score Import ---")
|
||||||
|
}
|
||||||
|
|
||||||
// importPlayerShotCharts fetches shot charts for a PREDEFINED list of players for a given range of seasons.
|
// importPlayerShotCharts fetches shot charts for a PREDEFINED list of players for a given range of seasons.
|
||||||
func importPlayerShotCharts(db *gorm.DB) {
|
func importPlayerShotCharts(db *gorm.DB) {
|
||||||
log.Println("--- Starting Player Shot Chart Import from Predefined List ---")
|
log.Println("--- Starting Player Shot Chart Import from Predefined List ---")
|
||||||
@@ -184,7 +204,7 @@ func importPlayerShotCharts(db *gorm.DB) {
|
|||||||
|
|
||||||
// importMarkPlayoffGames marks games as playoff using the dedicated Basketball Reference playoff schedule.
|
// importMarkPlayoffGames marks games as playoff using the dedicated Basketball Reference playoff schedule.
|
||||||
func importMarkPlayoffGames(db *gorm.DB) {
|
func importMarkPlayoffGames(db *gorm.DB) {
|
||||||
for season := 2001; season <= 2001; season++ {
|
for season := 2008; season <= 2008; season++ {
|
||||||
if err := services.FetchAndMarkPlayoffGames(db, season); err != nil {
|
if err := services.FetchAndMarkPlayoffGames(db, season); err != nil {
|
||||||
log.Printf("playoff marking failed for %d: %v", season, err)
|
log.Printf("playoff marking failed for %d: %v", season, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ func main() {
|
|||||||
// importGameSchedules(db)
|
// importGameSchedules(db)
|
||||||
// log.Println("🎉 Game Imports completed successfully 🏀")
|
// log.Println("🎉 Game Imports completed successfully 🏀")
|
||||||
|
|
||||||
importBoxScores(db)
|
importMissingBoxScores(db)
|
||||||
log.Println("🎉 Related Box Score Imports completed successfully 📦")
|
log.Println("🎉 Missing Box Score Imports completed successfully 📦")
|
||||||
|
|
||||||
importMarkPlayoffGames(db)
|
importMarkPlayoffGames(db)
|
||||||
log.Println("🎉 Playoff games marked successfully 🏆")
|
log.Println("🎉 Playoff games marked successfully 🏆")
|
||||||
|
|||||||
@@ -146,6 +146,95 @@ func FetchAndStoreBoxScoreDataChunked(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchAndStoreMissingBoxScores auto-detects all games in the database lacking line_scores,
|
||||||
|
// groups them into batches, and scrapes their box scores with rate limiting and immediate upserts.
|
||||||
|
func FetchAndStoreMissingBoxScores(
|
||||||
|
ctx context.Context,
|
||||||
|
db *gorm.DB,
|
||||||
|
batchSize int,
|
||||||
|
coolOffBase time.Duration,
|
||||||
|
) error {
|
||||||
|
if batchSize <= 0 {
|
||||||
|
batchSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
var missingGames []models.Game
|
||||||
|
err := db.Where("game_id NOT IN (SELECT DISTINCT game_id FROM line_scores WHERE deleted_at IS NULL)").
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Order("date ASC").
|
||||||
|
Find(&missingGames).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query missing games: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMissing := len(missingGames)
|
||||||
|
if totalMissing == 0 {
|
||||||
|
log.Println("🎉 All games in the database already have box score and line score data! Nothing to scrape.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBatches := (totalMissing + batchSize - 1) / batchSize
|
||||||
|
log.Printf("🔍 Auto-Detect: Found %d missing games across all seasons. Processing in %d batches (%d games/batch).",
|
||||||
|
totalMissing, totalBatches, batchSize)
|
||||||
|
|
||||||
|
totalSaved := 0
|
||||||
|
|
||||||
|
for i := 0; i < totalBatches; i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("🛑 Interrupt received. Halting missing box score scraping.")
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
startIdx := i * batchSize
|
||||||
|
endIdx := startIdx + batchSize
|
||||||
|
if endIdx > totalMissing {
|
||||||
|
endIdx = totalMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
batchGames := missingGames[startIdx:endIdx]
|
||||||
|
batchNumber := i + 1
|
||||||
|
|
||||||
|
log.Printf("\n📦 ========================================================")
|
||||||
|
log.Printf("📦 [Batch %d/%d] Processing %d games (%s to %s)",
|
||||||
|
batchNumber, totalBatches, len(batchGames),
|
||||||
|
batchGames[0].Date.Format("2006-01-02"),
|
||||||
|
batchGames[len(batchGames)-1].Date.Format("2006-01-02"))
|
||||||
|
log.Printf("📦 ========================================================")
|
||||||
|
|
||||||
|
// Scrape batch with worker pool
|
||||||
|
results := processGamesWithWorkers(ctx, batchGames)
|
||||||
|
|
||||||
|
// Immediate database upsert
|
||||||
|
if len(results) > 0 {
|
||||||
|
log.Printf("💾 Saving and upserting data for %d games from Batch %d into PostgreSQL...", len(results), batchNumber)
|
||||||
|
if err := persistScrapedResults(db, results); err != nil {
|
||||||
|
log.Printf("❌ Failed to upsert results for batch %d: %v", batchNumber, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalSaved += len(results)
|
||||||
|
log.Printf("✅ [Batch %d/%d] Successfully saved %d games. (Total progress: %d/%d)",
|
||||||
|
batchNumber, totalBatches, len(results), totalSaved, totalMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
log.Printf("🛑 Process interrupted! All data scraped up to Batch %d was safely committed to DB.", batchNumber)
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cool-off pause between batches
|
||||||
|
if i < totalBatches-1 {
|
||||||
|
log.Printf("😴 Cool-off period: Pausing before Batch %d/%d...", batchNumber+1, totalBatches)
|
||||||
|
utils.SleepWithJitter(coolOffBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("\n🎉 All Missing Box Scores Finished! Successfully processed %d total games.", totalSaved)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// processGamesWithWorkers runs the worker pool for a slice of games.
|
// processGamesWithWorkers runs the worker pool for a slice of games.
|
||||||
func processGamesWithWorkers(ctx context.Context, games []models.Game) []ScrapedResult {
|
func processGamesWithWorkers(ctx context.Context, games []models.Game) []ScrapedResult {
|
||||||
jobs := make(chan models.Game, len(games))
|
jobs := make(chan models.Game, len(games))
|
||||||
@@ -243,8 +332,11 @@ func scrapeAndParseWorker(ctx context.Context, id int, jobs <-chan models.Game,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("🐝 Worker %d: Processing game %s", id, game.GameID)
|
boxScoreURL := game.BoxScoreURL
|
||||||
fullURL := boxScoreURLBase + game.BoxScoreURL
|
if boxScoreURL == "" {
|
||||||
|
boxScoreURL = fmt.Sprintf("/boxscores/%s.html", game.GameID)
|
||||||
|
}
|
||||||
|
fullURL := boxScoreURLBase + boxScoreURL
|
||||||
|
|
||||||
utils.SleepWithJitter(baseDelay)
|
utils.SleepWithJitter(baseDelay)
|
||||||
time.Sleep(2500 * time.Millisecond)
|
time.Sleep(2500 * time.Millisecond)
|
||||||
|
|||||||
Reference in New Issue
Block a user