cricinfo-cli-go 0.1.0

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.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. package/scripts/postinstall.js +153 -0
@@ -0,0 +1,1727 @@
1
+ package cricinfo
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "strconv"
7
+ "strings"
8
+ )
9
+
10
+ // NormalizeMatch maps a competition payload into the normalized match shape.
11
+ func NormalizeMatch(data []byte) (*Match, error) {
12
+ payload, err := decodePayloadMap(data)
13
+ if err != nil {
14
+ return nil, err
15
+ }
16
+
17
+ match := normalizeMatchFromCompetitionMap(payload, eventMatchContext{})
18
+ return &match, nil
19
+ }
20
+
21
+ // NormalizeMatchesFromEvent maps an event payload into one or more normalized match entries.
22
+ func NormalizeMatchesFromEvent(data []byte) ([]Match, error) {
23
+ payload, err := decodePayloadMap(data)
24
+ if err != nil {
25
+ return nil, err
26
+ }
27
+
28
+ context := buildEventMatchContext(payload)
29
+ competitions := mapSliceField(payload, "competitions")
30
+ if len(competitions) == 0 {
31
+ return []Match{}, nil
32
+ }
33
+
34
+ matches := make([]Match, 0, len(competitions))
35
+ for _, competition := range competitions {
36
+ matches = append(matches, normalizeMatchFromCompetitionMap(competition, context))
37
+ }
38
+
39
+ return matches, nil
40
+ }
41
+
42
+ // NormalizePlayer maps an athlete profile payload into the normalized player shape.
43
+ func NormalizePlayer(data []byte) (*Player, error) {
44
+ payload, err := decodePayloadMap(data)
45
+ if err != nil {
46
+ return nil, err
47
+ }
48
+
49
+ ref := stringField(payload, "$ref")
50
+ ids := refIDs(ref)
51
+ position := mapField(payload, "position")
52
+ styles := normalizePlayerStyles(payload)
53
+ majorTeams := normalizePlayerAffiliations(mapSliceField(payload, "majorTeams"))
54
+ debuts := normalizePlayerDebuts(mapSliceField(payload, "debuts"))
55
+ team := normalizePlayerAffiliation(mapField(payload, "team"))
56
+
57
+ player := &Player{
58
+ Ref: ref,
59
+ ID: nonEmpty(stringField(payload, "id"), ids["athleteId"]),
60
+ UID: stringField(payload, "uid"),
61
+ GUID: stringField(payload, "guid"),
62
+ Type: stringField(payload, "type"),
63
+ Name: stringField(payload, "name"),
64
+ FirstName: stringField(payload, "firstName"),
65
+ MiddleName: stringField(payload, "middleName"),
66
+ LastName: stringField(payload, "lastName"),
67
+ DisplayName: stringField(payload, "displayName"),
68
+ FullName: stringField(payload, "fullName"),
69
+ ShortName: stringField(payload, "shortName"),
70
+ BattingName: stringField(payload, "battingName"),
71
+ FieldingName: stringField(payload, "fieldingName"),
72
+ Gender: stringField(payload, "gender"),
73
+ Age: intField(payload, "age"),
74
+ DateOfBirth: stringField(payload, "dateOfBirth"),
75
+ DateOfBirthDisplay: nonEmpty(stringField(payload, "dateOfBirthStr"), stringField(payload, "dateOfBirth")),
76
+ Active: truthyField(payload, "active") || truthyField(payload, "isActive"),
77
+ Position: stringField(position, "name"),
78
+ PositionRef: stringField(position, "$ref"),
79
+ PositionAbbreviation: stringField(position, "abbreviation"),
80
+ Styles: styles,
81
+ Team: team,
82
+ MajorTeams: majorTeams,
83
+ Debuts: debuts,
84
+ NewsRef: refFromField(payload, "news"),
85
+ Extensions: extensionsFromMap(payload,
86
+ "$ref", "id", "uid", "guid", "type", "name", "firstName", "middleName", "lastName",
87
+ "displayName", "fullName", "shortName", "battingName", "fieldingName", "gender", "age",
88
+ "dateOfBirth", "dateOfBirthStr", "active", "isActive", "team", "position", "style", "styles",
89
+ "majorTeams", "debuts", "news",
90
+ ),
91
+ }
92
+
93
+ return player, nil
94
+ }
95
+
96
+ // NormalizePlayerStatistics maps an athlete statistics payload into a grouped split/category view.
97
+ func NormalizePlayerStatistics(data []byte) (*PlayerStatistics, error) {
98
+ payload, err := decodePayloadMap(data)
99
+ if err != nil {
100
+ return nil, err
101
+ }
102
+
103
+ ref := stringField(payload, "$ref")
104
+ ids := refIDs(ref)
105
+ athleteRef := refFromField(payload, "athlete")
106
+ splits := mapField(payload, "splits")
107
+
108
+ playerStats := &PlayerStatistics{
109
+ Ref: ref,
110
+ PlayerID: nonEmpty(refIDs(athleteRef)["athleteId"], ids["athleteId"]),
111
+ PlayerRef: athleteRef,
112
+ SplitID: stringField(splits, "id"),
113
+ Name: stringField(splits, "name"),
114
+ Abbreviation: stringField(splits, "abbreviation"),
115
+ Categories: []StatCategory{},
116
+ Extensions: extensionsFromMap(payload,
117
+ "$ref", "athlete", "competition", "team", "splits",
118
+ ),
119
+ }
120
+
121
+ for _, item := range mapSliceField(splits, "categories") {
122
+ stats := make([]StatValue, 0, len(mapSliceField(item, "stats")))
123
+ for _, statRaw := range mapSliceField(item, "stats") {
124
+ stats = append(stats, StatValue{
125
+ Name: stringField(statRaw, "name"),
126
+ DisplayName: stringField(statRaw, "displayName"),
127
+ ShortName: stringField(statRaw, "shortDisplayName"),
128
+ Description: stringField(statRaw, "description"),
129
+ Abbreviation: stringField(statRaw, "abbreviation"),
130
+ DisplayValue: stringField(statRaw, "displayValue"),
131
+ Value: statRaw["value"],
132
+ Type: stringField(statRaw, "type"),
133
+ Extensions: extensionsFromMap(statRaw,
134
+ "name", "displayName", "shortDisplayName", "description", "abbreviation", "displayValue", "value", "type",
135
+ ),
136
+ })
137
+ }
138
+
139
+ playerStats.Categories = append(playerStats.Categories, StatCategory{
140
+ Name: stringField(item, "name"),
141
+ DisplayName: nonEmpty(stringField(item, "displayName"), stringField(item, "name")),
142
+ ShortName: stringField(item, "shortDisplayName"),
143
+ Abbreviation: stringField(item, "abbreviation"),
144
+ Summary: stringField(item, "summary"),
145
+ Stats: stats,
146
+ Extensions: extensionsFromMap(item,
147
+ "name", "displayName", "shortDisplayName", "abbreviation", "summary", "stats",
148
+ ),
149
+ })
150
+ }
151
+
152
+ return playerStats, nil
153
+ }
154
+
155
+ // NormalizeNewsArticle maps one Cricinfo news payload into a normalized article object.
156
+ func NormalizeNewsArticle(data []byte) (*NewsArticle, error) {
157
+ payload, err := decodePayloadMap(data)
158
+ if err != nil {
159
+ return nil, err
160
+ }
161
+
162
+ links := mapField(payload, "links")
163
+ web := mapField(links, "web")
164
+ api := mapField(mapField(links, "api"), "v1")
165
+
166
+ article := &NewsArticle{
167
+ Ref: stringField(payload, "$ref"),
168
+ ID: nonEmpty(stringField(payload, "id"), refIDs(stringField(payload, "$ref"))["newsId"]),
169
+ UID: stringField(payload, "uid"),
170
+ Type: stringField(payload, "type"),
171
+ Headline: stringField(payload, "headline"),
172
+ Title: stringField(payload, "title"),
173
+ LinkText: stringField(payload, "linkText"),
174
+ Byline: stringField(payload, "byline"),
175
+ Description: stringField(payload, "description"),
176
+ Published: stringField(payload, "published"),
177
+ LastModified: stringField(payload, "lastModified"),
178
+ WebURL: stringField(web, "href"),
179
+ APIURL: stringField(api, "href"),
180
+ Extensions: extensionsFromMap(payload,
181
+ "$ref", "id", "uid", "type", "headline", "title", "linkText", "byline", "description",
182
+ "published", "lastModified", "links",
183
+ ),
184
+ }
185
+
186
+ return article, nil
187
+ }
188
+
189
+ // NormalizeTeam maps a competitor/team payload into the normalized team shape.
190
+ func NormalizeTeam(data []byte) (*Team, error) {
191
+ payload, err := decodePayloadMap(data)
192
+ if err != nil {
193
+ return nil, err
194
+ }
195
+
196
+ team := normalizeTeamMap(payload)
197
+ return &team, nil
198
+ }
199
+
200
+ // NormalizeTeamRosterEntries maps match-scoped competitor roster payloads into roster entries.
201
+ func NormalizeTeamRosterEntries(data []byte, team Team, scope TeamScope, matchID string) ([]TeamRosterEntry, error) {
202
+ entries, err := DecodeObjectCollection[map[string]any](data, "entries")
203
+ if err != nil {
204
+ return nil, err
205
+ }
206
+
207
+ normalized := make([]TeamRosterEntry, 0, len(entries))
208
+ for _, entry := range entries {
209
+ athlete := mapField(entry, "athlete")
210
+ athleteRef := refFromField(entry, "athlete")
211
+ playerID := nonEmpty(stringField(entry, "playerId"), refIDs(athleteRef)["athleteId"], refIDs(stringField(entry, "$ref"))["athleteId"])
212
+ displayName := nonEmpty(
213
+ stringField(athlete, "displayName"),
214
+ stringField(athlete, "fullName"),
215
+ stringField(athlete, "name"),
216
+ stringField(entry, "displayName"),
217
+ stringField(entry, "fullName"),
218
+ stringField(entry, "name"),
219
+ )
220
+
221
+ normalized = append(normalized, TeamRosterEntry{
222
+ PlayerID: playerID,
223
+ PlayerRef: athleteRef,
224
+ DisplayName: displayName,
225
+ TeamID: team.ID,
226
+ TeamRef: team.Ref,
227
+ MatchID: strings.TrimSpace(matchID),
228
+ Scope: scope,
229
+ Captain: boolField(entry, "captain"),
230
+ Starter: boolField(entry, "starter"),
231
+ Active: boolField(entry, "active"),
232
+ ActiveName: stringField(entry, "activeName"),
233
+ PositionRef: refFromField(entry, "position"),
234
+ LinescoresRef: refFromField(entry, "linescores"),
235
+ StatisticsRef: refFromField(entry, "statistics"),
236
+ Extensions: extensionsFromMap(entry,
237
+ "$ref", "playerId", "athlete", "captain", "starter", "active", "activeName", "position", "linescores", "statistics",
238
+ ),
239
+ })
240
+ }
241
+
242
+ return normalized, nil
243
+ }
244
+
245
+ // NormalizeTeamAthletePage maps a global team athletes page into roster-like entries for player-command bridging.
246
+ func NormalizeTeamAthletePage(data []byte, team Team) ([]TeamRosterEntry, error) {
247
+ page, err := DecodePage[Ref](data)
248
+ if err != nil {
249
+ return nil, err
250
+ }
251
+
252
+ entries := make([]TeamRosterEntry, 0, len(page.Items))
253
+ for _, item := range page.Items {
254
+ playerRef := strings.TrimSpace(item.URL)
255
+ if playerRef == "" {
256
+ continue
257
+ }
258
+
259
+ entries = append(entries, TeamRosterEntry{
260
+ PlayerID: refIDs(playerRef)["athleteId"],
261
+ PlayerRef: playerRef,
262
+ TeamID: team.ID,
263
+ TeamRef: team.Ref,
264
+ Scope: TeamScopeGlobal,
265
+ })
266
+ }
267
+
268
+ return entries, nil
269
+ }
270
+
271
+ // NormalizeTeamScore maps a score payload into a stable team score object.
272
+ func NormalizeTeamScore(data []byte, team Team, scope TeamScope, matchID string) (*TeamScore, error) {
273
+ payload, err := decodePayloadMap(data)
274
+ if err != nil {
275
+ return nil, err
276
+ }
277
+
278
+ score := &TeamScore{
279
+ Ref: nonEmpty(stringField(payload, "$ref"), team.ScoreRef),
280
+ TeamID: team.ID,
281
+ MatchID: strings.TrimSpace(matchID),
282
+ Scope: scope,
283
+ DisplayValue: stringField(payload, "displayValue"),
284
+ Value: stringField(payload, "value"),
285
+ Place: stringField(payload, "place"),
286
+ Source: stringField(payload, "source"),
287
+ Winner: boolField(payload, "winner"),
288
+ Extensions: extensionsFromMap(payload,
289
+ "$ref", "displayValue", "value", "place", "source", "winner",
290
+ ),
291
+ }
292
+
293
+ return score, nil
294
+ }
295
+
296
+ // NormalizeTeamLeaders maps category-based team leaders payloads into batting/bowling-friendly structures.
297
+ func NormalizeTeamLeaders(data []byte, team Team, scope TeamScope, matchID string) (*TeamLeaders, error) {
298
+ payload, err := decodePayloadMap(data)
299
+ if err != nil {
300
+ return nil, err
301
+ }
302
+
303
+ leaders := &TeamLeaders{
304
+ Ref: stringField(payload, "$ref"),
305
+ TeamID: team.ID,
306
+ TeamName: nonEmpty(team.ShortName, team.Name),
307
+ MatchID: strings.TrimSpace(matchID),
308
+ Scope: scope,
309
+ Name: nonEmpty(stringField(payload, "displayName"), stringField(payload, "name"), nonEmpty(team.ShortName, team.Name), "Leaders"),
310
+ Categories: []TeamLeaderCategory{},
311
+ Extensions: extensionsFromMap(payload, "$ref", "id", "name", "displayName", "abbreviation", "categories"),
312
+ }
313
+
314
+ for _, rawCategory := range mapSliceField(payload, "categories") {
315
+ category := TeamLeaderCategory{
316
+ Name: stringField(rawCategory, "name"),
317
+ DisplayName: nonEmpty(stringField(rawCategory, "displayName"), stringField(rawCategory, "name")),
318
+ ShortName: stringField(rawCategory, "shortDisplayName"),
319
+ Abbreviation: stringField(rawCategory, "abbreviation"),
320
+ Leaders: []TeamLeaderEntry{},
321
+ Extensions: extensionsFromMap(rawCategory,
322
+ "name", "displayName", "shortDisplayName", "abbreviation", "leaders",
323
+ ),
324
+ }
325
+
326
+ for _, rawLeader := range mapSliceField(rawCategory, "leaders") {
327
+ athlete := mapField(rawLeader, "athlete")
328
+ athleteRef := refFromField(rawLeader, "athlete")
329
+ athleteID := nonEmpty(
330
+ stringField(rawLeader, "athleteId"),
331
+ stringField(athlete, "id"),
332
+ refIDs(athleteRef)["athleteId"],
333
+ )
334
+ athleteName := nonEmpty(
335
+ stringField(athlete, "displayName"),
336
+ stringField(athlete, "fullName"),
337
+ stringField(athlete, "name"),
338
+ stringField(rawLeader, "athleteDisplayName"),
339
+ stringField(rawLeader, "name"),
340
+ )
341
+ entry := TeamLeaderEntry{
342
+ Order: intField(rawLeader, "order"),
343
+ DisplayValue: stringField(rawLeader, "displayValue"),
344
+ Value: stringField(rawLeader, "value"),
345
+ AthleteID: athleteID,
346
+ AthleteName: athleteName,
347
+ AthleteRef: athleteRef,
348
+ TeamRef: refFromField(rawLeader, "team"),
349
+ StatisticsRef: refFromField(rawLeader, "statistics"),
350
+ Runs: stringField(rawLeader, "runs"),
351
+ Wickets: stringField(rawLeader, "wickets"),
352
+ Overs: stringField(rawLeader, "overs"),
353
+ Maidens: stringField(rawLeader, "maidens"),
354
+ EconomyRate: stringField(rawLeader, "economyRate"),
355
+ Balls: stringField(rawLeader, "balls"),
356
+ Fours: stringField(rawLeader, "fours"),
357
+ Sixes: stringField(rawLeader, "sixes"),
358
+ Extensions: extensionsFromMap(rawLeader,
359
+ "order", "displayValue", "value", "athlete", "team", "statistics", "runs", "wickets", "overs", "maidens", "economyRate", "balls", "fours", "sixes",
360
+ ),
361
+ }
362
+ category.Leaders = append(category.Leaders, entry)
363
+ }
364
+
365
+ leaders.Categories = append(leaders.Categories, category)
366
+ }
367
+
368
+ return leaders, nil
369
+ }
370
+
371
+ // NormalizeTeamRecordCategories maps team records pages into stat-category-like entries.
372
+ func NormalizeTeamRecordCategories(data []byte) ([]StatCategory, error) {
373
+ payload, err := decodePayloadMap(data)
374
+ if err != nil {
375
+ return nil, err
376
+ }
377
+
378
+ items := mapSliceField(payload, "items")
379
+ if len(items) == 0 {
380
+ return []StatCategory{}, nil
381
+ }
382
+
383
+ categories := make([]StatCategory, 0, len(items))
384
+ for _, item := range items {
385
+ stats := make([]StatValue, 0)
386
+ for _, statRaw := range mapSliceField(item, "stats") {
387
+ stats = append(stats, StatValue{
388
+ Name: stringField(statRaw, "name"),
389
+ DisplayName: stringField(statRaw, "displayName"),
390
+ ShortName: stringField(statRaw, "shortDisplayName"),
391
+ Description: stringField(statRaw, "description"),
392
+ Abbreviation: stringField(statRaw, "abbreviation"),
393
+ DisplayValue: stringField(statRaw, "displayValue"),
394
+ Value: statRaw["value"],
395
+ Type: stringField(statRaw, "type"),
396
+ Extensions: extensionsFromMap(statRaw,
397
+ "name", "displayName", "shortDisplayName", "description", "abbreviation", "displayValue", "value", "type",
398
+ ),
399
+ })
400
+ }
401
+
402
+ categories = append(categories, StatCategory{
403
+ Name: stringField(item, "name"),
404
+ DisplayName: nonEmpty(stringField(item, "displayName"), stringField(item, "name")),
405
+ ShortName: stringField(item, "shortDisplayName"),
406
+ Abbreviation: stringField(item, "abbreviation"),
407
+ Summary: stringField(item, "summary"),
408
+ Stats: stats,
409
+ Extensions: extensionsFromMap(item,
410
+ "$ref", "id", "name", "displayName", "shortDisplayName", "abbreviation", "summary", "stats",
411
+ ),
412
+ })
413
+ }
414
+
415
+ return categories, nil
416
+ }
417
+
418
+ // NormalizeLeague maps a league/root payload into the normalized league shape.
419
+ func NormalizeLeague(data []byte) (*League, error) {
420
+ payload, err := decodePayloadMap(data)
421
+ if err != nil {
422
+ return nil, err
423
+ }
424
+
425
+ ref := stringField(payload, "$ref")
426
+ ids := refIDs(ref)
427
+
428
+ league := &League{
429
+ Ref: ref,
430
+ ID: nonEmpty(stringField(payload, "id"), ids["leagueId"]),
431
+ UID: stringField(payload, "uid"),
432
+ Name: stringField(payload, "name"),
433
+ Slug: stringField(payload, "slug"),
434
+ SeasonRef: refFromField(payload, "season"),
435
+ Extensions: extensionsFromMap(payload,
436
+ "$ref", "id", "uid", "name", "slug", "season",
437
+ ),
438
+ }
439
+
440
+ return league, nil
441
+ }
442
+
443
+ // NormalizeSeasonList maps a seasons page payload into normalized season entries.
444
+ func NormalizeSeasonList(data []byte) ([]Season, error) {
445
+ payload, err := decodePayloadMap(data)
446
+ if err != nil {
447
+ return nil, err
448
+ }
449
+
450
+ items := mapSliceField(payload, "items")
451
+ seasons := make([]Season, 0, len(items))
452
+ for _, item := range items {
453
+ ref := stringField(item, "$ref")
454
+ ids := refIDs(ref)
455
+ season := Season{
456
+ Ref: ref,
457
+ ID: ids["seasonId"],
458
+ LeagueID: ids["leagueId"],
459
+ Year: parseYear(ids["seasonId"]),
460
+ Extensions: extensionsFromMap(item,
461
+ "$ref",
462
+ ),
463
+ }
464
+ seasons = append(seasons, season)
465
+ }
466
+
467
+ return seasons, nil
468
+ }
469
+
470
+ // NormalizeStandingsGroups maps standings payloads into normalized group entries.
471
+ func NormalizeStandingsGroups(data []byte) ([]StandingsGroup, error) {
472
+ payload, err := decodePayloadMap(data)
473
+ if err != nil {
474
+ return nil, err
475
+ }
476
+
477
+ items := mapSliceField(payload, "items")
478
+ groups := make([]StandingsGroup, 0, len(items))
479
+ for _, item := range items {
480
+ ref := stringField(item, "$ref")
481
+ ids := refIDs(ref)
482
+ group := StandingsGroup{
483
+ Ref: ref,
484
+ ID: ids["standingsId"],
485
+ LeagueID: ids["leagueId"],
486
+ SeasonID: ids["seasonId"],
487
+ GroupID: ids["groupId"],
488
+ Entries: normalizeStandingsEntries(item),
489
+ Extensions: extensionsFromMap(item,
490
+ "$ref", "entries",
491
+ ),
492
+ }
493
+ groups = append(groups, group)
494
+ }
495
+
496
+ return groups, nil
497
+ }
498
+
499
+ // NormalizeInnings maps an innings payload into the normalized innings shape.
500
+ func NormalizeInnings(data []byte) (*Innings, error) {
501
+ payload, err := decodePayloadMap(data)
502
+ if err != nil {
503
+ return nil, err
504
+ }
505
+ return normalizeInningsFromMap(payload), nil
506
+ }
507
+
508
+ func normalizeInningsFromMap(payload map[string]any) *Innings {
509
+ ref := stringField(payload, "$ref")
510
+ ids := refIDs(ref)
511
+ inningsNumber := parseInt(ids["inningsId"])
512
+ period := intField(payload, "period")
513
+ if period == 0 {
514
+ period = parseInt(ids["periodId"])
515
+ }
516
+
517
+ innings := &Innings{
518
+ Ref: ref,
519
+ ID: ids["inningsId"],
520
+ LeagueID: ids["leagueId"],
521
+ EventID: ids["eventId"],
522
+ CompetitionID: ids["competitionId"],
523
+ MatchID: ids["competitionId"],
524
+ TeamID: ids["competitorId"],
525
+ InningsNumber: inningsNumber,
526
+ Period: period,
527
+ Runs: intField(payload, "runs"),
528
+ Wickets: intField(payload, "wickets"),
529
+ Overs: floatField(payload, "overs"),
530
+ Score: stringField(payload, "score"),
531
+ Description: stringField(payload, "description"),
532
+ Target: intField(payload, "target"),
533
+ IsBatting: truthyField(payload, "isBatting"),
534
+ IsCurrent: truthyField(payload, "isCurrent"),
535
+ Fours: intField(payload, "fours"),
536
+ Sixes: intField(payload, "sixes"),
537
+ StatisticsRef: refFromField(payload, "statistics"),
538
+ LeadersRef: refFromField(payload, "leaders"),
539
+ PartnershipsRef: refFromField(payload, "partnerships"),
540
+ FallOfWicketRef: refFromField(payload, "fow"),
541
+ Extensions: extensionsFromMap(payload,
542
+ "$ref", "period", "runs", "wickets", "overs", "score", "description", "target",
543
+ "isBatting", "isCurrent", "fours", "sixes", "value", "displayValue", "source", "followOn",
544
+ "statistics", "leaders", "partnerships", "fow",
545
+ ),
546
+ }
547
+
548
+ return innings
549
+ }
550
+
551
+ // NormalizeInningsPeriodStatistics maps period statistics payloads into over and wicket timelines.
552
+ func NormalizeInningsPeriodStatistics(data []byte) ([]InningsOver, []InningsWicket, error) {
553
+ payload, err := decodePayloadMap(data)
554
+ if err != nil {
555
+ return nil, nil, err
556
+ }
557
+
558
+ splits := mapField(payload, "splits")
559
+ if splits == nil {
560
+ return []InningsOver{}, []InningsWicket{}, nil
561
+ }
562
+
563
+ overs := normalizeOverTimeline(splits)
564
+ wickets := make([]InningsWicket, 0)
565
+ for _, over := range overs {
566
+ wickets = append(wickets, over.Wickets...)
567
+ }
568
+
569
+ return overs, wickets, nil
570
+ }
571
+
572
+ func normalizeOverTimeline(splits map[string]any) []InningsOver {
573
+ if splits == nil {
574
+ return []InningsOver{}
575
+ }
576
+
577
+ rawOvers, ok := splits["overs"]
578
+ if !ok || rawOvers == nil {
579
+ return []InningsOver{}
580
+ }
581
+
582
+ rows := make([]map[string]any, 0)
583
+ switch typed := rawOvers.(type) {
584
+ case []any:
585
+ for _, entry := range typed {
586
+ switch rowOrSlice := entry.(type) {
587
+ case map[string]any:
588
+ rows = append(rows, rowOrSlice)
589
+ case []any:
590
+ for _, nested := range rowOrSlice {
591
+ row, ok := nested.(map[string]any)
592
+ if !ok {
593
+ continue
594
+ }
595
+ rows = append(rows, row)
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ overs := make([]InningsOver, 0, len(rows))
602
+ for _, row := range rows {
603
+ wickets := normalizeTimelineWickets(row)
604
+ overs = append(overs, InningsOver{
605
+ Number: intField(row, "number"),
606
+ Runs: intField(row, "runs"),
607
+ WicketCount: len(wickets),
608
+ Wickets: wickets,
609
+ Extensions: extensionsFromMap(row, "number", "runs", "wicket"),
610
+ })
611
+ }
612
+
613
+ return overs
614
+ }
615
+
616
+ func normalizeTimelineWickets(overRow map[string]any) []InningsWicket {
617
+ if overRow == nil {
618
+ return []InningsWicket{}
619
+ }
620
+ rawWickets, ok := overRow["wicket"]
621
+ if !ok || rawWickets == nil {
622
+ return []InningsWicket{}
623
+ }
624
+
625
+ entries, ok := rawWickets.([]any)
626
+ if !ok {
627
+ return []InningsWicket{}
628
+ }
629
+
630
+ wickets := make([]InningsWicket, 0, len(entries))
631
+ for _, raw := range entries {
632
+ entry, ok := raw.(map[string]any)
633
+ if !ok {
634
+ continue
635
+ }
636
+ details := mapField(entry, "details")
637
+ wickets = append(wickets, InningsWicket{
638
+ Number: intField(entry, "number"),
639
+ FOW: stringField(entry, "fow"),
640
+ Over: stringField(entry, "over"),
641
+ FOWType: stringField(entry, "fowType"),
642
+ Runs: intField(entry, "runs"),
643
+ BallsFaced: intField(entry, "ballsFaced"),
644
+ StrikeRate: floatField(entry, "strikeRate"),
645
+ DismissalCard: stringField(entry, "dismissalCard"),
646
+ ShortText: stringField(entry, "shortText"),
647
+ DetailRef: stringField(details, "$ref"),
648
+ DetailShortText: stringField(details, "shortText"),
649
+ DetailText: stringField(details, "text"),
650
+ Extensions: extensionsFromMap(entry,
651
+ "number", "fow", "over", "fowType", "runs", "ballsFaced", "dismissalCard", "shortText", "details",
652
+ ),
653
+ })
654
+ }
655
+
656
+ return wickets
657
+ }
658
+
659
+ // NormalizeDeliveryEvent maps a detail payload into the normalized delivery-event shape.
660
+ func NormalizeDeliveryEvent(data []byte) (*DeliveryEvent, error) {
661
+ payload, err := decodePayloadMap(data)
662
+ if err != nil {
663
+ return nil, err
664
+ }
665
+
666
+ ref := stringField(payload, "$ref")
667
+ ids := refIDs(ref)
668
+ over := mapField(payload, "over")
669
+ playType := mapField(payload, "playType")
670
+ dismissal := mapField(payload, "dismissal")
671
+ batsman := mapField(payload, "batsman")
672
+ bowler := mapField(payload, "bowler")
673
+ fielder := mapField(dismissal, "fielder")
674
+ batsmanRef := nonEmpty(nestedRef(payload, "batsman", "athlete"), refFromField(payload, "batsman"))
675
+ bowlerRef := nonEmpty(nestedRef(payload, "bowler", "athlete"), refFromField(payload, "bowler"))
676
+ fielderRef := nonEmpty(nestedRef(payload, "dismissal", "fielder", "athlete"), nestedRef(payload, "dismissal", "fielder"))
677
+ batsmanID := nonEmpty(stringField(batsman, "playerId"), stringField(batsman, "id"), refIDs(batsmanRef)["athleteId"])
678
+ bowlerID := nonEmpty(stringField(bowler, "playerId"), stringField(bowler, "id"), refIDs(bowlerRef)["athleteId"])
679
+ fielderID := nonEmpty(stringField(fielder, "playerId"), stringField(fielder, "id"), refIDs(fielderRef)["athleteId"])
680
+ teamRef := refFromField(payload, "team")
681
+ teamIDs := refIDs(teamRef)
682
+ athletePlayerIDs := extractAthletePlayerIDs(payload)
683
+ xCoordinate := nullableFloatField(payload, "xCoordinate")
684
+ yCoordinate := nullableFloatField(payload, "yCoordinate")
685
+ bbbTimestamp := int64Field(payload, "bbbTimestamp")
686
+
687
+ event := &DeliveryEvent{
688
+ Ref: ref,
689
+ ID: nonEmpty(stringField(payload, "id"), ids["detailId"]),
690
+ LeagueID: ids["leagueId"],
691
+ EventID: ids["eventId"],
692
+ CompetitionID: ids["competitionId"],
693
+ MatchID: ids["competitionId"],
694
+ TeamID: nonEmpty(teamIDs["teamId"], teamIDs["competitorId"]),
695
+ Period: intField(payload, "period"),
696
+ PeriodText: stringField(payload, "periodText"),
697
+ OverNumber: intField(over, "number"),
698
+ BallNumber: intField(over, "ball"),
699
+ ScoreValue: intField(payload, "scoreValue"),
700
+ ShortText: stringField(payload, "shortText"),
701
+ Text: stringField(payload, "text"),
702
+ HomeScore: stringField(payload, "homeScore"),
703
+ AwayScore: stringField(payload, "awayScore"),
704
+ BatsmanRef: batsmanRef,
705
+ BowlerRef: bowlerRef,
706
+ BatsmanPlayerID: batsmanID,
707
+ BowlerPlayerID: bowlerID,
708
+ FielderPlayerID: fielderID,
709
+ AthletePlayerIDs: athletePlayerIDs,
710
+ PlayType: playType,
711
+ Dismissal: dismissal,
712
+ DismissalType: stringField(dismissal, "type"),
713
+ DismissalName: nonEmpty(stringField(dismissal, "name"), stringField(dismissal, "type")),
714
+ DismissalCard: stringField(dismissal, "dismissalCard"),
715
+ DismissalText: stringField(dismissal, "text"),
716
+ SpeedKPH: floatField(payload, "speedKPH"),
717
+ XCoordinate: xCoordinate,
718
+ YCoordinate: yCoordinate,
719
+ BBBTimestamp: bbbTimestamp,
720
+ CoordinateX: xCoordinate,
721
+ CoordinateY: yCoordinate,
722
+ Timestamp: bbbTimestamp,
723
+ Extensions: extensionsFromMap(payload,
724
+ "$ref", "id", "period", "periodText", "over", "scoreValue", "shortText", "text", "homeScore", "awayScore",
725
+ "batsman", "bowler", "playType", "dismissal", "speedKPH", "xCoordinate", "yCoordinate", "bbbTimestamp",
726
+ ),
727
+ }
728
+
729
+ return event, nil
730
+ }
731
+
732
+ func extractAthletePlayerIDs(payload map[string]any) []string {
733
+ if payload == nil {
734
+ return nil
735
+ }
736
+ rawItems, ok := payload["athletesInvolved"].([]any)
737
+ if !ok || len(rawItems) == 0 {
738
+ return nil
739
+ }
740
+
741
+ seen := map[string]struct{}{}
742
+ out := make([]string, 0, len(rawItems))
743
+ for _, raw := range rawItems {
744
+ ref := refValue(raw)
745
+ if ref == "" {
746
+ if mapped, ok := raw.(map[string]any); ok {
747
+ ref = nonEmpty(refFromField(mapped, "athlete"), nestedRef(mapped, "athlete"))
748
+ }
749
+ }
750
+ playerID := strings.TrimSpace(refIDs(ref)["athleteId"])
751
+ if playerID == "" {
752
+ continue
753
+ }
754
+ if _, ok := seen[playerID]; ok {
755
+ continue
756
+ }
757
+ seen[playerID] = struct{}{}
758
+ out = append(out, playerID)
759
+ }
760
+ if len(out) == 0 {
761
+ return nil
762
+ }
763
+ return out
764
+ }
765
+
766
+ // NormalizeMatchScorecard maps a matchcards payload into batting, bowling, and partnerships views.
767
+ func NormalizeMatchScorecard(data []byte, match Match) (*MatchScorecard, error) {
768
+ payload, err := decodePayloadMap(data)
769
+ if err != nil {
770
+ return nil, err
771
+ }
772
+
773
+ scorecard := &MatchScorecard{
774
+ Ref: nonEmpty(stringField(payload, "$ref"), matchSubresourceRef(match, "matchcards", "matchcards")),
775
+ LeagueID: match.LeagueID,
776
+ EventID: match.EventID,
777
+ CompetitionID: match.CompetitionID,
778
+ MatchID: match.ID,
779
+ BattingCards: []BattingCard{},
780
+ BowlingCards: []BowlingCard{},
781
+ PartnershipCards: []PartnershipCard{},
782
+ Extensions: extensionsFromMap(payload,
783
+ "$ref", "count", "items", "pageCount", "pageIndex", "pageSize",
784
+ ),
785
+ }
786
+
787
+ for _, item := range mapSliceField(payload, "items") {
788
+ headline := strings.ToLower(strings.TrimSpace(stringField(item, "headline")))
789
+ typeID := strings.TrimSpace(stringField(item, "typeID"))
790
+
791
+ switch {
792
+ case headline == "batting" || typeID == "11":
793
+ scorecard.BattingCards = append(scorecard.BattingCards, normalizeBattingCard(item))
794
+ case headline == "bowling" || typeID == "12":
795
+ scorecard.BowlingCards = append(scorecard.BowlingCards, normalizeBowlingCard(item))
796
+ case headline == "partnerships" || typeID == "13":
797
+ scorecard.PartnershipCards = append(scorecard.PartnershipCards, normalizePartnershipCard(item))
798
+ default:
799
+ // Preserve unclassified card payloads for --all-fields without failing command execution.
800
+ if scorecard.Extensions == nil {
801
+ scorecard.Extensions = map[string]any{}
802
+ }
803
+ unknown, _ := scorecard.Extensions["unknownCards"].([]any)
804
+ scorecard.Extensions["unknownCards"] = append(unknown, item)
805
+ }
806
+ }
807
+
808
+ return scorecard, nil
809
+ }
810
+
811
+ // NormalizeMatchSituation maps a situation payload into a stable shape that tolerates sparse data.
812
+ func NormalizeMatchSituation(data []byte, match Match) (*MatchSituation, error) {
813
+ payload, err := decodePayloadMap(data)
814
+ if err != nil {
815
+ return nil, err
816
+ }
817
+
818
+ situation := &MatchSituation{
819
+ Ref: nonEmpty(stringField(payload, "$ref"), matchSubresourceRef(match, "situation", "situation")),
820
+ LeagueID: match.LeagueID,
821
+ EventID: match.EventID,
822
+ CompetitionID: match.CompetitionID,
823
+ MatchID: match.ID,
824
+ OddsRef: refFromField(payload, "odds"),
825
+ Data: extensionsFromMap(payload, "$ref", "odds"),
826
+ }
827
+
828
+ return situation, nil
829
+ }
830
+
831
+ func normalizeBattingCard(payload map[string]any) BattingCard {
832
+ card := BattingCard{
833
+ InningsNumber: parseInt(stringField(payload, "inningsNumber")),
834
+ TeamName: stringField(payload, "teamName"),
835
+ Runs: stringField(payload, "runs"),
836
+ Total: stringField(payload, "total"),
837
+ Extras: stringField(payload, "extras"),
838
+ Players: []BattingCardEntry{},
839
+ }
840
+
841
+ for _, row := range mapSliceField(payload, "playerDetails") {
842
+ card.Players = append(card.Players, BattingCardEntry{
843
+ PlayerID: stringField(row, "playerID"),
844
+ PlayerName: stringField(row, "playerName"),
845
+ Dismissal: stringField(row, "dismissal"),
846
+ Runs: stringField(row, "runs"),
847
+ BallsFaced: stringField(row, "ballsFaced"),
848
+ Fours: stringField(row, "fours"),
849
+ Sixes: stringField(row, "sixes"),
850
+ Href: stringField(row, "href"),
851
+ })
852
+ }
853
+
854
+ return card
855
+ }
856
+
857
+ func normalizeBowlingCard(payload map[string]any) BowlingCard {
858
+ card := BowlingCard{
859
+ InningsNumber: parseInt(stringField(payload, "inningsNumber")),
860
+ TeamName: stringField(payload, "teamName"),
861
+ Players: []BowlingCardEntry{},
862
+ }
863
+
864
+ for _, row := range mapSliceField(payload, "playerDetails") {
865
+ card.Players = append(card.Players, BowlingCardEntry{
866
+ PlayerID: stringField(row, "playerID"),
867
+ PlayerName: stringField(row, "playerName"),
868
+ Overs: stringField(row, "overs"),
869
+ Maidens: stringField(row, "maidens"),
870
+ Conceded: stringField(row, "conceded"),
871
+ Wickets: stringField(row, "wickets"),
872
+ EconomyRate: stringField(row, "economyRate"),
873
+ NBW: stringField(row, "nbw"),
874
+ Href: stringField(row, "href"),
875
+ })
876
+ }
877
+
878
+ return card
879
+ }
880
+
881
+ func normalizePartnershipCard(payload map[string]any) PartnershipCard {
882
+ card := PartnershipCard{
883
+ InningsNumber: parseInt(stringField(payload, "inningsNumber")),
884
+ TeamName: stringField(payload, "teamName"),
885
+ Players: []PartnershipCardEntry{},
886
+ }
887
+
888
+ for _, row := range mapSliceField(payload, "playerDetails") {
889
+ card.Players = append(card.Players, PartnershipCardEntry{
890
+ PartnershipRuns: stringField(row, "partnershipRuns"),
891
+ PartnershipOvers: stringField(row, "partnershipOvers"),
892
+ PartnershipWicketName: stringField(row, "partnershipWicketName"),
893
+ FOWType: stringField(row, "fowType"),
894
+ Player1Name: stringField(row, "player1Name"),
895
+ Player1Runs: stringField(row, "player1Runs"),
896
+ Player2Name: stringField(row, "player2Name"),
897
+ Player2Runs: stringField(row, "player2Runs"),
898
+ })
899
+ }
900
+
901
+ return card
902
+ }
903
+
904
+ // NormalizeStatCategories maps a stats payload into normalized category entries.
905
+ func NormalizeStatCategories(data []byte) ([]StatCategory, error) {
906
+ payload, err := decodePayloadMap(data)
907
+ if err != nil {
908
+ return nil, err
909
+ }
910
+
911
+ splits := mapField(payload, "splits")
912
+ if splits == nil {
913
+ return []StatCategory{}, nil
914
+ }
915
+
916
+ categories := make([]StatCategory, 0)
917
+ for _, item := range mapSliceField(splits, "categories") {
918
+ stats := make([]StatValue, 0)
919
+ for _, statRaw := range mapSliceField(item, "stats") {
920
+ stats = append(stats, StatValue{
921
+ Name: stringField(statRaw, "name"),
922
+ DisplayName: stringField(statRaw, "displayName"),
923
+ ShortName: stringField(statRaw, "shortDisplayName"),
924
+ Description: stringField(statRaw, "description"),
925
+ Abbreviation: stringField(statRaw, "abbreviation"),
926
+ DisplayValue: stringField(statRaw, "displayValue"),
927
+ Value: statRaw["value"],
928
+ Type: stringField(statRaw, "type"),
929
+ Extensions: extensionsFromMap(statRaw,
930
+ "name", "displayName", "shortDisplayName", "description", "abbreviation", "displayValue", "value", "type",
931
+ ),
932
+ })
933
+ }
934
+
935
+ categories = append(categories, StatCategory{
936
+ Name: stringField(item, "name"),
937
+ DisplayName: stringField(item, "displayName"),
938
+ ShortName: stringField(item, "shortDisplayName"),
939
+ Abbreviation: stringField(item, "abbreviation"),
940
+ Summary: stringField(item, "summary"),
941
+ Stats: stats,
942
+ Extensions: extensionsFromMap(item,
943
+ "name", "displayName", "shortDisplayName", "abbreviation", "summary", "stats",
944
+ ),
945
+ })
946
+ }
947
+
948
+ return categories, nil
949
+ }
950
+
951
+ // NormalizePartnerships maps a partnerships page into normalized partnership entries.
952
+ func NormalizePartnerships(data []byte) ([]Partnership, error) {
953
+ payload, err := decodePayloadMap(data)
954
+ if err != nil {
955
+ return nil, err
956
+ }
957
+
958
+ items := mapSliceField(payload, "items")
959
+ partnerships := make([]Partnership, 0, len(items))
960
+ for _, item := range items {
961
+ ref := stringField(item, "$ref")
962
+ ids := refIDs(ref)
963
+ partnerships = append(partnerships, Partnership{
964
+ Ref: ref,
965
+ ID: ids["partnershipId"],
966
+ TeamID: ids["competitorId"],
967
+ MatchID: ids["competitionId"],
968
+ InningsID: ids["inningsId"],
969
+ Period: ids["periodId"],
970
+ Order: parseInt(ids["partnershipId"]),
971
+ WicketNumber: parseInt(ids["partnershipId"]),
972
+ Extensions: extensionsFromMap(item,
973
+ "$ref",
974
+ ),
975
+ })
976
+ }
977
+
978
+ return partnerships, nil
979
+ }
980
+
981
+ // NormalizePartnership maps a single partnership payload into a detailed normalized object.
982
+ func NormalizePartnership(data []byte) (*Partnership, error) {
983
+ payload, err := decodePayloadMap(data)
984
+ if err != nil {
985
+ return nil, err
986
+ }
987
+
988
+ ref := stringField(payload, "$ref")
989
+ ids := refIDs(ref)
990
+ start := mapField(payload, "start")
991
+ end := mapField(payload, "end")
992
+
993
+ batsmen := make([]PartnershipBatsman, 0)
994
+ for _, batsman := range mapSliceField(payload, "batsmen") {
995
+ athleteRef := strings.TrimSpace(stringField(batsman, "athlete"))
996
+ if athleteRef == "" {
997
+ athleteRef = refFromField(batsman, "athlete")
998
+ }
999
+ batsmen = append(batsmen, PartnershipBatsman{
1000
+ AthleteRef: athleteRef,
1001
+ Balls: intField(batsman, "balls"),
1002
+ Runs: intField(batsman, "runs"),
1003
+ })
1004
+ }
1005
+
1006
+ partnership := &Partnership{
1007
+ Ref: ref,
1008
+ ID: ids["partnershipId"],
1009
+ TeamID: ids["competitorId"],
1010
+ MatchID: ids["competitionId"],
1011
+ InningsID: ids["inningsId"],
1012
+ Period: ids["periodId"],
1013
+ Order: parseInt(ids["partnershipId"]),
1014
+ WicketNumber: intField(payload, "wicketNumber"),
1015
+ WicketName: stringField(payload, "wicketName"),
1016
+ FOWType: stringField(payload, "fowType"),
1017
+ Overs: floatField(payload, "overs"),
1018
+ Runs: intField(payload, "runs"),
1019
+ RunRate: floatField(payload, "runRate"),
1020
+ Start: PartnershipSnapshot{
1021
+ Overs: floatField(start, "overs"),
1022
+ Runs: intField(start, "runs"),
1023
+ Wickets: intField(start, "wickets"),
1024
+ },
1025
+ End: PartnershipSnapshot{
1026
+ Overs: floatField(end, "overs"),
1027
+ Runs: intField(end, "runs"),
1028
+ Wickets: intField(end, "wickets"),
1029
+ },
1030
+ Batsmen: batsmen,
1031
+ Extensions: extensionsFromMap(payload,
1032
+ "$ref", "wicketNumber", "wicketName", "fowType", "overs", "runs", "runRate", "start", "end", "batsmen",
1033
+ ),
1034
+ }
1035
+
1036
+ return partnership, nil
1037
+ }
1038
+
1039
+ // NormalizeFallOfWickets maps a fall-of-wicket page into normalized entries.
1040
+ func NormalizeFallOfWickets(data []byte) ([]FallOfWicket, error) {
1041
+ payload, err := decodePayloadMap(data)
1042
+ if err != nil {
1043
+ return nil, err
1044
+ }
1045
+
1046
+ items := mapSliceField(payload, "items")
1047
+ wickets := make([]FallOfWicket, 0, len(items))
1048
+ for _, item := range items {
1049
+ ref := stringField(item, "$ref")
1050
+ ids := refIDs(ref)
1051
+ wickets = append(wickets, FallOfWicket{
1052
+ Ref: ref,
1053
+ ID: ids["fowId"],
1054
+ MatchID: ids["competitionId"],
1055
+ TeamID: ids["competitorId"],
1056
+ InningsID: ids["inningsId"],
1057
+ Period: ids["periodId"],
1058
+ WicketNumber: parseInt(ids["fowId"]),
1059
+ Extensions: extensionsFromMap(item,
1060
+ "$ref",
1061
+ ),
1062
+ })
1063
+ }
1064
+
1065
+ return wickets, nil
1066
+ }
1067
+
1068
+ // NormalizeFallOfWicket maps a single fall-of-wicket payload into a detailed normalized object.
1069
+ func NormalizeFallOfWicket(data []byte) (*FallOfWicket, error) {
1070
+ payload, err := decodePayloadMap(data)
1071
+ if err != nil {
1072
+ return nil, err
1073
+ }
1074
+
1075
+ ref := stringField(payload, "$ref")
1076
+ ids := refIDs(ref)
1077
+
1078
+ fow := &FallOfWicket{
1079
+ Ref: ref,
1080
+ ID: ids["fowId"],
1081
+ MatchID: ids["competitionId"],
1082
+ TeamID: ids["competitorId"],
1083
+ InningsID: ids["inningsId"],
1084
+ Period: ids["periodId"],
1085
+ WicketNumber: intField(payload, "wicketNumber"),
1086
+ WicketOver: floatField(payload, "wicketOver"),
1087
+ FOWType: stringField(payload, "fowType"),
1088
+ Runs: intField(payload, "runs"),
1089
+ RunsScored: intField(payload, "runsScored"),
1090
+ BallsFaced: intField(payload, "ballsFaced"),
1091
+ AthleteRef: refFromField(payload, "athlete"),
1092
+ Extensions: extensionsFromMap(payload,
1093
+ "$ref", "wicketNumber", "wicketOver", "fowType", "runs", "runsScored", "ballsFaced", "athlete",
1094
+ ),
1095
+ }
1096
+
1097
+ if fow.WicketNumber == 0 {
1098
+ fow.WicketNumber = parseInt(ids["fowId"])
1099
+ }
1100
+
1101
+ return fow, nil
1102
+ }
1103
+
1104
+ func normalizeStandingsEntries(item map[string]any) []Team {
1105
+ entries := make([]Team, 0)
1106
+ for _, raw := range mapSliceField(item, "entries") {
1107
+ entries = append(entries, normalizeTeamMap(raw))
1108
+ }
1109
+ return entries
1110
+ }
1111
+
1112
+ func normalizeTeamMap(payload map[string]any) Team {
1113
+ ref := stringField(payload, "$ref")
1114
+ teamRef := refFromField(payload, "team")
1115
+ ids := refIDs(ref)
1116
+
1117
+ name := stringField(payload, "displayName")
1118
+ if name == "" {
1119
+ name = stringField(payload, "name")
1120
+ }
1121
+ shortName := stringField(payload, "shortName")
1122
+ if shortName == "" {
1123
+ shortName = stringField(payload, "shortDisplayName")
1124
+ }
1125
+
1126
+ id := nonEmpty(stringField(payload, "id"), ids["teamId"], ids["competitorId"])
1127
+ score := mapField(payload, "score")
1128
+ scoreSummary := nonEmpty(stringField(score, "displayValue"), stringField(score, "value"))
1129
+
1130
+ return Team{
1131
+ Ref: nonEmpty(teamRef, ref),
1132
+ ID: id,
1133
+ UID: stringField(payload, "uid"),
1134
+ Name: name,
1135
+ ShortName: shortName,
1136
+ Abbreviation: stringField(payload, "abbreviation"),
1137
+ ScoreSummary: scoreSummary,
1138
+ Type: stringField(payload, "type"),
1139
+ HomeAway: stringField(payload, "homeAway"),
1140
+ Order: intField(payload, "order"),
1141
+ Winner: boolField(payload, "winner"),
1142
+ ScoreRef: refFromField(payload, "score"),
1143
+ RosterRef: refFromField(payload, "roster"),
1144
+ LeadersRef: refFromField(payload, "leaders"),
1145
+ StatisticsRef: refFromField(payload, "statistics"),
1146
+ RecordRef: nonEmpty(refFromField(payload, "record"), refFromField(payload, "records")),
1147
+ LinescoresRef: refFromField(payload, "linescores"),
1148
+ Extensions: extensionsFromMap(payload,
1149
+ "$ref", "id", "uid", "displayName", "name", "shortName", "shortDisplayName", "abbreviation",
1150
+ "type", "homeAway", "order", "winner", "score", "roster", "leaders", "statistics", "record", "records", "linescores",
1151
+ ),
1152
+ }
1153
+ }
1154
+
1155
+ type eventMatchContext struct {
1156
+ ref string
1157
+ leagueID string
1158
+ eventID string
1159
+ date string
1160
+ endDate string
1161
+ description string
1162
+ shortDescription string
1163
+ venueName string
1164
+ venueSummary string
1165
+ }
1166
+
1167
+ func buildEventMatchContext(payload map[string]any) eventMatchContext {
1168
+ ref := stringField(payload, "$ref")
1169
+ ids := refIDs(ref)
1170
+ leagueID := ids["leagueId"]
1171
+
1172
+ if leagueID == "" {
1173
+ for _, league := range mapSliceField(payload, "leagues") {
1174
+ leagueID = nonEmpty(
1175
+ stringField(league, "id"),
1176
+ refIDs(stringField(league, "$ref"))["leagueId"],
1177
+ )
1178
+ if leagueID != "" {
1179
+ break
1180
+ }
1181
+ }
1182
+ }
1183
+
1184
+ venueName, venueSummary := eventVenueSummary(payload)
1185
+ return eventMatchContext{
1186
+ ref: ref,
1187
+ leagueID: leagueID,
1188
+ eventID: nonEmpty(stringField(payload, "id"), ids["eventId"]),
1189
+ date: stringField(payload, "date"),
1190
+ endDate: stringField(payload, "endDate"),
1191
+ description: nonEmpty(stringField(payload, "description"), stringField(payload, "name")),
1192
+ shortDescription: nonEmpty(stringField(payload, "shortDescription"), stringField(payload, "shortName")),
1193
+ venueName: venueName,
1194
+ venueSummary: venueSummary,
1195
+ }
1196
+ }
1197
+
1198
+ func normalizeMatchFromCompetitionMap(payload map[string]any, context eventMatchContext) Match {
1199
+ ref := stringField(payload, "$ref")
1200
+ ids := refIDs(ref)
1201
+
1202
+ venue := mapField(payload, "venue")
1203
+ venueName := nonEmpty(stringField(venue, "fullName"), context.venueName)
1204
+ venueSummary := nonEmpty(venueAddressSummary(venue), context.venueSummary)
1205
+
1206
+ teams := make([]Team, 0)
1207
+ for _, item := range mapSliceField(payload, "competitors") {
1208
+ teams = append(teams, normalizeTeamMap(item))
1209
+ }
1210
+
1211
+ scoreSummary := matchScoreSummary(teams)
1212
+ matchState := nonEmpty(
1213
+ stringField(payload, "state"),
1214
+ stringField(payload, "summary"),
1215
+ stringField(payload, "statusSummary"),
1216
+ stringField(mapField(payload, "status"), "summary"),
1217
+ stringField(mapField(mapField(payload, "status"), "type"), "detail"),
1218
+ stringField(mapField(mapField(payload, "status"), "type"), "description"),
1219
+ )
1220
+
1221
+ return Match{
1222
+ Ref: nonEmpty(ref, context.ref),
1223
+ ID: nonEmpty(stringField(payload, "id"), ids["competitionId"]),
1224
+ UID: stringField(payload, "uid"),
1225
+ LeagueID: nonEmpty(ids["leagueId"], context.leagueID),
1226
+ EventID: nonEmpty(ids["eventId"], context.eventID),
1227
+ CompetitionID: nonEmpty(ids["competitionId"], stringField(payload, "id")),
1228
+ Description: nonEmpty(stringField(payload, "description"), context.description),
1229
+ ShortDescription: nonEmpty(stringField(payload, "shortDescription"), context.shortDescription),
1230
+ Note: stringField(payload, "note"),
1231
+ MatchState: matchState,
1232
+ Date: nonEmpty(stringField(payload, "date"), context.date),
1233
+ EndDate: nonEmpty(stringField(payload, "endDate"), context.endDate),
1234
+ VenueName: venueName,
1235
+ VenueSummary: venueSummary,
1236
+ ScoreSummary: scoreSummary,
1237
+ StatusRef: refFromField(payload, "status"),
1238
+ DetailsRef: refFromField(payload, "details"),
1239
+ Teams: teams,
1240
+ Extensions: extensionsFromMap(payload,
1241
+ "$ref", "id", "uid", "description", "shortDescription", "note", "state", "summary", "statusSummary",
1242
+ "date", "endDate", "status", "details", "competitors",
1243
+ ),
1244
+ }
1245
+ }
1246
+
1247
+ func eventVenueSummary(payload map[string]any) (string, string) {
1248
+ venues := mapSliceField(payload, "venues")
1249
+ if len(venues) == 0 {
1250
+ return "", ""
1251
+ }
1252
+ return nonEmpty(
1253
+ stringField(venues[0], "fullName"),
1254
+ stringField(venues[0], "shortName"),
1255
+ ), venueAddressSummary(venues[0])
1256
+ }
1257
+
1258
+ func venueAddressSummary(venue map[string]any) string {
1259
+ if venue == nil {
1260
+ return ""
1261
+ }
1262
+ address := mapField(venue, "address")
1263
+ if address == nil {
1264
+ return ""
1265
+ }
1266
+ return nonEmpty(
1267
+ stringField(address, "summary"),
1268
+ strings.Join(compactValues(
1269
+ stringField(address, "city"),
1270
+ stringField(address, "state"),
1271
+ stringField(address, "country"),
1272
+ ), ", "),
1273
+ )
1274
+ }
1275
+
1276
+ func matchScoreSummary(teams []Team) string {
1277
+ parts := make([]string, 0, len(teams))
1278
+ for _, team := range teams {
1279
+ if team.ScoreSummary == "" {
1280
+ continue
1281
+ }
1282
+ label := nonEmpty(team.ShortName, team.Name, team.ID)
1283
+ if label == "" {
1284
+ parts = append(parts, team.ScoreSummary)
1285
+ continue
1286
+ }
1287
+ parts = append(parts, label+" "+team.ScoreSummary)
1288
+ }
1289
+ return strings.Join(parts, " | ")
1290
+ }
1291
+
1292
+ func compactValues(values ...string) []string {
1293
+ out := make([]string, 0, len(values))
1294
+ for _, value := range values {
1295
+ value = strings.TrimSpace(value)
1296
+ if value == "" {
1297
+ continue
1298
+ }
1299
+ out = append(out, value)
1300
+ }
1301
+ return out
1302
+ }
1303
+
1304
+ func decodePayloadMap(data []byte) (map[string]any, error) {
1305
+ var payload map[string]any
1306
+ if err := json.Unmarshal(data, &payload); err != nil {
1307
+ return nil, fmt.Errorf("decode payload: %w", err)
1308
+ }
1309
+ if payload == nil {
1310
+ return nil, fmt.Errorf("decode payload: empty object")
1311
+ }
1312
+ return payload, nil
1313
+ }
1314
+
1315
+ func extensionsFromMap(payload map[string]any, knownKeys ...string) map[string]any {
1316
+ if len(payload) == 0 {
1317
+ return nil
1318
+ }
1319
+
1320
+ known := map[string]struct{}{}
1321
+ for _, key := range knownKeys {
1322
+ known[key] = struct{}{}
1323
+ }
1324
+
1325
+ ext := map[string]any{}
1326
+ for key, value := range payload {
1327
+ if _, ok := known[key]; ok {
1328
+ continue
1329
+ }
1330
+ ext[key] = value
1331
+ }
1332
+ if len(ext) == 0 {
1333
+ return nil
1334
+ }
1335
+ return ext
1336
+ }
1337
+
1338
+ func stringField(payload map[string]any, key string) string {
1339
+ if payload == nil {
1340
+ return ""
1341
+ }
1342
+ value, ok := payload[key]
1343
+ if !ok || value == nil {
1344
+ return ""
1345
+ }
1346
+ switch typed := value.(type) {
1347
+ case string:
1348
+ return strings.TrimSpace(typed)
1349
+ case float64:
1350
+ if typed == float64(int64(typed)) {
1351
+ return strconv.FormatInt(int64(typed), 10)
1352
+ }
1353
+ return strconv.FormatFloat(typed, 'f', -1, 64)
1354
+ case float32:
1355
+ if typed == float32(int64(typed)) {
1356
+ return strconv.FormatInt(int64(typed), 10)
1357
+ }
1358
+ return strconv.FormatFloat(float64(typed), 'f', -1, 32)
1359
+ case int:
1360
+ return strconv.Itoa(typed)
1361
+ case int64:
1362
+ return strconv.FormatInt(typed, 10)
1363
+ case json.Number:
1364
+ return strings.TrimSpace(typed.String())
1365
+ case fmt.Stringer:
1366
+ return strings.TrimSpace(typed.String())
1367
+ default:
1368
+ return strings.TrimSpace(fmt.Sprintf("%v", value))
1369
+ }
1370
+ }
1371
+
1372
+ func intField(payload map[string]any, key string) int {
1373
+ if payload == nil {
1374
+ return 0
1375
+ }
1376
+ value, ok := payload[key]
1377
+ if !ok || value == nil {
1378
+ return 0
1379
+ }
1380
+ switch typed := value.(type) {
1381
+ case float64:
1382
+ return int(typed)
1383
+ case float32:
1384
+ return int(typed)
1385
+ case int:
1386
+ return typed
1387
+ case int64:
1388
+ return int(typed)
1389
+ case json.Number:
1390
+ parsed, _ := typed.Int64()
1391
+ return int(parsed)
1392
+ case string:
1393
+ parsed, err := strconv.Atoi(strings.TrimSpace(typed))
1394
+ if err != nil {
1395
+ return 0
1396
+ }
1397
+ return parsed
1398
+ default:
1399
+ return 0
1400
+ }
1401
+ }
1402
+
1403
+ func int64Field(payload map[string]any, key string) int64 {
1404
+ if payload == nil {
1405
+ return 0
1406
+ }
1407
+ value, ok := payload[key]
1408
+ if !ok || value == nil {
1409
+ return 0
1410
+ }
1411
+ switch typed := value.(type) {
1412
+ case float64:
1413
+ return int64(typed)
1414
+ case float32:
1415
+ return int64(typed)
1416
+ case int:
1417
+ return int64(typed)
1418
+ case int64:
1419
+ return typed
1420
+ case json.Number:
1421
+ parsed, _ := typed.Int64()
1422
+ return parsed
1423
+ case string:
1424
+ parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
1425
+ if err != nil {
1426
+ return 0
1427
+ }
1428
+ return parsed
1429
+ default:
1430
+ return 0
1431
+ }
1432
+ }
1433
+
1434
+ func floatField(payload map[string]any, key string) float64 {
1435
+ if payload == nil {
1436
+ return 0
1437
+ }
1438
+ value, ok := payload[key]
1439
+ if !ok || value == nil {
1440
+ return 0
1441
+ }
1442
+ switch typed := value.(type) {
1443
+ case float64:
1444
+ return typed
1445
+ case float32:
1446
+ return float64(typed)
1447
+ case int:
1448
+ return float64(typed)
1449
+ case int64:
1450
+ return float64(typed)
1451
+ case json.Number:
1452
+ parsed, _ := typed.Float64()
1453
+ return parsed
1454
+ case string:
1455
+ parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
1456
+ if err != nil {
1457
+ return 0
1458
+ }
1459
+ return parsed
1460
+ default:
1461
+ return 0
1462
+ }
1463
+ }
1464
+
1465
+ func nullableFloatField(payload map[string]any, key string) *float64 {
1466
+ if payload == nil {
1467
+ return nil
1468
+ }
1469
+ value, ok := payload[key]
1470
+ if !ok || value == nil {
1471
+ return nil
1472
+ }
1473
+ parsed := floatField(payload, key)
1474
+ return &parsed
1475
+ }
1476
+
1477
+ func boolField(payload map[string]any, key string) bool {
1478
+ if payload == nil {
1479
+ return false
1480
+ }
1481
+ value, ok := payload[key]
1482
+ if !ok || value == nil {
1483
+ return false
1484
+ }
1485
+ switch typed := value.(type) {
1486
+ case bool:
1487
+ return typed
1488
+ case string:
1489
+ parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
1490
+ if err != nil {
1491
+ return false
1492
+ }
1493
+ return parsed
1494
+ default:
1495
+ return false
1496
+ }
1497
+ }
1498
+
1499
+ func truthyField(payload map[string]any, key string) bool {
1500
+ if boolField(payload, key) {
1501
+ return true
1502
+ }
1503
+ if payload == nil {
1504
+ return false
1505
+ }
1506
+ value, ok := payload[key]
1507
+ if !ok || value == nil {
1508
+ return false
1509
+ }
1510
+ switch typed := value.(type) {
1511
+ case float64:
1512
+ return typed != 0
1513
+ case float32:
1514
+ return typed != 0
1515
+ case int:
1516
+ return typed != 0
1517
+ case int64:
1518
+ return typed != 0
1519
+ case json.Number:
1520
+ parsed, err := typed.Int64()
1521
+ if err != nil {
1522
+ return false
1523
+ }
1524
+ return parsed != 0
1525
+ case string:
1526
+ trimmed := strings.TrimSpace(typed)
1527
+ if trimmed == "" {
1528
+ return false
1529
+ }
1530
+ if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
1531
+ return parsed != 0
1532
+ }
1533
+ parsed, err := strconv.ParseBool(trimmed)
1534
+ if err != nil {
1535
+ return false
1536
+ }
1537
+ return parsed
1538
+ default:
1539
+ return false
1540
+ }
1541
+ }
1542
+
1543
+ func mapField(payload map[string]any, key string) map[string]any {
1544
+ if payload == nil {
1545
+ return nil
1546
+ }
1547
+ value, ok := payload[key]
1548
+ if !ok || value == nil {
1549
+ return nil
1550
+ }
1551
+ mapped, ok := value.(map[string]any)
1552
+ if !ok {
1553
+ return nil
1554
+ }
1555
+ return mapped
1556
+ }
1557
+
1558
+ func mapSliceField(payload map[string]any, key string) []map[string]any {
1559
+ if payload == nil {
1560
+ return []map[string]any{}
1561
+ }
1562
+ value, ok := payload[key]
1563
+ if !ok || value == nil {
1564
+ return []map[string]any{}
1565
+ }
1566
+ rawItems, ok := value.([]any)
1567
+ if !ok {
1568
+ return []map[string]any{}
1569
+ }
1570
+ out := make([]map[string]any, 0, len(rawItems))
1571
+ for _, item := range rawItems {
1572
+ mapped, ok := item.(map[string]any)
1573
+ if !ok {
1574
+ continue
1575
+ }
1576
+ out = append(out, mapped)
1577
+ }
1578
+ return out
1579
+ }
1580
+
1581
+ func refFromField(payload map[string]any, key string) string {
1582
+ if payload == nil {
1583
+ return ""
1584
+ }
1585
+ return refValue(payload[key])
1586
+ }
1587
+
1588
+ func nestedRef(payload map[string]any, keys ...string) string {
1589
+ if len(keys) == 0 {
1590
+ return ""
1591
+ }
1592
+
1593
+ var current any = payload
1594
+ for idx, key := range keys {
1595
+ mapped, ok := current.(map[string]any)
1596
+ if !ok || mapped == nil {
1597
+ return ""
1598
+ }
1599
+ next, ok := mapped[key]
1600
+ if !ok || next == nil {
1601
+ return ""
1602
+ }
1603
+ if idx == len(keys)-1 {
1604
+ return refValue(next)
1605
+ }
1606
+ current = next
1607
+ }
1608
+ return ""
1609
+ }
1610
+
1611
+ func refValue(value any) string {
1612
+ switch typed := value.(type) {
1613
+ case string:
1614
+ return strings.TrimSpace(typed)
1615
+ case map[string]any:
1616
+ return strings.TrimSpace(stringField(typed, "$ref"))
1617
+ default:
1618
+ return ""
1619
+ }
1620
+ }
1621
+
1622
+ func normalizePlayerStyles(payload map[string]any) []PlayerStyle {
1623
+ rawStyles := append(mapSliceField(payload, "style"), mapSliceField(payload, "styles")...)
1624
+ if len(rawStyles) == 0 {
1625
+ return nil
1626
+ }
1627
+
1628
+ out := make([]PlayerStyle, 0, len(rawStyles))
1629
+ seen := map[string]struct{}{}
1630
+ for _, raw := range rawStyles {
1631
+ style := PlayerStyle{
1632
+ Type: stringField(raw, "type"),
1633
+ Description: stringField(raw, "description"),
1634
+ ShortDescription: stringField(raw, "shortDescription"),
1635
+ }
1636
+ key := strings.Join([]string{style.Type, style.Description, style.ShortDescription}, "|")
1637
+ if _, ok := seen[key]; ok {
1638
+ continue
1639
+ }
1640
+ seen[key] = struct{}{}
1641
+ out = append(out, style)
1642
+ }
1643
+ return out
1644
+ }
1645
+
1646
+ func normalizePlayerAffiliations(items []map[string]any) []PlayerAffiliation {
1647
+ if len(items) == 0 {
1648
+ return nil
1649
+ }
1650
+ out := make([]PlayerAffiliation, 0, len(items))
1651
+ for _, item := range items {
1652
+ if affiliation := normalizePlayerAffiliation(item); affiliation != nil {
1653
+ out = append(out, *affiliation)
1654
+ }
1655
+ }
1656
+ return out
1657
+ }
1658
+
1659
+ func normalizePlayerAffiliation(item map[string]any) *PlayerAffiliation {
1660
+ if len(item) == 0 {
1661
+ return nil
1662
+ }
1663
+ ref := stringField(item, "$ref")
1664
+ ids := refIDs(ref)
1665
+ return &PlayerAffiliation{
1666
+ ID: nonEmpty(stringField(item, "id"), ids["teamId"]),
1667
+ Ref: ref,
1668
+ Name: nonEmpty(stringField(item, "displayName"), stringField(item, "name"), stringField(item, "shortName")),
1669
+ }
1670
+ }
1671
+
1672
+ func normalizePlayerDebuts(items []map[string]any) []PlayerDebut {
1673
+ if len(items) == 0 {
1674
+ return nil
1675
+ }
1676
+ out := make([]PlayerDebut, 0, len(items))
1677
+ for _, item := range items {
1678
+ ref := stringField(item, "$ref")
1679
+ ids := refIDs(ref)
1680
+ out = append(out, PlayerDebut{
1681
+ ID: nonEmpty(stringField(item, "id"), ids["competitionId"], ids["eventId"]),
1682
+ Ref: ref,
1683
+ Name: nonEmpty(stringField(item, "displayName"), stringField(item, "name"), stringField(item, "shortName")),
1684
+ })
1685
+ }
1686
+ return out
1687
+ }
1688
+
1689
+ func styleDescriptions(payload map[string]any, field string) []string {
1690
+ entries := mapSliceField(payload, field)
1691
+ out := make([]string, 0, len(entries))
1692
+ for _, entry := range entries {
1693
+ description := stringField(entry, "description")
1694
+ if description == "" {
1695
+ continue
1696
+ }
1697
+ out = append(out, description)
1698
+ }
1699
+ return out
1700
+ }
1701
+
1702
+ func uniqueStrings(values []string) []string {
1703
+ seen := map[string]struct{}{}
1704
+ out := make([]string, 0, len(values))
1705
+ for _, value := range values {
1706
+ trimmed := strings.TrimSpace(value)
1707
+ if trimmed == "" {
1708
+ continue
1709
+ }
1710
+ if _, ok := seen[trimmed]; ok {
1711
+ continue
1712
+ }
1713
+ seen[trimmed] = struct{}{}
1714
+ out = append(out, trimmed)
1715
+ }
1716
+ return out
1717
+ }
1718
+
1719
+ func nonEmpty(values ...string) string {
1720
+ for _, value := range values {
1721
+ trimmed := strings.TrimSpace(value)
1722
+ if trimmed != "" {
1723
+ return trimmed
1724
+ }
1725
+ }
1726
+ return ""
1727
+ }