cricinfo-cli-go 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ const (
19
19
  analysisMetricSixesConceded = "sixes-conceded"
20
20
  analysisMetricFours = "fours"
21
21
  analysisMetricSixes = "sixes"
22
+ analysisMetricRuns = "runs"
22
23
  analysisMetricStrikeRate = "strike-rate"
23
24
  )
24
25
 
@@ -349,54 +350,62 @@ func (s *AnalysisService) Bowling(ctx context.Context, opts AnalysisMetricOption
349
350
 
350
351
  agg := map[string]*analysisAggregate{}
351
352
  warnings := append([]string{}, run.warnings...)
352
- for _, match := range run.session.ScopedMatches() {
353
- seasonID := seasonForMatch(match, run.seasonHint)
354
- players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
355
- if hydrateErr != nil {
356
- if statusErr := analysisTransportResult(EntityAnalysisBowl, match.ID, hydrateErr); statusErr != nil {
357
- return *statusErr, nil
358
- }
359
- warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
360
- continue
353
+ if run.mode == analysisScopeMatch {
354
+ if fastAgg, fastWarnings, used := s.hydrateMatchScopeBowlingFromScorecard(ctx, run, filters, groupBy); used {
355
+ agg = fastAgg
356
+ warnings = append(warnings, fastWarnings...)
361
357
  }
362
- warnings = append(warnings, playerWarnings...)
363
-
364
- for _, player := range players {
365
- totals := extractBowlingTotals(player)
366
- playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
367
- teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
368
- row := analysisSourceRow{
369
- MatchID: strings.TrimSpace(player.MatchID),
370
- LeagueID: strings.TrimSpace(player.LeagueID),
371
- SeasonID: seasonID,
372
- TeamID: strings.TrimSpace(player.TeamID),
373
- TeamName: strings.TrimSpace(teamName),
374
- PlayerID: strings.TrimSpace(player.PlayerID),
375
- PlayerName: strings.TrimSpace(playerName),
376
- CountValue: 1,
377
- Dots: totals.dots,
378
- SixesConceded: totals.sixesConceded,
379
- Balls: totals.balls,
380
- RunsConceded: totals.conceded,
381
- EconomySample: totals.economy,
382
- }
383
- if !filters.matches(row) {
358
+ }
359
+ if len(agg) == 0 {
360
+ for _, match := range run.session.ScopedMatches() {
361
+ seasonID := seasonForMatch(match, run.seasonHint)
362
+ players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
363
+ if hydrateErr != nil {
364
+ if statusErr := analysisTransportResult(EntityAnalysisBowl, match.ID, hydrateErr); statusErr != nil {
365
+ return *statusErr, nil
366
+ }
367
+ warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
384
368
  continue
385
369
  }
386
- key, dims := buildAnalysisGroup(row, groupBy)
387
- entry := agg[key]
388
- if entry == nil {
389
- entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
390
- agg[key] = entry
391
- }
392
- entry.matchIDs[row.MatchID] = struct{}{}
393
- entry.dots += row.Dots
394
- entry.sixesConceded += row.SixesConceded
395
- entry.balls += row.Balls
396
- entry.runsConceded += row.RunsConceded
397
- if row.EconomySample > 0 {
398
- entry.economyTotal += row.EconomySample
399
- entry.economyCount++
370
+ warnings = append(warnings, playerWarnings...)
371
+
372
+ for _, player := range players {
373
+ totals := extractBowlingTotals(player)
374
+ playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
375
+ teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
376
+ row := analysisSourceRow{
377
+ MatchID: strings.TrimSpace(player.MatchID),
378
+ LeagueID: strings.TrimSpace(player.LeagueID),
379
+ SeasonID: seasonID,
380
+ TeamID: strings.TrimSpace(player.TeamID),
381
+ TeamName: strings.TrimSpace(teamName),
382
+ PlayerID: strings.TrimSpace(player.PlayerID),
383
+ PlayerName: strings.TrimSpace(playerName),
384
+ CountValue: 1,
385
+ Dots: totals.dots,
386
+ SixesConceded: totals.sixesConceded,
387
+ Balls: totals.balls,
388
+ RunsConceded: totals.conceded,
389
+ EconomySample: totals.economy,
390
+ }
391
+ if !filters.matches(row) {
392
+ continue
393
+ }
394
+ key, dims := buildAnalysisGroup(row, groupBy)
395
+ entry := agg[key]
396
+ if entry == nil {
397
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
398
+ agg[key] = entry
399
+ }
400
+ entry.matchIDs[row.MatchID] = struct{}{}
401
+ entry.dots += row.Dots
402
+ entry.sixesConceded += row.SixesConceded
403
+ entry.balls += row.Balls
404
+ entry.runsConceded += row.RunsConceded
405
+ if row.EconomySample > 0 {
406
+ entry.economyTotal += row.EconomySample
407
+ entry.economyCount++
408
+ }
400
409
  }
401
410
  }
402
411
  }
@@ -447,6 +456,103 @@ func (s *AnalysisService) Bowling(ctx context.Context, opts AnalysisMetricOption
447
456
  return analysisResult(EntityAnalysisBowl, view, warnings), nil
448
457
  }
449
458
 
459
+ func (s *AnalysisService) hydrateMatchScopeBowlingFromScorecard(
460
+ ctx context.Context,
461
+ run *analysisScopeRun,
462
+ filters analysisFilterSpec,
463
+ groupBy []string,
464
+ ) (map[string]*analysisAggregate, []string, bool) {
465
+ if run == nil || run.session == nil {
466
+ return nil, nil, false
467
+ }
468
+ matches := run.session.ScopedMatches()
469
+ if len(matches) != 1 {
470
+ return nil, nil, false
471
+ }
472
+ match := matches[0]
473
+ scorecardRef := matchSubresourceRef(match, "matchcards", "matchcards")
474
+ if strings.TrimSpace(scorecardRef) == "" {
475
+ return nil, nil, false
476
+ }
477
+ resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
478
+ if err != nil {
479
+ return nil, nil, false
480
+ }
481
+ scorecard, err := NormalizeMatchScorecard(resolved.Body, match)
482
+ if err != nil {
483
+ return nil, nil, false
484
+ }
485
+ if len(scorecard.BowlingCards) == 0 {
486
+ return nil, nil, false
487
+ }
488
+
489
+ teamIDByAlias := map[string]string{}
490
+ for _, team := range match.Teams {
491
+ teamID := strings.TrimSpace(team.ID)
492
+ for _, raw := range []string{team.Name, team.ShortName, team.Abbreviation} {
493
+ alias := normalizeAlias(raw)
494
+ if alias == "" {
495
+ continue
496
+ }
497
+ if _, exists := teamIDByAlias[alias]; !exists {
498
+ teamIDByAlias[alias] = teamID
499
+ }
500
+ }
501
+ }
502
+
503
+ seasonID := seasonForMatch(match, run.seasonHint)
504
+ agg := map[string]*analysisAggregate{}
505
+ for _, card := range scorecard.BowlingCards {
506
+ teamName := strings.TrimSpace(card.TeamName)
507
+ teamID := teamIDByAlias[normalizeAlias(teamName)]
508
+ teamName = analysisDisplayTeamName(s.resolver, teamID, teamName)
509
+
510
+ for _, player := range card.Players {
511
+ conceded := analysisNumericString(player.Conceded)
512
+ wickets := analysisNumericString(player.Wickets)
513
+ balls := oversToBalls(player.Overs)
514
+ econ := 0.0
515
+ if balls > 0 {
516
+ econ = float64(conceded) / (float64(balls) / 6.0)
517
+ }
518
+ row := analysisSourceRow{
519
+ MatchID: strings.TrimSpace(match.ID),
520
+ LeagueID: strings.TrimSpace(nonEmpty(match.LeagueID, run.scope.League.ID)),
521
+ SeasonID: seasonID,
522
+ TeamID: teamID,
523
+ TeamName: teamName,
524
+ PlayerID: strings.TrimSpace(player.PlayerID),
525
+ PlayerName: analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName),
526
+ CountValue: 1,
527
+ Balls: balls,
528
+ RunsConceded: conceded,
529
+ EconomySample: econ,
530
+ }
531
+ if wickets > 0 {
532
+ row.Dots = 0
533
+ }
534
+ if !filters.matches(row) {
535
+ continue
536
+ }
537
+
538
+ key, dims := buildAnalysisGroup(row, groupBy)
539
+ entry := agg[key]
540
+ if entry == nil {
541
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
542
+ agg[key] = entry
543
+ }
544
+ entry.matchIDs[row.MatchID] = struct{}{}
545
+ entry.balls += row.Balls
546
+ entry.runsConceded += row.RunsConceded
547
+ if row.EconomySample > 0 {
548
+ entry.economyTotal += row.EconomySample
549
+ entry.economyCount++
550
+ }
551
+ }
552
+ }
553
+ return agg, nil, true
554
+ }
555
+
450
556
  // Batting ranks batting metrics over match or season scope.
451
557
  func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
452
558
  metric, err := normalizeBattingMetric(opts.Metric)
@@ -467,54 +573,62 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
467
573
 
468
574
  agg := map[string]*analysisAggregate{}
469
575
  warnings := append([]string{}, run.warnings...)
470
- for _, match := range run.session.ScopedMatches() {
471
- seasonID := seasonForMatch(match, run.seasonHint)
472
- players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
473
- if hydrateErr != nil {
474
- if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
475
- return *statusErr, nil
476
- }
477
- warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
478
- continue
576
+ if run.mode == analysisScopeMatch {
577
+ if fastAgg, fastWarnings, used := s.hydrateMatchScopeBattingFromScorecard(ctx, run, filters, groupBy); used {
578
+ agg = fastAgg
579
+ warnings = append(warnings, fastWarnings...)
479
580
  }
480
- warnings = append(warnings, playerWarnings...)
481
-
482
- for _, player := range players {
483
- totals := extractBattingTotals(player)
484
- playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
485
- teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
486
- row := analysisSourceRow{
487
- MatchID: strings.TrimSpace(player.MatchID),
488
- LeagueID: strings.TrimSpace(player.LeagueID),
489
- SeasonID: seasonID,
490
- TeamID: strings.TrimSpace(player.TeamID),
491
- TeamName: strings.TrimSpace(teamName),
492
- PlayerID: strings.TrimSpace(player.PlayerID),
493
- PlayerName: strings.TrimSpace(playerName),
494
- CountValue: 1,
495
- Fours: totals.fours,
496
- BattingSixes: totals.sixes,
497
- RunsScored: totals.runs,
498
- BallsFaced: totals.balls,
499
- StrikeSample: totals.strikeRate,
500
- }
501
- if !filters.matches(row) {
581
+ }
582
+ if len(agg) == 0 {
583
+ for _, match := range run.session.ScopedMatches() {
584
+ seasonID := seasonForMatch(match, run.seasonHint)
585
+ players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
586
+ if hydrateErr != nil {
587
+ if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
588
+ return *statusErr, nil
589
+ }
590
+ warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
502
591
  continue
503
592
  }
504
- key, dims := buildAnalysisGroup(row, groupBy)
505
- entry := agg[key]
506
- if entry == nil {
507
- entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
508
- agg[key] = entry
509
- }
510
- entry.matchIDs[row.MatchID] = struct{}{}
511
- entry.fours += row.Fours
512
- entry.battingSixes += row.BattingSixes
513
- entry.runsScored += row.RunsScored
514
- entry.ballsFaced += row.BallsFaced
515
- if row.StrikeSample > 0 {
516
- entry.strikeRateTotal += row.StrikeSample
517
- entry.strikeRateCount++
593
+ warnings = append(warnings, playerWarnings...)
594
+
595
+ for _, player := range players {
596
+ totals := extractBattingTotals(player)
597
+ playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
598
+ teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
599
+ row := analysisSourceRow{
600
+ MatchID: strings.TrimSpace(player.MatchID),
601
+ LeagueID: strings.TrimSpace(player.LeagueID),
602
+ SeasonID: seasonID,
603
+ TeamID: strings.TrimSpace(player.TeamID),
604
+ TeamName: strings.TrimSpace(teamName),
605
+ PlayerID: strings.TrimSpace(player.PlayerID),
606
+ PlayerName: strings.TrimSpace(playerName),
607
+ CountValue: 1,
608
+ Fours: totals.fours,
609
+ BattingSixes: totals.sixes,
610
+ RunsScored: totals.runs,
611
+ BallsFaced: totals.balls,
612
+ StrikeSample: totals.strikeRate,
613
+ }
614
+ if !filters.matches(row) {
615
+ continue
616
+ }
617
+ key, dims := buildAnalysisGroup(row, groupBy)
618
+ entry := agg[key]
619
+ if entry == nil {
620
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
621
+ agg[key] = entry
622
+ }
623
+ entry.matchIDs[row.MatchID] = struct{}{}
624
+ entry.fours += row.Fours
625
+ entry.battingSixes += row.BattingSixes
626
+ entry.runsScored += row.RunsScored
627
+ entry.ballsFaced += row.BallsFaced
628
+ if row.StrikeSample > 0 {
629
+ entry.strikeRateTotal += row.StrikeSample
630
+ entry.strikeRateCount++
631
+ }
518
632
  }
519
633
  }
520
634
  }
@@ -533,6 +647,9 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
533
647
  case analysisMetricSixes:
534
648
  row.Value = float64(entry.battingSixes)
535
649
  row.Count = entry.battingSixes
650
+ case analysisMetricRuns:
651
+ row.Value = float64(entry.runsScored)
652
+ row.Count = entry.runsScored
536
653
  case analysisMetricStrikeRate:
537
654
  row.Value = strikeRateFromAggregate(entry)
538
655
  }
@@ -559,6 +676,133 @@ func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOption
559
676
  return analysisResult(EntityAnalysisBat, view, warnings), nil
560
677
  }
561
678
 
679
+ func (s *AnalysisService) hydrateMatchScopeBattingFromScorecard(
680
+ ctx context.Context,
681
+ run *analysisScopeRun,
682
+ filters analysisFilterSpec,
683
+ groupBy []string,
684
+ ) (map[string]*analysisAggregate, []string, bool) {
685
+ if run == nil || run.session == nil {
686
+ return nil, nil, false
687
+ }
688
+ matches := run.session.ScopedMatches()
689
+ if len(matches) != 1 {
690
+ return nil, nil, false
691
+ }
692
+
693
+ match := matches[0]
694
+ scorecardRef := matchSubresourceRef(match, "matchcards", "matchcards")
695
+ if strings.TrimSpace(scorecardRef) == "" {
696
+ return nil, nil, false
697
+ }
698
+
699
+ resolved, err := s.client.ResolveRefChain(ctx, scorecardRef)
700
+ if err != nil {
701
+ return nil, nil, false
702
+ }
703
+ scorecard, err := NormalizeMatchScorecard(resolved.Body, match)
704
+ if err != nil {
705
+ return nil, nil, false
706
+ }
707
+
708
+ teamIDByAlias := map[string]string{}
709
+ playerNameByID := map[string]string{}
710
+ for _, team := range match.Teams {
711
+ teamID := strings.TrimSpace(team.ID)
712
+ for _, raw := range []string{team.Name, team.ShortName, team.Abbreviation} {
713
+ alias := normalizeAlias(raw)
714
+ if alias == "" {
715
+ continue
716
+ }
717
+ if _, exists := teamIDByAlias[alias]; !exists {
718
+ teamIDByAlias[alias] = teamID
719
+ }
720
+ }
721
+
722
+ rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(match, team.ID, "roster"))
723
+ if strings.TrimSpace(rosterRef) == "" {
724
+ continue
725
+ }
726
+ rosterDoc, rosterErr := s.client.ResolveRefChain(ctx, rosterRef)
727
+ if rosterErr != nil {
728
+ continue
729
+ }
730
+ entries, normalizeErr := NormalizeTeamRosterEntries(rosterDoc.Body, team, TeamScopeMatch, match.ID)
731
+ if normalizeErr != nil {
732
+ continue
733
+ }
734
+ for _, entry := range entries {
735
+ playerID := strings.TrimSpace(entry.PlayerID)
736
+ playerName := strings.TrimSpace(entry.DisplayName)
737
+ if playerID == "" || playerName == "" {
738
+ continue
739
+ }
740
+ if _, exists := playerNameByID[playerID]; !exists {
741
+ playerNameByID[playerID] = playerName
742
+ }
743
+ }
744
+ }
745
+
746
+ seasonID := seasonForMatch(match, run.seasonHint)
747
+ agg := map[string]*analysisAggregate{}
748
+ for _, card := range scorecard.BattingCards {
749
+ teamName := strings.TrimSpace(card.TeamName)
750
+ teamID := teamIDByAlias[normalizeAlias(teamName)]
751
+ teamName = analysisDisplayTeamName(s.resolver, teamID, teamName)
752
+
753
+ for _, player := range card.Players {
754
+ runs := analysisNumericString(player.Runs)
755
+ balls := analysisNumericString(player.BallsFaced)
756
+ fours := analysisNumericString(player.Fours)
757
+ sixes := analysisNumericString(player.Sixes)
758
+ strikeRate := 0.0
759
+ if balls > 0 {
760
+ strikeRate = (float64(runs) * 100.0) / float64(balls)
761
+ }
762
+
763
+ row := analysisSourceRow{
764
+ MatchID: strings.TrimSpace(match.ID),
765
+ LeagueID: strings.TrimSpace(nonEmpty(match.LeagueID, run.scope.League.ID)),
766
+ SeasonID: seasonID,
767
+ TeamID: teamID,
768
+ TeamName: teamName,
769
+ PlayerID: strings.TrimSpace(player.PlayerID),
770
+ PlayerName: analysisDisplayPlayerName(
771
+ s.resolver,
772
+ player.PlayerID,
773
+ nonEmpty(playerNameByID[strings.TrimSpace(player.PlayerID)], player.PlayerName),
774
+ ),
775
+ CountValue: 1,
776
+ Fours: fours,
777
+ BattingSixes: sixes,
778
+ RunsScored: runs,
779
+ BallsFaced: balls,
780
+ StrikeSample: strikeRate,
781
+ }
782
+ if !filters.matches(row) {
783
+ continue
784
+ }
785
+
786
+ key, dims := buildAnalysisGroup(row, groupBy)
787
+ entry := agg[key]
788
+ if entry == nil {
789
+ entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
790
+ agg[key] = entry
791
+ }
792
+ entry.matchIDs[row.MatchID] = struct{}{}
793
+ entry.fours += row.Fours
794
+ entry.battingSixes += row.BattingSixes
795
+ entry.runsScored += row.RunsScored
796
+ entry.ballsFaced += row.BallsFaced
797
+ if row.StrikeSample > 0 {
798
+ entry.strikeRateTotal += row.StrikeSample
799
+ entry.strikeRateCount++
800
+ }
801
+ }
802
+ }
803
+ return agg, nil, true
804
+ }
805
+
562
806
  // Partnerships ranks partnerships over match or season scope.
563
807
  func (s *AnalysisService) Partnerships(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
564
808
  groupBy, err := parseGroupBy(opts.GroupBy, []string{"innings"}, analysisGroupPartnershipAllowed)
@@ -858,10 +1102,10 @@ func normalizeBattingMetric(raw string) (string, error) {
858
1102
  metric = strings.ReplaceAll(metric, "_", "-")
859
1103
  metric = strings.ReplaceAll(metric, " ", "-")
860
1104
  switch metric {
861
- case analysisMetricFours, analysisMetricSixes, analysisMetricStrikeRate:
1105
+ case analysisMetricFours, analysisMetricSixes, analysisMetricRuns, analysisMetricStrikeRate:
862
1106
  return metric, nil
863
1107
  default:
864
- return "", fmt.Errorf("--metric must be one of: fours, sixes, strike-rate")
1108
+ return "", fmt.Errorf("--metric must be one of: fours, sixes, runs, strike-rate")
865
1109
  }
866
1110
  }
867
1111
 
@@ -1191,7 +1435,7 @@ type battingTotals struct {
1191
1435
  }
1192
1436
 
1193
1437
  func extractBattingTotals(player PlayerMatch) battingTotals {
1194
- totals := battingTotals{strikeRate: player.Summary.StrikeRate, balls: player.Summary.BallsFaced}
1438
+ totals := battingTotals{strikeRate: player.Summary.StrikeRate}
1195
1439
  for _, category := range player.Batting {
1196
1440
  for _, stat := range category.Stats {
1197
1441
  switch normalizeStatName(stat.Name) {
@@ -1210,6 +1454,12 @@ func extractBattingTotals(player PlayerMatch) battingTotals {
1210
1454
  }
1211
1455
  }
1212
1456
  }
1457
+
1458
+ // Batting summary fields are frequently derived from the same stats payload.
1459
+ // Prefer the larger value rather than summing to avoid duplicate counting.
1460
+ if player.Summary.BallsFaced > 0 {
1461
+ totals.balls = analysisMaxInt(totals.balls, player.Summary.BallsFaced)
1462
+ }
1213
1463
  return totals
1214
1464
  }
1215
1465
 
@@ -1267,6 +1517,56 @@ func analysisMaxInt(a, b int) int {
1267
1517
  return b
1268
1518
  }
1269
1519
 
1520
+ func analysisNumericString(raw string) int {
1521
+ raw = strings.TrimSpace(raw)
1522
+ if raw == "" {
1523
+ return 0
1524
+ }
1525
+ start := -1
1526
+ end := -1
1527
+ for i, r := range raw {
1528
+ if r >= '0' && r <= '9' {
1529
+ if start == -1 {
1530
+ start = i
1531
+ }
1532
+ end = i + 1
1533
+ continue
1534
+ }
1535
+ if start != -1 {
1536
+ break
1537
+ }
1538
+ }
1539
+ if start == -1 || end == -1 {
1540
+ return 0
1541
+ }
1542
+ value, err := strconv.Atoi(raw[start:end])
1543
+ if err != nil {
1544
+ return 0
1545
+ }
1546
+ return value
1547
+ }
1548
+
1549
+ func oversToBalls(raw string) int {
1550
+ raw = strings.TrimSpace(raw)
1551
+ if raw == "" {
1552
+ return 0
1553
+ }
1554
+ if strings.Contains(raw, ".") {
1555
+ parts := strings.SplitN(raw, ".", 2)
1556
+ whole, errWhole := strconv.Atoi(strings.TrimSpace(parts[0]))
1557
+ frac, errFrac := strconv.Atoi(strings.TrimSpace(parts[1]))
1558
+ if errWhole != nil || errFrac != nil || whole < 0 || frac < 0 || frac > 6 {
1559
+ return 0
1560
+ }
1561
+ return whole*6 + frac
1562
+ }
1563
+ value, err := strconv.Atoi(raw)
1564
+ if err != nil || value < 0 {
1565
+ return 0
1566
+ }
1567
+ return value * 6
1568
+ }
1569
+
1270
1570
  func analysisDisplayPlayerName(resolver *Resolver, playerID, fallback string) string {
1271
1571
  name := strings.TrimSpace(fallback)
1272
1572
  if name != "" {
@@ -203,6 +203,44 @@ func TestPhase15BowlingActivityFilterSkipsNonBowlers(t *testing.T) {
203
203
  }
204
204
  }
205
205
 
206
+ func TestPhase15ExtractBattingTotalsDoesNotDoubleCountSummaryBalls(t *testing.T) {
207
+ t.Parallel()
208
+
209
+ player := PlayerMatch{
210
+ PlayerID: "1408688",
211
+ PlayerName: "Vaibhav Sooryavanshi",
212
+ Batting: []StatCategory{
213
+ {
214
+ Name: "general",
215
+ Stats: []StatValue{
216
+ {Name: "runs", Value: "78"},
217
+ {Name: "ballsFaced", Value: "26"},
218
+ {Name: "fours", Value: "8"},
219
+ {Name: "sixes", Value: "7"},
220
+ {Name: "strikeRate", Value: "300"},
221
+ },
222
+ },
223
+ },
224
+ Summary: PlayerMatchSummary{
225
+ BallsFaced: 26,
226
+ StrikeRate: 300,
227
+ },
228
+ }
229
+
230
+ totals := extractBattingTotals(player)
231
+ if totals.runs != 78 || totals.balls != 26 {
232
+ t.Fatalf("expected 78/26 batting totals, got runs=%d balls=%d", totals.runs, totals.balls)
233
+ }
234
+
235
+ agg := &analysisAggregate{
236
+ runsScored: totals.runs,
237
+ ballsFaced: totals.balls,
238
+ }
239
+ if got := strikeRateFromAggregate(agg); got != 300 {
240
+ t.Fatalf("expected strike rate 300, got %.2f", got)
241
+ }
242
+ }
243
+
206
244
  func TestLivePhase15SmallHistoricalScope(t *testing.T) {
207
245
  t.Parallel()
208
246
  requireLiveMatrix(t)
@@ -8,6 +8,7 @@ import (
8
8
  "io"
9
9
  "math"
10
10
  "math/rand"
11
+ "net"
11
12
  "net/http"
12
13
  "net/url"
13
14
  "strings"
@@ -138,7 +139,18 @@ func NewClient(cfg Config) (*Client, error) {
138
139
 
139
140
  httpClient := cfg.HTTPClient
140
141
  if httpClient == nil {
141
- httpClient = &http.Client{}
142
+ httpClient = &http.Client{
143
+ Transport: &http.Transport{
144
+ Proxy: http.ProxyFromEnvironment,
145
+ DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
146
+ ForceAttemptHTTP2: true,
147
+ MaxIdleConns: 128,
148
+ MaxIdleConnsPerHost: 32,
149
+ IdleConnTimeout: 90 * time.Second,
150
+ TLSHandshakeTimeout: 5 * time.Second,
151
+ ExpectContinueTimeout: 1 * time.Second,
152
+ },
153
+ }
142
154
  }
143
155
 
144
156
  sleep := cfg.Sleep
@@ -207,7 +219,7 @@ func (c *Client) Fetch(ctx context.Context, ref string) (*Document, error) {
207
219
  return nil, fmt.Errorf("read response %q: %w", requestURL, readErr)
208
220
  }
209
221
 
210
- if resp.StatusCode >= defaultRetryStatusCode && attempt < c.maxRetries {
222
+ if c.shouldRetryStatus(resp.StatusCode) && attempt < c.maxRetries {
211
223
  if sleepErr := c.sleep(ctx, c.retryDelay(attempt)); sleepErr != nil {
212
224
  return nil, sleepErr
213
225
  }
@@ -348,6 +360,15 @@ func (c *Client) shouldRetryError(err error, parentCtx context.Context) bool {
348
360
  return parentCtx.Err() == nil
349
361
  }
350
362
 
363
+ func (c *Client) shouldRetryStatus(statusCode int) bool {
364
+ switch statusCode {
365
+ case http.StatusRequestTimeout, http.StatusTooManyRequests:
366
+ return true
367
+ default:
368
+ return statusCode >= defaultRetryStatusCode
369
+ }
370
+ }
371
+
351
372
  func (c *Client) retryDelay(attempt int) time.Duration {
352
373
  delay := float64(c.retryBaseDelay)
353
374
  delay *= math.Pow(2, float64(attempt))