┌─ GO ───────────────────────────────────────────────────────────────────────┐
│ package pipeline │
│ │
│ import ( │
│ "context" │
│ "fmt" │
│ "ookstats/internal/blizzard" │
│ "ookstats/internal/database" │
│ "strings" │
│ "time" │
│ ) │
│ │
│ // FetchCMOptions contains options for fetching challenge mode leaderboards │
│ type FetchCMOptions struct { │
│ Verbose bool │
│ Regions []string │
│ Realms []string │
│ Dungeons []string │
│ Periods []string │
│ LatestPeriodsOnly bool │
│ Concurrency int │
│ Timeout time.Duration │
│ } │
│ │
│ // FetchCMResult contains statistics from the fetch operation │
│ type FetchCMResult struct { │
│ TotalRuns int │
│ TotalPlayers int │
│ Duration time.Duration │
│ } │
│ │
│ // FetchChallengeMode fetches challenge mode leaderboard data for specified realms │
│ /dungeons/periods │
│ func FetchChallengeMode(db *database.DatabaseService, client *blizzard.Client, opt │
│ s FetchCMOptions) (*FetchCMResult, error) { │
│ // Get dungeons and realms from hardcoded lists │
│ _, dungeons := blizzard.GetHardcodedPeriodAndDungeons() │
│ allRealms := blizzard.GetAllRealms() │
│ │
│ fmt.Printf("Dungeons: %d, Realms: %d\n", len(dungeons), len(allRealms)) │
│ │
│ // Apply region filter │
│ if len(opts.Regions) > 0 { │
│ allowed := make(map[string]bool) │
│ for _, r := range opts.Regions { │
│ allowed[strings.TrimSpace(r)] = true │
│ } │
│ for slug, info := range allRealms { │
│ if !allowed[info.Region] { │
│ delete(allRealms, slug) │
│ } │
│ } │
│ } │
│ │
│ // Apply realm filter │
│ if len(opts.Realms) > 0 { │
│ allowed := make(map[string]bool) │
│ for _, s := range opts.Realms { │
│ s = strings.TrimSpace(s) │
│ if s != "" { │
│ allowed[s] = true │
│ } │
│ } │
│ filtered := make(map[string]blizzard.RealmInfo) │
│ for key, info := range allRealms { │
│ if allowed[key] || allowed[info.Slug] { │
│ filtered[key] = info │
│ } │
│ } │
│ allRealms = filtered │
│ } │
│ │
│ // Apply dungeon filter │
│ if len(opts.Dungeons) > 0 { │
│ allowed := make(map[string]bool) │
│ for _, s := range opts.Dungeons { │
│ allowed[strings.TrimSpace(s)] = true │
│ } │
│ filtered := make([]blizzard.DungeonInfo, 0, len(dungeons)) │
│ for _, d := range dungeons { │
│ idStr := fmt.Sprintf("%d", d.ID) │
│ if allowed[idStr] || allowed[d.Slug] { │
│ filtered = append(filtered, d) │
│ } │
│ } │
│ if len(filtered) > 0 { │
│ dungeons = filtered │
│ } │
│ } │
│ │
│ // Pre-populate reference data (includes ALL realms, even children) │
│ fmt.Printf("Pre-populating reference data...\n") │
│ fmt.Printf(" - Ensuring dungeons (%d)\n", len(dungeons)) │
│ if err := db.EnsureDungeonsOnce(dungeons); err != nil { │
│ return nil, fmt.Errorf("failed to ensure dungeons: %w", err) │
│ } │
│ fmt.Printf(" [OK] Dungeons ensured\n") │
│ fmt.Printf(" - Ensuring realms (%d, including child realms)\n", len(allRealms)) │
│ if err := db.EnsureRealmsBatch(allRealms); err != nil { │
│ return nil, fmt.Errorf("failed to ensure realms: %w", err) │
│ } │
│ fmt.Printf(" [OK] Realms ensured\n") │
│ │
│ // Filter out child realms for fetching (they don't have their own leaderboards) │
│ // Players from child realms appear on parent realm leaderboards │
│ fetchRealms := make(map[string]blizzard.RealmInfo) │
│ childRealmsFiltered := 0 │
│ for slug, info := range allRealms { │
│ if info.ParentRealmSlug != "" { │
│ childRealmsFiltered++ │
│ } else { │
│ fetchRealms[slug] = info │
│ } │
│ } │
│ if childRealmsFiltered > 0 { │
│ fmt.Printf("Filtered out %d child realms from fetch (no leaderboards to fetch)\n │
│ ", childRealmsFiltered) │
│ } │
│ fmt.Printf("Reference data populated for %d realms (%d to fetch from) and %d dung │
│ eons\n", │
│ len(allRealms), len(fetchRealms), len(dungeons)) │
│ │
│ // Group realms by region (only fetch realms, excluding children) │
│ realmsByRegion := make(map[string]map[string]blizzard.RealmInfo) │
│ for slug, info := range fetchRealms { │
│ if realmsByRegion[info.Region] == nil { │
│ realmsByRegion[info.Region] = make(map[string]blizzard.RealmInfo) │
│ } │
│ realmsByRegion[info.Region][slug] = info │
│ } │
│ │
│ ctx, cancel := context.WithTimeout(context.Background(), opts.Timeout) │
│ defer cancel() │
│ │
│ totalRuns := 0 │
│ totalPlayers := 0 │
│ sweepStart := time.Now() │
│ │
│ // Process each region independently │
│ for region, regionRealms := range realmsByRegion { │
│ fmt.Printf("\n========== Region: %s (%d realms) ==========\n", strings.ToUpper(r │
│ egion), len(regionRealms)) │
│ │
│ // Determine periods for this region │
│ var periods []string │
│ │
│ if len(opts.Periods) > 0 { │
│ // User-specified periods │
│ periods = opts.Periods │
│ fmt.Printf("Using user-specified periods: %v (%d periods)\n", periods, len(peri │
│ ods)) │
│ } else { │
│ // Fetch periods from database (populated by season sync) │
│ var periodInts []int │
│ var err error │
│ │
│ if opts.LatestPeriodsOnly { │
│ fmt.Printf("Fetching latest 2 periods from current season for %s...\n", string │
│ s.ToUpper(region)) │
│ periodInts, err = db.GetLatestPeriodsPerRegion(region) │
│ } else { │
│ fmt.Printf("Fetching period list from database for %s...\n", strings.ToUpper(r │
│ egion)) │
│ periodInts, err = db.GetPeriodsForRegion(region) │
│ } │
│ │
│ if err != nil { │
│ fmt.Printf("Failed to fetch periods from database for %s: %v - skipping region │
│ \n", strings.ToUpper(region), err) │
│ continue │
│ } │
│ │
│ // Convert []int to []string for compatibility │
│ periods = make([]string, len(periodInts)) │
│ for i, p := range periodInts { │
│ periods[i] = fmt.Sprintf("%d", p) │
│ } │
│ │
│ if len(periods) == 0 { │
│ fmt.Printf("No periods found in database for %s (run season sync first) - skip │
│ ping region\n", strings.ToUpper(region)) │
│ continue │
│ } │
│ │
│ if opts.LatestPeriodsOnly { │
│ fmt.Printf("[OK] Using latest %d period(s) from current season for %s: %v\n", │
│ len(periods), strings.ToUpper(region), periods) │
│ } else { │
│ fmt.Printf("[OK] Fetched %d periods from database for %s (newest: %s, oldest: │
│ %s)\n", │
│ len(periods), strings.ToUpper(region), periods[0], periods[len(periods)-1]) │
│ } │
│ } │
│ │
│ if len(periods) == 0 { │
│ fmt.Printf("No periods to process for %s - skipping region\n", strings.ToUpper( │
│ region)) │
│ continue │
│ } │
│ │
│ // Period sweep for this region │
│ fmt.Printf("Starting period sweep for %s: %d periods\n", strings.ToUpper(region) │
│ , len(periods)) │
│ for _, period := range periods { │
│ fmt.Printf("\n--- %s Period %s ---\n", strings.ToUpper(region), period) │
│ res := client.FetchAllRealmsConcurrent(ctx, regionRealms, dungeons, period) │
│ runs, players, berr := db.BatchProcessFetchResults(ctx, res) │
│ if berr != nil { │
│ fmt.Printf("Batch errors in %s period %s: %v\n", strings.ToUpper(region), peri │
│ od, berr) │
│ } │
│ fmt.Printf("%s Period %s -> inserted runs: %d, new players: %d\n", strings.ToUp │
│ per(region), period, runs, players) │
│ totalRuns += runs │
│ totalPlayers += players │
│ } │
│ } │
│ │
│ duration := time.Since(sweepStart) │
│ fmt.Printf("\n========== Sweep complete in %v ==========\n", duration) │
│ │
│ // Update fetch metadata │
│ if err := db.UpdateFetchMetadata("challenge_mode_leaderboard", totalRuns, totalPl │
│ ayers); err != nil { │
│ return nil, fmt.Errorf("failed to update fetch metadata: %w", err) │
│ } │
│ │
│ return &FetchCMResult{ │
│ TotalRuns: totalRuns, │
│ TotalPlayers: totalPlayers, │
│ Duration: duration, │
│ }, nil │
│ } │
│ │
│ // FetchProfilesOptions contains options for fetching player profiles │
│ type FetchProfilesOptions struct { │
│ Verbose bool │
│ BatchSize int │
│ MaxPlayers int │
│ StaleAfter time.Duration │
│ } │
│ │
│ // FetchProfilesResult contains statistics from the profile fetch operation │
│ type FetchProfilesResult struct { │
│ TotalProfiles int │
│ TotalEquipment int │
│ ProcessedCount int │
│ Duration time.Duration │
│ } │
│ │
│ // FetchPlayerProfiles fetches detailed player profile data including equipment │
│ func FetchPlayerProfiles(db *database.DatabaseService, client *blizzard.Client, op │
│ ts FetchProfilesOptions) (*FetchProfilesResult, error) { │
│ // Compute staleness cutoff │
│ staleCutoff := int64(0) │
│ if opts.StaleAfter > 0 { │
│ staleCutoff = time.Now().Add(-opts.StaleAfter).UnixMilli() │
│ } │
│ │
│ // Get eligible players (9/9 completion) │
│ fmt.Println("Finding eligible players with complete coverage (9/9 dungeons)...") │
│ players, err := db.GetEligiblePlayersForProfileFetch(staleCutoff) │
│ if err != nil { │
│ return nil, fmt.Errorf("failed to get eligible players: %w", err) │
│ } │
│ │
│ if len(players) == 0 { │
│ fmt.Println("No eligible players found. Run 'ookstats process players' first to │
│ generate player profiles.") │
│ return &FetchProfilesResult{}, nil │
│ } │
│ │
│ fmt.Printf("Found %d eligible players with 9/9 completion\n", len(players)) │
│ │
│ // Apply max players limit │
│ if opts.MaxPlayers > 0 && len(players) > opts.MaxPlayers { │
│ players = players[:opts.MaxPlayers] │
│ fmt.Printf("Limited to first %d players due to max-players limit\n", opts.MaxPla │
│ yers) │
│ } │
│ │
│ // Default batch size │
│ batchSize := opts.BatchSize │
│ if batchSize <= 0 { │
│ batchSize = 20 │
│ } │
│ │
│ fmt.Printf("Processing %d players in batches of %d with 20 concurrent requests\n" │
│ , len(players), batchSize) │
│ fmt.Printf("\nStarting player profile fetching...\n") │
│ │
│ startTime := time.Now() │
│ totalProfiles := 0 │
│ totalEquipment := 0 │
│ processedCount := 0 │
│ │
│ // Process in batches to avoid overwhelming the API │
│ for i := 0; i < len(players); i += batchSize { │
│ end := i + batchSize │
│ if end > len(players) { │
│ end = len(players) │
│ } │
│ batch := players[i:end] │
│ │
│ batchNumber := (i / batchSize) + 1 │
│ totalBatches := (len(players) + batchSize - 1) / batchSize │
│ │
│ fmt.Printf("\n--- Batch %d/%d (%d players) ---\n", batchNumber, totalBatches, le │
│ n(batch)) │
│ │
│ // Fetch profiles concurrently for this batch with fallback attempts │
│ sem := make(chan struct{}, 20) │
│ type out struct { │
│ profiles int │
│ equipment int │
│ err error │
│ } │
│ outCh := make(chan out, len(batch)) │
│ timestamp := time.Now().UnixMilli() │
│ │
│ for _, p := range batch { │
│ sem <- struct{}{} │
│ go func(p blizzard.PlayerInfo) { │
│ defer func() { <-sem }() │
│ │
│ // Build candidate list │
│ region := p.Region │
│ realm := blizzard.NormalizeRealmSlug(region, p.RealmSlug) │
│ name := p.Name │
│ tried := map[string]bool{} │
│ candidates := [][2]string{{realm, name}} │
│ │
│ // Connected-realm sweep │
│ if slugs, err := db.GetConnectedRealmSlugs(region, realm); err == nil { │
│ for _, s := range slugs { │
│ s = blizzard.NormalizeRealmSlug(region, s) │
│ if s != realm { │
│ candidates = append(candidates, [2]string{s, name}) │
│ } │
│ } │
│ } │
│ │
│ // Last-run realm heuristic │
│ if lrRegion, lrSlug, _, err := db.GetLastRunRealmForPlayer(p.ID); err == nil & │
│ & lrRegion != "" && lrSlug != "" { │
│ if lrRegion == region { │
│ lrSlug = blizzard.NormalizeRealmSlug(region, lrSlug) │
│ candidates = append(candidates, [2]string{lrSlug, name}) │
│ } │
│ } │
│ │
│ // Attempt candidates │
│ var profs, items int │
│ var finalErr error │
│ for _, c := range candidates { │
│ key := c[0] + "|" + c[1] │
│ if tried[key] { │
│ continue │
│ } │
│ tried[key] = true │
│ │
│ // Fetch summary first; if it 404s, skip to next candidate │
│ sum, err := client.FetchCharacterSummary(c[1], c[0], region) │
│ if err != nil { │
│ // try next candidate │
│ finalErr = err │
│ continue │
│ } │
│ eq, err2 := client.FetchCharacterEquipment(c[1], c[0], region) │
│ if err2 != nil { │
│ finalErr = err2 │
│ } │
│ med, err3 := client.FetchCharacterMedia(c[1], c[0], region) │
│ if err3 != nil { │
│ finalErr = err3 │
│ } │
│ │
│ // Insert profile data │
│ res := blizzard.PlayerProfileResult{ │
│ PlayerID: p.ID, │
│ PlayerName: c[1], │
│ RealmSlug: c[0], │
│ Region: region, │
│ Summary: sum, │
│ Equipment: eq, │
│ Media: med, │
│ } │
│ pr, eqc, derr := db.InsertPlayerProfileData(res, timestamp) │
│ if derr != nil { │
│ finalErr = derr │
│ continue │
│ } │
│ profs += pr │
│ items += eqc │
│ // Update players table to resolved identity for this build │
│ _ = db.UpdatePlayerIdentity(p.ID, c[1], region, c[0]) │
│ break │
│ } │
│ outCh <- out{profiles: profs, equipment: items, err: finalErr} │
│ }(p) │
│ } │
│ │
│ // Wait for batch: collect exactly len(batch) results │
│ batchProfiles := 0 │
│ batchEquipment := 0 │
│ for k := 0; k < len(batch); k++ { │
│ r := <-outCh │
│ processedCount++ │
│ if r.err != nil && r.profiles == 0 && r.equipment == 0 { │
│ // only log errors if nothing was inserted │
│ if opts.Verbose { │
│ fmt.Printf(" [ERROR] profile fetch failed: %v\n", r.err) │
│ } │
│ } │
│ batchProfiles += r.profiles │
│ batchEquipment += r.equipment │
│ } │
│ close(outCh) │
│ │
│ totalProfiles += batchProfiles │
│ totalEquipment += batchEquipment │
│ │
│ elapsed := time.Since(startTime) │
│ fmt.Printf(" -> Batch %d complete: %d profiles, %d items (Total: %d/%d players, │
│ %.1f players/min)\n", │
│ batchNumber, batchProfiles, batchEquipment, processedCount, len(players), │
│ float64(processedCount)/elapsed.Minutes()) │
│ │
│ // Small delay between batches to be respectful to the API │
│ if i+batchSize < len(players) { │
│ fmt.Printf(" [INFO] Waiting 1 second before next batch...\n") │
│ time.Sleep(1 * time.Second) │
│ } │
│ } │
│ │
│ elapsed := time.Since(startTime) │
│ return &FetchProfilesResult{ │
│ TotalProfiles: totalProfiles, │
│ TotalEquipment: totalEquipment, │
│ ProcessedCount: processedCount, │
│ Duration: elapsed, │
│ }, nil │
│ } │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ GO ─────────────────────────────────┐
│ package pipeline │
│ │
│ import ( │
│ "context" │
│ "fmt" │
│ "ookstats/internal/blizzard" │
│ "ookstats/internal/database" │
│ "strings" │
│ "time" │
│ ) │
│ │
│ // FetchCMOptions contains options for fetch │
│ ing challenge mode leaderboards │
│ type FetchCMOptions struct { │
│ Verbose bool │
│ Regions []string │
│ Realms []string │
│ Dungeons []string │
│ Periods []string │
│ LatestPeriodsOnly bool │
│ Concurrency int │
│ Timeout time.Duration │
│ } │
│ │
│ // FetchCMResult contains statistics from th │
│ e fetch operation │
│ type FetchCMResult struct { │
│ TotalRuns int │
│ TotalPlayers int │
│ Duration time.Duration │
│ } │
│ │
│ // FetchChallengeMode fetches challenge mode │
│ leaderboard data for specified realms/dunge │
│ ons/periods │
│ func FetchChallengeMode(db *database.Databas │
│ eService, client *blizzard.Client, opts Fetc │
│ hCMOptions) (*FetchCMResult, error) { │
│ // Get dungeons and realms from hardcoded l │
│ ists │
│ _, dungeons := blizzard.GetHardcodedPeriodA │
│ ndDungeons() │
│ allRealms := blizzard.GetAllRealms() │
│ │
│ fmt.Printf("Dungeons: %d, Realms: %d\n", le │
│ n(dungeons), len(allRealms)) │
│ │
│ // Apply region filter │
│ if len(opts.Regions) > 0 { │
│ allowed := make(map[string]bool) │
│ for _, r := range opts.Regions { │
│ allowed[strings.TrimSpace(r)] = true │
│ } │
│ for slug, info := range allRealms { │
│ if !allowed[info.Region] { │
│ delete(allRealms, slug) │
│ } │
│ } │
│ } │
│ │
│ // Apply realm filter │
│ if len(opts.Realms) > 0 { │
│ allowed := make(map[string]bool) │
│ for _, s := range opts.Realms { │
│ s = strings.TrimSpace(s) │
│ if s != "" { │
│ allowed[s] = true │
│ } │
│ } │
│ filtered := make(map[string]blizzard.Realm │
│ Info) │
│ for key, info := range allRealms { │
│ if allowed[key] || allowed[info.Slug] { │
│ filtered[key] = info │
│ } │
│ } │
│ allRealms = filtered │
│ } │
│ │
│ // Apply dungeon filter │
│ if len(opts.Dungeons) > 0 { │
│ allowed := make(map[string]bool) │
│ for _, s := range opts.Dungeons { │
│ allowed[strings.TrimSpace(s)] = true │
│ } │
│ filtered := make([]blizzard.DungeonInfo, 0 │
│ , len(dungeons)) │
│ for _, d := range dungeons { │
│ idStr := fmt.Sprintf("%d", d.ID) │
│ if allowed[idStr] || allowed[d.Slug] { │
│ filtered = append(filtered, d) │
│ } │
│ } │
│ if len(filtered) > 0 { │
│ dungeons = filtered │
│ } │
│ } │
│ │
│ // Pre-populate reference data (includes AL │
│ L realms, even children) │
│ fmt.Printf("Pre-populating reference data.. │
│ .\n") │
│ fmt.Printf(" - Ensuring dungeons (%d)\n", │
│ len(dungeons)) │
│ if err := db.EnsureDungeonsOnce(dungeons); │
│ err != nil { │
│ return nil, fmt.Errorf("failed to ensure d │
│ ungeons: %w", err) │
│ } │
│ fmt.Printf(" [OK] Dungeons ensured\n") │
│ fmt.Printf(" - Ensuring realms (%d, includ │
│ ing child realms)\n", len(allRealms)) │
│ if err := db.EnsureRealmsBatch(allRealms); │
│ err != nil { │
│ return nil, fmt.Errorf("failed to ensure r │
│ ealms: %w", err) │
│ } │
│ fmt.Printf(" [OK] Realms ensured\n") │
│ │
│ // Filter out child realms for fetching (th │
│ ey don't have their own leaderboards) │
│ // Players from child realms appear on pare │
│ nt realm leaderboards │
│ fetchRealms := make(map[string]blizzard.Rea │
│ lmInfo) │
│ childRealmsFiltered := 0 │
│ for slug, info := range allRealms { │
│ if info.ParentRealmSlug != "" { │
│ childRealmsFiltered++ │
│ } else { │
│ fetchRealms[slug] = info │
│ } │
│ } │
│ if childRealmsFiltered > 0 { │
│ fmt.Printf("Filtered out %d child realms f │
│ rom fetch (no leaderboards to fetch)\n", chi │
│ ldRealmsFiltered) │
│ } │
│ fmt.Printf("Reference data populated for %d │
│ realms (%d to fetch from) and %d dungeons\n │
│ ", │
│ len(allRealms), len(fetchRealms), len(dung │
│ eons)) │
│ │
│ // Group realms by region (only fetch realm │
│ s, excluding children) │
│ realmsByRegion := make(map[string]map[strin │
│ g]blizzard.RealmInfo) │
│ for slug, info := range fetchRealms { │
│ if realmsByRegion[info.Region] == nil { │
│ realmsByRegion[info.Region] = make(map[st │
│ ring]blizzard.RealmInfo) │
│ } │
│ realmsByRegion[info.Region][slug] = info │
│ } │
│ │
│ ctx, cancel := context.WithTimeout(context. │
│ Background(), opts.Timeout) │
│ defer cancel() │
│ │
│ totalRuns := 0 │
│ totalPlayers := 0 │
│ sweepStart := time.Now() │
│ │
│ // Process each region independently │
│ for region, regionRealms := range realmsByR │
│ egion { │
│ fmt.Printf("\n========== Region: %s (%d re │
│ alms) ==========\n", strings.ToUpper(region) │
│ , len(regionRealms)) │
│ │
│ // Determine periods for this region │
│ var periods []string │
│ │
│ if len(opts.Periods) > 0 { │
│ // User-specified periods │
│ periods = opts.Periods │
│ fmt.Printf("Using user-specified periods: │
│ %v (%d periods)\n", periods, len(periods)) │
│ } else { │
│ // Fetch periods from database (populated │
│ by season sync) │
│ var periodInts []int │
│ var err error │
│ │
│ if opts.LatestPeriodsOnly { │
│ fmt.Printf("Fetching latest 2 periods fr │
│ om current season for %s...\n", strings.ToUp │
│ per(region)) │
│ periodInts, err = db.GetLatestPeriodsPer │
│ Region(region) │
│ } else { │
│ fmt.Printf("Fetching period list from da │
│ tabase for %s...\n", strings.ToUpper(region) │
│ ) │
│ periodInts, err = db.GetPeriodsForRegion │
│ (region) │
│ } │
│ │
│ if err != nil { │
│ fmt.Printf("Failed to fetch periods from │
│ database for %s: %v - skipping region\n", s │
│ trings.ToUpper(region), err) │
│ continue │
│ } │
│ │
│ // Convert []int to []string for compatib │
│ ility │
│ periods = make([]string, len(periodInts)) │
│ for i, p := range periodInts { │
│ periods[i] = fmt.Sprintf("%d", p) │
│ } │
│ │
│ if len(periods) == 0 { │
│ fmt.Printf("No periods found in database │
│ for %s (run season sync first) - skipping r │
│ egion\n", strings.ToUpper(region)) │
│ continue │
│ } │
│ │
│ if opts.LatestPeriodsOnly { │
│ fmt.Printf("[OK] Using latest %d period( │
│ s) from current season for %s: %v\n", │
│ len(periods), strings.ToUpper(region), │
│ periods) │
│ } else { │
│ fmt.Printf("[OK] Fetched %d periods from │
│ database for %s (newest: %s, oldest: %s)\n" │
│ , │
│ len(periods), strings.ToUpper(region), │
│ periods[0], periods[len(periods)-1]) │
│ } │
│ } │
│ │
│ if len(periods) == 0 { │
│ fmt.Printf("No periods to process for %s │
│ - skipping region\n", strings.ToUpper(region │
│ )) │
│ continue │
│ } │
│ │
│ // Period sweep for this region │
│ fmt.Printf("Starting period sweep for %s: │
│ %d periods\n", strings.ToUpper(region), len( │
│ periods)) │
│ for _, period := range periods { │
│ fmt.Printf("\n--- %s Period %s ---\n", st │
│ rings.ToUpper(region), period) │
│ res := client.FetchAllRealmsConcurrent(ct │
│ x, regionRealms, dungeons, period) │
│ runs, players, berr := db.BatchProcessFet │
│ chResults(ctx, res) │
│ if berr != nil { │
│ fmt.Printf("Batch errors in %s period %s │
│ : %v\n", strings.ToUpper(region), period, be │
│ rr) │
│ } │
│ fmt.Printf("%s Period %s -> inserted runs │
│ : %d, new players: %d\n", strings.ToUpper(re │
│ gion), period, runs, players) │
│ totalRuns += runs │
│ totalPlayers += players │
│ } │
│ } │
│ │
│ duration := time.Since(sweepStart) │
│ fmt.Printf("\n========== Sweep complete in │
│ %v ==========\n", duration) │
│ │
│ // Update fetch metadata │
│ if err := db.UpdateFetchMetadata("challenge │
│ _mode_leaderboard", totalRuns, totalPlayers) │
│ ; err != nil { │
│ return nil, fmt.Errorf("failed to update f │
│ etch metadata: %w", err) │
│ } │
│ │
│ return &FetchCMResult{ │
│ TotalRuns: totalRuns, │
│ TotalPlayers: totalPlayers, │
│ Duration: duration, │
│ }, nil │
│ } │
│ │
│ // FetchProfilesOptions contains options for │
│ fetching player profiles │
│ type FetchProfilesOptions struct { │
│ Verbose bool │
│ BatchSize int │
│ MaxPlayers int │
│ StaleAfter time.Duration │
│ } │
│ │
│ // FetchProfilesResult contains statistics f │
│ rom the profile fetch operation │
│ type FetchProfilesResult struct { │
│ TotalProfiles int │
│ TotalEquipment int │
│ ProcessedCount int │
│ Duration time.Duration │
│ } │
│ │
│ // FetchPlayerProfiles fetches detailed play │
│ er profile data including equipment │
│ func FetchPlayerProfiles(db *database.Databa │
│ seService, client *blizzard.Client, opts Fet │
│ chProfilesOptions) (*FetchProfilesResult, er │
│ ror) { │
│ // Compute staleness cutoff │
│ staleCutoff := int64(0) │
│ if opts.StaleAfter > 0 { │
│ staleCutoff = time.Now().Add(-opts.StaleAf │
│ ter).UnixMilli() │
│ } │
│ │
│ // Get eligible players (9/9 completion) │
│ fmt.Println("Finding eligible players with │
│ complete coverage (9/9 dungeons)...") │
│ players, err := db.GetEligiblePlayersForPro │
│ fileFetch(staleCutoff) │
│ if err != nil { │
│ return nil, fmt.Errorf("failed to get elig │
│ ible players: %w", err) │
│ } │
│ │
│ if len(players) == 0 { │
│ fmt.Println("No eligible players found. Ru │
│ n 'ookstats process players' first to genera │
│ te player profiles.") │
│ return &FetchProfilesResult{}, nil │
│ } │
│ │
│ fmt.Printf("Found %d eligible players with │
│ 9/9 completion\n", len(players)) │
│ │
│ // Apply max players limit │
│ if opts.MaxPlayers > 0 && len(players) > op │
│ ts.MaxPlayers { │
│ players = players[:opts.MaxPlayers] │
│ fmt.Printf("Limited to first %d players du │
│ e to max-players limit\n", opts.MaxPlayers) │
│ } │
│ │
│ // Default batch size │
│ batchSize := opts.BatchSize │
│ if batchSize <= 0 { │
│ batchSize = 20 │
│ } │
│ │
│ fmt.Printf("Processing %d players in batche │
│ s of %d with 20 concurrent requests\n", len( │
│ players), batchSize) │
│ fmt.Printf("\nStarting player profile fetch │
│ ing...\n") │
│ │
│ startTime := time.Now() │
│ totalProfiles := 0 │
│ totalEquipment := 0 │
│ processedCount := 0 │
│ │
│ // Process in batches to avoid overwhelming │
│ the API │
│ for i := 0; i < len(players); i += batchSiz │
│ e { │
│ end := i + batchSize │
│ if end > len(players) { │
│ end = len(players) │
│ } │
│ batch := players[i:end] │
│ │
│ batchNumber := (i / batchSize) + 1 │
│ totalBatches := (len(players) + batchSize │
│ - 1) / batchSize │
│ │
│ fmt.Printf("\n--- Batch %d/%d (%d players) │
│ ---\n", batchNumber, totalBatches, len(batc │
│ h)) │
│ │
│ // Fetch profiles concurrently for this ba │
│ tch with fallback attempts │
│ sem := make(chan struct{}, 20) │
│ type out struct { │
│ profiles int │
│ equipment int │
│ err error │
│ } │
│ outCh := make(chan out, len(batch)) │
│ timestamp := time.Now().UnixMilli() │
│ │
│ for _, p := range batch { │
│ sem <- struct{}{} │
│ go func(p blizzard.PlayerInfo) { │
│ defer func() { <-sem }() │
│ │
│ // Build candidate list │
│ region := p.Region │
│ realm := blizzard.NormalizeRealmSlug(reg │
│ ion, p.RealmSlug) │
│ name := p.Name │
│ tried := map[string]bool{} │
│ candidates := [][2]string{{realm, name}} │
│ │
│ // Connected-realm sweep │
│ if slugs, err := db.GetConnectedRealmSlu │
│ gs(region, realm); err == nil { │
│ for _, s := range slugs { │
│ s = blizzard.NormalizeRealmSlug(region │
│ , s) │
│ if s != realm { │
│ candidates = append(candidates, [2]st │
│ ring{s, name}) │
│ } │
│ } │
│ } │
│ │
│ // Last-run realm heuristic │
│ if lrRegion, lrSlug, _, err := db.GetLas │
│ tRunRealmForPlayer(p.ID); err == nil && lrRe │
│ gion != "" && lrSlug != "" { │
│ if lrRegion == region { │
│ lrSlug = blizzard.NormalizeRealmSlug(r │
│ egion, lrSlug) │
│ candidates = append(candidates, [2]str │
│ ing{lrSlug, name}) │
│ } │
│ } │
│ │
│ // Attempt candidates │
│ var profs, items int │
│ var finalErr error │
│ for _, c := range candidates { │
│ key := c[0] + "|" + c[1] │
│ if tried[key] { │
│ continue │
│ } │
│ tried[key] = true │
│ │
│ // Fetch summary first; if it 404s, ski │
│ p to next candidate │
│ sum, err := client.FetchCharacterSummar │
│ y(c[1], c[0], region) │
│ if err != nil { │
│ // try next candidate │
│ finalErr = err │
│ continue │
│ } │
│ eq, err2 := client.FetchCharacterEquipm │
│ ent(c[1], c[0], region) │
│ if err2 != nil { │
│ finalErr = err2 │
│ } │
│ med, err3 := client.FetchCharacterMedia │
│ (c[1], c[0], region) │
│ if err3 != nil { │
│ finalErr = err3 │
│ } │
│ │
│ // Insert profile data │
│ res := blizzard.PlayerProfileResult{ │
│ PlayerID: p.ID, │
│ PlayerName: c[1], │
│ RealmSlug: c[0], │
│ Region: region, │
│ Summary: sum, │
│ Equipment: eq, │
│ Media: med, │
│ } │
│ pr, eqc, derr := db.InsertPlayerProfile │
│ Data(res, timestamp) │
│ if derr != nil { │
│ finalErr = derr │
│ continue │
│ } │
│ profs += pr │
│ items += eqc │
│ // Update players table to resolved ide │
│ ntity for this build │
│ _ = db.UpdatePlayerIdentity(p.ID, c[1], │
│ region, c[0]) │
│ break │
│ } │
│ outCh <- out{profiles: profs, equipment: │
│ items, err: finalErr} │
│ }(p) │
│ } │
│ │
│ // Wait for batch: collect exactly len(bat │
│ ch) results │
│ batchProfiles := 0 │
│ batchEquipment := 0 │
│ for k := 0; k < len(batch); k++ { │
│ r := <-outCh │
│ processedCount++ │
│ if r.err != nil && r.profiles == 0 && r.e │
│ quipment == 0 { │
│ // only log errors if nothing was insert │
│ ed │
│ if opts.Verbose { │
│ fmt.Printf(" [ERROR] profile fetch fai │
│ led: %v\n", r.err) │
│ } │
│ } │
│ batchProfiles += r.profiles │
│ batchEquipment += r.equipment │
│ } │
│ close(outCh) │
│ │
│ totalProfiles += batchProfiles │
│ totalEquipment += batchEquipment │
│ │
│ elapsed := time.Since(startTime) │
│ fmt.Printf(" -> Batch %d complete: %d pro │
│ files, %d items (Total: %d/%d players, %.1f │
│ players/min)\n", │
│ batchNumber, batchProfiles, batchEquipmen │
│ t, processedCount, len(players), │
│ float64(processedCount)/elapsed.Minutes() │
│ ) │
│ │
│ // Small delay between batches to be respe │
│ ctful to the API │
│ if i+batchSize < len(players) { │
│ fmt.Printf(" [INFO] Waiting 1 second bef │
│ ore next batch...\n") │
│ time.Sleep(1 * time.Second) │
│ } │
│ } │
│ │
│ elapsed := time.Since(startTime) │
│ return &FetchProfilesResult{ │
│ TotalProfiles: totalProfiles, │
│ TotalEquipment: totalEquipment, │
│ ProcessedCount: processedCount, │
│ Duration: elapsed, │
│ }, nil │
│ } │
└──────────────────────────────────────────────┘