cricinfo-cli-go 0.1.0 → 0.1.2
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.
- package/internal/cli/matches.go +30 -2
- package/internal/cli/matches_test.go +11 -0
- package/internal/cricinfo/analysis.go +216 -46
- package/internal/cricinfo/analysis_phase15_test.go +38 -0
- package/internal/cricinfo/historical_hydration.go +82 -42
- package/internal/cricinfo/matches.go +582 -43
- package/internal/cricinfo/normalize_entities.go +16 -0
- package/internal/cricinfo/render_contract.go +52 -12
- package/internal/cricinfo/renderer.go +196 -11
- package/internal/cricinfo/resolver.go +53 -9
- package/package.json +1 -1
|
@@ -5,13 +5,20 @@ import (
|
|
|
5
5
|
"errors"
|
|
6
6
|
"fmt"
|
|
7
7
|
"net/url"
|
|
8
|
+
"sort"
|
|
8
9
|
"strconv"
|
|
9
10
|
"strings"
|
|
10
11
|
"sync"
|
|
12
|
+
"time"
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
const defaultMatchListLimit = 20
|
|
14
|
-
const deliveryFetchConcurrency =
|
|
16
|
+
const deliveryFetchConcurrency = 96
|
|
17
|
+
const detailSubresourceFetchConcurrency = 24
|
|
18
|
+
const detailItemFetchTimeout = 3 * time.Second
|
|
19
|
+
const matchTeamQueryScanRange = 6
|
|
20
|
+
const maxTeamQueryEventCandidates = 36
|
|
21
|
+
const teamQueryEventFetchTimeout = 1500 * time.Millisecond
|
|
15
22
|
|
|
16
23
|
// MatchServiceConfig configures match discovery and lookup behavior.
|
|
17
24
|
type MatchServiceConfig struct {
|
|
@@ -21,7 +28,8 @@ type MatchServiceConfig struct {
|
|
|
21
28
|
|
|
22
29
|
// MatchListOptions controls list/live traversal behavior.
|
|
23
30
|
type MatchListOptions struct {
|
|
24
|
-
Limit
|
|
31
|
+
Limit int
|
|
32
|
+
LeagueID string
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
// MatchLookupOptions controls resolver-backed single match lookup.
|
|
@@ -225,6 +233,151 @@ func (s *MatchService) Situation(ctx context.Context, query string, opts MatchLo
|
|
|
225
233
|
return result, nil
|
|
226
234
|
}
|
|
227
235
|
|
|
236
|
+
// Phases resolves and returns fan-oriented innings phase splits (powerplay/middle/death).
|
|
237
|
+
func (s *MatchService) Phases(ctx context.Context, query string, opts MatchLookupOptions) (NormalizedResult, error) {
|
|
238
|
+
lookup, passthrough := s.resolveMatchLookup(ctx, query, opts)
|
|
239
|
+
if passthrough != nil {
|
|
240
|
+
passthrough.Kind = EntityMatchPhases
|
|
241
|
+
return *passthrough, nil
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
245
|
+
teamCache := map[string]teamIdentity{}
|
|
246
|
+
scoreCache := map[string]string{}
|
|
247
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
248
|
+
warnings = append(warnings, s.hydrateMatch(ctx, lookup.match, statusCache, teamCache, scoreCache)...)
|
|
249
|
+
|
|
250
|
+
teams, teamWarnings, teamResult := s.selectTeamsFromMatch(ctx, *lookup.match, "", opts.LeagueID)
|
|
251
|
+
if teamResult != nil {
|
|
252
|
+
teamResult.Kind = EntityMatchPhases
|
|
253
|
+
return *teamResult, nil
|
|
254
|
+
}
|
|
255
|
+
warnings = append(warnings, teamWarnings...)
|
|
256
|
+
|
|
257
|
+
report := MatchPhases{
|
|
258
|
+
MatchID: lookup.match.ID,
|
|
259
|
+
LeagueID: lookup.match.LeagueID,
|
|
260
|
+
EventID: lookup.match.EventID,
|
|
261
|
+
CompetitionID: nonEmpty(lookup.match.CompetitionID, lookup.match.ID),
|
|
262
|
+
Fixture: nonEmpty(lookup.match.ShortDescription, lookup.match.Description),
|
|
263
|
+
Result: nonEmpty(lookup.match.MatchState, lookup.match.Note),
|
|
264
|
+
Innings: make([]MatchPhaseInning, 0),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for _, team := range teams {
|
|
268
|
+
inningsList, _, inningsWarnings := s.fetchTeamInnings(ctx, *lookup.match, team)
|
|
269
|
+
warnings = append(warnings, inningsWarnings...)
|
|
270
|
+
for i := range inningsList {
|
|
271
|
+
innings := inningsList[i]
|
|
272
|
+
statsWarnings := s.hydrateInningsTimelines(ctx, &innings)
|
|
273
|
+
warnings = append(warnings, statsWarnings...)
|
|
274
|
+
if !isMeaningfulPhaseInnings(innings) {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
phaseInnings := buildPhaseInnings(team, innings)
|
|
279
|
+
if !phaseInningsHasData(phaseInnings) {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
report.Innings = append(report.Innings, phaseInnings)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
result := NewDataResult(EntityMatchPhases, report)
|
|
287
|
+
if len(warnings) > 0 {
|
|
288
|
+
result = NewPartialResult(EntityMatchPhases, report, compactWarnings(warnings)...)
|
|
289
|
+
}
|
|
290
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
291
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
292
|
+
return result, nil
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func isMeaningfulPhaseInnings(innings Innings) bool {
|
|
296
|
+
if strings.TrimSpace(innings.Score) != "" {
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
if innings.Runs > 0 || innings.Wickets > 0 || innings.Target > 0 {
|
|
300
|
+
return true
|
|
301
|
+
}
|
|
302
|
+
return len(innings.OverTimeline) > 0 || len(innings.WicketTimeline) > 0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
func buildPhaseInnings(team Team, innings Innings) MatchPhaseInning {
|
|
306
|
+
out := MatchPhaseInning{
|
|
307
|
+
TeamID: nonEmpty(strings.TrimSpace(team.ID), strings.TrimSpace(innings.TeamID)),
|
|
308
|
+
TeamName: nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), strings.TrimSpace(innings.TeamName), strings.TrimSpace(innings.TeamID)),
|
|
309
|
+
InningsNumber: innings.InningsNumber,
|
|
310
|
+
Period: innings.Period,
|
|
311
|
+
Score: innings.Score,
|
|
312
|
+
Target: innings.Target,
|
|
313
|
+
Powerplay: PhaseSummary{Name: "Powerplay"},
|
|
314
|
+
Middle: PhaseSummary{Name: "Middle"},
|
|
315
|
+
Death: PhaseSummary{Name: "Death"},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
bestRuns := -1
|
|
319
|
+
for _, over := range innings.OverTimeline {
|
|
320
|
+
phase := phaseBucket(over.Number)
|
|
321
|
+
switch phase {
|
|
322
|
+
case "Powerplay":
|
|
323
|
+
accumulatePhase(&out.Powerplay, over)
|
|
324
|
+
case "Middle":
|
|
325
|
+
accumulatePhase(&out.Middle, over)
|
|
326
|
+
case "Death":
|
|
327
|
+
accumulatePhase(&out.Death, over)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if over.Runs > bestRuns {
|
|
331
|
+
bestRuns = over.Runs
|
|
332
|
+
out.BestScoringOver = over.Number
|
|
333
|
+
out.BestScoringOverRuns = over.Runs
|
|
334
|
+
}
|
|
335
|
+
if over.WicketCount > out.CollapseWickets {
|
|
336
|
+
out.CollapseWickets = over.WicketCount
|
|
337
|
+
out.CollapseOver = over.Number
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
finalizePhase(&out.Powerplay)
|
|
342
|
+
finalizePhase(&out.Middle)
|
|
343
|
+
finalizePhase(&out.Death)
|
|
344
|
+
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
func phaseBucket(overNumber int) string {
|
|
349
|
+
switch {
|
|
350
|
+
case overNumber >= 1 && overNumber <= 6:
|
|
351
|
+
return "Powerplay"
|
|
352
|
+
case overNumber >= 7 && overNumber <= 15:
|
|
353
|
+
return "Middle"
|
|
354
|
+
case overNumber >= 16:
|
|
355
|
+
return "Death"
|
|
356
|
+
default:
|
|
357
|
+
return "Middle"
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func accumulatePhase(phase *PhaseSummary, over InningsOver) {
|
|
362
|
+
if phase == nil {
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
phase.Runs += over.Runs
|
|
366
|
+
phase.Wickets += over.WicketCount
|
|
367
|
+
phase.Overs += 1
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
func finalizePhase(phase *PhaseSummary) {
|
|
371
|
+
if phase == nil || phase.Overs <= 0 {
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
phase.RunRate = float64(phase.Runs) / phase.Overs
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
func phaseInningsHasData(innings MatchPhaseInning) bool {
|
|
378
|
+
return innings.Powerplay.Overs > 0 || innings.Middle.Overs > 0 || innings.Death.Overs > 0
|
|
379
|
+
}
|
|
380
|
+
|
|
228
381
|
// Innings resolves and returns innings summaries with over and wicket timelines when period statistics are available.
|
|
229
382
|
func (s *MatchService) Innings(ctx context.Context, query string, opts MatchInningsOptions) (NormalizedResult, error) {
|
|
230
383
|
lookup, passthrough := s.resolveMatchLookup(ctx, query, MatchLookupOptions{LeagueID: opts.LeagueID})
|
|
@@ -417,9 +570,14 @@ func (s *MatchService) Deliveries(ctx context.Context, query string, opts MatchI
|
|
|
417
570
|
}
|
|
418
571
|
|
|
419
572
|
func (s *MatchService) listFromEvents(ctx context.Context, opts MatchListOptions, liveOnly bool) (NormalizedResult, error) {
|
|
420
|
-
|
|
573
|
+
rootRef := "/events"
|
|
574
|
+
if leagueID := strings.TrimSpace(opts.LeagueID); leagueID != "" {
|
|
575
|
+
rootRef = "/leagues/" + leagueID + "/events"
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
resolved, err := s.client.ResolveRefChain(ctx, rootRef)
|
|
421
579
|
if err != nil {
|
|
422
|
-
return NewTransportErrorResult(EntityMatch,
|
|
580
|
+
return NewTransportErrorResult(EntityMatch, rootRef, err), nil
|
|
423
581
|
}
|
|
424
582
|
|
|
425
583
|
page, err := DecodePage[Ref](resolved.Body)
|
|
@@ -532,16 +690,24 @@ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opt
|
|
|
532
690
|
result := NewTransportErrorResult(EntityMatch, query, err)
|
|
533
691
|
return nil, &result
|
|
534
692
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
693
|
+
warnings := append([]string{}, searchResult.Warnings...)
|
|
694
|
+
entity := IndexedEntity{}
|
|
695
|
+
if len(searchResult.Entities) > 0 {
|
|
696
|
+
entity = searchResult.Entities[0]
|
|
697
|
+
} else {
|
|
698
|
+
discovered, discoveryWarnings := s.discoverMatchByTeamQuery(ctx, query, strings.TrimSpace(opts.LeagueID))
|
|
699
|
+
warnings = append(warnings, discoveryWarnings...)
|
|
700
|
+
if discovered == nil {
|
|
701
|
+
result := NormalizedResult{
|
|
702
|
+
Kind: EntityMatch,
|
|
703
|
+
Status: ResultStatusEmpty,
|
|
704
|
+
Message: fmt.Sprintf("no matches found for %q", query),
|
|
705
|
+
}
|
|
706
|
+
return nil, &result
|
|
540
707
|
}
|
|
541
|
-
|
|
708
|
+
entity = *discovered
|
|
542
709
|
}
|
|
543
710
|
|
|
544
|
-
entity := searchResult.Entities[0]
|
|
545
711
|
ref := buildMatchRef(entity)
|
|
546
712
|
if ref == "" {
|
|
547
713
|
result := NormalizedResult{
|
|
@@ -571,10 +737,322 @@ func (s *MatchService) resolveMatchLookup(ctx context.Context, query string, opt
|
|
|
571
737
|
return &matchLookup{
|
|
572
738
|
match: match,
|
|
573
739
|
resolved: resolved,
|
|
574
|
-
warnings:
|
|
740
|
+
warnings: compactWarnings(warnings),
|
|
575
741
|
}, nil
|
|
576
742
|
}
|
|
577
743
|
|
|
744
|
+
func (s *MatchService) discoverMatchByTeamQuery(ctx context.Context, query, leagueID string) (*IndexedEntity, []string) {
|
|
745
|
+
left, right, ok := parseTeamVsQuery(query)
|
|
746
|
+
if !ok {
|
|
747
|
+
return nil, nil
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
preferredLeagueID := inferTeamQueryLeagueHint(left, right)
|
|
751
|
+
candidates, err := s.buildTeamQueryEventCandidates(ctx, leagueID, preferredLeagueID)
|
|
752
|
+
if err != nil {
|
|
753
|
+
return nil, []string{fmt.Sprintf("team-query fallback unavailable: %v", err)}
|
|
754
|
+
}
|
|
755
|
+
if len(candidates) == 0 {
|
|
756
|
+
return nil, []string{"team-query fallback found no event candidates"}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
for _, eventID := range candidates {
|
|
760
|
+
reqCtx, cancel := context.WithTimeout(ctx, teamQueryEventFetchTimeout)
|
|
761
|
+
resolved, resolveErr := s.client.ResolveRefChain(reqCtx, "/events/"+eventID)
|
|
762
|
+
cancel()
|
|
763
|
+
if resolveErr != nil {
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
payload, decodeErr := decodePayloadMap(resolved.Body)
|
|
768
|
+
if decodeErr != nil {
|
|
769
|
+
continue
|
|
770
|
+
}
|
|
771
|
+
if !eventMatchesTeamQuery(payload, left, right) {
|
|
772
|
+
continue
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
competitions := mapSliceField(payload, "competitions")
|
|
776
|
+
competitionID := ""
|
|
777
|
+
competitionRef := ""
|
|
778
|
+
if len(competitions) > 0 {
|
|
779
|
+
competitionID = stringField(competitions[0], "id")
|
|
780
|
+
competitionRef = stringField(competitions[0], "$ref")
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
competitionID = nonEmpty(competitionID, stringField(payload, "id"))
|
|
784
|
+
if competitionID == "" {
|
|
785
|
+
continue
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
refIDsMap := refIDs(nonEmpty(competitionRef, resolved.CanonicalRef, resolved.RequestedRef))
|
|
789
|
+
eventIDResolved := nonEmpty(stringField(payload, "id"), refIDsMap["eventId"])
|
|
790
|
+
leagueIDResolved := nonEmpty(refIDsMap["leagueId"], strings.TrimSpace(leagueID))
|
|
791
|
+
if competitionRef == "" && leagueIDResolved != "" && eventIDResolved != "" {
|
|
792
|
+
competitionRef = fmt.Sprintf("/leagues/%s/events/%s/competitions/%s", leagueIDResolved, eventIDResolved, competitionID)
|
|
793
|
+
}
|
|
794
|
+
if competitionRef == "" {
|
|
795
|
+
continue
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
entity := IndexedEntity{
|
|
799
|
+
Kind: EntityMatch,
|
|
800
|
+
ID: competitionID,
|
|
801
|
+
Ref: competitionRef,
|
|
802
|
+
Name: nonEmpty(stringField(payload, "name"), stringField(payload, "shortDescription"), stringField(payload, "description")),
|
|
803
|
+
ShortName: nonEmpty(stringField(payload, "shortName"), stringField(payload, "shortDescription")),
|
|
804
|
+
LeagueID: leagueIDResolved,
|
|
805
|
+
EventID: eventIDResolved,
|
|
806
|
+
MatchID: competitionID,
|
|
807
|
+
Aliases: []string{
|
|
808
|
+
stringField(payload, "name"),
|
|
809
|
+
stringField(payload, "shortName"),
|
|
810
|
+
stringField(payload, "shortDescription"),
|
|
811
|
+
stringField(payload, "description"),
|
|
812
|
+
left,
|
|
813
|
+
right,
|
|
814
|
+
competitionID,
|
|
815
|
+
eventIDResolved,
|
|
816
|
+
},
|
|
817
|
+
UpdatedAt: time.Now().UTC(),
|
|
818
|
+
}
|
|
819
|
+
if s.resolver != nil && s.resolver.index != nil {
|
|
820
|
+
_ = s.resolver.index.Upsert(entity)
|
|
821
|
+
}
|
|
822
|
+
return &entity, []string{fmt.Sprintf("team-query fallback matched event %s", eventIDResolved)}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return nil, []string{"team-query fallback scanned recent events with no match"}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
func (s *MatchService) buildTeamQueryEventCandidates(ctx context.Context, leagueID, preferredLeagueID string) ([]string, error) {
|
|
829
|
+
rootRef := "/events"
|
|
830
|
+
if strings.TrimSpace(leagueID) != "" {
|
|
831
|
+
rootRef = "/leagues/" + strings.TrimSpace(leagueID) + "/events"
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
seedRefs := make([]Ref, 0)
|
|
835
|
+
if refs, err := s.fetchEventRefs(ctx, rootRef); err == nil {
|
|
836
|
+
seedRefs = append(seedRefs, refs...)
|
|
837
|
+
}
|
|
838
|
+
if len(seedRefs) == 0 && rootRef != "/events" {
|
|
839
|
+
refs, err := s.fetchEventRefs(ctx, "/events")
|
|
840
|
+
if err != nil {
|
|
841
|
+
return nil, err
|
|
842
|
+
}
|
|
843
|
+
seedRefs = append(seedRefs, refs...)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
seen := map[string]struct{}{}
|
|
847
|
+
candidates := make([]string, 0, maxTeamQueryEventCandidates)
|
|
848
|
+
type eventSeed struct {
|
|
849
|
+
id int
|
|
850
|
+
leagueID string
|
|
851
|
+
}
|
|
852
|
+
seeds := make([]eventSeed, 0, len(seedRefs))
|
|
853
|
+
for _, item := range seedRefs {
|
|
854
|
+
ids := refIDs(item.URL)
|
|
855
|
+
eventID := strings.TrimSpace(ids["eventId"])
|
|
856
|
+
if eventID == "" {
|
|
857
|
+
continue
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
seed, err := strconv.Atoi(eventID)
|
|
861
|
+
if err != nil {
|
|
862
|
+
continue
|
|
863
|
+
}
|
|
864
|
+
seeds = append(seeds, eventSeed{id: seed, leagueID: strings.TrimSpace(ids["leagueId"])})
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
sort.Slice(seeds, func(i, j int) bool {
|
|
868
|
+
iPref := strings.TrimSpace(preferredLeagueID) != "" && seeds[i].leagueID == strings.TrimSpace(preferredLeagueID)
|
|
869
|
+
jPref := strings.TrimSpace(preferredLeagueID) != "" && seeds[j].leagueID == strings.TrimSpace(preferredLeagueID)
|
|
870
|
+
if iPref != jPref {
|
|
871
|
+
return iPref
|
|
872
|
+
}
|
|
873
|
+
return seeds[i].id > seeds[j].id
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
for delta := 0; delta <= matchTeamQueryScanRange; delta++ {
|
|
877
|
+
for _, seed := range seeds {
|
|
878
|
+
down := strconv.Itoa(seed.id - delta)
|
|
879
|
+
if _, ok := seen[down]; !ok {
|
|
880
|
+
seen[down] = struct{}{}
|
|
881
|
+
candidates = append(candidates, down)
|
|
882
|
+
if len(candidates) >= maxTeamQueryEventCandidates {
|
|
883
|
+
return candidates, nil
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if delta == 0 {
|
|
888
|
+
continue
|
|
889
|
+
}
|
|
890
|
+
up := strconv.Itoa(seed.id + delta)
|
|
891
|
+
if _, ok := seen[up]; !ok {
|
|
892
|
+
seen[up] = struct{}{}
|
|
893
|
+
candidates = append(candidates, up)
|
|
894
|
+
if len(candidates) >= maxTeamQueryEventCandidates {
|
|
895
|
+
return candidates, nil
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
return candidates, nil
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
func (s *MatchService) fetchEventRefs(ctx context.Context, ref string) ([]Ref, error) {
|
|
905
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
906
|
+
if err != nil {
|
|
907
|
+
return nil, err
|
|
908
|
+
}
|
|
909
|
+
page, err := DecodePage[Ref](resolved.Body)
|
|
910
|
+
if err != nil {
|
|
911
|
+
return nil, err
|
|
912
|
+
}
|
|
913
|
+
return page.Items, nil
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
func parseTeamVsQuery(query string) (string, string, bool) {
|
|
917
|
+
normalized := normalizeAlias(query)
|
|
918
|
+
if normalized == "" {
|
|
919
|
+
return "", "", false
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
separators := []string{" versus ", " vs ", " v "}
|
|
923
|
+
for _, sep := range separators {
|
|
924
|
+
parts := strings.SplitN(normalized, sep, 2)
|
|
925
|
+
if len(parts) != 2 {
|
|
926
|
+
continue
|
|
927
|
+
}
|
|
928
|
+
left := strings.TrimSpace(parts[0])
|
|
929
|
+
right := strings.TrimSpace(parts[1])
|
|
930
|
+
if left == "" || right == "" {
|
|
931
|
+
return "", "", false
|
|
932
|
+
}
|
|
933
|
+
return left, right, true
|
|
934
|
+
}
|
|
935
|
+
return "", "", false
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
func eventMatchesTeamQuery(payload map[string]any, left, right string) bool {
|
|
939
|
+
parts := []string{
|
|
940
|
+
stringField(payload, "name"),
|
|
941
|
+
stringField(payload, "shortName"),
|
|
942
|
+
stringField(payload, "shortDescription"),
|
|
943
|
+
stringField(payload, "description"),
|
|
944
|
+
}
|
|
945
|
+
for _, competition := range mapSliceField(payload, "competitions") {
|
|
946
|
+
parts = append(parts,
|
|
947
|
+
stringField(competition, "name"),
|
|
948
|
+
stringField(competition, "shortName"),
|
|
949
|
+
stringField(competition, "shortDescription"),
|
|
950
|
+
stringField(competition, "description"),
|
|
951
|
+
stringField(competition, "note"),
|
|
952
|
+
)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
haystack := normalizeAlias(strings.Join(parts, " "))
|
|
956
|
+
return teamQuerySideMatches(haystack, left) && teamQuerySideMatches(haystack, right)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
func teamQuerySideMatches(haystack, side string) bool {
|
|
960
|
+
if haystack == "" {
|
|
961
|
+
return false
|
|
962
|
+
}
|
|
963
|
+
for _, variant := range teamQueryVariants(side) {
|
|
964
|
+
if variant != "" && strings.Contains(haystack, variant) {
|
|
965
|
+
return true
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return false
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
func teamQueryVariants(side string) []string {
|
|
972
|
+
base := normalizeAlias(side)
|
|
973
|
+
if base == "" {
|
|
974
|
+
return nil
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
seen := map[string]struct{}{base: {}}
|
|
978
|
+
variants := []string{base}
|
|
979
|
+
|
|
980
|
+
add := func(value string) {
|
|
981
|
+
value = normalizeAlias(value)
|
|
982
|
+
if value == "" {
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
if _, ok := seen[value]; ok {
|
|
986
|
+
return
|
|
987
|
+
}
|
|
988
|
+
seen[value] = struct{}{}
|
|
989
|
+
variants = append(variants, value)
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if strings.Contains(base, "bangalore") {
|
|
993
|
+
add(strings.ReplaceAll(base, "bangalore", "bengaluru"))
|
|
994
|
+
}
|
|
995
|
+
if strings.Contains(base, "bengaluru") {
|
|
996
|
+
add(strings.ReplaceAll(base, "bengaluru", "bangalore"))
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
for _, alias := range knownIPLTeamAliases[base] {
|
|
1000
|
+
add(alias)
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return variants
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
func inferTeamQueryLeagueHint(left, right string) string {
|
|
1007
|
+
left = normalizeAlias(left)
|
|
1008
|
+
right = normalizeAlias(right)
|
|
1009
|
+
if left == "" || right == "" {
|
|
1010
|
+
return ""
|
|
1011
|
+
}
|
|
1012
|
+
_, leftKnown := knownIPLTeamAliases[left]
|
|
1013
|
+
_, rightKnown := knownIPLTeamAliases[right]
|
|
1014
|
+
if leftKnown && rightKnown {
|
|
1015
|
+
return "8048"
|
|
1016
|
+
}
|
|
1017
|
+
return ""
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
var knownIPLTeamAliases = map[string][]string{
|
|
1021
|
+
"csk": {"chennai super kings", "chennai"},
|
|
1022
|
+
"chennai super kings": {"csk", "chennai"},
|
|
1023
|
+
"chennai": {"csk", "chennai super kings"},
|
|
1024
|
+
"dc": {"delhi capitals", "delhi"},
|
|
1025
|
+
"delhi capitals": {"dc", "delhi"},
|
|
1026
|
+
"delhi": {"dc", "delhi capitals"},
|
|
1027
|
+
"gt": {"gujarat titans", "gujarat"},
|
|
1028
|
+
"gujarat titans": {"gt", "gujarat"},
|
|
1029
|
+
"gujarat": {"gt", "gujarat titans"},
|
|
1030
|
+
"kkr": {"kolkata knight riders", "kolkata"},
|
|
1031
|
+
"kolkata knight riders": {"kkr", "kolkata"},
|
|
1032
|
+
"kolkata": {"kkr", "kolkata knight riders"},
|
|
1033
|
+
"lsg": {"lucknow super giants", "lucknow"},
|
|
1034
|
+
"lucknow super giants": {"lsg", "lucknow"},
|
|
1035
|
+
"lucknow": {"lsg", "lucknow super giants"},
|
|
1036
|
+
"mi": {"mumbai indians", "mumbai"},
|
|
1037
|
+
"mumbai indians": {"mi", "mumbai"},
|
|
1038
|
+
"mumbai": {"mi", "mumbai indians"},
|
|
1039
|
+
"pbks": {"punjab kings", "punjab"},
|
|
1040
|
+
"punjab kings": {"pbks", "punjab", "kxip"},
|
|
1041
|
+
"punjab": {"pbks", "punjab kings", "kxip"},
|
|
1042
|
+
"kxip": {"pbks", "punjab kings", "punjab"},
|
|
1043
|
+
"rcb": {"royal challengers bengaluru", "royal challengers bangalore", "bangalore", "bengaluru"},
|
|
1044
|
+
"royal challengers bengaluru": {"rcb", "royal challengers bangalore", "bangalore", "bengaluru"},
|
|
1045
|
+
"royal challengers bangalore": {"rcb", "royal challengers bengaluru", "bangalore", "bengaluru"},
|
|
1046
|
+
"bangalore": {"rcb", "royal challengers bengaluru", "royal challengers bangalore", "bengaluru"},
|
|
1047
|
+
"bengaluru": {"rcb", "royal challengers bengaluru", "royal challengers bangalore", "bangalore"},
|
|
1048
|
+
"rr": {"rajasthan royals", "rajasthan"},
|
|
1049
|
+
"rajasthan royals": {"rr", "rajasthan"},
|
|
1050
|
+
"rajasthan": {"rr", "rajasthan royals"},
|
|
1051
|
+
"srh": {"sunrisers hyderabad", "hyderabad"},
|
|
1052
|
+
"sunrisers hyderabad": {"srh", "hyderabad"},
|
|
1053
|
+
"hyderabad": {"srh", "sunrisers hyderabad"},
|
|
1054
|
+
}
|
|
1055
|
+
|
|
578
1056
|
func (s *MatchService) deliveryEventsFromRoute(ctx context.Context, ref string, baseWarnings []string) (NormalizedResult, error) {
|
|
579
1057
|
resolved, err := s.resolveRefChainResilient(ctx, ref)
|
|
580
1058
|
if err != nil {
|
|
@@ -628,7 +1106,9 @@ func (s *MatchService) loadDeliveryEvents(ctx context.Context, refs []Ref) ([]De
|
|
|
628
1106
|
return
|
|
629
1107
|
}
|
|
630
1108
|
|
|
631
|
-
|
|
1109
|
+
itemCtx, cancel := context.WithTimeout(ctx, detailItemFetchTimeout)
|
|
1110
|
+
itemResolved, itemErr := s.resolveRefChainResilient(itemCtx, itemRef)
|
|
1111
|
+
cancel()
|
|
632
1112
|
if itemErr != nil {
|
|
633
1113
|
results[index] = deliveryLoadResult{index: index, warning: fmt.Sprintf("detail %s: %v", itemRef, itemErr)}
|
|
634
1114
|
return
|
|
@@ -962,26 +1442,55 @@ func (s *MatchService) fetchDetailedRefCollection(
|
|
|
962
1442
|
return nil, nil, nil, err
|
|
963
1443
|
}
|
|
964
1444
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
continue
|
|
971
|
-
}
|
|
1445
|
+
type normalizedItemResult struct {
|
|
1446
|
+
index int
|
|
1447
|
+
item any
|
|
1448
|
+
warning string
|
|
1449
|
+
}
|
|
972
1450
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1451
|
+
results := make([]normalizedItemResult, len(pageItems))
|
|
1452
|
+
sem := make(chan struct{}, detailSubresourceFetchConcurrency)
|
|
1453
|
+
var wg sync.WaitGroup
|
|
1454
|
+
for i, item := range pageItems {
|
|
1455
|
+
wg.Add(1)
|
|
1456
|
+
go func(index int, item Ref) {
|
|
1457
|
+
defer wg.Done()
|
|
1458
|
+
sem <- struct{}{}
|
|
1459
|
+
defer func() { <-sem }()
|
|
1460
|
+
|
|
1461
|
+
itemRef := strings.TrimSpace(item.URL)
|
|
1462
|
+
if itemRef == "" {
|
|
1463
|
+
results[index] = normalizedItemResult{index: index, warning: "skip item with empty ref"}
|
|
1464
|
+
return
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
itemCtx, cancel := context.WithTimeout(ctx, detailItemFetchTimeout)
|
|
1468
|
+
itemResolved, itemErr := s.resolveRefChainResilient(itemCtx, itemRef)
|
|
1469
|
+
cancel()
|
|
1470
|
+
if itemErr != nil {
|
|
1471
|
+
results[index] = normalizedItemResult{index: index, warning: fmt.Sprintf("item %s: %v", itemRef, itemErr)}
|
|
1472
|
+
return
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
normalized, normalizeErr := normalize(itemResolved.Body)
|
|
1476
|
+
if normalizeErr != nil {
|
|
1477
|
+
results[index] = normalizedItemResult{index: index, warning: fmt.Sprintf("item %s: %v", itemRef, normalizeErr)}
|
|
1478
|
+
return
|
|
1479
|
+
}
|
|
1480
|
+
results[index] = normalizedItemResult{index: index, item: normalized}
|
|
1481
|
+
}(i, item)
|
|
1482
|
+
}
|
|
1483
|
+
wg.Wait()
|
|
978
1484
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1485
|
+
items := make([]any, 0, len(results))
|
|
1486
|
+
for _, result := range results {
|
|
1487
|
+
if strings.TrimSpace(result.warning) != "" {
|
|
1488
|
+
warnings = append(warnings, result.warning)
|
|
982
1489
|
continue
|
|
983
1490
|
}
|
|
984
|
-
|
|
1491
|
+
if result.item != nil {
|
|
1492
|
+
items = append(items, result.item)
|
|
1493
|
+
}
|
|
985
1494
|
}
|
|
986
1495
|
|
|
987
1496
|
return resolved, items, compactWarnings(warnings), nil
|
|
@@ -1001,25 +1510,55 @@ func (s *MatchService) resolvePageRefs(ctx context.Context, first *ResolvedDocum
|
|
|
1001
1510
|
return items, nil, nil
|
|
1002
1511
|
}
|
|
1003
1512
|
|
|
1004
|
-
|
|
1513
|
+
type pageLoadResult struct {
|
|
1514
|
+
page int
|
|
1515
|
+
items []Ref
|
|
1516
|
+
warning string
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
results := make([]pageLoadResult, page.PageCount+1)
|
|
1520
|
+
sem := make(chan struct{}, detailSubresourceFetchConcurrency)
|
|
1521
|
+
var wg sync.WaitGroup
|
|
1005
1522
|
baseRef := firstNonEmptyString(first.CanonicalRef, first.RequestedRef)
|
|
1006
1523
|
for pageIndex := 2; pageIndex <= page.PageCount; pageIndex++ {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1524
|
+
wg.Add(1)
|
|
1525
|
+
go func(index int) {
|
|
1526
|
+
defer wg.Done()
|
|
1527
|
+
sem <- struct{}{}
|
|
1528
|
+
defer func() { <-sem }()
|
|
1529
|
+
|
|
1530
|
+
pageRef := pagedRef(baseRef, index)
|
|
1531
|
+
if pageRef == "" {
|
|
1532
|
+
results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d unavailable for %s", index, baseRef)}
|
|
1533
|
+
return
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
pageDoc, pageErr := s.resolveRefChainResilient(ctx, pageRef)
|
|
1537
|
+
if pageErr != nil {
|
|
1538
|
+
results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d %s: %v", index, pageRef, pageErr)}
|
|
1539
|
+
return
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
nextPage, decodeErr := DecodePage[Ref](pageDoc.Body)
|
|
1543
|
+
if decodeErr != nil {
|
|
1544
|
+
results[index] = pageLoadResult{page: index, warning: fmt.Sprintf("page %d %s: %v", index, pageDoc.CanonicalRef, decodeErr)}
|
|
1545
|
+
return
|
|
1546
|
+
}
|
|
1547
|
+
results[index] = pageLoadResult{page: index, items: nextPage.Items}
|
|
1548
|
+
}(pageIndex)
|
|
1549
|
+
}
|
|
1550
|
+
wg.Wait()
|
|
1551
|
+
|
|
1552
|
+
warnings := make([]string, 0)
|
|
1553
|
+
for pageIndex := 2; pageIndex <= page.PageCount; pageIndex++ {
|
|
1554
|
+
result := results[pageIndex]
|
|
1555
|
+
if strings.TrimSpace(result.warning) != "" {
|
|
1556
|
+
warnings = append(warnings, result.warning)
|
|
1015
1557
|
continue
|
|
1016
1558
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
warnings = append(warnings, fmt.Sprintf("page %d %s: %v", pageIndex, pageDoc.CanonicalRef, decodeErr))
|
|
1020
|
-
continue
|
|
1559
|
+
if len(result.items) > 0 {
|
|
1560
|
+
items = append(items, result.items...)
|
|
1021
1561
|
}
|
|
1022
|
-
items = append(items, nextPage.Items...)
|
|
1023
1562
|
}
|
|
1024
1563
|
|
|
1025
1564
|
return items, compactWarnings(warnings), nil
|